From c58d5f20a45ab28b6cd5bb04f9e84d49594a0142 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 25 Dec 2025 23:46:44 +0100 Subject: [PATCH 001/161] Session tree structure with id/parentId linking - Add TreeNode base type with id, parentId, timestamp - Add *Content types for clean input/output separation - Entry types are now TreeNode & *Content intersections - SessionManager assigns id/parentId on save, tracks leafId - Add migrateSessionEntries() for v1 to v2 conversion - Migration runs on load, rewrites file - buildSessionContext() uses tree traversal from leaf - Compaction returns CompactionResult (content only) - Hooks return compaction content, not full entries - Add firstKeptEntryId to before_compact hook event - Update mom package for tree fields - Better error messages for compaction failures --- packages/ai/src/models.generated.ts | 12121 ++++++++-------- .../coding-agent/docs/session-tree-plan.md | 86 + .../examples/hooks/custom-compaction.ts | 10 +- .../coding-agent/src/core/agent-session.ts | 89 +- packages/coding-agent/src/core/compaction.ts | 43 +- packages/coding-agent/src/core/hooks/types.ts | 10 +- .../coding-agent/src/core/session-manager.ts | 411 +- packages/coding-agent/src/index.ts | 12 + .../test/agent-session-compaction.test.ts | 4 +- .../test/compaction-hooks-example.test.ts | 20 +- packages/coding-agent/test/compaction.test.ts | 214 +- packages/mom/src/context.ts | 55 +- 12 files changed, 6778 insertions(+), 6297 deletions(-) create mode 100644 packages/coding-agent/docs/session-tree-plan.md diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6cde070c..2df269d3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5,19 +5,87 @@ import type { Model } from "./types.js"; export const MODELS = { "anthropic": { - "claude-3-5-haiku-20241022": { - id: "claude-3-5-haiku-20241022", - name: "Claude Haiku 3.5", + "claude-opus-4-0": { + id: "claude-opus-4-0", + name: "Claude Opus 4 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-3-5-sonnet-20241022": { + id: "claude-3-5-sonnet-20241022", + name: "Claude Sonnet 3.5 v2", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", reasoning: false, input: ["text", "image"], cost: { - input: 0.8, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-1": { + id: "claude-opus-4-1", + name: "Claude Opus 4.1 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-haiku-4-5": { + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-3-5-sonnet-20240620": { + id: "claude-3-5-sonnet-20240620", + name: "Claude Sonnet 3.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 200000, maxTokens: 8192, @@ -39,91 +107,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, - "claude-3-5-sonnet-20240620": { - id: "claude-3-5-sonnet-20240620", - name: "Claude Sonnet 3.5", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "claude-3-5-sonnet-20241022": { - id: "claude-3-5-sonnet-20241022", - name: "Claude Sonnet 3.5 v2", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "claude-3-7-sonnet-20250219": { - id: "claude-3-7-sonnet-20250219", - name: "Claude Sonnet 3.7", + "claude-opus-4-5": { + id: "claude-opus-4-5", + name: "Claude Opus 4.5 (latest)", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", reasoning: true, input: ["text", "image"], cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, }, contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-3-7-sonnet-latest": { - id: "claude-3-7-sonnet-latest", - name: "Claude Sonnet 3.7 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-3-haiku-20240307": { - id: "claude-3-haiku-20240307", - name: "Claude Haiku 3", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.25, - output: 1.25, - cacheRead: 0.03, - cacheWrite: 0.3, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"anthropic-messages">, "claude-3-opus-20240229": { id: "claude-3-opus-20240229", name: "Claude Opus 3", @@ -141,142 +141,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 4096, } satisfies Model<"anthropic-messages">, - "claude-3-sonnet-20240229": { - id: "claude-3-sonnet-20240229", - name: "Claude Sonnet 3", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 0.3, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"anthropic-messages">, - "claude-haiku-4-5": { - id: "claude-haiku-4-5", - name: "Claude Haiku 4.5 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1, - output: 5, - cacheRead: 0.1, - cacheWrite: 1.25, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-haiku-4-5-20251001": { - id: "claude-haiku-4-5-20251001", - name: "Claude Haiku 4.5", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1, - output: 5, - cacheRead: 0.1, - cacheWrite: 1.25, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-0": { - id: "claude-opus-4-0", - name: "Claude Opus 4 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-1": { - id: "claude-opus-4-1", - name: "Claude Opus 4.1 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-1-20250805": { - id: "claude-opus-4-1-20250805", - name: "Claude Opus 4.1", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-20250514": { - id: "claude-opus-4-20250514", - name: "Claude Opus 4", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-5": { - id: "claude-opus-4-5", - name: "Claude Opus 4.5 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, "claude-opus-4-5-20251101": { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", @@ -294,40 +158,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-sonnet-4-0": { - id: "claude-sonnet-4-0", - name: "Claude Sonnet 4 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-sonnet-4-20250514": { - id: "claude-sonnet-4-20250514", - name: "Claude Sonnet 4", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, "claude-sonnet-4-5": { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (latest)", @@ -362,415 +192,467 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - }, - "cerebras": { - "gpt-oss-120b": { - id: "gpt-oss-120b", - name: "GPT OSS 120B", - api: "openai-completions", - provider: "cerebras", - baseUrl: "https://api.cerebras.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.25, - output: 0.69, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen-3-235b-a22b-instruct-2507": { - id: "qwen-3-235b-a22b-instruct-2507", - name: "Qwen 3 235B Instruct", - api: "openai-completions", - provider: "cerebras", - baseUrl: "https://api.cerebras.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.6, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "zai-glm-4.6": { - id: "zai-glm-4.6", - name: "Z.AI GLM-4.6", - api: "openai-completions", - provider: "cerebras", - baseUrl: "https://api.cerebras.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 40960, - } satisfies Model<"openai-completions">, - }, - "github-copilot": { - "claude-haiku-4.5": { - id: "claude-haiku-4.5", - name: "Claude Haiku 4.5", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16000, - } satisfies Model<"openai-completions">, - "claude-opus-4.5": { - id: "claude-opus-4.5", - name: "Claude Opus 4.5", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16000, - } satisfies Model<"openai-completions">, - "claude-sonnet-4": { - id: "claude-sonnet-4", + "claude-sonnet-4-20250514": { + id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16000, - } satisfies Model<"openai-completions">, - "claude-sonnet-4.5": { - id: "claude-sonnet-4.5", - name: "Claude Sonnet 4.5", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16000, - } satisfies Model<"openai-completions">, - "gemini-2.5-pro": { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "gemini-3-flash-preview": { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "gpt-4.1": { - id: "gpt-4.1", - name: "GPT-4.1", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "gpt-4o": { - id: "gpt-4o", - name: "GPT-4o", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 64000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "gpt-5": { - id: "gpt-5", - name: "GPT-5", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5-codex": { - id: "gpt-5-codex", - name: "GPT-5-Codex", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5-mini": { - id: "gpt-5-mini", - name: "GPT-5-mini", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-responses">, - "gpt-5.1": { - id: "gpt-5.1", - name: "GPT-5.1", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex": { - id: "gpt-5.1-codex", - name: "GPT-5.1-Codex", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex-max": { - id: "gpt-5.1-codex-max", - name: "GPT-5.1-Codex-max", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex-mini": { - id: "gpt-5.1-codex-mini", - name: "GPT-5.1-Codex-mini", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-5.2": { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-responses">, - "grok-code-fast-1": { - id: "grok-code-fast-1", - name: "Grok Code Fast 1", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "oswe-vscode-prime": { - id: "oswe-vscode-prime", - name: "Raptor Mini (Preview)", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 200000, maxTokens: 64000, - } satisfies Model<"openai-responses">, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-20250514": { + id: "claude-opus-4-20250514", + name: "Claude Opus 4", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-3-5-haiku-20241022": { + id: "claude-3-5-haiku-20241022", + name: "Claude Haiku 3.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.8, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3-haiku-20240307": { + id: "claude-3-haiku-20240307", + name: "Claude Haiku 3", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-3-7-sonnet-20250219": { + id: "claude-3-7-sonnet-20250219", + name: "Claude Sonnet 3.7", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-3-7-sonnet-latest": { + id: "claude-3-7-sonnet-latest", + name: "Claude Sonnet 3.7 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-0": { + id: "claude-sonnet-4-0", + name: "Claude Sonnet 4 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-1-20250805": { + id: "claude-opus-4-1-20250805", + name: "Claude Opus 4.1", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-3-sonnet-20240229": { + id: "claude-3-sonnet-20240229", + name: "Claude Sonnet 3", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-haiku-4-5-20251001": { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, }, "google": { + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-preview-05-20": { + id: "gemini-2.5-flash-preview-05-20", + name: "Gemini 2.5 Flash Preview 05-20", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.0375, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-flash-lite-latest": { + id: "gemini-flash-lite-latest", + name: "Gemini Flash-Lite Latest", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3-pro-preview": { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash": { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-flash-latest": { + id: "gemini-flash-latest", + name: "Gemini Flash Latest", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-pro-preview-05-06": { + id: "gemini-2.5-pro-preview-05-06", + name: "Gemini 2.5 Pro Preview 05-06", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.0-flash-lite": { + id: "gemini-2.0-flash-lite", + name: "Gemini 2.0 Flash Lite", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-live-2.5-flash-preview-native-audio": { + id: "gemini-live-2.5-flash-preview-native-audio", + name: "Gemini Live 2.5 Flash Preview Native Audio", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.0-flash": { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-lite": { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-pro-preview-06-05": { + id: "gemini-2.5-pro-preview-06-05", + name: "Gemini 2.5 Pro Preview 06-05", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-live-2.5-flash": { + id: "gemini-live-2.5-flash", + name: "Gemini Live 2.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8000, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-lite-preview-06-17": { + id: "gemini-2.5-flash-lite-preview-06-17", + name: "Gemini 2.5 Flash Lite Preview 06-17", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-preview-09-2025": { + id: "gemini-2.5-flash-preview-09-2025", + name: "Gemini 2.5 Flash Preview 09-25", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-preview-04-17": { + id: "gemini-2.5-flash-preview-04-17", + name: "Gemini 2.5 Flash Preview 04-17", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.0375, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, "gemini-1.5-flash": { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", @@ -805,108 +687,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 8192, } satisfies Model<"google-generative-ai">, - "gemini-1.5-pro": { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 5, - cacheRead: 0.3125, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-2.0-flash": { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-2.0-flash-lite": { - id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash": { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite": { - id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite-preview-06-17": { - id: "gemini-2.5-flash-lite-preview-06-17", - name: "Gemini 2.5 Flash Lite Preview 06-17", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, "gemini-2.5-flash-lite-preview-09-2025": { id: "gemini-2.5-flash-lite-preview-09-2025", name: "Gemini 2.5 Flash Lite Preview 09-25", @@ -924,1120 +704,41 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-04-17": { - id: "gemini-2.5-flash-preview-04-17", - name: "Gemini 2.5 Flash Preview 04-17", + "gemini-1.5-pro": { + id: "gemini-1.5-pro", + name: "Gemini 1.5 Pro", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-05-20": { - id: "gemini-2.5-flash-preview-05-20", - name: "Gemini 2.5 Flash Preview 05-20", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-09-2025": { - id: "gemini-2.5-flash-preview-09-2025", - name: "Gemini 2.5 Flash Preview 09-25", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro": { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, + reasoning: false, input: ["text", "image"], cost: { input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro-preview-05-06": { - id: "gemini-2.5-pro-preview-05-06", - name: "Gemini 2.5 Pro Preview 05-06", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro-preview-06-05": { - id: "gemini-2.5-pro-preview-06-05", - name: "Gemini 2.5 Pro Preview 06-05", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-3-flash-preview": { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5, - output: 3, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 12, - cacheRead: 0.2, + output: 5, + cacheRead: 0.3125, cacheWrite: 0, }, contextWindow: 1000000, - maxTokens: 64000, + maxTokens: 8192, } satisfies Model<"google-generative-ai">, - "gemini-flash-latest": { - id: "gemini-flash-latest", - name: "Gemini Flash Latest", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-flash-lite-latest": { - id: "gemini-flash-lite-latest", - name: "Gemini Flash-Lite Latest", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, + }, + "openai": { + "gpt-4.1-nano": { + id: "gpt-4.1-nano", + name: "GPT-4.1 nano", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, input: ["text", "image"], cost: { input: 0.1, output: 0.4, - cacheRead: 0.025, + cacheRead: 0.03, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-live-2.5-flash": { - id: "gemini-live-2.5-flash", - name: "Gemini Live 2.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 8000, - } satisfies Model<"google-generative-ai">, - "gemini-live-2.5-flash-preview-native-audio": { - id: "gemini-live-2.5-flash-preview-native-audio", - name: "Gemini Live 2.5 Flash Preview Native Audio", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text"], - cost: { - input: 0.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - }, - "google-antigravity": { - "claude-opus-4-5-thinking": { - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"google-gemini-cli">, - "claude-sonnet-4-5": { - id: "claude-sonnet-4-5", - name: "Claude Sonnet 4.5 (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"google-gemini-cli">, - "claude-sonnet-4-5-thinking": { - id: "claude-sonnet-4-5-thinking", - name: "Claude Sonnet 4.5 Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"google-gemini-cli">, - "gemini-3-flash": { - id: "gemini-3-flash", - name: "Gemini 3 Flash (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-3-pro-high": { - id: "gemini-3-pro-high", - name: "Gemini 3 Pro High (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-3-pro-low": { - id: "gemini-3-pro-low", - name: "Gemini 3 Pro Low (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gpt-oss-120b-medium": { - id: "gpt-oss-120b-medium", - name: "GPT-OSS 120B Medium (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, + contextWindow: 1047576, maxTokens: 32768, - } satisfies Model<"google-gemini-cli">, - }, - "google-gemini-cli": { - "gemini-2.0-flash": { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-gemini-cli">, - "gemini-2.5-flash": { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-2.5-pro": { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-3-flash-preview": { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - }, - "groq": { - "deepseek-r1-distill-llama-70b": { - id: "deepseek-r1-distill-llama-70b", - name: "DeepSeek R1 Distill Llama 70B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.75, - output: 0.99, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "gemma2-9b-it": { - id: "gemma2-9b-it", - name: "Gemma 2 9B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.2, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "llama-3.1-8b-instant": { - id: "llama-3.1-8b-instant", - name: "Llama 3.1 8B Instant", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.05, - output: 0.08, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "llama-3.3-70b-versatile": { - id: "llama-3.3-70b-versatile", - name: "Llama 3.3 70B Versatile", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.59, - output: 0.79, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "llama3-70b-8192": { - id: "llama3-70b-8192", - name: "Llama 3 70B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.59, - output: 0.79, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "llama3-8b-8192": { - id: "llama3-8b-8192", - name: "Llama 3 8B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.05, - output: 0.08, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-maverick-17b-128e-instruct": { - id: "meta-llama/llama-4-maverick-17b-128e-instruct", - name: "Llama 4 Maverick 17B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-scout-17b-16e-instruct": { - id: "meta-llama/llama-4-scout-17b-16e-instruct", - name: "Llama 4 Scout 17B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.11, - output: 0.34, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "mistral-saba-24b": { - id: "mistral-saba-24b", - name: "Mistral Saba 24B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.79, - output: 0.79, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-instruct": { - id: "moonshotai/kimi-k2-instruct", - name: "Kimi K2 Instruct", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-instruct-0905": { - id: "moonshotai/kimi-k2-instruct-0905", - name: "Kimi K2 Instruct 0905", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b": { - id: "openai/gpt-oss-120b", - name: "GPT OSS 120B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.15, - output: 0.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-20b": { - id: "openai/gpt-oss-20b", - name: "GPT OSS 20B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen-qwq-32b": { - id: "qwen-qwq-32b", - name: "Qwen QwQ 32B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.29, - output: 0.39, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "qwen/qwen3-32b": { - id: "qwen/qwen3-32b", - name: "Qwen3 32B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.29, - output: 0.59, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - }, - "mistral": { - "codestral-latest": { - id: "codestral-latest", - name: "Codestral", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.9, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "devstral-2512": { - id: "devstral-2512", - name: "Devstral 2", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "devstral-medium-2507": { - id: "devstral-medium-2507", - name: "Devstral Medium", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.4, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "devstral-medium-latest": { - id: "devstral-medium-latest", - name: "Devstral 2", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.4, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "devstral-small-2505": { - id: "devstral-small-2505", - name: "Devstral Small 2505", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "devstral-small-2507": { - id: "devstral-small-2507", - name: "Devstral Small", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "labs-devstral-small-2512": { - id: "labs-devstral-small-2512", - name: "Devstral Small 2", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"openai-completions">, - "magistral-medium-latest": { - id: "magistral-medium-latest", - name: "Magistral Medium", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 2, - output: 5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "magistral-small": { - id: "magistral-small", - name: "Magistral Small", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "ministral-3b-latest": { - id: "ministral-3b-latest", - name: "Ministral 3B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.04, - output: 0.04, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "ministral-8b-latest": { - id: "ministral-8b-latest", - name: "Ministral 8B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.1, - output: 0.1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "mistral-large-2411": { - id: "mistral-large-2411", - name: "Mistral Large 2.1", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistral-large-2512": { - id: "mistral-large-2512", - name: "Mistral Large 3", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "mistral-large-latest": { - id: "mistral-large-latest", - name: "Mistral Large", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "mistral-medium-2505": { - id: "mistral-medium-2505", - name: "Mistral Medium 3", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.4, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "mistral-medium-2508": { - id: "mistral-medium-2508", - name: "Mistral Medium 3.1", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.4, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "mistral-medium-latest": { - id: "mistral-medium-latest", - name: "Mistral Medium", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.4, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistral-nemo": { - id: "mistral-nemo", - name: "Mistral Nemo", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "mistral-small-2506": { - id: "mistral-small-2506", - name: "Mistral Small 3.2", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistral-small-latest": { - id: "mistral-small-latest", - name: "Mistral Small", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "open-mistral-7b": { - id: "open-mistral-7b", - name: "Mistral 7B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.25, - output: 0.25, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8000, - maxTokens: 8000, - } satisfies Model<"openai-completions">, - "open-mixtral-8x22b": { - id: "open-mixtral-8x22b", - name: "Mixtral 8x22B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 64000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "open-mixtral-8x7b": { - id: "open-mixtral-8x7b", - name: "Mixtral 8x7B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.7, - output: 0.7, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "pixtral-12b": { - id: "pixtral-12b", - name: "Pixtral 12B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "pixtral-large-latest": { - id: "pixtral-large-latest", - name: "Pixtral Large", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - }, - "openai": { - "codex-mini-latest": { - id: "codex-mini-latest", - name: "Codex Mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text"], - cost: { - input: 1.5, - output: 6, - cacheRead: 0.375, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, } satisfies Model<"openai-responses">, "gpt-4": { id: "gpt-4", @@ -2056,90 +757,22 @@ export const MODELS = { contextWindow: 8192, maxTokens: 8192, } satisfies Model<"openai-responses">, - "gpt-4-turbo": { - id: "gpt-4-turbo", - name: "GPT-4 Turbo", + "o1-pro": { + id: "o1-pro", + name: "o1-pro", api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { - input: 10, - output: 30, + input: 150, + output: 600, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-responses">, - "gpt-4.1": { - id: "gpt-4.1", - name: "GPT-4.1", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 8, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-responses">, - "gpt-4.1-mini": { - id: "gpt-4.1-mini", - name: "GPT-4.1 mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.4, - output: 1.6, - cacheRead: 0.1, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-responses">, - "gpt-4.1-nano": { - id: "gpt-4.1-nano", - name: "GPT-4.1 nano", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-responses">, - "gpt-4o": { - id: "gpt-4o", - name: "GPT-4o", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-responses">, "gpt-4o-2024-05-13": { id: "gpt-4o-2024-05-13", @@ -2158,6 +791,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-responses">, + "gpt-5.1-codex": { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, "gpt-4o-2024-08-06": { id: "gpt-4o-2024-08-06", name: "GPT-4o (2024-08-06)", @@ -2175,6 +825,295 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-responses">, + "gpt-4.1-mini": { + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 1.6, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "o3-deep-research": { + id: "o3-deep-research", + name: "o3-deep-research", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 40, + cacheRead: 2.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "gpt-5.2-pro": { + id: "gpt-5.2-pro", + name: "GPT-5.2 Pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 21, + output: 168, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-4-turbo": { + id: "gpt-4-turbo", + name: "GPT-4 Turbo", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-mini": { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1 Codex mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "o3-mini": { + id: "o3-mini", + name: "o3-mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "gpt-5.2-chat-latest": { + id: "gpt-5.2-chat-latest", + name: "GPT-5.2 Chat", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.13, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "codex-mini-latest": { + id: "codex-mini-latest", + name: "Codex Mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.5, + output: 6, + cacheRead: 0.375, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "gpt-5-nano": { + id: "gpt-5-nano", + name: "GPT-5 Nano", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.05, + output: 0.4, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-codex": { + id: "gpt-5-codex", + name: "GPT-5-Codex", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-4o": { + id: "gpt-4o", + name: "GPT-4o", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "o4-mini": { + id: "o4-mini", + name: "o4-mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.28, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o1": { + id: "o1", + name: "o1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 60, + cacheRead: 7.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "gpt-5-mini": { + id: "gpt-5-mini", + name: "GPT-5 Mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "o3-pro": { + id: "o3-pro", + name: "o3-pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 20, + output: 80, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, "gpt-4o-2024-11-20": { id: "gpt-4o-2024-11-20", name: "GPT-4o (2024-11-20)", @@ -2192,6 +1131,57 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-responses">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "o3": { + id: "o3", + name: "o3", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o4-mini-deep-research": { + id: "o4-mini-deep-research", + name: "o4-mini-deep-research", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, "gpt-4o-mini": { id: "gpt-4o-mini", name: "GPT-4o mini", @@ -2226,74 +1216,6 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "gpt-5-chat-latest": { - id: "gpt-5-chat-latest", - name: "GPT-5 Chat Latest", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-responses">, - "gpt-5-codex": { - id: "gpt-5-codex", - name: "GPT-5-Codex", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5-mini": { - id: "gpt-5-mini", - name: "GPT-5 Mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5-nano": { - id: "gpt-5-nano", - name: "GPT-5 Nano", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.05, - output: 0.4, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, "gpt-5-pro": { id: "gpt-5-pro", name: "GPT-5 Pro", @@ -2311,18 +1233,18 @@ export const MODELS = { contextWindow: 400000, maxTokens: 272000, } satisfies Model<"openai-responses">, - "gpt-5.1": { - id: "gpt-5.1", - name: "GPT-5.1", + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { - input: 1.25, - output: 10, - cacheRead: 0.13, + input: 1.75, + output: 14, + cacheRead: 0.175, cacheWrite: 0, }, contextWindow: 400000, @@ -2345,13 +1267,13 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-responses">, - "gpt-5.1-codex": { - id: "gpt-5.1-codex", - name: "GPT-5.1 Codex", + "gpt-5-chat-latest": { + id: "gpt-5-chat-latest", + name: "GPT-5 Chat Latest", api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", - reasoning: true, + reasoning: false, input: ["text", "image"], cost: { input: 1.25, @@ -2359,707 +1281,1615 @@ export const MODELS = { cacheRead: 0.125, cacheWrite: 0, }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex-max": { - id: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex-mini": { - id: "gpt-5.1-codex-mini", - name: "GPT-5.1 Codex mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5.2": { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5.2-chat-latest": { - id: "gpt-5.2-chat-latest", - name: "GPT-5.2 Chat", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-responses">, - "gpt-5.2-pro": { - id: "gpt-5.2-pro", - name: "GPT-5.2 Pro", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 21, - output: 168, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "o1": { - id: "o1", - name: "o1", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 60, - cacheRead: 7.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o1-pro": { - id: "o1-pro", - name: "o1-pro", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 150, - output: 600, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o3": { - id: "o3", - name: "o3", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 8, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o3-deep-research": { - id: "o3-deep-research", - name: "o3-deep-research", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 10, - output: 40, - cacheRead: 2.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o3-mini": { - id: "o3-mini", - name: "o3-mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.55, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o3-pro": { - id: "o3-pro", - name: "o3-pro", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 20, - output: 80, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o4-mini": { - id: "o4-mini", - name: "o4-mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.28, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o4-mini-deep-research": { - id: "o4-mini-deep-research", - name: "o4-mini-deep-research", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 8, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, }, - "openrouter": { - "ai21/jamba-large-1.7": { - id: "ai21/jamba-large-1.7", - name: "AI21: Jamba Large 1.7", + "groq": { + "llama-3.1-8b-instant": { + id: "llama-3.1-8b-instant", + name: "Llama 3.1 8B Instant", api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", reasoning: false, input: ["text"], cost: { - input: 2, - output: 8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "ai21/jamba-mini-1.7": { - id: "ai21/jamba-mini-1.7", - name: "AI21: Jamba Mini 1.7", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "alibaba/tongyi-deepresearch-30b-a3b": { - id: "alibaba/tongyi-deepresearch-30b-a3b", - name: "Tongyi DeepResearch 30B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.09, - output: 0.39999999999999997, + input: 0.05, + output: 0.08, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "alibaba/tongyi-deepresearch-30b-a3b:free": { - id: "alibaba/tongyi-deepresearch-30b-a3b:free", - name: "Tongyi DeepResearch 30B A3B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "allenai/olmo-3-7b-instruct": { - id: "allenai/olmo-3-7b-instruct", - name: "AllenAI: Olmo 3 7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.19999999999999998, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "amazon/nova-2-lite-v1": { - id: "amazon/nova-2-lite-v1", - name: "Amazon: Nova 2 Lite", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "amazon/nova-lite-v1": { - id: "amazon/nova-lite-v1", - name: "Amazon: Nova Lite 1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.06, - output: 0.24, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 300000, - maxTokens: 5120, - } satisfies Model<"openai-completions">, - "amazon/nova-micro-v1": { - id: "amazon/nova-micro-v1", - name: "Amazon: Nova Micro 1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.035, - output: 0.14, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 5120, - } satisfies Model<"openai-completions">, - "amazon/nova-premier-v1": { - id: "amazon/nova-premier-v1", - name: "Amazon: Nova Premier 1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 12.5, - cacheRead: 0.625, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "amazon/nova-pro-v1": { - id: "amazon/nova-pro-v1", - name: "Amazon: Nova Pro 1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.7999999999999999, - output: 3.1999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 300000, - maxTokens: 5120, - } satisfies Model<"openai-completions">, - "anthropic/claude-3-haiku": { - id: "anthropic/claude-3-haiku", - name: "Anthropic: Claude 3 Haiku", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.25, - output: 1.25, - cacheRead: 0.03, - cacheWrite: 0.3, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "anthropic/claude-3-opus": { - id: "anthropic/claude-3-opus", - name: "Anthropic: Claude 3 Opus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.7999999999999999, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "mistral-saba-24b": { + id: "mistral-saba-24b", + name: "Mistral Saba 24B", api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", reasoning: false, - input: ["text", "image"], + input: ["text"], cost: { - input: 0.7999999999999999, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-sonnet": { - id: "anthropic/claude-3.5-sonnet", - name: "Anthropic: Claude 3.5 Sonnet", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 6, - output: 30, + input: 0.79, + output: 0.79, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 200000, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "llama3-8b-8192": { + id: "llama3-8b-8192", + name: "Llama 3 8B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.05, + output: 0.08, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.7-sonnet": { - id: "anthropic/claude-3.7-sonnet", - name: "Anthropic: Claude 3.7 Sonnet", + "qwen-qwq-32b": { + id: "qwen-qwq-32b", + name: "Qwen QwQ 32B", api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "anthropic/claude-3.7-sonnet:thinking": { - id: "anthropic/claude-3.7-sonnet:thinking", - name: "Anthropic: Claude 3.7 Sonnet (thinking)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "anthropic/claude-haiku-4.5": { - id: "anthropic/claude-haiku-4.5", - name: "Anthropic: Claude Haiku 4.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1, - output: 5, - cacheRead: 0.09999999999999999, - cacheWrite: 1.25, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "anthropic/claude-opus-4": { - id: "anthropic/claude-opus-4", - name: "Anthropic: Claude Opus 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "anthropic/claude-opus-4.1": { - id: "anthropic/claude-opus-4.1", - name: "Anthropic: Claude Opus 4.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "anthropic/claude-opus-4.5": { - id: "anthropic/claude-opus-4.5", - name: "Anthropic: Claude Opus 4.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "anthropic/claude-sonnet-4": { - id: "anthropic/claude-sonnet-4", - name: "Anthropic: Claude Sonnet 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 1000000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "anthropic/claude-sonnet-4.5": { - id: "anthropic/claude-sonnet-4.5", - name: "Anthropic: Claude Sonnet 4.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 1000000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "arcee-ai/trinity-mini": { - id: "arcee-ai/trinity-mini", - name: "Arcee AI: Trinity Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", reasoning: true, input: ["text"], cost: { - input: 0.045, - output: 0.15, + input: 0.29, + output: 0.39, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, + maxTokens: 16384, } satisfies Model<"openai-completions">, - "arcee-ai/trinity-mini:free": { - id: "arcee-ai/trinity-mini:free", - name: "Arcee AI: Trinity Mini (free)", + "llama3-70b-8192": { + id: "llama3-70b-8192", + name: "Llama 3 70B", api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, input: ["text"], cost: { - input: 0, - output: 0, + input: 0.59, + output: 0.79, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 8192, + maxTokens: 8192, } satisfies Model<"openai-completions">, - "arcee-ai/virtuoso-large": { - id: "arcee-ai/virtuoso-large", - name: "Arcee AI: Virtuoso Large", + "deepseek-r1-distill-llama-70b": { + id: "deepseek-r1-distill-llama-70b", + name: "DeepSeek R1 Distill Llama 70B", api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, input: ["text"], cost: { input: 0.75, + output: 0.99, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "gemma2-9b-it": { + id: "gemma2-9b-it", + name: "Gemma 2 9B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.2, + output: 0.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "llama-3.3-70b-versatile": { + id: "llama-3.3-70b-versatile", + name: "Llama 3.3 70B Versatile", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.59, + output: 0.79, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-instruct-0905": { + id: "moonshotai/kimi-k2-instruct-0905", + name: "Kimi K2 Instruct 0905", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-instruct": { + id: "moonshotai/kimi-k2-instruct", + name: "Kimi K2 Instruct", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-32b": { + id: "qwen/qwen3-32b", + name: "Qwen3 32B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.29, + output: 0.59, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-scout-17b-16e-instruct": { + id: "meta-llama/llama-4-scout-17b-16e-instruct", + name: "Llama 4 Scout 17B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.11, + output: 0.34, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-maverick-17b-128e-instruct": { + id: "meta-llama/llama-4-maverick-17b-128e-instruct", + name: "Llama 4 Maverick 17B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + }, + "cerebras": { + "qwen-3-235b-a22b-instruct-2507": { + id: "qwen-3-235b-a22b-instruct-2507", + name: "Qwen 3 235B Instruct", + api: "openai-completions", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.6, output: 1.2, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 64000, + contextWindow: 131000, + maxTokens: 32000, } satisfies Model<"openai-completions">, - "baidu/ernie-4.5-21b-a3b": { - id: "baidu/ernie-4.5-21b-a3b", - name: "Baidu: ERNIE 4.5 21B A3B", + "zai-glm-4.6": { + id: "zai-glm-4.6", + name: "Z.AI GLM-4.6", api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", reasoning: false, input: ["text"], cost: { - input: 0.056, - output: 0.224, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 120000, + contextWindow: 131072, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "gpt-oss-120b": { + id: "gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.25, + output: 0.69, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + }, + "xai": { + "grok-4-fast-non-reasoning": { + id: "grok-4-fast-non-reasoning", + name: "Grok 4 Fast (Non-Reasoning)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-3-fast": { + id: "grok-3-fast", + name: "Grok 3 Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 5, + output: 25, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-4": { + id: "grok-4", + name: "Grok 4", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "grok-2-vision": { + id: "grok-2-vision", + name: "Grok 2 Vision", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-code-fast-1": { + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 1.5, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 10000, + } satisfies Model<"openai-completions">, + "grok-2": { + id: "grok-2", + name: "Grok 2", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-mini-fast-latest": { + id: "grok-3-mini-fast-latest", + name: "Grok 3 Mini Fast Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 4, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-2-vision-1212": { + id: "grok-2-vision-1212", + name: "Grok 2 Vision (1212)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-3": { + id: "grok-3", + name: "Grok 3", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-4-fast": { + id: "grok-4-fast", + name: "Grok 4 Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-2-latest": { + id: "grok-2-latest", + name: "Grok 2 Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-4-1-fast": { + id: "grok-4-1-fast", + name: "Grok 4.1 Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-2-1212": { + id: "grok-2-1212", + name: "Grok 2 (1212)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-fast-latest": { + id: "grok-3-fast-latest", + name: "Grok 3 Fast Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 5, + output: 25, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-latest": { + id: "grok-3-latest", + name: "Grok 3 Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-2-vision-latest": { + id: "grok-2-vision-latest", + name: "Grok 2 Vision Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-vision-beta": { + id: "grok-vision-beta", + name: "Grok Vision Beta", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 5, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-3-mini": { + id: "grok-3-mini", + name: "Grok 3 Mini", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 0.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-beta": { + id: "grok-beta", + name: "Grok Beta", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 5, + output: 15, + cacheRead: 5, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-3-mini-latest": { + id: "grok-3-mini-latest", + name: "Grok 3 Mini Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 0.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-4-1-fast-non-reasoning": { + id: "grok-4-1-fast-non-reasoning", + name: "Grok 4.1 Fast (Non-Reasoning)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-3-mini-fast": { + id: "grok-3-mini-fast", + name: "Grok 3 Mini Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 4, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + }, + "zai": { + "glm-4.7": { + id: "glm-4.7", + name: "GLM-4.7", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "glm-4.5-flash": { + id: "glm-4.5-flash", + name: "GLM-4.5-Flash", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"anthropic-messages">, + "glm-4.5": { + id: "glm-4.5", + name: "GLM-4.5", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"anthropic-messages">, + "glm-4.5-air": { + id: "glm-4.5-air", + name: "GLM-4.5-Air", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 1.1, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"anthropic-messages">, + "glm-4.5v": { + id: "glm-4.5v", + name: "GLM-4.5V", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 1.8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 64000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, + "glm-4.6": { + id: "glm-4.6", + name: "GLM-4.6", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "glm-4.6v": { + id: "glm-4.6v", + name: "GLM-4.6V", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 0.9, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, + }, + "mistral": { + "devstral-medium-2507": { + id: "devstral-medium-2507", + name: "Devstral Medium", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "mistral-large-2512": { + id: "mistral-large-2512", + name: "Mistral Large 3", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "open-mixtral-8x22b": { + id: "open-mixtral-8x22b", + name: "Mixtral 8x22B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 64000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "ministral-8b-latest": { + id: "ministral-8b-latest", + name: "Ministral 8B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.1, + output: 0.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "pixtral-large-latest": { + id: "pixtral-large-latest", + name: "Pixtral Large", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "mistral-small-2506": { + id: "mistral-small-2506", + name: "Mistral Small 3.2", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "devstral-2512": { + id: "devstral-2512", + name: "Devstral 2", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "ministral-3b-latest": { + id: "ministral-3b-latest", + name: "Ministral 3B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "pixtral-12b": { + id: "pixtral-12b", + name: "Pixtral 12B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "mistral-medium-2505": { + id: "mistral-medium-2505", + name: "Mistral Medium 3", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "labs-devstral-small-2512": { + id: "labs-devstral-small-2512", + name: "Devstral Small 2", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "devstral-medium-latest": { + id: "devstral-medium-latest", + name: "Devstral 2", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "devstral-small-2505": { + id: "devstral-small-2505", + name: "Devstral Small 2505", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "mistral-medium-2508": { + id: "mistral-medium-2508", + name: "Mistral Medium 3.1", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "mistral-small-latest": { + id: "mistral-small-latest", + name: "Mistral Small", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "magistral-small": { + id: "magistral-small", + name: "Magistral Small", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "devstral-small-2507": { + id: "devstral-small-2507", + name: "Devstral Small", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "codestral-latest": { + id: "codestral-latest", + name: "Codestral", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.9, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "open-mixtral-8x7b": { + id: "open-mixtral-8x7b", + name: "Mixtral 8x7B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.7, + output: 0.7, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "mistral-nemo": { + id: "mistral-nemo", + name: "Mistral Nemo", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "open-mistral-7b": { + id: "open-mistral-7b", + name: "Mistral 7B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8000, maxTokens: 8000, } satisfies Model<"openai-completions">, - "baidu/ernie-4.5-vl-28b-a3b": { - id: "baidu/ernie-4.5-vl-28b-a3b", - name: "Baidu: ERNIE 4.5 VL 28B A3B", + "mistral-large-latest": { + id: "mistral-large-latest", + name: "Mistral Large", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "mistral-medium-latest": { + id: "mistral-medium-latest", + name: "Mistral Medium", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistral-large-2411": { + id: "mistral-large-2411", + name: "Mistral Large 2.1", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "magistral-medium-latest": { + id: "magistral-medium-latest", + name: "Magistral Medium", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 2, + output: 5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + }, + "github-copilot": { + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "grok-code-fast-1": { + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "gpt-5.1-codex": { + id: "gpt-5.1-codex", + name: "GPT-5.1-Codex", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "claude-haiku-4.5": { + id: "claude-haiku-4.5", + name: "Claude Haiku 4.5", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16000, + } satisfies Model<"openai-completions">, + "gemini-3-pro-preview": { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "oswe-vscode-prime": { + id: "oswe-vscode-prime", + name: "Raptor Mini (Preview)", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-mini": { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1-Codex-mini", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-codex": { + id: "gpt-5-codex", + name: "GPT-5-Codex", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-4o": { + id: "gpt-4o", + name: "GPT-4o", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 64000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "gpt-5-mini": { + id: "gpt-5-mini", + name: "GPT-5-mini", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-responses">, + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1-Codex-max", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "claude-sonnet-4": { + id: "claude-sonnet-4", + name: "Claude Sonnet 4", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16000, + } satisfies Model<"openai-completions">, + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "claude-opus-4.5": { + id: "claude-opus-4.5", + name: "Claude Opus 4.5", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16000, + } satisfies Model<"openai-completions">, + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-responses">, + "claude-sonnet-4.5": { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16000, + } satisfies Model<"openai-completions">, + }, + "openrouter": { + "bytedance-seed/seed-1.6-flash": { + id: "bytedance-seed/seed-1.6-flash", + name: "ByteDance Seed: Seed 1.6 Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text", "image"], cost: { - input: 0.112, - output: 0.448, + input: 0.075, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 30000, - maxTokens: 8000, + contextWindow: 262144, + maxTokens: 16384, } satisfies Model<"openai-completions">, "bytedance-seed/seed-1.6": { id: "bytedance-seed/seed-1.6", @@ -3078,162 +2908,9 @@ export const MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, - "bytedance-seed/seed-1.6-flash": { - id: "bytedance-seed/seed-1.6-flash", - name: "ByteDance Seed: Seed 1.6 Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "cohere/command-r-08-2024": { - id: "cohere/command-r-08-2024", - name: "Cohere: Command R (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, - "cohere/command-r-plus-08-2024": { - id: "cohere/command-r-plus-08-2024", - name: "Cohere: Command R+ (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, - "deepcogito/cogito-v2-preview-llama-109b-moe": { - id: "deepcogito/cogito-v2-preview-llama-109b-moe", - name: "Cogito V2 Preview Llama 109B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.18, - output: 0.59, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32767, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepcogito/cogito-v2-preview-llama-405b": { - id: "deepcogito/cogito-v2-preview-llama-405b", - name: "Deep Cogito: Cogito V2 Preview Llama 405B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepcogito/cogito-v2-preview-llama-70b": { - id: "deepcogito/cogito-v2-preview-llama-70b", - name: "Deep Cogito: Cogito V2 Preview Llama 70B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.88, - output: 0.88, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-chat": { - id: "deepseek/deepseek-chat", - name: "DeepSeek: DeepSeek V3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-chat-v3-0324": { - id: "deepseek/deepseek-chat-v3-0324", - name: "DeepSeek: DeepSeek V3 0324", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 0.88, - cacheRead: 0.106, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-chat-v3.1": { - id: "deepseek/deepseek-chat-v3.1", - name: "DeepSeek: DeepSeek V3.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.15, - output: 0.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 7168, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-r1": { - id: "deepseek/deepseek-r1", - name: "DeepSeek: R1", + "minimax/minimax-m2.1": { + id: "minimax/minimax-m2.1", + name: "MiniMax: MiniMax M2.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -3242,15 +2919,15 @@ export const MODELS = { cost: { input: 0.3, output: 1.2, - cacheRead: 0, - cacheWrite: 0, + cacheRead: 0.03, + cacheWrite: 0.375, }, - contextWindow: 163840, - maxTokens: 4096, + contextWindow: 204800, + maxTokens: 131072, } satisfies Model<"openai-completions">, - "deepseek/deepseek-r1-0528": { - id: "deepseek/deepseek-r1-0528", - name: "DeepSeek: R1 0528", + "z-ai/glm-4.7": { + id: "z-ai/glm-4.7", + name: "Z.AI: GLM 4.7", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -3258,266 +2935,11 @@ export const MODELS = { input: ["text"], cost: { input: 0.39999999999999997, - output: 1.75, + output: 1.5, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-r1-distill-llama-70b": { - id: "deepseek/deepseek-r1-distill-llama-70b", - name: "DeepSeek: R1 Distill Llama 70B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.03, - output: 0.11, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-v3.1-terminus": { - id: "deepseek/deepseek-v3.1-terminus", - name: "DeepSeek: DeepSeek V3.1 Terminus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.21, - output: 0.7899999999999999, - cacheRead: 0.16799999999999998, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-v3.1-terminus:exacto": { - id: "deepseek/deepseek-v3.1-terminus:exacto", - name: "DeepSeek: DeepSeek V3.1 Terminus (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.21, - output: 0.7899999999999999, - cacheRead: 0.16799999999999998, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-v3.2": { - id: "deepseek/deepseek-v3.2", - name: "DeepSeek: DeepSeek V3.2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.224, - output: 0.32, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-v3.2-exp": { - id: "deepseek/deepseek-v3.2-exp", - name: "DeepSeek: DeepSeek V3.2 Exp", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.21, - output: 0.32, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "google/gemini-2.0-flash-001": { - id: "google/gemini-2.0-flash-001", - name: "Google: Gemini 2.0 Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.024999999999999998, - cacheWrite: 0.18330000000000002, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "google/gemini-2.0-flash-exp:free": { - id: "google/gemini-2.0-flash-exp:free", - name: "Google: Gemini 2.0 Flash Experimental (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "google/gemini-2.0-flash-lite-001": { - id: "google/gemini-2.0-flash-lite-001", - name: "Google: Gemini 2.0 Flash Lite", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-flash": { - id: "google/gemini-2.5-flash", - name: "Google: Gemini 2.5 Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.03, - cacheWrite: 0.3833, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-flash-lite": { - id: "google/gemini-2.5-flash-lite", - name: "Google: Gemini 2.5 Flash Lite", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.01, - cacheWrite: 0.18330000000000002, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-flash-lite-preview-09-2025": { - id: "google/gemini-2.5-flash-lite-preview-09-2025", - name: "Google: Gemini 2.5 Flash Lite Preview 09-2025", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-flash-preview-09-2025": { - id: "google/gemini-2.5-flash-preview-09-2025", - name: "Google: Gemini 2.5 Flash Preview 09-2025", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0.3833, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-pro": { - id: "google/gemini-2.5-pro", - name: "Google: Gemini 2.5 Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 1.625, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-pro-preview": { - id: "google/gemini-2.5-pro-preview", - name: "Google: Gemini 2.5 Pro Preview 06-05", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 1.625, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-pro-preview-05-06": { - id: "google/gemini-2.5-pro-preview-05-06", - name: "Google: Gemini 2.5 Pro Preview 05-06", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 1.625, - }, - contextWindow: 1048576, + contextWindow: 202752, maxTokens: 65535, } satisfies Model<"openai-completions">, "google/gemini-3-flash-preview": { @@ -3537,213 +2959,9 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65535, } satisfies Model<"openai-completions">, - "google/gemini-3-pro-preview": { - id: "google/gemini-3-pro-preview", - name: "Google: Gemini 3 Pro Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 12, - cacheRead: 0.19999999999999998, - cacheWrite: 2.375, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "google/gemma-3-27b-it": { - id: "google/gemma-3-27b-it", - name: "Google: Gemma 3 27B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.04, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 96000, - maxTokens: 96000, - } satisfies Model<"openai-completions">, - "google/gemma-3-27b-it:free": { - id: "google/gemma-3-27b-it:free", - name: "Google: Gemma 3 27B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "inception/mercury": { - id: "inception/mercury", - name: "Inception: Mercury", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.25, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "inception/mercury-coder": { - id: "inception/mercury-coder", - name: "Inception: Mercury Coder", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.25, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "kwaipilot/kat-coder-pro:free": { - id: "kwaipilot/kat-coder-pro:free", - name: "Kwaipilot: KAT-Coder-Pro V1 (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3-8b-instruct": { - id: "meta-llama/llama-3-8b-instruct", - name: "Meta: Llama 3 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.06, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 10000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-70b-instruct": { - id: "meta-llama/llama-3.1-70b-instruct", - name: "Meta: Llama 3.1 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.2-3b-instruct": { - id: "meta-llama/llama-3.2-3b-instruct", - name: "Meta: Llama 3.2 3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.02, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.3-70b-instruct": { - id: "meta-llama/llama-3.3-70b-instruct", - name: "Meta: Llama 3.3 70B Instruct", + "mistralai/mistral-small-creative": { + id: "mistralai/mistral-small-creative", + name: "Mistral: Mistral Small Creative", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -3751,183 +2969,132 @@ export const MODELS = { input: ["text"], cost: { input: 0.09999999999999999, - output: 0.32, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.3-70b-instruct:free": { - id: "meta-llama/llama-3.3-70b-instruct:free", - name: "Meta: Llama 3.3 70B Instruct (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-maverick": { - id: "meta-llama/llama-4-maverick", - name: "Meta: Llama 4 Maverick", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-scout": { - id: "meta-llama/llama-4-scout", - name: "Meta: Llama 4 Scout", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.08, output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 327680, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "microsoft/phi-3-medium-128k-instruct": { - id: "microsoft/phi-3-medium-128k-instruct", - name: "Microsoft: Phi-3 Medium 128K Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, + contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "microsoft/phi-3-mini-128k-instruct": { - id: "microsoft/phi-3-mini-128k-instruct", - name: "Microsoft: Phi-3 Mini 128K Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "microsoft/phi-3.5-mini-128k-instruct": { - id: "microsoft/phi-3.5-mini-128k-instruct", - name: "Microsoft: Phi-3.5 Mini 128K Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "minimax/minimax-m1": { - id: "minimax/minimax-m1", - name: "MiniMax: MiniMax M1", + "xiaomi/mimo-v2-flash:free": { + id: "xiaomi/mimo-v2-flash:free", + name: "Xiaomi: MiMo-V2-Flash (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, - output: 2.2, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 40000, + contextWindow: 262144, + maxTokens: 65536, } satisfies Model<"openai-completions">, - "minimax/minimax-m2": { - id: "minimax/minimax-m2", - name: "MiniMax: MiniMax M2", + "nvidia/nemotron-3-nano-30b-a3b:free": { + id: "nvidia/nemotron-3-nano-30b-a3b:free", + name: "NVIDIA: Nemotron 3 Nano 30B A3B (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, - output: 1, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 196608, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "minimax/minimax-m2.1": { - id: "minimax/minimax-m2.1", - name: "MiniMax: MiniMax M2.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.03, - cacheWrite: 0.375, - }, - contextWindow: 204800, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "mistralai/codestral-2508": { - id: "mistralai/codestral-2508", - name: "Mistral: Codestral 2508", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.8999999999999999, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 256000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-30b-a3b": { + id: "nvidia/nemotron-3-nano-30b-a3b", + name: "NVIDIA: Nemotron 3 Nano 30B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2-chat": { + id: "openai/gpt-5.2-chat", + name: "OpenAI: GPT-5.2 Chat", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2-pro": { + id: "openai/gpt-5.2-pro", + name: "OpenAI: GPT-5.2 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 21, + output: 168, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2": { + id: "openai/gpt-5.2", + name: "OpenAI: GPT-5.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "mistralai/devstral-2512:free": { + id: "mistralai/devstral-2512:free", + name: "Mistral: Devstral 2 2512 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/devstral-2512": { id: "mistralai/devstral-2512", name: "Mistral: Devstral 2 2512", @@ -3945,9 +3112,43 @@ export const MODELS = { contextWindow: 262144, maxTokens: 65536, } satisfies Model<"openai-completions">, - "mistralai/devstral-2512:free": { - id: "mistralai/devstral-2512:free", - name: "Mistral: Devstral 2 2512 (free)", + "relace/relace-search": { + id: "relace/relace-search", + name: "Relace: Relace Search", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.6v": { + id: "z-ai/glm-4.6v", + name: "Z.AI: GLM 4.6V", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 0.8999999999999999, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 24000, + } satisfies Model<"openai-completions">, + "nex-agi/deepseek-v3.1-nex-n1:free": { + id: "nex-agi/deepseek-v3.1-nex-n1:free", + name: "Nex AGI: DeepSeek V3.1 Nex N1 (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -3959,42 +3160,42 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/devstral-medium": { - id: "mistralai/devstral-medium", - name: "Mistral: Devstral Medium", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 163840, } satisfies Model<"openai-completions">, - "mistralai/devstral-small": { - id: "mistralai/devstral-small", - name: "Mistral: Devstral Small 1.1", + "openai/gpt-5.1-codex-max": { + id: "openai/gpt-5.1-codex-max", + name: "OpenAI: GPT-5.1-Codex-Max", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 0.07, - output: 0.28, + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "amazon/nova-2-lite-v1": { + id: "amazon/nova-2-lite-v1", + name: "Amazon: Nova 2 Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 1000000, + maxTokens: 65535, } satisfies Model<"openai-completions">, "mistralai/ministral-14b-2512": { id: "mistralai/ministral-14b-2512", @@ -4013,57 +3214,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/ministral-3b": { - id: "mistralai/ministral-3b", - name: "Mistral: Ministral 3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.04, - output: 0.04, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/ministral-3b-2512": { - id: "mistralai/ministral-3b-2512", - name: "Mistral: Ministral 3 3B 2512", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/ministral-8b": { - id: "mistralai/ministral-8b", - name: "Mistral: Ministral 8B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/ministral-8b-2512": { id: "mistralai/ministral-8b-2512", name: "Mistral: Ministral 3 8B 2512", @@ -4081,85 +3231,17 @@ export const MODELS = { contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/mistral-7b-instruct": { - id: "mistralai/mistral-7b-instruct", - name: "Mistral: Mistral 7B Instruct", + "mistralai/ministral-3b-2512": { + id: "mistralai/ministral-3b-2512", + name: "Mistral: Ministral 3 3B 2512", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.028, - output: 0.054, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistralai/mistral-7b-instruct:free": { - id: "mistralai/mistral-7b-instruct:free", - name: "Mistral: Mistral 7B Instruct (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistralai/mistral-large": { - id: "mistralai/mistral-large", - name: "Mistral Large", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-large-2407": { - id: "mistralai/mistral-large-2407", - name: "Mistral Large 2407", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-large-2411": { - id: "mistralai/mistral-large-2411", - name: "Mistral Large 2411", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, + input: 0.09999999999999999, + output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, }, @@ -4183,366 +3265,94 @@ export const MODELS = { contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/mistral-medium-3": { - id: "mistralai/mistral-medium-3", - name: "Mistral: Mistral Medium 3", + "arcee-ai/trinity-mini:free": { + id: "arcee-ai/trinity-mini:free", + name: "Arcee AI: Trinity Mini (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.39999999999999997, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-medium-3.1": { - id: "mistralai/mistral-medium-3.1", - name: "Mistral: Mistral Medium 3.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.39999999999999997, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-nemo": { - id: "mistralai/mistral-nemo", - name: "Mistral: Mistral Nemo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text"], cost: { - input: 0.02, - output: 0.04, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 16384, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/mistral-saba": { - id: "mistralai/mistral-saba", - name: "Mistral: Saba", + "arcee-ai/trinity-mini": { + id: "arcee-ai/trinity-mini", + name: "Arcee AI: Trinity Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, + input: ["text"], + cost: { + input: 0.045, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v3.2": { + id: "deepseek/deepseek-v3.2", + name: "DeepSeek: DeepSeek V3.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.224, + output: 0.32, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "prime-intellect/intellect-3": { + id: "prime-intellect/intellect-3", + name: "Prime Intellect: INTELLECT-3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, input: ["text"], cost: { input: 0.19999999999999998, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-24b-instruct-2501": { - id: "mistralai/mistral-small-24b-instruct-2501", - name: "Mistral: Mistral Small 3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.11, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-3.1-24b-instruct": { - id: "mistralai/mistral-small-3.1-24b-instruct", - name: "Mistral: Mistral Small 3.1 24B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.03, - output: 0.11, + output: 1.1, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, - "mistralai/mistral-small-3.1-24b-instruct:free": { - id: "mistralai/mistral-small-3.1-24b-instruct:free", - name: "Mistral: Mistral Small 3.1 24B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-3.2-24b-instruct": { - id: "mistralai/mistral-small-3.2-24b-instruct", - name: "Mistral: Mistral Small 3.2 24B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.06, - output: 0.18, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-creative": { - id: "mistralai/mistral-small-creative", - name: "Mistral: Mistral Small Creative", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-tiny": { - id: "mistralai/mistral-tiny", - name: "Mistral Tiny", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.25, - output: 0.25, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mixtral-8x22b-instruct": { - id: "mistralai/mixtral-8x22b-instruct", - name: "Mistral: Mixtral 8x22B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mixtral-8x7b-instruct": { - id: "mistralai/mixtral-8x7b-instruct", - name: "Mistral: Mixtral 8x7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.54, - output: 0.54, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistralai/pixtral-12b": { - id: "mistralai/pixtral-12b", - name: "Mistral: Pixtral 12B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/pixtral-large-2411": { - id: "mistralai/pixtral-large-2411", - name: "Mistral: Pixtral Large 2411", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/voxtral-small-24b-2507": { - id: "mistralai/voxtral-small-24b-2507", - name: "Mistral: Voxtral Small 24B 2507", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2": { - id: "moonshotai/kimi-k2", - name: "MoonshotAI: Kimi K2 0711", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.456, - output: 1.8399999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-0905": { - id: "moonshotai/kimi-k2-0905", - name: "MoonshotAI: Kimi K2 0905", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39, - output: 1.9, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-0905:exacto": { - id: "moonshotai/kimi-k2-0905:exacto", - name: "MoonshotAI: Kimi K2 0905 (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.6, - output: 2.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-thinking": { - id: "moonshotai/kimi-k2-thinking", - name: "MoonshotAI: Kimi K2 Thinking", + "tngtech/tng-r1t-chimera:free": { + id: "tngtech/tng-r1t-chimera:free", + name: "TNG: R1T Chimera (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "nex-agi/deepseek-v3.1-nex-n1:free": { - id: "nex-agi/deepseek-v3.1-nex-n1:free", - name: "Nex AGI: DeepSeek V3.1 Nex N1 (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 163840, maxTokens: 163840, } satisfies Model<"openai-completions">, - "nousresearch/deephermes-3-mistral-24b-preview": { - id: "nousresearch/deephermes-3-mistral-24b-preview", - name: "Nous: DeepHermes 3 Mistral 24B Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.02, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "nousresearch/hermes-4-405b": { - id: "nousresearch/hermes-4-405b", - name: "Nous: Hermes 4 405B", + "tngtech/tng-r1t-chimera": { + id: "tngtech/tng-r1t-chimera", + name: "TNG: R1T Chimera", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -4554,603 +3364,76 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 163840, + maxTokens: 65536, } satisfies Model<"openai-completions">, - "nousresearch/hermes-4-70b": { - id: "nousresearch/hermes-4-70b", - name: "Nous: Hermes 4 70B", + "anthropic/claude-opus-4.5": { + id: "anthropic/claude-opus-4.5", + name: "Anthropic: Claude Opus 4.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], - cost: { - input: 0.11, - output: 0.38, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "nvidia/llama-3.1-nemotron-70b-instruct": { - id: "nvidia/llama-3.1-nemotron-70b-instruct", - name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.2, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "nvidia/llama-3.3-nemotron-super-49b-v1.5": { - id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", - name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-3-nano-30b-a3b": { - id: "nvidia/nemotron-3-nano-30b-a3b", - name: "NVIDIA: Nemotron 3 Nano 30B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.06, - output: 0.24, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-3-nano-30b-a3b:free": { - id: "nvidia/nemotron-3-nano-30b-a3b:free", - name: "NVIDIA: Nemotron 3 Nano 30B A3B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-12b-v2-vl:free": { - id: "nvidia/nemotron-nano-12b-v2-vl:free", - name: "NVIDIA: Nemotron Nano 12B 2 VL (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-9b-v2": { - id: "nvidia/nemotron-nano-9b-v2", - name: "NVIDIA: Nemotron Nano 9B V2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.04, - output: 0.16, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-9b-v2:free": { - id: "nvidia/nemotron-nano-9b-v2:free", - name: "NVIDIA: Nemotron Nano 9B V2 (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/codex-mini": { - id: "openai/codex-mini", - name: "OpenAI: Codex Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.5, - output: 6, - cacheRead: 0.375, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-0613": { - id: "openai/gpt-3.5-turbo-0613", - name: "OpenAI: GPT-3.5 Turbo (older v0613)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-16k": { - id: "openai/gpt-3.5-turbo-16k", - name: "OpenAI: GPT-3.5 Turbo 16k", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4-0314": { - id: "openai/gpt-4-0314", - name: "OpenAI: GPT-4 (older v0314)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4-1106-preview": { - id: "openai/gpt-4-1106-preview", - name: "OpenAI: GPT-4 Turbo (older v1106)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 10, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4-turbo": { - id: "openai/gpt-4-turbo", - name: "OpenAI: GPT-4 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 10, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4-turbo-preview": { - id: "openai/gpt-4-turbo-preview", - name: "OpenAI: GPT-4 Turbo Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 10, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4.1": { - id: "openai/gpt-4.1", - name: "OpenAI: GPT-4.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 8, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "openai/gpt-4.1-mini": { - id: "openai/gpt-4.1-mini", - name: "OpenAI: GPT-4.1 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.39999999999999997, - output: 1.5999999999999999, - cacheRead: 0.09999999999999999, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "openai/gpt-4.1-nano": { - id: "openai/gpt-4.1-nano", - name: "OpenAI: GPT-4.1 Nano", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.024999999999999998, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "openai/gpt-4o": { - id: "openai/gpt-4o", - name: "OpenAI: GPT-4o", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, input: ["text", "image"], cost: { input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 32000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-08-06": { - id: "openai/gpt-4o-2024-08-06", - name: "OpenAI: GPT-4o (2024-08-06)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-11-20": { - id: "openai/gpt-4o-2024-11-20", - name: "OpenAI: GPT-4o (2024-11-20)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-4o-audio-preview": { - id: "openai/gpt-4o-audio-preview", - name: "OpenAI: GPT-4o Audio", + "allenai/olmo-3-7b-instruct": { + id: "allenai/olmo-3-7b-instruct", + name: "AllenAI: Olmo 3 7B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 2.5, - output: 10, + input: 0.09999999999999999, + output: 0.19999999999999998, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 65536, + maxTokens: 65536, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini": { - id: "openai/gpt-4o-mini", - name: "OpenAI: GPT-4o-mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini-2024-07-18": { - id: "openai/gpt-4o-mini-2024-07-18", - name: "OpenAI: GPT-4o-mini (2024-07-18)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-4o:extended": { - id: "openai/gpt-4o:extended", - name: "OpenAI: GPT-4o (extended)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 6, - output: 18, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "openai/gpt-5": { - id: "openai/gpt-5", - name: "OpenAI: GPT-5", + "x-ai/grok-4.1-fast": { + id: "x-ai/grok-4.1-fast", + name: "xAI: Grok 4.1 Fast", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text", "image"], cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, + input: 0.19999999999999998, + output: 0.5, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, - contextWindow: 400000, - maxTokens: 128000, + contextWindow: 2000000, + maxTokens: 30000, } satisfies Model<"openai-completions">, - "openai/gpt-5-codex": { - id: "openai/gpt-5-codex", - name: "OpenAI: GPT-5 Codex", + "google/gemini-3-pro-preview": { + id: "google/gemini-3-pro-preview", + name: "Google: Gemini 3 Pro Preview", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text", "image"], cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 2.375, }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5-image": { - id: "openai/gpt-5-image", - name: "OpenAI: GPT-5 Image", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 10, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5-image-mini": { - id: "openai/gpt-5-image-mini", - name: "OpenAI: GPT-5 Image Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2.5, - output: 2, - cacheRead: 0.25, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5-mini": { - id: "openai/gpt-5-mini", - name: "OpenAI: GPT-5 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0.024999999999999998, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5-nano": { - id: "openai/gpt-5-nano", - name: "OpenAI: GPT-5 Nano", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.049999999999999996, - output: 0.39999999999999997, - cacheRead: 0.005, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5-pro": { - id: "openai/gpt-5-pro", - name: "OpenAI: GPT-5 Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 120, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, + contextWindow: 1048576, + maxTokens: 65536, } satisfies Model<"openai-completions">, "openai/gpt-5.1": { id: "openai/gpt-5.1", @@ -5203,23 +3486,6 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "openai/gpt-5.1-codex-max": { - id: "openai/gpt-5.1-codex-max", - name: "OpenAI: GPT-5.1-Codex-Max", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex-mini": { id: "openai/gpt-5.1-codex-mini", name: "OpenAI: GPT-5.1-Codex-Mini", @@ -5237,142 +3503,74 @@ export const MODELS = { contextWindow: 400000, maxTokens: 100000, } satisfies Model<"openai-completions">, - "openai/gpt-5.2": { - id: "openai/gpt-5.2", - name: "OpenAI: GPT-5.2", + "kwaipilot/kat-coder-pro:free": { + id: "kwaipilot/kat-coder-pro:free", + name: "Kwaipilot: KAT-Coder-Pro V1 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-thinking": { + id: "moonshotai/kimi-k2-thinking", + name: "MoonshotAI: Kimi K2 Thinking", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, + input: 0.39999999999999997, + output: 1.75, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 400000, - maxTokens: 128000, + contextWindow: 262144, + maxTokens: 65535, } satisfies Model<"openai-completions">, - "openai/gpt-5.2-chat": { - id: "openai/gpt-5.2-chat", - name: "OpenAI: GPT-5.2 Chat", + "amazon/nova-premier-v1": { + id: "amazon/nova-premier-v1", + name: "Amazon: Nova Premier 1.0", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, + input: 2.5, + output: 12.5, + cacheRead: 0.625, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 1000000, + maxTokens: 32000, } satisfies Model<"openai-completions">, - "openai/gpt-5.2-pro": { - id: "openai/gpt-5.2-pro", - name: "OpenAI: GPT-5.2 Pro", + "mistralai/voxtral-small-24b-2507": { + id: "mistralai/voxtral-small-24b-2507", + name: "Mistral: Voxtral Small 24B 2507", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 21, - output: 168, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b": { - id: "openai/gpt-oss-120b", - name: "OpenAI: gpt-oss-120b", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text"], cost: { - input: 0.039, - output: 0.19, + input: 0.09999999999999999, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 32000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b:exacto": { - id: "openai/gpt-oss-120b:exacto", - name: "OpenAI: gpt-oss-120b (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.039, - output: 0.19, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b:free": { - id: "openai/gpt-oss-120b:free", - name: "OpenAI: gpt-oss-120b (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-20b": { - id: "openai/gpt-oss-20b", - name: "OpenAI: gpt-oss-20b", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.03, - output: 0.14, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-20b:free": { - id: "openai/gpt-oss-20b:free", - name: "OpenAI: gpt-oss-20b (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 128000, - } satisfies Model<"openai-completions">, "openai/gpt-oss-safeguard-20b": { id: "openai/gpt-oss-safeguard-20b", name: "OpenAI: gpt-oss-safeguard-20b", @@ -5390,39 +3588,141 @@ export const MODELS = { contextWindow: 131072, maxTokens: 65536, } satisfies Model<"openai-completions">, - "openai/o1": { - id: "openai/o1", - name: "OpenAI: o1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 15, - output: 60, - cacheRead: 7.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "openai/o3": { - id: "openai/o3", - name: "OpenAI: o3", + "nvidia/nemotron-nano-12b-v2-vl:free": { + id: "nvidia/nemotron-nano-12b-v2-vl:free", + name: "NVIDIA: Nemotron Nano 12B 2 VL (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text", "image"], cost: { - input: 2, - output: 8, - cacheRead: 0.5, + input: 0, + output: 0, + cacheRead: 0, cacheWrite: 0, }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "minimax/minimax-m2": { + id: "minimax/minimax-m2", + name: "MiniMax: MiniMax M2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 1, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 196608, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "deepcogito/cogito-v2-preview-llama-405b": { + id: "deepcogito/cogito-v2-preview-llama-405b", + name: "Deep Cogito: Cogito V2 Preview Llama 405B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-5-image-mini": { + id: "openai/gpt-5-image-mini", + name: "OpenAI: GPT-5 Image Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2.5, + output: 2, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "anthropic/claude-haiku-4.5": { + id: "anthropic/claude-haiku-4.5", + name: "Anthropic: Claude Haiku 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.09999999999999999, + cacheWrite: 1.25, + }, contextWindow: 200000, - maxTokens: 100000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-8b-thinking": { + id: "qwen/qwen3-vl-8b-thinking", + name: "Qwen: Qwen3 VL 8B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.18, + output: 2.0999999999999996, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-8b-instruct": { + id: "qwen/qwen3-vl-8b-instruct", + name: "Qwen: Qwen3 VL 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.064, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "openai/gpt-5-image": { + id: "openai/gpt-5-image", + name: "OpenAI: GPT-5 Image", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"openai-completions">, "openai/o3-deep-research": { id: "openai/o3-deep-research", @@ -5441,74 +3741,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 100000, } satisfies Model<"openai-completions">, - "openai/o3-mini": { - id: "openai/o3-mini", - name: "OpenAI: o3 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.55, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "openai/o3-mini-high": { - id: "openai/o3-mini-high", - name: "OpenAI: o3 Mini High", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.55, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "openai/o3-pro": { - id: "openai/o3-pro", - name: "OpenAI: o3 Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 20, - output: 80, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "openai/o4-mini": { - id: "openai/o4-mini", - name: "OpenAI: o4 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.275, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, "openai/o4-mini-deep-research": { id: "openai/o4-mini-deep-research", name: "OpenAI: o4 Mini Deep Research", @@ -5526,548 +3758,21 @@ export const MODELS = { contextWindow: 200000, maxTokens: 100000, } satisfies Model<"openai-completions">, - "openai/o4-mini-high": { - id: "openai/o4-mini-high", - name: "OpenAI: o4 Mini High", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.275, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "openrouter/auto": { - id: "openrouter/auto", - name: "OpenRouter: Auto Router", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "prime-intellect/intellect-3": { - id: "prime-intellect/intellect-3", - name: "Prime Intellect: INTELLECT-3", + "nvidia/llama-3.3-nemotron-super-49b-v1.5": { + id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", + name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, - output: 1.1, + input: 0.09999999999999999, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "qwen/qwen-2.5-72b-instruct": { - id: "qwen/qwen-2.5-72b-instruct", - name: "Qwen2.5 72B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.12, - output: 0.39, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "qwen/qwen-max": { - id: "qwen/qwen-max", - name: "Qwen: Qwen-Max ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.5999999999999999, - output: 6.3999999999999995, - cacheRead: 0.64, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus": { - id: "qwen/qwen-plus", - name: "Qwen: Qwen-Plus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.2, - cacheRead: 0.16, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus-2025-07-28": { - id: "qwen/qwen-plus-2025-07-28", - name: "Qwen: Qwen Plus 0728", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus-2025-07-28:thinking": { - id: "qwen/qwen-plus-2025-07-28:thinking", - name: "Qwen: Qwen Plus 0728 (thinking)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen-turbo": { - id: "qwen/qwen-turbo", - name: "Qwen: Qwen-Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.049999999999999996, - output: 0.19999999999999998, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-vl-max": { - id: "qwen/qwen-vl-max", - name: "Qwen: Qwen VL Max", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.7999999999999999, - output: 3.1999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen3-14b": { - id: "qwen/qwen3-14b", - name: "Qwen: Qwen3 14B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.049999999999999996, - output: 0.22, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 40960, - maxTokens: 40960, - } satisfies Model<"openai-completions">, - "qwen/qwen3-235b-a22b": { - id: "qwen/qwen3-235b-a22b", - name: "Qwen: Qwen3 235B A22B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.18, - output: 0.54, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 40960, - maxTokens: 40960, - } satisfies Model<"openai-completions">, - "qwen/qwen3-235b-a22b-2507": { - id: "qwen/qwen3-235b-a22b-2507", - name: "Qwen: Qwen3 235B A22B Instruct 2507", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.071, - output: 0.463, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "qwen/qwen3-235b-a22b-thinking-2507": { - id: "qwen/qwen3-235b-a22b-thinking-2507", - name: "Qwen: Qwen3 235B A22B Thinking 2507", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.11, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "qwen/qwen3-30b-a3b": { - id: "qwen/qwen3-30b-a3b", - name: "Qwen: Qwen3 30B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.06, - output: 0.22, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 40960, - maxTokens: 40960, - } satisfies Model<"openai-completions">, - "qwen/qwen3-30b-a3b-instruct-2507": { - id: "qwen/qwen3-30b-a3b-instruct-2507", - name: "Qwen: Qwen3 30B A3B Instruct 2507", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.08, - output: 0.33, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "qwen/qwen3-30b-a3b-thinking-2507": { - id: "qwen/qwen3-30b-a3b-thinking-2507", - name: "Qwen: Qwen3 30B A3B Thinking 2507", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.051, - output: 0.33999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "qwen/qwen3-32b": { - id: "qwen/qwen3-32b", - name: "Qwen: Qwen3 32B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.08, - output: 0.24, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 40960, - maxTokens: 40960, - } satisfies Model<"openai-completions">, - "qwen/qwen3-4b:free": { - id: "qwen/qwen3-4b:free", - name: "Qwen: Qwen3 4B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 40960, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "qwen/qwen3-8b": { - id: "qwen/qwen3-8b", - name: "Qwen: Qwen3 8B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.028, - output: 0.1104, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 20000, - } satisfies Model<"openai-completions">, - "qwen/qwen3-coder": { - id: "qwen/qwen3-coder", - name: "Qwen: Qwen3 Coder 480B A35B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.22, - output: 0.95, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-30b-a3b-instruct": { - id: "qwen/qwen3-coder-30b-a3b-instruct", - name: "Qwen: Qwen3 Coder 30B A3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.07, - output: 0.27, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 160000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-flash": { - id: "qwen/qwen3-coder-flash", - name: "Qwen: Qwen3 Coder Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 1.5, - cacheRead: 0.08, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-plus": { - id: "qwen/qwen3-coder-plus", - name: "Qwen: Qwen3 Coder Plus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 5, - cacheRead: 0.09999999999999999, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "qwen/qwen3-coder:exacto": { - id: "qwen/qwen3-coder:exacto", - name: "Qwen: Qwen3 Coder 480B A35B (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.22, - output: 1.7999999999999998, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "qwen/qwen3-coder:free": { - id: "qwen/qwen3-coder:free", - name: "Qwen: Qwen3 Coder 480B A35B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262000, - maxTokens: 262000, - } satisfies Model<"openai-completions">, - "qwen/qwen3-max": { - id: "qwen/qwen3-max", - name: "Qwen: Qwen3 Max", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.2, - output: 6, - cacheRead: 0.24, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-next-80b-a3b-instruct": { - id: "qwen/qwen3-next-80b-a3b-instruct", - name: "Qwen: Qwen3 Next 80B A3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09, - output: 1.1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "qwen/qwen3-next-80b-a3b-thinking": { - id: "qwen/qwen3-next-80b-a3b-thinking", - name: "Qwen: Qwen3 Next 80B A3B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.12, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-235b-a22b-instruct": { - id: "qwen/qwen3-vl-235b-a22b-instruct", - name: "Qwen: Qwen3 VL 235B A22B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-235b-a22b-thinking": { - id: "qwen/qwen3-vl-235b-a22b-thinking", - name: "Qwen: Qwen3 VL 235B A22B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-30b-a3b-instruct": { - id: "qwen/qwen3-vl-30b-a3b-instruct", - name: "Qwen: Qwen3 VL 30B A3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-30b-a3b-thinking": { @@ -6087,447 +3792,39 @@ export const MODELS = { contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-8b-instruct": { - id: "qwen/qwen3-vl-8b-instruct", - name: "Qwen: Qwen3 VL 8B Instruct", + "qwen/qwen3-vl-30b-a3b-instruct": { + id: "qwen/qwen3-vl-30b-a3b-instruct", + name: "Qwen: Qwen3 VL 30B A3B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], - cost: { - input: 0.064, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-8b-thinking": { - id: "qwen/qwen3-vl-8b-thinking", - name: "Qwen: Qwen3 VL 8B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.18, - output: 2.0999999999999996, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwq-32b": { - id: "qwen/qwq-32b", - name: "Qwen: QwQ 32B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], cost: { input: 0.15, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "relace/relace-search": { - id: "relace/relace-search", - name: "Relace: Relace Search", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "sao10k/l3-euryale-70b": { - id: "sao10k/l3-euryale-70b", - name: "Sao10k: Llama 3 Euryale 70B v2.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.48, - output: 1.48, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "sao10k/l3.1-euryale-70b": { - id: "sao10k/l3.1-euryale-70b", - name: "Sao10K: Llama 3.1 Euryale 70B v2.2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.65, - output: 0.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "stepfun-ai/step3": { - id: "stepfun-ai/step3", - name: "StepFun: Step3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5700000000000001, - output: 1.42, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "thedrummer/rocinante-12b": { - id: "thedrummer/rocinante-12b", - name: "TheDrummer: Rocinante 12B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.16999999999999998, - output: 0.43, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "thedrummer/unslopnemo-12b": { - id: "thedrummer/unslopnemo-12b", - name: "TheDrummer: UnslopNemo 12B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "tngtech/deepseek-r1t2-chimera": { - id: "tngtech/deepseek-r1t2-chimera", - name: "TNG: DeepSeek R1T2 Chimera", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "tngtech/tng-r1t-chimera": { - id: "tngtech/tng-r1t-chimera", - name: "TNG: R1T Chimera", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "tngtech/tng-r1t-chimera:free": { - id: "tngtech/tng-r1t-chimera:free", - name: "TNG: R1T Chimera (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "x-ai/grok-3": { - id: "x-ai/grok-3", - name: "xAI: Grok 3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-3-beta": { - id: "x-ai/grok-3-beta", - name: "xAI: Grok 3 Beta", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-3-mini": { - id: "x-ai/grok-3-mini", - name: "xAI: Grok 3 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-3-mini-beta": { - id: "x-ai/grok-3-mini-beta", - name: "xAI: Grok 3 Mini Beta", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-4": { - id: "x-ai/grok-4", - name: "xAI: Grok 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-4-fast": { - id: "x-ai/grok-4-fast", - name: "xAI: Grok 4 Fast", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 0.5, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "x-ai/grok-4.1-fast": { - id: "x-ai/grok-4.1-fast", - name: "xAI: Grok 4.1 Fast", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 0.5, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "x-ai/grok-code-fast-1": { - id: "x-ai/grok-code-fast-1", - name: "xAI: Grok Code Fast 1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 1.5, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 10000, - } satisfies Model<"openai-completions">, - "xiaomi/mimo-v2-flash:free": { - id: "xiaomi/mimo-v2-flash:free", - name: "Xiaomi: MiMo-V2-Flash (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, + output: 0.6, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "z-ai/glm-4-32b": { - id: "z-ai/glm-4-32b", - name: "Z.AI: GLM 4 32B ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "z-ai/glm-4.5": { - id: "z-ai/glm-4.5", - name: "Z.AI: GLM 4.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.35, - output: 1.55, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "z-ai/glm-4.5-air": { - id: "z-ai/glm-4.5-air", - name: "Z.AI: GLM 4.5 Air", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.10400000000000001, - output: 0.6799999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 98304, - } satisfies Model<"openai-completions">, - "z-ai/glm-4.5-air:free": { - id: "z-ai/glm-4.5-air:free", - name: "Z.AI: GLM 4.5 Air (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "z-ai/glm-4.5v": { - id: "z-ai/glm-4.5v", - name: "Z.AI: GLM 4.5V", + "openai/gpt-5-pro": { + id: "openai/gpt-5-pro", + name: "OpenAI: GPT-5 Pro", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text", "image"], cost: { - input: 0.48, - output: 1.44, - cacheRead: 0.088, + input: 15, + output: 120, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 65536, - maxTokens: 16384, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"openai-completions">, "z-ai/glm-4.6": { id: "z-ai/glm-4.6", @@ -6563,461 +3860,216 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, - "z-ai/glm-4.6v": { - id: "z-ai/glm-4.6v", - name: "Z.AI: GLM 4.6V", + "anthropic/claude-sonnet-4.5": { + id: "anthropic/claude-sonnet-4.5", + name: "Anthropic: Claude Sonnet 4.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text", "image"], cost: { - input: 0.3, - output: 0.8999999999999999, - cacheRead: 0.049999999999999996, - cacheWrite: 0, + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, }, - contextWindow: 131072, - maxTokens: 24000, + contextWindow: 1000000, + maxTokens: 64000, } satisfies Model<"openai-completions">, - "z-ai/glm-4.7": { - id: "z-ai/glm-4.7", - name: "Z.AI: GLM 4.7", + "deepseek/deepseek-v3.2-exp": { + id: "deepseek/deepseek-v3.2-exp", + name: "DeepSeek: DeepSeek V3.2 Exp", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, - output: 1.5, + input: 0.21, + output: 0.32, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 202752, - maxTokens: 65535, + contextWindow: 163840, + maxTokens: 65536, } satisfies Model<"openai-completions">, - }, - "xai": { - "grok-2": { - id: "grok-2", - name: "Grok 2", + "google/gemini-2.5-flash-preview-09-2025": { + id: "google/gemini-2.5-flash-preview-09-2025", + name: "Google: Gemini 2.5 Flash Preview 09-2025", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-1212": { - id: "grok-2-1212", - name: "Grok 2 (1212)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-latest": { - id: "grok-2-latest", - name: "Grok 2 Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-vision": { - id: "grok-2-vision", - name: "Grok 2 Vision", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-2-vision-1212": { - id: "grok-2-vision-1212", - name: "Grok 2 Vision (1212)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-2-vision-latest": { - id: "grok-2-vision-latest", - name: "Grok 2 Vision Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-3": { - id: "grok-3", - name: "Grok 3", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-fast": { - id: "grok-3-fast", - name: "Grok 3 Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 5, - output: 25, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-fast-latest": { - id: "grok-3-fast-latest", - name: "Grok 3 Fast Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 5, - output: 25, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-latest": { - id: "grok-3-latest", - name: "Grok 3 Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-mini": { - id: "grok-3-mini", - name: "Grok 3 Mini", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0.3, - output: 0.5, + output: 2.5, cacheRead: 0.075, - cacheWrite: 0, + cacheWrite: 0.3833, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 1048576, + maxTokens: 65536, } satisfies Model<"openai-completions">, - "grok-3-mini-fast": { - id: "grok-3-mini-fast", - name: "Grok 3 Mini Fast", + "google/gemini-2.5-flash-lite-preview-09-2025": { + id: "google/gemini-2.5-flash-lite-preview-09-2025", + name: "Google: Gemini 2.5 Flash Lite Preview 09-2025", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.6, - output: 4, - cacheRead: 0.15, + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 1048576, + maxTokens: 65536, } satisfies Model<"openai-completions">, - "grok-3-mini-fast-latest": { - id: "grok-3-mini-fast-latest", - name: "Grok 3 Mini Fast Latest", + "qwen/qwen3-vl-235b-a22b-thinking": { + id: "qwen/qwen3-vl-235b-a22b-thinking", + name: "Qwen: Qwen3 VL 235B A22B Thinking", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 4, - cacheRead: 0.15, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-mini-latest": { - id: "grok-3-mini-latest", - name: "Grok 3 Mini Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0.3, - output: 0.5, - cacheRead: 0.075, + output: 1.2, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 8192, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"openai-completions">, - "grok-4": { - id: "grok-4", - name: "Grok 4", + "qwen/qwen3-vl-235b-a22b-instruct": { + id: "qwen/qwen3-vl-235b-a22b-instruct", + name: "Qwen: Qwen3 VL 235B A22B Instruct", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-max": { + id: "qwen/qwen3-max", + name: "Qwen: Qwen3 Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, input: ["text"], cost: { - input: 3, - output: 15, - cacheRead: 0.75, + input: 1.2, + output: 6, + cacheRead: 0.24, cacheWrite: 0, }, contextWindow: 256000, - maxTokens: 64000, + maxTokens: 32768, } satisfies Model<"openai-completions">, - "grok-4-1-fast": { - id: "grok-4-1-fast", - name: "Grok 4.1 Fast", + "qwen/qwen3-coder-plus": { + id: "qwen/qwen3-coder-plus", + name: "Qwen: Qwen3 Coder Plus", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-4-1-fast-non-reasoning": { - id: "grok-4-1-fast-non-reasoning", - name: "Grok 4.1 Fast (Non-Reasoning)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-4-fast": { - id: "grok-4-fast", - name: "Grok 4 Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-4-fast-non-reasoning": { - id: "grok-4-fast-non-reasoning", - name: "Grok 4 Fast (Non-Reasoning)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-beta": { - id: "grok-beta", - name: "Grok Beta", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 5, - output: 15, - cacheRead: 5, + input: 1, + output: 5, + cacheRead: 0.09999999999999999, cacheWrite: 0, }, - contextWindow: 131072, + contextWindow: 128000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "openai/gpt-5-codex": { + id: "openai/gpt-5-codex", + name: "OpenAI: GPT-5 Codex", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v3.1-terminus:exacto": { + id: "deepseek/deepseek-v3.1-terminus:exacto", + name: "DeepSeek: DeepSeek V3.1 Terminus (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.21, + output: 0.7899999999999999, + cacheRead: 0.16799999999999998, + cacheWrite: 0, + }, + contextWindow: 163840, maxTokens: 4096, } satisfies Model<"openai-completions">, - "grok-code-fast-1": { - id: "grok-code-fast-1", - name: "Grok Code Fast 1", + "deepseek/deepseek-v3.1-terminus": { + id: "deepseek/deepseek-v3.1-terminus", + name: "DeepSeek: DeepSeek V3.1 Terminus", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.2, - output: 1.5, - cacheRead: 0.02, + input: 0.21, + output: 0.7899999999999999, + cacheRead: 0.16799999999999998, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 10000, - } satisfies Model<"openai-completions">, - "grok-vision-beta": { - id: "grok-vision-beta", - name: "Grok Vision Beta", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 5, - cacheWrite: 0, - }, - contextWindow: 8192, + contextWindow: 163840, maxTokens: 4096, } satisfies Model<"openai-completions">, - }, - "zai": { - "glm-4.5": { - id: "glm-4.5", - name: "GLM-4.5", + "x-ai/grok-4-fast": { + id: "x-ai/grok-4-fast", + name: "xAI: Grok 4 Fast", api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false}, + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.6, - output: 2.2, - cacheRead: 0.11, + input: 0.19999999999999998, + output: 0.5, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 98304, + contextWindow: 2000000, + maxTokens: 30000, } satisfies Model<"openai-completions">, - "glm-4.5-air": { - id: "glm-4.5-air", - name: "GLM-4.5-Air", + "alibaba/tongyi-deepresearch-30b-a3b:free": { + id: "alibaba/tongyi-deepresearch-30b-a3b:free", + name: "Tongyi DeepResearch 30B A3B (free)", api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0.2, - output: 1.1, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 98304, - } satisfies Model<"openai-completions">, - "glm-4.5-flash": { - id: "glm-4.5-flash", - name: "GLM-4.5-Flash", - api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false}, + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { @@ -7027,79 +4079,3020 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 98304, + maxTokens: 131072, } satisfies Model<"openai-completions">, - "glm-4.5v": { - id: "glm-4.5v", - name: "GLM-4.5V", + "alibaba/tongyi-deepresearch-30b-a3b": { + id: "alibaba/tongyi-deepresearch-30b-a3b", + name: "Tongyi DeepResearch 30B A3B", api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.6, - output: 1.8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 64000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "glm-4.6": { - id: "glm-4.6", - name: "GLM-4.6", - api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false}, + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.6, - output: 2.2, - cacheRead: 0.11, + input: 0.09, + output: 0.39999999999999997, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 204800, + contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, - "glm-4.6v": { - id: "glm-4.6v", - name: "GLM-4.6V", + "qwen/qwen3-coder-flash": { + id: "qwen/qwen3-coder-flash", + name: "Qwen: Qwen3 Coder Flash", api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false}, - reasoning: true, - input: ["text", "image"], + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], cost: { input: 0.3, - output: 0.9, + output: 1.5, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-next-80b-a3b-thinking": { + id: "qwen/qwen3-next-80b-a3b-thinking", + name: "Qwen: Qwen3 Next 80B A3B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.12, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-next-80b-a3b-instruct": { + id: "qwen/qwen3-next-80b-a3b-instruct", + name: "Qwen: Qwen3 Next 80B A3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09, + output: 1.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen-plus-2025-07-28": { + id: "qwen/qwen-plus-2025-07-28", + name: "Qwen: Qwen Plus 0728", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen-plus-2025-07-28:thinking": { + id: "qwen/qwen-plus-2025-07-28:thinking", + name: "Qwen: Qwen Plus 0728 (thinking)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-nano-9b-v2:free": { + id: "nvidia/nemotron-nano-9b-v2:free", + name: "NVIDIA: Nemotron Nano 9B V2 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 32768, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "glm-4.7": { - id: "glm-4.7", - name: "GLM-4.7", + "nvidia/nemotron-nano-9b-v2": { + id: "nvidia/nemotron-nano-9b-v2", + name: "NVIDIA: Nemotron Nano 9B V2", api: "openai-completions", - provider: "zai", - baseUrl: "https://api.z.ai/api/coding/paas/v4", - compat: {"supportsDeveloperRole":false}, + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.6, - output: 2.2, - cacheRead: 0.11, + input: 0.04, + output: 0.16, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 204800, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-0905": { + id: "moonshotai/kimi-k2-0905", + name: "MoonshotAI: Kimi K2 0905", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39, + output: 1.9, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-0905:exacto": { + id: "moonshotai/kimi-k2-0905:exacto", + name: "MoonshotAI: Kimi K2 0905 (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepcogito/cogito-v2-preview-llama-70b": { + id: "deepcogito/cogito-v2-preview-llama-70b", + name: "Deep Cogito: Cogito V2 Preview Llama 70B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.88, + output: 0.88, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepcogito/cogito-v2-preview-llama-109b-moe": { + id: "deepcogito/cogito-v2-preview-llama-109b-moe", + name: "Cogito V2 Preview Llama 109B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.18, + output: 0.59, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32767, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "stepfun-ai/step3": { + id: "stepfun-ai/step3", + name: "StepFun: Step3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5700000000000001, + output: 1.42, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b-thinking-2507": { + id: "qwen/qwen3-30b-a3b-thinking-2507", + name: "Qwen: Qwen3 30B A3B Thinking 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.051, + output: 0.33999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-code-fast-1": { + id: "x-ai/grok-code-fast-1", + name: "xAI: Grok Code Fast 1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 1.5, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 10000, + } satisfies Model<"openai-completions">, + "nousresearch/hermes-4-70b": { + id: "nousresearch/hermes-4-70b", + name: "Nous: Hermes 4 70B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.11, + output: 0.38, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, + "nousresearch/hermes-4-405b": { + id: "nousresearch/hermes-4-405b", + name: "Nous: Hermes 4 405B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-chat-v3.1": { + id: "deepseek/deepseek-chat-v3.1", + name: "DeepSeek: DeepSeek V3.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 7168, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-audio-preview": { + id: "openai/gpt-4o-audio-preview", + name: "OpenAI: GPT-4o Audio", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/mistral-medium-3.1": { + id: "mistralai/mistral-medium-3.1", + name: "Mistral: Mistral Medium 3.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "baidu/ernie-4.5-21b-a3b": { + id: "baidu/ernie-4.5-21b-a3b", + name: "Baidu: ERNIE 4.5 21B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.056, + output: 0.224, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 120000, + maxTokens: 8000, + } satisfies Model<"openai-completions">, + "baidu/ernie-4.5-vl-28b-a3b": { + id: "baidu/ernie-4.5-vl-28b-a3b", + name: "Baidu: ERNIE 4.5 VL 28B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.112, + output: 0.448, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 30000, + maxTokens: 8000, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5v": { + id: "z-ai/glm-4.5v", + name: "Z.AI: GLM 4.5V", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.48, + output: 1.44, + cacheRead: 0.088, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "ai21/jamba-mini-1.7": { + id: "ai21/jamba-mini-1.7", + name: "AI21: Jamba Mini 1.7", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "ai21/jamba-large-1.7": { + id: "ai21/jamba-large-1.7", + name: "AI21: Jamba Large 1.7", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-5": { + id: "openai/gpt-5", + name: "OpenAI: GPT-5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-mini": { + id: "openai/gpt-5-mini", + name: "OpenAI: GPT-5 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-nano": { + id: "openai/gpt-5-nano", + name: "OpenAI: GPT-5 Nano", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.049999999999999996, + output: 0.39999999999999997, + cacheRead: 0.005, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b:free": { + id: "openai/gpt-oss-120b:free", + name: "OpenAI: gpt-oss-120b (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "OpenAI: gpt-oss-120b", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.039, + output: 0.19, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b:exacto": { + id: "openai/gpt-oss-120b:exacto", + name: "OpenAI: gpt-oss-120b (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.039, + output: 0.19, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b:free": { + id: "openai/gpt-oss-20b:free", + name: "OpenAI: gpt-oss-20b (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "OpenAI: gpt-oss-20b", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.03, + output: 0.14, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.1": { + id: "anthropic/claude-opus-4.1", + name: "Anthropic: Claude Opus 4.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/codestral-2508": { + id: "mistralai/codestral-2508", + name: "Mistral: Codestral 2508", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.8999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-30b-a3b-instruct": { + id: "qwen/qwen3-coder-30b-a3b-instruct", + name: "Qwen: Qwen3 Coder 30B A3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.07, + output: 0.27, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 160000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b-instruct-2507": { + id: "qwen/qwen3-30b-a3b-instruct-2507", + name: "Qwen: Qwen3 30B A3B Instruct 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.08, + output: 0.33, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5": { + id: "z-ai/glm-4.5", + name: "Z.AI: GLM 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.35, + output: 1.55, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5-air:free": { + id: "z-ai/glm-4.5-air:free", + name: "Z.AI: GLM 4.5 Air (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5-air": { + id: "z-ai/glm-4.5-air", + name: "Z.AI: GLM 4.5 Air", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.10400000000000001, + output: 0.6799999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b-thinking-2507": { + id: "qwen/qwen3-235b-a22b-thinking-2507", + name: "Qwen: Qwen3 235B A22B Thinking 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.11, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "z-ai/glm-4-32b": { + id: "z-ai/glm-4-32b", + name: "Z.AI: GLM 4 32B ", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder:free": { + id: "qwen/qwen3-coder:free", + name: "Qwen: Qwen3 Coder 480B A35B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder": { + id: "qwen/qwen3-coder", + name: "Qwen: Qwen3 Coder 480B A35B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.22, + output: 0.95, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder:exacto": { + id: "qwen/qwen3-coder:exacto", + name: "Qwen: Qwen3 Coder 480B A35B (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.22, + output: 1.7999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-flash-lite": { + id: "google/gemini-2.5-flash-lite", + name: "Google: Gemini 2.5 Flash Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.01, + cacheWrite: 0.18330000000000002, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b-2507": { + id: "qwen/qwen3-235b-a22b-2507", + name: "Qwen: Qwen3 235B A22B Instruct 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.071, + output: 0.463, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2": { + id: "moonshotai/kimi-k2", + name: "MoonshotAI: Kimi K2 0711", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.456, + output: 1.8399999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mistralai/devstral-medium": { + id: "mistralai/devstral-medium", + name: "Mistral: Devstral Medium", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/devstral-small": { + id: "mistralai/devstral-small", + name: "Mistral: Devstral Small 1.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.07, + output: 0.28, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-4": { + id: "x-ai/grok-4", + name: "xAI: Grok 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "tngtech/deepseek-r1t2-chimera": { + id: "tngtech/deepseek-r1t2-chimera", + name: "TNG: DeepSeek R1T2 Chimera", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "inception/mercury": { + id: "inception/mercury", + name: "Inception: Mercury", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-3.2-24b-instruct": { + id: "mistralai/mistral-small-3.2-24b-instruct", + name: "Mistral: Mistral Small 3.2 24B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.06, + output: 0.18, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "minimax/minimax-m1": { + id: "minimax/minimax-m1", + name: "MiniMax: MiniMax M1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 2.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 40000, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-flash": { + id: "google/gemini-2.5-flash", + name: "Google: Gemini 2.5 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.03, + cacheWrite: 0.3833, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro": { + id: "google/gemini-2.5-pro", + name: "Google: Gemini 2.5 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 1.625, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "openai/o3-pro": { + id: "openai/o3-pro", + name: "OpenAI: o3 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 20, + output: 80, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "x-ai/grok-3-mini": { + id: "x-ai/grok-3-mini", + name: "xAI: Grok 3 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 0.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-3": { + id: "x-ai/grok-3", + name: "xAI: Grok 3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro-preview": { + id: "google/gemini-2.5-pro-preview", + name: "Google: Gemini 2.5 Pro Preview 06-05", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 1.625, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-r1-0528": { + id: "deepseek/deepseek-r1-0528", + name: "DeepSeek: R1 0528", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 1.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4": { + id: "anthropic/claude-opus-4", + name: "Anthropic: Claude Opus 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "anthropic/claude-sonnet-4": { + id: "anthropic/claude-sonnet-4", + name: "Anthropic: Claude Sonnet 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "openai/codex-mini": { + id: "openai/codex-mini", + name: "OpenAI: Codex Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 6, + cacheRead: 0.375, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "nousresearch/deephermes-3-mistral-24b-preview": { + id: "nousresearch/deephermes-3-mistral-24b-preview", + name: "Nous: DeepHermes 3 Mistral 24B Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.02, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "mistralai/mistral-medium-3": { + id: "mistralai/mistral-medium-3", + name: "Mistral: Mistral Medium 3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro-preview-05-06": { + id: "google/gemini-2.5-pro-preview-05-06", + name: "Google: Gemini 2.5 Pro Preview 05-06", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 1.625, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "arcee-ai/virtuoso-large": { + id: "arcee-ai/virtuoso-large", + name: "Arcee AI: Virtuoso Large", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.75, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "inception/mercury-coder": { + id: "inception/mercury-coder", + name: "Inception: Mercury Coder", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen3-4b:free": { + id: "qwen/qwen3-4b:free", + name: "Qwen: Qwen3 4B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b": { + id: "qwen/qwen3-30b-a3b", + name: "Qwen: Qwen3 30B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.22, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "qwen/qwen3-8b": { + id: "qwen/qwen3-8b", + name: "Qwen: Qwen3 8B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.028, + output: 0.1104, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 20000, + } satisfies Model<"openai-completions">, + "qwen/qwen3-14b": { + id: "qwen/qwen3-14b", + name: "Qwen: Qwen3 14B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.22, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "qwen/qwen3-32b": { + id: "qwen/qwen3-32b", + name: "Qwen: Qwen3 32B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.08, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b": { + id: "qwen/qwen3-235b-a22b", + name: "Qwen: Qwen3 235B A22B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.18, + output: 0.54, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "openai/o4-mini-high": { + id: "openai/o4-mini-high", + name: "OpenAI: o4 Mini High", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.275, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o3": { + id: "openai/o3", + name: "OpenAI: o3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/o4-mini": { + id: "openai/o4-mini", + name: "OpenAI: o4 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.275, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/gpt-4.1": { + id: "openai/gpt-4.1", + name: "OpenAI: GPT-4.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "openai/gpt-4.1-mini": { + id: "openai/gpt-4.1-mini", + name: "OpenAI: GPT-4.1 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 1.5999999999999999, + cacheRead: 0.09999999999999999, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "openai/gpt-4.1-nano": { + id: "openai/gpt-4.1-nano", + name: "OpenAI: GPT-4.1 Nano", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "x-ai/grok-3-mini-beta": { + id: "x-ai/grok-3-mini-beta", + name: "xAI: Grok 3 Mini Beta", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 0.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-3-beta": { + id: "x-ai/grok-3-beta", + name: "xAI: Grok 3 Beta", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-maverick": { + id: "meta-llama/llama-4-maverick", + name: "Meta: Llama 4 Maverick", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-scout": { + id: "meta-llama/llama-4-scout", + name: "Meta: Llama 4 Scout", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.08, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 327680, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-chat-v3-0324": { + id: "deepseek/deepseek-chat-v3-0324", + name: "DeepSeek: DeepSeek V3 0324", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 0.88, + cacheRead: 0.106, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-3.1-24b-instruct:free": { + id: "mistralai/mistral-small-3.1-24b-instruct:free", + name: "Mistral: Mistral Small 3.1 24B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-3.1-24b-instruct": { + id: "mistralai/mistral-small-3.1-24b-instruct", + name: "Mistral: Mistral Small 3.1 24B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.03, + output: 0.11, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "google/gemma-3-27b-it:free": { + id: "google/gemma-3-27b-it:free", + name: "Google: Gemma 3 27B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "google/gemma-3-27b-it": { + id: "google/gemma-3-27b-it", + name: "Google: Gemma 3 27B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.04, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 96000, + maxTokens: 96000, + } satisfies Model<"openai-completions">, + "qwen/qwq-32b": { + id: "qwen/qwq-32b", + name: "Qwen: QwQ 32B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "google/gemini-2.0-flash-lite-001": { + id: "google/gemini-2.0-flash-lite-001", + name: "Google: Gemini 2.0 Flash Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.7-sonnet:thinking": { + id: "anthropic/claude-3.7-sonnet:thinking", + name: "Anthropic: Claude 3.7 Sonnet (thinking)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.7-sonnet": { + id: "anthropic/claude-3.7-sonnet", + name: "Anthropic: Claude 3.7 Sonnet", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "mistralai/mistral-saba": { + id: "mistralai/mistral-saba", + name: "Mistral: Saba", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/o3-mini-high": { + id: "openai/o3-mini-high", + name: "OpenAI: o3 Mini High", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "google/gemini-2.0-flash-001": { + id: "google/gemini-2.0-flash-001", + name: "Google: Gemini 2.0 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.024999999999999998, + cacheWrite: 0.18330000000000002, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen-vl-max": { + id: "qwen/qwen-vl-max", + name: "Qwen: Qwen VL Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 3.1999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen-turbo": { + id: "qwen/qwen-turbo", + name: "Qwen: Qwen-Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.19999999999999998, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen-plus": { + id: "qwen/qwen-plus", + name: "Qwen: Qwen-Plus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 1.2, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen-max": { + id: "qwen/qwen-max", + name: "Qwen: Qwen-Max ", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.5999999999999999, + output: 6.3999999999999995, + cacheRead: 0.64, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "openai/o3-mini": { + id: "openai/o3-mini", + name: "OpenAI: o3 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.1, + output: 4.4, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-24b-instruct-2501": { + id: "mistralai/mistral-small-24b-instruct-2501", + name: "Mistral: Mistral Small 3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.03, + output: 0.11, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-r1-distill-llama-70b": { + id: "deepseek/deepseek-r1-distill-llama-70b", + name: "DeepSeek: R1 Distill Llama 70B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.03, + output: 0.11, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-r1": { + id: "deepseek/deepseek-r1", + name: "DeepSeek: R1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-chat": { + id: "deepseek/deepseek-chat", + name: "DeepSeek: DeepSeek V3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "openai/o1": { + id: "openai/o1", + name: "OpenAI: o1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 15, + output: 60, + cacheRead: 7.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "google/gemini-2.0-flash-exp:free": { + id: "google/gemini-2.0-flash-exp:free", + name: "Google: Gemini 2.0 Flash Experimental (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.3-70b-instruct:free": { + id: "meta-llama/llama-3.3-70b-instruct:free", + name: "Meta: Llama 3.3 70B Instruct (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.3-70b-instruct": { + id: "meta-llama/llama-3.3-70b-instruct", + name: "Meta: Llama 3.3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.32, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "amazon/nova-lite-v1": { + id: "amazon/nova-lite-v1", + name: "Amazon: Nova Lite 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.06, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 300000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "amazon/nova-micro-v1": { + id: "amazon/nova-micro-v1", + name: "Amazon: Nova Micro 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.035, + output: 0.14, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "amazon/nova-pro-v1": { + id: "amazon/nova-pro-v1", + name: "Amazon: Nova Pro 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 3.1999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 300000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-11-20": { + id: "openai/gpt-4o-2024-11-20", + name: "OpenAI: GPT-4o (2024-11-20)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-2411": { + id: "mistralai/mistral-large-2411", + name: "Mistral Large 2411", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-2407": { + id: "mistralai/mistral-large-2407", + name: "Mistral Large 2407", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/pixtral-large-2411": { + id: "mistralai/pixtral-large-2411", + name: "Mistral: Pixtral Large 2411", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "thedrummer/unslopnemo-12b": { + id: "thedrummer/unslopnemo-12b", + name: "TheDrummer: UnslopNemo 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.5-sonnet": { + id: "anthropic/claude-3.5-sonnet", + name: "Anthropic: Claude 3.5 Sonnet", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 6, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "mistralai/ministral-8b": { + id: "mistralai/ministral-8b", + name: "Mistral: Ministral 8B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/ministral-3b": { + id: "mistralai/ministral-3b", + name: "Mistral: Ministral 3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "nvidia/llama-3.1-nemotron-70b-instruct": { + id: "nvidia/llama-3.1-nemotron-70b-instruct", + name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.2, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "thedrummer/rocinante-12b": { + id: "thedrummer/rocinante-12b", + name: "TheDrummer: Rocinante 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.16999999999999998, + output: 0.43, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.2-3b-instruct": { + id: "meta-llama/llama-3.2-3b-instruct", + name: "Meta: Llama 3.2 3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.02, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen-2.5-72b-instruct": { + id: "qwen/qwen-2.5-72b-instruct", + name: "Qwen2.5 72B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.12, + output: 0.39, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/pixtral-12b": { + id: "mistralai/pixtral-12b", + name: "Mistral: Pixtral 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "cohere/command-r-08-2024": { + id: "cohere/command-r-08-2024", + name: "Cohere: Command R (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, + "cohere/command-r-plus-08-2024": { + id: "cohere/command-r-plus-08-2024", + name: "Cohere: Command R+ (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, + "sao10k/l3.1-euryale-70b": { + id: "sao10k/l3.1-euryale-70b", + name: "Sao10K: Llama 3.1 Euryale 70B v2.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.65, + output: 0.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "microsoft/phi-3.5-mini-128k-instruct": { + id: "microsoft/phi-3.5-mini-128k-instruct", + name: "Microsoft: Phi-3.5 Mini 128K Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-08-06": { + id: "openai/gpt-4o-2024-08-06", + name: "OpenAI: GPT-4o (2024-08-06)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 10000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-70b-instruct": { + id: "meta-llama/llama-3.1-70b-instruct", + name: "Meta: Llama 3.1 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-nemo": { + id: "mistralai/mistral-nemo", + name: "Mistral: Mistral Nemo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-mini-2024-07-18": { + id: "openai/gpt-4o-mini-2024-07-18", + name: "OpenAI: GPT-4o-mini (2024-07-18)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "OpenAI: GPT-4o-mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "sao10k/l3-euryale-70b": { + id: "sao10k/l3-euryale-70b", + name: "Sao10k: Llama 3 Euryale 70B v2.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.48, + output: 1.48, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "mistralai/mistral-7b-instruct:free": { + id: "mistralai/mistral-7b-instruct:free", + name: "Mistral: Mistral 7B Instruct (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/mistral-7b-instruct": { + id: "mistralai/mistral-7b-instruct", + name: "Mistral: Mistral 7B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.028, + output: 0.054, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "microsoft/phi-3-mini-128k-instruct": { + id: "microsoft/phi-3-mini-128k-instruct", + name: "Microsoft: Phi-3 Mini 128K Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "microsoft/phi-3-medium-128k-instruct": { + id: "microsoft/phi-3-medium-128k-instruct", + name: "Microsoft: Phi-3 Medium 128K Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4o": { + id: "openai/gpt-4o", + name: "OpenAI: GPT-4o", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o:extended": { + id: "openai/gpt-4o:extended", + name: "OpenAI: GPT-4o (extended)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 6, + output: 18, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3-70b-instruct": { + id: "meta-llama/llama-3-70b-instruct", + name: "Meta: Llama 3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3-8b-instruct": { + id: "meta-llama/llama-3-8b-instruct", + name: "Meta: Llama 3 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.03, + output: 0.06, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/mixtral-8x22b-instruct": { + id: "mistralai/mixtral-8x22b-instruct", + name: "Mistral: Mixtral 8x22B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-turbo": { + id: "openai/gpt-4-turbo", + name: "OpenAI: GPT-4 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "anthropic/claude-3-haiku": { + id: "anthropic/claude-3-haiku", + name: "Anthropic: Claude 3 Haiku", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "anthropic/claude-3-opus": { + id: "anthropic/claude-3-opus", + name: "Anthropic: Claude 3 Opus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large": { + id: "mistralai/mistral-large", + name: "Mistral Large", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4095, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-turbo-preview": { + id: "openai/gpt-4-turbo-preview", + name: "OpenAI: GPT-4 Turbo Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-tiny": { + id: "mistralai/mistral-tiny", + name: "Mistral Tiny", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mixtral-8x7b-instruct": { + id: "mistralai/mixtral-8x7b-instruct", + name: "Mistral: Mixtral 8x7B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.54, + output: 0.54, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4-1106-preview": { + id: "openai/gpt-4-1106-preview", + name: "OpenAI: GPT-4 Turbo (older v1106)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-16k": { + id: "openai/gpt-3.5-turbo-16k", + name: "OpenAI: GPT-3.5 Turbo 16k", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-0314": { + id: "openai/gpt-4-0314", + name: "OpenAI: GPT-4 (older v0314)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openrouter/auto": { + id: "openrouter/auto", + name: "OpenRouter: Auto Router", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + }, + "google-gemini-cli": { + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-2.5-flash": { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-2.0-flash": { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-gemini-cli">, + "gemini-3-pro-preview": { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + }, + "google-antigravity": { + "gemini-3-pro-high": { + id: "gemini-3-pro-high", + name: "Gemini 3 Pro High (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-3-pro-low": { + id: "gemini-3-pro-low", + name: "Gemini 3 Pro Low (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-3-flash": { + id: "gemini-3-flash", + name: "Gemini 3 Flash (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "claude-sonnet-4-5-thinking": { + id: "claude-sonnet-4-5-thinking", + name: "Claude Sonnet 4.5 Thinking (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "claude-opus-4-5-thinking": { + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "gpt-oss-120b-medium": { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B Medium (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"google-gemini-cli">, }, } as const; diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md new file mode 100644 index 00000000..5f2371df --- /dev/null +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -0,0 +1,86 @@ +# Session Tree Implementation Plan + +Reference: [session-tree.md](./session-tree.md) + +## Phase 1: SessionManager Core + +- [x] Update entry types with `id`, `parentId` fields (using TreeNode intersection) +- [x] Add `version` field to `SessionHeader` +- [x] Change `CompactionEntry.firstKeptEntryIndex` → `firstKeptEntryId` +- [x] Add `BranchSummaryEntry` type +- [x] Add `byId: Map` index +- [x] Add `leafId: string` tracking +- [x] Implement `getPath(fromId?)` tree traversal +- [x] Implement `getEntry(id)` lookup +- [x] Implement `getLeafId()` helper +- [x] Update `_buildIndex()` to populate `byId` map +- [x] Update `saveMessage()` to include id/parentId (returns id) +- [x] Update `saveCompaction()` signature and fields (returns id) +- [x] Update `saveThinkingLevelChange()` to include id/parentId (returns id) +- [x] Update `saveModelChange()` to include id/parentId (returns id) +- [x] Update `buildSessionContext()` to use `getPath()` traversal + +### Type Hierarchy + +```typescript +// Tree fields (added by SessionManager) +interface TreeNode { id, parentId, timestamp } + +// Content types (for input) +interface MessageContent { type: "message"; message: AppMessage } +interface CompactionContent { type: "compaction"; summary; firstKeptEntryId; tokensBefore } +// etc... + +// Full entry types (TreeNode & Content) +type SessionMessageEntry = TreeNode & MessageContent; +type CompactionEntry = TreeNode & CompactionContent; +// etc... +``` + +## Phase 2: Migration + +- [x] Add `CURRENT_SESSION_VERSION = 2` constant +- [x] Implement `_migrateToV2()` for v1→v2 +- [x] Update `setSessionFile()` to detect version and migrate +- [x] Implement `_rewriteFile()` for post-migration persistence +- [x] Handle `firstKeptEntryIndex` → `firstKeptEntryId` conversion in migration + +## Phase 3: Branching + +- [x] Implement `branchInPlace(id)` - switch leaf pointer +- [x] Implement `branchWithSummary(id, summary)` - create summary entry +- [x] Update `branchToNewFile()` to use IDs (no remapping) +- [ ] Update `AgentSession.branch()` to use new API + +## Phase 4: Compaction Integration + +- [x] Update `compaction.ts` to work with IDs +- [x] Update `prepareCompaction()` to return `firstKeptEntryId` +- [x] Update `compact()` to return `CompactionResult` with `firstKeptEntryId` +- [x] Update `AgentSession` compaction methods +- [x] Add `firstKeptEntryId` to `before_compact` hook event + +## Phase 5: Testing + +- [ ] Add test fixtures from existing sessions +- [ ] Test migration of v1 sessions +- [ ] Test context building with tree structure +- [ ] Test branching operations +- [ ] Test compaction with IDs +- [x] Update existing tests for new types + +## Phase 6: UI Integration + +- [ ] Update `/branch` command for new API +- [ ] Add `/branch-here` command for in-place branching +- [ ] Add `/branches` command to list branches (future) +- [ ] Update session display to show tree info (future) + +## Notes + +- All save methods return the new entry's ID +- Migration rewrites file on first load if version < CURRENT_VERSION +- Existing sessions become linear chains after migration (parentId = previous entry) +- Tree features available immediately after migration +- SessionHeader does NOT have id/parentId (it's metadata, not part of tree) +- Content types allow clean input/output separation diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index 3967b1ce..1781e7e3 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -94,14 +94,12 @@ Format the summary as structured markdown with clear sections.`, return; } - // Return a compaction entry that discards ALL messages - // firstKeptEntryIndex points past all current entries + // Return compaction content - SessionManager adds id/parentId + // Use firstKeptEntryId from event to keep recent messages return { - compactionEntry: { - type: "compaction" as const, - timestamp: new Date().toISOString(), + compaction: { summary, - firstKeptEntryIndex: entries.length, + firstKeptEntryId: event.firstKeptEntryId, tokensBefore, }, }; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 16f33e27..fe4804a1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -754,7 +754,12 @@ export class AgentSession { const preparation = prepareCompaction(entries, settings); if (!preparation) { - throw new Error("Already compacted"); + // Check why we can't compact + const lastEntry = entries[entries.length - 1]; + if (lastEntry?.type === "compaction") { + throw new Error("Already compacted"); + } + throw new Error("Nothing to compact (session too small or needs migration)"); } // Find previous compaction summary if any @@ -766,7 +771,7 @@ export class AgentSession { } } - let compactionEntry: CompactionEntry | undefined; + let hookCompaction: { summary: string; firstKeptEntryId: string; tokensBefore: number } | undefined; let fromHook = false; if (this._hookRunner?.hasHandlers("session")) { @@ -777,6 +782,7 @@ export class AgentSession { previousSessionFile: null, reason: "before_compact", cutPoint: preparation.cutPoint, + firstKeptEntryId: preparation.firstKeptEntryId, previousSummary, messagesToSummarize: [...preparation.messagesToSummarize], messagesToKeep: [...preparation.messagesToKeep], @@ -791,14 +797,24 @@ export class AgentSession { throw new Error("Compaction cancelled"); } - if (result?.compactionEntry) { - compactionEntry = result.compactionEntry; + if (result?.compaction) { + hookCompaction = result.compaction; fromHook = true; } } - if (!compactionEntry) { - compactionEntry = await compact( + let summary: string; + let firstKeptEntryId: string; + let tokensBefore: number; + + if (hookCompaction) { + // Hook provided compaction content + summary = hookCompaction.summary; + firstKeptEntryId = hookCompaction.firstKeptEntryId; + tokensBefore = hookCompaction.tokensBefore; + } else { + // Generate compaction result + const result = await compact( entries, this.model, settings, @@ -806,33 +822,41 @@ export class AgentSession { this._compactionAbortController.signal, customInstructions, ); + summary = result.summary; + firstKeptEntryId = result.firstKeptEntryId; + tokensBefore = result.tokensBefore; } if (this._compactionAbortController.signal.aborted) { throw new Error("Compaction cancelled"); } - this.sessionManager.saveCompaction(compactionEntry); + this.sessionManager.saveCompaction(summary, firstKeptEntryId, tokensBefore); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); - if (this._hookRunner) { + // Get the saved compaction entry for the hook + const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as + | CompactionEntry + | undefined; + + if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", entries: newEntries, sessionFile: this.sessionFile, previousSessionFile: null, reason: "compact", - compactionEntry, - tokensBefore: compactionEntry.tokensBefore, + compactionEntry: savedCompactionEntry, + tokensBefore, fromHook, }); } return { - tokensBefore: compactionEntry.tokensBefore, - summary: compactionEntry.summary, + tokensBefore, + summary, }; } finally { this._compactionAbortController = null; @@ -928,7 +952,7 @@ export class AgentSession { } } - let compactionEntry: CompactionEntry | undefined; + let hookCompaction: { summary: string; firstKeptEntryId: string; tokensBefore: number } | undefined; let fromHook = false; if (this._hookRunner?.hasHandlers("session")) { @@ -939,6 +963,7 @@ export class AgentSession { previousSessionFile: null, reason: "before_compact", cutPoint: preparation.cutPoint, + firstKeptEntryId: preparation.firstKeptEntryId, previousSummary, messagesToSummarize: [...preparation.messagesToSummarize], messagesToKeep: [...preparation.messagesToKeep], @@ -954,20 +979,33 @@ export class AgentSession { return; } - if (hookResult?.compactionEntry) { - compactionEntry = hookResult.compactionEntry; + if (hookResult?.compaction) { + hookCompaction = hookResult.compaction; fromHook = true; } } - if (!compactionEntry) { - compactionEntry = await compact( + let summary: string; + let firstKeptEntryId: string; + let tokensBefore: number; + + if (hookCompaction) { + // Hook provided compaction content + summary = hookCompaction.summary; + firstKeptEntryId = hookCompaction.firstKeptEntryId; + tokensBefore = hookCompaction.tokensBefore; + } else { + // Generate compaction result + const compactResult = await compact( entries, this.model, settings, apiKey, this._autoCompactionAbortController.signal, ); + summary = compactResult.summary; + firstKeptEntryId = compactResult.firstKeptEntryId; + tokensBefore = compactResult.tokensBefore; } if (this._autoCompactionAbortController.signal.aborted) { @@ -975,27 +1013,32 @@ export class AgentSession { return; } - this.sessionManager.saveCompaction(compactionEntry); + this.sessionManager.saveCompaction(summary, firstKeptEntryId, tokensBefore); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); - if (this._hookRunner) { + // Get the saved compaction entry for the hook + const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as + | CompactionEntry + | undefined; + + if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", entries: newEntries, sessionFile: this.sessionFile, previousSessionFile: null, reason: "compact", - compactionEntry, - tokensBefore: compactionEntry.tokensBefore, + compactionEntry: savedCompactionEntry, + tokensBefore, fromHook, }); } const result: CompactionResult = { - tokensBefore: compactionEntry.tokensBefore, - summary: compactionEntry.summary, + tokensBefore, + summary, }; this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry }); diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index af4edfb8..8abfef74 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -9,7 +9,14 @@ import type { AppMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; import { messageTransformer } from "./messages.js"; -import type { CompactionEntry, SessionEntry } from "./session-manager.js"; +import type { CompactionEntry, ConversationEntry, SessionEntry } from "./session-manager.js"; + +/** Result from compact() - SessionManager adds uuid/parentUuid when saving */ +export interface CompactionResult { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; +} // ============================================================================ // Types @@ -327,6 +334,8 @@ export async function generateSummary( export interface CompactionPreparation { cutPoint: CutPointResult; + /** UUID of first entry to keep */ + firstKeptEntryId: string; /** Messages that will be summarized and discarded */ messagesToSummarize: AppMessage[]; /** Messages that will be kept after the summary (recent turns) */ @@ -355,6 +364,16 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + // Get UUID of first kept entry + const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex]; + if (firstKeptEntry.type === "session") { + return null; // Can't compact if first kept is header + } + const firstKeptEntryId = (firstKeptEntry as ConversationEntry).id; + if (!firstKeptEntryId) { + return null; // Session needs migration + } + const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; // Messages to summarize (will be discarded after summary) @@ -375,7 +394,7 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS } } - return { cutPoint, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart }; + return { cutPoint, firstKeptEntryId, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart }; } // ============================================================================ @@ -394,9 +413,9 @@ Be concise. Focus on information needed to understand the retained recent work.` /** * Calculate compaction and generate summary. - * Returns the CompactionEntry to append to the session file. + * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. * - * @param entries - All session entries + * @param entries - All session entries (must have uuid fields for v2) * @param model - Model to use for summarization * @param settings - Compaction settings * @param apiKey - API key for LLM @@ -410,7 +429,7 @@ export async function compact( apiKey: string, signal?: AbortSignal, customInstructions?: string, -): Promise { +): Promise { // Don't compact if the last entry is already a compaction if (entries.length > 0 && entries[entries.length - 1].type === "compaction") { throw new Error("Already compacted"); @@ -490,11 +509,19 @@ export async function compact( ); } + // Get UUID of first kept entry + const firstKeptEntry = entries[cutResult.firstKeptEntryIndex]; + if (firstKeptEntry.type === "session") { + throw new Error("Cannot compact: first kept entry is session header"); + } + const firstKeptEntryId = (firstKeptEntry as ConversationEntry).id; + if (!firstKeptEntryId) { + throw new Error("First kept entry has no UUID - session may need migration"); + } + return { - type: "compaction", - timestamp: new Date().toISOString(), summary, - firstKeptEntryIndex: cutResult.firstKeptEntryIndex, + firstKeptEntryId, tokensBefore, }; } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index a60d009b..a51a93bf 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -130,6 +130,8 @@ export type SessionEvent = | (SessionEventBase & { reason: "before_compact"; cutPoint: CutPointResult; + /** ID of first entry to keep (for hooks that return CompactionEntry) */ + firstKeptEntryId: string; /** Summary from previous compaction, if any. Include this in your summary to preserve context. */ previousSummary?: string; /** Messages that will be summarized and discarded */ @@ -351,8 +353,12 @@ export interface SessionEventResult { cancel?: boolean; /** If true (for before_branch only), skip restoring conversation to branch point while still creating the branched session file */ skipConversationRestore?: boolean; - /** Custom compaction entry (for before_compact event) */ - compactionEntry?: CompactionEntry; + /** Custom compaction result (for before_compact event) - SessionManager adds id/parentId */ + compaction?: { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + }; } // ============================================================================ diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index dd118afd..3b9eda0b 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,9 +1,11 @@ import type { AppMessage } from "@mariozechner/pi-agent-core"; import { randomBytes } from "crypto"; -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs"; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"; import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir } from "../config.js"; +export const CURRENT_SESSION_VERSION = 2; + function uuidv4(): string { const bytes = randomBytes(16); bytes[6] = (bytes[6] & 0x0f) | 0x40; @@ -12,47 +14,89 @@ function uuidv4(): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } +// ============================================================================ +// Session Header (metadata, not part of conversation tree) +// ============================================================================ + export interface SessionHeader { type: "session"; + version?: number; // v1 sessions don't have this id: string; timestamp: string; cwd: string; branchedFrom?: string; } -export interface SessionMessageEntry { - type: "message"; +// ============================================================================ +// Tree Node (added by SessionManager to all conversation entries) +// ============================================================================ + +export interface TreeNode { + id: string; + parentId: string | null; timestamp: string; +} + +// ============================================================================ +// Content Types (what distinguishes entries - used for input) +// ============================================================================ + +export interface MessageContent { + type: "message"; message: AppMessage; } -export interface ThinkingLevelChangeEntry { +export interface ThinkingLevelContent { type: "thinking_level_change"; - timestamp: string; thinkingLevel: string; } -export interface ModelChangeEntry { +export interface ModelChangeContent { type: "model_change"; - timestamp: string; provider: string; modelId: string; } -export interface CompactionEntry { +export interface CompactionContent { type: "compaction"; - timestamp: string; summary: string; - firstKeptEntryIndex: number; + firstKeptEntryId: string; tokensBefore: number; } -export type SessionEntry = - | SessionHeader +export interface BranchSummaryContent { + type: "branch_summary"; + summary: string; +} + +/** Union of all content types (for input) */ +export type ConversationContent = + | MessageContent + | ThinkingLevelContent + | ModelChangeContent + | CompactionContent + | BranchSummaryContent; + +// ============================================================================ +// Full Entry Types (TreeNode + Content - returned from SessionManager) +// ============================================================================ + +export type SessionMessageEntry = TreeNode & MessageContent; +export type ThinkingLevelChangeEntry = TreeNode & ThinkingLevelContent; +export type ModelChangeEntry = TreeNode & ModelChangeContent; +export type CompactionEntry = TreeNode & CompactionContent; +export type BranchSummaryEntry = TreeNode & BranchSummaryContent; + +/** Conversation entry - has id/parentId for tree structure */ +export type ConversationEntry = | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry - | CompactionEntry; + | CompactionEntry + | BranchSummaryEntry; + +/** Any session entry (header or conversation) */ +export type SessionEntry = SessionHeader | ConversationEntry; export interface SessionContext { messages: AppMessage[]; @@ -87,6 +131,45 @@ export function createSummaryMessage(summary: string): AppMessage { }; } +/** + * Migrate v1 entries to v2 format by adding id/parentId fields. + * Mutates entries in place. Safe to call on already-migrated entries. + */ +export function migrateSessionEntries(entries: SessionEntry[]): void { + // Check if already migrated + const firstConv = entries.find((e) => e.type !== "session"); + if (firstConv && "id" in firstConv && firstConv.id) { + return; // Already migrated + } + + let prevId: string | null = null; + for (const entry of entries) { + if (entry.type === "session") { + entry.version = CURRENT_SESSION_VERSION; + continue; + } + + // Add id/parentId to conversation entries + const convEntry = entry as ConversationEntry; + convEntry.id = uuidv4(); + convEntry.parentId = prevId; + prevId = convEntry.id; + + // Convert firstKeptEntryIndex to firstKeptEntryId for compaction + if (entry.type === "compaction") { + const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number }; + if (typeof comp.firstKeptEntryIndex === "number") { + // Find the entry at that index and get its id + const targetEntry = entries[comp.firstKeptEntryIndex]; + if (targetEntry && targetEntry.type !== "session") { + comp.firstKeptEntryId = (targetEntry as ConversationEntry).id; + } + delete comp.firstKeptEntryIndex; + } + } + } +} + /** Exported for compaction.test.ts */ export function parseSessionEntries(content: string): SessionEntry[] { const entries: SessionEntry[] = []; @@ -115,59 +198,108 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt } /** - * Build the session context from entries. This is what gets sent to the LLM. - * - * If there's a compaction entry, returns the summary message plus messages - * from `firstKeptEntryIndex` onwards. Otherwise returns all messages. - * - * Also extracts the current thinking level and model from the entries. + * Build the session context from entries using tree traversal. + * If leafId is provided, walks from that entry to root. + * Handles compaction and branch summaries along the path. */ -export function buildSessionContext(entries: SessionEntry[]): SessionContext { +export function buildSessionContext(entries: SessionEntry[], leafId?: string): SessionContext { + // Build uuid index for conversation entries + const byId = new Map(); + for (const entry of entries) { + if (entry.type !== "session") { + byId.set(entry.id, entry); + } + } + + // Find leaf + let leaf: ConversationEntry | undefined; + if (leafId) { + leaf = byId.get(leafId); + } else { + // Find last conversation entry + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].type !== "session") { + leaf = entries[i] as ConversationEntry; + break; + } + } + } + + if (!leaf) { + return { messages: [], thinkingLevel: "off", model: null }; + } + + // Walk from leaf to root, collecting path + const path: ConversationEntry[] = []; + let current: ConversationEntry | undefined = leaf; + while (current) { + path.unshift(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + + // Extract settings and find compaction let thinkingLevel = "off"; let model: { provider: string; modelId: string } | null = null; + let compaction: CompactionEntry | null = null; - for (const entry of entries) { + for (const entry of path) { if (entry.type === "thinking_level_change") { thinkingLevel = entry.thinkingLevel; } else if (entry.type === "model_change") { model = { provider: entry.provider, modelId: entry.modelId }; } else if (entry.type === "message" && entry.message.role === "assistant") { model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "compaction") { + compaction = entry; } } - let latestCompactionIndex = -1; - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].type === "compaction") { - latestCompactionIndex = i; - break; - } - } + // Build messages - handle compaction ordering correctly + // When there's a compaction, we need to: + // 1. Emit summary first + // 2. Emit kept messages (from firstKeptEntryId up to compaction) + // 3. Emit messages after compaction + const messages: AppMessage[] = []; - if (latestCompactionIndex === -1) { - const messages: AppMessage[] = []; - for (const entry of entries) { - if (entry.type === "message") { + if (compaction) { + // Emit summary first + messages.push(createSummaryMessage(compaction.summary)); + + // Find compaction index in path + const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id); + + // Emit kept messages (before compaction, starting from firstKeptEntryId) + let foundFirstKept = false; + for (let i = 0; i < compactionIdx; i++) { + const entry = path[i]; + if (entry.id === compaction.firstKeptEntryId) { + foundFirstKept = true; + } + if (foundFirstKept && entry.type === "message") { messages.push(entry.message); } } - return { messages, thinkingLevel, model }; - } - const compactionEvent = entries[latestCompactionIndex] as CompactionEntry; - - const keptMessages: AppMessage[] = []; - for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) { - const entry = entries[i]; - if (entry.type === "message") { - keptMessages.push(entry.message); + // Emit messages after compaction + for (let i = compactionIdx + 1; i < path.length; i++) { + const entry = path[i]; + if (entry.type === "message") { + messages.push(entry.message); + } else if (entry.type === "branch_summary") { + messages.push(createSummaryMessage(entry.summary)); + } + } + } else { + // No compaction - emit all messages, handle branch summaries + for (const entry of path) { + if (entry.type === "message") { + messages.push(entry.message); + } else if (entry.type === "branch_summary") { + messages.push(createSummaryMessage(entry.summary)); + } } } - const messages: AppMessage[] = []; - messages.push(createSummaryMessage(compactionEvent.summary)); - messages.push(...keptMessages); - return { messages, thinkingLevel, model }; } @@ -229,6 +361,10 @@ export class SessionManager { private flushed: boolean = false; private inMemoryEntries: SessionEntry[] = []; + // Tree structure (v2) + private byId: Map = new Map(); + private leafId: string = ""; + private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) { this.cwd = cwd; this.sessionDir = sessionDir; @@ -240,10 +376,7 @@ export class SessionManager { if (sessionFile) { this.setSessionFile(sessionFile); } else { - this.sessionId = uuidv4(); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`); - this.setSessionFile(sessionFile); + this._initNewSession(); } } @@ -252,23 +385,61 @@ export class SessionManager { this.sessionFile = resolve(sessionFile); if (existsSync(this.sessionFile)) { this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); - const header = this.inMemoryEntries.find((e) => e.type === "session"); - this.sessionId = header ? (header as SessionHeader).id : uuidv4(); + const header = this.inMemoryEntries.find((e) => e.type === "session") as SessionHeader | undefined; + this.sessionId = header?.id ?? uuidv4(); + + // Migrate v1 to v2 if needed + const version = header?.version ?? 1; + if (version < CURRENT_SESSION_VERSION) { + this._migrateToV2(); + this._rewriteFile(); + } + + this._buildIndex(); this.flushed = true; } else { - this.sessionId = uuidv4(); - this.inMemoryEntries = []; - this.flushed = false; - const entry: SessionHeader = { - type: "session", - id: this.sessionId, - timestamp: new Date().toISOString(), - cwd: this.cwd, - }; - this.inMemoryEntries.push(entry); + this._initNewSession(); } } + private _initNewSession(): void { + this.sessionId = uuidv4(); + const timestamp = new Date().toISOString(); + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: this.sessionId, + timestamp, + cwd: this.cwd, + }; + this.inMemoryEntries = [header]; + this.byId.clear(); + this.leafId = ""; + this.flushed = false; + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); + } + + private _migrateToV2(): void { + migrateSessionEntries(this.inMemoryEntries); + } + + private _buildIndex(): void { + this.byId.clear(); + this.leafId = ""; + for (const entry of this.inMemoryEntries) { + if (entry.type === "session") continue; + this.byId.set(entry.id, entry); + this.leafId = entry.id; + } + } + + private _rewriteFile(): void { + if (!this.persist) return; + const content = `${this.inMemoryEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; + writeFileSync(this.sessionFile, content); + } + isPersisted(): boolean { return this.persist; } @@ -290,18 +461,7 @@ export class SessionManager { } reset(): void { - this.sessionId = uuidv4(); - this.flushed = false; - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - this.sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`); - this.inMemoryEntries = [ - { - type: "session", - id: this.sessionId, - timestamp: new Date().toISOString(), - cwd: this.cwd, - }, - ]; + this._initNewSession(); } _persist(entry: SessionEntry): void { @@ -320,49 +480,93 @@ export class SessionManager { } } - saveMessage(message: AppMessage): void { + private _appendEntry(entry: ConversationEntry): void { + this.inMemoryEntries.push(entry); + this.byId.set(entry.id, entry); + this.leafId = entry.id; + this._persist(entry); + } + + saveMessage(message: AppMessage): string { const entry: SessionMessageEntry = { type: "message", + id: uuidv4(), + parentId: this.leafId || null, timestamp: new Date().toISOString(), message, }; - this.inMemoryEntries.push(entry); - this._persist(entry); + this._appendEntry(entry); + return entry.id; } - saveThinkingLevelChange(thinkingLevel: string): void { + saveThinkingLevelChange(thinkingLevel: string): string { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", + id: uuidv4(), + parentId: this.leafId || null, timestamp: new Date().toISOString(), thinkingLevel, }; - this.inMemoryEntries.push(entry); - this._persist(entry); + this._appendEntry(entry); + return entry.id; } - saveModelChange(provider: string, modelId: string): void { + saveModelChange(provider: string, modelId: string): string { const entry: ModelChangeEntry = { type: "model_change", + id: uuidv4(), + parentId: this.leafId || null, timestamp: new Date().toISOString(), provider, modelId, }; - this.inMemoryEntries.push(entry); - this._persist(entry); + this._appendEntry(entry); + return entry.id; } - saveCompaction(entry: CompactionEntry): void { - this.inMemoryEntries.push(entry); - this._persist(entry); + saveCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): string { + const entry: CompactionEntry = { + type: "compaction", + id: uuidv4(), + parentId: this.leafId || null, + timestamp: new Date().toISOString(), + summary, + firstKeptEntryId, + tokensBefore, + }; + this._appendEntry(entry); + return entry.id; + } + + // ========================================================================= + // Tree Traversal + // ========================================================================= + + getLeafUuid(): string { + return this.leafId; + } + + getEntry(id: string): ConversationEntry | undefined { + return this.byId.get(id); + } + + /** Walk from entry to root, returning path (conversation entries only) */ + getPath(fromId?: string): ConversationEntry[] { + const path: ConversationEntry[] = []; + let current = this.byId.get(fromId ?? this.leafId); + while (current) { + path.unshift(current); + current = current.parentId ? this.byId.get(current.parentId) : undefined; + } + return path; } /** * Build the session context (what gets sent to the LLM). - * If compacted, returns summary + kept messages. Otherwise all messages. - * Includes thinking level and model. + * Uses tree traversal from current leaf. */ buildSessionContext(): SessionContext { - return buildSessionContext(this.getEntries()); + return buildSessionContext(this.getEntries(), this.leafId); } /** @@ -373,6 +577,35 @@ export class SessionManager { return [...this.inMemoryEntries]; } + // ========================================================================= + // Branching + // ========================================================================= + + /** Branch in-place by changing the leaf pointer */ + branchInPlace(branchFromId: string): void { + if (!this.byId.has(branchFromId)) { + throw new Error(`Entry ${branchFromId} not found`); + } + this.leafId = branchFromId; + } + + /** Branch with a summary of the abandoned path */ + branchWithSummary(branchFromId: string, summary: string): string { + if (!this.byId.has(branchFromId)) { + throw new Error(`Entry ${branchFromId} not found`); + } + this.leafId = branchFromId; + const entry: BranchSummaryEntry = { + type: "branch_summary", + id: uuidv4(), + parentId: branchFromId, + timestamp: new Date().toISOString(), + summary, + }; + this._appendEntry(entry); + return entry.id; + } + createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null { const newSessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); @@ -385,6 +618,7 @@ export class SessionManager { if (entry.type === "session") { newEntries.push({ ...entry, + version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp: new Date().toISOString(), branchedFrom: this.persist ? this.sessionFile : undefined, @@ -402,6 +636,7 @@ export class SessionManager { } this.inMemoryEntries = newEntries; this.sessionId = newSessionId; + this._buildIndex(); return null; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 2683765e..04151f0b 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -107,11 +107,20 @@ export { readOnlyTools, } from "./core/sdk.js"; export { + type BranchSummaryContent, + type BranchSummaryEntry, buildSessionContext, + type CompactionContent, type CompactionEntry, + type ConversationContent, + type ConversationEntry, + CURRENT_SESSION_VERSION, createSummaryMessage, getLatestCompactionEntry, + type MessageContent, + type ModelChangeContent, type ModelChangeEntry, + migrateSessionEntries, parseSessionEntries, type SessionContext as LoadedSession, type SessionEntry, @@ -122,6 +131,9 @@ export { SUMMARY_PREFIX, SUMMARY_SUFFIX, type ThinkingLevelChangeEntry, + type ThinkingLevelContent, + // Tree types (v2) + type TreeNode, } from "./core/session-manager.js"; export { type CompactionSettings, diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index ff519eb5..2b7f5e71 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -156,9 +156,9 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { expect(compaction.type).toBe("compaction"); if (compaction.type === "compaction") { expect(compaction.summary.length).toBeGreaterThan(0); - // firstKeptEntryIndex can be 0 if all messages fit within keepRecentTokens + // firstKeptEntryId can be 0 if all messages fit within keepRecentTokens // (which is the case for small conversations) - expect(compaction.firstKeptEntryIndex).toBeGreaterThanOrEqual(0); + expect(compaction.firstKeptEntryId).toBeGreaterThanOrEqual(0); expect(compaction.tokensBefore).toBeGreaterThan(0); } }, 120000); diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index 476d40fb..9c5f5479 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest"; import type { HookAPI } from "../src/core/hooks/index.js"; -import type { CompactionEntry } from "../src/core/session-manager.js"; describe("Documentation example", () => { it("custom compaction example should type-check correctly", () => { @@ -20,29 +19,30 @@ describe("Documentation example", () => { const tokensBefore = event.tokensBefore; const model = event.model; const resolveApiKey = event.resolveApiKey; + const firstKeptEntryId = event.firstKeptEntryId; // Verify types expect(Array.isArray(messages)).toBe(true); expect(Array.isArray(messagesToKeep)).toBe(true); - expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); + expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); // cutPoint still uses index expect(typeof tokensBefore).toBe("number"); expect(model).toBeDefined(); expect(typeof resolveApiKey).toBe("function"); + expect(typeof firstKeptEntryId).toBe("string"); const summary = messages .filter((m) => m.role === "user") .map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`) .join("\n"); - const compactionEntry: CompactionEntry = { - type: "compaction", - timestamp: new Date().toISOString(), - summary: `User requests:\n${summary}`, - firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, - tokensBefore: event.tokensBefore, + // Hooks return compaction content - SessionManager adds id/parentId + return { + compaction: { + summary: `User requests:\n${summary}`, + firstKeptEntryId, + tokensBefore, + }, }; - - return { compactionEntry }; }); }; diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 24c7e89d..787e863e 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -3,7 +3,7 @@ import type { AssistantMessage, Usage } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai"; import { readFileSync } from "fs"; import { join } from "path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { type CompactionSettings, calculateContextTokens, @@ -17,9 +17,12 @@ import { buildSessionContext, type CompactionEntry, createSummaryMessage, + type ModelChangeEntry, + migrateSessionEntries, parseSessionEntries, type SessionEntry, type SessionMessageEntry, + type ThinkingLevelChangeEntry, } from "../src/core/session-manager.js"; // ============================================================================ @@ -29,7 +32,9 @@ import { function loadLargeSessionEntries(): SessionEntry[] { const sessionPath = join(__dirname, "fixtures/large-session.jsonl"); const content = readFileSync(sessionPath, "utf-8"); - return parseSessionEntries(content); + const entries = parseSessionEntries(content); + migrateSessionEntries(entries); // Add id/parentId for v1 fixtures + return entries; } function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage { @@ -60,18 +65,82 @@ function createAssistantMessage(text: string, usage?: Usage): AssistantMessage { }; } -function createMessageEntry(message: AppMessage): SessionMessageEntry { - return { type: "message", timestamp: new Date().toISOString(), message }; +let entryCounter = 0; +let lastId: string | null = null; + +function resetEntryCounter() { + entryCounter = 0; + lastId = null; } -function createCompactionEntry(summary: string, firstKeptEntryIndex: number): CompactionEntry { +// Reset counter before each test to get predictable IDs +beforeEach(() => { + resetEntryCounter(); +}); + +function createSessionHeader() { return { + type: "session" as const, + version: 2, + id: "test-session", + timestamp: "", + cwd: "", + }; +} + +function createMessageEntry(message: AppMessage): SessionMessageEntry { + const id = `test-id-${entryCounter++}`; + const entry: SessionMessageEntry = { + type: "message", + id, + parentId: lastId, + timestamp: new Date().toISOString(), + message, + }; + lastId = id; + return entry; +} + +function createCompactionEntry(summary: string, firstKeptEntryId: string): CompactionEntry { + const id = `test-id-${entryCounter++}`; + const entry: CompactionEntry = { type: "compaction", + id, + parentId: lastId, timestamp: new Date().toISOString(), summary, - firstKeptEntryIndex, + firstKeptEntryId, tokensBefore: 10000, }; + lastId = id; + return entry; +} + +function createModelChangeEntry(provider: string, modelId: string): ModelChangeEntry { + const id = `test-id-${entryCounter++}`; + const entry: ModelChangeEntry = { + type: "model_change", + id, + parentId: lastId, + timestamp: new Date().toISOString(), + provider, + modelId, + }; + lastId = id; + return entry; +} + +function createThinkingLevelEntry(thinkingLevel: string): ThinkingLevelChangeEntry { + const id = `test-id-${entryCounter++}`; + const entry: ThinkingLevelChangeEntry = { + type: "thinking_level_change", + id, + parentId: lastId, + timestamp: new Date().toISOString(), + thinkingLevel, + }; + lastId = id; + return entry; } // ============================================================================ @@ -248,78 +317,59 @@ describe("buildSessionContext", () => { }); it("should handle single compaction", () => { - // indices: 0=session, 1=u1, 2=a1, 3=u2, 4=a2, 5=compaction, 6=u3, 7=a3 - const entries: SessionEntry[] = [ - { - type: "session", - id: "1", - timestamp: "", - cwd: "", - }, - createMessageEntry(createUserMessage("1")), - createMessageEntry(createAssistantMessage("a")), - createMessageEntry(createUserMessage("2")), - createMessageEntry(createAssistantMessage("b")), - createCompactionEntry("Summary of 1,a,2,b", 3), // keep from index 3 (u2) onwards - createMessageEntry(createUserMessage("3")), - createMessageEntry(createAssistantMessage("c")), - ]; + // IDs: u1=test-id-0, a1=test-id-1, u2=test-id-2, a2=test-id-3, compaction=test-id-4, u3=test-id-5, a3=test-id-6 + const u1 = createMessageEntry(createUserMessage("1")); + const a1 = createMessageEntry(createAssistantMessage("a")); + const u2 = createMessageEntry(createUserMessage("2")); + const a2 = createMessageEntry(createAssistantMessage("b")); + const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id); // keep from u2 onwards + const u3 = createMessageEntry(createUserMessage("3")); + const a3 = createMessageEntry(createAssistantMessage("c")); + + const entries: SessionEntry[] = [createSessionHeader(), u1, a1, u2, a2, compaction, u3, a3]; const loaded = buildSessionContext(entries); - // summary + kept (u2,a2 from idx 3-4) + after (u3,a3 from idx 6-7) = 5 + // summary + kept (u2, a2) + after (u3, a3) = 5 expect(loaded.messages.length).toBe(5); expect(loaded.messages[0].role).toBe("user"); expect((loaded.messages[0] as any).content).toContain("Summary of 1,a,2,b"); }); it("should handle multiple compactions (only latest matters)", () => { - // indices: 0=session, 1=u1, 2=a1, 3=compact1, 4=u2, 5=b, 6=u3, 7=c, 8=compact2, 9=u4, 10=d - const entries: SessionEntry[] = [ - { - type: "session", - id: "1", - timestamp: "", - cwd: "", - }, - createMessageEntry(createUserMessage("1")), - createMessageEntry(createAssistantMessage("a")), - createCompactionEntry("First summary", 1), // keep from index 1 - createMessageEntry(createUserMessage("2")), - createMessageEntry(createAssistantMessage("b")), - createMessageEntry(createUserMessage("3")), - createMessageEntry(createAssistantMessage("c")), - createCompactionEntry("Second summary", 6), // keep from index 6 (u3) onwards - createMessageEntry(createUserMessage("4")), - createMessageEntry(createAssistantMessage("d")), - ]; + // First batch + const u1 = createMessageEntry(createUserMessage("1")); + const a1 = createMessageEntry(createAssistantMessage("a")); + const compact1 = createCompactionEntry("First summary", u1.id); + // Second batch + const u2 = createMessageEntry(createUserMessage("2")); + const b = createMessageEntry(createAssistantMessage("b")); + const u3 = createMessageEntry(createUserMessage("3")); + const c = createMessageEntry(createAssistantMessage("c")); + const compact2 = createCompactionEntry("Second summary", u3.id); // keep from u3 onwards + // After second compaction + const u4 = createMessageEntry(createUserMessage("4")); + const d = createMessageEntry(createAssistantMessage("d")); + + const entries: SessionEntry[] = [createSessionHeader(), u1, a1, compact1, u2, b, u3, c, compact2, u4, d]; const loaded = buildSessionContext(entries); - // summary + kept from idx 6 (u3,c) + after (u4,d) = 5 + // summary + kept from u3 (u3, c) + after (u4, d) = 5 expect(loaded.messages.length).toBe(5); expect((loaded.messages[0] as any).content).toContain("Second summary"); }); - it("should clamp firstKeptEntryIndex to valid range", () => { - // indices: 0=session, 1=u1, 2=a1, 3=compact1, 4=u2, 5=b, 6=compact2 - const entries: SessionEntry[] = [ - { - type: "session", - id: "1", - timestamp: "", - cwd: "", - }, - createMessageEntry(createUserMessage("1")), - createMessageEntry(createAssistantMessage("a")), - createCompactionEntry("First summary", 1), - createMessageEntry(createUserMessage("2")), - createMessageEntry(createAssistantMessage("b")), - createCompactionEntry("Second summary", 0), // index 0 is before compaction1, should still work - ]; + it("should keep all messages when firstKeptEntryId is first entry", () => { + const u1 = createMessageEntry(createUserMessage("1")); + const a1 = createMessageEntry(createAssistantMessage("a")); + const compact1 = createCompactionEntry("First summary", u1.id); // keep from first entry + const u2 = createMessageEntry(createUserMessage("2")); + const b = createMessageEntry(createAssistantMessage("b")); + + const entries: SessionEntry[] = [createSessionHeader(), u1, a1, compact1, u2, b]; const loaded = buildSessionContext(entries); - // Keeps from index 0, but compaction entries are skipped, so u1,a1,u2,b = 4 + summary = 5 - // Actually index 0 is session header, so messages are u1,a1,u2,b - expect(loaded.messages.length).toBe(5); // summary + 4 messages + // summary + all messages (u1, a1, u2, b) = 5 + expect(loaded.messages.length).toBe(5); }); it("should track model and thinking level changes", () => { @@ -331,9 +381,9 @@ describe("buildSessionContext", () => { cwd: "", }, createMessageEntry(createUserMessage("1")), - { type: "model_change", timestamp: "", provider: "openai", modelId: "gpt-4" }, + createModelChangeEntry("openai", "gpt-4"), createMessageEntry(createAssistantMessage("a")), - { type: "thinking_level_change", timestamp: "", thinkingLevel: "high" }, + createThinkingLevelEntry("high"), ]; const loaded = buildSessionContext(entries); @@ -380,27 +430,26 @@ describe("Large session fixture", () => { // ============================================================================ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => { - it("should generate a compaction event for the large session", async () => { + it("should generate a compaction result for the large session", async () => { const entries = loadLargeSessionEntries(); const model = getModel("anthropic", "claude-sonnet-4-5")!; - const compactionEvent = await compact( + const compactionResult = await compact( entries, model, DEFAULT_COMPACTION_SETTINGS, process.env.ANTHROPIC_OAUTH_TOKEN!, ); - expect(compactionEvent.type).toBe("compaction"); - expect(compactionEvent.summary.length).toBeGreaterThan(100); - expect(compactionEvent.firstKeptEntryIndex).toBeGreaterThan(0); - expect(compactionEvent.tokensBefore).toBeGreaterThan(0); + expect(compactionResult.summary.length).toBeGreaterThan(100); + expect(compactionResult.firstKeptEntryId).toBeTruthy(); + expect(compactionResult.tokensBefore).toBeGreaterThan(0); - console.log("Summary length:", compactionEvent.summary.length); - console.log("First kept entry index:", compactionEvent.firstKeptEntryIndex); - console.log("Tokens before:", compactionEvent.tokensBefore); + console.log("Summary length:", compactionResult.summary.length); + console.log("First kept entry ID:", compactionResult.firstKeptEntryId); + console.log("Tokens before:", compactionResult.tokensBefore); console.log("\n--- SUMMARY ---\n"); - console.log(compactionEvent.summary); + console.log(compactionResult.summary); }, 60000); it("should produce valid session after compaction", async () => { @@ -408,21 +457,30 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => { const loaded = buildSessionContext(entries); const model = getModel("anthropic", "claude-sonnet-4-5")!; - const compactionEvent = await compact( + const compactionResult = await compact( entries, model, DEFAULT_COMPACTION_SETTINGS, process.env.ANTHROPIC_OAUTH_TOKEN!, ); - // Simulate appending compaction to entries - const newEntries = [...entries, compactionEvent]; + // Simulate appending compaction to entries by creating a proper entry + const lastEntry = entries[entries.length - 1]; + const parentId = lastEntry.type === "session" ? null : lastEntry.id; + const compactionEntry: CompactionEntry = { + type: "compaction", + id: "compaction-test-id", + parentId, + timestamp: new Date().toISOString(), + ...compactionResult, + }; + const newEntries = [...entries, compactionEntry]; const reloaded = buildSessionContext(newEntries); // Should have summary + kept messages expect(reloaded.messages.length).toBeLessThan(loaded.messages.length); expect(reloaded.messages[0].role).toBe("user"); - expect((reloaded.messages[0] as any).content).toContain(compactionEvent.summary); + expect((reloaded.messages[0] as any).content).toContain(compactionResult.summary); console.log("Original messages:", loaded.messages.length); console.log("After compaction:", reloaded.messages.length); diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index 00920ae4..2e24bf5a 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -15,10 +15,14 @@ import { buildSessionContext, type CompactionEntry, type LoadedSession, + type MessageContent, + type ModelChangeContent, type ModelChangeEntry, type SessionEntry, type SessionMessageEntry, type ThinkingLevelChangeEntry, + type ThinkingLevelContent, + type TreeNode, } from "@mariozechner/pi-coding-agent"; import { randomBytes } from "crypto"; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; @@ -49,6 +53,7 @@ export class MomSessionManager { private channelDir: string; private flushed: boolean = false; private inMemoryEntries: SessionEntry[] = []; + private leafId: string | null = null; constructor(channelDir: string) { this.channelDir = channelDir; @@ -64,12 +69,14 @@ export class MomSessionManager { if (existsSync(this.contextFile)) { this.inMemoryEntries = this.loadEntriesFromFile(); this.sessionId = this.extractSessionId() || uuidv4(); + this._updateLeafId(); this.flushed = true; } else { this.sessionId = uuidv4(); this.inMemoryEntries = [ { type: "session", + version: 2, id: this.sessionId, timestamp: new Date().toISOString(), cwd: this.channelDir, @@ -79,6 +86,28 @@ export class MomSessionManager { // Note: syncFromLog() is called explicitly from agent.ts with excludeTimestamp } + private _updateLeafId(): void { + for (let i = this.inMemoryEntries.length - 1; i >= 0; i--) { + const entry = this.inMemoryEntries[i]; + if (entry.type !== "session") { + this.leafId = entry.id; + return; + } + } + this.leafId = null; + } + + private _createTreeNode(): TreeNode { + const id = uuidv4(); + const node: TreeNode = { + id, + parentId: this.leafId, + timestamp: new Date().toISOString(), + }; + this.leafId = id; + return node; + } + private _persist(entry: SessionEntry): void { const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) return; @@ -206,11 +235,15 @@ export class MomSessionManager { newMessages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); for (const { timestamp, message } of newMessages) { + const id = uuidv4(); const entry: SessionMessageEntry = { type: "message", + id, + parentId: this.leafId, timestamp, // Use log date as entry timestamp for consistent deduplication message, }; + this.leafId = id; this.inMemoryEntries.push(entry); appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`); @@ -247,32 +280,22 @@ export class MomSessionManager { } saveMessage(message: AppMessage): void { - const entry: SessionMessageEntry = { - type: "message", - timestamp: new Date().toISOString(), - message, - }; + const content: MessageContent = { type: "message", message }; + const entry: SessionMessageEntry = { ...this._createTreeNode(), ...content }; this.inMemoryEntries.push(entry); this._persist(entry); } saveThinkingLevelChange(thinkingLevel: string): void { - const entry: ThinkingLevelChangeEntry = { - type: "thinking_level_change", - timestamp: new Date().toISOString(), - thinkingLevel, - }; + const content: ThinkingLevelContent = { type: "thinking_level_change", thinkingLevel }; + const entry: ThinkingLevelChangeEntry = { ...this._createTreeNode(), ...content }; this.inMemoryEntries.push(entry); this._persist(entry); } saveModelChange(provider: string, modelId: string): void { - const entry: ModelChangeEntry = { - type: "model_change", - timestamp: new Date().toISOString(), - provider, - modelId, - }; + const content: ModelChangeContent = { type: "model_change", provider, modelId }; + const entry: ModelChangeEntry = { ...this._createTreeNode(), ...content }; this.inMemoryEntries.push(entry); this._persist(entry); } From 251fea752c46364e17a81aa425b3e23c8133af8d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 00:05:02 +0100 Subject: [PATCH 002/161] Fix API key priority and compaction bugs - getEnvApiKey: ANTHROPIC_OAUTH_TOKEN now takes precedence over ANTHROPIC_API_KEY - findCutPoint: Stop scan-backwards loop at session header (was decrementing past it causing null preparation) - generateSummary/generateTurnPrefixSummary: Throw on stopReason=error instead of returning empty string - Test files: Fix API key priority order, use keepRecentTokens=1 for small test conversations --- packages/ai/src/models.generated.ts | 102 +++++++++--------- packages/ai/src/stream.ts | 6 +- .../examples/hooks/custom-compaction.ts | 11 +- packages/coding-agent/src/core/compaction.ts | 14 ++- .../test/agent-session-branching.test.ts | 2 +- .../test/agent-session-compaction.test.ts | 71 ++++-------- .../test/compaction-hooks.test.ts | 2 +- 7 files changed, 98 insertions(+), 110 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 2df269d3..7bc2f673 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6359,6 +6359,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-70b-instruct": { + id: "meta-llama/llama-3.1-70b-instruct", + name: "Meta: Llama 3.1 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-8b-instruct": { id: "meta-llama/llama-3.1-8b-instruct", name: "Meta: Llama 3.1 8B Instruct", @@ -6393,23 +6410,6 @@ export const MODELS = { contextWindow: 10000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-70b-instruct": { - id: "meta-llama/llama-3.1-70b-instruct", - name: "Meta: Llama 3.1 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,23 +6546,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6597,6 +6580,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6716,23 +6716,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-0613": { - id: "openai/gpt-3.5-turbo-0613", - name: "OpenAI: GPT-3.5 Turbo (older v0613)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-turbo-preview": { id: "openai/gpt-4-turbo-preview", name: "OpenAI: GPT-4 Turbo Preview", @@ -6750,6 +6733,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4095, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-tiny": { id: "mistralai/mistral-tiny", name: "Mistral Tiny", diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index a71656e9..fb8f5ca3 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -30,9 +30,13 @@ export function getEnvApiKey(provider: any): string | undefined { return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN; } + // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY + if (provider === "anthropic") { + return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; + } + const envMap: Record = { openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", google: "GEMINI_API_KEY", groq: "GROQ_API_KEY", cerebras: "CEREBRAS_API_KEY", diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index 1781e7e3..1eabaf57 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -23,8 +23,15 @@ export default function (pi: HookAPI) { ctx.ui.notify("Custom compaction hook triggered", "info"); - const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, resolveApiKey, entries, signal } = - event; + const { + messagesToSummarize, + messagesToKeep, + previousSummary, + tokensBefore, + resolveApiKey, + entries: _, + signal, + } = event; // Use Gemini Flash for summarization (cheaper/faster than most conversation models) const model = getModel("google", "gemini-2.5-flash"); diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index 8abfef74..6a56a3ac 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -224,7 +224,7 @@ export function findCutPoint( // Walk backwards from newest, accumulating estimated message sizes let accumulatedTokens = 0; - let cutIndex = startIndex; // Default: keep everything in range + let cutIndex = cutPoints[0]; // Default: keep from first message (not header) for (let i = endIndex - 1; i >= startIndex; i--) { const entry = entries[i]; @@ -250,8 +250,8 @@ export function findCutPoint( // Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.) while (cutIndex > startIndex) { const prevEntry = entries[cutIndex - 1]; - // Stop at compaction boundaries - if (prevEntry.type === "compaction") { + // Stop at session header or compaction boundaries + if (prevEntry.type === "session" || prevEntry.type === "compaction") { break; } if (prevEntry.type === "message") { @@ -320,6 +320,10 @@ export async function generateSummary( const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey }); + if (response.stopReason === "error") { + throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); + } + const textContent = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) @@ -550,6 +554,10 @@ async function generateTurnPrefixSummary( const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey }); + if (response.stopReason === "error") { + throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); + } + return response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index d6f2db1c..de6e6e20 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -20,7 +20,7 @@ import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import { codingTools } from "../src/core/tools/index.js"; -const API_KEY = process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN; +const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; describe.skipIf(!API_KEY)("AgentSession branching", () => { let session: AgentSession; diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 2b7f5e71..580e4fc9 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -20,7 +20,7 @@ import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import { codingTools } from "../src/core/tools/index.js"; -const API_KEY = process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN; +const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { let session: AgentSession; @@ -46,7 +46,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { } }); - function createSession() { + function createSession(inMemory = false) { const model = getModel("anthropic", "claude-sonnet-4-5")!; const transport = new ProviderTransport({ @@ -62,8 +62,10 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { }, }); - sessionManager = SessionManager.create(tempDir); + sessionManager = inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir); + // Use minimal keepRecentTokens so small test conversations have something to summarize + settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); const authStorage = new AuthStorage(join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage); @@ -156,64 +158,31 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { expect(compaction.type).toBe("compaction"); if (compaction.type === "compaction") { expect(compaction.summary.length).toBeGreaterThan(0); - // firstKeptEntryId can be 0 if all messages fit within keepRecentTokens - // (which is the case for small conversations) - expect(compaction.firstKeptEntryId).toBeGreaterThanOrEqual(0); + expect(typeof compaction.firstKeptEntryId).toBe("string"); expect(compaction.tokensBefore).toBeGreaterThan(0); } }, 120000); it("should work with --no-session mode (in-memory only)", async () => { - const model = getModel("anthropic", "claude-sonnet-4-5")!; + createSession(true); // in-memory mode - const transport = new ProviderTransport({ - getApiKey: () => API_KEY, - }); + // Send prompts + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); - const agent = new Agent({ - transport, - initialState: { - model, - systemPrompt: "You are a helpful assistant. Be concise.", - tools: codingTools, - }, - }); + await session.prompt("What is 3+3? Reply with just the number."); + await session.agent.waitForIdle(); - // Create in-memory session manager - const noSessionManager = SessionManager.inMemory(); + // Compact should work even without file persistence + const result = await session.compact(); - const settingsManager = SettingsManager.create(tempDir, tempDir); - const authStorage = new AuthStorage(join(tempDir, "auth.json")); - const modelRegistry = new ModelRegistry(authStorage); + expect(result.summary).toBeDefined(); + expect(result.summary.length).toBeGreaterThan(0); - const noSessionSession = new AgentSession({ - agent, - sessionManager: noSessionManager, - settingsManager, - modelRegistry, - }); - - try { - // Send prompts - await noSessionSession.prompt("What is 2+2? Reply with just the number."); - await noSessionSession.agent.waitForIdle(); - - await noSessionSession.prompt("What is 3+3? Reply with just the number."); - await noSessionSession.agent.waitForIdle(); - - // Compact should work even without file persistence - const result = await noSessionSession.compact(); - - expect(result.summary).toBeDefined(); - expect(result.summary.length).toBeGreaterThan(0); - - // In-memory entries should have the compaction - const entries = noSessionManager.getEntries(); - const compactionEntries = entries.filter((e) => e.type === "compaction"); - expect(compactionEntries.length).toBe(1); - } finally { - noSessionSession.dispose(); - } + // In-memory entries should have the compaction + const entries = sessionManager.getEntries(); + const compactionEntries = entries.filter((e) => e.type === "compaction"); + expect(compactionEntries.length).toBe(1); }, 120000); it("should emit correct events during auto-compaction", async () => { diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index e3773742..76d227bf 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -16,7 +16,7 @@ import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import { codingTools } from "../src/core/tools/index.js"; -const API_KEY = process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN; +const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; describe.skipIf(!API_KEY)("Compaction hooks", () => { let session: AgentSession; From 9478a3c1f55ef1a4dd522d9b635807d3298dea5e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 00:31:53 +0100 Subject: [PATCH 003/161] Fix SessionEntry type to exclude SessionHeader - SessionEntry now only contains conversation entries (messages, compaction, etc.) - SessionHeader is separate, not part of SessionEntry - FileEntry = SessionHeader | SessionEntry (for file storage) - getEntries() filters out header, returns SessionEntry[] - Added getHeader() for accessing session metadata - Updated compaction and tests to not expect header in entries - Updated mom package to use FileEntry for internal storage --- .../coding-agent/src/core/agent-session.ts | 2 +- packages/coding-agent/src/core/compaction.ts | 18 ++---- .../coding-agent/src/core/session-manager.ts | 57 ++++++++++--------- packages/coding-agent/src/index.ts | 1 + packages/coding-agent/test/compaction.test.ts | 32 ++--------- packages/mom/src/context.ts | 15 +++-- 6 files changed, 50 insertions(+), 75 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index fe4804a1..b6f45978 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -759,7 +759,7 @@ export class AgentSession { if (lastEntry?.type === "compaction") { throw new Error("Already compacted"); } - throw new Error("Nothing to compact (session too small or needs migration)"); + throw new Error("Nothing to compact (session too small)"); } // Find previous compaction summary if any diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index 6a56a3ac..ea1e44a3 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -9,7 +9,7 @@ import type { AppMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; import { messageTransformer } from "./messages.js"; -import type { CompactionEntry, ConversationEntry, SessionEntry } from "./session-manager.js"; +import type { CompactionEntry, SessionEntry } from "./session-manager.js"; /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ export interface CompactionResult { @@ -251,7 +251,7 @@ export function findCutPoint( while (cutIndex > startIndex) { const prevEntry = entries[cutIndex - 1]; // Stop at session header or compaction boundaries - if (prevEntry.type === "session" || prevEntry.type === "compaction") { + if (prevEntry.type === "compaction") { break; } if (prevEntry.type === "message") { @@ -370,13 +370,10 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS // Get UUID of first kept entry const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex]; - if (firstKeptEntry.type === "session") { - return null; // Can't compact if first kept is header - } - const firstKeptEntryId = (firstKeptEntry as ConversationEntry).id; - if (!firstKeptEntryId) { + if (!firstKeptEntry?.id) { return null; // Session needs migration } + const firstKeptEntryId = firstKeptEntry.id; const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; @@ -405,7 +402,7 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS // Main compaction function // ============================================================================ -const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn. +const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn. This is the PREFIX of a turn that was too large to keep in full. The SUFFIX (recent work) is being kept. Create a handoff summary that captures: @@ -515,10 +512,7 @@ export async function compact( // Get UUID of first kept entry const firstKeptEntry = entries[cutResult.firstKeptEntryIndex]; - if (firstKeptEntry.type === "session") { - throw new Error("Cannot compact: first kept entry is session header"); - } - const firstKeptEntryId = (firstKeptEntry as ConversationEntry).id; + const firstKeptEntryId = firstKeptEntry.id; if (!firstKeptEntryId) { throw new Error("First kept entry has no UUID - session may need migration"); } diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 3b9eda0b..e81791b9 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -87,16 +87,19 @@ export type ModelChangeEntry = TreeNode & ModelChangeContent; export type CompactionEntry = TreeNode & CompactionContent; export type BranchSummaryEntry = TreeNode & BranchSummaryContent; -/** Conversation entry - has id/parentId for tree structure */ -export type ConversationEntry = +/** Session entry - has id/parentId for tree structure */ +export type SessionEntry = | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | CompactionEntry | BranchSummaryEntry; -/** Any session entry (header or conversation) */ -export type SessionEntry = SessionHeader | ConversationEntry; +/** @deprecated Use SessionEntry */ +export type ConversationEntry = SessionEntry; + +/** Raw file entry (includes header) */ +export type FileEntry = SessionHeader | SessionEntry; export interface SessionContext { messages: AppMessage[]; @@ -135,7 +138,7 @@ export function createSummaryMessage(summary: string): AppMessage { * Migrate v1 entries to v2 format by adding id/parentId fields. * Mutates entries in place. Safe to call on already-migrated entries. */ -export function migrateSessionEntries(entries: SessionEntry[]): void { +export function migrateSessionEntries(entries: FileEntry[]): void { // Check if already migrated const firstConv = entries.find((e) => e.type !== "session"); if (firstConv && "id" in firstConv && firstConv.id) { @@ -171,7 +174,7 @@ export function migrateSessionEntries(entries: SessionEntry[]): void { } /** Exported for compaction.test.ts */ -export function parseSessionEntries(content: string): SessionEntry[] { +export function parseSessionEntries(content: string): FileEntry[] { const entries: SessionEntry[] = []; const lines = content.trim().split("\n"); @@ -203,26 +206,18 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt * Handles compaction and branch summaries along the path. */ export function buildSessionContext(entries: SessionEntry[], leafId?: string): SessionContext { - // Build uuid index for conversation entries - const byId = new Map(); + // Build uuid index + const byId = new Map(); for (const entry of entries) { - if (entry.type !== "session") { - byId.set(entry.id, entry); - } + byId.set(entry.id, entry); } // Find leaf - let leaf: ConversationEntry | undefined; + let leaf: SessionEntry | undefined; if (leafId) { leaf = byId.get(leafId); } else { - // Find last conversation entry - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].type !== "session") { - leaf = entries[i] as ConversationEntry; - break; - } - } + leaf = entries[entries.length - 1]; } if (!leaf) { @@ -230,8 +225,8 @@ export function buildSessionContext(entries: SessionEntry[], leafId?: string): S } // Walk from leaf to root, collecting path - const path: ConversationEntry[] = []; - let current: ConversationEntry | undefined = leaf; + const path: SessionEntry[] = []; + let current: SessionEntry | undefined = leaf; while (current) { path.unshift(current); current = current.parentId ? byId.get(current.parentId) : undefined; @@ -316,7 +311,7 @@ function getDefaultSessionDir(cwd: string): string { return sessionDir; } -function loadEntriesFromFile(filePath: string): SessionEntry[] { +function loadEntriesFromFile(filePath: string): FileEntry[] { if (!existsSync(filePath)) return []; const content = readFileSync(filePath, "utf8"); @@ -359,7 +354,7 @@ export class SessionManager { private cwd: string; private persist: boolean; private flushed: boolean = false; - private inMemoryEntries: SessionEntry[] = []; + private inMemoryEntries: FileEntry[] = []; // Tree structure (v2) private byId: Map = new Map(); @@ -570,11 +565,19 @@ export class SessionManager { } /** - * Get all session entries. Returns a defensive copy. + * Get session header. + */ + getHeader(): SessionHeader | null { + const h = this.inMemoryEntries.find((e) => e.type === "session"); + return h ? (h as SessionHeader) : null; + } + + /** + * Get all session entries (excludes header). Returns a defensive copy. * Use buildSessionContext() if you need the messages for the LLM. */ getEntries(): SessionEntry[] { - return [...this.inMemoryEntries]; + return this.inMemoryEntries.filter((e): e is SessionEntry => e.type !== "session"); } // ========================================================================= @@ -606,12 +609,12 @@ export class SessionManager { return entry.id; } - createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null { + createBranchedSessionFromEntries(entries: FileEntry[], branchBeforeIndex: number): string | null { const newSessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const newSessionFile = join(this.getSessionDir(), `${timestamp}_${newSessionId}.jsonl`); - const newEntries: SessionEntry[] = []; + const newEntries: FileEntry[] = []; for (let i = 0; i < branchBeforeIndex; i++) { const entry = entries[i]; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 04151f0b..f9673384 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -116,6 +116,7 @@ export { type ConversationEntry, CURRENT_SESSION_VERSION, createSummaryMessage, + type FileEntry, getLatestCompactionEntry, type MessageContent, type ModelChangeContent, diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 787e863e..c12748e4 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -34,7 +34,7 @@ function loadLargeSessionEntries(): SessionEntry[] { const content = readFileSync(sessionPath, "utf-8"); const entries = parseSessionEntries(content); migrateSessionEntries(entries); // Add id/parentId for v1 fixtures - return entries; + return entries.filter((e): e is SessionEntry => e.type !== "session"); } function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage { @@ -78,16 +78,6 @@ beforeEach(() => { resetEntryCounter(); }); -function createSessionHeader() { - return { - type: "session" as const, - version: 2, - id: "test-session", - timestamp: "", - cwd: "", - }; -} - function createMessageEntry(message: AppMessage): SessionMessageEntry { const id = `test-id-${entryCounter++}`; const entry: SessionMessageEntry = { @@ -298,12 +288,6 @@ describe("createSummaryMessage", () => { describe("buildSessionContext", () => { it("should load all messages when no compaction", () => { const entries: SessionEntry[] = [ - { - type: "session", - id: "1", - timestamp: "", - cwd: "", - }, createMessageEntry(createUserMessage("1")), createMessageEntry(createAssistantMessage("a")), createMessageEntry(createUserMessage("2")), @@ -326,7 +310,7 @@ describe("buildSessionContext", () => { const u3 = createMessageEntry(createUserMessage("3")); const a3 = createMessageEntry(createAssistantMessage("c")); - const entries: SessionEntry[] = [createSessionHeader(), u1, a1, u2, a2, compaction, u3, a3]; + const entries: SessionEntry[] = [u1, a1, u2, a2, compaction, u3, a3]; const loaded = buildSessionContext(entries); // summary + kept (u2, a2) + after (u3, a3) = 5 @@ -350,7 +334,7 @@ describe("buildSessionContext", () => { const u4 = createMessageEntry(createUserMessage("4")); const d = createMessageEntry(createAssistantMessage("d")); - const entries: SessionEntry[] = [createSessionHeader(), u1, a1, compact1, u2, b, u3, c, compact2, u4, d]; + const entries: SessionEntry[] = [u1, a1, compact1, u2, b, u3, c, compact2, u4, d]; const loaded = buildSessionContext(entries); // summary + kept from u3 (u3, c) + after (u4, d) = 5 @@ -365,7 +349,7 @@ describe("buildSessionContext", () => { const u2 = createMessageEntry(createUserMessage("2")); const b = createMessageEntry(createAssistantMessage("b")); - const entries: SessionEntry[] = [createSessionHeader(), u1, a1, compact1, u2, b]; + const entries: SessionEntry[] = [u1, a1, compact1, u2, b]; const loaded = buildSessionContext(entries); // summary + all messages (u1, a1, u2, b) = 5 @@ -374,12 +358,6 @@ describe("buildSessionContext", () => { it("should track model and thinking level changes", () => { const entries: SessionEntry[] = [ - { - type: "session", - id: "1", - timestamp: "", - cwd: "", - }, createMessageEntry(createUserMessage("1")), createModelChangeEntry("openai", "gpt-4"), createMessageEntry(createAssistantMessage("a")), @@ -466,7 +444,7 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => { // Simulate appending compaction to entries by creating a proper entry const lastEntry = entries[entries.length - 1]; - const parentId = lastEntry.type === "session" ? null : lastEntry.id; + const parentId = lastEntry.id; const compactionEntry: CompactionEntry = { type: "compaction", id: "compaction-test-id", diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index 2e24bf5a..58a43b24 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -14,6 +14,7 @@ import type { AppMessage } from "@mariozechner/pi-agent-core"; import { buildSessionContext, type CompactionEntry, + type FileEntry, type LoadedSession, type MessageContent, type ModelChangeContent, @@ -52,7 +53,7 @@ export class MomSessionManager { private logFile: string; private channelDir: string; private flushed: boolean = false; - private inMemoryEntries: SessionEntry[] = []; + private inMemoryEntries: FileEntry[] = []; private leafId: string | null = null; constructor(channelDir: string) { @@ -259,17 +260,17 @@ export class MomSessionManager { return null; } - private loadEntriesFromFile(): SessionEntry[] { + private loadEntriesFromFile(): FileEntry[] { if (!existsSync(this.contextFile)) return []; const content = readFileSync(this.contextFile, "utf8"); - const entries: SessionEntry[] = []; + const entries: FileEntry[] = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { - const entry = JSON.parse(line) as SessionEntry; + const entry = JSON.parse(line) as FileEntry; entries.push(entry); } catch { // Skip malformed lines @@ -313,10 +314,8 @@ export class MomSessionManager { loadEntries(): SessionEntry[] { // Re-read from file to get latest state - if (existsSync(this.contextFile)) { - return this.loadEntriesFromFile(); - } - return [...this.inMemoryEntries]; + const entries = existsSync(this.contextFile) ? this.loadEntriesFromFile() : this.inMemoryEntries; + return entries.filter((e): e is SessionEntry => e.type !== "session"); } getSessionId(): string { From 77595b97f94e5693a8ee87673b4da811f340bc8b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 00:35:55 +0100 Subject: [PATCH 004/161] Fix --session flag to use provided filename When --session path was provided for a non-existent file, _initNewSession() was overwriting the path with an auto-generated one. Now it only generates a filename if sessionFile wasn't already set. --- packages/coding-agent/src/core/session-manager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e81791b9..ad873189 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -411,8 +411,11 @@ export class SessionManager { this.byId.clear(); this.leafId = ""; this.flushed = false; - const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); + // Only generate filename if not already set (e.g., via --session flag) + if (!this.sessionFile) { + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); + } } private _migrateToV2(): void { From 95312e00bba6138bc23533a6bf8fa52600aafe2a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 00:42:33 +0100 Subject: [PATCH 005/161] Use short 8-char IDs for session entries - Replace custom uuidv4() with native crypto.randomUUID() - Entry IDs use first 8 hex chars with collision checking - Session IDs stay full UUIDs (used in filenames) - ~0.01 collisions per 10k entries, retry handles it --- .../coding-agent/src/core/session-manager.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index ad873189..e6cd7f33 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,19 +1,11 @@ import type { AppMessage } from "@mariozechner/pi-agent-core"; -import { randomBytes } from "crypto"; +import { randomUUID } from "crypto"; import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"; import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir } from "../config.js"; export const CURRENT_SESSION_VERSION = 2; -function uuidv4(): string { - const bytes = randomBytes(16); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = bytes.toString("hex"); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; -} - // ============================================================================ // Session Header (metadata, not part of conversation tree) // ============================================================================ @@ -154,7 +146,7 @@ export function migrateSessionEntries(entries: FileEntry[]): void { // Add id/parentId to conversation entries const convEntry = entry as ConversationEntry; - convEntry.id = uuidv4(); + convEntry.id = randomUUID(); convEntry.parentId = prevId; prevId = convEntry.id; @@ -357,9 +349,19 @@ export class SessionManager { private inMemoryEntries: FileEntry[] = []; // Tree structure (v2) - private byId: Map = new Map(); + private byId: Map = new Map(); private leafId: string = ""; + /** Generate a unique short ID (8 hex chars, collision-checked) */ + private _generateId(): string { + for (let i = 0; i < 100; i++) { + const id = randomUUID().slice(0, 8); + if (!this.byId.has(id)) return id; + } + // Fallback to full UUID if somehow we have collisions + return randomUUID(); + } + private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) { this.cwd = cwd; this.sessionDir = sessionDir; @@ -381,7 +383,7 @@ export class SessionManager { if (existsSync(this.sessionFile)) { this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); const header = this.inMemoryEntries.find((e) => e.type === "session") as SessionHeader | undefined; - this.sessionId = header?.id ?? uuidv4(); + this.sessionId = header?.id ?? randomUUID(); // Migrate v1 to v2 if needed const version = header?.version ?? 1; @@ -398,7 +400,7 @@ export class SessionManager { } private _initNewSession(): void { - this.sessionId = uuidv4(); + this.sessionId = randomUUID(); const timestamp = new Date().toISOString(); const header: SessionHeader = { type: "session", @@ -488,7 +490,7 @@ export class SessionManager { saveMessage(message: AppMessage): string { const entry: SessionMessageEntry = { type: "message", - id: uuidv4(), + id: this._generateId(), parentId: this.leafId || null, timestamp: new Date().toISOString(), message, @@ -500,7 +502,7 @@ export class SessionManager { saveThinkingLevelChange(thinkingLevel: string): string { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", - id: uuidv4(), + id: this._generateId(), parentId: this.leafId || null, timestamp: new Date().toISOString(), thinkingLevel, @@ -512,7 +514,7 @@ export class SessionManager { saveModelChange(provider: string, modelId: string): string { const entry: ModelChangeEntry = { type: "model_change", - id: uuidv4(), + id: this._generateId(), parentId: this.leafId || null, timestamp: new Date().toISOString(), provider, @@ -525,7 +527,7 @@ export class SessionManager { saveCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): string { const entry: CompactionEntry = { type: "compaction", - id: uuidv4(), + id: this._generateId(), parentId: this.leafId || null, timestamp: new Date().toISOString(), summary, @@ -603,7 +605,7 @@ export class SessionManager { this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", - id: uuidv4(), + id: this._generateId(), parentId: branchFromId, timestamp: new Date().toISOString(), summary, @@ -613,7 +615,7 @@ export class SessionManager { } createBranchedSessionFromEntries(entries: FileEntry[], branchBeforeIndex: number): string | null { - const newSessionId = uuidv4(); + const newSessionId = randomUUID(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const newSessionFile = join(this.getSessionDir(), `${timestamp}_${newSessionId}.jsonl`); From beb70f126ddcbd06e51937c8c3cfee19f0fbc702 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 01:33:13 +0100 Subject: [PATCH 006/161] Refactor session manager: migration chain, validation, tests - Add migrateV1ToV2/migrateToCurrentVersion for extensible migrations - createSummaryMessage now takes timestamp from entry - loadEntriesFromFile validates session header - findMostRecentSession only returns valid session files (reads first 512 bytes) - Remove ConversationEntry alias - Fix mom context.ts TreeNode type Tests: - migration.test.ts: v1 migration, idempotency - build-context.test.ts: 14 tests covering trivial, compaction, branches - file-operations.test.ts: loadEntriesFromFile, findMostRecentSession --- .../coding-agent/src/core/session-manager.ts | 221 +++++++------- packages/coding-agent/src/index.ts | 3 +- packages/coding-agent/test/compaction.test.ts | 6 +- .../session-manager/build-context.test.ts | 269 ++++++++++++++++++ .../session-manager/file-operations.test.ts | 127 +++++++++ .../test/session-manager/migration.test.ts | 78 +++++ packages/mom/src/context.ts | 4 +- 7 files changed, 606 insertions(+), 102 deletions(-) create mode 100644 packages/coding-agent/test/session-manager/build-context.test.ts create mode 100644 packages/coding-agent/test/session-manager/file-operations.test.ts create mode 100644 packages/coding-agent/test/session-manager/migration.test.ts diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e6cd7f33..47a41c7c 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,15 +1,22 @@ import type { AppMessage } from "@mariozechner/pi-agent-core"; import { randomUUID } from "crypto"; -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"; +import { + appendFileSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + readSync, + statSync, + writeFileSync, +} from "fs"; import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir } from "../config.js"; export const CURRENT_SESSION_VERSION = 2; -// ============================================================================ -// Session Header (metadata, not part of conversation tree) -// ============================================================================ - export interface SessionHeader { type: "session"; version?: number; // v1 sessions don't have this @@ -19,20 +26,6 @@ export interface SessionHeader { branchedFrom?: string; } -// ============================================================================ -// Tree Node (added by SessionManager to all conversation entries) -// ============================================================================ - -export interface TreeNode { - id: string; - parentId: string | null; - timestamp: string; -} - -// ============================================================================ -// Content Types (what distinguishes entries - used for input) -// ============================================================================ - export interface MessageContent { type: "message"; message: AppMessage; @@ -61,17 +54,20 @@ export interface BranchSummaryContent { summary: string; } -/** Union of all content types (for input) */ -export type ConversationContent = +/** Union of all content types (for "write" methods in SessionManager) */ +export type SessionContent = | MessageContent | ThinkingLevelContent | ModelChangeContent | CompactionContent | BranchSummaryContent; -// ============================================================================ -// Full Entry Types (TreeNode + Content - returned from SessionManager) -// ============================================================================ +export interface TreeNode { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} export type SessionMessageEntry = TreeNode & MessageContent; export type ThinkingLevelChangeEntry = TreeNode & ThinkingLevelContent; @@ -79,7 +75,7 @@ export type ModelChangeEntry = TreeNode & ModelChangeContent; export type CompactionEntry = TreeNode & CompactionContent; export type BranchSummaryEntry = TreeNode & BranchSummaryContent; -/** Session entry - has id/parentId for tree structure */ +/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ export type SessionEntry = | SessionMessageEntry | ThinkingLevelChangeEntry @@ -87,9 +83,6 @@ export type SessionEntry = | CompactionEntry | BranchSummaryEntry; -/** @deprecated Use SessionEntry */ -export type ConversationEntry = SessionEntry; - /** Raw file entry (includes header) */ export type FileEntry = SessionHeader | SessionEntry; @@ -118,46 +111,46 @@ export const SUMMARY_SUFFIX = ` `; /** Exported for compaction.test.ts */ -export function createSummaryMessage(summary: string): AppMessage { +export function createSummaryMessage(summary: string, timestamp: string): AppMessage { return { role: "user", content: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX, - timestamp: Date.now(), + timestamp: new Date(timestamp).getTime(), }; } -/** - * Migrate v1 entries to v2 format by adding id/parentId fields. - * Mutates entries in place. Safe to call on already-migrated entries. - */ -export function migrateSessionEntries(entries: FileEntry[]): void { - // Check if already migrated - const firstConv = entries.find((e) => e.type !== "session"); - if (firstConv && "id" in firstConv && firstConv.id) { - return; // Already migrated +/** Generate a unique short ID (8 hex chars, collision-checked) */ +function generateId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = randomUUID().slice(0, 8); + if (!byId.has(id)) return id; } + // Fallback to full UUID if somehow we have collisions + return randomUUID(); +} +/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */ +function migrateV1ToV2(entries: FileEntry[]): void { + const ids = new Set(); let prevId: string | null = null; + for (const entry of entries) { if (entry.type === "session") { - entry.version = CURRENT_SESSION_VERSION; + entry.version = 2; continue; } - // Add id/parentId to conversation entries - const convEntry = entry as ConversationEntry; - convEntry.id = randomUUID(); - convEntry.parentId = prevId; - prevId = convEntry.id; + entry.id = generateId(ids); + entry.parentId = prevId; + prevId = entry.id; // Convert firstKeptEntryIndex to firstKeptEntryId for compaction if (entry.type === "compaction") { const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number }; if (typeof comp.firstKeptEntryIndex === "number") { - // Find the entry at that index and get its id const targetEntry = entries[comp.firstKeptEntryIndex]; if (targetEntry && targetEntry.type !== "session") { - comp.firstKeptEntryId = (targetEntry as ConversationEntry).id; + comp.firstKeptEntryId = targetEntry.id; } delete comp.firstKeptEntryIndex; } @@ -165,15 +158,39 @@ export function migrateSessionEntries(entries: FileEntry[]): void { } } +// Add future migrations here: +// function migrateV2ToV3(entries: FileEntry[]): void { ... } + +/** + * Run all necessary migrations to bring entries to current version. + * Mutates entries in place. Returns true if any migration was applied. + */ +function migrateToCurrentVersion(entries: FileEntry[]): boolean { + const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; + const version = header?.version ?? 1; + + if (version >= CURRENT_SESSION_VERSION) return false; + + if (version < 2) migrateV1ToV2(entries); + // if (version < 3) migrateV2ToV3(entries); + + return true; +} + +/** Exported for testing */ +export function migrateSessionEntries(entries: FileEntry[]): void { + migrateToCurrentVersion(entries); +} + /** Exported for compaction.test.ts */ export function parseSessionEntries(content: string): FileEntry[] { - const entries: SessionEntry[] = []; + const entries: FileEntry[] = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { - const entry = JSON.parse(line) as SessionEntry; + const entry = JSON.parse(line) as FileEntry; entries.push(entry); } catch { // Skip malformed lines @@ -197,18 +214,26 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt * If leafId is provided, walks from that entry to root. * Handles compaction and branch summaries along the path. */ -export function buildSessionContext(entries: SessionEntry[], leafId?: string): SessionContext { - // Build uuid index - const byId = new Map(); - for (const entry of entries) { - byId.set(entry.id, entry); +export function buildSessionContext( + entries: SessionEntry[], + leafId?: string, + byId?: Map, +): SessionContext { + // Build uuid index if not available + if (!byId) { + byId = new Map(); + for (const entry of entries) { + byId.set(entry.id, entry); + } } // Find leaf let leaf: SessionEntry | undefined; if (leafId) { leaf = byId.get(leafId); - } else { + } + if (!leaf) { + // Fallback to last entry leaf = entries[entries.length - 1]; } @@ -250,7 +275,7 @@ export function buildSessionContext(entries: SessionEntry[], leafId?: string): S if (compaction) { // Emit summary first - messages.push(createSummaryMessage(compaction.summary)); + messages.push(createSummaryMessage(compaction.summary, compaction.timestamp)); // Find compaction index in path const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id); @@ -273,7 +298,7 @@ export function buildSessionContext(entries: SessionEntry[], leafId?: string): S if (entry.type === "message") { messages.push(entry.message); } else if (entry.type === "branch_summary") { - messages.push(createSummaryMessage(entry.summary)); + messages.push(createSummaryMessage(entry.summary, entry.timestamp)); } } } else { @@ -282,7 +307,7 @@ export function buildSessionContext(entries: SessionEntry[], leafId?: string): S if (entry.type === "message") { messages.push(entry.message); } else if (entry.type === "branch_summary") { - messages.push(createSummaryMessage(entry.summary)); + messages.push(createSummaryMessage(entry.summary, entry.timestamp)); } } } @@ -303,34 +328,57 @@ function getDefaultSessionDir(cwd: string): string { return sessionDir; } -function loadEntriesFromFile(filePath: string): FileEntry[] { +/** Exported for testing */ +export function loadEntriesFromFile(filePath: string): FileEntry[] { if (!existsSync(filePath)) return []; const content = readFileSync(filePath, "utf8"); - const entries: SessionEntry[] = []; + const entries: FileEntry[] = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { - const entry = JSON.parse(line) as SessionEntry; + const entry = JSON.parse(line) as FileEntry; entries.push(entry); } catch { // Skip malformed lines } } + // Validate session header + if (entries.length === 0) return entries; + const header = entries[0]; + if (header.type !== "session" || typeof (header as any).id !== "string") { + return []; + } + return entries; } -function findMostRecentSession(sessionDir: string): string | null { +function isValidSessionFile(filePath: string): boolean { + try { + const fd = openSync(filePath, "r"); + const buffer = Buffer.alloc(512); + const bytesRead = readSync(fd, buffer, 0, 512, 0); + closeSync(fd); + const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; + if (!firstLine) return false; + const header = JSON.parse(firstLine); + return header.type === "session" && typeof header.id === "string"; + } catch { + return false; + } +} + +/** Exported for testing */ +export function findMostRecentSession(sessionDir: string): string | null { try { const files = readdirSync(sessionDir) .filter((f) => f.endsWith(".jsonl")) - .map((f) => ({ - path: join(sessionDir, f), - mtime: statSync(join(sessionDir, f)).mtime, - })) + .map((f) => join(sessionDir, f)) + .filter(isValidSessionFile) + .map((path) => ({ path, mtime: statSync(path).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); return files[0]?.path || null; @@ -347,21 +395,9 @@ export class SessionManager { private persist: boolean; private flushed: boolean = false; private inMemoryEntries: FileEntry[] = []; - - // Tree structure (v2) private byId: Map = new Map(); private leafId: string = ""; - /** Generate a unique short ID (8 hex chars, collision-checked) */ - private _generateId(): string { - for (let i = 0; i < 100; i++) { - const id = randomUUID().slice(0, 8); - if (!this.byId.has(id)) return id; - } - // Fallback to full UUID if somehow we have collisions - return randomUUID(); - } - private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) { this.cwd = cwd; this.sessionDir = sessionDir; @@ -385,10 +421,7 @@ export class SessionManager { const header = this.inMemoryEntries.find((e) => e.type === "session") as SessionHeader | undefined; this.sessionId = header?.id ?? randomUUID(); - // Migrate v1 to v2 if needed - const version = header?.version ?? 1; - if (version < CURRENT_SESSION_VERSION) { - this._migrateToV2(); + if (migrateToCurrentVersion(this.inMemoryEntries)) { this._rewriteFile(); } @@ -420,10 +453,6 @@ export class SessionManager { } } - private _migrateToV2(): void { - migrateSessionEntries(this.inMemoryEntries); - } - private _buildIndex(): void { this.byId.clear(); this.leafId = ""; @@ -480,7 +509,7 @@ export class SessionManager { } } - private _appendEntry(entry: ConversationEntry): void { + private _appendEntry(entry: SessionEntry): void { this.inMemoryEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; @@ -490,7 +519,7 @@ export class SessionManager { saveMessage(message: AppMessage): string { const entry: SessionMessageEntry = { type: "message", - id: this._generateId(), + id: generateId(this.byId), parentId: this.leafId || null, timestamp: new Date().toISOString(), message, @@ -502,7 +531,7 @@ export class SessionManager { saveThinkingLevelChange(thinkingLevel: string): string { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", - id: this._generateId(), + id: generateId(this.byId), parentId: this.leafId || null, timestamp: new Date().toISOString(), thinkingLevel, @@ -514,7 +543,7 @@ export class SessionManager { saveModelChange(provider: string, modelId: string): string { const entry: ModelChangeEntry = { type: "model_change", - id: this._generateId(), + id: generateId(this.byId), parentId: this.leafId || null, timestamp: new Date().toISOString(), provider, @@ -527,7 +556,7 @@ export class SessionManager { saveCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): string { const entry: CompactionEntry = { type: "compaction", - id: this._generateId(), + id: generateId(this.byId), parentId: this.leafId || null, timestamp: new Date().toISOString(), summary, @@ -546,13 +575,13 @@ export class SessionManager { return this.leafId; } - getEntry(id: string): ConversationEntry | undefined { + getEntry(id: string): SessionEntry | undefined { return this.byId.get(id); } /** Walk from entry to root, returning path (conversation entries only) */ - getPath(fromId?: string): ConversationEntry[] { - const path: ConversationEntry[] = []; + getPath(fromId?: string): SessionEntry[] { + const path: SessionEntry[] = []; let current = this.byId.get(fromId ?? this.leafId); while (current) { path.unshift(current); @@ -566,7 +595,7 @@ export class SessionManager { * Uses tree traversal from current leaf. */ buildSessionContext(): SessionContext { - return buildSessionContext(this.getEntries(), this.leafId); + return buildSessionContext(this.getEntries(), this.leafId, this.byId); } /** @@ -605,7 +634,7 @@ export class SessionManager { this.leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", - id: this._generateId(), + id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), summary, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index f9673384..2eafe3c4 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -112,8 +112,6 @@ export { buildSessionContext, type CompactionContent, type CompactionEntry, - type ConversationContent, - type ConversationEntry, CURRENT_SESSION_VERSION, createSummaryMessage, type FileEntry, @@ -123,6 +121,7 @@ export { type ModelChangeEntry, migrateSessionEntries, parseSessionEntries, + type SessionContent as ConversationContent, type SessionContext as LoadedSession, type SessionEntry, type SessionHeader, diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index c12748e4..8b756bb3 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -273,9 +273,11 @@ describe("findCutPoint", () => { }); describe("createSummaryMessage", () => { - it("should create user message with prefix", () => { - const msg = createSummaryMessage("This is the summary"); + it("should create user message with prefix and correct timestamp", () => { + const ts = "2025-01-01T12:00:00.000Z"; + const msg = createSummaryMessage("This is the summary", ts); expect(msg.role).toBe("user"); + expect(msg.timestamp).toBe(new Date(ts).getTime()); if (msg.role === "user") { expect(msg.content).toContain( "The conversation history before this point was compacted into the following summary:", diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts new file mode 100644 index 00000000..cb34bb84 --- /dev/null +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest"; +import { + type BranchSummaryEntry, + buildSessionContext, + type CompactionEntry, + type ModelChangeEntry, + type SessionEntry, + type SessionMessageEntry, + SUMMARY_PREFIX, + type ThinkingLevelChangeEntry, +} from "../../src/core/session-manager.js"; + +function msg(id: string, parentId: string | null, role: "user" | "assistant", text: string): SessionMessageEntry { + const base = { type: "message" as const, id, parentId, timestamp: "2025-01-01T00:00:00Z" }; + if (role === "user") { + return { ...base, message: { role, content: text, timestamp: 1 } }; + } + return { + ...base, + message: { + role, + content: [{ type: "text", text }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + }, + }; +} + +function compaction(id: string, parentId: string | null, summary: string, firstKeptEntryId: string): CompactionEntry { + return { + type: "compaction", + id, + parentId, + timestamp: "2025-01-01T00:00:00Z", + summary, + firstKeptEntryId, + tokensBefore: 1000, + }; +} + +function branchSummary(id: string, parentId: string | null, summary: string): BranchSummaryEntry { + return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary }; +} + +function thinkingLevel(id: string, parentId: string | null, level: string): ThinkingLevelChangeEntry { + return { type: "thinking_level_change", id, parentId, timestamp: "2025-01-01T00:00:00Z", thinkingLevel: level }; +} + +function modelChange(id: string, parentId: string | null, provider: string, modelId: string): ModelChangeEntry { + return { type: "model_change", id, parentId, timestamp: "2025-01-01T00:00:00Z", provider, modelId }; +} + +describe("buildSessionContext", () => { + describe("trivial cases", () => { + it("empty entries returns empty context", () => { + const ctx = buildSessionContext([]); + expect(ctx.messages).toEqual([]); + expect(ctx.thinkingLevel).toBe("off"); + expect(ctx.model).toBeNull(); + }); + + it("single user message", () => { + const entries: SessionEntry[] = [msg("1", null, "user", "hello")]; + const ctx = buildSessionContext(entries); + expect(ctx.messages).toHaveLength(1); + expect(ctx.messages[0].role).toBe("user"); + }); + + it("simple conversation", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "hello"), + msg("2", "1", "assistant", "hi there"), + msg("3", "2", "user", "how are you"), + msg("4", "3", "assistant", "great"), + ]; + const ctx = buildSessionContext(entries); + expect(ctx.messages).toHaveLength(4); + expect(ctx.messages.map((m) => m.role)).toEqual(["user", "assistant", "user", "assistant"]); + }); + + it("tracks thinking level changes", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "hello"), + thinkingLevel("2", "1", "high"), + msg("3", "2", "assistant", "thinking hard"), + ]; + const ctx = buildSessionContext(entries); + expect(ctx.thinkingLevel).toBe("high"); + expect(ctx.messages).toHaveLength(2); + }); + + it("tracks model from assistant message", () => { + const entries: SessionEntry[] = [msg("1", null, "user", "hello"), msg("2", "1", "assistant", "hi")]; + const ctx = buildSessionContext(entries); + expect(ctx.model).toEqual({ provider: "anthropic", modelId: "claude-test" }); + }); + + it("tracks model from model change entry", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "hello"), + modelChange("2", "1", "openai", "gpt-4"), + msg("3", "2", "assistant", "hi"), + ]; + const ctx = buildSessionContext(entries); + // Assistant message overwrites model change + expect(ctx.model).toEqual({ provider: "anthropic", modelId: "claude-test" }); + }); + }); + + describe("with compaction", () => { + it("includes summary before kept messages", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "first"), + msg("2", "1", "assistant", "response1"), + msg("3", "2", "user", "second"), + msg("4", "3", "assistant", "response2"), + compaction("5", "4", "Summary of first two turns", "3"), + msg("6", "5", "user", "third"), + msg("7", "6", "assistant", "response3"), + ]; + const ctx = buildSessionContext(entries); + + // Should have: summary + kept (3,4) + after (6,7) = 5 messages + expect(ctx.messages).toHaveLength(5); + expect((ctx.messages[0] as any).content).toContain("Summary of first two turns"); + expect((ctx.messages[1] as any).content).toBe("second"); + expect((ctx.messages[2] as any).content[0].text).toBe("response2"); + expect((ctx.messages[3] as any).content).toBe("third"); + expect((ctx.messages[4] as any).content[0].text).toBe("response3"); + }); + + it("handles compaction keeping from first message", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "first"), + msg("2", "1", "assistant", "response"), + compaction("3", "2", "Empty summary", "1"), + msg("4", "3", "user", "second"), + ]; + const ctx = buildSessionContext(entries); + + // Summary + all messages (1,2,4) + expect(ctx.messages).toHaveLength(4); + expect((ctx.messages[0] as any).content).toContain(SUMMARY_PREFIX); + }); + + it("multiple compactions uses latest", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "a"), + msg("2", "1", "assistant", "b"), + compaction("3", "2", "First summary", "1"), + msg("4", "3", "user", "c"), + msg("5", "4", "assistant", "d"), + compaction("6", "5", "Second summary", "4"), + msg("7", "6", "user", "e"), + ]; + const ctx = buildSessionContext(entries); + + // Should use second summary, keep from 4 + expect(ctx.messages).toHaveLength(4); + expect((ctx.messages[0] as any).content).toContain("Second summary"); + }); + }); + + describe("with branches", () => { + it("follows path to specified leaf", () => { + // Tree: + // 1 -> 2 -> 3 (branch A) + // \-> 4 (branch B) + const entries: SessionEntry[] = [ + msg("1", null, "user", "start"), + msg("2", "1", "assistant", "response"), + msg("3", "2", "user", "branch A"), + msg("4", "2", "user", "branch B"), + ]; + + const ctxA = buildSessionContext(entries, "3"); + expect(ctxA.messages).toHaveLength(3); + expect((ctxA.messages[2] as any).content).toBe("branch A"); + + const ctxB = buildSessionContext(entries, "4"); + expect(ctxB.messages).toHaveLength(3); + expect((ctxB.messages[2] as any).content).toBe("branch B"); + }); + + it("includes branch summary in path", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "start"), + msg("2", "1", "assistant", "response"), + msg("3", "2", "user", "abandoned path"), + branchSummary("4", "2", "Summary of abandoned work"), + msg("5", "4", "user", "new direction"), + ]; + const ctx = buildSessionContext(entries, "5"); + + expect(ctx.messages).toHaveLength(4); + expect((ctx.messages[2] as any).content).toContain("Summary of abandoned work"); + expect((ctx.messages[3] as any).content).toBe("new direction"); + }); + + it("complex tree with multiple branches and compaction", () => { + // Tree: + // 1 -> 2 -> 3 -> 4 -> compaction(5) -> 6 -> 7 (main path) + // \-> 8 -> 9 (abandoned branch) + // \-> branchSummary(10) -> 11 (resumed from 3) + const entries: SessionEntry[] = [ + msg("1", null, "user", "start"), + msg("2", "1", "assistant", "r1"), + msg("3", "2", "user", "q2"), + msg("4", "3", "assistant", "r2"), + compaction("5", "4", "Compacted history", "3"), + msg("6", "5", "user", "q3"), + msg("7", "6", "assistant", "r3"), + // Abandoned branch from 3 + msg("8", "3", "user", "wrong path"), + msg("9", "8", "assistant", "wrong response"), + // Branch summary resuming from 3 + branchSummary("10", "3", "Tried wrong approach"), + msg("11", "10", "user", "better approach"), + ]; + + // Main path to 7: summary + kept(3,4) + after(6,7) + const ctxMain = buildSessionContext(entries, "7"); + expect(ctxMain.messages).toHaveLength(5); + expect((ctxMain.messages[0] as any).content).toContain("Compacted history"); + expect((ctxMain.messages[1] as any).content).toBe("q2"); + expect((ctxMain.messages[2] as any).content[0].text).toBe("r2"); + expect((ctxMain.messages[3] as any).content).toBe("q3"); + expect((ctxMain.messages[4] as any).content[0].text).toBe("r3"); + + // Branch path to 11: 1,2,3 + branch_summary + 11 + const ctxBranch = buildSessionContext(entries, "11"); + expect(ctxBranch.messages).toHaveLength(5); + expect((ctxBranch.messages[0] as any).content).toBe("start"); + expect((ctxBranch.messages[1] as any).content[0].text).toBe("r1"); + expect((ctxBranch.messages[2] as any).content).toBe("q2"); + expect((ctxBranch.messages[3] as any).content).toContain("Tried wrong approach"); + expect((ctxBranch.messages[4] as any).content).toBe("better approach"); + }); + }); + + describe("edge cases", () => { + it("uses last entry when leafId not found", () => { + const entries: SessionEntry[] = [msg("1", null, "user", "hello"), msg("2", "1", "assistant", "hi")]; + const ctx = buildSessionContext(entries, "nonexistent"); + expect(ctx.messages).toHaveLength(2); + }); + + it("handles orphaned entries gracefully", () => { + const entries: SessionEntry[] = [ + msg("1", null, "user", "hello"), + msg("2", "missing", "assistant", "orphan"), // parent doesn't exist + ]; + const ctx = buildSessionContext(entries, "2"); + // Should only get the orphan since parent chain is broken + expect(ctx.messages).toHaveLength(1); + }); + }); +}); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts new file mode 100644 index 00000000..4b598578 --- /dev/null +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -0,0 +1,127 @@ +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { findMostRecentSession, loadEntriesFromFile } from "../../src/core/session-manager.js"; + +describe("loadEntriesFromFile", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `session-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns empty array for non-existent file", () => { + const entries = loadEntriesFromFile(join(tempDir, "nonexistent.jsonl")); + expect(entries).toEqual([]); + }); + + it("returns empty array for empty file", () => { + const file = join(tempDir, "empty.jsonl"); + writeFileSync(file, ""); + expect(loadEntriesFromFile(file)).toEqual([]); + }); + + it("returns empty array for file without valid session header", () => { + const file = join(tempDir, "no-header.jsonl"); + writeFileSync(file, '{"type":"message","id":"1"}\n'); + expect(loadEntriesFromFile(file)).toEqual([]); + }); + + it("returns empty array for malformed JSON", () => { + const file = join(tempDir, "malformed.jsonl"); + writeFileSync(file, "not json\n"); + expect(loadEntriesFromFile(file)).toEqual([]); + }); + + it("loads valid session file", () => { + const file = join(tempDir, "valid.jsonl"); + writeFileSync( + file, + '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' + + '{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n', + ); + const entries = loadEntriesFromFile(file); + expect(entries).toHaveLength(2); + expect(entries[0].type).toBe("session"); + expect(entries[1].type).toBe("message"); + }); + + it("skips malformed lines but keeps valid ones", () => { + const file = join(tempDir, "mixed.jsonl"); + writeFileSync( + file, + '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' + + "not valid json\n" + + '{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n', + ); + const entries = loadEntriesFromFile(file); + expect(entries).toHaveLength(2); + }); +}); + +describe("findMostRecentSession", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `session-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns null for empty directory", () => { + expect(findMostRecentSession(tempDir)).toBeNull(); + }); + + it("returns null for non-existent directory", () => { + expect(findMostRecentSession(join(tempDir, "nonexistent"))).toBeNull(); + }); + + it("ignores non-jsonl files", () => { + writeFileSync(join(tempDir, "file.txt"), "hello"); + writeFileSync(join(tempDir, "file.json"), "{}"); + expect(findMostRecentSession(tempDir)).toBeNull(); + }); + + it("ignores jsonl files without valid session header", () => { + writeFileSync(join(tempDir, "invalid.jsonl"), '{"type":"message"}\n'); + expect(findMostRecentSession(tempDir)).toBeNull(); + }); + + it("returns single valid session file", () => { + const file = join(tempDir, "session.jsonl"); + writeFileSync(file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + expect(findMostRecentSession(tempDir)).toBe(file); + }); + + it("returns most recently modified session", async () => { + const file1 = join(tempDir, "older.jsonl"); + const file2 = join(tempDir, "newer.jsonl"); + + writeFileSync(file1, '{"type":"session","id":"old","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + // Small delay to ensure different mtime + await new Promise((r) => setTimeout(r, 10)); + writeFileSync(file2, '{"type":"session","id":"new","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + + expect(findMostRecentSession(tempDir)).toBe(file2); + }); + + it("skips invalid files and returns valid one", async () => { + const invalid = join(tempDir, "invalid.jsonl"); + const valid = join(tempDir, "valid.jsonl"); + + writeFileSync(invalid, '{"type":"not-session"}\n'); + await new Promise((r) => setTimeout(r, 10)); + writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + + expect(findMostRecentSession(tempDir)).toBe(valid); + }); +}); diff --git a/packages/coding-agent/test/session-manager/migration.test.ts b/packages/coding-agent/test/session-manager/migration.test.ts new file mode 100644 index 00000000..129ba87d --- /dev/null +++ b/packages/coding-agent/test/session-manager/migration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { type FileEntry, migrateSessionEntries } from "../../src/core/session-manager.js"; + +describe("migrateSessionEntries", () => { + it("should add id/parentId to v1 entries", () => { + const entries: FileEntry[] = [ + { type: "session", id: "sess-1", timestamp: "2025-01-01T00:00:00Z", cwd: "/tmp" }, + { type: "message", timestamp: "2025-01-01T00:00:01Z", message: { role: "user", content: "hi", timestamp: 1 } }, + { + type: "message", + timestamp: "2025-01-01T00:00:02Z", + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + api: "test", + provider: "test", + model: "test", + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, + stopReason: "stop", + timestamp: 2, + }, + }, + ] as FileEntry[]; + + migrateSessionEntries(entries); + + // Header should have version set + expect((entries[0] as any).version).toBe(2); + + // Entries should have id/parentId + const msg1 = entries[1] as any; + const msg2 = entries[2] as any; + + expect(msg1.id).toBeDefined(); + expect(msg1.id.length).toBe(8); + expect(msg1.parentId).toBeNull(); + + expect(msg2.id).toBeDefined(); + expect(msg2.id.length).toBe(8); + expect(msg2.parentId).toBe(msg1.id); + }); + + it("should be idempotent (skip already migrated)", () => { + const entries: FileEntry[] = [ + { type: "session", id: "sess-1", version: 2, timestamp: "2025-01-01T00:00:00Z", cwd: "/tmp" }, + { + type: "message", + id: "abc12345", + parentId: null, + timestamp: "2025-01-01T00:00:01Z", + message: { role: "user", content: "hi", timestamp: 1 }, + }, + { + type: "message", + id: "def67890", + parentId: "abc12345", + timestamp: "2025-01-01T00:00:02Z", + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + api: "test", + provider: "test", + model: "test", + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, + stopReason: "stop", + timestamp: 2, + }, + }, + ] as FileEntry[]; + + migrateSessionEntries(entries); + + // IDs should be unchanged + expect((entries[1] as any).id).toBe("abc12345"); + expect((entries[2] as any).id).toBe("def67890"); + expect((entries[2] as any).parentId).toBe("abc12345"); + }); +}); diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index 58a43b24..a5a8024c 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -98,9 +98,9 @@ export class MomSessionManager { this.leafId = null; } - private _createTreeNode(): TreeNode { + private _createTreeNode(): Omit { const id = uuidv4(); - const node: TreeNode = { + const node = { id, parentId: this.leafId, timestamp: new Date().toISOString(), From 6f94e246293d061b669a226c65e9731d0193ab8c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 02:37:42 +0100 Subject: [PATCH 007/161] Session tree: simplify types, add branching API, comprehensive tests Types: - SessionEntryBase with type field, extended by all entry types - CustomEntry for hooks (type: 'custom', customType, data) - Remove XXXContent types and TreeNode (redundant) API: - Rename saveXXX to appendXXX with JSDoc explaining tree semantics - Rename branchInPlace to branch() with better docs - Add createBranchedSession(leafId) replacing index-based version - Add getTree() returning SessionTreeNode[] for tree traversal - Add appendCustomEntry(customType, data) for hooks Tests: - tree-traversal.test.ts: 28 tests covering append, getPath, getTree, branch, branchWithSummary, createBranchedSession - save-entry.test.ts: custom entry integration Docs: - Class-level JSDoc explaining append-only tree model - Method docs explaining leaf advancement and branching - CHANGELOG.md entry for all changes --- packages/coding-agent/CHANGELOG.md | 28 + .../coding-agent/src/core/agent-session.ts | 28 +- .../coding-agent/src/core/session-manager.ts | 271 ++++++---- packages/coding-agent/src/index.ts | 11 +- .../test/session-manager/save-entry.test.ts | 55 ++ .../session-manager/tree-traversal.test.ts | 483 ++++++++++++++++++ packages/mom/src/agent.ts | 4 +- packages/mom/src/context.ts | 34 +- 8 files changed, 779 insertions(+), 135 deletions(-) create mode 100644 packages/coding-agent/test/session-manager/save-entry.test.ts create mode 100644 packages/coding-agent/test/session-manager/tree-traversal.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 43246cf7..987e4d5f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,13 +2,41 @@ ## [Unreleased] +### Breaking Changes + +- **Session tree structure (v2)**: Sessions now store entries as a tree with `id`/`parentId` fields, enabling in-place branching without creating new files. Existing v1 sessions are auto-migrated on load. +- **SessionManager API**: + - `saveXXX()` renamed to `appendXXX()` (e.g., `appendMessage`, `appendCompaction`) + - `branchInPlace()` renamed to `branch()` + - `reset()` renamed to `newSession()` + - `createBranchedSessionFromEntries(entries, index)` replaced with `createBranchedSession(leafId)` + - `saveCompaction(entry)` replaced with `appendCompaction(summary, firstKeptEntryId, tokensBefore)` + - `getEntries()` now excludes the session header (use `getHeader()` separately) + - New methods: `getTree()`, `getPath()`, `getLeafUuid()`, `getLeafEntry()`, `getEntry()`, `branchWithSummary()` + - New `appendCustomEntry(customType, data)` for hooks to store custom data +- **Compaction API**: + - `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore }`) instead of `CompactionEntry` + - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` + - `prepareCompaction()` now returns `firstKeptEntryId` in its result +- **Hook types**: + - `SessionEventResult.compactionEntry` replaced with `SessionEventResult.compaction` (content only, SessionManager adds id/parentId) + - `before_compact` event now includes `firstKeptEntryId` field for hooks that return custom compaction + ### Added - **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting. +### Changed + +- **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs +- **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY` +- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook data + ### Fixed - **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355)) +- **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded +- **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings ## [0.30.2] - 2025-12-26 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b6f45978..1547ba61 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -211,7 +211,7 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { - this.sessionManager.saveMessage(event.message); + this.sessionManager.appendMessage(event.message); // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { @@ -535,7 +535,7 @@ export class AgentSession { this._disconnectFromAgent(); await this.abort(); this.agent.reset(); - this.sessionManager.reset(); + this.sessionManager.newSession(); this._queuedMessages = []; this._reconnectToAgent(); @@ -572,7 +572,7 @@ export class AgentSession { } this.agent.setModel(model); - this.sessionManager.saveModelChange(model.provider, model.id); + this.sessionManager.appendModelChange(model.provider, model.id); this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); // Re-clamp thinking level for new model's capabilities @@ -611,7 +611,7 @@ export class AgentSession { // Apply model this.agent.setModel(next.model); - this.sessionManager.saveModelChange(next.model.provider, next.model.id); + this.sessionManager.appendModelChange(next.model.provider, next.model.id); this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); // Apply thinking level (setThinkingLevel clamps to model capabilities) @@ -638,7 +638,7 @@ export class AgentSession { } this.agent.setModel(nextModel); - this.sessionManager.saveModelChange(nextModel.provider, nextModel.id); + this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); // Re-clamp thinking level for new model's capabilities @@ -671,7 +671,7 @@ export class AgentSession { effectiveLevel = "high"; } this.agent.setThinkingLevel(effectiveLevel); - this.sessionManager.saveThinkingLevelChange(effectiveLevel); + this.sessionManager.appendThinkingLevelChange(effectiveLevel); this.settingsManager.setDefaultThinkingLevel(effectiveLevel); } @@ -831,7 +831,7 @@ export class AgentSession { throw new Error("Compaction cancelled"); } - this.sessionManager.saveCompaction(summary, firstKeptEntryId, tokensBefore); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); @@ -1013,7 +1013,7 @@ export class AgentSession { return; } - this.sessionManager.saveCompaction(summary, firstKeptEntryId, tokensBefore); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); @@ -1271,7 +1271,7 @@ export class AgentSession { this.agent.appendMessage(bashMessage); // Save to session - this.sessionManager.saveMessage(bashMessage); + this.sessionManager.appendMessage(bashMessage); } return result; @@ -1309,7 +1309,7 @@ export class AgentSession { this.agent.appendMessage(bashMessage); // Save to session - this.sessionManager.saveMessage(bashMessage); + this.sessionManager.appendMessage(bashMessage); } this._pendingBashMessages = []; @@ -1431,8 +1431,12 @@ export class AgentSession { skipConversationRestore = result?.skipConversationRestore ?? false; } - // Create branched session (returns null in --no-session mode) - const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex); + // Create branched session ending before the selected message (returns null in --no-session mode) + // User will re-enter/edit the selected message + if (!selectedEntry.parentId) { + throw new Error("Cannot branch from first message"); + } + const newSessionFile = this.sessionManager.createBranchedSession(selectedEntry.parentId); // Update session file if we have one (file-based mode) if (newSessionFile !== null) { diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 47a41c7c..148eec05 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -26,54 +26,47 @@ export interface SessionHeader { branchedFrom?: string; } -export interface MessageContent { - type: "message"; - message: AppMessage; -} - -export interface ThinkingLevelContent { - type: "thinking_level_change"; - thinkingLevel: string; -} - -export interface ModelChangeContent { - type: "model_change"; - provider: string; - modelId: string; -} - -export interface CompactionContent { - type: "compaction"; - summary: string; - firstKeptEntryId: string; - tokensBefore: number; -} - -export interface BranchSummaryContent { - type: "branch_summary"; - summary: string; -} - -/** Union of all content types (for "write" methods in SessionManager) */ -export type SessionContent = - | MessageContent - | ThinkingLevelContent - | ModelChangeContent - | CompactionContent - | BranchSummaryContent; - -export interface TreeNode { +export interface SessionEntryBase { type: string; id: string; parentId: string | null; timestamp: string; } -export type SessionMessageEntry = TreeNode & MessageContent; -export type ThinkingLevelChangeEntry = TreeNode & ThinkingLevelContent; -export type ModelChangeEntry = TreeNode & ModelChangeContent; -export type CompactionEntry = TreeNode & CompactionContent; -export type BranchSummaryEntry = TreeNode & BranchSummaryContent; +export interface SessionMessageEntry extends SessionEntryBase { + type: "message"; + message: AppMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionEntryBase { + type: "thinking_level_change"; + thinkingLevel: string; +} + +export interface ModelChangeEntry extends SessionEntryBase { + type: "model_change"; + provider: string; + modelId: string; +} + +export interface CompactionEntry extends SessionEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; +} + +export interface BranchSummaryEntry extends SessionEntryBase { + type: "branch_summary"; + summary: string; +} + +/** Custom entry for hooks. Use customType to identify your hook's entries. */ +export interface CustomEntry extends SessionEntryBase { + type: "custom"; + customType: string; + data?: unknown; +} /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ export type SessionEntry = @@ -81,11 +74,18 @@ export type SessionEntry = | ThinkingLevelChangeEntry | ModelChangeEntry | CompactionEntry - | BranchSummaryEntry; + | BranchSummaryEntry + | CustomEntry; /** Raw file entry (includes header) */ export type FileEntry = SessionHeader | SessionEntry; +/** Tree node for getTree() - defensive copy of session structure */ +export interface SessionTreeNode { + entry: SessionEntry; + children: SessionTreeNode[]; +} + export interface SessionContext { messages: AppMessage[]; thinkingLevel: string; @@ -387,6 +387,17 @@ export function findMostRecentSession(sessionDir: string): string | null { } } +/** + * Manages conversation sessions as append-only trees stored in JSONL files. + * + * Each session entry has an id and parentId forming a tree structure. The "leaf" + * pointer tracks the current position. Appending creates a child of the current leaf. + * Branching moves the leaf to an earlier entry, allowing new branches without + * modifying history. + * + * Use buildSessionContext() to get the resolved message list for the LLM, which + * handles compaction summaries and follows the path from root to current leaf. + */ export class SessionManager { private sessionId: string = ""; private sessionFile: string = ""; @@ -394,7 +405,7 @@ export class SessionManager { private cwd: string; private persist: boolean; private flushed: boolean = false; - private inMemoryEntries: FileEntry[] = []; + private fileEntries: FileEntry[] = []; private byId: Map = new Map(); private leafId: string = ""; @@ -409,7 +420,7 @@ export class SessionManager { if (sessionFile) { this.setSessionFile(sessionFile); } else { - this._initNewSession(); + this.newSession(); } } @@ -417,22 +428,22 @@ export class SessionManager { setSessionFile(sessionFile: string): void { this.sessionFile = resolve(sessionFile); if (existsSync(this.sessionFile)) { - this.inMemoryEntries = loadEntriesFromFile(this.sessionFile); - const header = this.inMemoryEntries.find((e) => e.type === "session") as SessionHeader | undefined; + this.fileEntries = loadEntriesFromFile(this.sessionFile); + const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; this.sessionId = header?.id ?? randomUUID(); - if (migrateToCurrentVersion(this.inMemoryEntries)) { + if (migrateToCurrentVersion(this.fileEntries)) { this._rewriteFile(); } this._buildIndex(); this.flushed = true; } else { - this._initNewSession(); + this.newSession(); } } - private _initNewSession(): void { + newSession(): void { this.sessionId = randomUUID(); const timestamp = new Date().toISOString(); const header: SessionHeader = { @@ -442,7 +453,7 @@ export class SessionManager { timestamp, cwd: this.cwd, }; - this.inMemoryEntries = [header]; + this.fileEntries = [header]; this.byId.clear(); this.leafId = ""; this.flushed = false; @@ -456,7 +467,7 @@ export class SessionManager { private _buildIndex(): void { this.byId.clear(); this.leafId = ""; - for (const entry of this.inMemoryEntries) { + for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); this.leafId = entry.id; @@ -465,7 +476,7 @@ export class SessionManager { private _rewriteFile(): void { if (!this.persist) return; - const content = `${this.inMemoryEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; + const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; writeFileSync(this.sessionFile, content); } @@ -489,18 +500,14 @@ export class SessionManager { return this.sessionFile; } - reset(): void { - this._initNewSession(); - } - _persist(entry: SessionEntry): void { if (!this.persist) return; - const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant"); + const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) return; if (!this.flushed) { - for (const e of this.inMemoryEntries) { + for (const e of this.fileEntries) { appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`); } this.flushed = true; @@ -510,13 +517,14 @@ export class SessionManager { } private _appendEntry(entry: SessionEntry): void { - this.inMemoryEntries.push(entry); + this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; this._persist(entry); } - saveMessage(message: AppMessage): string { + /** Append a message as child of current leaf, then advance leaf. Returns entry id. */ + appendMessage(message: AppMessage): string { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), @@ -528,7 +536,8 @@ export class SessionManager { return entry.id; } - saveThinkingLevelChange(thinkingLevel: string): string { + /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */ + appendThinkingLevelChange(thinkingLevel: string): string { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", id: generateId(this.byId), @@ -540,7 +549,8 @@ export class SessionManager { return entry.id; } - saveModelChange(provider: string, modelId: string): string { + /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */ + appendModelChange(provider: string, modelId: string): string { const entry: ModelChangeEntry = { type: "model_change", id: generateId(this.byId), @@ -553,7 +563,8 @@ export class SessionManager { return entry.id; } - saveCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): string { + /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ + appendCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): string { const entry: CompactionEntry = { type: "compaction", id: generateId(this.byId), @@ -567,6 +578,20 @@ export class SessionManager { return entry.id; } + /** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */ + appendCustomEntry(customType: string, data?: unknown): string { + const entry: CustomEntry = { + type: "custom", + customType, + data, + id: generateId(this.byId), + parentId: this.leafId || null, + timestamp: new Date().toISOString(), + }; + this._appendEntry(entry); + return entry.id; + } + // ========================================================================= // Tree Traversal // ========================================================================= @@ -575,11 +600,19 @@ export class SessionManager { return this.leafId; } + getLeafEntry(): SessionEntry | undefined { + return this.byId.get(this.leafId); + } + getEntry(id: string): SessionEntry | undefined { return this.byId.get(id); } - /** Walk from entry to root, returning path (conversation entries only) */ + /** + * Walk from entry to root, returning all entries in path order. + * Includes all entry types (messages, compaction, model changes, etc.). + * Use buildSessionContext() to get the resolved messages for the LLM. + */ getPath(fromId?: string): SessionEntry[] { const path: SessionEntry[] = []; let current = this.byId.get(fromId ?? this.leafId); @@ -602,31 +635,75 @@ export class SessionManager { * Get session header. */ getHeader(): SessionHeader | null { - const h = this.inMemoryEntries.find((e) => e.type === "session"); + const h = this.fileEntries.find((e) => e.type === "session"); return h ? (h as SessionHeader) : null; } /** - * Get all session entries (excludes header). Returns a defensive copy. - * Use buildSessionContext() if you need the messages for the LLM. + * Get all session entries (excludes header). Returns a shallow copy. + * The session is append-only: use appendXXX() to add entries, branch() to + * change the leaf pointer. Entries cannot be modified or deleted. */ getEntries(): SessionEntry[] { - return this.inMemoryEntries.filter((e): e is SessionEntry => e.type !== "session"); + return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session"); + } + + /** + * Get the session as a tree structure. Returns a shallow defensive copy of all entries. + * A well-formed session has exactly one root (first entry with parentId === null). + * Orphaned entries (broken parent chain) are also returned as roots. + */ + getTree(): SessionTreeNode[] { + const entries = this.getEntries(); + const nodeMap = new Map(); + const roots: SessionTreeNode[] = []; + + // Create nodes + for (const entry of entries) { + nodeMap.set(entry.id, { entry, children: [] }); + } + + // Build tree + for (const entry of entries) { + const node = nodeMap.get(entry.id)!; + if (entry.parentId === null) { + roots.push(node); + } else { + const parent = nodeMap.get(entry.parentId); + if (parent) { + parent.children.push(node); + } else { + // Orphan - treat as root + roots.push(node); + } + } + } + + return roots; } // ========================================================================= // Branching // ========================================================================= - /** Branch in-place by changing the leaf pointer */ - branchInPlace(branchFromId: string): void { + /** + * Start a new branch from an earlier entry. + * Moves the leaf pointer to the specified entry. The next appendXXX() call + * will create a child of that entry, forming a new branch. Existing entries + * are not modified or deleted. + */ + branch(branchFromId: string): void { if (!this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.leafId = branchFromId; } - /** Branch with a summary of the abandoned path */ + /** + * Start a new branch with a summary of the abandoned path. + * Same as branch(), but also appends a branch_summary entry that captures + * context from the abandoned conversation path. + */ branchWithSummary(branchFromId: string, summary: string): string { if (!this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); @@ -643,35 +720,41 @@ export class SessionManager { return entry.id; } - createBranchedSessionFromEntries(entries: FileEntry[], branchBeforeIndex: number): string | null { - const newSessionId = randomUUID(); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const newSessionFile = join(this.getSessionDir(), `${timestamp}_${newSessionId}.jsonl`); - - const newEntries: FileEntry[] = []; - for (let i = 0; i < branchBeforeIndex; i++) { - const entry = entries[i]; - - if (entry.type === "session") { - newEntries.push({ - ...entry, - version: CURRENT_SESSION_VERSION, - id: newSessionId, - timestamp: new Date().toISOString(), - branchedFrom: this.persist ? this.sessionFile : undefined, - }); - } else { - newEntries.push(entry); - } + /** + * Create a new session file containing only the path from root to the specified leaf. + * Useful for extracting a single conversation path from a branched session. + * Returns the new session file path, or null if not persisting. + */ + createBranchedSession(leafId: string): string | null { + const path = this.getPath(leafId); + if (path.length === 0) { + throw new Error(`Entry ${leafId} not found`); } + const newSessionId = randomUUID(); + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); + + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: newSessionId, + timestamp, + cwd: this.cwd, + branchedFrom: this.persist ? this.sessionFile : undefined, + }; + if (this.persist) { - for (const entry of newEntries) { + appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`); + for (const entry of path) { appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); } return newSessionFile; } - this.inMemoryEntries = newEntries; + + // In-memory mode: replace current session with the path + this.fileEntries = [header, ...path]; this.sessionId = newSessionId; this._buildIndex(); return null; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 2eafe3c4..6ddaedb1 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -107,23 +107,19 @@ export { readOnlyTools, } from "./core/sdk.js"; export { - type BranchSummaryContent, type BranchSummaryEntry, buildSessionContext, - type CompactionContent, type CompactionEntry, CURRENT_SESSION_VERSION, createSummaryMessage, type FileEntry, getLatestCompactionEntry, - type MessageContent, - type ModelChangeContent, type ModelChangeEntry, migrateSessionEntries, parseSessionEntries, - type SessionContent as ConversationContent, - type SessionContext as LoadedSession, + type SessionContext, type SessionEntry, + type SessionEntryBase, type SessionHeader, type SessionInfo, SessionManager, @@ -131,9 +127,6 @@ export { SUMMARY_PREFIX, SUMMARY_SUFFIX, type ThinkingLevelChangeEntry, - type ThinkingLevelContent, - // Tree types (v2) - type TreeNode, } from "./core/session-manager.js"; export { type CompactionSettings, diff --git a/packages/coding-agent/test/session-manager/save-entry.test.ts b/packages/coding-agent/test/session-manager/save-entry.test.ts new file mode 100644 index 00000000..2a618986 --- /dev/null +++ b/packages/coding-agent/test/session-manager/save-entry.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { type CustomEntry, SessionManager } from "../../src/core/session-manager.js"; + +describe("SessionManager.saveCustomEntry", () => { + it("saves custom entries and includes them in tree traversal", () => { + const session = SessionManager.inMemory(); + + // Save a message + const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + + // Save a custom entry + const customId = session.appendCustomEntry("my_hook", { foo: "bar" }); + + // Save another message + const msg2Id = session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "hi" }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }); + + // Custom entry should be in entries + const entries = session.getEntries(); + expect(entries).toHaveLength(3); + + const customEntry = entries.find((e) => e.type === "custom") as CustomEntry; + expect(customEntry).toBeDefined(); + expect(customEntry.customType).toBe("my_hook"); + expect(customEntry.data).toEqual({ foo: "bar" }); + expect(customEntry.id).toBe(customId); + expect(customEntry.parentId).toBe(msgId); + + // Tree structure should be correct + const path = session.getPath(); + expect(path).toHaveLength(3); + expect(path[0].id).toBe(msgId); + expect(path[1].id).toBe(customId); + expect(path[2].id).toBe(msg2Id); + + // buildSessionContext should work (custom entries skipped in messages) + const ctx = session.buildSessionContext(); + expect(ctx.messages).toHaveLength(2); // only message entries + }); +}); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts new file mode 100644 index 00000000..fa788164 --- /dev/null +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it } from "vitest"; +import { type CustomEntry, SessionManager } from "../../src/core/session-manager.js"; + +function userMsg(text: string) { + return { role: "user" as const, content: text, timestamp: Date.now() }; +} + +function assistantMsg(text: string) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "anthropic-messages" as const, + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} + +describe("SessionManager append and tree traversal", () => { + describe("append operations", () => { + it("appendMessage creates entry with correct parentId chain", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("first")); + const id2 = session.appendMessage(assistantMsg("second")); + const id3 = session.appendMessage(userMsg("third")); + + const entries = session.getEntries(); + expect(entries).toHaveLength(3); + + expect(entries[0].id).toBe(id1); + expect(entries[0].parentId).toBeNull(); + expect(entries[0].type).toBe("message"); + + expect(entries[1].id).toBe(id2); + expect(entries[1].parentId).toBe(id1); + + expect(entries[2].id).toBe(id3); + expect(entries[2].parentId).toBe(id2); + }); + + it("appendThinkingLevelChange integrates into tree", () => { + const session = SessionManager.inMemory(); + + const msgId = session.appendMessage(userMsg("hello")); + const thinkingId = session.appendThinkingLevelChange("high"); + const _msg2Id = session.appendMessage(assistantMsg("response")); + + const entries = session.getEntries(); + expect(entries).toHaveLength(3); + + const thinkingEntry = entries.find((e) => e.type === "thinking_level_change"); + expect(thinkingEntry).toBeDefined(); + expect(thinkingEntry!.id).toBe(thinkingId); + expect(thinkingEntry!.parentId).toBe(msgId); + + expect(entries[2].parentId).toBe(thinkingId); + }); + + it("appendModelChange integrates into tree", () => { + const session = SessionManager.inMemory(); + + const msgId = session.appendMessage(userMsg("hello")); + const modelId = session.appendModelChange("openai", "gpt-4"); + const _msg2Id = session.appendMessage(assistantMsg("response")); + + const entries = session.getEntries(); + const modelEntry = entries.find((e) => e.type === "model_change"); + expect(modelEntry).toBeDefined(); + expect(modelEntry?.id).toBe(modelId); + expect(modelEntry?.parentId).toBe(msgId); + if (modelEntry?.type === "model_change") { + expect(modelEntry.provider).toBe("openai"); + expect(modelEntry.modelId).toBe("gpt-4"); + } + + expect(entries[2].parentId).toBe(modelId); + }); + + it("appendCompaction integrates into tree", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + const compactionId = session.appendCompaction("summary", id1, 1000); + const _id3 = session.appendMessage(userMsg("3")); + + const entries = session.getEntries(); + const compactionEntry = entries.find((e) => e.type === "compaction"); + expect(compactionEntry).toBeDefined(); + expect(compactionEntry?.id).toBe(compactionId); + expect(compactionEntry?.parentId).toBe(id2); + if (compactionEntry?.type === "compaction") { + expect(compactionEntry.summary).toBe("summary"); + expect(compactionEntry.firstKeptEntryId).toBe(id1); + expect(compactionEntry.tokensBefore).toBe(1000); + } + + expect(entries[3].parentId).toBe(compactionId); + }); + + it("appendCustomEntry integrates into tree", () => { + const session = SessionManager.inMemory(); + + const msgId = session.appendMessage(userMsg("hello")); + const customId = session.appendCustomEntry("my_hook", { key: "value" }); + const _msg2Id = session.appendMessage(assistantMsg("response")); + + const entries = session.getEntries(); + const customEntry = entries.find((e) => e.type === "custom") as CustomEntry; + expect(customEntry).toBeDefined(); + expect(customEntry.id).toBe(customId); + expect(customEntry.parentId).toBe(msgId); + expect(customEntry.customType).toBe("my_hook"); + expect(customEntry.data).toEqual({ key: "value" }); + + expect(entries[2].parentId).toBe(customId); + }); + + it("leaf pointer advances after each append", () => { + const session = SessionManager.inMemory(); + + expect(session.getLeafUuid()).toBe(""); + + const id1 = session.appendMessage(userMsg("1")); + expect(session.getLeafUuid()).toBe(id1); + + const id2 = session.appendMessage(assistantMsg("2")); + expect(session.getLeafUuid()).toBe(id2); + + const id3 = session.appendThinkingLevelChange("high"); + expect(session.getLeafUuid()).toBe(id3); + }); + }); + + describe("getPath", () => { + it("returns empty array for empty session", () => { + const session = SessionManager.inMemory(); + expect(session.getPath()).toEqual([]); + }); + + it("returns single entry path", () => { + const session = SessionManager.inMemory(); + const id = session.appendMessage(userMsg("hello")); + + const path = session.getPath(); + expect(path).toHaveLength(1); + expect(path[0].id).toBe(id); + }); + + it("returns full path from root to leaf", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + const id3 = session.appendThinkingLevelChange("high"); + const id4 = session.appendMessage(userMsg("3")); + + const path = session.getPath(); + expect(path).toHaveLength(4); + expect(path.map((e) => e.id)).toEqual([id1, id2, id3, id4]); + }); + + it("returns path from specified entry to root", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + const _id3 = session.appendMessage(userMsg("3")); + const _id4 = session.appendMessage(assistantMsg("4")); + + const path = session.getPath(id2); + expect(path).toHaveLength(2); + expect(path.map((e) => e.id)).toEqual([id1, id2]); + }); + }); + + describe("getTree", () => { + it("returns empty array for empty session", () => { + const session = SessionManager.inMemory(); + expect(session.getTree()).toEqual([]); + }); + + it("returns single root for linear session", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + const id3 = session.appendMessage(userMsg("3")); + + const tree = session.getTree(); + expect(tree).toHaveLength(1); + + const root = tree[0]; + expect(root.entry.id).toBe(id1); + expect(root.children).toHaveLength(1); + expect(root.children[0].entry.id).toBe(id2); + expect(root.children[0].children).toHaveLength(1); + expect(root.children[0].children[0].entry.id).toBe(id3); + expect(root.children[0].children[0].children).toHaveLength(0); + }); + + it("returns tree with branches after branch", () => { + const session = SessionManager.inMemory(); + + // Build: 1 -> 2 -> 3 + const id1 = session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + const id3 = session.appendMessage(userMsg("3")); + + // Branch from id2, add new path: 2 -> 4 + session.branch(id2); + const id4 = session.appendMessage(userMsg("4-branch")); + + const tree = session.getTree(); + expect(tree).toHaveLength(1); + + const root = tree[0]; + expect(root.entry.id).toBe(id1); + expect(root.children).toHaveLength(1); + + const node2 = root.children[0]; + expect(node2.entry.id).toBe(id2); + expect(node2.children).toHaveLength(2); // id3 and id4 are siblings + + const childIds = node2.children.map((c) => c.entry.id).sort(); + expect(childIds).toEqual([id3, id4].sort()); + }); + + it("handles multiple branches at same point", () => { + const session = SessionManager.inMemory(); + + const _id1 = session.appendMessage(userMsg("root")); + const id2 = session.appendMessage(assistantMsg("response")); + + // Branch A + session.branch(id2); + const idA = session.appendMessage(userMsg("branch-A")); + + // Branch B + session.branch(id2); + const idB = session.appendMessage(userMsg("branch-B")); + + // Branch C + session.branch(id2); + const idC = session.appendMessage(userMsg("branch-C")); + + const tree = session.getTree(); + const node2 = tree[0].children[0]; + expect(node2.entry.id).toBe(id2); + expect(node2.children).toHaveLength(3); + + const branchIds = node2.children.map((c) => c.entry.id).sort(); + expect(branchIds).toEqual([idA, idB, idC].sort()); + }); + + it("handles deep branching", () => { + const session = SessionManager.inMemory(); + + // Main path: 1 -> 2 -> 3 -> 4 + const _id1 = session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + const id3 = session.appendMessage(userMsg("3")); + const _id4 = session.appendMessage(assistantMsg("4")); + + // Branch from 2: 2 -> 5 -> 6 + session.branch(id2); + const id5 = session.appendMessage(userMsg("5")); + const _id6 = session.appendMessage(assistantMsg("6")); + + // Branch from 5: 5 -> 7 + session.branch(id5); + const _id7 = session.appendMessage(userMsg("7")); + + const tree = session.getTree(); + + // Verify structure + const node2 = tree[0].children[0]; + expect(node2.children).toHaveLength(2); // id3 and id5 + + const node5 = node2.children.find((c) => c.entry.id === id5)!; + expect(node5.children).toHaveLength(2); // id6 and id7 + + const node3 = node2.children.find((c) => c.entry.id === id3)!; + expect(node3.children).toHaveLength(1); // id4 + }); + }); + + describe("branch", () => { + it("moves leaf pointer to specified entry", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("1")); + const _id2 = session.appendMessage(assistantMsg("2")); + const id3 = session.appendMessage(userMsg("3")); + + expect(session.getLeafUuid()).toBe(id3); + + session.branch(id1); + expect(session.getLeafUuid()).toBe(id1); + }); + + it("throws for non-existent entry", () => { + const session = SessionManager.inMemory(); + session.appendMessage(userMsg("hello")); + + expect(() => session.branch("nonexistent")).toThrow("Entry nonexistent not found"); + }); + + it("new appends become children of branch point", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("1")); + const _id2 = session.appendMessage(assistantMsg("2")); + + session.branch(id1); + const id3 = session.appendMessage(userMsg("branched")); + + const entries = session.getEntries(); + const branchedEntry = entries.find((e) => e.id === id3)!; + expect(branchedEntry.parentId).toBe(id1); // sibling of id2 + }); + }); + + describe("branchWithSummary", () => { + it("inserts branch summary and advances leaf", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("1")); + const _id2 = session.appendMessage(assistantMsg("2")); + const _id3 = session.appendMessage(userMsg("3")); + + const summaryId = session.branchWithSummary(id1, "Summary of abandoned work"); + + expect(session.getLeafUuid()).toBe(summaryId); + + const entries = session.getEntries(); + const summaryEntry = entries.find((e) => e.type === "branch_summary"); + expect(summaryEntry).toBeDefined(); + expect(summaryEntry?.parentId).toBe(id1); + if (summaryEntry?.type === "branch_summary") { + expect(summaryEntry.summary).toBe("Summary of abandoned work"); + } + }); + + it("throws for non-existent entry", () => { + const session = SessionManager.inMemory(); + session.appendMessage(userMsg("hello")); + + expect(() => session.branchWithSummary("nonexistent", "summary")).toThrow("Entry nonexistent not found"); + }); + }); + + describe("getLeafEntry", () => { + it("returns undefined for empty session", () => { + const session = SessionManager.inMemory(); + expect(session.getLeafEntry()).toBeUndefined(); + }); + + it("returns current leaf entry", () => { + const session = SessionManager.inMemory(); + + session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + + const leaf = session.getLeafEntry(); + expect(leaf).toBeDefined(); + expect(leaf!.id).toBe(id2); + }); + }); + + describe("getEntry", () => { + it("returns undefined for non-existent id", () => { + const session = SessionManager.inMemory(); + expect(session.getEntry("nonexistent")).toBeUndefined(); + }); + + it("returns entry by id", () => { + const session = SessionManager.inMemory(); + + const id1 = session.appendMessage(userMsg("first")); + const id2 = session.appendMessage(assistantMsg("second")); + + const entry1 = session.getEntry(id1); + expect(entry1).toBeDefined(); + expect(entry1?.type).toBe("message"); + if (entry1?.type === "message" && entry1.message.role === "user") { + expect(entry1.message.content).toBe("first"); + } + + const entry2 = session.getEntry(id2); + expect(entry2).toBeDefined(); + if (entry2?.type === "message" && entry2.message.role === "assistant") { + expect((entry2.message.content as any)[0].text).toBe("second"); + } + }); + }); + + describe("buildSessionContext with branches", () => { + it("returns messages from current branch only", () => { + const session = SessionManager.inMemory(); + + // Main: 1 -> 2 -> 3 + session.appendMessage(userMsg("msg1")); + const id2 = session.appendMessage(assistantMsg("msg2")); + session.appendMessage(userMsg("msg3")); + + // Branch from 2: 2 -> 4 + session.branch(id2); + session.appendMessage(assistantMsg("msg4-branch")); + + const ctx = session.buildSessionContext(); + expect(ctx.messages).toHaveLength(3); // msg1, msg2, msg4-branch (not msg3) + + expect((ctx.messages[0] as any).content).toBe("msg1"); + expect((ctx.messages[1] as any).content[0].text).toBe("msg2"); + expect((ctx.messages[2] as any).content[0].text).toBe("msg4-branch"); + }); + }); +}); + +describe("createBranchedSession", () => { + it("throws for non-existent entry", () => { + const session = SessionManager.inMemory(); + session.appendMessage(userMsg("hello")); + + expect(() => session.createBranchedSession("nonexistent")).toThrow("Entry nonexistent not found"); + }); + + it("creates new session with path to specified leaf (in-memory)", () => { + const session = SessionManager.inMemory(); + + // Build: 1 -> 2 -> 3 -> 4 + const id1 = session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + const id3 = session.appendMessage(userMsg("3")); + session.appendMessage(assistantMsg("4")); + + // Branch from 3: 3 -> 5 + session.branch(id3); + const _id5 = session.appendMessage(userMsg("5")); + + // Create branched session from id2 (should only have 1 -> 2) + const result = session.createBranchedSession(id2); + expect(result).toBeNull(); // in-memory returns null + + // Session should now only have entries 1 and 2 + const entries = session.getEntries(); + expect(entries).toHaveLength(2); + expect(entries[0].id).toBe(id1); + expect(entries[1].id).toBe(id2); + }); + + it("extracts correct path from branched tree", () => { + const session = SessionManager.inMemory(); + + // Build: 1 -> 2 -> 3 + const id1 = session.appendMessage(userMsg("1")); + const id2 = session.appendMessage(assistantMsg("2")); + session.appendMessage(userMsg("3")); + + // Branch from 2: 2 -> 4 -> 5 + session.branch(id2); + const id4 = session.appendMessage(userMsg("4")); + const id5 = session.appendMessage(assistantMsg("5")); + + // Create branched session from id5 (should have 1 -> 2 -> 4 -> 5) + session.createBranchedSession(id5); + + const entries = session.getEntries(); + expect(entries).toHaveLength(4); + expect(entries.map((e) => e.id)).toEqual([id1, id2, id4, id5]); + }); +}); diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index b5cb6333..3e13e8b6 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -441,7 +441,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi }); // Load existing messages - const loadedSession = sessionManager.loadSession(); + const loadedSession = sessionManager.buildSessionContex(); if (loadedSession.messages.length > 0) { agent.replaceMessages(loadedSession.messages); log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`); @@ -628,7 +628,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi // Reload messages from context.jsonl // This picks up any messages synced from log.jsonl before this run - const reloadedSession = sessionManager.loadSession(); + const reloadedSession = sessionManager.buildSessionContex(); if (reloadedSession.messages.length > 0) { agent.replaceMessages(reloadedSession.messages); log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`); diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index a5a8024c..36ae4c1d 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -15,15 +15,12 @@ import { buildSessionContext, type CompactionEntry, type FileEntry, - type LoadedSession, - type MessageContent, - type ModelChangeContent, type ModelChangeEntry, + type SessionContext, type SessionEntry, + type SessionEntryBase, type SessionMessageEntry, type ThinkingLevelChangeEntry, - type ThinkingLevelContent, - type TreeNode, } from "@mariozechner/pi-coding-agent"; import { randomBytes } from "crypto"; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; @@ -98,15 +95,15 @@ export class MomSessionManager { this.leafId = null; } - private _createTreeNode(): Omit { + private _createEntryBase(): Omit { const id = uuidv4(); - const node = { + const base = { id, parentId: this.leafId, timestamp: new Date().toISOString(), }; this.leafId = id; - return node; + return base; } private _persist(entry: SessionEntry): void { @@ -281,22 +278,23 @@ export class MomSessionManager { } saveMessage(message: AppMessage): void { - const content: MessageContent = { type: "message", message }; - const entry: SessionMessageEntry = { ...this._createTreeNode(), ...content }; + const entry: SessionMessageEntry = { ...this._createEntryBase(), type: "message", message }; this.inMemoryEntries.push(entry); this._persist(entry); } saveThinkingLevelChange(thinkingLevel: string): void { - const content: ThinkingLevelContent = { type: "thinking_level_change", thinkingLevel }; - const entry: ThinkingLevelChangeEntry = { ...this._createTreeNode(), ...content }; + const entry: ThinkingLevelChangeEntry = { + ...this._createEntryBase(), + type: "thinking_level_change", + thinkingLevel, + }; this.inMemoryEntries.push(entry); this._persist(entry); } saveModelChange(provider: string, modelId: string): void { - const content: ModelChangeContent = { type: "model_change", provider, modelId }; - const entry: ModelChangeEntry = { ...this._createTreeNode(), ...content }; + const entry: ModelChangeEntry = { ...this._createEntryBase(), type: "model_change", provider, modelId }; this.inMemoryEntries.push(entry); this._persist(entry); } @@ -307,7 +305,7 @@ export class MomSessionManager { } /** Load session with compaction support */ - loadSession(): LoadedSession { + buildSessionContex(): SessionContext { const entries = this.loadEntries(); return buildSessionContext(entries); } @@ -354,15 +352,15 @@ export class MomSessionManager { } loadModel(): { provider: string; modelId: string } | null { - return this.loadSession().model; + return this.buildSessionContex().model; } loadThinkingLevel(): string { - return this.loadSession().thinkingLevel; + return this.buildSessionContex().thinkingLevel; } /** Not used by mom but required by AgentSession interface */ - createBranchedSessionFromEntries(_entries: SessionEntry[], _branchBeforeIndex: number): string | null { + createBranchedSession(_leafId: string): string | null { return null; // Mom doesn't support branching } } From 898607f742460ed289d061404a1b8f4c7c4bf349 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 02:49:01 +0100 Subject: [PATCH 008/161] Update session-tree-plan.md with remaining work --- .../coding-agent/docs/session-tree-plan.md | 132 ++++++++++++------ 1 file changed, 86 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 5f2371df..6e45dd85 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -2,57 +2,40 @@ Reference: [session-tree.md](./session-tree.md) -## Phase 1: SessionManager Core +## Phase 1: SessionManager Core ✅ -- [x] Update entry types with `id`, `parentId` fields (using TreeNode intersection) +- [x] Update entry types with `id`, `parentId` fields (using SessionEntryBase) - [x] Add `version` field to `SessionHeader` - [x] Change `CompactionEntry.firstKeptEntryIndex` → `firstKeptEntryId` - [x] Add `BranchSummaryEntry` type -- [x] Add `byId: Map` index +- [x] Add `CustomEntry` type for hooks +- [x] Add `byId: Map` index - [x] Add `leafId: string` tracking - [x] Implement `getPath(fromId?)` tree traversal +- [x] Implement `getTree()` returning `SessionTreeNode[]` - [x] Implement `getEntry(id)` lookup -- [x] Implement `getLeafId()` helper +- [x] Implement `getLeafUuid()` and `getLeafEntry()` helpers - [x] Update `_buildIndex()` to populate `byId` map -- [x] Update `saveMessage()` to include id/parentId (returns id) -- [x] Update `saveCompaction()` signature and fields (returns id) -- [x] Update `saveThinkingLevelChange()` to include id/parentId (returns id) -- [x] Update `saveModelChange()` to include id/parentId (returns id) +- [x] Rename `saveXXX()` to `appendXXX()` (returns id, advances leaf) +- [x] Add `appendCustomEntry(customType, data)` for hooks - [x] Update `buildSessionContext()` to use `getPath()` traversal -### Type Hierarchy - -```typescript -// Tree fields (added by SessionManager) -interface TreeNode { id, parentId, timestamp } - -// Content types (for input) -interface MessageContent { type: "message"; message: AppMessage } -interface CompactionContent { type: "compaction"; summary; firstKeptEntryId; tokensBefore } -// etc... - -// Full entry types (TreeNode & Content) -type SessionMessageEntry = TreeNode & MessageContent; -type CompactionEntry = TreeNode & CompactionContent; -// etc... -``` - -## Phase 2: Migration +## Phase 2: Migration ✅ - [x] Add `CURRENT_SESSION_VERSION = 2` constant -- [x] Implement `_migrateToV2()` for v1→v2 +- [x] Implement `migrateV1ToV2()` with extensible migration chain - [x] Update `setSessionFile()` to detect version and migrate - [x] Implement `_rewriteFile()` for post-migration persistence - [x] Handle `firstKeptEntryIndex` → `firstKeptEntryId` conversion in migration -## Phase 3: Branching +## Phase 3: Branching ✅ -- [x] Implement `branchInPlace(id)` - switch leaf pointer +- [x] Implement `branch(id)` - switch leaf pointer - [x] Implement `branchWithSummary(id, summary)` - create summary entry -- [x] Update `branchToNewFile()` to use IDs (no remapping) -- [ ] Update `AgentSession.branch()` to use new API +- [x] Implement `createBranchedSession(leafId)` - extract path to new file +- [x] Update `AgentSession.branch()` to use new API -## Phase 4: Compaction Integration +## Phase 4: Compaction Integration ✅ - [x] Update `compaction.ts` to work with IDs - [x] Update `prepareCompaction()` to return `firstKeptEntryId` @@ -60,27 +43,84 @@ type CompactionEntry = TreeNode & CompactionContent; - [x] Update `AgentSession` compaction methods - [x] Add `firstKeptEntryId` to `before_compact` hook event -## Phase 5: Testing +## Phase 5: Testing ✅ -- [ ] Add test fixtures from existing sessions -- [ ] Test migration of v1 sessions -- [ ] Test context building with tree structure -- [ ] Test branching operations -- [ ] Test compaction with IDs -- [x] Update existing tests for new types +- [x] `migration.test.ts` - v1 to v2 migration, idempotency +- [x] `build-context.test.ts` - context building with tree structure, compaction, branches +- [x] `tree-traversal.test.ts` - append operations, getPath, getTree, branching +- [x] `file-operations.test.ts` - loadEntriesFromFile, findMostRecentSession +- [x] `save-entry.test.ts` - custom entry integration +- [x] Update existing compaction tests for new types -## Phase 6: UI Integration +--- -- [ ] Update `/branch` command for new API -- [ ] Add `/branch-here` command for in-place branching -- [ ] Add `/branches` command to list branches (future) -- [ ] Update session display to show tree info (future) +## Remaining Work + +### Compaction Refactor + +- [ ] Clean up types passed to hooks (currently messy mix of `CompactionEntry`, `CompactionResult`, hook's `compaction` content) +- [ ] Ensure consistent API between what hooks receive and what they return + +### Branch Summary Design + +Current type: +```typescript +export interface BranchSummaryEntry extends SessionEntryBase { + type: "branch_summary"; + summary: string; +} +``` + +Questions to resolve: +- [ ] Add `abandonedLeafId` field to reference what was abandoned? +- [ ] Store metadata about why the branch happened? +- [ ] Who generates the summary - user, LLM, or both options? +- [ ] Design and implement branch summarizer +- [ ] Add tests for `branchWithSummary()` flow + +### Entry Labels + +- [ ] Add optional `label?: string` field to `SessionEntryBase` +- [ ] Allow users to label any entry +- [ ] Display labels in UI (tree view, path view) + +### HTML Export + +- [ ] Add collapsible sidebar showing full tree structure +- [ ] Allow selecting any node in tree to view that path +- [ ] Add "reset to session leaf" button +- [ ] Render full path (no compaction resolution needed) +- [ ] Responsive: collapse sidebar on mobile + +### UI Commands + +Design new commands based on refactored SessionManager: + +**`/branch`** - Current behavior (creates new session file from path) +- [ ] Review if this is still the right UX with tree structure +- [ ] Consider: should this use `createBranchedSession()` or `branch()`? + +**`/branch-here`** - In-place branching (new) +- [ ] Use `branch(id)` to move leaf pointer without creating new file +- [ ] Subsequent messages become new branch in same file +- [ ] Design: how to select branch point? (similar to current `/branch` UI?) + +**`/branches`** - List/navigate branches (new) +- [ ] Show tree structure or list of branch points +- [ ] Allow switching between branches (move leaf pointer) +- [ ] Show current position in tree + +**`/label`** - Label entries (new, if labels implemented) +- [ ] Allow labeling current or selected entry +- [ ] Display in tree view + +--- ## Notes -- All save methods return the new entry's ID +- All append methods return the new entry's ID - Migration rewrites file on first load if version < CURRENT_VERSION - Existing sessions become linear chains after migration (parentId = previous entry) - Tree features available immediately after migration - SessionHeader does NOT have id/parentId (it's metadata, not part of tree) -- Content types allow clean input/output separation +- Session is append-only: entries cannot be modified or deleted, only branching changes the leaf pointer From 6af547afd90c896636be10de36754694d127c06f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 17:21:24 +0100 Subject: [PATCH 009/161] Version updates --- package-lock.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/package-lock.json b/package-lock.json index 7aa30116..ec3c84d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "packages/web-ui/example" ], "dependencies": { + "@mariozechner/pi-coding-agent": "^0.30.2", "get-east-asian-width": "^1.4.0" }, "devDependencies": { diff --git a/package.json b/package.json index e608b9f2..a431f9ed 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "version": "0.0.3", "dependencies": { + "@mariozechner/pi-coding-agent": "^0.30.2", "get-east-asian-width": "^1.4.0" } } From 9e68a59fed7417c5ecaf9e7488cfca61bf89eb95 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 21:30:24 +0100 Subject: [PATCH 010/161] Add label support for session entries - Add LabelEntry type with targetId and label (string | undefined) - Add labelsById map built on load via linear scan - Add getLabel(id) and appendLabelChange(targetId, label) methods - Add label field to SessionTreeNode, populated by getTree() - Update createBranchedSession to preserve labels for entries on path - Labels are ignored by buildSessionContext (not sent to LLM) - Add comprehensive tests for label functionality --- .../coding-agent/docs/session-tree-plan.md | 19 +- .../coding-agent/src/core/session-manager.ts | 107 ++++++++++- .../test/session-manager/labels.test.ts | 178 ++++++++++++++++++ 3 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 packages/coding-agent/test/session-manager/labels.test.ts diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 6e45dd85..c862162b 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -78,11 +78,18 @@ Questions to resolve: - [ ] Design and implement branch summarizer - [ ] Add tests for `branchWithSummary()` flow -### Entry Labels +### Entry Labels ✅ -- [ ] Add optional `label?: string` field to `SessionEntryBase` -- [ ] Allow users to label any entry -- [ ] Display labels in UI (tree view, path view) +- [x] Add `LabelEntry` type with `targetId` and `label` fields +- [x] Add `labelsById: Map` private field +- [x] Build labels map in `_buildIndex()` via linear scan +- [x] Add `getLabel(id)` method +- [x] Add `appendLabelChange(targetId, label)` method (undefined clears) +- [x] Update `createBranchedSession()` to filter out LabelEntry and recreate from resolved map +- [x] `buildSessionContext()` already ignores LabelEntry (only handles message types) +- [x] Add `label?: string` to `SessionTreeNode`, populated by `getTree()` +- [ ] Display labels in UI (tree view, path view) - deferred to UI phase +- [ ] `/label` command - deferred to UI phase ### HTML Export @@ -110,10 +117,6 @@ Design new commands based on refactored SessionManager: - [ ] Allow switching between branches (move leaf pointer) - [ ] Show current position in tree -**`/label`** - Label entries (new, if labels implemented) -- [ ] Allow labeling current or selected entry -- [ ] Display in tree view - --- ## Notes diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 148eec05..9f724372 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -68,6 +68,13 @@ export interface CustomEntry extends SessionEntryBase { data?: unknown; } +/** Label entry for user-defined bookmarks/markers on entries. */ +export interface LabelEntry extends SessionEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ export type SessionEntry = | SessionMessageEntry @@ -75,7 +82,8 @@ export type SessionEntry = | ModelChangeEntry | CompactionEntry | BranchSummaryEntry - | CustomEntry; + | CustomEntry + | LabelEntry; /** Raw file entry (includes header) */ export type FileEntry = SessionHeader | SessionEntry; @@ -84,6 +92,8 @@ export type FileEntry = SessionHeader | SessionEntry; export interface SessionTreeNode { entry: SessionEntry; children: SessionTreeNode[]; + /** Resolved label for this entry, if any */ + label?: string; } export interface SessionContext { @@ -407,6 +417,7 @@ export class SessionManager { private flushed: boolean = false; private fileEntries: FileEntry[] = []; private byId: Map = new Map(); + private labelsById: Map = new Map(); private leafId: string = ""; private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) { @@ -466,11 +477,19 @@ export class SessionManager { private _buildIndex(): void { this.byId.clear(); + this.labelsById.clear(); this.leafId = ""; for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); this.leafId = entry.id; + if (entry.type === "label") { + if (entry.label) { + this.labelsById.set(entry.targetId, entry.label); + } else { + this.labelsById.delete(entry.targetId); + } + } } } @@ -608,6 +627,39 @@ export class SessionManager { return this.byId.get(id); } + /** + * Get the label for an entry, if any. + */ + getLabel(id: string): string | undefined { + return this.labelsById.get(id); + } + + /** + * Set or clear a label on an entry. + * Labels are user-defined markers for bookmarking/navigation. + * Pass undefined or empty string to clear the label. + */ + appendLabelChange(targetId: string, label: string | undefined): string { + if (!this.byId.has(targetId)) { + throw new Error(`Entry ${targetId} not found`); + } + const entry: LabelEntry = { + type: "label", + id: generateId(this.byId), + parentId: this.leafId || null, + timestamp: new Date().toISOString(), + targetId, + label, + }; + this._appendEntry(entry); + if (label) { + this.labelsById.set(targetId, label); + } else { + this.labelsById.delete(targetId); + } + return entry.id; + } + /** * Walk from entry to root, returning all entries in path order. * Includes all entry types (messages, compaction, model changes, etc.). @@ -658,9 +710,10 @@ export class SessionManager { const nodeMap = new Map(); const roots: SessionTreeNode[] = []; - // Create nodes + // Create nodes with resolved labels for (const entry of entries) { - nodeMap.set(entry.id, { entry, children: [] }); + const label = this.labelsById.get(entry.id); + nodeMap.set(entry.id, { entry, children: [], label }); } // Build tree @@ -731,6 +784,9 @@ export class SessionManager { throw new Error(`Entry ${leafId} not found`); } + // Filter out LabelEntry from path - we'll recreate them from the resolved map + const pathWithoutLabels = path.filter((e) => e.type !== "label"); + const newSessionId = randomUUID(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); @@ -745,16 +801,55 @@ export class SessionManager { branchedFrom: this.persist ? this.sessionFile : undefined, }; + // Collect labels for entries in the path + const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id)); + const labelsToWrite: Array<{ targetId: string; label: string }> = []; + for (const [targetId, label] of this.labelsById) { + if (pathEntryIds.has(targetId)) { + labelsToWrite.push({ targetId, label }); + } + } + if (this.persist) { appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`); - for (const entry of path) { + for (const entry of pathWithoutLabels) { appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); } + // Write fresh label entries at the end + const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; + let parentId = lastEntryId; + for (const { targetId, label } of labelsToWrite) { + const labelEntry: LabelEntry = { + type: "label", + id: generateId(new Set(pathEntryIds)), + parentId, + timestamp: new Date().toISOString(), + targetId, + label, + }; + appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`); + pathEntryIds.add(labelEntry.id); + parentId = labelEntry.id; + } return newSessionFile; } - // In-memory mode: replace current session with the path - this.fileEntries = [header, ...path]; + // In-memory mode: replace current session with the path + labels + const labelEntries: LabelEntry[] = []; + let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; + for (const { targetId, label } of labelsToWrite) { + const labelEntry: LabelEntry = { + type: "label", + id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])), + parentId, + timestamp: new Date().toISOString(), + targetId, + label, + }; + labelEntries.push(labelEntry); + parentId = labelEntry.id; + } + this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; this._buildIndex(); return null; diff --git a/packages/coding-agent/test/session-manager/labels.test.ts b/packages/coding-agent/test/session-manager/labels.test.ts new file mode 100644 index 00000000..e349aa10 --- /dev/null +++ b/packages/coding-agent/test/session-manager/labels.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { type LabelEntry, SessionManager } from "../../src/core/session-manager.js"; + +describe("SessionManager labels", () => { + it("sets and gets labels", () => { + const session = SessionManager.inMemory(); + + const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + + // No label initially + expect(session.getLabel(msgId)).toBeUndefined(); + + // Set a label + const labelId = session.appendLabelChange(msgId, "checkpoint"); + expect(session.getLabel(msgId)).toBe("checkpoint"); + + // Label entry should be in entries + const entries = session.getEntries(); + const labelEntry = entries.find((e) => e.type === "label") as LabelEntry; + expect(labelEntry).toBeDefined(); + expect(labelEntry.id).toBe(labelId); + expect(labelEntry.targetId).toBe(msgId); + expect(labelEntry.label).toBe("checkpoint"); + }); + + it("clears labels with undefined", () => { + const session = SessionManager.inMemory(); + + const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + + session.appendLabelChange(msgId, "checkpoint"); + expect(session.getLabel(msgId)).toBe("checkpoint"); + + // Clear the label + session.appendLabelChange(msgId, undefined); + expect(session.getLabel(msgId)).toBeUndefined(); + }); + + it("last label wins", () => { + const session = SessionManager.inMemory(); + + const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + + session.appendLabelChange(msgId, "first"); + session.appendLabelChange(msgId, "second"); + session.appendLabelChange(msgId, "third"); + + expect(session.getLabel(msgId)).toBe("third"); + }); + + it("labels are included in tree nodes", () => { + const session = SessionManager.inMemory(); + + const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + const msg2Id = session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "hi" }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }); + + session.appendLabelChange(msg1Id, "start"); + session.appendLabelChange(msg2Id, "response"); + + const tree = session.getTree(); + + // Find the message nodes (skip label entries) + const msg1Node = tree.find((n) => n.entry.id === msg1Id); + expect(msg1Node?.label).toBe("start"); + + // msg2 is a child of msg1 + const msg2Node = msg1Node?.children.find((n) => n.entry.id === msg2Id); + expect(msg2Node?.label).toBe("response"); + }); + + it("labels are preserved in createBranchedSession", () => { + const session = SessionManager.inMemory(); + + const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + const msg2Id = session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "hi" }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }); + + session.appendLabelChange(msg1Id, "important"); + session.appendLabelChange(msg2Id, "also-important"); + + // Branch from msg2 (in-memory mode returns null, but updates internal state) + session.createBranchedSession(msg2Id); + + // Labels should be preserved + expect(session.getLabel(msg1Id)).toBe("important"); + expect(session.getLabel(msg2Id)).toBe("also-important"); + + // New label entries should exist + const entries = session.getEntries(); + const labelEntries = entries.filter((e) => e.type === "label") as LabelEntry[]; + expect(labelEntries).toHaveLength(2); + }); + + it("labels not on path are not preserved in createBranchedSession", () => { + const session = SessionManager.inMemory(); + + const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + const msg2Id = session.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "hi" }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }); + const msg3Id = session.appendMessage({ role: "user", content: "followup", timestamp: 3 }); + + // Label all messages + session.appendLabelChange(msg1Id, "first"); + session.appendLabelChange(msg2Id, "second"); + session.appendLabelChange(msg3Id, "third"); + + // Branch from msg2 (excludes msg3) + session.createBranchedSession(msg2Id); + + // Only labels for msg1 and msg2 should be preserved + expect(session.getLabel(msg1Id)).toBe("first"); + expect(session.getLabel(msg2Id)).toBe("second"); + expect(session.getLabel(msg3Id)).toBeUndefined(); + }); + + it("labels are not included in buildSessionContext", () => { + const session = SessionManager.inMemory(); + + const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + session.appendLabelChange(msgId, "checkpoint"); + + const ctx = session.buildSessionContext(); + expect(ctx.messages).toHaveLength(1); + expect(ctx.messages[0].role).toBe("user"); + }); + + it("throws when labeling non-existent entry", () => { + const session = SessionManager.inMemory(); + + expect(() => session.appendLabelChange("non-existent", "label")).toThrow("Entry non-existent not found"); + }); +}); From e8419423771e71a36fcea3ecab73c23e6343cd3f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 21:34:48 +0100 Subject: [PATCH 011/161] Use CompactionResult type for hook compaction return value - Import CompactionResult in hooks/types.ts - Replace inline type with CompactionResult for SessionEventResult.compaction - Add labels feature to changelog --- packages/coding-agent/CHANGELOG.md | 3 ++- .../coding-agent/docs/session-tree-plan.md | 24 +++++++++++++++++++ packages/coding-agent/src/core/hooks/types.ts | 8 ++----- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 987e4d5f..8441876d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -30,7 +30,8 @@ - **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs - **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY` -- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook data +- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook data, `LabelEntry` for user-defined bookmarks +- **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export. ### Fixed diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index c862162b..fbc60e81 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -91,6 +91,30 @@ Questions to resolve: - [ ] Display labels in UI (tree view, path view) - deferred to UI phase - [ ] `/label` command - deferred to UI phase +### CustomMessageEntry + +Hooks can define their own custom message entry types and inject them into the session. + +```typescript +export interface CustomMessageEntry extends SessionEntryBase { + type: "custom_message"; + customType: string; // Hook identifier + content: (string | Attachment)[]; // Message content + details?: T; // Hook-specific data (like tool result details) + display: boolean; // Whether to display in TUI +} +``` + +Behavior: +- [ ] Participates in context and compaction as user messages (after messageTransformer) +- [ ] Not displayed as user messages in TUI +- [ ] Display options: + - `display: false` - hidden entirely + - `display: true` - baseline renderer (content with different bg/fg color) + - Custom renderer defined by the hook that contributes it +- [ ] Define injection mechanism for hooks to add CustomMessageEntry +- [ ] Hook registration for custom renderers + ### HTML Export - [ ] Add collapsible sidebar showing full tree structure diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index a51a93bf..82033e21 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -7,7 +7,7 @@ import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { CutPointResult } from "../compaction.js"; +import type { CompactionResult, CutPointResult } from "../compaction.js"; import type { CompactionEntry, SessionEntry } from "../session-manager.js"; import type { BashToolDetails, @@ -354,11 +354,7 @@ export interface SessionEventResult { /** If true (for before_branch only), skip restoring conversation to branch point while still creating the branched session file */ skipConversationRestore?: boolean; /** Custom compaction result (for before_compact event) - SessionManager adds id/parentId */ - compaction?: { - summary: string; - firstKeptEntryId: string; - tokensBefore: number; - }; + compaction?: CompactionResult; } // ============================================================================ From efb1036d8e320d471c0d833ea72962a795157136 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 21:40:09 +0100 Subject: [PATCH 012/161] Improve CustomEntry docs and make it generic - Add detailed doc comment explaining purpose (hook state persistence) - Make CustomEntry generic - Clarify difference from CustomMessageEntry in plan - Update changelog --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/docs/session-tree-plan.md | 6 ++++-- packages/coding-agent/src/core/session-manager.ts | 15 ++++++++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8441876d..d31056bd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -30,7 +30,7 @@ - **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs - **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY` -- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook data, `LabelEntry` for user-defined bookmarks +- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook state persistence, `LabelEntry` for user-defined bookmarks - **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export. ### Fixed diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index fbc60e81..1f058d5d 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -93,14 +93,14 @@ Questions to resolve: ### CustomMessageEntry -Hooks can define their own custom message entry types and inject them into the session. +Hook-injected messages that participate in LLM context. Unlike `CustomEntry` (for hook state only), these are sent to the model. ```typescript export interface CustomMessageEntry extends SessionEntryBase { type: "custom_message"; customType: string; // Hook identifier content: (string | Attachment)[]; // Message content - details?: T; // Hook-specific data (like tool result details) + details?: T; // Hook-specific data for state reconstruction on reload display: boolean; // Whether to display in TUI } ``` @@ -115,6 +115,8 @@ Behavior: - [ ] Define injection mechanism for hooks to add CustomMessageEntry - [ ] Hook registration for custom renderers +See also: `CustomEntry` for storing hook state that does NOT participate in context. + ### HTML Export - [ ] Add collapsible sidebar showing full tree structure diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 9f724372..e59a2934 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -61,11 +61,20 @@ export interface BranchSummaryEntry extends SessionEntryBase { summary: string; } -/** Custom entry for hooks. Use customType to identify your hook's entries. */ -export interface CustomEntry extends SessionEntryBase { +/** + * Custom entry for hooks to store hook-specific data in the session. + * Use customType to identify your hook's entries. + * + * Purpose: Persist hook state across session reloads. On reload, hooks can + * scan entries for their customType and reconstruct internal state. + * + * Does NOT participate in LLM context (ignored by buildSessionContext). + * For injecting content into context, see CustomMessageEntry. + */ +export interface CustomEntry extends SessionEntryBase { type: "custom"; customType: string; - data?: unknown; + data?: T; } /** Label entry for user-defined bookmarks/markers on entries. */ From d96375b5e508cdba9f51548c1f334d2837de9cbb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 22:05:03 +0100 Subject: [PATCH 013/161] Make CompactionEntry and CompactionResult generic with details field - CompactionEntry and CompactionResult now have optional details?: T - appendCompaction() accepts optional details parameter - Hooks can return compaction.details to store custom data - Enables structured compaction with ArtifactIndex (see #314) - Fix CompactionResult export location (now from compaction.ts) - Update plan with remaining compaction refactor items --- packages/coding-agent/CHANGELOG.md | 5 ++- .../coding-agent/docs/session-tree-plan.md | 14 ++++++-- .../coding-agent/src/core/agent-session.ts | 36 ++++++++++++------- packages/coding-agent/src/core/compaction.ts | 4 ++- packages/coding-agent/src/core/index.ts | 2 +- .../coding-agent/src/core/session-manager.ts | 9 +++-- packages/coding-agent/src/index.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-client.ts | 3 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 3 +- 9 files changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d31056bd..84e5735d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,12 +15,15 @@ - New methods: `getTree()`, `getPath()`, `getLeafUuid()`, `getLeafEntry()`, `getEntry()`, `branchWithSummary()` - New `appendCustomEntry(customType, data)` for hooks to store custom data - **Compaction API**: - - `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore }`) instead of `CompactionEntry` + - `CompactionEntry` and `CompactionResult` are now generic with optional `details?: T` for hook-specific data + - `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry` + - `appendCompaction()` now accepts optional `details` parameter - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` - `prepareCompaction()` now returns `firstKeptEntryId` in its result - **Hook types**: - `SessionEventResult.compactionEntry` replaced with `SessionEventResult.compaction` (content only, SessionManager adds id/parentId) - `before_compact` event now includes `firstKeptEntryId` field for hooks that return custom compaction + - Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction) ### Added diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 1f058d5d..3c9bea18 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -58,8 +58,18 @@ Reference: [session-tree.md](./session-tree.md) ### Compaction Refactor -- [ ] Clean up types passed to hooks (currently messy mix of `CompactionEntry`, `CompactionResult`, hook's `compaction` content) -- [ ] Ensure consistent API between what hooks receive and what they return +- [x] Use `CompactionResult` type for hook return value +- [ ] Make `CompactionEntry` generic with optional `details?: T` field for hook-specific data +- [ ] Make `CompactionResult` generic to match +- [ ] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields +- [ ] Update `before_compact` event: + - Pass `preparation: CompactionPreparation` instead of individual fields + - Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string` + - Keep: `customInstructions`, `model`, `signal` + - Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries` +- [ ] Update hook example `custom-compaction.ts` to use new API + +Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers. ### Branch Summary Design diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1547ba61..a1bac423 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -18,7 +18,13 @@ import type { AssistantMessage, Message, Model, TextContent } from "@mariozechne import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; -import { calculateContextTokens, compact, prepareCompaction, shouldCompact } from "./compaction.js"; +import { + type CompactionResult, + calculateContextTokens, + compact, + prepareCompaction, + shouldCompact, +} from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; import type { HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; @@ -76,12 +82,6 @@ export interface ModelCycleResult { isScoped: boolean; } -/** Result from compact() or checkAutoCompaction() */ -export interface CompactionResult { - tokensBefore: number; - summary: string; -} - /** Session statistics for /session command */ export interface SessionStats { sessionFile: string | null; @@ -771,7 +771,7 @@ export class AgentSession { } } - let hookCompaction: { summary: string; firstKeptEntryId: string; tokensBefore: number } | undefined; + let hookCompaction: CompactionResult | undefined; let fromHook = false; if (this._hookRunner?.hasHandlers("session")) { @@ -806,12 +806,14 @@ export class AgentSession { let summary: string; let firstKeptEntryId: string; let tokensBefore: number; + let details: unknown; if (hookCompaction) { // Hook provided compaction content summary = hookCompaction.summary; firstKeptEntryId = hookCompaction.firstKeptEntryId; tokensBefore = hookCompaction.tokensBefore; + details = hookCompaction.details; } else { // Generate compaction result const result = await compact( @@ -825,13 +827,14 @@ export class AgentSession { summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; tokensBefore = result.tokensBefore; + details = result.details; } if (this._compactionAbortController.signal.aborted) { throw new Error("Compaction cancelled"); } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); @@ -855,8 +858,10 @@ export class AgentSession { } return { - tokensBefore, summary, + firstKeptEntryId, + tokensBefore, + details, }; } finally { this._compactionAbortController = null; @@ -952,7 +957,7 @@ export class AgentSession { } } - let hookCompaction: { summary: string; firstKeptEntryId: string; tokensBefore: number } | undefined; + let hookCompaction: CompactionResult | undefined; let fromHook = false; if (this._hookRunner?.hasHandlers("session")) { @@ -988,12 +993,14 @@ export class AgentSession { let summary: string; let firstKeptEntryId: string; let tokensBefore: number; + let details: unknown; if (hookCompaction) { // Hook provided compaction content summary = hookCompaction.summary; firstKeptEntryId = hookCompaction.firstKeptEntryId; tokensBefore = hookCompaction.tokensBefore; + details = hookCompaction.details; } else { // Generate compaction result const compactResult = await compact( @@ -1006,6 +1013,7 @@ export class AgentSession { summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; tokensBefore = compactResult.tokensBefore; + details = compactResult.details; } if (this._autoCompactionAbortController.signal.aborted) { @@ -1013,7 +1021,7 @@ export class AgentSession { return; } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); @@ -1037,8 +1045,10 @@ export class AgentSession { } const result: CompactionResult = { - tokensBefore, summary, + firstKeptEntryId, + tokensBefore, + details, }; this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry }); diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index ea1e44a3..b0156ec5 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -12,10 +12,12 @@ import { messageTransformer } from "./messages.js"; import type { CompactionEntry, SessionEntry } from "./session-manager.js"; /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ -export interface CompactionResult { +export interface CompactionResult { summary: string; firstKeptEntryId: string; tokensBefore: number; + /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + details?: T; } // ============================================================================ diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 117d96b6..4b75e1cd 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -7,12 +7,12 @@ export { type AgentSessionConfig, type AgentSessionEvent, type AgentSessionEventListener, - type CompactionResult, type ModelCycleResult, type PromptOptions, type SessionStats, } from "./agent-session.js"; export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js"; +export type { CompactionResult } from "./compaction.js"; export { type CustomAgentTool, type CustomToolFactory, diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e59a2934..0b5934b1 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -49,11 +49,13 @@ export interface ModelChangeEntry extends SessionEntryBase { modelId: string; } -export interface CompactionEntry extends SessionEntryBase { +export interface CompactionEntry extends SessionEntryBase { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; + /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + details?: T; } export interface BranchSummaryEntry extends SessionEntryBase { @@ -592,8 +594,8 @@ export class SessionManager { } /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ - appendCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number): string { - const entry: CompactionEntry = { + appendCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number, details?: T): string { + const entry: CompactionEntry = { type: "compaction", id: generateId(this.byId), parentId: this.leafId || null, @@ -601,6 +603,7 @@ export class SessionManager { summary, firstKeptEntryId, tokensBefore, + details, }; this._appendEntry(entry); return entry.id; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 6ddaedb1..acfc766a 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -4,7 +4,6 @@ export { type AgentSessionConfig, type AgentSessionEvent, type AgentSessionEventListener, - type CompactionResult, type ModelCycleResult, type PromptOptions, type SessionStats, @@ -13,6 +12,7 @@ export { export { type ApiKeyCredential, type AuthCredential, AuthStorage, type OAuthCredential } from "./core/auth-storage.js"; // Compaction export { + type CompactionResult, type CutPointResult, calculateContextTokens, compact, diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 4b79b946..53a242e5 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -7,8 +7,9 @@ import { type ChildProcess, spawn } from "node:child_process"; import * as readline from "node:readline"; import type { AgentEvent, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { CompactionResult, SessionStats } from "../../core/agent-session.js"; +import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; +import type { CompactionResult } from "../../core/compaction.js"; import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js"; // ============================================================================ diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index ab4f0b61..79f9e12a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -7,8 +7,9 @@ import type { AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; -import type { CompactionResult, SessionStats } from "../../core/agent-session.js"; +import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; +import type { CompactionResult } from "../../core/compaction.js"; // ============================================================================ // RPC Commands (stdin) From 9bba388ec5cccb149afb1fcf266de6452da14861 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 22:22:43 +0100 Subject: [PATCH 014/161] Refactor SessionEventBase to pass sessionManager and modelRegistry Breaking changes to hook types: - SessionEventBase now passes sessionManager and modelRegistry directly - before_compact: passes preparation, previousCompactions (newest first) - before_switch: has targetSessionFile; switch: has previousSessionFile - Removed resolveApiKey (use modelRegistry.getApiKey()) - getSessionFile() returns string | undefined for in-memory sessions Updated: - All session event emissions in agent-session.ts - Hook examples (custom-compaction.ts, auto-commit-on-exit.ts, confirm-destructive.ts) - Tests (compaction-hooks.test.ts, compaction-hooks-example.test.ts) - export-html.ts guards for in-memory sessions --- packages/coding-agent/CHANGELOG.md | 8 +- .../coding-agent/docs/session-tree-plan.md | 12 ++- .../examples/hooks/auto-commit-on-exit.ts | 5 +- .../examples/hooks/confirm-destructive.ts | 6 +- .../examples/hooks/custom-compaction.ts | 20 ++-- .../coding-agent/src/core/agent-session.ts | 102 ++++++------------ packages/coding-agent/src/core/export-html.ts | 3 + packages/coding-agent/src/core/hooks/types.ts | 46 ++++---- .../coding-agent/src/core/session-manager.ts | 14 +-- .../src/modes/interactive/interactive-mode.ts | 11 +- packages/coding-agent/src/modes/print-mode.ts | 5 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 5 +- .../test/compaction-hooks-example.test.ts | 24 ++--- .../test/compaction-hooks.test.ts | 61 ++++++----- 14 files changed, 145 insertions(+), 177 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84e5735d..eaf139ae 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,9 +21,13 @@ - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` - `prepareCompaction()` now returns `firstKeptEntryId` in its result - **Hook types**: - - `SessionEventResult.compactionEntry` replaced with `SessionEventResult.compaction` (content only, SessionManager adds id/parentId) - - `before_compact` event now includes `firstKeptEntryId` field for hooks that return custom compaction + - `SessionEventBase` now passes `sessionManager` and `modelRegistry` instead of `entries`, `sessionFile`, `previousSessionFile` + - `before_compact` event passes `preparation: CompactionPreparation` and `previousCompactions: CompactionEntry[]` (newest first) + - `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile` + - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) - Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction) +- **SessionManager**: + - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) ### Added diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 3c9bea18..8dfa9bbb 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -59,15 +59,17 @@ Reference: [session-tree.md](./session-tree.md) ### Compaction Refactor - [x] Use `CompactionResult` type for hook return value -- [ ] Make `CompactionEntry` generic with optional `details?: T` field for hook-specific data -- [ ] Make `CompactionResult` generic to match -- [ ] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields -- [ ] Update `before_compact` event: +- [x] Make `CompactionEntry` generic with optional `details?: T` field for hook-specific data +- [x] Make `CompactionResult` generic to match +- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields +- [x] Update `before_compact` event: - Pass `preparation: CompactionPreparation` instead of individual fields - Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string` - Keep: `customInstructions`, `model`, `signal` - Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries` -- [ ] Update hook example `custom-compaction.ts` to use new API +- [x] Update hook example `custom-compaction.ts` to use new API +- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions +- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile` Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers. diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts index 8d167882..daef3a92 100644 --- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts +++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts @@ -20,9 +20,10 @@ export default function (pi: HookAPI) { } // Find the last assistant message for commit context + const entries = event.sessionManager.getEntries(); let lastAssistantText = ""; - for (let i = event.entries.length - 1; i >= 0; i--) { - const entry = event.entries[i]; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; if (entry.type === "message" && entry.message.role === "assistant") { const content = entry.message.content; if (Array.isArray(content)) { diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts index 61293868..72ecc9d3 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -5,6 +5,7 @@ * Demonstrates how to cancel session events using the before_* variants. */ +import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { @@ -28,7 +29,10 @@ export default function (pi: HookAPI) { if (!ctx.hasUI) return; // Check if there are unsaved changes (messages since last assistant response) - const hasUnsavedWork = event.entries.some((e) => e.type === "message" && e.message.role === "user"); + const entries = event.sessionManager.getEntries(); + const hasUnsavedWork = entries.some( + (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user", + ); if (hasUnsavedWork) { const confirmed = await ctx.ui.confirm( diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index 1eabaf57..efc2f59b 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -23,15 +23,11 @@ export default function (pi: HookAPI) { ctx.ui.notify("Custom compaction hook triggered", "info"); - const { - messagesToSummarize, - messagesToKeep, - previousSummary, - tokensBefore, - resolveApiKey, - entries: _, - signal, - } = event; + const { preparation, previousCompactions, modelRegistry, signal } = event; + const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId } = preparation; + + // Get previous summary from most recent compaction (if any) + const previousSummary = previousCompactions[0]?.summary; // Use Gemini Flash for summarization (cheaper/faster than most conversation models) const model = getModel("google", "gemini-2.5-flash"); @@ -41,7 +37,7 @@ export default function (pi: HookAPI) { } // Resolve API key for the summarization model - const apiKey = await resolveApiKey(model); + const apiKey = await modelRegistry.getApiKey(model); if (!apiKey) { ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning"); return; @@ -102,11 +98,11 @@ Format the summary as structured markdown with clear sections.`, } // Return compaction content - SessionManager adds id/parentId - // Use firstKeptEntryId from event to keep recent messages + // Use firstKeptEntryId from preparation to keep recent messages return { compaction: { summary, - firstKeptEntryId: event.firstKeptEntryId, + firstKeptEntryId, tokensBefore, }, }; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a1bac423..0281e5d5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -395,7 +395,7 @@ export class AgentSession { /** Current session file path, or null if sessions are disabled */ get sessionFile(): string | null { - return this.sessionManager.isPersisted() ? this.sessionManager.getSessionFile() : null; + return this.sessionManager.getSessionFile() ?? null; } /** Current session ID */ @@ -515,15 +515,13 @@ export class AgentSession { */ async reset(): Promise { const previousSessionFile = this.sessionFile; - const entries = this.sessionManager.getEntries(); // Emit before_new event (can be cancelled) if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - entries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_new", })) as SessionEventResult | undefined; @@ -544,9 +542,8 @@ export class AgentSession { this._hookRunner.setSessionFile(this.sessionFile); await this._hookRunner.emit({ type: "session", - entries: [], - sessionFile: this.sessionFile, - previousSessionFile, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "new", }); } @@ -762,34 +759,22 @@ export class AgentSession { throw new Error("Nothing to compact (session too small)"); } - // Find previous compaction summary if any - let previousSummary: string | undefined; - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].type === "compaction") { - previousSummary = (entries[i] as CompactionEntry).summary; - break; - } - } - let hookCompaction: CompactionResult | undefined; let fromHook = false; if (this._hookRunner?.hasHandlers("session")) { + // Get previous compactions, newest first + const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse(); + const result = (await this._hookRunner.emit({ type: "session", - entries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_compact", - cutPoint: preparation.cutPoint, - firstKeptEntryId: preparation.firstKeptEntryId, - previousSummary, - messagesToSummarize: [...preparation.messagesToSummarize], - messagesToKeep: [...preparation.messagesToKeep], - tokensBefore: preparation.tokensBefore, + preparation, + previousCompactions, customInstructions, model: this.model, - resolveApiKey: async (m: Model) => (await this._modelRegistry.getApiKey(m)) ?? undefined, signal: this._compactionAbortController.signal, })) as SessionEventResult | undefined; @@ -847,12 +832,10 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", - entries: newEntries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "compact", compactionEntry: savedCompactionEntry, - tokensBefore, fromHook, }); } @@ -948,34 +931,22 @@ export class AgentSession { return; } - // Find previous compaction summary if any - let previousSummary: string | undefined; - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].type === "compaction") { - previousSummary = (entries[i] as CompactionEntry).summary; - break; - } - } - let hookCompaction: CompactionResult | undefined; let fromHook = false; if (this._hookRunner?.hasHandlers("session")) { + // Get previous compactions, newest first + const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse(); + const hookResult = (await this._hookRunner.emit({ type: "session", - entries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_compact", - cutPoint: preparation.cutPoint, - firstKeptEntryId: preparation.firstKeptEntryId, - previousSummary, - messagesToSummarize: [...preparation.messagesToSummarize], - messagesToKeep: [...preparation.messagesToKeep], - tokensBefore: preparation.tokensBefore, + preparation, + previousCompactions, customInstructions: undefined, model: this.model, - resolveApiKey: async (m: Model) => (await this._modelRegistry.getApiKey(m)) ?? undefined, signal: this._autoCompactionAbortController.signal, })) as SessionEventResult | undefined; @@ -1034,12 +1005,10 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", - entries: newEntries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "compact", compactionEntry: savedCompactionEntry, - tokensBefore, fromHook, }); } @@ -1337,16 +1306,15 @@ export class AgentSession { */ async switchSession(sessionPath: string): Promise { const previousSessionFile = this.sessionFile; - const oldEntries = this.sessionManager.getEntries(); // Emit before_switch event (can be cancelled) if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - entries: oldEntries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_switch", + targetSessionFile: sessionPath, })) as SessionEventResult | undefined; if (result?.cancel) { @@ -1362,7 +1330,6 @@ export class AgentSession { this.sessionManager.setSessionFile(sessionPath); // Reload messages - const entries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); // Emit session event to hooks @@ -1370,10 +1337,10 @@ export class AgentSession { this._hookRunner.setSessionFile(sessionPath); await this._hookRunner.emit({ type: "session", - entries, - sessionFile: sessionPath, - previousSessionFile, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "switch", + previousSessionFile, }); } @@ -1428,9 +1395,8 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - entries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_branch", targetTurnIndex: entryIndex, })) as SessionEventResult | undefined; @@ -1454,7 +1420,6 @@ export class AgentSession { } // Reload messages from entries (works for both file and in-memory mode) - const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); // Emit branch event to hooks (after branch completes) @@ -1462,9 +1427,8 @@ export class AgentSession { this._hookRunner.setSessionFile(newSessionFile); await this._hookRunner.emit({ type: "session", - entries: newEntries, - sessionFile: newSessionFile, - previousSessionFile, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "branch", targetTurnIndex: entryIndex, }); diff --git a/packages/coding-agent/src/core/export-html.ts b/packages/coding-agent/src/core/export-html.ts index bff53487..db53d743 100644 --- a/packages/coding-agent/src/core/export-html.ts +++ b/packages/coding-agent/src/core/export-html.ts @@ -1343,6 +1343,9 @@ export function exportSessionToHtml( const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) { + throw new Error("Cannot export in-memory session to HTML"); + } const content = readFileSync(sessionFile, "utf8"); const data = parseSessionFile(content); diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 82033e21..8d621ae8 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -7,8 +7,9 @@ import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { CompactionResult, CutPointResult } from "../compaction.js"; -import type { CompactionEntry, SessionEntry } from "../session-manager.js"; +import type { CompactionPreparation, CompactionResult } from "../compaction.js"; +import type { ModelRegistry } from "../model-registry.js"; +import type { CompactionEntry, SessionManager } from "../session-manager.js"; import type { BashToolDetails, FindToolDetails, @@ -95,12 +96,10 @@ export interface HookEventContext { */ interface SessionEventBase { type: "session"; - /** All session entries (including pre-compaction history) */ - entries: SessionEntry[]; - /** Current session file path, or null in --no-session mode */ - sessionFile: string | null; - /** Previous session file path, or null for "start" and "new" */ - previousSessionFile: string | null; + /** Session manager instance - use for entries, session file, etc. */ + sessionManager: SessionManager; + /** Model registry - use for API key resolution */ + modelRegistry: ModelRegistry; } /** @@ -120,7 +119,17 @@ interface SessionEventBase { */ export type SessionEvent = | (SessionEventBase & { - reason: "start" | "switch" | "new" | "before_switch" | "before_new" | "shutdown"; + reason: "start" | "new" | "before_new" | "shutdown"; + }) + | (SessionEventBase & { + reason: "before_switch"; + /** Session file we're switching to */ + targetSessionFile: string; + }) + | (SessionEventBase & { + reason: "switch"; + /** Session file we came from */ + previousSessionFile: string | null; }) | (SessionEventBase & { reason: "branch" | "before_branch"; @@ -129,27 +138,20 @@ export type SessionEvent = }) | (SessionEventBase & { reason: "before_compact"; - cutPoint: CutPointResult; - /** ID of first entry to keep (for hooks that return CompactionEntry) */ - firstKeptEntryId: string; - /** Summary from previous compaction, if any. Include this in your summary to preserve context. */ - previousSummary?: string; - /** Messages that will be summarized and discarded */ - messagesToSummarize: AppMessage[]; - /** Messages that will be kept after the summary (recent turns) */ - messagesToKeep: AppMessage[]; - tokensBefore: number; + /** Compaction preparation with cut point, messages to summarize/keep, etc. */ + preparation: CompactionPreparation; + /** Previous compaction entries, newest first. Use for iterative summarization. */ + previousCompactions: CompactionEntry[]; + /** Optional user-provided instructions for the summary */ customInstructions?: string; + /** Current model */ model: Model; - /** Resolve API key for any model (checks settings, OAuth, env vars) */ - resolveApiKey: (model: Model) => Promise; /** Abort signal - hooks should pass this to LLM calls and check it periodically */ signal: AbortSignal; }) | (SessionEventBase & { reason: "compact"; compactionEntry: CompactionEntry; - tokensBefore: number; /** Whether the compaction entry was provided by a hook */ fromHook: boolean; }); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 0b5934b1..79d610c7 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -421,7 +421,7 @@ export function findMostRecentSession(sessionDir: string): string | null { */ export class SessionManager { private sessionId: string = ""; - private sessionFile: string = ""; + private sessionFile: string | undefined; private sessionDir: string; private cwd: string; private persist: boolean; @@ -434,10 +434,10 @@ export class SessionManager { private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) { this.cwd = cwd; this.sessionDir = sessionDir; + this.persist = persist; if (persist && sessionDir && !existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } - this.persist = persist; if (sessionFile) { this.setSessionFile(sessionFile); @@ -479,8 +479,8 @@ export class SessionManager { this.byId.clear(); this.leafId = ""; this.flushed = false; - // Only generate filename if not already set (e.g., via --session flag) - if (!this.sessionFile) { + // Only generate filename if persisting and not already set (e.g., via --session flag) + if (this.persist && !this.sessionFile) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); } @@ -505,7 +505,7 @@ export class SessionManager { } private _rewriteFile(): void { - if (!this.persist) return; + if (!this.persist || !this.sessionFile) return; const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; writeFileSync(this.sessionFile, content); } @@ -526,12 +526,12 @@ export class SessionManager { return this.sessionId; } - getSessionFile(): string { + getSessionFile(): string | undefined { return this.sessionFile; } _persist(entry: SessionEntry): void { - if (!this.persist) return; + if (!this.persist || !this.sessionFile) return; const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) return; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f0e1c01c..6da1e0fd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -384,9 +384,8 @@ export class InteractiveMode { // Emit session event await hookRunner.emit({ type: "session", - entries, - sessionFile: this.session.sessionFile, - previousSessionFile: null, + sessionManager: this.session.sessionManager, + modelRegistry: this.session.modelRegistry, reason: "start", }); } @@ -1156,12 +1155,10 @@ export class InteractiveMode { // Emit shutdown event to hooks const hookRunner = this.session.hookRunner; if (hookRunner?.hasHandlers("session")) { - const entries = this.sessionManager.getEntries(); await hookRunner.emit({ type: "session", - entries, - sessionFile: this.session.sessionFile, - previousSessionFile: null, + sessionManager: this.session.sessionManager, + modelRegistry: this.session.modelRegistry, reason: "shutdown", }); } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 46724701..af1e48d8 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -46,9 +46,8 @@ export async function runPrintMode( // Emit session event await hookRunner.emit({ type: "session", - entries, - sessionFile: session.sessionFile, - previousSessionFile: null, + sessionManager: session.sessionManager, + modelRegistry: session.modelRegistry, reason: "start", }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 0c67969b..eb0ad807 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -145,9 +145,8 @@ export async function runRpcMode(session: AgentSession): Promise { // Emit session event await hookRunner.emit({ type: "session", - entries, - sessionFile: session.sessionFile, - previousSessionFile: null, + sessionManager: session.sessionManager, + modelRegistry: session.modelRegistry, reason: "start", }); } diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index 9c5f5479..0d200702 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -13,24 +13,23 @@ describe("Documentation example", () => { if (event.reason !== "before_compact") return; // After narrowing, these should all be accessible - const messages = event.messagesToSummarize; - const messagesToKeep = event.messagesToKeep; - const cutPoint = event.cutPoint; - const tokensBefore = event.tokensBefore; - const model = event.model; - const resolveApiKey = event.resolveApiKey; - const firstKeptEntryId = event.firstKeptEntryId; + const { preparation, previousCompactions, sessionManager, modelRegistry, model } = event; + const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId, cutPoint } = preparation; + + // Get previous summary from most recent compaction + const _previousSummary = previousCompactions[0]?.summary; // Verify types - expect(Array.isArray(messages)).toBe(true); + expect(Array.isArray(messagesToSummarize)).toBe(true); expect(Array.isArray(messagesToKeep)).toBe(true); - expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); // cutPoint still uses index + expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); expect(typeof tokensBefore).toBe("number"); expect(model).toBeDefined(); - expect(typeof resolveApiKey).toBe("function"); + expect(typeof sessionManager.getEntries).toBe("function"); + expect(typeof modelRegistry.getApiKey).toBe("function"); expect(typeof firstKeptEntryId).toBe("string"); - const summary = messages + const summary = messagesToSummarize .filter((m) => m.role === "user") .map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`) .join("\n"); @@ -57,12 +56,11 @@ describe("Documentation example", () => { // After narrowing, these should all be accessible const entry = event.compactionEntry; - const tokensBefore = event.tokensBefore; const fromHook = event.fromHook; expect(entry.type).toBe("compaction"); expect(typeof entry.summary).toBe("string"); - expect(typeof tokensBefore).toBe("number"); + expect(typeof entry.tokensBefore).toBe("number"); expect(typeof fromHook).toBe("boolean"); }); }; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 76d227bf..d056dc37 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -40,7 +40,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }); function createHook( - onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compactionEntry?: any } | undefined, + onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compaction?: any } | undefined, onCompact?: (event: SessionEvent) => void, ): LoadedHook { const handlers = new Map Promise)[]>(); @@ -98,7 +98,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }, false, ); - hookRunner.setSessionFile(sessionManager.getSessionFile()); + hookRunner.setSessionFile(sessionManager.getSessionFile() ?? null); session = new AgentSession({ agent, @@ -131,20 +131,21 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const beforeEvent = beforeCompactEvents[0]; if (beforeEvent.reason === "before_compact") { - expect(beforeEvent.cutPoint).toBeDefined(); - expect(beforeEvent.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0); - expect(beforeEvent.messagesToSummarize).toBeDefined(); - expect(beforeEvent.messagesToKeep).toBeDefined(); - expect(beforeEvent.tokensBefore).toBeGreaterThanOrEqual(0); + expect(beforeEvent.preparation).toBeDefined(); + expect(beforeEvent.preparation.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0); + expect(beforeEvent.preparation.messagesToSummarize).toBeDefined(); + expect(beforeEvent.preparation.messagesToKeep).toBeDefined(); + expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0); expect(beforeEvent.model).toBeDefined(); - expect(beforeEvent.resolveApiKey).toBeDefined(); + expect(beforeEvent.sessionManager).toBeDefined(); + expect(beforeEvent.modelRegistry).toBeDefined(); } const afterEvent = compactEvents[0]; if (afterEvent.reason === "compact") { expect(afterEvent.compactionEntry).toBeDefined(); expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0); - expect(afterEvent.tokensBefore).toBeGreaterThanOrEqual(0); + expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0); expect(afterEvent.fromHook).toBe(false); } }, 120000); @@ -162,18 +163,16 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(compactEvents.length).toBe(0); }, 120000); - it("should allow hooks to provide custom compactionEntry", async () => { + it("should allow hooks to provide custom compaction", async () => { const customSummary = "Custom summary from hook"; const hook = createHook((event) => { if (event.reason === "before_compact") { return { - compactionEntry: { - type: "compaction" as const, - timestamp: new Date().toISOString(), + compaction: { summary: customSummary, - firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, - tokensBefore: event.tokensBefore, + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, }, }; } @@ -215,7 +214,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const afterEvent = compactEvents[0]; if (afterEvent.reason === "compact") { - const hasCompactionEntry = afterEvent.entries.some((e) => e.type === "compaction"); + const entries = afterEvent.sessionManager.getEntries(); + const hasCompactionEntry = entries.some((e) => e.type === "compaction"); expect(hasCompactionEntry).toBe(true); } }, 120000); @@ -337,35 +337,34 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(capturedBeforeEvent).not.toBeNull(); const event = capturedBeforeEvent!; - expect(event.cutPoint).toHaveProperty("firstKeptEntryIndex"); - expect(event.cutPoint).toHaveProperty("isSplitTurn"); - expect(event.cutPoint).toHaveProperty("turnStartIndex"); + expect(event.preparation.cutPoint).toHaveProperty("firstKeptEntryIndex"); + expect(event.preparation.cutPoint).toHaveProperty("isSplitTurn"); + expect(event.preparation.cutPoint).toHaveProperty("turnStartIndex"); - expect(Array.isArray(event.messagesToSummarize)).toBe(true); - expect(Array.isArray(event.messagesToKeep)).toBe(true); + expect(Array.isArray(event.preparation.messagesToSummarize)).toBe(true); + expect(Array.isArray(event.preparation.messagesToKeep)).toBe(true); - expect(typeof event.tokensBefore).toBe("number"); + expect(typeof event.preparation.tokensBefore).toBe("number"); expect(event.model).toHaveProperty("provider"); expect(event.model).toHaveProperty("id"); - expect(typeof event.resolveApiKey).toBe("function"); + expect(typeof event.modelRegistry.getApiKey).toBe("function"); - expect(Array.isArray(event.entries)).toBe(true); - expect(event.entries.length).toBeGreaterThan(0); + const entries = event.sessionManager.getEntries(); + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThan(0); }, 120000); - it("should use hook compactionEntry even with different firstKeptEntryIndex", async () => { - const customSummary = "Custom summary with modified index"; + it("should use hook compaction even with different values", async () => { + const customSummary = "Custom summary with modified values"; const hook = createHook((event) => { if (event.reason === "before_compact") { return { - compactionEntry: { - type: "compaction" as const, - timestamp: new Date().toISOString(), + compaction: { summary: customSummary, - firstKeptEntryIndex: 0, + firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: 999, }, }; From 9da36e5ac67e0808c80b73f5be561d7b1bd2cc81 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 22:45:18 +0100 Subject: [PATCH 015/161] Add CustomMessageEntry for hook-injected messages in LLM context - CustomMessageEntry type with customType, content, display, details - appendCustomMessageEntry() in SessionManager - buildSessionContext() includes custom_message entries as user messages - Exported CustomEntry and CustomMessageEntry from main index CustomEntry is for hook state (not in context). CustomMessageEntry is for hook-injected content (in context). --- packages/coding-agent/CHANGELOG.md | 3 +- .../coding-agent/docs/session-tree-plan.md | 12 +-- .../coding-agent/src/core/session-manager.ts | 94 ++++++++++++++++++- packages/coding-agent/src/index.ts | 2 + 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eaf139ae..a77e38da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,7 +13,8 @@ - `saveCompaction(entry)` replaced with `appendCompaction(summary, firstKeptEntryId, tokensBefore)` - `getEntries()` now excludes the session header (use `getHeader()` separately) - New methods: `getTree()`, `getPath()`, `getLeafUuid()`, `getLeafEntry()`, `getEntry()`, `branchWithSummary()` - - New `appendCustomEntry(customType, data)` for hooks to store custom data + - New `appendCustomEntry(customType, data)` for hooks to store custom data (not in LLM context) + - New `appendCustomMessageEntry(customType, content, display, details?)` for hooks to inject messages into LLM context - **Compaction API**: - `CompactionEntry` and `CompactionResult` are now generic with optional `details?: T` for hook-specific data - `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry` diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 8dfa9bbb..b6ac5f45 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -118,14 +118,14 @@ export interface CustomMessageEntry extends SessionEntryBase { ``` Behavior: -- [ ] Participates in context and compaction as user messages (after messageTransformer) -- [ ] Not displayed as user messages in TUI -- [ ] Display options: +- [x] Type definition matching plan +- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager +- [x] `buildSessionContext()` includes custom_message entries as user messages +- [x] Exported from main index +- [ ] TUI rendering: - `display: false` - hidden entirely - `display: true` - baseline renderer (content with different bg/fg color) - - Custom renderer defined by the hook that contributes it -- [ ] Define injection mechanism for hooks to add CustomMessageEntry -- [ ] Hook registration for custom renderers + - Custom renderer defined by the hook that contributes it (future) See also: `CustomEntry` for storing hook state that does NOT participate in context. diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 79d610c7..02e43194 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,4 +1,4 @@ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; import { randomUUID } from "crypto"; import { appendFileSync, @@ -86,6 +86,26 @@ export interface LabelEntry extends SessionEntryBase { label: string | undefined; } +/** + * Custom message entry for hooks to inject messages into LLM context. + * Use customType to identify your hook's entries. + * + * Unlike CustomEntry, this DOES participate in LLM context. + * The content is converted to a user message in buildSessionContext(). + * Use details for hook-specific metadata (not sent to LLM). + * + * display controls TUI rendering: + * - false: hidden entirely + * - true: rendered with distinct styling (different from user messages) + */ +export interface CustomMessageEntry extends SessionEntryBase { + type: "custom_message"; + customType: string; + content: (string | Attachment)[]; + details?: T; + display: boolean; +} + /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ export type SessionEntry = | SessionMessageEntry @@ -94,6 +114,7 @@ export type SessionEntry = | CompactionEntry | BranchSummaryEntry | CustomEntry + | CustomMessageEntry | LabelEntry; /** Raw file entry (includes header) */ @@ -140,6 +161,35 @@ export function createSummaryMessage(summary: string, timestamp: string): AppMes }; } +/** Convert CustomMessageEntry content to AppMessage format */ +function createCustomMessage(entry: CustomMessageEntry): AppMessage { + // Convert content array to AppMessage content format + const content = entry.content.map((item) => { + if (typeof item === "string") { + return { type: "text" as const, text: item }; + } + // Attachment - convert to appropriate content type + if (item.type === "image") { + return { + type: "image" as const, + data: item.content, + mimeType: item.mimeType, + }; + } + // Document attachment - use extracted text or indicate document + return { + type: "text" as const, + text: item.extractedText ?? `[Document: ${item.fileName}]`, + }; + }); + + return { + role: "user", + content, + timestamp: new Date(entry.timestamp).getTime(), + }; +} + /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { @@ -308,8 +358,12 @@ export function buildSessionContext( if (entry.id === compaction.firstKeptEntryId) { foundFirstKept = true; } - if (foundFirstKept && entry.type === "message") { - messages.push(entry.message); + if (foundFirstKept) { + if (entry.type === "message") { + messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push(createCustomMessage(entry)); + } } } @@ -318,15 +372,19 @@ export function buildSessionContext( const entry = path[i]; if (entry.type === "message") { messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push(createCustomMessage(entry)); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); } } } else { - // No compaction - emit all messages, handle branch summaries + // No compaction - emit all messages, handle branch summaries and custom messages for (const entry of path) { if (entry.type === "message") { messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push(createCustomMessage(entry)); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); } @@ -623,6 +681,34 @@ export class SessionManager { return entry.id; } + /** + * Append a custom message entry (for hooks) that participates in LLM context. + * @param customType Hook identifier for filtering on reload + * @param content Message content (strings and attachments) + * @param display Whether to show in TUI (true = styled display, false = hidden) + * @param details Optional hook-specific metadata (not sent to LLM) + * @returns Entry id + */ + appendCustomMessageEntry( + customType: string, + content: (string | Attachment)[], + display: boolean, + details?: T, + ): string { + const entry: CustomMessageEntry = { + type: "custom_message", + customType, + content, + display, + details, + id: generateId(this.byId), + parentId: this.leafId || null, + timestamp: new Date().toISOString(), + }; + this._appendEntry(entry); + return entry.id; + } + // ========================================================================= // Tree Traversal // ========================================================================= diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index acfc766a..55909056 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -111,6 +111,8 @@ export { buildSessionContext, type CompactionEntry, CURRENT_SESSION_VERSION, + type CustomEntry, + type CustomMessageEntry, createSummaryMessage, type FileEntry, getLatestCompactionEntry, From 3ecaaa593725855d6b4135dee0ac61827e7abb65 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 22:50:04 +0100 Subject: [PATCH 016/161] Fix CustomMessageEntry content type to match UserMessage content: string | (TextContent | ImageContent)[] This matches the UserMessage type from pi-ai, so content can be passed directly to AppMessage without conversion. --- .../coding-agent/docs/session-tree-plan.md | 8 ++--- .../coding-agent/src/core/session-manager.ts | 33 ++++--------------- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index b6ac5f45..204cb598 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -110,10 +110,10 @@ Hook-injected messages that participate in LLM context. Unlike `CustomEntry` ```typescript export interface CustomMessageEntry extends SessionEntryBase { type: "custom_message"; - customType: string; // Hook identifier - content: (string | Attachment)[]; // Message content - details?: T; // Hook-specific data for state reconstruction on reload - display: boolean; // Whether to display in TUI + customType: string; // Hook identifier + content: string | (TextContent | ImageContent)[]; // Message content (same as UserMessage) + details?: T; // Hook-specific data for state reconstruction on reload + display: boolean; // Whether to display in TUI } ``` diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 02e43194..ed6fbdfb 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,4 +1,5 @@ -import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; +import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { randomUUID } from "crypto"; import { appendFileSync, @@ -101,7 +102,7 @@ export interface LabelEntry extends SessionEntryBase { export interface CustomMessageEntry extends SessionEntryBase { type: "custom_message"; customType: string; - content: (string | Attachment)[]; + content: string | (TextContent | ImageContent)[]; details?: T; display: boolean; } @@ -161,31 +162,11 @@ export function createSummaryMessage(summary: string, timestamp: string): AppMes }; } -/** Convert CustomMessageEntry content to AppMessage format */ +/** Convert CustomMessageEntry to AppMessage format */ function createCustomMessage(entry: CustomMessageEntry): AppMessage { - // Convert content array to AppMessage content format - const content = entry.content.map((item) => { - if (typeof item === "string") { - return { type: "text" as const, text: item }; - } - // Attachment - convert to appropriate content type - if (item.type === "image") { - return { - type: "image" as const, - data: item.content, - mimeType: item.mimeType, - }; - } - // Document attachment - use extracted text or indicate document - return { - type: "text" as const, - text: item.extractedText ?? `[Document: ${item.fileName}]`, - }; - }); - return { role: "user", - content, + content: entry.content, timestamp: new Date(entry.timestamp).getTime(), }; } @@ -684,14 +665,14 @@ export class SessionManager { /** * Append a custom message entry (for hooks) that participates in LLM context. * @param customType Hook identifier for filtering on reload - * @param content Message content (strings and attachments) + * @param content Message content (string or TextContent/ImageContent array) * @param display Whether to show in TUI (true = styled display, false = hidden) * @param details Optional hook-specific metadata (not sent to LLM) * @returns Entry id */ appendCustomMessageEntry( customType: string, - content: (string | Attachment)[], + content: string | (TextContent | ImageContent)[], display: boolean, details?: T, ): string { From 11a7845ceb74b8a9430295a6c219db3b8ab9e7f4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 23:02:53 +0100 Subject: [PATCH 017/161] Add CustomMessageEntry rendering infrastructure - Add renderCustomMessage to HookAPI for registering custom renderers - Add CustomMessageRenderer type and CustomMessageRenderOptions - Store customMessageRenderers in LoadedHook - Add getCustomMessageRenderer(customType) to HookRunner - SessionContext.entries now aligned with messages (same length, corresponding indices) TUI can now correlate messages with their source entries to identify custom_message entries and use hook-provided renderers. --- packages/coding-agent/src/core/hooks/index.ts | 2 ++ .../coding-agent/src/core/hooks/loader.ts | 18 ++++++++---- .../coding-agent/src/core/hooks/runner.ts | 15 ++++++++++ packages/coding-agent/src/core/hooks/types.ts | 29 ++++++++++++++++++- packages/coding-agent/src/core/sdk.ts | 5 ++++ .../coding-agent/src/core/session-manager.ts | 20 ++++++++++--- .../test/compaction-hooks.test.ts | 4 +++ 7 files changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index db222989..7524b605 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -5,6 +5,8 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, + CustomMessageRenderer, + CustomMessageRenderOptions, CustomToolResultEvent, EditToolResultEvent, ExecResult, diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 18e09c19..5f4fef75 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; import type { Attachment } from "@mariozechner/pi-agent-core"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; -import type { HookAPI, HookFactory } from "./types.js"; +import type { CustomMessageRenderer, HookAPI, HookFactory } from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -61,6 +61,8 @@ export interface LoadedHook { resolvedPath: string; /** Map of event type to handler functions */ handlers: Map; + /** Map of customType to custom message renderer */ + customMessageRenderers: Map; /** Set the send handler for this hook's pi.send() */ setSendHandler: (handler: SendHandler) => void; } @@ -110,16 +112,18 @@ function resolveHookPath(hookPath: string, cwd: string): string { } /** - * Create a HookAPI instance that collects handlers. - * Returns the API and a function to set the send handler later. + * Create a HookAPI instance that collects handlers and renderers. + * Returns the API, renderers map, and a function to set the send handler later. */ function createHookAPI(handlers: Map): { api: HookAPI; + customMessageRenderers: Map; setSendHandler: (handler: SendHandler) => void; } { let sendHandler: SendHandler = () => { // Default no-op until mode sets the handler }; + const customMessageRenderers = new Map(); const api: HookAPI = { on(event: string, handler: HandlerFn): void { @@ -130,10 +134,14 @@ function createHookAPI(handlers: Map): { send(text: string, attachments?: Attachment[]): void { sendHandler(text, attachments); }, + renderCustomMessage(customType: string, renderer: CustomMessageRenderer): void { + customMessageRenderers.set(customType, renderer); + }, } as HookAPI; return { api, + customMessageRenderers, setSendHandler: (handler: SendHandler) => { sendHandler = handler; }, @@ -164,13 +172,13 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo // Create handlers map and API const handlers = new Map(); - const { api, setSendHandler } = createHookAPI(handlers); + const { api, customMessageRenderers, setSendHandler } = createHookAPI(handlers); // Call factory to register handlers factory(api); return { - hook: { path: hookPath, resolvedPath, handlers, setSendHandler }, + hook: { path: hookPath, resolvedPath, handlers, customMessageRenderers, setSendHandler }, error: null, }; } catch (err) { diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index e7646a19..2d4c9d2a 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -5,6 +5,7 @@ import { spawn } from "node:child_process"; import type { LoadedHook, SendHandler } from "./loader.js"; import type { + CustomMessageRenderer, ExecOptions, ExecResult, HookError, @@ -203,6 +204,20 @@ export class HookRunner { return false; } + /** + * Get a custom message renderer for the given customType. + * Returns the first renderer found across all hooks, or undefined if none. + */ + getCustomMessageRenderer(customType: string): CustomMessageRenderer | undefined { + for (const hook of this.hooks) { + const renderer = hook.customMessageRenderers.get(customType); + if (renderer) { + return renderer; + } + } + return undefined; + } + /** * Create the event context for handlers. */ diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 8d621ae8..6b46a903 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -7,9 +7,11 @@ import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; +import type { Component } from "@mariozechner/pi-tui"; +import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { CompactionEntry, SessionManager } from "../session-manager.js"; +import type { CompactionEntry, CustomMessageEntry, SessionManager } from "../session-manager.js"; import type { BashToolDetails, FindToolDetails, @@ -368,6 +370,24 @@ export interface SessionEventResult { */ export type HookHandler = (event: E, ctx: HookEventContext) => Promise; +/** + * Options passed to custom message renderers. + */ +export interface CustomMessageRenderOptions { + /** Whether the view is expanded */ + expanded: boolean; +} + +/** + * Renderer for custom message entries. + * Hooks register these to provide custom TUI rendering for their CustomMessageEntry types. + */ +export type CustomMessageRenderer = ( + entry: CustomMessageEntry, + options: CustomMessageRenderOptions, + theme: Theme, +) => Component | null; + /** * HookAPI passed to hook factory functions. * Hooks use pi.on() to subscribe to events and pi.send() to inject messages. @@ -388,6 +408,13 @@ export interface HookAPI { * If the agent is idle, a new agent loop is started. */ send(text: string, attachments?: Attachment[]): void; + + /** + * Register a custom renderer for CustomMessageEntry with a specific customType. + * The renderer is called when rendering the entry in the TUI. + * Return null to use the default renderer. + */ + renderCustomMessage(customType: string, renderer: CustomMessageRenderer): void; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e245f233..c78a6bd9 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -340,6 +340,7 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory { function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] { return definitions.map((def) => { const handlers = new Map Promise>>(); + const customMessageRenderers = new Map(); let sendHandler: (text: string, attachments?: any[]) => void = () => {}; const api = { @@ -351,6 +352,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa send: (text: string, attachments?: any[]) => { sendHandler(text, attachments); }, + renderCustomMessage: (customType: string, renderer: any) => { + customMessageRenderers.set(customType, renderer); + }, }; def.factory(api as any); @@ -359,6 +363,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa path: def.path ?? "", resolvedPath: def.path ?? "", handlers, + customMessageRenderers, setSendHandler: (handler: (text: string, attachments?: any[]) => void) => { sendHandler = handler; }, diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index ed6fbdfb..8d2323d8 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -131,6 +131,8 @@ export interface SessionTreeNode { export interface SessionContext { messages: AppMessage[]; + /** Entries in the current path (root to leaf). Use to identify custom_message entries for rendering. */ + entries: SessionEntry[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; } @@ -290,7 +292,7 @@ export function buildSessionContext( } if (!leaf) { - return { messages: [], thinkingLevel: "off", model: null }; + return { messages: [], entries: [], thinkingLevel: "off", model: null }; } // Walk from leaf to root, collecting path @@ -318,16 +320,18 @@ export function buildSessionContext( } } - // Build messages - handle compaction ordering correctly + // Build messages and collect corresponding entries // When there's a compaction, we need to: - // 1. Emit summary first + // 1. Emit summary first (entry = compaction) // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction const messages: AppMessage[] = []; + const contextEntries: SessionEntry[] = []; if (compaction) { // Emit summary first messages.push(createSummaryMessage(compaction.summary, compaction.timestamp)); + contextEntries.push(compaction); // Find compaction index in path const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id); @@ -342,8 +346,10 @@ export function buildSessionContext( if (foundFirstKept) { if (entry.type === "message") { messages.push(entry.message); + contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); + contextEntries.push(entry); } } } @@ -353,10 +359,13 @@ export function buildSessionContext( const entry = path[i]; if (entry.type === "message") { messages.push(entry.message); + contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); + contextEntries.push(entry); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); + contextEntries.push(entry); } } } else { @@ -364,15 +373,18 @@ export function buildSessionContext( for (const entry of path) { if (entry.type === "message") { messages.push(entry.message); + contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); + contextEntries.push(entry); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); + contextEntries.push(entry); } } } - return { messages, thinkingLevel, model }; + return { messages, entries: contextEntries, thinkingLevel, model }; } /** diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index d056dc37..b24618e8 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -63,6 +63,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { path: "test-hook", resolvedPath: "/test/test-hook.ts", handlers, + customMessageRenderers: new Map(), setSendHandler: () => {}, }; } @@ -238,6 +239,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ], ]), + customMessageRenderers: new Map(), setSendHandler: () => {}, }; @@ -281,6 +283,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ], ]), + customMessageRenderers: new Map(), setSendHandler: () => {}, }; @@ -303,6 +306,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ], ]), + customMessageRenderers: new Map(), setSendHandler: () => {}, }; From 754f55e3f6ce4cbf5b60ccd28f5674eedb6339e2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 23:06:37 +0100 Subject: [PATCH 018/161] Fix: handle branch_summary in kept messages before compaction --- packages/coding-agent/src/core/session-manager.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 8d2323d8..6bd8a46f 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -350,6 +350,9 @@ export function buildSessionContext( } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); contextEntries.push(entry); + } else if (entry.type === "branch_summary") { + messages.push(createSummaryMessage(entry.summary, entry.timestamp)); + contextEntries.push(entry); } } } From beb804cda03477d62e3e2c59c8e86631ccbc7e82 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 23:09:16 +0100 Subject: [PATCH 019/161] Fix: compaction now handles branch_summary and custom_message entries - Add getMessageFromEntry helper to extract AppMessage from any context-producing entry - Update findValidCutPoints to treat branch_summary/custom_message as valid cut points - Update findTurnStartIndex to recognize branch_summary/custom_message as turn starters - Update all message extraction loops to use getMessageFromEntry --- packages/coding-agent/src/core/compaction.ts | 56 +++++++++++++------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index b0156ec5..3fb771dd 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -9,7 +9,28 @@ import type { AppMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; import { messageTransformer } from "./messages.js"; -import type { CompactionEntry, SessionEntry } from "./session-manager.js"; +import { type CompactionEntry, createSummaryMessage, type SessionEntry } from "./session-manager.js"; + +/** + * Extract AppMessage from an entry if it produces one. + * Returns null for entries that don't contribute to LLM context. + */ +function getMessageFromEntry(entry: SessionEntry): AppMessage | null { + if (entry.type === "message") { + return entry.message; + } + if (entry.type === "custom_message") { + return { + role: "user", + content: entry.content, + timestamp: new Date(entry.timestamp).getTime(), + }; + } + if (entry.type === "branch_summary") { + return createSummaryMessage(entry.summary, entry.timestamp); + } + return null; +} /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ export interface CompactionResult { @@ -157,7 +178,10 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde const cutPoints: number[] = []; for (let i = startIndex; i < endIndex; i++) { const entry = entries[i]; - if (entry.type === "message") { + // branch_summary and custom_message are user-role messages, valid cut points + if (entry.type === "branch_summary" || entry.type === "custom_message") { + cutPoints.push(i); + } else if (entry.type === "message") { const role = entry.message.role; // user, assistant, and bashExecution are valid cut points // toolResult must stay with its preceding tool call @@ -177,6 +201,10 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number { for (let i = entryIndex; i >= startIndex; i--) { const entry = entries[i]; + // branch_summary and custom_message are user-role messages, can start a turn + if (entry.type === "branch_summary" || entry.type === "custom_message") { + return i; + } if (entry.type === "message") { const role = entry.message.role; if (role === "user" || role === "bashExecution") { @@ -382,19 +410,15 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS // Messages to summarize (will be discarded after summary) const messagesToSummarize: AppMessage[] = []; for (let i = boundaryStart; i < historyEnd; i++) { - const entry = entries[i]; - if (entry.type === "message") { - messagesToSummarize.push(entry.message); - } + const msg = getMessageFromEntry(entries[i]); + if (msg) messagesToSummarize.push(msg); } // Messages to keep (recent turns, kept after summary) const messagesToKeep: AppMessage[] = []; for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) { - const entry = entries[i]; - if (entry.type === "message") { - messagesToKeep.push(entry.message); - } + const msg = getMessageFromEntry(entries[i]); + if (msg) messagesToKeep.push(msg); } return { cutPoint, firstKeptEntryId, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart }; @@ -460,10 +484,8 @@ export async function compact( const historyEnd = cutResult.isSplitTurn ? cutResult.turnStartIndex : cutResult.firstKeptEntryIndex; const historyMessages: AppMessage[] = []; for (let i = boundaryStart; i < historyEnd; i++) { - const entry = entries[i]; - if (entry.type === "message") { - historyMessages.push(entry.message); - } + const msg = getMessageFromEntry(entries[i]); + if (msg) historyMessages.push(msg); } // Include previous summary if there was a compaction @@ -480,10 +502,8 @@ export async function compact( const turnPrefixMessages: AppMessage[] = []; if (cutResult.isSplitTurn) { for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) { - const entry = entries[i]; - if (entry.type === "message") { - turnPrefixMessages.push(entry.message); - } + const msg = getMessageFromEntry(entries[i]); + if (msg) turnPrefixMessages.push(msg); } } From 7b94ddf36bf5a57ff93a7b775366fff7e9a4fbfc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 23:21:04 +0100 Subject: [PATCH 020/161] Add TUI rendering for CustomMessageEntry - Add CustomMessageComponent with purple-tinted styling - Add theme colors: customMessageBg, customMessageText, customMessageLabel - Rename renderMessages to renderSessionContext taking SessionContext directly - renderInitialMessages now gets context from sessionManager - Skip rendering for display: false entries --- packages/coding-agent/src/main.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 44 ++++++++++++++----- .../src/modes/interactive/theme/dark.json | 6 ++- .../src/modes/interactive/theme/light.json | 6 ++- .../modes/interactive/theme/theme-schema.json | 15 +++++++ .../src/modes/interactive/theme/theme.ts | 17 +++++-- 6 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5edba853..e716cbd0 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -77,7 +77,7 @@ async function runInteractiveMode( } }); - mode.renderInitialMessages(session.state); + mode.renderInitialMessages(); if (migratedProviders.length > 0) { mode.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 6da1e0fd..86ca9837 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -31,6 +31,7 @@ import type { HookUIContext } from "../../core/hooks/index.js"; import { isBashExecutionMessage } from "../../core/messages.js"; import { getLatestCompactionEntry, + type SessionContext, SessionManager, SUMMARY_PREFIX, SUMMARY_SUFFIX, @@ -45,6 +46,7 @@ import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; import { CompactionComponent } from "./components/compaction.js"; import { CustomEditor } from "./components/custom-editor.js"; +import { CustomMessageComponent } from "./components/custom-message.js"; import { DynamicBorder } from "./components/dynamic-border.js"; import { FooterComponent } from "./components/footer.js"; import { HookInputComponent } from "./components/hook-input.js"; @@ -1020,13 +1022,13 @@ export class InteractiveMode { } /** - * Render messages to chat. Used for initial load and rebuild after compaction. - * @param messages Messages to render + * Render session context to chat. Used for initial load and rebuild after compaction. + * @param sessionContext Session context to render * @param options.updateFooter Update footer state * @param options.populateHistory Add user messages to editor history */ - private renderMessages( - messages: readonly (Message | AppMessage)[], + private renderSessionContext( + sessionContext: SessionContext, options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { this.isFirstUserMessage = true; @@ -1038,13 +1040,25 @@ export class InteractiveMode { } const compactionEntry = getLatestCompactionEntry(this.sessionManager.getEntries()); + const entries = sessionContext.entries; + + for (let i = 0; i < sessionContext.messages.length; i++) { + const message = sessionContext.messages[i]; + const entry = entries?.[i]; - for (const message of messages) { if (isBashExecutionMessage(message)) { this.addMessageToChat(message); continue; } + // Check if this is a custom_message entry + if (entry?.type === "custom_message") { + if (entry.display) { + this.chatContainer.addChild(new CustomMessageComponent(entry)); + } + continue; + } + if (message.role === "user") { const textContent = this.getUserMessageText(message); if (textContent) { @@ -1103,12 +1117,17 @@ export class InteractiveMode { this.ui.requestRender(); } - renderInitialMessages(state: AgentState): void { - this.renderMessages(state.messages, { updateFooter: true, populateHistory: true }); + renderInitialMessages(): void { + // Get aligned messages and entries from session context + const context = this.sessionManager.buildSessionContext(); + this.renderSessionContext(context, { + updateFooter: true, + populateHistory: true, + }); // Show compaction info if session was compacted - const entries = this.sessionManager.getEntries(); - const compactionCount = entries.filter((e) => e.type === "compaction").length; + const allEntries = this.sessionManager.getEntries(); + const compactionCount = allEntries.filter((e) => e.type === "compaction").length; if (compactionCount > 0) { const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; this.showStatus(`Session compacted ${times}`); @@ -1125,7 +1144,8 @@ export class InteractiveMode { } private rebuildChatFromMessages(): void { - this.renderMessages(this.session.messages); + const context = this.sessionManager.buildSessionContext(); + this.renderSessionContext(context); } // ========================================================================= @@ -1500,7 +1520,7 @@ export class InteractiveMode { this.chatContainer.clear(); this.isFirstUserMessage = true; - this.renderInitialMessages(this.session.state); + this.renderInitialMessages(); this.editor.setText(result.selectedText); done(); this.showStatus("Branched to new session"); @@ -1554,7 +1574,7 @@ export class InteractiveMode { // Clear and re-render the chat this.chatContainer.clear(); this.isFirstUserMessage = true; - this.renderInitialMessages(this.session.state); + this.renderInitialMessages(); this.showStatus("Resumed session"); } diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 51ad7749..25c61db6 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -14,7 +14,8 @@ "userMsgBg": "#343541", "toolPendingBg": "#282832", "toolSuccessBg": "#283228", - "toolErrorBg": "#3c2828" + "toolErrorBg": "#3c2828", + "customMsgBg": "#2d2838" }, "colors": { "accent": "accent", @@ -30,6 +31,9 @@ "userMessageBg": "userMsgBg", "userMessageText": "", + "customMessageBg": "customMsgBg", + "customMessageText": "", + "customMessageLabel": "#9575cd", "toolPendingBg": "toolPendingBg", "toolSuccessBg": "toolSuccessBg", "toolErrorBg": "toolErrorBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 57eb2643..36e9c763 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -13,7 +13,8 @@ "userMsgBg": "#e8e8e8", "toolPendingBg": "#e8e8f0", "toolSuccessBg": "#e8f0e8", - "toolErrorBg": "#f0e8e8" + "toolErrorBg": "#f0e8e8", + "customMsgBg": "#ede7f6" }, "colors": { "accent": "teal", @@ -29,6 +30,9 @@ "userMessageBg": "userMsgBg", "userMessageText": "", + "customMessageBg": "customMsgBg", + "customMessageText": "", + "customMessageLabel": "#7e57c2", "toolPendingBg": "toolPendingBg", "toolSuccessBg": "toolSuccessBg", "toolErrorBg": "toolErrorBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 7f060d23..f561ecb1 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -47,6 +47,9 @@ "text", "userMessageBg", "userMessageText", + "customMessageBg", + "customMessageText", + "customMessageLabel", "toolPendingBg", "toolSuccessBg", "toolErrorBg", @@ -122,6 +125,18 @@ "$ref": "#/$defs/colorValue", "description": "User message text color" }, + "customMessageBg": { + "$ref": "#/$defs/colorValue", + "description": "Custom message background (hook-injected messages)" + }, + "customMessageText": { + "$ref": "#/$defs/colorValue", + "description": "Custom message text color" + }, + "customMessageLabel": { + "$ref": "#/$defs/colorValue", + "description": "Custom message type label color" + }, "toolPendingBg": { "$ref": "#/$defs/colorValue", "description": "Tool execution box (pending state)" diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 8f56b8e6..915182f0 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -34,9 +34,12 @@ const ThemeJsonSchema = Type.Object({ muted: ColorValueSchema, dim: ColorValueSchema, text: ColorValueSchema, - // Backgrounds & Content Text (7 colors) + // Backgrounds & Content Text (10 colors) userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, + customMessageBg: ColorValueSchema, + customMessageText: ColorValueSchema, + customMessageLabel: ColorValueSchema, toolPendingBg: ColorValueSchema, toolSuccessBg: ColorValueSchema, toolErrorBg: ColorValueSchema, @@ -95,6 +98,8 @@ export type ThemeColor = | "dim" | "text" | "userMessageText" + | "customMessageText" + | "customMessageLabel" | "toolTitle" | "toolOutput" | "mdHeading" @@ -127,7 +132,7 @@ export type ThemeColor = | "thinkingXhigh" | "bashMode"; -export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; +export type ThemeBg = "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; type ColorMode = "truecolor" | "256color"; @@ -482,7 +487,13 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme { const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); const fgColors: Record = {} as Record; const bgColors: Record = {} as Record; - const bgColorKeys: Set = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]); + const bgColorKeys: Set = new Set([ + "userMessageBg", + "customMessageBg", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", + ]); for (const [key, value] of Object.entries(resolvedColors)) { if (bgColorKeys.has(key)) { bgColors[key as ThemeBg] = value; From ba11622d0c3e78ce214173e4ce6209973b0ed975 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 23:32:22 +0100 Subject: [PATCH 021/161] Wire up hook custom message renderers to TUI - CustomMessageComponent accepts optional CustomMessageRenderer - If hook provides a renderer, call it and use returned Component inside Box - Falls back to default rendering (label + Markdown) if no renderer or null returned - renderSessionContext gets renderer from hookRunner and passes to component --- AGENTS.md | 1 + .../coding-agent/src/modes/interactive/interactive-mode.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3990816f..f509ba3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,3 +89,4 @@ Location: `packages/coding-agent/CHANGELOG.md`, `packages/ai/CHANGELOG.md`, `pac 5. **Add new [Unreleased] section** at top of CHANGELOG.md for next cycle, commit it +**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). \ No newline at end of file diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 86ca9837..ebd013f2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1054,7 +1054,8 @@ export class InteractiveMode { // Check if this is a custom_message entry if (entry?.type === "custom_message") { if (entry.display) { - this.chatContainer.addChild(new CustomMessageComponent(entry)); + const renderer = this.session.hookRunner?.getCustomMessageRenderer(entry.customType); + this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); } continue; } From d43a5e47a1cddd8b8c6766d5a5b91af47db8746a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 26 Dec 2025 23:35:08 +0100 Subject: [PATCH 022/161] Update CHANGELOG with CustomMessageEntry and hook rendering API --- packages/coding-agent/CHANGELOG.md | 8 ++- .../interactive/components/custom-message.ts | 58 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/custom-message.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a77e38da..0c7fb9c8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -27,8 +27,13 @@ - `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile` - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) - Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction) +- **Hook API**: + - New `pi.renderCustomMessage(customType, renderer)` to register custom renderers for `CustomMessageEntry` + - `CustomMessageRenderer` type: `(entry, options, theme) => Component | null` + - Renderers return inner content; the TUI wraps it in a styled Box - **SessionManager**: - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) +- **Themes**: Custom themes must add `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens ### Added @@ -38,8 +43,9 @@ - **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs - **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY` -- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook state persistence, `LabelEntry` for user-defined bookmarks +- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook state persistence, `CustomMessageEntry` for hook-injected context messages, `LabelEntry` for user-defined bookmarks - **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export. +- **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden. ### Fixed diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts new file mode 100644 index 00000000..5e952c32 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -0,0 +1,58 @@ +import type { TextContent } from "@mariozechner/pi-ai"; +import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import type { CustomMessageRenderer } from "../../../core/hooks/types.js"; +import type { CustomMessageEntry } from "../../../core/session-manager.js"; +import { getMarkdownTheme, theme } from "../theme/theme.js"; + +/** + * Component that renders a custom message entry from hooks. + * Uses distinct styling to differentiate from user messages. + */ +export class CustomMessageComponent extends Container { + constructor(entry: CustomMessageEntry, customRenderer?: CustomMessageRenderer) { + super(); + + this.addChild(new Spacer(1)); + + // Create box with purple background + const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + + // Try custom renderer first + if (customRenderer) { + try { + const component = customRenderer(entry, { expanded: false }, theme); + if (component) { + box.addChild(component); + this.addChild(box); + return; + } + } catch { + // Fall through to default rendering + } + } + + // Default rendering: label + content + const label = theme.fg("customMessageLabel", `\x1b[1m[${entry.customType}]\x1b[22m`); + box.addChild(new Text(label, 0, 0)); + box.addChild(new Spacer(1)); + + // Extract text content + let text: string; + if (typeof entry.content === "string") { + text = entry.content; + } else { + text = entry.content + .filter((c): c is TextContent => c.type === "text") + .map((c) => c.text) + .join("\n"); + } + + box.addChild( + new Markdown(text, 0, 0, getMarkdownTheme(), { + color: (text: string) => theme.fg("customMessageText", text), + }), + ); + + this.addChild(box); + } +} From ba185b0571c5fb281a9acacd266ddeba30aa0fb3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 00:52:10 +0100 Subject: [PATCH 023/161] Hook API: replace send() with sendMessage(), add appendEntry() and registerCommand() Breaking changes to Hook API: - pi.send(text, attachments?) replaced with pi.sendMessage(message, triggerTurn?) - Creates CustomMessageEntry instead of user messages - Properly handles queuing during streaming via agent loop - Supports optional turn triggering when idle - New pi.appendEntry(customType, data?) for hook state persistence - New pi.registerCommand(name, options) for custom slash commands - Handler types renamed: SendHandler -> SendMessageHandler, new AppendEntryHandler Implementation: - AgentSession.sendHookMessage() handles all three cases: - Streaming: queues message with _hookData marker, agent loop processes it - Not streaming + triggerTurn: appends to state/session, calls agent.continue() - Not streaming + no trigger: appends to state/session only - message_end handler routes based on _hookData presence to correct persistence - HookRunner gains getRegisteredCommands() and getCommand() methods New types: HookMessage, RegisteredCommand, CommandContext --- packages/coding-agent/CHANGELOG.md | 8 +- .../coding-agent/docs/session-tree-plan.md | 63 +++++++++++- .../examples/hooks/file-trigger.ts | 9 +- .../coding-agent/src/core/agent-session.ts | 81 ++++++++++++++- packages/coding-agent/src/core/hooks/index.ts | 11 ++- .../coding-agent/src/core/hooks/loader.ts | 67 +++++++++---- .../coding-agent/src/core/hooks/runner.ts | 46 ++++++++- packages/coding-agent/src/core/hooks/types.ts | 99 +++++++++++++++++-- packages/coding-agent/src/core/sdk.ts | 24 +++-- .../src/modes/interactive/interactive-mode.ts | 36 +++---- packages/coding-agent/src/modes/print-mode.ts | 11 ++- .../coding-agent/src/modes/rpc/rpc-mode.ts | 18 ++-- .../test/compaction-hooks.test.ts | 16 ++- 13 files changed, 412 insertions(+), 77 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c7fb9c8..b68ff18a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -28,9 +28,14 @@ - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) - Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction) - **Hook API**: - - New `pi.renderCustomMessage(customType, renderer)` to register custom renderers for `CustomMessageEntry` + - `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages + - New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context) + - New `pi.registerCommand(name, options)` to register custom slash commands + - New `pi.registerCustomMessageRenderer(customType, renderer)` to register custom renderers for `CustomMessageEntry` - `CustomMessageRenderer` type: `(entry, options, theme) => Component | null` - Renderers return inner content; the TUI wraps it in a styled Box + - New types: `HookMessage`, `RegisteredCommand`, `CommandContext` + - Handler types renamed: `SendHandler` → `SendMessageHandler`, new `AppendEntryHandler` - **SessionManager**: - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) - **Themes**: Custom themes must add `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens @@ -46,6 +51,7 @@ - **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook state persistence, `CustomMessageEntry` for hook-injected context messages, `LabelEntry` for user-defined bookmarks - **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export. - **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden. +- **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering. ### Fixed diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 204cb598..b99fdb8c 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -122,10 +122,67 @@ Behavior: - [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager - [x] `buildSessionContext()` includes custom_message entries as user messages - [x] Exported from main index -- [ ] TUI rendering: +- [x] TUI rendering: - `display: false` - hidden entirely - - `display: true` - baseline renderer (content with different bg/fg color) - - Custom renderer defined by the hook that contributes it (future) + - `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors) + - [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers + - [x] Renderer returns inner Component, TUI wraps in styled Box + +### Hook API Changes + +**Renamed:** +- `renderCustomMessage()` → `registerCustomMessageRenderer()` + +**New: `sendMessage()`** + +Replaces `send()`. Always creates CustomMessageEntry, never user messages. + +```typescript +type HookMessage = Pick, 'customType' | 'content' | 'display' | 'details'>; + +sendMessage(message: HookMessage, triggerTurn?: boolean): void; +``` + +Behavior: +- If streaming → queue, append after turn ends (never triggers turn) +- If idle AND `triggerTurn: true` → append and trigger turn +- If idle AND `triggerTurn: false` (default) → just append, no turn +- TUI updates if `display: true` + +For hook state (CustomEntry), use `sessionManager.appendCustomEntry()` directly. + +**New: `registerCommand()`** + +```typescript +interface CommandContext { + args: string; // Everything after /commandname + session: LimitedAgentSession; // No prompt(), use sendMessage() + ui: HookUIContext; + exec(command: string, args: string[], options?: ExecOptions): Promise; +} + +registerCommand(name: string, options: { + description?: string; + handler: (ctx: CommandContext) => Promise; +}): void; +``` + +Handler return: +- `void` - command completed +- `string` - text to send as prompt (like file-based slash commands) + +**New: `ui.custom()`** + +For arbitrary hook UI with keyboard focus: + +```typescript +interface HookUIContext { + // ... existing: select, confirm, input, notify + + /** Show custom component with keyboard focus. Call done() when finished. */ + custom(component: Component, done: () => void): void; +} +``` See also: `CustomEntry` for storing hook state that does NOT participate in context. diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/hooks/file-trigger.ts index df98d2a2..74f6314b 100644 --- a/packages/coding-agent/examples/hooks/file-trigger.ts +++ b/packages/coding-agent/examples/hooks/file-trigger.ts @@ -21,7 +21,14 @@ export default function (pi: HookAPI) { try { const content = fs.readFileSync(triggerFile, "utf-8").trim(); if (content) { - pi.send(`External trigger: ${content}`); + pi.sendMessage( + { + customType: "file-trigger", + content: `External trigger: ${content}`, + display: true, + }, + true, // triggerTurn - get LLM to respond + ); fs.writeFileSync(triggerFile, ""); // Clear after reading } } catch { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0281e5d5..e7a352d4 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,7 +14,7 @@ */ import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Message, Model, TextContent } from "@mariozechner/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; @@ -27,7 +27,7 @@ import { } from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; -import type { HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; +import type { HookMessage, HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; import type { BashExecutionMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import type { CompactionEntry, SessionManager } from "./session-manager.js"; @@ -101,6 +101,13 @@ export interface SessionStats { cost: number; } +/** Internal marker for hook messages queued through the agent loop */ +interface HookMessageData { + customType: string; + display: boolean; + details?: unknown; +} + // ============================================================================ // Constants // ============================================================================ @@ -211,7 +218,21 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { - this.sessionManager.appendMessage(event.message); + // Check if this is a hook message (has _hookData marker) + type HookAppMessage = AppMessage & { _hookData?: HookMessageData; content: (TextContent | ImageContent)[] }; + const hookMessage = event.message as HookAppMessage; + if (hookMessage._hookData) { + // Persist as CustomMessageEntry + this.sessionManager.appendCustomMessageEntry( + hookMessage._hookData.customType, + hookMessage.content, + hookMessage._hookData.display, + hookMessage._hookData.details, + ); + } else { + // Regular message - persist as SessionMessageEntry + this.sessionManager.appendMessage(event.message); + } // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { @@ -473,6 +494,60 @@ export class AgentSession { }); } + /** + * Send a hook message to the session. Creates a CustomMessageEntry. + * + * Handles three cases: + * - Streaming: queues message, processed when loop pulls from queue + * - Not streaming + triggerTurn: appends to state/session, starts new turn + * - Not streaming + no trigger: appends to state/session, no turn + * + * @param message Hook message with customType, content, display, details + * @param triggerTurn If true and not streaming, triggers a new LLM turn + */ + async sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise { + // Normalize content to array format for the AppMessage + const content: (TextContent | ImageContent)[] = + typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content; + + // Create AppMessage with _hookData marker for routing in message_end handler + const appMessage: AppMessage & { _hookData: HookMessageData } = { + role: "user", + content, + timestamp: Date.now(), + _hookData: { + customType: message.customType, + display: message.display, + details: message.details, + }, + }; + + if (this.isStreaming) { + // Queue for processing by agent loop + await this.agent.queueMessage(appMessage); + } else if (triggerTurn) { + // Append to agent state and session, then trigger a turn + this.agent.appendMessage(appMessage); + this.sessionManager.appendCustomMessageEntry( + message.customType, + message.content, + message.display, + message.details, + ); + // Start a new turn - agent.continue() works because last message is user role + await this.agent.continue(); + } else { + // Just append to agent state and session, no turn + this.agent.appendMessage(appMessage); + this.sessionManager.appendCustomMessageEntry( + message.customType, + message.content, + message.display, + message.details, + ); + } + } + /** * Clear queued messages and return them. * Useful for restoring to editor when user aborts. diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 7524b605..1e0a5488 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -1,4 +1,11 @@ -export { discoverAndLoadHooks, type LoadedHook, type LoadHooksResult, loadHooks, type SendHandler } from "./loader.js"; +export { + type AppendEntryHandler, + discoverAndLoadHooks, + type LoadedHook, + type LoadHooksResult, + loadHooks, + type SendMessageHandler, +} from "./loader.js"; export { type HookErrorListener, HookRunner } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type { @@ -17,9 +24,11 @@ export type { HookEvent, HookEventContext, HookFactory, + HookMessage, HookUIContext, LsToolResultEvent, ReadToolResultEvent, + RegisteredCommand, SessionEvent, SessionEventResult, ToolCallEvent, diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 5f4fef75..736ed673 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -7,10 +7,9 @@ import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import type { Attachment } from "@mariozechner/pi-agent-core"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; -import type { CustomMessageRenderer, HookAPI, HookFactory } from "./types.js"; +import type { CustomMessageRenderer, HookAPI, HookFactory, HookMessage, RegisteredCommand } from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -47,9 +46,14 @@ function getAliases(): Record { type HandlerFn = (...args: unknown[]) => Promise; /** - * Send handler type for pi.send(). + * Send message handler type for pi.sendMessage(). */ -export type SendHandler = (text: string, attachments?: Attachment[]) => void; +export type SendMessageHandler = (message: HookMessage, triggerTurn?: boolean) => void; + +/** + * Append entry handler type for pi.appendEntry(). + */ +export type AppendEntryHandler = (customType: string, data?: T) => void; /** * Registered handlers for a loaded hook. @@ -63,8 +67,12 @@ export interface LoadedHook { handlers: Map; /** Map of customType to custom message renderer */ customMessageRenderers: Map; - /** Set the send handler for this hook's pi.send() */ - setSendHandler: (handler: SendHandler) => void; + /** Map of command name to registered command */ + commands: Map; + /** Set the send message handler for this hook's pi.sendMessage() */ + setSendMessageHandler: (handler: SendMessageHandler) => void; + /** Set the append entry handler for this hook's pi.appendEntry() */ + setAppendEntryHandler: (handler: AppendEntryHandler) => void; } /** @@ -112,18 +120,24 @@ function resolveHookPath(hookPath: string, cwd: string): string { } /** - * Create a HookAPI instance that collects handlers and renderers. - * Returns the API, renderers map, and a function to set the send handler later. + * Create a HookAPI instance that collects handlers, renderers, and commands. + * Returns the API, maps, and a function to set the send message handler later. */ function createHookAPI(handlers: Map): { api: HookAPI; customMessageRenderers: Map; - setSendHandler: (handler: SendHandler) => void; + commands: Map; + setSendMessageHandler: (handler: SendMessageHandler) => void; + setAppendEntryHandler: (handler: AppendEntryHandler) => void; } { - let sendHandler: SendHandler = () => { + let sendMessageHandler: SendMessageHandler = () => { + // Default no-op until mode sets the handler + }; + let appendEntryHandler: AppendEntryHandler = () => { // Default no-op until mode sets the handler }; const customMessageRenderers = new Map(); + const commands = new Map(); const api: HookAPI = { on(event: string, handler: HandlerFn): void { @@ -131,19 +145,29 @@ function createHookAPI(handlers: Map): { list.push(handler); handlers.set(event, list); }, - send(text: string, attachments?: Attachment[]): void { - sendHandler(text, attachments); + sendMessage(message: HookMessage, triggerTurn?: boolean): void { + sendMessageHandler(message, triggerTurn); }, - renderCustomMessage(customType: string, renderer: CustomMessageRenderer): void { + appendEntry(customType: string, data?: T): void { + appendEntryHandler(customType, data); + }, + registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void { customMessageRenderers.set(customType, renderer); }, + registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { + commands.set(name, { name, ...options }); + }, } as HookAPI; return { api, customMessageRenderers, - setSendHandler: (handler: SendHandler) => { - sendHandler = handler; + commands, + setSendMessageHandler: (handler: SendMessageHandler) => { + sendMessageHandler = handler; + }, + setAppendEntryHandler: (handler: AppendEntryHandler) => { + appendEntryHandler = handler; }, }; } @@ -172,13 +196,22 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo // Create handlers map and API const handlers = new Map(); - const { api, customMessageRenderers, setSendHandler } = createHookAPI(handlers); + const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = + createHookAPI(handlers); // Call factory to register handlers factory(api); return { - hook: { path: hookPath, resolvedPath, handlers, customMessageRenderers, setSendHandler }, + hook: { + path: hookPath, + resolvedPath, + handlers, + customMessageRenderers, + commands, + setSendMessageHandler, + setAppendEntryHandler, + }, error: null, }; } catch (err) { diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 2d4c9d2a..78277f85 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -3,7 +3,7 @@ */ import { spawn } from "node:child_process"; -import type { LoadedHook, SendHandler } from "./loader.js"; +import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { CustomMessageRenderer, ExecOptions, @@ -12,6 +12,7 @@ import type { HookEvent, HookEventContext, HookUIContext, + RegisteredCommand, SessionEvent, SessionEventResult, ToolCallEvent, @@ -164,12 +165,22 @@ export class HookRunner { } /** - * Set the send handler for all hooks' pi.send(). + * Set the send message handler for all hooks' pi.sendMessage(). * Call this when the mode initializes. */ - setSendHandler(handler: SendHandler): void { + setSendMessageHandler(handler: SendMessageHandler): void { for (const hook of this.hooks) { - hook.setSendHandler(handler); + hook.setSendMessageHandler(handler); + } + } + + /** + * Set the append entry handler for all hooks' pi.appendEntry(). + * Call this when the mode initializes. + */ + setAppendEntryHandler(handler: AppendEntryHandler): void { + for (const hook of this.hooks) { + hook.setAppendEntryHandler(handler); } } @@ -218,6 +229,33 @@ export class HookRunner { return undefined; } + /** + * Get all registered commands from all hooks. + */ + getRegisteredCommands(): RegisteredCommand[] { + const commands: RegisteredCommand[] = []; + for (const hook of this.hooks) { + for (const command of hook.commands.values()) { + commands.push(command); + } + } + return commands; + } + + /** + * Get a registered command by name. + * Returns the first command found across all hooks, or undefined if none. + */ + getCommand(name: string): RegisteredCommand | undefined { + for (const hook of this.hooks) { + const command = hook.commands.get(name); + if (command) { + return command; + } + } + return undefined; + } + /** * Create the event context for handlers. */ diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 6b46a903..28902cb7 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -5,7 +5,7 @@ * and interact with the user via UI primitives. */ -import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; +import type { AppMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; @@ -388,9 +388,50 @@ export type CustomMessageRenderer = ( theme: Theme, ) => Component | null; +/** + * Message type for hooks to send. Creates CustomMessageEntry in the session. + */ +export type HookMessage = Pick, "customType" | "content" | "display" | "details">; + +/** + * Context passed to command handlers. + */ +export interface CommandContext { + /** Arguments after the command name */ + args: string; + /** UI methods for user interaction */ + ui: HookUIContext; + /** Execute a command and return stdout/stderr/code */ + exec(command: string, args: string[], options?: ExecOptions): Promise; + /** Whether UI is available (false in print mode) */ + hasUI: boolean; + /** Current working directory */ + cwd: string; + /** Session manager for reading/writing session entries */ + sessionManager: SessionManager; + /** Model registry for API keys */ + modelRegistry: ModelRegistry; + /** + * Send a custom message to the session. + * If streaming, queued and appended after turn ends. + * If idle and triggerTurn=true, appends and triggers a new turn. + * If idle and triggerTurn=false (default), just appends. + */ + sendMessage(message: HookMessage, triggerTurn?: boolean): void; +} + +/** + * Command registration options. + */ +export interface RegisteredCommand { + name: string; + description?: string; + handler: (ctx: CommandContext) => Promise; +} + /** * HookAPI passed to hook factory functions. - * Hooks use pi.on() to subscribe to events and pi.send() to inject messages. + * Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages. */ export interface HookAPI { // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything @@ -403,18 +444,62 @@ export interface HookAPI { on(event: "tool_result", handler: HookHandler): void; /** - * Send a message to the agent. - * If the agent is streaming, the message is queued. - * If the agent is idle, a new agent loop is started. + * Send a custom message to the session. Creates a CustomMessageEntry that + * participates in LLM context and can be displayed in the TUI. + * + * Use this when you want the LLM to see the message content. + * For hook state that should NOT be sent to the LLM, use appendEntry() instead. + * + * @param message - The message to send + * @param message.customType - Identifier for your hook (used for filtering on reload) + * @param message.content - Message content (string or TextContent/ImageContent array) + * @param message.display - Whether to show in TUI (true = styled display, false = hidden) + * @param message.details - Optional hook-specific metadata (not sent to LLM) + * @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false. + * If agent is streaming, message is queued and triggerTurn is ignored. */ - send(text: string, attachments?: Attachment[]): void; + sendMessage(message: HookMessage, triggerTurn?: boolean): void; + + /** + * Append a custom entry to the session for hook state persistence. + * Creates a CustomEntry that does NOT participate in LLM context. + * + * Use this to store hook-specific data that should persist across session reloads + * but should NOT be sent to the LLM. On reload, scan session entries for your + * customType to reconstruct hook state. + * + * For messages that SHOULD be sent to the LLM, use sendMessage() instead. + * + * @param customType - Identifier for your hook (used for filtering on reload) + * @param data - Hook-specific data to persist (must be JSON-serializable) + * + * @example + * // Store permission state + * pi.appendEntry("permissions", { level: "full", grantedAt: Date.now() }); + * + * // On reload, reconstruct state from entries + * pi.on("session", async (event, ctx) => { + * if (event.reason === "start") { + * const entries = event.sessionManager.getEntries(); + * const myEntries = entries.filter(e => e.type === "custom" && e.customType === "permissions"); + * // Reconstruct state from myEntries... + * } + * }); + */ + appendEntry(customType: string, data?: T): void; /** * Register a custom renderer for CustomMessageEntry with a specific customType. * The renderer is called when rendering the entry in the TUI. * Return null to use the default renderer. */ - renderCustomMessage(customType: string, renderer: CustomMessageRenderer): void; + registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void; + + /** + * Register a custom slash command. + * Handler receives CommandContext and can return a string to send as prompt. + */ + registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index c78a6bd9..8397d2e9 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -341,7 +341,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa return definitions.map((def) => { const handlers = new Map Promise>>(); const customMessageRenderers = new Map(); - let sendHandler: (text: string, attachments?: any[]) => void = () => {}; + const commands = new Map(); + let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {}; + let appendEntryHandler: (customType: string, data?: any) => void = () => {}; const api = { on: (event: string, handler: (...args: unknown[]) => Promise) => { @@ -349,12 +351,18 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa list.push(handler); handlers.set(event, list); }, - send: (text: string, attachments?: any[]) => { - sendHandler(text, attachments); + sendMessage: (message: any, triggerTurn?: boolean) => { + sendMessageHandler(message, triggerTurn); }, - renderCustomMessage: (customType: string, renderer: any) => { + appendEntry: (customType: string, data?: any) => { + appendEntryHandler(customType, data); + }, + registerCustomMessageRenderer: (customType: string, renderer: any) => { customMessageRenderers.set(customType, renderer); }, + registerCommand: (name: string, options: any) => { + commands.set(name, { name, ...options }); + }, }; def.factory(api as any); @@ -364,8 +372,12 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa resolvedPath: def.path ?? "", handlers, customMessageRenderers, - setSendHandler: (handler: (text: string, attachments?: any[]) => void) => { - sendHandler = handler; + commands, + setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => { + sendMessageHandler = handler; + }, + setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => { + appendEntryHandler = handler; }, }; }); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ebd013f2..1f2c8d3a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentState, AppMessage, Attachment } from "@mariozechner/pi-agent-core"; +import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -370,9 +370,24 @@ export class InteractiveMode { this.showHookError(error.hookPath, error.error); }); - // Set up send handler for pi.send() - hookRunner.setSendHandler((text, attachments) => { - this.handleHookSend(text, attachments); + // Set up handlers for pi.sendMessage() and pi.appendEntry() + hookRunner.setSendMessageHandler((message, triggerTurn) => { + const wasStreaming = this.session.isStreaming; + this.session + .sendHookMessage(message, triggerTurn) + .then(() => { + // For non-streaming cases with display=true, update UI + // (streaming cases update via message_end event) + if (!wasStreaming && message.display) { + this.rebuildChatFromMessages(); + } + }) + .catch((err) => { + this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }); + hookRunner.setAppendEntryHandler((customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); }); // Show loaded hooks @@ -534,19 +549,6 @@ export class InteractiveMode { * Handle pi.send() from hooks. * If streaming, queue the message. Otherwise, start a new agent loop. */ - private handleHookSend(text: string, attachments?: Attachment[]): void { - if (this.session.isStreaming) { - // Queue the message for later (note: attachments are lost when queuing) - this.session.queueMessage(text); - this.updatePendingMessagesDisplay(); - } else { - // Start a new agent loop immediately - this.session.prompt(text, { attachments }).catch((err) => { - this.showError(err instanceof Error ? err.message : String(err)); - }); - } - } - // ========================================================================= // Key Handlers // ========================================================================= diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index af1e48d8..9375dd5c 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -39,9 +39,14 @@ export async function runPrintMode( hookRunner.onError((err) => { console.error(`Hook error (${err.hookPath}): ${err.error}`); }); - // No-op send handler for print mode (single-shot, no async messages) - hookRunner.setSendHandler(() => { - console.error("Warning: pi.send() is not supported in print mode"); + // Set up handlers - sendHookMessage handles queuing/direct append as needed + hookRunner.setSendMessageHandler((message, triggerTurn) => { + session.sendHookMessage(message, triggerTurn).catch((e) => { + console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); + }); + }); + hookRunner.setAppendEntryHandler((customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); }); // Emit session event await hookRunner.emit({ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index eb0ad807..4cf17630 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -131,16 +131,14 @@ export async function runRpcMode(session: AgentSession): Promise { hookRunner.onError((err) => { output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); }); - // Set up send handler for pi.send() - hookRunner.setSendHandler((text, attachments) => { - // In RPC mode, just queue or prompt based on streaming state - if (session.isStreaming) { - session.queueMessage(text); - } else { - session.prompt(text, { attachments }).catch((e) => { - output(error(undefined, "hook_send", e.message)); - }); - } + // Set up handlers for pi.sendMessage() and pi.appendEntry() + hookRunner.setSendMessageHandler((message, triggerTurn) => { + session.sendHookMessage(message, triggerTurn).catch((e) => { + output(error(undefined, "hook_send", e.message)); + }); + }); + hookRunner.setAppendEntryHandler((customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); }); // Emit session event await hookRunner.emit({ diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index b24618e8..6e9a071d 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -64,7 +64,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { resolvedPath: "/test/test-hook.ts", handlers, customMessageRenderers: new Map(), - setSendHandler: () => {}, + commands: new Map(), + setSendMessageHandler: () => {}, + setAppendEntryHandler: () => {}, }; } @@ -240,7 +242,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ]), customMessageRenderers: new Map(), - setSendHandler: () => {}, + commands: new Map(), + setSendMessageHandler: () => {}, + setAppendEntryHandler: () => {}, }; createSession([throwingHook]); @@ -284,7 +288,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ]), customMessageRenderers: new Map(), - setSendHandler: () => {}, + commands: new Map(), + setSendMessageHandler: () => {}, + setAppendEntryHandler: () => {}, }; const hook2: LoadedHook = { @@ -307,7 +313,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ]), customMessageRenderers: new Map(), - setSendHandler: () => {}, + commands: new Map(), + setSendMessageHandler: () => {}, + setAppendEntryHandler: () => {}, }; createSession([hook1, hook2]); From c8d9382aaa9e4eafff16eb74a810502cea1f41cf Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 01:13:52 +0100 Subject: [PATCH 024/161] Move hook command execution to AgentSession.prompt() Hook commands registered via pi.registerCommand() are now handled in AgentSession.prompt() alongside file-based slash commands. This: - Removes duplicate tryHandleHookCommand from interactive-mode and rpc-mode - All modes (interactive, RPC, print) share the same command handling logic - AgentSession._tryExecuteHookCommand() builds CommandContext using: - UI context from hookRunner (set by mode) - sessionManager, modelRegistry from AgentSession - sendMessage via sendHookMessage - exec via exported execCommand - Handler returning string uses it as prompt, undefined returns early Also: - Export execCommand from hooks/runner.ts - Add getUIContext() and getHasUI() to HookRunner - Make HookRunner.emitError() public for error reporting --- .../coding-agent/docs/session-tree-plan.md | 45 ++++++++--- .../coding-agent/src/core/agent-session.ts | 80 ++++++++++++++++++- packages/coding-agent/src/core/hooks/index.ts | 4 +- .../coding-agent/src/core/hooks/runner.ts | 29 ++++++- .../src/modes/interactive/interactive-mode.ts | 8 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 +- 6 files changed, 149 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index b99fdb8c..26d9b01c 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -133,7 +133,7 @@ Behavior: **Renamed:** - `renderCustomMessage()` → `registerCustomMessageRenderer()` -**New: `sendMessage()`** +**New: `sendMessage()` ✅** Replaces `send()`. Always creates CustomMessageEntry, never user messages. @@ -143,34 +143,57 @@ type HookMessage = Pick, 'customType' | 'cont sendMessage(message: HookMessage, triggerTurn?: boolean): void; ``` -Behavior: -- If streaming → queue, append after turn ends (never triggers turn) -- If idle AND `triggerTurn: true` → append and trigger turn -- If idle AND `triggerTurn: false` (default) → just append, no turn -- TUI updates if `display: true` +Implementation: +- Uses agent's queue mechanism with `_hookData` marker on AppMessage +- `message_end` handler routes based on marker presence +- `AgentSession.sendHookMessage()` handles three cases: + - Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end` + - Not streaming + triggerTurn: direct append + `agent.continue()` + - Not streaming + no trigger: direct append only +- TUI updates via event (streaming) or explicit rebuild (non-streaming) -For hook state (CustomEntry), use `sessionManager.appendCustomEntry()` directly. +**New: `appendEntry()` ✅** -**New: `registerCommand()`** +For hook state persistence (NOT in LLM context): + +```typescript +appendEntry(customType: string, data?: unknown): void; +``` + +Calls `sessionManager.appendCustomEntry()` directly. + +**New: `registerCommand()` (types ✅, wiring TODO)** ```typescript interface CommandContext { args: string; // Everything after /commandname - session: LimitedAgentSession; // No prompt(), use sendMessage() ui: HookUIContext; + hasUI: boolean; + cwd: string; + sessionManager: SessionManager; + modelRegistry: ModelRegistry; + sendMessage: HookAPI['sendMessage']; exec(command: string, args: string[], options?: ExecOptions): Promise; } registerCommand(name: string, options: { description?: string; - handler: (ctx: CommandContext) => Promise; + handler: (ctx: CommandContext) => Promise; }): void; ``` Handler return: -- `void` - command completed +- `undefined` - command completed - `string` - text to send as prompt (like file-based slash commands) +Wiring (all in AgentSession.prompt()): +- [x] Add hook commands to autocomplete in interactive-mode +- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution +- [x] Build CommandContext with ui (from hookRunner), exec, sessionManager, etc. +- [x] If handler returns string, use as prompt text +- [x] If handler returns undefined, return early (no LLM call) +- [x] Works for all modes (interactive, RPC, print) via shared AgentSession + **New: `ui.custom()`** For arbitrary hook UI with keyboard focus: diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e7a352d4..cf0942f8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -27,7 +27,16 @@ import { } from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; -import type { HookMessage, HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; +import { + type CommandContext, + type ExecOptions, + execCommand, + type HookMessage, + type HookRunner, + type SessionEventResult, + type TurnEndEvent, + type TurnStartEvent, +} from "./hooks/index.js"; import type { BashExecutionMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import type { CompactionEntry, SessionManager } from "./session-manager.js"; @@ -441,6 +450,7 @@ export class AgentSession { /** * Send a prompt to the agent. * - Validates model and API key before sending + * - Handles hook commands (registered via pi.registerCommand) * - Expands file-based slash commands by default * @throws Error if no model selected or no API key available */ @@ -450,6 +460,20 @@ export class AgentSession { const expandCommands = options?.expandSlashCommands ?? true; + // Handle hook commands first (if enabled and text is a slash command) + if (expandCommands && text.startsWith("/")) { + const result = await this._tryExecuteHookCommand(text); + if (result.handled) { + if (result.prompt) { + // Hook returned text to use as prompt + text = result.prompt; + } else { + // Hook command executed, no prompt to send + return; + } + } + } + // Validate model if (!this.model) { throw new Error( @@ -474,13 +498,65 @@ export class AgentSession { await this._checkCompaction(lastAssistant, false); } - // Expand slash commands if requested + // Expand file-based slash commands if requested const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text; await this.agent.prompt(expandedText, options?.attachments); await this.waitForRetry(); } + /** + * Try to execute a hook command. Returns whether it was handled and optional prompt text. + */ + private async _tryExecuteHookCommand(text: string): Promise<{ handled: boolean; prompt?: string }> { + if (!this._hookRunner) return { handled: false }; + + // Parse command name and args + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); + + const command = this._hookRunner.getCommand(commandName); + if (!command) return { handled: false }; + + // Get UI context from hook runner (set by mode) + const uiContext = this._hookRunner.getUIContext(); + if (!uiContext) return { handled: false }; + + // Build command context + const cwd = process.cwd(); + const ctx: CommandContext = { + args, + ui: uiContext, + hasUI: this._hookRunner.getHasUI(), + cwd, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, + sendMessage: (message, triggerTurn) => { + this.sendHookMessage(message, triggerTurn).catch(() => { + // Error handling is done in sendHookMessage + }); + }, + exec: (cmd: string, cmdArgs: string[], options?: ExecOptions) => execCommand(cmd, cmdArgs, cwd, options), + }; + + try { + const result = await command.handler(ctx); + if (typeof result === "string") { + return { handled: true, prompt: result }; + } + return { handled: true }; + } catch (err) { + // Emit error via hook runner + this._hookRunner.emitError({ + hookPath: `command:${commandName}`, + event: "command", + error: err instanceof Error ? err.message : String(err), + }); + return { handled: true }; + } + } + /** * Queue a message to be sent after the current response completes. * Use when agent is currently streaming. diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 1e0a5488..43901993 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -6,16 +6,18 @@ export { loadHooks, type SendMessageHandler, } from "./loader.js"; -export { type HookErrorListener, HookRunner } from "./runner.js"; +export { execCommand, type HookErrorListener, HookRunner } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, + CommandContext, CustomMessageRenderer, CustomMessageRenderOptions, CustomToolResultEvent, EditToolResultEvent, + ExecOptions, ExecResult, FindToolResultEvent, GrepToolResultEvent, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 78277f85..e2efb149 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -34,7 +34,12 @@ export type HookErrorListener = (error: HookError) => void; * Execute a command and return stdout/stderr/code. * Supports cancellation via AbortSignal and timeout. */ -async function exec(command: string, args: string[], cwd: string, options?: ExecOptions): Promise { +export async function execCommand( + command: string, + args: string[], + cwd: string, + options?: ExecOptions, +): Promise { return new Promise((resolve) => { const proc = spawn(command, args, { cwd, shell: false }); @@ -150,6 +155,20 @@ export class HookRunner { this.hasUI = hasUI; } + /** + * Get the UI context (set by mode). + */ + getUIContext(): HookUIContext | null { + return this.uiContext; + } + + /** + * Get whether UI is available. + */ + getHasUI(): boolean { + return this.hasUI; + } + /** * Get the paths of all loaded hooks. */ @@ -196,7 +215,10 @@ export class HookRunner { /** * Emit an error to all listeners. */ - private emitError(error: HookError): void { + /** + * Emit an error to all error listeners. + */ + emitError(error: HookError): void { for (const listener of this.errorListeners) { listener(error); } @@ -261,7 +283,8 @@ export class HookRunner { */ private createContext(): HookEventContext { return { - exec: (command: string, args: string[], options?: ExecOptions) => exec(command, args, this.cwd, options), + exec: (command: string, args: string[], options?: ExecOptions) => + execCommand(command, args, this.cwd, options), ui: this.uiContext, hasUI: this.hasUI, cwd: this.cwd, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1f2c8d3a..fb320316 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -179,9 +179,15 @@ export class InteractiveMode { description: cmd.description, })); + // Convert hook commands to SlashCommand format + const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({ + name: cmd.name, + description: cmd.description ?? "(hook command)", + })); + // Setup autocomplete const autocompleteProvider = new CombinedAutocompleteProvider( - [...slashCommands, ...fileSlashCommands], + [...slashCommands, ...fileSlashCommands, ...hookCommands], process.cwd(), fdPath, ); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 4cf17630..3840fb61 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -182,10 +182,10 @@ export async function runRpcMode(session: AgentSession): Promise { case "prompt": { // Don't await - events will stream + // Hook commands and file slash commands are handled in session.prompt() session .prompt(command.message, { attachments: command.attachments, - expandSlashCommands: false, }) .catch((e) => output(error(id, "prompt", e.message))); return success(id, "prompt"); From 30cd723411c0eeb2fc86d2558e96115ac20d83c9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 01:21:18 +0100 Subject: [PATCH 025/161] Hook commands: remove string return, use sendMessage() for prompting - Command handler now returns Promise instead of Promise - To trigger LLM response, use sendMessage() with triggerTurn: true - Simplify _tryExecuteHookCommand to return boolean Added example hook and slash command in .pi/: - .pi/hooks/test-command.ts - /greet command using sendMessage - .pi/commands/review.md - file-based /review command --- .pi/commands/review.md | 12 +++++++ .pi/hooks/test-command.ts | 24 ++++++++++++++ .../coding-agent/docs/session-tree-plan.md | 3 +- .../coding-agent/src/core/agent-session.ts | 32 +++++++------------ packages/coding-agent/src/core/hooks/types.ts | 2 +- 5 files changed, 50 insertions(+), 23 deletions(-) create mode 100644 .pi/commands/review.md create mode 100644 .pi/hooks/test-command.ts diff --git a/.pi/commands/review.md b/.pi/commands/review.md new file mode 100644 index 00000000..3e1db779 --- /dev/null +++ b/.pi/commands/review.md @@ -0,0 +1,12 @@ +--- +description: Review a file for issues +--- +Please review the following file for potential issues, bugs, or improvements: + +$1 + +Focus on: +- Logic errors +- Edge cases +- Code style +- Performance concerns diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts new file mode 100644 index 00000000..07354c87 --- /dev/null +++ b/.pi/hooks/test-command.ts @@ -0,0 +1,24 @@ +/** + * Test hook that registers a /greet command. + * Usage: /greet [name] + */ +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; + +export default function (pi: HookAPI) { + pi.registerCommand("greet", { + description: "Send a greeting message to the LLM", + handler: async (ctx) => { + const name = ctx.args.trim() || "world"; + + // Insert a custom message and trigger LLM response + ctx.sendMessage( + { + customType: "greeting", + content: `Hello, ${name}! Please say something nice about them.`, + display: true, + }, + true, // triggerTurn - get LLM to respond + ); + }, + }); +} diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 26d9b01c..af7efc47 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -183,8 +183,7 @@ registerCommand(name: string, options: { ``` Handler return: -- `undefined` - command completed -- `string` - text to send as prompt (like file-based slash commands) +- `void` - command completed (use `sendMessage()` with `triggerTurn: true` to prompt LLM) Wiring (all in AgentSession.prompt()): - [x] Add hook commands to autocomplete in interactive-mode diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cf0942f8..45c6f3c0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -462,15 +462,10 @@ export class AgentSession { // Handle hook commands first (if enabled and text is a slash command) if (expandCommands && text.startsWith("/")) { - const result = await this._tryExecuteHookCommand(text); - if (result.handled) { - if (result.prompt) { - // Hook returned text to use as prompt - text = result.prompt; - } else { - // Hook command executed, no prompt to send - return; - } + const handled = await this._tryExecuteHookCommand(text); + if (handled) { + // Hook command executed, no prompt to send + return; } } @@ -506,10 +501,10 @@ export class AgentSession { } /** - * Try to execute a hook command. Returns whether it was handled and optional prompt text. + * Try to execute a hook command. Returns true if command was found and executed. */ - private async _tryExecuteHookCommand(text: string): Promise<{ handled: boolean; prompt?: string }> { - if (!this._hookRunner) return { handled: false }; + private async _tryExecuteHookCommand(text: string): Promise { + if (!this._hookRunner) return false; // Parse command name and args const spaceIndex = text.indexOf(" "); @@ -517,11 +512,11 @@ export class AgentSession { const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); const command = this._hookRunner.getCommand(commandName); - if (!command) return { handled: false }; + if (!command) return false; // Get UI context from hook runner (set by mode) const uiContext = this._hookRunner.getUIContext(); - if (!uiContext) return { handled: false }; + if (!uiContext) return false; // Build command context const cwd = process.cwd(); @@ -541,11 +536,8 @@ export class AgentSession { }; try { - const result = await command.handler(ctx); - if (typeof result === "string") { - return { handled: true, prompt: result }; - } - return { handled: true }; + await command.handler(ctx); + return true; } catch (err) { // Emit error via hook runner this._hookRunner.emitError({ @@ -553,7 +545,7 @@ export class AgentSession { event: "command", error: err instanceof Error ? err.message : String(err), }); - return { handled: true }; + return true; } } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 28902cb7..7a3e72f7 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -426,7 +426,7 @@ export interface CommandContext { export interface RegisteredCommand { name: string; description?: string; - handler: (ctx: CommandContext) => Promise; + handler: (ctx: CommandContext) => Promise; } /** From 357bd946c2e3588faa2794689a5626be40f235c3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 01:31:31 +0100 Subject: [PATCH 026/161] Add emitLastMessage flag to agent.continue() When calling continue() with emitLastMessage=true, the agent loop emits message_start/message_end events for the last message in context. This allows messages added outside the loop (e.g., hook messages via sendHookMessage) to trigger proper TUI rendering. Changes across packages: - packages/ai: agentLoopContinue() accepts emitLastMessage parameter - packages/agent: Agent.continue(), transports updated to pass flag - packages/coding-agent: sendHookMessage passes true when triggerTurn --- packages/agent/src/agent.ts | 13 +++++++++---- packages/agent/src/transports/AppTransport.ts | 4 ++-- packages/agent/src/transports/ProviderTransport.ts | 4 ++-- packages/agent/src/transports/types.ts | 7 ++++++- packages/ai/src/agent/agent-loop.ts | 14 +++++++++++++- packages/coding-agent/src/core/agent-session.ts | 4 ++-- 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 86febabb..214bb403 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -206,7 +206,12 @@ export class Agent { * Continue from the current context without adding a new user message. * Used for retry after overflow recovery when context already has user message or tool results. */ - async continue() { + /** + * Continue from the current context without adding a new user message. + * Used for retry after overflow recovery when context already has user message or tool results. + * @param emitLastMessage If true, emit message_start/message_end for the last message + */ + async continue(emitLastMessage?: boolean) { const messages = this._state.messages; if (messages.length === 0) { throw new Error("No messages to continue from"); @@ -217,7 +222,7 @@ export class Agent { throw new Error(`Cannot continue from message role: ${lastMessage.role}`); } - await this._runAgentLoopContinue(); + await this._runAgentLoopContinue(emitLastMessage); } /** @@ -234,10 +239,10 @@ export class Agent { /** * Internal: Continue the agent loop from current context. */ - private async _runAgentLoopContinue() { + private async _runAgentLoopContinue(emitLastMessage?: boolean) { const { llmMessages, cfg } = await this._prepareRun(); - const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal); + const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal, emitLastMessage); await this._processEvents(events); } diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts index 69b9af46..8748525c 100644 --- a/packages/agent/src/transports/AppTransport.ts +++ b/packages/agent/src/transports/AppTransport.ts @@ -380,7 +380,7 @@ export class AppTransport implements AgentTransport { } } - async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { + async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal, emitLastMessage?: boolean) { const authToken = await this.options.getAuthToken(); if (!authToken) { throw new Error("Auth token is required for AppTransport"); @@ -390,7 +390,7 @@ export class AppTransport implements AgentTransport { const context = this.buildContext(messages, cfg); const pc = this.buildLoopConfig(cfg); - for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any)) { + for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any, emitLastMessage)) { yield ev; } } diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts index 024db0e4..09240f73 100644 --- a/packages/agent/src/transports/ProviderTransport.ts +++ b/packages/agent/src/transports/ProviderTransport.ts @@ -73,12 +73,12 @@ export class ProviderTransport implements AgentTransport { } } - async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { + async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal, emitLastMessage?: boolean) { const model = this.getModel(cfg); const context = this.buildContext(messages, cfg); const pc = this.buildLoopConfig(model, cfg); - for await (const ev of agentLoopContinue(context, pc, signal)) { + for await (const ev of agentLoopContinue(context, pc, signal, undefined, emitLastMessage)) { yield ev; } } diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index 736ba0c3..baed2b81 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -28,5 +28,10 @@ export interface AgentTransport { ): AsyncIterable; /** Continue from current context (no new user message) */ - continue(messages: Message[], config: AgentRunConfig, signal?: AbortSignal): AsyncIterable; + continue( + messages: Message[], + config: AgentRunConfig, + signal?: AbortSignal, + emitLastMessage?: boolean, + ): AsyncIterable; } diff --git a/packages/ai/src/agent/agent-loop.ts b/packages/ai/src/agent/agent-loop.ts index badb1bca..059cb666 100644 --- a/packages/ai/src/agent/agent-loop.ts +++ b/packages/ai/src/agent/agent-loop.ts @@ -40,11 +40,18 @@ export function agentLoop( * Used for retry after overflow - context already has user message or tool results. * Throws if the last message is not a user message or tool result. */ +/** + * Continue an agent loop from the current context without adding a new message. + * Used for retry after overflow - context already has user message or tool results. + * Throws if the last message is not a user message or tool result. + * @param emitLastMessage If true, emit message_start/message_end for the last message in context + */ export function agentLoopContinue( context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: typeof streamSimple, + emitLastMessage?: boolean, ): EventStream { // Validate that we can continue from this context const lastMessage = context.messages[context.messages.length - 1]; @@ -63,7 +70,12 @@ export function agentLoopContinue( stream.push({ type: "agent_start" }); stream.push({ type: "turn_start" }); - // No user message events - we're continuing from existing context + + // Optionally emit events for the last message (used when message was added outside the loop) + if (emitLastMessage) { + stream.push({ type: "message_start", message: lastMessage }); + stream.push({ type: "message_end", message: lastMessage }); + } await runLoop(currentContext, newMessages, config, signal, stream, streamFn); })(); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 45c6f3c0..8839317d 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -602,8 +602,8 @@ export class AgentSession { message.display, message.details, ); - // Start a new turn - agent.continue() works because last message is user role - await this.agent.continue(); + // Start a new turn - emit message events for the hook message so TUI can render it + await this.agent.continue(true); } else { // Just append to agent state and session, no turn this.agent.appendMessage(appMessage); From 75a9c3c7147b446f8be7e22e1e4ec0b2b711b397 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 01:42:00 +0100 Subject: [PATCH 027/161] Use proper HookAppMessage type instead of _hookData marker Following the same pattern as BashExecutionMessage: - HookAppMessage has role: 'hookMessage' with customType, content, display, details - isHookAppMessage() type guard for checking message type - messageTransformer converts to user message for LLM context - TUI checks isHookAppMessage() for rendering as CustomMessageComponent This makes the API clean for anyone building on AgentSession - they can use the type guard instead of knowing about internal marker fields. --- packages/ai/src/models.generated.ts | 102 +++++++++--------- .../coding-agent/src/core/agent-session.ts | 40 +++---- packages/coding-agent/src/core/messages.ts | 31 ++++++ .../src/modes/interactive/interactive-mode.ts | 19 +++- 4 files changed, 111 insertions(+), 81 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 7bc2f673..2df269d3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6359,23 +6359,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-70b-instruct": { - id: "meta-llama/llama-3.1-70b-instruct", - name: "Meta: Llama 3.1 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-8b-instruct": { id: "meta-llama/llama-3.1-8b-instruct", name: "Meta: Llama 3.1 8B Instruct", @@ -6410,6 +6393,23 @@ export const MODELS = { contextWindow: 10000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-70b-instruct": { + id: "meta-llama/llama-3.1-70b-instruct", + name: "Meta: Llama 3.1 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,6 +6546,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6580,23 +6597,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6716,23 +6716,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4-turbo-preview": { - id: "openai/gpt-4-turbo-preview", - name: "OpenAI: GPT-4 Turbo Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 10, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo-0613": { id: "openai/gpt-3.5-turbo-0613", name: "OpenAI: GPT-3.5 Turbo (older v0613)", @@ -6750,6 +6733,23 @@ export const MODELS = { contextWindow: 4095, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4-turbo-preview": { + id: "openai/gpt-4-turbo-preview", + name: "OpenAI: GPT-4 Turbo Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-tiny": { id: "mistralai/mistral-tiny", name: "Mistral Tiny", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8839317d..9390f6ea 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -37,7 +37,7 @@ import { type TurnEndEvent, type TurnStartEvent, } from "./hooks/index.js"; -import type { BashExecutionMessage } from "./messages.js"; +import { type BashExecutionMessage, type HookAppMessage, isHookAppMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import type { CompactionEntry, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; @@ -111,12 +111,6 @@ export interface SessionStats { } /** Internal marker for hook messages queued through the agent loop */ -interface HookMessageData { - customType: string; - display: boolean; - details?: unknown; -} - // ============================================================================ // Constants // ============================================================================ @@ -228,15 +222,13 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { // Check if this is a hook message (has _hookData marker) - type HookAppMessage = AppMessage & { _hookData?: HookMessageData; content: (TextContent | ImageContent)[] }; - const hookMessage = event.message as HookAppMessage; - if (hookMessage._hookData) { + if (isHookAppMessage(event.message)) { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry( - hookMessage._hookData.customType, - hookMessage.content, - hookMessage._hookData.display, - hookMessage._hookData.details, + event.message.customType, + event.message.content, + event.message.display, + event.message.details, ); } else { // Regular message - persist as SessionMessageEntry @@ -578,16 +570,14 @@ export class AgentSession { const content: (TextContent | ImageContent)[] = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content; - // Create AppMessage with _hookData marker for routing in message_end handler - const appMessage: AppMessage & { _hookData: HookMessageData } = { - role: "user", + // Create HookAppMessage with proper role for type-safe handling + const appMessage: HookAppMessage = { + role: "hookMessage", + customType: message.customType, content, + display: message.display, + details: message.details, timestamp: Date.now(), - _hookData: { - customType: message.customType, - display: message.display, - details: message.details, - }, }; if (this.isStreaming) { @@ -596,12 +586,6 @@ export class AgentSession { } else if (triggerTurn) { // Append to agent state and session, then trigger a turn this.agent.appendMessage(appMessage); - this.sessionManager.appendCustomMessageEntry( - message.customType, - message.content, - message.display, - message.details, - ); // Start a new turn - emit message events for the hook message so TUI can render it await this.agent.continue(true); } else { diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 947801f8..12367ead 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -26,10 +26,26 @@ export interface BashExecutionMessage { timestamp: number; } +import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; + +/** + * Message type for hook-injected messages via sendMessage(). + * These are custom messages that hooks can inject into the conversation. + */ +export interface HookAppMessage { + role: "hookMessage"; + customType: string; + content: (TextContent | ImageContent)[]; + display: boolean; + details?: T; + timestamp: number; +} + // Extend CustomMessages via declaration merging declare module "@mariozechner/pi-agent-core" { interface CustomMessages { bashExecution: BashExecutionMessage; + hookMessage: HookAppMessage; } } @@ -44,6 +60,13 @@ export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashEx return (msg as BashExecutionMessage).role === "bashExecution"; } +/** + * Type guard for HookAppMessage. + */ +export function isHookAppMessage(msg: AppMessage | Message): msg is HookAppMessage { + return (msg as HookAppMessage).role === "hookMessage"; +} + // ============================================================================ // Message Formatting // ============================================================================ @@ -91,6 +114,14 @@ export function messageTransformer(messages: AppMessage[]): Message[] { timestamp: m.timestamp, }; } + if (isHookAppMessage(m)) { + // Convert hook message to user message + return { + role: "user", + content: m.content, + timestamp: m.timestamp, + }; + } // Pass through standard LLM roles if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") { return m as Message; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index fb320316..1a718dd4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -28,7 +28,7 @@ import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "../../core/custom-tools/index.js"; import type { HookUIContext } from "../../core/hooks/index.js"; -import { isBashExecutionMessage } from "../../core/messages.js"; +import { isBashExecutionMessage, isHookAppMessage } from "../../core/messages.js"; import { getLatestCompactionEntry, type SessionContext, @@ -1016,7 +1016,22 @@ export class InteractiveMode { return; } - if (message.role === "user") { + if (isHookAppMessage(message)) { + // Render as custom message if display is true + if (message.display) { + const entry = { + type: "custom_message" as const, + customType: message.customType, + content: message.content, + display: true, + id: "", + parentId: null, + timestamp: new Date().toISOString(), + }; + const renderer = this.session.hookRunner?.getCustomMessageRenderer(message.customType); + this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); + } + } else if (message.role === "user") { const textContent = this.getUserMessageText(message); if (textContent) { const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); From 02f2c501553acd3938b2617d108aee5b6df21c1a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 01:44:52 +0100 Subject: [PATCH 028/161] Handle hookMessage role in message_start event handler --- .../coding-agent/src/modes/interactive/interactive-mode.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1a718dd4..b2baec16 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -769,7 +769,10 @@ export class InteractiveMode { break; case "message_start": - if (event.message.role === "user") { + if (isHookAppMessage(event.message)) { + this.addMessageToChat(event.message); + this.ui.requestRender(); + } else if (event.message.role === "user") { this.addMessageToChat(event.message); this.editor.setText(""); this.updatePendingMessagesDisplay(); From a6322fda59988b9a6f5e0e3e61ab346fbe2d518b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 01:52:52 +0100 Subject: [PATCH 029/161] Add Agent.prompt(AppMessage) overload for custom message types Instead of using continue() which validates roles, prompt() now accepts an AppMessage directly. This allows hook messages with role: 'hookMessage' to trigger proper agent loop with message events. - Add overloads: prompt(AppMessage) and prompt(string, attachments?) - sendHookMessage uses prompt(appMessage) instead of appendMessage+continue --- packages/agent/src/agent.ts | 50 +++++++++++-------- .../coding-agent/src/core/agent-session.ts | 6 +-- packages/coding-agent/src/core/messages.ts | 2 +- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 214bb403..59ead2a3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -170,35 +170,45 @@ export class Agent { this.messageQueue = []; } - async prompt(input: string, attachments?: Attachment[]) { + /** Send a prompt to the agent with an AppMessage. */ + async prompt(message: AppMessage): Promise; + /** Send a prompt to the agent with text and optional attachments. */ + async prompt(input: string, attachments?: Attachment[]): Promise; + async prompt(input: string | AppMessage, attachments?: Attachment[]) { const model = this._state.model; if (!model) { throw new Error("No model configured"); } - // Build user message with attachments - const content: Array = [{ type: "text", text: input }]; - if (attachments?.length) { - for (const a of attachments) { - if (a.type === "image") { - content.push({ type: "image", data: a.content, mimeType: a.mimeType }); - } else if (a.type === "document" && a.extractedText) { - content.push({ - type: "text", - text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`, - isDocument: true, - } as TextContent); + let userMessage: AppMessage; + + if (typeof input === "string") { + // Build user message from text + attachments + const content: Array = [{ type: "text", text: input }]; + if (attachments?.length) { + for (const a of attachments) { + if (a.type === "image") { + content.push({ type: "image", data: a.content, mimeType: a.mimeType }); + } else if (a.type === "document" && a.extractedText) { + content.push({ + type: "text", + text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`, + isDocument: true, + } as TextContent); + } } } + userMessage = { + role: "user", + content, + attachments: attachments?.length ? attachments : undefined, + timestamp: Date.now(), + }; + } else { + // Use provided AppMessage directly + userMessage = input; } - const userMessage: AppMessage = { - role: "user", - content, - attachments: attachments?.length ? attachments : undefined, - timestamp: Date.now(), - }; - await this._runAgentLoop(userMessage); } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9390f6ea..ffc964f8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -584,10 +584,8 @@ export class AgentSession { // Queue for processing by agent loop await this.agent.queueMessage(appMessage); } else if (triggerTurn) { - // Append to agent state and session, then trigger a turn - this.agent.appendMessage(appMessage); - // Start a new turn - emit message events for the hook message so TUI can render it - await this.agent.continue(true); + // Send as prompt - agent loop will emit message events + await this.agent.prompt(appMessage); } else { // Just append to agent state and session, no turn this.agent.appendMessage(appMessage); diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 12367ead..80121d6e 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -115,7 +115,7 @@ export function messageTransformer(messages: AppMessage[]): Message[] { }; } if (isHookAppMessage(m)) { - // Convert hook message to user message + // Convert hook message to user message for LLM return { role: "user", content: m.content, From c1b4d043a8f7aa48139889ff56e54d6013fde9a8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 01:58:58 +0100 Subject: [PATCH 030/161] Remove emitLastMessage from continue(), use prompt(AppMessage) instead Cleans up the temporary emitLastMessage plumbing since we now use Agent.prompt(AppMessage) for hook messages instead of appendMessage+continue. - Remove emitLastMessage parameter from Agent.continue() - Remove from transport interface and implementations - Remove from agentLoopContinue() --- packages/agent/src/agent.ts | 13 ++++--------- packages/agent/src/transports/AppTransport.ts | 4 ++-- packages/agent/src/transports/ProviderTransport.ts | 4 ++-- packages/agent/src/transports/types.ts | 7 +------ packages/ai/src/agent/agent-loop.ts | 9 +-------- 5 files changed, 10 insertions(+), 27 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 59ead2a3..ab4620d8 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -216,12 +216,7 @@ export class Agent { * Continue from the current context without adding a new user message. * Used for retry after overflow recovery when context already has user message or tool results. */ - /** - * Continue from the current context without adding a new user message. - * Used for retry after overflow recovery when context already has user message or tool results. - * @param emitLastMessage If true, emit message_start/message_end for the last message - */ - async continue(emitLastMessage?: boolean) { + async continue() { const messages = this._state.messages; if (messages.length === 0) { throw new Error("No messages to continue from"); @@ -232,7 +227,7 @@ export class Agent { throw new Error(`Cannot continue from message role: ${lastMessage.role}`); } - await this._runAgentLoopContinue(emitLastMessage); + await this._runAgentLoopContinue(); } /** @@ -249,10 +244,10 @@ export class Agent { /** * Internal: Continue the agent loop from current context. */ - private async _runAgentLoopContinue(emitLastMessage?: boolean) { + private async _runAgentLoopContinue() { const { llmMessages, cfg } = await this._prepareRun(); - const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal, emitLastMessage); + const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal); await this._processEvents(events); } diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts index 8748525c..69b9af46 100644 --- a/packages/agent/src/transports/AppTransport.ts +++ b/packages/agent/src/transports/AppTransport.ts @@ -380,7 +380,7 @@ export class AppTransport implements AgentTransport { } } - async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal, emitLastMessage?: boolean) { + async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { const authToken = await this.options.getAuthToken(); if (!authToken) { throw new Error("Auth token is required for AppTransport"); @@ -390,7 +390,7 @@ export class AppTransport implements AgentTransport { const context = this.buildContext(messages, cfg); const pc = this.buildLoopConfig(cfg); - for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any, emitLastMessage)) { + for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any)) { yield ev; } } diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts index 09240f73..024db0e4 100644 --- a/packages/agent/src/transports/ProviderTransport.ts +++ b/packages/agent/src/transports/ProviderTransport.ts @@ -73,12 +73,12 @@ export class ProviderTransport implements AgentTransport { } } - async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal, emitLastMessage?: boolean) { + async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { const model = this.getModel(cfg); const context = this.buildContext(messages, cfg); const pc = this.buildLoopConfig(model, cfg); - for await (const ev of agentLoopContinue(context, pc, signal, undefined, emitLastMessage)) { + for await (const ev of agentLoopContinue(context, pc, signal)) { yield ev; } } diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index baed2b81..736ba0c3 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -28,10 +28,5 @@ export interface AgentTransport { ): AsyncIterable; /** Continue from current context (no new user message) */ - continue( - messages: Message[], - config: AgentRunConfig, - signal?: AbortSignal, - emitLastMessage?: boolean, - ): AsyncIterable; + continue(messages: Message[], config: AgentRunConfig, signal?: AbortSignal): AsyncIterable; } diff --git a/packages/ai/src/agent/agent-loop.ts b/packages/ai/src/agent/agent-loop.ts index 059cb666..95f8b25b 100644 --- a/packages/ai/src/agent/agent-loop.ts +++ b/packages/ai/src/agent/agent-loop.ts @@ -44,14 +44,12 @@ export function agentLoop( * Continue an agent loop from the current context without adding a new message. * Used for retry after overflow - context already has user message or tool results. * Throws if the last message is not a user message or tool result. - * @param emitLastMessage If true, emit message_start/message_end for the last message in context */ export function agentLoopContinue( context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: typeof streamSimple, - emitLastMessage?: boolean, ): EventStream { // Validate that we can continue from this context const lastMessage = context.messages[context.messages.length - 1]; @@ -70,12 +68,7 @@ export function agentLoopContinue( stream.push({ type: "agent_start" }); stream.push({ type: "turn_start" }); - - // Optionally emit events for the last message (used when message was added outside the loop) - if (emitLastMessage) { - stream.push({ type: "message_start", message: lastMessage }); - stream.push({ type: "message_end", message: lastMessage }); - } + // No user message events - we're continuing from existing context await runLoop(currentContext, newMessages, config, signal, stream, streamFn); })(); From cf211b13e85693815dfedb421713d6d1453ed6ea Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 01:59:47 +0100 Subject: [PATCH 031/161] Document HookAppMessage and Agent.prompt(AppMessage) in CHANGELOG --- packages/coding-agent/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b68ff18a..2ae715f4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -52,6 +52,8 @@ - **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export. - **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden. - **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering. +- **HookAppMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookAppMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`. +- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookAppMessage`. ### Fixed From ccfa1ac3bb0a987779803d26e12c9a10f674b1e2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 02:01:53 +0100 Subject: [PATCH 032/161] Add documentation tasks to session-tree plan --- .../coding-agent/docs/session-tree-plan.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index af7efc47..b6035f32 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -234,6 +234,24 @@ Design new commands based on refactored SessionManager: - [ ] Allow switching between branches (move leaf pointer) - [ ] Show current position in tree +### Documentation + +Update docs to reflect all changes: + +- [ ] `docs/hooks.md` - Update hook API: + - `pi.send()` → `pi.sendMessage()` with new signature + - New `pi.appendEntry()` for state persistence + - New `pi.registerCommand()` for custom slash commands + - New `pi.registerCustomMessageRenderer()` for custom TUI rendering + - `CommandContext` interface and handler patterns + - `HookMessage` type + - Updated event signatures (`SessionEventBase`, `before_compact`, etc.) +- [ ] `README.md` - Update hook examples if any +- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools docs +- [ ] Add `HookAppMessage` and `isHookAppMessage()` to public API docs +- [ ] Document `Agent.prompt(AppMessage)` overload +- [ ] Document session v2 tree structure (for programmatic users) + --- ## Notes From 60130a4c5319dcbc224e4538462561e8a63949bb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 02:03:14 +0100 Subject: [PATCH 033/161] Expand documentation and examples sections in session-tree plan --- .../coding-agent/docs/session-tree-plan.md | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index b6035f32..68aa7e6f 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -236,9 +236,9 @@ Design new commands based on refactored SessionManager: ### Documentation -Update docs to reflect all changes: +Review and update all docs: -- [ ] `docs/hooks.md` - Update hook API: +- [ ] `docs/hooks.md` - Major update for hook API: - `pi.send()` → `pi.sendMessage()` with new signature - New `pi.appendEntry()` for state persistence - New `pi.registerCommand()` for custom slash commands @@ -246,11 +246,31 @@ Update docs to reflect all changes: - `CommandContext` interface and handler patterns - `HookMessage` type - Updated event signatures (`SessionEventBase`, `before_compact`, etc.) +- [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete +- [ ] `docs/sdk.md` - Update for: + - `HookAppMessage` and `isHookAppMessage()` + - `Agent.prompt(AppMessage)` overload + - Session v2 tree structure + - SessionManager API changes +- [ ] `docs/session.md` - Update for v2 tree structure, new entry types +- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools +- [ ] `docs/rpc.md` - Check if hook commands work in RPC mode +- [ ] `docs/skills.md` - Review for any hook-related updates +- [ ] `docs/extension-loading.md` - Review +- [ ] `docs/theme.md` - Add customMessageBg/Text/Label color tokens - [ ] `README.md` - Update hook examples if any -- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools docs -- [ ] Add `HookAppMessage` and `isHookAppMessage()` to public API docs -- [ ] Document `Agent.prompt(AppMessage)` overload -- [ ] Document session v2 tree structure (for programmatic users) + +### Examples + +Review and update examples: + +- [ ] `examples/hooks/` - Update existing, add new examples: + - [ ] Review `custom-compaction.ts` for new API + - [ ] Add `registerCommand()` example + - [ ] Add `sendMessage()` example + - [ ] Add `registerCustomMessageRenderer()` example +- [ ] `examples/sdk/` - Update for new session/hook APIs +- [ ] `examples/custom-tools/` - Review for compatibility --- From 09e7e9196c53525d9ad823860a3d88cea9ae5e03 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 02:16:25 +0100 Subject: [PATCH 034/161] Update plan: HookCommandContext without sendMessage (use pi closure) --- packages/coding-agent/docs/session-tree-plan.md | 10 +++++----- packages/coding-agent/src/core/agent-session.ts | 9 ++------- packages/coding-agent/src/core/hooks/index.ts | 9 +++++---- packages/coding-agent/src/core/hooks/types.ts | 16 +++++----------- packages/coding-agent/src/core/tools/edit.ts | 5 +++++ 5 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 68aa7e6f..44a3eb04 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -165,20 +165,20 @@ Calls `sessionManager.appendCustomEntry()` directly. **New: `registerCommand()` (types ✅, wiring TODO)** ```typescript -interface CommandContext { +interface HookCommandContext { args: string; // Everything after /commandname ui: HookUIContext; hasUI: boolean; cwd: string; sessionManager: SessionManager; modelRegistry: ModelRegistry; - sendMessage: HookAPI['sendMessage']; exec(command: string, args: string[], options?: ExecOptions): Promise; } +// Note: sendMessage not on context - handler captures `pi` in closure registerCommand(name: string, options: { description?: string; - handler: (ctx: CommandContext) => Promise; + handler: (ctx: HookCommandContext) => Promise; }): void; ``` @@ -188,7 +188,7 @@ Handler return: Wiring (all in AgentSession.prompt()): - [x] Add hook commands to autocomplete in interactive-mode - [x] `_tryExecuteHookCommand()` in AgentSession handles command execution -- [x] Build CommandContext with ui (from hookRunner), exec, sessionManager, etc. +- [x] Build HookCommandContext with ui (from hookRunner), exec, sessionManager, etc. - [x] If handler returns string, use as prompt text - [x] If handler returns undefined, return early (no LLM call) - [x] Works for all modes (interactive, RPC, print) via shared AgentSession @@ -243,7 +243,7 @@ Review and update all docs: - New `pi.appendEntry()` for state persistence - New `pi.registerCommand()` for custom slash commands - New `pi.registerCustomMessageRenderer()` for custom TUI rendering - - `CommandContext` interface and handler patterns + - `HookCommandContext` interface and handler patterns - `HookMessage` type - Updated event signatures (`SessionEventBase`, `before_compact`, etc.) - [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ffc964f8..0a7d523a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -28,9 +28,9 @@ import { import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; import { - type CommandContext, type ExecOptions, execCommand, + type HookCommandContext, type HookMessage, type HookRunner, type SessionEventResult, @@ -512,18 +512,13 @@ export class AgentSession { // Build command context const cwd = process.cwd(); - const ctx: CommandContext = { + const ctx: HookCommandContext = { args, ui: uiContext, hasUI: this._hookRunner.getHasUI(), cwd, sessionManager: this.sessionManager, modelRegistry: this._modelRegistry, - sendMessage: (message, triggerTurn) => { - this.sendHookMessage(message, triggerTurn).catch(() => { - // Error handling is done in sendHookMessage - }); - }, exec: (cmd: string, cmdArgs: string[], options?: ExecOptions) => execCommand(cmd, cmdArgs, cwd, options), }; diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 43901993..5bff545d 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -1,18 +1,18 @@ +// biome-ignore assist/source/organizeImports: biome is not smart export { - type AppendEntryHandler, discoverAndLoadHooks, + loadHooks, + type AppendEntryHandler, type LoadedHook, type LoadHooksResult, - loadHooks, type SendMessageHandler, } from "./loader.js"; -export { execCommand, type HookErrorListener, HookRunner } from "./runner.js"; +export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, - CommandContext, CustomMessageRenderer, CustomMessageRenderOptions, CustomToolResultEvent, @@ -22,6 +22,7 @@ export type { FindToolResultEvent, GrepToolResultEvent, HookAPI, + HookCommandContext, HookError, HookEvent, HookEventContext, diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 7a3e72f7..35b339e1 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -12,6 +12,7 @@ import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { ModelRegistry } from "../model-registry.js"; import type { CompactionEntry, CustomMessageEntry, SessionManager } from "../session-manager.js"; +import type { EditToolDetails } from "../tools/edit.js"; import type { BashToolDetails, FindToolDetails, @@ -237,7 +238,7 @@ export interface ReadToolResultEvent extends ToolResultEventBase { /** Tool result event for edit tool */ export interface EditToolResultEvent extends ToolResultEventBase { toolName: "edit"; - details: undefined; + details: EditToolDetails | undefined; } /** Tool result event for write tool */ @@ -394,9 +395,9 @@ export type CustomMessageRenderer = ( export type HookMessage = Pick, "customType" | "content" | "display" | "details">; /** - * Context passed to command handlers. + * Context passed to hook command handlers. */ -export interface CommandContext { +export interface HookCommandContext { /** Arguments after the command name */ args: string; /** UI methods for user interaction */ @@ -411,13 +412,6 @@ export interface CommandContext { sessionManager: SessionManager; /** Model registry for API keys */ modelRegistry: ModelRegistry; - /** - * Send a custom message to the session. - * If streaming, queued and appended after turn ends. - * If idle and triggerTurn=true, appends and triggers a new turn. - * If idle and triggerTurn=false (default), just appends. - */ - sendMessage(message: HookMessage, triggerTurn?: boolean): void; } /** @@ -426,7 +420,7 @@ export interface CommandContext { export interface RegisteredCommand { name: string; description?: string; - handler: (ctx: CommandContext) => Promise; + handler: (ctx: HookCommandContext) => Promise; } /** diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index ff040091..4cd3dd0d 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -122,6 +122,11 @@ const editSchema = Type.Object({ newText: Type.String({ description: "New text to replace the old text with" }), }); +export interface EditToolDetails { + /** Unified diff of the changes made */ + diff: string; +} + export function createEditTool(cwd: string): AgentTool { return { name: "edit", From e25aef0594045100a737a6663352967145744494 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 02:22:45 +0100 Subject: [PATCH 035/161] Update plan: move exec to HookAPI, sessionManager/modelRegistry to contexts - exec() moves from HookEventContext/HookCommandContext to HookAPI - sessionManager/modelRegistry move from SessionEventBase to HookEventContext - HookCommandContext keeps sessionManager/modelRegistry (command handlers need them) - Both sendMessage and exec accessed via pi closure in command handlers --- .../coding-agent/docs/session-tree-plan.md | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 44a3eb04..ea85d95b 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -165,6 +165,26 @@ Calls `sessionManager.appendCustomEntry()` directly. **New: `registerCommand()` (types ✅, wiring TODO)** ```typescript +// HookAPI (the `pi` object) - utilities available to all hooks: +interface HookAPI { + sendMessage(message: HookMessage, triggerTurn?: boolean): void; + appendEntry(customType: string, data?: unknown): void; + registerCommand(name: string, options: RegisteredCommand): void; + registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void; + exec(command: string, args: string[], options?: ExecOptions): Promise; +} + +// HookEventContext - passed to event handlers, has stable context: +interface HookEventContext { + ui: HookUIContext; + hasUI: boolean; + cwd: string; + sessionManager: SessionManager; + modelRegistry: ModelRegistry; +} +// Note: exec moved to HookAPI, sessionManager/modelRegistry moved from SessionEventBase + +// HookCommandContext - passed to command handlers: interface HookCommandContext { args: string; // Everything after /commandname ui: HookUIContext; @@ -172,9 +192,8 @@ interface HookCommandContext { cwd: string; sessionManager: SessionManager; modelRegistry: ModelRegistry; - exec(command: string, args: string[], options?: ExecOptions): Promise; } -// Note: sendMessage not on context - handler captures `pi` in closure +// Note: exec and sendMessage accessed via `pi` closure registerCommand(name: string, options: { description?: string; From 7ed8e2e9fc1b7fe8ebd3f512b16aa8fb5bcc0ed3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 02:24:09 +0100 Subject: [PATCH 036/161] Fix CHANGELOG: CommandContext -> HookCommandContext --- packages/coding-agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2ae715f4..a42712ee 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -34,7 +34,7 @@ - New `pi.registerCustomMessageRenderer(customType, renderer)` to register custom renderers for `CustomMessageEntry` - `CustomMessageRenderer` type: `(entry, options, theme) => Component | null` - Renderers return inner content; the TUI wraps it in a styled Box - - New types: `HookMessage`, `RegisteredCommand`, `CommandContext` + - New types: `HookMessage`, `RegisteredCommand`, `HookCommandContext` - Handler types renamed: `SendHandler` → `SendMessageHandler`, new `AppendEntryHandler` - **SessionManager**: - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) From 29fec7848e2a88839a5b3cd1fa3af3b07144e131 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 02:43:36 +0100 Subject: [PATCH 037/161] Move exec to HookAPI, sessionManager/modelRegistry to HookEventContext Breaking changes: - HookEventContext now has sessionManager and modelRegistry (moved from SessionEventBase) - HookAPI now has exec() method (moved from HookEventContext/HookCommandContext) - HookRunner constructor takes sessionManager and modelRegistry as required params - Session events no longer include sessionManager/modelRegistry fields Hook code migration: - event.sessionManager -> ctx.sessionManager - event.modelRegistry -> ctx.modelRegistry - ctx.exec() -> pi.exec() Updated: - src/core/hooks/types.ts - type changes - src/core/hooks/runner.ts - constructor, createContext - src/core/hooks/loader.ts - add exec to HookAPI - src/core/sdk.ts - pass sessionManager/modelRegistry to HookRunner - src/core/agent-session.ts - remove sessionManager/modelRegistry from events - src/modes/* - remove setSessionFile calls, update events - examples/hooks/* - update to new API --- packages/coding-agent/CHANGELOG.md | 6 ++- .../examples/hooks/auto-commit-on-exit.ts | 8 ++-- .../examples/hooks/confirm-destructive.ts | 2 +- .../examples/hooks/custom-compaction.ts | 4 +- .../examples/hooks/dirty-repo-guard.ts | 2 +- .../examples/hooks/git-checkpoint.ts | 6 +-- .../coding-agent/src/core/agent-session.ts | 40 ++++--------------- .../coding-agent/src/core/hooks/loader.ts | 32 +++++++++++---- .../coding-agent/src/core/hooks/runner.ts | 28 +++++++------ packages/coding-agent/src/core/hooks/types.ts | 24 ++++++----- packages/coding-agent/src/core/sdk.ts | 4 +- .../src/modes/interactive/interactive-mode.ts | 5 --- packages/coding-agent/src/modes/print-mode.ts | 4 -- .../coding-agent/src/modes/rpc/rpc-mode.ts | 3 -- 14 files changed, 78 insertions(+), 90 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a42712ee..d6e35849 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -22,7 +22,10 @@ - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` - `prepareCompaction()` now returns `firstKeptEntryId` in its result - **Hook types**: - - `SessionEventBase` now passes `sessionManager` and `modelRegistry` instead of `entries`, `sessionFile`, `previousSessionFile` + - `SessionEventBase` no longer has `sessionManager`/`modelRegistry` - access them via `HookEventContext` instead + - `HookEventContext` now has `sessionManager` and `modelRegistry` (moved from events) + - `HookEventContext` no longer has `exec()` - use `pi.exec()` instead + - `HookCommandContext` no longer has `exec()` - use `pi.exec()` instead - `before_compact` event passes `preparation: CompactionPreparation` and `previousCompactions: CompactionEntry[]` (newest first) - `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile` - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) @@ -32,6 +35,7 @@ - New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context) - New `pi.registerCommand(name, options)` to register custom slash commands - New `pi.registerCustomMessageRenderer(customType, renderer)` to register custom renderers for `CustomMessageEntry` + - New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`) - `CustomMessageRenderer` type: `(entry, options, theme) => Component | null` - Renderers return inner content; the TUI wraps it in a styled Box - New types: `HookMessage`, `RegisteredCommand`, `HookCommandContext` diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts index daef3a92..0f3ef871 100644 --- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts +++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts @@ -12,7 +12,7 @@ export default function (pi: HookAPI) { if (event.reason !== "shutdown") return; // Check for uncommitted changes - const { stdout: status, code } = await ctx.exec("git", ["status", "--porcelain"]); + const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]); if (code !== 0 || status.trim().length === 0) { // Not a git repo or no changes @@ -20,7 +20,7 @@ export default function (pi: HookAPI) { } // Find the last assistant message for commit context - const entries = event.sessionManager.getEntries(); + const entries = ctx.sessionManager.getEntries(); let lastAssistantText = ""; for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; @@ -41,8 +41,8 @@ export default function (pi: HookAPI) { const commitMessage = `[pi] ${firstLine.slice(0, 50)}${firstLine.length > 50 ? "..." : ""}`; // Stage and commit - await ctx.exec("git", ["add", "-A"]); - const { code: commitCode } = await ctx.exec("git", ["commit", "-m", commitMessage]); + await pi.exec("git", ["add", "-A"]); + const { code: commitCode } = await pi.exec("git", ["commit", "-m", commitMessage]); if (commitCode === 0 && ctx.hasUI) { ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info"); diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts index 72ecc9d3..81e32eb5 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -29,7 +29,7 @@ export default function (pi: HookAPI) { if (!ctx.hasUI) return; // Check if there are unsaved changes (messages since last assistant response) - const entries = event.sessionManager.getEntries(); + const entries = ctx.sessionManager.getEntries(); const hasUnsavedWork = entries.some( (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user", ); diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index efc2f59b..f2794060 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -23,7 +23,7 @@ export default function (pi: HookAPI) { ctx.ui.notify("Custom compaction hook triggered", "info"); - const { preparation, previousCompactions, modelRegistry, signal } = event; + const { preparation, previousCompactions, signal } = event; const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId } = preparation; // Get previous summary from most recent compaction (if any) @@ -37,7 +37,7 @@ export default function (pi: HookAPI) { } // Resolve API key for the summarization model - const apiKey = await modelRegistry.getApiKey(model); + const apiKey = await ctx.modelRegistry.getApiKey(model); if (!apiKey) { ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning"); return; diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts index 8e6e5d66..852ff006 100644 --- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts +++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts @@ -15,7 +15,7 @@ export default function (pi: HookAPI) { } // Check for uncommitted changes - const { stdout, code } = await ctx.exec("git", ["status", "--porcelain"]); + const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); if (code !== 0) { // Not a git repo, allow the action diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts index f7a73a7f..a69cadc8 100644 --- a/packages/coding-agent/examples/hooks/git-checkpoint.ts +++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts @@ -10,9 +10,9 @@ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { const checkpoints = new Map(); - pi.on("turn_start", async (event, ctx) => { + pi.on("turn_start", async (event) => { // Create a git stash entry before LLM makes changes - const { stdout } = await ctx.exec("git", ["stash", "create"]); + const { stdout } = await pi.exec("git", ["stash", "create"]); const ref = stdout.trim(); if (ref) { checkpoints.set(event.turnIndex, ref); @@ -37,7 +37,7 @@ export default function (pi: HookAPI) { ]); if (choice?.startsWith("Yes")) { - await ctx.exec("git", ["stash", "apply", ref]); + await pi.exec("git", ["stash", "apply", ref]); ctx.ui.notify("Code restored to checkpoint", "info"); } }); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0a7d523a..b00caad8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -27,15 +27,13 @@ import { } from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; -import { - type ExecOptions, - execCommand, - type HookCommandContext, - type HookMessage, - type HookRunner, - type SessionEventResult, - type TurnEndEvent, - type TurnStartEvent, +import type { + HookCommandContext, + HookMessage, + HookRunner, + SessionEventResult, + TurnEndEvent, + TurnStartEvent, } from "./hooks/index.js"; import { type BashExecutionMessage, type HookAppMessage, isHookAppMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; @@ -519,7 +517,6 @@ export class AgentSession { cwd, sessionManager: this.sessionManager, modelRegistry: this._modelRegistry, - exec: (cmd: string, cmdArgs: string[], options?: ExecOptions) => execCommand(cmd, cmdArgs, cwd, options), }; try { @@ -640,8 +637,6 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_new", })) as SessionEventResult | undefined; @@ -659,11 +654,8 @@ export class AgentSession { // Emit session event with reason "new" to hooks if (this._hookRunner) { - this._hookRunner.setSessionFile(this.sessionFile); await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "new", }); } @@ -888,8 +880,6 @@ export class AgentSession { const result = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_compact", preparation, previousCompactions, @@ -952,8 +942,6 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "compact", compactionEntry: savedCompactionEntry, fromHook, @@ -1060,8 +1048,6 @@ export class AgentSession { const hookResult = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_compact", preparation, previousCompactions, @@ -1125,8 +1111,6 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "compact", compactionEntry: savedCompactionEntry, fromHook, @@ -1431,8 +1415,6 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_switch", targetSessionFile: sessionPath, })) as SessionEventResult | undefined; @@ -1454,11 +1436,8 @@ export class AgentSession { // Emit session event to hooks if (this._hookRunner) { - this._hookRunner.setSessionFile(sessionPath); await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "switch", previousSessionFile, }); @@ -1515,8 +1494,6 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "before_branch", targetTurnIndex: entryIndex, })) as SessionEventResult | undefined; @@ -1544,11 +1521,8 @@ export class AgentSession { // Emit branch event to hooks (after branch completes) if (this._hookRunner) { - this._hookRunner.setSessionFile(newSessionFile); await this._hookRunner.emit({ type: "session", - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, reason: "branch", targetTurnIndex: entryIndex, }); diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 736ed673..12b9966a 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -9,7 +9,15 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; -import type { CustomMessageRenderer, HookAPI, HookFactory, HookMessage, RegisteredCommand } from "./types.js"; +import { execCommand } from "./runner.js"; +import type { + CustomMessageRenderer, + ExecOptions, + HookAPI, + HookFactory, + HookMessage, + RegisteredCommand, +} from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -123,7 +131,10 @@ function resolveHookPath(hookPath: string, cwd: string): string { * Create a HookAPI instance that collects handlers, renderers, and commands. * Returns the API, maps, and a function to set the send message handler later. */ -function createHookAPI(handlers: Map): { +function createHookAPI( + handlers: Map, + cwd: string, +): { api: HookAPI; customMessageRenderers: Map; commands: Map; @@ -139,7 +150,9 @@ function createHookAPI(handlers: Map): { const customMessageRenderers = new Map(); const commands = new Map(); - const api: HookAPI = { + // Cast to HookAPI - the implementation is more general (string event names) + // but the interface has specific overloads for type safety in hooks + const api = { on(event: string, handler: HandlerFn): void { const list = handlers.get(event) ?? []; list.push(handler); @@ -151,12 +164,15 @@ function createHookAPI(handlers: Map): { appendEntry(customType: string, data?: T): void { appendEntryHandler(customType, data); }, - registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void { - customMessageRenderers.set(customType, renderer); + registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void { + customMessageRenderers.set(customType, renderer as CustomMessageRenderer); }, registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { commands.set(name, { name, ...options }); }, + exec(command: string, args: string[], options?: ExecOptions) { + return execCommand(command, args, options?.cwd ?? cwd, options); + }, } as HookAPI; return { @@ -196,8 +212,10 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo // Create handlers map and API const handlers = new Map(); - const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = - createHookAPI(handlers); + const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI( + handlers, + cwd, + ); // Call factory to register handlers factory(api); diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index e2efb149..bfdebd28 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -3,6 +3,8 @@ */ import { spawn } from "node:child_process"; +import type { ModelRegistry } from "../model-registry.js"; +import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { CustomMessageRenderer, @@ -133,16 +135,24 @@ export class HookRunner { private uiContext: HookUIContext; private hasUI: boolean; private cwd: string; - private sessionFile: string | null; + private sessionManager: SessionManager; + private modelRegistry: ModelRegistry; private timeout: number; private errorListeners: Set = new Set(); - constructor(hooks: LoadedHook[], cwd: string, timeout: number = DEFAULT_TIMEOUT) { + constructor( + hooks: LoadedHook[], + cwd: string, + sessionManager: SessionManager, + modelRegistry: ModelRegistry, + timeout: number = DEFAULT_TIMEOUT, + ) { this.hooks = hooks; this.uiContext = noOpUIContext; this.hasUI = false; this.cwd = cwd; - this.sessionFile = null; + this.sessionManager = sessionManager; + this.modelRegistry = modelRegistry; this.timeout = timeout; } @@ -176,13 +186,6 @@ export class HookRunner { return this.hooks.map((h) => h.path); } - /** - * Set the session file path. - */ - setSessionFile(sessionFile: string | null): void { - this.sessionFile = sessionFile; - } - /** * Set the send message handler for all hooks' pi.sendMessage(). * Call this when the mode initializes. @@ -283,12 +286,11 @@ export class HookRunner { */ private createContext(): HookEventContext { return { - exec: (command: string, args: string[], options?: ExecOptions) => - execCommand(command, args, this.cwd, options), ui: this.uiContext, hasUI: this.hasUI, cwd: this.cwd, - sessionFile: this.sessionFile, + sessionManager: this.sessionManager, + modelRegistry: this.modelRegistry, }; } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 35b339e1..7798dd6a 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -41,6 +41,8 @@ export interface ExecOptions { signal?: AbortSignal; /** Timeout in milliseconds */ timeout?: number; + /** Working directory */ + cwd?: string; } /** @@ -78,16 +80,16 @@ export interface HookUIContext { * Context passed to hook event handlers. */ export interface HookEventContext { - /** Execute a command and return stdout/stderr/code */ - exec(command: string, args: string[], options?: ExecOptions): Promise; /** UI methods for user interaction */ ui: HookUIContext; /** Whether UI is available (false in print mode) */ hasUI: boolean; /** Current working directory */ cwd: string; - /** Path to session file, or null if --no-session */ - sessionFile: string | null; + /** Session manager instance - use for entries, session file, etc. */ + sessionManager: SessionManager; + /** Model registry - use for API key resolution and model retrieval */ + modelRegistry: ModelRegistry; } // ============================================================================ @@ -99,10 +101,6 @@ export interface HookEventContext { */ interface SessionEventBase { type: "session"; - /** Session manager instance - use for entries, session file, etc. */ - sessionManager: SessionManager; - /** Model registry - use for API key resolution */ - modelRegistry: ModelRegistry; } /** @@ -402,8 +400,6 @@ export interface HookCommandContext { args: string; /** UI methods for user interaction */ ui: HookUIContext; - /** Execute a command and return stdout/stderr/code */ - exec(command: string, args: string[], options?: ExecOptions): Promise; /** Whether UI is available (false in print mode) */ hasUI: boolean; /** Current working directory */ @@ -491,9 +487,15 @@ export interface HookAPI { /** * Register a custom slash command. - * Handler receives CommandContext and can return a string to send as prompt. + * Handler receives HookCommandContext. */ registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; + + /** + * Execute a shell command and return stdout/stderr/code. + * Supports timeout and abort signal. + */ + exec(command: string, args: string[], options?: ExecOptions): Promise; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8397d2e9..1c7ab14f 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -534,7 +534,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (options.hooks !== undefined) { if (options.hooks.length > 0) { const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); - hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout()); + hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout()); } } else { // Discover hooks, merging with additional paths @@ -545,7 +545,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} console.error(`Failed to load hook "${path}": ${error}`); } if (hooks.length > 0) { - hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout()); + hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout()); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b2baec16..23efe7c9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -369,7 +369,6 @@ export class InteractiveMode { // Set UI context on hook runner hookRunner.setUIContext(uiContext, true); - hookRunner.setSessionFile(this.session.sessionFile); // Subscribe to hook errors hookRunner.onError((error) => { @@ -407,8 +406,6 @@ export class InteractiveMode { // Emit session event await hookRunner.emit({ type: "session", - sessionManager: this.session.sessionManager, - modelRegistry: this.session.modelRegistry, reason: "start", }); } @@ -1204,8 +1201,6 @@ export class InteractiveMode { if (hookRunner?.hasHandlers("session")) { await hookRunner.emit({ type: "session", - sessionManager: this.session.sessionManager, - modelRegistry: this.session.modelRegistry, reason: "shutdown", }); } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 9375dd5c..6bc06dea 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -34,8 +34,6 @@ export async function runPrintMode( // Set up hooks for print mode (no UI) const hookRunner = session.hookRunner; if (hookRunner) { - // Use actual session file if configured (via --session), otherwise null - hookRunner.setSessionFile(session.sessionFile); hookRunner.onError((err) => { console.error(`Hook error (${err.hookPath}): ${err.error}`); }); @@ -51,8 +49,6 @@ export async function runPrintMode( // Emit session event await hookRunner.emit({ type: "session", - sessionManager: session.sessionManager, - modelRegistry: session.modelRegistry, reason: "start", }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 3840fb61..cae2d5f4 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -127,7 +127,6 @@ export async function runRpcMode(session: AgentSession): Promise { const hookRunner = session.hookRunner; if (hookRunner) { hookRunner.setUIContext(createHookUIContext(), false); - hookRunner.setSessionFile(session.sessionFile); hookRunner.onError((err) => { output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); }); @@ -143,8 +142,6 @@ export async function runRpcMode(session: AgentSession): Promise { // Emit session event await hookRunner.emit({ type: "session", - sessionManager: session.sessionManager, - modelRegistry: session.modelRegistry, reason: "start", }); } From 5fee9005b7602fc78ac5e466dd00ab2bbef86283 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 02:46:52 +0100 Subject: [PATCH 038/161] Fix tests for sessionManager/modelRegistry on context - compaction-hooks-example.test.ts: get sessionManager/modelRegistry from ctx - compaction-hooks.test.ts: - Pass sessionManager/modelRegistry to HookRunner constructor - Remove setSessionFile call - Update tests to use session.sessionManager instead of event.sessionManager --- .../src/core/custom-tools/loader.ts | 3 ++- .../src/core/custom-tools/types.ts | 2 ++ .../test/compaction-hooks-example.test.ts | 6 ++++-- .../coding-agent/test/compaction-hooks.test.ts | 18 ++++++++++-------- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 095f41c8..3772e0d7 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -298,7 +298,8 @@ export async function loadCustomTools( // Shared API object - all tools get the same instance const sharedApi: ToolAPI = { cwd, - exec: (command: string, args: string[], options?: ExecOptions) => execCommand(command, args, cwd, options), + exec: (command: string, args: string[], options?: ExecOptions) => + execCommand(command, args, options?.cwd ?? cwd, options), ui: createNoOpUIContext(), hasUI: false, }; diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index b5ccc591..d9e3297d 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -31,6 +31,8 @@ export interface ExecOptions { signal?: AbortSignal; /** Timeout in milliseconds */ timeout?: number; + /** Working directory */ + cwd?: string; } /** API passed to custom tool factory (stable across session changes) */ diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index 0d200702..126cb1a6 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -9,11 +9,13 @@ describe("Documentation example", () => { it("custom compaction example should type-check correctly", () => { // This is the example from hooks.md - verify it compiles const exampleHook = (pi: HookAPI) => { - pi.on("session", async (event, _ctx) => { + pi.on("session", async (event, ctx) => { if (event.reason !== "before_compact") return; // After narrowing, these should all be accessible - const { preparation, previousCompactions, sessionManager, modelRegistry, model } = event; + // sessionManager and modelRegistry come from ctx, not event + const { preparation, previousCompactions, model } = event; + const { sessionManager, modelRegistry } = ctx; const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId, cutPoint } = preparation; // Get previous summary from most recent compaction diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 6e9a071d..66d21b3d 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -91,7 +91,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const authStorage = new AuthStorage(join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage); - hookRunner = new HookRunner(hooks, tempDir); + hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry); hookRunner.setUIContext( { select: async () => null, @@ -101,7 +101,6 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }, false, ); - hookRunner.setSessionFile(sessionManager.getSessionFile() ?? null); session = new AgentSession({ agent, @@ -140,8 +139,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(beforeEvent.preparation.messagesToKeep).toBeDefined(); expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0); expect(beforeEvent.model).toBeDefined(); - expect(beforeEvent.sessionManager).toBeDefined(); - expect(beforeEvent.modelRegistry).toBeDefined(); + // sessionManager and modelRegistry are now on ctx, not event } const afterEvent = compactEvents[0]; @@ -217,8 +215,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const afterEvent = compactEvents[0]; if (afterEvent.reason === "compact") { - const entries = afterEvent.sessionManager.getEntries(); - const hasCompactionEntry = entries.some((e) => e.type === "compaction"); + // sessionManager is now on ctx, use session.sessionManager directly + const entries = session.sessionManager.getEntries(); + const hasCompactionEntry = entries.some((e: { type: string }) => e.type === "compaction"); expect(hasCompactionEntry).toBe(true); } }, 120000); @@ -361,9 +360,12 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(event.model).toHaveProperty("provider"); expect(event.model).toHaveProperty("id"); - expect(typeof event.modelRegistry.getApiKey).toBe("function"); + // sessionManager and modelRegistry are now on ctx, not event + // Verify they're accessible via session + expect(typeof session.sessionManager.getEntries).toBe("function"); + expect(typeof session.modelRegistry.getApiKey).toBe("function"); - const entries = event.sessionManager.getEntries(); + const entries = session.sessionManager.getEntries(); expect(Array.isArray(entries)).toBe(true); expect(entries.length).toBeGreaterThan(0); }, 120000); From a8866d7a83f12cf749d4316e6f01e80cfd41d540 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:10:08 +0100 Subject: [PATCH 039/161] Refactor: shared exec utility, rename CustomMessageRenderer to HookMessageRenderer - Extract execCommand to src/core/exec.ts, shared by hooks and custom-tools - Rename CustomMessageRenderer -> HookMessageRenderer - Rename registerCustomMessageRenderer -> registerMessageRenderer - Renderer now receives HookMessage instead of CustomMessageEntry - CustomMessageComponent now has setExpanded() and responds to Ctrl+E toggle - Re-export ExecOptions/ExecResult from exec.ts for backward compatibility --- packages/coding-agent/CHANGELOG.md | 4 +- .../src/core/custom-tools/loader.ts | 94 +--------------- .../src/core/custom-tools/types.ts | 19 +--- packages/coding-agent/src/core/exec.ts | 104 ++++++++++++++++++ packages/coding-agent/src/core/hooks/index.ts | 4 +- .../coding-agent/src/core/hooks/loader.ts | 20 ++-- .../coding-agent/src/core/hooks/runner.ts | 85 +------------- packages/coding-agent/src/core/hooks/types.ts | 50 +++------ packages/coding-agent/src/core/sdk.ts | 8 +- .../interactive/components/custom-message.ts | 58 +++++++--- .../src/modes/interactive/interactive-mode.ts | 6 +- .../test/compaction-hooks.test.ts | 8 +- 12 files changed, 199 insertions(+), 261 deletions(-) create mode 100644 packages/coding-agent/src/core/exec.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d6e35849..5efd1ee1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -34,9 +34,9 @@ - `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages - New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context) - New `pi.registerCommand(name, options)` to register custom slash commands - - New `pi.registerCustomMessageRenderer(customType, renderer)` to register custom renderers for `CustomMessageEntry` + - New `pi.registerMessageRenderer(customType, renderer)` to register custom renderers for hook messages - New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`) - - `CustomMessageRenderer` type: `(entry, options, theme) => Component | null` + - `HookMessageRenderer` type: `(message: HookMessage, options, theme) => Component | null` - Renderers return inner content; the TUI wraps it in a styled Box - New types: `HookMessage`, `RegisteredCommand`, `HookCommandContext` - Handler types renamed: `SendHandler` → `SendMessageHandler`, new `AppendEntryHandler` diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 3772e0d7..e3e7d0d9 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -7,7 +7,6 @@ * for custom tools that depend on pi packages. */ -import { spawn } from "node:child_process"; import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as os from "node:os"; @@ -15,15 +14,10 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import { getAgentDir, isBunBinary } from "../../config.js"; +import type { ExecOptions } from "../exec.js"; +import { execCommand } from "../exec.js"; import type { HookUIContext } from "../hooks/types.js"; -import type { - CustomToolFactory, - CustomToolsLoadResult, - ExecOptions, - ExecResult, - LoadedCustomTool, - ToolAPI, -} from "./types.js"; +import type { CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool, ToolAPI } from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -87,88 +81,6 @@ function resolveToolPath(toolPath: string, cwd: string): string { return path.resolve(cwd, expanded); } -/** - * Execute a command and return stdout/stderr/code. - * Supports cancellation via AbortSignal and timeout. - */ -async function execCommand(command: string, args: string[], cwd: string, options?: ExecOptions): Promise { - return new Promise((resolve) => { - const proc = spawn(command, args, { - cwd, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let killed = false; - let timeoutId: NodeJS.Timeout | undefined; - - const killProcess = () => { - if (!killed) { - killed = true; - proc.kill("SIGTERM"); - // Force kill after 5 seconds if SIGTERM doesn't work - setTimeout(() => { - if (!proc.killed) { - proc.kill("SIGKILL"); - } - }, 5000); - } - }; - - // Handle abort signal - if (options?.signal) { - if (options.signal.aborted) { - killProcess(); - } else { - options.signal.addEventListener("abort", killProcess, { once: true }); - } - } - - // Handle timeout - if (options?.timeout && options.timeout > 0) { - timeoutId = setTimeout(() => { - killProcess(); - }, options.timeout); - } - - proc.stdout.on("data", (data) => { - stdout += data.toString(); - }); - - proc.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) { - options.signal.removeEventListener("abort", killProcess); - } - resolve({ - stdout, - stderr, - code: code ?? 0, - killed, - }); - }); - - proc.on("error", (err) => { - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) { - options.signal.removeEventListener("abort", killProcess); - } - resolve({ - stdout, - stderr: stderr || err.message, - code: 1, - killed, - }); - }); - }); -} - /** * Create a no-op UI context for headless modes. */ diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index d9e3297d..43713a61 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -9,6 +9,7 @@ import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mario import type { Component } from "@mariozechner/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme.js"; +import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookUIContext } from "../hooks/types.js"; import type { SessionEntry } from "../session-manager.js"; @@ -18,22 +19,8 @@ export type ToolUIContext = HookUIContext; /** Re-export for custom tools to use in execute signature */ export type { AgentToolUpdateCallback }; -export interface ExecResult { - stdout: string; - stderr: string; - code: number; - /** True if the process was killed due to signal or timeout */ - killed?: boolean; -} - -export interface ExecOptions { - /** AbortSignal to cancel the process */ - signal?: AbortSignal; - /** Timeout in milliseconds */ - timeout?: number; - /** Working directory */ - cwd?: string; -} +// Re-export for backward compatibility +export type { ExecOptions, ExecResult } from "../exec.js"; /** API passed to custom tool factory (stable across session changes) */ export interface ToolAPI { diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts new file mode 100644 index 00000000..fccf5504 --- /dev/null +++ b/packages/coding-agent/src/core/exec.ts @@ -0,0 +1,104 @@ +/** + * Shared command execution utilities for hooks and custom tools. + */ + +import { spawn } from "node:child_process"; + +/** + * Options for executing shell commands. + */ +export interface ExecOptions { + /** AbortSignal to cancel the command */ + signal?: AbortSignal; + /** Timeout in milliseconds */ + timeout?: number; + /** Working directory */ + cwd?: string; +} + +/** + * Result of executing a shell command. + */ +export interface ExecResult { + stdout: string; + stderr: string; + code: number; + killed: boolean; +} + +/** + * Execute a shell command and return stdout/stderr/code. + * Supports timeout and abort signal. + */ +export async function execCommand( + command: string, + args: string[], + cwd: string, + options?: ExecOptions, +): Promise { + return new Promise((resolve) => { + const proc = spawn(command, args, { + cwd, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let killed = false; + let timeoutId: NodeJS.Timeout | undefined; + + const killProcess = () => { + if (!killed) { + killed = true; + proc.kill("SIGTERM"); + // Force kill after 5 seconds if SIGTERM doesn't work + setTimeout(() => { + if (!proc.killed) { + proc.kill("SIGKILL"); + } + }, 5000); + } + }; + + // Handle abort signal + if (options?.signal) { + if (options.signal.aborted) { + killProcess(); + } else { + options.signal.addEventListener("abort", killProcess, { once: true }); + } + } + + // Handle timeout + if (options?.timeout && options.timeout > 0) { + timeoutId = setTimeout(() => { + killProcess(); + }, options.timeout); + } + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) { + options.signal.removeEventListener("abort", killProcess); + } + resolve({ stdout, stderr, code: code ?? 0, killed }); + }); + + proc.on("error", (_err) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) { + options.signal.removeEventListener("abort", killProcess); + } + resolve({ stdout, stderr, code: 1, killed }); + }); + }); +} diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 5bff545d..e16282bb 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -13,8 +13,8 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, - CustomMessageRenderer, - CustomMessageRenderOptions, + HookMessageRenderer, + HookMessageRenderOptions, CustomToolResultEvent, EditToolResultEvent, ExecOptions, diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 12b9966a..6cfb88f8 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -11,11 +11,11 @@ import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; import { execCommand } from "./runner.js"; import type { - CustomMessageRenderer, ExecOptions, HookAPI, HookFactory, HookMessage, + HookMessageRenderer, RegisteredCommand, } from "./types.js"; @@ -73,8 +73,8 @@ export interface LoadedHook { resolvedPath: string; /** Map of event type to handler functions */ handlers: Map; - /** Map of customType to custom message renderer */ - customMessageRenderers: Map; + /** Map of customType to hook message renderer */ + messageRenderers: Map; /** Map of command name to registered command */ commands: Map; /** Set the send message handler for this hook's pi.sendMessage() */ @@ -136,7 +136,7 @@ function createHookAPI( cwd: string, ): { api: HookAPI; - customMessageRenderers: Map; + messageRenderers: Map; commands: Map; setSendMessageHandler: (handler: SendMessageHandler) => void; setAppendEntryHandler: (handler: AppendEntryHandler) => void; @@ -147,7 +147,7 @@ function createHookAPI( let appendEntryHandler: AppendEntryHandler = () => { // Default no-op until mode sets the handler }; - const customMessageRenderers = new Map(); + const messageRenderers = new Map(); const commands = new Map(); // Cast to HookAPI - the implementation is more general (string event names) @@ -164,8 +164,8 @@ function createHookAPI( appendEntry(customType: string, data?: T): void { appendEntryHandler(customType, data); }, - registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void { - customMessageRenderers.set(customType, renderer as CustomMessageRenderer); + registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void { + messageRenderers.set(customType, renderer as HookMessageRenderer); }, registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { commands.set(name, { name, ...options }); @@ -177,7 +177,7 @@ function createHookAPI( return { api, - customMessageRenderers, + messageRenderers, commands, setSendMessageHandler: (handler: SendMessageHandler) => { sendMessageHandler = handler; @@ -212,7 +212,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo // Create handlers map and API const handlers = new Map(); - const { api, customMessageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI( + const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI( handlers, cwd, ); @@ -225,7 +225,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo path: hookPath, resolvedPath, handlers, - customMessageRenderers, + messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index bfdebd28..768ffc86 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -2,17 +2,14 @@ * Hook runner - executes hooks and manages their lifecycle. */ -import { spawn } from "node:child_process"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { - CustomMessageRenderer, - ExecOptions, - ExecResult, HookError, HookEvent, HookEventContext, + HookMessageRenderer, HookUIContext, RegisteredCommand, SessionEvent, @@ -32,78 +29,8 @@ const DEFAULT_TIMEOUT = 30000; */ export type HookErrorListener = (error: HookError) => void; -/** - * Execute a command and return stdout/stderr/code. - * Supports cancellation via AbortSignal and timeout. - */ -export async function execCommand( - command: string, - args: string[], - cwd: string, - options?: ExecOptions, -): Promise { - return new Promise((resolve) => { - const proc = spawn(command, args, { cwd, shell: false }); - - let stdout = ""; - let stderr = ""; - let killed = false; - let timeoutId: NodeJS.Timeout | undefined; - - const killProcess = () => { - if (!killed) { - killed = true; - proc.kill("SIGTERM"); - // Force kill after 5 seconds if SIGTERM doesn't work - setTimeout(() => { - if (!proc.killed) { - proc.kill("SIGKILL"); - } - }, 5000); - } - }; - - // Handle abort signal - if (options?.signal) { - if (options.signal.aborted) { - killProcess(); - } else { - options.signal.addEventListener("abort", killProcess, { once: true }); - } - } - - // Handle timeout - if (options?.timeout && options.timeout > 0) { - timeoutId = setTimeout(() => { - killProcess(); - }, options.timeout); - } - - proc.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - - proc.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) { - options.signal.removeEventListener("abort", killProcess); - } - resolve({ stdout, stderr, code: code ?? 0, killed }); - }); - - proc.on("error", (_err) => { - if (timeoutId) clearTimeout(timeoutId); - if (options?.signal) { - options.signal.removeEventListener("abort", killProcess); - } - resolve({ stdout, stderr, code: 1, killed }); - }); - }); -} +// Re-export execCommand for backward compatibility +export { execCommand } from "../exec.js"; /** * Create a promise that rejects after a timeout. @@ -241,12 +168,12 @@ export class HookRunner { } /** - * Get a custom message renderer for the given customType. + * Get a message renderer for the given customType. * Returns the first renderer found across all hooks, or undefined if none. */ - getCustomMessageRenderer(customType: string): CustomMessageRenderer | undefined { + getMessageRenderer(customType: string): HookMessageRenderer | undefined { for (const hook of this.hooks) { - const renderer = hook.customMessageRenderers.get(customType); + const renderer = hook.messageRenderers.get(customType); if (renderer) { return renderer; } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 7798dd6a..7b329438 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -10,6 +10,7 @@ import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mario import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction.js"; +import type { ExecOptions, ExecResult } from "../exec.js"; import type { ModelRegistry } from "../model-registry.js"; import type { CompactionEntry, CustomMessageEntry, SessionManager } from "../session-manager.js"; import type { EditToolDetails } from "../tools/edit.js"; @@ -21,29 +22,8 @@ import type { ReadToolDetails, } from "../tools/index.js"; -// ============================================================================ -// Execution Context -// ============================================================================ - -/** - * Result of executing a command via ctx.exec() - */ -export interface ExecResult { - stdout: string; - stderr: string; - code: number; - /** True if the process was killed due to signal or timeout */ - killed?: boolean; -} - -export interface ExecOptions { - /** AbortSignal to cancel the process */ - signal?: AbortSignal; - /** Timeout in milliseconds */ - timeout?: number; - /** Working directory */ - cwd?: string; -} +// Re-export for backward compatibility +export type { ExecOptions, ExecResult } from "../exec.js"; /** * UI context for hooks to request interactive UI from the harness. @@ -372,26 +352,26 @@ export type HookHandler = (event: E, ctx: HookEventContext) => Prom /** * Options passed to custom message renderers. */ -export interface CustomMessageRenderOptions { +/** + * Message type for hooks to send. Creates CustomMessageEntry in the session. + */ +export type HookMessage = Pick, "customType" | "content" | "display" | "details">; + +export interface HookMessageRenderOptions { /** Whether the view is expanded */ expanded: boolean; } /** - * Renderer for custom message entries. - * Hooks register these to provide custom TUI rendering for their CustomMessageEntry types. + * Renderer for hook messages. + * Hooks register these to provide custom TUI rendering for their message types. */ -export type CustomMessageRenderer = ( - entry: CustomMessageEntry, - options: CustomMessageRenderOptions, +export type HookMessageRenderer = ( + message: HookMessage, + options: HookMessageRenderOptions, theme: Theme, ) => Component | null; -/** - * Message type for hooks to send. Creates CustomMessageEntry in the session. - */ -export type HookMessage = Pick, "customType" | "content" | "display" | "details">; - /** * Context passed to hook command handlers. */ @@ -483,7 +463,7 @@ export interface HookAPI { * The renderer is called when rendering the entry in the TUI. * Return null to use the default renderer. */ - registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void; + registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void; /** * Register a custom slash command. diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 1c7ab14f..072cb009 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -340,7 +340,7 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory { function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] { return definitions.map((def) => { const handlers = new Map Promise>>(); - const customMessageRenderers = new Map(); + const messageRenderers = new Map(); const commands = new Map(); let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {}; let appendEntryHandler: (customType: string, data?: any) => void = () => {}; @@ -357,8 +357,8 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa appendEntry: (customType: string, data?: any) => { appendEntryHandler(customType, data); }, - registerCustomMessageRenderer: (customType: string, renderer: any) => { - customMessageRenderers.set(customType, renderer); + registerMessageRenderer: (customType: string, renderer: any) => { + messageRenderers.set(customType, renderer); }, registerCommand: (name: string, options: any) => { commands.set(name, { name, ...options }); @@ -371,7 +371,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa path: def.path ?? "", resolvedPath: def.path ?? "", handlers, - customMessageRenderers, + messageRenderers, commands, setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => { sendMessageHandler = handler; diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index 5e952c32..5e2d3410 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -1,6 +1,6 @@ import type { TextContent } from "@mariozechner/pi-ai"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import type { CustomMessageRenderer } from "../../../core/hooks/types.js"; +import type { HookMessage, HookMessageRenderer } from "../../../core/hooks/types.js"; import type { CustomMessageEntry } from "../../../core/session-manager.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; @@ -9,21 +9,49 @@ import { getMarkdownTheme, theme } from "../theme/theme.js"; * Uses distinct styling to differentiate from user messages. */ export class CustomMessageComponent extends Container { - constructor(entry: CustomMessageEntry, customRenderer?: CustomMessageRenderer) { + private entry: CustomMessageEntry; + private customRenderer?: HookMessageRenderer; + private box: Box; + private _expanded = false; + + constructor(entry: CustomMessageEntry, customRenderer?: HookMessageRenderer) { super(); + this.entry = entry; + this.customRenderer = customRenderer; this.addChild(new Spacer(1)); // Create box with purple background - const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + this.addChild(this.box); + + this.rebuild(); + } + + setExpanded(expanded: boolean): void { + if (this._expanded !== expanded) { + this._expanded = expanded; + this.rebuild(); + } + } + + private rebuild(): void { + this.box.clear(); + + // Convert entry to HookMessage for renderer + const message: HookMessage = { + customType: this.entry.customType, + content: this.entry.content, + display: this.entry.display, + details: this.entry.details, + }; // Try custom renderer first - if (customRenderer) { + if (this.customRenderer) { try { - const component = customRenderer(entry, { expanded: false }, theme); + const component = this.customRenderer(message, { expanded: this._expanded }, theme); if (component) { - box.addChild(component); - this.addChild(box); + this.box.addChild(component); return; } } catch { @@ -32,27 +60,25 @@ export class CustomMessageComponent extends Container { } // Default rendering: label + content - const label = theme.fg("customMessageLabel", `\x1b[1m[${entry.customType}]\x1b[22m`); - box.addChild(new Text(label, 0, 0)); - box.addChild(new Spacer(1)); + const label = theme.fg("customMessageLabel", `\x1b[1m[${this.entry.customType}]\x1b[22m`); + this.box.addChild(new Text(label, 0, 0)); + this.box.addChild(new Spacer(1)); // Extract text content let text: string; - if (typeof entry.content === "string") { - text = entry.content; + if (typeof this.entry.content === "string") { + text = this.entry.content; } else { - text = entry.content + text = this.entry.content .filter((c): c is TextContent => c.type === "text") .map((c) => c.text) .join("\n"); } - box.addChild( + this.box.addChild( new Markdown(text, 0, 0, getMarkdownTheme(), { color: (text: string) => theme.fg("customMessageText", text), }), ); - - this.addChild(box); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 23efe7c9..1c910d37 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1028,7 +1028,7 @@ export class InteractiveMode { parentId: null, timestamp: new Date().toISOString(), }; - const renderer = this.session.hookRunner?.getCustomMessageRenderer(message.customType); + const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); } } else if (message.role === "user") { @@ -1077,7 +1077,7 @@ export class InteractiveMode { // Check if this is a custom_message entry if (entry?.type === "custom_message") { if (entry.display) { - const renderer = this.session.hookRunner?.getCustomMessageRenderer(entry.customType); + const renderer = this.session.hookRunner?.getMessageRenderer(entry.customType); this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); } continue; @@ -1271,6 +1271,8 @@ export class InteractiveMode { child.setExpanded(this.toolOutputExpanded); } else if (child instanceof BashExecutionComponent) { child.setExpanded(this.toolOutputExpanded); + } else if (child instanceof CustomMessageComponent) { + child.setExpanded(this.toolOutputExpanded); } } this.ui.requestRender(); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 66d21b3d..ab23364b 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -63,7 +63,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { path: "test-hook", resolvedPath: "/test/test-hook.ts", handlers, - customMessageRenderers: new Map(), + messageRenderers: new Map(), commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, @@ -240,7 +240,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ], ]), - customMessageRenderers: new Map(), + messageRenderers: new Map(), commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, @@ -286,7 +286,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ], ]), - customMessageRenderers: new Map(), + messageRenderers: new Map(), commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, @@ -311,7 +311,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { ], ], ]), - customMessageRenderers: new Map(), + messageRenderers: new Map(), commands: new Map(), setSendMessageHandler: () => {}, setAppendEntryHandler: () => {}, From 14ad8d6228c6d1a2a3e5266bc1a19e211f52e847 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:17:38 +0100 Subject: [PATCH 040/161] Add ui.custom() for custom hook components with keyboard focus - Add custom() to HookUIContext: returns { close, requestRender } - Component receives keyboard input via handleInput() - CustomMessageComponent default rendering now limits to 5 lines when collapsed - Add snake.ts example hook with /snake command --- packages/coding-agent/examples/hooks/snake.ts | 251 ++++++++++++++++++ .../src/core/custom-tools/loader.ts | 1 + packages/coding-agent/src/core/hooks/index.ts | 4 +- .../coding-agent/src/core/hooks/runner.ts | 1 + packages/coding-agent/src/core/hooks/types.ts | 9 + .../interactive/components/custom-message.ts | 8 + .../src/modes/interactive/interactive-mode.ts | 37 +++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 5 + .../test/compaction-hooks.test.ts | 1 + 9 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/examples/hooks/snake.ts diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts new file mode 100644 index 00000000..05013e94 --- /dev/null +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -0,0 +1,251 @@ +/** + * Snake game hook - play snake with /snake command + */ + +import type { HookAPI } from "../../src/core/hooks/types.js"; + +const GAME_WIDTH = 40; +const GAME_HEIGHT = 15; +const TICK_MS = 100; + +type Direction = "up" | "down" | "left" | "right"; +type Point = { x: number; y: number }; + +interface GameState { + snake: Point[]; + food: Point; + direction: Direction; + nextDirection: Direction; + score: number; + gameOver: boolean; + highScore: number; +} + +function createInitialState(): GameState { + const startX = Math.floor(GAME_WIDTH / 2); + const startY = Math.floor(GAME_HEIGHT / 2); + return { + snake: [ + { x: startX, y: startY }, + { x: startX - 1, y: startY }, + { x: startX - 2, y: startY }, + ], + food: spawnFood([{ x: startX, y: startY }]), + direction: "right", + nextDirection: "right", + score: 0, + gameOver: false, + highScore: 0, + }; +} + +function spawnFood(snake: Point[]): Point { + let food: Point; + do { + food = { + x: Math.floor(Math.random() * GAME_WIDTH), + y: Math.floor(Math.random() * GAME_HEIGHT), + }; + } while (snake.some((s) => s.x === food.x && s.y === food.y)); + return food; +} + +class SnakeComponent { + private state: GameState; + private interval: ReturnType | null = null; + private onClose: () => void; + private requestRender: () => void; + private cachedLines: string[] = []; + private cachedWidth = 0; + private version = 0; + private cachedVersion = -1; + + constructor(onClose: () => void, requestRender: () => void) { + this.state = createInitialState(); + this.onClose = onClose; + this.requestRender = requestRender; + this.startGame(); + } + + private startGame(): void { + this.interval = setInterval(() => { + if (!this.state.gameOver) { + this.tick(); + this.version++; + this.requestRender(); + } + }, TICK_MS); + } + + private tick(): void { + // Apply queued direction change + this.state.direction = this.state.nextDirection; + + // Calculate new head position + const head = this.state.snake[0]; + let newHead: Point; + + switch (this.state.direction) { + case "up": + newHead = { x: head.x, y: head.y - 1 }; + break; + case "down": + newHead = { x: head.x, y: head.y + 1 }; + break; + case "left": + newHead = { x: head.x - 1, y: head.y }; + break; + case "right": + newHead = { x: head.x + 1, y: head.y }; + break; + } + + // Check wall collision + if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) { + this.state.gameOver = true; + return; + } + + // Check self collision + if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) { + this.state.gameOver = true; + return; + } + + // Move snake + this.state.snake.unshift(newHead); + + // Check food collision + if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) { + this.state.score += 10; + if (this.state.score > this.state.highScore) { + this.state.highScore = this.state.score; + } + this.state.food = spawnFood(this.state.snake); + } else { + this.state.snake.pop(); + } + } + + handleInput(data: string): void { + // ESC or q to quit + if (data === "\x1b" || data === "q" || data === "Q") { + this.dispose(); + this.onClose(); + return; + } + + // Arrow keys + if (data === "\x1b[A" || data === "w" || data === "W") { + if (this.state.direction !== "down") this.state.nextDirection = "up"; + } else if (data === "\x1b[B" || data === "s" || data === "S") { + if (this.state.direction !== "up") this.state.nextDirection = "down"; + } else if (data === "\x1b[C" || data === "d" || data === "D") { + if (this.state.direction !== "left") this.state.nextDirection = "right"; + } else if (data === "\x1b[D" || data === "a" || data === "A") { + if (this.state.direction !== "right") this.state.nextDirection = "left"; + } + + // Restart on game over + if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) { + const highScore = this.state.highScore; + this.state = createInitialState(); + this.state.highScore = highScore; + this.version++; + this.requestRender(); + } + } + + invalidate(): void { + this.cachedWidth = 0; + } + + render(width: number): string[] { + if (width === this.cachedWidth && this.cachedVersion === this.version) { + return this.cachedLines; + } + + const lines: string[] = []; + + // Clamp game width to available terminal width (leaving space for border) + const effectiveWidth = Math.min(GAME_WIDTH, width - 4); + const effectiveHeight = GAME_HEIGHT; + + // Header + const header = ` SNAKE | Score: ${this.state.score} | High: ${this.state.highScore} `; + lines.push(this.padLine(header, width)); + lines.push(this.padLine(`+${"-".repeat(effectiveWidth)}+`, width)); + + // Game grid + for (let y = 0; y < effectiveHeight; y++) { + let row = "|"; + for (let x = 0; x < effectiveWidth; x++) { + const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y; + const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y); + const isFood = this.state.food.x === x && this.state.food.y === y; + + if (isHead) { + row += "\x1b[32m@\x1b[0m"; // Green head + } else if (isBody) { + row += "\x1b[32mo\x1b[0m"; // Green body + } else if (isFood) { + row += "\x1b[31m*\x1b[0m"; // Red food + } else { + row += " "; + } + } + row += "|"; + lines.push(this.padLine(row, width)); + } + + lines.push(this.padLine(`+${"-".repeat(effectiveWidth)}+`, width)); + + // Footer + if (this.state.gameOver) { + lines.push(this.padLine("\x1b[31m GAME OVER! \x1b[0m Press R to restart, ESC to quit", width)); + } else { + lines.push(this.padLine(" Arrow keys or WASD to move, ESC to quit", width)); + } + + this.cachedLines = lines; + this.cachedWidth = width; + this.cachedVersion = this.version; + + return lines; + } + + private padLine(line: string, width: number): string { + // Calculate visible length (strip ANSI codes) + const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length; + const padding = Math.max(0, width - visibleLen); + return line + " ".repeat(padding); + } + + dispose(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +export default function (pi: HookAPI) { + pi.registerCommand("snake", { + description: "Play Snake!", + handler: async (ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("Snake requires interactive mode", "error"); + return; + } + + let ui: { close: () => void; requestRender: () => void } | null = null; + + const component = new SnakeComponent( + () => ui?.close(), + () => ui?.requestRender(), + ); + + ui = ctx.ui.custom(component); + }, + }); +} diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index e3e7d0d9..f22cb6c9 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -90,6 +90,7 @@ function createNoOpUIContext(): HookUIContext { confirm: async () => false, input: async () => null, notify: () => {}, + custom: () => ({ close: () => {}, requestRender: () => {} }), }; } diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index e16282bb..5df5f5fa 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -13,8 +13,6 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, - HookMessageRenderer, - HookMessageRenderOptions, CustomToolResultEvent, EditToolResultEvent, ExecOptions, @@ -28,6 +26,8 @@ export type { HookEventContext, HookFactory, HookMessage, + HookMessageRenderer, + HookMessageRenderOptions, HookUIContext, LsToolResultEvent, ReadToolResultEvent, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 768ffc86..6af0b77c 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -52,6 +52,7 @@ const noOpUIContext: HookUIContext = { confirm: async () => false, input: async () => null, notify: () => {}, + custom: () => ({ close: () => {}, requestRender: () => {} }), }; /** diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 7b329438..6ca2f11b 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -54,6 +54,15 @@ export interface HookUIContext { * Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; + + /** + * Show a custom component with keyboard focus. + * The component receives keyboard input via handleInput() if implemented. + * + * @param component - Component to display (implement handleInput for keyboard, dispose for cleanup) + * @returns Object with close() to restore normal UI and requestRender() to trigger redraw + */ + custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void }; } /** diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index 5e2d3410..cb1ccf34 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -75,6 +75,14 @@ export class CustomMessageComponent extends Container { .join("\n"); } + // Limit lines when collapsed + if (!this._expanded) { + const lines = text.split("\n"); + if (lines.length > 5) { + text = `${lines.slice(0, 5).join("\n")}\n...`; + } + } + this.box.addChild( new Markdown(text, 0, 0, getMarkdownTheme(), { color: (text: string) => theme.fg("customMessageText", text), diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1c910d37..3f2e7fda 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -443,6 +443,7 @@ export class InteractiveMode { confirm: (title, message) => this.showHookConfirm(title, message), input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), + custom: (component) => this.showHookCustom(component), }; } @@ -539,6 +540,42 @@ export class InteractiveMode { } } + /** + * Show a custom component with keyboard focus. + * Returns a function to call when done. + */ + private showHookCustom(component: Component & { dispose?(): void }): { + close: () => void; + requestRender: () => void; + } { + // Store current editor content + const savedText = this.editor.getText(); + + // Replace editor with custom component + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(component); + this.ui.requestRender(); + + // Return control object + return { + close: () => { + // Call dispose if available + component.dispose?.(); + + // Restore editor + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.editor.setText(savedText); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + }, + requestRender: () => { + this.ui.requestRender(); + }, + }; + } + /** * Show a hook error in the UI. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index cae2d5f4..55819d9e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -118,6 +118,11 @@ export async function runRpcMode(session: AgentSession): Promise { notifyType: type, } as RpcHookUIRequest); }, + + custom() { + // Custom UI not supported in RPC mode + return { close: () => {}, requestRender: () => {} }; + }, }); // Load entries once for session start events diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index ab23364b..92b056cd 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { confirm: async () => false, input: async () => null, notify: () => {}, + custom: () => ({ close: () => {}, requestRender: () => {} }), }, false, ); From 165fb58b398cb5b63b11cfe8bb4b60b2b876b4e4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:20:10 +0100 Subject: [PATCH 041/161] Fix snake.ts: use key helpers from pi-tui for ESC and arrow keys --- packages/coding-agent/examples/hooks/snake.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index 05013e94..4dffef53 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -2,6 +2,7 @@ * Snake game hook - play snake with /snake command */ +import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape } from "@mariozechner/pi-tui"; import type { HookAPI } from "../../src/core/hooks/types.js"; const GAME_WIDTH = 40; @@ -129,20 +130,20 @@ class SnakeComponent { handleInput(data: string): void { // ESC or q to quit - if (data === "\x1b" || data === "q" || data === "Q") { + if (isEscape(data) || data === "q" || data === "Q") { this.dispose(); this.onClose(); return; } - // Arrow keys - if (data === "\x1b[A" || data === "w" || data === "W") { + // Arrow keys or WASD + if (isArrowUp(data) || data === "w" || data === "W") { if (this.state.direction !== "down") this.state.nextDirection = "up"; - } else if (data === "\x1b[B" || data === "s" || data === "S") { + } else if (isArrowDown(data) || data === "s" || data === "S") { if (this.state.direction !== "up") this.state.nextDirection = "down"; - } else if (data === "\x1b[C" || data === "d" || data === "D") { + } else if (isArrowRight(data) || data === "d" || data === "D") { if (this.state.direction !== "left") this.state.nextDirection = "right"; - } else if (data === "\x1b[D" || data === "a" || data === "A") { + } else if (isArrowLeft(data) || data === "a" || data === "A") { if (this.state.direction !== "right") this.state.nextDirection = "left"; } From 818196d2c32318fb9accb279afca9a0c6d7ee92f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:22:55 +0100 Subject: [PATCH 042/161] Add immediate flag to hook commands for non-queued execution Commands with immediate: true run right away even during streaming. Used for UI-only commands like /snake that don't interact with LLM. --- packages/coding-agent/examples/hooks/snake.ts | 1 + packages/coding-agent/src/core/hooks/types.ts | 7 ++++++- .../src/modes/interactive/interactive-mode.ts | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index 4dffef53..10f34c71 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -233,6 +233,7 @@ class SnakeComponent { export default function (pi: HookAPI) { pi.registerCommand("snake", { description: "Play Snake!", + immediate: true, // Run immediately, even during streaming handler: async (ctx) => { if (!ctx.hasUI) { ctx.ui.notify("Snake requires interactive mode", "error"); diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 6ca2f11b..07f7b3f7 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -405,6 +405,8 @@ export interface HookCommandContext { export interface RegisteredCommand { name: string; description?: string; + /** If true, command runs immediately even during streaming (doesn't get queued) */ + immediate?: boolean; handler: (ctx: HookCommandContext) => Promise; } @@ -478,7 +480,10 @@ export interface HookAPI { * Register a custom slash command. * Handler receives HookCommandContext. */ - registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; + registerCommand( + name: string, + options: { description?: string; immediate?: boolean; handler: RegisteredCommand["handler"] }, + ): void; /** * Execute a shell command and return stdout/stderr/code. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3f2e7fda..babb141f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -752,6 +752,20 @@ export class InteractiveMode { return; } + // Check if this is an immediate hook command (runs even during streaming) + if (text.startsWith("/") && this.session.hookRunner && this.session.isStreaming) { + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const command = this.session.hookRunner.getCommand(commandName); + if (command?.immediate) { + // Execute immediate hook command right away + this.editor.addToHistory(text); + this.editor.setText(""); + await this.session.prompt(text); + return; + } + } + // Queue message if agent is streaming if (this.session.isStreaming) { await this.session.queueMessage(text); From 516c0ea8bc98e711a336a6ca6b19bfbdc442ff38 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:27:06 +0100 Subject: [PATCH 043/161] Improve snake game visuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rounded box corners (╭╮╰╯) - Better characters: ● head, ○ body, ◆ food, · empty - Colored title with emoji - Dimmed borders and help text - Bold highlights for score and controls --- packages/coding-agent/examples/hooks/snake.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index 10f34c71..ea8bd3a5 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -172,40 +172,54 @@ class SnakeComponent { const effectiveWidth = Math.min(GAME_WIDTH, width - 4); const effectiveHeight = GAME_HEIGHT; - // Header - const header = ` SNAKE | Score: ${this.state.score} | High: ${this.state.highScore} `; - lines.push(this.padLine(header, width)); - lines.push(this.padLine(`+${"-".repeat(effectiveWidth)}+`, width)); + // Colors + const dim = (s: string) => `\x1b[2m${s}\x1b[22m`; + const green = (s: string) => `\x1b[32m${s}\x1b[0m`; + const red = (s: string) => `\x1b[31m${s}\x1b[0m`; + const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; + const bold = (s: string) => `\x1b[1m${s}\x1b[22m`; + + // Header with score + const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`; + const highText = `High: ${bold(yellow(String(this.state.highScore)))}`; + const title = `${bold(green("🐍 SNAKE"))} │ ${scoreText} │ ${highText}`; + lines.push(this.padLine(` ${title}`, width)); + + // Top border with rounded corners + lines.push(this.padLine(dim(` ╭${"─".repeat(effectiveWidth)}╮`), width)); // Game grid for (let y = 0; y < effectiveHeight; y++) { - let row = "|"; + let row = dim(" │"); for (let x = 0; x < effectiveWidth; x++) { const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y; const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y); const isFood = this.state.food.x === x && this.state.food.y === y; if (isHead) { - row += "\x1b[32m@\x1b[0m"; // Green head + row += green("●"); // Snake head } else if (isBody) { - row += "\x1b[32mo\x1b[0m"; // Green body + row += green("○"); // Snake body } else if (isFood) { - row += "\x1b[31m*\x1b[0m"; // Red food + row += red("◆"); // Food } else { - row += " "; + row += dim("·"); // Empty cell } } - row += "|"; + row += dim("│"); lines.push(this.padLine(row, width)); } - lines.push(this.padLine(`+${"-".repeat(effectiveWidth)}+`, width)); + // Bottom border with rounded corners + lines.push(this.padLine(dim(` ╰${"─".repeat(effectiveWidth)}╯`), width)); // Footer if (this.state.gameOver) { - lines.push(this.padLine("\x1b[31m GAME OVER! \x1b[0m Press R to restart, ESC to quit", width)); + lines.push( + this.padLine(` ${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("ESC")} to quit`, width), + ); } else { - lines.push(this.padLine(" Arrow keys or WASD to move, ESC to quit", width)); + lines.push(this.padLine(dim(` ↑↓←→ or WASD to move, ESC to quit`), width)); } this.cachedLines = lines; From 5ae33defd3fa70f2bbb5fea78cd4867096d66df8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:28:30 +0100 Subject: [PATCH 044/161] Fix snake velocity: render cells as 2 chars wide for square aspect Terminal cells are ~2:1 aspect ratio, so movement appeared faster vertically. Now each game cell is 2 characters wide. --- packages/coding-agent/examples/hooks/snake.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index ea8bd3a5..7eb861da 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -168,8 +168,9 @@ class SnakeComponent { const lines: string[] = []; - // Clamp game width to available terminal width (leaving space for border) - const effectiveWidth = Math.min(GAME_WIDTH, width - 4); + // Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect) + const cellWidth = 2; + const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth)); const effectiveHeight = GAME_HEIGHT; // Colors @@ -186,7 +187,7 @@ class SnakeComponent { lines.push(this.padLine(` ${title}`, width)); // Top border with rounded corners - lines.push(this.padLine(dim(` ╭${"─".repeat(effectiveWidth)}╮`), width)); + lines.push(this.padLine(dim(` ╭${"─".repeat(effectiveWidth * cellWidth)}╮`), width)); // Game grid for (let y = 0; y < effectiveHeight; y++) { @@ -197,13 +198,13 @@ class SnakeComponent { const isFood = this.state.food.x === x && this.state.food.y === y; if (isHead) { - row += green("●"); // Snake head + row += green("██"); // Snake head (2 chars) } else if (isBody) { - row += green("○"); // Snake body + row += green("▓▓"); // Snake body (2 chars) } else if (isFood) { - row += red("◆"); // Food + row += red("◆ "); // Food (2 chars) } else { - row += dim("·"); // Empty cell + row += " "; // Empty cell (2 spaces) } } row += dim("│"); @@ -211,7 +212,7 @@ class SnakeComponent { } // Bottom border with rounded corners - lines.push(this.padLine(dim(` ╰${"─".repeat(effectiveWidth)}╯`), width)); + lines.push(this.padLine(dim(` ╰${"─".repeat(effectiveWidth * cellWidth)}╯`), width)); // Footer if (this.state.gameOver) { From b47151a04f53f9c89d800d158957aa5fa6b95c3b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:33:48 +0100 Subject: [PATCH 045/161] Snake: add full border frame and pause/resume with session persistence - Full box border around title, game area, and instructions - ESC pauses and saves state to session via pi.appendEntry() - Resume shows 'PAUSED - press any key to continue' - Q quits and clears saved state - High score persists across games --- packages/coding-agent/examples/hooks/snake.ts | 118 +++++++++++++++--- 1 file changed, 100 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index 7eb861da..fee47d0b 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -55,17 +55,36 @@ class SnakeComponent { private state: GameState; private interval: ReturnType | null = null; private onClose: () => void; + private onSave: (state: GameState | null) => void; private requestRender: () => void; private cachedLines: string[] = []; private cachedWidth = 0; private version = 0; private cachedVersion = -1; + private paused: boolean; - constructor(onClose: () => void, requestRender: () => void) { - this.state = createInitialState(); + constructor( + onClose: () => void, + onSave: (state: GameState | null) => void, + requestRender: () => void, + savedState?: GameState, + ) { + if (savedState && !savedState.gameOver) { + // Resume from saved state, start paused + this.state = savedState; + this.paused = true; + } else { + // New game or saved game was over + this.state = createInitialState(); + if (savedState) { + this.state.highScore = savedState.highScore; + } + this.paused = false; + this.startGame(); + } this.onClose = onClose; + this.onSave = onSave; this.requestRender = requestRender; - this.startGame(); } private startGame(): void { @@ -129,9 +148,32 @@ class SnakeComponent { } handleInput(data: string): void { - // ESC or q to quit - if (isEscape(data) || data === "q" || data === "Q") { + // If paused (resuming), wait for any key + if (this.paused) { + if (isEscape(data) || data === "q" || data === "Q") { + // Quit without clearing save + this.dispose(); + this.onClose(); + return; + } + // Any other key resumes + this.paused = false; + this.startGame(); + return; + } + + // ESC to pause and save + if (isEscape(data)) { this.dispose(); + this.onSave(this.state); + this.onClose(); + return; + } + + // Q to quit without saving (clears saved state) + if (data === "q" || data === "Q") { + this.dispose(); + this.onSave(null); // Clear saved state this.onClose(); return; } @@ -152,6 +194,7 @@ class SnakeComponent { const highScore = this.state.highScore; this.state = createInitialState(); this.state.highScore = highScore; + this.onSave(null); // Clear saved state on restart this.version++; this.requestRender(); } @@ -180,18 +223,31 @@ class SnakeComponent { const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; const bold = (s: string) => `\x1b[1m${s}\x1b[22m`; + const boxWidth = effectiveWidth * cellWidth; + + // Helper to pad content inside box + const boxLine = (content: string, contentLen: number) => { + const padding = Math.max(0, boxWidth - contentLen); + return dim(" │") + content + " ".repeat(padding) + dim("│"); + }; + + // Top border + lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width)); + // Header with score const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`; const highText = `High: ${bold(yellow(String(this.state.highScore)))}`; const title = `${bold(green("🐍 SNAKE"))} │ ${scoreText} │ ${highText}`; - lines.push(this.padLine(` ${title}`, width)); + // Approximate visible length (emojis and formatting make this tricky) + const titleLen = 8 + 3 + 7 + String(this.state.score).length + 3 + 6 + String(this.state.highScore).length; + lines.push(this.padLine(boxLine(title, titleLen), width)); - // Top border with rounded corners - lines.push(this.padLine(dim(` ╭${"─".repeat(effectiveWidth * cellWidth)}╮`), width)); + // Separator + lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width)); // Game grid for (let y = 0; y < effectiveHeight; y++) { - let row = dim(" │"); + let row = ""; for (let x = 0; x < effectiveWidth; x++) { const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y; const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y); @@ -207,21 +263,29 @@ class SnakeComponent { row += " "; // Empty cell (2 spaces) } } - row += dim("│"); - lines.push(this.padLine(row, width)); + lines.push(this.padLine(dim(" │") + row + dim("│"), width)); } - // Bottom border with rounded corners - lines.push(this.padLine(dim(` ╰${"─".repeat(effectiveWidth * cellWidth)}╯`), width)); + // Separator + lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width)); // Footer - if (this.state.gameOver) { - lines.push( - this.padLine(` ${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("ESC")} to quit`, width), - ); + let footer: string; + let footerLen: number; + if (this.paused) { + footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`; + footerLen = 42; + } else if (this.state.gameOver) { + footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`; + footerLen = 40; } else { - lines.push(this.padLine(dim(` ↑↓←→ or WASD to move, ESC to quit`), width)); + footer = dim(`↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`); + footerLen = 38; } + lines.push(this.padLine(boxLine(footer, footerLen), width)); + + // Bottom border + lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width)); this.cachedLines = lines; this.cachedWidth = width; @@ -245,6 +309,8 @@ class SnakeComponent { } } +const SNAKE_SAVE_TYPE = "snake-save"; + export default function (pi: HookAPI) { pi.registerCommand("snake", { description: "Play Snake!", @@ -255,11 +321,27 @@ export default function (pi: HookAPI) { return; } + // Load saved state from session + const entries = ctx.sessionManager.getEntries(); + let savedState: GameState | undefined; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) { + savedState = entry.data as GameState; + break; + } + } + let ui: { close: () => void; requestRender: () => void } | null = null; const component = new SnakeComponent( () => ui?.close(), + (state) => { + // Save or clear state + pi.appendEntry(SNAKE_SAVE_TYPE, state); + }, () => ui?.requestRender(), + savedState, ); ui = ctx.ui.custom(component); From 6977bc73ca164485b02f5d5e4cd0c2299a1e405c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:36:15 +0100 Subject: [PATCH 046/161] Fix snake border alignment using visibleWidth for proper ANSI length calculation --- packages/coding-agent/examples/hooks/snake.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index fee47d0b..e7569c60 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -2,7 +2,7 @@ * Snake game hook - play snake with /snake command */ -import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape } from "@mariozechner/pi-tui"; +import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui"; import type { HookAPI } from "../../src/core/hooks/types.js"; const GAME_WIDTH = 40; @@ -226,7 +226,8 @@ class SnakeComponent { const boxWidth = effectiveWidth * cellWidth; // Helper to pad content inside box - const boxLine = (content: string, contentLen: number) => { + const boxLine = (content: string) => { + const contentLen = visibleWidth(content); const padding = Math.max(0, boxWidth - contentLen); return dim(" │") + content + " ".repeat(padding) + dim("│"); }; @@ -237,10 +238,8 @@ class SnakeComponent { // Header with score const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`; const highText = `High: ${bold(yellow(String(this.state.highScore)))}`; - const title = `${bold(green("🐍 SNAKE"))} │ ${scoreText} │ ${highText}`; - // Approximate visible length (emojis and formatting make this tricky) - const titleLen = 8 + 3 + 7 + String(this.state.score).length + 3 + 6 + String(this.state.highScore).length; - lines.push(this.padLine(boxLine(title, titleLen), width)); + const title = `${bold(green("SNAKE"))} │ ${scoreText} │ ${highText}`; + lines.push(this.padLine(boxLine(title), width)); // Separator lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width)); @@ -271,18 +270,14 @@ class SnakeComponent { // Footer let footer: string; - let footerLen: number; if (this.paused) { footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`; - footerLen = 42; } else if (this.state.gameOver) { footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`; - footerLen = 40; } else { - footer = dim(`↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`); - footerLen = 38; + footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`; } - lines.push(this.padLine(boxLine(footer, footerLen), width)); + lines.push(this.padLine(boxLine(footer), width)); // Bottom border lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width)); From 9e165d1d81e1c7c0ad12351cf88c9cd5d097998d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 03:57:55 +0100 Subject: [PATCH 047/161] Add context event TODO with dynamic context pruning example Reference: https://github.com/badlogic/pi-mono/discussions/330 --- .../coding-agent/docs/session-tree-plan.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index ea85d95b..65c79bed 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -227,6 +227,64 @@ interface HookUIContext { See also: `CustomEntry` for storing hook state that does NOT participate in context. +**New: `context` event (TODO)** + +Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively. + +```typescript +interface ContextEvent { + type: "context"; + /** Messages that will be sent to the LLM */ + messages: Message[]; +} + +interface ContextEventResult { + /** Modified messages to send instead */ + messages?: Message[]; +} + +// In HookAPI: +on(event: "context", handler: HookHandler): void; +``` + +Example use case: **Dynamic Context Pruning** ([discussion #330](https://github.com/badlogic/pi-mono/discussions/330)) + +Non-destructive pruning of tool results to reduce context size: + +```typescript +export default function(pi: HookAPI) { + // Register /prune command + pi.registerCommand("prune", { + description: "Mark tool results for pruning", + handler: async (ctx) => { + // Show UI to select which tool results to prune + // Append custom entry recording pruning decisions: + // { toolResultId, strategy: "summary" | "truncate" | "remove" } + pi.appendEntry("tool-result-pruning", { ... }); + } + }); + + // Intercept context before LLM call + pi.on("context", async (event, ctx) => { + // Find all pruning entries in session + const entries = ctx.sessionManager.getEntries(); + const pruningRules = entries + .filter(e => e.type === "custom" && e.customType === "tool-result-pruning") + .map(e => e.data); + + // Apply pruning rules to messages + const prunedMessages = applyPruning(event.messages, pruningRules); + return { messages: prunedMessages }; + }); +} +``` + +Benefits: +- Original tool results stay intact in session +- Pruning is stored as custom entries, survives session reload +- Works with branching (pruning entries are part of the tree) +- Trade-off: cache busting on first submission after pruning + ### HTML Export - [ ] Add collapsible sidebar showing full tree structure From 77fe3f1a130e91c78a47ffb3e1375d91407101e9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 19:33:41 +0100 Subject: [PATCH 048/161] Add context event for non-destructive message modification before LLM calls - Add contextTransform option to Agent (runs before messageTransformer) - Deep copy messages before passing to contextTransform (modifications are ephemeral) - Add ContextEvent and ContextEventResult types - Add emitContext() to HookRunner (chains multiple handlers) - Wire up in sdk.ts when creating Agent with hooks Enables dynamic context pruning: hooks can modify messages sent to LLM without changing session data. See discussion #330. --- packages/agent/src/agent.ts | 17 +++++++- .../coding-agent/docs/session-tree-plan.md | 2 +- packages/coding-agent/src/core/hooks/index.ts | 2 + .../coding-agent/src/core/hooks/runner.ts | 42 +++++++++++++++++++ packages/coding-agent/src/core/hooks/types.ts | 23 ++++++++++ packages/coding-agent/src/core/sdk.ts | 5 +++ 6 files changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ab4620d8..c857bfec 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -55,6 +55,8 @@ export interface AgentOptions { transport: AgentTransport; // Transform app messages to LLM-compatible messages before sending to transport messageTransformer?: (messages: AppMessage[]) => Message[] | Promise; + // Called before messageTransformer - can modify messages before they're sent to LLM (non-destructive) + contextTransform?: (messages: AppMessage[]) => Promise; // Queue mode: "all" = send all queued messages at once, "one-at-a-time" = send one queued message per turn queueMode?: "all" | "one-at-a-time"; } @@ -75,6 +77,7 @@ export class Agent { private abortController?: AbortController; private transport: AgentTransport; private messageTransformer: (messages: AppMessage[]) => Message[] | Promise; + private contextTransform?: (messages: AppMessage[]) => Promise; private messageQueue: Array> = []; private queueMode: "all" | "one-at-a-time"; private runningPrompt?: Promise; @@ -84,6 +87,7 @@ export class Agent { this._state = { ...this._state, ...opts.initialState }; this.transport = opts.transport; this.messageTransformer = opts.messageTransformer || defaultMessageTransformer; + this.contextTransform = opts.contextTransform; this.queueMode = opts.queueMode || "one-at-a-time"; } @@ -298,7 +302,18 @@ export class Agent { }, }; - const llmMessages = await this.messageTransformer(this._state.messages); + // Apply context transform (hooks can modify messages non-destructively) + // Deep copy so modifications don't affect the original state + let messagesToSend = this._state.messages; + if (this.contextTransform) { + const messagesCopy = JSON.parse(JSON.stringify(messagesToSend)) as AppMessage[]; + const transformed = await this.contextTransform(messagesCopy); + if (transformed) { + messagesToSend = transformed; + } + } + + const llmMessages = await this.messageTransformer(messagesToSend); return { llmMessages, cfg, model }; } diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 65c79bed..319cf632 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -227,7 +227,7 @@ interface HookUIContext { See also: `CustomEntry` for storing hook state that does NOT participate in context. -**New: `context` event (TODO)** +**New: `context` event ✅** Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively. diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 5df5f5fa..4f98b704 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -13,6 +13,8 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, + ContextEvent, + ContextEventResult, CustomToolResultEvent, EditToolResultEvent, ExecOptions, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 6af0b77c..42623afc 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -2,10 +2,13 @@ * Hook runner - executes hooks and manages their lifecycle. */ +import type { AppMessage } from "@mariozechner/pi-agent-core"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { + ContextEvent, + ContextEventResult, HookError, HookEvent, HookEventContext, @@ -304,4 +307,43 @@ export class HookRunner { return result; } + + /** + * Emit a context event to all hooks. + * Handlers are chained - each gets the previous handler's output (if any). + * Returns the final modified messages, or undefined if no modifications. + */ + async emitContext(messages: AppMessage[]): Promise { + const ctx = this.createContext(); + let currentMessages = messages; + let modified = false; + + for (const hook of this.hooks) { + const handlers = hook.handlers.get("context"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: ContextEvent = { type: "context", messages: currentMessages }; + const timeout = createTimeout(this.timeout); + const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); + timeout.clear(); + + if (handlerResult && (handlerResult as ContextEventResult).messages) { + currentMessages = (handlerResult as ContextEventResult).messages!; + modified = true; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.emitError({ + hookPath: hook.path, + event: "context", + error: message, + }); + } + } + } + + return modified ? currentMessages : undefined; + } } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 07f7b3f7..a0089300 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -146,6 +146,17 @@ export type SessionEvent = fromHook: boolean; }); +/** + * Event data for context event. + * Fired before messages are sent to the LLM, allowing hooks to modify context non-destructively. + * Original session messages are NOT modified - only the messages sent to the LLM are affected. + */ +export interface ContextEvent { + type: "context"; + /** Messages about to be sent to the LLM */ + messages: AppMessage[]; +} + /** * Event data for agent_start event. * Fired when an agent loop starts (once per user prompt). @@ -301,6 +312,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { */ export type HookEvent = | SessionEvent + | ContextEvent | AgentStartEvent | AgentEndEvent | TurnStartEvent @@ -312,6 +324,15 @@ export type HookEvent = // Event Results // ============================================================================ +/** + * Return type for context event handlers. + * Allows hooks to modify messages before they're sent to the LLM. + */ +export interface ContextEventResult { + /** Modified messages to send instead of the original */ + messages?: AppMessage[]; +} + /** * Return type for tool_call event handlers. * Allows hooks to block tool execution. @@ -417,6 +438,8 @@ export interface RegisteredCommand { export interface HookAPI { // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything on(event: "session", handler: HookHandler): void; + // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything + on(event: "context", handler: HookHandler): void; on(event: "agent_start", handler: HookHandler): void; on(event: "agent_end", handler: HookHandler): void; on(event: "turn_start", handler: HookHandler): void; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 072cb009..d563a099 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -589,6 +589,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: allToolsArray, }, messageTransformer, + contextTransform: hookRunner + ? async (messages) => { + return hookRunner.emitContext(messages); + } + : undefined, queueMode: settingsManager.getQueueMode(), transport: new ProviderTransport({ getApiKey: async () => { From a2515cf43f5345a5af51c80c610b788c6a77c1c2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 19:57:14 +0100 Subject: [PATCH 049/161] Wire context event to preprocessor for per-LLM-call execution - Change from contextTransform (runs once at agent start) to preprocessor - preprocessor runs before EACH LLM call inside the agent loop - ContextEvent now uses Message[] (pi-ai format) instead of AppMessage[] - Deep copy handled by pi-ai preprocessor, not Agent This enables: - Pruning rules applied on every turn (not just agent start) - /prune during long agent loop takes effect immediately - Compaction can use same transforms (future work) --- packages/agent/src/agent.ts | 22 +++++-------------- .../agent/src/transports/ProviderTransport.ts | 1 + packages/agent/src/transports/types.ts | 2 ++ .../coding-agent/src/core/hooks/runner.ts | 12 +++++----- packages/coding-agent/src/core/hooks/types.ts | 11 +++++----- packages/coding-agent/src/core/sdk.ts | 2 +- 6 files changed, 22 insertions(+), 28 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index c857bfec..39b4c943 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -55,8 +55,8 @@ export interface AgentOptions { transport: AgentTransport; // Transform app messages to LLM-compatible messages before sending to transport messageTransformer?: (messages: AppMessage[]) => Message[] | Promise; - // Called before messageTransformer - can modify messages before they're sent to LLM (non-destructive) - contextTransform?: (messages: AppMessage[]) => Promise; + // Called before each LLM call inside the agent loop - can modify messages (e.g., for pruning) + preprocessor?: (messages: Message[]) => Promise; // Queue mode: "all" = send all queued messages at once, "one-at-a-time" = send one queued message per turn queueMode?: "all" | "one-at-a-time"; } @@ -77,7 +77,7 @@ export class Agent { private abortController?: AbortController; private transport: AgentTransport; private messageTransformer: (messages: AppMessage[]) => Message[] | Promise; - private contextTransform?: (messages: AppMessage[]) => Promise; + private preprocessor?: (messages: Message[]) => Promise; private messageQueue: Array> = []; private queueMode: "all" | "one-at-a-time"; private runningPrompt?: Promise; @@ -87,7 +87,7 @@ export class Agent { this._state = { ...this._state, ...opts.initialState }; this.transport = opts.transport; this.messageTransformer = opts.messageTransformer || defaultMessageTransformer; - this.contextTransform = opts.contextTransform; + this.preprocessor = opts.preprocessor; this.queueMode = opts.queueMode || "one-at-a-time"; } @@ -286,6 +286,7 @@ export class Agent { tools: this._state.tools, model, reasoning, + preprocessor: this.preprocessor, getQueuedMessages: async () => { if (this.queueMode === "one-at-a-time") { if (this.messageQueue.length > 0) { @@ -302,18 +303,7 @@ export class Agent { }, }; - // Apply context transform (hooks can modify messages non-destructively) - // Deep copy so modifications don't affect the original state - let messagesToSend = this._state.messages; - if (this.contextTransform) { - const messagesCopy = JSON.parse(JSON.stringify(messagesToSend)) as AppMessage[]; - const transformed = await this.contextTransform(messagesCopy); - if (transformed) { - messagesToSend = transformed; - } - } - - const llmMessages = await this.messageTransformer(messagesToSend); + const llmMessages = await this.messageTransformer(this._state.messages); return { llmMessages, cfg, model }; } diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts index 024db0e4..ee494553 100644 --- a/packages/agent/src/transports/ProviderTransport.ts +++ b/packages/agent/src/transports/ProviderTransport.ts @@ -60,6 +60,7 @@ export class ProviderTransport implements AgentTransport { // Resolve API key per assistant response (important for expiring OAuth tokens) getApiKey: this.options.getApiKey, getQueuedMessages: cfg.getQueuedMessages, + preprocessor: cfg.preprocessor, }; } diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index 736ba0c3..f74dcac5 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -9,6 +9,8 @@ export interface AgentRunConfig { model: Model; reasoning?: ReasoningEffort; getQueuedMessages?: () => Promise[]>; + /** Called before each LLM call - can modify messages (e.g., for pruning) */ + preprocessor?: (messages: Message[]) => Promise; } /** diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 42623afc..df1bd087 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -2,7 +2,7 @@ * Hook runner - executes hooks and manages their lifecycle. */ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { Message } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; @@ -311,12 +311,13 @@ export class HookRunner { /** * Emit a context event to all hooks. * Handlers are chained - each gets the previous handler's output (if any). - * Returns the final modified messages, or undefined if no modifications. + * Returns the final modified messages, or the original if no modifications. + * + * Note: Messages are already deep-copied by the caller (pi-ai preprocessor). */ - async emitContext(messages: AppMessage[]): Promise { + async emitContext(messages: Message[]): Promise { const ctx = this.createContext(); let currentMessages = messages; - let modified = false; for (const hook of this.hooks) { const handlers = hook.handlers.get("context"); @@ -331,7 +332,6 @@ export class HookRunner { if (handlerResult && (handlerResult as ContextEventResult).messages) { currentMessages = (handlerResult as ContextEventResult).messages!; - modified = true; } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -344,6 +344,6 @@ export class HookRunner { } } - return modified ? currentMessages : undefined; + return currentMessages; } } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index a0089300..480696b7 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -6,7 +6,7 @@ */ import type { AppMessage } from "@mariozechner/pi-agent-core"; -import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; +import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction.js"; @@ -148,13 +148,14 @@ export type SessionEvent = /** * Event data for context event. - * Fired before messages are sent to the LLM, allowing hooks to modify context non-destructively. + * Fired before each LLM call, allowing hooks to modify context non-destructively. * Original session messages are NOT modified - only the messages sent to the LLM are affected. + * Messages are already in LLM format (Message[], not AppMessage[]). */ export interface ContextEvent { type: "context"; - /** Messages about to be sent to the LLM */ - messages: AppMessage[]; + /** Messages about to be sent to the LLM (deep copy, safe to modify) */ + messages: Message[]; } /** @@ -330,7 +331,7 @@ export type HookEvent = */ export interface ContextEventResult { /** Modified messages to send instead of the original */ - messages?: AppMessage[]; + messages?: Message[]; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index d563a099..30ac53a1 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -589,7 +589,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: allToolsArray, }, messageTransformer, - contextTransform: hookRunner + preprocessor: hookRunner ? async (messages) => { return hookRunner.emitContext(messages); } From 204d27581ba2a0da94438b4bee119192cdbece57 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 20:52:12 +0100 Subject: [PATCH 050/161] Cleanup: unify HookMessage naming and simplify SessionContext - Rename HookAppMessage to HookMessage, isHookAppMessage to isHookMessage - Remove entries array from SessionContext (use isHookMessage type guard instead) - HookMessage.content now accepts string directly (not just array) - Fix streamMessage type in AgentState (AppMessage, not Message) - Rename CustomMessageComponent to HookMessageComponent - Fix test hook to use pi.sendMessage --- .pi/hooks/test-command.ts | 2 +- packages/agent/src/agent.ts | 6 ++-- packages/agent/src/types.ts | 2 +- packages/coding-agent/CHANGELOG.md | 4 +-- .../coding-agent/docs/session-tree-plan.md | 4 +-- .../coding-agent/src/core/agent-session.ts | 26 +++++++-------- packages/coding-agent/src/core/hooks/index.ts | 1 - .../coding-agent/src/core/hooks/loader.ts | 15 ++++----- packages/coding-agent/src/core/hooks/types.ts | 16 ++++----- packages/coding-agent/src/core/messages.ts | 12 +++---- .../coding-agent/src/core/session-manager.ts | 17 ++-------- .../{custom-message.ts => hook-message.ts} | 30 +++++++---------- .../src/modes/interactive/interactive-mode.ts | 33 +++++++------------ 13 files changed, 62 insertions(+), 106 deletions(-) rename packages/coding-agent/src/modes/interactive/components/{custom-message.ts => hook-message.ts} (65%) diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index 07354c87..065b5c9b 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -11,7 +11,7 @@ export default function (pi: HookAPI) { const name = ctx.args.trim() || "world"; // Insert a custom message and trigger LLM response - ctx.sendMessage( + pi.sendMessage( { customType: "greeting", content: `Hello, ${name}! Please say something nice about them.`, diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 39b4c943..ecf785b3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -325,15 +325,15 @@ export class Agent { break; } case "message_update": { - partial = ev.message as AppMessage; + partial = ev.message; this._state.streamMessage = ev.message as Message; break; } case "message_end": { partial = null; this._state.streamMessage = null; - this.appendMessage(ev.message as AppMessage); - generatedMessages.push(ev.message as AppMessage); + this.appendMessage(ev.message); + generatedMessages.push(ev.message); break; } case "tool_execution_start": { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 46da1492..6fccb591 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -73,7 +73,7 @@ export interface AgentState { tools: AgentTool[]; messages: AppMessage[]; // Can include attachments + custom message types isStreaming: boolean; - streamMessage: Message | null; + streamMessage: AppMessage | null; pendingToolCalls: Set; error?: string; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5efd1ee1..b2c064c8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -56,8 +56,8 @@ - **Entry labels**: New `getLabel(id)` and `appendLabelChange(targetId, label)` methods for labeling entries. Labels are included in `SessionTreeNode` for UI/export. - **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden. - **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering. -- **HookAppMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookAppMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`. -- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookAppMessage`. +- **HookMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`. +- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookMessage`. ### Fixed diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 319cf632..969748e6 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -219,7 +219,7 @@ For arbitrary hook UI with keyboard focus: ```typescript interface HookUIContext { // ... existing: select, confirm, input, notify - + /** Show custom component with keyboard focus. Call done() when finished. */ custom(component: Component, done: () => void): void; } @@ -325,7 +325,7 @@ Review and update all docs: - Updated event signatures (`SessionEventBase`, `before_compact`, etc.) - [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete - [ ] `docs/sdk.md` - Update for: - - `HookAppMessage` and `isHookAppMessage()` + - `HookMessage` and `isHookMessage()` - `Agent.prompt(AppMessage)` overload - Session v2 tree structure - SessionManager API changes diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b00caad8..ecd509d0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,7 +14,7 @@ */ import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; @@ -29,13 +29,12 @@ import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custo import { exportSessionToHtml } from "./export-html.js"; import type { HookCommandContext, - HookMessage, HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent, } from "./hooks/index.js"; -import { type BashExecutionMessage, type HookAppMessage, isHookAppMessage } from "./messages.js"; +import { type BashExecutionMessage, type HookMessage, isHookMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import type { CompactionEntry, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; @@ -220,7 +219,7 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { // Check if this is a hook message (has _hookData marker) - if (isHookAppMessage(event.message)) { + if (isHookMessage(event.message)) { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry( event.message.customType, @@ -557,21 +556,18 @@ export class AgentSession { * @param message Hook message with customType, content, display, details * @param triggerTurn If true and not streaming, triggers a new LLM turn */ - async sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise { - // Normalize content to array format for the AppMessage - const content: (TextContent | ImageContent)[] = - typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content; - - // Create HookAppMessage with proper role for type-safe handling - const appMessage: HookAppMessage = { - role: "hookMessage", + async sendHookMessage( + message: Pick, "customType" | "content" | "display" | "details">, + triggerTurn?: boolean, + ): Promise { + const appMessage = { + role: "hookMessage" as const, customType: message.customType, - content, + content: message.content, display: message.display, details: message.details, timestamp: Date.now(), - }; - + } satisfies HookMessage; if (this.isStreaming) { // Queue for processing by agent loop await this.agent.queueMessage(appMessage); diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 4f98b704..43441e3a 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -27,7 +27,6 @@ export type { HookEvent, HookEventContext, HookFactory, - HookMessage, HookMessageRenderer, HookMessageRenderOptions, HookUIContext, diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 6cfb88f8..3ac44b27 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -9,15 +9,9 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; +import type { HookMessage } from "../messages.js"; import { execCommand } from "./runner.js"; -import type { - ExecOptions, - HookAPI, - HookFactory, - HookMessage, - HookMessageRenderer, - RegisteredCommand, -} from "./types.js"; +import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -56,7 +50,10 @@ type HandlerFn = (...args: unknown[]) => Promise; /** * Send message handler type for pi.sendMessage(). */ -export type SendMessageHandler = (message: HookMessage, triggerTurn?: boolean) => void; +export type SendMessageHandler = ( + message: Pick, "customType" | "content" | "display" | "details">, + triggerTurn?: boolean, +) => void; /** * Append entry handler type for pi.appendEntry(). diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 480696b7..74e011f1 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -11,8 +11,9 @@ import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { ExecOptions, ExecResult } from "../exec.js"; +import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { CompactionEntry, CustomMessageEntry, SessionManager } from "../session-manager.js"; +import type { CompactionEntry, SessionManager } from "../session-manager.js"; import type { EditToolDetails } from "../tools/edit.js"; import type { BashToolDetails, @@ -380,14 +381,6 @@ export interface SessionEventResult { */ export type HookHandler = (event: E, ctx: HookEventContext) => Promise; -/** - * Options passed to custom message renderers. - */ -/** - * Message type for hooks to send. Creates CustomMessageEntry in the session. - */ -export type HookMessage = Pick, "customType" | "content" | "display" | "details">; - export interface HookMessageRenderOptions { /** Whether the view is expanded */ expanded: boolean; @@ -463,7 +456,10 @@ export interface HookAPI { * @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false. * If agent is streaming, message is queued and triggerTurn is ignored. */ - sendMessage(message: HookMessage, triggerTurn?: boolean): void; + sendMessage( + message: Pick, "customType" | "content" | "display" | "details">, + triggerTurn?: boolean, + ): void; /** * Append a custom entry to the session for hook state persistence. diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 80121d6e..b202ca7d 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -32,10 +32,10 @@ import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; * Message type for hook-injected messages via sendMessage(). * These are custom messages that hooks can inject into the conversation. */ -export interface HookAppMessage { +export interface HookMessage { role: "hookMessage"; customType: string; - content: (TextContent | ImageContent)[]; + content: string | (TextContent | ImageContent)[]; display: boolean; details?: T; timestamp: number; @@ -45,7 +45,7 @@ export interface HookAppMessage { declare module "@mariozechner/pi-agent-core" { interface CustomMessages { bashExecution: BashExecutionMessage; - hookMessage: HookAppMessage; + hookMessage: HookMessage; } } @@ -63,8 +63,8 @@ export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashEx /** * Type guard for HookAppMessage. */ -export function isHookAppMessage(msg: AppMessage | Message): msg is HookAppMessage { - return (msg as HookAppMessage).role === "hookMessage"; +export function isHookMessage(msg: AppMessage | Message): msg is HookMessage { + return (msg as HookMessage).role === "hookMessage"; } // ============================================================================ @@ -114,7 +114,7 @@ export function messageTransformer(messages: AppMessage[]): Message[] { timestamp: m.timestamp, }; } - if (isHookAppMessage(m)) { + if (isHookMessage(m)) { // Convert hook message to user message for LLM return { role: "user", diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 6bd8a46f..fb1ce775 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -131,8 +131,6 @@ export interface SessionTreeNode { export interface SessionContext { messages: AppMessage[]; - /** Entries in the current path (root to leaf). Use to identify custom_message entries for rendering. */ - entries: SessionEntry[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; } @@ -292,7 +290,7 @@ export function buildSessionContext( } if (!leaf) { - return { messages: [], entries: [], thinkingLevel: "off", model: null }; + return { messages: [], thinkingLevel: "off", model: null }; } // Walk from leaf to root, collecting path @@ -326,12 +324,10 @@ export function buildSessionContext( // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction const messages: AppMessage[] = []; - const contextEntries: SessionEntry[] = []; if (compaction) { // Emit summary first messages.push(createSummaryMessage(compaction.summary, compaction.timestamp)); - contextEntries.push(compaction); // Find compaction index in path const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id); @@ -346,13 +342,10 @@ export function buildSessionContext( if (foundFirstKept) { if (entry.type === "message") { messages.push(entry.message); - contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); - contextEntries.push(entry); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - contextEntries.push(entry); } } } @@ -362,13 +355,10 @@ export function buildSessionContext( const entry = path[i]; if (entry.type === "message") { messages.push(entry.message); - contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); - contextEntries.push(entry); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - contextEntries.push(entry); } } } else { @@ -376,18 +366,15 @@ export function buildSessionContext( for (const entry of path) { if (entry.type === "message") { messages.push(entry.message); - contextEntries.push(entry); } else if (entry.type === "custom_message") { messages.push(createCustomMessage(entry)); - contextEntries.push(entry); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - contextEntries.push(entry); } } } - return { messages, entries: contextEntries, thinkingLevel, model }; + return { messages, thinkingLevel, model }; } /** diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/hook-message.ts similarity index 65% rename from packages/coding-agent/src/modes/interactive/components/custom-message.ts rename to packages/coding-agent/src/modes/interactive/components/hook-message.ts index cb1ccf34..9ef94d6a 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-message.ts @@ -1,22 +1,22 @@ import type { TextContent } from "@mariozechner/pi-ai"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import type { HookMessage, HookMessageRenderer } from "../../../core/hooks/types.js"; -import type { CustomMessageEntry } from "../../../core/session-manager.js"; +import type { HookMessage } from "packages/coding-agent/src/core/messages.js"; +import type { HookMessageRenderer } from "../../../core/hooks/types.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; /** * Component that renders a custom message entry from hooks. * Uses distinct styling to differentiate from user messages. */ -export class CustomMessageComponent extends Container { - private entry: CustomMessageEntry; +export class HookMessageComponent extends Container { + private message: HookMessage; private customRenderer?: HookMessageRenderer; private box: Box; private _expanded = false; - constructor(entry: CustomMessageEntry, customRenderer?: HookMessageRenderer) { + constructor(message: HookMessage, customRenderer?: HookMessageRenderer) { super(); - this.entry = entry; + this.message = message; this.customRenderer = customRenderer; this.addChild(new Spacer(1)); @@ -38,18 +38,10 @@ export class CustomMessageComponent extends Container { private rebuild(): void { this.box.clear(); - // Convert entry to HookMessage for renderer - const message: HookMessage = { - customType: this.entry.customType, - content: this.entry.content, - display: this.entry.display, - details: this.entry.details, - }; - // Try custom renderer first if (this.customRenderer) { try { - const component = this.customRenderer(message, { expanded: this._expanded }, theme); + const component = this.customRenderer(this.message, { expanded: this._expanded }, theme); if (component) { this.box.addChild(component); return; @@ -60,16 +52,16 @@ export class CustomMessageComponent extends Container { } // Default rendering: label + content - const label = theme.fg("customMessageLabel", `\x1b[1m[${this.entry.customType}]\x1b[22m`); + const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`); this.box.addChild(new Text(label, 0, 0)); this.box.addChild(new Spacer(1)); // Extract text content let text: string; - if (typeof this.entry.content === "string") { - text = this.entry.content; + if (typeof this.message.content === "string") { + text = this.message.content; } else { - text = this.entry.content + text = this.message.content .filter((c): c is TextContent => c.type === "text") .map((c) => c.text) .join("\n"); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index babb141f..97da3727 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -28,7 +28,7 @@ import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "../../core/custom-tools/index.js"; import type { HookUIContext } from "../../core/hooks/index.js"; -import { isBashExecutionMessage, isHookAppMessage } from "../../core/messages.js"; +import { isBashExecutionMessage, isHookMessage } from "../../core/messages.js"; import { getLatestCompactionEntry, type SessionContext, @@ -46,10 +46,10 @@ import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; import { CompactionComponent } from "./components/compaction.js"; import { CustomEditor } from "./components/custom-editor.js"; -import { CustomMessageComponent } from "./components/custom-message.js"; import { DynamicBorder } from "./components/dynamic-border.js"; import { FooterComponent } from "./components/footer.js"; import { HookInputComponent } from "./components/hook-input.js"; +import { HookMessageComponent } from "./components/hook-message.js"; import { HookSelectorComponent } from "./components/hook-selector.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; @@ -817,7 +817,7 @@ export class InteractiveMode { break; case "message_start": - if (isHookAppMessage(event.message)) { + if (isHookMessage(event.message)) { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { @@ -1051,7 +1051,7 @@ export class InteractiveMode { this.ui.requestRender(); } - private addMessageToChat(message: Message | AppMessage): void { + private addMessageToChat(message: AppMessage): void { if (isBashExecutionMessage(message)) { const component = new BashExecutionComponent(message.command, this.ui); if (message.output) { @@ -1067,20 +1067,11 @@ export class InteractiveMode { return; } - if (isHookAppMessage(message)) { + if (isHookMessage(message)) { // Render as custom message if display is true if (message.display) { - const entry = { - type: "custom_message" as const, - customType: message.customType, - content: message.content, - display: true, - id: "", - parentId: null, - timestamp: new Date().toISOString(), - }; const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); - this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); + this.chatContainer.addChild(new HookMessageComponent(message, renderer)); } } else if (message.role === "user") { const textContent = this.getUserMessageText(message); @@ -1114,11 +1105,9 @@ export class InteractiveMode { } const compactionEntry = getLatestCompactionEntry(this.sessionManager.getEntries()); - const entries = sessionContext.entries; for (let i = 0; i < sessionContext.messages.length; i++) { const message = sessionContext.messages[i]; - const entry = entries?.[i]; if (isBashExecutionMessage(message)) { this.addMessageToChat(message); @@ -1126,10 +1115,10 @@ export class InteractiveMode { } // Check if this is a custom_message entry - if (entry?.type === "custom_message") { - if (entry.display) { - const renderer = this.session.hookRunner?.getMessageRenderer(entry.customType); - this.chatContainer.addChild(new CustomMessageComponent(entry, renderer)); + if (isHookMessage(message)) { + if (message.display) { + const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); + this.chatContainer.addChild(new HookMessageComponent(message, renderer)); } continue; } @@ -1322,7 +1311,7 @@ export class InteractiveMode { child.setExpanded(this.toolOutputExpanded); } else if (child instanceof BashExecutionComponent) { child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof CustomMessageComponent) { + } else if (child instanceof HookMessageComponent) { child.setExpanded(this.toolOutputExpanded); } } From 574f1cba3d5f0336e84a95a57e99caa9a8712328 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 20:53:46 +0100 Subject: [PATCH 051/161] Fix messageTransformer to normalize HookMessage string content to array HookMessage.content can be string or array, but LLM Message.content must be an array. This was causing 'messages: at least one message is required' errors when hooks sent string content. --- packages/coding-agent/src/core/messages.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index b202ca7d..fa4c2969 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -116,9 +116,11 @@ export function messageTransformer(messages: AppMessage[]): Message[] { } if (isHookMessage(m)) { // Convert hook message to user message for LLM + // Normalize string content to array format + const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; return { role: "user", - content: m.content, + content, timestamp: m.timestamp, }; } From 1113c95931cf9c6cc753831aba9c77243d7dc0e1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 20:55:35 +0100 Subject: [PATCH 052/161] Fix import path in hook-message.ts --- packages/ai/src/models.generated.ts | 114 +++++++++--------- .../interactive/components/hook-message.ts | 2 +- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 2df269d3..6392308c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,23 +6359,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6410,6 +6393,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,23 +6546,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6597,6 +6580,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/src/modes/interactive/components/hook-message.ts b/packages/coding-agent/src/modes/interactive/components/hook-message.ts index 9ef94d6a..ee5be2b6 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-message.ts @@ -1,7 +1,7 @@ import type { TextContent } from "@mariozechner/pi-ai"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import type { HookMessage } from "packages/coding-agent/src/core/messages.js"; import type { HookMessageRenderer } from "../../../core/hooks/types.js"; +import type { HookMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; /** From f7ef44dc38f5d98672fc8e2fa68e6bf444d5b997 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 27 Dec 2025 21:02:07 +0100 Subject: [PATCH 053/161] Fix: transform userMessage in _runAgentLoop before passing to transport HookMessage (role: hookMessage) was being passed directly to transport without transformation. Now it's transformed via messageTransformer which converts it to a proper user message for the LLM. --- packages/agent/src/agent.ts | 7 +- packages/ai/src/models.generated.ts | 114 ++++++++++++++-------------- 2 files changed, 62 insertions(+), 59 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ecf785b3..41f19ef5 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -240,7 +240,10 @@ export class Agent { private async _runAgentLoop(userMessage: AppMessage) { const { llmMessages, cfg } = await this._prepareRun(); - const events = this.transport.run(llmMessages, userMessage as Message, cfg, this.abortController!.signal); + // Transform user message (e.g., HookMessage -> user message) + const [transformedUserMessage] = await this.messageTransformer([userMessage]); + + const events = this.transport.run(llmMessages, transformedUserMessage, cfg, this.abortController!.signal); await this._processEvents(events); } @@ -326,7 +329,7 @@ export class Agent { } case "message_update": { partial = ev.message; - this._state.streamMessage = ev.message as Message; + this._state.streamMessage = ev.message; break; } case "message_end": { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6392308c..2df269d3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,6 +6359,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6393,23 +6410,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,6 +6546,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6580,23 +6597,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4": { id: "openai/gpt-4", name: "OpenAI: GPT-4", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", From a055fd4481bcdcb6196102b1d651a564db8163ff Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 09:23:38 +0100 Subject: [PATCH 054/161] WIP: Refactor agent package - not compiling - Renamed AppMessage to AgentMessage throughout - New agent-loop.ts with AgentLoopContext, AgentLoopConfig - Removed transport abstraction, Agent now takes streamFn directly - Extracted streamProxy to proxy.ts utility - Removed agent-loop from pi-ai (now in agent package) - Updated consumers (coding-agent, mom) for AgentMessage rename - Tests updated but some consumers still need migration Known issues: - AgentTool, AgentToolResult not exported from pi-ai - Attachment not exported from pi-agent-core - ProviderTransport removed but still referenced - messageTransformer -> convertToLlm migration incomplete - CustomMessages declaration merging not working properly --- .../{ai/src/agent => agent/src}/agent-loop.ts | 198 ++--- packages/agent/src/agent.ts | 330 ++++----- packages/agent/src/index.ts | 24 +- packages/agent/src/proxy.ts | 340 +++++++++ packages/agent/src/transports/AppTransport.ts | 397 ---------- .../agent/src/transports/ProviderTransport.ts | 86 --- packages/agent/src/transports/index.ts | 4 - packages/agent/src/transports/proxy-types.ts | 20 - packages/agent/src/transports/types.ts | 34 - packages/agent/src/types.ts | 145 +++- packages/agent/test/agent-loop.test.ts | 535 +++++++++++++ packages/agent/test/agent.test.ts | 40 +- packages/agent/test/e2e.test.ts | 32 +- .../tools => agent/test/utils}/calculate.ts | 2 +- .../test/utils}/get-current-time.ts | 3 +- packages/ai/src/agent/index.ts | 11 - packages/ai/src/agent/tools/index.ts | 2 - packages/ai/src/agent/types.ts | 105 --- packages/ai/src/index.ts | 2 +- .../ai/test/agent-queue-interrupt.test.ts | 166 ----- packages/ai/test/agent.test.ts | 701 ------------------ packages/ai/test/tool-validation.test.ts | 39 +- .../coding-agent/src/core/agent-session.ts | 11 +- packages/coding-agent/src/core/compaction.ts | 26 +- packages/coding-agent/src/core/hooks/types.ts | 8 +- packages/coding-agent/src/core/messages.ts | 14 +- .../coding-agent/src/core/session-manager.ts | 16 +- .../src/modes/interactive/interactive-mode.ts | 4 +- .../coding-agent/src/modes/rpc/rpc-client.ts | 6 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 4 +- packages/coding-agent/test/compaction.test.ts | 6 +- packages/mom/src/context.ts | 10 +- 32 files changed, 1312 insertions(+), 2009 deletions(-) rename packages/{ai/src/agent => agent/src}/agent-loop.ts (62%) create mode 100644 packages/agent/src/proxy.ts delete mode 100644 packages/agent/src/transports/AppTransport.ts delete mode 100644 packages/agent/src/transports/ProviderTransport.ts delete mode 100644 packages/agent/src/transports/index.ts delete mode 100644 packages/agent/src/transports/proxy-types.ts delete mode 100644 packages/agent/src/transports/types.ts create mode 100644 packages/agent/test/agent-loop.test.ts rename packages/{ai/src/agent/tools => agent/test/utils}/calculate.ts (93%) rename packages/{ai/src/agent/tools => agent/test/utils}/get-current-time.ts (93%) delete mode 100644 packages/ai/src/agent/index.ts delete mode 100644 packages/ai/src/agent/tools/index.ts delete mode 100644 packages/ai/src/agent/types.ts delete mode 100644 packages/ai/test/agent-queue-interrupt.test.ts delete mode 100644 packages/ai/test/agent.test.ts diff --git a/packages/ai/src/agent/agent-loop.ts b/packages/agent/src/agent-loop.ts similarity index 62% rename from packages/ai/src/agent/agent-loop.ts rename to packages/agent/src/agent-loop.ts index 95f8b25b..4927a62a 100644 --- a/packages/ai/src/agent/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -1,24 +1,41 @@ -import { streamSimple } from "../stream.js"; -import type { AssistantMessage, Context, Message, ToolResultMessage, UserMessage } from "../types.js"; -import { EventStream } from "../utils/event-stream.js"; -import { validateToolArguments } from "../utils/validation.js"; -import type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, AgentToolResult, QueuedMessage } from "./types.js"; +/** + * Agent loop that works with AgentMessage throughout. + * Transforms to Message[] only at the LLM call boundary. + */ + +import { + type AssistantMessage, + type Context, + EventStream, + streamSimple, + type ToolResultMessage, + validateToolArguments, +} from "@mariozechner/pi-ai"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + AgentToolResult, + StreamFn, +} from "./types.js"; /** - * Start an agent loop with a new user message. + * Start an agent loop with a new prompt message. * The prompt is added to the context and events are emitted for it. */ export function agentLoop( - prompt: UserMessage, + prompt: AgentMessage, context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, - streamFn?: typeof streamSimple, -): EventStream { + streamFn?: StreamFn, +): EventStream { const stream = createAgentStream(); (async () => { - const newMessages: AgentContext["messages"] = [prompt]; + const newMessages: AgentMessage[] = [prompt]; const currentContext: AgentContext = { ...context, messages: [...context.messages, prompt], @@ -37,38 +54,34 @@ export function agentLoop( /** * Continue an agent loop from the current context without adding a new message. - * Used for retry after overflow - context already has user message or tool results. - * Throws if the last message is not a user message or tool result. - */ -/** - * Continue an agent loop from the current context without adding a new message. - * Used for retry after overflow - context already has user message or tool results. - * Throws if the last message is not a user message or tool result. + * Used for retries - context already has user message or tool results. + * + * **Important:** The last message in context must convert to a `user` or `toolResult` message + * via `convertToLlm`. If it doesn't, the LLM provider will reject the request. + * This cannot be validated here since `convertToLlm` is only called once per turn. */ export function agentLoopContinue( context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, - streamFn?: typeof streamSimple, -): EventStream { - // Validate that we can continue from this context - const lastMessage = context.messages[context.messages.length - 1]; - if (!lastMessage) { + streamFn?: StreamFn, +): EventStream { + if (context.messages.length === 0) { throw new Error("Cannot continue: no messages in context"); } - if (lastMessage.role !== "user" && lastMessage.role !== "toolResult") { - throw new Error(`Cannot continue from message role: ${lastMessage.role}. Expected 'user' or 'toolResult'.`); + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); } const stream = createAgentStream(); (async () => { - const newMessages: AgentContext["messages"] = []; + const newMessages: AgentMessage[] = []; const currentContext: AgentContext = { ...context }; stream.push({ type: "agent_start" }); stream.push({ type: "turn_start" }); - // No user message events - we're continuing from existing context await runLoop(currentContext, newMessages, config, signal, stream, streamFn); })(); @@ -76,28 +89,28 @@ export function agentLoopContinue( return stream; } -function createAgentStream(): EventStream { - return new EventStream( +function createAgentStream(): EventStream { + return new EventStream( (event: AgentEvent) => event.type === "agent_end", (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []), ); } /** - * Shared loop logic for both agentLoop and agentLoopContinue. + * Main loop logic shared by agentLoop and agentLoopContinue. */ async function runLoop( currentContext: AgentContext, - newMessages: AgentContext["messages"], + newMessages: AgentMessage[], config: AgentLoopConfig, signal: AbortSignal | undefined, - stream: EventStream, - streamFn?: typeof streamSimple, + stream: EventStream, + streamFn?: StreamFn, ): Promise { let hasMoreToolCalls = true; let firstTurn = true; - let queuedMessages: QueuedMessage[] = (await config.getQueuedMessages?.()) || []; - let queuedAfterTools: QueuedMessage[] | null = null; + let queuedMessages: AgentMessage[] = (await config.getQueuedMessages?.()) || []; + let queuedAfterTools: AgentMessage[] | null = null; while (hasMoreToolCalls || queuedMessages.length > 0) { if (!firstTurn) { @@ -106,15 +119,13 @@ async function runLoop( firstTurn = false; } - // Process queued messages first (inject before next assistant response) + // Process queued messages (inject before next assistant response) if (queuedMessages.length > 0) { - for (const { original, llm } of queuedMessages) { - stream.push({ type: "message_start", message: original }); - stream.push({ type: "message_end", message: original }); - if (llm) { - currentContext.messages.push(llm); - newMessages.push(llm); - } + for (const message of queuedMessages) { + stream.push({ type: "message_start", message }); + stream.push({ type: "message_end", message }); + currentContext.messages.push(message); + newMessages.push(message); } queuedMessages = []; } @@ -124,7 +135,6 @@ async function runLoop( newMessages.push(message); if (message.stopReason === "error" || message.stopReason === "aborted") { - // Stop the loop on error or abort stream.push({ type: "turn_end", message, toolResults: [] }); stream.push({ type: "agent_end", messages: newMessages }); stream.end(newMessages); @@ -137,7 +147,6 @@ async function runLoop( const toolResults: ToolResultMessage[] = []; if (hasMoreToolCalls) { - // Execute tool calls const toolExecution = await executeToolCalls( currentContext.tools, message, @@ -147,10 +156,14 @@ async function runLoop( ); toolResults.push(...toolExecution.toolResults); queuedAfterTools = toolExecution.queuedMessages ?? null; - currentContext.messages.push(...toolResults); - newMessages.push(...toolResults); + + for (const result of toolResults) { + currentContext.messages.push(result); + newMessages.push(result); + } } - stream.push({ type: "turn_end", message, toolResults: toolResults }); + + stream.push({ type: "turn_end", message, toolResults }); // Get queued messages after turn completes if (queuedAfterTools && queuedAfterTools.length > 0) { @@ -165,41 +178,44 @@ async function runLoop( stream.end(newMessages); } -// Helper functions +/** + * Stream an assistant response from the LLM. + * This is where AgentMessage[] gets transformed to Message[] for the LLM. + */ async function streamAssistantResponse( context: AgentContext, config: AgentLoopConfig, signal: AbortSignal | undefined, - stream: EventStream, - streamFn?: typeof streamSimple, + stream: EventStream, + streamFn?: StreamFn, ): Promise { - // Convert AgentContext to Context for streamSimple - // Use a copy of messages to avoid mutating the original context - const processedMessages = config.preprocessor - ? await config.preprocessor(context.messages, signal) - : [...context.messages]; - const processedContext: Context = { + // Apply context transform if configured (AgentMessage[] → AgentMessage[]) + let messages = context.messages; + if (config.transformContext) { + messages = await config.transformContext(messages, signal); + } + + // Convert to LLM-compatible messages (AgentMessage[] → Message[]) + const llmMessages = await config.convertToLlm(messages); + + // Build LLM context + const llmContext: Context = { systemPrompt: context.systemPrompt, - messages: [...processedMessages].map((m) => { - if (m.role === "toolResult") { - // biome-ignore lint/correctness/noUnusedVariables: fine here - const { details, ...rest } = m; - return rest; - } else { - return m; - } - }), - tools: context.tools, // AgentTool extends Tool, so this works + messages: llmMessages, + tools: context.tools, }; - // Use custom stream function if provided, otherwise use default streamSimple const streamFunction = streamFn || streamSimple; - // Resolve API key for every assistant response (important for expiring tokens) + // Resolve API key (important for expiring tokens) const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; - const response = await streamFunction(config.model, processedContext, { ...config, apiKey: resolvedApiKey, signal }); + const response = streamFunction(config.model, llmContext, { + ...config, + apiKey: resolvedApiKey, + signal, + }); let partialMessage: AssistantMessage | null = null; let addedPartial = false; @@ -225,7 +241,11 @@ async function streamAssistantResponse( if (partialMessage) { partialMessage = event.partial; context.messages[context.messages.length - 1] = partialMessage; - stream.push({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } }); + stream.push({ + type: "message_update", + assistantMessageEvent: event, + message: { ...partialMessage }, + }); } break; @@ -249,16 +269,19 @@ async function streamAssistantResponse( return await response.result(); } -async function executeToolCalls( - tools: AgentTool[] | undefined, +/** + * Execute tool calls from an assistant message. + */ +async function executeToolCalls( + tools: AgentTool[] | undefined, assistantMessage: AssistantMessage, signal: AbortSignal | undefined, - stream: EventStream, + stream: EventStream, getQueuedMessages?: AgentLoopConfig["getQueuedMessages"], -): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: QueuedMessage[] }> { +): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: AgentMessage[] }> { const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); - const results: ToolResultMessage[] = []; - let queuedMessages: QueuedMessage[] | undefined; + const results: ToolResultMessage[] = []; + let queuedMessages: AgentMessage[] | undefined; for (let index = 0; index < toolCalls.length; index++) { const toolCall = toolCalls[index]; @@ -271,16 +294,14 @@ async function executeToolCalls( args: toolCall.arguments, }); - let result: AgentToolResult; + let result: AgentToolResult; let isError = false; try { if (!tool) throw new Error(`Tool ${toolCall.name} not found`); - // Validate arguments using shared validation function const validatedArgs = validateToolArguments(tool, toolCall); - // Execute with validated, typed arguments, passing update callback result = await tool.execute(toolCall.id, validatedArgs, signal, (partialResult) => { stream.push({ type: "tool_execution_update", @@ -293,7 +314,7 @@ async function executeToolCalls( } catch (e) { result = { content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], - details: {} as T, + details: {}, }; isError = true; } @@ -306,7 +327,7 @@ async function executeToolCalls( isError, }); - const toolResultMessage: ToolResultMessage = { + const toolResultMessage: ToolResultMessage = { role: "toolResult", toolCallId: toolCall.id, toolName: toolCall.name, @@ -320,6 +341,7 @@ async function executeToolCalls( stream.push({ type: "message_start", message: toolResultMessage }); stream.push({ type: "message_end", message: toolResultMessage }); + // Check for queued messages - skip remaining tools if user interrupted if (getQueuedMessages) { const queued = await getQueuedMessages(); if (queued.length > 0) { @@ -336,13 +358,13 @@ async function executeToolCalls( return { toolResults: results, queuedMessages }; } -function skipToolCall( +function skipToolCall( toolCall: Extract, - stream: EventStream, -): ToolResultMessage { - const result: AgentToolResult = { + stream: EventStream, +): ToolResultMessage { + const result: AgentToolResult = { content: [{ type: "text", text: "Skipped due to queued user message." }], - details: {} as T, + details: {}, }; stream.push({ @@ -359,12 +381,12 @@ function skipToolCall( isError: true, }); - const toolResultMessage: ToolResultMessage = { + const toolResultMessage: ToolResultMessage = { role: "toolResult", toolCallId: toolCall.id, toolName: toolCall.name, content: result.content, - details: result.details, + details: {}, isError: true, timestamp: Date.now(), }; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 41f19ef5..b87c582d 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,64 +1,66 @@ -import type { ImageContent, Message, QueuedMessage, ReasoningEffort, TextContent } from "@mariozechner/pi-ai"; -import { getModel } from "@mariozechner/pi-ai"; -import type { AgentTransport } from "./transports/types.js"; -import type { AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "./types.js"; +/** + * Agent class that uses the agent-loop directly. + * No transport abstraction - calls streamSimple via the loop. + */ + +import { + getModel, + type ImageContent, + type Message, + type Model, + type ReasoningEffort, + streamSimple, + type TextContent, +} from "@mariozechner/pi-ai"; +import { agentLoop, agentLoopContinue } from "./agent-loop.js"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentState, + AgentTool, + StreamFn, + ThinkingLevel, +} from "./types.js"; /** - * Default message transformer: Keep only LLM-compatible messages, strip app-specific fields. - * Converts attachments to proper content blocks (images → ImageContent, documents → TextContent). + * Default convertToLlm: Keep only LLM-compatible messages, convert attachments. */ -function defaultMessageTransformer(messages: AppMessage[]): Message[] { - return messages - .filter((m) => { - // Only keep standard LLM message roles - return m.role === "user" || m.role === "assistant" || m.role === "toolResult"; - }) - .map((m) => { - if (m.role === "user") { - const { attachments, ...rest } = m as any; - - // If no attachments, return as-is - if (!attachments || attachments.length === 0) { - return rest as Message; - } - - // Convert attachments to content blocks - const content = Array.isArray(rest.content) ? [...rest.content] : [{ type: "text", text: rest.content }]; - - for (const attachment of attachments as Attachment[]) { - // Add image blocks for image attachments - if (attachment.type === "image") { - content.push({ - type: "image", - data: attachment.content, - mimeType: attachment.mimeType, - } as ImageContent); - } - // Add text blocks for documents with extracted text - else if (attachment.type === "document" && attachment.extractedText) { - content.push({ - type: "text", - text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`, - isDocument: true, - } as TextContent); - } - } - - return { ...rest, content } as Message; - } - return m as Message; - }); +function defaultConvertToLlm(messages: AgentMessage[]): Message[] { + return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult"); } export interface AgentOptions { initialState?: Partial; - transport: AgentTransport; - // Transform app messages to LLM-compatible messages before sending to transport - messageTransformer?: (messages: AppMessage[]) => Message[] | Promise; - // Called before each LLM call inside the agent loop - can modify messages (e.g., for pruning) - preprocessor?: (messages: Message[]) => Promise; - // Queue mode: "all" = send all queued messages at once, "one-at-a-time" = send one queued message per turn + + /** + * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. + * Default filters to user/assistant/toolResult and converts attachments. + */ + convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; + + /** + * Optional transform applied to context before convertToLlm. + * Use for context pruning, injecting external context, etc. + */ + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + + /** + * Queue mode: "all" = send all queued messages at once, "one-at-a-time" = one per turn + */ queueMode?: "all" | "one-at-a-time"; + + /** + * Custom stream function (for proxy backends, etc.). Default uses streamSimple. + */ + streamFn?: StreamFn; + + /** + * Resolves an API key dynamically for each LLM call. + * Useful for expiring tokens (e.g., GitHub Copilot OAuth). + */ + getApiKey?: (provider: string) => Promise | string | undefined; } export class Agent { @@ -73,22 +75,25 @@ export class Agent { pendingToolCalls: new Set(), error: undefined, }; + private listeners = new Set<(e: AgentEvent) => void>(); private abortController?: AbortController; - private transport: AgentTransport; - private messageTransformer: (messages: AppMessage[]) => Message[] | Promise; - private preprocessor?: (messages: Message[]) => Promise; - private messageQueue: Array> = []; + private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + private messageQueue: AgentMessage[] = []; private queueMode: "all" | "one-at-a-time"; + private streamFn: StreamFn; + private getApiKey?: (provider: string) => Promise | string | undefined; private runningPrompt?: Promise; private resolveRunningPrompt?: () => void; - constructor(opts: AgentOptions) { + constructor(opts: AgentOptions = {}) { this._state = { ...this._state, ...opts.initialState }; - this.transport = opts.transport; - this.messageTransformer = opts.messageTransformer || defaultMessageTransformer; - this.preprocessor = opts.preprocessor; + this.convertToLlm = opts.convertToLlm || defaultConvertToLlm; + this.transformContext = opts.transformContext; this.queueMode = opts.queueMode || "one-at-a-time"; + this.streamFn = opts.streamFn || streamSimple; + this.getApiKey = opts.getApiKey; } get state(): AgentState { @@ -100,12 +105,12 @@ export class Agent { return () => this.listeners.delete(fn); } - // State mutators - update internal state without emitting events + // State mutators setSystemPrompt(v: string) { this._state.systemPrompt = v; } - setModel(m: typeof this._state.model) { + setModel(m: Model) { this._state.model = m; } @@ -121,25 +126,20 @@ export class Agent { return this.queueMode; } - setTools(t: typeof this._state.tools) { + setTools(t: AgentTool[]) { this._state.tools = t; } - replaceMessages(ms: AppMessage[]) { + replaceMessages(ms: AgentMessage[]) { this._state.messages = ms.slice(); } - appendMessage(m: AppMessage) { + appendMessage(m: AgentMessage) { this._state.messages = [...this._state.messages, m]; } - async queueMessage(m: AppMessage) { - // Transform message and queue it for injection at next turn - const transformed = await this.messageTransformer([m]); - this.messageQueue.push({ - original: m, - llm: transformed[0], // undefined if filtered out - }); + queueMessage(m: AgentMessage) { + this.messageQueue.push(m); } clearMessageQueue() { @@ -154,17 +154,10 @@ export class Agent { this.abortController?.abort(); } - /** - * Returns a promise that resolves when the current prompt completes. - * Returns immediately resolved promise if no prompt is running. - */ waitForIdle(): Promise { return this.runningPrompt ?? Promise.resolve(); } - /** - * Clear all messages and state. Call abort() first if a prompt is in flight. - */ reset() { this._state.messages = []; this._state.isStreaming = false; @@ -174,99 +167,53 @@ export class Agent { this.messageQueue = []; } - /** Send a prompt to the agent with an AppMessage. */ - async prompt(message: AppMessage): Promise; - /** Send a prompt to the agent with text and optional attachments. */ - async prompt(input: string, attachments?: Attachment[]): Promise; - async prompt(input: string | AppMessage, attachments?: Attachment[]) { + /** Send a prompt with an AgentMessage */ + async prompt(message: AgentMessage): Promise; + async prompt(input: string, images?: ImageContent[]): Promise; + async prompt(input: string | AgentMessage, images?: ImageContent[]) { const model = this._state.model; - if (!model) { - throw new Error("No model configured"); - } + if (!model) throw new Error("No model configured"); - let userMessage: AppMessage; + let userMessage: AgentMessage; if (typeof input === "string") { - // Build user message from text + attachments const content: Array = [{ type: "text", text: input }]; - if (attachments?.length) { - for (const a of attachments) { - if (a.type === "image") { - content.push({ type: "image", data: a.content, mimeType: a.mimeType }); - } else if (a.type === "document" && a.extractedText) { - content.push({ - type: "text", - text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`, - isDocument: true, - } as TextContent); - } - } + if (images && images.length > 0) { + content.push(...images); } userMessage = { role: "user", content, - attachments: attachments?.length ? attachments : undefined, timestamp: Date.now(), }; } else { - // Use provided AppMessage directly userMessage = input; } - await this._runAgentLoop(userMessage); + await this._runLoop(userMessage); } - /** - * Continue from the current context without adding a new user message. - * Used for retry after overflow recovery when context already has user message or tool results. - */ + /** Continue from current context (for retry after overflow) */ async continue() { const messages = this._state.messages; if (messages.length === 0) { throw new Error("No messages to continue from"); } - - const lastMessage = messages[messages.length - 1]; - if (lastMessage.role !== "user" && lastMessage.role !== "toolResult") { - throw new Error(`Cannot continue from message role: ${lastMessage.role}`); + if (messages[messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); } - await this._runAgentLoopContinue(); + await this._runLoop(undefined); } /** - * Internal: Run the agent loop with a new user message. + * Run the agent loop. + * If userMessage is provided, starts a new conversation turn. + * Otherwise, continues from existing context. */ - private async _runAgentLoop(userMessage: AppMessage) { - const { llmMessages, cfg } = await this._prepareRun(); - - // Transform user message (e.g., HookMessage -> user message) - const [transformedUserMessage] = await this.messageTransformer([userMessage]); - - const events = this.transport.run(llmMessages, transformedUserMessage, cfg, this.abortController!.signal); - - await this._processEvents(events); - } - - /** - * Internal: Continue the agent loop from current context. - */ - private async _runAgentLoopContinue() { - const { llmMessages, cfg } = await this._prepareRun(); - - const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal); - - await this._processEvents(events); - } - - /** - * Prepare for running the agent loop. - */ - private async _prepareRun() { + private async _runLoop(userMessage?: AgentMessage) { const model = this._state.model; - if (!model) { - throw new Error("No model configured"); - } + if (!model) throw new Error("No model configured"); this.runningPrompt = new Promise((resolve) => { this.resolveRunningPrompt = resolve; @@ -282,88 +229,89 @@ export class Agent { ? undefined : this._state.thinkingLevel === "minimal" ? "low" - : this._state.thinkingLevel; + : (this._state.thinkingLevel as ReasoningEffort); - const cfg = { + const context: AgentContext = { systemPrompt: this._state.systemPrompt, + messages: this._state.messages.slice(), tools: this._state.tools, + }; + + const config: AgentLoopConfig = { model, reasoning, - preprocessor: this.preprocessor, - getQueuedMessages: async () => { + convertToLlm: this.convertToLlm, + transformContext: this.transformContext, + getApiKey: this.getApiKey, + getQueuedMessages: async () => { if (this.queueMode === "one-at-a-time") { if (this.messageQueue.length > 0) { const first = this.messageQueue[0]; this.messageQueue = this.messageQueue.slice(1); - return [first] as QueuedMessage[]; + return [first]; } return []; } else { const queued = this.messageQueue.slice(); this.messageQueue = []; - return queued as QueuedMessage[]; + return queued; } }, }; - const llmMessages = await this.messageTransformer(this._state.messages); - - return { llmMessages, cfg, model }; - } - - /** - * Process events from the transport. - */ - private async _processEvents(events: AsyncIterable) { - const model = this._state.model!; - const generatedMessages: AppMessage[] = []; - let partial: AppMessage | null = null; + let partial: AgentMessage | null = null; try { - for await (const ev of events) { - switch (ev.type) { - case "message_start": { - partial = ev.message as AppMessage; - this._state.streamMessage = ev.message as Message; + const stream = userMessage + ? agentLoop(userMessage, context, config, this.abortController.signal, this.streamFn) + : agentLoopContinue(context, config, this.abortController.signal, this.streamFn); + + for await (const event of stream) { + // Update internal state based on events + switch (event.type) { + case "message_start": + partial = event.message; + this._state.streamMessage = event.message; break; - } - case "message_update": { - partial = ev.message; - this._state.streamMessage = ev.message; + + case "message_update": + partial = event.message; + this._state.streamMessage = event.message; break; - } - case "message_end": { + + case "message_end": partial = null; this._state.streamMessage = null; - this.appendMessage(ev.message); - generatedMessages.push(ev.message); + this.appendMessage(event.message); break; - } + case "tool_execution_start": { const s = new Set(this._state.pendingToolCalls); - s.add(ev.toolCallId); + s.add(event.toolCallId); this._state.pendingToolCalls = s; break; } + case "tool_execution_end": { const s = new Set(this._state.pendingToolCalls); - s.delete(ev.toolCallId); + s.delete(event.toolCallId); this._state.pendingToolCalls = s; break; } - case "turn_end": { - if (ev.message.role === "assistant" && ev.message.errorMessage) { - this._state.error = ev.message.errorMessage; + + case "turn_end": + if (event.message.role === "assistant" && (event.message as any).errorMessage) { + this._state.error = (event.message as any).errorMessage; } break; - } - case "agent_end": { + + case "agent_end": this._state.streamMessage = null; break; - } } - this.emit(ev as AgentEvent); + // Emit to listeners + this.emit(event); } // Handle any remaining partial message @@ -375,8 +323,7 @@ export class Agent { (c.type === "toolCall" && c.name.trim().length > 0), ); if (!onlyEmpty) { - this.appendMessage(partial as AppMessage); - generatedMessages.push(partial as AppMessage); + this.appendMessage(partial); } else { if (this.abortController?.signal.aborted) { throw new Error("Request was aborted"); @@ -384,7 +331,7 @@ export class Agent { } } } catch (err: any) { - const msg: Message = { + const errorMsg: AgentMessage = { role: "assistant", content: [{ type: "text", text: "" }], api: model.api, @@ -401,10 +348,11 @@ export class Agent { stopReason: this.abortController?.signal.aborted ? "aborted" : "error", errorMessage: err?.message || String(err), timestamp: Date.now(), - }; - this.appendMessage(msg as AppMessage); - generatedMessages.push(msg as AppMessage); + } as AgentMessage; + + this.appendMessage(errorMsg); this._state.error = err?.message || String(err); + this.emit({ type: "agent_end", messages: [errorMsg] }); } finally { this._state.isStreaming = false; this._state.streamMessage = null; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 95a5094a..1f287856 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,22 +1,6 @@ // Core Agent -export { Agent, type AgentOptions } from "./agent.js"; -// Transports -export { - type AgentRunConfig, - type AgentTransport, - AppTransport, - type AppTransportOptions, - ProviderTransport, - type ProviderTransportOptions, - type ProxyAssistantMessageEvent, -} from "./transports/index.js"; +export * from "./agent.js"; +// Loop functions +export * from "./agent-loop.js"; // Types -export type { - AgentEvent, - AgentState, - AppMessage, - Attachment, - CustomMessages, - ThinkingLevel, - UserMessageWithAttachments, -} from "./types.js"; +export * from "./types.js"; diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts new file mode 100644 index 00000000..ba098cd6 --- /dev/null +++ b/packages/agent/src/proxy.ts @@ -0,0 +1,340 @@ +/** + * Proxy stream function for apps that route LLM calls through a server. + * The server manages auth and proxies requests to LLM providers. + */ + +import { + type AssistantMessage, + type AssistantMessageEvent, + type Context, + EventStream, + type Model, + type SimpleStreamOptions, + type StopReason, + type ToolCall, +} from "@mariozechner/pi-ai"; +// Internal import for JSON parsing utility +import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js"; + +// Create stream class matching ProxyMessageEventStream +class ProxyMessageEventStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +/** + * Proxy event types - server sends these with partial field stripped to reduce bandwidth. + */ +export type ProxyAssistantMessageEvent = + | { type: "start" } + | { type: "text_start"; contentIndex: number } + | { type: "text_delta"; contentIndex: number; delta: string } + | { type: "text_end"; contentIndex: number; contentSignature?: string } + | { type: "thinking_start"; contentIndex: number } + | { type: "thinking_delta"; contentIndex: number; delta: string } + | { type: "thinking_end"; contentIndex: number; contentSignature?: string } + | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } + | { type: "toolcall_delta"; contentIndex: number; delta: string } + | { type: "toolcall_end"; contentIndex: number } + | { + type: "done"; + reason: Extract; + usage: AssistantMessage["usage"]; + } + | { + type: "error"; + reason: Extract; + errorMessage?: string; + usage: AssistantMessage["usage"]; + }; + +export interface ProxyStreamOptions extends SimpleStreamOptions { + /** Auth token for the proxy server */ + authToken: string; + /** Proxy server URL (e.g., "https://genai.example.com") */ + proxyUrl: string; +} + +/** + * Stream function that proxies through a server instead of calling LLM providers directly. + * The server strips the partial field from delta events to reduce bandwidth. + * We reconstruct the partial message client-side. + * + * Use this as the `streamFn` option when creating an Agent that needs to go through a proxy. + * + * @example + * ```typescript + * const agent = new Agent({ + * streamFn: (model, context, options) => + * streamProxy(model, context, { + * ...options, + * authToken: await getAuthToken(), + * proxyUrl: "https://genai.example.com", + * }), + * }); + * ``` + */ +export function streamProxy(model: Model, context: Context, options: ProxyStreamOptions): ProxyMessageEventStream { + const stream = new ProxyMessageEventStream(); + + (async () => { + // Initialize the partial message that we'll build up from events + const partial: AssistantMessage = { + role: "assistant", + stopReason: "stop", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + + let reader: ReadableStreamDefaultReader | undefined; + + const abortHandler = () => { + if (reader) { + reader.cancel("Request aborted by user").catch(() => {}); + } + }; + + if (options.signal) { + options.signal.addEventListener("abort", abortHandler); + } + + try { + const response = await fetch(`${options.proxyUrl}/api/stream`, { + method: "POST", + headers: { + Authorization: `Bearer ${options.authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + context, + options: { + temperature: options.temperature, + maxTokens: options.maxTokens, + reasoning: options.reasoning, + }, + }), + signal: options.signal, + }); + + if (!response.ok) { + let errorMessage = `Proxy error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as { error?: string }; + if (errorData.error) { + errorMessage = `Proxy error: ${errorData.error}`; + } + } catch { + // Couldn't parse error response + } + throw new Error(errorMessage); + } + + reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data) { + const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; + const event = processProxyEvent(proxyEvent, partial); + if (event) { + stream.push(event); + } + } + } + } + } + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + stream.end(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const reason = options.signal?.aborted ? "aborted" : "error"; + partial.stopReason = reason; + partial.errorMessage = errorMessage; + stream.push({ + type: "error", + reason, + error: partial, + }); + stream.end(); + } finally { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + } + })(); + + return stream; +} + +/** + * Process a proxy event and update the partial message. + */ +function processProxyEvent( + proxyEvent: ProxyAssistantMessageEvent, + partial: AssistantMessage, +): AssistantMessageEvent | undefined { + switch (proxyEvent.type) { + case "start": + return { type: "start", partial }; + + case "text_start": + partial.content[proxyEvent.contentIndex] = { type: "text", text: "" }; + return { type: "text_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "text_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.text += proxyEvent.delta; + return { + type: "text_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received text_delta for non-text content"); + } + + case "text_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.textSignature = proxyEvent.contentSignature; + return { + type: "text_end", + contentIndex: proxyEvent.contentIndex, + content: content.text, + partial, + }; + } + throw new Error("Received text_end for non-text content"); + } + + case "thinking_start": + partial.content[proxyEvent.contentIndex] = { type: "thinking", thinking: "" }; + return { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "thinking_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinking += proxyEvent.delta; + return { + type: "thinking_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received thinking_delta for non-thinking content"); + } + + case "thinking_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinkingSignature = proxyEvent.contentSignature; + return { + type: "thinking_end", + contentIndex: proxyEvent.contentIndex, + content: content.thinking, + partial, + }; + } + throw new Error("Received thinking_end for non-thinking content"); + } + + case "toolcall_start": + partial.content[proxyEvent.contentIndex] = { + type: "toolCall", + id: proxyEvent.id, + name: proxyEvent.toolName, + arguments: {}, + partialJson: "", + } satisfies ToolCall & { partialJson: string } as ToolCall; + return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "toolcall_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + (content as any).partialJson += proxyEvent.delta; + content.arguments = parseStreamingJson((content as any).partialJson) || {}; + partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity + return { + type: "toolcall_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received toolcall_delta for non-toolCall content"); + } + + case "toolcall_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + delete (content as any).partialJson; + return { + type: "toolcall_end", + contentIndex: proxyEvent.contentIndex, + toolCall: content, + partial, + }; + } + return undefined; + } + + case "done": + partial.stopReason = proxyEvent.reason; + partial.usage = proxyEvent.usage; + return { type: "done", reason: proxyEvent.reason, message: partial }; + + case "error": + partial.stopReason = proxyEvent.reason; + partial.errorMessage = proxyEvent.errorMessage; + partial.usage = proxyEvent.usage; + return { type: "error", reason: proxyEvent.reason, error: partial }; + + default: { + const _exhaustiveCheck: never = proxyEvent; + console.warn(`Unhandled proxy event type: ${(proxyEvent as any).type}`); + return undefined; + } + } +} diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts deleted file mode 100644 index 69b9af46..00000000 --- a/packages/agent/src/transports/AppTransport.ts +++ /dev/null @@ -1,397 +0,0 @@ -import type { - AgentContext, - AgentLoopConfig, - Api, - AssistantMessage, - AssistantMessageEvent, - Context, - Message, - Model, - SimpleStreamOptions, - ToolCall, - UserMessage, -} from "@mariozechner/pi-ai"; -import { agentLoop, agentLoopContinue } from "@mariozechner/pi-ai"; -import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js"; -import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js"; -import type { ProxyAssistantMessageEvent } from "./proxy-types.js"; -import type { AgentRunConfig, AgentTransport } from "./types.js"; - -/** - * Stream function that proxies through a server instead of calling providers directly. - * The server strips the partial field from delta events to reduce bandwidth. - * We reconstruct the partial message client-side. - */ -function streamSimpleProxy( - model: Model, - context: Context, - options: SimpleStreamOptions & { authToken: string }, - proxyUrl: string, -): AssistantMessageEventStream { - const stream = new AssistantMessageEventStream(); - - (async () => { - // Initialize the partial message that we'll build up from events - const partial: AssistantMessage = { - role: "assistant", - stopReason: "stop", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: Date.now(), - }; - - let reader: ReadableStreamDefaultReader | undefined; - - // Set up abort handler to cancel the reader - const abortHandler = () => { - if (reader) { - reader.cancel("Request aborted by user").catch(() => {}); - } - }; - - if (options.signal) { - options.signal.addEventListener("abort", abortHandler); - } - - try { - const response = await fetch(`${proxyUrl}/api/stream`, { - method: "POST", - headers: { - Authorization: `Bearer ${options.authToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model, - context, - options: { - temperature: options.temperature, - maxTokens: options.maxTokens, - reasoning: options.reasoning, - // Don't send apiKey or signal - those are added server-side - }, - }), - signal: options.signal, - }); - - if (!response.ok) { - let errorMessage = `Proxy error: ${response.status} ${response.statusText}`; - try { - const errorData = (await response.json()) as { error?: string }; - if (errorData.error) { - errorMessage = `Proxy error: ${errorData.error}`; - } - } catch { - // Couldn't parse error response, use default message - } - throw new Error(errorMessage); - } - - // Parse SSE stream - reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - // Check if aborted after reading - if (options.signal?.aborted) { - throw new Error("Request aborted by user"); - } - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6).trim(); - if (data) { - const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; - let event: AssistantMessageEvent | undefined; - - // Handle different event types - // Server sends events with partial for non-delta events, - // and without partial for delta events - switch (proxyEvent.type) { - case "start": - event = { type: "start", partial }; - break; - - case "text_start": - partial.content[proxyEvent.contentIndex] = { - type: "text", - text: "", - }; - event = { type: "text_start", contentIndex: proxyEvent.contentIndex, partial }; - break; - - case "text_delta": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "text") { - content.text += proxyEvent.delta; - event = { - type: "text_delta", - contentIndex: proxyEvent.contentIndex, - delta: proxyEvent.delta, - partial, - }; - } else { - throw new Error("Received text_delta for non-text content"); - } - break; - } - case "text_end": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "text") { - content.textSignature = proxyEvent.contentSignature; - event = { - type: "text_end", - contentIndex: proxyEvent.contentIndex, - content: content.text, - partial, - }; - } else { - throw new Error("Received text_end for non-text content"); - } - break; - } - - case "thinking_start": - partial.content[proxyEvent.contentIndex] = { - type: "thinking", - thinking: "", - }; - event = { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial }; - break; - - case "thinking_delta": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "thinking") { - content.thinking += proxyEvent.delta; - event = { - type: "thinking_delta", - contentIndex: proxyEvent.contentIndex, - delta: proxyEvent.delta, - partial, - }; - } else { - throw new Error("Received thinking_delta for non-thinking content"); - } - break; - } - - case "thinking_end": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "thinking") { - content.thinkingSignature = proxyEvent.contentSignature; - event = { - type: "thinking_end", - contentIndex: proxyEvent.contentIndex, - content: content.thinking, - partial, - }; - } else { - throw new Error("Received thinking_end for non-thinking content"); - } - break; - } - - case "toolcall_start": - partial.content[proxyEvent.contentIndex] = { - type: "toolCall", - id: proxyEvent.id, - name: proxyEvent.toolName, - arguments: {}, - partialJson: "", - } satisfies ToolCall & { partialJson: string } as ToolCall; - event = { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial }; - break; - - case "toolcall_delta": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "toolCall") { - (content as any).partialJson += proxyEvent.delta; - content.arguments = parseStreamingJson((content as any).partialJson) || {}; - event = { - type: "toolcall_delta", - contentIndex: proxyEvent.contentIndex, - delta: proxyEvent.delta, - partial, - }; - partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity - } else { - throw new Error("Received toolcall_delta for non-toolCall content"); - } - break; - } - - case "toolcall_end": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "toolCall") { - delete (content as any).partialJson; - event = { - type: "toolcall_end", - contentIndex: proxyEvent.contentIndex, - toolCall: content, - partial, - }; - } - break; - } - - case "done": - partial.stopReason = proxyEvent.reason; - partial.usage = proxyEvent.usage; - event = { type: "done", reason: proxyEvent.reason, message: partial }; - break; - - case "error": - partial.stopReason = proxyEvent.reason; - partial.errorMessage = proxyEvent.errorMessage; - partial.usage = proxyEvent.usage; - event = { type: "error", reason: proxyEvent.reason, error: partial }; - break; - - default: { - // Exhaustive check - const _exhaustiveCheck: never = proxyEvent; - console.warn(`Unhandled event type: ${(proxyEvent as any).type}`); - break; - } - } - - // Push the event to stream - if (event) { - stream.push(event); - } else { - throw new Error("Failed to create event from proxy event"); - } - } - } - } - } - - // Check if aborted after reading - if (options.signal?.aborted) { - throw new Error("Request aborted by user"); - } - - stream.end(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - partial.stopReason = options.signal?.aborted ? "aborted" : "error"; - partial.errorMessage = errorMessage; - stream.push({ - type: "error", - reason: partial.stopReason, - error: partial, - } satisfies AssistantMessageEvent); - stream.end(); - } finally { - // Clean up abort handler - if (options.signal) { - options.signal.removeEventListener("abort", abortHandler); - } - } - })(); - - return stream; -} - -export interface AppTransportOptions { - /** - * Proxy server URL. The server manages user accounts and proxies requests to LLM providers. - * Example: "https://genai.mariozechner.at" - */ - proxyUrl: string; - - /** - * Function to retrieve auth token for the proxy server. - * The token is used for user authentication and authorization. - */ - getAuthToken: () => Promise | string; -} - -/** - * Transport that uses an app server with user authentication tokens. - * The server manages user accounts and proxies requests to LLM providers. - */ -export class AppTransport implements AgentTransport { - private options: AppTransportOptions; - - constructor(options: AppTransportOptions) { - this.options = options; - } - - private async getStreamFn(authToken: string) { - return (model: Model, context: Context, options?: SimpleStreamOptions) => { - return streamSimpleProxy( - model, - context, - { - ...options, - authToken, - }, - this.options.proxyUrl, - ); - }; - } - - private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext { - return { - systemPrompt: cfg.systemPrompt, - messages, - tools: cfg.tools, - }; - } - - private buildLoopConfig(cfg: AgentRunConfig): AgentLoopConfig { - return { - model: cfg.model, - reasoning: cfg.reasoning, - getQueuedMessages: cfg.getQueuedMessages, - }; - } - - async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { - const authToken = await this.options.getAuthToken(); - if (!authToken) { - throw new Error("Auth token is required for AppTransport"); - } - - const streamFn = await this.getStreamFn(authToken); - const context = this.buildContext(messages, cfg); - const pc = this.buildLoopConfig(cfg); - - for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn as any)) { - yield ev; - } - } - - async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { - const authToken = await this.options.getAuthToken(); - if (!authToken) { - throw new Error("Auth token is required for AppTransport"); - } - - const streamFn = await this.getStreamFn(authToken); - const context = this.buildContext(messages, cfg); - const pc = this.buildLoopConfig(cfg); - - for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any)) { - yield ev; - } - } -} diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts deleted file mode 100644 index ee494553..00000000 --- a/packages/agent/src/transports/ProviderTransport.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - type AgentContext, - type AgentLoopConfig, - agentLoop, - agentLoopContinue, - type Message, - type UserMessage, -} from "@mariozechner/pi-ai"; -import type { AgentRunConfig, AgentTransport } from "./types.js"; - -export interface ProviderTransportOptions { - /** - * Function to retrieve API key for a given provider. - * If not provided, transport will try to use environment variables. - */ - getApiKey?: (provider: string) => Promise | string | undefined; - - /** - * Optional CORS proxy URL for browser environments. - * If provided, all requests will be routed through this proxy. - * Format: "https://proxy.example.com" - */ - corsProxyUrl?: string; -} - -/** - * Transport that calls LLM providers directly. - * Optionally routes calls through a CORS proxy if configured. - */ -export class ProviderTransport implements AgentTransport { - private options: ProviderTransportOptions; - - constructor(options: ProviderTransportOptions = {}) { - this.options = options; - } - - private getModel(cfg: AgentRunConfig) { - let model = cfg.model; - if (this.options.corsProxyUrl && cfg.model.baseUrl) { - model = { - ...cfg.model, - baseUrl: `${this.options.corsProxyUrl}/?url=${encodeURIComponent(cfg.model.baseUrl)}`, - }; - } - return model; - } - - private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext { - return { - systemPrompt: cfg.systemPrompt, - messages, - tools: cfg.tools, - }; - } - - private buildLoopConfig(model: AgentRunConfig["model"], cfg: AgentRunConfig): AgentLoopConfig { - return { - model, - reasoning: cfg.reasoning, - // Resolve API key per assistant response (important for expiring OAuth tokens) - getApiKey: this.options.getApiKey, - getQueuedMessages: cfg.getQueuedMessages, - preprocessor: cfg.preprocessor, - }; - } - - async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { - const model = this.getModel(cfg); - const context = this.buildContext(messages, cfg); - const pc = this.buildLoopConfig(model, cfg); - - for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) { - yield ev; - } - } - - async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { - const model = this.getModel(cfg); - const context = this.buildContext(messages, cfg); - const pc = this.buildLoopConfig(model, cfg); - - for await (const ev of agentLoopContinue(context, pc, signal)) { - yield ev; - } - } -} diff --git a/packages/agent/src/transports/index.ts b/packages/agent/src/transports/index.ts deleted file mode 100644 index 7dfc9702..00000000 --- a/packages/agent/src/transports/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { AppTransport, type AppTransportOptions } from "./AppTransport.js"; -export { ProviderTransport, type ProviderTransportOptions } from "./ProviderTransport.js"; -export type { ProxyAssistantMessageEvent } from "./proxy-types.js"; -export type { AgentRunConfig, AgentTransport } from "./types.js"; diff --git a/packages/agent/src/transports/proxy-types.ts b/packages/agent/src/transports/proxy-types.ts deleted file mode 100644 index fbbe7848..00000000 --- a/packages/agent/src/transports/proxy-types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { StopReason, Usage } from "@mariozechner/pi-ai"; - -/** - * Event types emitted by the proxy server. - * The server strips the `partial` field from delta events to reduce bandwidth. - * Clients reconstruct the partial message from these events. - */ -export type ProxyAssistantMessageEvent = - | { type: "start" } - | { type: "text_start"; contentIndex: number } - | { type: "text_delta"; contentIndex: number; delta: string } - | { type: "text_end"; contentIndex: number; contentSignature?: string } - | { type: "thinking_start"; contentIndex: number } - | { type: "thinking_delta"; contentIndex: number; delta: string } - | { type: "thinking_end"; contentIndex: number; contentSignature?: string } - | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } - | { type: "toolcall_delta"; contentIndex: number; delta: string } - | { type: "toolcall_end"; contentIndex: number } - | { type: "done"; reason: Extract; usage: Usage } - | { type: "error"; reason: Extract; errorMessage: string; usage: Usage }; diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts deleted file mode 100644 index f74dcac5..00000000 --- a/packages/agent/src/transports/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { AgentEvent, AgentTool, Message, Model, QueuedMessage, ReasoningEffort } from "@mariozechner/pi-ai"; - -/** - * The minimal configuration needed to run an agent turn. - */ -export interface AgentRunConfig { - systemPrompt: string; - tools: AgentTool[]; - model: Model; - reasoning?: ReasoningEffort; - getQueuedMessages?: () => Promise[]>; - /** Called before each LLM call - can modify messages (e.g., for pruning) */ - preprocessor?: (messages: Message[]) => Promise; -} - -/** - * Transport interface for executing agent turns. - * Transports handle the communication with LLM providers, - * abstracting away the details of API calls, proxies, etc. - * - * Events yielded must match the @mariozechner/pi-ai AgentEvent types. - */ -export interface AgentTransport { - /** Run with a new user message */ - run( - messages: Message[], - userMessage: Message, - config: AgentRunConfig, - signal?: AbortSignal, - ): AsyncIterable; - - /** Continue from current context (no new user message) */ - continue(messages: Message[], config: AgentRunConfig, signal?: AbortSignal): AsyncIterable; -} diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 6fccb591..9cd8d11e 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,26 +1,83 @@ import type { - AgentTool, - AssistantMessage, AssistantMessageEvent, + ImageContent, Message, Model, + SimpleStreamOptions, + streamSimple, + TextContent, + Tool, ToolResultMessage, - UserMessage, } from "@mariozechner/pi-ai"; +import type { Static, TSchema } from "@sinclair/typebox"; + +export type StreamFn = typeof streamSimple; /** - * Attachment type definition. - * Processing is done by consumers (e.g., document extraction in web-ui). + * Configuration for the agent loop. */ -export interface Attachment { - id: string; - type: "image" | "document"; - fileName: string; - mimeType: string; - size: number; - content: string; // base64 encoded (without data URL prefix) - extractedText?: string; // For documents - preview?: string; // base64 image preview +export interface AgentLoopConfig extends SimpleStreamOptions { + model: Model; + + /** + * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. + * + * Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage + * that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications, + * status messages) should be filtered out. + * + * @example + * ```typescript + * convertToLlm: (messages) => messages.flatMap(m => { + * if (m.role === "hookMessage") { + * // Convert custom message to user message + * return [{ role: "user", content: m.content, timestamp: m.timestamp }]; + * } + * if (m.role === "notification") { + * // Filter out UI-only messages + * return []; + * } + * // Pass through standard LLM messages + * return [m]; + * }) + * ``` + */ + convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + + /** + * Optional transform applied to the context before `convertToLlm`. + * + * Use this for operations that work at the AgentMessage level: + * - Context window management (pruning old messages) + * - Injecting context from external sources + * + * @example + * ```typescript + * transformContext: async (messages) => { + * if (estimateTokens(messages) > MAX_TOKENS) { + * return pruneOldMessages(messages); + * } + * return messages; + * } + * ``` + */ + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + + /** + * Resolves an API key dynamically for each LLM call. + * + * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire + * during long-running tool execution phases. + */ + getApiKey?: (provider: string) => Promise | string | undefined; + + /** + * Returns queued messages to inject into the conversation. + * + * Called after each turn to check for user interruptions or injected messages. + * If messages are returned, they're added to the context before the next LLM call. + */ + getQueuedMessages?: () => Promise; } /** @@ -29,11 +86,6 @@ export interface Attachment { */ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; -/** - * User message with optional attachments. - */ -export type UserMessageWithAttachments = UserMessage & { attachments?: Attachment[] }; - /** * Extensible interface for custom app messages. * Apps can extend via declaration merging: @@ -41,27 +93,23 @@ export type UserMessageWithAttachments = UserMessage & { attachments?: Attachmen * @example * ```typescript * declare module "@mariozechner/agent" { - * interface CustomMessages { + * interface CustomAgentMessages { * artifact: ArtifactMessage; * notification: NotificationMessage; * } * } * ``` */ -export interface CustomMessages { +export interface CustomAgentMessages { // Empty by default - apps extend via declaration merging } /** - * AppMessage: Union of LLM messages + attachments + custom messages. + * AgentMessage: Union of LLM messages + custom messages. * This abstraction allows apps to add custom message types while maintaining * type safety and compatibility with the base LLM messages. */ -export type AppMessage = - | AssistantMessage - | UserMessageWithAttachments - | Message // Includes ToolResultMessage - | CustomMessages[keyof CustomMessages]; +export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]; /** * Agent state containing all configuration and conversation data. @@ -71,13 +119,42 @@ export interface AgentState { model: Model; thinkingLevel: ThinkingLevel; tools: AgentTool[]; - messages: AppMessage[]; // Can include attachments + custom message types + messages: AgentMessage[]; // Can include attachments + custom message types isStreaming: boolean; - streamMessage: AppMessage | null; + streamMessage: AgentMessage | null; pendingToolCalls: Set; error?: string; } +export interface AgentToolResult { + // Content blocks supporting text and images + content: (TextContent | ImageContent)[]; + // Details to be displayed in a UI or logged + details: T; +} + +// Callback for streaming tool execution updates +export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; + +// AgentTool extends Tool but adds the execute function +export interface AgentTool extends Tool { + // A human-readable label for the tool to be displayed in UI + label: string; + execute: ( + toolCallId: string, + params: Static, + signal?: AbortSignal, + onUpdate?: AgentToolUpdateCallback, + ) => Promise>; +} + +// AgentContext is like Context but uses AgentTool +export interface AgentContext { + systemPrompt: string; + messages: Message[]; + tools?: AgentTool[]; +} + /** * Events emitted by the Agent for UI updates. * These events provide fine-grained lifecycle information for messages, turns, and tool executions. @@ -85,15 +162,15 @@ export interface AgentState { export type AgentEvent = // Agent lifecycle | { type: "agent_start" } - | { type: "agent_end"; messages: AppMessage[] } + | { type: "agent_end"; messages: AgentMessage[] } // Turn lifecycle - a turn is one assistant response + any tool calls/results | { type: "turn_start" } - | { type: "turn_end"; message: AppMessage; toolResults: ToolResultMessage[] } + | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] } // Message lifecycle - emitted for user, assistant, and toolResult messages - | { type: "message_start"; message: AppMessage } + | { type: "message_start"; message: AgentMessage } // Only emitted for assistant messages during streaming - | { type: "message_update"; message: AppMessage; assistantMessageEvent: AssistantMessageEvent } - | { type: "message_end"; message: AppMessage } + | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent } + | { type: "message_end"; message: AgentMessage } // Tool execution lifecycle | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any } | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any } diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts new file mode 100644 index 00000000..5eb8d30e --- /dev/null +++ b/packages/agent/test/agent-loop.test.ts @@ -0,0 +1,535 @@ +import { + type AssistantMessage, + type AssistantMessageEvent, + EventStream, + type Message, + type Model, + type UserMessage, +} from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { describe, expect, it } from "vitest"; +import { agentLoop, agentLoopContinue } from "../src/agent-loop.js"; +import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.js"; + +// Mock stream for testing - mimics MockAssistantStream +class MockAssistantStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +function createUsage() { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function createModel(): Model<"openai-responses"> { + return { + id: "mock", + name: "mock", + api: "openai-responses", + provider: "openai", + baseUrl: "https://example.invalid", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }; +} + +function createAssistantMessage( + content: AssistantMessage["content"], + stopReason: AssistantMessage["stopReason"] = "stop", +): AssistantMessage { + return { + role: "assistant", + content, + api: "openai-responses", + provider: "openai", + model: "mock", + usage: createUsage(), + stopReason, + timestamp: Date.now(), + }; +} + +function createUserMessage(text: string): UserMessage { + return { + role: "user", + content: text, + timestamp: Date.now(), + }; +} + +// Simple identity converter for tests - just passes through standard messages +function identityConverter(messages: AgentMessage[]): Message[] { + return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[]; +} + +describe("agentLoop with AgentMessage", () => { + it("should emit events with AgentMessage types", async () => { + const context: AgentContext = { + systemPrompt: "You are helpful.", + messages: [], + tools: [], + }; + + const userPrompt: AgentMessage = createUserMessage("Hello"); + + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + }; + + const streamFn = () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + const message = createAssistantMessage([{ type: "text", text: "Hi there!" }]); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }; + + const events: AgentEvent[] = []; + const stream = agentLoop(userPrompt, context, config, undefined, streamFn); + + for await (const event of stream) { + events.push(event); + } + + const messages = await stream.result(); + + // Should have user message and assistant message + expect(messages.length).toBe(2); + expect(messages[0].role).toBe("user"); + expect(messages[1].role).toBe("assistant"); + + // Verify event sequence + const eventTypes = events.map((e) => e.type); + expect(eventTypes).toContain("agent_start"); + expect(eventTypes).toContain("turn_start"); + expect(eventTypes).toContain("message_start"); + expect(eventTypes).toContain("message_end"); + expect(eventTypes).toContain("turn_end"); + expect(eventTypes).toContain("agent_end"); + }); + + it("should handle custom message types via convertToLlm", async () => { + // Create a custom message type + interface CustomNotification { + role: "notification"; + text: string; + timestamp: number; + } + + const notification: CustomNotification = { + role: "notification", + text: "This is a notification", + timestamp: Date.now(), + }; + + const context: AgentContext = { + systemPrompt: "You are helpful.", + messages: [notification as unknown as AgentMessage], // Custom message in context + tools: [], + }; + + const userPrompt: AgentMessage = createUserMessage("Hello"); + + let convertedMessages: Message[] = []; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: (messages) => { + // Filter out notifications, convert rest + convertedMessages = messages + .filter((m) => (m as { role: string }).role !== "notification") + .filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[]; + return convertedMessages; + }, + }; + + const streamFn = () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + const message = createAssistantMessage([{ type: "text", text: "Response" }]); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }; + + const events: AgentEvent[] = []; + const stream = agentLoop(userPrompt, context, config, undefined, streamFn); + + for await (const event of stream) { + events.push(event); + } + + // The notification should have been filtered out in convertToLlm + expect(convertedMessages.length).toBe(1); // Only user message + expect(convertedMessages[0].role).toBe("user"); + }); + + it("should apply transformContext before convertToLlm", async () => { + const context: AgentContext = { + systemPrompt: "You are helpful.", + messages: [ + createUserMessage("old message 1"), + createAssistantMessage([{ type: "text", text: "old response 1" }]), + createUserMessage("old message 2"), + createAssistantMessage([{ type: "text", text: "old response 2" }]), + ], + tools: [], + }; + + const userPrompt: AgentMessage = createUserMessage("new message"); + + let transformedMessages: AgentMessage[] = []; + let convertedMessages: Message[] = []; + + const config: AgentLoopConfig = { + model: createModel(), + transformContext: async (messages) => { + // Keep only last 2 messages (prune old ones) + transformedMessages = messages.slice(-2); + return transformedMessages; + }, + convertToLlm: (messages) => { + convertedMessages = messages.filter( + (m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult", + ) as Message[]; + return convertedMessages; + }, + }; + + const streamFn = () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + const message = createAssistantMessage([{ type: "text", text: "Response" }]); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }; + + const stream = agentLoop(userPrompt, context, config, undefined, streamFn); + + for await (const _ of stream) { + // consume + } + + // transformContext should have been called first, keeping only last 2 + expect(transformedMessages.length).toBe(2); + // Then convertToLlm receives the pruned messages + expect(convertedMessages.length).toBe(2); + }); + + it("should handle tool calls and results", async () => { + const toolSchema = Type.Object({ value: Type.String() }); + const executed: string[] = []; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + executed.push(params.value); + return { + content: [{ type: "text", text: `echoed: ${params.value}` }], + details: { value: params.value }, + }; + }, + }; + + const context: AgentContext = { + systemPrompt: "", + messages: [], + tools: [tool], + }; + + const userPrompt: AgentMessage = createUserMessage("echo something"); + + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + }; + + let callIndex = 0; + const streamFn = () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + if (callIndex === 0) { + // First call: return tool call + const message = createAssistantMessage( + [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }], + "toolUse", + ); + stream.push({ type: "done", reason: "toolUse", message }); + } else { + // Second call: return final response + const message = createAssistantMessage([{ type: "text", text: "done" }]); + stream.push({ type: "done", reason: "stop", message }); + } + callIndex++; + }); + return stream; + }; + + const events: AgentEvent[] = []; + const stream = agentLoop(userPrompt, context, config, undefined, streamFn); + + for await (const event of stream) { + events.push(event); + } + + // Tool should have been executed + expect(executed).toEqual(["hello"]); + + // Should have tool execution events + const toolStart = events.find((e) => e.type === "tool_execution_start"); + const toolEnd = events.find((e) => e.type === "tool_execution_end"); + expect(toolStart).toBeDefined(); + expect(toolEnd).toBeDefined(); + if (toolEnd?.type === "tool_execution_end") { + expect(toolEnd.isError).toBe(false); + } + }); + + it("should inject queued messages and skip remaining tool calls", async () => { + const toolSchema = Type.Object({ value: Type.String() }); + const executed: string[] = []; + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + executed.push(params.value); + return { + content: [{ type: "text", text: `ok:${params.value}` }], + details: { value: params.value }, + }; + }, + }; + + const context: AgentContext = { + systemPrompt: "", + messages: [], + tools: [tool], + }; + + const userPrompt: AgentMessage = createUserMessage("start"); + const queuedUserMessage: AgentMessage = createUserMessage("interrupt"); + + let queuedDelivered = false; + let callIndex = 0; + let sawInterruptInContext = false; + + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + getQueuedMessages: async () => { + // Return queued message after first tool executes + if (executed.length === 1 && !queuedDelivered) { + queuedDelivered = true; + return [queuedUserMessage]; + } + return []; + }, + }; + + const events: AgentEvent[] = []; + const stream = agentLoop(userPrompt, context, config, undefined, (_model, ctx, _options) => { + // Check if interrupt message is in context on second call + if (callIndex === 1) { + sawInterruptInContext = ctx.messages.some( + (m) => m.role === "user" && typeof m.content === "string" && m.content === "interrupt", + ); + } + + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + if (callIndex === 0) { + // First call: return two tool calls + const message = createAssistantMessage( + [ + { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } }, + { type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } }, + ], + "toolUse", + ); + mockStream.push({ type: "done", reason: "toolUse", message }); + } else { + // Second call: return final response + const message = createAssistantMessage([{ type: "text", text: "done" }]); + mockStream.push({ type: "done", reason: "stop", message }); + } + callIndex++; + }); + return mockStream; + }); + + for await (const event of stream) { + events.push(event); + } + + // Only first tool should have executed + expect(executed).toEqual(["first"]); + + // Second tool should be skipped + const toolEnds = events.filter( + (e): e is Extract => e.type === "tool_execution_end", + ); + expect(toolEnds.length).toBe(2); + expect(toolEnds[0].isError).toBe(false); + expect(toolEnds[1].isError).toBe(true); + if (toolEnds[1].result.content[0]?.type === "text") { + expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message"); + } + + // Queued message should appear in events + const queuedMessageEvent = events.find( + (e) => + e.type === "message_start" && + e.message.role === "user" && + typeof e.message.content === "string" && + e.message.content === "interrupt", + ); + expect(queuedMessageEvent).toBeDefined(); + + // Interrupt message should be in context when second LLM call is made + expect(sawInterruptInContext).toBe(true); + }); +}); + +describe("agentLoopContinue with AgentMessage", () => { + it("should throw when context has no messages", () => { + const context: AgentContext = { + systemPrompt: "You are helpful.", + messages: [], + tools: [], + }; + + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + }; + + expect(() => agentLoopContinue(context, config)).toThrow("Cannot continue: no messages in context"); + }); + + it("should continue from existing context without emitting user message events", async () => { + const userMessage: AgentMessage = createUserMessage("Hello"); + + const context: AgentContext = { + systemPrompt: "You are helpful.", + messages: [userMessage], + tools: [], + }; + + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + }; + + const streamFn = () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + const message = createAssistantMessage([{ type: "text", text: "Response" }]); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }; + + const events: AgentEvent[] = []; + const stream = agentLoopContinue(context, config, undefined, streamFn); + + for await (const event of stream) { + events.push(event); + } + + const messages = await stream.result(); + + // Should only return the new assistant message (not the existing user message) + expect(messages.length).toBe(1); + expect(messages[0].role).toBe("assistant"); + + // Should NOT have user message events (that's the key difference from agentLoop) + const messageEndEvents = events.filter((e) => e.type === "message_end"); + expect(messageEndEvents.length).toBe(1); + expect((messageEndEvents[0] as any).message.role).toBe("assistant"); + }); + + it("should allow custom message types as last message (caller responsibility)", async () => { + // Custom message that will be converted to user message by convertToLlm + interface HookMessage { + role: "hookMessage"; + text: string; + timestamp: number; + } + + const hookMessage: HookMessage = { + role: "hookMessage", + text: "Hook content", + timestamp: Date.now(), + }; + + const context: AgentContext = { + systemPrompt: "You are helpful.", + messages: [hookMessage as unknown as AgentMessage], + tools: [], + }; + + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: (messages) => { + // Convert hookMessage to user message + return messages + .map((m) => { + if ((m as any).role === "hookMessage") { + return { + role: "user" as const, + content: (m as any).text, + timestamp: m.timestamp, + }; + } + return m; + }) + .filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[]; + }, + }; + + const streamFn = () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + const message = createAssistantMessage([{ type: "text", text: "Response to hook" }]); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }; + + // Should not throw - the hookMessage will be converted to user message + const stream = agentLoopContinue(context, config, undefined, streamFn); + + const events: AgentEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const messages = await stream.result(); + expect(messages.length).toBe(1); + expect(messages[0].role).toBe("assistant"); + }); +}); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 235136cf..8fee033f 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,12 +1,10 @@ import { getModel } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; -import { Agent, ProviderTransport } from "../src/index.js"; +import { Agent } from "../src/index.js"; describe("Agent", () => { it("should create an agent instance with default state", () => { - const agent = new Agent({ - transport: new ProviderTransport(), - }); + const agent = new Agent(); expect(agent.state).toBeDefined(); expect(agent.state.systemPrompt).toBe(""); @@ -23,7 +21,6 @@ describe("Agent", () => { it("should create an agent instance with custom initial state", () => { const customModel = getModel("openai", "gpt-4o-mini"); const agent = new Agent({ - transport: new ProviderTransport(), initialState: { systemPrompt: "You are a helpful assistant.", model: customModel, @@ -37,9 +34,7 @@ describe("Agent", () => { }); it("should subscribe to events", () => { - const agent = new Agent({ - transport: new ProviderTransport(), - }); + const agent = new Agent(); let eventCount = 0; const unsubscribe = agent.subscribe((_event) => { @@ -61,9 +56,7 @@ describe("Agent", () => { }); it("should update state with mutators", () => { - const agent = new Agent({ - transport: new ProviderTransport(), - }); + const agent = new Agent(); // Test setSystemPrompt agent.setSystemPrompt("Custom prompt"); @@ -101,38 +94,19 @@ describe("Agent", () => { }); it("should support message queueing", async () => { - const agent = new Agent({ - transport: new ProviderTransport(), - }); + const agent = new Agent(); const message = { role: "user" as const, content: "Queued message", timestamp: Date.now() }; - await agent.queueMessage(message); + agent.queueMessage(message); // The message is queued but not yet in state.messages expect(agent.state.messages).not.toContainEqual(message); }); it("should handle abort controller", () => { - const agent = new Agent({ - transport: new ProviderTransport(), - }); + const agent = new Agent(); // Should not throw even if nothing is running expect(() => agent.abort()).not.toThrow(); }); }); - -describe("ProviderTransport", () => { - it("should create a provider transport instance", () => { - const transport = new ProviderTransport(); - expect(transport).toBeDefined(); - }); - - it("should create a provider transport with options", () => { - const transport = new ProviderTransport({ - getApiKey: async (provider) => `test-key-${provider}`, - corsProxyUrl: "https://proxy.example.com", - }); - expect(transport).toBeDefined(); - }); -}); diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts index f18030b3..7996543a 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -1,25 +1,8 @@ import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; -import { calculateTool, getModel } from "@mariozechner/pi-ai"; +import { getModel } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; -import { Agent, ProviderTransport } from "../src/index.js"; - -function createTransport() { - return new ProviderTransport({ - getApiKey: async (provider) => { - const envVarMap: Record = { - google: "GEMINI_API_KEY", - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - xai: "XAI_API_KEY", - groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - zai: "ZAI_API_KEY", - }; - const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`; - return process.env[envVar]; - }, - }); -} +import { Agent } from "../src/index.js"; +import { calculateTool } from "./utils/calculate.js"; async function basicPrompt(model: Model) { const agent = new Agent({ @@ -29,7 +12,6 @@ async function basicPrompt(model: Model) { thinkingLevel: "off", tools: [], }, - transport: createTransport(), }); await agent.prompt("What is 2+2? Answer with just the number."); @@ -57,7 +39,6 @@ async function toolExecution(model: Model) { thinkingLevel: "off", tools: [calculateTool], }, - transport: createTransport(), }); await agent.prompt("Calculate 123 * 456 using the calculator tool."); @@ -99,7 +80,6 @@ async function abortExecution(model: Model) { thinkingLevel: "off", tools: [calculateTool], }, - transport: createTransport(), }); const promptPromise = agent.prompt("Calculate 100 * 200, then 300 * 400, then sum the results."); @@ -129,7 +109,6 @@ async function stateUpdates(model: Model) { thinkingLevel: "off", tools: [], }, - transport: createTransport(), }); const events: Array = []; @@ -162,7 +141,6 @@ async function multiTurnConversation(model: Model) { thinkingLevel: "off", tools: [], }, - transport: createTransport(), }); await agent.prompt("My name is Alice."); @@ -356,7 +334,6 @@ describe("Agent.continue()", () => { systemPrompt: "Test", model: getModel("anthropic", "claude-haiku-4-5"), }, - transport: createTransport(), }); await expect(agent.continue()).rejects.toThrow("No messages to continue from"); @@ -368,7 +345,6 @@ describe("Agent.continue()", () => { systemPrompt: "Test", model: getModel("anthropic", "claude-haiku-4-5"), }, - transport: createTransport(), }); const assistantMessage: AssistantMessage = { @@ -405,7 +381,6 @@ describe("Agent.continue()", () => { thinkingLevel: "off", tools: [], }, - transport: createTransport(), }); // Manually add a user message without calling prompt() @@ -445,7 +420,6 @@ describe("Agent.continue()", () => { thinkingLevel: "off", tools: [calculateTool], }, - transport: createTransport(), }); // Set up a conversation state as if tool was just executed diff --git a/packages/ai/src/agent/tools/calculate.ts b/packages/agent/test/utils/calculate.ts similarity index 93% rename from packages/ai/src/agent/tools/calculate.ts rename to packages/agent/test/utils/calculate.ts index afc75889..af5b17c4 100644 --- a/packages/ai/src/agent/tools/calculate.ts +++ b/packages/agent/test/utils/calculate.ts @@ -1,5 +1,5 @@ import { type Static, Type } from "@sinclair/typebox"; -import type { AgentTool, AgentToolResult } from "../../agent/types.js"; +import type { AgentTool, AgentToolResult } from "../../src/types.js"; export interface CalculateResult extends AgentToolResult { content: Array<{ type: "text"; text: string }>; diff --git a/packages/ai/src/agent/tools/get-current-time.ts b/packages/agent/test/utils/get-current-time.ts similarity index 93% rename from packages/ai/src/agent/tools/get-current-time.ts rename to packages/agent/test/utils/get-current-time.ts index 2d989243..814233b1 100644 --- a/packages/ai/src/agent/tools/get-current-time.ts +++ b/packages/agent/test/utils/get-current-time.ts @@ -1,6 +1,5 @@ import { type Static, Type } from "@sinclair/typebox"; -import type { AgentTool } from "../../agent/index.js"; -import type { AgentToolResult } from "../types.js"; +import type { AgentTool, AgentToolResult } from "../../src/types.js"; export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {} diff --git a/packages/ai/src/agent/index.ts b/packages/ai/src/agent/index.ts deleted file mode 100644 index 16e323d4..00000000 --- a/packages/ai/src/agent/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { agentLoop, agentLoopContinue } from "./agent-loop.js"; -export * from "./tools/index.js"; -export type { - AgentContext, - AgentEvent, - AgentLoopConfig, - AgentTool, - AgentToolResult, - AgentToolUpdateCallback, - QueuedMessage, -} from "./types.js"; diff --git a/packages/ai/src/agent/tools/index.ts b/packages/ai/src/agent/tools/index.ts deleted file mode 100644 index 0a9cc7ad..00000000 --- a/packages/ai/src/agent/tools/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { calculate, calculateTool } from "./calculate.js"; -export { getCurrentTime, getCurrentTimeTool } from "./get-current-time.js"; diff --git a/packages/ai/src/agent/types.ts b/packages/ai/src/agent/types.ts deleted file mode 100644 index c0cb2df6..00000000 --- a/packages/ai/src/agent/types.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Static, TSchema } from "@sinclair/typebox"; -import type { - AssistantMessage, - AssistantMessageEvent, - ImageContent, - Message, - Model, - SimpleStreamOptions, - TextContent, - Tool, - ToolResultMessage, -} from "../types.js"; - -export interface AgentToolResult { - // Content blocks supporting text and images - content: (TextContent | ImageContent)[]; - // Details to be displayed in a UI or logged - details: T; -} - -// Callback for streaming tool execution updates -export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; - -// AgentTool extends Tool but adds the execute function -export interface AgentTool extends Tool { - // A human-readable label for the tool to be displayed in UI - label: string; - execute: ( - toolCallId: string, - params: Static, - signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, - ) => Promise>; -} - -// AgentContext is like Context but uses AgentTool -export interface AgentContext { - systemPrompt: string; - messages: Message[]; - tools?: AgentTool[]; -} - -// Event types -export type AgentEvent = - // Emitted when the agent starts. An agent can emit multiple turns - | { type: "agent_start" } - // Emitted when a turn starts. A turn can emit an optional user message (initial prompt), an assistant message (response) and multiple tool result messages - | { type: "turn_start" } - // Emitted when a user, assistant or tool result message starts - | { type: "message_start"; message: Message } - // Emitted when an asssitant messages is updated due to streaming - | { type: "message_update"; assistantMessageEvent: AssistantMessageEvent; message: AssistantMessage } - // Emitted when a user, assistant or tool result message is complete - | { type: "message_end"; message: Message } - // Emitted when a tool execution starts - | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any } - // Emitted when a tool execution produces output (streaming) - | { - type: "tool_execution_update"; - toolCallId: string; - toolName: string; - args: any; - partialResult: AgentToolResult; - } - // Emitted when a tool execution completes - | { - type: "tool_execution_end"; - toolCallId: string; - toolName: string; - result: AgentToolResult; - isError: boolean; - } - // Emitted when a full turn completes - | { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] } - // Emitted when the agent has completed all its turns. All messages from every turn are - // contained in messages, which can be appended to the context - | { type: "agent_end"; messages: AgentContext["messages"] }; - -// Queued message with optional LLM representation -export interface QueuedMessage { - original: TApp; // Original message for UI events - llm?: Message; // Optional transformed message for loop context (undefined if filtered) -} - -// Configuration for agent loop execution -export interface AgentLoopConfig extends SimpleStreamOptions { - model: Model; - - /** - * Optional hook to resolve an API key dynamically for each LLM call. - * - * This is useful for short-lived OAuth tokens (e.g. GitHub Copilot) that may - * expire during long-running tool execution phases. - * - * The agent loop will call this before each assistant response and pass the - * returned value as `apiKey` to `streamSimple()` (or a custom `streamFn`). - * - * If it returns `undefined`, the loop falls back to `config.apiKey`, and then - * to `streamSimple()`'s own provider key lookup (setApiKey/env vars). - */ - getApiKey?: (provider: string) => Promise | string | undefined; - - preprocessor?: (messages: AgentContext["messages"], abortSignal?: AbortSignal) => Promise; - getQueuedMessages?: () => Promise[]>; -} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index e24431d8..5ff971de 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,4 +1,3 @@ -export * from "./agent/index.js"; export * from "./models.js"; export * from "./providers/anthropic.js"; export * from "./providers/google.js"; @@ -7,6 +6,7 @@ export * from "./providers/openai-completions.js"; export * from "./providers/openai-responses.js"; export * from "./stream.js"; export * from "./types.js"; +export * from "./utils/event-stream.js"; export * from "./utils/oauth/index.js"; export * from "./utils/overflow.js"; export * from "./utils/typebox-helpers.js"; diff --git a/packages/ai/test/agent-queue-interrupt.test.ts b/packages/ai/test/agent-queue-interrupt.test.ts deleted file mode 100644 index 42a5db45..00000000 --- a/packages/ai/test/agent-queue-interrupt.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import { describe, expect, it } from "vitest"; -import { agentLoop } from "../src/agent/agent-loop.js"; -import type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, QueuedMessage } from "../src/agent/types.js"; -import type { AssistantMessage, Message, Model, UserMessage } from "../src/types.js"; -import { AssistantMessageEventStream } from "../src/utils/event-stream.js"; - -function createUsage() { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; -} - -function createModel(): Model<"openai-responses"> { - return { - id: "mock", - name: "mock", - api: "openai-responses", - provider: "openai", - baseUrl: "https://example.invalid", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 2048, - }; -} - -describe("agentLoop queued message interrupt", () => { - it("injects queued messages after a tool call and skips remaining tool calls", async () => { - const toolSchema = Type.Object({ value: Type.String() }); - const executed: string[] = []; - const tool: AgentTool = { - name: "echo", - label: "Echo", - description: "Echo tool", - parameters: toolSchema, - async execute(_toolCallId, params) { - executed.push(params.value); - return { - content: [{ type: "text", text: `ok:${params.value}` }], - details: { value: params.value }, - }; - }, - }; - - const context: AgentContext = { - systemPrompt: "", - messages: [], - tools: [tool], - }; - - const userPrompt: UserMessage = { - role: "user", - content: "start", - timestamp: Date.now(), - }; - - const queuedUserMessage: Message = { - role: "user", - content: "interrupt", - timestamp: Date.now(), - }; - const queuedMessages: QueuedMessage[] = [{ original: queuedUserMessage, llm: queuedUserMessage }]; - - let queuedDelivered = false; - let sawInterruptInContext = false; - let callIndex = 0; - - const streamFn = () => { - const stream = new AssistantMessageEventStream(); - queueMicrotask(() => { - if (callIndex === 0) { - const message: AssistantMessage = { - role: "assistant", - content: [ - { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } }, - { type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } }, - ], - api: "openai-responses", - provider: "openai", - model: "mock", - usage: createUsage(), - stopReason: "toolUse", - timestamp: Date.now(), - }; - stream.push({ type: "done", reason: "toolUse", message }); - } else { - const message: AssistantMessage = { - role: "assistant", - content: [{ type: "text", text: "done" }], - api: "openai-responses", - provider: "openai", - model: "mock", - usage: createUsage(), - stopReason: "stop", - timestamp: Date.now(), - }; - stream.push({ type: "done", reason: "stop", message }); - } - callIndex += 1; - }); - return stream; - }; - - const getQueuedMessages: AgentLoopConfig["getQueuedMessages"] = async () => { - if (executed.length === 1 && !queuedDelivered) { - queuedDelivered = true; - return queuedMessages as QueuedMessage[]; - } - return []; - }; - - const config: AgentLoopConfig = { - model: createModel(), - getQueuedMessages, - }; - - const events: AgentEvent[] = []; - const stream = agentLoop(userPrompt, context, config, undefined, (_model, ctx, _options) => { - if (callIndex === 1) { - sawInterruptInContext = ctx.messages.some( - (m) => m.role === "user" && typeof m.content === "string" && m.content === "interrupt", - ); - } - return streamFn(); - }); - - for await (const event of stream) { - events.push(event); - } - - expect(executed).toEqual(["first"]); - const toolEnds = events.filter( - (event): event is Extract => event.type === "tool_execution_end", - ); - expect(toolEnds.length).toBe(2); - expect(toolEnds[1].isError).toBe(true); - expect(toolEnds[1].result.content[0]?.type).toBe("text"); - if (toolEnds[1].result.content[0]?.type === "text") { - expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message"); - } - - const firstTurnEndIndex = events.findIndex((event) => event.type === "turn_end"); - const queuedMessageIndex = events.findIndex( - (event) => - event.type === "message_start" && - event.message.role === "user" && - typeof event.message.content === "string" && - event.message.content === "interrupt", - ); - const nextAssistantIndex = events.findIndex( - (event, index) => - index > queuedMessageIndex && event.type === "message_start" && event.message.role === "assistant", - ); - - expect(queuedMessageIndex).toBeGreaterThan(firstTurnEndIndex); - expect(queuedMessageIndex).toBeLessThan(nextAssistantIndex); - expect(sawInterruptInContext).toBe(true); - }); -}); diff --git a/packages/ai/test/agent.test.ts b/packages/ai/test/agent.test.ts deleted file mode 100644 index 8b7fdd46..00000000 --- a/packages/ai/test/agent.test.ts +++ /dev/null @@ -1,701 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { agentLoop, agentLoopContinue } from "../src/agent/agent-loop.js"; -import { calculateTool } from "../src/agent/tools/calculate.js"; -import type { AgentContext, AgentEvent, AgentLoopConfig } from "../src/agent/types.js"; -import { getModel } from "../src/models.js"; -import type { - Api, - AssistantMessage, - Message, - Model, - OptionsForApi, - ToolResultMessage, - UserMessage, -} from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; - -// Resolve OAuth tokens at module level (async, runs before tests) -const oauthTokens = await Promise.all([ - resolveApiKey("anthropic"), - resolveApiKey("github-copilot"), - resolveApiKey("google-gemini-cli"), - resolveApiKey("google-antigravity"), -]); -const [anthropicOAuthToken, githubCopilotToken, geminiCliToken, antigravityToken] = oauthTokens; - -async function calculateTest(model: Model, options: OptionsForApi = {}) { - // Create the agent context with the calculator tool - const context: AgentContext = { - systemPrompt: - "You are a helpful assistant that performs mathematical calculations. When asked to calculate multiple expressions, you can use parallel tool calls if the model supports it. In your final answer, output ONLY the final sum as a single integer number, nothing else.", - messages: [], - tools: [calculateTool], - }; - - // Create the prompt config - const config: AgentLoopConfig = { - model, - ...options, - }; - - // Create the user prompt asking for multiple calculations - const userPrompt: UserMessage = { - role: "user", - content: `Use the calculator tool to complete the following mulit-step task. -1. Calculate 3485 * 4234 and 88823 * 3482 in parallel -2. Calculate the sum of the two results using the calculator tool -3. Output ONLY the final sum as a single integer number, nothing else.`, - timestamp: Date.now(), - }; - - // Calculate expected results (using integers) - const expectedFirst = 3485 * 4234; // = 14755490 - const expectedSecond = 88823 * 3482; // = 309281786 - const expectedSum = expectedFirst + expectedSecond; // = 324037276 - - // Track events for verification - const events: AgentEvent[] = []; - let turns = 0; - let toolCallCount = 0; - const toolResults: number[] = []; - let finalAnswer: number | undefined; - - // Execute the prompt - const stream = agentLoop(userPrompt, context, config); - - for await (const event of stream) { - events.push(event); - - switch (event.type) { - case "turn_start": - turns++; - console.log(`\n=== Turn ${turns} started ===`); - break; - - case "turn_end": - console.log(`=== Turn ${turns} ended with ${event.toolResults.length} tool results ===`); - console.log(event.message); - break; - - case "tool_execution_end": - if (!event.isError && typeof event.result === "object" && event.result.content) { - const textOutput = event.result.content - .filter((c: any) => c.type === "text") - .map((c: any) => c.text) - .join("\n"); - toolCallCount++; - // Extract number from output like "expression = result" - const match = textOutput.match(/=\s*([\d.]+)/); - if (match) { - const value = parseFloat(match[1]); - toolResults.push(value); - console.log(`Tool ${toolCallCount}: ${textOutput}`); - } - } - break; - - case "message_end": - // Just track the message end event, don't extract answer here - break; - } - } - - // Get the final messages - const finalMessages = await stream.result(); - - // Verify the results - expect(finalMessages).toBeDefined(); - expect(finalMessages.length).toBeGreaterThan(0); - - const finalMessage = finalMessages[finalMessages.length - 1]; - expect(finalMessage).toBeDefined(); - expect(finalMessage.role).toBe("assistant"); - if (finalMessage.role !== "assistant") throw new Error("Final message is not from assistant"); - - // Extract the final answer from the last assistant message - const content = finalMessage.content - .filter((c) => c.type === "text") - .map((c) => (c.type === "text" ? c.text : "")) - .join(" "); - - // Look for integers in the response that might be the final answer - const numbers = content.match(/\b\d+\b/g); - if (numbers) { - // Check if any of the numbers matches our expected sum - for (const num of numbers) { - const value = parseInt(num, 10); - if (Math.abs(value - expectedSum) < 10) { - finalAnswer = value; - break; - } - } - // If no exact match, take the last large number as likely the answer - if (finalAnswer === undefined) { - const largeNumbers = numbers.map((n) => parseInt(n, 10)).filter((n) => n > 1000000); - if (largeNumbers.length > 0) { - finalAnswer = largeNumbers[largeNumbers.length - 1]; - } - } - } - - // Should have executed at least 3 tool calls: 2 for the initial calculations, 1 for the sum - // (or possibly 2 if the model calculates the sum itself without a tool) - expect(toolCallCount).toBeGreaterThanOrEqual(2); - - // Must be at least 3 turns: first to calculate the expressions, then to sum them, then give the answer - // Could be 3 turns if model does parallel calls, or 4 turns if sequential calculation of expressions - expect(turns).toBeGreaterThanOrEqual(3); - expect(turns).toBeLessThanOrEqual(4); - - // Verify the individual calculations are in the results - const hasFirstCalc = toolResults.some((r) => r === expectedFirst); - const hasSecondCalc = toolResults.some((r) => r === expectedSecond); - expect(hasFirstCalc).toBe(true); - expect(hasSecondCalc).toBe(true); - - // Verify the final sum - if (finalAnswer !== undefined) { - expect(finalAnswer).toBe(expectedSum); - console.log(`Final answer: ${finalAnswer} (expected: ${expectedSum})`); - } else { - // If we couldn't extract the final answer from text, check if it's in the tool results - const hasSum = toolResults.some((r) => r === expectedSum); - expect(hasSum).toBe(true); - } - - // Log summary - console.log(`\nTest completed with ${turns} turns and ${toolCallCount} tool calls`); - if (turns === 3) { - console.log("Model used parallel tool calls for initial calculations"); - } else { - console.log("Model used sequential tool calls"); - } - - return { - turns, - toolCallCount, - toolResults, - finalAnswer, - events, - }; -} - -async function abortTest(model: Model, options: OptionsForApi = {}) { - // Create the agent context with the calculator tool - const context: AgentContext = { - systemPrompt: - "You are a helpful assistant that performs mathematical calculations. Always use the calculator tool for each calculation.", - messages: [], - tools: [calculateTool], - }; - - // Create the prompt config - const config: AgentLoopConfig = { - model, - ...options, - }; - - // Create a prompt that will require multiple calculations - const userPrompt: UserMessage = { - role: "user", - content: "Calculate 100 * 200, then 300 * 400, then 500 * 600, then sum all three results.", - timestamp: Date.now(), - }; - - // Create abort controller - const abortController = new AbortController(); - - // Track events for verification - const events: AgentEvent[] = []; - let toolCallCount = 0; - const errorReceived = false; - let finalMessages: Message[] | undefined; - - // Execute the prompt - const stream = agentLoop(userPrompt, context, config, abortController.signal); - - // Abort after first tool execution - (async () => { - for await (const event of stream) { - events.push(event); - - if (event.type === "tool_execution_end" && !event.isError) { - toolCallCount++; - // Abort after first successful tool execution - if (toolCallCount === 1) { - console.log("Aborting after first tool execution"); - abortController.abort(); - } - } - - if (event.type === "agent_end") { - finalMessages = event.messages; - } - } - })(); - - finalMessages = await stream.result(); - - // Verify abort behavior - console.log(`\nAbort test completed with ${toolCallCount} tool calls`); - const assistantMessage = finalMessages[finalMessages.length - 1]; - if (!assistantMessage) throw new Error("No final message received"); - expect(assistantMessage).toBeDefined(); - expect(assistantMessage.role).toBe("assistant"); - if (assistantMessage.role !== "assistant") throw new Error("Final message is not from assistant"); - - // Should have executed 1 tool call before abort - expect(toolCallCount).toBeGreaterThanOrEqual(1); - expect(assistantMessage.stopReason).toBe("aborted"); - - return { - toolCallCount, - events, - errorReceived, - finalMessages, - }; -} - -describe("Agent Calculator Tests", () => { - describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider Agent", () => { - const model = getModel("google", "gemini-2.5-flash"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions Provider Agent", () => { - const model = getModel("openai", "gpt-4o-mini"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider Agent", () => { - const model = getModel("openai", "gpt-5-mini"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider Agent", () => { - const model = getModel("anthropic", "claude-haiku-4-5"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider Agent", () => { - const model = getModel("xai", "grok-3"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe.skipIf(!process.env.GROQ_API_KEY)("Groq Provider Agent", () => { - const model = getModel("groq", "openai/gpt-oss-20b"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider Agent", () => { - const model = getModel("cerebras", "gpt-oss-120b"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Agent", () => { - const model = getModel("zai", "glm-4.5-air"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Agent", () => { - const model = getModel("mistral", "devstral-medium-latest"); - - it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => { - const result = await calculateTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }); - - it("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - // ========================================================================= - // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) - // ========================================================================= - - describe("Anthropic OAuth Provider Agent", () => { - const model = getModel("anthropic", "claude-haiku-4-5"); - - it.skipIf(!anthropicOAuthToken)( - "should calculate multiple expressions and sum the results", - { retry: 3 }, - async () => { - const result = await calculateTest(model, { apiKey: anthropicOAuthToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }, - ); - - it.skipIf(!anthropicOAuthToken)("should handle abort during tool execution", { retry: 3 }, async () => { - const result = await abortTest(model, { apiKey: anthropicOAuthToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - }); - - describe("GitHub Copilot Provider Agent", () => { - it.skipIf(!githubCopilotToken)( - "gpt-4o - should calculate multiple expressions and sum the results", - { retry: 3 }, - async () => { - const model = getModel("github-copilot", "gpt-4o"); - const result = await calculateTest(model, { apiKey: githubCopilotToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }, - ); - - it.skipIf(!githubCopilotToken)("gpt-4o - should handle abort during tool execution", { retry: 3 }, async () => { - const model = getModel("github-copilot", "gpt-4o"); - const result = await abortTest(model, { apiKey: githubCopilotToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }); - - it.skipIf(!githubCopilotToken)( - "claude-sonnet-4 - should calculate multiple expressions and sum the results", - { retry: 3 }, - async () => { - const model = getModel("github-copilot", "claude-sonnet-4"); - const result = await calculateTest(model, { apiKey: githubCopilotToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }, - ); - - it.skipIf(!githubCopilotToken)( - "claude-sonnet-4 - should handle abort during tool execution", - { retry: 3 }, - async () => { - const model = getModel("github-copilot", "claude-sonnet-4"); - const result = await abortTest(model, { apiKey: githubCopilotToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }, - ); - }); - - describe("Google Gemini CLI Provider Agent", () => { - it.skipIf(!geminiCliToken)( - "gemini-2.5-flash - should calculate multiple expressions and sum the results", - { retry: 3 }, - async () => { - const model = getModel("google-gemini-cli", "gemini-2.5-flash"); - const result = await calculateTest(model, { apiKey: geminiCliToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }, - ); - - it.skipIf(!geminiCliToken)( - "gemini-2.5-flash - should handle abort during tool execution", - { retry: 3 }, - async () => { - const model = getModel("google-gemini-cli", "gemini-2.5-flash"); - const result = await abortTest(model, { apiKey: geminiCliToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }, - ); - }); - - describe("Google Antigravity Provider Agent", () => { - it.skipIf(!antigravityToken)( - "gemini-3-flash - should calculate multiple expressions and sum the results", - { retry: 3 }, - async () => { - const model = getModel("google-antigravity", "gemini-3-flash"); - const result = await calculateTest(model, { apiKey: antigravityToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }, - ); - - it.skipIf(!antigravityToken)( - "gemini-3-flash - should handle abort during tool execution", - { retry: 3 }, - async () => { - const model = getModel("google-antigravity", "gemini-3-flash"); - const result = await abortTest(model, { apiKey: antigravityToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }, - ); - - it.skipIf(!antigravityToken)( - "claude-sonnet-4-5 - should calculate multiple expressions and sum the results", - { retry: 3 }, - async () => { - const model = getModel("google-antigravity", "claude-sonnet-4-5"); - const result = await calculateTest(model, { apiKey: antigravityToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }, - ); - - it.skipIf(!antigravityToken)( - "claude-sonnet-4-5 - should handle abort during tool execution", - { retry: 3 }, - async () => { - const model = getModel("google-antigravity", "claude-sonnet-4-5"); - const result = await abortTest(model, { apiKey: antigravityToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }, - ); - - it.skipIf(!antigravityToken)( - "gpt-oss-120b-medium - should calculate multiple expressions and sum the results", - { retry: 3 }, - async () => { - const model = getModel("google-antigravity", "gpt-oss-120b-medium"); - const result = await calculateTest(model, { apiKey: antigravityToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(2); - }, - ); - - it.skipIf(!antigravityToken)( - "gpt-oss-120b-medium - should handle abort during tool execution", - { retry: 3 }, - async () => { - const model = getModel("google-antigravity", "gpt-oss-120b-medium"); - const result = await abortTest(model, { apiKey: antigravityToken }); - expect(result.toolCallCount).toBeGreaterThanOrEqual(1); - }, - ); - }); -}); - -describe("agentLoopContinue", () => { - describe("validation", () => { - const model = getModel("anthropic", "claude-haiku-4-5"); - const baseContext: AgentContext = { - systemPrompt: "You are a helpful assistant.", - messages: [], - tools: [], - }; - const config: AgentLoopConfig = { model }; - - it("should throw when context has no messages", () => { - expect(() => agentLoopContinue(baseContext, config)).toThrow("Cannot continue: no messages in context"); - }); - - it("should throw when last message is an assistant message", () => { - const assistantMessage: AssistantMessage = { - role: "assistant", - content: [{ type: "text", text: "Hello" }], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-haiku-4-5", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - const context: AgentContext = { - ...baseContext, - messages: [assistantMessage], - }; - expect(() => agentLoopContinue(context, config)).toThrow( - "Cannot continue from message role: assistant. Expected 'user' or 'toolResult'.", - ); - }); - - // Note: "should not throw" tests for valid inputs are covered by the E2E tests below - // which actually consume the stream and verify the output - }); - - describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from user message", () => { - const model = getModel("anthropic", "claude-haiku-4-5"); - - it("should continue and get assistant response when last message is user", { retry: 3 }, async () => { - const userMessage: UserMessage = { - role: "user", - content: [{ type: "text", text: "Say exactly: HELLO WORLD" }], - timestamp: Date.now(), - }; - - const context: AgentContext = { - systemPrompt: "You are a helpful assistant. Follow instructions exactly.", - messages: [userMessage], - tools: [], - }; - - const config: AgentLoopConfig = { model }; - - const events: AgentEvent[] = []; - const stream = agentLoopContinue(context, config); - - for await (const event of stream) { - events.push(event); - } - - const messages = await stream.result(); - - // Should have gotten an assistant response - expect(messages.length).toBe(1); - expect(messages[0].role).toBe("assistant"); - - // Verify event sequence - no user message events since we're continuing - const eventTypes = events.map((e) => e.type); - expect(eventTypes).toContain("agent_start"); - expect(eventTypes).toContain("turn_start"); - expect(eventTypes).toContain("message_start"); - expect(eventTypes).toContain("message_end"); - expect(eventTypes).toContain("turn_end"); - expect(eventTypes).toContain("agent_end"); - - // Should NOT have user message events (that's the difference from agentLoop) - const messageEndEvents = events.filter((e) => e.type === "message_end"); - expect(messageEndEvents.length).toBe(1); // Only assistant message - expect((messageEndEvents[0] as any).message.role).toBe("assistant"); - }); - }); - - describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from tool result", () => { - const model = getModel("anthropic", "claude-haiku-4-5"); - - it("should continue processing after tool results", { retry: 3 }, async () => { - // Simulate a conversation where: - // 1. User asked to calculate something - // 2. Assistant made a tool call - // 3. Tool result is ready - // 4. We continue from here - - const userMessage: UserMessage = { - role: "user", - content: [{ type: "text", text: "What is 5 + 3? Use the calculator." }], - timestamp: Date.now(), - }; - - const assistantMessage: AssistantMessage = { - role: "assistant", - content: [ - { type: "text", text: "Let me calculate that for you." }, - { type: "toolCall", id: "calc-1", name: "calculate", arguments: { expression: "5 + 3" } }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-haiku-4-5", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "toolUse", - timestamp: Date.now(), - }; - - const toolResult: ToolResultMessage = { - role: "toolResult", - toolCallId: "calc-1", - toolName: "calculate", - content: [{ type: "text", text: "5 + 3 = 8" }], - isError: false, - timestamp: Date.now(), - }; - - const context: AgentContext = { - systemPrompt: "You are a helpful assistant. After getting a calculation result, state the answer clearly.", - messages: [userMessage, assistantMessage, toolResult], - tools: [calculateTool], - }; - - const config: AgentLoopConfig = { model }; - - const events: AgentEvent[] = []; - const stream = agentLoopContinue(context, config); - - for await (const event of stream) { - events.push(event); - } - - const messages = await stream.result(); - - // Should have gotten an assistant response - expect(messages.length).toBeGreaterThanOrEqual(1); - const lastMessage = messages[messages.length - 1]; - expect(lastMessage.role).toBe("assistant"); - - // The assistant should mention the result (8) - if (lastMessage.role === "assistant") { - const textContent = lastMessage.content - .filter((c) => c.type === "text") - .map((c) => (c as any).text) - .join(" "); - expect(textContent).toMatch(/8/); - } - }); - }); -}); diff --git a/packages/ai/test/tool-validation.test.ts b/packages/ai/test/tool-validation.test.ts index 709d0946..a2315ab5 100644 --- a/packages/ai/test/tool-validation.test.ts +++ b/packages/ai/test/tool-validation.test.ts @@ -1,4 +1,4 @@ -import { type Static, Type } from "@sinclair/typebox"; +import { Type } from "@sinclair/typebox"; import AjvModule from "ajv"; import addFormatsModule from "ajv-formats"; @@ -7,7 +7,7 @@ const Ajv = (AjvModule as any).default || AjvModule; const addFormats = (addFormatsModule as any).default || addFormatsModule; import { describe, expect, it } from "vitest"; -import type { AgentTool } from "../src/agent/types.js"; +import type { Tool } from "../src/types.js"; describe("Tool Validation with TypeBox and AJV", () => { // Define a test tool with TypeBox schema @@ -18,20 +18,11 @@ describe("Tool Validation with TypeBox and AJV", () => { tags: Type.Optional(Type.Array(Type.String())), }); - type TestParams = Static; - - const testTool: AgentTool = { - label: "Test Tool", + const testTool = { name: "test_tool", description: "A test tool for validation", parameters: testSchema, - execute: async (_toolCallId, args) => { - return { - content: [{ type: "text", text: `Processed: ${args.name}, ${args.age}, ${args.email}` }], - details: undefined, - }; - }, - }; + } satisfies Tool; // Create AJV instance for validation const ajv = new Ajv({ allErrors: true }); @@ -115,26 +106,4 @@ describe("Tool Validation with TypeBox and AJV", () => { expect(errors).toContain('email: must match format "email"'); } }); - - it("should have type-safe execute function", async () => { - const validInput = { - name: "John Doe", - age: 30, - email: "john@example.com", - }; - - // Validate and execute - const validate = ajv.compile(testTool.parameters); - const isValid = validate(validInput); - expect(isValid).toBe(true); - - const result = await testTool.execute("test-id", validInput as TestParams); - - const textOutput = result.content - .filter((c: any) => c.type === "text") - .map((c: any) => c.text) - .join("\n"); - expect(textOutput).toBe("Processed: John Doe, 30, john@example.com"); - expect(result.details).toBeUndefined(); - }); }); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ecd509d0..b1609a53 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,7 +13,14 @@ * Modes use this class and add their own I/O layer on top. */ -import type { Agent, AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { + Agent, + AgentEvent, + AgentMessage, + AgentState, + Attachment, + ThinkingLevel, +} from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, Model, TextContent } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { getAuthPath } from "../config.js"; @@ -403,7 +410,7 @@ export class AgentSession { } /** All messages including custom types like BashExecutionMessage */ - get messages(): AppMessage[] { + get messages(): AgentMessage[] { return this.agent.state.messages; } diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index 3fb771dd..6f0f1c90 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -5,17 +5,17 @@ * and after compaction the session is reloaded. */ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; import { messageTransformer } from "./messages.js"; import { type CompactionEntry, createSummaryMessage, type SessionEntry } from "./session-manager.js"; /** - * Extract AppMessage from an entry if it produces one. + * Extract AgentMessage from an entry if it produces one. * Returns null for entries that don't contribute to LLM context. */ -function getMessageFromEntry(entry: SessionEntry): AppMessage | null { +function getMessageFromEntry(entry: SessionEntry): AgentMessage | null { if (entry.type === "message") { return entry.message; } @@ -73,7 +73,7 @@ export function calculateContextTokens(usage: Usage): number { * Get usage from an assistant message if available. * Skips aborted and error messages as they don't have valid usage data. */ -function getAssistantUsage(msg: AppMessage): Usage | null { +function getAssistantUsage(msg: AgentMessage): Usage | null { if (msg.role === "assistant" && "usage" in msg) { const assistantMsg = msg as AssistantMessage; if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) { @@ -113,7 +113,7 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett * Estimate token count for a message using chars/4 heuristic. * This is conservative (overestimates tokens). */ -export function estimateTokens(message: AppMessage): number { +export function estimateTokens(message: AgentMessage): number { let chars = 0; // Handle bashExecution messages @@ -323,7 +323,7 @@ Be concise, structured, and focused on helping the next LLM seamlessly continue * Generate a summary of the conversation using the LLM. */ export async function generateSummary( - currentMessages: AppMessage[], + currentMessages: AgentMessage[], model: Model, reserveTokens: number, apiKey: string, @@ -371,9 +371,9 @@ export interface CompactionPreparation { /** UUID of first entry to keep */ firstKeptEntryId: string; /** Messages that will be summarized and discarded */ - messagesToSummarize: AppMessage[]; + messagesToSummarize: AgentMessage[]; /** Messages that will be kept after the summary (recent turns) */ - messagesToKeep: AppMessage[]; + messagesToKeep: AgentMessage[]; tokensBefore: number; boundaryStart: number; } @@ -408,14 +408,14 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; // Messages to summarize (will be discarded after summary) - const messagesToSummarize: AppMessage[] = []; + const messagesToSummarize: AgentMessage[] = []; for (let i = boundaryStart; i < historyEnd; i++) { const msg = getMessageFromEntry(entries[i]); if (msg) messagesToSummarize.push(msg); } // Messages to keep (recent turns, kept after summary) - const messagesToKeep: AppMessage[] = []; + const messagesToKeep: AgentMessage[] = []; for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) { const msg = getMessageFromEntry(entries[i]); if (msg) messagesToKeep.push(msg); @@ -482,7 +482,7 @@ export async function compact( // Extract messages for history summary (before the turn that contains the cut point) const historyEnd = cutResult.isSplitTurn ? cutResult.turnStartIndex : cutResult.firstKeptEntryIndex; - const historyMessages: AppMessage[] = []; + const historyMessages: AgentMessage[] = []; for (let i = boundaryStart; i < historyEnd; i++) { const msg = getMessageFromEntry(entries[i]); if (msg) historyMessages.push(msg); @@ -499,7 +499,7 @@ export async function compact( } // Extract messages for turn prefix summary (if splitting a turn) - const turnPrefixMessages: AppMessage[] = []; + const turnPrefixMessages: AgentMessage[] = []; if (cutResult.isSplitTurn) { for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) { const msg = getMessageFromEntry(entries[i]); @@ -550,7 +550,7 @@ export async function compact( * Generate a summary for a turn prefix (when splitting a turn). */ async function generateTurnPrefixSummary( - messages: AppMessage[], + messages: AgentMessage[], model: Model, reserveTokens: number, apiKey: string, diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 74e011f1..d7424dba 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -5,7 +5,7 @@ * and interact with the user via UI primitives. */ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; @@ -151,7 +151,7 @@ export type SessionEvent = * Event data for context event. * Fired before each LLM call, allowing hooks to modify context non-destructively. * Original session messages are NOT modified - only the messages sent to the LLM are affected. - * Messages are already in LLM format (Message[], not AppMessage[]). + * Messages are already in LLM format (Message[], not AgentMessage[]). */ export interface ContextEvent { type: "context"; @@ -172,7 +172,7 @@ export interface AgentStartEvent { */ export interface AgentEndEvent { type: "agent_end"; - messages: AppMessage[]; + messages: AgentMessage[]; } /** @@ -190,7 +190,7 @@ export interface TurnStartEvent { export interface TurnEndEvent { type: "turn_end"; turnIndex: number; - message: AppMessage; + message: AgentMessage; toolResults: ToolResultMessage[]; } diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index fa4c2969..678dbac5 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -1,11 +1,11 @@ /** * Custom message types and transformers for the coding agent. * - * Extends the base AppMessage type with coding-agent specific message types, + * Extends the base AgentMessage type with coding-agent specific message types, * and provides a transformer to convert them to LLM-compatible messages. */ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { Message } from "@mariozechner/pi-ai"; // ============================================================================ @@ -56,14 +56,14 @@ declare module "@mariozechner/pi-agent-core" { /** * Type guard for BashExecutionMessage. */ -export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashExecutionMessage { +export function isBashExecutionMessage(msg: AgentMessage | Message): msg is BashExecutionMessage { return (msg as BashExecutionMessage).role === "bashExecution"; } /** - * Type guard for HookAppMessage. + * Type guard for HookAgentMessage. */ -export function isHookMessage(msg: AppMessage | Message): msg is HookMessage { +export function isHookMessage(msg: AgentMessage | Message): msg is HookMessage { return (msg as HookMessage).role === "hookMessage"; } @@ -97,13 +97,13 @@ export function bashExecutionToText(msg: BashExecutionMessage): string { // ============================================================================ /** - * Transform AppMessages (including custom types) to LLM-compatible Messages. + * Transform AgentMessages (including custom types) to LLM-compatible Messages. * * This is used by: * - Agent's messageTransformer option (for prompt calls) * - Compaction's generateSummary (for summarization) */ -export function messageTransformer(messages: AppMessage[]): Message[] { +export function messageTransformer(messages: AgentMessage[]): Message[] { return messages .map((m): Message | null => { if (isBashExecutionMessage(m)) { diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index fb1ce775..b8b16f36 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,4 +1,4 @@ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { randomUUID } from "crypto"; import { @@ -36,7 +36,7 @@ export interface SessionEntryBase { export interface SessionMessageEntry extends SessionEntryBase { type: "message"; - message: AppMessage; + message: AgentMessage; } export interface ThinkingLevelChangeEntry extends SessionEntryBase { @@ -130,7 +130,7 @@ export interface SessionTreeNode { } export interface SessionContext { - messages: AppMessage[]; + messages: AgentMessage[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; } @@ -154,7 +154,7 @@ export const SUMMARY_SUFFIX = ` `; /** Exported for compaction.test.ts */ -export function createSummaryMessage(summary: string, timestamp: string): AppMessage { +export function createSummaryMessage(summary: string, timestamp: string): AgentMessage { return { role: "user", content: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX, @@ -162,8 +162,8 @@ export function createSummaryMessage(summary: string, timestamp: string): AppMes }; } -/** Convert CustomMessageEntry to AppMessage format */ -function createCustomMessage(entry: CustomMessageEntry): AppMessage { +/** Convert CustomMessageEntry to AgentMessage format */ +function createCustomMessage(entry: CustomMessageEntry): AgentMessage { return { role: "user", content: entry.content, @@ -323,7 +323,7 @@ export function buildSessionContext( // 1. Emit summary first (entry = compaction) // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction - const messages: AppMessage[] = []; + const messages: AgentMessage[] = []; if (compaction) { // Emit summary first @@ -595,7 +595,7 @@ export class SessionManager { } /** Append a message as child of current leaf, then advance leaf. Returns entry id. */ - appendMessage(message: AppMessage): string { + appendMessage(message: AgentMessage): string { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 97da3727..c1a824e3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core"; +import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -1051,7 +1051,7 @@ export class InteractiveMode { this.ui.requestRender(); } - private addMessageToChat(message: AppMessage): void { + private addMessageToChat(message: AgentMessage): void { if (isBashExecutionMessage(message)) { const component = new BashExecutionComponent(message.command, this.ui); if (message.output) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 53a242e5..5d27c760 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -6,7 +6,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import * as readline from "node:readline"; -import type { AgentEvent, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { AgentEvent, AgentMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; import type { CompactionResult } from "../../core/compaction.js"; @@ -349,9 +349,9 @@ export class RpcClient { /** * Get all messages in the session. */ - async getMessages(): Promise { + async getMessages(): Promise { const response = await this.send({ type: "get_messages" }); - return this.getData<{ messages: AppMessage[] }>(response).messages; + return this.getData<{ messages: AgentMessage[] }>(response).messages; } // ========================================================================= diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 79f9e12a..3eeab9b1 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -5,7 +5,7 @@ * Responses and events are emitted as JSON lines on stdout. */ -import type { AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { AgentMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; @@ -161,7 +161,7 @@ export type RpcResponse = } // Messages - | { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AppMessage[] } } + | { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } } // Error response (any command can fail) | { id?: string; type: "response"; command: string; success: false; error: string }; diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 8b756bb3..84ce3b3c 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -1,4 +1,4 @@ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Usage } from "@mariozechner/pi-ai"; import { getModel } from "@mariozechner/pi-ai"; import { readFileSync } from "fs"; @@ -48,7 +48,7 @@ function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrit }; } -function createUserMessage(text: string): AppMessage { +function createUserMessage(text: string): AgentMessage { return { role: "user", content: text, timestamp: Date.now() }; } @@ -78,7 +78,7 @@ beforeEach(() => { resetEntryCounter(); }); -function createMessageEntry(message: AppMessage): SessionMessageEntry { +function createMessageEntry(message: AgentMessage): SessionMessageEntry { const id = `test-id-${entryCounter++}`; const entry: SessionMessageEntry = { type: "message", diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index 36ae4c1d..11f8a69c 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -10,7 +10,7 @@ * - MomSettingsManager: Simple settings for mom (compaction, retry, model preferences) */ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { buildSessionContext, type CompactionEntry, @@ -153,7 +153,7 @@ export class MomSessionManager { contextSlackTimestamps.add(entry.timestamp); // Also store message text to catch duplicates added via prompt() - // AppMessage has different shapes, check for content property + // AgentMessage has different shapes, check for content property const msg = msgEntry.message as { role: string; content?: unknown }; if (msg.role === "user" && msg.content !== undefined) { const content = msg.content; @@ -189,7 +189,7 @@ export class MomSessionManager { isBot?: boolean; } - const newMessages: Array<{ timestamp: string; slackTs: string; message: AppMessage }> = []; + const newMessages: Array<{ timestamp: string; slackTs: string; message: AgentMessage }> = []; for (const line of logLines) { try { @@ -215,7 +215,7 @@ export class MomSessionManager { if (contextMessageTexts.has(messageText)) continue; const msgTime = new Date(date).getTime() || Date.now(); - const userMessage: AppMessage = { + const userMessage: AgentMessage = { role: "user", content: messageText, timestamp: msgTime, @@ -277,7 +277,7 @@ export class MomSessionManager { return entries; } - saveMessage(message: AppMessage): void { + saveMessage(message: AgentMessage): void { const entry: SessionMessageEntry = { ...this._createEntryBase(), type: "message", message }; this.inMemoryEntries.push(entry); this._persist(entry); From fa22595f25f3ac9ecbddbc0e6a65449231c9c618 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 09:27:51 +0100 Subject: [PATCH 055/161] Update READMEs: remove agent section from pi-ai, rewrite pi-agent-core - Removed Agent API section from pi-ai README (moved to agent package) - Rewrote agent package README for new architecture: - No more transports (ProviderTransport, AppTransport removed) - Uses streamFn directly with streamProxy for proxy usage - Documents convertToLlm and transformContext - Documents low-level agentLoop/agentLoopContinue API - Updated custom message types documentation --- packages/agent/README.md | 167 +++++++++----- packages/ai/README.md | 270 ----------------------- packages/ai/test/tool-validation.test.ts | 109 --------- 3 files changed, 111 insertions(+), 435 deletions(-) delete mode 100644 packages/ai/test/tool-validation.test.ts diff --git a/packages/agent/README.md b/packages/agent/README.md index 2e30bf74..6567a562 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -1,6 +1,6 @@ # @mariozechner/pi-agent-core -Stateful agent abstraction with transport layer for LLM interactions. Provides a reactive `Agent` class that manages conversation state, emits granular events, and supports pluggable transports for different deployment scenarios. +Stateful agent with tool execution, event streaming, and extensible message types. Built on `@mariozechner/pi-ai`. ## Installation @@ -11,12 +11,10 @@ npm install @mariozechner/pi-agent-core ## Quick Start ```typescript -import { Agent, ProviderTransport } from '@mariozechner/pi-agent-core'; +import { Agent } from '@mariozechner/pi-agent-core'; import { getModel } from '@mariozechner/pi-ai'; -// Create agent with direct provider transport const agent = new Agent({ - transport: new ProviderTransport(), initialState: { systemPrompt: 'You are a helpful assistant.', model: getModel('anthropic', 'claude-sonnet-4-20250514'), @@ -29,37 +27,48 @@ const agent = new Agent({ agent.subscribe((event) => { switch (event.type) { case 'message_update': - // Stream text to UI - const content = event.message.content; - for (const block of content) { - if (block.type === 'text') console.log(block.text); + for (const block of event.message.content) { + if (block.type === 'text') process.stdout.write(block.text); } break; case 'tool_execution_start': console.log(`Calling ${event.toolName}...`); break; - case 'tool_execution_update': - // Stream tool output (e.g., bash stdout) - console.log('Progress:', event.partialResult.content); - break; case 'tool_execution_end': console.log(`Result:`, event.result.content); break; } }); -// Send a prompt await agent.prompt('Hello, world!'); - -// Access conversation state console.log(agent.state.messages); ``` -## Core Concepts +## Agent Options -### Agent State +```typescript +interface AgentOptions { + initialState?: Partial; -The `Agent` maintains reactive state: + // Converts AgentMessage[] to LLM-compatible Message[] before each call. + // Default: filters to user/assistant/toolResult and converts attachments. + convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; + + // Transform context before convertToLlm (for pruning, injecting context, etc.) + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + + // Queue mode: 'all' sends all queued messages, 'one-at-a-time' sends one per turn + queueMode?: 'all' | 'one-at-a-time'; + + // Custom stream function (for proxy backends). Default: streamSimple from pi-ai + streamFn?: StreamFn; + + // Dynamic API key resolution (useful for expiring OAuth tokens) + getApiKey?: (provider: string) => Promise | string | undefined; +} +``` + +## Agent State ```typescript interface AgentState { @@ -67,17 +76,17 @@ interface AgentState { model: Model; thinkingLevel: ThinkingLevel; // 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' tools: AgentTool[]; - messages: AppMessage[]; + messages: AgentMessage[]; isStreaming: boolean; - streamMessage: Message | null; + streamMessage: AgentMessage | null; pendingToolCalls: Set; error?: string; } ``` -### Events +## Events -Events provide fine-grained lifecycle information: +Events provide fine-grained lifecycle information for building reactive UIs: | Event | Description | |-------|-------------| @@ -89,30 +98,40 @@ Events provide fine-grained lifecycle information: | `message_update` | Assistant message streaming update | | `message_end` | Message completes | | `tool_execution_start` | Tool begins execution | -| `tool_execution_update` | Tool streams progress (e.g., bash output) | +| `tool_execution_update` | Tool streams progress | | `tool_execution_end` | Tool completes with result | -### Transports +## Custom Message Types -Transports abstract LLM communication: - -- **`ProviderTransport`**: Direct API calls using `@mariozechner/pi-ai` -- **`AppTransport`**: Proxy through a backend server (for browser apps) +Extend `AgentMessage` for app-specific messages via declaration merging: ```typescript -// Direct provider access (Node.js) -const agent = new Agent({ - transport: new ProviderTransport({ - apiKey: process.env.ANTHROPIC_API_KEY - }) -}); +declare module '@mariozechner/pi-agent-core' { + interface CustomMessages { + artifact: { role: 'artifact'; code: string; language: string; timestamp: number }; + notification: { role: 'notification'; text: string; timestamp: number }; + } +} -// Via proxy (browser) +// AgentMessage now includes your custom types +const msg: AgentMessage = { role: 'artifact', code: '...', language: 'typescript', timestamp: Date.now() }; +``` + +Custom messages are stored in state but filtered out by the default `convertToLlm`. Provide your own converter to handle them: + +```typescript const agent = new Agent({ - transport: new AppTransport({ - endpoint: '/api/agent', - headers: { 'Authorization': 'Bearer ...' } - }) + convertToLlm: (messages) => { + return messages + .filter(m => m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult') + .map(m => { + // Convert custom types or pass through + if (m.role === 'artifact') { + return { role: 'user', content: `[Artifact: ${m.language}]\n${m.code}`, timestamp: m.timestamp }; + } + return m; + }); + } }); ``` @@ -121,20 +140,21 @@ const agent = new Agent({ Queue messages to inject at the next turn: ```typescript -// Queue mode: 'all' or 'one-at-a-time' agent.setQueueMode('one-at-a-time'); -// Queue a message while agent is streaming -await agent.queueMessage({ +// Queue while agent is streaming +agent.queueMessage({ role: 'user', - content: 'Additional context...', + content: 'Stop what you are doing and focus on this instead.', timestamp: Date.now() }); ``` +When queued messages are detected after a tool call, remaining tool calls are skipped with error results. + ## Attachments -User messages can include attachments: +User messages can include attachments (images, documents): ```typescript await agent.prompt('What is in this image?', [{ @@ -143,23 +163,57 @@ await agent.prompt('What is in this image?', [{ fileName: 'photo.jpg', mimeType: 'image/jpeg', size: 102400, - content: base64ImageData + content: base64ImageData // base64 without data URL prefix }]); ``` -## Custom Message Types +## Proxy Usage -Extend `AppMessage` for app-specific messages via declaration merging: +For browser apps that need to proxy through a backend, use `streamProxy`: ```typescript -declare module '@mariozechner/pi-agent-core' { - interface CustomMessages { - artifact: { role: 'artifact'; code: string; language: string }; - } +import { Agent, streamProxy } from '@mariozechner/pi-agent-core'; + +const agent = new Agent({ + streamFn: (model, context, options) => streamProxy( + '/api/agent', + model, + context, + options, + { 'Authorization': 'Bearer ...' } + ) +}); +``` + +## Low-Level API + +For more control, use `agentLoop` and `agentLoopContinue` directly: + +```typescript +import { agentLoop, agentLoopContinue, AgentLoopContext, AgentLoopConfig } from '@mariozechner/pi-agent-core'; +import { getModel, streamSimple } from '@mariozechner/pi-ai'; + +const context: AgentLoopContext = { + systemPrompt: 'You are helpful.', + messages: [], + tools: [myTool] +}; + +const config: AgentLoopConfig = { + model: getModel('openai', 'gpt-4o-mini'), + convertToLlm: (msgs) => msgs.filter(m => ['user', 'assistant', 'toolResult'].includes(m.role)) +}; + +const userMessage = { role: 'user', content: 'Hello', timestamp: Date.now() }; + +for await (const event of agentLoop(userMessage, context, config, undefined, streamSimple)) { + console.log(event.type); } -// Now AppMessage includes your custom type -const msg: AppMessage = { role: 'artifact', code: '...', language: 'typescript' }; +// Continue from existing context (e.g., after overflow recovery) +for await (const event of agentLoopContinue(context, config, undefined, streamSimple)) { + console.log(event.type); +} ``` ## API Reference @@ -169,9 +223,10 @@ const msg: AppMessage = { role: 'artifact', code: '...', language: 'typescript' | Method | Description | |--------|-------------| | `prompt(text, attachments?)` | Send a user prompt | -| `continue()` | Continue from current context (for retry after overflow) | +| `prompt(message)` | Send an AgentMessage directly | +| `continue()` | Continue from current context | | `abort()` | Abort current operation | -| `waitForIdle()` | Returns promise that resolves when agent is idle | +| `waitForIdle()` | Promise that resolves when agent is idle | | `reset()` | Clear all messages and state | | `subscribe(fn)` | Subscribe to events, returns unsubscribe function | | `queueMessage(msg)` | Queue message for next turn | @@ -184,7 +239,7 @@ const msg: AppMessage = { role: 'artifact', code: '...', language: 'typescript' | `setSystemPrompt(v)` | Update system prompt | | `setModel(m)` | Switch model | | `setThinkingLevel(l)` | Set reasoning level | -| `setQueueMode(m)` | Set queue mode ('all' or 'one-at-a-time') | +| `setQueueMode(m)` | Set queue mode | | `setTools(t)` | Update available tools | | `replaceMessages(ms)` | Replace all messages | | `appendMessage(m)` | Append a message | diff --git a/packages/ai/README.md b/packages/ai/README.md index 9b03f402..89cdcfc4 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -782,276 +782,6 @@ const continuation = await complete(newModel, restored); > **Note**: If the context contains images (encoded as base64 as shown in the Image Input section), those will also be serialized. -## Agent API - -The Agent API provides a higher-level interface for building agents with tools. It handles tool execution, validation, and provides detailed event streaming for interactive applications. - -### Event System - -The Agent API streams events during execution, allowing you to build reactive UIs and track agent progress. The agent processes prompts in **turns**, where each turn consists of: -1. An assistant message (the LLM's response) -2. Optional tool executions if the assistant calls tools -3. Tool result messages that are fed back to the LLM - -This continues until the assistant produces a response without tool calls. - -**Queued messages**: If you provide `getQueuedMessages` in the loop config, the agent checks for queued user messages after each tool call. When queued messages are found, any remaining tool calls from the current assistant message are skipped and returned as error tool results (`isError: true`) with the message "Skipped due to queued user message." The queued user messages are injected before the next assistant response. - -### Event Flow Example - -Given a prompt asking to calculate two expressions and sum them: - -```typescript -import { agentLoop, AgentContext, calculateTool } from '@mariozechner/pi-ai'; - -const context: AgentContext = { - systemPrompt: 'You are a helpful math assistant.', - messages: [], - tools: [calculateTool] -}; - -const stream = agentLoop( - { role: 'user', content: 'Calculate 15 * 20 and 30 * 40, then sum the results', timestamp: Date.now() }, - context, - { model: getModel('openai', 'gpt-4o-mini') } -); - -// Expected event sequence: -// 1. agent_start - Agent begins processing -// 2. turn_start - First turn begins -// 3. message_start - User message starts -// 4. message_end - User message ends -// 5. message_start - Assistant message starts -// 6. message_update - Assistant streams response with tool calls -// 7. message_end - Assistant message ends -// 8. tool_execution_start - First calculation (15 * 20) -// 9. tool_execution_update - Streaming progress (for long-running tools) -// 10. tool_execution_end - Result: 300 -// 11. tool_execution_start - Second calculation (30 * 40) -// 12. tool_execution_update - Streaming progress -// 13. tool_execution_end - Result: 1200 -// 12. message_start - Tool result message for first calculation -// 13. message_end - Tool result message ends -// 14. message_start - Tool result message for second calculation -// 15. message_end - Tool result message ends -// 16. turn_end - First turn ends with 2 tool results -// 17. turn_start - Second turn begins -// 18. message_start - Assistant message starts -// 19. message_update - Assistant streams response with sum calculation -// 20. message_end - Assistant message ends -// 21. tool_execution_start - Sum calculation (300 + 1200) -// 22. tool_execution_end - Result: 1500 -// 23. message_start - Tool result message for sum -// 24. message_end - Tool result message ends -// 25. turn_end - Second turn ends with 1 tool result -// 26. turn_start - Third turn begins -// 27. message_start - Final assistant message starts -// 28. message_update - Assistant streams final answer -// 29. message_end - Final assistant message ends -// 30. turn_end - Third turn ends with 0 tool results -// 31. agent_end - Agent completes with all messages -``` - -### Handling Events - -```typescript -for await (const event of stream) { - switch (event.type) { - case 'agent_start': - console.log('Agent started'); - break; - - case 'turn_start': - console.log('New turn started'); - break; - - case 'message_start': - console.log(`${event.message.role} message started`); - break; - - case 'message_update': - // Only for assistant messages during streaming - if (event.message.content.some(c => c.type === 'text')) { - console.log('Assistant:', event.message.content); - } - break; - - case 'tool_execution_start': - console.log(`Calling ${event.toolName} with:`, event.args); - break; - - case 'tool_execution_update': - // Streaming progress for long-running tools (e.g., bash output) - console.log(`Progress:`, event.partialResult.content); - break; - - case 'tool_execution_end': - if (event.isError) { - console.error(`Tool failed:`, event.result); - } else { - console.log(`Tool result:`, event.result.content); - } - break; - - case 'turn_end': - console.log(`Turn ended with ${event.toolResults.length} tool calls`); - break; - - case 'agent_end': - console.log(`Agent completed with ${event.messages.length} new messages`); - break; - } -} - -// Get all messages generated during this agent execution -// These include the user message and can be directly appended to context.messages -const messages = await stream.result(); -context.messages.push(...messages); -``` - -### Continuing from Existing Context - -Use `agentLoopContinue` to resume an agent loop without adding a new user message. This is useful for: -- Retrying after context overflow (after compaction reduces context size) -- Resuming from tool results that were added manually to the context - -```typescript -import { agentLoopContinue, AgentContext } from '@mariozechner/pi-ai'; - -// Context already has messages - last must be 'user' or 'toolResult' -const context: AgentContext = { - systemPrompt: 'You are helpful.', - messages: [userMessage, assistantMessage, toolResult], - tools: [myTool] -}; - -// Continue processing from the tool result -const stream = agentLoopContinue(context, { model }); - -for await (const event of stream) { - // Same events as agentLoop, but no user message events emitted -} - -const newMessages = await stream.result(); -``` - -**Validation**: Throws if context has no messages or if the last message is an assistant message. - -### Defining Tools with TypeBox - -Tools use TypeBox schemas for runtime validation and type inference: - -```typescript -import { Type, Static, AgentTool, AgentToolResult, StringEnum } from '@mariozechner/pi-ai'; - -const weatherSchema = Type.Object({ - city: Type.String({ minLength: 1 }), - units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' }) -}); - -type WeatherParams = Static; - -const weatherTool: AgentTool = { - label: 'Get Weather', - name: 'get_weather', - description: 'Get current weather for a city', - parameters: weatherSchema, - execute: async (toolCallId, args, signal, onUpdate) => { - // args is fully typed: { city: string, units: 'celsius' | 'fahrenheit' } - // signal: AbortSignal for cancellation - // onUpdate: Optional callback for streaming progress (emits tool_execution_update events) - const temp = Math.round(Math.random() * 30); - return { - content: [{ type: 'text', text: `Temperature in ${args.city}: ${temp}°${args.units[0].toUpperCase()}` }], - details: { temp } - }; - } -}; - -// Tools can also return images alongside text -const chartTool: AgentTool = { - label: 'Generate Chart', - name: 'generate_chart', - description: 'Generate a chart from data', - parameters: Type.Object({ data: Type.Array(Type.Number()) }), - execute: async (toolCallId, args) => { - const chartImage = await generateChartImage(args.data); - return { - content: [ - { type: 'text', text: `Generated chart with ${args.data.length} data points` }, - { type: 'image', data: chartImage.toString('base64'), mimeType: 'image/png' } - ] - }; - } -}; - -// Tools can stream progress via the onUpdate callback (emits tool_execution_update events) -const bashTool: AgentTool = { - label: 'Run Bash', - name: 'bash', - description: 'Execute a bash command', - parameters: Type.Object({ command: Type.String() }), - execute: async (toolCallId, args, signal, onUpdate) => { - let output = ''; - const child = spawn('bash', ['-c', args.command]); - - child.stdout.on('data', (data) => { - output += data.toString(); - // Stream partial output to UI via tool_execution_update events - onUpdate?.({ - content: [{ type: 'text', text: output }], - details: { exitCode: -1 } // Not finished yet - }); - }); - - const exitCode = await new Promise((resolve) => { - child.on('close', resolve); - }); - - return { - content: [{ type: 'text', text: output }], - details: { exitCode } - }; - } -}; -``` - -### Validation and Error Handling - -Tool arguments are automatically validated using AJV with the TypeBox schema. Invalid arguments result in detailed error messages: - -```typescript -// If the LLM calls with invalid arguments: -// get_weather({ city: '', units: 'kelvin' }) - -// The tool execution will fail with: -/* -Validation failed for tool "get_weather": - - city: must NOT have fewer than 1 characters - - units: must be equal to one of the allowed values - -Received arguments: -{ - "city": "", - "units": "kelvin" -} -*/ -``` - -### Built-in Example Tools - -The library includes example tools for common operations: - -```typescript -import { calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai'; - -const context: AgentContext = { - systemPrompt: 'You are a helpful assistant.', - messages: [], - tools: [calculateTool, getCurrentTimeTool] -}; -``` - ## Browser Usage The library supports browser environments. You must pass the API key explicitly since environment variables are not available in browsers: diff --git a/packages/ai/test/tool-validation.test.ts b/packages/ai/test/tool-validation.test.ts deleted file mode 100644 index a2315ab5..00000000 --- a/packages/ai/test/tool-validation.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import AjvModule from "ajv"; -import addFormatsModule from "ajv-formats"; - -// Handle both default and named exports -const Ajv = (AjvModule as any).default || AjvModule; -const addFormats = (addFormatsModule as any).default || addFormatsModule; - -import { describe, expect, it } from "vitest"; -import type { Tool } from "../src/types.js"; - -describe("Tool Validation with TypeBox and AJV", () => { - // Define a test tool with TypeBox schema - const testSchema = Type.Object({ - name: Type.String({ minLength: 1 }), - age: Type.Integer({ minimum: 0, maximum: 150 }), - email: Type.String({ format: "email" }), - tags: Type.Optional(Type.Array(Type.String())), - }); - - const testTool = { - name: "test_tool", - description: "A test tool for validation", - parameters: testSchema, - } satisfies Tool; - - // Create AJV instance for validation - const ajv = new Ajv({ allErrors: true }); - addFormats(ajv); - - it("should validate correct input", () => { - const validInput = { - name: "John Doe", - age: 30, - email: "john@example.com", - tags: ["developer", "typescript"], - }; - - // Validate with AJV - const validate = ajv.compile(testTool.parameters); - const isValid = validate(validInput); - expect(isValid).toBe(true); - }); - - it("should reject invalid email", () => { - const invalidInput = { - name: "John Doe", - age: 30, - email: "not-an-email", - }; - - const validate = ajv.compile(testTool.parameters); - const isValid = validate(invalidInput); - expect(isValid).toBe(false); - expect(validate.errors).toBeDefined(); - }); - - it("should reject missing required fields", () => { - const invalidInput = { - age: 30, - email: "john@example.com", - }; - - const validate = ajv.compile(testTool.parameters); - const isValid = validate(invalidInput); - expect(isValid).toBe(false); - expect(validate.errors).toBeDefined(); - }); - - it("should reject invalid age", () => { - const invalidInput = { - name: "John Doe", - age: -5, - email: "john@example.com", - }; - - const validate = ajv.compile(testTool.parameters); - const isValid = validate(invalidInput); - expect(isValid).toBe(false); - expect(validate.errors).toBeDefined(); - }); - - it("should format validation errors nicely", () => { - const invalidInput = { - name: "", - age: 200, - email: "invalid", - }; - - const validate = ajv.compile(testTool.parameters); - const isValid = validate(invalidInput); - expect(isValid).toBe(false); - expect(validate.errors).toBeDefined(); - - if (validate.errors) { - const errors = validate.errors - .map((err: any) => { - const path = err.instancePath ? err.instancePath.substring(1) : err.params.missingProperty || "root"; - return ` - ${path}: ${err.message}`; - }) - .join("\n"); - - // AJV error messages are different from Zod - expect(errors).toContain("name: must NOT have fewer than 1 characters"); - expect(errors).toContain("age: must be <= 150"); - expect(errors).toContain('email: must match format "email"'); - } - }); -}); From 0f50982e0a02e69f3ef38ce8aa7a9f159b4291c9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 09:28:16 +0100 Subject: [PATCH 056/161] Add CHANGELOG entry for agent API move to pi-agent-core --- packages/ai/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 69c4bee9..9e4af665 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- **Agent API moved**: All agent functionality (`agentLoop`, `agentLoopContinue`, `AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, etc.) has moved to `@mariozechner/pi-agent-core`. See the [agent-core README](../agent/README.md) for documentation. + ## [0.28.0] - 2025-12-25 ### Breaking Changes From 256d9a26bb68249410bf4d77ea2e564bebde7049 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 09:29:34 +0100 Subject: [PATCH 057/161] Add CHANGELOG.md to agent package with refactor details --- packages/agent/CHANGELOG.md | 69 +++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/agent/CHANGELOG.md diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md new file mode 100644 index 00000000..1e94f69c --- /dev/null +++ b/packages/agent/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +## [Unreleased] + +### Breaking Changes + +- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, and `AgentTransport` interface have been removed. The `Agent` class now takes a `streamFn` option directly for custom streaming implementations. + +- **Agent options renamed**: + - `transport` → removed (use `streamFn` instead) + - `messageTransformer` → `convertToLlm` (converts `AgentMessage[]` to LLM-compatible `Message[]`) + - `preprocessor` → `transformContext` (transforms `AgentMessage[]` before `convertToLlm`) + +- **AppMessage renamed to AgentMessage**: All references to `AppMessage` have been renamed to `AgentMessage` for consistency. + +- **Agent loop moved from pi-ai**: The `agentLoop`, `agentLoopContinue`, and related types (`AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, `AgentToolUpdateCallback`, `AgentLoopConfig`) have moved from `@mariozechner/pi-ai` to this package. + +### Added + +- **`streamFn` option**: Pass a custom stream function to the Agent for proxy backends or custom implementations. Default uses `streamSimple` from pi-ai. + +- **`streamProxy` utility**: New helper function for browser apps that need to proxy through a backend server. Replaces `AppTransport`. + +- **`getApiKey` option**: Dynamic API key resolution for expiring OAuth tokens (e.g., GitHub Copilot). + +- **`AgentLoopContext` and `AgentLoopConfig`**: Exported types for the low-level agent loop API. + +- **`agentLoop` and `agentLoopContinue`**: Low-level functions for running the agent loop directly without the `Agent` class wrapper. + +### Migration Guide + +**Before (0.30.x):** +```typescript +import { Agent, ProviderTransport } from '@mariozechner/pi-agent-core'; + +const agent = new Agent({ + transport: new ProviderTransport({ apiKey: '...' }), + messageTransformer: (messages) => messages.filter(...), + preprocessor: async (messages) => compactMessages(messages) +}); +``` + +**After:** +```typescript +import { Agent } from '@mariozechner/pi-agent-core'; +import { streamSimple } from '@mariozechner/pi-ai'; + +const agent = new Agent({ + streamFn: streamSimple, // or omit for default + convertToLlm: (messages) => messages.filter(...), + transformContext: async (messages) => compactMessages(messages), + getApiKey: async (provider) => resolveApiKey(provider) +}); +``` + +**For proxy usage (replaces AppTransport):** +```typescript +import { Agent, streamProxy } from '@mariozechner/pi-agent-core'; + +const agent = new Agent({ + streamFn: (model, context, options) => streamProxy( + '/api/agent', + model, + context, + options, + { 'Authorization': 'Bearer ...' } + ) +}); +``` From b6d9442916ba681144090031bcd13802fa3851d6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 09:31:41 +0100 Subject: [PATCH 058/161] Fix README: prompt takes ImageContent[], not Attachment[] --- packages/agent/README.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 6567a562..ef545216 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -152,19 +152,14 @@ agent.queueMessage({ When queued messages are detected after a tool call, remaining tool calls are skipped with error results. -## Attachments +## Images -User messages can include attachments (images, documents): +User messages can include images: ```typescript -await agent.prompt('What is in this image?', [{ - id: 'img1', - type: 'image', - fileName: 'photo.jpg', - mimeType: 'image/jpeg', - size: 102400, - content: base64ImageData // base64 without data URL prefix -}]); +await agent.prompt('What is in this image?', [ + { type: 'image', data: base64ImageData, mimeType: 'image/jpeg' } +]); ``` ## Proxy Usage From f86dea2e4fe0d634dac473fe5607ed6a83f4c002 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 09:37:34 +0100 Subject: [PATCH 059/161] Expand README with AgentMessage/LLM conversion, event semantics, partial messages - Explain AgentMessage vs LLM Message separation and why it's needed - Document conversion flow: AgentMessage -> transformContext -> convertToLlm -> LLM - Clarify that prompt/queueMessage/continue must convert to user or toolResult - Document message_start/end events for prompt() and queued messages - Explain message_update is assistant-only with partial content - Add pattern for handling partial messages in reactive UIs - Document agent.state.streamMessage for accessing current partial --- packages/agent/README.md | 160 +++++++++++++++++++++++++++++++++++---- 1 file changed, 146 insertions(+), 14 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index ef545216..ab3daeb9 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -26,11 +26,19 @@ const agent = new Agent({ // Subscribe to events for reactive UI updates agent.subscribe((event) => { switch (event.type) { + case 'message_start': + console.log(`${event.message.role} message started`); + break; case 'message_update': + // Only emitted for assistant messages during streaming + // event.message is partial - may have incomplete content for (const block of event.message.content) { if (block.type === 'text') process.stdout.write(block.text); } break; + case 'message_end': + console.log(`${event.message.role} message complete`); + break; case 'tool_execution_start': console.log(`Calling ${event.toolName}...`); break; @@ -44,17 +52,66 @@ await agent.prompt('Hello, world!'); console.log(agent.state.messages); ``` +## AgentMessage vs LLM Message + +The agent internally works with `AgentMessage`, a flexible type that can include: +- Standard LLM messages (`user`, `assistant`, `toolResult`) +- User messages with attachments +- Custom app-specific message types (via declaration merging) + +LLMs only understand a subset: `user`, `assistant`, and `toolResult` messages with specific content formats. The `convertToLlm` function bridges this gap. + +### Why This Separation? + +1. **Rich UI state**: Store UI-specific data (attachments metadata, custom message types) alongside the conversation +2. **Session persistence**: Save the full conversation state including app-specific messages +3. **Context manipulation**: Transform messages before sending to LLM (compaction, injection, filtering) + +### The Conversion Flow + +``` +AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM + ↑ (optional) (required) + | + App state with custom types, + attachments, UI metadata +``` + +### Constraints + +**Messages passed to `prompt()` or queued via `queueMessage()` must convert to LLM messages with `role: "user"` or `role: "toolResult"`.** + +When calling `continue()`, the last message in the context must also convert to `user` or `toolResult`. The LLM expects to respond to a user or tool result, not to its own assistant message. + +```typescript +// OK: Standard user message +await agent.prompt('Hello'); + +// OK: Custom type that converts to user message +await agent.prompt({ role: 'hookMessage', content: 'System notification', timestamp: Date.now() }); +// But convertToLlm must handle this: +convertToLlm: (messages) => messages.map(m => { + if (m.role === 'hookMessage') { + return { role: 'user', content: m.content, timestamp: m.timestamp }; + } + return m; +}) + +// ERROR: Cannot prompt with assistant message +await agent.prompt({ role: 'assistant', content: [...], ... }); // Will fail at LLM +``` + ## Agent Options ```typescript interface AgentOptions { initialState?: Partial; - // Converts AgentMessage[] to LLM-compatible Message[] before each call. - // Default: filters to user/assistant/toolResult and converts attachments. + // Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. + // Default: filters to user/assistant/toolResult and converts image attachments. convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; - // Transform context before convertToLlm (for pruning, injecting context, etc.) + // Transform context before convertToLlm (for pruning, compaction, injecting context) transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; // Queue mode: 'all' sends all queued messages, 'one-at-a-time' sends one per turn @@ -76,9 +133,9 @@ interface AgentState { model: Model; thinkingLevel: ThinkingLevel; // 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' tools: AgentTool[]; - messages: AgentMessage[]; + messages: AgentMessage[]; // Full conversation including custom types isStreaming: boolean; - streamMessage: AgentMessage | null; + streamMessage: AgentMessage | null; // Current partial message during streaming pendingToolCalls: Set; error?: string; } @@ -86,7 +143,9 @@ interface AgentState { ## Events -Events provide fine-grained lifecycle information for building reactive UIs: +Events provide fine-grained lifecycle information for building reactive UIs. + +### Event Types | Event | Description | |-------|-------------| @@ -95,12 +154,84 @@ Events provide fine-grained lifecycle information for building reactive UIs: | `turn_start` | New turn begins (one LLM response + tool executions) | | `turn_end` | Turn completes with assistant message and tool results | | `message_start` | Message begins (user, assistant, or toolResult) | -| `message_update` | Assistant message streaming update | +| `message_update` | **Assistant messages only.** Partial message during streaming | | `message_end` | Message completes | | `tool_execution_start` | Tool begins execution | | `tool_execution_update` | Tool streams progress | | `tool_execution_end` | Tool completes with result | +### Message Events for prompt() and queueMessage() + +When you call `prompt(message)`, the agent emits `message_start` and `message_end` events for that message before the assistant responds: + +``` +prompt(userMessage) + → message_start { message: userMessage } + → message_end { message: userMessage } + → message_start { message: assistantMessage } // LLM starts responding + → message_update { message: partialAssistant } // streaming... + → message_end { message: assistantMessage } +``` + +Queued messages (via `queueMessage()`) emit the same events when injected: + +``` +// During tool execution, a message is queued +agent.queueMessage(interruptMessage) + +// After tool completes, before next LLM call: + → message_start { message: interruptMessage } + → message_end { message: interruptMessage } + → message_start { message: assistantMessage } // LLM responds to interrupt + ... +``` + +### Handling Partial Messages in Reactive UIs + +`message_update` events contain partial assistant messages during streaming. The `event.message` may have: +- Incomplete text (truncated mid-word) +- Partial tool call arguments +- Missing content blocks that haven't started streaming yet + +**Pattern for reactive UIs:** + +```typescript +agent.subscribe((event) => { + switch (event.type) { + case 'message_start': + if (event.message.role === 'assistant') { + // Create placeholder in UI + ui.addMessage({ id: tempId, role: 'assistant', content: [] }); + } + break; + + case 'message_update': + // Replace placeholder content with partial content + // This is only emitted for assistant messages + ui.updateMessage(tempId, event.message.content); + break; + + case 'message_end': + if (event.message.role === 'assistant') { + // Finalize with complete message + ui.finalizeMessage(tempId, event.message); + } + break; + } +}); +``` + +**Accessing the current partial message:** + +During streaming, `agent.state.streamMessage` contains the current partial message. This is useful for rendering outside the event handler: + +```typescript +// In a render loop or reactive binding +if (agent.state.isStreaming && agent.state.streamMessage) { + renderPartialMessage(agent.state.streamMessage); +} +``` + ## Custom Message Types Extend `AgentMessage` for app-specific messages via declaration merging: @@ -123,10 +254,10 @@ Custom messages are stored in state but filtered out by the default `convertToLl const agent = new Agent({ convertToLlm: (messages) => { return messages - .filter(m => m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult') + .filter(m => m.role !== 'notification') // Filter out UI-only messages .map(m => { - // Convert custom types or pass through if (m.role === 'artifact') { + // Convert to user message so LLM sees the artifact return { role: 'user', content: `[Artifact: ${m.language}]\n${m.code}`, timestamp: m.timestamp }; } return m; @@ -150,7 +281,7 @@ agent.queueMessage({ }); ``` -When queued messages are detected after a tool call, remaining tool calls are skipped with error results. +When queued messages are detected after a tool call, remaining tool calls are skipped with error results ("Skipped due to queued user message"). The queued message is then injected before the next assistant response. ## Images @@ -206,6 +337,7 @@ for await (const event of agentLoop(userMessage, context, config, undefined, str } // Continue from existing context (e.g., after overflow recovery) +// Last message in context must convert to 'user' or 'toolResult' for await (const event of agentLoopContinue(context, config, undefined, streamSimple)) { console.log(event.type); } @@ -217,14 +349,14 @@ for await (const event of agentLoopContinue(context, config, undefined, streamSi | Method | Description | |--------|-------------| -| `prompt(text, attachments?)` | Send a user prompt | -| `prompt(message)` | Send an AgentMessage directly | -| `continue()` | Continue from current context | +| `prompt(text, images?)` | Send a user prompt with optional images | +| `prompt(message)` | Send an AgentMessage directly (must convert to user/toolResult) | +| `continue()` | Continue from current context (last message must convert to user/toolResult) | | `abort()` | Abort current operation | | `waitForIdle()` | Promise that resolves when agent is idle | | `reset()` | Clear all messages and state | | `subscribe(fn)` | Subscribe to events, returns unsubscribe function | -| `queueMessage(msg)` | Queue message for next turn | +| `queueMessage(msg)` | Queue message for next turn (must convert to user/toolResult) | | `clearMessageQueue()` | Clear queued messages | ### State Mutators From 6ddc7418daabbc3ca8f4a0eeb90d543591821768 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 10:55:12 +0100 Subject: [PATCH 060/161] WIP: Major cleanup - move Attachment to consumers, simplify agent API - Removed Attachment from agent package (now in web-ui/coding-agent) - Agent.prompt now takes (text, images?: ImageContent[]) - Removed transports from web-ui (duplicate of agent package) - Updated coding-agent to use local message types - Updated mom package for new agent API Remaining: Fix AgentInterface.ts to compose UserMessageWithAttachments --- packages/agent/README.md | 6 +- packages/agent/src/types.ts | 2 +- .../examples/custom-tools/subagent/index.ts | 3 +- .../examples/hooks/custom-compaction.ts | 4 +- .../coding-agent/src/cli/file-processor.ts | 25 +- .../coding-agent/src/core/agent-session.ts | 17 +- packages/coding-agent/src/core/compaction.ts | 6 +- .../src/core/custom-tools/types.ts | 2 +- .../coding-agent/src/core/hooks/runner.ts | 4 +- .../src/core/hooks/tool-wrapper.ts | 2 +- packages/coding-agent/src/core/hooks/types.ts | 3 +- packages/coding-agent/src/core/messages.ts | 37 +- packages/coding-agent/src/core/sdk.ts | 32 +- packages/coding-agent/src/core/tools/bash.ts | 2 +- packages/coding-agent/src/core/tools/edit.ts | 2 +- packages/coding-agent/src/core/tools/find.ts | 2 +- packages/coding-agent/src/core/tools/grep.ts | 2 +- packages/coding-agent/src/core/tools/index.ts | 3 +- packages/coding-agent/src/core/tools/ls.ts | 2 +- packages/coding-agent/src/core/tools/read.ts | 3 +- packages/coding-agent/src/core/tools/write.ts | 2 +- packages/coding-agent/src/index.ts | 2 +- packages/coding-agent/src/main.ts | 23 +- packages/coding-agent/src/modes/print-mode.ts | 9 +- .../coding-agent/src/modes/rpc/rpc-client.ts | 11 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 6 +- .../test/agent-session-branching.test.ts | 9 +- .../test/agent-session-compaction.test.ts | 9 +- .../test/compaction-hooks.test.ts | 9 +- packages/mom/src/agent.ts | 18 +- packages/mom/src/tools/attach.ts | 2 +- packages/mom/src/tools/bash.ts | 2 +- packages/mom/src/tools/edit.ts | 2 +- packages/mom/src/tools/index.ts | 2 +- packages/mom/src/tools/read.ts | 3 +- packages/mom/src/tools/write.ts | 2 +- .../web-ui/example/src/custom-messages.ts | 6 +- packages/web-ui/src/ChatPanel.ts | 5 +- packages/web-ui/src/agent/agent.ts | 341 ---------------- .../src/agent/transports/AppTransport.ts | 371 ------------------ .../src/agent/transports/ProviderTransport.ts | 71 ---- packages/web-ui/src/agent/transports/index.ts | 3 - .../src/agent/transports/proxy-types.ts | 15 - packages/web-ui/src/agent/transports/types.ts | 26 -- packages/web-ui/src/agent/types.ts | 11 - .../web-ui/src/components/AgentInterface.ts | 13 +- packages/web-ui/src/components/MessageList.ts | 5 +- packages/web-ui/src/components/Messages.ts | 37 +- .../components/StreamingMessageContainer.ts | 9 +- .../components/message-renderer-registry.ts | 10 +- packages/web-ui/src/index.ts | 12 +- .../src/storage/stores/sessions-store.ts | 2 +- packages/web-ui/src/storage/types.ts | 5 +- .../web-ui/src/tools/artifacts/artifacts.ts | 8 +- packages/web-ui/src/tools/extract-document.ts | 3 +- packages/web-ui/src/tools/javascript-repl.ts | 3 +- 57 files changed, 167 insertions(+), 1061 deletions(-) delete mode 100644 packages/web-ui/src/agent/agent.ts delete mode 100644 packages/web-ui/src/agent/transports/AppTransport.ts delete mode 100644 packages/web-ui/src/agent/transports/ProviderTransport.ts delete mode 100644 packages/web-ui/src/agent/transports/index.ts delete mode 100644 packages/web-ui/src/agent/transports/proxy-types.ts delete mode 100644 packages/web-ui/src/agent/transports/types.ts delete mode 100644 packages/web-ui/src/agent/types.ts diff --git a/packages/agent/README.md b/packages/agent/README.md index ab3daeb9..3ac6783f 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -56,7 +56,6 @@ console.log(agent.state.messages); The agent internally works with `AgentMessage`, a flexible type that can include: - Standard LLM messages (`user`, `assistant`, `toolResult`) -- User messages with attachments - Custom app-specific message types (via declaration merging) LLMs only understand a subset: `user`, `assistant`, and `toolResult` messages with specific content formats. The `convertToLlm` function bridges this gap. @@ -166,11 +165,14 @@ When you call `prompt(message)`, the agent emits `message_start` and `message_en ``` prompt(userMessage) + → agent_start + → turn_start → message_start { message: userMessage } → message_end { message: userMessage } → message_start { message: assistantMessage } // LLM starts responding → message_update { message: partialAssistant } // streaming... → message_end { message: assistantMessage } + ... ``` Queued messages (via `queueMessage()`) emit the same events when injected: @@ -238,7 +240,7 @@ Extend `AgentMessage` for app-specific messages via declaration merging: ```typescript declare module '@mariozechner/pi-agent-core' { - interface CustomMessages { + interface CustomAgentMessages { artifact: { role: 'artifact'; code: string; language: string; timestamp: number }; notification: { role: 'notification'; text: string; timestamp: number }; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 9cd8d11e..1c9c7f2d 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -151,7 +151,7 @@ export interface AgentTool[]; } diff --git a/packages/coding-agent/examples/custom-tools/subagent/index.ts b/packages/coding-agent/examples/custom-tools/subagent/index.ts index 406ee4ae..67a2d526 100644 --- a/packages/coding-agent/examples/custom-tools/subagent/index.ts +++ b/packages/coding-agent/examples/custom-tools/subagent/index.ts @@ -16,7 +16,8 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentToolResult, Message } from "@mariozechner/pi-ai"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { Message } from "@mariozechner/pi-ai"; import { StringEnum } from "@mariozechner/pi-ai"; import { type CustomAgentTool, diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index f2794060..559cd682 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -14,7 +14,7 @@ */ import { complete, getModel } from "@mariozechner/pi-ai"; -import { messageTransformer } from "@mariozechner/pi-coding-agent"; +import { convertToLlm } from "@mariozechner/pi-coding-agent"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { @@ -52,7 +52,7 @@ export default function (pi: HookAPI) { ); // Transform app messages to pi-ai package format - const transformedMessages = messageTransformer(allMessages); + const transformedMessages = convertToLlm(allMessages); // Include previous summary context if available const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : ""; diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 3afce9c7..7f82d796 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -3,21 +3,21 @@ */ import { access, readFile, stat } from "node:fs/promises"; -import type { Attachment } from "@mariozechner/pi-agent-core"; +import type { ImageContent } from "@mariozechner/pi-ai"; import chalk from "chalk"; import { resolve } from "path"; import { resolveReadPath } from "../core/tools/path-utils.js"; import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js"; export interface ProcessedFiles { - textContent: string; - imageAttachments: Attachment[]; + text: string; + images: ImageContent[]; } /** Process @file arguments into text content and image attachments */ export async function processFileArguments(fileArgs: string[]): Promise { - let textContent = ""; - const imageAttachments: Attachment[] = []; + let text = ""; + const images: ImageContent[] = []; for (const fileArg of fileArgs) { // Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces) @@ -45,24 +45,21 @@ export async function processFileArguments(fileArgs: string[]): Promise\n`; + text += `\n`; } else { // Handle text file try { const content = await readFile(absolutePath, "utf-8"); - textContent += `\n${content}\n\n`; + text += `\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`)); @@ -71,5 +68,5 @@ export async function processFileArguments(fileArgs: string[]): Promise { const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix - const transformedMessages = messageTransformer(messages); + const transformedMessages = convertToLlm(messages); const summarizationMessages = [ ...transformedMessages, { diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index 43713a61..fa4446a7 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -5,7 +5,7 @@ * They can provide custom rendering for tool calls and results in the TUI. */ -import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-ai"; +import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; import type { Component } from "@mariozechner/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme.js"; diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index df1bd087..ced7e994 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -2,7 +2,7 @@ * Hook runner - executes hooks and manages their lifecycle. */ -import type { Message } from "@mariozechner/pi-ai"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; @@ -315,7 +315,7 @@ export class HookRunner { * * Note: Messages are already deep-copied by the caller (pi-ai preprocessor). */ - async emitContext(messages: Message[]): Promise { + async emitContext(messages: AgentMessage[]): Promise { const ctx = this.createContext(); let currentMessages = messages; diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts index b9e518d2..c3499d9f 100644 --- a/packages/coding-agent/src/core/hooks/tool-wrapper.ts +++ b/packages/coding-agent/src/core/hooks/tool-wrapper.ts @@ -2,7 +2,7 @@ * Tool wrapper - wraps tools with hook callbacks for interception. */ -import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-ai"; +import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; import type { HookRunner } from "./runner.js"; import type { ToolCallEventResult, ToolResultEventResult } from "./types.js"; diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index d7424dba..05eba8e8 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -151,12 +151,11 @@ export type SessionEvent = * Event data for context event. * Fired before each LLM call, allowing hooks to modify context non-destructively. * Original session messages are NOT modified - only the messages sent to the LLM are affected. - * Messages are already in LLM format (Message[], not AgentMessage[]). */ export interface ContextEvent { type: "context"; /** Messages about to be sent to the LLM (deep copy, safe to modify) */ - messages: Message[]; + messages: AgentMessage[]; } /** diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 678dbac5..74451907 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -6,11 +6,7 @@ */ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { Message } from "@mariozechner/pi-ai"; - -// ============================================================================ -// Custom Message Types -// ============================================================================ +import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai"; /** * Message type for bash executions via the ! command. @@ -26,8 +22,6 @@ export interface BashExecutionMessage { timestamp: number; } -import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; - /** * Message type for hook-injected messages via sendMessage(). * These are custom messages that hooks can inject into the conversation. @@ -41,36 +35,28 @@ export interface HookMessage { timestamp: number; } -// Extend CustomMessages via declaration merging +// Extend CustomAgentMessages via declaration merging declare module "@mariozechner/pi-agent-core" { - interface CustomMessages { + interface CustomAgentMessages { bashExecution: BashExecutionMessage; hookMessage: HookMessage; } } -// ============================================================================ -// Type Guards -// ============================================================================ - /** * Type guard for BashExecutionMessage. */ export function isBashExecutionMessage(msg: AgentMessage | Message): msg is BashExecutionMessage { - return (msg as BashExecutionMessage).role === "bashExecution"; + return msg.role === "bashExecution"; } /** - * Type guard for HookAgentMessage. + * Type guard for HookMessage. */ export function isHookMessage(msg: AgentMessage | Message): msg is HookMessage { - return (msg as HookMessage).role === "hookMessage"; + return msg.role === "hookMessage"; } -// ============================================================================ -// Message Formatting -// ============================================================================ - /** * Convert a BashExecutionMessage to user message text for LLM context. */ @@ -92,18 +78,15 @@ export function bashExecutionToText(msg: BashExecutionMessage): string { return text; } -// ============================================================================ -// Message Transformer -// ============================================================================ - /** * Transform AgentMessages (including custom types) to LLM-compatible Messages. * * This is used by: - * - Agent's messageTransformer option (for prompt calls) + * - Agent's transormToLlm option (for prompt calls and queued messages) * - Compaction's generateSummary (for summarization) + * - Custom hooks and tools */ -export function messageTransformer(messages: AgentMessage[]): Message[] { +export function convertToLlm(messages: AgentMessage[]): Message[] { return messages .map((m): Message | null => { if (isBashExecutionMessage(m)) { @@ -131,5 +114,5 @@ export function messageTransformer(messages: AgentMessage[]): Message[] { // Filter out unknown message types return null; }) - .filter((m): m is Message => m !== null); + .filter((m) => m !== null); } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 30ac53a1..80de1ca6 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -29,7 +29,7 @@ * ``` */ -import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; +import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; import { join } from "path"; import { getAgentDir } from "../config.js"; @@ -39,7 +39,7 @@ import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tool import type { CustomAgentTool } from "./custom-tools/types.js"; import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js"; import type { HookFactory } from "./hooks/types.js"; -import { messageTransformer } from "./messages.js"; +import { convertToLlm } from "./messages.js"; import { ModelRegistry } from "./model-registry.js"; import { SessionManager } from "./session-manager.js"; import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js"; @@ -588,26 +588,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingLevel, tools: allToolsArray, }, - messageTransformer, - preprocessor: hookRunner + convertToLlm, + transformContext: hookRunner ? async (messages) => { return hookRunner.emitContext(messages); } : undefined, queueMode: settingsManager.getQueueMode(), - transport: new ProviderTransport({ - getApiKey: async () => { - const currentModel = agent.state.model; - if (!currentModel) { - throw new Error("No model selected"); - } - const key = await modelRegistry.getApiKey(currentModel); - if (!key) { - throw new Error(`No API key found for provider "${currentModel.provider}"`); - } - return key; - }, - }), + getApiKey: async () => { + const currentModel = agent.state.model; + if (!currentModel) { + throw new Error("No model selected"); + } + const key = await modelRegistry.getApiKey(currentModel); + if (!key) { + throw new Error(`No API key found for provider "${currentModel.provider}"`); + } + return key; + }, }); time("createAgent"); diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index f11a677f..9d851922 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { createWriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { spawn } from "child_process"; import { getShellConfig, killProcessTree } from "../../utils/shell.js"; diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 4cd3dd0d..cdc8577e 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import * as Diff from "diff"; import { constants } from "fs"; diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 174a46d3..07c7694b 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { spawnSync } from "child_process"; import { existsSync } from "fs"; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 80996c9f..5402bd83 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -1,5 +1,5 @@ import { createInterface } from "node:readline"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { spawn } from "child_process"; import { readFileSync, type Stats, statSync } from "fs"; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 539395fd..74701568 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,5 +1,3 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; - export { type BashToolDetails, bashTool, createBashTool } from "./bash.js"; export { createEditTool, editTool } from "./edit.js"; export { createFindTool, type FindToolDetails, findTool } from "./find.js"; @@ -9,6 +7,7 @@ export { createReadTool, type ReadToolDetails, readTool } from "./read.js"; export type { TruncationResult } from "./truncate.js"; export { createWriteTool, writeTool } from "./write.js"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { bashTool, createBashTool } from "./bash.js"; import { createEditTool, editTool } from "./edit.js"; import { createFindTool, findTool } from "./find.js"; diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 4ebe26ed..ca27bfe4 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { existsSync, readdirSync, statSync } from "fs"; import nodePath from "path"; diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 36d75818..da3a62b6 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -1,4 +1,5 @@ -import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 5aa2b336..02317b70 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { mkdir, writeFile } from "fs/promises"; import { dirname } from "path"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 55909056..d33b8ca9 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -73,7 +73,7 @@ export { isReadToolResult, isWriteToolResult, } from "./core/hooks/index.js"; -export { messageTransformer } from "./core/messages.js"; +export { convertToLlm } from "./core/messages.js"; export { ModelRegistry } from "./core/model-registry.js"; // SDK for programmatic usage export { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e716cbd0..30648990 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,8 +5,7 @@ * createAgentSession() options. The SDK does the heavy lifting. */ -import type { Attachment } from "@mariozechner/pi-agent-core"; -import { supportsXhigh } from "@mariozechner/pi-ai"; +import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai"; import chalk from "chalk"; import { existsSync } from "fs"; import { join } from "path"; @@ -64,7 +63,7 @@ async function runInteractiveMode( customTools: LoadedCustomTool[], setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void, initialMessage?: string, - initialAttachments?: Attachment[], + initialImages?: ImageContent[], fdPath: string | null = null, ): Promise { const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath); @@ -93,7 +92,7 @@ async function runInteractiveMode( if (initialMessage) { try { - await session.prompt(initialMessage, { attachments: initialAttachments }); + await session.prompt(initialMessage, { images: initialImages }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; mode.showError(errorMessage); @@ -122,25 +121,25 @@ async function runInteractiveMode( async function prepareInitialMessage(parsed: Args): Promise<{ initialMessage?: string; - initialAttachments?: Attachment[]; + initialImages?: ImageContent[]; }> { if (parsed.fileArgs.length === 0) { return {}; } - const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs); + const { text, images } = await processFileArguments(parsed.fileArgs); let initialMessage: string; if (parsed.messages.length > 0) { - initialMessage = textContent + parsed.messages[0]; + initialMessage = text + parsed.messages[0]; parsed.messages.shift(); } else { - initialMessage = textContent; + initialMessage = text; } return { initialMessage, - initialAttachments: imageAttachments.length > 0 ? imageAttachments : undefined, + initialImages: images.length > 0 ? images : undefined, }; } @@ -330,7 +329,7 @@ export async function main(args: string[]) { } const cwd = process.cwd(); - const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed); + const { initialMessage, initialImages } = await prepareInitialMessage(parsed); time("prepareInitialMessage"); const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; @@ -438,11 +437,11 @@ export async function main(args: string[]) { customToolsResult.tools, customToolsResult.setUIContext, initialMessage, - initialAttachments, + initialImages, fdPath, ); } else { - await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments); + await runPrintMode(session, mode, parsed.messages, initialMessage, initialImages); stopThemeWatcher(); if (process.stdout.writableLength > 0) { await new Promise((resolve) => process.stdout.once("drain", resolve)); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 6bc06dea..c8b302c0 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -6,8 +6,7 @@ * - `pi --mode json "prompt"` - JSON event stream */ -import type { Attachment } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai"; import type { AgentSession } from "../core/agent-session.js"; /** @@ -18,14 +17,14 @@ import type { AgentSession } from "../core/agent-session.js"; * @param mode Output mode: "text" for final response only, "json" for all events * @param messages Array of prompts to send * @param initialMessage Optional first message (may contain @file content) - * @param initialAttachments Optional attachments for the initial message + * @param initialImages Optional images for the initial message */ export async function runPrintMode( session: AgentSession, mode: "text" | "json", messages: string[], initialMessage?: string, - initialAttachments?: Attachment[], + initialImages?: ImageContent[], ): Promise { // Load entries once for session start events const entries = session.sessionManager.getEntries(); @@ -79,7 +78,7 @@ export async function runPrintMode( // Send initial message with attachments if (initialMessage) { - await session.prompt(initialMessage, { attachments: initialAttachments }); + await session.prompt(initialMessage, { images: initialImages }); } // Send remaining messages diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 5d27c760..f8d8f213 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -6,7 +6,8 @@ import { type ChildProcess, spawn } from "node:child_process"; import * as readline from "node:readline"; -import type { AgentEvent, AgentMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { AgentEvent, AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { ImageContent } from "@mariozechner/pi-ai"; import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; import type { CompactionResult } from "../../core/compaction.js"; @@ -167,8 +168,8 @@ export class RpcClient { * Returns immediately after sending; use onEvent() to receive streaming events. * Use waitForIdle() to wait for completion. */ - async prompt(message: string, attachments?: Attachment[]): Promise { - await this.send({ type: "prompt", message, attachments }); + async prompt(message: string, images?: ImageContent[]): Promise { + await this.send({ type: "prompt", message, images }); } /** @@ -404,9 +405,9 @@ export class RpcClient { /** * Send prompt and wait for completion, returning all events. */ - async promptAndWait(message: string, attachments?: Attachment[], timeout = 60000): Promise { + async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise { const eventsPromise = this.collectEvents(timeout); - await this.prompt(message, attachments); + await this.prompt(message, images); return eventsPromise; } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 55819d9e..9ae57cd3 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -187,7 +187,7 @@ export async function runRpcMode(session: AgentSession): Promise { // Hook commands and file slash commands are handled in session.prompt() session .prompt(command.message, { - attachments: command.attachments, + images: command.images, }) .catch((e) => output(error(id, "prompt", e.message))); return success(id, "prompt"); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 3eeab9b1..993bc85f 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -5,8 +5,8 @@ * Responses and events are emitted as JSON lines on stdout. */ -import type { AgentMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core"; -import type { Model } from "@mariozechner/pi-ai"; +import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { ImageContent, Model } from "@mariozechner/pi-ai"; import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; import type { CompactionResult } from "../../core/compaction.js"; @@ -17,7 +17,7 @@ import type { CompactionResult } from "../../core/compaction.js"; export type RpcCommand = // Prompting - | { id?: string; type: "prompt"; message: string; attachments?: Attachment[] } + | { id?: string; type: "prompt"; message: string; images?: ImageContent[] } | { id?: string; type: "queue_message"; message: string } | { id?: string; type: "abort" } | { id?: string; type: "reset" } diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index de6e6e20..19f1e969 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -10,7 +10,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Agent, ProviderTransport } from "@mariozechner/pi-agent-core"; +import { Agent } from "@mariozechner/pi-agent-core"; import { getModel } from "@mariozechner/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session.js"; @@ -44,13 +44,8 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { function createSession(noSession: boolean = false) { const model = getModel("anthropic", "claude-sonnet-4-5")!; - - const transport = new ProviderTransport({ - getApiKey: () => API_KEY, - }); - const agent = new Agent({ - transport, + getApiKey: () => API_KEY, initialState: { model, systemPrompt: "You are a helpful assistant. Be extremely concise, reply with just a few words.", diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 580e4fc9..fc79bdc1 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -10,7 +10,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Agent, ProviderTransport } from "@mariozechner/pi-agent-core"; +import { Agent } from "@mariozechner/pi-agent-core"; import { getModel } from "@mariozechner/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.js"; @@ -48,13 +48,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { function createSession(inMemory = false) { const model = getModel("anthropic", "claude-sonnet-4-5")!; - - const transport = new ProviderTransport({ - getApiKey: () => API_KEY, - }); - const agent = new Agent({ - transport, + getApiKey: () => API_KEY, initialState: { model, systemPrompt: "You are a helpful assistant. Be concise.", diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 92b056cd..fe2fff3b 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Agent, ProviderTransport } from "@mariozechner/pi-agent-core"; +import { Agent } from "@mariozechner/pi-agent-core"; import { getModel } from "@mariozechner/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session.js"; @@ -72,13 +72,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { function createSession(hooks: LoadedHook[]) { const model = getModel("anthropic", "claude-sonnet-4-5")!; - - const transport = new ProviderTransport({ - getApiKey: () => API_KEY, - }); - const agent = new Agent({ - transport, + getApiKey: () => API_KEY, initialState: { model, systemPrompt: "You are a helpful assistant. Be concise.", diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index 3e13e8b6..6b285b75 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -1,15 +1,15 @@ -import { Agent, type AgentEvent, type Attachment, ProviderTransport } from "@mariozechner/pi-agent-core"; -import { getModel } from "@mariozechner/pi-ai"; +import { Agent, type AgentEvent } from "@mariozechner/pi-agent-core"; +import { getModel, type ImageContent } from "@mariozechner/pi-ai"; import { AgentSession, AuthStorage, + convertToLlm, formatSkillsForPrompt, loadSkillsFromDir, ModelRegistry, - messageTransformer, type Skill, } from "@mariozechner/pi-coding-agent"; -import { existsSync, readFileSync, statSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { mkdir, writeFile } from "fs/promises"; import { homedir } from "os"; import { join } from "path"; @@ -716,7 +716,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`; let userMessage = `[${timestamp}] [${ctx.message.userName || "unknown"}]: ${ctx.message.text}`; - const imageAttachments: Attachment[] = []; + const imageAttachments: ImageContent[] = []; const nonImagePaths: string[] = []; for (const a of ctx.message.attachments || []) { @@ -725,14 +725,10 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi if (mimeType && existsSync(fullPath)) { try { - const stats = statSync(fullPath); imageAttachments.push({ - id: a.local, type: "image", - fileName: a.local.split("/").pop() || a.local, mimeType, - size: stats.size, - content: readFileSync(fullPath).toString("base64"), + data: readFileSync(fullPath).toString("base64"), }); } catch { nonImagePaths.push(fullPath); @@ -755,7 +751,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi }; await writeFile(join(channelDir, "last_prompt.jsonl"), JSON.stringify(debugContext, null, 2)); - await session.prompt(userMessage, imageAttachments.length > 0 ? { attachments: imageAttachments } : undefined); + await session.prompt(userMessage, imageAttachments.length > 0 ? { images: imageAttachments } : undefined); // Wait for queued messages await queueChain; diff --git a/packages/mom/src/tools/attach.ts b/packages/mom/src/tools/attach.ts index 174faf02..fae9e8db 100644 --- a/packages/mom/src/tools/attach.ts +++ b/packages/mom/src/tools/attach.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { basename, resolve as resolvePath } from "path"; diff --git a/packages/mom/src/tools/bash.ts b/packages/mom/src/tools/bash.ts index dbda5e43..82e9dacd 100644 --- a/packages/mom/src/tools/bash.ts +++ b/packages/mom/src/tools/bash.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { createWriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import type { Executor } from "../sandbox.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; diff --git a/packages/mom/src/tools/edit.ts b/packages/mom/src/tools/edit.ts index 3fce6146..5ee678e8 100644 --- a/packages/mom/src/tools/edit.ts +++ b/packages/mom/src/tools/edit.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import * as Diff from "diff"; import type { Executor } from "../sandbox.js"; diff --git a/packages/mom/src/tools/index.ts b/packages/mom/src/tools/index.ts index 607e2e83..ff21ad0a 100644 --- a/packages/mom/src/tools/index.ts +++ b/packages/mom/src/tools/index.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { Executor } from "../sandbox.js"; import { attachTool } from "./attach.js"; import { createBashTool } from "./bash.js"; diff --git a/packages/mom/src/tools/read.ts b/packages/mom/src/tools/read.ts index db36d615..4f284d70 100644 --- a/packages/mom/src/tools/read.ts +++ b/packages/mom/src/tools/read.ts @@ -1,4 +1,5 @@ -import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { extname } from "path"; import type { Executor } from "../sandbox.js"; diff --git a/packages/mom/src/tools/write.ts b/packages/mom/src/tools/write.ts index 22bdb1e5..ebd0735b 100644 --- a/packages/mom/src/tools/write.ts +++ b/packages/mom/src/tools/write.ts @@ -1,4 +1,4 @@ -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import type { Executor } from "../sandbox.js"; diff --git a/packages/web-ui/example/src/custom-messages.ts b/packages/web-ui/example/src/custom-messages.ts index 8b02b13f..8cb3bdfe 100644 --- a/packages/web-ui/example/src/custom-messages.ts +++ b/packages/web-ui/example/src/custom-messages.ts @@ -18,7 +18,7 @@ export interface SystemNotificationMessage { // Extend CustomMessages interface via declaration merging declare module "@mariozechner/pi-web-ui" { - interface CustomMessages { + interface CustomAgentMessages { "system-notification": SystemNotificationMessage; } } @@ -99,8 +99,8 @@ export function customMessageTransformer(messages: AppMessage[]): Message[] { } // Strip attachments from user messages - if (m.role === "user") { - const { attachments: _, ...rest } = m as any; + if (m.role === "user-with-attachment") { + const { attachments: _, ...rest } = m; return rest as Message; } diff --git a/packages/web-ui/src/ChatPanel.ts b/packages/web-ui/src/ChatPanel.ts index ed2d152f..e055c776 100644 --- a/packages/web-ui/src/ChatPanel.ts +++ b/packages/web-ui/src/ChatPanel.ts @@ -1,9 +1,8 @@ import { Badge } from "@mariozechner/mini-lit/dist/Badge.js"; import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; -import type { Agent } from "./agent/agent.js"; import "./components/AgentInterface.js"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { Agent, AgentTool } from "@mariozechner/pi-agent-core"; import type { AgentInterface } from "./components/AgentInterface.js"; import { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js"; import { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js"; @@ -95,7 +94,7 @@ export class ChatPanel extends LitElement { const runtimeProvidersFactory = () => { const attachments: Attachment[] = []; for (const message of this.agent!.state.messages) { - if (message.role === "user") { + if (message.role === "user-with-attachments") { message.attachments?.forEach((a) => { attachments.push(a); }); diff --git a/packages/web-ui/src/agent/agent.ts b/packages/web-ui/src/agent/agent.ts deleted file mode 100644 index f751e2e1..00000000 --- a/packages/web-ui/src/agent/agent.ts +++ /dev/null @@ -1,341 +0,0 @@ -import type { Context, QueuedMessage } from "@mariozechner/pi-ai"; -import { - type AgentTool, - type AssistantMessage as AssistantMessageType, - getModel, - type ImageContent, - type Message, - type Model, - type TextContent, -} from "@mariozechner/pi-ai"; -import type { AppMessage } from "../components/Messages.js"; -import type { Attachment } from "../utils/attachment-utils.js"; -import type { AgentRunConfig, AgentTransport } from "./transports/types.js"; -import type { DebugLogEntry } from "./types.js"; - -// Default transformer: Keep only LLM-compatible messages, strip app-specific fields -function defaultMessageTransformer(messages: AppMessage[]): Message[] { - return messages - .filter((m) => { - // Only keep standard LLM message roles - return m.role === "user" || m.role === "assistant" || m.role === "toolResult"; - }) - .map((m) => { - if (m.role === "user") { - // Strip attachments field (app-specific) - - // biome-ignore lint/correctness/noUnusedVariables: fine here - const { attachments, ...rest } = m as any; - return rest as Message; - } - return m as Message; - }); -} - -export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high"; - -export interface AgentState { - systemPrompt: string; - model: Model; - thinkingLevel: ThinkingLevel; - tools: AgentTool[]; - messages: AppMessage[]; - isStreaming: boolean; - streamMessage: Message | null; - pendingToolCalls: Set; - error?: string; -} - -export type AgentEvent = - | { type: "state-update"; state: AgentState } - | { type: "error-no-model" } - | { type: "error-no-api-key"; provider: string } - | { type: "started" } - | { type: "completed" }; - -export interface AgentOptions { - initialState?: Partial; - debugListener?: (entry: DebugLogEntry) => void; - transport: AgentTransport; - // Transform app messages to LLM-compatible messages before sending to transport - messageTransformer?: (messages: AppMessage[]) => Message[] | Promise; -} - -export class Agent { - private _state: AgentState = { - systemPrompt: "", - model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"), - thinkingLevel: "off", - tools: [], - messages: [], - isStreaming: false, - streamMessage: null, - pendingToolCalls: new Set(), - error: undefined, - }; - private listeners = new Set<(e: AgentEvent) => void>(); - private abortController?: AbortController; - private transport: AgentTransport; - private debugListener?: (entry: DebugLogEntry) => void; - private messageTransformer: (messages: AppMessage[]) => Message[] | Promise; - private messageQueue: Array> = []; - - constructor(opts: AgentOptions) { - this._state = { ...this._state, ...opts.initialState }; - this.debugListener = opts.debugListener; - this.transport = opts.transport; - this.messageTransformer = opts.messageTransformer || defaultMessageTransformer; - } - - get state(): AgentState { - return this._state; - } - - subscribe(fn: (e: AgentEvent) => void): () => void { - this.listeners.add(fn); - fn({ type: "state-update", state: this._state }); - return () => this.listeners.delete(fn); - } - - // Mutators - setSystemPrompt(v: string) { - this.patch({ systemPrompt: v }); - } - setModel(m: Model) { - this.patch({ model: m }); - } - setThinkingLevel(l: ThinkingLevel) { - this.patch({ thinkingLevel: l }); - } - setTools(t: AgentTool[]) { - this.patch({ tools: t }); - } - replaceMessages(ms: AppMessage[]) { - this.patch({ messages: ms.slice() }); - } - appendMessage(m: AppMessage) { - this.patch({ messages: [...this._state.messages, m] }); - } - async queueMessage(m: AppMessage) { - // Transform message and queue it for injection at next turn - const transformed = await this.messageTransformer([m]); - this.messageQueue.push({ - original: m, - llm: transformed[0], // undefined if filtered out - }); - } - clearMessages() { - this.patch({ messages: [] }); - } - - abort() { - this.abortController?.abort(); - } - - private logState(message: string) { - const { systemPrompt, model, messages } = this._state; - console.log(message, { systemPrompt, model, messages }); - } - - async prompt(input: string, attachments?: Attachment[]) { - const model = this._state.model; - if (!model) { - this.emit({ type: "error-no-model" }); - return; - } - - // Build user message with attachments - const content: Array = [{ type: "text", text: input }]; - if (attachments?.length) { - for (const a of attachments) { - if (a.type === "image") { - content.push({ type: "image", data: a.content, mimeType: a.mimeType }); - } else if (a.type === "document" && a.extractedText) { - content.push({ - type: "text", - text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`, - isDocument: true, - } as TextContent); - } - } - } - - const userMessage: AppMessage = { - role: "user", - content, - attachments: attachments?.length ? attachments : undefined, - timestamp: Date.now(), - }; - - this.abortController = new AbortController(); - this.patch({ isStreaming: true, streamMessage: null, error: undefined }); - this.emit({ type: "started" }); - - const reasoning = - this._state.thinkingLevel === "off" - ? undefined - : this._state.thinkingLevel === "minimal" - ? "low" - : this._state.thinkingLevel; - const cfg: AgentRunConfig = { - systemPrompt: this._state.systemPrompt, - tools: this._state.tools, - model, - reasoning, - getQueuedMessages: async () => { - // Return queued messages (they'll be added to state via message_end event) - const queued = this.messageQueue.slice(); - this.messageQueue = []; - return queued as QueuedMessage[]; - }, - }; - - try { - let partial: Message | null = null; - let turnDebug: DebugLogEntry | null = null; - let turnStart = 0; - - this.logState("prompt started, current state:"); - - // Transform app messages to LLM-compatible messages (initial set) - const llmMessages = await this.messageTransformer(this._state.messages); - - console.log("transformed messages:", llmMessages); - for await (const ev of this.transport.run( - llmMessages, - userMessage as Message, - cfg, - this.abortController.signal, - )) { - switch (ev.type) { - case "turn_start": { - turnStart = performance.now(); - // Build request context snapshot (use transformed messages) - const ctx: Context = { - systemPrompt: this._state.systemPrompt, - messages: [...llmMessages], - tools: this._state.tools, - }; - turnDebug = { - timestamp: new Date().toISOString(), - request: { - provider: cfg.model.provider, - model: cfg.model.id, - context: { ...ctx }, - }, - sseEvents: [], - }; - break; - } - case "message_start": - case "message_update": { - partial = ev.message; - // Collect SSE-like events for debug (drop heavy partial) - if (ev.type === "message_update" && ev.assistantMessageEvent && turnDebug) { - const copy: any = { ...ev.assistantMessageEvent }; - if (copy && "partial" in copy) delete copy.partial; - turnDebug.sseEvents.push(JSON.stringify(copy)); - if (!turnDebug.ttft) turnDebug.ttft = performance.now() - turnStart; - } - this.patch({ streamMessage: ev.message }); - break; - } - case "message_end": { - partial = null; - this.appendMessage(ev.message as AppMessage); - this.patch({ streamMessage: null }); - if (turnDebug) { - if (ev.message.role !== "assistant" && ev.message.role !== "toolResult") { - turnDebug.request.context.messages.push(ev.message); - } - if (ev.message.role === "assistant") turnDebug.response = ev.message as any; - } - break; - } - case "tool_execution_start": { - const s = new Set(this._state.pendingToolCalls); - s.add(ev.toolCallId); - this.patch({ pendingToolCalls: s }); - break; - } - case "tool_execution_end": { - const s = new Set(this._state.pendingToolCalls); - s.delete(ev.toolCallId); - this.patch({ pendingToolCalls: s }); - break; - } - case "turn_end": { - // finalize current turn - if (turnDebug) { - turnDebug.totalTime = performance.now() - turnStart; - this.debugListener?.(turnDebug); - turnDebug = null; - } - break; - } - case "agent_end": { - this.patch({ streamMessage: null }); - break; - } - } - } - - if (partial && partial.role === "assistant" && partial.content.length > 0) { - const onlyEmpty = !partial.content.some( - (c) => - (c.type === "thinking" && c.thinking.trim().length > 0) || - (c.type === "text" && c.text.trim().length > 0) || - (c.type === "toolCall" && c.name.trim().length > 0), - ); - if (!onlyEmpty) { - this.appendMessage(partial as AppMessage); - } else { - if (this.abortController?.signal.aborted) { - throw new Error("Request was aborted"); - } - } - } - } catch (err: any) { - if (String(err?.message || err) === "no-api-key") { - this.emit({ type: "error-no-api-key", provider: model.provider }); - } else { - const msg: AssistantMessageType = { - role: "assistant", - content: [{ type: "text", text: "" }], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: this.abortController?.signal.aborted ? "aborted" : "error", - errorMessage: err?.message || String(err), - timestamp: Date.now(), - }; - this.appendMessage(msg as AppMessage); - this.patch({ error: err?.message || String(err) }); - } - } finally { - this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set() }); - this.abortController = undefined; - this.emit({ type: "completed" }); - } - this.logState("final state:"); - } - - private patch(p: Partial): void { - this._state = { ...this._state, ...p }; - this.emit({ type: "state-update", state: this._state }); - } - - private emit(e: AgentEvent) { - for (const listener of this.listeners) { - listener(e); - } - } -} diff --git a/packages/web-ui/src/agent/transports/AppTransport.ts b/packages/web-ui/src/agent/transports/AppTransport.ts deleted file mode 100644 index 90525a7b..00000000 --- a/packages/web-ui/src/agent/transports/AppTransport.ts +++ /dev/null @@ -1,371 +0,0 @@ -import type { - AgentContext, - AgentLoopConfig, - Api, - AssistantMessage, - AssistantMessageEvent, - Context, - Message, - Model, - SimpleStreamOptions, - ToolCall, - UserMessage, -} from "@mariozechner/pi-ai"; -import { agentLoop, agentLoopContinue } from "@mariozechner/pi-ai"; -import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js"; -import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js"; -import { clearAuthToken, getAuthToken } from "../../utils/auth-token.js"; -import { i18n } from "../../utils/i18n.js"; -import type { ProxyAssistantMessageEvent } from "./proxy-types.js"; -import type { AgentRunConfig, AgentTransport } from "./types.js"; - -/** - * Stream function that proxies through a server instead of calling providers directly. - * The server strips the partial field from delta events to reduce bandwidth. - * We reconstruct the partial message client-side. - */ -function streamSimpleProxy( - model: Model, - context: Context, - options: SimpleStreamOptions & { authToken: string }, - proxyUrl: string, -): AssistantMessageEventStream { - const stream = new AssistantMessageEventStream(); - - (async () => { - // Initialize the partial message that we'll build up from events - const partial: AssistantMessage = { - role: "assistant", - stopReason: "stop", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: Date.now(), - }; - - let reader: ReadableStreamDefaultReader | undefined; - - // Set up abort handler to cancel the reader - const abortHandler = () => { - if (reader) { - reader.cancel("Request aborted by user").catch(() => {}); - } - }; - - if (options.signal) { - options.signal.addEventListener("abort", abortHandler); - } - - try { - const response = await fetch(`${proxyUrl}/api/stream`, { - method: "POST", - headers: { - Authorization: `Bearer ${options.authToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model, - context, - options: { - temperature: options.temperature, - maxTokens: options.maxTokens, - reasoning: options.reasoning, - // Don't send apiKey or signal - those are added server-side - }, - }), - signal: options.signal, - }); - - if (!response.ok) { - let errorMessage = `Proxy error: ${response.status} ${response.statusText}`; - try { - const errorData = await response.json(); - if (errorData.error) { - errorMessage = `Proxy error: ${errorData.error}`; - } - } catch { - // Couldn't parse error response, use default message - } - throw new Error(errorMessage); - } - - // Parse SSE stream - reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - // Check if aborted after reading - if (options.signal?.aborted) { - throw new Error("Request aborted by user"); - } - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6).trim(); - if (data) { - const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; - let event: AssistantMessageEvent | undefined; - - // Handle different event types - // Server sends events with partial for non-delta events, - // and without partial for delta events - switch (proxyEvent.type) { - case "start": - event = { type: "start", partial }; - break; - - case "text_start": - partial.content[proxyEvent.contentIndex] = { - type: "text", - text: "", - }; - event = { type: "text_start", contentIndex: proxyEvent.contentIndex, partial }; - break; - - case "text_delta": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "text") { - content.text += proxyEvent.delta; - event = { - type: "text_delta", - contentIndex: proxyEvent.contentIndex, - delta: proxyEvent.delta, - partial, - }; - } else { - throw new Error("Received text_delta for non-text content"); - } - break; - } - case "text_end": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "text") { - content.textSignature = proxyEvent.contentSignature; - event = { - type: "text_end", - contentIndex: proxyEvent.contentIndex, - content: content.text, - partial, - }; - } else { - throw new Error("Received text_end for non-text content"); - } - break; - } - - case "thinking_start": - partial.content[proxyEvent.contentIndex] = { - type: "thinking", - thinking: "", - }; - event = { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial }; - break; - - case "thinking_delta": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "thinking") { - content.thinking += proxyEvent.delta; - event = { - type: "thinking_delta", - contentIndex: proxyEvent.contentIndex, - delta: proxyEvent.delta, - partial, - }; - } else { - throw new Error("Received thinking_delta for non-thinking content"); - } - break; - } - - case "thinking_end": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "thinking") { - content.thinkingSignature = proxyEvent.contentSignature; - event = { - type: "thinking_end", - contentIndex: proxyEvent.contentIndex, - content: content.thinking, - partial, - }; - } else { - throw new Error("Received thinking_end for non-thinking content"); - } - break; - } - - case "toolcall_start": - partial.content[proxyEvent.contentIndex] = { - type: "toolCall", - id: proxyEvent.id, - name: proxyEvent.toolName, - arguments: {}, - partialJson: "", - } satisfies ToolCall & { partialJson: string } as ToolCall; - event = { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial }; - break; - - case "toolcall_delta": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "toolCall") { - (content as any).partialJson += proxyEvent.delta; - content.arguments = parseStreamingJson((content as any).partialJson) || {}; - event = { - type: "toolcall_delta", - contentIndex: proxyEvent.contentIndex, - delta: proxyEvent.delta, - partial, - }; - partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity - } else { - throw new Error("Received toolcall_delta for non-toolCall content"); - } - break; - } - - case "toolcall_end": { - const content = partial.content[proxyEvent.contentIndex]; - if (content?.type === "toolCall") { - delete (content as any).partialJson; - event = { - type: "toolcall_end", - contentIndex: proxyEvent.contentIndex, - toolCall: content, - partial, - }; - } - break; - } - - case "done": - partial.stopReason = proxyEvent.reason; - partial.usage = proxyEvent.usage; - event = { type: "done", reason: proxyEvent.reason, message: partial }; - break; - - case "error": - partial.stopReason = proxyEvent.reason; - partial.errorMessage = proxyEvent.errorMessage; - partial.usage = proxyEvent.usage; - event = { type: "error", reason: proxyEvent.reason, error: partial }; - break; - - default: { - // Exhaustive check - const _exhaustiveCheck: never = proxyEvent; - console.warn(`Unhandled event type: ${(proxyEvent as any).type}`); - break; - } - } - - // Push the event to stream - if (event) { - stream.push(event); - } else { - throw new Error("Failed to create event from proxy event"); - } - } - } - } - } - - // Check if aborted after reading - if (options.signal?.aborted) { - throw new Error("Request aborted by user"); - } - - stream.end(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.toLowerCase().includes("proxy") && errorMessage.includes("Unauthorized")) { - clearAuthToken(); - } - partial.stopReason = options.signal?.aborted ? "aborted" : "error"; - partial.errorMessage = errorMessage; - stream.push({ - type: "error", - reason: partial.stopReason, - error: partial, - } satisfies AssistantMessageEvent); - stream.end(); - } finally { - // Clean up abort handler - if (options.signal) { - options.signal.removeEventListener("abort", abortHandler); - } - } - })(); - - return stream; -} - -/** - * Transport that uses an app server with user authentication tokens. - * The server manages user accounts and proxies requests to LLM providers. - */ -export class AppTransport implements AgentTransport { - private readonly proxyUrl = "https://genai.mariozechner.at"; - - private async getStreamFn() { - const authToken = await getAuthToken(); - if (!authToken) { - throw new Error(i18n("Auth token is required for proxy transport")); - } - - return (model: Model, context: Context, options?: SimpleStreamOptions) => { - return streamSimpleProxy(model, context, { ...options, authToken }, this.proxyUrl); - }; - } - - private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext { - return { - systemPrompt: cfg.systemPrompt, - messages, - tools: cfg.tools, - }; - } - - private buildLoopConfig(cfg: AgentRunConfig): AgentLoopConfig { - return { - model: cfg.model, - reasoning: cfg.reasoning, - getQueuedMessages: cfg.getQueuedMessages, - }; - } - - async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { - const streamFn = await this.getStreamFn(); - const context = this.buildContext(messages, cfg); - const pc = this.buildLoopConfig(cfg); - - for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn as any)) { - yield ev; - } - } - - async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { - const streamFn = await this.getStreamFn(); - const context = this.buildContext(messages, cfg); - const pc = this.buildLoopConfig(cfg); - - for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any)) { - yield ev; - } - } -} diff --git a/packages/web-ui/src/agent/transports/ProviderTransport.ts b/packages/web-ui/src/agent/transports/ProviderTransport.ts deleted file mode 100644 index b16991d1..00000000 --- a/packages/web-ui/src/agent/transports/ProviderTransport.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - type AgentContext, - type AgentLoopConfig, - agentLoop, - agentLoopContinue, - type Message, - type UserMessage, -} from "@mariozechner/pi-ai"; -import { getAppStorage } from "../../storage/app-storage.js"; -import { applyProxyIfNeeded } from "../../utils/proxy-utils.js"; -import type { AgentRunConfig, AgentTransport } from "./types.js"; - -/** - * Transport that calls LLM providers directly. - * Uses CORS proxy only for providers that require it (Anthropic OAuth, Z-AI). - */ -export class ProviderTransport implements AgentTransport { - private async getModel(cfg: AgentRunConfig) { - const apiKey = await getAppStorage().providerKeys.get(cfg.model.provider); - if (!apiKey) { - throw new Error("no-api-key"); - } - - const proxyEnabled = await getAppStorage().settings.get("proxy.enabled"); - const proxyUrl = await getAppStorage().settings.get("proxy.url"); - const model = applyProxyIfNeeded(cfg.model, apiKey, proxyEnabled ? proxyUrl || undefined : undefined); - - return model; - } - - private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext { - return { - systemPrompt: cfg.systemPrompt, - messages, - tools: cfg.tools, - }; - } - - private buildLoopConfig(model: AgentRunConfig["model"], cfg: AgentRunConfig): AgentLoopConfig { - return { - model, - reasoning: cfg.reasoning, - // Resolve API key per assistant response (important for expiring OAuth tokens) - getApiKey: async (provider: string) => { - const key = await getAppStorage().providerKeys.get(provider); - return key ?? undefined; // Convert null to undefined for type compatibility - }, - getQueuedMessages: cfg.getQueuedMessages, - }; - } - - async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { - const model = await this.getModel(cfg); - const context = this.buildContext(messages, cfg); - const pc = this.buildLoopConfig(model, cfg); - - for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) { - yield ev; - } - } - - async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { - const model = await this.getModel(cfg); - const context = this.buildContext(messages, cfg); - const pc = this.buildLoopConfig(model, cfg); - - for await (const ev of agentLoopContinue(context, pc, signal)) { - yield ev; - } - } -} diff --git a/packages/web-ui/src/agent/transports/index.ts b/packages/web-ui/src/agent/transports/index.ts deleted file mode 100644 index 8dd56057..00000000 --- a/packages/web-ui/src/agent/transports/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./AppTransport.js"; -export * from "./ProviderTransport.js"; -export * from "./types.js"; diff --git a/packages/web-ui/src/agent/transports/proxy-types.ts b/packages/web-ui/src/agent/transports/proxy-types.ts deleted file mode 100644 index 94d4dbf9..00000000 --- a/packages/web-ui/src/agent/transports/proxy-types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { StopReason, Usage } from "@mariozechner/pi-ai"; - -export type ProxyAssistantMessageEvent = - | { type: "start" } - | { type: "text_start"; contentIndex: number } - | { type: "text_delta"; contentIndex: number; delta: string } - | { type: "text_end"; contentIndex: number; contentSignature?: string } - | { type: "thinking_start"; contentIndex: number } - | { type: "thinking_delta"; contentIndex: number; delta: string } - | { type: "thinking_end"; contentIndex: number; contentSignature?: string } - | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } - | { type: "toolcall_delta"; contentIndex: number; delta: string } - | { type: "toolcall_end"; contentIndex: number } - | { type: "done"; reason: Extract; usage: Usage } - | { type: "error"; reason: Extract; errorMessage: string; usage: Usage }; diff --git a/packages/web-ui/src/agent/transports/types.ts b/packages/web-ui/src/agent/transports/types.ts deleted file mode 100644 index 74d28628..00000000 --- a/packages/web-ui/src/agent/transports/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { AgentEvent, AgentTool, Message, Model, QueuedMessage } from "@mariozechner/pi-ai"; - -// The minimal configuration needed to run a turn. -export interface AgentRunConfig { - systemPrompt: string; - tools: AgentTool[]; - model: Model; - reasoning?: "low" | "medium" | "high"; - getQueuedMessages?: () => Promise[]>; -} - -// Events yielded by transports must match the @mariozechner/pi-ai prompt() events. -// We re-export the Message type above; consumers should use the upstream AgentEvent type. - -export interface AgentTransport { - /** Run with a new user message */ - run( - messages: Message[], - userMessage: Message, - config: AgentRunConfig, - signal?: AbortSignal, - ): AsyncIterable; - - /** Continue from current context (no new user message) */ - continue(messages: Message[], config: AgentRunConfig, signal?: AbortSignal): AsyncIterable; -} diff --git a/packages/web-ui/src/agent/types.ts b/packages/web-ui/src/agent/types.ts deleted file mode 100644 index c5513941..00000000 --- a/packages/web-ui/src/agent/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AssistantMessage, Context } from "@mariozechner/pi-ai"; - -export interface DebugLogEntry { - timestamp: string; - request: { provider: string; model: string; context: Context }; - response?: AssistantMessage; - error?: unknown; - sseEvents: string[]; - ttft?: number; - totalTime?: number; -} diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 3d44faa3..6ee33126 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -6,9 +6,9 @@ import type { MessageEditor } from "./MessageEditor.js"; import "./MessageEditor.js"; import "./MessageList.js"; import "./Messages.js"; // Import for side effects to register the custom elements -import type { Agent, AgentEvent } from "../agent/agent.js"; import { getAppStorage } from "../storage/app-storage.js"; import "./StreamingMessageContainer.js"; +import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core"; import type { Attachment } from "../utils/attachment-utils.js"; import { formatUsage } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; @@ -130,16 +130,13 @@ export class AgentInterface extends LitElement { } if (!this.session) return; this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => { - if (ev.type === "state-update") { + if (ev.type === "message_update") { if (this._streamingContainer) { - this._streamingContainer.isStreaming = ev.state.isStreaming; - this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming); + const isStreaming = this.session?.state.isStreaming || false; + this._streamingContainer.isStreaming = isStreaming; + this._streamingContainer.setMessage(ev.message, !isStreaming); } this.requestUpdate(); - } else if (ev.type === "error-no-model") { - // TODO show some UI feedback - } else if (ev.type === "error-no-api-key") { - // Handled by onApiKeyRequired callback } }); } diff --git a/packages/web-ui/src/components/MessageList.ts b/packages/web-ui/src/components/MessageList.ts index 0cad8bd0..2586bf58 100644 --- a/packages/web-ui/src/components/MessageList.ts +++ b/packages/web-ui/src/components/MessageList.ts @@ -1,16 +1,15 @@ +import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core"; import type { - AgentTool, AssistantMessage as AssistantMessageType, ToolResultMessage as ToolResultMessageType, } from "@mariozechner/pi-ai"; import { html, LitElement, type TemplateResult } from "lit"; import { property } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; -import type { AppMessage } from "./Messages.js"; import { renderMessage } from "./message-renderer-registry.js"; export class MessageList extends LitElement { - @property({ type: Array }) messages: AppMessage[] = []; + @property({ type: Array }) messages: AgentMessage[] = []; @property({ type: Array }) tools: AgentTool[] = []; @property({ type: Object }) pendingToolCalls?: Set; @property({ type: Boolean }) isStreaming: boolean = false; diff --git a/packages/web-ui/src/components/Messages.ts b/packages/web-ui/src/components/Messages.ts index 73e9a3e6..5588005c 100644 --- a/packages/web-ui/src/components/Messages.ts +++ b/packages/web-ui/src/components/Messages.ts @@ -1,6 +1,7 @@ import type { - AgentTool, AssistantMessage as AssistantMessageType, + ImageContent, + TextContent, ToolCall, ToolResultMessage as ToolResultMessageType, UserMessage as UserMessageType, @@ -12,8 +13,14 @@ import type { Attachment } from "../utils/attachment-utils.js"; import { formatUsage } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; import "./ThinkingBlock.js"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; -export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] }; +export type UserMessageWithAttachments = { + role: "user-with-attachments"; + content: string | (TextContent | ImageContent)[]; + timestamp: number; + attachments?: Attachment[]; +}; // Artifact message type for session persistence export interface ArtifactMessage { @@ -25,26 +32,16 @@ export interface ArtifactMessage { timestamp: string; } -// Base message union -type BaseMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType | ArtifactMessage; - -// Extensible interface - apps can extend via declaration merging -// Example: -// declare module "@mariozechner/pi-web-ui" { -// interface CustomMessages { -// "system-notification": SystemNotificationMessage; -// } -// } -export interface CustomMessages { - // Empty by default - apps extend via declaration merging +declare module "@mariozechner/pi-agent-core" { + interface CustomAgentMessages { + "user-with-attachment": UserMessageWithAttachments; + artifact: ArtifactMessage; + } } -// AppMessage is union of base messages + custom messages -export type AppMessage = BaseMessage | CustomMessages[keyof CustomMessages]; - @customElement("user-message") export class UserMessage extends LitElement { - @property({ type: Object }) message!: UserMessageWithAttachments; + @property({ type: Object }) message!: UserMessageWithAttachments | UserMessageType; protected override createRenderRoot(): HTMLElement | DocumentFragment { return this; @@ -66,7 +63,9 @@ export class UserMessage extends LitElement {
${ - this.message.attachments && this.message.attachments.length > 0 + this.message.role === "user-with-attachments" && + this.message.attachments && + this.message.attachments.length > 0 ? html`
${this.message.attachments.map( diff --git a/packages/web-ui/src/components/StreamingMessageContainer.ts b/packages/web-ui/src/components/StreamingMessageContainer.ts index 3b5790ea..d3703dea 100644 --- a/packages/web-ui/src/components/StreamingMessageContainer.ts +++ b/packages/web-ui/src/components/StreamingMessageContainer.ts @@ -1,4 +1,5 @@ -import type { AgentTool, Message, ToolResultMessage } from "@mariozechner/pi-ai"; +import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core"; +import type { ToolResultMessage } from "@mariozechner/pi-ai"; import { html, LitElement } from "lit"; import { property, state } from "lit/decorators.js"; @@ -9,8 +10,8 @@ export class StreamingMessageContainer extends LitElement { @property({ type: Object }) toolResultsById?: Map; @property({ attribute: false }) onCostClick?: () => void; - @state() private _message: Message | null = null; - private _pendingMessage: Message | null = null; + @state() private _message: AgentMessage | null = null; + private _pendingMessage: AgentMessage | null = null; private _updateScheduled = false; private _immediateUpdate = false; @@ -24,7 +25,7 @@ export class StreamingMessageContainer extends LitElement { } // Public method to update the message with batching for performance - public setMessage(message: Message | null, immediate = false) { + public setMessage(message: AgentMessage | null, immediate = false) { // Store the latest message this._pendingMessage = message; diff --git a/packages/web-ui/src/components/message-renderer-registry.ts b/packages/web-ui/src/components/message-renderer-registry.ts index eac4689e..51f84a48 100644 --- a/packages/web-ui/src/components/message-renderer-registry.ts +++ b/packages/web-ui/src/components/message-renderer-registry.ts @@ -1,11 +1,11 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { TemplateResult } from "lit"; -import type { AppMessage } from "./Messages.js"; // Extract role type from AppMessage union -export type MessageRole = AppMessage["role"]; +export type MessageRole = AgentMessage["role"]; // Generic message renderer typed to specific message type -export interface MessageRenderer { +export interface MessageRenderer { render(message: TMessage): TemplateResult; } @@ -14,7 +14,7 @@ const messageRenderers = new Map>(); export function registerMessageRenderer( role: TRole, - renderer: MessageRenderer>, + renderer: MessageRenderer>, ): void { messageRenderers.set(role, renderer); } @@ -23,6 +23,6 @@ export function getMessageRenderer(role: MessageRole): MessageRenderer | undefin return messageRenderers.get(role); } -export function renderMessage(message: AppMessage): TemplateResult | undefined { +export function renderMessage(message: AgentMessage): TemplateResult | undefined { return messageRenderers.get(message.role)?.render(message); } diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 547cb959..e6c9e808 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -1,13 +1,7 @@ // Main chat interface -export type { AgentState, ThinkingLevel } from "./agent/agent.js"; -// State management -export { Agent } from "./agent/agent.js"; -// Transports -export { AppTransport } from "./agent/transports/AppTransport.js"; -export { ProviderTransport } from "./agent/transports/ProviderTransport.js"; -export type { ProxyAssistantMessageEvent } from "./agent/transports/proxy-types.js"; -export type { AgentRunConfig, AgentTransport } from "./agent/transports/types.js"; +export type { Agent, AgentMessage, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core"; +export type { Model } from "@mariozechner/pi-ai"; export { ChatPanel } from "./ChatPanel.js"; // Components export { AgentInterface } from "./components/AgentInterface.js"; @@ -18,7 +12,7 @@ export { Input } from "./components/Input.js"; export { MessageEditor } from "./components/MessageEditor.js"; export { MessageList } from "./components/MessageList.js"; // Message components -export type { AppMessage, CustomMessages, UserMessageWithAttachments } from "./components/Messages.js"; +export type { UserMessageWithAttachments } from "./components/Messages.js"; export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js"; // Message renderer registry export { diff --git a/packages/web-ui/src/storage/stores/sessions-store.ts b/packages/web-ui/src/storage/stores/sessions-store.ts index 40a34edb..b0084fdd 100644 --- a/packages/web-ui/src/storage/stores/sessions-store.ts +++ b/packages/web-ui/src/storage/stores/sessions-store.ts @@ -1,4 +1,4 @@ -import type { AgentState } from "../../agent/agent.js"; +import type { AgentState } from "@mariozechner/pi-agent-core"; import { Store } from "../store.js"; import type { SessionData, SessionMetadata, StoreConfig } from "../types.js"; diff --git a/packages/web-ui/src/storage/types.ts b/packages/web-ui/src/storage/types.ts index 038f9657..3bbcf602 100644 --- a/packages/web-ui/src/storage/types.ts +++ b/packages/web-ui/src/storage/types.ts @@ -1,6 +1,5 @@ +import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; -import type { ThinkingLevel } from "../agent/agent.js"; -import type { AppMessage } from "../components/Messages.js"; /** * Transaction interface for atomic operations across stores. @@ -159,7 +158,7 @@ export interface SessionData { thinkingLevel: ThinkingLevel; /** Full conversation history (with attachments inline) */ - messages: AppMessage[]; + messages: AgentMessage[]; /** ISO 8601 UTC timestamp of creation */ createdAt: string; diff --git a/packages/web-ui/src/tools/artifacts/artifacts.ts b/packages/web-ui/src/tools/artifacts/artifacts.ts index 5aac9066..0a5474a7 100644 --- a/packages/web-ui/src/tools/artifacts/artifacts.ts +++ b/packages/web-ui/src/tools/artifacts/artifacts.ts @@ -1,13 +1,13 @@ import { icon } from "@mariozechner/mini-lit"; import "@mariozechner/mini-lit/dist/MarkdownBlock.js"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; -import { type AgentTool, type Message, StringEnum, type ToolCall } from "@mariozechner/pi-ai"; +import type { Agent, AgentMessage, AgentTool } from "@mariozechner/pi-agent-core"; +import { StringEnum, type ToolCall } from "@mariozechner/pi-ai"; import { type Static, Type } from "@sinclair/typebox"; import { html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, type Ref, ref } from "lit/directives/ref.js"; import { X } from "lucide"; -import type { Agent } from "../../agent/agent.js"; import type { ArtifactMessage } from "../../components/Messages.js"; import { ArtifactsRuntimeProvider } from "../../components/sandbox/ArtifactsRuntimeProvider.js"; import { AttachmentsRuntimeProvider } from "../../components/sandbox/AttachmentsRuntimeProvider.js"; @@ -85,7 +85,7 @@ export class ArtifactsPanel extends LitElement { if (this.agent) { const attachments: Attachment[] = []; for (const message of this.agent.state.messages) { - if (message.role === "user" && message.attachments) { + if (message.role === "user-with-attachments" && message.attachments) { attachments.push(...message.attachments); } } @@ -292,7 +292,7 @@ export class ArtifactsPanel extends LitElement { // Re-apply artifacts by scanning a message list (optional utility) public async reconstructFromMessages( - messages: Array, + messages: Array, ): Promise { const toolCalls = new Map(); const artifactToolName = "artifacts"; diff --git a/packages/web-ui/src/tools/extract-document.ts b/packages/web-ui/src/tools/extract-document.ts index 73eddcb4..b733c7ee 100644 --- a/packages/web-ui/src/tools/extract-document.ts +++ b/packages/web-ui/src/tools/extract-document.ts @@ -1,4 +1,5 @@ -import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { ToolResultMessage } from "@mariozechner/pi-ai"; import { type Static, Type } from "@sinclair/typebox"; import { html } from "lit"; import { createRef, ref } from "lit/directives/ref.js"; diff --git a/packages/web-ui/src/tools/javascript-repl.ts b/packages/web-ui/src/tools/javascript-repl.ts index 11e9233c..c42ed9e7 100644 --- a/packages/web-ui/src/tools/javascript-repl.ts +++ b/packages/web-ui/src/tools/javascript-repl.ts @@ -1,5 +1,6 @@ import { i18n } from "@mariozechner/mini-lit"; -import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { ToolResultMessage } from "@mariozechner/pi-ai"; import { type Static, Type } from "@sinclair/typebox"; import { html } from "lit"; import { createRef, ref } from "lit/directives/ref.js"; From 7a39f9eb111bc9a39375cce39b6b699a74681a2d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:01:12 +0100 Subject: [PATCH 061/161] Fix web-ui and example for new agent API - AgentInterface composes UserMessageWithAttachments for attachments - Updated example to use convertToLlm instead of transport/messageTransformer - Fixed declaration merging: target pi-agent-core, add path to example tsconfig - Fixed typo in CustomAgentMessages key (user-with-attachment -> user-with-attachments) - customMessageTransformer properly converts UserMessageWithAttachments to content blocks --- .../web-ui/example/src/custom-messages.ts | 59 +++++++++++++++---- packages/web-ui/example/src/main.ts | 15 ++--- packages/web-ui/example/tsconfig.json | 1 + .../web-ui/src/components/AgentInterface.ts | 14 ++++- .../web-ui/src/components/MessageEditor.ts | 3 +- packages/web-ui/src/components/Messages.ts | 2 +- 6 files changed, 70 insertions(+), 24 deletions(-) diff --git a/packages/web-ui/example/src/custom-messages.ts b/packages/web-ui/example/src/custom-messages.ts index 8cb3bdfe..31c5d3fd 100644 --- a/packages/web-ui/example/src/custom-messages.ts +++ b/packages/web-ui/example/src/custom-messages.ts @@ -1,6 +1,6 @@ import { Alert } from "@mariozechner/mini-lit/dist/Alert.js"; -import type { Message } from "@mariozechner/pi-ai"; -import type { AppMessage, MessageRenderer } from "@mariozechner/pi-web-ui"; +import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai"; +import type { AgentMessage, Attachment, MessageRenderer, UserMessageWithAttachments } from "@mariozechner/pi-web-ui"; import { registerMessageRenderer } from "@mariozechner/pi-web-ui"; import { html } from "lit"; @@ -16,8 +16,9 @@ export interface SystemNotificationMessage { timestamp: string; } -// Extend CustomMessages interface via declaration merging -declare module "@mariozechner/pi-web-ui" { +// Extend CustomAgentMessages interface via declaration merging +// This must target pi-agent-core where CustomAgentMessages is defined +declare module "@mariozechner/pi-agent-core" { interface CustomAgentMessages { "system-notification": SystemNotificationMessage; } @@ -74,8 +75,28 @@ export function createSystemNotification( // 5. CUSTOM MESSAGE TRANSFORMER // ============================================================================ -// Transform custom messages to user messages with tags so LLM can see them -export function customMessageTransformer(messages: AppMessage[]): Message[] { +// Convert attachments to content blocks +function convertAttachments(attachments: Attachment[]): (TextContent | ImageContent)[] { + const content: (TextContent | ImageContent)[] = []; + for (const attachment of attachments) { + if (attachment.type === "image") { + content.push({ + type: "image", + data: attachment.content, + mimeType: attachment.mimeType, + } as ImageContent); + } else if (attachment.type === "document" && attachment.extractedText) { + content.push({ + type: "text", + text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`, + } as TextContent); + } + } + return content; +} + +// Transform custom messages to LLM-compatible messages +export function customMessageTransformer(messages: AgentMessage[]): Message[] { return messages .filter((m) => { // Filter out artifact messages - they're for session reconstruction only @@ -85,7 +106,11 @@ export function customMessageTransformer(messages: AppMessage[]): Message[] { // Keep LLM-compatible messages + custom messages return ( - m.role === "user" || m.role === "assistant" || m.role === "toolResult" || m.role === "system-notification" + m.role === "user" || + m.role === "user-with-attachments" || + m.role === "assistant" || + m.role === "toolResult" || + m.role === "system-notification" ); }) .map((m) => { @@ -95,13 +120,25 @@ export function customMessageTransformer(messages: AppMessage[]): Message[] { return { role: "user", content: `${notification.message}`, + timestamp: Date.now(), } as Message; } - // Strip attachments from user messages - if (m.role === "user-with-attachment") { - const { attachments: _, ...rest } = m; - return rest as Message; + // Convert user-with-attachments to user message with content blocks + if (m.role === "user-with-attachments") { + const msg = m as UserMessageWithAttachments; + const textContent: (TextContent | ImageContent)[] = + typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : [...msg.content]; + + if (msg.attachments) { + textContent.push(...convertAttachments(msg.attachments)); + } + + return { + role: "user", + content: textContent, + timestamp: msg.timestamp, + } as Message; } return m as Message; diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index ef49faba..376d6b36 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -1,10 +1,9 @@ import "@mariozechner/mini-lit/dist/ThemeToggle.js"; +import { Agent, type AgentMessage } from "@mariozechner/pi-agent-core"; import { getModel } from "@mariozechner/pi-ai"; import { - Agent, type AgentState, ApiKeyPromptDialog, - type AppMessage, AppStorage, ChatPanel, CustomProvidersStore, @@ -13,7 +12,6 @@ import { // PersistentStorageDialog, // TODO: Fix - currently broken ProviderKeysStore, ProvidersModelsTab, - ProviderTransport, ProxyTab, SessionListDialog, SessionsStore, @@ -75,7 +73,7 @@ let agent: Agent; let chatPanel: ChatPanel; let agentUnsubscribe: (() => void) | undefined; -const generateTitle = (messages: AppMessage[]): string => { +const generateTitle = (messages: AgentMessage[]): string => { const firstUserMsg = messages.find((m) => m.role === "user"); if (!firstUserMsg || firstUserMsg.role !== "user") return ""; @@ -99,7 +97,7 @@ const generateTitle = (messages: AppMessage[]): string => { return text.length <= 50 ? text : `${text.substring(0, 47)}...`; }; -const shouldSaveSession = (messages: AppMessage[]): boolean => { +const shouldSaveSession = (messages: AgentMessage[]): boolean => { const hasUserMsg = messages.some((m: any) => m.role === "user"); const hasAssistantMsg = messages.some((m: any) => m.role === "assistant"); return hasUserMsg && hasAssistantMsg; @@ -166,8 +164,6 @@ const createAgent = async (initialState?: Partial) => { agentUnsubscribe(); } - const transport = new ProviderTransport(); - agent = new Agent({ initialState: initialState || { systemPrompt: `You are a helpful AI assistant with access to various tools. @@ -182,9 +178,8 @@ Feel free to use these tools when needed to provide accurate and helpful respons messages: [], tools: [], }, - transport, - // Custom transformer: convert system notifications to user messages with tags - messageTransformer: customMessageTransformer, + // Custom transformer: convert custom messages to LLM-compatible format + convertToLlm: customMessageTransformer, }); agentUnsubscribe = agent.subscribe((event: any) => { diff --git a/packages/web-ui/example/tsconfig.json b/packages/web-ui/example/tsconfig.json index 340e2439..e095a279 100644 --- a/packages/web-ui/example/tsconfig.json +++ b/packages/web-ui/example/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "bundler", "paths": { "*": ["./*"], + "@mariozechner/pi-agent-core": ["../../agent/dist/index.d.ts"], "@mariozechner/pi-ai": ["../../ai/dist/index.d.ts"], "@mariozechner/pi-tui": ["../../tui/dist/index.d.ts"], "@mariozechner/pi-web-ui": ["../dist/index.d.ts"] diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 6ee33126..210ea714 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -12,6 +12,7 @@ import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core"; import type { Attachment } from "../utils/attachment-utils.js"; import { formatUsage } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; +import type { UserMessageWithAttachments } from "./Messages.js"; import type { StreamingMessageContainer } from "./StreamingMessageContainer.js"; @customElement("agent-interface") @@ -202,7 +203,18 @@ export class AgentInterface extends LitElement { this._messageEditor.attachments = []; this._autoScroll = true; // Enable auto-scroll when sending a message - await this.session?.prompt(input, attachments); + // Compose message with attachments if any + if (attachments && attachments.length > 0) { + const message: UserMessageWithAttachments = { + role: "user-with-attachments", + content: input, + attachments, + timestamp: Date.now(), + }; + await this.session?.prompt(message); + } else { + await this.session?.prompt(input); + } } private renderMessages() { diff --git a/packages/web-ui/src/components/MessageEditor.ts b/packages/web-ui/src/components/MessageEditor.ts index ae286d05..78e44230 100644 --- a/packages/web-ui/src/components/MessageEditor.ts +++ b/packages/web-ui/src/components/MessageEditor.ts @@ -9,6 +9,7 @@ import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide"; import { type Attachment, loadAttachment } from "../utils/attachment-utils.js"; import { i18n } from "../utils/i18n.js"; import "./AttachmentTile.js"; +import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; @customElement("message-editor") export class MessageEditor extends LitElement { @@ -28,7 +29,7 @@ export class MessageEditor extends LitElement { @property() isStreaming = false; @property() currentModel?: Model; - @property() thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" = "off"; + @property() thinkingLevel: ThinkingLevel = "off"; @property() showAttachmentButton = true; @property() showModelSelector = true; @property() showThinkingSelector = true; diff --git a/packages/web-ui/src/components/Messages.ts b/packages/web-ui/src/components/Messages.ts index 5588005c..35637a30 100644 --- a/packages/web-ui/src/components/Messages.ts +++ b/packages/web-ui/src/components/Messages.ts @@ -34,7 +34,7 @@ export interface ArtifactMessage { declare module "@mariozechner/pi-agent-core" { interface CustomAgentMessages { - "user-with-attachment": UserMessageWithAttachments; + "user-with-attachments": UserMessageWithAttachments; artifact: ArtifactMessage; } } From 13a1991ec205c9d0745ebd02c5d7868291e44092 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:06:26 +0100 Subject: [PATCH 062/161] Add defaultConvertToLlm to web-ui, simplify example - web-ui exports: defaultConvertToLlm, convertAttachments, isUserMessageWithAttachments, isArtifactMessage - defaultConvertToLlm handles UserMessageWithAttachments and filters ArtifactMessage - Example's customMessageTransformer now extends defaultConvertToLlm - Removes duplicated attachment conversion logic from example --- .../web-ui/example/src/custom-messages.ts | 91 +++++-------------- packages/web-ui/example/src/main.ts | 6 +- packages/web-ui/src/components/MessageList.ts | 2 +- packages/web-ui/src/components/Messages.ts | 90 ++++++++++++++++++ .../components/StreamingMessageContainer.ts | 2 +- packages/web-ui/src/index.ts | 12 ++- 6 files changed, 127 insertions(+), 76 deletions(-) diff --git a/packages/web-ui/example/src/custom-messages.ts b/packages/web-ui/example/src/custom-messages.ts index 31c5d3fd..ef9544fe 100644 --- a/packages/web-ui/example/src/custom-messages.ts +++ b/packages/web-ui/example/src/custom-messages.ts @@ -1,7 +1,7 @@ import { Alert } from "@mariozechner/mini-lit/dist/Alert.js"; -import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai"; -import type { AgentMessage, Attachment, MessageRenderer, UserMessageWithAttachments } from "@mariozechner/pi-web-ui"; -import { registerMessageRenderer } from "@mariozechner/pi-web-ui"; +import type { Message } from "@mariozechner/pi-ai"; +import type { AgentMessage, MessageRenderer } from "@mariozechner/pi-web-ui"; +import { defaultConvertToLlm, registerMessageRenderer } from "@mariozechner/pi-web-ui"; import { html } from "lit"; // ============================================================================ @@ -75,72 +75,25 @@ export function createSystemNotification( // 5. CUSTOM MESSAGE TRANSFORMER // ============================================================================ -// Convert attachments to content blocks -function convertAttachments(attachments: Attachment[]): (TextContent | ImageContent)[] { - const content: (TextContent | ImageContent)[] = []; - for (const attachment of attachments) { - if (attachment.type === "image") { - content.push({ - type: "image", - data: attachment.content, - mimeType: attachment.mimeType, - } as ImageContent); - } else if (attachment.type === "document" && attachment.extractedText) { - content.push({ - type: "text", - text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`, - } as TextContent); - } - } - return content; -} - -// Transform custom messages to LLM-compatible messages +/** + * Custom message transformer that extends defaultConvertToLlm. + * Handles system-notification messages by converting them to user messages. + */ export function customMessageTransformer(messages: AgentMessage[]): Message[] { - return messages - .filter((m) => { - // Filter out artifact messages - they're for session reconstruction only - if (m.role === "artifact") { - return false; - } + // First, handle our custom system-notification type + const processed = messages.map((m): AgentMessage => { + if (m.role === "system-notification") { + const notification = m as SystemNotificationMessage; + // Convert to user message with tags + return { + role: "user", + content: `${notification.message}`, + timestamp: Date.now(), + }; + } + return m; + }); - // Keep LLM-compatible messages + custom messages - return ( - m.role === "user" || - m.role === "user-with-attachments" || - m.role === "assistant" || - m.role === "toolResult" || - m.role === "system-notification" - ); - }) - .map((m) => { - // Transform system notifications to user messages - if (m.role === "system-notification") { - const notification = m as SystemNotificationMessage; - return { - role: "user", - content: `${notification.message}`, - timestamp: Date.now(), - } as Message; - } - - // Convert user-with-attachments to user message with content blocks - if (m.role === "user-with-attachments") { - const msg = m as UserMessageWithAttachments; - const textContent: (TextContent | ImageContent)[] = - typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : [...msg.content]; - - if (msg.attachments) { - textContent.push(...convertAttachments(msg.attachments)); - } - - return { - role: "user", - content: textContent, - timestamp: msg.timestamp, - } as Message; - } - - return m as Message; - }); + // Then use defaultConvertToLlm for standard handling + return defaultConvertToLlm(processed); } diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 376d6b36..c233517e 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -74,8 +74,8 @@ let chatPanel: ChatPanel; let agentUnsubscribe: (() => void) | undefined; const generateTitle = (messages: AgentMessage[]): string => { - const firstUserMsg = messages.find((m) => m.role === "user"); - if (!firstUserMsg || firstUserMsg.role !== "user") return ""; + const firstUserMsg = messages.find((m) => m.role === "user" || m.role === "user-with-attachments"); + if (!firstUserMsg || (firstUserMsg.role !== "user" && firstUserMsg.role !== "user-with-attachments")) return ""; let text = ""; const content = firstUserMsg.content; @@ -98,7 +98,7 @@ const generateTitle = (messages: AgentMessage[]): string => { }; const shouldSaveSession = (messages: AgentMessage[]): boolean => { - const hasUserMsg = messages.some((m: any) => m.role === "user"); + const hasUserMsg = messages.some((m: any) => m.role === "user" || m.role === "user-with-attachments"); const hasAssistantMsg = messages.some((m: any) => m.role === "assistant"); return hasUserMsg && hasAssistantMsg; }; diff --git a/packages/web-ui/src/components/MessageList.ts b/packages/web-ui/src/components/MessageList.ts index 2586bf58..97670ba3 100644 --- a/packages/web-ui/src/components/MessageList.ts +++ b/packages/web-ui/src/components/MessageList.ts @@ -50,7 +50,7 @@ export class MessageList extends LitElement { } // Fall back to built-in renderers - if (msg.role === "user") { + if (msg.role === "user" || msg.role === "user-with-attachments") { items.push({ key: `msg:${index}`, template: html``, diff --git a/packages/web-ui/src/components/Messages.ts b/packages/web-ui/src/components/Messages.ts index 35637a30..7d68ff4a 100644 --- a/packages/web-ui/src/components/Messages.ts +++ b/packages/web-ui/src/components/Messages.ts @@ -285,3 +285,93 @@ export class AbortedMessage extends LitElement { return html`${i18n("Request aborted")}`; } } + +// ============================================================================ +// Default Message Transformer +// ============================================================================ + +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { Message } from "@mariozechner/pi-ai"; + +/** + * Convert attachments to content blocks for LLM. + * - Images become ImageContent blocks + * - Documents with extractedText become TextContent blocks with filename header + */ +export function convertAttachments(attachments: Attachment[]): (TextContent | ImageContent)[] { + const content: (TextContent | ImageContent)[] = []; + for (const attachment of attachments) { + if (attachment.type === "image") { + content.push({ + type: "image", + data: attachment.content, + mimeType: attachment.mimeType, + } as ImageContent); + } else if (attachment.type === "document" && attachment.extractedText) { + content.push({ + type: "text", + text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`, + } as TextContent); + } + } + return content; +} + +/** + * Check if a message is a UserMessageWithAttachments. + */ +export function isUserMessageWithAttachments(msg: AgentMessage): msg is UserMessageWithAttachments { + return (msg as UserMessageWithAttachments).role === "user-with-attachments"; +} + +/** + * Check if a message is an ArtifactMessage. + */ +export function isArtifactMessage(msg: AgentMessage): msg is ArtifactMessage { + return (msg as ArtifactMessage).role === "artifact"; +} + +/** + * Default convertToLlm for web-ui apps. + * + * Handles: + * - UserMessageWithAttachments: converts to user message with content blocks + * - ArtifactMessage: filtered out (UI-only, for session reconstruction) + * - Standard LLM messages (user, assistant, toolResult): passed through + */ +export function defaultConvertToLlm(messages: AgentMessage[]): Message[] { + return messages + .filter((m) => { + // Filter out artifact messages - they're for session reconstruction only + if (isArtifactMessage(m)) { + return false; + } + return true; + }) + .map((m): Message | null => { + // Convert user-with-attachments to user message with content blocks + if (isUserMessageWithAttachments(m)) { + const textContent: (TextContent | ImageContent)[] = + typeof m.content === "string" ? [{ type: "text", text: m.content }] : [...m.content]; + + if (m.attachments) { + textContent.push(...convertAttachments(m.attachments)); + } + + return { + role: "user", + content: textContent, + timestamp: m.timestamp, + } as Message; + } + + // Pass through standard LLM roles + if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") { + return m as Message; + } + + // Filter out unknown message types + return null; + }) + .filter((m): m is Message => m !== null); +} diff --git a/packages/web-ui/src/components/StreamingMessageContainer.ts b/packages/web-ui/src/components/StreamingMessageContainer.ts index d3703dea..3d269315 100644 --- a/packages/web-ui/src/components/StreamingMessageContainer.ts +++ b/packages/web-ui/src/components/StreamingMessageContainer.ts @@ -74,7 +74,7 @@ export class StreamingMessageContainer extends LitElement { if (msg.role === "toolResult") { // Skip standalone tool result in streaming; the stable list will render paired tool-message return html``; - } else if (msg.role === "user") { + } else if (msg.role === "user" || msg.role === "user-with-attachments") { // Skip standalone tool result in streaming; the stable list will render it immediiately return html``; } else if (msg.role === "assistant") { diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index e6c9e808..6c9fd4d5 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -12,8 +12,16 @@ export { Input } from "./components/Input.js"; export { MessageEditor } from "./components/MessageEditor.js"; export { MessageList } from "./components/MessageList.js"; // Message components -export type { UserMessageWithAttachments } from "./components/Messages.js"; -export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js"; +export type { ArtifactMessage, UserMessageWithAttachments } from "./components/Messages.js"; +export { + AssistantMessage, + convertAttachments, + defaultConvertToLlm, + isArtifactMessage, + isUserMessageWithAttachments, + ToolMessage, + UserMessage, +} from "./components/Messages.js"; // Message renderer registry export { getMessageRenderer, From 6a6d1a59f4f25cd6b5c95fd96e41d6287d7efe13 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:07:22 +0100 Subject: [PATCH 063/161] Rename customMessageTransformer to customConvertToLlm --- packages/web-ui/example/src/custom-messages.ts | 2 +- packages/web-ui/example/src/main.ts | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/web-ui/example/src/custom-messages.ts b/packages/web-ui/example/src/custom-messages.ts index ef9544fe..d5700b60 100644 --- a/packages/web-ui/example/src/custom-messages.ts +++ b/packages/web-ui/example/src/custom-messages.ts @@ -79,7 +79,7 @@ export function createSystemNotification( * Custom message transformer that extends defaultConvertToLlm. * Handles system-notification messages by converting them to user messages. */ -export function customMessageTransformer(messages: AgentMessage[]): Message[] { +export function customConvertToLlm(messages: AgentMessage[]): Message[] { // First, handle our custom system-notification type const processed = messages.map((m): AgentMessage => { if (m.role === "system-notification") { diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index c233517e..f0b5425e 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -25,11 +25,7 @@ import "./app.css"; import { icon } from "@mariozechner/mini-lit"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { Input } from "@mariozechner/mini-lit/dist/Input.js"; -import { - createSystemNotification, - customMessageTransformer, - registerCustomMessageRenderers, -} from "./custom-messages.js"; +import { createSystemNotification, customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js"; // Register custom message renderers registerCustomMessageRenderers(); @@ -179,7 +175,7 @@ Feel free to use these tools when needed to provide accurate and helpful respons tools: [], }, // Custom transformer: convert custom messages to LLM-compatible format - convertToLlm: customMessageTransformer, + convertToLlm: customConvertToLlm, }); agentUnsubscribe = agent.subscribe((event: any) => { From c10fc1e08164ffff7f7516285f4a35800d21ae39 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:14:27 +0100 Subject: [PATCH 064/161] Use Web Crypto API for OAuth PKCE, remove Node.js crypto dependency - Add pkce.ts with generatePKCE() using Web Crypto API - Update anthropic.ts, google-gemini-cli.ts, google-antigravity.ts - Replace Buffer.from() with atob() for base64 decoding - Works in both Node.js 20+ and browsers The OAuth modules still use Node.js http.createServer for callbacks, so they only work in CLI environments, but they no longer crash on import in browser bundles. --- packages/ai/src/utils/oauth/anthropic.ts | 15 ++------ .../ai/src/utils/oauth/google-antigravity.ts | 18 ++++------ .../ai/src/utils/oauth/google-gemini-cli.ts | 18 ++++------ packages/ai/src/utils/oauth/pkce.ts | 34 +++++++++++++++++++ 4 files changed, 49 insertions(+), 36 deletions(-) create mode 100644 packages/ai/src/utils/oauth/pkce.ts diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index ea772e94..74a2228c 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -2,25 +2,16 @@ * Anthropic OAuth flow (Claude Pro/Max) */ -import { createHash, randomBytes } from "crypto"; +import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials } from "./types.js"; -const decode = (s: string) => Buffer.from(s, "base64").toString(); +const decode = (s: string) => atob(s); const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; const SCOPES = "org:create_api_key user:profile user:inference"; -/** - * Generate PKCE code verifier and challenge - */ -function generatePKCE(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - /** * Login with Anthropic OAuth (device code flow) * @@ -31,7 +22,7 @@ export async function loginAnthropic( onAuthUrl: (url: string) => void, onPromptCode: () => Promise, ): Promise { - const { verifier, challenge } = generatePKCE(); + const { verifier, challenge } = await generatePKCE(); // Build authorization URL const authParams = new URLSearchParams({ diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/utils/oauth/google-antigravity.ts index 80deb402..20ad3e17 100644 --- a/packages/ai/src/utils/oauth/google-antigravity.ts +++ b/packages/ai/src/utils/oauth/google-antigravity.ts @@ -1,14 +1,17 @@ /** * Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud) * Uses different OAuth credentials than google-gemini-cli for access to additional models. + * + * NOTE: This module uses Node.js http.createServer for the OAuth callback. + * It is only intended for CLI use, not browser environments. */ -import { createHash, randomBytes } from "crypto"; import { createServer, type Server } from "http"; +import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials } from "./types.js"; // Antigravity OAuth credentials (different from Gemini CLI) -const decode = (s: string) => Buffer.from(s, "base64").toString(); +const decode = (s: string) => atob(s); const CLIENT_ID = decode( "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", ); @@ -30,15 +33,6 @@ const TOKEN_URL = "https://oauth2.googleapis.com/token"; // Fallback project ID when discovery fails const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; -/** - * Generate PKCE code verifier and challenge - */ -function generatePKCE(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - /** * Start a local HTTP server to receive the OAuth callback */ @@ -232,7 +226,7 @@ export async function loginAntigravity( onAuth: (info: { url: string; instructions?: string }) => void, onProgress?: (message: string) => void, ): Promise { - const { verifier, challenge } = generatePKCE(); + const { verifier, challenge } = await generatePKCE(); // Start local server for callback onProgress?.("Starting local server for OAuth callback..."); diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/utils/oauth/google-gemini-cli.ts index 6c06d375..68a80492 100644 --- a/packages/ai/src/utils/oauth/google-gemini-cli.ts +++ b/packages/ai/src/utils/oauth/google-gemini-cli.ts @@ -1,13 +1,16 @@ /** * Gemini CLI OAuth flow (Google Cloud Code Assist) * Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*) + * + * NOTE: This module uses Node.js http.createServer for the OAuth callback. + * It is only intended for CLI use, not browser environments. */ -import { createHash, randomBytes } from "crypto"; import { createServer, type Server } from "http"; +import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials } from "./types.js"; -const decode = (s: string) => Buffer.from(s, "base64").toString(); +const decode = (s: string) => atob(s); const CLIENT_ID = decode( "NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t", ); @@ -22,15 +25,6 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL = "https://oauth2.googleapis.com/token"; const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; -/** - * Generate PKCE code verifier and challenge - */ -function generatePKCE(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - /** * Start a local HTTP server to receive the OAuth callback */ @@ -263,7 +257,7 @@ export async function loginGeminiCli( onAuth: (info: { url: string; instructions?: string }) => void, onProgress?: (message: string) => void, ): Promise { - const { verifier, challenge } = generatePKCE(); + const { verifier, challenge } = await generatePKCE(); // Start local server for callback onProgress?.("Starting local server for OAuth callback..."); diff --git a/packages/ai/src/utils/oauth/pkce.ts b/packages/ai/src/utils/oauth/pkce.ts new file mode 100644 index 00000000..bf7ac7d5 --- /dev/null +++ b/packages/ai/src/utils/oauth/pkce.ts @@ -0,0 +1,34 @@ +/** + * PKCE utilities using Web Crypto API. + * Works in both Node.js 20+ and browsers. + */ + +/** + * Encode bytes as base64url string. + */ +function base64urlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +/** + * Generate PKCE code verifier and challenge. + * Uses Web Crypto API for cross-platform compatibility. + */ +export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + // Generate random verifier + const verifierBytes = new Uint8Array(32); + crypto.getRandomValues(verifierBytes); + const verifier = base64urlEncode(verifierBytes); + + // Compute SHA-256 challenge + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const challenge = base64urlEncode(new Uint8Array(hashBuffer)); + + return { verifier, challenge }; +} From 481bc79f83cadbdf765146853bdf66d895ae0ea6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:15:26 +0100 Subject: [PATCH 065/161] Fix Buffer usage in github-copilot.ts - use atob() --- packages/ai/src/utils/oauth/github-copilot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index e74901d0..744d9f18 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -5,7 +5,7 @@ import { getModels } from "../../models.js"; import type { OAuthCredentials } from "./types.js"; -const decode = (s: string) => Buffer.from(s, "base64").toString(); +const decode = (s: string) => atob(s); const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg="); const COPILOT_HEADERS = { From 3bf168615a9db0bbbddc3a7112caae5cc21bff14 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:17:08 +0100 Subject: [PATCH 066/161] Use dynamic import for http module in OAuth files Prevents browser bundles from failing on import. The http module is only loaded when login functions are actually called. --- packages/ai/src/utils/oauth/google-antigravity.ts | 9 +++++++-- packages/ai/src/utils/oauth/google-gemini-cli.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/utils/oauth/google-antigravity.ts index 20ad3e17..16f45c51 100644 --- a/packages/ai/src/utils/oauth/google-antigravity.ts +++ b/packages/ai/src/utils/oauth/google-antigravity.ts @@ -6,7 +6,7 @@ * It is only intended for CLI use, not browser environments. */ -import { createServer, type Server } from "http"; +import type { Server } from "http"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials } from "./types.js"; @@ -36,7 +36,12 @@ const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; /** * Start a local HTTP server to receive the OAuth callback */ -function startCallbackServer(): Promise<{ server: Server; getCode: () => Promise<{ code: string; state: string }> }> { +async function startCallbackServer(): Promise<{ + server: Server; + getCode: () => Promise<{ code: string; state: string }>; +}> { + const { createServer } = await import("http"); + return new Promise((resolve, reject) => { let codeResolve: (value: { code: string; state: string }) => void; let codeReject: (error: Error) => void; diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/utils/oauth/google-gemini-cli.ts index 68a80492..d3585d15 100644 --- a/packages/ai/src/utils/oauth/google-gemini-cli.ts +++ b/packages/ai/src/utils/oauth/google-gemini-cli.ts @@ -6,7 +6,7 @@ * It is only intended for CLI use, not browser environments. */ -import { createServer, type Server } from "http"; +import type { Server } from "http"; import { generatePKCE } from "./pkce.js"; import type { OAuthCredentials } from "./types.js"; @@ -28,7 +28,12 @@ const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; /** * Start a local HTTP server to receive the OAuth callback */ -function startCallbackServer(): Promise<{ server: Server; getCode: () => Promise<{ code: string; state: string }> }> { +async function startCallbackServer(): Promise<{ + server: Server; + getCode: () => Promise<{ code: string; state: string }>; +}> { + const { createServer } = await import("http"); + return new Promise((resolve, reject) => { let codeResolve: (value: { code: string; state: string }) => void; let codeReject: (error: Error) => void; From 977e4ea6ef3c981bd53a7eb72d1bc4c99215e0d4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:18:54 +0100 Subject: [PATCH 067/161] Add getApiKey to web-ui example Agent config Provides API keys from the provider keys store, matching old ProviderTransport behavior. --- packages/web-ui/example/src/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index f0b5425e..c2cf293e 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -176,6 +176,11 @@ Feel free to use these tools when needed to provide accurate and helpful respons }, // Custom transformer: convert custom messages to LLM-compatible format convertToLlm: customConvertToLlm, + // Get API keys from provider keys store + getApiKey: async (provider: string) => { + const key = await storage.providerKeys.get(provider); + return key ?? undefined; + }, }); agentUnsubscribe = agent.subscribe((event: any) => { From 347d4cf72932648a3ff2e562d15393a9774b5869 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:22:56 +0100 Subject: [PATCH 068/161] Fix AgentInterface to requestUpdate on all message lifecycle events Was only handling message_update, now also handles message_start, message_end, turn_start, turn_end, agent_start, agent_end. --- packages/ai/src/models.generated.ts | 114 +++++++++--------- .../web-ui/src/components/AgentInterface.ts | 24 ++-- 2 files changed, 74 insertions(+), 64 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 2df269d3..6392308c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,23 +6359,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6410,6 +6393,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,23 +6546,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6597,6 +6580,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 210ea714..10b2b2bc 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -131,13 +131,23 @@ export class AgentInterface extends LitElement { } if (!this.session) return; this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => { - if (ev.type === "message_update") { - if (this._streamingContainer) { - const isStreaming = this.session?.state.isStreaming || false; - this._streamingContainer.isStreaming = isStreaming; - this._streamingContainer.setMessage(ev.message, !isStreaming); - } - this.requestUpdate(); + switch (ev.type) { + case "message_start": + case "message_end": + case "turn_start": + case "turn_end": + case "agent_start": + case "agent_end": + this.requestUpdate(); + break; + case "message_update": + if (this._streamingContainer) { + const isStreaming = this.session?.state.isStreaming || false; + this._streamingContainer.isStreaming = isStreaming; + this._streamingContainer.setMessage(ev.message, !isStreaming); + } + this.requestUpdate(); + break; } }); } From 6972ce4e877b70dde58bcb48fb2dfff2cbf828c0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:24:16 +0100 Subject: [PATCH 069/161] Clear streaming container on agent_end --- packages/web-ui/src/components/AgentInterface.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 10b2b2bc..92b9352b 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -137,7 +137,14 @@ export class AgentInterface extends LitElement { case "turn_start": case "turn_end": case "agent_start": + this.requestUpdate(); + break; case "agent_end": + // Clear streaming container when agent finishes + if (this._streamingContainer) { + this._streamingContainer.isStreaming = false; + this._streamingContainer.setMessage(null, true); + } this.requestUpdate(); break; case "message_update": From e49e7873221e20fa71117a44c22d426d5119ca8c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:25:50 +0100 Subject: [PATCH 070/161] Set isStreaming=false on agent_end event, before emitting to listeners Was only setting in finally block, after event was emitted. --- packages/agent/src/agent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index b87c582d..dec35373 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -306,6 +306,7 @@ export class Agent { break; case "agent_end": + this._state.isStreaming = false; this._state.streamMessage = null; break; } From e0be2e650db0e33c0e3d7e3d79437a49c0e0ba41 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:37:42 +0100 Subject: [PATCH 071/161] Add createStreamFn for CORS proxy support - createStreamFn(proxyUrl?) returns a sync streamFn that applies proxy - Example reads proxy settings once when creating Agent - Matches old ProviderTransport behavior --- packages/web-ui/example/src/main.ts | 7 +++++++ packages/web-ui/src/index.ts | 1 + packages/web-ui/src/utils/proxy-utils.ts | 21 ++++++++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index c2cf293e..2a87c9c5 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -8,6 +8,7 @@ import { ChatPanel, CustomProvidersStore, createJavaScriptReplTool, + createStreamFn, IndexedDBStorageBackend, // PersistentStorageDialog, // TODO: Fix - currently broken ProviderKeysStore, @@ -160,6 +161,10 @@ const createAgent = async (initialState?: Partial) => { agentUnsubscribe(); } + // Read proxy settings for streamFn + const proxyEnabled = await storage.settings.get("proxy.enabled"); + const proxyUrl = proxyEnabled ? (await storage.settings.get("proxy.url")) || undefined : undefined; + agent = new Agent({ initialState: initialState || { systemPrompt: `You are a helpful AI assistant with access to various tools. @@ -181,6 +186,8 @@ Feel free to use these tools when needed to provide accurate and helpful respons const key = await storage.providerKeys.get(provider); return key ?? undefined; }, + // Use streamFn with CORS proxy support + streamFn: createStreamFn(proxyUrl), }); agentUnsubscribe = agent.subscribe((event: any) => { diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 6c9fd4d5..cff990b0 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -112,3 +112,4 @@ export { loadAttachment } from "./utils/attachment-utils.js"; export { clearAuthToken, getAuthToken } from "./utils/auth-token.js"; export { formatCost, formatModelCost, formatTokenCount, formatUsage } from "./utils/format.js"; export { i18n, setLanguage, translations } from "./utils/i18n.js"; +export { applyProxyIfNeeded, createStreamFn, isCorsError, shouldUseProxyForProvider } from "./utils/proxy-utils.js"; diff --git a/packages/web-ui/src/utils/proxy-utils.ts b/packages/web-ui/src/utils/proxy-utils.ts index 65c68e57..0792809a 100644 --- a/packages/web-ui/src/utils/proxy-utils.ts +++ b/packages/web-ui/src/utils/proxy-utils.ts @@ -1,4 +1,5 @@ -import type { Api, Model } from "@mariozechner/pi-ai"; +import type { Api, Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; +import { streamSimple } from "@mariozechner/pi-ai"; /** * Centralized proxy decision logic. @@ -110,3 +111,21 @@ export function isCorsError(error: unknown): boolean { return false; } + +/** + * Create a streamFn that applies CORS proxy when needed. + * + * @param proxyUrl - CORS proxy URL, or undefined to disable + * @returns A streamFn compatible with Agent's streamFn option + */ +export function createStreamFn(proxyUrl?: string) { + return (model: Model, context: Context, options?: SimpleStreamOptions) => { + const apiKey = options?.apiKey; + if (!apiKey || !proxyUrl) { + return streamSimple(model, context, options); + } + + const proxiedModel = applyProxyIfNeeded(model, apiKey, proxyUrl); + return streamSimple(proxiedModel, context, options); + }; +} From 92898f486b2c3f67ceeee6707b2b79374be4eff4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:39:46 +0100 Subject: [PATCH 072/161] Allow async streamFn for dynamic proxy settings - StreamFn type now allows returning Promise - agent-loop awaits streamFn result - createStreamFn takes getProxyUrl callback, reads settings on each call --- packages/agent/src/agent-loop.ts | 2 +- packages/agent/src/types.ts | 5 ++++- packages/web-ui/example/src/main.ts | 11 +++++------ packages/web-ui/src/utils/proxy-utils.ts | 9 ++++++--- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 4927a62a..0fd0d6a0 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -211,7 +211,7 @@ async function streamAssistantResponse( const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; - const response = streamFunction(config.model, llmContext, { + const response = await streamFunction(config.model, llmContext, { ...config, apiKey: resolvedApiKey, signal, diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 1c9c7f2d..e8af618e 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -11,7 +11,10 @@ import type { } from "@mariozechner/pi-ai"; import type { Static, TSchema } from "@sinclair/typebox"; -export type StreamFn = typeof streamSimple; +/** Stream function - can return sync or Promise for async config lookup */ +export type StreamFn = ( + ...args: Parameters +) => ReturnType | Promise>; /** * Configuration for the agent loop. diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 2a87c9c5..3705f9b0 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -161,10 +161,6 @@ const createAgent = async (initialState?: Partial) => { agentUnsubscribe(); } - // Read proxy settings for streamFn - const proxyEnabled = await storage.settings.get("proxy.enabled"); - const proxyUrl = proxyEnabled ? (await storage.settings.get("proxy.url")) || undefined : undefined; - agent = new Agent({ initialState: initialState || { systemPrompt: `You are a helpful AI assistant with access to various tools. @@ -186,8 +182,11 @@ Feel free to use these tools when needed to provide accurate and helpful respons const key = await storage.providerKeys.get(provider); return key ?? undefined; }, - // Use streamFn with CORS proxy support - streamFn: createStreamFn(proxyUrl), + // Use streamFn with CORS proxy support (reads settings on each call) + streamFn: createStreamFn(async () => { + const enabled = await storage.settings.get("proxy.enabled"); + return enabled ? (await storage.settings.get("proxy.url")) || undefined : undefined; + }), }); agentUnsubscribe = agent.subscribe((event: any) => { diff --git a/packages/web-ui/src/utils/proxy-utils.ts b/packages/web-ui/src/utils/proxy-utils.ts index 0792809a..27816b18 100644 --- a/packages/web-ui/src/utils/proxy-utils.ts +++ b/packages/web-ui/src/utils/proxy-utils.ts @@ -114,13 +114,16 @@ export function isCorsError(error: unknown): boolean { /** * Create a streamFn that applies CORS proxy when needed. + * Reads proxy settings from storage on each call. * - * @param proxyUrl - CORS proxy URL, or undefined to disable + * @param getProxyUrl - Async function to get current proxy URL (or undefined if disabled) * @returns A streamFn compatible with Agent's streamFn option */ -export function createStreamFn(proxyUrl?: string) { - return (model: Model, context: Context, options?: SimpleStreamOptions) => { +export function createStreamFn(getProxyUrl: () => Promise) { + return async (model: Model, context: Context, options?: SimpleStreamOptions) => { const apiKey = options?.apiKey; + const proxyUrl = await getProxyUrl(); + if (!apiKey || !proxyUrl) { return streamSimple(model, context, options); } From 4d1936d3dfd9974b4f924537e3242863a28762e4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:42:37 +0100 Subject: [PATCH 073/161] AgentInterface sets default streamFn and getApiKey - Make Agent.streamFn and Agent.getApiKey public - AgentInterface sets defaults if not already set - Example no longer needs to configure these --- packages/agent/src/agent.ts | 4 ++-- packages/web-ui/example/src/main.ts | 11 ---------- .../web-ui/src/components/AgentInterface.ts | 20 ++++++++++++++++++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index dec35373..8d5d7706 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -82,8 +82,8 @@ export class Agent { private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; private messageQueue: AgentMessage[] = []; private queueMode: "all" | "one-at-a-time"; - private streamFn: StreamFn; - private getApiKey?: (provider: string) => Promise | string | undefined; + public streamFn: StreamFn; + public getApiKey?: (provider: string) => Promise | string | undefined; private runningPrompt?: Promise; private resolveRunningPrompt?: () => void; diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 3705f9b0..f0b5425e 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -8,7 +8,6 @@ import { ChatPanel, CustomProvidersStore, createJavaScriptReplTool, - createStreamFn, IndexedDBStorageBackend, // PersistentStorageDialog, // TODO: Fix - currently broken ProviderKeysStore, @@ -177,16 +176,6 @@ Feel free to use these tools when needed to provide accurate and helpful respons }, // Custom transformer: convert custom messages to LLM-compatible format convertToLlm: customConvertToLlm, - // Get API keys from provider keys store - getApiKey: async (provider: string) => { - const key = await storage.providerKeys.get(provider); - return key ?? undefined; - }, - // Use streamFn with CORS proxy support (reads settings on each call) - streamFn: createStreamFn(async () => { - const enabled = await storage.settings.get("proxy.enabled"); - return enabled ? (await storage.settings.get("proxy.url")) || undefined : undefined; - }), }); agentUnsubscribe = agent.subscribe((event: any) => { diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 92b9352b..fd4872a5 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -1,4 +1,4 @@ -import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai"; +import { streamSimple, type ToolResultMessage, type Usage } from "@mariozechner/pi-ai"; import { html, LitElement } from "lit"; import { customElement, property, query } from "lit/decorators.js"; import { ModelSelector } from "../dialogs/ModelSelector.js"; @@ -12,6 +12,7 @@ import type { Agent, AgentEvent } from "@mariozechner/pi-agent-core"; import type { Attachment } from "../utils/attachment-utils.js"; import { formatUsage } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; +import { createStreamFn } from "../utils/proxy-utils.js"; import type { UserMessageWithAttachments } from "./Messages.js"; import type { StreamingMessageContainer } from "./StreamingMessageContainer.js"; @@ -130,6 +131,23 @@ export class AgentInterface extends LitElement { this._unsubscribeSession = undefined; } if (!this.session) return; + + // Set default streamFn with proxy support if not already set + if (this.session.streamFn === streamSimple) { + this.session.streamFn = createStreamFn(async () => { + const enabled = await getAppStorage().settings.get("proxy.enabled"); + return enabled ? (await getAppStorage().settings.get("proxy.url")) || undefined : undefined; + }); + } + + // Set default getApiKey if not already set + if (!this.session.getApiKey) { + this.session.getApiKey = async (provider: string) => { + const key = await getAppStorage().providerKeys.get(provider); + return key ?? undefined; + }; + } + this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => { switch (ev.type) { case "message_start": From b73a9169ccb2f7be18fddaf4422d79b7dfb732de Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:44:21 +0100 Subject: [PATCH 074/161] Add CHANGELOG.md for web-ui package --- packages/web-ui/CHANGELOG.md | 83 ++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 packages/web-ui/CHANGELOG.md diff --git a/packages/web-ui/CHANGELOG.md b/packages/web-ui/CHANGELOG.md new file mode 100644 index 00000000..517fdef1 --- /dev/null +++ b/packages/web-ui/CHANGELOG.md @@ -0,0 +1,83 @@ +# Changelog + +## [Unreleased] + +### Breaking Changes + +- **Agent class moved to `@mariozechner/pi-agent-core`**: The `Agent` class, `AgentState`, and related types are no longer exported from this package. Import them from `@mariozechner/pi-agent-core` instead. + +- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, `AgentTransport` interface, and related types have been removed. The `Agent` class now uses `streamFn` for custom streaming. + +- **`AppMessage` renamed to `AgentMessage`**: Now imported from `@mariozechner/pi-agent-core`. Custom message types use declaration merging on `CustomAgentMessages` interface. + +- **`UserMessageWithAttachments` is now a custom message type**: Has `role: "user-with-attachments"` instead of `role: "user"`. Use `isUserMessageWithAttachments()` type guard. + +- **`CustomMessages` interface removed**: Use declaration merging on `CustomAgentMessages` from `@mariozechner/pi-agent-core` instead. + +### Added + +- **`defaultConvertToLlm`**: Default message transformer that handles `UserMessageWithAttachments` and `ArtifactMessage`. Apps can extend this for custom message types. + +- **`convertAttachments`**: Utility to convert `Attachment[]` to LLM content blocks (images and extracted document text). + +- **`isUserMessageWithAttachments` / `isArtifactMessage`**: Type guard functions for custom message types. + +- **`createStreamFn`**: Creates a stream function with CORS proxy support. Reads proxy settings on each call for dynamic configuration. + +- **Default `streamFn` and `getApiKey`**: `AgentInterface` now sets sensible defaults if not provided: + - `streamFn`: Uses `createStreamFn` with proxy settings from storage + - `getApiKey`: Reads from `providerKeys` storage + +- **Proxy utilities exported**: `applyProxyIfNeeded`, `shouldUseProxyForProvider`, `isCorsError`, `createStreamFn` + +### Removed + +- `Agent` class (moved to `@mariozechner/pi-agent-core`) +- `ProviderTransport` class +- `AppTransport` class +- `AgentTransport` interface +- `AgentRunConfig` type +- `ProxyAssistantMessageEvent` type + +### Migration Guide + +**Before (0.30.x):** +```typescript +import { Agent, ProviderTransport, type AppMessage } from '@mariozechner/pi-web-ui'; + +const agent = new Agent({ + transport: new ProviderTransport(), + messageTransformer: (messages: AppMessage[]) => messages.filter(...) +}); +``` + +**After:** +```typescript +import { Agent, type AgentMessage } from '@mariozechner/pi-agent-core'; +import { defaultConvertToLlm } from '@mariozechner/pi-web-ui'; + +const agent = new Agent({ + convertToLlm: (messages: AgentMessage[]) => { + // Extend defaultConvertToLlm for custom types + return defaultConvertToLlm(messages); + } +}); +// AgentInterface will set streamFn and getApiKey defaults automatically +``` + +**Custom message types:** +```typescript +// Before: declaration merging on CustomMessages +declare module "@mariozechner/pi-web-ui" { + interface CustomMessages { + "my-message": MyMessage; + } +} + +// After: declaration merging on CustomAgentMessages +declare module "@mariozechner/pi-agent-core" { + interface CustomAgentMessages { + "my-message": MyMessage; + } +} +``` From 262ba5487c4b511294e1b82491b7f23310afc785 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:49:41 +0100 Subject: [PATCH 075/161] Use queueMessage for notifications in example --- packages/web-ui/example/src/main.ts | 4 +- packages/web-ui/example/src/test-sessions.ts | 104 ------------------- 2 files changed, 2 insertions(+), 106 deletions(-) delete mode 100644 packages/web-ui/example/src/test-sessions.ts diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index f0b5425e..4a93f409 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -344,9 +344,9 @@ const renderApp = () => { size: "sm", children: icon(Bell, "sm"), onClick: () => { - // Demo: Inject custom message + // Demo: Inject custom message (will appear on next agent run) if (agent) { - agent.appendMessage( + agent.queueMessage( createSystemNotification( "This is a custom message! It appears in the UI but is never sent to the LLM.", ), diff --git a/packages/web-ui/example/src/test-sessions.ts b/packages/web-ui/example/src/test-sessions.ts deleted file mode 100644 index bb4e008e..00000000 --- a/packages/web-ui/example/src/test-sessions.ts +++ /dev/null @@ -1,104 +0,0 @@ -const simpleHtml = { - "systemPrompt": "You are a helpful AI assistant with access to various tools.\n\nAvailable tools:\n- Calculator: Perform mathematical calculations\n- Current Time: Get the current date and time in any timezone\n- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts\n\nFeel free to use these tools when needed to provide accurate and helpful responses.", - "model": { - "id": "claude-sonnet-4-5-20250929", - "name": "Claude Sonnet 4.5", - "api": "anthropic-messages", - "provider": "anthropic", - "baseUrl": "https://api.anthropic.com", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 3, - "output": 15, - "cacheRead": 0.3, - "cacheWrite": 3.75 - }, - "contextWindow": 200000, - "maxTokens": 64000 - }, - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "simple html" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "I'll create a simple HTML page for you!" - }, - { - "type": "toolCall", - "id": "toolu_01JXaq9rMAUt1goJ5tMQXgLh", - "name": "artifacts", - "arguments": { - "command": "create", - "filename": "index.html", - "title": "Simple HTML Page", - "content": "\n\n\n \n \n Simple HTML Page\n \n\n\n
\n

Welcome to My Simple Page

\n
\n \n
\n

Hello!

\n

This is a simple HTML page with some basic styling.

\n \n

Features:

\n
    \n
  • Clean design
  • \n
  • Responsive layout
  • \n
  • Interactive button
  • \n
\n \n \n

\n
\n \n \n\n" - } - } - ], - "api": "anthropic-messages", - "provider": "anthropic", - "model": "claude-sonnet-4-5-20250929", - "usage": { - "input": 2108, - "output": 666, - "cacheRead": 0, - "cacheWrite": 0, - "cost": { - "input": 0.006324, - "output": 0.00999, - "cacheRead": 0, - "cacheWrite": 0, - "total": 0.016314000000000002 - } - }, - "stopReason": "toolUse" - }, - { - "role": "toolResult", - "toolCallId": "toolu_01JXaq9rMAUt1goJ5tMQXgLh", - "toolName": "artifacts", - "output": "Created file index.html\n\nExecution timed out. Partial logs:\nKeine Logs für index.html", - "isError": false - }, - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "I've created a simple HTML page for you! It includes:\n\n- **Clean header** with a green background\n- **Main content area** with a white card-style design\n- **A list** showing some features\n- **An interactive button** that displays a message when clicked\n- **Responsive styling** that looks good on different screen sizes\n\nThe page has a light gray background and uses simple, clean styling. Try clicking the button to see it in action! \n\nWould you like me to modify anything or add more features?" - } - ], - "api": "anthropic-messages", - "provider": "anthropic", - "model": "claude-sonnet-4-5-20250929", - "usage": { - "input": 2811, - "output": 115, - "cacheRead": 0, - "cacheWrite": 0, - "cost": { - "input": 0.008433, - "output": 0.001725, - "cacheRead": 0, - "cacheWrite": 0, - "total": 0.010158 - } - }, - "stopReason": "stop" - } - ] -}; \ No newline at end of file From 6bbe3147d86308cfcfc4328054ebea848969c92c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:55:41 +0100 Subject: [PATCH 076/161] Add release script Usage: npm run release:patch|minor|major Automates: - Check for uncommitted changes - Bump version - Update CHANGELOGs: [Unreleased] -> [version] - date - Commit and tag - Publish to npm - Add new [Unreleased] sections - Push --- package.json | 3 + scripts/release.mjs | 145 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100755 scripts/release.mjs diff --git a/package.json b/package.json index a431f9ed..a6a804d1 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run check", "publish": "npm run prepublishOnly && npm publish -ws --access public", "publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", + "release:patch": "node scripts/release.mjs patch", + "release:minor": "node scripts/release.mjs minor", + "release:major": "node scripts/release.mjs major", "prepare": "husky" }, "devDependencies": { diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100755 index 00000000..12b33d04 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * Release script for pi-mono + * + * Usage: node scripts/release.mjs + * + * Steps: + * 1. Check for uncommitted changes + * 2. Bump version via npm run version:xxx + * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date + * 4. Commit and tag + * 5. Publish to npm + * 6. Add new [Unreleased] section to changelogs + * 7. Commit + */ + +import { execSync } from "child_process"; +import { readFileSync, writeFileSync, readdirSync, existsSync } from "fs"; +import { join } from "path"; + +const BUMP_TYPE = process.argv[2]; + +if (!["major", "minor", "patch"].includes(BUMP_TYPE)) { + console.error("Usage: node scripts/release.mjs "); + process.exit(1); +} + +function run(cmd, options = {}) { + console.log(`$ ${cmd}`); + try { + return execSync(cmd, { encoding: "utf-8", stdio: options.silent ? "pipe" : "inherit", ...options }); + } catch (e) { + if (!options.ignoreError) { + console.error(`Command failed: ${cmd}`); + process.exit(1); + } + return null; + } +} + +function getVersion() { + const pkg = JSON.parse(readFileSync("packages/ai/package.json", "utf-8")); + return pkg.version; +} + +function getChangelogs() { + const packagesDir = "packages"; + const packages = readdirSync(packagesDir); + return packages + .map((pkg) => join(packagesDir, pkg, "CHANGELOG.md")) + .filter((path) => existsSync(path)); +} + +function updateChangelogsForRelease(version) { + const date = new Date().toISOString().split("T")[0]; + const changelogs = getChangelogs(); + + for (const changelog of changelogs) { + const content = readFileSync(changelog, "utf-8"); + + if (!content.includes("## [Unreleased]")) { + console.log(` Skipping ${changelog}: no [Unreleased] section`); + continue; + } + + const updated = content.replace( + "## [Unreleased]", + `## [${version}] - ${date}` + ); + writeFileSync(changelog, updated); + console.log(` Updated ${changelog}`); + } +} + +function addUnreleasedSection() { + const changelogs = getChangelogs(); + const unreleasedSection = "## [Unreleased]\n\n"; + + for (const changelog of changelogs) { + const content = readFileSync(changelog, "utf-8"); + + // Insert after "# Changelog\n\n" + const updated = content.replace( + /^(# Changelog\n\n)/, + `$1${unreleasedSection}` + ); + writeFileSync(changelog, updated); + console.log(` Added [Unreleased] to ${changelog}`); + } +} + +// Main flow +console.log("\n=== Release Script ===\n"); + +// 1. Check for uncommitted changes +console.log("Checking for uncommitted changes..."); +const status = run("git status --porcelain", { silent: true }); +if (status && status.trim()) { + console.error("Error: Uncommitted changes detected. Commit or stash first."); + console.error(status); + process.exit(1); +} +console.log(" Working directory clean\n"); + +// 2. Bump version +console.log(`Bumping version (${BUMP_TYPE})...`); +run(`npm run version:${BUMP_TYPE}`); +const version = getVersion(); +console.log(` New version: ${version}\n`); + +// 3. Update changelogs +console.log("Updating CHANGELOG.md files..."); +updateChangelogsForRelease(version); +console.log(); + +// 4. Commit and tag +console.log("Committing and tagging..."); +run("git add ."); +run(`git commit -m "Release v${version}"`); +run(`git tag v${version}`); +console.log(); + +// 5. Publish +console.log("Publishing to npm..."); +run("npm run publish"); +console.log(); + +// 6. Add new [Unreleased] sections +console.log("Adding [Unreleased] sections for next cycle..."); +addUnreleasedSection(); +console.log(); + +// 7. Commit +console.log("Committing changelog updates..."); +run("git add ."); +run(`git commit -m "Add [Unreleased] section for next cycle"`); +console.log(); + +// 8. Push +console.log("Pushing to remote..."); +run("git push origin main"); +run(`git push origin v${version}`); +console.log(); + +console.log(`=== Released v${version} ===`); From 0f98decf65daed5c35ec5048fdeeb4ab19402bc2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 11:59:02 +0100 Subject: [PATCH 077/161] Remove proxy package The proxy functionality is now handled by web-ui's createStreamFn with external proxy servers. --- AGENTS.md | 4 +- README.md | 1 - package.json | 4 +- packages/ai/src/models.generated.ts | 114 ++++++++++++++-------------- packages/proxy/README.md | 67 ---------------- packages/proxy/package.json | 26 ------- packages/proxy/src/cli.ts | 16 ---- packages/proxy/src/cors-proxy.ts | 73 ------------------ packages/proxy/src/index.ts | 1 - packages/proxy/tsconfig.json | 8 -- packages/web-ui/package.json | 2 +- tsconfig.json | 2 - 12 files changed, 62 insertions(+), 256 deletions(-) delete mode 100644 packages/proxy/README.md delete mode 100644 packages/proxy/package.json delete mode 100644 packages/proxy/src/cli.ts delete mode 100644 packages/proxy/src/cors-proxy.ts delete mode 100644 packages/proxy/src/index.ts delete mode 100644 packages/proxy/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index f509ba3b..30ced1a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ When reading issues: When creating issues: - Add `pkg:*` labels to indicate which package(s) the issue affects - - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:proxy`, `pkg:tui`, `pkg:web-ui` + - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui` - If an issue spans multiple packages, add all relevant labels When closing issues via commit: @@ -39,7 +39,7 @@ When closing issues via commit: ## Tools - GitHub CLI for issues/PRs -- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:proxy, pkg:tui, pkg:web-ui +- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui - TUI interaction: use tmux ## Style diff --git a/README.md b/README.md index d550a1df..86553d81 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Tools for building AI agents and managing LLM deployments. | **[@mariozechner/pi-mom](packages/mom)** | Slack bot that delegates messages to the pi coding agent | | **[@mariozechner/pi-tui](packages/tui)** | Terminal UI library with differential rendering | | **[@mariozechner/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces | -| **[@mariozechner/pi-proxy](packages/proxy)** | CORS proxy for browser-based LLM API calls | | **[@mariozechner/pi-pods](packages/pods)** | CLI for managing vLLM deployments on GPU pods | ## Development diff --git a/package.json b/package.json index a6a804d1..fd20638e 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent-core && npm run build -w @mariozechner/pi-coding-agent && npm run build -w @mariozechner/pi-mom && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi", - "dev": "concurrently --names \"ai,agent,coding-agent,mom,web-ui,tui,proxy\" --prefix-colors \"cyan,yellow,red,white,green,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-agent-core\" \"npm run dev -w @mariozechner/pi-coding-agent\" \"npm run dev -w @mariozechner/pi-mom\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"", + "build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent-core && npm run build -w @mariozechner/pi-coding-agent && npm run build -w @mariozechner/pi-mom && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi", + "dev": "concurrently --names \"ai,agent,coding-agent,mom,web-ui,tui\" --prefix-colors \"cyan,yellow,red,white,green,magenta\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-agent-core\" \"npm run dev -w @mariozechner/pi-coding-agent\" \"npm run dev -w @mariozechner/pi-mom\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\"", "dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"npm run dev:tsc -w @mariozechner/pi-ai\" \"npm run dev:tsc -w @mariozechner/pi-web-ui\"", "check": "biome check --write . && tsgo --noEmit && npm run check -w @mariozechner/pi-web-ui", "test": "npm run test --workspaces --if-present", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6392308c..2df269d3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,6 +6359,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6393,23 +6410,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,6 +6546,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6580,23 +6597,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4": { id: "openai/gpt-4", name: "OpenAI: GPT-4", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/proxy/README.md b/packages/proxy/README.md deleted file mode 100644 index 61b721f6..00000000 --- a/packages/proxy/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# @mariozechner/pi-proxy - -CORS and authentication proxy for pi-ai. Enables browser clients to access OAuth-protected endpoints. - -## Usage - -### CORS Proxy - -Zero-config CORS proxy for development: - -```bash -# Run directly with tsx -npx tsx packages/proxy/src/cors-proxy.ts 3001 - -# Or use npm script -npm run dev -w @mariozechner/pi-proxy - -# Or install globally and use CLI -npm install -g @mariozechner/pi-proxy -pi-proxy 3001 -``` - -The proxy will forward requests to any URL: - -```javascript -// Instead of: -fetch('https://api.anthropic.com/v1/messages', { ... }) - -// Use: -fetch('http://localhost:3001?url=https://api.anthropic.com/v1/messages', { ... }) -``` - -### OAuth Integration - -For Anthropic OAuth tokens, configure your client to use the proxy: - -```typescript -import Anthropic from '@anthropic-ai/sdk'; - -const client = new Anthropic({ - apiKey: 'oauth_token_here', - baseURL: 'http://localhost:3001?url=https://api.anthropic.com' -}); -``` - -## Future Proxy Types - -- **BunnyCDN Edge Function**: Deploy as edge function -- **Managed Proxy**: Self-hosted with provider key management and credential auth -- **Cloudflare Worker**: Deploy as CF worker - -## Architecture - -The proxy: -1. Accepts requests with `?url=` query parameter -2. Forwards all headers (except `host`, `origin`) -3. Forwards request body for non-GET/HEAD requests -4. Returns response with CORS headers enabled -5. Strips CORS headers from upstream response - -## Development - -```bash -npm install -npm run build -npm run check -``` diff --git a/packages/proxy/package.json b/packages/proxy/package.json deleted file mode 100644 index b6d9c515..00000000 --- a/packages/proxy/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@mariozechner/pi-proxy", - "version": "0.30.2", - "type": "module", - "description": "CORS and authentication proxy for pi-ai", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "bin": { - "pi-proxy": "dist/cli.js" - }, - "scripts": { - "clean": "rm -rf dist", - "build": "tsc", - "typecheck": "tsgo --noEmit", - "dev": "tsx src/cors-proxy.ts 3001" - }, - "dependencies": { - "@hono/node-server": "^1.14.0", - "hono": "^4.6.16" - }, - "devDependencies": { - "@types/node": "^22.10.5", - "tsx": "^4.19.2", - "typescript": "^5.7.3" - } -} diff --git a/packages/proxy/src/cli.ts b/packages/proxy/src/cli.ts deleted file mode 100644 index 29ac06b3..00000000 --- a/packages/proxy/src/cli.ts +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -import { spawn } from "node:child_process"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const port = process.argv[2] || "3001"; - -// Run the CORS proxy -const child = spawn("node", [path.join(__dirname, "cors-proxy.js"), port], { - stdio: "inherit", -}); - -child.on("exit", (code) => { - process.exit(code || 0); -}); diff --git a/packages/proxy/src/cors-proxy.ts b/packages/proxy/src/cors-proxy.ts deleted file mode 100644 index 9fbeb387..00000000 --- a/packages/proxy/src/cors-proxy.ts +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env node -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; - -export function createCorsProxy() { - const app = new Hono(); - - // Enable CORS for all origins - app.use("*", cors()); - - // Proxy all requests - app.all("*", async (c) => { - const url = new URL(c.req.url); - const targetUrl = url.searchParams.get("url"); - - if (!targetUrl) { - return c.json({ error: "Missing 'url' query parameter" }, 400); - } - - try { - // Forward the request - const headers = new Headers(); - c.req.raw.headers.forEach((value, key) => { - // Skip host and origin headers - if (key.toLowerCase() !== "host" && key.toLowerCase() !== "origin") { - headers.set(key, value); - } - }); - - const response = await fetch(targetUrl, { - method: c.req.method, - headers, - body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.raw.clone().arrayBuffer() : undefined, - }); - - // Forward response headers - const responseHeaders = new Headers(); - response.headers.forEach((value, key) => { - // Skip CORS headers (we handle them) - if (!key.toLowerCase().startsWith("access-control-")) { - responseHeaders.set(key, value); - } - }); - - // Return proxied response - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - }); - } catch (error) { - console.error("Proxy error:", error); - return c.json({ error: error instanceof Error ? error.message : "Proxy request failed" }, 502); - } - }); - - return app; -} - -// CLI entry point -if (import.meta.url === `file://${process.argv[1]}`) { - const app = createCorsProxy(); - const port = Number.parseInt(process.argv[2] || "3001", 10); - - console.log(`🔌 CORS proxy running on http://localhost:${port}`); - console.log(`Usage: http://localhost:${port}?url=`); - - serve({ - fetch: app.fetch, - port, - }); -} diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts deleted file mode 100644 index 4e1bcaee..00000000 --- a/packages/proxy/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createCorsProxy } from "./cors-proxy.js"; diff --git a/packages/proxy/tsconfig.json b/packages/proxy/tsconfig.json deleted file mode 100644 index 3502c876..00000000 --- a/packages/proxy/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*"] -} diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 234271c6..c732b97a 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -11,7 +11,7 @@ }, "scripts": { "clean": "rm -rf dist", - "build": "tsc -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify", + "build": "tsgo -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify", "dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"", "dev:tsc": "concurrently --names \"build\" --prefix-colors \"cyan\" \"tsc -p tsconfig.build.json --watch --preserveWatchOutput\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\"", "check": "biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit" diff --git a/tsconfig.json b/tsconfig.json index 2f00b8ea..62eb337a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,8 +17,6 @@ "@mariozechner/pi-mom/*": ["./packages/mom/src/*"], "@mariozechner/pi": ["./packages/pods/src/index.ts"], "@mariozechner/pi/*": ["./packages/pods/src/*"], - "@mariozechner/pi-proxy": ["./packages/proxy/src/index.ts"], - "@mariozechner/pi-proxy/*": ["./packages/proxy/src/*"], "@mariozechner/pi-tui": ["./packages/tui/src/index.ts"], "@mariozechner/pi-tui/*": ["./packages/tui/src/*"], "@mariozechner/pi-web-ui": ["./packages/web-ui/src/index.ts"], From c05e7fafddab31197469ebde0a56e1ca1e7795f4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 12:01:06 +0100 Subject: [PATCH 078/161] Simplify Publishing section in README.md --- README.md | 57 ++++++++++--------------------------------------------- 1 file changed, 10 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 86553d81..bccc46de 100644 --- a/README.md +++ b/README.md @@ -70,55 +70,18 @@ These commands: ### Publishing -Complete release process: +```bash +npm run release:patch # Bug fixes +npm run release:minor # New features +npm run release:major # Breaking changes +``` -1. **Add changes to CHANGELOG.md** (if changes affect coding-agent): - ```bash - # Add your changes to the [Unreleased] section in packages/coding-agent/CHANGELOG.md - # Always add new entries under [Unreleased], never under already-released versions - ``` +This handles version bump, CHANGELOG updates, commit, tag, publish, and push. -2. **Bump version** (all packages): - ```bash - npm run version:patch # For bug fixes - npm run version:minor # For new features - npm run version:major # For breaking changes - ``` - -3. **Finalize CHANGELOG.md for release** (if changes affect coding-agent): - ```bash - # Change [Unreleased] to the new version number with today's date - # e.g., ## [0.7.16] - 2025-11-17 - # NEVER add entries to already-released version sections - # Each version section is immutable once released - ``` - -4. **Commit and tag**: - ```bash - git add . - git commit -m "Release v0.7.16" - git tag v0.7.16 - git push origin main - git push origin v0.7.16 - ``` - -5. **Publish to npm**: - ```bash - npm run publish # Publish all packages to npm - ``` - - **NPM Token Setup**: Publishing requires a granular access token with "Bypass 2FA on publish" enabled. - - Go to https://www.npmjs.com/settings/badlogic/tokens/ - - Create a new "Granular Access Token" - - Select "Bypass 2FA on publish" - - Tokens expire after 90 days, so regenerate when needed - - Set the token: `npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN` - -6. **Add new [Unreleased] section** (for next development cycle): - ```bash - # Add a new [Unreleased] section at the top of CHANGELOG.md - # Commit: git commit -am "Add [Unreleased] section" - ``` +**NPM Token Setup**: Requires a granular access token with "Bypass 2FA on publish" enabled. +- Go to https://www.npmjs.com/settings/badlogic/tokens/ +- Create a new "Granular Access Token" with "Bypass 2FA on publish" +- Set the token: `npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN` ## License From 78aa417ef1b39f20de78020c225f2fbf63804d10 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 12:02:47 +0100 Subject: [PATCH 079/161] Update Changelog and Releasing sections in AGENTS.md --- AGENTS.md | 50 +++++++++++++++++++++----------------------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30ced1a2..89eb1069 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,44 +49,36 @@ When closing issues via commit: - Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") ## Changelog -Location: `packages/coding-agent/CHANGELOG.md`, `packages/ai/CHANGELOG.md`, `packages/tui/CHANGELOG.md`, pick the one relevant to the changes or ask user. +Location: `packages/*/CHANGELOG.md` (each package has its own) + +### Format +Use these sections under `## [Unreleased]`: +- `### Breaking Changes` - API changes requiring migration +- `### Added` - New features +- `### Changed` - Changes to existing functionality +- `### Fixed` - Bug fixes +- `### Removed` - Removed features + +### Rules - New entries ALWAYS go under `## [Unreleased]` section - NEVER modify already-released version sections (e.g., `## [0.12.2]`) - Each version section is immutable once released -- When releasing: rename `[Unreleased]` to the new version, then add a fresh empty `[Unreleased]` section -### Attribution format -- **Internal changes (from issues)**: Reference issue only - - Example: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))` -- **External contributions (PRs from others)**: Reference PR and credit the contributor - - Example: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))` -- If a PR addresses an issue, reference both: `([#123](...issues/123), [#456](...pull/456) by [@user](...))` or just the PR if the issue context is clear from the description +### Attribution +- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))` +- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))` ## Releasing -1. **Bump version** (all packages use lockstep versioning): +1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md + +2. **Run release script**: ```bash - npm run version:patch # For bug fixes - npm run version:minor # For new features - npm run version:major # For breaking changes + npm run release:patch # Bug fixes + npm run release:minor # New features + npm run release:major # Breaking changes ``` -2. **Finalize CHANGELOG.md**: Change `[Unreleased]` to the new version with today's date (e.g., `## [0.12.12] - 2025-12-05`) - -3. **Commit and tag**: - ```bash - git add . - git commit -m "Release v0.12.12" - git tag v0.12.12 - git push origin main - git push origin v0.12.12 - ``` - -4. **Publish to npm**: - ```bash - npm run publish - ``` - -5. **Add new [Unreleased] section** at top of CHANGELOG.md for next cycle, commit it +The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. **CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). \ No newline at end of file From 01c4e879a170d714615f1fd98183157d62208974 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 12:04:26 +0100 Subject: [PATCH 080/161] Clean-up --- AGENTS.md | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 89eb1069..686a1a32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,4 +81,5 @@ Use these sections under `## [Unreleased]`: The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. +### Tool Usage **CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). \ No newline at end of file diff --git a/README.md b/README.md index bccc46de..5f4637fb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Tools for building AI agents and managing LLM deployments. | Package | Description | |---------|-------------| | **[@mariozechner/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) | -| **[@mariozechner/pi-agent](packages/agent)** | Agent runtime with tool calling and state management | +| **[@mariozechner/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management | | **[@mariozechner/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI | | **[@mariozechner/pi-mom](packages/mom)** | Slack bot that delegates messages to the pi coding agent | | **[@mariozechner/pi-tui](packages/tui)** | Terminal UI library with differential rendering | From ecef601d195620b6b107f25db07d54bca99be4f0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 12:16:36 +0100 Subject: [PATCH 081/161] Fix hook message duplication in TUI Two bugs: 1. createCustomMessage was returning role: 'user' instead of preserving the hook message structure (role: 'hookMessage', customType, etc.) 2. rebuildChatFromMessages wasn't clearing the container before rebuilding --- packages/coding-agent/src/core/session-manager.ts | 7 +++++-- .../coding-agent/src/modes/interactive/interactive-mode.ts | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index b8b16f36..68a8e338 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -165,10 +165,13 @@ export function createSummaryMessage(summary: string, timestamp: string): AgentM /** Convert CustomMessageEntry to AgentMessage format */ function createCustomMessage(entry: CustomMessageEntry): AgentMessage { return { - role: "user", + role: "hookMessage", + customType: entry.customType, content: entry.content, + display: entry.display, + details: entry.details, timestamp: new Date(entry.timestamp).getTime(), - }; + } as AgentMessage; } /** Generate a unique short ID (8 hex chars, collision-checked) */ diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c1a824e3..5b6e6844 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1208,6 +1208,7 @@ export class InteractiveMode { } private rebuildChatFromMessages(): void { + this.chatContainer.clear(); const context = this.sessionManager.buildSessionContext(); this.renderSessionContext(context); } From b921298af7c31b3ce4916037f02348841514c2ae Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:12:08 +0100 Subject: [PATCH 082/161] Use exhaustive switch on message.role throughout coding-agent - addMessageToChat: exhaustive switch for all AgentMessage roles - renderSessionContext: delegates to addMessageToChat, special handling for assistant tool calls and tool results - export-html formatMessage: exhaustive switch for all AgentMessage roles - Removed isHookMessage, isBashExecutionMessage type guards in favor of role checks - Fixed imports and removed unused getLatestCompactionEntry --- .../coding-agent/src/core/agent-session.ts | 15 +- packages/coding-agent/src/core/compaction.ts | 130 +++++++----- packages/coding-agent/src/core/export-html.ts | 193 ++++++++++-------- packages/coding-agent/src/core/messages.ts | 145 +++++++++---- .../coding-agent/src/core/session-manager.ts | 91 ++++----- packages/coding-agent/src/index.ts | 3 - ...ction.ts => compaction-summary-message.ts} | 19 +- .../interactive/components/user-message.ts | 8 +- .../src/modes/interactive/interactive-mode.ts | 187 ++++++++--------- packages/coding-agent/test/compaction.test.ts | 16 -- .../session-manager/build-context.test.ts | 11 +- 11 files changed, 442 insertions(+), 376 deletions(-) rename packages/coding-agent/src/modes/interactive/components/{compaction.ts => compaction-summary-message.ts} (63%) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 03806c51..9502a2a3 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -34,7 +34,7 @@ import type { TurnEndEvent, TurnStartEvent, } from "./hooks/index.js"; -import { type BashExecutionMessage, type HookMessage, isHookMessage } from "./messages.js"; +import type { BashExecutionMessage, HookMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import type { CompactionEntry, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; @@ -218,8 +218,8 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { - // Check if this is a hook message (has _hookData marker) - if (isHookMessage(event.message)) { + // Check if this is a hook message + if (event.message.role === "hookMessage") { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry( event.message.customType, @@ -227,10 +227,15 @@ export class AgentSession { event.message.display, event.message.details, ); - } else { - // Regular message - persist as SessionMessageEntry + } else if ( + event.message.role === "user" || + event.message.role === "assistant" || + event.message.role === "toolResult" + ) { + // Regular LLM message - persist as SessionMessageEntry this.sessionManager.appendMessage(event.message); } + // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index 38e2dece..ac750a9e 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -8,8 +8,8 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; -import { convertToLlm } from "./messages.js"; -import { type CompactionEntry, createSummaryMessage, type SessionEntry } from "./session-manager.js"; +import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "./messages.js"; +import type { CompactionEntry, SessionEntry } from "./session-manager.js"; /** * Extract AgentMessage from an entry if it produces one. @@ -20,14 +20,10 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | null { return entry.message; } if (entry.type === "custom_message") { - return { - role: "user", - content: entry.content, - timestamp: new Date(entry.timestamp).getTime(), - }; + return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); } if (entry.type === "branch_summary") { - return createSummaryMessage(entry.summary, entry.timestamp); + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); } return null; } @@ -116,59 +112,65 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett export function estimateTokens(message: AgentMessage): number { let chars = 0; - // Handle bashExecution messages - if (message.role === "bashExecution") { - const bash = message as unknown as { command: string; output: string }; - chars = bash.command.length + bash.output.length; - return Math.ceil(chars / 4); - } - - // Handle user messages - if (message.role === "user") { - const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; - if (typeof content === "string") { - chars = content.length; - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - chars += block.text.length; + switch (message.role) { + case "user": { + const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; + if (typeof content === "string") { + chars = content.length; + } else if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } } } + return Math.ceil(chars / 4); } - return Math.ceil(chars / 4); - } - - // Handle assistant messages - if (message.role === "assistant") { - const assistant = message as AssistantMessage; - for (const block of assistant.content) { - if (block.type === "text") { - chars += block.text.length; - } else if (block.type === "thinking") { - chars += block.thinking.length; - } else if (block.type === "toolCall") { - chars += block.name.length + JSON.stringify(block.arguments).length; + case "assistant": { + const assistant = message as AssistantMessage; + for (const block of assistant.content) { + if (block.type === "text") { + chars += block.text.length; + } else if (block.type === "thinking") { + chars += block.thinking.length; + } else if (block.type === "toolCall") { + chars += block.name.length + JSON.stringify(block.arguments).length; + } } + return Math.ceil(chars / 4); } - return Math.ceil(chars / 4); - } - - // Handle tool results - if (message.role === "toolResult") { - const toolResult = message as { content: Array<{ type: string; text?: string }> }; - for (const block of toolResult.content) { - if (block.type === "text" && block.text) { - chars += block.text.length; + case "hookMessage": + case "toolResult": { + if (typeof message.content === "string") { + chars = message.content.length; + } else { + for (const block of message.content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } + if (block.type === "image") { + chars += 4800; // Estimate images as 4000 chars, or 1200 tokens + } + } } + return Math.ceil(chars / 4); + } + case "bashExecution": { + chars = message.command.length + message.output.length; + return Math.ceil(chars / 4); + } + case "branchSummary": + case "compactionSummary": { + chars = message.summary.length; + return Math.ceil(chars / 4); } - return Math.ceil(chars / 4); } return 0; } /** - * Find valid cut points: indices of user, assistant, or bashExecution messages. + * Find valid cut points: indices of user, assistant, custom, or bashExecution messages. * Never cut at tool results (they must follow their tool call). * When we cut at an assistant message with tool calls, its tool results follow it * and will be kept. @@ -178,16 +180,34 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde const cutPoints: number[] = []; for (let i = startIndex; i < endIndex; i++) { const entry = entries[i]; + switch (entry.type) { + case "message": { + const role = entry.message.role; + switch (role) { + case "bashExecution": + case "hookMessage": + case "branchSummary": + case "compactionSummary": + case "user": + case "assistant": + cutPoints.push(i); + break; + case "toolResult": + break; + } + break; + } + case "thinking_level_change": + case "model_change": + case "compaction": + case "branch_summary": + case "custom": + case "custom_message": + case "label": + } // branch_summary and custom_message are user-role messages, valid cut points if (entry.type === "branch_summary" || entry.type === "custom_message") { cutPoints.push(i); - } else if (entry.type === "message") { - const role = entry.message.role; - // user, assistant, and bashExecution are valid cut points - // toolResult must stay with its preceding tool call - if (role === "user" || role === "assistant" || role === "bashExecution") { - cutPoints.push(i); - } } } return cutPoints; diff --git a/packages/coding-agent/src/core/export-html.ts b/packages/coding-agent/src/core/export-html.ts index db53d743..f553e140 100644 --- a/packages/coding-agent/src/core/export-html.ts +++ b/packages/coding-agent/src/core/export-html.ts @@ -1,4 +1,4 @@ -import type { AgentState } from "@mariozechner/pi-agent-core"; +import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { existsSync, readFileSync, writeFileSync } from "fs"; import hljs from "highlight.js"; @@ -7,7 +7,6 @@ import { homedir } from "os"; import * as path from "path"; import { basename } from "path"; import { APP_NAME, getCustomThemesDir, getThemesDir, VERSION } from "../config.js"; -import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js"; import type { SessionManager } from "./session-manager.js"; // ============================================================================ @@ -821,110 +820,136 @@ function formatToolExecution( return { html, bgColor }; } -function formatMessage(message: Message, toolResultsMap: Map, colors: ThemeColors): string { +function formatMessage( + message: AgentMessage, + toolResultsMap: Map, + colors: ThemeColors, +): string { let html = ""; const timestamp = (message as { timestamp?: number }).timestamp; const timestampHtml = timestamp ? `
${formatTimestamp(timestamp)}
` : ""; - // Handle bash execution messages (user-executed via ! command) - if (isBashExecutionMessage(message)) { - const bashMsg = message as unknown as BashExecutionMessage; - const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null); + switch (message.role) { + case "bashExecution": { + const isError = message.cancelled || (message.exitCode !== 0 && message.exitCode !== null); - html += `
`; - html += timestampHtml; - html += `
$ ${escapeHtml(bashMsg.command)}
`; + html += `
`; + html += timestampHtml; + html += `
$ ${escapeHtml(message.command)}
`; - if (bashMsg.output) { - const lines = bashMsg.output.split("\n"); - html += formatExpandableOutput(lines, 10); + if (message.output) { + const lines = message.output.split("\n"); + html += formatExpandableOutput(lines, 10); + } + + if (message.cancelled) { + html += `
(cancelled)
`; + } else if (message.exitCode !== 0 && message.exitCode !== null) { + html += `
(exit ${message.exitCode})
`; + } + + if (message.truncated && message.fullOutputPath) { + html += `
Output truncated. Full output: ${escapeHtml(message.fullOutputPath)}
`; + } + + html += `
`; + break; } + case "user": { + const userMsg = message as UserMessage; + let textContent = ""; + const images: ImageContent[] = []; - if (bashMsg.cancelled) { - html += `
(cancelled)
`; - } else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) { - html += `
(exit ${bashMsg.exitCode})
`; - } - - if (bashMsg.truncated && bashMsg.fullOutputPath) { - html += `
Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}
`; - } - - html += `
`; - return html; - } - - if (message.role === "user") { - const userMsg = message as UserMessage; - let textContent = ""; - const images: ImageContent[] = []; - - if (typeof userMsg.content === "string") { - textContent = userMsg.content; - } else { - for (const block of userMsg.content) { - if (block.type === "text") { - textContent += block.text; - } else if (block.type === "image") { - images.push(block as ImageContent); + if (typeof userMsg.content === "string") { + textContent = userMsg.content; + } else { + for (const block of userMsg.content) { + if (block.type === "text") { + textContent += block.text; + } else if (block.type === "image") { + images.push(block as ImageContent); + } } } - } - html += `
${timestampHtml}`; + html += `
${timestampHtml}`; - // Render images first - if (images.length > 0) { - html += `
`; - for (const img of images) { - html += `User uploaded image`; + // Render images first + if (images.length > 0) { + html += `
`; + for (const img of images) { + html += `User uploaded image`; + } + html += `
`; } + + // Render text as markdown (server-side) + if (textContent.trim()) { + html += `
${renderMarkdown(textContent)}
`; + } + html += `
`; + break; } + case "assistant": { + html += timestampHtml ? `
${timestampHtml}` : ""; - // Render text as markdown (server-side) - if (textContent.trim()) { - html += `
${renderMarkdown(textContent)}
`; - } - - html += `
`; - } else if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - html += timestampHtml ? `
${timestampHtml}` : ""; - - for (const content of assistantMsg.content) { - if (content.type === "text" && content.text.trim()) { - // Render markdown server-side - html += `
${renderMarkdown(content.text)}
`; - } else if (content.type === "thinking" && content.thinking.trim()) { - html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; + for (const content of message.content) { + if (content.type === "text" && content.text.trim()) { + // Render markdown server-side + html += `
${renderMarkdown(content.text)}
`; + } else if (content.type === "thinking" && content.thinking.trim()) { + html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; + } } - } - for (const content of assistantMsg.content) { - if (content.type === "toolCall") { - const toolResult = toolResultsMap.get(content.id); - const { html: toolHtml, bgColor } = formatToolExecution( - content.name, - content.arguments as Record, - toolResult, - colors, - ); - html += `
${toolHtml}
`; + for (const content of message.content) { + if (content.type === "toolCall") { + const toolResult = toolResultsMap.get(content.id); + const { html: toolHtml, bgColor } = formatToolExecution( + content.name, + content.arguments as Record, + toolResult, + colors, + ); + html += `
${toolHtml}
`; + } } - } - const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall"); - if (!hasToolCalls) { - if (assistantMsg.stopReason === "aborted") { - html += '
Aborted
'; - } else if (assistantMsg.stopReason === "error") { - html += `
Error: ${escapeHtml(assistantMsg.errorMessage || "Unknown error")}
`; + const hasToolCalls = message.content.some((c) => c.type === "toolCall"); + if (!hasToolCalls) { + if (message.stopReason === "aborted") { + html += '
Aborted
'; + } else if (message.stopReason === "error") { + html += `
Error: ${escapeHtml(message.errorMessage || "Unknown error")}
`; + } } - } - if (timestampHtml) { - html += "
"; + if (timestampHtml) { + html += "
"; + } + break; + } + case "toolResult": + // Tool results are rendered inline with tool calls + break; + case "hookMessage": + // Hook messages with display:true shown as info boxes + if (message.display) { + const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content); + html += `
${timestampHtml}
[${escapeHtml(message.customType)}]
${renderMarkdown(content)}
`; + } + break; + case "compactionSummary": + // Rendered separately via formatCompaction + break; + case "branchSummary": + // Rendered as compaction-like summary + html += `
Branch Summary
${escapeHtml(message.summary).replace(/\n/g, "
")}
`; + break; + default: { + // Exhaustive check + const _exhaustive: never = message; } } diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 74451907..f9d33bbf 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -8,6 +8,21 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai"; +export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + + +`; + +export const COMPACTION_SUMMARY_SUFFIX = ` +`; + +export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: + + +`; + +export const BRANCH_SUMMARY_SUFFIX = ``; + /** * Message type for bash executions via the ! command. */ @@ -35,28 +50,30 @@ export interface HookMessage { timestamp: number; } +export interface BranchSummaryMessage { + role: "branchSummary"; + summary: string; + fromId: string; + timestamp: number; +} + +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + tokensBefore: number; + timestamp: number; +} + // Extend CustomAgentMessages via declaration merging declare module "@mariozechner/pi-agent-core" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; hookMessage: HookMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; } } -/** - * Type guard for BashExecutionMessage. - */ -export function isBashExecutionMessage(msg: AgentMessage | Message): msg is BashExecutionMessage { - return msg.role === "bashExecution"; -} - -/** - * Type guard for HookMessage. - */ -export function isHookMessage(msg: AgentMessage | Message): msg is HookMessage { - return msg.role === "hookMessage"; -} - /** * Convert a BashExecutionMessage to user message text for LLM context. */ @@ -78,6 +95,46 @@ export function bashExecutionToText(msg: BashExecutionMessage): string { return text; } +export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { + return { + role: "branchSummary", + summary, + fromId, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCompactionSummaryMessage( + summary: string, + tokensBefore: number, + timestamp: string, +): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary: summary, + tokensBefore, + timestamp: new Date(timestamp).getTime(), + }; +} + +/** Convert CustomMessageEntry to AgentMessage format */ +export function createHookMessage( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details: unknown | undefined, + timestamp: string, +): HookMessage { + return { + role: "hookMessage", + customType, + content, + display, + details, + timestamp: new Date(timestamp).getTime(), + }; +} + /** * Transform AgentMessages (including custom types) to LLM-compatible Messages. * @@ -89,30 +146,44 @@ export function bashExecutionToText(msg: BashExecutionMessage): string { export function convertToLlm(messages: AgentMessage[]): Message[] { return messages .map((m): Message | null => { - if (isBashExecutionMessage(m)) { - // Convert bash execution to user message - return { - role: "user", - content: [{ type: "text", text: bashExecutionToText(m) }], - timestamp: m.timestamp, - }; + switch (m.role) { + case "bashExecution": + return { + role: "user", + content: [{ type: "text", text: bashExecutionToText(m) }], + timestamp: m.timestamp, + }; + case "hookMessage": { + const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; + return { + role: "user", + content, + timestamp: m.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }], + timestamp: m.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX }, + ], + timestamp: m.timestamp, + }; + case "user": + case "assistant": + case "toolResult": + return m; + default: + // biome-ignore lint/correctness/noSwitchDeclarations: fine + const _exhaustiveCheck: never = m; + return null; } - if (isHookMessage(m)) { - // Convert hook message to user message for LLM - // Normalize string content to array format - const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; - return { - role: "user", - content, - timestamp: m.timestamp, - }; - } - // Pass through standard LLM roles - if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") { - return m as Message; - } - // Filter out unknown message types - return null; }) .filter((m) => m !== null); } diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 68a8e338..7035c1e0 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; +import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai"; import { randomUUID } from "crypto"; import { appendFileSync, @@ -15,6 +15,13 @@ import { } from "fs"; import { join, resolve } from "path"; import { getAgentDir as getDefaultAgentDir } from "../config.js"; +import { + type BashExecutionMessage, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createHookMessage, + type HookMessage, +} from "./messages.js"; export const CURRENT_SESSION_VERSION = 2; @@ -59,9 +66,12 @@ export interface CompactionEntry extends SessionEntryBase { details?: T; } -export interface BranchSummaryEntry extends SessionEntryBase { +export interface BranchSummaryEntry extends SessionEntryBase { type: "branch_summary"; + fromId: string; summary: string; + /** Hook-specific data (not sent to LLM) */ + details?: T; } /** @@ -145,35 +155,6 @@ export interface SessionInfo { allMessagesText: string; } -export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: - - -`; - -export const SUMMARY_SUFFIX = ` -`; - -/** Exported for compaction.test.ts */ -export function createSummaryMessage(summary: string, timestamp: string): AgentMessage { - return { - role: "user", - content: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX, - timestamp: new Date(timestamp).getTime(), - }; -} - -/** Convert CustomMessageEntry to AgentMessage format */ -function createCustomMessage(entry: CustomMessageEntry): AgentMessage { - return { - role: "hookMessage", - customType: entry.customType, - content: entry.content, - display: entry.display, - details: entry.details, - timestamp: new Date(entry.timestamp).getTime(), - } as AgentMessage; -} - /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { @@ -328,9 +309,21 @@ export function buildSessionContext( // 3. Emit messages after compaction const messages: AgentMessage[] = []; + const appendMessage = (entry: SessionEntry) => { + if (entry.type === "message") { + messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push( + createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp), + ); + } else if (entry.type === "branch_summary" && entry.summary) { + messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + } + }; + if (compaction) { // Emit summary first - messages.push(createSummaryMessage(compaction.summary, compaction.timestamp)); + messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp)); // Find compaction index in path const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id); @@ -343,37 +336,19 @@ export function buildSessionContext( foundFirstKept = true; } if (foundFirstKept) { - if (entry.type === "message") { - messages.push(entry.message); - } else if (entry.type === "custom_message") { - messages.push(createCustomMessage(entry)); - } else if (entry.type === "branch_summary") { - messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - } + appendMessage(entry); } } // Emit messages after compaction for (let i = compactionIdx + 1; i < path.length; i++) { const entry = path[i]; - if (entry.type === "message") { - messages.push(entry.message); - } else if (entry.type === "custom_message") { - messages.push(createCustomMessage(entry)); - } else if (entry.type === "branch_summary") { - messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - } + appendMessage(entry); } } else { // No compaction - emit all messages, handle branch summaries and custom messages for (const entry of path) { - if (entry.type === "message") { - messages.push(entry.message); - } else if (entry.type === "custom_message") { - messages.push(createCustomMessage(entry)); - } else if (entry.type === "branch_summary") { - messages.push(createSummaryMessage(entry.summary, entry.timestamp)); - } + appendMessage(entry); } } @@ -597,8 +572,13 @@ export class SessionManager { this._persist(entry); } - /** Append a message as child of current leaf, then advance leaf. Returns entry id. */ - appendMessage(message: AgentMessage): string { + /** Append a message as child of current leaf, then advance leaf. Returns entry id. + * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. + * Reason: we want these to be top-level entries in the session, not message session entries, + * so it is easier to find them. + * These need to be appended via appendCompaction() and appendBranchSummary() methods. + */ + appendMessage(message: Message | HookMessage | BashExecutionMessage): string { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), @@ -851,6 +831,7 @@ export class SessionManager { id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), + fromId: branchFromId, summary, }; this._appendEntry(entry); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index d33b8ca9..01c0d689 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -113,7 +113,6 @@ export { CURRENT_SESSION_VERSION, type CustomEntry, type CustomMessageEntry, - createSummaryMessage, type FileEntry, getLatestCompactionEntry, type ModelChangeEntry, @@ -126,8 +125,6 @@ export { type SessionInfo, SessionManager, type SessionMessageEntry, - SUMMARY_PREFIX, - SUMMARY_SUFFIX, type ThinkingLevelChangeEntry, } from "./core/session-manager.js"; export { diff --git a/packages/coding-agent/src/modes/interactive/components/compaction.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts similarity index 63% rename from packages/coding-agent/src/modes/interactive/components/compaction.ts rename to packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index f2835ee7..049880a4 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -1,20 +1,19 @@ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import type { CompactionSummaryMessage } from "packages/coding-agent/src/core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; /** - * Component that renders a compaction indicator with collapsed/expanded state. + * Component that renders a compaction message with collapsed/expanded state. * Collapsed: shows "Context compacted from X tokens" * Expanded: shows the full summary rendered as markdown (like a user message) */ -export class CompactionComponent extends Container { +export class CompactionSummaryMessageComponent extends Container { private expanded = false; - private tokensBefore: number; - private summary: string; + private message: CompactionSummaryMessage; - constructor(tokensBefore: number, summary: string) { + constructor(message: CompactionSummaryMessage) { super(); - this.tokensBefore = tokensBefore; - this.summary = summary; + this.message = message; this.updateDisplay(); } @@ -29,9 +28,9 @@ export class CompactionComponent extends Container { if (this.expanded) { // Show header + summary as markdown (like user message) this.addChild(new Spacer(1)); - const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`; + const header = `**Context compacted from ${this.message.tokensBefore.toLocaleString()} tokens**\n\n`; this.addChild( - new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), { + new Markdown(header + this.message.summary, 1, 1, getMarkdownTheme(), { bgColor: (text: string) => theme.bg("userMessageBg", text), color: (text: string) => theme.fg("userMessageText", text), }), @@ -39,7 +38,7 @@ export class CompactionComponent extends Container { this.addChild(new Spacer(1)); } else { // Collapsed: simple text in warning color with token count - const tokenStr = this.tokensBefore.toLocaleString(); + const tokenStr = this.message.tokensBefore.toLocaleString(); this.addChild( new Text( theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`), diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index dfeee875..8b95a3b2 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -5,13 +5,9 @@ import { getMarkdownTheme, theme } from "../theme/theme.js"; * Component that renders a user message */ export class UserMessageComponent extends Container { - constructor(text: string, isFirst: boolean) { + constructor(text: string) { super(); - - // Add spacer before user message (except first one) - if (!isFirst) { - this.addChild(new Spacer(1)); - } + this.addChild(new Spacer(1)); this.addChild( new Markdown(text, 1, 1, getMarkdownTheme(), { bgColor: (text: string) => theme.bg("userMessageBg", text), diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5b6e6844..405bcf23 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -28,14 +28,8 @@ import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "../../core/custom-tools/index.js"; import type { HookUIContext } from "../../core/hooks/index.js"; -import { isBashExecutionMessage, isHookMessage } from "../../core/messages.js"; -import { - getLatestCompactionEntry, - type SessionContext, - SessionManager, - SUMMARY_PREFIX, - SUMMARY_SUFFIX, -} from "../../core/session-manager.js"; +import { createCompactionSummaryMessage } from "../../core/messages.js"; +import { type SessionContext, SessionManager } from "../../core/session-manager.js"; import { loadSkills } from "../../core/skills.js"; import { loadProjectContextFiles } from "../../core/system-prompt.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; @@ -44,7 +38,7 @@ import { copyToClipboard } from "../../utils/clipboard.js"; import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; -import { CompactionComponent } from "./components/compaction.js"; +import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js"; import { CustomEditor } from "./components/custom-editor.js"; import { DynamicBorder } from "./components/dynamic-border.js"; import { FooterComponent } from "./components/footer.js"; @@ -84,9 +78,6 @@ export class InteractiveMode { // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); - // Track if this is the first user message (to skip spacer) - private isFirstUserMessage = true; - // Tool output expansion state private toolOutputExpanded = false; @@ -817,7 +808,7 @@ export class InteractiveMode { break; case "message_start": - if (isHookMessage(event.message)) { + if (event.message.role === "hookMessage") { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { @@ -828,7 +819,7 @@ export class InteractiveMode { } else if (event.message.role === "assistant") { this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock); this.chatContainer.addChild(this.streamingComponent); - this.streamingComponent.updateContent(event.message as AssistantMessage); + this.streamingComponent.updateContent(event.message); this.ui.requestRender(); } break; @@ -983,7 +974,12 @@ export class InteractiveMode { this.chatContainer.clear(); this.rebuildChatFromMessages(); // Add compaction component (same as manual /compact) - const compactionComponent = new CompactionComponent(event.result.tokensBefore, event.result.summary); + const compactionComponent = new CompactionSummaryMessageComponent({ + role: "compactionSummary", + tokensBefore: event.result.tokensBefore, + summary: event.result.summary, + timestamp: Date.now(), + }); compactionComponent.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(compactionComponent); this.footer.updateState(this.session.state); @@ -1051,38 +1047,70 @@ export class InteractiveMode { this.ui.requestRender(); } - private addMessageToChat(message: AgentMessage): void { - if (isBashExecutionMessage(message)) { - const component = new BashExecutionComponent(message.command, this.ui); - if (message.output) { - component.appendOutput(message.output); + private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { + switch (message.role) { + case "bashExecution": { + const component = new BashExecutionComponent(message.command, this.ui); + if (message.output) { + component.appendOutput(message.output); + } + component.setComplete( + message.exitCode, + message.cancelled, + message.truncated ? ({ truncated: true } as TruncationResult) : undefined, + message.fullOutputPath, + ); + this.chatContainer.addChild(component); + break; } - component.setComplete( - message.exitCode, - message.cancelled, - message.truncated ? ({ truncated: true } as TruncationResult) : undefined, - message.fullOutputPath, - ); - this.chatContainer.addChild(component); - return; - } - - if (isHookMessage(message)) { - // Render as custom message if display is true - if (message.display) { - const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); - this.chatContainer.addChild(new HookMessageComponent(message, renderer)); + case "hookMessage": { + if (message.display) { + const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); + this.chatContainer.addChild(new HookMessageComponent(message, renderer)); + } + break; } - } else if (message.role === "user") { - const textContent = this.getUserMessageText(message); - if (textContent) { - const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); - this.chatContainer.addChild(userComponent); - this.isFirstUserMessage = false; + case "compactionSummary": { + const component = new CompactionSummaryMessageComponent(message); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + break; + } + case "branchSummary": { + // Branch summaries are rendered as compaction summaries + const component = new CompactionSummaryMessageComponent({ + role: "compactionSummary", + summary: message.summary, + tokensBefore: 0, + timestamp: message.timestamp, + }); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + break; + } + case "user": { + const textContent = this.getUserMessageText(message); + if (textContent) { + const userComponent = new UserMessageComponent(textContent); + this.chatContainer.addChild(userComponent); + if (options?.populateHistory) { + this.editor.addToHistory(textContent); + } + } + break; + } + case "assistant": { + const assistantComponent = new AssistantMessageComponent(message, this.hideThinkingBlock); + this.chatContainer.addChild(assistantComponent); + break; + } + case "toolResult": { + // Tool results are rendered inline with tool calls, handled separately + break; + } + default: { + const _exhaustive: never = message; } - } else if (message.role === "assistant") { - const assistantComponent = new AssistantMessageComponent(message as AssistantMessage, this.hideThinkingBlock); - this.chatContainer.addChild(assistantComponent); } } @@ -1096,7 +1124,6 @@ export class InteractiveMode { sessionContext: SessionContext, options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { - this.isFirstUserMessage = true; this.pendingTools.clear(); if (options.updateFooter) { @@ -1104,65 +1131,25 @@ export class InteractiveMode { this.updateEditorBorderColor(); } - const compactionEntry = getLatestCompactionEntry(this.sessionManager.getEntries()); - - for (let i = 0; i < sessionContext.messages.length; i++) { - const message = sessionContext.messages[i]; - - if (isBashExecutionMessage(message)) { + for (const message of sessionContext.messages) { + // Assistant messages need special handling for tool calls + if (message.role === "assistant") { this.addMessageToChat(message); - continue; - } - - // Check if this is a custom_message entry - if (isHookMessage(message)) { - if (message.display) { - const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); - this.chatContainer.addChild(new HookMessageComponent(message, renderer)); - } - continue; - } - - if (message.role === "user") { - const textContent = this.getUserMessageText(message); - if (textContent) { - if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) { - const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length); - const component = new CompactionComponent(compactionEntry.tokensBefore, summary); - component.setExpanded(this.toolOutputExpanded); - this.chatContainer.addChild(component); - } else { - const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); - this.chatContainer.addChild(userComponent); - this.isFirstUserMessage = false; - if (options.populateHistory) { - this.editor.addToHistory(textContent); - } - } - } - } else if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock); - this.chatContainer.addChild(assistantComponent); - - for (const content of assistantMsg.content) { + // Render tool call components + for (const content of message.content) { if (content.type === "toolCall") { const component = new ToolExecutionComponent( content.name, content.arguments, - { - showImages: this.settingsManager.getShowImages(), - }, + { showImages: this.settingsManager.getShowImages() }, this.customTools.get(content.name)?.tool, this.ui, ); this.chatContainer.addChild(component); - if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") { + if (message.stopReason === "aborted" || message.stopReason === "error") { const errorMessage = - assistantMsg.stopReason === "aborted" - ? "Operation aborted" - : assistantMsg.errorMessage || "Error"; + message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error"; component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); } else { this.pendingTools.set(content.id, component); @@ -1170,13 +1157,18 @@ export class InteractiveMode { } } } else if (message.role === "toolResult") { + // Match tool results to pending tool components const component = this.pendingTools.get(message.toolCallId); if (component) { component.updateResult(message); this.pendingTools.delete(message.toolCallId); } + } else { + // All other messages use standard rendering + this.addMessageToChat(message, options); } } + this.pendingTools.clear(); this.ui.requestRender(); } @@ -1308,7 +1300,7 @@ export class InteractiveMode { for (const child of this.chatContainer.children) { if (child instanceof ToolExecutionComponent) { child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof CompactionComponent) { + } else if (child instanceof CompactionSummaryMessageComponent) { child.setExpanded(this.toolOutputExpanded); } else if (child instanceof BashExecutionComponent) { child.setExpanded(this.toolOutputExpanded); @@ -1584,7 +1576,6 @@ export class InteractiveMode { } this.chatContainer.clear(); - this.isFirstUserMessage = true; this.renderInitialMessages(); this.editor.setText(result.selectedText); done(); @@ -1638,7 +1629,6 @@ export class InteractiveMode { // Clear and re-render the chat this.chatContainer.clear(); - this.isFirstUserMessage = true; this.renderInitialMessages(); this.showStatus("Resumed session"); } @@ -1899,7 +1889,6 @@ export class InteractiveMode { this.pendingMessagesContainer.clear(); this.streamingComponent = null; this.pendingTools.clear(); - this.isFirstUserMessage = true; this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); @@ -2027,11 +2016,11 @@ export class InteractiveMode { const result = await this.session.compact(customInstructions); // Rebuild UI - this.chatContainer.clear(); this.rebuildChatFromMessages(); // Add compaction component - const compactionComponent = new CompactionComponent(result.tokensBefore, result.summary); + const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString()); + const compactionComponent = new CompactionSummaryMessageComponent(msg); compactionComponent.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(compactionComponent); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 84ce3b3c..43f56712 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -16,7 +16,6 @@ import { import { buildSessionContext, type CompactionEntry, - createSummaryMessage, type ModelChangeEntry, migrateSessionEntries, parseSessionEntries, @@ -272,21 +271,6 @@ describe("findCutPoint", () => { }); }); -describe("createSummaryMessage", () => { - it("should create user message with prefix and correct timestamp", () => { - const ts = "2025-01-01T12:00:00.000Z"; - const msg = createSummaryMessage("This is the summary", ts); - expect(msg.role).toBe("user"); - expect(msg.timestamp).toBe(new Date(ts).getTime()); - if (msg.role === "user") { - expect(msg.content).toContain( - "The conversation history before this point was compacted into the following summary:", - ); - expect(msg.content).toContain("This is the summary"); - } - }); -}); - describe("buildSessionContext", () => { it("should load all messages when no compaction", () => { const entries: SessionEntry[] = [ diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index cb34bb84..31479b7b 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -6,7 +6,6 @@ import { type ModelChangeEntry, type SessionEntry, type SessionMessageEntry, - SUMMARY_PREFIX, type ThinkingLevelChangeEntry, } from "../../src/core/session-manager.js"; @@ -49,8 +48,8 @@ function compaction(id: string, parentId: string | null, summary: string, firstK }; } -function branchSummary(id: string, parentId: string | null, summary: string): BranchSummaryEntry { - return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary }; +function branchSummary(id: string, parentId: string | null, fromId: string, summary: string): BranchSummaryEntry { + return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary, fromId }; } function thinkingLevel(id: string, parentId: string | null, level: string): ThinkingLevelChangeEntry { @@ -151,7 +150,7 @@ describe("buildSessionContext", () => { // Summary + all messages (1,2,4) expect(ctx.messages).toHaveLength(4); - expect((ctx.messages[0] as any).content).toContain(SUMMARY_PREFIX); + expect((ctx.messages[0] as any).content).toContain("Empty summary"); }); it("multiple compactions uses latest", () => { @@ -198,7 +197,7 @@ describe("buildSessionContext", () => { msg("1", null, "user", "start"), msg("2", "1", "assistant", "response"), msg("3", "2", "user", "abandoned path"), - branchSummary("4", "2", "Summary of abandoned work"), + branchSummary("4", "2", "Summary of abandoned work", "3"), msg("5", "4", "user", "new direction"), ]; const ctx = buildSessionContext(entries, "5"); @@ -225,7 +224,7 @@ describe("buildSessionContext", () => { msg("8", "3", "user", "wrong path"), msg("9", "8", "assistant", "wrong response"), // Branch summary resuming from 3 - branchSummary("10", "3", "Tried wrong approach"), + branchSummary("10", "3", "Tried wrong approach", "9"), msg("11", "10", "user", "better approach"), ]; From 4ff5f61ffc3245a583a57c2d3df793e339984a55 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:22:15 +0100 Subject: [PATCH 083/161] Add BranchSummaryMessageComponent, unify styling with hook messages - CompactionSummaryMessageComponent now extends Box, uses customMessageBg - New BranchSummaryMessageComponent for branch summaries - Both use same background color as HookMessageComponent for consistency - Added Spacer before compaction/branch components in chat --- .../components/branch-summary-message.ts | 42 +++++++++++++++++++ .../components/compaction-summary-message.ts | 34 +++++++-------- .../src/modes/interactive/interactive-mode.ts | 11 ++--- 3 files changed, 60 insertions(+), 27 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts new file mode 100644 index 00000000..d46b2bc5 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -0,0 +1,42 @@ +import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import type { BranchSummaryMessage } from "../../../core/messages.js"; +import { getMarkdownTheme, theme } from "../theme/theme.js"; + +/** + * Component that renders a branch summary message with collapsed/expanded state. + * Uses same background color as hook messages for visual consistency. + */ +export class BranchSummaryMessageComponent extends Box { + private expanded = false; + private message: BranchSummaryMessage; + + constructor(message: BranchSummaryMessage) { + super(1, 1, (t) => theme.bg("customMessageBg", t)); + this.message = message; + this.updateDisplay(); + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.updateDisplay(); + } + + private updateDisplay(): void { + this.clear(); + + const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`); + this.addChild(new Text(label, 0, 0)); + this.addChild(new Spacer(1)); + + if (this.expanded) { + const header = "**Branch Summary**\n\n"; + this.addChild( + new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { + color: (text: string) => theme.fg("customMessageText", text), + }), + ); + } else { + this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0)); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index 049880a4..dc07d3b5 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -1,18 +1,17 @@ -import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import type { CompactionSummaryMessage } from "packages/coding-agent/src/core/messages.js"; +import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import type { CompactionSummaryMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; /** * Component that renders a compaction message with collapsed/expanded state. - * Collapsed: shows "Context compacted from X tokens" - * Expanded: shows the full summary rendered as markdown (like a user message) + * Uses same background color as hook messages for visual consistency. */ -export class CompactionSummaryMessageComponent extends Container { +export class CompactionSummaryMessageComponent extends Box { private expanded = false; private message: CompactionSummaryMessage; constructor(message: CompactionSummaryMessage) { - super(); + super(1, 1, (t) => theme.bg("customMessageBg", t)); this.message = message; this.updateDisplay(); } @@ -25,26 +24,21 @@ export class CompactionSummaryMessageComponent extends Container { private updateDisplay(): void { this.clear(); + const tokenStr = this.message.tokensBefore.toLocaleString(); + const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`); + this.addChild(new Text(label, 0, 0)); + this.addChild(new Spacer(1)); + if (this.expanded) { - // Show header + summary as markdown (like user message) - this.addChild(new Spacer(1)); - const header = `**Context compacted from ${this.message.tokensBefore.toLocaleString()} tokens**\n\n`; + const header = `**Compacted from ${tokenStr} tokens**\n\n`; this.addChild( - new Markdown(header + this.message.summary, 1, 1, getMarkdownTheme(), { - bgColor: (text: string) => theme.bg("userMessageBg", text), - color: (text: string) => theme.fg("userMessageText", text), + new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { + color: (text: string) => theme.fg("customMessageText", text), }), ); - this.addChild(new Spacer(1)); } else { - // Collapsed: simple text in warning color with token count - const tokenStr = this.message.tokensBefore.toLocaleString(); this.addChild( - new Text( - theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`), - 1, - 1, - ), + new Text(theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (ctrl+o to expand)`), 0, 0), ); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 405bcf23..18149fd4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -38,6 +38,7 @@ import { copyToClipboard } from "../../utils/clipboard.js"; import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; +import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js"; import { CustomEditor } from "./components/custom-editor.js"; import { DynamicBorder } from "./components/dynamic-border.js"; @@ -1071,19 +1072,15 @@ export class InteractiveMode { break; } case "compactionSummary": { + this.chatContainer.addChild(new Spacer(1)); const component = new CompactionSummaryMessageComponent(message); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); break; } case "branchSummary": { - // Branch summaries are rendered as compaction summaries - const component = new CompactionSummaryMessageComponent({ - role: "compactionSummary", - summary: message.summary, - tokensBefore: 0, - timestamp: message.timestamp, - }); + this.chatContainer.addChild(new Spacer(1)); + const component = new BranchSummaryMessageComponent(message); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); break; From 1721bb8398ea230ba1bf835d92fdbf734e19037a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:24:20 +0100 Subject: [PATCH 084/161] Mark Hook API Changes as complete in plan --- packages/coding-agent/docs/session-tree-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 969748e6..636d3625 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -128,7 +128,7 @@ Behavior: - [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers - [x] Renderer returns inner Component, TUI wraps in styled Box -### Hook API Changes +### Hook API Changes ✅ **Renamed:** - `renderCustomMessage()` → `registerCustomMessageRenderer()` @@ -212,7 +212,7 @@ Wiring (all in AgentSession.prompt()): - [x] If handler returns undefined, return early (no LLM call) - [x] Works for all modes (interactive, RPC, print) via shared AgentSession -**New: `ui.custom()`** +**New: `ui.custom()` ✅** For arbitrary hook UI with keyboard focus: From ae614f93e36161bc651585bbc930867c2da38308 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:29:29 +0100 Subject: [PATCH 085/161] Add investigation item for context event vs before_agent_start Reference: #324 Documents: - Current context event behavior (AgentMessage[], transient) - Proposed before_agent_start event (persistent, TUI visible) - Key differences table - Open design questions - Need to verify AgentMessage vs Message abstraction level --- packages/ai/src/models.generated.ts | 114 +++++++++--------- .../coding-agent/docs/session-tree-plan.md | 53 ++++++++ 2 files changed, 110 insertions(+), 57 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 2df269d3..6392308c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,23 +6359,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6410,6 +6393,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,23 +6546,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6597,6 +6580,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 636d3625..650d0c51 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -285,6 +285,59 @@ Benefits: - Works with branching (pruning entries are part of the tree) - Trade-off: cache busting on first submission after pruning +### Investigate: `context` event vs `before_agent_start` + +Reference: [#324](https://github.com/badlogic/pi-mono/issues/324) + +**Current `context` event:** +- Fires before each LLM call within the agent loop +- Receives `AgentMessage[]` (deep copy, safe to modify) +- Modifications are transient (not persisted to session) +- No TUI visibility of what was changed +- Use case: non-destructive pruning, dynamic context manipulation + +**Problem:** `AgentMessage` includes custom types (hookMessage, bashExecution, etc.) that need conversion to LLM `Message[]` before sending. Need to verify: +- [ ] Where does `AgentMessage[]` → `Message[]` conversion happen relative to `context` event? +- [ ] Should hooks work with `AgentMessage[]` or `Message[]`? +- [ ] Is the current abstraction level correct? + +**Proposed `before_agent_start` event:** +- Fires once when user submits a prompt, before `agent_start` +- Allows hooks to inject additional content that gets **persisted** to session +- Injected content is visible in TUI (observability) +- Does not bust prompt cache (appended after user message, not modifying system prompt) + +**Key difference:** +| Aspect | `context` | `before_agent_start` | +|--------|-----------|---------------------| +| When | Before each LLM call | Once per user prompt | +| Persisted | No | Yes (as SystemMessage) | +| TUI visible | No | Yes (collapsible) | +| Cache impact | Can bust cache | Append-only, cache-safe | +| Use case | Transient manipulation | Persistent context injection | + +**Design questions:** +- [ ] Should `before_agent_start` create a new message type (`SystemMessage` with `role: "system"`)? +- [ ] How should it render in TUI? (label when collapsed, full content when expanded) +- [ ] How does it interact with compaction? (treated like user messages?) +- [ ] Can hook return multiple messages or just one? + +**Implementation sketch:** +```typescript +interface BeforeAgentStartEvent { + type: "before_agent_start"; + userMessage: UserMessage; // The prompt user just submitted +} + +interface BeforeAgentStartResult { + /** Additional context to inject (persisted as SystemMessage) */ + inject?: { + label: string; // Shown in collapsed TUI state + content: string | (TextContent | ImageContent)[]; + }; +} +``` + ### HTML Export - [ ] Add collapsible sidebar showing full tree structure From bbdc3503942877b435b33f913de5406030cc7b09 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:31:26 +0100 Subject: [PATCH 086/161] Add reference to #330 (Dynamic Context Pruning) in plan Documents why context event was added and notes the type inconsistency between ContextEvent (AgentMessage[]) and ContextEventResult (Message[]) --- .../coding-agent/docs/session-tree-plan.md | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 650d0c51..ad4f6c60 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -287,19 +287,32 @@ Benefits: ### Investigate: `context` event vs `before_agent_start` -Reference: [#324](https://github.com/badlogic/pi-mono/issues/324) +References: +- [#324](https://github.com/badlogic/pi-mono/issues/324) - `before_agent_start` proposal +- [#330](https://github.com/badlogic/pi-mono/discussions/330) - Dynamic Context Pruning (why `context` was added) **Current `context` event:** - Fires before each LLM call within the agent loop - Receives `AgentMessage[]` (deep copy, safe to modify) +- Returns `Message[]` (inconsistent with input type) - Modifications are transient (not persisted to session) - No TUI visibility of what was changed - Use case: non-destructive pruning, dynamic context manipulation -**Problem:** `AgentMessage` includes custom types (hookMessage, bashExecution, etc.) that need conversion to LLM `Message[]` before sending. Need to verify: -- [ ] Where does `AgentMessage[]` → `Message[]` conversion happen relative to `context` event? -- [ ] Should hooks work with `AgentMessage[]` or `Message[]`? -- [ ] Is the current abstraction level correct? +**Type inconsistency:** Event receives `AgentMessage[]` but result returns `Message[]`: +```typescript +interface ContextEvent { + messages: AgentMessage[]; // Input +} +interface ContextEventResult { + messages?: Message[]; // Output - different type! +} +``` + +Questions: +- [ ] Should input/output both be `Message[]` (LLM format)? +- [ ] Or both be `AgentMessage[]` with conversion happening after? +- [ ] Where does `AgentMessage[]` → `Message[]` conversion currently happen? **Proposed `before_agent_start` event:** - Fires once when user submits a prompt, before `agent_start` From 57146de202b68dfb333dc5227ea754d2bdfb2e26 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:50:22 +0100 Subject: [PATCH 087/161] Implement before_agent_start hook event - Add BeforeAgentStartEvent and BeforeAgentStartEventResult types - Add emitBeforeAgentStart to HookRunner - Call in AgentSession.prompt() before agent.prompt() - Hook can return a message to inject into context (persisted + visible) - Add test hook demonstrating custom message rendering and before_agent_start --- .pi/hooks/test-command.ts | 89 +++++++++++++++---- .../coding-agent/src/core/agent-session.ts | 23 +++++ packages/coding-agent/src/core/hooks/index.ts | 2 + .../coding-agent/src/core/hooks/runner.ts | 42 +++++++++ packages/coding-agent/src/core/hooks/types.ts | 28 ++++++ packages/coding-agent/src/index.ts | 2 + 6 files changed, 170 insertions(+), 16 deletions(-) diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index 065b5c9b..9e909de2 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -1,24 +1,81 @@ /** - * Test hook that registers a /greet command. - * Usage: /greet [name] + * Test hook demonstrating custom commands, message rendering, and before_agent_start. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import { Box, Text } from "@mariozechner/pi-tui"; export default function (pi: HookAPI) { - pi.registerCommand("greet", { - description: "Send a greeting message to the LLM", - handler: async (ctx) => { - const name = ctx.args.trim() || "world"; + // Track whether injection is enabled + let injectEnabled = false; - // Insert a custom message and trigger LLM response - pi.sendMessage( - { - customType: "greeting", - content: `Hello, ${name}! Please say something nice about them.`, - display: true, - }, - true, // triggerTurn - get LLM to respond - ); + // Register a custom message renderer for our "test-info" type + pi.registerMessageRenderer("test-info", (message, options, theme) => { + const box = new Box(0, 0, (t) => theme.bg("success", t)); + + const label = theme.fg("successText", "[TEST INFO]"); + box.addChild(new Text(label, 0, 0)); + + const content = + typeof message.content === "string" + ? message.content + : message.content.map((c) => (c.type === "text" ? c.text : "[image]")).join(""); + + box.addChild(new Text(theme.fg("successText", content), 0, 1)); + + if (options.expanded && message.details) { + box.addChild(new Text(theme.fg("dim", `Details: ${JSON.stringify(message.details)}`), 0, 2)); + } + + return box; + }); + + // Register /test-msg command + pi.registerCommand("test-msg", { + description: "Send a test custom message", + handler: async (ctx) => { + pi.sendMessage({ + customType: "test-info", + content: "This is a test message with custom rendering!", + display: true, + details: { timestamp: Date.now(), source: "test-command hook" }, + }); }, }); + + // Register /test-hidden command + pi.registerCommand("test-hidden", { + description: "Send a hidden message (display: false)", + handler: async (ctx) => { + pi.sendMessage({ + customType: "test-info", + content: "This message is in context but not displayed", + display: false, + }); + ctx.ui.notify("Sent hidden message (check session file)"); + }, + }); + + // Register /test-inject command to toggle before_agent_start injection + pi.registerCommand("test-inject", { + description: "Toggle context injection before agent starts", + handler: async (ctx) => { + injectEnabled = !injectEnabled; + ctx.ui.notify(`Context injection ${injectEnabled ? "enabled" : "disabled"}`); + }, + }); + + // Demonstrate before_agent_start: inject context when enabled + pi.on("before_agent_start", async (event, ctx) => { + if (!injectEnabled) return; + + // Return a message to inject before the user's prompt + return { + message: { + customType: "test-info", + content: `[Injected context for prompt: "${event.prompt.slice(0, 50)}..."]`, + display: true, + details: { injectedAt: Date.now() }, + }, + }; + }); } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9502a2a3..306a7d17 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -490,6 +490,29 @@ export class AgentSession { // Expand file-based slash commands if requested const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text; + // Emit before_agent_start hook event + if (this._hookRunner) { + const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images); + if (result?.message) { + // Append hook message to agent state and session + const hookMessage: HookMessage = { + role: "hookMessage", + customType: result.message.customType, + content: result.message.content, + display: result.message.display, + details: result.message.details, + timestamp: Date.now(), + }; + this.agent.appendMessage(hookMessage); + this.sessionManager.appendCustomMessageEntry( + result.message.customType, + result.message.content, + result.message.display, + result.message.details, + ); + } + } + await this.agent.prompt(expandedText, options?.images); await this.waitForRetry(); } diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 43441e3a..6f34fe00 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -12,6 +12,8 @@ export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type { AgentEndEvent, AgentStartEvent, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, BashToolResultEvent, ContextEvent, ContextEventResult, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index ced7e994..76c17f51 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -7,6 +7,8 @@ import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; import type { + BeforeAgentStartEvent, + BeforeAgentStartEventResult, ContextEvent, ContextEventResult, HookError, @@ -346,4 +348,44 @@ export class HookRunner { return currentMessages; } + + /** + * Emit before_agent_start event to all hooks. + * Returns the first message to inject (if any handler returns one). + */ + async emitBeforeAgentStart( + prompt: string, + images?: import("@mariozechner/pi-ai").ImageContent[], + ): Promise { + const ctx = this.createContext(); + let result: BeforeAgentStartEventResult | undefined; + + for (const hook of this.hooks) { + const handlers = hook.handlers.get("before_agent_start"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images }; + const timeout = createTimeout(this.timeout); + const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); + timeout.clear(); + + // Take the first message returned + if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) { + result = handlerResult as BeforeAgentStartEventResult; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.emitError({ + hookPath: hook.path, + event: "before_agent_start", + error: message, + }); + } + } + } + + return result; + } } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 05eba8e8..32426f45 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -158,6 +158,19 @@ export interface ContextEvent { messages: AgentMessage[]; } +/** + * Event data for before_agent_start event. + * Fired after user submits a prompt but before the agent loop starts. + * Allows hooks to inject context that will be persisted and visible in TUI. + */ +export interface BeforeAgentStartEvent { + type: "before_agent_start"; + /** The user's prompt text */ + prompt: string; + /** Any images attached to the prompt */ + images?: ImageContent[]; +} + /** * Event data for agent_start event. * Fired when an agent loop starts (once per user prompt). @@ -314,6 +327,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { export type HookEvent = | SessionEvent | ContextEvent + | BeforeAgentStartEvent | AgentStartEvent | AgentEndEvent | TurnStartEvent @@ -358,6 +372,15 @@ export interface ToolResultEventResult { isError?: boolean; } +/** + * Return type for before_agent_start event handlers. + * Allows hooks to inject context before the agent runs. + */ +export interface BeforeAgentStartEventResult { + /** Message to inject into context (persisted to session, visible in TUI) */ + message?: Pick; +} + /** * Return type for session event handlers. * Allows hooks to cancel "before_*" actions. @@ -433,6 +456,11 @@ export interface HookAPI { on(event: "session", handler: HookHandler): void; // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything on(event: "context", handler: HookHandler): void; + // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything + on( + event: "before_agent_start", + handler: HookHandler, + ): void; on(event: "agent_start", handler: HookHandler): void; on(event: "agent_end", handler: HookHandler): void; on(event: "turn_start", handler: HookHandler): void; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 01c0d689..b363b1ce 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -42,6 +42,8 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, CustomToolResultEvent, EditToolResultEvent, FindToolResultEvent, From f43969b31ab3b83ec55ab0eb90b034556db457a7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:50:45 +0100 Subject: [PATCH 088/161] Update plan: mark before_agent_start as complete --- .pi/hooks/test-command.ts | 2 +- packages/coding-agent/docs/session-tree-plan.md | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index 9e909de2..086770bd 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -10,7 +10,7 @@ export default function (pi: HookAPI) { // Register a custom message renderer for our "test-info" type pi.registerMessageRenderer("test-info", (message, options, theme) => { - const box = new Box(0, 0, (t) => theme.bg("success", t)); + const box = new Box(0, 0, (t) => theme.bg("customMessageBg", t)); const label = theme.fg("successText", "[TEST INFO]"); box.addChild(new Text(label, 0, 0)); diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index ad4f6c60..49c1ae0e 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -285,7 +285,7 @@ Benefits: - Works with branching (pruning entries are part of the tree) - Trade-off: cache busting on first submission after pruning -### Investigate: `context` event vs `before_agent_start` +### Investigate: `context` event vs `before_agent_start` ✅ References: - [#324](https://github.com/badlogic/pi-mono/issues/324) - `before_agent_start` proposal @@ -329,9 +329,11 @@ Questions: | Cache impact | Can bust cache | Append-only, cache-safe | | Use case | Transient manipulation | Persistent context injection | -**Design questions:** -- [ ] Should `before_agent_start` create a new message type (`SystemMessage` with `role: "system"`)? -- [ ] How should it render in TUI? (label when collapsed, full content when expanded) +**Implementation (completed):** +- Reuses `HookMessage` type (no new message type needed) +- Handler returns `{ message: Pick }` +- Message is appended to agent state AND persisted to session before `agent.prompt()` is called +- Renders using existing `HookMessageComponent` (or custom renderer if registered) - [ ] How does it interact with compaction? (treated like user messages?) - [ ] Can hook return multiple messages or just one? From 0d558813e461bbbd4baa198580dbc37644b43f3c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:54:11 +0100 Subject: [PATCH 089/161] Fix test hook: use valid theme colors, proper type import --- .pi/hooks/test-command.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index 086770bd..a183a56d 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -1,7 +1,7 @@ /** * Test hook demonstrating custom commands, message rendering, and before_agent_start. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import type { BeforeAgentStartEvent, HookAPI } from "@mariozechner/pi-coding-agent"; import { Box, Text } from "@mariozechner/pi-tui"; export default function (pi: HookAPI) { @@ -12,7 +12,7 @@ export default function (pi: HookAPI) { pi.registerMessageRenderer("test-info", (message, options, theme) => { const box = new Box(0, 0, (t) => theme.bg("customMessageBg", t)); - const label = theme.fg("successText", "[TEST INFO]"); + const label = theme.fg("success", "[TEST INFO]"); box.addChild(new Text(label, 0, 0)); const content = @@ -20,7 +20,7 @@ export default function (pi: HookAPI) { ? message.content : message.content.map((c) => (c.type === "text" ? c.text : "[image]")).join(""); - box.addChild(new Text(theme.fg("successText", content), 0, 1)); + box.addChild(new Text(theme.fg("text", content), 0, 1)); if (options.expanded && message.details) { box.addChild(new Text(theme.fg("dim", `Details: ${JSON.stringify(message.details)}`), 0, 2)); @@ -32,7 +32,7 @@ export default function (pi: HookAPI) { // Register /test-msg command pi.registerCommand("test-msg", { description: "Send a test custom message", - handler: async (ctx) => { + handler: async () => { pi.sendMessage({ customType: "test-info", content: "This is a test message with custom rendering!", @@ -65,7 +65,7 @@ export default function (pi: HookAPI) { }); // Demonstrate before_agent_start: inject context when enabled - pi.on("before_agent_start", async (event, ctx) => { + pi.on("before_agent_start", async (event: BeforeAgentStartEvent) => { if (!injectEnabled) return; // Return a message to inject before the user's prompt From 454dd919beff3999a3ef8ac98531bb37e5fc8cc4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 14:57:43 +0100 Subject: [PATCH 090/161] Fix test hook: add triggerTurn to start agent run --- .pi/hooks/test-command.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index a183a56d..52382d85 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -33,12 +33,15 @@ export default function (pi: HookAPI) { pi.registerCommand("test-msg", { description: "Send a test custom message", handler: async () => { - pi.sendMessage({ - customType: "test-info", - content: "This is a test message with custom rendering!", - display: true, - details: { timestamp: Date.now(), source: "test-command hook" }, - }); + pi.sendMessage( + { + customType: "test-info", + content: "This is a test message with custom rendering!", + display: true, + details: { timestamp: Date.now(), source: "test-command hook" }, + }, + true, // triggerTurn: start agent run + ); }, }); From f8352bb7d73c2ef15d07ed6d6b41431fd98f2237 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 15:00:52 +0100 Subject: [PATCH 091/161] Rename immediate -> allowDuringStreaming for hook commands --- .pi/hooks/test-command.ts | 8 ++++++++ packages/coding-agent/examples/hooks/snake.ts | 2 +- packages/coding-agent/src/core/hooks/types.ts | 6 +++--- .../src/modes/interactive/interactive-mode.ts | 6 +++--- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index 52382d85..a9bdc16e 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -29,6 +29,14 @@ export default function (pi: HookAPI) { return box; }); + pi.registerCommand("no-stream", { + description: "Send a message without streaming", + handler: async (ctx) => { + ctx.ui.notify("Sending message after streaming is done..."); + }, + allowDuringStreaming: true, + }) + // Register /test-msg command pi.registerCommand("test-msg", { description: "Send a test custom message", diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index e7569c60..ea7031da 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -309,7 +309,7 @@ const SNAKE_SAVE_TYPE = "snake-save"; export default function (pi: HookAPI) { pi.registerCommand("snake", { description: "Play Snake!", - immediate: true, // Run immediately, even during streaming + allowDuringStreaming: true, // Run even during streaming, not queued handler: async (ctx) => { if (!ctx.hasUI) { ctx.ui.notify("Snake requires interactive mode", "error"); diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 32426f45..45943521 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -442,8 +442,8 @@ export interface HookCommandContext { export interface RegisteredCommand { name: string; description?: string; - /** If true, command runs immediately even during streaming (doesn't get queued) */ - immediate?: boolean; + /** If true, command runs during streaming instead of being queued */ + allowDuringStreaming?: boolean; handler: (ctx: HookCommandContext) => Promise; } @@ -529,7 +529,7 @@ export interface HookAPI { */ registerCommand( name: string, - options: { description?: string; immediate?: boolean; handler: RegisteredCommand["handler"] }, + options: { description?: string; allowDuringStreaming?: boolean; handler: RegisteredCommand["handler"] }, ): void; /** diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 18149fd4..68681cb9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -744,13 +744,13 @@ export class InteractiveMode { return; } - // Check if this is an immediate hook command (runs even during streaming) + // Check if this hook command can run during streaming (not queued) if (text.startsWith("/") && this.session.hookRunner && this.session.isStreaming) { const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const command = this.session.hookRunner.getCommand(commandName); - if (command?.immediate) { - // Execute immediate hook command right away + if (command?.allowDuringStreaming) { + // Execute hook command right away this.editor.addToHistory(text); this.editor.setText(""); await this.session.prompt(text); From 575c87547578c9083257f400782647726b67a72f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 16:54:43 +0100 Subject: [PATCH 092/161] Remove allowDuringStreaming flag - commands always run immediately Hook commands now always execute immediately, even during streaming. If a command needs to interact with the LLM, it uses pi.sendMessage() which handles queueing automatically. This simplifies the API and eliminates the issue of queued slash commands being sent to the LLM instead of executing. --- .pi/hooks/test-command.ts | 8 -- packages/ai/src/models.generated.ts | 114 +++++++++--------- packages/coding-agent/examples/hooks/snake.ts | 2 +- packages/coding-agent/src/core/hooks/types.ts | 10 +- .../src/modes/interactive/interactive-mode.ts | 10 +- 5 files changed, 66 insertions(+), 78 deletions(-) diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index a9bdc16e..52382d85 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -29,14 +29,6 @@ export default function (pi: HookAPI) { return box; }); - pi.registerCommand("no-stream", { - description: "Send a message without streaming", - handler: async (ctx) => { - ctx.ui.notify("Sending message after streaming is done..."); - }, - allowDuringStreaming: true, - }) - // Register /test-msg command pi.registerCommand("test-msg", { description: "Send a test custom message", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6392308c..2df269d3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,6 +6359,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6393,23 +6410,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,6 +6546,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6580,23 +6597,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4": { id: "openai/gpt-4", name: "OpenAI: GPT-4", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index ea7031da..3b5ffe8d 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -309,7 +309,7 @@ const SNAKE_SAVE_TYPE = "snake-save"; export default function (pi: HookAPI) { pi.registerCommand("snake", { description: "Play Snake!", - allowDuringStreaming: true, // Run even during streaming, not queued + handler: async (ctx) => { if (!ctx.hasUI) { ctx.ui.notify("Snake requires interactive mode", "error"); diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 45943521..fa753b7e 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -442,8 +442,7 @@ export interface HookCommandContext { export interface RegisteredCommand { name: string; description?: string; - /** If true, command runs during streaming instead of being queued */ - allowDuringStreaming?: boolean; + handler: (ctx: HookCommandContext) => Promise; } @@ -456,9 +455,9 @@ export interface HookAPI { on(event: "session", handler: HookHandler): void; // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything on(event: "context", handler: HookHandler): void; - // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything on( event: "before_agent_start", + // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything handler: HookHandler, ): void; on(event: "agent_start", handler: HookHandler): void; @@ -527,10 +526,7 @@ export interface HookAPI { * Register a custom slash command. * Handler receives HookCommandContext. */ - registerCommand( - name: string, - options: { description?: string; allowDuringStreaming?: boolean; handler: RegisteredCommand["handler"] }, - ): void; + registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; /** * Execute a shell command and return stdout/stderr/code. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 68681cb9..d1e12f6c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -744,13 +744,13 @@ export class InteractiveMode { return; } - // Check if this hook command can run during streaming (not queued) - if (text.startsWith("/") && this.session.hookRunner && this.session.isStreaming) { + // Hook commands always run immediately, even during streaming + // (if they need to interact with LLM, they use pi.sendMessage which handles queueing) + if (text.startsWith("/") && this.session.hookRunner) { const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const command = this.session.hookRunner.getCommand(commandName); - if (command?.allowDuringStreaming) { - // Execute hook command right away + if (command) { this.editor.addToHistory(text); this.editor.setText(""); await this.session.prompt(text); @@ -758,7 +758,7 @@ export class InteractiveMode { } } - // Queue message if agent is streaming + // Queue regular messages if agent is streaming if (this.session.isStreaming) { await this.session.queueMessage(text); this.updatePendingMessagesDisplay(); From 41af99cccfe7f1e34ed664d498ee37c3dac3205e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 17:03:38 +0100 Subject: [PATCH 093/161] Support multiple messages in agent.prompt() and agentLoop - agentLoop now accepts AgentMessage[] instead of single message - agent.prompt() accepts AgentMessage | AgentMessage[] - Emits message_start/end for each message in the array - AgentSession.prompt() builds array with hook message + user message - TUI now receives events for before_agent_start injected messages --- packages/agent/src/agent-loop.ts | 12 ++++--- packages/agent/src/agent.ts | 34 +++++++++++-------- packages/agent/test/agent-loop.test.ts | 10 +++--- .../coding-agent/src/core/agent-session.ts | 28 +++++++++------ 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 0fd0d6a0..ee63b89f 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -26,7 +26,7 @@ import type { * The prompt is added to the context and events are emitted for it. */ export function agentLoop( - prompt: AgentMessage, + prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, @@ -35,16 +35,18 @@ export function agentLoop( const stream = createAgentStream(); (async () => { - const newMessages: AgentMessage[] = [prompt]; + const newMessages: AgentMessage[] = [...prompts]; const currentContext: AgentContext = { ...context, - messages: [...context.messages, prompt], + messages: [...context.messages, ...prompts], }; stream.push({ type: "agent_start" }); stream.push({ type: "turn_start" }); - stream.push({ type: "message_start", message: prompt }); - stream.push({ type: "message_end", message: prompt }); + for (const prompt of prompts) { + stream.push({ type: "message_start", message: prompt }); + stream.push({ type: "message_end", message: prompt }); + } await runLoop(currentContext, newMessages, config, signal, stream, streamFn); })(); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 8d5d7706..078b707e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -168,29 +168,33 @@ export class Agent { } /** Send a prompt with an AgentMessage */ - async prompt(message: AgentMessage): Promise; + async prompt(message: AgentMessage | AgentMessage[]): Promise; async prompt(input: string, images?: ImageContent[]): Promise; - async prompt(input: string | AgentMessage, images?: ImageContent[]) { + async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) { const model = this._state.model; if (!model) throw new Error("No model configured"); - let userMessage: AgentMessage; + let msgs: AgentMessage[]; - if (typeof input === "string") { + if (Array.isArray(input)) { + msgs = input; + } else if (typeof input === "string") { const content: Array = [{ type: "text", text: input }]; if (images && images.length > 0) { content.push(...images); } - userMessage = { - role: "user", - content, - timestamp: Date.now(), - }; + msgs = [ + { + role: "user", + content, + timestamp: Date.now(), + }, + ]; } else { - userMessage = input; + msgs = [input]; } - await this._runLoop(userMessage); + await this._runLoop(msgs); } /** Continue from current context (for retry after overflow) */ @@ -208,10 +212,10 @@ export class Agent { /** * Run the agent loop. - * If userMessage is provided, starts a new conversation turn. + * If messages are provided, starts a new conversation turn with those messages. * Otherwise, continues from existing context. */ - private async _runLoop(userMessage?: AgentMessage) { + private async _runLoop(messages?: AgentMessage[]) { const model = this._state.model; if (!model) throw new Error("No model configured"); @@ -262,8 +266,8 @@ export class Agent { let partial: AgentMessage | null = null; try { - const stream = userMessage - ? agentLoop(userMessage, context, config, this.abortController.signal, this.streamFn) + const stream = messages + ? agentLoop(messages, context, config, this.abortController.signal, this.streamFn) : agentLoopContinue(context, config, this.abortController.signal, this.streamFn); for await (const event of stream) { diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 5eb8d30e..b8295038 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -105,7 +105,7 @@ describe("agentLoop with AgentMessage", () => { }; const events: AgentEvent[] = []; - const stream = agentLoop(userPrompt, context, config, undefined, streamFn); + const stream = agentLoop([userPrompt], context, config, undefined, streamFn); for await (const event of stream) { events.push(event); @@ -172,7 +172,7 @@ describe("agentLoop with AgentMessage", () => { }; const events: AgentEvent[] = []; - const stream = agentLoop(userPrompt, context, config, undefined, streamFn); + const stream = agentLoop([userPrompt], context, config, undefined, streamFn); for await (const event of stream) { events.push(event); @@ -224,7 +224,7 @@ describe("agentLoop with AgentMessage", () => { return stream; }; - const stream = agentLoop(userPrompt, context, config, undefined, streamFn); + const stream = agentLoop([userPrompt], context, config, undefined, streamFn); for await (const _ of stream) { // consume @@ -288,7 +288,7 @@ describe("agentLoop with AgentMessage", () => { }; const events: AgentEvent[] = []; - const stream = agentLoop(userPrompt, context, config, undefined, streamFn); + const stream = agentLoop([userPrompt], context, config, undefined, streamFn); for await (const event of stream) { events.push(event); @@ -351,7 +351,7 @@ describe("agentLoop with AgentMessage", () => { }; const events: AgentEvent[] = []; - const stream = agentLoop(userPrompt, context, config, undefined, (_model, ctx, _options) => { + const stream = agentLoop([userPrompt], context, config, undefined, (_model, ctx, _options) => { // Check if interrupt message is in context on second call if (callIndex === 1) { sawInterruptInContext = ctx.messages.some( diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 306a7d17..a0d0b107 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -490,30 +490,36 @@ export class AgentSession { // Expand file-based slash commands if requested const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text; + // Build messages array (hook message if any, then user message) + const messages: AgentMessage[] = []; + + // Add user message + const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; + if (options?.images) { + userContent.push(...options.images); + } + messages.push({ + role: "user", + content: userContent, + timestamp: Date.now(), + }); + // Emit before_agent_start hook event if (this._hookRunner) { const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images); if (result?.message) { - // Append hook message to agent state and session - const hookMessage: HookMessage = { + messages.push({ role: "hookMessage", customType: result.message.customType, content: result.message.content, display: result.message.display, details: result.message.details, timestamp: Date.now(), - }; - this.agent.appendMessage(hookMessage); - this.sessionManager.appendCustomMessageEntry( - result.message.customType, - result.message.content, - result.message.display, - result.message.details, - ); + }); } } - await this.agent.prompt(expandedText, options?.images); + await this.agent.prompt(messages); await this.waitForRetry(); } From 26e1c9d91c8366ec62b30c24c2a73ac503abf855 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 17:08:49 +0100 Subject: [PATCH 094/161] Fix nested background issue in HookMessageComponent When custom renderer returns a component with its own background, don't wrap it in another background box. The nested bg resets were causing padding to lose its background color. --- .../interactive/components/hook-message.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/hook-message.ts b/packages/coding-agent/src/modes/interactive/components/hook-message.ts index ee5be2b6..186e902f 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-message.ts @@ -1,4 +1,5 @@ import type { TextContent } from "@mariozechner/pi-ai"; +import type { Component } from "@mariozechner/pi-tui"; import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; import type { HookMessageRenderer } from "../../../core/hooks/types.js"; import type { HookMessage } from "../../../core/messages.js"; @@ -12,6 +13,7 @@ export class HookMessageComponent extends Container { private message: HookMessage; private customRenderer?: HookMessageRenderer; private box: Box; + private customComponent?: Component; private _expanded = false; constructor(message: HookMessage, customRenderer?: HookMessageRenderer) { @@ -21,9 +23,8 @@ export class HookMessageComponent extends Container { this.addChild(new Spacer(1)); - // Create box with purple background + // Create box with purple background (used for default rendering) this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); - this.addChild(this.box); this.rebuild(); } @@ -36,14 +37,21 @@ export class HookMessageComponent extends Container { } private rebuild(): void { - this.box.clear(); + // Remove previous content component + if (this.customComponent) { + this.removeChild(this.customComponent); + this.customComponent = undefined; + } + this.removeChild(this.box); - // Try custom renderer first + // Try custom renderer first - it handles its own styling if (this.customRenderer) { try { const component = this.customRenderer(this.message, { expanded: this._expanded }, theme); if (component) { - this.box.addChild(component); + // Custom renderer provides its own styled component + this.customComponent = component; + this.addChild(component); return; } } catch { @@ -51,6 +59,10 @@ export class HookMessageComponent extends Container { } } + // Default rendering uses our box + this.addChild(this.box); + this.box.clear(); + // Default rendering: label + content const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`); this.box.addChild(new Text(label, 0, 0)); From 38d65dfe5996ea2f78123b9bd8a1bd84164d6612 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 17:24:31 +0100 Subject: [PATCH 095/161] Add ReadonlySessionManager type for hooks Hooks now receive ReadonlySessionManager in contexts, which only exposes read methods. Writes must go through pi.sendMessage() or pi.appendEntry(). --- .pi/hooks/test-command.ts | 10 +++-- packages/coding-agent/src/core/hooks/index.ts | 1 + packages/coding-agent/src/core/hooks/types.ts | 37 ++++++++++++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts index 52382d85..7f737498 100644 --- a/.pi/hooks/test-command.ts +++ b/.pi/hooks/test-command.ts @@ -2,7 +2,7 @@ * Test hook demonstrating custom commands, message rendering, and before_agent_start. */ import type { BeforeAgentStartEvent, HookAPI } from "@mariozechner/pi-coding-agent"; -import { Box, Text } from "@mariozechner/pi-tui"; +import { Box, Spacer, Text } from "@mariozechner/pi-tui"; export default function (pi: HookAPI) { // Track whether injection is enabled @@ -10,20 +10,22 @@ export default function (pi: HookAPI) { // Register a custom message renderer for our "test-info" type pi.registerMessageRenderer("test-info", (message, options, theme) => { - const box = new Box(0, 0, (t) => theme.bg("customMessageBg", t)); + const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); const label = theme.fg("success", "[TEST INFO]"); box.addChild(new Text(label, 0, 0)); + box.addChild(new Spacer(1)); const content = typeof message.content === "string" ? message.content : message.content.map((c) => (c.type === "text" ? c.text : "[image]")).join(""); - box.addChild(new Text(theme.fg("text", content), 0, 1)); + box.addChild(new Text(theme.fg("text", content), 0, 0)); if (options.expanded && message.details) { - box.addChild(new Text(theme.fg("dim", `Details: ${JSON.stringify(message.details)}`), 0, 2)); + box.addChild(new Spacer(1)); + box.addChild(new Text(theme.fg("dim", `Details: ${JSON.stringify(message.details)}`), 0, 0)); } return box; diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 6f34fe00..395d15e0 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -33,6 +33,7 @@ export type { HookMessageRenderOptions, HookUIContext, LsToolResultEvent, + ReadonlySessionManager, ReadToolResultEvent, RegisteredCommand, SessionEvent, diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index fa753b7e..e97e20e0 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -13,7 +13,34 @@ import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { CompactionEntry, SessionManager } from "../session-manager.js"; +import type { + CompactionEntry, + SessionEntry, + SessionHeader, + SessionManager, + SessionTreeNode, +} from "../session-manager.js"; + +/** + * Read-only view of SessionManager for hooks. + * Hooks should use pi.sendMessage() and pi.appendEntry() for writes. + */ +export type ReadonlySessionManager = Pick< + SessionManager, + | "getCwd" + | "getSessionDir" + | "getSessionId" + | "getSessionFile" + | "getLeafUuid" + | "getLeafEntry" + | "getEntry" + | "getLabel" + | "getPath" + | "getHeader" + | "getEntries" + | "getTree" +>; + import type { EditToolDetails } from "../tools/edit.js"; import type { BashToolDetails, @@ -76,8 +103,8 @@ export interface HookEventContext { hasUI: boolean; /** Current working directory */ cwd: string; - /** Session manager instance - use for entries, session file, etc. */ - sessionManager: SessionManager; + /** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */ + sessionManager: ReadonlySessionManager; /** Model registry - use for API key resolution and model retrieval */ modelRegistry: ModelRegistry; } @@ -430,8 +457,8 @@ export interface HookCommandContext { hasUI: boolean; /** Current working directory */ cwd: string; - /** Session manager for reading/writing session entries */ - sessionManager: SessionManager; + /** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */ + sessionManager: ReadonlySessionManager; /** Model registry for API keys */ modelRegistry: ModelRegistry; } From d6283f99dc38fb0c6cd026c9ed419b84190bd881 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 28 Dec 2025 20:06:20 +0100 Subject: [PATCH 096/161] refactor(hooks): split session events into individual typed events Major changes: - Replace monolithic SessionEvent with reason discriminator with individual event types: session_start, session_before_switch, session_switch, session_before_new, session_new, session_before_branch, session_branch, session_before_compact, session_compact, session_shutdown - Each event has dedicated result type (SessionBeforeSwitchResult, etc.) - HookHandler type now allows bare return statements (void in return type) - HookAPI.on() has proper overloads for each event with correct typing Additional fixes: - AgentSession now always subscribes to agent in constructor (was only subscribing when external subscribe() called, breaking internal handlers) - Standardize on undefined over null throughout codebase - HookUIContext methods return undefined instead of null - SessionManager methods return undefined instead of null - Simplify hook exports to 'export type * from types.js' - Add detailed JSDoc for skipConversationRestore vs cancel - Fix createBranchedSession to rebuild index in persist mode - newSession() now returns the session file path Updated all example hooks, tests, and emission sites to use new event types. --- .../examples/custom-tools/question/index.ts | 2 +- .../examples/hooks/auto-commit-on-exit.ts | 4 +- .../examples/hooks/confirm-destructive.ts | 82 ++- .../examples/hooks/custom-compaction.ts | 4 +- .../examples/hooks/dirty-repo-guard.ts | 84 +-- .../examples/hooks/file-trigger.ts | 4 +- .../examples/hooks/git-checkpoint.ts | 7 +- ..._d1555962-63f5-402c-b6e5-24fa5d528fb2.html | 496 ++++++++++++++++++ ..._6646a9e5-779b-4f81-8d45-d8d32f99aa25.html | 496 ++++++++++++++++++ ..._7225b2a7-7612-440d-84f1-fbcd8037d2f2.html | 495 +++++++++++++++++ .../coding-agent/src/core/agent-session.ts | 196 ++++--- .../coding-agent/src/core/auth-storage.ts | 8 +- .../coding-agent/src/core/bash-executor.ts | 8 +- packages/coding-agent/src/core/compaction.ts | 23 +- .../src/core/custom-tools/loader.ts | 4 +- .../src/core/custom-tools/types.ts | 8 +- packages/coding-agent/src/core/export-html.ts | 16 +- packages/coding-agent/src/core/hooks/index.ts | 47 +- .../coding-agent/src/core/hooks/runner.ts | 40 +- packages/coding-agent/src/core/hooks/types.ts | 232 ++++---- packages/coding-agent/src/core/messages.ts | 10 +- .../coding-agent/src/core/model-registry.ts | 22 +- .../coding-agent/src/core/model-resolver.ts | 42 +- packages/coding-agent/src/core/sdk.ts | 2 +- .../coding-agent/src/core/session-manager.ts | 22 +- packages/coding-agent/src/index.ts | 28 +- packages/coding-agent/src/main.ts | 32 +- .../interactive/components/bash-execution.ts | 10 +- .../interactive/components/model-selector.ts | 6 +- .../src/modes/interactive/interactive-mode.ts | 74 ++- packages/coding-agent/src/modes/print-mode.ts | 7 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 19 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 4 +- .../coding-agent/src/utils/tools-manager.ts | 6 +- .../test/agent-session-branching.test.ts | 6 +- .../test/agent-session-compaction.test.ts | 2 +- .../test/compaction-hooks-example.test.ts | 18 +- .../test/compaction-hooks.test.ts | 147 +++--- packages/coding-agent/test/compaction.test.ts | 14 +- .../coding-agent/test/model-resolver.test.ts | 24 +- packages/coding-agent/test/rpc.test.ts | 2 +- .../session-manager/build-context.test.ts | 14 +- .../session-manager/tree-traversal.test.ts | 2 +- 43 files changed, 2129 insertions(+), 640 deletions(-) create mode 100644 packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html create mode 100644 packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html create mode 100644 packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html diff --git a/packages/coding-agent/examples/custom-tools/question/index.ts b/packages/coding-agent/examples/custom-tools/question/index.ts index 23de95b7..76c068ca 100644 --- a/packages/coding-agent/examples/custom-tools/question/index.ts +++ b/packages/coding-agent/examples/custom-tools/question/index.ts @@ -41,7 +41,7 @@ const factory: CustomToolFactory = (pi) => { const answer = await pi.ui.select(params.question, params.options); - if (answer === null) { + if (answer === undefined) { return { content: [{ type: "text", text: "User cancelled the selection" }], details: { question: params.question, options: params.options, answer: null }, diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts index 0f3ef871..bfcc37f7 100644 --- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts +++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts @@ -8,9 +8,7 @@ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - if (event.reason !== "shutdown") return; - + pi.on("session_shutdown", async (_event, ctx) => { // Check for uncommitted changes const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]); diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts index 81e32eb5..75c5ee0c 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -2,63 +2,57 @@ * Confirm Destructive Actions Hook * * Prompts for confirmation before destructive session actions (clear, switch, branch). - * Demonstrates how to cancel session events using the before_* variants. + * Demonstrates how to cancel session events using the before_* events. */ import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - // Only handle before_* events (the ones that can be cancelled) - if (event.reason === "before_new") { - if (!ctx.hasUI) return; + pi.on("session_before_new", async (_event, ctx) => { + if (!ctx.hasUI) return; + const confirmed = await ctx.ui.confirm("Clear session?", "This will delete all messages in the current session."); + + if (!confirmed) { + ctx.ui.notify("Clear cancelled", "info"); + return { cancel: true }; + } + }); + + pi.on("session_before_switch", async (_event, ctx) => { + if (!ctx.hasUI) return; + + // Check if there are unsaved changes (messages since last assistant response) + const entries = ctx.sessionManager.getEntries(); + const hasUnsavedWork = entries.some( + (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user", + ); + + if (hasUnsavedWork) { const confirmed = await ctx.ui.confirm( - "Clear session?", - "This will delete all messages in the current session.", + "Switch session?", + "You have messages in the current session. Switch anyway?", ); if (!confirmed) { - ctx.ui.notify("Clear cancelled", "info"); - return { cancel: true }; - } - } - - if (event.reason === "before_switch") { - if (!ctx.hasUI) return; - - // Check if there are unsaved changes (messages since last assistant response) - const entries = ctx.sessionManager.getEntries(); - const hasUnsavedWork = entries.some( - (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user", - ); - - if (hasUnsavedWork) { - const confirmed = await ctx.ui.confirm( - "Switch session?", - "You have messages in the current session. Switch anyway?", - ); - - if (!confirmed) { - ctx.ui.notify("Switch cancelled", "info"); - return { cancel: true }; - } - } - } - - if (event.reason === "before_branch") { - if (!ctx.hasUI) return; - - const choice = await ctx.ui.select(`Branch from turn ${event.targetTurnIndex}?`, [ - "Yes, create branch", - "No, stay in current session", - ]); - - if (choice !== "Yes, create branch") { - ctx.ui.notify("Branch cancelled", "info"); + ctx.ui.notify("Switch cancelled", "info"); return { cancel: true }; } } }); + + pi.on("session_before_branch", async (event, ctx) => { + if (!ctx.hasUI) return; + + const choice = await ctx.ui.select(`Branch from turn ${event.entryIndex}?`, [ + "Yes, create branch", + "No, stay in current session", + ]); + + if (choice !== "Yes, create branch") { + ctx.ui.notify("Branch cancelled", "info"); + return { cancel: true }; + } + }); } diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index 559cd682..f912a3d6 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -18,9 +18,7 @@ import { convertToLlm } from "@mariozechner/pi-coding-agent"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - if (event.reason !== "before_compact") return; - + pi.on("session_before_compact", async (event, ctx) => { ctx.ui.notify("Custom compaction hook triggered", "info"); const { preparation, previousCompactions, signal } = event; diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts index 852ff006..134667e3 100644 --- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts +++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts @@ -5,47 +5,55 @@ * Useful to ensure work is committed before switching context. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI, HookEventContext } from "@mariozechner/pi-coding-agent/hooks"; + +async function checkDirtyRepo( + pi: HookAPI, + ctx: HookEventContext, + action: string, +): Promise<{ cancel: boolean } | undefined> { + // Check for uncommitted changes + const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); + + if (code !== 0) { + // Not a git repo, allow the action + return; + } + + const hasChanges = stdout.trim().length > 0; + if (!hasChanges) { + return; + } + + if (!ctx.hasUI) { + // In non-interactive mode, block by default + return { cancel: true }; + } + + // Count changed files + const changedFiles = stdout.trim().split("\n").filter(Boolean).length; + + const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [ + "Yes, proceed anyway", + "No, let me commit first", + ]); + + if (choice !== "Yes, proceed anyway") { + ctx.ui.notify("Commit your changes first", "warning"); + return { cancel: true }; + } +} export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - // Only guard destructive actions - if (event.reason !== "before_new" && event.reason !== "before_switch" && event.reason !== "before_branch") { - return; - } + pi.on("session_before_new", async (_event, ctx) => { + return checkDirtyRepo(pi, ctx, "new session"); + }); - // Check for uncommitted changes - const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); + pi.on("session_before_switch", async (_event, ctx) => { + return checkDirtyRepo(pi, ctx, "switch session"); + }); - if (code !== 0) { - // Not a git repo, allow the action - return; - } - - const hasChanges = stdout.trim().length > 0; - if (!hasChanges) { - return; - } - - if (!ctx.hasUI) { - // In non-interactive mode, block by default - return { cancel: true }; - } - - // Count changed files - const changedFiles = stdout.trim().split("\n").filter(Boolean).length; - - const action = - event.reason === "before_new" ? "new session" : event.reason === "before_switch" ? "switch session" : "branch"; - - const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [ - "Yes, proceed anyway", - "No, let me commit first", - ]); - - if (choice !== "Yes, proceed anyway") { - ctx.ui.notify("Commit your changes first", "warning"); - return { cancel: true }; - } + pi.on("session_before_branch", async (_event, ctx) => { + return checkDirtyRepo(pi, ctx, "branch"); }); } diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/hooks/file-trigger.ts index 74f6314b..4363bdce 100644 --- a/packages/coding-agent/examples/hooks/file-trigger.ts +++ b/packages/coding-agent/examples/hooks/file-trigger.ts @@ -12,9 +12,7 @@ import * as fs from "node:fs"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - if (event.reason !== "start") return; - + pi.on("session_start", async (_event, ctx) => { const triggerFile = "/tmp/agent-trigger.txt"; fs.watch(triggerFile, () => { diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts index a69cadc8..87c8f0b5 100644 --- a/packages/coding-agent/examples/hooks/git-checkpoint.ts +++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts @@ -19,11 +19,8 @@ export default function (pi: HookAPI) { } }); - pi.on("session", async (event, ctx) => { - // Only handle before_branch events - if (event.reason !== "before_branch") return; - - const ref = checkpoints.get(event.targetTurnIndex); + pi.on("session_before_branch", async (event, ctx) => { + const ref = checkpoints.get(event.entryIndex); if (!ref) return; if (!ctx.hasUI) { diff --git a/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html b/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html new file mode 100644 index 00000000..c8f5a813 --- /dev/null +++ b/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html @@ -0,0 +1,496 @@ + + + + + + Session Export - 2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.jsonl + + + +
+
+

pi v0.30.2

+
+
Session:d1555962-63f5-402c-b6e5-24fa5d528fb2
+
Date:12/28/2025, 6:08:16 PM
+
Models:unknown
+
+
+ +
+

Messages

+
+
User:1
+
Assistant:2
+
Tool Calls:1
+
+
+ +
+

Tokens & Cost

+
+
Input:9 tokens
+
Output:268 tokens
+
Cache Read:2,462 tokens
+
Cache Write:3,505 tokens
+
Total:6,244 tokens
+
Input Cost:$0.0000
+
Output Cost:$0.0040
+
Cache Read Cost:$0.0007
+
Cache Write Cost:$0.0131
+
Total Cost:$0.0179
+
Context Usage:3,647 / 200,000 tokens (1.8%) - anthropic/claude-sonnet-4-5
+
+
+ +
+
System Prompt
+
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. + +Available tools: +- read: Read file contents +- bash: Execute bash commands (ls, grep, find, etc.) +- edit: Make surgical edits to files (find exact text and replace) +- write: Create or overwrite files + +Guidelines: +- Use bash for file operations like ls, grep, find +- Use read to examine files before editing. You must use this tool instead of cat or sed. +- Use edit for precise changes (old text must match exactly) +- Use write only for new files or complete rewrites +- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did +- Be concise in your responses +- Show file paths clearly when working with files + +Documentation: +- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md +- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs +- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md) + +# Project Context + +The following project context files have been loaded: + +## /Users/badlogic/workspaces/pi-mono/AGENTS.md + +# Development Rules + +## First Message +If the user did not give you a concrete task in their first message, +read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel. +- packages/ai/README.md +- packages/tui/README.md +- packages/agent/README.md +- packages/coding-agent/README.md +- packages/mom/README.md +- packages/pods/README.md +- packages/web-ui/README.md + +## Code Quality +- No `any` types unless absolutely necessary +- Check node_modules for external API type definitions instead of guessing +- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. +- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead +- Always ask before removing functionality or code that appears to be intentional + +## Commands +- After code changes: `npm run check` (get full output, no tail) +- NEVER run: `npm run dev`, `npm run build`, `npm test` +- Only run specific tests if user instructs: `npm test -- test/specific.test.ts` +- NEVER commit unless user asks + +## GitHub Issues +When reading issues: +- Always read all comments on the issue + +When creating issues: +- Add `pkg:*` labels to indicate which package(s) the issue affects + - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui` +- If an issue spans multiple packages, add all relevant labels + +When closing issues via commit: +- Include `fixes #<number>` or `closes #<number>` in the commit message +- This automatically closes the issue when the commit is merged + +## Tools +- GitHub CLI for issues/PRs +- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui +- TUI interaction: use tmux + +## Style +- Keep answers short and concise +- No emojis in commits, issues, PR comments, or code +- No fluff or cheerful filler text +- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") + +## Changelog +Location: `packages/*/CHANGELOG.md` (each package has its own) + +### Format +Use these sections under `## [Unreleased]`: +- `### Breaking Changes` - API changes requiring migration +- `### Added` - New features +- `### Changed` - Changes to existing functionality +- `### Fixed` - Bug fixes +- `### Removed` - Removed features + +### Rules +- New entries ALWAYS go under `## [Unreleased]` section +- NEVER modify already-released version sections (e.g., `## [0.12.2]`) +- Each version section is immutable once released + +### Attribution +- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))` +- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))` + +## Releasing + +1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md + +2. **Run release script**: + ```bash + npm run release:patch # Bug fixes + npm run release:minor # New features + npm run release:major # Breaking changes + ``` + +The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. + +### Tool Usage +**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). + + +Current date and time: Sunday, December 28, 2025 at 06:08:16 PM GMT+1 +Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
+
+
+
Available Tools
+
+
read - Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.
bash - Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.
edit - Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.
write - Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.
+
+
+ + +
+
06:08:16 PM

Hello

+
06:08:16 PM

Hello! I'm Claude Code, here to help you with coding tasks in this pi-mono monorepo.

+

Since you haven't given me a specific task yet, let me read the main README to understand the project better, then I can ask which module(s) you'd like to work on.

+
read ~/workspaces/pi-mono/README.md
06:08:20 PM

Great! This is a monorepo with several packages for building AI agents and managing LLM deployments.

+

Which module(s) would you like to work on?

+
    +
  • ai - Unified multi-provider LLM API
  • +
  • agent - Agent runtime with tool calling and state management
  • +
  • coding-agent - Interactive coding agent CLI
  • +
  • mom - Slack bot that delegates to the coding agent
  • +
  • tui - Terminal UI library
  • +
  • web-ui - Web components for AI chat interfaces
  • +
  • pods - CLI for managing vLLM deployments
  • +
+
+
+ + +
+ + \ No newline at end of file diff --git a/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html b/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html new file mode 100644 index 00000000..13c3df87 --- /dev/null +++ b/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html @@ -0,0 +1,496 @@ + + + + + + Session Export - 2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.jsonl + + + +
+
+

pi v0.30.2

+
+
Session:6646a9e5-779b-4f81-8d45-d8d32f99aa25
+
Date:12/28/2025, 7:08:12 PM
+
Models:unknown
+
+
+ +
+

Messages

+
+
User:1
+
Assistant:2
+
Tool Calls:1
+
+
+ +
+

Tokens & Cost

+
+
Input:9 tokens
+
Output:302 tokens
+
Cache Read:2,462 tokens
+
Cache Write:3,487 tokens
+
Total:6,260 tokens
+
Input Cost:$0.0000
+
Output Cost:$0.0045
+
Cache Read Cost:$0.0007
+
Cache Write Cost:$0.0131
+
Total Cost:$0.0184
+
Context Usage:3,681 / 200,000 tokens (1.8%) - anthropic/claude-sonnet-4-5
+
+
+ +
+
System Prompt
+
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. + +Available tools: +- read: Read file contents +- bash: Execute bash commands (ls, grep, find, etc.) +- edit: Make surgical edits to files (find exact text and replace) +- write: Create or overwrite files + +Guidelines: +- Use bash for file operations like ls, grep, find +- Use read to examine files before editing. You must use this tool instead of cat or sed. +- Use edit for precise changes (old text must match exactly) +- Use write only for new files or complete rewrites +- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did +- Be concise in your responses +- Show file paths clearly when working with files + +Documentation: +- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md +- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs +- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md) + +# Project Context + +The following project context files have been loaded: + +## /Users/badlogic/workspaces/pi-mono/AGENTS.md + +# Development Rules + +## First Message +If the user did not give you a concrete task in their first message, +read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel. +- packages/ai/README.md +- packages/tui/README.md +- packages/agent/README.md +- packages/coding-agent/README.md +- packages/mom/README.md +- packages/pods/README.md +- packages/web-ui/README.md + +## Code Quality +- No `any` types unless absolutely necessary +- Check node_modules for external API type definitions instead of guessing +- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. +- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead +- Always ask before removing functionality or code that appears to be intentional + +## Commands +- After code changes: `npm run check` (get full output, no tail) +- NEVER run: `npm run dev`, `npm run build`, `npm test` +- Only run specific tests if user instructs: `npm test -- test/specific.test.ts` +- NEVER commit unless user asks + +## GitHub Issues +When reading issues: +- Always read all comments on the issue + +When creating issues: +- Add `pkg:*` labels to indicate which package(s) the issue affects + - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui` +- If an issue spans multiple packages, add all relevant labels + +When closing issues via commit: +- Include `fixes #<number>` or `closes #<number>` in the commit message +- This automatically closes the issue when the commit is merged + +## Tools +- GitHub CLI for issues/PRs +- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui +- TUI interaction: use tmux + +## Style +- Keep answers short and concise +- No emojis in commits, issues, PR comments, or code +- No fluff or cheerful filler text +- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") + +## Changelog +Location: `packages/*/CHANGELOG.md` (each package has its own) + +### Format +Use these sections under `## [Unreleased]`: +- `### Breaking Changes` - API changes requiring migration +- `### Added` - New features +- `### Changed` - Changes to existing functionality +- `### Fixed` - Bug fixes +- `### Removed` - Removed features + +### Rules +- New entries ALWAYS go under `## [Unreleased]` section +- NEVER modify already-released version sections (e.g., `## [0.12.2]`) +- Each version section is immutable once released + +### Attribution +- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))` +- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))` + +## Releasing + +1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md + +2. **Run release script**: + ```bash + npm run release:patch # Bug fixes + npm run release:minor # New features + npm run release:major # Breaking changes + ``` + +The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. + +### Tool Usage +**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). + + +Current date and time: Sunday, December 28, 2025 at 07:08:12 PM GMT+1 +Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
+
+
+
Available Tools
+
+
read - Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.
bash - Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.
edit - Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.
write - Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.
+
+
+ + +
+
07:08:12 PM

Hello

+
07:08:12 PM

Hello! I'm Claude Code, ready to help with coding tasks in this pi-mono project.

+

Since you haven't given me a specific task yet, let me first read the main README to understand the project structure:

+
read ~/workspaces/pi-mono/README.md
07:08:16 PM

Great! I can see this is a monorepo for building AI agents and managing LLM deployments with several packages.

+

Which module(s) would you like to work on?

+
    +
  • @mariozechner/pi-ai - Unified multi-provider LLM API
  • +
  • @mariozechner/pi-agent-core - Agent runtime with tool calling
  • +
  • @mariozechner/pi-coding-agent - Interactive coding agent CLI (current directory)
  • +
  • @mariozechner/pi-mom - Slack bot integration
  • +
  • @mariozechner/pi-tui - Terminal UI library
  • +
  • @mariozechner/pi-web-ui - Web components for chat interfaces
  • +
  • @mariozechner/pi-pods - vLLM deployment CLI
  • +
+
+
+ + +
+ + \ No newline at end of file diff --git a/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html b/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html new file mode 100644 index 00000000..40978803 --- /dev/null +++ b/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html @@ -0,0 +1,495 @@ + + + + + + Session Export - 2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.jsonl + + + +
+
+

pi v0.30.2

+
+
Session:7225b2a7-7612-440d-84f1-fbcd8037d2f2
+
Date:12/28/2025, 8:05:42 PM
+
Models:unknown
+
+
+ +
+

Messages

+
+
User:1
+
Assistant:2
+
Tool Calls:1
+
+
+ +
+

Tokens & Cost

+
+
Input:9 tokens
+
Output:269 tokens
+
Cache Read:2,462 tokens
+
Cache Write:3,470 tokens
+
Total:6,210 tokens
+
Input Cost:$0.0000
+
Output Cost:$0.0040
+
Cache Read Cost:$0.0007
+
Cache Write Cost:$0.0130
+
Total Cost:$0.0178
+
Context Usage:3,648 / 200,000 tokens (1.8%) - anthropic/claude-sonnet-4-5
+
+
+ +
+
System Prompt
+
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. + +Available tools: +- read: Read file contents +- bash: Execute bash commands (ls, grep, find, etc.) +- edit: Make surgical edits to files (find exact text and replace) +- write: Create or overwrite files + +Guidelines: +- Use bash for file operations like ls, grep, find +- Use read to examine files before editing. You must use this tool instead of cat or sed. +- Use edit for precise changes (old text must match exactly) +- Use write only for new files or complete rewrites +- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did +- Be concise in your responses +- Show file paths clearly when working with files + +Documentation: +- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md +- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs +- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md) + +# Project Context + +The following project context files have been loaded: + +## /Users/badlogic/workspaces/pi-mono/AGENTS.md + +# Development Rules + +## First Message +If the user did not give you a concrete task in their first message, +read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel. +- packages/ai/README.md +- packages/tui/README.md +- packages/agent/README.md +- packages/coding-agent/README.md +- packages/mom/README.md +- packages/pods/README.md +- packages/web-ui/README.md + +## Code Quality +- No `any` types unless absolutely necessary +- Check node_modules for external API type definitions instead of guessing +- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. +- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead +- Always ask before removing functionality or code that appears to be intentional + +## Commands +- After code changes: `npm run check` (get full output, no tail) +- NEVER run: `npm run dev`, `npm run build`, `npm test` +- Only run specific tests if user instructs: `npm test -- test/specific.test.ts` +- NEVER commit unless user asks + +## GitHub Issues +When reading issues: +- Always read all comments on the issue + +When creating issues: +- Add `pkg:*` labels to indicate which package(s) the issue affects + - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui` +- If an issue spans multiple packages, add all relevant labels + +When closing issues via commit: +- Include `fixes #<number>` or `closes #<number>` in the commit message +- This automatically closes the issue when the commit is merged + +## Tools +- GitHub CLI for issues/PRs +- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui +- TUI interaction: use tmux + +## Style +- Keep answers short and concise +- No emojis in commits, issues, PR comments, or code +- No fluff or cheerful filler text +- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") + +## Changelog +Location: `packages/*/CHANGELOG.md` (each package has its own) + +### Format +Use these sections under `## [Unreleased]`: +- `### Breaking Changes` - API changes requiring migration +- `### Added` - New features +- `### Changed` - Changes to existing functionality +- `### Fixed` - Bug fixes +- `### Removed` - Removed features + +### Rules +- New entries ALWAYS go under `## [Unreleased]` section +- NEVER modify already-released version sections (e.g., `## [0.12.2]`) +- Each version section is immutable once released + +### Attribution +- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))` +- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))` + +## Releasing + +1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md + +2. **Run release script**: + ```bash + npm run release:patch # Bug fixes + npm run release:minor # New features + npm run release:major # Breaking changes + ``` + +The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. + +### Tool Usage +**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). + + +Current date and time: Sunday, December 28, 2025 at 08:05:42 PM GMT+1 +Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
+
+
+
Available Tools
+
+
read - Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.
bash - Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.
edit - Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.
write - Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.
+
+
+ + +
+
08:05:42 PM

Hello

+
08:05:42 PM

Hello! I see you haven't given me a specific task yet. Let me start by reading the main README to understand the project structure.

+
read ~/workspaces/pi-mono/README.md
08:05:45 PM

Great! I can see this is a monorepo with several packages for building AI agents and managing LLM deployments. The main packages are:

+
    +
  • pi-ai - Unified multi-provider LLM API
  • +
  • pi-agent-core - Agent runtime with tool calling and state management
  • +
  • pi-coding-agent - Interactive coding agent CLI (the package we're currently in)
  • +
  • pi-mom - Slack bot that delegates to the coding agent
  • +
  • pi-tui - Terminal UI library
  • +
  • pi-web-ui - Web components for AI chat interfaces
  • +
  • pi-pods - CLI for managing vLLM deployments on GPU pods
  • +
+

Which package(s) would you like to work on today?

+
+
+ + +
+ + \ No newline at end of file diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a0d0b107..11a9befd 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -30,7 +30,10 @@ import { exportSessionToHtml } from "./export-html.js"; import type { HookCommandContext, HookRunner, - SessionEventResult, + SessionBeforeBranchResult, + SessionBeforeCompactResult, + SessionBeforeNewResult, + SessionBeforeSwitchResult, TurnEndEvent, TurnStartEvent, } from "./hooks/index.js"; @@ -44,7 +47,7 @@ import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; export type AgentSessionEvent = | AgentEvent | { type: "auto_compaction_start"; reason: "threshold" | "overflow" } - | { type: "auto_compaction_end"; result: CompactionResult | null; aborted: boolean; willRetry: boolean } + | { type: "auto_compaction_end"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean } | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; @@ -64,7 +67,7 @@ export interface AgentSessionConfig { /** File-based slash commands for expansion */ fileCommands?: FileSlashCommand[]; /** Hook runner (created in main.ts with wrapped tools) */ - hookRunner?: HookRunner | null; + hookRunner?: HookRunner; /** Custom tools for session lifecycle events */ customTools?: LoadedCustomTool[]; skillsSettings?: Required; @@ -90,7 +93,7 @@ export interface ModelCycleResult { /** Session statistics for /session command */ export interface SessionStats { - sessionFile: string | null; + sessionFile: string | undefined; sessionId: string; userMessages: number; assistantMessages: number; @@ -138,21 +141,21 @@ export class AgentSession { private _queuedMessages: string[] = []; // Compaction state - private _compactionAbortController: AbortController | null = null; - private _autoCompactionAbortController: AbortController | null = null; + private _compactionAbortController: AbortController | undefined = undefined; + private _autoCompactionAbortController: AbortController | undefined = undefined; // Retry state - private _retryAbortController: AbortController | null = null; + private _retryAbortController: AbortController | undefined = undefined; private _retryAttempt = 0; - private _retryPromise: Promise | null = null; - private _retryResolve: (() => void) | null = null; + private _retryPromise: Promise | undefined = undefined; + private _retryResolve: (() => void) | undefined = undefined; // Bash execution state - private _bashAbortController: AbortController | null = null; + private _bashAbortController: AbortController | undefined = undefined; private _pendingBashMessages: BashExecutionMessage[] = []; // Hook system - private _hookRunner: HookRunner | null = null; + private _hookRunner: HookRunner | undefined = undefined; private _turnIndex = 0; // Custom tools for session lifecycle @@ -169,10 +172,14 @@ export class AgentSession { this.settingsManager = config.settingsManager; this._scopedModels = config.scopedModels ?? []; this._fileCommands = config.fileCommands ?? []; - this._hookRunner = config.hookRunner ?? null; + this._hookRunner = config.hookRunner; this._customTools = config.customTools ?? []; this._skillsSettings = config.skillsSettings; this._modelRegistry = config.modelRegistry; + + // Always subscribe to agent events for internal handling + // (session persistence, hooks, auto-compaction, retry logic) + this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); } /** Model registry for API key resolution and model discovery */ @@ -192,7 +199,7 @@ export class AgentSession { } // Track last assistant message for auto-compaction check - private _lastAssistantMessage: AssistantMessage | null = null; + private _lastAssistantMessage: AssistantMessage | undefined = undefined; /** Internal handler for agent events - shared by subscribe and reconnect */ private _handleAgentEvent = async (event: AgentEvent): Promise => { @@ -246,7 +253,7 @@ export class AgentSession { // Check auto-retry and auto-compaction after agent completes if (event.type === "agent_end" && this._lastAssistantMessage) { const msg = this._lastAssistantMessage; - this._lastAssistantMessage = null; + this._lastAssistantMessage = undefined; // Check for retryable errors first (overloaded, rate limit, server errors) if (this._isRetryableError(msg)) { @@ -272,8 +279,8 @@ export class AgentSession { private _resolveRetry(): void { if (this._retryResolve) { this._retryResolve(); - this._retryResolve = null; - this._retryPromise = null; + this._retryResolve = undefined; + this._retryPromise = undefined; } } @@ -287,7 +294,7 @@ export class AgentSession { } /** Find the last assistant message in agent state (including aborted ones) */ - private _findLastAssistantMessage(): AssistantMessage | null { + private _findLastAssistantMessage(): AssistantMessage | undefined { const messages = this.agent.state.messages; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; @@ -295,7 +302,7 @@ export class AgentSession { return msg as AssistantMessage; } } - return null; + return undefined; } /** Emit hook events based on agent events */ @@ -334,11 +341,6 @@ export class AgentSession { subscribe(listener: AgentSessionEventListener): () => void { this._eventListeners.push(listener); - // Set up agent subscription if not already done - if (!this._unsubscribeAgent) { - this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); - } - // Return unsubscribe function for this specific listener return () => { const index = this._eventListeners.indexOf(listener); @@ -387,8 +389,8 @@ export class AgentSession { return this.agent.state; } - /** Current model (may be null if not yet selected) */ - get model(): Model | null { + /** Current model (may be undefined if not yet selected) */ + get model(): Model | undefined { return this.agent.state.model; } @@ -404,7 +406,7 @@ export class AgentSession { /** Whether auto-compaction is currently running */ get isCompacting(): boolean { - return this._autoCompactionAbortController !== null || this._compactionAbortController !== null; + return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined; } /** All messages including custom types like BashExecutionMessage */ @@ -417,9 +419,9 @@ export class AgentSession { return this.agent.getQueueMode(); } - /** Current session file path, or null if sessions are disabled */ - get sessionFile(): string | null { - return this.sessionManager.getSessionFile() ?? null; + /** Current session file path, or undefined if sessions are disabled */ + get sessionFile(): string | undefined { + return this.sessionManager.getSessionFile(); } /** Current session ID */ @@ -663,12 +665,11 @@ export class AgentSession { async reset(): Promise { const previousSessionFile = this.sessionFile; - // Emit before_new event (can be cancelled) - if (this._hookRunner?.hasHandlers("session")) { + // Emit session_before_new event (can be cancelled) + if (this._hookRunner?.hasHandlers("session_before_new")) { const result = (await this._hookRunner.emit({ - type: "session", - reason: "before_new", - })) as SessionEventResult | undefined; + type: "session_before_new", + })) as SessionBeforeNewResult | undefined; if (result?.cancel) { return false; @@ -682,11 +683,10 @@ export class AgentSession { this._queuedMessages = []; this._reconnectToAgent(); - // Emit session event with reason "new" to hooks + // Emit session_new event to hooks if (this._hookRunner) { await this._hookRunner.emit({ - type: "session", - reason: "new", + type: "session_new", }); } @@ -722,17 +722,17 @@ export class AgentSession { * Cycle to next/previous model. * Uses scoped models (from --models flag) if available, otherwise all available models. * @param direction - "forward" (default) or "backward" - * @returns The new model info, or null if only one model available + * @returns The new model info, or undefined if only one model available */ - async cycleModel(direction: "forward" | "backward" = "forward"): Promise { + async cycleModel(direction: "forward" | "backward" = "forward"): Promise { if (this._scopedModels.length > 0) { return this._cycleScopedModel(direction); } return this._cycleAvailableModel(direction); } - private async _cycleScopedModel(direction: "forward" | "backward"): Promise { - if (this._scopedModels.length <= 1) return null; + private async _cycleScopedModel(direction: "forward" | "backward"): Promise { + if (this._scopedModels.length <= 1) return undefined; const currentModel = this.model; let currentIndex = this._scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel)); @@ -759,9 +759,9 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } - private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { + private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { const availableModels = await this._modelRegistry.getAvailable(); - if (availableModels.length <= 1) return null; + if (availableModels.length <= 1) return undefined; const currentModel = this.model; let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel)); @@ -816,10 +816,10 @@ export class AgentSession { /** * Cycle to next thinking level. - * @returns New level, or null if model doesn't support thinking + * @returns New level, or undefined if model doesn't support thinking */ - cycleThinkingLevel(): ThinkingLevel | null { - if (!this.supportsThinking()) return null; + cycleThinkingLevel(): ThinkingLevel | undefined { + if (!this.supportsThinking()) return undefined; const levels = this.getAvailableThinkingLevels(); const currentIndex = levels.indexOf(this.thinkingLevel); @@ -904,19 +904,18 @@ export class AgentSession { let hookCompaction: CompactionResult | undefined; let fromHook = false; - if (this._hookRunner?.hasHandlers("session")) { + if (this._hookRunner?.hasHandlers("session_before_compact")) { // Get previous compactions, newest first const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse(); const result = (await this._hookRunner.emit({ - type: "session", - reason: "before_compact", + type: "session_before_compact", preparation, previousCompactions, customInstructions, model: this.model, signal: this._compactionAbortController.signal, - })) as SessionEventResult | undefined; + })) as SessionBeforeCompactResult | undefined; if (result?.cancel) { throw new Error("Compaction cancelled"); @@ -971,8 +970,7 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ - type: "session", - reason: "compact", + type: "session_compact", compactionEntry: savedCompactionEntry, fromHook, }); @@ -985,7 +983,7 @@ export class AgentSession { details, }; } finally { - this._compactionAbortController = null; + this._compactionAbortController = undefined; this._reconnectToAgent(); } } @@ -1051,13 +1049,13 @@ export class AgentSession { try { if (!this.model) { - this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false }); + this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } const apiKey = await this._modelRegistry.getApiKey(this.model); if (!apiKey) { - this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false }); + this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } @@ -1065,29 +1063,28 @@ export class AgentSession { const preparation = prepareCompaction(entries, settings); if (!preparation) { - this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false }); + this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } let hookCompaction: CompactionResult | undefined; let fromHook = false; - if (this._hookRunner?.hasHandlers("session")) { + if (this._hookRunner?.hasHandlers("session_before_compact")) { // Get previous compactions, newest first const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse(); const hookResult = (await this._hookRunner.emit({ - type: "session", - reason: "before_compact", + type: "session_before_compact", preparation, previousCompactions, customInstructions: undefined, model: this.model, signal: this._autoCompactionAbortController.signal, - })) as SessionEventResult | undefined; + })) as SessionBeforeCompactResult | undefined; if (hookResult?.cancel) { - this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false }); + this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); return; } @@ -1124,7 +1121,7 @@ export class AgentSession { } if (this._autoCompactionAbortController.signal.aborted) { - this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false }); + this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); return; } @@ -1140,8 +1137,7 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ - type: "session", - reason: "compact", + type: "session_compact", compactionEntry: savedCompactionEntry, fromHook, }); @@ -1167,7 +1163,7 @@ export class AgentSession { }, 100); } } catch (error) { - this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false }); + this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); if (reason === "overflow") { throw new Error( @@ -1175,7 +1171,7 @@ export class AgentSession { ); } } finally { - this._autoCompactionAbortController = null; + this._autoCompactionAbortController = undefined; } } @@ -1267,7 +1263,7 @@ export class AgentSession { // Aborted during sleep - emit end event so UI can clean up const attempt = this._retryAttempt; this._retryAttempt = 0; - this._retryAbortController = null; + this._retryAbortController = undefined; this._emit({ type: "auto_retry_end", success: false, @@ -1277,7 +1273,7 @@ export class AgentSession { this._resolveRetry(); return false; } - this._retryAbortController = null; + this._retryAbortController = undefined; // Retry via continue() - use setTimeout to break out of event handler chain setTimeout(() => { @@ -1329,7 +1325,7 @@ export class AgentSession { /** Whether auto-retry is currently in progress */ get isRetrying(): boolean { - return this._retryPromise !== null; + return this._retryPromise !== undefined; } /** Whether auto-retry is enabled */ @@ -1389,7 +1385,7 @@ export class AgentSession { return result; } finally { - this._bashAbortController = null; + this._bashAbortController = undefined; } } @@ -1402,7 +1398,7 @@ export class AgentSession { /** Whether a bash command is currently running */ get isBashRunning(): boolean { - return this._bashAbortController !== null; + return this._bashAbortController !== undefined; } /** Whether there are pending bash messages waiting to be flushed */ @@ -1439,15 +1435,14 @@ export class AgentSession { * @returns true if switch completed, false if cancelled by hook */ async switchSession(sessionPath: string): Promise { - const previousSessionFile = this.sessionFile; + const previousSessionFile = this.sessionManager.getSessionFile(); - // Emit before_switch event (can be cancelled) - if (this._hookRunner?.hasHandlers("session")) { + // Emit session_before_switch event (can be cancelled) + if (this._hookRunner?.hasHandlers("session_before_switch")) { const result = (await this._hookRunner.emit({ - type: "session", - reason: "before_switch", + type: "session_before_switch", targetSessionFile: sessionPath, - })) as SessionEventResult | undefined; + })) as SessionBeforeSwitchResult | undefined; if (result?.cancel) { return false; @@ -1464,11 +1459,10 @@ export class AgentSession { // Reload messages const sessionContext = this.sessionManager.buildSessionContext(); - // Emit session event to hooks + // Emit session_switch event to hooks if (this._hookRunner) { await this._hookRunner.emit({ - type: "session", - reason: "switch", + type: "session_switch", previousSessionFile, }); } @@ -1520,13 +1514,12 @@ export class AgentSession { let skipConversationRestore = false; - // Emit before_branch event (can be cancelled) - if (this._hookRunner?.hasHandlers("session")) { + // Emit session_before_branch event (can be cancelled) + if (this._hookRunner?.hasHandlers("session_before_branch")) { const result = (await this._hookRunner.emit({ - type: "session", - reason: "before_branch", - targetTurnIndex: entryIndex, - })) as SessionEventResult | undefined; + type: "session_before_branch", + entryIndex: entryIndex, + })) as SessionBeforeBranchResult | undefined; if (result?.cancel) { return { selectedText, cancelled: true }; @@ -1534,27 +1527,20 @@ export class AgentSession { skipConversationRestore = result?.skipConversationRestore ?? false; } - // Create branched session ending before the selected message (returns null in --no-session mode) - // User will re-enter/edit the selected message if (!selectedEntry.parentId) { - throw new Error("Cannot branch from first message"); - } - const newSessionFile = this.sessionManager.createBranchedSession(selectedEntry.parentId); - - // Update session file if we have one (file-based mode) - if (newSessionFile !== null) { - this.sessionManager.setSessionFile(newSessionFile); + this.sessionManager.newSession(); + } else { + this.sessionManager.createBranchedSession(selectedEntry.parentId); } // Reload messages from entries (works for both file and in-memory mode) const sessionContext = this.sessionManager.buildSessionContext(); - // Emit branch event to hooks (after branch completes) + // Emit session_branch event to hooks (after branch completes) if (this._hookRunner) { await this._hookRunner.emit({ - type: "session", - reason: "branch", - targetTurnIndex: entryIndex, + type: "session_branch", + previousSessionFile, }); } @@ -1664,9 +1650,9 @@ export class AgentSession { /** * Get text content of last assistant message. * Useful for /copy command. - * @returns Text content, or null if no assistant message exists + * @returns Text content, or undefined if no assistant message exists */ - getLastAssistantText(): string | null { + getLastAssistantText(): string | undefined { const lastAssistant = this.messages .slice() .reverse() @@ -1678,7 +1664,7 @@ export class AgentSession { return true; }); - if (!lastAssistant) return null; + if (!lastAssistant) return undefined; let text = ""; for (const content of (lastAssistant as AssistantMessage).content) { @@ -1687,7 +1673,7 @@ export class AgentSession { } } - return text.trim() || null; + return text.trim() || undefined; } // ========================================================================= @@ -1704,7 +1690,7 @@ export class AgentSession { /** * Get the hook runner (for setting UI context and error handlers). */ - get hookRunner(): HookRunner | null { + get hookRunner(): HookRunner | undefined { return this._hookRunner; } @@ -1721,7 +1707,7 @@ export class AgentSession { */ private async _emitToolSessionEvent( reason: ToolSessionEvent["reason"], - previousSessionFile: string | null, + previousSessionFile: string | undefined, ): Promise { const event: ToolSessionEvent = { entries: this.sessionManager.getEntries(), diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index afc5e076..0b0c23a5 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -94,8 +94,8 @@ export class AuthStorage { /** * Get credential for a provider. */ - get(provider: string): AuthCredential | null { - return this.data[provider] ?? null; + get(provider: string): AuthCredential | undefined { + return this.data[provider] ?? undefined; } /** @@ -191,7 +191,7 @@ export class AuthStorage { * 4. Environment variable * 5. Fallback resolver (models.json custom providers) */ - async getApiKey(provider: string): Promise { + async getApiKey(provider: string): Promise { // Runtime override takes highest priority const runtimeKey = this.runtimeOverrides.get(provider); if (runtimeKey) { @@ -230,6 +230,6 @@ export class AuthStorage { if (envKey) return envKey; // Fall back to custom resolver (e.g., models.json custom providers) - return this.fallbackResolver?.(provider) ?? null; + return this.fallbackResolver?.(provider) ?? undefined; } } diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index d83f63b5..0a83d2e5 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -29,8 +29,8 @@ export interface BashExecutorOptions { export interface BashResult { /** Combined stdout + stderr output (sanitized, possibly truncated) */ output: string; - /** Process exit code (null if killed/cancelled) */ - exitCode: number | null; + /** Process exit code (undefined if killed/cancelled) */ + exitCode: number | undefined; /** Whether the command was cancelled via signal */ cancelled: boolean; /** Whether the output was truncated */ @@ -88,7 +88,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro child.kill(); resolve({ output: "", - exitCode: null, + exitCode: undefined, cancelled: true, truncated: false, }); @@ -154,7 +154,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro resolve({ output: truncationResult.truncated ? truncationResult.content : fullOutput, - exitCode: code, + exitCode: cancelled ? undefined : code, cancelled, truncated: truncationResult.truncated, fullOutputPath: tempFilePath, diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index ac750a9e..35d933a3 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -13,9 +13,9 @@ import type { CompactionEntry, SessionEntry } from "./session-manager.js"; /** * Extract AgentMessage from an entry if it produces one. - * Returns null for entries that don't contribute to LLM context. + * Returns undefined for entries that don't contribute to LLM context. */ -function getMessageFromEntry(entry: SessionEntry): AgentMessage | null { +function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { if (entry.type === "message") { return entry.message; } @@ -25,7 +25,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | null { if (entry.type === "branch_summary") { return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); } - return null; + return undefined; } /** Result from compact() - SessionManager adds uuid/parentUuid when saving */ @@ -69,20 +69,20 @@ export function calculateContextTokens(usage: Usage): number { * Get usage from an assistant message if available. * Skips aborted and error messages as they don't have valid usage data. */ -function getAssistantUsage(msg: AgentMessage): Usage | null { +function getAssistantUsage(msg: AgentMessage): Usage | undefined { if (msg.role === "assistant" && "usage" in msg) { const assistantMsg = msg as AssistantMessage; if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) { return assistantMsg.usage; } } - return null; + return undefined; } /** * Find the last non-aborted assistant message usage from session entries. */ -export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null { +export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "message") { @@ -90,7 +90,7 @@ export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null { if (usage) return usage; } } - return null; + return undefined; } /** @@ -398,9 +398,12 @@ export interface CompactionPreparation { boundaryStart: number; } -export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null { +export function prepareCompaction( + entries: SessionEntry[], + settings: CompactionSettings, +): CompactionPreparation | undefined { if (entries.length > 0 && entries[entries.length - 1].type === "compaction") { - return null; + return undefined; } let prevCompactionIndex = -1; @@ -421,7 +424,7 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS // Get UUID of first kept entry const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex]; if (!firstKeptEntry?.id) { - return null; // Session needs migration + return undefined; // Session needs migration } const firstKeptEntryId = firstKeptEntry.id; diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index f22cb6c9..f4480611 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -86,9 +86,9 @@ function resolveToolPath(toolPath: string, cwd: string): string { */ function createNoOpUIContext(): HookUIContext { return { - select: async () => null, + select: async () => undefined, confirm: async () => false, - input: async () => null, + input: async () => undefined, notify: () => {}, custom: () => ({ close: () => {}, requestRender: () => {} }), }; diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index fa4446a7..a9a69b99 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -38,10 +38,10 @@ export interface ToolAPI { export interface SessionEvent { /** All session entries (including pre-compaction history) */ entries: SessionEntry[]; - /** Current session file path, or null in --no-session mode */ - sessionFile: string | null; - /** Previous session file path, or null for "start" and "new" */ - previousSessionFile: string | null; + /** Current session file path, or undefined in --no-session mode */ + sessionFile: string | undefined; + /** Previous session file path, or undefined for "start" and "new" */ + previousSessionFile: string | undefined; /** Reason for the session event */ reason: "start" | "switch" | "branch" | "new"; } diff --git a/packages/coding-agent/src/core/export-html.ts b/packages/coding-agent/src/core/export-html.ts index f553e140..c6f3139f 100644 --- a/packages/coding-agent/src/core/export-html.ts +++ b/packages/coding-agent/src/core/export-html.ts @@ -121,7 +121,7 @@ function resolveColorValue( } /** Load theme JSON from built-in or custom themes directory. */ -function loadThemeJson(name: string): ThemeJson | null { +function loadThemeJson(name: string): ThemeJson | undefined { // Try built-in themes first const themesDir = getThemesDir(); const builtinPath = path.join(themesDir, `${name}.json`); @@ -129,7 +129,7 @@ function loadThemeJson(name: string): ThemeJson | null { try { return JSON.parse(readFileSync(builtinPath, "utf-8")) as ThemeJson; } catch { - return null; + return undefined; } } @@ -140,11 +140,11 @@ function loadThemeJson(name: string): ThemeJson | null { try { return JSON.parse(readFileSync(customPath, "utf-8")) as ThemeJson; } catch { - return null; + return undefined; } } - return null; + return undefined; } /** Build complete theme colors object, resolving theme JSON values against defaults. */ @@ -831,7 +831,9 @@ function formatMessage( switch (message.role) { case "bashExecution": { - const isError = message.cancelled || (message.exitCode !== 0 && message.exitCode !== null); + const isError = + message.cancelled || + (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined); html += `
`; html += timestampHtml; @@ -844,7 +846,7 @@ function formatMessage( if (message.cancelled) { html += `
(cancelled)
`; - } else if (message.exitCode !== 0 && message.exitCode !== null) { + } else if (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined) { html += `
(exit ${message.exitCode})
`; } @@ -1020,7 +1022,7 @@ function generateHtml(data: ParsedSessionData, filename: string, colors: ThemeCo const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel; const contextWindow = data.contextWindow || 0; - const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null; + const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : undefined; let messagesHtml = ""; for (const event of data.sessionEvents) { diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 395d15e0..fc49f2ae 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -9,49 +9,4 @@ export { } from "./loader.js"; export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; -export type { - AgentEndEvent, - AgentStartEvent, - BeforeAgentStartEvent, - BeforeAgentStartEventResult, - BashToolResultEvent, - ContextEvent, - ContextEventResult, - CustomToolResultEvent, - EditToolResultEvent, - ExecOptions, - ExecResult, - FindToolResultEvent, - GrepToolResultEvent, - HookAPI, - HookCommandContext, - HookError, - HookEvent, - HookEventContext, - HookFactory, - HookMessageRenderer, - HookMessageRenderOptions, - HookUIContext, - LsToolResultEvent, - ReadonlySessionManager, - ReadToolResultEvent, - RegisteredCommand, - SessionEvent, - SessionEventResult, - ToolCallEvent, - ToolCallEventResult, - ToolResultEvent, - ToolResultEventResult, - TurnEndEvent, - TurnStartEvent, - WriteToolResultEvent, -} from "./types.js"; -export { - isBashToolResult, - isEditToolResult, - isFindToolResult, - isGrepToolResult, - isLsToolResult, - isReadToolResult, - isWriteToolResult, -} from "./types.js"; +export type * from "./types.js"; diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 76c17f51..eee9090a 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -17,8 +17,7 @@ import type { HookMessageRenderer, HookUIContext, RegisteredCommand, - SessionEvent, - SessionEventResult, + SessionBeforeCompactResult, ToolCallEvent, ToolCallEventResult, ToolResultEventResult, @@ -53,9 +52,9 @@ function createTimeout(ms: number): { promise: Promise; clear: () => void /** No-op UI context used when no UI is available */ const noOpUIContext: HookUIContext = { - select: async () => null, + select: async () => undefined, confirm: async () => false, - input: async () => null, + input: async () => undefined, notify: () => {}, custom: () => ({ close: () => {}, requestRender: () => {} }), }; @@ -228,12 +227,26 @@ export class HookRunner { } /** - * Emit an event to all hooks. - * Returns the result from session/tool_result events (if any handler returns one). + * Check if event type is a session "before_*" event that can be cancelled. */ - async emit(event: HookEvent): Promise { + private isSessionBeforeEvent( + type: string, + ): type is "session_before_switch" | "session_before_new" | "session_before_branch" | "session_before_compact" { + return ( + type === "session_before_switch" || + type === "session_before_new" || + type === "session_before_branch" || + type === "session_before_compact" + ); + } + + /** + * Emit an event to all hooks. + * Returns the result from session before_* / tool_result events (if any handler returns one). + */ + async emit(event: HookEvent): Promise { const ctx = this.createContext(); - let result: SessionEventResult | ToolResultEventResult | undefined; + let result: SessionBeforeCompactResult | ToolResultEventResult | undefined; for (const hook of this.hooks) { const handlers = hook.handlers.get(event.type); @@ -241,11 +254,10 @@ export class HookRunner { for (const handler of handlers) { try { - // No timeout for before_compact events (like tool_call, they may take a while) - const isBeforeCompact = event.type === "session" && (event as SessionEvent).reason === "before_compact"; + // No timeout for session_before_compact events (like tool_call, they may take a while) let handlerResult: unknown; - if (isBeforeCompact) { + if (event.type === "session_before_compact") { handlerResult = await handler(event, ctx); } else { const timeout = createTimeout(this.timeout); @@ -253,9 +265,9 @@ export class HookRunner { timeout.clear(); } - // For session events, capture the result (for before_* cancellation) - if (event.type === "session" && handlerResult) { - result = handlerResult as SessionEventResult; + // For session before_* events, capture the result (for cancellation) + if (this.isSessionBeforeEvent(event.type) && handlerResult) { + result = handlerResult as SessionBeforeCompactResult; // If cancelled, stop processing further hooks if (result.cancel) { return result; diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index e97e20e0..950464d6 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -13,13 +13,7 @@ import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { - CompactionEntry, - SessionEntry, - SessionHeader, - SessionManager, - SessionTreeNode, -} from "../session-manager.js"; +import type { CompactionEntry, SessionManager } from "../session-manager.js"; /** * Read-only view of SessionManager for hooks. @@ -64,7 +58,7 @@ export interface HookUIContext { * @param options - Array of string options * @returns Selected option string, or null if cancelled */ - select(title: string, options: string[]): Promise; + select(title: string, options: string[]): Promise; /** * Show a confirmation dialog. @@ -74,9 +68,9 @@ export interface HookUIContext { /** * Show a text input dialog. - * @returns User input, or null if cancelled + * @returns User input, or undefined if cancelled */ - input(title: string, placeholder?: string): Promise; + input(title: string, placeholder?: string): Promise; /** * Show a notification to the user. @@ -110,69 +104,91 @@ export interface HookEventContext { } // ============================================================================ -// Events +// Session Events // ============================================================================ -/** - * Base fields shared by all session events. - */ -interface SessionEventBase { - type: "session"; +/** Fired on initial session load */ +export interface SessionStartEvent { + type: "session_start"; } -/** - * Event data for session events. - * Discriminated union based on reason. - * - * Lifecycle: - * - start: Initial session load - * - before_switch / switch: Session switch (e.g., /resume command) - * - before_new / new: New session (e.g., /new command) - * - before_branch / branch: Session branch (e.g., /branch command) - * - before_compact / compact: Before/after context compaction - * - shutdown: Process exit (SIGINT/SIGTERM) - * - * "before_*" events fire before the action and can be cancelled via SessionEventResult. - * Other events fire after the action completes. - */ +/** Fired before switching to another session (can be cancelled) */ +export interface SessionBeforeSwitchEvent { + type: "session_before_switch"; + /** Session file we're switching to */ + targetSessionFile: string; +} + +/** Fired after switching to another session */ +export interface SessionSwitchEvent { + type: "session_switch"; + /** Session file we came from */ + previousSessionFile: string | undefined; +} + +/** Fired before creating a new session (can be cancelled) */ +export interface SessionBeforeNewEvent { + type: "session_before_new"; +} + +/** Fired after creating a new session */ +export interface SessionNewEvent { + type: "session_new"; +} + +/** Fired before branching a session (can be cancelled) */ +export interface SessionBeforeBranchEvent { + type: "session_before_branch"; + /** Index of the entry in the session (SessionManager.getEntries()) to branch from */ + entryIndex: number; +} + +/** Fired after branching a session */ +export interface SessionBranchEvent { + type: "session_branch"; + previousSessionFile: string | undefined; +} + +/** Fired before context compaction (can be cancelled or customized) */ +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + /** Compaction preparation with cut point, messages to summarize/keep, etc. */ + preparation: CompactionPreparation; + /** Previous compaction entries, newest first. Use for iterative summarization. */ + previousCompactions: CompactionEntry[]; + /** Optional user-provided instructions for the summary */ + customInstructions?: string; + /** Current model */ + model: Model; + /** Abort signal - hooks should pass this to LLM calls and check it periodically */ + signal: AbortSignal; +} + +/** Fired after context compaction */ +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + /** Whether the compaction entry was provided by a hook */ + fromHook: boolean; +} + +/** Fired on process exit (SIGINT/SIGTERM) */ +export interface SessionShutdownEvent { + type: "session_shutdown"; +} + +/** Union of all session event types */ export type SessionEvent = - | (SessionEventBase & { - reason: "start" | "new" | "before_new" | "shutdown"; - }) - | (SessionEventBase & { - reason: "before_switch"; - /** Session file we're switching to */ - targetSessionFile: string; - }) - | (SessionEventBase & { - reason: "switch"; - /** Session file we came from */ - previousSessionFile: string | null; - }) - | (SessionEventBase & { - reason: "branch" | "before_branch"; - /** Index of the turn to branch from */ - targetTurnIndex: number; - }) - | (SessionEventBase & { - reason: "before_compact"; - /** Compaction preparation with cut point, messages to summarize/keep, etc. */ - preparation: CompactionPreparation; - /** Previous compaction entries, newest first. Use for iterative summarization. */ - previousCompactions: CompactionEntry[]; - /** Optional user-provided instructions for the summary */ - customInstructions?: string; - /** Current model */ - model: Model; - /** Abort signal - hooks should pass this to LLM calls and check it periodically */ - signal: AbortSignal; - }) - | (SessionEventBase & { - reason: "compact"; - compactionEntry: CompactionEntry; - /** Whether the compaction entry was provided by a hook */ - fromHook: boolean; - }); + | SessionStartEvent + | SessionBeforeSwitchEvent + | SessionSwitchEvent + | SessionBeforeNewEvent + | SessionNewEvent + | SessionBeforeBranchEvent + | SessionBranchEvent + | SessionBeforeCompactEvent + | SessionCompactEvent + | SessionShutdownEvent; /** * Event data for context event. @@ -408,16 +424,45 @@ export interface BeforeAgentStartEventResult { message?: Pick; } -/** - * Return type for session event handlers. - * Allows hooks to cancel "before_*" actions. - */ -export interface SessionEventResult { - /** If true, cancel the pending action (switch, clear, or branch) */ +/** Return type for session_before_switch handlers */ +export interface SessionBeforeSwitchResult { + /** If true, cancel the switch */ cancel?: boolean; - /** If true (for before_branch only), skip restoring conversation to branch point while still creating the branched session file */ +} + +/** Return type for session_before_new handlers */ +export interface SessionBeforeNewResult { + /** If true, cancel the new session */ + cancel?: boolean; +} + +/** Return type for session_before_branch handlers */ +export interface SessionBeforeBranchResult { + /** + * If true, abort the branch entirely. No new session file is created, + * conversation stays unchanged. + */ + cancel?: boolean; + /** + * If true, the branch proceeds (new session file created, session state updated) + * but the in-memory conversation is NOT rewound to the branch point. + * + * Use case: git-checkpoint hook that restores code state separately. + * The hook handles state restoration itself, so it doesn't want the + * agent's conversation to be rewound (which would lose recent context). + * + * - `cancel: true` → nothing happens, user stays in current session + * - `skipConversationRestore: true` → branch happens, but messages stay as-is + * - neither → branch happens AND messages rewind to branch point (default) + */ skipConversationRestore?: boolean; - /** Custom compaction result (for before_compact event) - SessionManager adds id/parentId */ +} + +/** Return type for session_before_compact handlers */ +export interface SessionBeforeCompactResult { + /** If true, cancel the compaction */ + cancel?: boolean; + /** Custom compaction result - SessionManager adds id/parentId */ compaction?: CompactionResult; } @@ -427,8 +472,10 @@ export interface SessionEventResult { /** * Handler function type for each event. + * Handlers can return R, undefined, or void (bare return statements). */ -export type HookHandler = (event: E, ctx: HookEventContext) => Promise; +// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers +export type HookHandler = (event: E, ctx: HookEventContext) => Promise | R | void; export interface HookMessageRenderOptions { /** Whether the view is expanded */ @@ -443,7 +490,7 @@ export type HookMessageRenderer = ( message: HookMessage, options: HookMessageRenderOptions, theme: Theme, -) => Component | null; +) => Component | undefined; /** * Context passed to hook command handlers. @@ -478,21 +525,30 @@ export interface RegisteredCommand { * Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages. */ export interface HookAPI { - // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything - on(event: "session", handler: HookHandler): void; - // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything - on(event: "context", handler: HookHandler): void; + // Session events + on(event: "session_start", handler: HookHandler): void; + on(event: "session_before_switch", handler: HookHandler): void; + on(event: "session_switch", handler: HookHandler): void; + on(event: "session_before_new", handler: HookHandler): void; + on(event: "session_new", handler: HookHandler): void; + on(event: "session_before_branch", handler: HookHandler): void; + on(event: "session_branch", handler: HookHandler): void; on( - event: "before_agent_start", - // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything - handler: HookHandler, + event: "session_before_compact", + handler: HookHandler, ): void; + on(event: "session_compact", handler: HookHandler): void; + on(event: "session_shutdown", handler: HookHandler): void; + + // Context and agent events + on(event: "context", handler: HookHandler): void; + on(event: "before_agent_start", handler: HookHandler): void; on(event: "agent_start", handler: HookHandler): void; on(event: "agent_end", handler: HookHandler): void; on(event: "turn_start", handler: HookHandler): void; on(event: "turn_end", handler: HookHandler): void; - on(event: "tool_call", handler: HookHandler): void; - on(event: "tool_result", handler: HookHandler): void; + on(event: "tool_call", handler: HookHandler): void; + on(event: "tool_result", handler: HookHandler): void; /** * Send a custom message to the session. Creates a CustomMessageEntry that @@ -545,7 +601,7 @@ export interface HookAPI { /** * Register a custom renderer for CustomMessageEntry with a specific customType. * The renderer is called when rendering the entry in the TUI. - * Return null to use the default renderer. + * Return nothing to use the default renderer. */ registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void; diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index f9d33bbf..2726903d 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -30,7 +30,7 @@ export interface BashExecutionMessage { role: "bashExecution"; command: string; output: string; - exitCode: number | null; + exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string; @@ -86,7 +86,7 @@ export function bashExecutionToText(msg: BashExecutionMessage): string { } if (msg.cancelled) { text += "\n\n(command cancelled)"; - } else if (msg.exitCode !== null && msg.exitCode !== 0) { + } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { text += `\n\nCommand exited with code ${msg.exitCode}`; } if (msg.truncated && msg.fullOutputPath) { @@ -145,7 +145,7 @@ export function createHookMessage( */ export function convertToLlm(messages: AgentMessage[]): Message[] { return messages - .map((m): Message | null => { + .map((m): Message | undefined => { switch (m.role) { case "bashExecution": return { @@ -182,8 +182,8 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { default: // biome-ignore lint/correctness/noSwitchDeclarations: fine const _exhaustiveCheck: never = m; - return null; + return undefined; } }) - .filter((m) => m !== null); + .filter((m) => m !== undefined); } diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 91aa0f8c..94a839f8 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -90,11 +90,11 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined { export class ModelRegistry { private models: Model[] = []; private customProviderApiKeys: Map = new Map(); - private loadError: string | null = null; + private loadError: string | undefined = undefined; constructor( readonly authStorage: AuthStorage, - private modelsJsonPath: string | null = null, + private modelsJsonPath: string | undefined = undefined, ) { // Set up fallback resolver for custom provider API keys this.authStorage.setFallbackResolver((provider) => { @@ -114,14 +114,14 @@ export class ModelRegistry { */ refresh(): void { this.customProviderApiKeys.clear(); - this.loadError = null; + this.loadError = undefined; this.loadModels(); } /** - * Get any error from loading models.json (null if no error). + * Get any error from loading models.json (undefined if no error). */ - getError(): string | null { + getError(): string | undefined { return this.loadError; } @@ -160,9 +160,9 @@ export class ModelRegistry { } } - private loadCustomModels(modelsJsonPath: string): { models: Model[]; error: string | null } { + private loadCustomModels(modelsJsonPath: string): { models: Model[]; error: string | undefined } { if (!existsSync(modelsJsonPath)) { - return { models: [], error: null }; + return { models: [], error: undefined }; } try { @@ -186,7 +186,7 @@ export class ModelRegistry { this.validateConfig(config); // Parse models - return { models: this.parseModels(config), error: null }; + return { models: this.parseModels(config), error: undefined }; } catch (error) { if (error instanceof SyntaxError) { return { @@ -294,14 +294,14 @@ export class ModelRegistry { /** * Find a model by provider and ID. */ - find(provider: string, modelId: string): Model | null { - return this.models.find((m) => m.provider === provider && m.id === modelId) ?? null; + find(provider: string, modelId: string): Model | undefined { + return this.models.find((m) => m.provider === provider && m.id === modelId) ?? undefined; } /** * Get API key for a model. */ - async getApiKey(model: Model): Promise { + async getApiKey(model: Model): Promise { return this.authStorage.getApiKey(model.provider); } diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 1734c015..981f11f2 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -44,9 +44,9 @@ function isAlias(id: string): boolean { /** * Try to match a pattern to a model from the available models list. - * Returns the matched model or null if no match found. + * Returns the matched model or undefined if no match found. */ -function tryMatchModel(modelPattern: string, availableModels: Model[]): Model | null { +function tryMatchModel(modelPattern: string, availableModels: Model[]): Model | undefined { // Check for provider/modelId format (provider is everything before the first /) const slashIndex = modelPattern.indexOf("/"); if (slashIndex !== -1) { @@ -75,7 +75,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model[]): Mod ); if (matches.length === 0) { - return null; + return undefined; } // Separate into aliases and dated versions @@ -94,9 +94,9 @@ function tryMatchModel(modelPattern: string, availableModels: Model[]): Mod } export interface ParsedModelResult { - model: Model | null; + model: Model | undefined; thinkingLevel: ThinkingLevel; - warning: string | null; + warning: string | undefined; } /** @@ -116,14 +116,14 @@ export function parseModelPattern(pattern: string, availableModels: Model[] // Try exact match first const exactMatch = tryMatchModel(pattern, availableModels); if (exactMatch) { - return { model: exactMatch, thinkingLevel: "off", warning: null }; + return { model: exactMatch, thinkingLevel: "off", warning: undefined }; } // No match - try splitting on last colon if present const lastColonIndex = pattern.lastIndexOf(":"); if (lastColonIndex === -1) { // No colons, pattern simply doesn't match any model - return { model: null, thinkingLevel: "off", warning: null }; + return { model: undefined, thinkingLevel: "off", warning: undefined }; } const prefix = pattern.substring(0, lastColonIndex); @@ -193,9 +193,9 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model } export interface InitialModelResult { - model: Model | null; + model: Model | undefined; thinkingLevel: ThinkingLevel; - fallbackMessage: string | null; + fallbackMessage: string | undefined; } /** @@ -227,7 +227,7 @@ export async function findInitialModel(options: { modelRegistry, } = options; - let model: Model | null = null; + let model: Model | undefined; let thinkingLevel: ThinkingLevel = "off"; // 1. CLI args take priority @@ -237,7 +237,7 @@ export async function findInitialModel(options: { console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`)); process.exit(1); } - return { model: found, thinkingLevel: "off", fallbackMessage: null }; + return { model: found, thinkingLevel: "off", fallbackMessage: undefined }; } // 2. Use first model from scoped models (skip if continuing/resuming) @@ -245,7 +245,7 @@ export async function findInitialModel(options: { return { model: scopedModels[0].model, thinkingLevel: scopedModels[0].thinkingLevel, - fallbackMessage: null, + fallbackMessage: undefined, }; } @@ -257,7 +257,7 @@ export async function findInitialModel(options: { if (defaultThinkingLevel) { thinkingLevel = defaultThinkingLevel; } - return { model, thinkingLevel, fallbackMessage: null }; + return { model, thinkingLevel, fallbackMessage: undefined }; } } @@ -270,16 +270,16 @@ export async function findInitialModel(options: { const defaultId = defaultModelPerProvider[provider]; const match = availableModels.find((m) => m.provider === provider && m.id === defaultId); if (match) { - return { model: match, thinkingLevel: "off", fallbackMessage: null }; + return { model: match, thinkingLevel: "off", fallbackMessage: undefined }; } } // If no default found, use first available - return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: null }; + return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: undefined }; } // 5. No model found - return { model: null, thinkingLevel: "off", fallbackMessage: null }; + return { model: undefined, thinkingLevel: "off", fallbackMessage: undefined }; } /** @@ -288,10 +288,10 @@ export async function findInitialModel(options: { export async function restoreModelFromSession( savedProvider: string, savedModelId: string, - currentModel: Model | null, + currentModel: Model | undefined, shouldPrintMessages: boolean, modelRegistry: ModelRegistry, -): Promise<{ model: Model | null; fallbackMessage: string | null }> { +): Promise<{ model: Model | undefined; fallbackMessage: string | undefined }> { const restoredModel = modelRegistry.find(savedProvider, savedModelId); // Check if restored model exists and has a valid API key @@ -301,7 +301,7 @@ export async function restoreModelFromSession( if (shouldPrintMessages) { console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`)); } - return { model: restoredModel, fallbackMessage: null }; + return { model: restoredModel, fallbackMessage: undefined }; } // Model not found or no API key - fall back @@ -327,7 +327,7 @@ export async function restoreModelFromSession( if (availableModels.length > 0) { // Try to find a default model from known providers - let fallbackModel: Model | null = null; + let fallbackModel: Model | undefined; for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { const defaultId = defaultModelPerProvider[provider]; const match = availableModels.find((m) => m.provider === provider && m.id === defaultId); @@ -353,5 +353,5 @@ export async function restoreModelFromSession( } // No models available - return { model: null, fallbackMessage: null }; + return { model: undefined, fallbackMessage: undefined }; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 80de1ca6..089691d3 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -530,7 +530,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} customToolsResult = result; } - let hookRunner: HookRunner | null = null; + let hookRunner: HookRunner | undefined; if (options.hooks !== undefined) { if (options.hooks.length > 0) { const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 7035c1e0..9a061eb6 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -450,7 +450,7 @@ export class SessionManager { private labelsById: Map = new Map(); private leafId: string = ""; - private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) { + private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { this.cwd = cwd; this.sessionDir = sessionDir; this.persist = persist; @@ -484,7 +484,7 @@ export class SessionManager { } } - newSession(): void { + newSession(): string | undefined { this.sessionId = randomUUID(); const timestamp = new Date().toISOString(); const header: SessionHeader = { @@ -503,6 +503,7 @@ export class SessionManager { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); } + return this.sessionFile; } private _buildIndex(): void { @@ -841,9 +842,9 @@ export class SessionManager { /** * Create a new session file containing only the path from root to the specified leaf. * Useful for extracting a single conversation path from a branched session. - * Returns the new session file path, or null if not persisting. + * Returns the new session file path, or undefined if not persisting. */ - createBranchedSession(leafId: string): string | null { + createBranchedSession(leafId: string): string | undefined { const path = this.getPath(leafId); if (path.length === 0) { throw new Error(`Entry ${leafId} not found`); @@ -883,6 +884,7 @@ export class SessionManager { // Write fresh label entries at the end const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; let parentId = lastEntryId; + const labelEntries: LabelEntry[] = []; for (const { targetId, label } of labelsToWrite) { const labelEntry: LabelEntry = { type: "label", @@ -894,8 +896,12 @@ export class SessionManager { }; appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`); pathEntryIds.add(labelEntry.id); + labelEntries.push(labelEntry); parentId = labelEntry.id; } + this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; + this.sessionId = newSessionId; + this._buildIndex(); return newSessionFile; } @@ -917,7 +923,7 @@ export class SessionManager { this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; this._buildIndex(); - return null; + return undefined; } /** @@ -927,7 +933,7 @@ export class SessionManager { */ static create(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ?? getDefaultSessionDir(cwd); - return new SessionManager(cwd, dir, null, true); + return new SessionManager(cwd, dir, undefined, true); } /** @@ -956,12 +962,12 @@ export class SessionManager { if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); } - return new SessionManager(cwd, dir, null, true); + return new SessionManager(cwd, dir, undefined, true); } /** Create an in-memory session (no file persistence) */ static inMemory(cwd: string = process.cwd()): SessionManager { - return new SessionManager(cwd, "", null, false); + return new SessionManager(cwd, "", undefined, false); } /** diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index b363b1ce..1e563429 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -38,33 +38,7 @@ export type { ToolUIContext, } from "./core/custom-tools/index.js"; export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js"; -export type { - AgentEndEvent, - AgentStartEvent, - BashToolResultEvent, - BeforeAgentStartEvent, - BeforeAgentStartEventResult, - CustomToolResultEvent, - EditToolResultEvent, - FindToolResultEvent, - GrepToolResultEvent, - HookAPI, - HookEvent, - HookEventContext, - HookFactory, - HookUIContext, - LsToolResultEvent, - ReadToolResultEvent, - SessionEvent, - SessionEventResult, - ToolCallEvent, - ToolCallEventResult, - ToolResultEvent, - ToolResultEventResult, - TurnEndEvent, - TurnStartEvent, - WriteToolResultEvent, -} from "./core/hooks/index.js"; +export type * from "./core/hooks/index.js"; // Hook system types and type guards export { isBashToolResult, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 30648990..9b67d748 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -33,10 +33,10 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js" import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js"; import { ensureTool } from "./utils/tools-manager.js"; -async function checkForNewVersion(currentVersion: string): Promise { +async function checkForNewVersion(currentVersion: string): Promise { try { const response = await fetch("https://registry.npmjs.org/@mariozechner/pi -coding-agent/latest"); - if (!response.ok) return null; + if (!response.ok) return undefined; const data = (await response.json()) as { version?: string }; const latestVersion = data.version; @@ -45,26 +45,26 @@ async function checkForNewVersion(currentVersion: string): Promise, + versionCheckPromise: Promise, initialMessages: string[], customTools: LoadedCustomTool[], setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void, initialMessage?: string, initialImages?: ImageContent[], - fdPath: string | null = null, + fdPath: string | undefined = undefined, ): Promise { const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath); @@ -143,9 +143,9 @@ async function prepareInitialMessage(parsed: Args): Promise<{ }; } -function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null { +function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined { if (parsed.continue || parsed.resume) { - return null; + return undefined; } const lastVersion = settingsManager.getLastChangelogVersion(); @@ -165,10 +165,10 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): } } - return null; + return undefined; } -function createSessionManager(parsed: Args, cwd: string): SessionManager | null { +function createSessionManager(parsed: Args, cwd: string): SessionManager | undefined { if (parsed.noSession) { return SessionManager.inMemory(); } @@ -183,8 +183,8 @@ function createSessionManager(parsed: Args, cwd: string): SessionManager | null if (parsed.sessionDir) { return SessionManager.create(cwd, parsed.sessionDir); } - // Default case (new session) returns null, SDK will create one - return null; + // Default case (new session) returns undefined, SDK will create one + return undefined; } /** Discover SYSTEM.md file if no CLI system prompt was provided */ @@ -207,7 +207,7 @@ function discoverSystemPromptFile(): string | undefined { function buildSessionOptions( parsed: Args, scopedModels: ScopedModel[], - sessionManager: SessionManager | null, + sessionManager: SessionManager | undefined, modelRegistry: ModelRegistry, ): CreateAgentSessionOptions { const options: CreateAgentSessionOptions = {}; @@ -408,7 +408,7 @@ export async function main(args: string[]) { if (mode === "rpc") { await runRpcMode(session); } else if (isInteractive) { - const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null); + const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined); const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager); if (scopedModels.length > 0) { diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index 64a32af7..2e914fa9 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -21,7 +21,7 @@ export class BashExecutionComponent extends Container { private command: string; private outputLines: string[] = []; private status: "running" | "complete" | "cancelled" | "error" = "running"; - private exitCode: number | null = null; + private exitCode: number | undefined = undefined; private loader: Loader; private truncationResult?: TruncationResult; private fullOutputPath?: string; @@ -90,13 +90,17 @@ export class BashExecutionComponent extends Container { } setComplete( - exitCode: number | null, + exitCode: number | undefined, cancelled: boolean, truncationResult?: TruncationResult, fullOutputPath?: string, ): void { this.exitCode = exitCode; - this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete"; + this.status = cancelled + ? "cancelled" + : exitCode !== 0 && exitCode !== undefined && exitCode !== null + ? "error" + : "complete"; this.truncationResult = truncationResult; this.fullOutputPath = fullOutputPath; diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 40236899..bbf77f2a 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -36,18 +36,18 @@ export class ModelSelectorComponent extends Container { private allModels: ModelItem[] = []; private filteredModels: ModelItem[] = []; private selectedIndex: number = 0; - private currentModel: Model | null; + private currentModel?: Model; private settingsManager: SettingsManager; private modelRegistry: ModelRegistry; private onSelectCallback: (model: Model) => void; private onCancelCallback: () => void; - private errorMessage: string | null = null; + private errorMessage?: string; private tui: TUI; private scopedModels: ReadonlyArray; constructor( tui: TUI, - currentModel: Model | null, + currentModel: Model | undefined, settingsManager: SettingsManager, modelRegistry: ModelRegistry, scopedModels: ReadonlyArray, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d1e12f6c..4d804832 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -67,14 +67,14 @@ export class InteractiveMode { private version: string; private isInitialized = false; private onInputCallback?: (text: string) => void; - private loadingAnimation: Loader | null = null; + private loadingAnimation: Loader | undefined = undefined; private lastSigintTime = 0; private lastEscapeTime = 0; - private changelogMarkdown: string | null = null; + private changelogMarkdown: string | undefined = undefined; // Streaming message tracking - private streamingComponent: AssistantMessageComponent | null = null; + private streamingComponent: AssistantMessageComponent | undefined = undefined; // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); @@ -92,22 +92,22 @@ export class InteractiveMode { private isBashMode = false; // Track current bash execution component - private bashComponent: BashExecutionComponent | null = null; + private bashComponent: BashExecutionComponent | undefined = undefined; // Track pending bash components (shown in pending area, moved to chat on submit) private pendingBashComponents: BashExecutionComponent[] = []; // Auto-compaction state - private autoCompactionLoader: Loader | null = null; + private autoCompactionLoader: Loader | undefined = undefined; private autoCompactionEscapeHandler?: () => void; // Auto-retry state - private retryLoader: Loader | null = null; + private retryLoader: Loader | undefined = undefined; private retryEscapeHandler?: () => void; // Hook UI state - private hookSelector: HookSelectorComponent | null = null; - private hookInput: HookInputComponent | null = null; + private hookSelector: HookSelectorComponent | undefined = undefined; + private hookInput: HookInputComponent | undefined = undefined; // Custom tools for custom rendering private customTools: Map; @@ -126,10 +126,10 @@ export class InteractiveMode { constructor( session: AgentSession, version: string, - changelogMarkdown: string | null = null, + changelogMarkdown: string | undefined = undefined, customTools: LoadedCustomTool[] = [], private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {}, - fdPath: string | null = null, + fdPath: string | undefined = undefined, ) { this.session = session; this.version = version; @@ -350,7 +350,7 @@ export class InteractiveMode { await this.emitToolSessionEvent({ entries, sessionFile: this.session.sessionFile, - previousSessionFile: null, + previousSessionFile: undefined, reason: "start", }); @@ -395,10 +395,9 @@ export class InteractiveMode { this.chatContainer.addChild(new Spacer(1)); } - // Emit session event + // Emit session_start event await hookRunner.emit({ - type: "session", - reason: "start", + type: "session_start", }); } @@ -442,7 +441,7 @@ export class InteractiveMode { /** * Show a selector for hooks. */ - private showHookSelector(title: string, options: string[]): Promise { + private showHookSelector(title: string, options: string[]): Promise { return new Promise((resolve) => { this.hookSelector = new HookSelectorComponent( title, @@ -453,7 +452,7 @@ export class InteractiveMode { }, () => { this.hideHookSelector(); - resolve(null); + resolve(undefined); }, ); @@ -470,7 +469,7 @@ export class InteractiveMode { private hideHookSelector(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); - this.hookSelector = null; + this.hookSelector = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); } @@ -486,7 +485,7 @@ export class InteractiveMode { /** * Show a text input for hooks. */ - private showHookInput(title: string, placeholder?: string): Promise { + private showHookInput(title: string, placeholder?: string): Promise { return new Promise((resolve) => { this.hookInput = new HookInputComponent( title, @@ -497,7 +496,7 @@ export class InteractiveMode { }, () => { this.hideHookInput(); - resolve(null); + resolve(undefined); }, ); @@ -514,7 +513,7 @@ export class InteractiveMode { private hideHookInput(): void { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); - this.hookInput = null; + this.hookInput = undefined; this.ui.setFocus(this.editor); this.ui.requestRender(); } @@ -874,7 +873,7 @@ export class InteractiveMode { } this.pendingTools.clear(); } - this.streamingComponent = null; + this.streamingComponent = undefined; this.footer.invalidate(); } this.ui.requestRender(); @@ -920,12 +919,12 @@ export class InteractiveMode { case "agent_end": if (this.loadingAnimation) { this.loadingAnimation.stop(); - this.loadingAnimation = null; + this.loadingAnimation = undefined; this.statusContainer.clear(); } if (this.streamingComponent) { this.chatContainer.removeChild(this.streamingComponent); - this.streamingComponent = null; + this.streamingComponent = undefined; } this.pendingTools.clear(); this.ui.requestRender(); @@ -964,7 +963,7 @@ export class InteractiveMode { // Stop loader if (this.autoCompactionLoader) { this.autoCompactionLoader.stop(); - this.autoCompactionLoader = null; + this.autoCompactionLoader = undefined; this.statusContainer.clear(); } // Handle result @@ -1018,7 +1017,7 @@ export class InteractiveMode { // Stop loader if (this.retryLoader) { this.retryLoader.stop(); - this.retryLoader = null; + this.retryLoader = undefined; this.statusContainer.clear(); } // Show error only on final failure (success shows normal response) @@ -1228,10 +1227,9 @@ export class InteractiveMode { private async shutdown(): Promise { // Emit shutdown event to hooks const hookRunner = this.session.hookRunner; - if (hookRunner?.hasHandlers("session")) { + if (hookRunner?.hasHandlers("session_shutdown")) { await hookRunner.emit({ - type: "session", - reason: "shutdown", + type: "session_shutdown", }); } @@ -1265,7 +1263,7 @@ export class InteractiveMode { private cycleThinkingLevel(): void { const newLevel = this.session.cycleThinkingLevel(); - if (newLevel === null) { + if (newLevel === undefined) { this.showStatus("Current model does not support thinking"); } else { this.footer.updateState(this.session.state); @@ -1277,7 +1275,7 @@ export class InteractiveMode { private async cycleModel(direction: "forward" | "backward"): Promise { try { const result = await this.session.cycleModel(direction); - if (result === null) { + if (result === undefined) { const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; this.showStatus(msg); } else { @@ -1612,13 +1610,13 @@ export class InteractiveMode { // Stop loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); - this.loadingAnimation = null; + this.loadingAnimation = undefined; } this.statusContainer.clear(); // Clear UI state this.pendingMessagesContainer.clear(); - this.streamingComponent = null; + this.streamingComponent = undefined; this.pendingTools.clear(); // Switch session via AgentSession (emits hook and tool session events) @@ -1874,7 +1872,7 @@ export class InteractiveMode { // Stop loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); - this.loadingAnimation = null; + this.loadingAnimation = undefined; } this.statusContainer.clear(); @@ -1884,7 +1882,7 @@ export class InteractiveMode { // Clear UI state this.chatContainer.clear(); this.pendingMessagesContainer.clear(); - this.streamingComponent = null; + this.streamingComponent = undefined; this.pendingTools.clear(); this.chatContainer.addChild(new Spacer(1)); @@ -1962,12 +1960,12 @@ export class InteractiveMode { } } catch (error) { if (this.bashComponent) { - this.bashComponent.setComplete(null, false); + this.bashComponent.setComplete(undefined, false); } this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`); } - this.bashComponent = null; + this.bashComponent = undefined; this.ui.requestRender(); } @@ -1987,7 +1985,7 @@ export class InteractiveMode { // Stop loading animation if (this.loadingAnimation) { this.loadingAnimation.stop(); - this.loadingAnimation = null; + this.loadingAnimation = undefined; } this.statusContainer.clear(); @@ -2039,7 +2037,7 @@ export class InteractiveMode { stop(): void { if (this.loadingAnimation) { this.loadingAnimation.stop(); - this.loadingAnimation = null; + this.loadingAnimation = undefined; } this.footer.dispose(); if (this.unsubscribe) { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index c8b302c0..fbf3037a 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -45,10 +45,9 @@ export async function runPrintMode( hookRunner.setAppendEntryHandler((customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }); - // Emit session event + // Emit session_start event await hookRunner.emit({ - type: "session", - reason: "start", + type: "session_start", }); } @@ -59,7 +58,7 @@ export async function runPrintMode( await tool.onSession({ entries, sessionFile: session.sessionFile, - previousSessionFile: null, + previousSessionFile: undefined, reason: "start", }); } catch (_err) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 9ae57cd3..de378612 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -51,17 +51,17 @@ export async function runRpcMode(session: AgentSession): Promise { * Create a hook UI context that uses the RPC protocol. */ const createHookUIContext = (): HookUIContext => ({ - async select(title: string, options: string[]): Promise { + async select(title: string, options: string[]): Promise { const id = crypto.randomUUID(); return new Promise((resolve, reject) => { pendingHookRequests.set(id, { resolve: (response: RpcHookUIResponse) => { if ("cancelled" in response && response.cancelled) { - resolve(null); + resolve(undefined); } else if ("value" in response) { resolve(response.value); } else { - resolve(null); + resolve(undefined); } }, reject, @@ -89,17 +89,17 @@ export async function runRpcMode(session: AgentSession): Promise { }); }, - async input(title: string, placeholder?: string): Promise { + async input(title: string, placeholder?: string): Promise { const id = crypto.randomUUID(); return new Promise((resolve, reject) => { pendingHookRequests.set(id, { resolve: (response: RpcHookUIResponse) => { if ("cancelled" in response && response.cancelled) { - resolve(null); + resolve(undefined); } else if ("value" in response) { resolve(response.value); } else { - resolve(null); + resolve(undefined); } }, reject, @@ -144,10 +144,9 @@ export async function runRpcMode(session: AgentSession): Promise { hookRunner.setAppendEntryHandler((customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }); - // Emit session event + // Emit session_start event await hookRunner.emit({ - type: "session", - reason: "start", + type: "session_start", }); } @@ -159,7 +158,7 @@ export async function runRpcMode(session: AgentSession): Promise { await tool.onSession({ entries, sessionFile: session.sessionFile, - previousSessionFile: null, + previousSessionFile: undefined, reason: "start", }); } catch (_err) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 993bc85f..d4f7a73a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -65,12 +65,12 @@ export type RpcCommand = // ============================================================================ export interface RpcSessionState { - model: Model | null; + model?: Model; thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; queueMode: "all" | "one-at-a-time"; - sessionFile: string | null; + sessionFile?: string; sessionId: string; autoCompactionEnabled: boolean; messageCount: number; diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 8111b3d2..e969950c 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -184,14 +184,14 @@ async function downloadTool(tool: "fd" | "rg"): Promise { // Ensure a tool is available, downloading if necessary // Returns the path to the tool, or null if unavailable -export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { +export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { const existingPath = getToolPath(tool); if (existingPath) { return existingPath; } const config = TOOLS[tool]; - if (!config) return null; + if (!config) return undefined; // Tool not found - download it if (!silent) { @@ -208,6 +208,6 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr if (!silent) { console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`)); } - return null; + return undefined; } } diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 19f1e969..51b3aa59 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -100,7 +100,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { createSession(true); // Verify sessions are disabled - expect(session.sessionFile).toBeNull(); + expect(session.sessionFile).toBeUndefined(); // Send one message await session.prompt("Say hi"); @@ -121,8 +121,8 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { // After branching, conversation should be empty expect(session.messages.length).toBe(0); - // Session file should still be null (no file created) - expect(session.sessionFile).toBeNull(); + // Session file should still be undefined (no file created) + expect(session.sessionFile).toBeUndefined(); }); it("should branch from middle of conversation", async () => { diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index fc79bdc1..529961c4 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -102,7 +102,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { // First message should be the summary (a user message with summary content) const firstMsg = messages[0]; - expect(firstMsg.role).toBe("user"); + expect(firstMsg.role).toBe("compactionSummary"); }, 120000); it("should maintain valid session state after compaction", async () => { diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index 126cb1a6..fbf1dadd 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -3,18 +3,16 @@ */ import { describe, expect, it } from "vitest"; -import type { HookAPI } from "../src/core/hooks/index.js"; +import type { HookAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/hooks/index.js"; describe("Documentation example", () => { it("custom compaction example should type-check correctly", () => { // This is the example from hooks.md - verify it compiles const exampleHook = (pi: HookAPI) => { - pi.on("session", async (event, ctx) => { - if (event.reason !== "before_compact") return; - - // After narrowing, these should all be accessible - // sessionManager and modelRegistry come from ctx, not event + pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => { + // All these should be accessible on the event const { preparation, previousCompactions, model } = event; + // sessionManager and modelRegistry come from ctx, not event const { sessionManager, modelRegistry } = ctx; const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId, cutPoint } = preparation; @@ -51,12 +49,10 @@ describe("Documentation example", () => { expect(typeof exampleHook).toBe("function"); }); - it("compact event should have correct fields after narrowing", () => { + it("compact event should have correct fields", () => { const checkCompactEvent = (pi: HookAPI) => { - pi.on("session", async (event, _ctx) => { - if (event.reason !== "compact") return; - - // After narrowing, these should all be accessible + pi.on("session_compact", async (event: SessionCompactEvent) => { + // These should all be accessible const entry = event.compactionEntry; const fromHook = event.fromHook; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index fe2fff3b..9ca29281 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -10,7 +10,13 @@ import { getModel } from "@mariozechner/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session.js"; import { AuthStorage } from "../src/core/auth-storage.js"; -import { HookRunner, type LoadedHook, type SessionEvent } from "../src/core/hooks/index.js"; +import { + HookRunner, + type LoadedHook, + type SessionBeforeCompactEvent, + type SessionCompactEvent, + type SessionEvent, +} from "../src/core/hooks/index.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; @@ -40,19 +46,25 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }); function createHook( - onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compaction?: any } | undefined, - onCompact?: (event: SessionEvent) => void, + onBeforeCompact?: (event: SessionBeforeCompactEvent) => { cancel?: boolean; compaction?: any } | undefined, + onCompact?: (event: SessionCompactEvent) => void, ): LoadedHook { const handlers = new Map Promise)[]>(); - handlers.set("session", [ - async (event: SessionEvent) => { + handlers.set("session_before_compact", [ + async (event: SessionBeforeCompactEvent) => { capturedEvents.push(event); - - if (event.reason === "before_compact" && onBeforeCompact) { + if (onBeforeCompact) { return onBeforeCompact(event); } - if (event.reason === "compact" && onCompact) { + return undefined; + }, + ]); + + handlers.set("session_compact", [ + async (event: SessionCompactEvent) => { + capturedEvents.push(event); + if (onCompact) { onCompact(event); } return undefined; @@ -89,9 +101,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry); hookRunner.setUIContext( { - select: async () => null, + select: async () => undefined, confirm: async () => false, - input: async () => null, + input: async () => undefined, notify: () => {}, custom: () => ({ close: () => {}, requestRender: () => {} }), }, @@ -121,30 +133,28 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { await session.compact(); - const beforeCompactEvents = capturedEvents.filter((e) => e.reason === "before_compact"); - const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + const beforeCompactEvents = capturedEvents.filter( + (e): e is SessionBeforeCompactEvent => e.type === "session_before_compact", + ); + const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact"); expect(beforeCompactEvents.length).toBe(1); expect(compactEvents.length).toBe(1); const beforeEvent = beforeCompactEvents[0]; - if (beforeEvent.reason === "before_compact") { - expect(beforeEvent.preparation).toBeDefined(); - expect(beforeEvent.preparation.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0); - expect(beforeEvent.preparation.messagesToSummarize).toBeDefined(); - expect(beforeEvent.preparation.messagesToKeep).toBeDefined(); - expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0); - expect(beforeEvent.model).toBeDefined(); - // sessionManager and modelRegistry are now on ctx, not event - } + expect(beforeEvent.preparation).toBeDefined(); + expect(beforeEvent.preparation.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0); + expect(beforeEvent.preparation.messagesToSummarize).toBeDefined(); + expect(beforeEvent.preparation.messagesToKeep).toBeDefined(); + expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0); + expect(beforeEvent.model).toBeDefined(); + // sessionManager and modelRegistry are now on ctx, not event const afterEvent = compactEvents[0]; - if (afterEvent.reason === "compact") { - expect(afterEvent.compactionEntry).toBeDefined(); - expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0); - expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0); - expect(afterEvent.fromHook).toBe(false); - } + expect(afterEvent.compactionEntry).toBeDefined(); + expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0); + expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0); + expect(afterEvent.fromHook).toBe(false); }, 120000); it("should allow hooks to cancel compaction", async () => { @@ -156,7 +166,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { await expect(session.compact()).rejects.toThrow("Compaction cancelled"); - const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + const compactEvents = capturedEvents.filter((e) => e.type === "session_compact"); expect(compactEvents.length).toBe(0); }, 120000); @@ -164,7 +174,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const customSummary = "Custom summary from hook"; const hook = createHook((event) => { - if (event.reason === "before_compact") { + if (event.type === "session_before_compact") { return { compaction: { summary: customSummary, @@ -187,11 +197,11 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(result.summary).toBe(customSummary); - const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + const compactEvents = capturedEvents.filter((e) => e.type === "session_compact"); expect(compactEvents.length).toBe(1); const afterEvent = compactEvents[0]; - if (afterEvent.reason === "compact") { + if (afterEvent.type === "session_compact") { expect(afterEvent.compactionEntry.summary).toBe(customSummary); expect(afterEvent.fromHook).toBe(true); } @@ -206,11 +216,11 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { await session.compact(); - const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + const compactEvents = capturedEvents.filter((e) => e.type === "session_compact"); expect(compactEvents.length).toBe(1); const afterEvent = compactEvents[0]; - if (afterEvent.reason === "compact") { + if (afterEvent.type === "session_compact") { // sessionManager is now on ctx, use session.sessionManager directly const entries = session.sessionManager.getEntries(); const hasCompactionEntry = entries.some((e: { type: string }) => e.type === "compaction"); @@ -224,13 +234,19 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { resolvedPath: "/test/throwing-hook.ts", handlers: new Map Promise)[]>([ [ - "session", + "session_before_compact", [ - async (event: SessionEvent) => { + async (event: SessionBeforeCompactEvent) => { + capturedEvents.push(event); + throw new Error("Hook intentionally throws"); + }, + ], + ], + [ + "session_compact", + [ + async (event: SessionCompactEvent) => { capturedEvents.push(event); - if (event.reason === "before_compact") { - throw new Error("Hook intentionally failed"); - } return undefined; }, ], @@ -252,12 +268,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(result.summary).toBeDefined(); expect(result.summary.length).toBeGreaterThan(0); - const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact"); expect(compactEvents.length).toBe(1); - - if (compactEvents[0].reason === "compact") { - expect(compactEvents[0].fromHook).toBe(false); - } + expect(compactEvents[0].fromHook).toBe(false); }, 120000); it("should call multiple hooks in order", async () => { @@ -268,15 +281,19 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { resolvedPath: "/test/hook1.ts", handlers: new Map Promise)[]>([ [ - "session", + "session_before_compact", [ - async (event: SessionEvent) => { - if (event.reason === "before_compact") { - callOrder.push("hook1-before"); - } - if (event.reason === "compact") { - callOrder.push("hook1-after"); - } + async () => { + callOrder.push("hook1-before"); + return undefined; + }, + ], + ], + [ + "session_compact", + [ + async () => { + callOrder.push("hook1-after"); return undefined; }, ], @@ -293,15 +310,19 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { resolvedPath: "/test/hook2.ts", handlers: new Map Promise)[]>([ [ - "session", + "session_before_compact", [ - async (event: SessionEvent) => { - if (event.reason === "before_compact") { - callOrder.push("hook2-before"); - } - if (event.reason === "compact") { - callOrder.push("hook2-after"); - } + async () => { + callOrder.push("hook2-before"); + return undefined; + }, + ], + ], + [ + "session_compact", + [ + async () => { + callOrder.push("hook2-after"); return undefined; }, ], @@ -324,12 +345,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }, 120000); it("should pass correct data in before_compact event", async () => { - let capturedBeforeEvent: (SessionEvent & { reason: "before_compact" }) | null = null; + let capturedBeforeEvent: SessionBeforeCompactEvent | null = null; const hook = createHook((event) => { - if (event.reason === "before_compact") { - capturedBeforeEvent = event; - } + capturedBeforeEvent = event; return undefined; }); createSession([hook]); @@ -370,7 +389,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const customSummary = "Custom summary with modified values"; const hook = createHook((event) => { - if (event.reason === "before_compact") { + if (event.type === "session_before_compact") { return { compaction: { summary: customSummary, diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 43f56712..331c3b1b 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -180,9 +180,9 @@ describe("getLastAssistantUsage", () => { expect(usage!.input).toBe(100); }); - it("should return null if no assistant messages", () => { + it("should return undefined if no assistant messages", () => { const entries: SessionEntry[] = [createMessageEntry(createUserMessage("Hello"))]; - expect(getLastAssistantUsage(entries)).toBeNull(); + expect(getLastAssistantUsage(entries)).toBeUndefined(); }); }); @@ -301,8 +301,8 @@ describe("buildSessionContext", () => { const loaded = buildSessionContext(entries); // summary + kept (u2, a2) + after (u3, a3) = 5 expect(loaded.messages.length).toBe(5); - expect(loaded.messages[0].role).toBe("user"); - expect((loaded.messages[0] as any).content).toContain("Summary of 1,a,2,b"); + expect(loaded.messages[0].role).toBe("compactionSummary"); + expect((loaded.messages[0] as any).summary).toContain("Summary of 1,a,2,b"); }); it("should handle multiple compactions (only latest matters)", () => { @@ -325,7 +325,7 @@ describe("buildSessionContext", () => { const loaded = buildSessionContext(entries); // summary + kept from u3 (u3, c) + after (u4, d) = 5 expect(loaded.messages.length).toBe(5); - expect((loaded.messages[0] as any).content).toContain("Second summary"); + expect((loaded.messages[0] as any).summary).toContain("Second summary"); }); it("should keep all messages when firstKeptEntryId is first entry", () => { @@ -443,8 +443,8 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => { // Should have summary + kept messages expect(reloaded.messages.length).toBeLessThan(loaded.messages.length); - expect(reloaded.messages[0].role).toBe("user"); - expect((reloaded.messages[0] as any).content).toContain(compactionResult.summary); + expect(reloaded.messages[0].role).toBe("compactionSummary"); + expect((reloaded.messages[0] as any).summary).toContain(compactionResult.summary); console.log("Original messages:", loaded.messages.length); console.log("After compaction:", reloaded.messages.length); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 59162d6f..0b7b47ca 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -66,21 +66,21 @@ describe("parseModelPattern", () => { const result = parseModelPattern("claude-sonnet-4-5", allModels); expect(result.model?.id).toBe("claude-sonnet-4-5"); expect(result.thinkingLevel).toBe("off"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); test("partial match returns best model", () => { const result = parseModelPattern("sonnet", allModels); expect(result.model?.id).toBe("claude-sonnet-4-5"); expect(result.thinkingLevel).toBe("off"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); test("no match returns null model", () => { const result = parseModelPattern("nonexistent", allModels); - expect(result.model).toBeNull(); + expect(result.model).toBeUndefined(); expect(result.thinkingLevel).toBe("off"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); }); @@ -89,14 +89,14 @@ describe("parseModelPattern", () => { const result = parseModelPattern("sonnet:high", allModels); expect(result.model?.id).toBe("claude-sonnet-4-5"); expect(result.thinkingLevel).toBe("high"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); test("gpt-4o:medium returns gpt-4o with medium thinking level", () => { const result = parseModelPattern("gpt-4o:medium", allModels); expect(result.model?.id).toBe("gpt-4o"); expect(result.thinkingLevel).toBe("medium"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); test("all valid thinking levels work", () => { @@ -104,7 +104,7 @@ describe("parseModelPattern", () => { const result = parseModelPattern(`sonnet:${level}`, allModels); expect(result.model?.id).toBe("claude-sonnet-4-5"); expect(result.thinkingLevel).toBe(level); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); } }); }); @@ -131,7 +131,7 @@ describe("parseModelPattern", () => { const result = parseModelPattern("qwen/qwen3-coder:exacto", allModels); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.thinkingLevel).toBe("off"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); test("openrouter/qwen/qwen3-coder:exacto matches with provider prefix", () => { @@ -139,14 +139,14 @@ describe("parseModelPattern", () => { expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.model?.provider).toBe("openrouter"); expect(result.thinkingLevel).toBe("off"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); test("qwen3-coder:exacto:high matches model with high thinking level", () => { const result = parseModelPattern("qwen/qwen3-coder:exacto:high", allModels); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.thinkingLevel).toBe("high"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); test("openrouter/qwen/qwen3-coder:exacto:high matches with provider and thinking level", () => { @@ -154,14 +154,14 @@ describe("parseModelPattern", () => { expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.model?.provider).toBe("openrouter"); expect(result.thinkingLevel).toBe("high"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); test("gpt-4o:extended matches the extended model", () => { const result = parseModelPattern("openai/gpt-4o:extended", allModels); expect(result.model?.id).toBe("openai/gpt-4o:extended"); expect(result.thinkingLevel).toBe("off"); - expect(result.warning).toBeNull(); + expect(result.warning).toBeUndefined(); }); }); diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 8c0b3aac..e0d6edac 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -273,7 +273,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T // Initially null let text = await client.getLastAssistantText(); - expect(text).toBeNull(); + expect(text).toBeUndefined(); // Send prompt await client.promptAndWait("Reply with just: test123"); diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index 31479b7b..5bc336a6 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -48,7 +48,7 @@ function compaction(id: string, parentId: string | null, summary: string, firstK }; } -function branchSummary(id: string, parentId: string | null, fromId: string, summary: string): BranchSummaryEntry { +function branchSummary(id: string, parentId: string | null, summary: string, fromId: string): BranchSummaryEntry { return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary, fromId }; } @@ -132,7 +132,7 @@ describe("buildSessionContext", () => { // Should have: summary + kept (3,4) + after (6,7) = 5 messages expect(ctx.messages).toHaveLength(5); - expect((ctx.messages[0] as any).content).toContain("Summary of first two turns"); + expect((ctx.messages[0] as any).summary).toContain("Summary of first two turns"); expect((ctx.messages[1] as any).content).toBe("second"); expect((ctx.messages[2] as any).content[0].text).toBe("response2"); expect((ctx.messages[3] as any).content).toBe("third"); @@ -150,7 +150,7 @@ describe("buildSessionContext", () => { // Summary + all messages (1,2,4) expect(ctx.messages).toHaveLength(4); - expect((ctx.messages[0] as any).content).toContain("Empty summary"); + expect((ctx.messages[0] as any).summary).toContain("Empty summary"); }); it("multiple compactions uses latest", () => { @@ -167,7 +167,7 @@ describe("buildSessionContext", () => { // Should use second summary, keep from 4 expect(ctx.messages).toHaveLength(4); - expect((ctx.messages[0] as any).content).toContain("Second summary"); + expect((ctx.messages[0] as any).summary).toContain("Second summary"); }); }); @@ -203,7 +203,7 @@ describe("buildSessionContext", () => { const ctx = buildSessionContext(entries, "5"); expect(ctx.messages).toHaveLength(4); - expect((ctx.messages[2] as any).content).toContain("Summary of abandoned work"); + expect((ctx.messages[2] as any).summary).toContain("Summary of abandoned work"); expect((ctx.messages[3] as any).content).toBe("new direction"); }); @@ -231,7 +231,7 @@ describe("buildSessionContext", () => { // Main path to 7: summary + kept(3,4) + after(6,7) const ctxMain = buildSessionContext(entries, "7"); expect(ctxMain.messages).toHaveLength(5); - expect((ctxMain.messages[0] as any).content).toContain("Compacted history"); + expect((ctxMain.messages[0] as any).summary).toContain("Compacted history"); expect((ctxMain.messages[1] as any).content).toBe("q2"); expect((ctxMain.messages[2] as any).content[0].text).toBe("r2"); expect((ctxMain.messages[3] as any).content).toBe("q3"); @@ -243,7 +243,7 @@ describe("buildSessionContext", () => { expect((ctxBranch.messages[0] as any).content).toBe("start"); expect((ctxBranch.messages[1] as any).content[0].text).toBe("r1"); expect((ctxBranch.messages[2] as any).content).toBe("q2"); - expect((ctxBranch.messages[3] as any).content).toContain("Tried wrong approach"); + expect((ctxBranch.messages[3] as any).summary).toContain("Tried wrong approach"); expect((ctxBranch.messages[4] as any).content).toBe("better approach"); }); }); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index fa788164..8cf1dcde 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -451,7 +451,7 @@ describe("createBranchedSession", () => { // Create branched session from id2 (should only have 1 -> 2) const result = session.createBranchedSession(id2); - expect(result).toBeNull(); // in-memory returns null + expect(result).toBeUndefined(); // in-memory returns null // Session should now only have entries 1 and 2 const entries = session.getEntries(); From 59ce62bffc8717c43a5da61500609dc279ed6482 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 02:03:30 +0100 Subject: [PATCH 097/161] Tree spec --- packages/coding-agent/docs/tree.md | 469 +++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 packages/coding-agent/docs/tree.md diff --git a/packages/coding-agent/docs/tree.md b/packages/coding-agent/docs/tree.md new file mode 100644 index 00000000..d44e5d56 --- /dev/null +++ b/packages/coding-agent/docs/tree.md @@ -0,0 +1,469 @@ +# Branch Summary + +This document describes the `/tree` command and branch summarization feature. + +## Overview + +The `/tree` command provides tree-based navigation of the session history, allowing users to: +1. View the entire session tree structure +2. Switch to any branch point +3. Optionally summarize the branch being abandoned + +This differs from `/branch` which extracts a linear path to a new session file. + +## Commands + +### `/branch` (existing) +- Shows a flat list of user messages +- Extracts selected path to a **new session file** +- Selected user message text goes to editor for re-submission +- Fires `session_before_branch` / `session_branch` events + +### `/tree` (new) +- Shows the **full session tree** with visual hierarchy +- Navigates within the **same session file** (changes active leaf) +- Optionally summarizes the abandoned branch +- Fires `session_before_tree` / `session_tree` events + +## Tree UI + +The tree selector displays the session structure with ASCII art: + +``` +├─ user: "Hello, can you help..." +│ └─ assistant: "Of course! I can..." +│ ├─ user: "Let's try approach A..." +│ │ └─ assistant: "For approach A..." +│ │ └─ [compaction: 12k tokens] +│ │ └─ user: "That worked, now..." +│ │ └─ assistant: "Great! Next..." ← active +│ └─ user: "Actually, approach B..." +│ └─ assistant: "For approach B..." +``` + +### Visual Indicators + +| Element | Display | +|---------|---------| +| Current active leaf | `← active` suffix, highlighted | +| User messages | Normal color (selectable) | +| Custom messages (display: true) | Normal color (selectable) | +| Assistant/tool results | Dimmed (selectable, for context continuation) | +| Compaction nodes | `[compaction: Xk tokens]` | +| Branch points | Node with multiple children visible | + +### Navigation + +| Key | Action | +|-----|--------| +| ↑/↓ | Move through nodes (depth-first pre-order) | +| Enter | Select node and proceed | +| Escape | Cancel | +| Ctrl+C | Cancel | +| Ctrl+U | Toggle: show only user messages | +| Ctrl+O | Toggle: show all entries (including custom/label) | + +### Filtering + +Default view hides: +- `label` entries (labels shown inline on their target node) +- `custom` entries (hook state, not relevant for navigation) + +Ctrl+O shows everything for debugging/inspection. + +### Component Size + +Height is capped at **half terminal height** to show substantial tree context without overshooting the terminal. + +## Selection Behavior + +### Selecting Current Active Leaf + +No-op. Display message: "Already at this point." + +### Switching to Different Node + +### User Message or Custom Message Selected +1. Active leaf is set to **parent** of selected node +2. Selected message text is placed in the **editor** for re-submission +3. User edits and submits, creating a new branch from that point + +### Non-User Message Selected (assistant, tool result, etc.) +1. Active leaf is set to the **selected node itself** +2. Editor remains empty +3. User continues the conversation from that point + +## Branch Summarization + +When switching branches, the user is prompted: "Summarize the branch you're leaving?" + +### What Gets Summarized + +The abandoned branch is the path from the **old active leaf** back to the **common ancestor** of the old leaf and newly selected node. + +``` +A → B → C → D → E → F ← old active leaf + ↘ G → H ← user selects H +``` + +- Common ancestor: C +- Abandoned path: D → E → F +- These nodes are summarized + +### Stopping Conditions + +When walking back from the old leaf to gather content for summarization: + +1. **Stop at common ancestor** (always) +2. **Stop at compaction node** (if encountered before common ancestor) + - Compaction already summarizes older content + - Only summarize "fresh" content after the compaction + +### Summary Storage + +The summary is stored as a `BranchSummaryEntry`: + +```typescript +interface BranchSummaryEntry { + type: "branch_summary"; + id: string; + parentId: string; // Points to common ancestor + timestamp: string; + fromId: string; // The old leaf we abandoned + summary: string; // LLM-generated summary + details?: unknown; // Optional hook data +} +``` + +The summary entry becomes a sibling of the path we're switching to, preserving the record of what was abandoned. + +### Summary Generation + +The summarizer: +1. Collects messages from old leaf back to stopping point +2. Sends to LLM with prompt: "Summarize this conversation branch concisely" +3. Creates `BranchSummaryEntry` with the result + +User can skip summarization, in which case no `BranchSummaryEntry` is created. + +## Example Flow + +``` +Initial state: +A → B → C → D ← active + +User runs /tree, selects B: + +1. Show tree: + ├─ A (user): "Start task..." + │ └─ B (assistant): "I'll help..." + │ └─ C (user): "Do X..." + │ └─ D (assistant): "Done X..." ← active + +2. User navigates to B, presses Enter + +3. Prompt: "Summarize branch you're leaving? [Y/n]" + +4a. If Yes: + - Summarize C → D + - Create BranchSummaryEntry(fromId: D, summary: "...") + - Set active leaf to B + - Tree becomes: + A → B → C → D + ↓ ↘ [summary: "Tried X..."] + └─ (active, user continues from here) + +4b. If No: + - Set active leaf to B + - No summary entry created + +5. Since B is assistant message: + - Editor stays empty + - User types new message, branches from B +``` + +## Implementation Notes + +### SessionManager Methods (already exist) + +- `getTree()` - Get full tree structure for display (needs: sort children by timestamp) +- `getPath(id)` - Get path from root to any node +- `getEntry(id)` - Look up individual entries +- `getLeafUuid()` - Get current active leaf +- `branch(id)` - Change active leaf +- `branchWithSummary(fromId, summary)` - Create branch summary entry +- `buildSessionContext()` - Get messages for LLM from current leaf + +### AgentSession: New `navigateTree()` Method + +```typescript +interface NavigateTreeOptions { + /** Whether user wants to summarize abandoned branch */ + summarize?: boolean; + /** Custom instructions for summarizer */ + customInstructions?: string; +} + +interface NavigateTreeResult { + /** Text to put in editor (if user message selected) */ + editorText?: string; + /** Whether navigation was cancelled */ + cancelled: boolean; +} + +async navigateTree(targetId: string, options?: NavigateTreeOptions): Promise +``` + +Implementation flow: + +1. **Validate target exists** +2. **Check if no-op** (target === current leaf) → return early +3. **Prepare summarization** (if `options.summarize`): + - Find common ancestor + - Collect entries to summarize (old leaf → common ancestor, stop at compaction) +4. **Fire `session_before_tree` event**: + - Pass preparation, model, signal + - If hook returns `cancel: true` → return `{ cancelled: true }` + - If hook returns custom summary → use it, skip default summarizer +5. **Run default summarizer** (if needed): + - Use conversation model + - On failure/abort → return `{ cancelled: true }` +6. **Switch leaf**: + - If summarizing: `sessionManager.branchWithSummary(targetId, summary)` + - Otherwise: `sessionManager.branch(targetId)` +7. **Update agent state**: + ```typescript + const context = this.sessionManager.buildSessionContext(); + this.agent.replaceMessages(context.messages); + ``` +8. **Fire `session_tree` event** +9. **Notify custom tools** via `_emitToolSessionEvent("tree", ...)` +10. **Return result**: + - If target was user message: `{ editorText: messageText, cancelled: false }` + - Otherwise: `{ cancelled: false }` + +### InteractiveMode: `/tree` Command Handler + +```typescript +if (text === "/tree") { + this.showTreeSelector(); + this.editor.setText(""); + return; +} +``` + +`showTreeSelector()` flow: + +1. Get tree via `sessionManager.getTree()` +2. Show `TreeSelectorComponent` (new component) +3. On selection: + - If target === current leaf → show "Already at this point", done + - Prompt: "Summarize branch you're leaving? [Y/n]" + - Call `session.navigateTree(targetId, { summarize })` + - If cancelled → done + - Clear chat: `this.chatContainer.clear()` + - Re-render: `this.renderInitialMessages()` + - If `result.editorText` → `this.editor.setText(result.editorText)` + - Show status: "Switched to entry X" + +### TUI Update Flow + +After `navigateTree()` completes successfully: + +```typescript +// In InteractiveMode, after navigateTree returns +if (!result.cancelled) { + this.chatContainer.clear(); + this.renderInitialMessages(); // Uses sessionManager.buildSessionContext() + if (result.editorText) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); +} +``` + +This matches the existing pattern in `handleResumeSession()` and `handleClearCommand()`. + +### Finding Common Ancestor + +```typescript +function findCommonAncestor(nodeA: string, nodeB: string): string { + const pathA = new Set(sessionManager.getPath(nodeA).map(e => e.id)); + for (const entry of sessionManager.getPath(nodeB)) { + if (pathA.has(entry.id)) { + return entry.id; + } + } + throw new Error("No common ancestor found"); +} +``` + +### Collecting Abandoned Branch + +```typescript +function collectAbandonedBranch(oldLeaf: string, commonAncestor: string): SessionEntry[] { + const entries: SessionEntry[] = []; + let current = oldLeaf; + + while (current !== commonAncestor) { + const entry = sessionManager.getEntry(current); + if (!entry) break; + + // Stop at compaction - older content already summarized + if (entry.type === "compaction") break; + + entries.push(entry); + current = entry.parentId; + } + + return entries.reverse(); // Chronological order +} +``` + +### Tree Child Ordering + +`getTree()` should sort children by timestamp (oldest first, newest at bottom): + +```typescript +// In getTree(), after building tree: +function sortChildren(node: SessionTreeNode): void { + node.children.sort((a, b) => + new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() + ); + node.children.forEach(sortChildren); +} +roots.forEach(sortChildren); +``` + +### Error Handling + +**Summarization fails** (API error, timeout, etc.): +- Cancel the entire switch +- Show error message +- User stays at current position + +**User aborts during summarization** (Escape): +- Cancel the entire switch +- Show "Navigation cancelled" +- User stays at current position + +**Hook returns `cancel: true`**: +- Cancel the switch +- No error message (hook may have shown its own UI) +- User stays at current position + +### TreeSelectorComponent + +New TUI component at `src/modes/interactive/components/tree-selector.ts`: + +```typescript +interface TreeSelectorProps { + tree: SessionTreeNode[]; + currentLeafId: string; + onSelect: (entryId: string) => void; + onCancel: () => void; +} +``` + +Features: +- Height: half terminal height (capped) +- ASCII tree rendering with `├─`, `│`, `└─` connectors +- Depth-first traversal for up/down navigation +- Visual indicators: + - `← active` for current leaf + - Resolved labels shown inline + - Compaction nodes as `[compaction: Xk tokens]` +- Filter modes: + - Default: hide `label` and `custom` entries + - Ctrl+U: user messages only + - Ctrl+O: show all entries +- Scrolling with selected node kept visible + +## Hook Events + +These events are separate from `session_before_branch`/`session_branch` which are used by the existing `/branch` command (creates new session file). + +### `session_before_tree` + +Fired before switching branches within the same session file. Hooks can cancel or provide custom summary. + +```typescript +interface TreePreparation { + /** Node being switched to */ + targetId: string; + /** Current active leaf (being abandoned) */ + oldLeafId: string; + /** Common ancestor of target and old leaf */ + commonAncestorId: string; + /** Entries to summarize (old leaf back to common ancestor or compaction) */ + entriesToSummarize: SessionEntry[]; + /** Whether user chose to summarize */ + userWantsSummary: boolean; +} + +interface SessionBeforeTreeEvent { + type: "session_before_tree"; + preparation: TreePreparation; + /** Model to use for summarization (conversation model) */ + model: Model; + /** Abort signal - honors Escape during summarization */ + signal: AbortSignal; +} + +interface SessionBeforeTreeResult { + /** Cancel the navigation entirely */ + cancel?: boolean; + /** Custom summary (skips default summarizer). Only used if userWantsSummary is true. */ + summary?: { + summary: string; + details?: unknown; + }; +} +``` + +### `session_tree` + +Fired after navigation completes successfully. Not fired if cancelled. + +```typescript +interface SessionTreeEvent { + type: "session_tree"; + /** The new active leaf */ + newLeafId: string; + /** Previous active leaf */ + oldLeafId: string; + /** Branch summary entry if one was created, undefined if user skipped summarization */ + summaryEntry?: BranchSummaryEntry; + /** Whether summary came from hook (false if default summarizer used, undefined if no summary) */ + fromHook?: boolean; +} +``` + +### Example: Custom Branch Summarizer + +```typescript +export default function(pi: HookAPI) { + pi.on("session_before_tree", async (event, ctx) => { + if (!event.preparation.userWantsSummary) return; + if (event.preparation.entriesToSummarize.length === 0) return; + + // Use a different model for summarization + const model = getModel("google", "gemini-2.5-flash"); + const apiKey = await ctx.modelRegistry.getApiKey(model); + + // Custom summarization logic + const summary = await summarizeWithCustomPrompt( + event.preparation.entriesToSummarize, + model, + apiKey + ); + + return { + summary: { + summary, + details: { model: model.id, timestamp: Date.now() } + } + }; + }); +} +``` From 256761e4104fce91b388d3223ed6a8d0ee67134e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 02:11:31 +0100 Subject: [PATCH 098/161] Clean-up --- .gitignore | 1 + ..._d1555962-63f5-402c-b6e5-24fa5d528fb2.html | 496 ------------------ ..._6646a9e5-779b-4f81-8d45-d8d32f99aa25.html | 496 ------------------ ..._7225b2a7-7612-440d-84f1-fbcd8037d2f2.html | 495 ----------------- 4 files changed, 1 insertion(+), 1487 deletions(-) delete mode 100644 packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html delete mode 100644 packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html delete mode 100644 packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html diff --git a/.gitignore b/.gitignore index 8ad2078d..d320e0b1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ compaction-results/ .opencode/ syntax.jsonl out.jsonl +pi-*.html diff --git a/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html b/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html deleted file mode 100644 index c8f5a813..00000000 --- a/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html +++ /dev/null @@ -1,496 +0,0 @@ - - - - - - Session Export - 2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.jsonl - - - -
-
-

pi v0.30.2

-
-
Session:d1555962-63f5-402c-b6e5-24fa5d528fb2
-
Date:12/28/2025, 6:08:16 PM
-
Models:unknown
-
-
- -
-

Messages

-
-
User:1
-
Assistant:2
-
Tool Calls:1
-
-
- -
-

Tokens & Cost

-
-
Input:9 tokens
-
Output:268 tokens
-
Cache Read:2,462 tokens
-
Cache Write:3,505 tokens
-
Total:6,244 tokens
-
Input Cost:$0.0000
-
Output Cost:$0.0040
-
Cache Read Cost:$0.0007
-
Cache Write Cost:$0.0131
-
Total Cost:$0.0179
-
Context Usage:3,647 / 200,000 tokens (1.8%) - anthropic/claude-sonnet-4-5
-
-
- -
-
System Prompt
-
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. - -Available tools: -- read: Read file contents -- bash: Execute bash commands (ls, grep, find, etc.) -- edit: Make surgical edits to files (find exact text and replace) -- write: Create or overwrite files - -Guidelines: -- Use bash for file operations like ls, grep, find -- Use read to examine files before editing. You must use this tool instead of cat or sed. -- Use edit for precise changes (old text must match exactly) -- Use write only for new files or complete rewrites -- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did -- Be concise in your responses -- Show file paths clearly when working with files - -Documentation: -- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md -- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs -- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md) - -# Project Context - -The following project context files have been loaded: - -## /Users/badlogic/workspaces/pi-mono/AGENTS.md - -# Development Rules - -## First Message -If the user did not give you a concrete task in their first message, -read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel. -- packages/ai/README.md -- packages/tui/README.md -- packages/agent/README.md -- packages/coding-agent/README.md -- packages/mom/README.md -- packages/pods/README.md -- packages/web-ui/README.md - -## Code Quality -- No `any` types unless absolutely necessary -- Check node_modules for external API type definitions instead of guessing -- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. -- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead -- Always ask before removing functionality or code that appears to be intentional - -## Commands -- After code changes: `npm run check` (get full output, no tail) -- NEVER run: `npm run dev`, `npm run build`, `npm test` -- Only run specific tests if user instructs: `npm test -- test/specific.test.ts` -- NEVER commit unless user asks - -## GitHub Issues -When reading issues: -- Always read all comments on the issue - -When creating issues: -- Add `pkg:*` labels to indicate which package(s) the issue affects - - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui` -- If an issue spans multiple packages, add all relevant labels - -When closing issues via commit: -- Include `fixes #<number>` or `closes #<number>` in the commit message -- This automatically closes the issue when the commit is merged - -## Tools -- GitHub CLI for issues/PRs -- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui -- TUI interaction: use tmux - -## Style -- Keep answers short and concise -- No emojis in commits, issues, PR comments, or code -- No fluff or cheerful filler text -- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") - -## Changelog -Location: `packages/*/CHANGELOG.md` (each package has its own) - -### Format -Use these sections under `## [Unreleased]`: -- `### Breaking Changes` - API changes requiring migration -- `### Added` - New features -- `### Changed` - Changes to existing functionality -- `### Fixed` - Bug fixes -- `### Removed` - Removed features - -### Rules -- New entries ALWAYS go under `## [Unreleased]` section -- NEVER modify already-released version sections (e.g., `## [0.12.2]`) -- Each version section is immutable once released - -### Attribution -- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))` -- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))` - -## Releasing - -1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md - -2. **Run release script**: - ```bash - npm run release:patch # Bug fixes - npm run release:minor # New features - npm run release:major # Breaking changes - ``` - -The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. - -### Tool Usage -**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). - - -Current date and time: Sunday, December 28, 2025 at 06:08:16 PM GMT+1 -Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
-
-
-
Available Tools
-
-
read - Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.
bash - Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.
edit - Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.
write - Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.
-
-
- - -
-
06:08:16 PM

Hello

-
06:08:16 PM

Hello! I'm Claude Code, here to help you with coding tasks in this pi-mono monorepo.

-

Since you haven't given me a specific task yet, let me read the main README to understand the project better, then I can ask which module(s) you'd like to work on.

-
read ~/workspaces/pi-mono/README.md
06:08:20 PM

Great! This is a monorepo with several packages for building AI agents and managing LLM deployments.

-

Which module(s) would you like to work on?

-
    -
  • ai - Unified multi-provider LLM API
  • -
  • agent - Agent runtime with tool calling and state management
  • -
  • coding-agent - Interactive coding agent CLI
  • -
  • mom - Slack bot that delegates to the coding agent
  • -
  • tui - Terminal UI library
  • -
  • web-ui - Web components for AI chat interfaces
  • -
  • pods - CLI for managing vLLM deployments
  • -
-
-
- - -
- - \ No newline at end of file diff --git a/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html b/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html deleted file mode 100644 index 13c3df87..00000000 --- a/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html +++ /dev/null @@ -1,496 +0,0 @@ - - - - - - Session Export - 2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.jsonl - - - -
-
-

pi v0.30.2

-
-
Session:6646a9e5-779b-4f81-8d45-d8d32f99aa25
-
Date:12/28/2025, 7:08:12 PM
-
Models:unknown
-
-
- -
-

Messages

-
-
User:1
-
Assistant:2
-
Tool Calls:1
-
-
- -
-

Tokens & Cost

-
-
Input:9 tokens
-
Output:302 tokens
-
Cache Read:2,462 tokens
-
Cache Write:3,487 tokens
-
Total:6,260 tokens
-
Input Cost:$0.0000
-
Output Cost:$0.0045
-
Cache Read Cost:$0.0007
-
Cache Write Cost:$0.0131
-
Total Cost:$0.0184
-
Context Usage:3,681 / 200,000 tokens (1.8%) - anthropic/claude-sonnet-4-5
-
-
- -
-
System Prompt
-
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. - -Available tools: -- read: Read file contents -- bash: Execute bash commands (ls, grep, find, etc.) -- edit: Make surgical edits to files (find exact text and replace) -- write: Create or overwrite files - -Guidelines: -- Use bash for file operations like ls, grep, find -- Use read to examine files before editing. You must use this tool instead of cat or sed. -- Use edit for precise changes (old text must match exactly) -- Use write only for new files or complete rewrites -- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did -- Be concise in your responses -- Show file paths clearly when working with files - -Documentation: -- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md -- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs -- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md) - -# Project Context - -The following project context files have been loaded: - -## /Users/badlogic/workspaces/pi-mono/AGENTS.md - -# Development Rules - -## First Message -If the user did not give you a concrete task in their first message, -read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel. -- packages/ai/README.md -- packages/tui/README.md -- packages/agent/README.md -- packages/coding-agent/README.md -- packages/mom/README.md -- packages/pods/README.md -- packages/web-ui/README.md - -## Code Quality -- No `any` types unless absolutely necessary -- Check node_modules for external API type definitions instead of guessing -- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. -- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead -- Always ask before removing functionality or code that appears to be intentional - -## Commands -- After code changes: `npm run check` (get full output, no tail) -- NEVER run: `npm run dev`, `npm run build`, `npm test` -- Only run specific tests if user instructs: `npm test -- test/specific.test.ts` -- NEVER commit unless user asks - -## GitHub Issues -When reading issues: -- Always read all comments on the issue - -When creating issues: -- Add `pkg:*` labels to indicate which package(s) the issue affects - - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui` -- If an issue spans multiple packages, add all relevant labels - -When closing issues via commit: -- Include `fixes #<number>` or `closes #<number>` in the commit message -- This automatically closes the issue when the commit is merged - -## Tools -- GitHub CLI for issues/PRs -- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui -- TUI interaction: use tmux - -## Style -- Keep answers short and concise -- No emojis in commits, issues, PR comments, or code -- No fluff or cheerful filler text -- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") - -## Changelog -Location: `packages/*/CHANGELOG.md` (each package has its own) - -### Format -Use these sections under `## [Unreleased]`: -- `### Breaking Changes` - API changes requiring migration -- `### Added` - New features -- `### Changed` - Changes to existing functionality -- `### Fixed` - Bug fixes -- `### Removed` - Removed features - -### Rules -- New entries ALWAYS go under `## [Unreleased]` section -- NEVER modify already-released version sections (e.g., `## [0.12.2]`) -- Each version section is immutable once released - -### Attribution -- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))` -- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))` - -## Releasing - -1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md - -2. **Run release script**: - ```bash - npm run release:patch # Bug fixes - npm run release:minor # New features - npm run release:major # Breaking changes - ``` - -The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. - -### Tool Usage -**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). - - -Current date and time: Sunday, December 28, 2025 at 07:08:12 PM GMT+1 -Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
-
-
-
Available Tools
-
-
read - Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.
bash - Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.
edit - Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.
write - Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.
-
-
- - -
-
07:08:12 PM

Hello

-
07:08:12 PM

Hello! I'm Claude Code, ready to help with coding tasks in this pi-mono project.

-

Since you haven't given me a specific task yet, let me first read the main README to understand the project structure:

-
read ~/workspaces/pi-mono/README.md
07:08:16 PM

Great! I can see this is a monorepo for building AI agents and managing LLM deployments with several packages.

-

Which module(s) would you like to work on?

-
    -
  • @mariozechner/pi-ai - Unified multi-provider LLM API
  • -
  • @mariozechner/pi-agent-core - Agent runtime with tool calling
  • -
  • @mariozechner/pi-coding-agent - Interactive coding agent CLI (current directory)
  • -
  • @mariozechner/pi-mom - Slack bot integration
  • -
  • @mariozechner/pi-tui - Terminal UI library
  • -
  • @mariozechner/pi-web-ui - Web components for chat interfaces
  • -
  • @mariozechner/pi-pods - vLLM deployment CLI
  • -
-
-
- - -
- - \ No newline at end of file diff --git a/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html b/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html deleted file mode 100644 index 40978803..00000000 --- a/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html +++ /dev/null @@ -1,495 +0,0 @@ - - - - - - Session Export - 2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.jsonl - - - -
-
-

pi v0.30.2

-
-
Session:7225b2a7-7612-440d-84f1-fbcd8037d2f2
-
Date:12/28/2025, 8:05:42 PM
-
Models:unknown
-
-
- -
-

Messages

-
-
User:1
-
Assistant:2
-
Tool Calls:1
-
-
- -
-

Tokens & Cost

-
-
Input:9 tokens
-
Output:269 tokens
-
Cache Read:2,462 tokens
-
Cache Write:3,470 tokens
-
Total:6,210 tokens
-
Input Cost:$0.0000
-
Output Cost:$0.0040
-
Cache Read Cost:$0.0007
-
Cache Write Cost:$0.0130
-
Total Cost:$0.0178
-
Context Usage:3,648 / 200,000 tokens (1.8%) - anthropic/claude-sonnet-4-5
-
-
- -
-
System Prompt
-
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. - -Available tools: -- read: Read file contents -- bash: Execute bash commands (ls, grep, find, etc.) -- edit: Make surgical edits to files (find exact text and replace) -- write: Create or overwrite files - -Guidelines: -- Use bash for file operations like ls, grep, find -- Use read to examine files before editing. You must use this tool instead of cat or sed. -- Use edit for precise changes (old text must match exactly) -- Use write only for new files or complete rewrites -- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did -- Be concise in your responses -- Show file paths clearly when working with files - -Documentation: -- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md -- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs -- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md) - -# Project Context - -The following project context files have been loaded: - -## /Users/badlogic/workspaces/pi-mono/AGENTS.md - -# Development Rules - -## First Message -If the user did not give you a concrete task in their first message, -read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel. -- packages/ai/README.md -- packages/tui/README.md -- packages/agent/README.md -- packages/coding-agent/README.md -- packages/mom/README.md -- packages/pods/README.md -- packages/web-ui/README.md - -## Code Quality -- No `any` types unless absolutely necessary -- Check node_modules for external API type definitions instead of guessing -- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. -- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead -- Always ask before removing functionality or code that appears to be intentional - -## Commands -- After code changes: `npm run check` (get full output, no tail) -- NEVER run: `npm run dev`, `npm run build`, `npm test` -- Only run specific tests if user instructs: `npm test -- test/specific.test.ts` -- NEVER commit unless user asks - -## GitHub Issues -When reading issues: -- Always read all comments on the issue - -When creating issues: -- Add `pkg:*` labels to indicate which package(s) the issue affects - - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui` -- If an issue spans multiple packages, add all relevant labels - -When closing issues via commit: -- Include `fixes #<number>` or `closes #<number>` in the commit message -- This automatically closes the issue when the commit is merged - -## Tools -- GitHub CLI for issues/PRs -- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui -- TUI interaction: use tmux - -## Style -- Keep answers short and concise -- No emojis in commits, issues, PR comments, or code -- No fluff or cheerful filler text -- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!") - -## Changelog -Location: `packages/*/CHANGELOG.md` (each package has its own) - -### Format -Use these sections under `## [Unreleased]`: -- `### Breaking Changes` - API changes requiring migration -- `### Added` - New features -- `### Changed` - Changes to existing functionality -- `### Fixed` - Bug fixes -- `### Removed` - Removed features - -### Rules -- New entries ALWAYS go under `## [Unreleased]` section -- NEVER modify already-released version sections (e.g., `## [0.12.2]`) -- Each version section is immutable once released - -### Attribution -- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))` -- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))` - -## Releasing - -1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md - -2. **Run release script**: - ```bash - npm run release:patch # Bug fixes - npm run release:minor # New features - npm run release:major # Breaking changes - ``` - -The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. - -### Tool Usage -**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). - - -Current date and time: Sunday, December 28, 2025 at 08:05:42 PM GMT+1 -Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
-
-
-
Available Tools
-
-
read - Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.
bash - Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.
edit - Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.
write - Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.
-
-
- - -
-
08:05:42 PM

Hello

-
08:05:42 PM

Hello! I see you haven't given me a specific task yet. Let me start by reading the main README to understand the project structure.

-
read ~/workspaces/pi-mono/README.md
08:05:45 PM

Great! I can see this is a monorepo with several packages for building AI agents and managing LLM deployments. The main packages are:

-
    -
  • pi-ai - Unified multi-provider LLM API
  • -
  • pi-agent-core - Agent runtime with tool calling and state management
  • -
  • pi-coding-agent - Interactive coding agent CLI (the package we're currently in)
  • -
  • pi-mom - Slack bot that delegates to the coding agent
  • -
  • pi-tui - Terminal UI library
  • -
  • pi-web-ui - Web components for AI chat interfaces
  • -
  • pi-pods - CLI for managing vLLM deployments on GPU pods
  • -
-

Which package(s) would you like to work on today?

-
-
- - -
- - \ No newline at end of file From 4958271dd30faebe58dab2f98be4637aa7eea1bc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 02:29:35 +0100 Subject: [PATCH 099/161] feat(coding-agent): implement /tree command for session tree navigation - Add TreeSelectorComponent with ASCII tree visualization - Add AgentSession.navigateTree() for switching branches - Add session_before_tree/session_tree hook events - Add SessionManager.resetLeaf() for navigating to root - Change leafId from string to string|null for consistency with parentId - Support optional branch summarization when switching - Update buildSessionContext() to handle null leafId - Add /tree to slash commands in interactive mode --- packages/ai/src/models.generated.ts | 114 ++--- packages/coding-agent/docs/tree.md | 452 ++++-------------- .../coding-agent/src/core/agent-session.ts | 259 +++++++++- .../src/core/custom-tools/types.ts | 2 +- .../coding-agent/src/core/hooks/runner.ts | 19 +- packages/coding-agent/src/core/hooks/types.ts | 60 ++- .../coding-agent/src/core/session-manager.ts | 51 +- .../interactive/components/tree-selector.ts | 315 ++++++++++++ .../src/modes/interactive/interactive-mode.ts | 64 +++ 9 files changed, 893 insertions(+), 443 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/tree-selector.ts diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 2df269d3..6392308c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,23 +6359,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6410,6 +6393,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,23 +6546,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6597,6 +6580,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/docs/tree.md b/packages/coding-agent/docs/tree.md index d44e5d56..768a1cc0 100644 --- a/packages/coding-agent/docs/tree.md +++ b/packages/coding-agent/docs/tree.md @@ -1,445 +1,182 @@ -# Branch Summary +# Session Tree Navigation -This document describes the `/tree` command and branch summarization feature. +The `/tree` command provides tree-based navigation of the session history. ## Overview -The `/tree` command provides tree-based navigation of the session history, allowing users to: -1. View the entire session tree structure -2. Switch to any branch point -3. Optionally summarize the branch being abandoned +Sessions are stored as trees where each entry has an `id` and `parentId`. The "leaf" pointer tracks the current position. `/tree` lets you navigate to any point and optionally summarize the branch you're leaving. -This differs from `/branch` which extracts a linear path to a new session file. +### Comparison with `/branch` -## Commands - -### `/branch` (existing) -- Shows a flat list of user messages -- Extracts selected path to a **new session file** -- Selected user message text goes to editor for re-submission -- Fires `session_before_branch` / `session_branch` events - -### `/tree` (new) -- Shows the **full session tree** with visual hierarchy -- Navigates within the **same session file** (changes active leaf) -- Optionally summarizes the abandoned branch -- Fires `session_before_tree` / `session_tree` events +| Feature | `/branch` | `/tree` | +|---------|-----------|---------| +| View | Flat list of user messages | Full tree structure | +| Action | Extracts path to **new session file** | Changes leaf in **same session** | +| Summary | Never | Optional (user prompted) | +| Events | `session_before_branch` / `session_branch` | `session_before_tree` / `session_tree` | ## Tree UI -The tree selector displays the session structure with ASCII art: - ``` ├─ user: "Hello, can you help..." │ └─ assistant: "Of course! I can..." │ ├─ user: "Let's try approach A..." │ │ └─ assistant: "For approach A..." │ │ └─ [compaction: 12k tokens] -│ │ └─ user: "That worked, now..." -│ │ └─ assistant: "Great! Next..." ← active +│ │ └─ user: "That worked..." ← active │ └─ user: "Actually, approach B..." │ └─ assistant: "For approach B..." ``` -### Visual Indicators - -| Element | Display | -|---------|---------| -| Current active leaf | `← active` suffix, highlighted | -| User messages | Normal color (selectable) | -| Custom messages (display: true) | Normal color (selectable) | -| Assistant/tool results | Dimmed (selectable, for context continuation) | -| Compaction nodes | `[compaction: Xk tokens]` | -| Branch points | Node with multiple children visible | - -### Navigation +### Controls | Key | Action | |-----|--------| -| ↑/↓ | Move through nodes (depth-first pre-order) | -| Enter | Select node and proceed | -| Escape | Cancel | -| Ctrl+C | Cancel | -| Ctrl+U | Toggle: show only user messages | -| Ctrl+O | Toggle: show all entries (including custom/label) | +| ↑/↓ | Navigate (depth-first order) | +| Enter | Select node | +| Escape/Ctrl+C | Cancel | +| Ctrl+U | Toggle: user messages only | +| Ctrl+O | Toggle: show all (including custom/label entries) | -### Filtering +### Display -Default view hides: -- `label` entries (labels shown inline on their target node) -- `custom` entries (hook state, not relevant for navigation) - -Ctrl+O shows everything for debugging/inspection. - -### Component Size - -Height is capped at **half terminal height** to show substantial tree context without overshooting the terminal. +- Height: half terminal height +- Current leaf marked with `← active` +- Labels shown inline: `[label-name]` +- Default filter hides `label` and `custom` entries (shown in Ctrl+O mode) +- Children sorted by timestamp (oldest first) ## Selection Behavior -### Selecting Current Active Leaf +### User Message or Custom Message +1. Leaf set to **parent** of selected node (or `null` if root) +2. Message text placed in **editor** for re-submission +3. User edits and submits, creating a new branch -No-op. Display message: "Already at this point." +### Non-User Message (assistant, compaction, etc.) +1. Leaf set to **selected node** +2. Editor stays empty +3. User continues from that point -### Switching to Different Node - -### User Message or Custom Message Selected -1. Active leaf is set to **parent** of selected node -2. Selected message text is placed in the **editor** for re-submission -3. User edits and submits, creating a new branch from that point - -### Non-User Message Selected (assistant, tool result, etc.) -1. Active leaf is set to the **selected node itself** -2. Editor remains empty -3. User continues the conversation from that point +### Selecting Root User Message +If user selects the very first message (has no parent): +1. Leaf reset to `null` (empty conversation) +2. Message text placed in editor +3. User effectively restarts from scratch ## Branch Summarization -When switching branches, the user is prompted: "Summarize the branch you're leaving?" +When switching, user is prompted: "Summarize the branch you're leaving?" ### What Gets Summarized -The abandoned branch is the path from the **old active leaf** back to the **common ancestor** of the old leaf and newly selected node. +Path from old leaf back to common ancestor with target: ``` -A → B → C → D → E → F ← old active leaf - ↘ G → H ← user selects H +A → B → C → D → E → F ← old leaf + ↘ G → H ← target ``` -- Common ancestor: C -- Abandoned path: D → E → F -- These nodes are summarized +Abandoned path: D → E → F (summarized) -### Stopping Conditions - -When walking back from the old leaf to gather content for summarization: - -1. **Stop at common ancestor** (always) -2. **Stop at compaction node** (if encountered before common ancestor) - - Compaction already summarizes older content - - Only summarize "fresh" content after the compaction +Summarization stops at: +1. Common ancestor (always) +2. Compaction node (if encountered first) ### Summary Storage -The summary is stored as a `BranchSummaryEntry`: +Stored as `BranchSummaryEntry`: ```typescript interface BranchSummaryEntry { type: "branch_summary"; id: string; - parentId: string; // Points to common ancestor + parentId: string; // New leaf position timestamp: string; - fromId: string; // The old leaf we abandoned + fromId: string; // Old leaf we abandoned summary: string; // LLM-generated summary details?: unknown; // Optional hook data } ``` -The summary entry becomes a sibling of the path we're switching to, preserving the record of what was abandoned. +## Implementation -### Summary Generation - -The summarizer: -1. Collects messages from old leaf back to stopping point -2. Sends to LLM with prompt: "Summarize this conversation branch concisely" -3. Creates `BranchSummaryEntry` with the result - -User can skip summarization, in which case no `BranchSummaryEntry` is created. - -## Example Flow - -``` -Initial state: -A → B → C → D ← active - -User runs /tree, selects B: - -1. Show tree: - ├─ A (user): "Start task..." - │ └─ B (assistant): "I'll help..." - │ └─ C (user): "Do X..." - │ └─ D (assistant): "Done X..." ← active - -2. User navigates to B, presses Enter - -3. Prompt: "Summarize branch you're leaving? [Y/n]" - -4a. If Yes: - - Summarize C → D - - Create BranchSummaryEntry(fromId: D, summary: "...") - - Set active leaf to B - - Tree becomes: - A → B → C → D - ↓ ↘ [summary: "Tried X..."] - └─ (active, user continues from here) - -4b. If No: - - Set active leaf to B - - No summary entry created - -5. Since B is assistant message: - - Editor stays empty - - User types new message, branches from B -``` - -## Implementation Notes - -### SessionManager Methods (already exist) - -- `getTree()` - Get full tree structure for display (needs: sort children by timestamp) -- `getPath(id)` - Get path from root to any node -- `getEntry(id)` - Look up individual entries -- `getLeafUuid()` - Get current active leaf -- `branch(id)` - Change active leaf -- `branchWithSummary(fromId, summary)` - Create branch summary entry -- `buildSessionContext()` - Get messages for LLM from current leaf - -### AgentSession: New `navigateTree()` Method +### AgentSession.navigateTree() ```typescript -interface NavigateTreeOptions { - /** Whether user wants to summarize abandoned branch */ - summarize?: boolean; - /** Custom instructions for summarizer */ - customInstructions?: string; -} - -interface NavigateTreeResult { - /** Text to put in editor (if user message selected) */ - editorText?: string; - /** Whether navigation was cancelled */ - cancelled: boolean; -} - -async navigateTree(targetId: string, options?: NavigateTreeOptions): Promise +async navigateTree( + targetId: string, + options?: { summarize?: boolean; customInstructions?: string } +): Promise<{ editorText?: string; cancelled: boolean }> ``` -Implementation flow: +Flow: +1. Validate target, check no-op (target === current leaf) +2. Find common ancestor between old leaf and target +3. Collect entries to summarize (if requested) +4. Fire `session_before_tree` event (hook can cancel or provide summary) +5. Run default summarizer if needed +6. Switch leaf via `branch()` or `branchWithSummary()` +7. Update agent: `agent.replaceMessages(sessionManager.buildSessionContext().messages)` +8. Fire `session_tree` event +9. Notify custom tools via session event +10. Return result with `editorText` if user message was selected -1. **Validate target exists** -2. **Check if no-op** (target === current leaf) → return early -3. **Prepare summarization** (if `options.summarize`): - - Find common ancestor - - Collect entries to summarize (old leaf → common ancestor, stop at compaction) -4. **Fire `session_before_tree` event**: - - Pass preparation, model, signal - - If hook returns `cancel: true` → return `{ cancelled: true }` - - If hook returns custom summary → use it, skip default summarizer -5. **Run default summarizer** (if needed): - - Use conversation model - - On failure/abort → return `{ cancelled: true }` -6. **Switch leaf**: - - If summarizing: `sessionManager.branchWithSummary(targetId, summary)` - - Otherwise: `sessionManager.branch(targetId)` -7. **Update agent state**: - ```typescript - const context = this.sessionManager.buildSessionContext(); - this.agent.replaceMessages(context.messages); - ``` -8. **Fire `session_tree` event** -9. **Notify custom tools** via `_emitToolSessionEvent("tree", ...)` -10. **Return result**: - - If target was user message: `{ editorText: messageText, cancelled: false }` - - Otherwise: `{ cancelled: false }` +### SessionManager -### InteractiveMode: `/tree` Command Handler +- `getLeafUuid(): string | null` - Current leaf (null if empty) +- `resetLeaf(): void` - Set leaf to null (for root user message navigation) +- `getTree(): SessionTreeNode[]` - Full tree with children sorted by timestamp +- `branch(id)` - Change leaf pointer +- `branchWithSummary(id, summary)` - Change leaf and create summary entry -```typescript -if (text === "/tree") { - this.showTreeSelector(); - this.editor.setText(""); - return; -} -``` +### InteractiveMode -`showTreeSelector()` flow: - -1. Get tree via `sessionManager.getTree()` -2. Show `TreeSelectorComponent` (new component) -3. On selection: - - If target === current leaf → show "Already at this point", done - - Prompt: "Summarize branch you're leaving? [Y/n]" - - Call `session.navigateTree(targetId, { summarize })` - - If cancelled → done - - Clear chat: `this.chatContainer.clear()` - - Re-render: `this.renderInitialMessages()` - - If `result.editorText` → `this.editor.setText(result.editorText)` - - Show status: "Switched to entry X" - -### TUI Update Flow - -After `navigateTree()` completes successfully: - -```typescript -// In InteractiveMode, after navigateTree returns -if (!result.cancelled) { - this.chatContainer.clear(); - this.renderInitialMessages(); // Uses sessionManager.buildSessionContext() - if (result.editorText) { - this.editor.setText(result.editorText); - } - this.showStatus("Navigated to selected point"); -} -``` - -This matches the existing pattern in `handleResumeSession()` and `handleClearCommand()`. - -### Finding Common Ancestor - -```typescript -function findCommonAncestor(nodeA: string, nodeB: string): string { - const pathA = new Set(sessionManager.getPath(nodeA).map(e => e.id)); - for (const entry of sessionManager.getPath(nodeB)) { - if (pathA.has(entry.id)) { - return entry.id; - } - } - throw new Error("No common ancestor found"); -} -``` - -### Collecting Abandoned Branch - -```typescript -function collectAbandonedBranch(oldLeaf: string, commonAncestor: string): SessionEntry[] { - const entries: SessionEntry[] = []; - let current = oldLeaf; - - while (current !== commonAncestor) { - const entry = sessionManager.getEntry(current); - if (!entry) break; - - // Stop at compaction - older content already summarized - if (entry.type === "compaction") break; - - entries.push(entry); - current = entry.parentId; - } - - return entries.reverse(); // Chronological order -} -``` - -### Tree Child Ordering - -`getTree()` should sort children by timestamp (oldest first, newest at bottom): - -```typescript -// In getTree(), after building tree: -function sortChildren(node: SessionTreeNode): void { - node.children.sort((a, b) => - new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() - ); - node.children.forEach(sortChildren); -} -roots.forEach(sortChildren); -``` - -### Error Handling - -**Summarization fails** (API error, timeout, etc.): -- Cancel the entire switch -- Show error message -- User stays at current position - -**User aborts during summarization** (Escape): -- Cancel the entire switch -- Show "Navigation cancelled" -- User stays at current position - -**Hook returns `cancel: true`**: -- Cancel the switch -- No error message (hook may have shown its own UI) -- User stays at current position - -### TreeSelectorComponent - -New TUI component at `src/modes/interactive/components/tree-selector.ts`: - -```typescript -interface TreeSelectorProps { - tree: SessionTreeNode[]; - currentLeafId: string; - onSelect: (entryId: string) => void; - onCancel: () => void; -} -``` - -Features: -- Height: half terminal height (capped) -- ASCII tree rendering with `├─`, `│`, `└─` connectors -- Depth-first traversal for up/down navigation -- Visual indicators: - - `← active` for current leaf - - Resolved labels shown inline - - Compaction nodes as `[compaction: Xk tokens]` -- Filter modes: - - Default: hide `label` and `custom` entries - - Ctrl+U: user messages only - - Ctrl+O: show all entries -- Scrolling with selected node kept visible +`/tree` command shows `TreeSelectorComponent`, then: +1. Prompt for summarization +2. Call `session.navigateTree()` +3. Clear and re-render chat +4. Set editor text if applicable ## Hook Events -These events are separate from `session_before_branch`/`session_branch` which are used by the existing `/branch` command (creates new session file). - ### `session_before_tree` -Fired before switching branches within the same session file. Hooks can cancel or provide custom summary. - ```typescript interface TreePreparation { - /** Node being switched to */ targetId: string; - /** Current active leaf (being abandoned) */ - oldLeafId: string; - /** Common ancestor of target and old leaf */ - commonAncestorId: string; - /** Entries to summarize (old leaf back to common ancestor or compaction) */ + oldLeafId: string | null; + commonAncestorId: string | null; entriesToSummarize: SessionEntry[]; - /** Whether user chose to summarize */ userWantsSummary: boolean; } interface SessionBeforeTreeEvent { type: "session_before_tree"; preparation: TreePreparation; - /** Model to use for summarization (conversation model) */ model: Model; - /** Abort signal - honors Escape during summarization */ signal: AbortSignal; } interface SessionBeforeTreeResult { - /** Cancel the navigation entirely */ cancel?: boolean; - /** Custom summary (skips default summarizer). Only used if userWantsSummary is true. */ - summary?: { - summary: string; - details?: unknown; - }; + summary?: { summary: string; details?: unknown }; } ``` ### `session_tree` -Fired after navigation completes successfully. Not fired if cancelled. - ```typescript interface SessionTreeEvent { type: "session_tree"; - /** The new active leaf */ - newLeafId: string; - /** Previous active leaf */ - oldLeafId: string; - /** Branch summary entry if one was created, undefined if user skipped summarization */ + newLeafId: string | null; + oldLeafId: string | null; summaryEntry?: BranchSummaryEntry; - /** Whether summary came from hook (false if default summarizer used, undefined if no summary) */ fromHook?: boolean; } ``` -### Example: Custom Branch Summarizer +### Example: Custom Summarizer ```typescript export default function(pi: HookAPI) { @@ -447,23 +184,14 @@ export default function(pi: HookAPI) { if (!event.preparation.userWantsSummary) return; if (event.preparation.entriesToSummarize.length === 0) return; - // Use a different model for summarization - const model = getModel("google", "gemini-2.5-flash"); - const apiKey = await ctx.modelRegistry.getApiKey(model); - - // Custom summarization logic - const summary = await summarizeWithCustomPrompt( - event.preparation.entriesToSummarize, - model, - apiKey - ); - - return { - summary: { - summary, - details: { model: model.id, timestamp: Date.now() } - } - }; + const summary = await myCustomSummarizer(event.preparation.entriesToSummarize); + return { summary: { summary, details: { custom: true } } }; }); } ``` + +## Error Handling + +- Summarization failure: cancels navigation, shows error +- User abort (Escape): cancels navigation +- Hook returns `cancel: true`: cancels navigation silently diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 11a9befd..ac5a86b4 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -34,12 +34,14 @@ import type { SessionBeforeCompactResult, SessionBeforeNewResult, SessionBeforeSwitchResult, + SessionBeforeTreeResult, + TreePreparation, TurnEndEvent, TurnStartEvent, } from "./hooks/index.js"; import type { BashExecutionMessage, HookMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; -import type { CompactionEntry, SessionManager } from "./session-manager.js"; +import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; @@ -1554,6 +1556,261 @@ export class AgentSession { return { selectedText, cancelled: false }; } + // ========================================================================= + // Tree Navigation + // ========================================================================= + + /** + * Navigate to a different node in the session tree. + * Unlike branch() which creates a new session file, this stays in the same file. + * + * @param targetId The entry ID to navigate to + * @param options.summarize Whether user wants to summarize abandoned branch + * @param options.customInstructions Custom instructions for summarizer + * @returns Result with editorText (if user message) and cancelled status + */ + async navigateTree( + targetId: string, + options: { summarize?: boolean; customInstructions?: string } = {}, + ): Promise<{ editorText?: string; cancelled: boolean }> { + const oldLeafId = this.sessionManager.getLeafUuid(); + + // No-op if already at target + if (targetId === oldLeafId) { + return { cancelled: false }; + } + + // Model required for summarization + if (options.summarize && !this.model) { + throw new Error("No model available for summarization"); + } + + const targetEntry = this.sessionManager.getEntry(targetId); + if (!targetEntry) { + throw new Error(`Entry ${targetId} not found`); + } + + // Find common ancestor (if oldLeafId is null, there's no old path) + const oldPath = oldLeafId ? new Set(this.sessionManager.getPath(oldLeafId).map((e) => e.id)) : new Set(); + const targetPath = this.sessionManager.getPath(targetId); + let commonAncestorId: string | null = null; + for (const entry of targetPath) { + if (oldPath.has(entry.id)) { + commonAncestorId = entry.id; + break; + } + } + + // Collect entries to summarize (old leaf back to common ancestor, stop at compaction) + const entriesToSummarize: SessionEntry[] = []; + if (options.summarize && oldLeafId) { + let current: string | null = oldLeafId; + while (current && current !== commonAncestorId) { + const entry = this.sessionManager.getEntry(current); + if (!entry) break; + if (entry.type === "compaction") break; + entriesToSummarize.push(entry); + current = entry.parentId; + } + entriesToSummarize.reverse(); // Chronological order + } + + // Prepare event data + const preparation: TreePreparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize, + userWantsSummary: options.summarize ?? false, + }; + + // Set up abort controller for summarization + const abortController = new AbortController(); + let hookSummary: { summary: string; details?: unknown } | undefined; + let fromHook = false; + + // Emit session_before_tree event + if (this._hookRunner?.hasHandlers("session_before_tree")) { + const result = (await this._hookRunner.emit({ + type: "session_before_tree", + preparation, + model: this.model!, // Checked above if summarize is true + signal: abortController.signal, + })) as SessionBeforeTreeResult | undefined; + + if (result?.cancel) { + return { cancelled: true }; + } + + if (result?.summary && options.summarize) { + hookSummary = result.summary; + fromHook = true; + } + } + + // Run default summarizer if needed + let summaryText: string | undefined; + if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { + try { + summaryText = await this._generateBranchSummary( + entriesToSummarize, + options.customInstructions, + abortController.signal, + ); + } catch { + // Summarization failed - cancel navigation + return { cancelled: true }; + } + } else if (hookSummary) { + summaryText = hookSummary.summary; + } + + // Determine the new leaf position based on target type + let newLeafId: string | null; + let editorText: string | undefined; + + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + // User message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = this._extractUserMessageText(targetEntry.message.content); + } else if (targetEntry.type === "custom_message") { + // Custom message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else { + // Non-user message: leaf = selected node + newLeafId = targetId; + } + + // Switch leaf (with or without summary) + let summaryEntry: BranchSummaryEntry | undefined; + if (newLeafId === null) { + // Navigating to root user message - reset leaf to empty + this.sessionManager.resetLeaf(); + } else if (summaryText) { + const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText); + summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; + } else { + this.sessionManager.branch(newLeafId); + } + + // Update agent state + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.replaceMessages(sessionContext.messages); + + // Emit session_tree event + if (this._hookRunner) { + await this._hookRunner.emit({ + type: "session_tree", + newLeafId: this.sessionManager.getLeafUuid(), + oldLeafId, + summaryEntry, + fromHook: summaryText ? fromHook : undefined, + }); + } + + // Emit to custom tools + await this._emitToolSessionEvent("tree", this.sessionFile); + + return { editorText, cancelled: false }; + } + + /** + * Generate a summary of abandoned branch entries. + */ + private async _generateBranchSummary( + entries: SessionEntry[], + customInstructions: string | undefined, + signal: AbortSignal, + ): Promise { + // Convert entries to messages for summarization + const messages: Array<{ role: string; content: string }> = []; + for (const entry of entries) { + if (entry.type === "message") { + const text = this._extractMessageText(entry.message); + if (text) { + messages.push({ role: entry.message.role, content: text }); + } + } else if (entry.type === "custom_message") { + const text = + typeof entry.content === "string" + ? entry.content + : entry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (text) { + messages.push({ role: "user", content: text }); + } + } else if (entry.type === "branch_summary") { + messages.push({ role: "system", content: `[Previous branch summary: ${entry.summary}]` }); + } + } + + if (messages.length === 0) { + return "No content to summarize"; + } + + // Build prompt for summarization + const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"); + const instructions = customInstructions + ? `${customInstructions}\n\n` + : "Summarize this conversation branch concisely, capturing key decisions, actions taken, and outcomes.\n\n"; + + const prompt = `${instructions}Conversation:\n${conversationText}`; + + // Get API key for current model (model is checked in navigateTree before calling this) + const model = this.model!; + const apiKey = await this._modelRegistry.getApiKey(model); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}`); + } + + // Call LLM for summarization + const { complete } = await import("@mariozechner/pi-ai"); + const response = await complete( + model, + { + messages: [ + { + role: "user", + content: [{ type: "text", text: prompt }], + timestamp: Date.now(), + }, + ], + }, + { apiKey, signal, maxTokens: 1024 }, + ); + + const summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return summary || "No summary generated"; + } + + /** + * Extract text content from any message type. + */ + private _extractMessageText(message: any): string { + if (!message.content) return ""; + if (typeof message.content === "string") return message.content; + if (Array.isArray(message.content)) { + return message.content + .filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join(""); + } + return ""; + } + /** * Get all user messages from session for branch selector. */ diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index a9a69b99..28192b05 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -43,7 +43,7 @@ export interface SessionEvent { /** Previous session file path, or undefined for "start" and "new" */ previousSessionFile: string | undefined; /** Reason for the session event */ - reason: "start" | "switch" | "branch" | "new"; + reason: "start" | "switch" | "branch" | "new" | "tree"; } /** Rendering options passed to renderResult */ diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index eee9090a..fb908cf8 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -18,6 +18,7 @@ import type { HookUIContext, RegisteredCommand, SessionBeforeCompactResult, + SessionBeforeTreeResult, ToolCallEvent, ToolCallEventResult, ToolResultEventResult, @@ -231,12 +232,18 @@ export class HookRunner { */ private isSessionBeforeEvent( type: string, - ): type is "session_before_switch" | "session_before_new" | "session_before_branch" | "session_before_compact" { + ): type is + | "session_before_switch" + | "session_before_new" + | "session_before_branch" + | "session_before_compact" + | "session_before_tree" { return ( type === "session_before_switch" || type === "session_before_new" || type === "session_before_branch" || - type === "session_before_compact" + type === "session_before_compact" || + type === "session_before_tree" ); } @@ -244,9 +251,11 @@ export class HookRunner { * Emit an event to all hooks. * Returns the result from session before_* / tool_result events (if any handler returns one). */ - async emit(event: HookEvent): Promise { + async emit( + event: HookEvent, + ): Promise { const ctx = this.createContext(); - let result: SessionBeforeCompactResult | ToolResultEventResult | undefined; + let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined; for (const hook of this.hooks) { const handlers = hook.handlers.get(event.type); @@ -267,7 +276,7 @@ export class HookRunner { // For session before_* events, capture the result (for cancellation) if (this.isSessionBeforeEvent(event.type) && handlerResult) { - result = handlerResult as SessionBeforeCompactResult; + result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult; // If cancelled, stop processing further hooks if (result.cancel) { return result; diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 950464d6..847a9134 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -13,7 +13,7 @@ import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { CompactionEntry, SessionManager } from "../session-manager.js"; +import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "../session-manager.js"; /** * Read-only view of SessionManager for hooks. @@ -177,6 +177,44 @@ export interface SessionShutdownEvent { type: "session_shutdown"; } +/** Preparation data for tree navigation (used by session_before_tree event) */ +export interface TreePreparation { + /** Node being switched to */ + targetId: string; + /** Current active leaf (being abandoned), null if no current position */ + oldLeafId: string | null; + /** Common ancestor of target and old leaf, null if no common ancestor */ + commonAncestorId: string | null; + /** Entries to summarize (old leaf back to common ancestor or compaction) */ + entriesToSummarize: SessionEntry[]; + /** Whether user chose to summarize */ + userWantsSummary: boolean; +} + +/** Fired before navigating to a different node in the session tree (can be cancelled) */ +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + /** Preparation data for the navigation */ + preparation: TreePreparation; + /** Model to use for summarization (conversation model) */ + model: Model; + /** Abort signal - honors Escape during summarization */ + signal: AbortSignal; +} + +/** Fired after navigating to a different node in the session tree */ +export interface SessionTreeEvent { + type: "session_tree"; + /** The new active leaf, null if navigated to before first entry */ + newLeafId: string | null; + /** Previous active leaf, null if there was no position */ + oldLeafId: string | null; + /** Branch summary entry if one was created */ + summaryEntry?: BranchSummaryEntry; + /** Whether summary came from hook */ + fromHook?: boolean; +} + /** Union of all session event types */ export type SessionEvent = | SessionStartEvent @@ -188,7 +226,9 @@ export type SessionEvent = | SessionBranchEvent | SessionBeforeCompactEvent | SessionCompactEvent - | SessionShutdownEvent; + | SessionShutdownEvent + | SessionBeforeTreeEvent + | SessionTreeEvent; /** * Event data for context event. @@ -466,6 +506,20 @@ export interface SessionBeforeCompactResult { compaction?: CompactionResult; } +/** Return type for session_before_tree handlers */ +export interface SessionBeforeTreeResult { + /** If true, cancel the navigation entirely */ + cancel?: boolean; + /** + * Custom summary (skips default summarizer). + * Only used if preparation.userWantsSummary is true. + */ + summary?: { + summary: string; + details?: unknown; + }; +} + // ============================================================================ // Hook API // ============================================================================ @@ -539,6 +593,8 @@ export interface HookAPI { ): void; on(event: "session_compact", handler: HookHandler): void; on(event: "session_shutdown", handler: HookHandler): void; + on(event: "session_before_tree", handler: HookHandler): void; + on(event: "session_tree", handler: HookHandler): void; // Context and agent events on(event: "context", handler: HookHandler): void; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 9a061eb6..ef319371 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -252,7 +252,7 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt */ export function buildSessionContext( entries: SessionEntry[], - leafId?: string, + leafId?: string | null, byId?: Map, ): SessionContext { // Build uuid index if not available @@ -265,11 +265,15 @@ export function buildSessionContext( // Find leaf let leaf: SessionEntry | undefined; + if (leafId === null) { + // Explicitly null - return no messages (navigated to before first entry) + return { messages: [], thinkingLevel: "off", model: null }; + } if (leafId) { leaf = byId.get(leafId); } if (!leaf) { - // Fallback to last entry + // Fallback to last entry (when leafId is undefined) leaf = entries[entries.length - 1]; } @@ -448,7 +452,7 @@ export class SessionManager { private fileEntries: FileEntry[] = []; private byId: Map = new Map(); private labelsById: Map = new Map(); - private leafId: string = ""; + private leafId: string | null = null; private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { this.cwd = cwd; @@ -496,7 +500,7 @@ export class SessionManager { }; this.fileEntries = [header]; this.byId.clear(); - this.leafId = ""; + this.leafId = null; this.flushed = false; // Only generate filename if persisting and not already set (e.g., via --session flag) if (this.persist && !this.sessionFile) { @@ -509,7 +513,7 @@ export class SessionManager { private _buildIndex(): void { this.byId.clear(); this.labelsById.clear(); - this.leafId = ""; + this.leafId = null; for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); @@ -583,7 +587,7 @@ export class SessionManager { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), message, }; @@ -596,7 +600,7 @@ export class SessionManager { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), thinkingLevel, }; @@ -609,7 +613,7 @@ export class SessionManager { const entry: ModelChangeEntry = { type: "model_change", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), provider, modelId, @@ -623,7 +627,7 @@ export class SessionManager { const entry: CompactionEntry = { type: "compaction", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), summary, firstKeptEntryId, @@ -641,7 +645,7 @@ export class SessionManager { customType, data, id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), }; this._appendEntry(entry); @@ -669,7 +673,7 @@ export class SessionManager { display, details, id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), }; this._appendEntry(entry); @@ -680,12 +684,12 @@ export class SessionManager { // Tree Traversal // ========================================================================= - getLeafUuid(): string { + getLeafUuid(): string | null { return this.leafId; } getLeafEntry(): SessionEntry | undefined { - return this.byId.get(this.leafId); + return this.leafId ? this.byId.get(this.leafId) : undefined; } getEntry(id: string): SessionEntry | undefined { @@ -711,7 +715,7 @@ export class SessionManager { const entry: LabelEntry = { type: "label", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), targetId, label, @@ -732,7 +736,8 @@ export class SessionManager { */ getPath(fromId?: string): SessionEntry[] { const path: SessionEntry[] = []; - let current = this.byId.get(fromId ?? this.leafId); + const startId = fromId ?? this.leafId; + let current = startId ? this.byId.get(startId) : undefined; while (current) { path.unshift(current); current = current.parentId ? this.byId.get(current.parentId) : undefined; @@ -797,6 +802,13 @@ export class SessionManager { } } + // Sort children by timestamp (oldest first, newest at bottom) + const sortChildren = (node: SessionTreeNode): void => { + node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()); + node.children.forEach(sortChildren); + }; + roots.forEach(sortChildren); + return roots; } @@ -817,6 +829,15 @@ export class SessionManager { this.leafId = branchFromId; } + /** + * Reset the leaf pointer to null (before any entries). + * The next appendXXX() call will create a new root entry (parentId = null). + * Use this when navigating to re-edit the first user message. + */ + resetLeaf(): void { + this.leafId = null; + } + /** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts new file mode 100644 index 00000000..2c729b72 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -0,0 +1,315 @@ +import { + type Component, + Container, + isArrowDown, + isArrowUp, + isCtrlC, + isCtrlO, + isCtrlU, + isEnter, + isEscape, + Spacer, + Text, + truncateToWidth, +} from "@mariozechner/pi-tui"; +import type { SessionTreeNode } from "../../../core/session-manager.js"; +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +/** Flattened tree node for navigation */ +interface FlatNode { + node: SessionTreeNode; + depth: number; + isLast: boolean; + /** Prefix chars showing tree structure (│, ├, └, spaces) */ + prefix: string; +} + +/** Filter mode for tree display */ +type FilterMode = "default" | "user-only" | "all"; + +/** + * Tree list component with selection and ASCII art visualization + */ +class TreeList implements Component { + private flatNodes: FlatNode[] = []; + private filteredNodes: FlatNode[] = []; + private selectedIndex = 0; + private currentLeafId: string | null; + private maxVisibleLines: number; + private filterMode: FilterMode = "default"; + + public onSelect?: (entryId: string) => void; + public onCancel?: () => void; + + constructor(tree: SessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number) { + this.currentLeafId = currentLeafId; + this.maxVisibleLines = maxVisibleLines; + this.flatNodes = this.flattenTree(tree); + this.applyFilter(); + + // Start with current leaf selected + const leafIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === currentLeafId); + if (leafIndex !== -1) { + this.selectedIndex = leafIndex; + } else { + this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); + } + } + + private flattenTree(roots: SessionTreeNode[]): FlatNode[] { + const result: FlatNode[] = []; + + const traverse = (node: SessionTreeNode, depth: number, prefix: string, isLast: boolean) => { + result.push({ node, depth, isLast, prefix }); + + const children = node.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const childIsLast = i === children.length - 1; + const childPrefix = prefix + (isLast ? " " : "│ "); + traverse(child, depth + 1, childPrefix, childIsLast); + } + }; + + for (let i = 0; i < roots.length; i++) { + traverse(roots[i], 0, "", i === roots.length - 1); + } + + return result; + } + + private applyFilter(): void { + this.filteredNodes = this.flatNodes.filter((flatNode) => { + const entry = flatNode.node.entry; + + if (this.filterMode === "all") { + return true; + } + + if (this.filterMode === "user-only") { + return ( + (entry.type === "message" && entry.message.role === "user") || + (entry.type === "custom_message" && entry.display) + ); + } + + // Default mode: hide label and custom entries + return entry.type !== "label" && entry.type !== "custom"; + }); + + // Adjust selected index if needed + if (this.selectedIndex >= this.filteredNodes.length) { + this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); + } + } + + invalidate(): void { + // No cached state to invalidate + } + + render(width: number): string[] { + const lines: string[] = []; + + if (this.filteredNodes.length === 0) { + lines.push(theme.fg("muted", " No entries found")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(this.maxVisibleLines / 2), + this.filteredNodes.length - this.maxVisibleLines, + ), + ); + const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length); + + for (let i = startIndex; i < endIndex; i++) { + const flatNode = this.filteredNodes[i]; + const entry = flatNode.node.entry; + const isSelected = i === this.selectedIndex; + const isCurrentLeaf = entry.id === this.currentLeafId; + + // Build tree connector + let connector = ""; + if (flatNode.depth > 0) { + connector = flatNode.prefix + (flatNode.isLast ? "└─ " : "├─ "); + } + + // Get entry display text + const displayText = this.getEntryDisplayText(flatNode.node, width - connector.length - 15); + + // Build suffix + let suffix = ""; + if (isCurrentLeaf) { + suffix = theme.fg("accent", " ← active"); + } + if (flatNode.node.label) { + suffix += theme.fg("warning", ` [${flatNode.node.label}]`); + } + + // Combine with selection indicator + const cursor = isSelected ? theme.fg("accent", "› ") : " "; + const text = isSelected ? theme.bold(displayText) : displayText; + const line = cursor + theme.fg("dim", connector) + text + suffix; + + lines.push(line); + } + + // Add scroll and filter info + const filterLabel = + this.filterMode === "default" ? "" : this.filterMode === "user-only" ? " [user only]" : " [all]"; + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${filterLabel}`); + lines.push(scrollInfo); + + return lines; + } + + private getEntryDisplayText(node: SessionTreeNode, maxWidth: number): string { + const entry = node.entry; + + switch (entry.type) { + case "message": { + const msg = entry.message; + const role = msg.role; + // Handle messages that have content property + if (role === "user" || role === "assistant" || role === "toolResult") { + const msgWithContent = msg as { content?: unknown }; + const content = this.extractContent(msgWithContent.content); + const roleColor = role === "user" ? "accent" : role === "assistant" ? "success" : "muted"; + const roleLabel = theme.fg(roleColor, `${role}: `); + const truncated = truncateToWidth(content.replace(/\n/g, " ").trim(), maxWidth - role.length - 2); + return roleLabel + truncated; + } + // Handle special message types + if (role === "bashExecution") { + const bashMsg = msg as { command?: string }; + return theme.fg("dim", `[bash]: ${bashMsg.command ?? ""}`); + } + if (role === "compactionSummary" || role === "branchSummary" || role === "hookMessage") { + return theme.fg("dim", `[${role}]`); + } + return theme.fg("dim", `[${role}]`); + } + case "custom_message": { + const content = + typeof entry.content === "string" + ? entry.content + : entry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + const label = theme.fg("customMessageLabel", `[${entry.customType}]: `); + const truncated = truncateToWidth( + content.replace(/\n/g, " ").trim(), + maxWidth - entry.customType.length - 4, + ); + return label + truncated; + } + case "compaction": { + const tokens = Math.round(entry.tokensBefore / 1000); + return theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`); + } + case "branch_summary": { + const truncated = truncateToWidth(entry.summary.replace(/\n/g, " ").trim(), maxWidth - 20); + return theme.fg("warning", `[branch summary]: `) + truncated; + } + case "model_change": { + return theme.fg("dim", `[model: ${entry.modelId}]`); + } + case "thinking_level_change": { + return theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`); + } + case "custom": { + return theme.fg("dim", `[custom: ${entry.customType}]`); + } + case "label": { + return theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`); + } + } + } + + private extractContent(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((c) => typeof c === "object" && c !== null && "type" in c && c.type === "text") + .map((c) => (c as { text: string }).text) + .join(""); + } + return ""; + } + + handleInput(keyData: string): void { + if (isArrowUp(keyData)) { + this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; + } else if (isArrowDown(keyData)) { + this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; + } else if (isEnter(keyData)) { + const selected = this.filteredNodes[this.selectedIndex]; + if (selected && this.onSelect) { + this.onSelect(selected.node.entry.id); + } + } else if (isEscape(keyData) || isCtrlC(keyData)) { + this.onCancel?.(); + } else if (isCtrlU(keyData)) { + // Toggle user-only filter + this.filterMode = this.filterMode === "user-only" ? "default" : "user-only"; + this.applyFilter(); + } else if (isCtrlO(keyData)) { + // Toggle show-all filter + this.filterMode = this.filterMode === "all" ? "default" : "all"; + this.applyFilter(); + } + } +} + +/** + * Component that renders a session tree selector for navigation + */ +export class TreeSelectorComponent extends Container { + private treeList: TreeList; + + constructor( + tree: SessionTreeNode[], + currentLeafId: string | null, + terminalHeight: number, + onSelect: (entryId: string) => void, + onCancel: () => void, + ) { + super(); + + // Cap at half terminal height + const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2)); + + // Add header + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.bold("Session Tree"), 1, 0)); + this.addChild(new Text(theme.fg("muted", "Navigate to a different point. Ctrl+U: user only, Ctrl+O: all"), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Create tree list + this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines); + this.treeList.onSelect = onSelect; + this.treeList.onCancel = onCancel; + + this.addChild(this.treeList); + + // Add bottom border + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + // Auto-cancel if empty tree + if (tree.length === 0) { + setTimeout(() => onCancel(), 100); + } + } + + getTreeList(): TreeList { + return this.treeList; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4d804832..70185bc0 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -51,6 +51,7 @@ import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { ToolExecutionComponent } from "./components/tool-execution.js"; +import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; @@ -155,6 +156,7 @@ export class InteractiveMode { { name: "changelog", description: "Show changelog entries" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "branch", description: "Create a new branch from a previous message" }, + { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "login", description: "Login with OAuth provider" }, { name: "logout", description: "Logout from OAuth provider" }, { name: "new", description: "Start a new session" }, @@ -679,6 +681,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/tree") { + this.showTreeSelector(); + this.editor.setText(""); + return; + } if (text === "/login") { this.showOAuthSelector("login"); this.editor.setText(""); @@ -1585,6 +1592,63 @@ export class InteractiveMode { }); } + private showTreeSelector(): void { + const tree = this.sessionManager.getTree(); + const currentLeafId = this.sessionManager.getLeafUuid(); + + if (tree.length === 0) { + this.showStatus("No entries in session"); + return; + } + + this.showSelector((done) => { + const selector = new TreeSelectorComponent( + tree, + currentLeafId, + this.ui.terminal.rows, + async (entryId) => { + // Check if selecting current leaf (no-op) + if (entryId === currentLeafId) { + done(); + this.showStatus("Already at this point"); + return; + } + + // Ask about summarization + done(); // Close selector first + + const wantsSummary = await this.showHookConfirm( + "Summarize branch?", + "Create a summary of the branch you're leaving?", + ); + + try { + const result = await this.session.navigateTree(entryId, { summarize: wantsSummary }); + if (result.cancelled) { + this.showStatus("Navigation cancelled"); + return; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + }, + () => { + done(); + this.ui.requestRender(); + }, + ); + return { component: selector, focus: selector.getTreeList() }; + }); + } + private showSessionSelector(): void { this.showSelector((done) => { const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir()); From 1f4594598ba9152ae5906e00f5a3f850982458c4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 02:58:25 +0100 Subject: [PATCH 100/161] feat(coding-agent): add search to /tree selector - Type to filter nodes by matching tokens in content - Search field always visible above tree - Backspace removes chars, Escape clears search - Restructured layout: title, border, help+search, border, tree, border --- .../interactive/components/tree-selector.ts | 167 +++++++++++++++--- 1 file changed, 145 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 2c729b72..64c817a8 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -3,6 +3,7 @@ import { Container, isArrowDown, isArrowUp, + isBackspace, isCtrlC, isCtrlO, isCtrlU, @@ -38,6 +39,7 @@ class TreeList implements Component { private currentLeafId: string | null; private maxVisibleLines: number; private filterMode: FilterMode = "default"; + private searchQuery = ""; public onSelect?: (entryId: string) => void; public onCancel?: () => void; @@ -80,22 +82,32 @@ class TreeList implements Component { } private applyFilter(): void { + // Parse search tokens (lowercase, split by whitespace) + const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean); + this.filteredNodes = this.flatNodes.filter((flatNode) => { const entry = flatNode.node.entry; - if (this.filterMode === "all") { - return true; - } - + // Apply filter mode first + let passesFilter = true; if (this.filterMode === "user-only") { - return ( + passesFilter = (entry.type === "message" && entry.message.role === "user") || - (entry.type === "custom_message" && entry.display) - ); + (entry.type === "custom_message" && entry.display); + } else if (this.filterMode !== "all") { + // Default mode: hide label and custom entries + passesFilter = entry.type !== "label" && entry.type !== "custom"; } - // Default mode: hide label and custom entries - return entry.type !== "label" && entry.type !== "custom"; + if (!passesFilter) return false; + + // Apply search filter + if (searchTokens.length > 0) { + const nodeText = this.getSearchableText(flatNode.node).toLowerCase(); + return searchTokens.every((token) => nodeText.includes(token)); + } + + return true; }); // Adjust selected index if needed @@ -104,15 +116,79 @@ class TreeList implements Component { } } + /** Get searchable text content from a node */ + private getSearchableText(node: SessionTreeNode): string { + const entry = node.entry; + const parts: string[] = []; + + // Add label if present + if (node.label) { + parts.push(node.label); + } + + switch (entry.type) { + case "message": { + const msg = entry.message; + parts.push(msg.role); + if ("content" in msg && msg.content) { + parts.push(this.extractContent(msg.content)); + } + if (msg.role === "bashExecution") { + const bashMsg = msg as { command?: string }; + if (bashMsg.command) parts.push(bashMsg.command); + } + break; + } + case "custom_message": { + parts.push(entry.customType); + if (typeof entry.content === "string") { + parts.push(entry.content); + } else { + parts.push(this.extractContent(entry.content)); + } + break; + } + case "compaction": + parts.push("compaction"); + break; + case "branch_summary": + parts.push("branch summary", entry.summary); + break; + case "model_change": + parts.push("model", entry.modelId); + break; + case "thinking_level_change": + parts.push("thinking", entry.thinkingLevel); + break; + case "custom": + parts.push("custom", entry.customType); + break; + case "label": + parts.push("label", entry.label ?? ""); + break; + } + + return parts.join(" "); + } + invalidate(): void { // No cached state to invalidate } + /** Get current search query for external rendering */ + getSearchQuery(): string { + return this.searchQuery; + } + render(width: number): string[] { const lines: string[] = []; if (this.filteredNodes.length === 0) { lines.push(theme.fg("muted", " No entries found")); + // Still show status with filter info + const filterLabel = + this.filterMode === "default" ? "" : this.filterMode === "user-only" ? " [user only]" : " [all]"; + lines.push(theme.fg("muted", ` (0/0)${filterLabel}`)); return lines; } @@ -158,7 +234,7 @@ class TreeList implements Component { lines.push(line); } - // Add scroll and filter info + // Add scroll and filter info (search shown separately above) const filterLabel = this.filterMode === "default" ? "" : this.filterMode === "user-only" ? " [user only]" : " [all]"; const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${filterLabel}`); @@ -252,7 +328,15 @@ class TreeList implements Component { if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } - } else if (isEscape(keyData) || isCtrlC(keyData)) { + } else if (isEscape(keyData)) { + // Escape: clear search first, then cancel + if (this.searchQuery) { + this.searchQuery = ""; + this.applyFilter(); + } else { + this.onCancel?.(); + } + } else if (isCtrlC(keyData)) { this.onCancel?.(); } else if (isCtrlU(keyData)) { // Toggle user-only filter @@ -262,10 +346,43 @@ class TreeList implements Component { // Toggle show-all filter this.filterMode = this.filterMode === "all" ? "default" : "all"; this.applyFilter(); + } else if (isBackspace(keyData)) { + // Remove last character from search + if (this.searchQuery.length > 0) { + this.searchQuery = this.searchQuery.slice(0, -1); + this.applyFilter(); + } + } else { + // Check for printable characters (reject control chars) + const hasControlChars = [...keyData].some((ch) => { + const code = ch.charCodeAt(0); + return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); + }); + if (!hasControlChars && keyData.length > 0) { + this.searchQuery += keyData; + this.applyFilter(); + } } } } +/** Component that displays the current search query */ +class SearchLine implements Component { + constructor(private treeList: TreeList) {} + + invalidate(): void {} + + render(_width: number): string[] { + const query = this.treeList.getSearchQuery(); + if (query) { + return [` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`]; + } + return [` ${theme.fg("muted", "Search:")}`]; + } + + handleInput(_keyData: string): void {} +} + /** * Component that renders a session tree selector for navigation */ @@ -284,22 +401,28 @@ export class TreeSelectorComponent extends Container { // Cap at half terminal height const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2)); - // Add header - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.bold("Session Tree"), 1, 0)); - this.addChild(new Text(theme.fg("muted", "Navigate to a different point. Ctrl+U: user only, Ctrl+O: all"), 1, 0)); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - - // Create tree list + // Create tree list first (needed by SearchLine) this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines); this.treeList.onSelect = onSelect; this.treeList.onCancel = onCancel; - this.addChild(this.treeList); + // Layout: + // Title + // Border + // Help text + // Search field + // Border + // Tree entries + // Border - // Add bottom border + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.bold("Session Tree"), 1, 0)); + this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.fg("muted", " Type to search. Ctrl+U: user only, Ctrl+O: all"), 0, 0)); + this.addChild(new SearchLine(this.treeList)); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(this.treeList); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); From 544814875e76a261346aaf15b6b4e7bef1e3cd17 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 04:50:15 +0100 Subject: [PATCH 101/161] Add global debug key (Shift+Ctrl+D), iterative tree sorting to avoid stack overflow --- packages/coding-agent/src/core/session-manager.ts | 12 +++++++----- .../src/modes/interactive/interactive-mode.ts | 5 ++++- packages/tui/src/index.ts | 1 + packages/tui/src/keys.ts | 8 ++++++++ packages/tui/src/tui.ts | 10 ++++++++++ 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index ef319371..5d6b6c90 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -789,7 +789,7 @@ export class SessionManager { // Build tree for (const entry of entries) { const node = nodeMap.get(entry.id)!; - if (entry.parentId === null) { + if (entry.parentId === null || entry.parentId === entry.id) { roots.push(node); } else { const parent = nodeMap.get(entry.parentId); @@ -803,11 +803,13 @@ export class SessionManager { } // Sort children by timestamp (oldest first, newest at bottom) - const sortChildren = (node: SessionTreeNode): void => { + // Use iterative approach to avoid stack overflow on deep trees + const stack: SessionTreeNode[] = [...roots]; + while (stack.length > 0) { + const node = stack.pop()!; node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()); - node.children.forEach(sortChildren); - }; - roots.forEach(sortChildren); + stack.push(...node.children); + } return roots; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 70185bc0..b7b4853f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -621,6 +621,9 @@ export class InteractiveMode { this.editor.onShiftTab = () => this.cycleThinkingLevel(); this.editor.onCtrlP = () => this.cycleModel("forward"); this.editor.onShiftCtrlP = () => this.cycleModel("backward"); + + // Global debug handler on TUI (works regardless of focus) + this.ui.onDebug = () => this.handleDebugCommand(); this.editor.onCtrlL = () => this.showModelSelector(); this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); @@ -1645,7 +1648,7 @@ export class InteractiveMode { this.ui.requestRender(); }, ); - return { component: selector, focus: selector.getTreeList() }; + return { component: selector, focus: selector }; }); } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 71f0eca3..2c741eb8 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -50,6 +50,7 @@ export { isEnter, isEscape, isHome, + isShiftCtrlD, isShiftCtrlP, isShiftEnter, isShiftTab, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index edd2c06d..12965fec 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -320,6 +320,14 @@ export function isShiftCtrlP(data: string): boolean { return matchesKittySequence(data, CODEPOINTS.p, MODIFIERS.shift + MODIFIERS.ctrl); } +/** + * Check if input matches Shift+Ctrl+D (Kitty protocol only, for debug). + * Ignores lock key bits. + */ +export function isShiftCtrlD(data: string): boolean { + return matchesKittySequence(data, CODEPOINTS.d, MODIFIERS.shift + MODIFIERS.ctrl); +} + /** * Check if input matches Ctrl+T (raw byte or Kitty protocol). * Ignores lock key bits. diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1baf5e4d..583f099e 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5,6 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { isShiftCtrlD } from "./keys.js"; import type { Terminal } from "./terminal.js"; import { getCapabilities, setCellDimensions } from "./terminal-image.js"; import { visibleWidth } from "./utils.js"; @@ -78,6 +79,9 @@ export class TUI extends Container { private previousLines: string[] = []; private previousWidth = 0; private focusedComponent: Component | null = null; + + /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ + public onDebug?: () => void; private renderRequested = false; private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line) private inputBuffer = ""; // Buffer for parsing terminal responses @@ -141,6 +145,12 @@ export class TUI extends Container { data = filtered; } + // Global debug key handler (Shift+Ctrl+D) + if (isShiftCtrlD(data) && this.onDebug) { + this.onDebug(); + return; + } + // Pass input to focused component (including Ctrl+C) // The focused component can decide how to handle Ctrl+C if (this.focusedComponent?.handleInput) { From 6b7ad0ed4b352b5af58bec358326e31da380b86b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 13:30:34 +0100 Subject: [PATCH 102/161] feat(coding-agent): reimplement tree selector features - Label editing with 'l' key - Ctrl+O cycles through filters: default -> user-only -> labeled-only -> all - Preserve cursor position when toggling filters - Labels shown at front of node - Normalize tabs/newlines in content - extractContent limited to 200 chars for performance - truncateToWidth on all rendered lines - Iterative flattenTree to avoid stack overflow - Linear chain optimization (no indent for single-child chains) --- .../interactive/components/tree-selector.ts | 352 ++++++++++++------ .../src/modes/interactive/interactive-mode.ts | 4 + 2 files changed, 237 insertions(+), 119 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 64c817a8..6fd82439 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -1,16 +1,17 @@ import { type Component, Container, + Input, isArrowDown, isArrowUp, isBackspace, isCtrlC, isCtrlO, - isCtrlU, isEnter, isEscape, Spacer, Text, + TruncatedText, truncateToWidth, } from "@mariozechner/pi-tui"; import type { SessionTreeNode } from "../../../core/session-manager.js"; @@ -22,12 +23,14 @@ interface FlatNode { node: SessionTreeNode; depth: number; isLast: boolean; - /** Prefix chars showing tree structure (│, ├, └, spaces) */ + /** Prefix chars showing tree structure (│ and spaces for gutter) */ prefix: string; + /** Whether to show ├─/└─ connector (true at branch points) */ + showConnector: boolean; } /** Filter mode for tree display */ -type FilterMode = "default" | "user-only" | "all"; +type FilterMode = "default" | "user-only" | "labeled-only" | "all"; /** * Tree list component with selection and ASCII art visualization @@ -43,6 +46,7 @@ class TreeList implements Component { public onSelect?: (entryId: string) => void; public onCancel?: () => void; + public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void; constructor(tree: SessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number) { this.currentLeafId = currentLeafId; @@ -62,27 +66,50 @@ class TreeList implements Component { private flattenTree(roots: SessionTreeNode[]): FlatNode[] { const result: FlatNode[] = []; - const traverse = (node: SessionTreeNode, depth: number, prefix: string, isLast: boolean) => { - result.push({ node, depth, isLast, prefix }); + // Use iterative approach to avoid stack overflow on deep trees + // Stack items: [node, prefix, isLast, showConnector] + const stack: [SessionTreeNode, string, boolean, boolean][] = []; + + // Add roots in reverse order so first root is processed first + const multipleRoots = roots.length > 1; + for (let i = roots.length - 1; i >= 0; i--) { + stack.push([roots[i], "", i === roots.length - 1, multipleRoots]); + } + + while (stack.length > 0) { + const [node, prefix, isLast, showConnector] = stack.pop()!; + + const depth = prefix.length / 3 + (showConnector ? 1 : 0); + result.push({ node, depth, isLast, prefix, showConnector }); const children = node.children; - for (let i = 0; i < children.length; i++) { + const multipleChildren = children.length > 1; + + // Build prefix for children + let childPrefix: string; + if (showConnector) { + childPrefix = prefix + (isLast ? " " : "│ "); + } else if (multipleChildren) { + childPrefix = prefix; + } else { + childPrefix = prefix; + } + + // Add children in reverse order so first child is processed first + for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; const childIsLast = i === children.length - 1; - const childPrefix = prefix + (isLast ? " " : "│ "); - traverse(child, depth + 1, childPrefix, childIsLast); + stack.push([child, childPrefix, childIsLast, multipleChildren]); } - }; - - for (let i = 0; i < roots.length; i++) { - traverse(roots[i], 0, "", i === roots.length - 1); } return result; } private applyFilter(): void { - // Parse search tokens (lowercase, split by whitespace) + // Remember currently selected node to preserve cursor position + const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id; + const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean); this.filteredNodes = this.flatNodes.filter((flatNode) => { @@ -94,6 +121,8 @@ class TreeList implements Component { passesFilter = (entry.type === "message" && entry.message.role === "user") || (entry.type === "custom_message" && entry.display); + } else if (this.filterMode === "labeled-only") { + passesFilter = flatNode.node.label !== undefined; } else if (this.filterMode !== "all") { // Default mode: hide label and custom entries passesFilter = entry.type !== "label" && entry.type !== "custom"; @@ -110,7 +139,16 @@ class TreeList implements Component { return true; }); - // Adjust selected index if needed + // Try to preserve cursor on the same node after filtering + if (previouslySelectedId) { + const newIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === previouslySelectedId); + if (newIndex !== -1) { + this.selectedIndex = newIndex; + return; + } + } + + // Fall back: clamp index if out of bounds if (this.selectedIndex >= this.filteredNodes.length) { this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); } @@ -121,7 +159,6 @@ class TreeList implements Component { const entry = node.entry; const parts: string[] = []; - // Add label if present if (node.label) { parts.push(node.label); } @@ -171,28 +208,47 @@ class TreeList implements Component { return parts.join(" "); } - invalidate(): void { - // No cached state to invalidate - } + invalidate(): void {} - /** Get current search query for external rendering */ getSearchQuery(): string { return this.searchQuery; } + getSelectedNode(): SessionTreeNode | undefined { + return this.filteredNodes[this.selectedIndex]?.node; + } + + updateNodeLabel(entryId: string, label: string | undefined): void { + for (const flatNode of this.flatNodes) { + if (flatNode.node.entry.id === entryId) { + flatNode.node.label = label; + break; + } + } + } + + private getFilterLabel(): string { + switch (this.filterMode) { + case "user-only": + return " [user]"; + case "labeled-only": + return " [labeled]"; + case "all": + return " [all]"; + default: + return ""; + } + } + render(width: number): string[] { const lines: string[] = []; if (this.filteredNodes.length === 0) { - lines.push(theme.fg("muted", " No entries found")); - // Still show status with filter info - const filterLabel = - this.filterMode === "default" ? "" : this.filterMode === "user-only" ? " [user only]" : " [all]"; - lines.push(theme.fg("muted", ` (0/0)${filterLabel}`)); + lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width)); + lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getFilterLabel()}`), width)); return lines; } - // Calculate visible range with scrolling const startIndex = Math.max( 0, Math.min( @@ -208,66 +264,50 @@ class TreeList implements Component { const isSelected = i === this.selectedIndex; const isCurrentLeaf = entry.id === this.currentLeafId; - // Build tree connector - let connector = ""; - if (flatNode.depth > 0) { - connector = flatNode.prefix + (flatNode.isLast ? "└─ " : "├─ "); - } - - // Get entry display text - const displayText = this.getEntryDisplayText(flatNode.node, width - connector.length - 15); - - // Build suffix - let suffix = ""; - if (isCurrentLeaf) { - suffix = theme.fg("accent", " ← active"); - } - if (flatNode.node.label) { - suffix += theme.fg("warning", ` [${flatNode.node.label}]`); - } - - // Combine with selection indicator + // Build line: cursor + gutter + connector + label + content + suffix const cursor = isSelected ? theme.fg("accent", "› ") : " "; - const text = isSelected ? theme.bold(displayText) : displayText; - const line = cursor + theme.fg("dim", connector) + text + suffix; + const gutter = flatNode.prefix ? theme.fg("dim", flatNode.prefix) : ""; + const connector = flatNode.showConnector ? theme.fg("dim", flatNode.isLast ? "└─ " : "├─ ") : ""; + const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; + const content = this.getEntryDisplayText(flatNode.node, isSelected); + const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; - lines.push(line); + const line = cursor + gutter + connector + label + content + suffix; + lines.push(truncateToWidth(line, width)); } - // Add scroll and filter info (search shown separately above) - const filterLabel = - this.filterMode === "default" ? "" : this.filterMode === "user-only" ? " [user only]" : " [all]"; - const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${filterLabel}`); - lines.push(scrollInfo); + lines.push( + truncateToWidth( + theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`), + width, + ), + ); return lines; } - private getEntryDisplayText(node: SessionTreeNode, maxWidth: number): string { + private getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string { const entry = node.entry; + let result: string; + + const normalize = (s: string) => s.replace(/[\n\t]/g, " ").trim(); switch (entry.type) { case "message": { const msg = entry.message; const role = msg.role; - // Handle messages that have content property if (role === "user" || role === "assistant" || role === "toolResult") { const msgWithContent = msg as { content?: unknown }; - const content = this.extractContent(msgWithContent.content); + const content = normalize(this.extractContent(msgWithContent.content)); const roleColor = role === "user" ? "accent" : role === "assistant" ? "success" : "muted"; - const roleLabel = theme.fg(roleColor, `${role}: `); - const truncated = truncateToWidth(content.replace(/\n/g, " ").trim(), maxWidth - role.length - 2); - return roleLabel + truncated; - } - // Handle special message types - if (role === "bashExecution") { + result = theme.fg(roleColor, `${role}: `) + content; + } else if (role === "bashExecution") { const bashMsg = msg as { command?: string }; - return theme.fg("dim", `[bash]: ${bashMsg.command ?? ""}`); + result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`); + } else { + result = theme.fg("dim", `[${role}]`); } - if (role === "compactionSummary" || role === "branchSummary" || role === "hookMessage") { - return theme.fg("dim", `[${role}]`); - } - return theme.fg("dim", `[${role}]`); + break; } case "custom_message": { const content = @@ -277,43 +317,48 @@ class TreeList implements Component { .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join(""); - const label = theme.fg("customMessageLabel", `[${entry.customType}]: `); - const truncated = truncateToWidth( - content.replace(/\n/g, " ").trim(), - maxWidth - entry.customType.length - 4, - ); - return label + truncated; + result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(content); + break; } case "compaction": { const tokens = Math.round(entry.tokensBefore / 1000); - return theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`); - } - case "branch_summary": { - const truncated = truncateToWidth(entry.summary.replace(/\n/g, " ").trim(), maxWidth - 20); - return theme.fg("warning", `[branch summary]: `) + truncated; - } - case "model_change": { - return theme.fg("dim", `[model: ${entry.modelId}]`); - } - case "thinking_level_change": { - return theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`); - } - case "custom": { - return theme.fg("dim", `[custom: ${entry.customType}]`); - } - case "label": { - return theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`); + result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`); + break; } + case "branch_summary": + result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary); + break; + case "model_change": + result = theme.fg("dim", `[model: ${entry.modelId}]`); + break; + case "thinking_level_change": + result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`); + break; + case "custom": + result = theme.fg("dim", `[custom: ${entry.customType}]`); + break; + case "label": + result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`); + break; + default: + result = ""; } + + return isSelected ? theme.bold(result) : result; } private extractContent(content: unknown): string { - if (typeof content === "string") return content; + const maxLen = 200; + if (typeof content === "string") return content.slice(0, maxLen); if (Array.isArray(content)) { - return content - .filter((c) => typeof c === "object" && c !== null && "type" in c && c.type === "text") - .map((c) => (c as { text: string }).text) - .join(""); + let result = ""; + for (const c of content) { + if (typeof c === "object" && c !== null && "type" in c && c.type === "text") { + result += (c as { text: string }).text; + if (result.length >= maxLen) return result.slice(0, maxLen); + } + } + return result; } return ""; } @@ -329,7 +374,6 @@ class TreeList implements Component { this.onSelect(selected.node.entry.id); } } else if (isEscape(keyData)) { - // Escape: clear search first, then cancel if (this.searchQuery) { this.searchQuery = ""; this.applyFilter(); @@ -338,22 +382,23 @@ class TreeList implements Component { } } else if (isCtrlC(keyData)) { this.onCancel?.(); - } else if (isCtrlU(keyData)) { - // Toggle user-only filter - this.filterMode = this.filterMode === "user-only" ? "default" : "user-only"; - this.applyFilter(); } else if (isCtrlO(keyData)) { - // Toggle show-all filter - this.filterMode = this.filterMode === "all" ? "default" : "all"; + // Cycle filter: default → user-only → labeled-only → all → default + const modes: FilterMode[] = ["default", "user-only", "labeled-only", "all"]; + const currentIndex = modes.indexOf(this.filterMode); + this.filterMode = modes[(currentIndex + 1) % modes.length]; this.applyFilter(); } else if (isBackspace(keyData)) { - // Remove last character from search if (this.searchQuery.length > 0) { this.searchQuery = this.searchQuery.slice(0, -1); this.applyFilter(); } + } else if (keyData === "l" && !this.searchQuery) { + const selected = this.filteredNodes[this.selectedIndex]; + if (selected && this.onLabelEdit) { + this.onLabelEdit(selected.node.entry.id, selected.node.label); + } } else { - // Check for printable characters (reject control chars) const hasControlChars = [...keyData].some((ch) => { const code = ch.charCodeAt(0); return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); @@ -372,22 +417,65 @@ class SearchLine implements Component { invalidate(): void {} - render(_width: number): string[] { + render(width: number): string[] { const query = this.treeList.getSearchQuery(); if (query) { - return [` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`]; + return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)]; } - return [` ${theme.fg("muted", "Search:")}`]; + return [truncateToWidth(` ${theme.fg("muted", "Search:")}`, width)]; } handleInput(_keyData: string): void {} } +/** Label input component shown when editing a label */ +class LabelInput implements Component { + private input: Input; + private entryId: string; + public onSubmit?: (entryId: string, label: string | undefined) => void; + public onCancel?: () => void; + + constructor(entryId: string, currentLabel: string | undefined) { + this.entryId = entryId; + this.input = new Input(); + if (currentLabel) { + this.input.setValue(currentLabel); + } + } + + invalidate(): void {} + + render(width: number): string[] { + const lines: string[] = []; + const indent = " "; + const availableWidth = width - indent.length; + lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width)); + lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width))); + lines.push(truncateToWidth(`${indent}${theme.fg("dim", "enter: save esc: cancel")}`, width)); + return lines; + } + + handleInput(keyData: string): void { + if (isEnter(keyData)) { + const value = this.input.getValue().trim(); + this.onSubmit?.(this.entryId, value || undefined); + } else if (isEscape(keyData)) { + this.onCancel?.(); + } else { + this.input.handleInput(keyData); + } + } +} + /** * Component that renders a session tree selector for navigation */ export class TreeSelectorComponent extends Container { private treeList: TreeList; + private labelInput: LabelInput | null = null; + private labelInputContainer: Container; + private treeContainer: Container; + private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void; constructor( tree: SessionTreeNode[], @@ -395,43 +483,69 @@ export class TreeSelectorComponent extends Container { terminalHeight: number, onSelect: (entryId: string) => void, onCancel: () => void, + onLabelChange?: (entryId: string, label: string | undefined) => void, ) { super(); - // Cap at half terminal height + this.onLabelChangeCallback = onLabelChange; const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2)); - // Create tree list first (needed by SearchLine) this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines); this.treeList.onSelect = onSelect; this.treeList.onCancel = onCancel; + this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel); - // Layout: - // Title - // Border - // Help text - // Search field - // Border - // Tree entries - // Border + this.treeContainer = new Container(); + this.treeContainer.addChild(this.treeList); + + this.labelInputContainer = new Container(); this.addChild(new Spacer(1)); this.addChild(new Text(theme.bold("Session Tree"), 1, 0)); this.addChild(new DynamicBorder()); - this.addChild(new Text(theme.fg("muted", " Type to search. Ctrl+U: user only, Ctrl+O: all"), 0, 0)); + this.addChild(new TruncatedText(theme.fg("muted", " Type to search. l: label. ^O: cycle filter"), 0, 0)); this.addChild(new SearchLine(this.treeList)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - this.addChild(this.treeList); + this.addChild(this.treeContainer); + this.addChild(this.labelInputContainer); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - // Auto-cancel if empty tree if (tree.length === 0) { setTimeout(() => onCancel(), 100); } } + private showLabelInput(entryId: string, currentLabel: string | undefined): void { + this.labelInput = new LabelInput(entryId, currentLabel); + this.labelInput.onSubmit = (id, label) => { + this.treeList.updateNodeLabel(id, label); + this.onLabelChangeCallback?.(id, label); + this.hideLabelInput(); + }; + this.labelInput.onCancel = () => this.hideLabelInput(); + + this.treeContainer.clear(); + this.labelInputContainer.clear(); + this.labelInputContainer.addChild(this.labelInput); + } + + private hideLabelInput(): void { + this.labelInput = null; + this.labelInputContainer.clear(); + this.treeContainer.clear(); + this.treeContainer.addChild(this.treeList); + } + + handleInput(keyData: string): void { + if (this.labelInput) { + this.labelInput.handleInput(keyData); + } else { + this.treeList.handleInput(keyData); + } + } + getTreeList(): TreeList { return this.treeList; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b7b4853f..8429f776 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1647,6 +1647,10 @@ export class InteractiveMode { done(); this.ui.requestRender(); }, + (entryId, label) => { + this.sessionManager.appendLabelChange(entryId, label); + this.ui.requestRender(); + }, ); return { component: selector, focus: selector }; }); From b153b528e82d34f79cdd39555f55de83f16df9fb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 13:54:00 +0100 Subject: [PATCH 103/161] feat(coding-agent): show tool name instead of result content in tree selector --- .../src/modes/interactive/components/tree-selector.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 6fd82439..20284f09 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -296,11 +296,15 @@ class TreeList implements Component { case "message": { const msg = entry.message; const role = msg.role; - if (role === "user" || role === "assistant" || role === "toolResult") { + if (role === "user" || role === "assistant") { const msgWithContent = msg as { content?: unknown }; const content = normalize(this.extractContent(msgWithContent.content)); - const roleColor = role === "user" ? "accent" : role === "assistant" ? "success" : "muted"; + const roleColor = role === "user" ? "accent" : "success"; result = theme.fg(roleColor, `${role}: `) + content; + } else if (role === "toolResult") { + const toolMsg = msg as { toolName?: string }; + const toolName = toolMsg.toolName ?? "tool"; + result = theme.fg("muted", `[${toolName}]`); } else if (role === "bashExecution") { const bashMsg = msg as { command?: string }; result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`); From 29220203828382108a7259a7af7e78307b5a1bcd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 14:06:49 +0100 Subject: [PATCH 104/161] feat(coding-agent): improve tree selector tool display - Tool results show formatted call info like [read: path:1-10], [bash: cmd] - Skip assistant messages with stopReason error/aborted - Skip assistant messages with only tool calls (no text) - Format built-in tools like tool-execution.ts (read, write, edit, bash, grep, find, ls) - Custom tools show truncated JSON args --- .../interactive/components/tree-selector.ts | 125 +++++++++++++++++- 1 file changed, 118 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 20284f09..d2167f1a 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -35,6 +35,12 @@ type FilterMode = "default" | "user-only" | "labeled-only" | "all"; /** * Tree list component with selection and ASCII art visualization */ +/** Tool call info for lookup */ +interface ToolCallInfo { + name: string; + arguments: Record; +} + class TreeList implements Component { private flatNodes: FlatNode[] = []; private filteredNodes: FlatNode[] = []; @@ -43,6 +49,7 @@ class TreeList implements Component { private maxVisibleLines: number; private filterMode: FilterMode = "default"; private searchQuery = ""; + private toolCallMap: Map = new Map(); public onSelect?: (entryId: string) => void; public onCancel?: () => void; @@ -65,6 +72,7 @@ class TreeList implements Component { private flattenTree(roots: SessionTreeNode[]): FlatNode[] { const result: FlatNode[] = []; + this.toolCallMap.clear(); // Use iterative approach to avoid stack overflow on deep trees // Stack items: [node, prefix, isLast, showConnector] @@ -79,6 +87,20 @@ class TreeList implements Component { while (stack.length > 0) { const [node, prefix, isLast, showConnector] = stack.pop()!; + // Extract tool calls from assistant messages for later lookup + const entry = node.entry; + if (entry.type === "message" && entry.message.role === "assistant") { + const content = (entry.message as { content?: unknown }).content; + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block === "object" && block !== null && "type" in block && block.type === "toolCall") { + const tc = block as { id: string; name: string; arguments: Record }; + this.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments }); + } + } + } + } + const depth = prefix.length / 3 + (showConnector ? 1 : 0); result.push({ node, depth, isLast, prefix, showConnector }); @@ -115,7 +137,21 @@ class TreeList implements Component { this.filteredNodes = this.flatNodes.filter((flatNode) => { const entry = flatNode.node.entry; - // Apply filter mode first + // Skip assistant messages with failed stopReason or no text content + if (entry.type === "message" && entry.message.role === "assistant") { + const msg = entry.message as { stopReason?: string; content?: unknown }; + // Skip aborted/error messages + if (msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse") { + return false; + } + // Skip messages with only tool calls (no text content) + const hasText = this.hasTextContent(msg.content); + if (!hasText) { + return false; + } + } + + // Apply filter mode let passesFilter = true; if (this.filterMode === "user-only") { passesFilter = @@ -296,15 +332,23 @@ class TreeList implements Component { case "message": { const msg = entry.message; const role = msg.role; - if (role === "user" || role === "assistant") { + if (role === "user") { const msgWithContent = msg as { content?: unknown }; const content = normalize(this.extractContent(msgWithContent.content)); - const roleColor = role === "user" ? "accent" : "success"; - result = theme.fg(roleColor, `${role}: `) + content; + result = theme.fg("accent", "user: ") + content; + } else if (role === "assistant") { + // Assistant messages without text content are filtered out, so we always have text here + const msgWithContent = msg as { content?: unknown }; + const textContent = normalize(this.extractContent(msgWithContent.content)); + result = theme.fg("success", "assistant: ") + textContent; } else if (role === "toolResult") { - const toolMsg = msg as { toolName?: string }; - const toolName = toolMsg.toolName ?? "tool"; - result = theme.fg("muted", `[${toolName}]`); + const toolMsg = msg as { toolCallId?: string; toolName?: string }; + const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined; + if (toolCall) { + result = theme.fg("muted", this.formatToolCall(toolCall.name, toolCall.arguments)); + } else { + result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`); + } } else if (role === "bashExecution") { const bashMsg = msg as { command?: string }; result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`); @@ -367,6 +411,73 @@ class TreeList implements Component { return ""; } + private hasTextContent(content: unknown): boolean { + if (typeof content === "string") return content.trim().length > 0; + if (Array.isArray(content)) { + for (const c of content) { + if (typeof c === "object" && c !== null && "type" in c && c.type === "text") { + const text = (c as { text?: string }).text; + if (text && text.trim().length > 0) return true; + } + } + } + return false; + } + + private formatToolCall(name: string, args: Record): string { + const shortenPath = (p: string): string => { + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (home && p.startsWith(home)) return `~${p.slice(home.length)}`; + return p; + }; + + switch (name) { + case "read": { + const path = shortenPath(String(args.path || args.file_path || "")); + const offset = args.offset as number | undefined; + const limit = args.limit as number | undefined; + let display = path; + if (offset !== undefined || limit !== undefined) { + const start = offset ?? 1; + const end = limit !== undefined ? start + limit - 1 : ""; + display += `:${start}${end ? `-${end}` : ""}`; + } + return `[read: ${display}]`; + } + case "write": { + const path = shortenPath(String(args.path || args.file_path || "")); + return `[write: ${path}]`; + } + case "edit": { + const path = shortenPath(String(args.path || args.file_path || "")); + return `[edit: ${path}]`; + } + case "bash": { + const cmd = String(args.command || "").slice(0, 50); + return `[bash: ${cmd}${(args.command as string)?.length > 50 ? "..." : ""}]`; + } + case "grep": { + const pattern = String(args.pattern || ""); + const path = shortenPath(String(args.path || ".")); + return `[grep: /${pattern}/ in ${path}]`; + } + case "find": { + const pattern = String(args.pattern || ""); + const path = shortenPath(String(args.path || ".")); + return `[find: ${pattern} in ${path}]`; + } + case "ls": { + const path = shortenPath(String(args.path || ".")); + return `[ls: ${path}]`; + } + default: { + // Custom tool - show name and truncated JSON args + const argsStr = JSON.stringify(args).slice(0, 40); + return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`; + } + } + } + handleInput(keyData: string): void { if (isArrowUp(keyData)) { this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; From 5726770d1f8917de99a44911e16295676db01cd5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 14:16:39 +0100 Subject: [PATCH 105/161] fix(coding-agent): always show current leaf in tree selector even if aborted/error --- .../interactive/components/tree-selector.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index d2167f1a..6a109c9b 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -136,9 +136,11 @@ class TreeList implements Component { this.filteredNodes = this.flatNodes.filter((flatNode) => { const entry = flatNode.node.entry; + const isCurrentLeaf = entry.id === this.currentLeafId; // Skip assistant messages with failed stopReason or no text content - if (entry.type === "message" && entry.message.role === "assistant") { + // BUT always show the current leaf so active position is visible + if (entry.type === "message" && entry.message.role === "assistant" && !isCurrentLeaf) { const msg = entry.message as { stopReason?: string; content?: unknown }; // Skip aborted/error messages if (msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse") { @@ -337,10 +339,18 @@ class TreeList implements Component { const content = normalize(this.extractContent(msgWithContent.content)); result = theme.fg("accent", "user: ") + content; } else if (role === "assistant") { - // Assistant messages without text content are filtered out, so we always have text here - const msgWithContent = msg as { content?: unknown }; + const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string }; const textContent = normalize(this.extractContent(msgWithContent.content)); - result = theme.fg("success", "assistant: ") + textContent; + if (textContent) { + result = theme.fg("success", "assistant: ") + textContent; + } else if (msgWithContent.stopReason === "aborted") { + result = theme.fg("warning", "assistant: ") + theme.fg("muted", "(aborted)"); + } else if (msgWithContent.errorMessage) { + const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80); + result = theme.fg("error", "assistant: ") + errMsg; + } else { + result = theme.fg("muted", "assistant: (no content)"); + } } else if (role === "toolResult") { const toolMsg = msg as { toolCallId?: string; toolName?: string }; const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined; From e8debe78c6e0fa2e3e7a9441402d6caef98aa6ca Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 14:24:28 +0100 Subject: [PATCH 106/161] fix(coding-agent): use visible leaf for tree display, preserve metadata on branch --- .../src/modes/interactive/interactive-mode.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8429f776..bc2cab45 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1597,7 +1597,16 @@ export class InteractiveMode { private showTreeSelector(): void { const tree = this.sessionManager.getTree(); - const currentLeafId = this.sessionManager.getLeafUuid(); + const realLeafId = this.sessionManager.getLeafUuid(); + + // Find the visible leaf for display (skip metadata entries like labels) + let visibleLeafId = realLeafId; + while (visibleLeafId) { + const entry = this.sessionManager.getEntry(visibleLeafId); + if (!entry) break; + if (entry.type !== "label" && entry.type !== "custom") break; + visibleLeafId = entry.parentId ?? null; + } if (tree.length === 0) { this.showStatus("No entries in session"); @@ -1607,11 +1616,11 @@ export class InteractiveMode { this.showSelector((done) => { const selector = new TreeSelectorComponent( tree, - currentLeafId, + visibleLeafId, this.ui.terminal.rows, async (entryId) => { - // Check if selecting current leaf (no-op) - if (entryId === currentLeafId) { + // Selecting the visible leaf is a no-op (already there) + if (entryId === visibleLeafId) { done(); this.showStatus("Already at this point"); return; From 1d90592df1a00be111ad864789814bf6d96c01a9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 14:26:18 +0100 Subject: [PATCH 107/161] fix(coding-agent): always show error/aborted assistant messages in tree --- .../modes/interactive/components/tree-selector.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 6a109c9b..c5d58316 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -138,17 +138,14 @@ class TreeList implements Component { const entry = flatNode.node.entry; const isCurrentLeaf = entry.id === this.currentLeafId; - // Skip assistant messages with failed stopReason or no text content - // BUT always show the current leaf so active position is visible + // Skip assistant messages with only tool calls (no text) unless error/aborted + // Always show current leaf so active position is visible if (entry.type === "message" && entry.message.role === "assistant" && !isCurrentLeaf) { const msg = entry.message as { stopReason?: string; content?: unknown }; - // Skip aborted/error messages - if (msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse") { - return false; - } - // Skip messages with only tool calls (no text content) const hasText = this.hasTextContent(msg.content); - if (!hasText) { + const isErrorOrAborted = msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse"; + // Only hide if no text AND not an error/aborted message + if (!hasText && !isErrorOrAborted) { return false; } } From 6fbc3a01ef77b45338dad667b143a2054541c09a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 14:30:48 +0100 Subject: [PATCH 108/161] refactor(coding-agent): cleaner tree gutter/indent logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only indent when parent has siblings (branch point) - gutterLevels array tracks which levels need │ vs spaces - connector: none/branch/last for ├─/└─ display --- .../interactive/components/tree-selector.ts | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index c5d58316..a69777eb 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -21,12 +21,10 @@ import { DynamicBorder } from "./dynamic-border.js"; /** Flattened tree node for navigation */ interface FlatNode { node: SessionTreeNode; - depth: number; - isLast: boolean; - /** Prefix chars showing tree structure (│ and spaces for gutter) */ - prefix: string; - /** Whether to show ├─/└─ connector (true at branch points) */ - showConnector: boolean; + /** For each indent level, true = show │ gutter, false = show spaces */ + gutterLevels: boolean[]; + /** none = no connector, branch = ├─, last = └─ */ + connector: "none" | "branch" | "last"; } /** Filter mode for tree display */ @@ -74,18 +72,26 @@ class TreeList implements Component { const result: FlatNode[] = []; this.toolCallMap.clear(); - // Use iterative approach to avoid stack overflow on deep trees - // Stack items: [node, prefix, isLast, showConnector] - const stack: [SessionTreeNode, string, boolean, boolean][] = []; + // Iterative traversal to avoid stack overflow + // Stack items: [node, gutterLevels, connector] + // gutterLevels: for each indent level, true = show │, false = show spaces + // connector: none/branch/last + type StackItem = [SessionTreeNode, boolean[], "none" | "branch" | "last"]; + const stack: StackItem[] = []; - // Add roots in reverse order so first root is processed first + // Add roots in reverse order const multipleRoots = roots.length > 1; for (let i = roots.length - 1; i >= 0; i--) { - stack.push([roots[i], "", i === roots.length - 1, multipleRoots]); + const connector: "none" | "branch" | "last" = multipleRoots + ? i === roots.length - 1 + ? "last" + : "branch" + : "none"; + stack.push([roots[i], [], connector]); } while (stack.length > 0) { - const [node, prefix, isLast, showConnector] = stack.pop()!; + const [node, gutterLevels, connector] = stack.pop()!; // Extract tool calls from assistant messages for later lookup const entry = node.entry; @@ -101,27 +107,32 @@ class TreeList implements Component { } } - const depth = prefix.length / 3 + (showConnector ? 1 : 0); - result.push({ node, depth, isLast, prefix, showConnector }); + result.push({ node, gutterLevels, connector }); const children = node.children; const multipleChildren = children.length > 1; - // Build prefix for children - let childPrefix: string; - if (showConnector) { - childPrefix = prefix + (isLast ? " " : "│ "); - } else if (multipleChildren) { - childPrefix = prefix; + // Build gutterLevels for children + // Only add a gutter level if THIS node had a connector (was in a branch) + let childGutterLevels: boolean[]; + if (connector !== "none") { + // We showed a connector, so children get a gutter level + // If we're not last, show │; if we are last, show spaces + childGutterLevels = [...gutterLevels, connector !== "last"]; } else { - childPrefix = prefix; + // No connector, children inherit same gutter levels + childGutterLevels = gutterLevels; } - // Add children in reverse order so first child is processed first + // Add children in reverse order for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; - const childIsLast = i === children.length - 1; - stack.push([child, childPrefix, childIsLast, multipleChildren]); + const childConnector: "none" | "branch" | "last" = multipleChildren + ? i === children.length - 1 + ? "last" + : "branch" + : "none"; + stack.push([child, childGutterLevels, childConnector]); } } @@ -301,13 +312,14 @@ class TreeList implements Component { // Build line: cursor + gutter + connector + label + content + suffix const cursor = isSelected ? theme.fg("accent", "› ") : " "; - const gutter = flatNode.prefix ? theme.fg("dim", flatNode.prefix) : ""; - const connector = flatNode.showConnector ? theme.fg("dim", flatNode.isLast ? "└─ " : "├─ ") : ""; + const gutter = flatNode.gutterLevels.map((show) => (show ? "│ " : " ")).join(""); + const connector = flatNode.connector === "branch" ? "├─ " : flatNode.connector === "last" ? "└─ " : ""; + const prefix = gutter || connector ? theme.fg("dim", gutter + connector) : ""; const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; - const line = cursor + gutter + connector + label + content + suffix; + const line = cursor + prefix + label + content + suffix; lines.push(truncateToWidth(line, width)); } From 7c103ddc55616c61727b44d4e2b847401700ce0f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 14:45:06 +0100 Subject: [PATCH 109/161] refactor(coding-agent): simplify tree indentation - At indent 0: stay flat unless parent branches - At indent 1: children go to indent 2 (visual grouping) - At indent 2+: stay flat for single-child chains - Remove gutter/connector complexity for now --- .../interactive/components/tree-selector.ts | 66 ++++++++----------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index a69777eb..e1007d70 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -21,10 +21,8 @@ import { DynamicBorder } from "./dynamic-border.js"; /** Flattened tree node for navigation */ interface FlatNode { node: SessionTreeNode; - /** For each indent level, true = show │ gutter, false = show spaces */ - gutterLevels: boolean[]; - /** none = no connector, branch = ├─, last = └─ */ - connector: "none" | "branch" | "last"; + /** Indentation level (each level = 2 spaces) */ + indent: number; } /** Filter mode for tree display */ @@ -72,26 +70,23 @@ class TreeList implements Component { const result: FlatNode[] = []; this.toolCallMap.clear(); - // Iterative traversal to avoid stack overflow - // Stack items: [node, gutterLevels, connector] - // gutterLevels: for each indent level, true = show │, false = show spaces - // connector: none/branch/last - type StackItem = [SessionTreeNode, boolean[], "none" | "branch" | "last"]; + // Indentation rules: + // - At indent 0: stay at 0 unless parent has >1 children (then +1) + // - At indent 1: children always go to indent 2 (visual grouping of subtree) + // - At indent 2+: stay flat for single-child chains, +1 only if parent branches + + // Stack items: [node, indent, justBranched] + // justBranched: true if parent had multiple children (used for indent 1 -> 2 transition) + type StackItem = [SessionTreeNode, number, boolean]; const stack: StackItem[] = []; // Add roots in reverse order - const multipleRoots = roots.length > 1; for (let i = roots.length - 1; i >= 0; i--) { - const connector: "none" | "branch" | "last" = multipleRoots - ? i === roots.length - 1 - ? "last" - : "branch" - : "none"; - stack.push([roots[i], [], connector]); + stack.push([roots[i], 0, roots.length > 1]); } while (stack.length > 0) { - const [node, gutterLevels, connector] = stack.pop()!; + const [node, indent, justBranched] = stack.pop()!; // Extract tool calls from assistant messages for later lookup const entry = node.entry; @@ -107,32 +102,27 @@ class TreeList implements Component { } } - result.push({ node, gutterLevels, connector }); + result.push({ node, indent }); const children = node.children; const multipleChildren = children.length > 1; - // Build gutterLevels for children - // Only add a gutter level if THIS node had a connector (was in a branch) - let childGutterLevels: boolean[]; - if (connector !== "none") { - // We showed a connector, so children get a gutter level - // If we're not last, show │; if we are last, show spaces - childGutterLevels = [...gutterLevels, connector !== "last"]; + // Calculate child indent + let childIndent: number; + if (multipleChildren) { + // Parent branches: children get +1 + childIndent = indent + 1; + } else if (justBranched && indent > 0) { + // First generation after a branch: +1 for visual grouping + childIndent = indent + 1; } else { - // No connector, children inherit same gutter levels - childGutterLevels = gutterLevels; + // Single-child chain: stay flat + childIndent = indent; } // Add children in reverse order for (let i = children.length - 1; i >= 0; i--) { - const child = children[i]; - const childConnector: "none" | "branch" | "last" = multipleChildren - ? i === children.length - 1 - ? "last" - : "branch" - : "none"; - stack.push([child, childGutterLevels, childConnector]); + stack.push([children[i], childIndent, multipleChildren]); } } @@ -310,16 +300,14 @@ class TreeList implements Component { const isSelected = i === this.selectedIndex; const isCurrentLeaf = entry.id === this.currentLeafId; - // Build line: cursor + gutter + connector + label + content + suffix + // Build line: cursor + indent + label + content + suffix const cursor = isSelected ? theme.fg("accent", "› ") : " "; - const gutter = flatNode.gutterLevels.map((show) => (show ? "│ " : " ")).join(""); - const connector = flatNode.connector === "branch" ? "├─ " : flatNode.connector === "last" ? "└─ " : ""; - const prefix = gutter || connector ? theme.fg("dim", gutter + connector) : ""; + const indentStr = " ".repeat(flatNode.indent); const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; - const line = cursor + prefix + label + content + suffix; + const line = cursor + indentStr + label + content + suffix; lines.push(truncateToWidth(line, width)); } From 32ec1fa883a5ae28792e5aeb84209e936d63068a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 14:55:11 +0100 Subject: [PATCH 110/161] fix(coding-agent): treat multiple roots as children of virtual branching root --- .../src/modes/interactive/components/tree-selector.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index e1007d70..1b3cd280 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -81,8 +81,11 @@ class TreeList implements Component { const stack: StackItem[] = []; // Add roots in reverse order + // If multiple roots, treat them as children of a virtual root that branches + // So they start at indent 1 with justBranched=true + const multipleRoots = roots.length > 1; for (let i = roots.length - 1; i >= 0; i--) { - stack.push([roots[i], 0, roots.length > 1]); + stack.push([roots[i], multipleRoots ? 1 : 0, multipleRoots]); } while (stack.length > 0) { From e50eae105476c7177a2d36f97f1271d4fe39f222 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 14:58:41 +0100 Subject: [PATCH 111/161] fix(coding-agent): display multiple roots at indent 0, keep child logic intact --- .../src/modes/interactive/components/tree-selector.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 1b3cd280..438e66b5 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -46,6 +46,7 @@ class TreeList implements Component { private filterMode: FilterMode = "default"; private searchQuery = ""; private toolCallMap: Map = new Map(); + private multipleRoots = false; public onSelect?: (entryId: string) => void; public onCancel?: () => void; @@ -54,6 +55,7 @@ class TreeList implements Component { constructor(tree: SessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number) { this.currentLeafId = currentLeafId; this.maxVisibleLines = maxVisibleLines; + this.multipleRoots = tree.length > 1; this.flatNodes = this.flattenTree(tree); this.applyFilter(); @@ -305,7 +307,9 @@ class TreeList implements Component { // Build line: cursor + indent + label + content + suffix const cursor = isSelected ? theme.fg("accent", "› ") : " "; - const indentStr = " ".repeat(flatNode.indent); + // If multiple roots, shift indent down by 1 for display (roots at 0, not 1) + const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; + const indentStr = " ".repeat(displayIndent); const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; From d568ebd6596ed2579df32315518fab2299d4b541 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 15:01:38 +0100 Subject: [PATCH 112/161] feat(coding-agent): add gutters and connectors back to tree selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show ├─ for non-last siblings, └─ for last sibling - Show │ gutter for descendants of non-last siblings - Properly handle multiple roots display shift --- .../interactive/components/tree-selector.ts | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 438e66b5..7aa72a71 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -23,6 +23,12 @@ interface FlatNode { node: SessionTreeNode; /** Indentation level (each level = 2 spaces) */ indent: number; + /** Whether to show connector (├─ or └─) - true if parent has multiple children */ + showConnector: boolean; + /** If showConnector, true = last sibling (└─), false = not last (├─) */ + isLast: boolean; + /** For each ancestor branch point, true = show │ (more siblings below), false = show space */ + gutters: boolean[]; } /** Filter mode for tree display */ @@ -77,21 +83,20 @@ class TreeList implements Component { // - At indent 1: children always go to indent 2 (visual grouping of subtree) // - At indent 2+: stay flat for single-child chains, +1 only if parent branches - // Stack items: [node, indent, justBranched] - // justBranched: true if parent had multiple children (used for indent 1 -> 2 transition) - type StackItem = [SessionTreeNode, number, boolean]; + // Stack items: [node, indent, justBranched, showConnector, isLast, gutters] + type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, boolean[]]; const stack: StackItem[] = []; // Add roots in reverse order // If multiple roots, treat them as children of a virtual root that branches - // So they start at indent 1 with justBranched=true const multipleRoots = roots.length > 1; for (let i = roots.length - 1; i >= 0; i--) { - stack.push([roots[i], multipleRoots ? 1 : 0, multipleRoots]); + const isLast = i === roots.length - 1; + stack.push([roots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, []]); } while (stack.length > 0) { - const [node, indent, justBranched] = stack.pop()!; + const [node, indent, justBranched, showConnector, isLast, gutters] = stack.pop()!; // Extract tool calls from assistant messages for later lookup const entry = node.entry; @@ -107,7 +112,7 @@ class TreeList implements Component { } } - result.push({ node, indent }); + result.push({ node, indent, showConnector, isLast, gutters }); const children = node.children; const multipleChildren = children.length > 1; @@ -125,9 +130,14 @@ class TreeList implements Component { childIndent = indent; } + // Build gutters for children + // If this node showed a connector, add a gutter entry for descendants + const childGutters = showConnector ? [...gutters, !isLast] : gutters; + // Add children in reverse order for (let i = children.length - 1; i >= 0; i--) { - stack.push([children[i], childIndent, multipleChildren]); + const childIsLast = i === children.length - 1; + stack.push([children[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters]); } } @@ -305,16 +315,27 @@ class TreeList implements Component { const isSelected = i === this.selectedIndex; const isCurrentLeaf = entry.id === this.currentLeafId; - // Build line: cursor + indent + label + content + suffix + // Build line: cursor + gutters + connector + extra indent + label + content + suffix const cursor = isSelected ? theme.fg("accent", "› ") : " "; - // If multiple roots, shift indent down by 1 for display (roots at 0, not 1) + + // If multiple roots, shift display (roots at 0, not 1) const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; - const indentStr = " ".repeat(displayIndent); + // Also shift gutters for multiple roots (skip first gutter which is the virtual root level) + const displayGutters = this.multipleRoots ? flatNode.gutters.slice(1) : flatNode.gutters; + + // Build prefix: gutters + connector + extra spaces + const gutterStr = displayGutters.map((g) => (g ? "│ " : " ")).join(""); + const connector = flatNode.showConnector ? (flatNode.isLast ? "└─" : "├─") : ""; + // Extra indent for visual grouping beyond gutters/connector + const prefixLevels = displayGutters.length + (flatNode.showConnector ? 1 : 0); + const extraIndent = " ".repeat(Math.max(0, displayIndent - prefixLevels)); + const prefix = gutterStr + connector + extraIndent; + const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; - const line = cursor + indentStr + label + content + suffix; + const line = cursor + theme.fg("dim", prefix) + label + content + suffix; lines.push(truncateToWidth(line, width)); } From 6465bc502f9967998697cd6173055e4040cd98a0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 15:08:35 +0100 Subject: [PATCH 113/161] fix(coding-agent): show gutters without connectors on virtual root children --- .../interactive/components/tree-selector.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 7aa72a71..a9343b79 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -29,6 +29,8 @@ interface FlatNode { isLast: boolean; /** For each ancestor branch point, true = show │ (more siblings below), false = show space */ gutters: boolean[]; + /** True if this node is a root under a virtual branching root (multiple roots) */ + isVirtualRootChild: boolean; } /** Filter mode for tree display */ @@ -84,7 +86,7 @@ class TreeList implements Component { // - At indent 2+: stay flat for single-child chains, +1 only if parent branches // Stack items: [node, indent, justBranched, showConnector, isLast, gutters] - type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, boolean[]]; + type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, boolean[], boolean]; const stack: StackItem[] = []; // Add roots in reverse order @@ -92,11 +94,11 @@ class TreeList implements Component { const multipleRoots = roots.length > 1; for (let i = roots.length - 1; i >= 0; i--) { const isLast = i === roots.length - 1; - stack.push([roots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, []]); + stack.push([roots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]); } while (stack.length > 0) { - const [node, indent, justBranched, showConnector, isLast, gutters] = stack.pop()!; + const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!; // Extract tool calls from assistant messages for later lookup const entry = node.entry; @@ -112,7 +114,7 @@ class TreeList implements Component { } } - result.push({ node, indent, showConnector, isLast, gutters }); + result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild }); const children = node.children; const multipleChildren = children.length > 1; @@ -137,7 +139,15 @@ class TreeList implements Component { // Add children in reverse order for (let i = children.length - 1; i >= 0; i--) { const childIsLast = i === children.length - 1; - stack.push([children[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters]); + stack.push([ + children[i], + childIndent, + multipleChildren, + multipleChildren, + childIsLast, + childGutters, + false, + ]); } } @@ -325,9 +335,10 @@ class TreeList implements Component { // Build prefix: gutters + connector + extra spaces const gutterStr = displayGutters.map((g) => (g ? "│ " : " ")).join(""); - const connector = flatNode.showConnector ? (flatNode.isLast ? "└─" : "├─") : ""; + const connector = + flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─" : "├─") : ""; // Extra indent for visual grouping beyond gutters/connector - const prefixLevels = displayGutters.length + (flatNode.showConnector ? 1 : 0); + const prefixLevels = displayGutters.length + (connector ? 1 : 0); const extraIndent = " ".repeat(Math.max(0, displayIndent - prefixLevels)); const prefix = gutterStr + connector + extraIndent; From 8d3b4dd762ece93ec5dcecc9c06b638736f36587 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 15:11:28 +0100 Subject: [PATCH 114/161] fix(coding-agent): keep gutters when rendering virtual root children --- .../src/modes/interactive/components/tree-selector.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index a9343b79..2ce199ec 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -330,15 +330,13 @@ class TreeList implements Component { // If multiple roots, shift display (roots at 0, not 1) const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; - // Also shift gutters for multiple roots (skip first gutter which is the virtual root level) - const displayGutters = this.multipleRoots ? flatNode.gutters.slice(1) : flatNode.gutters; // Build prefix: gutters + connector + extra spaces - const gutterStr = displayGutters.map((g) => (g ? "│ " : " ")).join(""); + const gutterStr = flatNode.gutters.map((g) => (g ? "│ " : " ")).join(""); const connector = flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─" : "├─") : ""; // Extra indent for visual grouping beyond gutters/connector - const prefixLevels = displayGutters.length + (connector ? 1 : 0); + const prefixLevels = flatNode.gutters.length + (connector ? 1 : 0); const extraIndent = " ".repeat(Math.max(0, displayIndent - prefixLevels)); const prefix = gutterStr + connector + extraIndent; From 96c071b4c4fcd00bbd064004a11ad796e79e8243 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 15:18:02 +0100 Subject: [PATCH 115/161] =?UTF-8?q?feat(coding-agent):=20highlight=20activ?= =?UTF-8?q?e=20path=20with=20=E2=97=8F=20marker=20in=20tree=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interactive/components/tree-selector.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 2ce199ec..47bce4de 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -55,6 +55,7 @@ class TreeList implements Component { private searchQuery = ""; private toolCallMap: Map = new Map(); private multipleRoots = false; + private activePathIds: Set = new Set(); public onSelect?: (entryId: string) => void; public onCancel?: () => void; @@ -65,6 +66,7 @@ class TreeList implements Component { this.maxVisibleLines = maxVisibleLines; this.multipleRoots = tree.length > 1; this.flatNodes = this.flattenTree(tree); + this.buildActivePath(); this.applyFilter(); // Start with current leaf selected @@ -76,6 +78,27 @@ class TreeList implements Component { } } + /** Build the set of entry IDs on the path from root to current leaf */ + private buildActivePath(): void { + this.activePathIds.clear(); + if (!this.currentLeafId) return; + + // Build a map of id -> entry for parent lookup + const entryMap = new Map(); + for (const flatNode of this.flatNodes) { + entryMap.set(flatNode.node.entry.id, flatNode); + } + + // Walk from leaf to root + let currentId: string | null = this.currentLeafId; + while (currentId) { + this.activePathIds.add(currentId); + const node = entryMap.get(currentId); + if (!node) break; + currentId = node.node.entry.parentId ?? null; + } + } + private flattenTree(roots: SessionTreeNode[]): FlatNode[] { const result: FlatNode[] = []; this.toolCallMap.clear(); @@ -340,11 +363,15 @@ class TreeList implements Component { const extraIndent = " ".repeat(Math.max(0, displayIndent - prefixLevels)); const prefix = gutterStr + connector + extraIndent; + // Active path marker + const isOnActivePath = this.activePathIds.has(entry.id); + const pathMarker = isOnActivePath ? theme.fg("accent", "● ") : " "; + const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; - const line = cursor + theme.fg("dim", prefix) + label + content + suffix; + const line = cursor + pathMarker + theme.fg("dim", prefix) + label + content + suffix; lines.push(truncateToWidth(line, width)); } From 3493e47b4dbae8548eeea52859305c3b54b6c838 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 15:20:39 +0100 Subject: [PATCH 116/161] fix(coding-agent): move active path marker to right before entry text --- .../src/modes/interactive/components/tree-selector.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 47bce4de..9f143f8f 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -363,15 +363,15 @@ class TreeList implements Component { const extraIndent = " ".repeat(Math.max(0, displayIndent - prefixLevels)); const prefix = gutterStr + connector + extraIndent; - // Active path marker + // Active path marker - shown right before the entry text const isOnActivePath = this.activePathIds.has(entry.id); - const pathMarker = isOnActivePath ? theme.fg("accent", "● ") : " "; + const pathMarker = isOnActivePath ? theme.fg("accent", "●") : ""; const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; - const line = cursor + pathMarker + theme.fg("dim", prefix) + label + content + suffix; + const line = cursor + theme.fg("dim", prefix) + pathMarker + label + content + suffix; lines.push(truncateToWidth(line, width)); } From 97e5e8c918995c7003a52d1a5624cd3c0d1fc4de Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 15:26:58 +0100 Subject: [PATCH 117/161] fix(coding-agent): use smaller bullet marker with space for active path --- .../src/modes/interactive/components/tree-selector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 9f143f8f..7be8fa18 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -365,7 +365,7 @@ class TreeList implements Component { // Active path marker - shown right before the entry text const isOnActivePath = this.activePathIds.has(entry.id); - const pathMarker = isOnActivePath ? theme.fg("accent", "●") : ""; + const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : ""; const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); From 975e90ea8cd0a91a4f6be990dcaac14046c0f312 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 15:46:09 +0100 Subject: [PATCH 118/161] fix(coding-agent): prioritize active branch in sorting and fix gutters spacing --- .../interactive/components/tree-selector.ts | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 7be8fa18..24d104ec 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -108,16 +108,34 @@ class TreeList implements Component { // - At indent 1: children always go to indent 2 (visual grouping of subtree) // - At indent 2+: stay flat for single-child chains, +1 only if parent branches - // Stack items: [node, indent, justBranched, showConnector, isLast, gutters] + // Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, boolean[], boolean]; const stack: StackItem[] = []; - // Add roots in reverse order + // Determine which subtrees contain the active leaf (to sort current branch first) + const containsActive = new Map(); + const leafId = this.currentLeafId; + const markContains = (node: SessionTreeNode): boolean => { + let has = leafId !== null && node.entry.id === leafId; + for (const child of node.children) { + if (markContains(child)) { + has = true; + } + } + containsActive.set(node, has); + return has; + }; + for (const root of roots) { + markContains(root); + } + + // Add roots in reverse order, prioritizing the one containing the active leaf // If multiple roots, treat them as children of a virtual root that branches const multipleRoots = roots.length > 1; - for (let i = roots.length - 1; i >= 0; i--) { - const isLast = i === roots.length - 1; - stack.push([roots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]); + const orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a))); + for (let i = orderedRoots.length - 1; i >= 0; i--) { + const isLast = i === orderedRoots.length - 1; + stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]); } while (stack.length > 0) { @@ -142,6 +160,20 @@ class TreeList implements Component { const children = node.children; const multipleChildren = children.length > 1; + // Order children so the branch containing the active leaf comes first + const orderedChildren = (() => { + const prioritized: SessionTreeNode[] = []; + const rest: SessionTreeNode[] = []; + for (const child of children) { + if (containsActive.get(child)) { + prioritized.push(child); + } else { + rest.push(child); + } + } + return [...prioritized, ...rest]; + })(); + // Calculate child indent let childIndent: number; if (multipleChildren) { @@ -160,10 +192,10 @@ class TreeList implements Component { const childGutters = showConnector ? [...gutters, !isLast] : gutters; // Add children in reverse order - for (let i = children.length - 1; i >= 0; i--) { - const childIsLast = i === children.length - 1; + for (let i = orderedChildren.length - 1; i >= 0; i--) { + const childIsLast = i === orderedChildren.length - 1; stack.push([ - children[i], + orderedChildren[i], childIndent, multipleChildren, multipleChildren, @@ -355,9 +387,9 @@ class TreeList implements Component { const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; // Build prefix: gutters + connector + extra spaces - const gutterStr = flatNode.gutters.map((g) => (g ? "│ " : " ")).join(""); + const gutterStr = flatNode.gutters.map((g) => (g ? "│ " : " ")).join(""); const connector = - flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─" : "├─") : ""; + flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : ""; // Extra indent for visual grouping beyond gutters/connector const prefixLevels = flatNode.gutters.length + (connector ? 1 : 0); const extraIndent = " ".repeat(Math.max(0, displayIndent - prefixLevels)); From 159e19a0100add137698a42414fa0a698e41b85f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 18:57:15 +0100 Subject: [PATCH 119/161] Fix tree selector gutter alignment, add page navigation, improve styling - Fix gutter/connector alignment by tracking gutter positions with GutterInfo - Convert recursive markContains to iterative to avoid stack overflow - Add left/right arrow keys for page up/down navigation - Consistent assistant message styling (green prefix, appropriate status colors) - Remove leaf marker (*), active path bullets are sufficient - Add Expandable interface for toggle expansion - Fix BranchSummaryMessageComponent expansion toggle --- packages/ai/src/models.generated.ts | 118 ++++++++--------- packages/coding-agent/src/core/tools/edit.ts | 22 +++- .../interactive/components/tool-execution.ts | 12 +- .../interactive/components/tree-selector.ts | 121 +++++++++++++----- .../src/modes/interactive/interactive-mode.ts | 17 ++- 5 files changed, 186 insertions(+), 104 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6392308c..9110bbb5 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3620,7 +3620,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 196608, - maxTokens: 131072, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepcogito/cogito-v2-preview-llama-405b": { id: "deepcogito/cogito-v2-preview-llama-405b", @@ -4623,7 +4623,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 128000, + maxTokens: 131072, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,6 +6359,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6393,23 +6410,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,6 +6546,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6580,23 +6597,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4": { id: "openai/gpt-4", name: "OpenAI: GPT-4", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index cdc8577e..a46a209d 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -23,8 +23,13 @@ function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { /** * Generate a unified diff string with line numbers and context + * Returns both the diff string and the first changed line number (in the new file) */ -function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string { +function generateDiffString( + oldContent: string, + newContent: string, + contextLines = 4, +): { diff: string; firstChangedLine: number | undefined } { const parts = Diff.diffLines(oldContent, newContent); const output: string[] = []; @@ -36,6 +41,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines let oldLineNum = 1; let newLineNum = 1; let lastWasChange = false; + let firstChangedLine: number | undefined; for (let i = 0; i < parts.length; i++) { const part = parts[i]; @@ -45,6 +51,11 @@ function generateDiffString(oldContent: string, newContent: string, contextLines } if (part.added || part.removed) { + // Capture the first changed line (in the new file) + if (firstChangedLine === undefined) { + firstChangedLine = newLineNum; + } + // Show the change for (const line of raw) { if (part.added) { @@ -113,7 +124,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines } } - return output.join("\n"); + return { diff: output.join("\n"), firstChangedLine }; } const editSchema = Type.Object({ @@ -125,6 +136,8 @@ const editSchema = Type.Object({ export interface EditToolDetails { /** Unified diff of the changes made */ diff: string; + /** Line number of the first change in the new file (for editor navigation) */ + firstChangedLine?: number; } export function createEditTool(cwd: string): AgentTool { @@ -143,7 +156,7 @@ export function createEditTool(cwd: string): AgentTool { return new Promise<{ content: Array<{ type: "text"; text: string }>; - details: { diff: string } | undefined; + details: EditToolDetails | undefined; }>((resolve, reject) => { // Check if already aborted if (signal?.aborted) { @@ -262,6 +275,7 @@ export function createEditTool(cwd: string): AgentTool { signal.removeEventListener("abort", onAbort); } + const diffResult = generateDiffString(normalizedContent, normalizedNewContent); resolve({ content: [ { @@ -269,7 +283,7 @@ export function createEditTool(cwd: string): AgentTool { text: `Successfully replaced text in ${path}.`, }, ], - details: { diff: generateDiffString(normalizedContent, normalizedNewContent) }, + details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine }, }); } catch (error: any) { // Clean up abort handler diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index fe32a3b4..7124c84b 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -415,10 +415,14 @@ export class ToolExecutionComponent extends Container { } else if (this.toolName === "edit") { const rawPath = this.args?.file_path || this.args?.path || ""; const path = shortenPath(rawPath); - text = - theme.fg("toolTitle", theme.bold("edit")) + - " " + - (path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); + + // Build path display, appending :line if we have a successful result with line info + let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + if (this.result && !this.result.isError && this.result.details?.firstChangedLine) { + pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`); + } + + text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; if (this.result) { if (this.result.isError) { diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 24d104ec..18c3ca8b 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -3,6 +3,8 @@ import { Container, Input, isArrowDown, + isArrowLeft, + isArrowRight, isArrowUp, isBackspace, isCtrlC, @@ -18,17 +20,23 @@ import type { SessionTreeNode } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +/** Gutter info: position (displayIndent where connector was) and whether to show │ */ +interface GutterInfo { + position: number; // displayIndent level where the connector was shown + show: boolean; // true = show │, false = show spaces +} + /** Flattened tree node for navigation */ interface FlatNode { node: SessionTreeNode; - /** Indentation level (each level = 2 spaces) */ + /** Indentation level (each level = 3 chars) */ indent: number; /** Whether to show connector (├─ or └─) - true if parent has multiple children */ showConnector: boolean; /** If showConnector, true = last sibling (└─), false = not last (├─) */ isLast: boolean; - /** For each ancestor branch point, true = show │ (more siblings below), false = show space */ - gutters: boolean[]; + /** Gutter info for each ancestor branch point */ + gutters: GutterInfo[]; /** True if this node is a root under a virtual branching root (multiple roots) */ isVirtualRootChild: boolean; } @@ -109,24 +117,36 @@ class TreeList implements Component { // - At indent 2+: stay flat for single-child chains, +1 only if parent branches // Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] - type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, boolean[], boolean]; + type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean]; const stack: StackItem[] = []; // Determine which subtrees contain the active leaf (to sort current branch first) + // Use iterative post-order traversal to avoid stack overflow const containsActive = new Map(); const leafId = this.currentLeafId; - const markContains = (node: SessionTreeNode): boolean => { - let has = leafId !== null && node.entry.id === leafId; - for (const child of node.children) { - if (markContains(child)) { - has = true; + { + // Build list in pre-order, then process in reverse for post-order effect + const allNodes: SessionTreeNode[] = []; + const preOrderStack: SessionTreeNode[] = [...roots]; + while (preOrderStack.length > 0) { + const node = preOrderStack.pop()!; + allNodes.push(node); + // Push children in reverse so they're processed left-to-right + for (let i = node.children.length - 1; i >= 0; i--) { + preOrderStack.push(node.children[i]); } } - containsActive.set(node, has); - return has; - }; - for (const root of roots) { - markContains(root); + // Process in reverse (post-order): children before parents + for (let i = allNodes.length - 1; i >= 0; i--) { + const node = allNodes[i]; + let has = leafId !== null && node.entry.id === leafId; + for (const child of node.children) { + if (containsActive.get(child)) { + has = true; + } + } + containsActive.set(node, has); + } } // Add roots in reverse order, prioritizing the one containing the active leaf @@ -189,7 +209,15 @@ class TreeList implements Component { // Build gutters for children // If this node showed a connector, add a gutter entry for descendants - const childGutters = showConnector ? [...gutters, !isLast] : gutters; + // Only add gutter if connector is actually displayed (not suppressed for virtual root children) + const connectorDisplayed = showConnector && !isVirtualRootChild; + // When connector is displayed, add a gutter entry at the connector's position + // Connector is at position (displayIndent - 1), so gutter should be there too + const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent; + const connectorPosition = Math.max(0, currentDisplayIndent - 1); + const childGutters: GutterInfo[] = connectorDisplayed + ? [...gutters, { position: connectorPosition, show: !isLast }] + : gutters; // Add children in reverse order for (let i = orderedChildren.length - 1; i >= 0; i--) { @@ -378,22 +406,48 @@ class TreeList implements Component { const flatNode = this.filteredNodes[i]; const entry = flatNode.node.entry; const isSelected = i === this.selectedIndex; - const isCurrentLeaf = entry.id === this.currentLeafId; - // Build line: cursor + gutters + connector + extra indent + label + content + suffix + // Build line: cursor + prefix + path marker + label + content const cursor = isSelected ? theme.fg("accent", "› ") : " "; // If multiple roots, shift display (roots at 0, not 1) const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; - // Build prefix: gutters + connector + extra spaces - const gutterStr = flatNode.gutters.map((g) => (g ? "│ " : " ")).join(""); + // Build prefix with gutters at their correct positions + // Each gutter has a position (displayIndent where its connector was shown) const connector = flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : ""; - // Extra indent for visual grouping beyond gutters/connector - const prefixLevels = flatNode.gutters.length + (connector ? 1 : 0); - const extraIndent = " ".repeat(Math.max(0, displayIndent - prefixLevels)); - const prefix = gutterStr + connector + extraIndent; + const connectorPosition = connector ? displayIndent - 1 : -1; + + // Build prefix char by char, placing gutters and connector at their positions + const totalChars = displayIndent * 3; + const prefixChars: string[] = []; + for (let i = 0; i < totalChars; i++) { + const level = Math.floor(i / 3); + const posInLevel = i % 3; + + // Check if there's a gutter at this level + const gutter = flatNode.gutters.find((g) => g.position === level); + if (gutter) { + if (posInLevel === 0) { + prefixChars.push(gutter.show ? "│" : " "); + } else { + prefixChars.push(" "); + } + } else if (connector && level === connectorPosition) { + // Connector at this level + if (posInLevel === 0) { + prefixChars.push(flatNode.isLast ? "└" : "├"); + } else if (posInLevel === 1) { + prefixChars.push("─"); + } else { + prefixChars.push(" "); + } + } else { + prefixChars.push(" "); + } + } + const prefix = prefixChars.join(""); // Active path marker - shown right before the entry text const isOnActivePath = this.activePathIds.has(entry.id); @@ -401,9 +455,8 @@ class TreeList implements Component { const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); - const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; - const line = cursor + theme.fg("dim", prefix) + pathMarker + label + content + suffix; + const line = cursor + theme.fg("dim", prefix) + pathMarker + label + content; lines.push(truncateToWidth(line, width)); } @@ -437,12 +490,12 @@ class TreeList implements Component { if (textContent) { result = theme.fg("success", "assistant: ") + textContent; } else if (msgWithContent.stopReason === "aborted") { - result = theme.fg("warning", "assistant: ") + theme.fg("muted", "(aborted)"); + result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)"); } else if (msgWithContent.errorMessage) { const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80); - result = theme.fg("error", "assistant: ") + errMsg; + result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg); } else { - result = theme.fg("muted", "assistant: (no content)"); + result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)"); } } else if (role === "toolResult") { const toolMsg = msg as { toolCallId?: string; toolName?: string }; @@ -586,6 +639,12 @@ class TreeList implements Component { this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; } else if (isArrowDown(keyData)) { this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; + } else if (isArrowLeft(keyData)) { + // Page up + this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); + } else if (isArrowRight(keyData)) { + // Page down + this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); } else if (isEnter(keyData)) { const selected = this.filteredNodes[this.selectedIndex]; if (selected && this.onSelect) { @@ -719,9 +778,11 @@ export class TreeSelectorComponent extends Container { this.labelInputContainer = new Container(); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.bold("Session Tree"), 1, 0)); this.addChild(new DynamicBorder()); - this.addChild(new TruncatedText(theme.fg("muted", " Type to search. l: label. ^O: cycle filter"), 0, 0)); + this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); + this.addChild( + new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O: filter. Type to search"), 0, 0), + ); this.addChild(new SearchLine(this.treeList)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index bc2cab45..8d7109b4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -56,6 +56,15 @@ import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; +/** Interface for components that can be expanded/collapsed */ +interface Expandable { + setExpanded(expanded: boolean): void; +} + +function isExpandable(obj: unknown): obj is Expandable { + return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function"; +} + export class InteractiveMode { private session: AgentSession; private ui: TUI; @@ -1303,13 +1312,7 @@ export class InteractiveMode { private toggleToolOutputExpansion(): void { this.toolOutputExpanded = !this.toolOutputExpanded; for (const child of this.chatContainer.children) { - if (child instanceof ToolExecutionComponent) { - child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof CompactionSummaryMessageComponent) { - child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof BashExecutionComponent) { - child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof HookMessageComponent) { + if (isExpandable(child)) { child.setExpanded(this.toolOutputExpanded); } } From 9dac0a142370f2f70434203bb4dccfd94af37740 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 19:36:03 +0100 Subject: [PATCH 120/161] WIP: Add branch summarization abort support with loader and escape handler --- packages/ai/src/models.generated.ts | 118 +++++++++--------- .../coding-agent/src/core/agent-session.ts | 30 +++-- .../src/modes/interactive/interactive-mode.ts | 30 +++++ 3 files changed, 112 insertions(+), 66 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 9110bbb5..6392308c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3620,7 +3620,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 196608, - maxTokens: 65536, + maxTokens: 131072, } satisfies Model<"openai-completions">, "deepcogito/cogito-v2-preview-llama-405b": { id: "deepcogito/cogito-v2-preview-llama-405b", @@ -4623,7 +4623,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, + maxTokens: 128000, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,23 +6359,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6410,6 +6393,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,23 +6546,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6597,6 +6580,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ac5a86b4..e9f24225 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -146,6 +146,9 @@ export class AgentSession { private _compactionAbortController: AbortController | undefined = undefined; private _autoCompactionAbortController: AbortController | undefined = undefined; + // Branch summarization state + private _branchSummaryAbortController: AbortController | undefined = undefined; + // Retry state private _retryAbortController: AbortController | undefined = undefined; private _retryAttempt = 0; @@ -998,6 +1001,13 @@ export class AgentSession { this._autoCompactionAbortController?.abort(); } + /** + * Cancel in-progress branch summarization. + */ + abortBranchSummary(): void { + this._branchSummaryAbortController?.abort(); + } + /** * Check if compaction is needed and run it. * Called after agent_end and before prompt submission. @@ -1572,7 +1582,7 @@ export class AgentSession { async navigateTree( targetId: string, options: { summarize?: boolean; customInstructions?: string } = {}, - ): Promise<{ editorText?: string; cancelled: boolean }> { + ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean }> { const oldLeafId = this.sessionManager.getLeafUuid(); // No-op if already at target @@ -1625,7 +1635,7 @@ export class AgentSession { }; // Set up abort controller for summarization - const abortController = new AbortController(); + this._branchSummaryAbortController = new AbortController(); let hookSummary: { summary: string; details?: unknown } | undefined; let fromHook = false; @@ -1635,7 +1645,7 @@ export class AgentSession { type: "session_before_tree", preparation, model: this.model!, // Checked above if summarize is true - signal: abortController.signal, + signal: this._branchSummaryAbortController.signal, })) as SessionBeforeTreeResult | undefined; if (result?.cancel) { @@ -1655,11 +1665,16 @@ export class AgentSession { summaryText = await this._generateBranchSummary( entriesToSummarize, options.customInstructions, - abortController.signal, + this._branchSummaryAbortController.signal, ); - } catch { - // Summarization failed - cancel navigation - return { cancelled: true }; + } catch (error) { + this._branchSummaryAbortController = undefined; + // Check if aborted + if (error instanceof Error && (error.name === "AbortError" || error.message === "aborted")) { + return { cancelled: true, aborted: true }; + } + // Re-throw actual errors so UI can display them + throw error; } } else if (hookSummary) { summaryText = hookSummary.summary; @@ -1718,6 +1733,7 @@ export class AgentSession { // Emit to custom tools await this._emitToolSessionEvent("tree", this.sessionFile); + this._branchSummaryAbortController = undefined; return { editorText, cancelled: false }; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8d7109b4..bad29cc2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1637,8 +1637,32 @@ export class InteractiveMode { "Create a summary of the branch you're leaving?", ); + // Set up escape handler and loader if summarizing + let summaryLoader: Loader | undefined; + const originalOnEscape = this.editor.onEscape; + + if (wantsSummary) { + this.editor.onEscape = () => { + this.session.abortBranchSummary(); + }; + this.chatContainer.addChild(new Spacer(1)); + summaryLoader = new Loader( + this.ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + "Summarizing branch... (esc to cancel)", + ); + this.statusContainer.addChild(summaryLoader); + this.ui.requestRender(); + } + try { const result = await this.session.navigateTree(entryId, { summarize: wantsSummary }); + + if (result.aborted) { + this.showStatus("Branch summarization cancelled"); + return; + } if (result.cancelled) { this.showStatus("Navigation cancelled"); return; @@ -1653,6 +1677,12 @@ export class InteractiveMode { this.showStatus("Navigated to selected point"); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); + } finally { + if (summaryLoader) { + summaryLoader.stop(); + this.statusContainer.clear(); + } + this.editor.onEscape = originalOnEscape; } }, () => { From 01dae9ebcc86ab17d9f49bb391e738754df0f7fa Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 20:15:19 +0100 Subject: [PATCH 121/161] Fix branch summarization abort handling and tree navigation - Check response.stopReason instead of catching errors for abort detection - Return result object from _generateBranchSummary instead of throwing - Fix summary attachment: attach to navigation target position, not old branch - Support root-level summaries (parentId=null) in branchWithSummary - Remove setTimeout hack for re-showing tree selector on abort --- packages/ai/src/models.generated.ts | 4 +- .../coding-agent/src/core/agent-session.ts | 55 +++++++++++-------- packages/coding-agent/src/core/hooks/types.ts | 2 +- .../coding-agent/src/core/session-manager.ts | 8 +-- .../interactive/components/tree-selector.ts | 8 ++- .../src/modes/interactive/interactive-mode.ts | 4 +- .../session-manager/tree-traversal.test.ts | 14 ++--- 7 files changed, 55 insertions(+), 40 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6392308c..6f32cbb3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3620,7 +3620,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 196608, - maxTokens: 131072, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepcogito/cogito-v2-preview-llama-405b": { id: "deepcogito/cogito-v2-preview-llama-405b", @@ -4623,7 +4623,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 128000, + maxTokens: 131072, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e9f24225..f42ee625 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1583,7 +1583,7 @@ export class AgentSession { targetId: string, options: { summarize?: boolean; customInstructions?: string } = {}, ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean }> { - const oldLeafId = this.sessionManager.getLeafUuid(); + const oldLeafId = this.sessionManager.getLeafId(); // No-op if already at target if (targetId === oldLeafId) { @@ -1661,21 +1661,19 @@ export class AgentSession { // Run default summarizer if needed let summaryText: string | undefined; if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { - try { - summaryText = await this._generateBranchSummary( - entriesToSummarize, - options.customInstructions, - this._branchSummaryAbortController.signal, - ); - } catch (error) { - this._branchSummaryAbortController = undefined; - // Check if aborted - if (error instanceof Error && (error.name === "AbortError" || error.message === "aborted")) { - return { cancelled: true, aborted: true }; - } - // Re-throw actual errors so UI can display them - throw error; + const result = await this._generateBranchSummary( + entriesToSummarize, + options.customInstructions, + this._branchSummaryAbortController.signal, + ); + this._branchSummaryAbortController = undefined; + if (result.aborted) { + return { cancelled: true, aborted: true }; } + if (result.error) { + throw new Error(result.error); + } + summaryText = result.summary; } else if (hookSummary) { summaryText = hookSummary.summary; } @@ -1704,14 +1702,17 @@ export class AgentSession { } // Switch leaf (with or without summary) + // Summary is attached at the navigation target position (newLeafId), not the old branch let summaryEntry: BranchSummaryEntry | undefined; - if (newLeafId === null) { - // Navigating to root user message - reset leaf to empty - this.sessionManager.resetLeaf(); - } else if (summaryText) { + if (summaryText) { + // Create summary at target position (can be null for root) const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; + } else if (newLeafId === null) { + // No summary, navigating to root - reset leaf + this.sessionManager.resetLeaf(); } else { + // No summary, navigating to non-root this.sessionManager.branch(newLeafId); } @@ -1723,7 +1724,7 @@ export class AgentSession { if (this._hookRunner) { await this._hookRunner.emit({ type: "session_tree", - newLeafId: this.sessionManager.getLeafUuid(), + newLeafId: this.sessionManager.getLeafId(), oldLeafId, summaryEntry, fromHook: summaryText ? fromHook : undefined, @@ -1744,7 +1745,7 @@ export class AgentSession { entries: SessionEntry[], customInstructions: string | undefined, signal: AbortSignal, - ): Promise { + ): Promise<{ summary?: string; aborted?: boolean; error?: string }> { // Convert entries to messages for summarization const messages: Array<{ role: string; content: string }> = []; for (const entry of entries) { @@ -1770,7 +1771,7 @@ export class AgentSession { } if (messages.length === 0) { - return "No content to summarize"; + return { summary: "No content to summarize" }; } // Build prompt for summarization @@ -1804,12 +1805,20 @@ export class AgentSession { { apiKey, signal, maxTokens: 1024 }, ); + // Check if aborted or errored + if (response.stopReason === "aborted") { + return { aborted: true }; + } + if (response.stopReason === "error") { + return { error: response.errorMessage || "Summarization failed" }; + } + const summary = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n"); - return summary || "No summary generated"; + return { summary: summary || "No summary generated" }; } /** diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 847a9134..e3d20e31 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -25,7 +25,7 @@ export type ReadonlySessionManager = Pick< | "getSessionDir" | "getSessionId" | "getSessionFile" - | "getLeafUuid" + | "getLeafId" | "getLeafEntry" | "getEntry" | "getLabel" diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 5d6b6c90..ef33f15d 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -684,7 +684,7 @@ export class SessionManager { // Tree Traversal // ========================================================================= - getLeafUuid(): string | null { + getLeafId(): string | null { return this.leafId; } @@ -845,8 +845,8 @@ export class SessionManager { * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ - branchWithSummary(branchFromId: string, summary: string): string { - if (!this.byId.has(branchFromId)) { + branchWithSummary(branchFromId: string | null, summary: string): string { + if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.leafId = branchFromId; @@ -855,7 +855,7 @@ export class SessionManager { id: generateId(this.byId), parentId: branchFromId, timestamp: new Date().toISOString(), - fromId: branchFromId, + fromId: branchFromId ?? "root", summary, }; this._appendEntry(entry); diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 18c3ca8b..15945373 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -609,8 +609,12 @@ class TreeList implements Component { return `[edit: ${path}]`; } case "bash": { - const cmd = String(args.command || "").slice(0, 50); - return `[bash: ${cmd}${(args.command as string)?.length > 50 ? "..." : ""}]`; + const rawCmd = String(args.command || ""); + const cmd = rawCmd + .replace(/[\n\t]/g, " ") + .trim() + .slice(0, 50); + return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`; } case "grep": { const pattern = String(args.pattern || ""); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index bad29cc2..bd680fdf 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1600,7 +1600,7 @@ export class InteractiveMode { private showTreeSelector(): void { const tree = this.sessionManager.getTree(); - const realLeafId = this.sessionManager.getLeafUuid(); + const realLeafId = this.sessionManager.getLeafId(); // Find the visible leaf for display (skip metadata entries like labels) let visibleLeafId = realLeafId; @@ -1660,7 +1660,9 @@ export class InteractiveMode { const result = await this.session.navigateTree(entryId, { summarize: wantsSummary }); if (result.aborted) { + // Summarization aborted - re-show tree selector this.showStatus("Branch summarization cancelled"); + this.showTreeSelector(); return; } if (result.cancelled) { diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index 8cf1dcde..93715ed6 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -129,16 +129,16 @@ describe("SessionManager append and tree traversal", () => { it("leaf pointer advances after each append", () => { const session = SessionManager.inMemory(); - expect(session.getLeafUuid()).toBe(""); + expect(session.getLeafId()).toBe(""); const id1 = session.appendMessage(userMsg("1")); - expect(session.getLeafUuid()).toBe(id1); + expect(session.getLeafId()).toBe(id1); const id2 = session.appendMessage(assistantMsg("2")); - expect(session.getLeafUuid()).toBe(id2); + expect(session.getLeafId()).toBe(id2); const id3 = session.appendThinkingLevelChange("high"); - expect(session.getLeafUuid()).toBe(id3); + expect(session.getLeafId()).toBe(id3); }); }); @@ -303,10 +303,10 @@ describe("SessionManager append and tree traversal", () => { const _id2 = session.appendMessage(assistantMsg("2")); const id3 = session.appendMessage(userMsg("3")); - expect(session.getLeafUuid()).toBe(id3); + expect(session.getLeafId()).toBe(id3); session.branch(id1); - expect(session.getLeafUuid()).toBe(id1); + expect(session.getLeafId()).toBe(id1); }); it("throws for non-existent entry", () => { @@ -341,7 +341,7 @@ describe("SessionManager append and tree traversal", () => { const summaryId = session.branchWithSummary(id1, "Summary of abandoned work"); - expect(session.getLeafUuid()).toBe(summaryId); + expect(session.getLeafId()).toBe(summaryId); const entries = session.getEntries(); const summaryEntry = entries.find((e) => e.type === "branch_summary"); From 31c5cd38d1f37ec92283362f1ac6426902f275f5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 20:30:13 +0100 Subject: [PATCH 122/161] Add tree navigation tests and shared test utilities - Add test/utilities.ts with shared helpers (API_KEY, userMsg, assistantMsg, createTestSession) - Add agent-session-tree-navigation.test.ts with e2e tests for tree navigation - Add getChildren() method to SessionManager - Add summaryEntry to navigateTree return type - Update existing tests to use shared utilities --- .../coding-agent/src/core/agent-session.ts | 4 +- .../coding-agent/src/core/session-manager.ts | 13 + .../test/agent-session-branching.test.ts | 3 +- .../test/agent-session-compaction.test.ts | 3 +- .../agent-session-tree-navigation.test.ts | 318 ++++++++++++++++++ .../session-manager/tree-traversal.test.ts | 25 +- packages/coding-agent/test/utilities.ts | 158 +++++++++ 7 files changed, 494 insertions(+), 30 deletions(-) create mode 100644 packages/coding-agent/test/agent-session-tree-navigation.test.ts create mode 100644 packages/coding-agent/test/utilities.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f42ee625..f335a707 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1582,7 +1582,7 @@ export class AgentSession { async navigateTree( targetId: string, options: { summarize?: boolean; customInstructions?: string } = {}, - ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean }> { + ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> { const oldLeafId = this.sessionManager.getLeafId(); // No-op if already at target @@ -1735,7 +1735,7 @@ export class AgentSession { await this._emitToolSessionEvent("tree", this.sessionFile); this._branchSummaryAbortController = undefined; - return { editorText, cancelled: false }; + return { editorText, cancelled: false, summaryEntry }; } /** diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index ef33f15d..ccf6e4ea 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -696,6 +696,19 @@ export class SessionManager { return this.byId.get(id); } + /** + * Get all direct children of an entry. + */ + getChildren(parentId: string): SessionEntry[] { + const children: SessionEntry[] = []; + for (const entry of this.byId.values()) { + if (entry.parentId === parentId) { + children.push(entry); + } + } + return children; + } + /** * Get the label for an entry, if any. */ diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 51b3aa59..71b78ab7 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -19,8 +19,7 @@ import { ModelRegistry } from "../src/core/model-registry.js"; import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import { codingTools } from "../src/core/tools/index.js"; - -const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; +import { API_KEY } from "./utilities.js"; describe.skipIf(!API_KEY)("AgentSession branching", () => { let session: AgentSession; diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 529961c4..14a664bb 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -19,8 +19,7 @@ import { ModelRegistry } from "../src/core/model-registry.js"; import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import { codingTools } from "../src/core/tools/index.js"; - -const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; +import { API_KEY } from "./utilities.js"; describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { let session: AgentSession; diff --git a/packages/coding-agent/test/agent-session-tree-navigation.test.ts b/packages/coding-agent/test/agent-session-tree-navigation.test.ts new file mode 100644 index 00000000..82e82439 --- /dev/null +++ b/packages/coding-agent/test/agent-session-tree-navigation.test.ts @@ -0,0 +1,318 @@ +/** + * E2E tests for AgentSession tree navigation with branch summarization. + * + * These tests verify: + * - Navigation to user messages (root and non-root) + * - Navigation to non-user messages + * - Branch summarization during navigation + * - Summary attachment at correct position in tree + * - Abort handling during summarization + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { API_KEY, createTestSession, type TestSessionContext } from "./utilities.js"; + +describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => { + let ctx: TestSessionContext; + + beforeEach(() => { + ctx = createTestSession({ + systemPrompt: "You are a helpful assistant. Reply with just a few words.", + settingsOverrides: { compaction: { keepRecentTokens: 1 } }, + }); + }); + + afterEach(() => { + ctx.cleanup(); + }); + + it("should navigate to user message and put text in editor", async () => { + const { session } = ctx; + + // Build conversation: u1 -> a1 -> u2 -> a2 + await session.prompt("First message"); + await session.agent.waitForIdle(); + await session.prompt("Second message"); + await session.agent.waitForIdle(); + + // Get tree entries + const tree = session.sessionManager.getTree(); + expect(tree.length).toBe(1); + + // Find the first user entry (u1) + const rootNode = tree[0]; + expect(rootNode.entry.type).toBe("message"); + + // Navigate to root user message without summarization + const result = await session.navigateTree(rootNode.entry.id, { summarize: false }); + + expect(result.cancelled).toBe(false); + expect(result.editorText).toBe("First message"); + + // After navigating to root user message, leaf should be null (empty conversation) + expect(session.sessionManager.getLeafId()).toBeNull(); + }, 60000); + + it("should navigate to non-user message without editor text", async () => { + const { session, sessionManager } = ctx; + + // Build conversation + await session.prompt("Hello"); + await session.agent.waitForIdle(); + + // Get the assistant message + const entries = sessionManager.getEntries(); + const assistantEntry = entries.find((e) => e.type === "message" && e.message.role === "assistant"); + expect(assistantEntry).toBeDefined(); + + // Navigate to assistant message + const result = await session.navigateTree(assistantEntry!.id, { summarize: false }); + + expect(result.cancelled).toBe(false); + expect(result.editorText).toBeUndefined(); + + // Leaf should be the assistant entry + expect(sessionManager.getLeafId()).toBe(assistantEntry!.id); + }, 60000); + + it("should create branch summary when navigating with summarize=true", async () => { + const { session, sessionManager } = ctx; + + // Build conversation: u1 -> a1 -> u2 -> a2 + await session.prompt("What is 2+2?"); + await session.agent.waitForIdle(); + await session.prompt("What is 3+3?"); + await session.agent.waitForIdle(); + + // Get tree and find first user message + const tree = sessionManager.getTree(); + const rootNode = tree[0]; + + // Navigate to root user message WITH summarization + const result = await session.navigateTree(rootNode.entry.id, { summarize: true }); + + expect(result.cancelled).toBe(false); + expect(result.editorText).toBe("What is 2+2?"); + expect(result.summaryEntry).toBeDefined(); + expect(result.summaryEntry?.type).toBe("branch_summary"); + expect(result.summaryEntry?.summary).toBeTruthy(); + expect(result.summaryEntry?.summary.length).toBeGreaterThan(0); + + // Summary should be a root entry (parentId = null) since we navigated to root user + expect(result.summaryEntry?.parentId).toBeNull(); + + // Leaf should be the summary entry + expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id); + }, 120000); + + it("should attach summary to correct parent when navigating to nested user message", async () => { + const { session, sessionManager } = ctx; + + // Build conversation: u1 -> a1 -> u2 -> a2 -> u3 -> a3 + await session.prompt("Message one"); + await session.agent.waitForIdle(); + await session.prompt("Message two"); + await session.agent.waitForIdle(); + await session.prompt("Message three"); + await session.agent.waitForIdle(); + + // Get the second user message (u2) + const entries = sessionManager.getEntries(); + const userEntries = entries.filter((e) => e.type === "message" && e.message.role === "user"); + expect(userEntries.length).toBe(3); + + const u2 = userEntries[1]; + const a1 = entries.find((e) => e.id === u2.parentId); // a1 is parent of u2 + + // Navigate to u2 with summarization + const result = await session.navigateTree(u2.id, { summarize: true }); + + expect(result.cancelled).toBe(false); + expect(result.editorText).toBe("Message two"); + expect(result.summaryEntry).toBeDefined(); + + // Summary should be attached to a1 (parent of u2) + // So a1 now has two children: u2 and the summary + expect(result.summaryEntry?.parentId).toBe(a1?.id); + + // Verify tree structure + const children = sessionManager.getChildren(a1!.id); + expect(children.length).toBe(2); + + const childTypes = children.map((c) => c.type).sort(); + expect(childTypes).toContain("branch_summary"); + expect(childTypes).toContain("message"); + }, 120000); + + it("should attach summary to selected node when navigating to assistant message", async () => { + const { session, sessionManager } = ctx; + + // Build conversation: u1 -> a1 -> u2 -> a2 + await session.prompt("Hello"); + await session.agent.waitForIdle(); + await session.prompt("Goodbye"); + await session.agent.waitForIdle(); + + // Get the first assistant message (a1) + const entries = sessionManager.getEntries(); + const assistantEntries = entries.filter((e) => e.type === "message" && e.message.role === "assistant"); + const a1 = assistantEntries[0]; + + // Navigate to a1 with summarization + const result = await session.navigateTree(a1.id, { summarize: true }); + + expect(result.cancelled).toBe(false); + expect(result.editorText).toBeUndefined(); // No editor text for assistant messages + expect(result.summaryEntry).toBeDefined(); + + // Summary should be attached to a1 (the selected node) + expect(result.summaryEntry?.parentId).toBe(a1.id); + + // Leaf should be the summary entry + expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id); + }, 120000); + + it("should handle abort during summarization", async () => { + const { session, sessionManager } = ctx; + + // Build conversation + await session.prompt("Tell me about something"); + await session.agent.waitForIdle(); + await session.prompt("Continue"); + await session.agent.waitForIdle(); + + const entriesBefore = sessionManager.getEntries(); + const leafBefore = sessionManager.getLeafId(); + + // Get root user message + const tree = sessionManager.getTree(); + const rootNode = tree[0]; + + // Start navigation with summarization but abort immediately + const navigationPromise = session.navigateTree(rootNode.entry.id, { summarize: true }); + + // Abort after a short delay (let the LLM call start) + await new Promise((resolve) => setTimeout(resolve, 100)); + session.abortBranchSummary(); + + const result = await navigationPromise; + + expect(result.cancelled).toBe(true); + expect(result.aborted).toBe(true); + expect(result.summaryEntry).toBeUndefined(); + + // Session should be unchanged + const entriesAfter = sessionManager.getEntries(); + expect(entriesAfter.length).toBe(entriesBefore.length); + expect(sessionManager.getLeafId()).toBe(leafBefore); + }, 60000); + + it("should not create summary when navigating without summarize option", async () => { + const { session, sessionManager } = ctx; + + // Build conversation + await session.prompt("First"); + await session.agent.waitForIdle(); + await session.prompt("Second"); + await session.agent.waitForIdle(); + + const entriesBefore = sessionManager.getEntries().length; + + // Navigate without summarization + const tree = sessionManager.getTree(); + await session.navigateTree(tree[0].entry.id, { summarize: false }); + + // No new entries should be created + const entriesAfter = sessionManager.getEntries().length; + expect(entriesAfter).toBe(entriesBefore); + + // No branch_summary entries + const summaries = sessionManager.getEntries().filter((e) => e.type === "branch_summary"); + expect(summaries.length).toBe(0); + }, 60000); + + it("should handle navigation to same position (no-op)", async () => { + const { session, sessionManager } = ctx; + + // Build conversation + await session.prompt("Hello"); + await session.agent.waitForIdle(); + + const leafBefore = sessionManager.getLeafId(); + expect(leafBefore).toBeTruthy(); + const entriesBefore = sessionManager.getEntries().length; + + // Navigate to current leaf + const result = await session.navigateTree(leafBefore!, { summarize: false }); + + expect(result.cancelled).toBe(false); + expect(sessionManager.getLeafId()).toBe(leafBefore); + expect(sessionManager.getEntries().length).toBe(entriesBefore); + }, 60000); + + it("should support custom summarization instructions", async () => { + const { session, sessionManager } = ctx; + + // Build conversation + await session.prompt("What is TypeScript?"); + await session.agent.waitForIdle(); + + // Navigate with custom instructions + const tree = sessionManager.getTree(); + const result = await session.navigateTree(tree[0].entry.id, { + summarize: true, + customInstructions: "Summarize in exactly 3 words.", + }); + + expect(result.summaryEntry).toBeDefined(); + expect(result.summaryEntry?.summary).toBeTruthy(); + // Can't reliably test 3 words exactly, but summary should be short + expect(result.summaryEntry?.summary.split(/\s+/).length).toBeLessThan(20); + }, 120000); +}); + +describe.skipIf(!API_KEY)("AgentSession tree navigation - branch scenarios", () => { + let ctx: TestSessionContext; + + beforeEach(() => { + ctx = createTestSession({ + systemPrompt: "You are a helpful assistant. Reply with just a few words.", + }); + }); + + afterEach(() => { + ctx.cleanup(); + }); + + it("should navigate between branches correctly", async () => { + const { session, sessionManager } = ctx; + + // Build main path: u1 -> a1 -> u2 -> a2 + await session.prompt("Main branch start"); + await session.agent.waitForIdle(); + await session.prompt("Main branch continue"); + await session.agent.waitForIdle(); + + // Get a1 id for branching + const entries = sessionManager.getEntries(); + const a1 = entries.find((e) => e.type === "message" && e.message.role === "assistant"); + + // Create a branch from a1: a1 -> u3 -> a3 + sessionManager.branch(a1!.id); + await session.prompt("Branch path"); + await session.agent.waitForIdle(); + + // Now navigate back to u2 (on main branch) with summarization + const userEntries = entries.filter((e) => e.type === "message" && e.message.role === "user"); + const u2 = userEntries[1]; // "Main branch continue" + + const result = await session.navigateTree(u2.id, { summarize: true }); + + expect(result.cancelled).toBe(false); + expect(result.editorText).toBe("Main branch continue"); + expect(result.summaryEntry).toBeDefined(); + + // Summary captures the branch we're leaving (the "Branch path" conversation) + expect(result.summaryEntry?.summary.length).toBeGreaterThan(0); + }, 180000); +}); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index 93715ed6..65193479 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -1,29 +1,6 @@ import { describe, expect, it } from "vitest"; import { type CustomEntry, SessionManager } from "../../src/core/session-manager.js"; - -function userMsg(text: string) { - return { role: "user" as const, content: text, timestamp: Date.now() }; -} - -function assistantMsg(text: string) { - return { - role: "assistant" as const, - content: [{ type: "text" as const, text }], - api: "anthropic-messages" as const, - provider: "anthropic", - model: "test", - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop" as const, - timestamp: Date.now(), - }; -} +import { assistantMsg, userMsg } from "../utilities.js"; describe("SessionManager append and tree traversal", () => { describe("append operations", () => { diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts new file mode 100644 index 00000000..3fe5d4cc --- /dev/null +++ b/packages/coding-agent/test/utilities.ts @@ -0,0 +1,158 @@ +/** + * Shared test utilities for coding-agent tests. + */ + +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent } from "@mariozechner/pi-agent-core"; +import { getModel } from "@mariozechner/pi-ai"; +import { AgentSession } from "../src/core/agent-session.js"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { ModelRegistry } from "../src/core/model-registry.js"; +import { SessionManager } from "../src/core/session-manager.js"; +import { SettingsManager } from "../src/core/settings-manager.js"; +import { codingTools } from "../src/core/tools/index.js"; + +/** + * API key for authenticated tests. Tests using this should be wrapped in + * describe.skipIf(!API_KEY) + */ +export const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; + +/** + * Create a minimal user message for testing. + */ +export function userMsg(text: string) { + return { role: "user" as const, content: text, timestamp: Date.now() }; +} + +/** + * Create a minimal assistant message for testing. + */ +export function assistantMsg(text: string) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "anthropic-messages" as const, + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} + +/** + * Options for creating a test session. + */ +export interface TestSessionOptions { + /** Use in-memory session (no file persistence) */ + inMemory?: boolean; + /** Custom system prompt */ + systemPrompt?: string; + /** Custom settings overrides */ + settingsOverrides?: Record; +} + +/** + * Resources returned by createTestSession that need cleanup. + */ +export interface TestSessionContext { + session: AgentSession; + sessionManager: SessionManager; + tempDir: string; + cleanup: () => void; +} + +/** + * Create an AgentSession for testing with proper setup and cleanup. + * Use this for e2e tests that need real LLM calls. + */ +export function createTestSession(options: TestSessionOptions = {}): TestSessionContext { + const tempDir = join(tmpdir(), `pi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + + const model = getModel("anthropic", "claude-sonnet-4-5")!; + const agent = new Agent({ + getApiKey: () => API_KEY, + initialState: { + model, + systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.", + tools: codingTools, + }, + }); + + const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir); + const settingsManager = SettingsManager.create(tempDir, tempDir); + + if (options.settingsOverrides) { + settingsManager.applyOverrides(options.settingsOverrides); + } + + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + const modelRegistry = new ModelRegistry(authStorage, tempDir); + + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + modelRegistry, + }); + + // Must subscribe to enable session persistence + session.subscribe(() => {}); + + const cleanup = () => { + session.dispose(); + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + }; + + return { session, sessionManager, tempDir, cleanup }; +} + +/** + * Build a session tree for testing using SessionManager. + * Returns the IDs of all created entries. + * + * Example tree structure: + * ``` + * u1 -> a1 -> u2 -> a2 + * -> u3 -> a3 (branch from a1) + * u4 -> a4 (another root) + * ``` + */ +export function buildTestTree( + session: SessionManager, + structure: { + messages: Array<{ role: "user" | "assistant"; text: string; branchFrom?: string }>; + }, +): Map { + const ids = new Map(); + + for (const msg of structure.messages) { + if (msg.branchFrom) { + const branchFromId = ids.get(msg.branchFrom); + if (!branchFromId) { + throw new Error(`Cannot branch from unknown entry: ${msg.branchFrom}`); + } + session.branch(branchFromId); + } + + const id = + msg.role === "user" ? session.appendMessage(userMsg(msg.text)) : session.appendMessage(assistantMsg(msg.text)); + + ids.set(msg.text, id); + } + + return ids; +} From aee61b1a6be0dfbc782ede92317db113ac1d3e2a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 20:30:54 +0100 Subject: [PATCH 123/161] Fix test: getLeafId returns null for empty session, not empty string --- .../coding-agent/test/session-manager/tree-traversal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index 65193479..5fe7610a 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -106,7 +106,7 @@ describe("SessionManager append and tree traversal", () => { it("leaf pointer advances after each append", () => { const session = SessionManager.inMemory(); - expect(session.getLeafId()).toBe(""); + expect(session.getLeafId()).toBeNull(); const id1 = session.appendMessage(userMsg("1")); expect(session.getLeafId()).toBe(id1); From fd13b53b1c99c094ff43a352cb1502ff7752db55 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 20:59:35 +0100 Subject: [PATCH 124/161] Refactor: move compaction code to src/core/compaction/ - Move compaction.ts to src/core/compaction/compaction.ts - Extract branch summarization to src/core/compaction/branch-summarization.ts - Add index.ts to re-export all compaction utilities - Update all imports across the codebase --- .../coding-agent/src/core/agent-session.ts | 112 ++--------------- .../core/compaction/branch-summarization.ts | 115 ++++++++++++++++++ .../src/core/{ => compaction}/compaction.ts | 4 +- .../coding-agent/src/core/compaction/index.ts | 6 + packages/coding-agent/src/core/hooks/types.ts | 2 +- packages/coding-agent/src/core/index.ts | 2 +- packages/coding-agent/src/index.ts | 4 +- .../coding-agent/src/modes/rpc/rpc-client.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 2 +- packages/coding-agent/test/compaction.test.ts | 2 +- 10 files changed, 142 insertions(+), 109 deletions(-) create mode 100644 packages/coding-agent/src/core/compaction/branch-summarization.ts rename packages/coding-agent/src/core/{ => compaction}/compaction.ts (99%) create mode 100644 packages/coding-agent/src/core/compaction/index.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f335a707..978fb1b8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -22,9 +22,10 @@ import { type CompactionResult, calculateContextTokens, compact, + generateBranchSummary, prepareCompaction, shouldCompact, -} from "./compaction.js"; +} from "./compaction/index.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; import type { @@ -1661,10 +1662,17 @@ export class AgentSession { // Run default summarizer if needed let summaryText: string | undefined; if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { - const result = await this._generateBranchSummary( + const model = this.model!; + const apiKey = await this._modelRegistry.getApiKey(model); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}`); + } + const result = await generateBranchSummary( entriesToSummarize, - options.customInstructions, + model, + apiKey, this._branchSummaryAbortController.signal, + options.customInstructions, ); this._branchSummaryAbortController = undefined; if (result.aborted) { @@ -1738,104 +1746,6 @@ export class AgentSession { return { editorText, cancelled: false, summaryEntry }; } - /** - * Generate a summary of abandoned branch entries. - */ - private async _generateBranchSummary( - entries: SessionEntry[], - customInstructions: string | undefined, - signal: AbortSignal, - ): Promise<{ summary?: string; aborted?: boolean; error?: string }> { - // Convert entries to messages for summarization - const messages: Array<{ role: string; content: string }> = []; - for (const entry of entries) { - if (entry.type === "message") { - const text = this._extractMessageText(entry.message); - if (text) { - messages.push({ role: entry.message.role, content: text }); - } - } else if (entry.type === "custom_message") { - const text = - typeof entry.content === "string" - ? entry.content - : entry.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - if (text) { - messages.push({ role: "user", content: text }); - } - } else if (entry.type === "branch_summary") { - messages.push({ role: "system", content: `[Previous branch summary: ${entry.summary}]` }); - } - } - - if (messages.length === 0) { - return { summary: "No content to summarize" }; - } - - // Build prompt for summarization - const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"); - const instructions = customInstructions - ? `${customInstructions}\n\n` - : "Summarize this conversation branch concisely, capturing key decisions, actions taken, and outcomes.\n\n"; - - const prompt = `${instructions}Conversation:\n${conversationText}`; - - // Get API key for current model (model is checked in navigateTree before calling this) - const model = this.model!; - const apiKey = await this._modelRegistry.getApiKey(model); - if (!apiKey) { - throw new Error(`No API key for ${model.provider}`); - } - - // Call LLM for summarization - const { complete } = await import("@mariozechner/pi-ai"); - const response = await complete( - model, - { - messages: [ - { - role: "user", - content: [{ type: "text", text: prompt }], - timestamp: Date.now(), - }, - ], - }, - { apiKey, signal, maxTokens: 1024 }, - ); - - // Check if aborted or errored - if (response.stopReason === "aborted") { - return { aborted: true }; - } - if (response.stopReason === "error") { - return { error: response.errorMessage || "Summarization failed" }; - } - - const summary = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); - - return { summary: summary || "No summary generated" }; - } - - /** - * Extract text content from any message type. - */ - private _extractMessageText(message: any): string { - if (!message.content) return ""; - if (typeof message.content === "string") return message.content; - if (Array.isArray(message.content)) { - return message.content - .filter((c: any) => c.type === "text") - .map((c: any) => c.text) - .join(""); - } - return ""; - } - /** * Get all user messages from session for branch selector. */ diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts new file mode 100644 index 00000000..7ac097f7 --- /dev/null +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -0,0 +1,115 @@ +/** + * Branch summarization for tree navigation. + * + * When navigating to a different point in the session tree, this generates + * a summary of the branch being left so context isn't lost. + */ + +import type { Model } from "@mariozechner/pi-ai"; +import { complete } from "@mariozechner/pi-ai"; +import type { SessionEntry } from "../session-manager.js"; + +const DEFAULT_INSTRUCTIONS = + "Summarize this conversation branch concisely, capturing key decisions, actions taken, and outcomes."; + +export interface BranchSummaryResult { + summary?: string; + aborted?: boolean; + error?: string; +} + +/** + * Extract text content from any message type. + */ +function extractMessageText(message: any): string { + if (!message.content) return ""; + if (typeof message.content === "string") return message.content; + if (Array.isArray(message.content)) { + return message.content + .filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join(""); + } + return ""; +} + +/** + * Generate a summary of abandoned branch entries. + * + * @param entries - Session entries to summarize + * @param model - Model to use for summarization + * @param apiKey - API key for the model + * @param signal - Abort signal for cancellation + * @param customInstructions - Optional custom instructions for summarization + */ +export async function generateBranchSummary( + entries: SessionEntry[], + model: Model, + apiKey: string, + signal: AbortSignal, + customInstructions?: string, +): Promise { + // Convert entries to messages for summarization + const messages: Array<{ role: string; content: string }> = []; + + for (const entry of entries) { + if (entry.type === "message") { + const text = extractMessageText(entry.message); + if (text) { + messages.push({ role: entry.message.role, content: text }); + } + } else if (entry.type === "custom_message") { + const text = + typeof entry.content === "string" + ? entry.content + : entry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (text) { + messages.push({ role: "user", content: text }); + } + } else if (entry.type === "branch_summary") { + messages.push({ role: "system", content: `[Previous branch summary: ${entry.summary}]` }); + } + } + + if (messages.length === 0) { + return { summary: "No content to summarize" }; + } + + // Build prompt for summarization + const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"); + const instructions = customInstructions ? `${customInstructions}\n\n` : `${DEFAULT_INSTRUCTIONS}\n\n`; + const prompt = `${instructions}Conversation:\n${conversationText}`; + + // Call LLM for summarization + const response = await complete( + model, + { + messages: [ + { + role: "user", + content: [{ type: "text", text: prompt }], + timestamp: Date.now(), + }, + ], + }, + { apiKey, signal, maxTokens: 1024 }, + ); + + // Check if aborted or errored + if (response.stopReason === "aborted") { + return { aborted: true }; + } + if (response.stopReason === "error") { + return { error: response.errorMessage || "Summarization failed" }; + } + + const summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return { summary: summary || "No summary generated" }; +} diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts similarity index 99% rename from packages/coding-agent/src/core/compaction.ts rename to packages/coding-agent/src/core/compaction/compaction.ts index 35d933a3..61e4b950 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -8,8 +8,8 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; -import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "./messages.js"; -import type { CompactionEntry, SessionEntry } from "./session-manager.js"; +import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js"; +import type { CompactionEntry, SessionEntry } from "../session-manager.js"; /** * Extract AgentMessage from an entry if it produces one. diff --git a/packages/coding-agent/src/core/compaction/index.ts b/packages/coding-agent/src/core/compaction/index.ts new file mode 100644 index 00000000..4f8ad306 --- /dev/null +++ b/packages/coding-agent/src/core/compaction/index.ts @@ -0,0 +1,6 @@ +/** + * Compaction and summarization utilities. + */ + +export * from "./branch-summarization.js"; +export * from "./compaction.js"; diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index e3d20e31..e8dd689e 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -9,7 +9,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; -import type { CompactionPreparation, CompactionResult } from "../compaction.js"; +import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 4b75e1cd..b25df418 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -12,7 +12,7 @@ export { type SessionStats, } from "./agent-session.js"; export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js"; -export type { CompactionResult } from "./compaction.js"; +export type { CompactionResult } from "./compaction/index.js"; export { type CustomAgentTool, type CustomToolFactory, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 1e563429..1ae08511 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -12,6 +12,7 @@ export { export { type ApiKeyCredential, type AuthCredential, AuthStorage, type OAuthCredential } from "./core/auth-storage.js"; // Compaction export { + type BranchSummaryResult, type CompactionResult, type CutPointResult, calculateContextTokens, @@ -20,10 +21,11 @@ export { estimateTokens, findCutPoint, findTurnStartIndex, + generateBranchSummary, generateSummary, getLastAssistantUsage, shouldCompact, -} from "./core/compaction.js"; +} from "./core/compaction/index.js"; // Custom tools export type { AgentToolUpdateCallback, diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index f8d8f213..0249ca11 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -10,7 +10,7 @@ import type { AgentEvent, AgentMessage, ThinkingLevel } from "@mariozechner/pi-a import type { ImageContent } from "@mariozechner/pi-ai"; import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; -import type { CompactionResult } from "../../core/compaction.js"; +import type { CompactionResult } from "../../core/compaction/index.js"; import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js"; // ============================================================================ diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index d4f7a73a..5feead90 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -9,7 +9,7 @@ import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model } from "@mariozechner/pi-ai"; import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; -import type { CompactionResult } from "../../core/compaction.js"; +import type { CompactionResult } from "../../core/compaction/index.js"; // ============================================================================ // RPC Commands (stdin) diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 331c3b1b..0468a582 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -12,7 +12,7 @@ import { findCutPoint, getLastAssistantUsage, shouldCompact, -} from "../src/core/compaction.js"; +} from "../src/core/compaction/index.js"; import { buildSessionContext, type CompactionEntry, From 5cbaf2be885d62ddc899ee1de9aa9674c1670095 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:05:23 +0100 Subject: [PATCH 125/161] Improve branch summarization with preparation and file ops extraction - Add prepareBranchEntries() to extract messages and file operations - Extract read/write/edit operations from tool calls - Append static file operations section to summary - Improve prompt for branch summarization - Skip toolResult messages (context already in assistant message) - Export new types: BranchPreparation, FileOperations --- .../core/compaction/branch-summarization.ts | 218 +++++++++++++++--- packages/coding-agent/src/index.ts | 3 + 2 files changed, 190 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 7ac097f7..9c549ba2 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -9,8 +9,9 @@ import type { Model } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; import type { SessionEntry } from "../session-manager.js"; -const DEFAULT_INSTRUCTIONS = - "Summarize this conversation branch concisely, capturing key decisions, actions taken, and outcomes."; +// ============================================================================ +// Types +// ============================================================================ export interface BranchSummaryResult { summary?: string; @@ -18,6 +19,25 @@ export interface BranchSummaryResult { error?: string; } +export interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +export interface BranchPreparation { + /** Messages extracted for summarization */ + messages: Array<{ role: string; content: string }>; + /** File operations extracted from tool calls */ + fileOps: FileOperations; + /** Previous summaries found in entries */ + previousSummaries: string[]; +} + +// ============================================================================ +// Entry Parsing +// ============================================================================ + /** * Extract text content from any message type. */ @@ -33,6 +53,152 @@ function extractMessageText(message: any): string { return ""; } +/** + * Extract file operations from tool calls in an assistant message. + */ +function extractFileOpsFromToolCalls(message: any, fileOps: FileOperations): void { + if (!message.content || !Array.isArray(message.content)) return; + + for (const block of message.content) { + if (typeof block !== "object" || block === null) continue; + if (block.type !== "toolCall") continue; + + const args = block.arguments as Record | undefined; + if (!args) continue; + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) continue; + + switch (block.name) { + case "read": + fileOps.read.add(path); + break; + case "write": + fileOps.written.add(path); + break; + case "edit": + fileOps.edited.add(path); + break; + } + } +} + +/** + * Prepare entries for summarization. + * + * Extracts: + * - Messages (user, assistant text, custom_message) + * - File operations from tool calls + * - Previous branch summaries + * + * Skips: + * - toolResult messages (context already in assistant message) + * - thinking_level_change, model_change, custom, label entries + * - compaction entries (these are boundaries, shouldn't be in the input) + */ +export function prepareBranchEntries(entries: SessionEntry[]): BranchPreparation { + const messages: Array<{ role: string; content: string }> = []; + const fileOps: FileOperations = { + read: new Set(), + written: new Set(), + edited: new Set(), + }; + const previousSummaries: string[] = []; + + for (const entry of entries) { + switch (entry.type) { + case "message": { + const role = entry.message.role; + + // Skip tool results - the context is in the assistant's tool call + if (role === "toolResult") continue; + + // Extract file ops from assistant tool calls + if (role === "assistant") { + extractFileOpsFromToolCalls(entry.message, fileOps); + } + + // Extract text content + const text = extractMessageText(entry.message); + if (text) { + messages.push({ role, content: text }); + } + break; + } + + case "custom_message": { + const text = + typeof entry.content === "string" + ? entry.content + : entry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (text) { + messages.push({ role: "user", content: text }); + } + break; + } + + case "branch_summary": { + previousSummaries.push(entry.summary); + break; + } + + // Skip these entry types - they don't contribute to conversation content + case "compaction": + case "thinking_level_change": + case "model_change": + case "custom": + case "label": + break; + } + } + + return { messages, fileOps, previousSummaries }; +} + +// ============================================================================ +// Summary Generation +// ============================================================================ + +const BRANCH_SUMMARY_PROMPT = `Summarize this conversation branch concisely for context when returning later: +- Key decisions made and actions taken +- Important context, constraints, or preferences discovered +- Current state and any pending work +- Critical information needed to continue from a different point + +Be brief and focused on what matters for future reference.`; + +/** + * Format file operations as a static section to append to summary. + */ +function formatFileOperations(fileOps: FileOperations): string { + const sections: string[] = []; + + if (fileOps.read.size > 0) { + const files = [...fileOps.read].sort(); + sections.push(`**Read:** ${files.join(", ")}`); + } + + if (fileOps.edited.size > 0) { + const files = [...fileOps.edited].sort(); + sections.push(`**Edited:** ${files.join(", ")}`); + } + + if (fileOps.written.size > 0) { + // Exclude files that were also edited (edit implies write) + const writtenOnly = [...fileOps.written].filter((f) => !fileOps.edited.has(f)).sort(); + if (writtenOnly.length > 0) { + sections.push(`**Created:** ${writtenOnly.join(", ")}`); + } + } + + if (sections.length === 0) return ""; + + return `\n\n---\n**Files:**\n${sections.join("\n")}`; +} + /** * Generate a summary of abandoned branch entries. * @@ -49,39 +215,26 @@ export async function generateBranchSummary( signal: AbortSignal, customInstructions?: string, ): Promise { - // Convert entries to messages for summarization - const messages: Array<{ role: string; content: string }> = []; - - for (const entry of entries) { - if (entry.type === "message") { - const text = extractMessageText(entry.message); - if (text) { - messages.push({ role: entry.message.role, content: text }); - } - } else if (entry.type === "custom_message") { - const text = - typeof entry.content === "string" - ? entry.content - : entry.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - if (text) { - messages.push({ role: "user", content: text }); - } - } else if (entry.type === "branch_summary") { - messages.push({ role: "system", content: `[Previous branch summary: ${entry.summary}]` }); - } - } + const { messages, fileOps, previousSummaries } = prepareBranchEntries(entries); if (messages.length === 0) { return { summary: "No content to summarize" }; } - // Build prompt for summarization - const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"); - const instructions = customInstructions ? `${customInstructions}\n\n` : `${DEFAULT_INSTRUCTIONS}\n\n`; - const prompt = `${instructions}Conversation:\n${conversationText}`; + // Build conversation text + const parts: string[] = []; + + // Include previous summaries as context + if (previousSummaries.length > 0) { + parts.push(`[Previous context: ${previousSummaries.join(" | ")}]`); + } + + // Add conversation + parts.push(messages.map((m) => `${m.role}: ${m.content}`).join("\n\n")); + + const conversationText = parts.join("\n\n"); + const instructions = customInstructions || BRANCH_SUMMARY_PROMPT; + const prompt = `${instructions}\n\nConversation:\n${conversationText}`; // Call LLM for summarization const response = await complete( @@ -106,10 +259,13 @@ export async function generateBranchSummary( return { error: response.errorMessage || "Summarization failed" }; } - const summary = response.content + let summary = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n"); + // Append static file operations section + summary += formatFileOperations(fileOps); + return { summary: summary || "No summary generated" }; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 1ae08511..44419113 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -12,6 +12,7 @@ export { export { type ApiKeyCredential, type AuthCredential, AuthStorage, type OAuthCredential } from "./core/auth-storage.js"; // Compaction export { + type BranchPreparation, type BranchSummaryResult, type CompactionResult, type CutPointResult, @@ -19,11 +20,13 @@ export { compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, + type FileOperations, findCutPoint, findTurnStartIndex, generateBranchSummary, generateSummary, getLastAssistantUsage, + prepareBranchEntries, shouldCompact, } from "./core/compaction/index.js"; // Custom tools From 08fab16e2df76a77336fb6245b382cc1b426660e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:22:50 +0100 Subject: [PATCH 126/161] Add ReadonlySessionManager and refactor branch summarization - Add ReadonlySessionManager interface to session-manager.ts - Re-export from hooks/index.ts - Add collectEntriesForBranchSummary() to extract entries for summarization - Don't stop at compaction boundaries (include their summaries as context) - Add token budget support to prepareBranchEntries() - Walk entries newest-to-oldest to prioritize recent context - Use options object for generateBranchSummary() - Handle compaction entries as context summaries - Export new types: CollectEntriesResult, GenerateBranchSummaryOptions --- .../coding-agent/src/core/agent-session.ts | 42 +--- .../core/compaction/branch-summarization.ts | 210 ++++++++++++++---- packages/coding-agent/src/core/hooks/index.ts | 1 + .../coding-agent/src/core/session-manager.ts | 14 ++ packages/coding-agent/src/index.ts | 3 + 5 files changed, 191 insertions(+), 79 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 978fb1b8..c7277c3c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -21,6 +21,7 @@ import { type BashResult, executeBash as executeBashCommand } from "./bash-execu import { type CompactionResult, calculateContextTokens, + collectEntriesForBranchSummary, compact, generateBranchSummary, prepareCompaction, @@ -42,7 +43,7 @@ import type { } from "./hooks/index.js"; import type { BashExecutionMessage, HookMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; -import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.js"; +import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; @@ -1601,30 +1602,12 @@ export class AgentSession { throw new Error(`Entry ${targetId} not found`); } - // Find common ancestor (if oldLeafId is null, there's no old path) - const oldPath = oldLeafId ? new Set(this.sessionManager.getPath(oldLeafId).map((e) => e.id)) : new Set(); - const targetPath = this.sessionManager.getPath(targetId); - let commonAncestorId: string | null = null; - for (const entry of targetPath) { - if (oldPath.has(entry.id)) { - commonAncestorId = entry.id; - break; - } - } - - // Collect entries to summarize (old leaf back to common ancestor, stop at compaction) - const entriesToSummarize: SessionEntry[] = []; - if (options.summarize && oldLeafId) { - let current: string | null = oldLeafId; - while (current && current !== commonAncestorId) { - const entry = this.sessionManager.getEntry(current); - if (!entry) break; - if (entry.type === "compaction") break; - entriesToSummarize.push(entry); - current = entry.parentId; - } - entriesToSummarize.reverse(); // Chronological order - } + // Collect entries to summarize (from old leaf to common ancestor) + const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary( + this.sessionManager, + oldLeafId, + targetId, + ); // Prepare event data const preparation: TreePreparation = { @@ -1667,13 +1650,12 @@ export class AgentSession { if (!apiKey) { throw new Error(`No API key for ${model.provider}`); } - const result = await generateBranchSummary( - entriesToSummarize, + const result = await generateBranchSummary(entriesToSummarize, { model, apiKey, - this._branchSummaryAbortController.signal, - options.customInstructions, - ); + signal: this._branchSummaryAbortController.signal, + customInstructions: options.customInstructions, + }); this._branchSummaryAbortController = undefined; if (result.aborted) { return { cancelled: true, aborted: true }; diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 9c549ba2..d79fd301 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -7,7 +7,7 @@ import type { Model } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; -import type { SessionEntry } from "../session-manager.js"; +import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js"; // ============================================================================ // Types @@ -26,18 +26,100 @@ export interface FileOperations { } export interface BranchPreparation { - /** Messages extracted for summarization */ - messages: Array<{ role: string; content: string }>; + /** Messages extracted for summarization, in chronological order */ + messages: Array<{ role: string; content: string; tokens: number }>; /** File operations extracted from tool calls */ fileOps: FileOperations; - /** Previous summaries found in entries */ - previousSummaries: string[]; + /** Total tokens in messages */ + totalTokens: number; +} + +export interface CollectEntriesResult { + /** Entries to summarize, in chronological order */ + entries: SessionEntry[]; + /** Common ancestor between old and new position, if any */ + commonAncestorId: string | null; +} + +export interface GenerateBranchSummaryOptions { + /** Model to use for summarization */ + model: Model; + /** API key for the model */ + apiKey: string; + /** Abort signal for cancellation */ + signal: AbortSignal; + /** Optional custom instructions for summarization */ + customInstructions?: string; + /** Reserve this fraction of context window for summary (default 0.2) */ + reserveFraction?: number; +} + +// ============================================================================ +// Entry Collection +// ============================================================================ + +/** + * Collect entries that should be summarized when navigating from one position to another. + * + * Walks from oldLeafId back to the common ancestor with targetId, collecting entries + * along the way. Does NOT stop at compaction boundaries - those are included and their + * summaries become context. + * + * @param session - Session manager (read-only access) + * @param oldLeafId - Current position (where we're navigating from) + * @param targetId - Target position (where we're navigating to) + * @returns Entries to summarize and the common ancestor + */ +export function collectEntriesForBranchSummary( + session: ReadonlySessionManager, + oldLeafId: string | null, + targetId: string, +): CollectEntriesResult { + // If no old position, nothing to summarize + if (!oldLeafId) { + return { entries: [], commonAncestorId: null }; + } + + // Find common ancestor + const oldPath = new Set(session.getPath(oldLeafId).map((e) => e.id)); + const targetPath = session.getPath(targetId); + + let commonAncestorId: string | null = null; + for (const entry of targetPath) { + if (oldPath.has(entry.id)) { + commonAncestorId = entry.id; + break; + } + } + + // Collect entries from old leaf back to common ancestor + const entries: SessionEntry[] = []; + let current: string | null = oldLeafId; + + while (current && current !== commonAncestorId) { + const entry = session.getEntry(current); + if (!entry) break; + entries.push(entry); + current = entry.parentId; + } + + // Reverse to get chronological order + entries.reverse(); + + return { entries, commonAncestorId }; } // ============================================================================ // Entry Parsing // ============================================================================ +/** + * Estimate token count for a string using chars/4 heuristic. + */ +function estimateStringTokens(text: string): number { + return Math.ceil(text.length / 4); +} + /** * Extract text content from any message type. */ @@ -84,44 +166,55 @@ function extractFileOpsFromToolCalls(message: any, fileOps: FileOperations): voi } /** - * Prepare entries for summarization. + * Prepare entries for summarization with token budget. * - * Extracts: - * - Messages (user, assistant text, custom_message) - * - File operations from tool calls - * - Previous branch summaries + * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget. + * This ensures we keep the most recent context when the branch is too long. + * + * Handles: + * - message (user, assistant) - extracts text, counts tokens + * - custom_message - treated as user message + * - branch_summary - included as context + * - compaction - includes summary as context * * Skips: - * - toolResult messages (context already in assistant message) + * - toolResult messages (context already in assistant's tool call) * - thinking_level_change, model_change, custom, label entries - * - compaction entries (these are boundaries, shouldn't be in the input) + * + * @param entries - Entries in chronological order + * @param tokenBudget - Maximum tokens to include (0 = no limit) */ -export function prepareBranchEntries(entries: SessionEntry[]): BranchPreparation { - const messages: Array<{ role: string; content: string }> = []; +export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation { + const messages: Array<{ role: string; content: string; tokens: number }> = []; const fileOps: FileOperations = { read: new Set(), written: new Set(), edited: new Set(), }; - const previousSummaries: string[] = []; + let totalTokens = 0; + + // Walk from newest to oldest to prioritize recent context + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + let role: string | undefined; + let content: string | undefined; - for (const entry of entries) { switch (entry.type) { case "message": { - const role = entry.message.role; + const msgRole = entry.message.role; - // Skip tool results - the context is in the assistant's tool call - if (role === "toolResult") continue; + // Skip tool results - context is in assistant's tool call + if (msgRole === "toolResult") continue; // Extract file ops from assistant tool calls - if (role === "assistant") { + if (msgRole === "assistant") { extractFileOpsFromToolCalls(entry.message, fileOps); } - // Extract text content const text = extractMessageText(entry.message); if (text) { - messages.push({ role, content: text }); + role = msgRole; + content = text; } break; } @@ -135,27 +228,56 @@ export function prepareBranchEntries(entries: SessionEntry[]): BranchPreparation .map((c) => c.text) .join(""); if (text) { - messages.push({ role: "user", content: text }); + role = "user"; + content = text; } break; } case "branch_summary": { - previousSummaries.push(entry.summary); + role = "context"; + content = `[Branch summary: ${entry.summary}]`; break; } - // Skip these entry types - they don't contribute to conversation content - case "compaction": + case "compaction": { + role = "context"; + content = `[Session summary: ${entry.summary}]`; + break; + } + + // Skip these - don't contribute to conversation content case "thinking_level_change": case "model_change": case "custom": case "label": + continue; + } + + if (role && content) { + const tokens = estimateStringTokens(content); + + // Check budget before adding + if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { + // If this is a summary entry, try to fit it anyway as it's important context + if (entry.type === "compaction" || entry.type === "branch_summary") { + // Add truncated version or skip + if (totalTokens < tokenBudget * 0.9) { + // Still have some room, add it + messages.unshift({ role, content, tokens }); + totalTokens += tokens; + } + } + // Stop - we've hit the budget break; + } + + messages.unshift({ role, content, tokens }); + totalTokens += tokens; } } - return { messages, fileOps, previousSummaries }; + return { messages, fileOps, totalTokens }; } // ============================================================================ @@ -202,37 +324,27 @@ function formatFileOperations(fileOps: FileOperations): string { /** * Generate a summary of abandoned branch entries. * - * @param entries - Session entries to summarize - * @param model - Model to use for summarization - * @param apiKey - API key for the model - * @param signal - Abort signal for cancellation - * @param customInstructions - Optional custom instructions for summarization + * @param entries - Session entries to summarize (chronological order) + * @param options - Generation options */ export async function generateBranchSummary( entries: SessionEntry[], - model: Model, - apiKey: string, - signal: AbortSignal, - customInstructions?: string, + options: GenerateBranchSummaryOptions, ): Promise { - const { messages, fileOps, previousSummaries } = prepareBranchEntries(entries); + const { model, apiKey, signal, customInstructions, reserveFraction = 0.2 } = options; + + // Calculate token budget (leave room for summary generation) + const contextWindow = model.contextWindow || 128000; + const tokenBudget = Math.floor(contextWindow * (1 - reserveFraction)); + + const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); if (messages.length === 0) { return { summary: "No content to summarize" }; } // Build conversation text - const parts: string[] = []; - - // Include previous summaries as context - if (previousSummaries.length > 0) { - parts.push(`[Previous context: ${previousSummaries.join(" | ")}]`); - } - - // Add conversation - parts.push(messages.map((m) => `${m.role}: ${m.content}`).join("\n\n")); - - const conversationText = parts.join("\n\n"); + const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"); const instructions = customInstructions || BRANCH_SUMMARY_PROMPT; const prompt = `${instructions}\n\nConversation:\n${conversationText}`; @@ -248,7 +360,7 @@ export async function generateBranchSummary( }, ], }, - { apiKey, signal, maxTokens: 1024 }, + { apiKey, signal, maxTokens: 2048 }, ); // Check if aborted or errored diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index fc49f2ae..cb0806ed 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -10,3 +10,4 @@ export { export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; export type * from "./types.js"; +export type { ReadonlySessionManager } from "../session-manager.js"; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index ccf6e4ea..d8c2b420 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -155,6 +155,20 @@ export interface SessionInfo { allMessagesText: string; } +/** + * Read-only interface for SessionManager. + * Used by compaction/summarization utilities that only need to read session data. + */ +export interface ReadonlySessionManager { + getLeafId(): string | null; + getEntry(id: string): SessionEntry | undefined; + getPath(fromId?: string): SessionEntry[]; + getEntries(): SessionEntry[]; + getChildren(parentId: string): SessionEntry[]; + getTree(): SessionTreeNode[]; + getLabel(id: string): string | undefined; +} + /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 44419113..1dae4688 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -14,15 +14,18 @@ export { type ApiKeyCredential, type AuthCredential, AuthStorage, type OAuthCred export { type BranchPreparation, type BranchSummaryResult, + type CollectEntriesResult, type CompactionResult, type CutPointResult, calculateContextTokens, + collectEntriesForBranchSummary, compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, type FileOperations, findCutPoint, findTurnStartIndex, + type GenerateBranchSummaryOptions, generateBranchSummary, generateSummary, getLastAssistantUsage, From 839a46e6fe8fa65bfd964020fde8e07effd3c048 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:33:04 +0100 Subject: [PATCH 127/161] Use AgentMessage in BranchPreparation and add BranchSummarySettings - BranchPreparation now uses AgentMessage[] instead of custom type - Reuse getMessageFromEntry pattern from compaction.ts - Add BranchSummarySettings with reserveFraction to settings.json - Add getBranchSummarySettings() to SettingsManager - Use settings for reserveFraction instead of hardcoded value --- .../coding-agent/src/core/agent-session.ts | 2 + .../core/compaction/branch-summarization.ts | 204 +++++++++--------- .../coding-agent/src/core/settings-manager.ts | 11 + 3 files changed, 110 insertions(+), 107 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c7277c3c..24f182c6 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1650,11 +1650,13 @@ export class AgentSession { if (!apiKey) { throw new Error(`No API key for ${model.provider}`); } + const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); const result = await generateBranchSummary(entriesToSummarize, { model, apiKey, signal: this._branchSummaryAbortController.signal, customInstructions: options.customInstructions, + reserveFraction: branchSummarySettings.reserveFraction, }); this._branchSummaryAbortController = undefined; if (result.aborted) { diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index d79fd301..f621518e 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -5,9 +5,12 @@ * a summary of the branch being left so context isn't lost. */ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; +import { createBranchSummaryMessage, createCompactionSummaryMessage, createHookMessage } from "../messages.js"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js"; +import { estimateTokens } from "./compaction.js"; // ============================================================================ // Types @@ -27,10 +30,10 @@ export interface FileOperations { export interface BranchPreparation { /** Messages extracted for summarization, in chronological order */ - messages: Array<{ role: string; content: string; tokens: number }>; + messages: AgentMessage[]; /** File operations extracted from tool calls */ fileOps: FileOperations; - /** Total tokens in messages */ + /** Total estimated tokens in messages */ totalTokens: number; } @@ -110,40 +113,49 @@ export function collectEntriesForBranchSummary( } // ============================================================================ -// Entry Parsing +// Entry to Message Conversion // ============================================================================ /** - * Estimate token count for a string using chars/4 heuristic. + * Extract AgentMessage from a session entry. + * Similar to getMessageFromEntry in compaction.ts but also handles compaction entries. */ -function estimateStringTokens(text: string): number { - return Math.ceil(text.length / 4); -} +function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { + switch (entry.type) { + case "message": + // Skip tool results - context is in assistant's tool call + if (entry.message.role === "toolResult") return undefined; + return entry.message; -/** - * Extract text content from any message type. - */ -function extractMessageText(message: any): string { - if (!message.content) return ""; - if (typeof message.content === "string") return message.content; - if (Array.isArray(message.content)) { - return message.content - .filter((c: any) => c.type === "text") - .map((c: any) => c.text) - .join(""); + case "custom_message": + return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + + case "branch_summary": + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + + case "compaction": + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + + // These don't contribute to conversation content + case "thinking_level_change": + case "model_change": + case "custom": + case "label": + return undefined; } - return ""; } /** * Extract file operations from tool calls in an assistant message. */ -function extractFileOpsFromToolCalls(message: any, fileOps: FileOperations): void { - if (!message.content || !Array.isArray(message.content)) return; +function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== "assistant") return; + if (!("content" in message) || !Array.isArray(message.content)) return; for (const block of message.content) { if (typeof block !== "object" || block === null) continue; - if (block.type !== "toolCall") continue; + if (!("type" in block) || block.type !== "toolCall") continue; + if (!("arguments" in block) || !("name" in block)) continue; const args = block.arguments as Record | undefined; if (!args) continue; @@ -171,21 +183,11 @@ function extractFileOpsFromToolCalls(message: any, fileOps: FileOperations): voi * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget. * This ensures we keep the most recent context when the branch is too long. * - * Handles: - * - message (user, assistant) - extracts text, counts tokens - * - custom_message - treated as user message - * - branch_summary - included as context - * - compaction - includes summary as context - * - * Skips: - * - toolResult messages (context already in assistant's tool call) - * - thinking_level_change, model_change, custom, label entries - * * @param entries - Entries in chronological order * @param tokenBudget - Maximum tokens to include (0 = no limit) */ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation { - const messages: Array<{ role: string; content: string; tokens: number }> = []; + const messages: AgentMessage[] = []; const fileOps: FileOperations = { read: new Set(), written: new Set(), @@ -196,85 +198,29 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe // Walk from newest to oldest to prioritize recent context for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; - let role: string | undefined; - let content: string | undefined; + const message = getMessageFromEntry(entry); + if (!message) continue; - switch (entry.type) { - case "message": { - const msgRole = entry.message.role; + // Extract file ops from assistant messages + extractFileOpsFromMessage(message, fileOps); - // Skip tool results - context is in assistant's tool call - if (msgRole === "toolResult") continue; + const tokens = estimateTokens(message); - // Extract file ops from assistant tool calls - if (msgRole === "assistant") { - extractFileOpsFromToolCalls(entry.message, fileOps); + // Check budget before adding + if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { + // If this is a summary entry, try to fit it anyway as it's important context + if (entry.type === "compaction" || entry.type === "branch_summary") { + if (totalTokens < tokenBudget * 0.9) { + messages.unshift(message); + totalTokens += tokens; } - - const text = extractMessageText(entry.message); - if (text) { - role = msgRole; - content = text; - } - break; } - - case "custom_message": { - const text = - typeof entry.content === "string" - ? entry.content - : entry.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - if (text) { - role = "user"; - content = text; - } - break; - } - - case "branch_summary": { - role = "context"; - content = `[Branch summary: ${entry.summary}]`; - break; - } - - case "compaction": { - role = "context"; - content = `[Session summary: ${entry.summary}]`; - break; - } - - // Skip these - don't contribute to conversation content - case "thinking_level_change": - case "model_change": - case "custom": - case "label": - continue; + // Stop - we've hit the budget + break; } - if (role && content) { - const tokens = estimateStringTokens(content); - - // Check budget before adding - if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { - // If this is a summary entry, try to fit it anyway as it's important context - if (entry.type === "compaction" || entry.type === "branch_summary") { - // Add truncated version or skip - if (totalTokens < tokenBudget * 0.9) { - // Still have some room, add it - messages.unshift({ role, content, tokens }); - totalTokens += tokens; - } - } - // Stop - we've hit the budget - break; - } - - messages.unshift({ role, content, tokens }); - totalTokens += tokens; - } + messages.unshift(message); + totalTokens += tokens; } return { messages, fileOps, totalTokens }; @@ -321,6 +267,50 @@ function formatFileOperations(fileOps: FileOperations): string { return `\n\n---\n**Files:**\n${sections.join("\n")}`; } +/** + * Convert messages to text for the summarization prompt. + */ +function messagesToText(messages: AgentMessage[]): string { + const parts: string[] = []; + + for (const msg of messages) { + let text = ""; + + if (msg.role === "user" && typeof msg.content === "string") { + text = msg.content; + } else if (msg.role === "user" && Array.isArray(msg.content)) { + text = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else if (msg.role === "assistant" && "content" in msg && Array.isArray(msg.content)) { + text = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else if (msg.role === "branchSummary" && "summary" in msg) { + text = `[Branch summary: ${msg.summary}]`; + } else if (msg.role === "compactionSummary" && "summary" in msg) { + text = `[Session summary: ${msg.summary}]`; + } else if (msg.role === "hookMessage" && "content" in msg) { + if (typeof msg.content === "string") { + text = msg.content; + } else if (Array.isArray(msg.content)) { + text = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } + } + + if (text) { + parts.push(`${msg.role}: ${text}`); + } + } + + return parts.join("\n\n"); +} + /** * Generate a summary of abandoned branch entries. * @@ -343,8 +333,8 @@ export async function generateBranchSummary( return { summary: "No content to summarize" }; } - // Build conversation text - const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"); + // Build prompt + const conversationText = messagesToText(messages); const instructions = customInstructions || BRANCH_SUMMARY_PROMPT; const prompt = `${instructions}\n\nConversation:\n${conversationText}`; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 1e922c08..b957fd51 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -8,6 +8,10 @@ export interface CompactionSettings { keepRecentTokens?: number; // default: 20000 } +export interface BranchSummarySettings { + reserveFraction?: number; // default: 0.2 (fraction of context window reserved for summary) +} + export interface RetrySettings { enabled?: boolean; // default: true maxRetries?: number; // default: 3 @@ -38,6 +42,7 @@ export interface Settings { queueMode?: "all" | "one-at-a-time"; theme?: string; compaction?: CompactionSettings; + branchSummary?: BranchSummarySettings; retry?: RetrySettings; hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) @@ -255,6 +260,12 @@ export class SettingsManager { }; } + getBranchSummarySettings(): { reserveFraction: number } { + return { + reserveFraction: this.settings.branchSummary?.reserveFraction ?? 0.2, + }; + } + getRetryEnabled(): boolean { return this.settings.retry?.enabled ?? true; } From f5f39f08f1c3914c43e009fd864f1bd72ef2aa07 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:34:50 +0100 Subject: [PATCH 128/161] Use token-based maxTokens instead of fraction-based reserveFraction - BranchSummarySettings.maxTokens (default 100000) instead of reserveFraction - More intuitive and consistent with CompactionSettings.keepRecentTokens --- packages/coding-agent/src/core/agent-session.ts | 2 +- .../src/core/compaction/branch-summarization.ts | 12 ++++-------- packages/coding-agent/src/core/settings-manager.ts | 6 +++--- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 24f182c6..eaca341c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1656,7 +1656,7 @@ export class AgentSession { apiKey, signal: this._branchSummaryAbortController.signal, customInstructions: options.customInstructions, - reserveFraction: branchSummarySettings.reserveFraction, + maxTokens: branchSummarySettings.maxTokens, }); this._branchSummaryAbortController = undefined; if (result.aborted) { diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index f621518e..c303ca08 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -53,8 +53,8 @@ export interface GenerateBranchSummaryOptions { signal: AbortSignal; /** Optional custom instructions for summarization */ customInstructions?: string; - /** Reserve this fraction of context window for summary (default 0.2) */ - reserveFraction?: number; + /** Maximum tokens to include in summary context (default 100000) */ + maxTokens?: number; } // ============================================================================ @@ -321,13 +321,9 @@ export async function generateBranchSummary( entries: SessionEntry[], options: GenerateBranchSummaryOptions, ): Promise { - const { model, apiKey, signal, customInstructions, reserveFraction = 0.2 } = options; + const { model, apiKey, signal, customInstructions, maxTokens = 100000 } = options; - // Calculate token budget (leave room for summary generation) - const contextWindow = model.contextWindow || 128000; - const tokenBudget = Math.floor(contextWindow * (1 - reserveFraction)); - - const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); + const { messages, fileOps } = prepareBranchEntries(entries, maxTokens); if (messages.length === 0) { return { summary: "No content to summarize" }; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index b957fd51..737a812e 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -9,7 +9,7 @@ export interface CompactionSettings { } export interface BranchSummarySettings { - reserveFraction?: number; // default: 0.2 (fraction of context window reserved for summary) + maxTokens?: number; // default: 100000 (max tokens to include in branch summary context) } export interface RetrySettings { @@ -260,9 +260,9 @@ export class SettingsManager { }; } - getBranchSummarySettings(): { reserveFraction: number } { + getBranchSummarySettings(): { maxTokens: number } { return { - reserveFraction: this.settings.branchSummary?.reserveFraction ?? 0.2, + maxTokens: this.settings.branchSummary?.maxTokens ?? 100000, }; } From dc5fc4fc40e1767a4325e916d2323547bbdd0b41 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:36:23 +0100 Subject: [PATCH 129/161] Use reserveTokens for branch summary (tokens left for prompt + response) - tokenBudget = contextWindow - reserveTokens - Default 16384, same as compaction - Consistent naming with CompactionSettings.reserveTokens --- packages/coding-agent/src/core/agent-session.ts | 2 +- .../src/core/compaction/branch-summarization.ts | 12 ++++++++---- packages/coding-agent/src/core/settings-manager.ts | 6 +++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index eaca341c..2aae1195 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1656,7 +1656,7 @@ export class AgentSession { apiKey, signal: this._branchSummaryAbortController.signal, customInstructions: options.customInstructions, - maxTokens: branchSummarySettings.maxTokens, + reserveTokens: branchSummarySettings.reserveTokens, }); this._branchSummaryAbortController = undefined; if (result.aborted) { diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index c303ca08..a5897455 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -53,8 +53,8 @@ export interface GenerateBranchSummaryOptions { signal: AbortSignal; /** Optional custom instructions for summarization */ customInstructions?: string; - /** Maximum tokens to include in summary context (default 100000) */ - maxTokens?: number; + /** Tokens reserved for prompt + LLM response (default 16384) */ + reserveTokens?: number; } // ============================================================================ @@ -321,9 +321,13 @@ export async function generateBranchSummary( entries: SessionEntry[], options: GenerateBranchSummaryOptions, ): Promise { - const { model, apiKey, signal, customInstructions, maxTokens = 100000 } = options; + const { model, apiKey, signal, customInstructions, reserveTokens = 16384 } = options; - const { messages, fileOps } = prepareBranchEntries(entries, maxTokens); + // Token budget = context window minus reserved space for prompt + response + const contextWindow = model.contextWindow || 128000; + const tokenBudget = contextWindow - reserveTokens; + + const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); if (messages.length === 0) { return { summary: "No content to summarize" }; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 737a812e..4d58a5c1 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -9,7 +9,7 @@ export interface CompactionSettings { } export interface BranchSummarySettings { - maxTokens?: number; // default: 100000 (max tokens to include in branch summary context) + reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response) } export interface RetrySettings { @@ -260,9 +260,9 @@ export class SettingsManager { }; } - getBranchSummarySettings(): { maxTokens: number } { + getBranchSummarySettings(): { reserveTokens: number } { return { - maxTokens: this.settings.branchSummary?.maxTokens ?? 100000, + reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384, }; } From e7bfb5afe762ff9f0c8fe56903367314826aec6d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:37:24 +0100 Subject: [PATCH 130/161] Fix file ops: Read (not modified) and Modified (edited or written) - Read: files only read, not modified - Modified: files that were edited OR written - Avoids duplicate listings when same file is read then edited --- .../core/compaction/branch-summarization.ts | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index a5897455..bc67fa2d 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -242,24 +242,21 @@ Be brief and focused on what matters for future reference.`; * Format file operations as a static section to append to summary. */ function formatFileOperations(fileOps: FileOperations): string { + // Combine edited and written into "modified" + const modified = new Set([...fileOps.edited, ...fileOps.written]); + + // Read-only = read but not modified + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const sections: string[] = []; - if (fileOps.read.size > 0) { - const files = [...fileOps.read].sort(); - sections.push(`**Read:** ${files.join(", ")}`); + if (readOnly.length > 0) { + sections.push(`**Read:** ${readOnly.join(", ")}`); } - if (fileOps.edited.size > 0) { - const files = [...fileOps.edited].sort(); - sections.push(`**Edited:** ${files.join(", ")}`); - } - - if (fileOps.written.size > 0) { - // Exclude files that were also edited (edit implies write) - const writtenOnly = [...fileOps.written].filter((f) => !fileOps.edited.has(f)).sort(); - if (writtenOnly.length > 0) { - sections.push(`**Created:** ${writtenOnly.join(", ")}`); - } + if (modified.size > 0) { + const files = [...modified].sort(); + sections.push(`**Modified:** ${files.join(", ")}`); } if (sections.length === 0) return ""; From 04f2fcf004015ffecea34616ca72e53bde99cbb1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:39:53 +0100 Subject: [PATCH 131/161] Use XML tags for file operations in branch summary and with one path per line --- .../src/core/compaction/branch-summarization.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index bc67fa2d..fae49294 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -251,17 +251,17 @@ function formatFileOperations(fileOps: FileOperations): string { const sections: string[] = []; if (readOnly.length > 0) { - sections.push(`**Read:** ${readOnly.join(", ")}`); + sections.push(`\n${readOnly.join("\n")}\n`); } if (modified.size > 0) { const files = [...modified].sort(); - sections.push(`**Modified:** ${files.join(", ")}`); + sections.push(`\n${files.join("\n")}\n`); } if (sections.length === 0) return ""; - return `\n\n---\n**Files:**\n${sections.join("\n")}`; + return `\n\n${sections.join("\n\n")}`; } /** From 4ef3325ceca09e2ef27234d33c42007e2a73ed38 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:50:03 +0100 Subject: [PATCH 132/161] Store file lists in BranchSummaryEntry.details for cumulative tracking - BranchSummaryResult now returns readFiles and modifiedFiles separately - BranchSummaryDetails type for details: { readFiles, modifiedFiles } - branchWithSummary accepts optional details parameter - Collect files from existing branch_summary.details when preparing entries - Files accumulate across nested branch summaries --- packages/ai/src/models.generated.ts | 122 +++++++++--------- .../coding-agent/src/core/agent-session.ts | 8 +- .../core/compaction/branch-summarization.ts | 73 ++++++----- .../coding-agent/src/core/session-manager.ts | 3 +- 4 files changed, 111 insertions(+), 95 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6f32cbb3..795336b6 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3835,13 +3835,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39, - output: 1.9, + input: 0.35, + output: 1.5, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 204800, - maxTokens: 204800, + contextWindow: 202752, + maxTokens: 65536, } satisfies Model<"openai-completions">, "z-ai/glm-4.6:exacto": { id: "z-ai/glm-4.6:exacto", @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,6 +6359,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6393,23 +6410,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,6 +6546,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6580,23 +6597,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4": { id: "openai/gpt-4", name: "OpenAI: GPT-4", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 2aae1195..2db04ad4 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1644,6 +1644,7 @@ export class AgentSession { // Run default summarizer if needed let summaryText: string | undefined; + let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { const model = this.model!; const apiKey = await this._modelRegistry.getApiKey(model); @@ -1666,8 +1667,13 @@ export class AgentSession { throw new Error(result.error); } summaryText = result.summary; + summaryDetails = { + readFiles: result.readFiles || [], + modifiedFiles: result.modifiedFiles || [], + }; } else if (hookSummary) { summaryText = hookSummary.summary; + summaryDetails = hookSummary.details; } // Determine the new leaf position based on target type @@ -1698,7 +1704,7 @@ export class AgentSession { let summaryEntry: BranchSummaryEntry | undefined; if (summaryText) { // Create summary at target position (can be null for root) - const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText); + const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; } else if (newLeafId === null) { // No summary, navigating to root - reset leaf diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index fae49294..a0a750e6 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -18,10 +18,18 @@ import { estimateTokens } from "./compaction.js"; export interface BranchSummaryResult { summary?: string; + readFiles?: string[]; + modifiedFiles?: string[]; aborted?: boolean; error?: string; } +/** Details stored in BranchSummaryEntry.details for file tracking */ +export interface BranchSummaryDetails { + readFiles: string[]; + modifiedFiles: string[]; +} + export interface FileOperations { read: Set; written: Set; @@ -183,6 +191,10 @@ function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperation * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget. * This ensures we keep the most recent context when the branch is too long. * + * Also collects file operations from: + * - Tool calls in assistant messages + * - Existing branch_summary entries' details (for cumulative tracking) + * * @param entries - Entries in chronological order * @param tokenBudget - Maximum tokens to include (0 = no limit) */ @@ -195,13 +207,30 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe }; let totalTokens = 0; - // Walk from newest to oldest to prioritize recent context + // First pass: collect file ops from ALL entries (even if they don't fit in token budget) + // This ensures we capture cumulative file tracking from nested branch summaries + for (const entry of entries) { + if (entry.type === "branch_summary" && entry.details) { + const details = entry.details as BranchSummaryDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) fileOps.read.add(f); + } + if (Array.isArray(details.modifiedFiles)) { + // Modified files go into both edited and written for proper deduplication + for (const f of details.modifiedFiles) { + fileOps.edited.add(f); + } + } + } + } + + // Second pass: walk from newest to oldest, adding messages until token budget for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; const message = getMessageFromEntry(entry); if (!message) continue; - // Extract file ops from assistant messages + // Extract file ops from assistant messages (tool calls) extractFileOpsFromMessage(message, fileOps); const tokens = estimateTokens(message); @@ -238,32 +267,6 @@ const BRANCH_SUMMARY_PROMPT = `Summarize this conversation branch concisely for Be brief and focused on what matters for future reference.`; -/** - * Format file operations as a static section to append to summary. - */ -function formatFileOperations(fileOps: FileOperations): string { - // Combine edited and written into "modified" - const modified = new Set([...fileOps.edited, ...fileOps.written]); - - // Read-only = read but not modified - const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); - - const sections: string[] = []; - - if (readOnly.length > 0) { - sections.push(`\n${readOnly.join("\n")}\n`); - } - - if (modified.size > 0) { - const files = [...modified].sort(); - sections.push(`\n${files.join("\n")}\n`); - } - - if (sections.length === 0) return ""; - - return `\n\n${sections.join("\n\n")}`; -} - /** * Convert messages to text for the summarization prompt. */ @@ -358,13 +361,19 @@ export async function generateBranchSummary( return { error: response.errorMessage || "Summarization failed" }; } - let summary = response.content + const summary = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n"); - // Append static file operations section - summary += formatFileOperations(fileOps); + // Compute file lists for details + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const modifiedFiles = [...modified].sort(); - return { summary: summary || "No summary generated" }; + return { + summary: summary || "No summary generated", + readFiles: readOnly, + modifiedFiles, + }; } diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index d8c2b420..f1adda2a 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -872,7 +872,7 @@ export class SessionManager { * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ - branchWithSummary(branchFromId: string | null, summary: string): string { + branchWithSummary(branchFromId: string | null, summary: string, details?: unknown): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } @@ -884,6 +884,7 @@ export class SessionManager { timestamp: new Date().toISOString(), fromId: branchFromId ?? "root", summary, + details, }; this._appendEntry(entry); return entry.id; From 9427211f99cc9084e8888f4b8adfebc05f7daab1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 21:57:24 +0100 Subject: [PATCH 133/161] fix(tui): render HTML tags as plain text in Markdown component Handles both block-level and inline HTML tags that were previously silently dropped. fixes #359 --- packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/components/markdown.ts | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 298f5c39..e1c04859 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Markdown component now renders HTML tags as plain text instead of silently dropping them ([#359](https://github.com/badlogic/pi-mono/issues/359)) + ## [0.29.0] - 2025-12-25 ### Added diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index c43f8f2f..857af283 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -317,7 +317,10 @@ export class Markdown implements Component { break; case "html": - // Skip HTML for terminal output + // Render HTML as plain text (escaped for terminal) + if ("raw" in token && typeof token.raw === "string") { + lines.push(this.applyDefaultStyle(token.raw.trim())); + } break; case "space": @@ -394,6 +397,13 @@ export class Markdown implements Component { break; } + case "html": + // Render inline HTML as plain text + if ("raw" in token && typeof token.raw === "string") { + result += this.applyDefaultStyle(token.raw); + } + break; + default: // Handle any other inline token types as plain text if ("text" in token && typeof token.text === "string") { From d1a49c45ff6e6d1448831207417355c2d650f26b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 22:09:31 +0100 Subject: [PATCH 134/161] Append file lists to summary text for LLM context and TUI display Files are included both: - In summary text as / tags (visible to LLM and TUI) - In details for structured access by code --- .../src/core/compaction/branch-summarization.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index a0a750e6..4cb66dbf 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -361,16 +361,28 @@ export async function generateBranchSummary( return { error: response.errorMessage || "Summarization failed" }; } - const summary = response.content + let summary = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n"); - // Compute file lists for details + // Compute file lists const modified = new Set([...fileOps.edited, ...fileOps.written]); const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); const modifiedFiles = [...modified].sort(); + // Append file lists to summary text (for LLM context and TUI display) + const fileSections: string[] = []; + if (readOnly.length > 0) { + fileSections.push(`\n${readOnly.join("\n")}\n`); + } + if (modifiedFiles.length > 0) { + fileSections.push(`\n${modifiedFiles.join("\n")}\n`); + } + if (fileSections.length > 0) { + summary += `\n\n${fileSections.join("\n\n")}`; + } + return { summary: summary || "No summary generated", readFiles: readOnly, From ac71aac0904518f04007edaf356f51bd7ad4c7bd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 22:17:51 +0100 Subject: [PATCH 135/161] Use structured output format for compaction and branch summarization Both now use consistent sections: - Goal - Constraints & Preferences - Progress (Done/In Progress/Blocked) - Key Decisions - Next Steps - Critical Context (compaction only) Prompts instruct LLM to use EXACT format and preserve file paths/function names. --- .../core/compaction/branch-summarization.ts | 33 +++++++++-- .../src/core/compaction/compaction.ts | 57 ++++++++++++++----- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 4cb66dbf..5c7dd3af 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -259,13 +259,34 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe // Summary Generation // ============================================================================ -const BRANCH_SUMMARY_PROMPT = `Summarize this conversation branch concisely for context when returning later: -- Key decisions made and actions taken -- Important context, constraints, or preferences discovered -- Current state and any pending work -- Critical information needed to continue from a different point +const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later. -Be brief and focused on what matters for future reference.`; +Use this EXACT format: + +## Goal +[1-2 sentences: What was the user trying to accomplish in this branch?] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Work that was started but not finished] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [What should happen next to continue this work] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; /** * Convert messages to text for the summarization prompt. diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 61e4b950..e7e362ee 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -328,16 +328,38 @@ export function findCutPoint( // Summarization // ============================================================================ -const SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. +const SUMMARIZATION_PROMPT = `Create a structured context checkpoint summary. Another LLM will use this to continue the work. -Include: -- Current progress and key decisions made -- Important context, constraints, or user preferences -- Absolute file paths of any relevant files that were read or modified -- What remains to be done (clear next steps) -- Any critical data, examples, or references needed to continue +Use this EXACT format: -Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`; +## Goal +[1-2 sentences: What is the user trying to accomplish?] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; /** * Generate a summary of the conversation using the LLM. @@ -451,15 +473,20 @@ export function prepareCompaction( // Main compaction function // ============================================================================ -const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn. -This is the PREFIX of a turn that was too large to keep in full. The SUFFIX (recent work) is being kept. +const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. -Create a handoff summary that captures: -- What the user originally asked for in this turn -- Key decisions and progress made early in this turn -- Important context needed to understand the kept suffix +Summarize the prefix to provide context for the retained suffix: -Be concise. Focus on information needed to understand the retained recent work.`; +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.`; /** * Calculate compaction and generate summary. From a602e8aba8d673fbd969ff005e64e40bf9e75040 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 22:18:24 +0100 Subject: [PATCH 136/161] Remove restrictive sentence limits from Goal section --- .../coding-agent/src/core/compaction/branch-summarization.ts | 2 +- packages/coding-agent/src/core/compaction/compaction.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 5c7dd3af..952676be 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -264,7 +264,7 @@ const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation Use this EXACT format: ## Goal -[1-2 sentences: What was the user trying to accomplish in this branch?] +[What was the user trying to accomplish in this branch?] ## Constraints & Preferences - [Any constraints, preferences, or requirements mentioned] diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index e7e362ee..feb8f1cd 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -333,7 +333,7 @@ const SUMMARIZATION_PROMPT = `Create a structured context checkpoint summary. An Use this EXACT format: ## Goal -[1-2 sentences: What is the user trying to accomplish?] +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] ## Constraints & Preferences - [Any constraints, preferences, or requirements mentioned by user] From 92947a3dc450e1f8bd1eded4682bfe6766a1cbcc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 22:26:31 +0100 Subject: [PATCH 137/161] Fix common ancestor finding: iterate backwards to find deepest ancestor getPath returns root-first, so iterating forward found root as common ancestor instead of the deepest shared node. Now iterates backwards. --- .../src/core/compaction/branch-summarization.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 952676be..1d37158c 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -91,14 +91,15 @@ export function collectEntriesForBranchSummary( return { entries: [], commonAncestorId: null }; } - // Find common ancestor + // Find common ancestor (deepest node that's on both paths) const oldPath = new Set(session.getPath(oldLeafId).map((e) => e.id)); const targetPath = session.getPath(targetId); + // targetPath is root-first, so iterate backwards to find deepest common ancestor let commonAncestorId: string | null = null; - for (const entry of targetPath) { - if (oldPath.has(entry.id)) { - commonAncestorId = entry.id; + for (let i = targetPath.length - 1; i >= 0; i--) { + if (oldPath.has(targetPath[i].id)) { + commonAncestorId = targetPath[i].id; break; } } From 0fe9f74b4ec2ad7fc7f525f9c3f21c29e8dcb36c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 22:30:24 +0100 Subject: [PATCH 138/161] Return defensive copies from SettingsManager getters getHookPaths(), getCustomToolPaths(), and getSkillsSettings() now return copies of arrays instead of references to internal state. This prevents callers from accidentally mutating settings without going through setters. Fixes #361 --- packages/coding-agent/src/core/settings-manager.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4d58a5c1..3a116d63 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -314,7 +314,7 @@ export class SettingsManager { } getHookPaths(): string[] { - return this.settings.hooks ?? []; + return [...(this.settings.hooks ?? [])]; } setHookPaths(paths: string[]): void { @@ -332,7 +332,7 @@ export class SettingsManager { } getCustomToolPaths(): string[] { - return this.settings.customTools ?? []; + return [...(this.settings.customTools ?? [])]; } setCustomToolPaths(paths: string[]): void { @@ -360,9 +360,9 @@ export class SettingsManager { enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true, enablePiUser: this.settings.skills?.enablePiUser ?? true, enablePiProject: this.settings.skills?.enablePiProject ?? true, - customDirectories: this.settings.skills?.customDirectories ?? [], - ignoredSkills: this.settings.skills?.ignoredSkills ?? [], - includeSkills: this.settings.skills?.includeSkills ?? [], + customDirectories: [...(this.settings.skills?.customDirectories ?? [])], + ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])], + includeSkills: [...(this.settings.skills?.includeSkills ?? [])], }; } From 2ba69878eda90b91951f08ce82dc18e1bbe9f5be Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 22:37:46 +0100 Subject: [PATCH 139/161] Fix branch summarization: use convertToLlm instead of messagesToText The old messagesToText only extracted text content, losing all tool calls. Now uses convertToLlm like compaction does, preserving the full conversation including tool calls, which is essential for generating useful summaries. --- .../core/compaction/branch-summarization.ts | 80 +++++-------------- 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 1d37158c..608c968b 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -8,7 +8,12 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; -import { createBranchSummaryMessage, createCompactionSummaryMessage, createHookMessage } from "../messages.js"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createHookMessage, +} from "../messages.js"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js"; import { estimateTokens } from "./compaction.js"; @@ -289,50 +294,6 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; -/** - * Convert messages to text for the summarization prompt. - */ -function messagesToText(messages: AgentMessage[]): string { - const parts: string[] = []; - - for (const msg of messages) { - let text = ""; - - if (msg.role === "user" && typeof msg.content === "string") { - text = msg.content; - } else if (msg.role === "user" && Array.isArray(msg.content)) { - text = msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - } else if (msg.role === "assistant" && "content" in msg && Array.isArray(msg.content)) { - text = msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - } else if (msg.role === "branchSummary" && "summary" in msg) { - text = `[Branch summary: ${msg.summary}]`; - } else if (msg.role === "compactionSummary" && "summary" in msg) { - text = `[Session summary: ${msg.summary}]`; - } else if (msg.role === "hookMessage" && "content" in msg) { - if (typeof msg.content === "string") { - text = msg.content; - } else if (Array.isArray(msg.content)) { - text = msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - } - } - - if (text) { - parts.push(`${msg.role}: ${text}`); - } - } - - return parts.join("\n\n"); -} - /** * Generate a summary of abandoned branch entries. * @@ -355,25 +316,24 @@ export async function generateBranchSummary( return { summary: "No content to summarize" }; } + // Transform to LLM-compatible messages (preserves tool calls, etc.) + const transformedMessages = convertToLlm(messages); + // Build prompt - const conversationText = messagesToText(messages); const instructions = customInstructions || BRANCH_SUMMARY_PROMPT; - const prompt = `${instructions}\n\nConversation:\n${conversationText}`; + + // Append summarization prompt as final user message + const summarizationMessages = [ + ...transformedMessages, + { + role: "user" as const, + content: [{ type: "text" as const, text: instructions }], + timestamp: Date.now(), + }, + ]; // Call LLM for summarization - const response = await complete( - model, - { - messages: [ - { - role: "user", - content: [{ type: "text", text: prompt }], - timestamp: Date.now(), - }, - ], - }, - { apiKey, signal, maxTokens: 2048 }, - ); + const response = await complete(model, { messages: summarizationMessages }, { apiKey, signal, maxTokens: 2048 }); // Check if aborted or errored if (response.stopReason === "aborted") { From 8fe8fe9920b304521e5468c294ef4e0f75928fc4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 22:45:29 +0100 Subject: [PATCH 140/161] Add preamble to branch summary for context Prepends: 'The user explored a different conversation branch before returning here. Summary of that exploration:' This helps the LLM understand the summary is background context from a different path, not the current thread being continued. --- .../src/core/compaction/branch-summarization.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 608c968b..132fd2ec 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -265,6 +265,11 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe // Summary Generation // ============================================================================ +const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here. +Summary of that exploration: + +`; + const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later. Use this EXACT format: @@ -348,6 +353,9 @@ export async function generateBranchSummary( .map((c) => c.text) .join("\n"); + // Prepend preamble to provide context about the branch summary + summary = BRANCH_SUMMARY_PREAMBLE + summary; + // Compute file lists const modified = new Set([...fileOps.edited, ...fileOps.written]); const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); From 0445da666cdedd114dfc3db9431fe206156d7dfe Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 23:06:18 +0100 Subject: [PATCH 141/161] Fix compaction message rendering to go through addMessageToChat Both auto_compaction_end and executeCompaction were manually adding CompactionSummaryMessageComponent instead of using addMessageToChat, which caused missing spacers. Now both use addMessageToChat for consistent spacing and expansion state handling. --- .../src/modes/interactive/interactive-mode.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index bd680fdf..17fef8c0 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -992,15 +992,13 @@ export class InteractiveMode { // Rebuild chat to show compacted state this.chatContainer.clear(); this.rebuildChatFromMessages(); - // Add compaction component (same as manual /compact) - const compactionComponent = new CompactionSummaryMessageComponent({ + // Add compaction component at bottom so user sees it without scrolling + this.addMessageToChat({ role: "compactionSummary", tokensBefore: event.result.tokensBefore, summary: event.result.summary, timestamp: Date.now(), }); - compactionComponent.setExpanded(this.toolOutputExpanded); - this.chatContainer.addChild(compactionComponent); this.footer.updateState(this.session.state); } this.ui.requestRender(); @@ -2128,11 +2126,9 @@ export class InteractiveMode { // Rebuild UI this.rebuildChatFromMessages(); - // Add compaction component + // Add compaction component at bottom so user sees it without scrolling const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString()); - const compactionComponent = new CompactionSummaryMessageComponent(msg); - compactionComponent.setExpanded(this.toolOutputExpanded); - this.chatContainer.addChild(compactionComponent); + this.addMessageToChat(msg); this.footer.updateState(this.session.state); } catch (error) { From f118cdc67bc2b335a147e437151a9af0a47e7399 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 23:14:13 +0100 Subject: [PATCH 142/161] Add fromHook field to CompactionEntry and BranchSummaryEntry - fromHook: true = hook generated, skip file extraction - fromHook: undefined/false = pi generated, extract files (backward compatible) - branchWithSummary now accepts fromHook parameter - File extraction only runs for !entry.fromHook entries --- packages/coding-agent/src/core/agent-session.ts | 2 +- .../src/core/compaction/branch-summarization.ts | 3 ++- packages/coding-agent/src/core/session-manager.ts | 7 ++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 2db04ad4..f3c213d8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1704,7 +1704,7 @@ export class AgentSession { let summaryEntry: BranchSummaryEntry | undefined; if (summaryText) { // Create summary at target position (can be null for root) - const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails); + const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromHook); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; } else if (newLeafId === null) { // No summary, navigating to root - reset leaf diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 132fd2ec..3647afb8 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -215,8 +215,9 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe // First pass: collect file ops from ALL entries (even if they don't fit in token budget) // This ensures we capture cumulative file tracking from nested branch summaries + // Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones for (const entry of entries) { - if (entry.type === "branch_summary" && entry.details) { + if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { const details = entry.details as BranchSummaryDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index f1adda2a..11e4a146 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -64,6 +64,8 @@ export interface CompactionEntry extends SessionEntryBase { tokensBefore: number; /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; + /** True if generated by a hook, undefined/false if pi-generated (backward compatible) */ + fromHook?: boolean; } export interface BranchSummaryEntry extends SessionEntryBase { @@ -72,6 +74,8 @@ export interface BranchSummaryEntry extends SessionEntryBase { summary: string; /** Hook-specific data (not sent to LLM) */ details?: T; + /** True if generated by a hook, false if pi-generated */ + fromHook?: boolean; } /** @@ -872,7 +876,7 @@ export class SessionManager { * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ - branchWithSummary(branchFromId: string | null, summary: string, details?: unknown): string { + branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } @@ -885,6 +889,7 @@ export class SessionManager { fromId: branchFromId ?? "root", summary, details, + fromHook, }; this._appendEntry(entry); return entry.id; From d4dc07ab203d9d87cd00f0ed913dd8b5661b55a6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 23:18:03 +0100 Subject: [PATCH 143/161] Pass fromHook to appendCompaction for both manual and auto compaction --- packages/coding-agent/src/core/agent-session.ts | 4 ++-- packages/coding-agent/src/core/session-manager.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f3c213d8..89fe5395 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -965,7 +965,7 @@ export class AgentSession { throw new Error("Compaction cancelled"); } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); @@ -1139,7 +1139,7 @@ export class AgentSession { return; } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 11e4a146..c3e17714 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -641,7 +641,13 @@ export class SessionManager { } /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ - appendCompaction(summary: string, firstKeptEntryId: string, tokensBefore: number, details?: T): string { + appendCompaction( + summary: string, + firstKeptEntryId: string, + tokensBefore: number, + details?: T, + fromHook?: boolean, + ): string { const entry: CompactionEntry = { type: "compaction", id: generateId(this.byId), @@ -651,6 +657,7 @@ export class SessionManager { firstKeptEntryId, tokensBefore, details, + fromHook, }; this._appendEntry(entry); return entry.id; From 09d6131befe051f93a8667bf2ecb5f0eac1d8f46 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 23:26:01 +0100 Subject: [PATCH 144/161] Add file tracking and iterative summary merging to compaction - CompactionDetails type with readFiles/modifiedFiles - extractFileOperations collects from tool calls and previous compaction details - UPDATE_SUMMARIZATION_PROMPT for merging with previous summary - generateSummary now accepts previousSummary for iterative updates - compact() extracts files, passes previousSummary, returns details - Only merges from !fromHook compaction entries (backward compatible) --- .../src/core/compaction/compaction.ts | 226 ++++++++++++++++-- 1 file changed, 210 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index feb8f1cd..1f5f4091 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -11,6 +11,119 @@ import { complete } from "@mariozechner/pi-ai"; import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js"; import type { CompactionEntry, SessionEntry } from "../session-manager.js"; +// ============================================================================ +// File Operation Tracking +// ============================================================================ + +/** Details stored in CompactionEntry.details for file tracking */ +export interface CompactionDetails { + readFiles: string[]; + modifiedFiles: string[]; +} + +interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +/** + * Extract file operations from tool calls in an assistant message. + */ +function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== "assistant") return; + if (!("content" in message) || !Array.isArray(message.content)) return; + + for (const block of message.content) { + if (typeof block !== "object" || block === null) continue; + if (!("type" in block) || block.type !== "toolCall") continue; + if (!("arguments" in block) || !("name" in block)) continue; + + const args = block.arguments as Record | undefined; + if (!args) continue; + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) continue; + + switch (block.name) { + case "read": + fileOps.read.add(path); + break; + case "write": + fileOps.written.add(path); + break; + case "edit": + fileOps.edited.add(path); + break; + } + } +} + +/** + * Extract file operations from messages and previous compaction entries. + */ +function extractFileOperations( + messages: AgentMessage[], + entries: SessionEntry[], + prevCompactionIndex: number, +): FileOperations { + const fileOps: FileOperations = { + read: new Set(), + written: new Set(), + edited: new Set(), + }; + + // Collect from previous compaction's details (if pi-generated) + if (prevCompactionIndex >= 0) { + const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; + if (!prevCompaction.fromHook && prevCompaction.details) { + const details = prevCompaction.details as CompactionDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) fileOps.read.add(f); + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) fileOps.edited.add(f); + } + } + } + + // Extract from tool calls in messages + for (const msg of messages) { + extractFileOpsFromMessage(msg, fileOps); + } + + return fileOps; +} + +/** + * Compute final file lists from file operations. + */ +function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const modifiedFiles = [...modified].sort(); + return { readFiles: readOnly, modifiedFiles }; +} + +/** + * Format file operations as XML tags for summary. + */ +function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`\n${readFiles.join("\n")}\n`); + } + if (modifiedFiles.length > 0) { + sections.push(`\n${modifiedFiles.join("\n")}\n`); + } + if (sections.length === 0) return ""; + return `\n\n${sections.join("\n\n")}`; +} + +// ============================================================================ +// Message Extraction +// ============================================================================ + /** * Extract AgentMessage from an entry if it produces one. * Returns undefined for entries that don't contribute to LLM context. @@ -361,8 +474,48 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; +const UPDATE_SUMMARIZATION_PROMPT = `Update the existing structured summary with new information from the conversation. + +RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + /** * Generate a summary of the conversation using the LLM. + * If previousSummary is provided, uses the update prompt to merge. */ export async function generateSummary( currentMessages: AgentMessage[], @@ -371,24 +524,45 @@ export async function generateSummary( apiKey: string, signal?: AbortSignal, customInstructions?: string, + previousSummary?: string, ): Promise { const maxTokens = Math.floor(0.8 * reserveTokens); - const prompt = customInstructions - ? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}` - : SUMMARIZATION_PROMPT; + // Use update prompt if we have a previous summary, otherwise initial prompt + let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; + if (customInstructions) { + basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; + } // Transform custom messages (like bashExecution) to LLM-compatible messages const transformedMessages = convertToLlm(currentMessages); - const summarizationMessages = [ - ...transformedMessages, - { + // Build summarization messages + const summarizationMessages = []; + + // If we have a previous summary, include it as context + if (previousSummary) { + summarizationMessages.push({ role: "user" as const, - content: [{ type: "text" as const, text: prompt }], + content: [{ type: "text" as const, text: `PREVIOUS SUMMARY:\n\n${previousSummary}` }], timestamp: Date.now(), - }, - ]; + }); + summarizationMessages.push({ + role: "user" as const, + content: [{ type: "text" as const, text: "NEW MESSAGES TO INCORPORATE:" }], + timestamp: Date.now(), + }); + } + + // Add the conversation messages + summarizationMessages.push(...transformedMessages); + + // Add the prompt + summarizationMessages.push({ + role: "user" as const, + content: [{ type: "text" as const, text: basePrompt }], + timestamp: Date.now(), + }); const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey }); @@ -538,16 +712,18 @@ export async function compact( if (msg) historyMessages.push(msg); } - // Include previous summary if there was a compaction + // Get previous summary for iterative update (if not from hook) + let previousSummary: string | undefined; if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; - historyMessages.unshift({ - role: "user", - content: `Previous session summary:\n${prevCompaction.summary}`, - timestamp: Date.now(), - }); + if (!prevCompaction.fromHook) { + previousSummary = prevCompaction.summary; + } } + // Extract file operations from messages and previous compaction + const fileOps = extractFileOperations(historyMessages, entries, prevCompactionIndex); + // Extract messages for turn prefix summary (if splitting a turn) const turnPrefixMessages: AgentMessage[] = []; if (cutResult.isSplitTurn) { @@ -555,6 +731,10 @@ export async function compact( const msg = getMessageFromEntry(entries[i]); if (msg) turnPrefixMessages.push(msg); } + // Also extract file ops from turn prefix + for (const msg of turnPrefixMessages) { + extractFileOpsFromMessage(msg, fileOps); + } } // Generate summaries (can be parallel if both needed) and merge into one @@ -564,7 +744,15 @@ export async function compact( // Generate both summaries in parallel const [historyResult, turnPrefixResult] = await Promise.all([ historyMessages.length > 0 - ? generateSummary(historyMessages, model, settings.reserveTokens, apiKey, signal, customInstructions) + ? generateSummary( + historyMessages, + model, + settings.reserveTokens, + apiKey, + signal, + customInstructions, + previousSummary, + ) : Promise.resolve("No prior history."), generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal), ]); @@ -579,9 +767,14 @@ export async function compact( apiKey, signal, customInstructions, + previousSummary, ); } + // Compute file lists and append to summary + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + // Get UUID of first kept entry const firstKeptEntry = entries[cutResult.firstKeptEntryIndex]; const firstKeptEntryId = firstKeptEntry.id; @@ -593,6 +786,7 @@ export async function compact( summary, firstKeptEntryId, tokensBefore, + details: { readFiles, modifiedFiles } as CompactionDetails, }; } From 3c6c9e52cd2f46f9e1f8737e1b25f8b216d53483 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 23:54:06 +0100 Subject: [PATCH 145/161] Add system prompt to compaction summarization - SUMMARIZATION_SYSTEM_PROMPT explains the task clearly - Tells model to NOT continue conversation, ONLY output summary - Updated prompts to reference 'messages above' for clarity - Pass systemPrompt to completeSimple --- .../src/core/compaction/compaction.ts | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 1f5f4091..44d4ded7 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,8 +6,8 @@ */ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; -import { complete } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Model, Usage, UserMessage } from "@mariozechner/pi-ai"; +import { complete, completeSimple } from "@mariozechner/pi-ai"; import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js"; import type { CompactionEntry, SessionEntry } from "../session-manager.js"; @@ -441,7 +441,11 @@ export function findCutPoint( // Summarization // ============================================================================ -const SUMMARIZATION_PROMPT = `Create a structured context checkpoint summary. Another LLM will use this to continue the work. +const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; + +const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. Use this EXACT format: @@ -474,9 +478,9 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.`; -const UPDATE_SUMMARIZATION_PROMPT = `Update the existing structured summary with new information from the conversation. +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. -RULES: +Update the existing structured summary with new information. RULES: - PRESERVE all existing information from the previous summary - ADD new progress, decisions, and context from the new messages - UPDATE the Progress section: move items from "In Progress" to "Done" when completed @@ -540,31 +544,26 @@ export async function generateSummary( // Build summarization messages const summarizationMessages = []; - // If we have a previous summary, include it as context - if (previousSummary) { - summarizationMessages.push({ - role: "user" as const, - content: [{ type: "text" as const, text: `PREVIOUS SUMMARY:\n\n${previousSummary}` }], - timestamp: Date.now(), - }); - summarizationMessages.push({ - role: "user" as const, - content: [{ type: "text" as const, text: "NEW MESSAGES TO INCORPORATE:" }], - timestamp: Date.now(), - }); - } - // Add the conversation messages summarizationMessages.push(...transformedMessages); // Add the prompt - summarizationMessages.push({ + const prompt = { role: "user" as const, content: [{ type: "text" as const, text: basePrompt }], timestamp: Date.now(), - }); + } satisfies UserMessage; + summarizationMessages.push(prompt); - const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey }); + if (previousSummary) { + prompt.content.push({ type: "text" as const, text: `${previousSummary}` }); + } + + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { maxTokens, signal, apiKey, reasoning: "high" }, + ); if (response.stopReason === "error") { throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); @@ -716,9 +715,7 @@ export async function compact( let previousSummary: string | undefined; if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; - if (!prevCompaction.fromHook) { - previousSummary = prevCompaction.summary; - } + previousSummary = prevCompaction.summary; } // Extract file operations from messages and previous compaction From 2add465fbe44b15259e45fca3120cdb0f9cd6e0e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 23:56:52 +0100 Subject: [PATCH 146/161] Serialize conversation to text for summarization Instead of passing conversation as LLM messages (which makes the model try to continue it), serialize to text wrapped in tags. - serializeConversation() formats messages as [User]/[Assistant]/[Tool result] - Tool calls shown as function(args) format - Tool results truncated to prevent bloat - Conversation wrapped in tags in the prompt --- .../src/core/compaction/compaction.ts | 87 +++++++++++++++---- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 44d4ded7..96c8a829 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -120,6 +120,62 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str return `\n\n${sections.join("\n\n")}`; } +/** + * Serialize conversation messages to text for summarization. + * This prevents the model from treating it as a conversation to continue. + */ +function serializeConversation(messages: AgentMessage[]): string { + const parts: string[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + const content = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) parts.push(`[User]: ${content}`); + } else if (msg.role === "assistant" && "content" in msg && Array.isArray(msg.content)) { + const textParts: string[] = []; + const toolCalls: string[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + textParts.push(block.text); + } else if (block.type === "toolCall") { + const args = block.arguments as Record; + const argsStr = Object.entries(args) + .map(([k, v]) => `${k}=${JSON.stringify(v).slice(0, 100)}`) + .join(", "); + toolCalls.push(`${block.name}(${argsStr})`); + } + } + + if (textParts.length > 0) { + parts.push(`[Assistant]: ${textParts.join("\n")}`); + } + if (toolCalls.length > 0) { + parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); + } + } else if (msg.role === "toolResult" && "content" in msg) { + // Summarize tool results briefly + const content = Array.isArray(msg.content) + ? msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text.slice(0, 500)) + .join("") + : ""; + if (content) { + parts.push(`[Tool result]: ${content.slice(0, 1000)}${content.length > 1000 ? "..." : ""}`); + } + } + } + + return parts.join("\n\n"); +} + // ============================================================================ // Message Extraction // ============================================================================ @@ -538,26 +594,23 @@ export async function generateSummary( basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; } - // Transform custom messages (like bashExecution) to LLM-compatible messages - const transformedMessages = convertToLlm(currentMessages); - - // Build summarization messages - const summarizationMessages = []; - - // Add the conversation messages - summarizationMessages.push(...transformedMessages); - - // Add the prompt - const prompt = { - role: "user" as const, - content: [{ type: "text" as const, text: basePrompt }], - timestamp: Date.now(), - } satisfies UserMessage; - summarizationMessages.push(prompt); + // Serialize conversation to text so model doesn't try to continue it + const conversationText = serializeConversation(currentMessages); + // Build the prompt with conversation wrapped in tags + let promptText = `\n${conversationText}\n\n\n`; if (previousSummary) { - prompt.content.push({ type: "text" as const, text: `${previousSummary}` }); + promptText += `\n${previousSummary}\n\n\n`; } + promptText += basePrompt; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; const response = await completeSimple( model, From 17ce3814a81e2bf6be596290c65d6c09d2707ee0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 00:01:43 +0100 Subject: [PATCH 147/161] Use convertToLlm before serializing, include thinking, remove truncation - serializeConversation now takes Message[] (after convertToLlm) - Handles all custom message types via convertToLlm - Includes thinking blocks as [Assistant thinking] - Removes truncation of tool args and results (already token-budgeted) --- .../src/core/compaction/compaction.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 96c8a829..2883483d 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,7 +6,7 @@ */ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Model, Usage, UserMessage } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Message, Model, Usage } from "@mariozechner/pi-ai"; import { complete, completeSimple } from "@mariozechner/pi-ai"; import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js"; import type { CompactionEntry, SessionEntry } from "../session-manager.js"; @@ -121,10 +121,11 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str } /** - * Serialize conversation messages to text for summarization. + * Serialize LLM messages to text for summarization. * This prevents the model from treating it as a conversation to continue. + * Call convertToLlm() first to handle custom message types. */ -function serializeConversation(messages: AgentMessage[]): string { +function serializeConversation(messages: Message[]): string { const parts: string[] = []; for (const msg of messages) { @@ -137,38 +138,41 @@ function serializeConversation(messages: AgentMessage[]): string { .map((c) => c.text) .join(""); if (content) parts.push(`[User]: ${content}`); - } else if (msg.role === "assistant" && "content" in msg && Array.isArray(msg.content)) { + } else if (msg.role === "assistant") { const textParts: string[] = []; + const thinkingParts: string[] = []; const toolCalls: string[] = []; for (const block of msg.content) { if (block.type === "text") { textParts.push(block.text); + } else if (block.type === "thinking") { + thinkingParts.push(block.thinking); } else if (block.type === "toolCall") { const args = block.arguments as Record; const argsStr = Object.entries(args) - .map(([k, v]) => `${k}=${JSON.stringify(v).slice(0, 100)}`) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) .join(", "); toolCalls.push(`${block.name}(${argsStr})`); } } + if (thinkingParts.length > 0) { + parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); + } if (textParts.length > 0) { parts.push(`[Assistant]: ${textParts.join("\n")}`); } if (toolCalls.length > 0) { parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); } - } else if (msg.role === "toolResult" && "content" in msg) { - // Summarize tool results briefly - const content = Array.isArray(msg.content) - ? msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text.slice(0, 500)) - .join("") - : ""; + } else if (msg.role === "toolResult") { + const content = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); if (content) { - parts.push(`[Tool result]: ${content.slice(0, 1000)}${content.length > 1000 ? "..." : ""}`); + parts.push(`[Tool result]: ${content}`); } } } @@ -595,7 +599,9 @@ export async function generateSummary( } // Serialize conversation to text so model doesn't try to continue it - const conversationText = serializeConversation(currentMessages); + // Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.) + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); // Build the prompt with conversation wrapped in tags let promptText = `\n${conversationText}\n\n\n`; From 81f4cdf3e3d2ac6cfb2e4480a14bf1f92bf8766a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 00:13:11 +0100 Subject: [PATCH 148/161] Extract shared compaction/branch-summarization utils - New utils.ts with shared functions: - FileOperations type and createFileOps() - extractFileOpsFromMessage() - computeFileLists() - formatFileOperations() - serializeConversation() - SUMMARIZATION_SYSTEM_PROMPT - branch-summarization.ts now uses: - Serialization approach (conversation as text, not LLM messages) - completeSimple with system prompt - Shared utility functions --- .../core/compaction/branch-summarization.ts | 93 +++-------- .../src/core/compaction/compaction.ts | 144 ++-------------- .../coding-agent/src/core/compaction/index.ts | 1 + .../coding-agent/src/core/compaction/utils.ts | 154 ++++++++++++++++++ 4 files changed, 193 insertions(+), 199 deletions(-) create mode 100644 packages/coding-agent/src/core/compaction/utils.ts diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 3647afb8..8bca45ff 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -7,7 +7,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; -import { complete } from "@mariozechner/pi-ai"; +import { completeSimple } from "@mariozechner/pi-ai"; import { convertToLlm, createBranchSummaryMessage, @@ -16,6 +16,15 @@ import { } from "../messages.js"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js"; import { estimateTokens } from "./compaction.js"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + SUMMARIZATION_SYSTEM_PROMPT, + serializeConversation, +} from "./utils.js"; // ============================================================================ // Types @@ -35,11 +44,7 @@ export interface BranchSummaryDetails { modifiedFiles: string[]; } -export interface FileOperations { - read: Set; - written: Set; - edited: Set; -} +export type { FileOperations } from "./utils.js"; export interface BranchPreparation { /** Messages extracted for summarization, in chronological order */ @@ -159,38 +164,6 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { } } -/** - * Extract file operations from tool calls in an assistant message. - */ -function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { - if (message.role !== "assistant") return; - if (!("content" in message) || !Array.isArray(message.content)) return; - - for (const block of message.content) { - if (typeof block !== "object" || block === null) continue; - if (!("type" in block) || block.type !== "toolCall") continue; - if (!("arguments" in block) || !("name" in block)) continue; - - const args = block.arguments as Record | undefined; - if (!args) continue; - - const path = typeof args.path === "string" ? args.path : undefined; - if (!path) continue; - - switch (block.name) { - case "read": - fileOps.read.add(path); - break; - case "write": - fileOps.written.add(path); - break; - case "edit": - fileOps.edited.add(path); - break; - } - } -} - /** * Prepare entries for summarization with token budget. * @@ -206,11 +179,7 @@ function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperation */ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation { const messages: AgentMessage[] = []; - const fileOps: FileOperations = { - read: new Set(), - written: new Set(), - edited: new Set(), - }; + const fileOps = createFileOps(); let totalTokens = 0; // First pass: collect file ops from ALL entries (even if they don't fit in token budget) @@ -322,24 +291,29 @@ export async function generateBranchSummary( return { summary: "No content to summarize" }; } - // Transform to LLM-compatible messages (preserves tool calls, etc.) - const transformedMessages = convertToLlm(messages); + // Transform to LLM-compatible messages, then serialize to text + // Serialization prevents the model from treating it as a conversation to continue + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); // Build prompt const instructions = customInstructions || BRANCH_SUMMARY_PROMPT; + const promptText = `\n${conversationText}\n\n\n${instructions}`; - // Append summarization prompt as final user message const summarizationMessages = [ - ...transformedMessages, { role: "user" as const, - content: [{ type: "text" as const, text: instructions }], + content: [{ type: "text" as const, text: promptText }], timestamp: Date.now(), }, ]; // Call LLM for summarization - const response = await complete(model, { messages: summarizationMessages }, { apiKey, signal, maxTokens: 2048 }); + const response = await completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { apiKey, signal, maxTokens: 2048 }, + ); // Check if aborted or errored if (response.stopReason === "aborted") { @@ -357,26 +331,13 @@ export async function generateBranchSummary( // Prepend preamble to provide context about the branch summary summary = BRANCH_SUMMARY_PREAMBLE + summary; - // Compute file lists - const modified = new Set([...fileOps.edited, ...fileOps.written]); - const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); - const modifiedFiles = [...modified].sort(); - - // Append file lists to summary text (for LLM context and TUI display) - const fileSections: string[] = []; - if (readOnly.length > 0) { - fileSections.push(`\n${readOnly.join("\n")}\n`); - } - if (modifiedFiles.length > 0) { - fileSections.push(`\n${modifiedFiles.join("\n")}\n`); - } - if (fileSections.length > 0) { - summary += `\n\n${fileSections.join("\n\n")}`; - } + // Compute file lists and append to summary + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); return { summary: summary || "No summary generated", - readFiles: readOnly, + readFiles, modifiedFiles, }; } diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 2883483d..afa99152 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,10 +6,19 @@ */ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Message, Model, Usage } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete, completeSimple } from "@mariozechner/pi-ai"; import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js"; import type { CompactionEntry, SessionEntry } from "../session-manager.js"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + SUMMARIZATION_SYSTEM_PROMPT, + serializeConversation, +} from "./utils.js"; // ============================================================================ // File Operation Tracking @@ -21,44 +30,6 @@ export interface CompactionDetails { modifiedFiles: string[]; } -interface FileOperations { - read: Set; - written: Set; - edited: Set; -} - -/** - * Extract file operations from tool calls in an assistant message. - */ -function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { - if (message.role !== "assistant") return; - if (!("content" in message) || !Array.isArray(message.content)) return; - - for (const block of message.content) { - if (typeof block !== "object" || block === null) continue; - if (!("type" in block) || block.type !== "toolCall") continue; - if (!("arguments" in block) || !("name" in block)) continue; - - const args = block.arguments as Record | undefined; - if (!args) continue; - - const path = typeof args.path === "string" ? args.path : undefined; - if (!path) continue; - - switch (block.name) { - case "read": - fileOps.read.add(path); - break; - case "write": - fileOps.written.add(path); - break; - case "edit": - fileOps.edited.add(path); - break; - } - } -} - /** * Extract file operations from messages and previous compaction entries. */ @@ -67,11 +38,7 @@ function extractFileOperations( entries: SessionEntry[], prevCompactionIndex: number, ): FileOperations { - const fileOps: FileOperations = { - read: new Set(), - written: new Set(), - edited: new Set(), - }; + const fileOps = createFileOps(); // Collect from previous compaction's details (if pi-generated) if (prevCompactionIndex >= 0) { @@ -95,91 +62,6 @@ function extractFileOperations( return fileOps; } -/** - * Compute final file lists from file operations. - */ -function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { - const modified = new Set([...fileOps.edited, ...fileOps.written]); - const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); - const modifiedFiles = [...modified].sort(); - return { readFiles: readOnly, modifiedFiles }; -} - -/** - * Format file operations as XML tags for summary. - */ -function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { - const sections: string[] = []; - if (readFiles.length > 0) { - sections.push(`\n${readFiles.join("\n")}\n`); - } - if (modifiedFiles.length > 0) { - sections.push(`\n${modifiedFiles.join("\n")}\n`); - } - if (sections.length === 0) return ""; - return `\n\n${sections.join("\n\n")}`; -} - -/** - * Serialize LLM messages to text for summarization. - * This prevents the model from treating it as a conversation to continue. - * Call convertToLlm() first to handle custom message types. - */ -function serializeConversation(messages: Message[]): string { - const parts: string[] = []; - - for (const msg of messages) { - if (msg.role === "user") { - const content = - typeof msg.content === "string" - ? msg.content - : msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - if (content) parts.push(`[User]: ${content}`); - } else if (msg.role === "assistant") { - const textParts: string[] = []; - const thinkingParts: string[] = []; - const toolCalls: string[] = []; - - for (const block of msg.content) { - if (block.type === "text") { - textParts.push(block.text); - } else if (block.type === "thinking") { - thinkingParts.push(block.thinking); - } else if (block.type === "toolCall") { - const args = block.arguments as Record; - const argsStr = Object.entries(args) - .map(([k, v]) => `${k}=${JSON.stringify(v)}`) - .join(", "); - toolCalls.push(`${block.name}(${argsStr})`); - } - } - - if (thinkingParts.length > 0) { - parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); - } - if (textParts.length > 0) { - parts.push(`[Assistant]: ${textParts.join("\n")}`); - } - if (toolCalls.length > 0) { - parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); - } - } else if (msg.role === "toolResult") { - const content = msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - if (content) { - parts.push(`[Tool result]: ${content}`); - } - } - } - - return parts.join("\n\n"); -} - // ============================================================================ // Message Extraction // ============================================================================ @@ -501,10 +383,6 @@ export function findCutPoint( // Summarization // ============================================================================ -const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. - -Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; - const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. Use this EXACT format: diff --git a/packages/coding-agent/src/core/compaction/index.ts b/packages/coding-agent/src/core/compaction/index.ts index 4f8ad306..d8c92a67 100644 --- a/packages/coding-agent/src/core/compaction/index.ts +++ b/packages/coding-agent/src/core/compaction/index.ts @@ -4,3 +4,4 @@ export * from "./branch-summarization.js"; export * from "./compaction.js"; +export * from "./utils.js"; diff --git a/packages/coding-agent/src/core/compaction/utils.ts b/packages/coding-agent/src/core/compaction/utils.ts new file mode 100644 index 00000000..9c8f46bc --- /dev/null +++ b/packages/coding-agent/src/core/compaction/utils.ts @@ -0,0 +1,154 @@ +/** + * Shared utilities for compaction and branch summarization. + */ + +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { Message } from "@mariozechner/pi-ai"; + +// ============================================================================ +// File Operation Tracking +// ============================================================================ + +export interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +export function createFileOps(): FileOperations { + return { + read: new Set(), + written: new Set(), + edited: new Set(), + }; +} + +/** + * Extract file operations from tool calls in an assistant message. + */ +export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== "assistant") return; + if (!("content" in message) || !Array.isArray(message.content)) return; + + for (const block of message.content) { + if (typeof block !== "object" || block === null) continue; + if (!("type" in block) || block.type !== "toolCall") continue; + if (!("arguments" in block) || !("name" in block)) continue; + + const args = block.arguments as Record | undefined; + if (!args) continue; + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) continue; + + switch (block.name) { + case "read": + fileOps.read.add(path); + break; + case "write": + fileOps.written.add(path); + break; + case "edit": + fileOps.edited.add(path); + break; + } + } +} + +/** + * Compute final file lists from file operations. + * Returns readFiles (files only read, not modified) and modifiedFiles. + */ +export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const modifiedFiles = [...modified].sort(); + return { readFiles: readOnly, modifiedFiles }; +} + +/** + * Format file operations as XML tags for summary. + */ +export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`\n${readFiles.join("\n")}\n`); + } + if (modifiedFiles.length > 0) { + sections.push(`\n${modifiedFiles.join("\n")}\n`); + } + if (sections.length === 0) return ""; + return `\n\n${sections.join("\n\n")}`; +} + +// ============================================================================ +// Message Serialization +// ============================================================================ + +/** + * Serialize LLM messages to text for summarization. + * This prevents the model from treating it as a conversation to continue. + * Call convertToLlm() first to handle custom message types. + */ +export function serializeConversation(messages: Message[]): string { + const parts: string[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + const content = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) parts.push(`[User]: ${content}`); + } else if (msg.role === "assistant") { + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: string[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + textParts.push(block.text); + } else if (block.type === "thinking") { + thinkingParts.push(block.thinking); + } else if (block.type === "toolCall") { + const args = block.arguments as Record; + const argsStr = Object.entries(args) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(", "); + toolCalls.push(`${block.name}(${argsStr})`); + } + } + + if (thinkingParts.length > 0) { + parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); + } + if (textParts.length > 0) { + parts.push(`[Assistant]: ${textParts.join("\n")}`); + } + if (toolCalls.length > 0) { + parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); + } + } else if (msg.role === "toolResult") { + const content = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) { + parts.push(`[Tool result]: ${content}`); + } + } + } + + return parts.join("\n\n"); +} + +// ============================================================================ +// Summarization System Prompt +// ============================================================================ + +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; From 4a9c53347ec6efeba42e6c5eb212fbcee9e395a8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 00:19:43 +0100 Subject: [PATCH 149/161] Update session-tree-plan.md with completed items - Branch Summary Design: complete with fromId, fromHook, details fields - Entry Labels: UI display and /label command complete in tree-selector - UI Commands: /branch and /tree complete --- .../coding-agent/docs/session-tree-plan.md | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 49c1ae0e..3b01e818 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -73,22 +73,25 @@ Reference: [session-tree.md](./session-tree.md) Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers. -### Branch Summary Design +### Branch Summary Design ✅ Current type: ```typescript export interface BranchSummaryEntry extends SessionEntryBase { type: "branch_summary"; summary: string; + fromId: string; // References the abandoned leaf + fromHook?: boolean; // Whether summary was generated by a hook + details?: unknown; // File tracking: { readFiles, modifiedFiles } } ``` -Questions to resolve: -- [ ] Add `abandonedLeafId` field to reference what was abandoned? -- [ ] Store metadata about why the branch happened? -- [ ] Who generates the summary - user, LLM, or both options? -- [ ] Design and implement branch summarizer -- [ ] Add tests for `branchWithSummary()` flow +- [x] `fromId` field references the abandoned leaf +- [x] `fromHook` field distinguishes pi-generated vs hook-generated summaries +- [x] `details` field for file tracking +- [x] Branch summarizer implemented with structured output format +- [x] Uses serialization approach (same as compaction) to prevent model confusion +- [x] Tests for `branchWithSummary()` flow ### Entry Labels ✅ @@ -100,8 +103,8 @@ Questions to resolve: - [x] Update `createBranchedSession()` to filter out LabelEntry and recreate from resolved map - [x] `buildSessionContext()` already ignores LabelEntry (only handles message types) - [x] Add `label?: string` to `SessionTreeNode`, populated by `getTree()` -- [ ] Display labels in UI (tree view, path view) - deferred to UI phase -- [ ] `/label` command - deferred to UI phase +- [x] Display labels in UI (tree-selector shows labels) +- [x] `/label` command (implemented in tree-selector) ### CustomMessageEntry @@ -361,23 +364,14 @@ interface BeforeAgentStartResult { - [ ] Render full path (no compaction resolution needed) - [ ] Responsive: collapse sidebar on mobile -### UI Commands +### UI Commands ✅ -Design new commands based on refactored SessionManager: - -**`/branch`** - Current behavior (creates new session file from path) -- [ ] Review if this is still the right UX with tree structure -- [ ] Consider: should this use `createBranchedSession()` or `branch()`? - -**`/branch-here`** - In-place branching (new) -- [ ] Use `branch(id)` to move leaf pointer without creating new file -- [ ] Subsequent messages become new branch in same file -- [ ] Design: how to select branch point? (similar to current `/branch` UI?) - -**`/branches`** - List/navigate branches (new) -- [ ] Show tree structure or list of branch points -- [ ] Allow switching between branches (move leaf pointer) -- [ ] Show current position in tree +- [x] `/branch` - Creates new session file from current path (uses `createBranchedSession()`) +- [x] `/tree` - In-session tree navigation via tree-selector component + - Shows full tree structure with labels + - Navigate between branches (moves leaf pointer) + - Shows current position + - Generates branch summaries when switching branches ### Documentation From c86f2a0136dcef80cd095f0743c732ba110a7dda Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 00:54:15 +0100 Subject: [PATCH 150/161] Fix rebase issues: regenerate models, fix mom agent API --- package-lock.json | 148 +- packages/ai/src/models.generated.ts | 11640 +++++++++++++------------- packages/mom/src/agent.ts | 6 +- 3 files changed, 5874 insertions(+), 5920 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec3c84d9..3e84ed4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -696,18 +696,6 @@ } } }, - "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -965,10 +953,6 @@ "resolved": "packages/mom", "link": true }, - "node_modules/@mariozechner/pi-proxy": { - "resolved": "packages/proxy", - "link": true - }, "node_modules/@mariozechner/pi-tui": { "resolved": "packages/tui", "link": true @@ -996,9 +980,9 @@ } }, "node_modules/@napi-rs/canvas": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.86.tgz", - "integrity": "sha512-hOkywnrkdFdVpsuaNsZWfEY7kc96eROV2DuMTTvGF15AZfwobzdG2w0eDlU5UBx3Lg/XlWUnqVT5zLUWyo5h6A==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", + "integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==", "license": "MIT", "optional": true, "workspaces": [ @@ -1012,23 +996,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.86", - "@napi-rs/canvas-darwin-arm64": "0.1.86", - "@napi-rs/canvas-darwin-x64": "0.1.86", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.86", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.86", - "@napi-rs/canvas-linux-arm64-musl": "0.1.86", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.86", - "@napi-rs/canvas-linux-x64-gnu": "0.1.86", - "@napi-rs/canvas-linux-x64-musl": "0.1.86", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.86", - "@napi-rs/canvas-win32-x64-msvc": "0.1.86" + "@napi-rs/canvas-android-arm64": "0.1.88", + "@napi-rs/canvas-darwin-arm64": "0.1.88", + "@napi-rs/canvas-darwin-x64": "0.1.88", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.88", + "@napi-rs/canvas-linux-arm64-musl": "0.1.88", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.88", + "@napi-rs/canvas-linux-x64-gnu": "0.1.88", + "@napi-rs/canvas-linux-x64-musl": "0.1.88", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.88", + "@napi-rs/canvas-win32-x64-msvc": "0.1.88" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.86.tgz", - "integrity": "sha512-IjkZFKUr6GzMzzrawJaN3v+yY3Fvpa71e0DcbePfxWelFKnESIir+XUcdAbim29JOd0JE0/hQJdfUCb5t/Fjrw==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz", + "integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==", "cpu": [ "arm64" ], @@ -1046,9 +1030,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.86.tgz", - "integrity": "sha512-PUCxDq0wSSJbtaOqoKj3+t5tyDbtxWumziOTykdn3T839hu6koMaBFpGk9lXpsGaPNgyFpPqjxhtsPljBGnDHg==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz", + "integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==", "cpu": [ "arm64" ], @@ -1066,9 +1050,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.86.tgz", - "integrity": "sha512-rlCFLv4Rrg45qFZq7mysrKnsUbMhwdNg3YPuVfo9u4RkOqm7ooAJvdyDFxiqfSsJJTqupYqa9VQCUt8WKxKhNQ==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz", + "integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==", "cpu": [ "x64" ], @@ -1086,9 +1070,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.86.tgz", - "integrity": "sha512-6xWwyMc9BlDBt+9XHN/GzUo3MozHta/2fxQHMb80x0K2zpZuAdDKUYHmYzx9dFWDY3SbPYnx6iRlQl6wxnwS1w==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz", + "integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==", "cpu": [ "arm" ], @@ -1106,9 +1090,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.86.tgz", - "integrity": "sha512-r2OX3w50xHxrToTovOSQWwkVfSq752CUzH9dzlVXyr8UDKFV8dMjfa9hePXvAJhN3NBp4TkHcGx15QCdaCIwnA==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz", + "integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==", "cpu": [ "arm64" ], @@ -1126,9 +1110,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.86.tgz", - "integrity": "sha512-jbXuh8zVFUPw6a9SGpgc6EC+fRbGGyP1NFfeQiVqGLs6bN93ROtPLPL6MH9Bp6yt0CXUFallk2vgKdWDbmW+bw==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz", + "integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==", "cpu": [ "arm64" ], @@ -1146,9 +1130,9 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.86.tgz", - "integrity": "sha512-9IwHR2qbq2HceM9fgwyL7x37Jy3ptt1uxvikQEuWR0FisIx9QEdt7F3huljCky76aoouF2vSd0R2fHo3ESRoPw==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz", + "integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==", "cpu": [ "riscv64" ], @@ -1166,9 +1150,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.86.tgz", - "integrity": "sha512-Jor+rhRN6ubix+D2QkNn9XlPPVAYl+2qFrkZ4oZN9UgtqIUZ+n+HljxhlkkDFRaX1mlxXOXPQjxaZg17zDSFcQ==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz", + "integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==", "cpu": [ "x64" ], @@ -1186,9 +1170,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.86.tgz", - "integrity": "sha512-A28VTy91DbclopSGZ2tIon3p8hcVI1JhnNpDpJ5N9rYlUnVz1WQo4waEMh+FICTZF07O3coxBNZc4Vu4doFw7A==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz", + "integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==", "cpu": [ "x64" ], @@ -1206,9 +1190,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.86.tgz", - "integrity": "sha512-q6G1YXUt3gBCAS2bcDMCaBL4y20di8eVVBi1XhjUqZSVyZZxxwIuRQHy31NlPJUCMiyNiMuc6zeI0uqgkWwAmA==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz", + "integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==", "cpu": [ "arm64" ], @@ -1226,9 +1210,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.86", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.86.tgz", - "integrity": "sha512-X0g46uRVgnvCM1cOjRXAOSFSG63ktUFIf/TIfbKCUc7QpmYUcHmSP9iR6DGOYfk+SggLsXoJCIhPTotYeZEAmg==", + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz", + "integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==", "cpu": [ "x64" ], @@ -4009,16 +3993,6 @@ "node": "*" } }, - "node_modules/hono": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.2.tgz", - "integrity": "sha512-o+avdUAD1v94oHkjGBhiMhBV4WBHxhbu0+CUVH78hhphKy/OKQLxtKjkmmNcrMlbYAhAbsM/9F+l3KnYxyD3Lg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-parse-string": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/html-parse-string/-/html-parse-string-0.0.9.tgz", @@ -4584,7 +4558,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -5705,7 +5678,6 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -5734,8 +5706,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -6000,7 +5971,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6443,9 +6413,9 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" @@ -6637,22 +6607,6 @@ "node": ">=20.0.0" } }, - "packages/proxy": { - "name": "@mariozechner/pi-proxy", - "version": "0.30.2", - "dependencies": { - "@hono/node-server": "^1.14.0", - "hono": "^4.6.16" - }, - "bin": { - "pi-proxy": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^22.10.5", - "tsx": "^4.19.2", - "typescript": "^5.7.3" - } - }, "packages/tui": { "name": "@mariozechner/pi-tui", "version": "0.30.2", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 795336b6..f3d87fa6 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5,87 +5,19 @@ import type { Model } from "./types.js"; export const MODELS = { "anthropic": { - "claude-opus-4-0": { - id: "claude-opus-4-0", - name: "Claude Opus 4 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-3-5-sonnet-20241022": { - id: "claude-3-5-sonnet-20241022", - name: "Claude Sonnet 3.5 v2", + "claude-3-5-haiku-20241022": { + id: "claude-3-5-haiku-20241022", + name: "Claude Haiku 3.5", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", reasoning: false, input: ["text", "image"], cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-1": { - id: "claude-opus-4-1", - name: "Claude Opus 4.1 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-haiku-4-5": { - id: "claude-haiku-4-5", - name: "Claude Haiku 4.5 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1, - output: 5, - cacheRead: 0.1, - cacheWrite: 1.25, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-3-5-sonnet-20240620": { - id: "claude-3-5-sonnet-20240620", - name: "Claude Sonnet 3.5", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, + input: 0.8, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, }, contextWindow: 200000, maxTokens: 8192, @@ -107,23 +39,91 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, - "claude-opus-4-5": { - id: "claude-opus-4-5", - name: "Claude Opus 4.5 (latest)", + "claude-3-5-sonnet-20240620": { + id: "claude-3-5-sonnet-20240620", + name: "Claude Sonnet 3.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3-5-sonnet-20241022": { + id: "claude-3-5-sonnet-20241022", + name: "Claude Sonnet 3.5 v2", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + "claude-3-7-sonnet-20250219": { + id: "claude-3-7-sonnet-20250219", + name: "Claude Sonnet 3.7", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", reasoning: true, input: ["text", "image"], cost: { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, }, contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "claude-3-7-sonnet-latest": { + id: "claude-3-7-sonnet-latest", + name: "Claude Sonnet 3.7 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-3-haiku-20240307": { + id: "claude-3-haiku-20240307", + name: "Claude Haiku 3", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, "claude-3-opus-20240229": { id: "claude-3-opus-20240229", name: "Claude Opus 3", @@ -141,6 +141,142 @@ export const MODELS = { contextWindow: 200000, maxTokens: 4096, } satisfies Model<"anthropic-messages">, + "claude-3-sonnet-20240229": { + id: "claude-3-sonnet-20240229", + name: "Claude Sonnet 3", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"anthropic-messages">, + "claude-haiku-4-5": { + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-haiku-4-5-20251001": { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-0": { + id: "claude-opus-4-0", + name: "Claude Opus 4 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-1": { + id: "claude-opus-4-1", + name: "Claude Opus 4.1 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-1-20250805": { + id: "claude-opus-4-1-20250805", + name: "Claude Opus 4.1", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-20250514": { + id: "claude-opus-4-20250514", + name: "Claude Opus 4", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "claude-opus-4-5": { + id: "claude-opus-4-5", + name: "Claude Opus 4.5 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, "claude-opus-4-5-20251101": { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", @@ -158,6 +294,40 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-0": { + id: "claude-sonnet-4-0", + name: "Claude Sonnet 4 (latest)", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-4-20250514": { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, "claude-sonnet-4-5": { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (latest)", @@ -192,1357 +362,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-sonnet-4-20250514": { - id: "claude-sonnet-4-20250514", - name: "Claude Sonnet 4", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-20250514": { - id: "claude-opus-4-20250514", - name: "Claude Opus 4", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-3-5-haiku-20241022": { - id: "claude-3-5-haiku-20241022", - name: "Claude Haiku 3.5", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.8, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "claude-3-haiku-20240307": { - id: "claude-3-haiku-20240307", - name: "Claude Haiku 3", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.25, - output: 1.25, - cacheRead: 0.03, - cacheWrite: 0.3, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"anthropic-messages">, - "claude-3-7-sonnet-20250219": { - id: "claude-3-7-sonnet-20250219", - name: "Claude Sonnet 3.7", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-3-7-sonnet-latest": { - id: "claude-3-7-sonnet-latest", - name: "Claude Sonnet 3.7 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-sonnet-4-0": { - id: "claude-sonnet-4-0", - name: "Claude Sonnet 4 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-1-20250805": { - id: "claude-opus-4-1-20250805", - name: "Claude Opus 4.1", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-3-sonnet-20240229": { - id: "claude-3-sonnet-20240229", - name: "Claude Sonnet 3", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 0.3, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"anthropic-messages">, - "claude-haiku-4-5-20251001": { - id: "claude-haiku-4-5-20251001", - name: "Claude Haiku 4.5", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1, - output: 5, - cacheRead: 0.1, - cacheWrite: 1.25, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - }, - "google": { - "gemini-3-flash-preview": { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5, - output: 3, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-05-20": { - id: "gemini-2.5-flash-preview-05-20", - name: "Gemini 2.5 Flash Preview 05-20", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-flash-lite-latest": { - id: "gemini-flash-lite-latest", - name: "Gemini Flash-Lite Latest", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 12, - cacheRead: 0.2, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 64000, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash": { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-flash-latest": { - id: "gemini-flash-latest", - name: "Gemini Flash Latest", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro-preview-05-06": { - id: "gemini-2.5-pro-preview-05-06", - name: "Gemini 2.5 Pro Preview 05-06", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.0-flash-lite": { - id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-live-2.5-flash-preview-native-audio": { - id: "gemini-live-2.5-flash-preview-native-audio", - name: "Gemini Live 2.5 Flash Preview Native Audio", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text"], - cost: { - input: 0.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.0-flash": { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite": { - id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro-preview-06-05": { - id: "gemini-2.5-pro-preview-06-05", - name: "Gemini 2.5 Pro Preview 06-05", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-live-2.5-flash": { - id: "gemini-live-2.5-flash", - name: "Gemini Live 2.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 8000, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite-preview-06-17": { - id: "gemini-2.5-flash-lite-preview-06-17", - name: "Gemini 2.5 Flash Lite Preview 06-17", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-09-2025": { - id: "gemini-2.5-flash-preview-09-2025", - name: "Gemini 2.5 Flash Preview 09-25", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-preview-04-17": { - id: "gemini-2.5-flash-preview-04-17", - name: "Gemini 2.5 Flash Preview 04-17", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.0375, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-pro": { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-1.5-flash": { - id: "gemini-1.5-flash", - name: "Gemini 1.5 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.01875, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-1.5-flash-8b": { - id: "gemini-1.5-flash-8b", - name: "Gemini 1.5 Flash-8B", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.0375, - output: 0.15, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-2.5-flash-lite-preview-09-2025": { - id: "gemini-2.5-flash-lite-preview-09-2025", - name: "Gemini 2.5 Flash Lite Preview 09-25", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-1.5-pro": { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 5, - cacheRead: 0.3125, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - }, - "openai": { - "gpt-4.1-nano": { - id: "gpt-4.1-nano", - name: "GPT-4.1 nano", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-responses">, - "gpt-4": { - id: "gpt-4", - name: "GPT-4", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-responses">, - "o1-pro": { - id: "o1-pro", - name: "o1-pro", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 150, - output: 600, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-4o-2024-05-13": { - id: "gpt-4o-2024-05-13", - name: "GPT-4o (2024-05-13)", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex": { - id: "gpt-5.1-codex", - name: "GPT-5.1 Codex", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-4o-2024-08-06": { - id: "gpt-4o-2024-08-06", - name: "GPT-4o (2024-08-06)", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-responses">, - "gpt-4.1-mini": { - id: "gpt-4.1-mini", - name: "GPT-4.1 mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.4, - output: 1.6, - cacheRead: 0.1, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-responses">, - "o3-deep-research": { - id: "o3-deep-research", - name: "o3-deep-research", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 10, - output: 40, - cacheRead: 2.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-5.2-pro": { - id: "gpt-5.2-pro", - name: "GPT-5.2 Pro", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 21, - output: 168, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-4-turbo": { - id: "gpt-4-turbo", - name: "GPT-4 Turbo", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 10, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex-mini": { - id: "gpt-5.1-codex-mini", - name: "GPT-5.1 Codex mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "o3-mini": { - id: "o3-mini", - name: "o3-mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.55, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-5.2-chat-latest": { - id: "gpt-5.2-chat-latest", - name: "GPT-5.2 Chat", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-responses">, - "gpt-5.1": { - id: "gpt-5.1", - name: "GPT-5.1", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.13, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "codex-mini-latest": { - id: "codex-mini-latest", - name: "Codex Mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text"], - cost: { - input: 1.5, - output: 6, - cacheRead: 0.375, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-5-nano": { - id: "gpt-5-nano", - name: "GPT-5 Nano", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.05, - output: 0.4, - cacheRead: 0.01, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5-codex": { - id: "gpt-5-codex", - name: "GPT-5-Codex", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-4o": { - id: "gpt-4o", - name: "GPT-4o", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-responses">, - "gpt-4.1": { - id: "gpt-4.1", - name: "GPT-4.1", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 8, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 1047576, - maxTokens: 32768, - } satisfies Model<"openai-responses">, - "o4-mini": { - id: "o4-mini", - name: "o4-mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.28, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o1": { - id: "o1", - name: "o1", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 60, - cacheRead: 7.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-5-mini": { - id: "gpt-5-mini", - name: "GPT-5 Mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "o3-pro": { - id: "o3-pro", - name: "o3-pro", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 20, - output: 80, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-4o-2024-11-20": { - id: "gpt-4o-2024-11-20", - name: "GPT-4o (2024-11-20)", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex-max": { - id: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "o3": { - id: "o3", - name: "o3", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 8, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "o4-mini-deep-research": { - id: "o4-mini-deep-research", - name: "o4-mini-deep-research", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 8, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-4o-mini": { - id: "gpt-4o-mini", - name: "GPT-4o mini", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.08, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-responses">, - "gpt-5": { - id: "gpt-5", - name: "GPT-5", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.13, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5-pro": { - id: "gpt-5-pro", - name: "GPT-5 Pro", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 120, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 272000, - } satisfies Model<"openai-responses">, - "gpt-5.2": { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "gpt-5.1-chat-latest": { - id: "gpt-5.1-chat-latest", - name: "GPT-5.1 Chat", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-responses">, - "gpt-5-chat-latest": { - id: "gpt-5-chat-latest", - name: "GPT-5 Chat Latest", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-responses">, - }, - "groq": { - "llama-3.1-8b-instant": { - id: "llama-3.1-8b-instant", - name: "Llama 3.1 8B Instant", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.05, - output: 0.08, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "mistral-saba-24b": { - id: "mistral-saba-24b", - name: "Mistral Saba 24B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.79, - output: 0.79, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "llama3-8b-8192": { - id: "llama3-8b-8192", - name: "Llama 3 8B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.05, - output: 0.08, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen-qwq-32b": { - id: "qwen-qwq-32b", - name: "Qwen QwQ 32B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.29, - output: 0.39, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "llama3-70b-8192": { - id: "llama3-70b-8192", - name: "Llama 3 70B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.59, - output: 0.79, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "deepseek-r1-distill-llama-70b": { - id: "deepseek-r1-distill-llama-70b", - name: "DeepSeek R1 Distill Llama 70B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.75, - output: 0.99, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "gemma2-9b-it": { - id: "gemma2-9b-it", - name: "Gemma 2 9B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.2, - output: 0.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "llama-3.3-70b-versatile": { - id: "llama-3.3-70b-versatile", - name: "Llama 3.3 70B Versatile", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.59, - output: 0.79, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-instruct-0905": { - id: "moonshotai/kimi-k2-instruct-0905", - name: "Kimi K2 Instruct 0905", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-instruct": { - id: "moonshotai/kimi-k2-instruct", - name: "Kimi K2 Instruct", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-20b": { - id: "openai/gpt-oss-20b", - name: "GPT OSS 20B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.1, - output: 0.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b": { - id: "openai/gpt-oss-120b", - name: "GPT OSS 120B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.15, - output: 0.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-32b": { - id: "qwen/qwen3-32b", - name: "Qwen3 32B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.29, - output: 0.59, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-scout-17b-16e-instruct": { - id: "meta-llama/llama-4-scout-17b-16e-instruct", - name: "Llama 4 Scout 17B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.11, - output: 0.34, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-maverick-17b-128e-instruct": { - id: "meta-llama/llama-4-maverick-17b-128e-instruct", - name: "Llama 4 Maverick 17B", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, }, "cerebras": { + "gpt-oss-120b": { + id: "gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.25, + output: 0.69, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "qwen-3-235b-a22b-instruct-2507": { id: "qwen-3-235b-a22b-instruct-2507", name: "Qwen 3 235B Instruct", @@ -1577,424 +415,331 @@ export const MODELS = { contextWindow: 131072, maxTokens: 40960, } satisfies Model<"openai-completions">, - "gpt-oss-120b": { - id: "gpt-oss-120b", - name: "GPT OSS 120B", + }, + "github-copilot": { + "claude-haiku-4.5": { + id: "claude-haiku-4.5", + name: "Claude Haiku 4.5", api: "openai-completions", - provider: "cerebras", - baseUrl: "https://api.cerebras.ai/v1", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.25, - output: 0.69, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 32768, + contextWindow: 128000, + maxTokens: 16000, } satisfies Model<"openai-completions">, - }, - "xai": { - "grok-4-fast-non-reasoning": { - id: "grok-4-fast-non-reasoning", - name: "Grok 4 Fast (Non-Reasoning)", + "claude-opus-4.5": { + id: "claude-opus-4.5", + name: "Claude Opus 4.5", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16000, + } satisfies Model<"openai-completions">, + "claude-sonnet-4": { + id: "claude-sonnet-4", + name: "Claude Sonnet 4", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16000, + } satisfies Model<"openai-completions">, + "claude-sonnet-4.5": { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16000, + } satisfies Model<"openai-completions">, + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: false, input: ["text", "image"], cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, + input: 0, + output: 0, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-3-fast": { - id: "grok-3-fast", - name: "Grok 3 Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 5, - output: 25, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-4": { - id: "grok-4", - name: "Grok 4", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 256000, + contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "grok-2-vision": { - id: "grok-2-vision", - name: "Grok 2 Vision", + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "gemini-3-pro-preview": { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: false, input: ["text", "image"], cost: { - input: 2, - output: 10, - cacheRead: 2, + input: 0, + output: 0, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 8192, - maxTokens: 4096, + contextWindow: 128000, + maxTokens: 16384, } satisfies Model<"openai-completions">, + "gpt-4o": { + id: "gpt-4o", + name: "GPT-4o", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 64000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-codex": { + id: "gpt-5-codex", + name: "GPT-5-Codex", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-mini": { + id: "gpt-5-mini", + name: "GPT-5-mini", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex": { + id: "gpt-5.1-codex", + name: "GPT-5.1-Codex", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1-Codex-max", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-mini": { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1-Codex-mini", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-responses">, "grok-code-fast-1": { id: "grok-code-fast-1", name: "Grok Code Fast 1", api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.2, - output: 1.5, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 10000, - } satisfies Model<"openai-completions">, - "grok-2": { - id: "grok-2", - name: "Grok 2", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-mini-fast-latest": { - id: "grok-3-mini-fast-latest", - name: "Grok 3 Mini Fast Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 4, - cacheRead: 0.15, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-vision-1212": { - id: "grok-2-vision-1212", - name: "Grok 2 Vision (1212)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-3": { - id: "grok-3", - name: "Grok 3", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-4-fast": { - id: "grok-4-fast", - name: "Grok 4 Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-2-latest": { - id: "grok-2-latest", - name: "Grok 2 Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-4-1-fast": { - id: "grok-4-1-fast", - name: "Grok 4.1 Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-2-1212": { - id: "grok-2-1212", - name: "Grok 2 (1212)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-fast-latest": { - id: "grok-3-fast-latest", - name: "Grok 3 Fast Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 5, - output: 25, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-3-latest": { - id: "grok-3-latest", - name: "Grok 3 Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-2-vision-latest": { - id: "grok-2-vision-latest", - name: "Grok 2 Vision Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 10, - cacheRead: 2, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-vision-beta": { - id: "grok-vision-beta", - name: "Grok Vision Beta", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 5, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-3-mini": { - id: "grok-3-mini", - name: "Grok 3 Mini", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-beta": { - id: "grok-beta", - name: "Grok Beta", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 5, - output: 15, - cacheRead: 5, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "grok-3-mini-latest": { - id: "grok-3-mini-latest", - name: "Grok 3 Mini Latest", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "grok-4-1-fast-non-reasoning": { - id: "grok-4-1-fast-non-reasoning", - name: "Grok 4.1 Fast (Non-Reasoning)", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.2, - output: 0.5, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "grok-3-mini-fast": { - id: "grok-3-mini-fast", - name: "Grok 3 Mini Fast", - api: "openai-completions", - provider: "xai", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 4, - cacheRead: 0.15, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - }, - "zai": { - "glm-4.7": { - id: "glm-4.7", - name: "GLM-4.7", - api: "anthropic-messages", - provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", - reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 2.2, - cacheRead: 0.11, - cacheWrite: 0, - }, - contextWindow: 204800, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, - "glm-4.5-flash": { - id: "glm-4.5-flash", - name: "GLM-4.5-Flash", - api: "anthropic-messages", - provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, input: ["text"], cost: { @@ -2003,197 +748,869 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 98304, - } satisfies Model<"anthropic-messages">, - "glm-4.5": { - id: "glm-4.5", - name: "GLM-4.5", - api: "anthropic-messages", - provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", - reasoning: true, - input: ["text"], - cost: { - input: 0.6, - output: 2.2, - cacheRead: 0.11, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 98304, - } satisfies Model<"anthropic-messages">, - "glm-4.5-air": { - id: "glm-4.5-air", - name: "GLM-4.5-Air", - api: "anthropic-messages", - provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", - reasoning: true, - input: ["text"], - cost: { - input: 0.2, - output: 1.1, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 98304, - } satisfies Model<"anthropic-messages">, - "glm-4.5v": { - id: "glm-4.5v", - name: "GLM-4.5V", - api: "anthropic-messages", - provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + contextWindow: 128000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "oswe-vscode-prime": { + id: "oswe-vscode-prime", + name: "Raptor Mini (Preview)", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, reasoning: true, input: ["text", "image"], cost: { - input: 0.6, - output: 1.8, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 64000, - maxTokens: 16384, - } satisfies Model<"anthropic-messages">, - "glm-4.6": { - id: "glm-4.6", - name: "GLM-4.6", - api: "anthropic-messages", - provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-responses">, + }, + "google": { + "gemini-1.5-flash": { + id: "gemini-1.5-flash", + name: "Gemini 1.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0.01875, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-1.5-flash-8b": { + id: "gemini-1.5-flash-8b", + name: "Gemini 1.5 Flash-8B", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.0375, + output: 0.15, + cacheRead: 0.01, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-1.5-pro": { + id: "gemini-1.5-pro", + name: "Gemini 1.5 Pro", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 1.25, + output: 5, + cacheRead: 0.3125, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-2.0-flash": { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-2.0-flash-lite": { + id: "gemini-2.0-flash-lite", + name: "Gemini 2.0 Flash Lite", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash": { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-lite": { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-lite-preview-06-17": { + id: "gemini-2.5-flash-lite-preview-06-17", + name: "Gemini 2.5 Flash Lite Preview 06-17", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-lite-preview-09-2025": { + id: "gemini-2.5-flash-lite-preview-09-2025", + name: "Gemini 2.5 Flash Lite Preview 09-25", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-preview-04-17": { + id: "gemini-2.5-flash-preview-04-17", + name: "Gemini 2.5 Flash Preview 04-17", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.0375, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-preview-05-20": { + id: "gemini-2.5-flash-preview-05-20", + name: "Gemini 2.5 Flash Preview 05-20", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.0375, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-flash-preview-09-2025": { + id: "gemini-2.5-flash-preview-09-2025", + name: "Gemini 2.5 Flash Preview 09-25", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-pro-preview-05-06": { + id: "gemini-2.5-pro-preview-05-06", + name: "Gemini 2.5 Pro Preview 05-06", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-2.5-pro-preview-06-05": { + id: "gemini-2.5-pro-preview-06-05", + name: "Gemini 2.5 Pro Preview 06-05", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3-pro-preview": { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"google-generative-ai">, + "gemini-flash-latest": { + id: "gemini-flash-latest", + name: "Gemini Flash Latest", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-flash-lite-latest": { + id: "gemini-flash-lite-latest", + name: "Gemini Flash-Lite Latest", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-live-2.5-flash": { + id: "gemini-live-2.5-flash", + name: "Gemini Live 2.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 8000, + } satisfies Model<"google-generative-ai">, + "gemini-live-2.5-flash-preview-native-audio": { + id: "gemini-live-2.5-flash-preview-native-audio", + name: "Gemini Live 2.5 Flash Preview Native Audio", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", reasoning: true, input: ["text"], cost: { - input: 0.6, - output: 2.2, - cacheRead: 0.11, + input: 0.5, + output: 2, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 204800, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, - "glm-4.6v": { - id: "glm-4.6v", - name: "GLM-4.6V", - api: "anthropic-messages", - provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + }, + "google-antigravity": { + "claude-opus-4-5-thinking": { + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", reasoning: true, input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "claude-sonnet-4-5-thinking": { + id: "claude-sonnet-4-5-thinking", + name: "Claude Sonnet 4.5 Thinking (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "gemini-3-flash": { + id: "gemini-3-flash", + name: "Gemini 3 Flash (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-3-pro-high": { + id: "gemini-3-pro-high", + name: "Gemini 3 Pro High (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-3-pro-low": { + id: "gemini-3-pro-low", + name: "Gemini 3 Pro Low (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gpt-oss-120b-medium": { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B Medium (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"google-gemini-cli">, + }, + "google-gemini-cli": { + "gemini-2.0-flash": { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"google-gemini-cli">, + "gemini-2.5-flash": { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-2.5-pro": { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "gemini-3-pro-preview": { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview (Cloud Code Assist)", + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + }, + "groq": { + "deepseek-r1-distill-llama-70b": { + id: "deepseek-r1-distill-llama-70b", + name: "DeepSeek R1 Distill Llama 70B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.75, + output: 0.99, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "gemma2-9b-it": { + id: "gemma2-9b-it", + name: "Gemma 2 9B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.2, + output: 0.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "llama-3.1-8b-instant": { + id: "llama-3.1-8b-instant", + name: "Llama 3.1 8B Instant", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.05, + output: 0.08, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "llama-3.3-70b-versatile": { + id: "llama-3.3-70b-versatile", + name: "Llama 3.3 70B Versatile", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.59, + output: 0.79, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "llama3-70b-8192": { + id: "llama3-70b-8192", + name: "Llama 3 70B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.59, + output: 0.79, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "llama3-8b-8192": { + id: "llama3-8b-8192", + name: "Llama 3 8B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.05, + output: 0.08, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-maverick-17b-128e-instruct": { + id: "meta-llama/llama-4-maverick-17b-128e-instruct", + name: "Llama 4 Maverick 17B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-scout-17b-16e-instruct": { + id: "meta-llama/llama-4-scout-17b-16e-instruct", + name: "Llama 4 Scout 17B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.11, + output: 0.34, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "mistral-saba-24b": { + id: "mistral-saba-24b", + name: "Mistral Saba 24B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.79, + output: 0.79, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-instruct": { + id: "moonshotai/kimi-k2-instruct", + name: "Kimi K2 Instruct", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-instruct-0905": { + id: "moonshotai/kimi-k2-instruct-0905", + name: "Kimi K2 Instruct 0905", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.1, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen-qwq-32b": { + id: "qwen-qwq-32b", + name: "Qwen QwQ 32B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.29, + output: 0.39, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen3-32b": { + id: "qwen/qwen3-32b", + name: "Qwen3 32B", + api: "openai-completions", + provider: "groq", + baseUrl: "https://api.groq.com/openai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.29, + output: 0.59, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + }, + "mistral": { + "codestral-latest": { + id: "codestral-latest", + name: "Codestral", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], cost: { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 32768, - } satisfies Model<"anthropic-messages">, - }, - "mistral": { - "devstral-medium-2507": { - id: "devstral-medium-2507", - name: "Devstral Medium", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.4, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "mistral-large-2512": { - id: "mistral-large-2512", - name: "Mistral Large 3", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "open-mixtral-8x22b": { - id: "open-mixtral-8x22b", - name: "Mixtral 8x22B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 64000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "ministral-8b-latest": { - id: "ministral-8b-latest", - name: "Ministral 8B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.1, - output: 0.1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "pixtral-large-latest": { - id: "pixtral-large-latest", - name: "Pixtral Large", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "mistral-small-2506": { - id: "mistral-small-2506", - name: "Mistral Small 3.2", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 256000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "devstral-2512": { id: "devstral-2512", @@ -2212,73 +1629,22 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, - "ministral-3b-latest": { - id: "ministral-3b-latest", - name: "Ministral 3B", + "devstral-medium-2507": { + id: "devstral-medium-2507", + name: "Devstral Medium", api: "openai-completions", provider: "mistral", baseUrl: "https://api.mistral.ai/v1", reasoning: false, input: ["text"], - cost: { - input: 0.04, - output: 0.04, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "pixtral-12b": { - id: "pixtral-12b", - name: "Pixtral 12B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "mistral-medium-2505": { - id: "mistral-medium-2505", - name: "Mistral Medium 3", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "labs-devstral-small-2512": { - id: "labs-devstral-small-2512", - name: "Devstral Small 2", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, + contextWindow: 128000, + maxTokens: 128000, } satisfies Model<"openai-completions">, "devstral-medium-latest": { id: "devstral-medium-latest", @@ -2314,31 +1680,14 @@ export const MODELS = { contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "mistral-medium-2508": { - id: "mistral-medium-2508", - name: "Mistral Medium 3.1", + "devstral-small-2507": { + id: "devstral-small-2507", + name: "Devstral Small", api: "openai-completions", provider: "mistral", baseUrl: "https://api.mistral.ai/v1", reasoning: false, - input: ["text", "image"], - cost: { - input: 0.4, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "mistral-small-latest": { - id: "mistral-small-latest", - name: "Mistral Small", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text", "image"], + input: ["text"], cost: { input: 0.1, output: 0.3, @@ -2346,6 +1695,40 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "labs-devstral-small-2512": { + id: "labs-devstral-small-2512", + name: "Devstral Small 2", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "magistral-medium-latest": { + id: "magistral-medium-latest", + name: "Magistral Medium", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 2, + output: 5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, "magistral-small": { @@ -2365,9 +1748,26 @@ export const MODELS = { contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "devstral-small-2507": { - id: "devstral-small-2507", - name: "Devstral Small", + "ministral-3b-latest": { + id: "ministral-3b-latest", + name: "Ministral 3B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.04, + output: 0.04, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "ministral-8b-latest": { + id: "ministral-8b-latest", + name: "Ministral 8B", api: "openai-completions", provider: "mistral", baseUrl: "https://api.mistral.ai/v1", @@ -2375,80 +1775,46 @@ export const MODELS = { input: ["text"], cost: { input: 0.1, - output: 0.3, + output: 0.1, cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "codestral-latest": { - id: "codestral-latest", - name: "Codestral", + "mistral-large-2411": { + id: "mistral-large-2411", + name: "Mistral Large 2.1", api: "openai-completions", provider: "mistral", baseUrl: "https://api.mistral.ai/v1", reasoning: false, input: ["text"], cost: { - input: 0.3, - output: 0.9, + input: 2, + output: 6, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 4096, + contextWindow: 131072, + maxTokens: 16384, } satisfies Model<"openai-completions">, - "open-mixtral-8x7b": { - id: "open-mixtral-8x7b", - name: "Mixtral 8x7B", + "mistral-large-2512": { + id: "mistral-large-2512", + name: "Mistral Large 3", api: "openai-completions", provider: "mistral", baseUrl: "https://api.mistral.ai/v1", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.7, - output: 0.7, + input: 0.5, + output: 1.5, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 32000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "mistral-nemo": { - id: "mistral-nemo", - name: "Mistral Nemo", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "open-mistral-7b": { - id: "open-mistral-7b", - name: "Mistral 7B", - api: "openai-completions", - provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.25, - output: 0.25, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8000, - maxTokens: 8000, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"openai-completions">, "mistral-large-latest": { id: "mistral-large-latest", @@ -2467,6 +1833,40 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "mistral-medium-2505": { + id: "mistral-medium-2505", + name: "Mistral Medium 3", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mistral-medium-2508": { + id: "mistral-medium-2508", + name: "Mistral Medium 3.1", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, "mistral-medium-latest": { id: "mistral-medium-latest", name: "Mistral Medium", @@ -2484,9 +1884,77 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "mistral-large-2411": { - id: "mistral-large-2411", - name: "Mistral Large 2.1", + "mistral-nemo": { + id: "mistral-nemo", + name: "Mistral Nemo", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "mistral-small-2506": { + id: "mistral-small-2506", + name: "Mistral Small 3.2", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistral-small-latest": { + id: "mistral-small-latest", + name: "Mistral Small", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "open-mistral-7b": { + id: "open-mistral-7b", + name: "Mistral 7B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 0.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8000, + maxTokens: 8000, + } satisfies Model<"openai-completions">, + "open-mixtral-8x22b": { + id: "open-mixtral-8x22b", + name: "Mixtral 8x22B", api: "openai-completions", provider: "mistral", baseUrl: "https://api.mistral.ai/v1", @@ -2498,542 +1966,460 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 16384, + contextWindow: 64000, + maxTokens: 64000, } satisfies Model<"openai-completions">, - "magistral-medium-latest": { - id: "magistral-medium-latest", - name: "Magistral Medium", + "open-mixtral-8x7b": { + id: "open-mixtral-8x7b", + name: "Mixtral 8x7B", api: "openai-completions", provider: "mistral", baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.7, + output: 0.7, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "pixtral-12b": { + id: "pixtral-12b", + name: "Pixtral 12B", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "pixtral-large-latest": { + id: "pixtral-large-latest", + name: "Pixtral Large", + api: "openai-completions", + provider: "mistral", + baseUrl: "https://api.mistral.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + }, + "openai": { + "codex-mini-latest": { + id: "codex-mini-latest", + name: "Codex Mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text"], cost: { - input: 2, - output: 5, + input: 1.5, + output: 6, + cacheRead: 0.375, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "gpt-4": { + id: "gpt-4", + name: "GPT-4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, cacheRead: 0, cacheWrite: 0, }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-responses">, + "gpt-4-turbo": { + id: "gpt-4-turbo", + name: "GPT-4 Turbo", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-responses">, + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "gpt-4.1-mini": { + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.4, + output: 1.6, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "gpt-4.1-nano": { + id: "gpt-4.1-nano", + name: "GPT-4.1 nano", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "gpt-4o": { + id: "gpt-4o", + name: "GPT-4o", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, contextWindow: 128000, maxTokens: 16384, - } satisfies Model<"openai-completions">, - }, - "github-copilot": { - "gemini-3-flash-preview": { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "grok-code-fast-1": { - id: "grok-code-fast-1", - name: "Grok Code Fast 1", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "gpt-5.1-codex": { - id: "gpt-5.1-codex", - name: "GPT-5.1-Codex", + } satisfies Model<"openai-responses">, + "gpt-4o-2024-05-13": { + id: "gpt-4o-2024-05-13", + name: "GPT-4o (2024-05-13)", api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, input: ["text", "image"], cost: { - input: 0, - output: 0, + input: 5, + output: 15, cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-responses">, + "gpt-4o-2024-08-06": { + id: "gpt-4o-2024-08-06", + name: "GPT-4o (2024-08-06)", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-4o-2024-11-20": { + id: "gpt-4o-2024-11-20", + name: "GPT-4o (2024-11-20)", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-4o-mini": { + id: "gpt-4o-mini", + name: "GPT-4o mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.13, + cacheWrite: 0, + }, + contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "claude-haiku-4.5": { - id: "claude-haiku-4.5", - name: "Claude Haiku 4.5", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16000, - } satisfies Model<"openai-completions">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "oswe-vscode-prime": { - id: "oswe-vscode-prime", - name: "Raptor Mini (Preview)", + "gpt-5-chat-latest": { + id: "gpt-5-chat-latest", + name: "GPT-5 Chat Latest", api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"openai-responses">, - "gpt-5.1-codex-mini": { - id: "gpt-5.1-codex-mini", - name: "GPT-5.1-Codex-mini", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.25, + output: 10, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, - "gpt-5.1": { - id: "gpt-5.1", - name: "GPT-5.1", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, + maxTokens: 16384, } satisfies Model<"openai-responses">, "gpt-5-codex": { id: "gpt-5-codex", name: "GPT-5-Codex", api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.25, + output: 10, + cacheRead: 0.125, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "gpt-4o": { - id: "gpt-4o", - name: "GPT-4o", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 64000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "gpt-4.1": { - id: "gpt-4.1", - name: "GPT-4.1", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "gpt-5-mini": { id: "gpt-5-mini", - name: "GPT-5-mini", + name: "GPT-5 Mini", api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-responses">, - "gemini-2.5-pro": { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "gpt-5.1-codex-max": { - id: "gpt-5.1-codex-max", - name: "GPT-5.1-Codex-max", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "claude-sonnet-4": { - id: "claude-sonnet-4", - name: "Claude Sonnet 4", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16000, - } satisfies Model<"openai-completions">, - "gpt-5": { - id: "gpt-5", - name: "GPT-5", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, - "claude-opus-4.5": { - id: "claude-opus-4.5", - name: "Claude Opus 4.5", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16000, - } satisfies Model<"openai-completions">, - "gpt-5.2": { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-responses", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"openai-responses">, - "claude-sonnet-4.5": { - id: "claude-sonnet-4.5", - name: "Claude Sonnet 4.5", - api: "openai-completions", - provider: "github-copilot", - baseUrl: "https://api.individual.githubcopilot.com", - headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16000, - } satisfies Model<"openai-completions">, - }, - "openrouter": { - "bytedance-seed/seed-1.6-flash": { - id: "bytedance-seed/seed-1.6-flash", - name: "ByteDance Seed: Seed 1.6 Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "bytedance-seed/seed-1.6": { - id: "bytedance-seed/seed-1.6", - name: "ByteDance Seed: Seed 1.6", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { input: 0.25, output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "minimax/minimax-m2.1": { - id: "minimax/minimax-m2.1", - name: "MiniMax: MiniMax M2.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, cacheRead: 0.03, - cacheWrite: 0.375, - }, - contextWindow: 204800, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "z-ai/glm-4.7": { - id: "z-ai/glm-4.7", - name: "Z.AI: GLM 4.7", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.5, - cacheRead: 0, cacheWrite: 0, }, - contextWindow: 202752, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "google/gemini-3-flash-preview": { - id: "google/gemini-3-flash-preview", - name: "Google: Gemini 3 Flash Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-nano": { + id: "gpt-5-nano", + name: "GPT-5 Nano", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { - input: 0.5, - output: 3, - cacheRead: 0.049999999999999996, + input: 0.05, + output: 0.4, + cacheRead: 0.01, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-creative": { - id: "mistralai/mistral-small-creative", - name: "Mistral: Mistral Small Creative", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "xiaomi/mimo-v2-flash:free": { - id: "xiaomi/mimo-v2-flash:free", - name: "Xiaomi: MiMo-V2-Flash (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-pro": { + id: "gpt-5-pro", + name: "GPT-5 Pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0, - output: 0, + input: 15, + output: 120, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-3-nano-30b-a3b:free": { - id: "nvidia/nemotron-3-nano-30b-a3b:free", - name: "NVIDIA: Nemotron 3 Nano 30B A3B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + contextWindow: 400000, + maxTokens: 272000, + } satisfies Model<"openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 1.25, + output: 10, + cacheRead: 0.13, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-3-nano-30b-a3b": { - id: "nvidia/nemotron-3-nano-30b-a3b", - name: "NVIDIA: Nemotron 3 Nano 30B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-chat-latest": { + id: "gpt-5.1-chat-latest", + name: "GPT-5.1 Chat", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.06, - output: 0.24, - cacheRead: 0, + input: 1.25, + output: 10, + cacheRead: 0.125, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "openai/gpt-5.2-chat": { - id: "openai/gpt-5.2-chat", - name: "OpenAI: GPT-5.2 Chat", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex": { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1-codex-mini": { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1 Codex mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2": { + id: "gpt-5.2", + name: "GPT-5.2", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.2-chat-latest": { + id: "gpt-5.2-chat-latest", + name: "GPT-5.2 Chat", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, input: ["text", "image"], cost: { input: 1.75, @@ -3043,13 +2429,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-5.2-pro": { - id: "openai/gpt-5.2-pro", - name: "OpenAI: GPT-5.2 Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + } satisfies Model<"openai-responses">, + "gpt-5.2-pro": { + id: "gpt-5.2-pro", + name: "GPT-5.2 Pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { @@ -3060,693 +2446,47 @@ export const MODELS = { }, contextWindow: 400000, maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5.2": { - id: "openai/gpt-5.2", - name: "OpenAI: GPT-5.2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + } satisfies Model<"openai-responses">, + "o1": { + id: "o1", + name: "o1", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "mistralai/devstral-2512:free": { - id: "mistralai/devstral-2512:free", - name: "Mistral: Devstral 2 2512 (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/devstral-2512": { - id: "mistralai/devstral-2512", - name: "Mistral: Devstral 2 2512", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.049999999999999996, - output: 0.22, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "relace/relace-search": { - id: "relace/relace-search", - name: "Relace: Relace Search", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "z-ai/glm-4.6v": { - id: "z-ai/glm-4.6v", - name: "Z.AI: GLM 4.6V", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 0.8999999999999999, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 24000, - } satisfies Model<"openai-completions">, - "nex-agi/deepseek-v3.1-nex-n1:free": { - id: "nex-agi/deepseek-v3.1-nex-n1:free", - name: "Nex AGI: DeepSeek V3.1 Nex N1 (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "openai/gpt-5.1-codex-max": { - id: "openai/gpt-5.1-codex-max", - name: "OpenAI: GPT-5.1-Codex-Max", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "amazon/nova-2-lite-v1": { - id: "amazon/nova-2-lite-v1", - name: "Amazon: Nova 2 Lite", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "mistralai/ministral-14b-2512": { - id: "mistralai/ministral-14b-2512", - name: "Mistral: Ministral 3 14B 2512", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 0.19999999999999998, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/ministral-8b-2512": { - id: "mistralai/ministral-8b-2512", - name: "Mistral: Ministral 3 8B 2512", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/ministral-3b-2512": { - id: "mistralai/ministral-3b-2512", - name: "Mistral: Ministral 3 3B 2512", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-large-2512": { - id: "mistralai/mistral-large-2512", - name: "Mistral: Mistral Large 3 2512", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "arcee-ai/trinity-mini:free": { - id: "arcee-ai/trinity-mini:free", - name: "Arcee AI: Trinity Mini (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "arcee-ai/trinity-mini": { - id: "arcee-ai/trinity-mini", - name: "Arcee AI: Trinity Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.045, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-v3.2": { - id: "deepseek/deepseek-v3.2", - name: "DeepSeek: DeepSeek V3.2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.224, - output: 0.32, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "prime-intellect/intellect-3": { - id: "prime-intellect/intellect-3", - name: "Prime Intellect: INTELLECT-3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 1.1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "tngtech/tng-r1t-chimera:free": { - id: "tngtech/tng-r1t-chimera:free", - name: "TNG: R1T Chimera (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "tngtech/tng-r1t-chimera": { - id: "tngtech/tng-r1t-chimera", - name: "TNG: R1T Chimera", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "anthropic/claude-opus-4.5": { - id: "anthropic/claude-opus-4.5", - name: "Anthropic: Claude Opus 4.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "allenai/olmo-3-7b-instruct": { - id: "allenai/olmo-3-7b-instruct", - name: "AllenAI: Olmo 3 7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.19999999999999998, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "x-ai/grok-4.1-fast": { - id: "x-ai/grok-4.1-fast", - name: "xAI: Grok 4.1 Fast", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 0.5, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "google/gemini-3-pro-preview": { - id: "google/gemini-3-pro-preview", - name: "Google: Gemini 3 Pro Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 12, - cacheRead: 0.19999999999999998, - cacheWrite: 2.375, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "openai/gpt-5.1": { - id: "openai/gpt-5.1", - name: "OpenAI: GPT-5.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5.1-chat": { - id: "openai/gpt-5.1-chat", - name: "OpenAI: GPT-5.1 Chat", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-5.1-codex": { - id: "openai/gpt-5.1-codex", - name: "OpenAI: GPT-5.1-Codex", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5.1-codex-mini": { - id: "openai/gpt-5.1-codex-mini", - name: "OpenAI: GPT-5.1-Codex-Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.25, - output: 2, - cacheRead: 0.024999999999999998, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "kwaipilot/kat-coder-pro:free": { - id: "kwaipilot/kat-coder-pro:free", - name: "Kwaipilot: KAT-Coder-Pro V1 (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-thinking": { - id: "moonshotai/kimi-k2-thinking", - name: "MoonshotAI: Kimi K2 Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "amazon/nova-premier-v1": { - id: "amazon/nova-premier-v1", - name: "Amazon: Nova Premier 1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 12.5, - cacheRead: 0.625, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "mistralai/voxtral-small-24b-2507": { - id: "mistralai/voxtral-small-24b-2507", - name: "Mistral: Voxtral Small 24B 2507", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-safeguard-20b": { - id: "openai/gpt-oss-safeguard-20b", - name: "OpenAI: gpt-oss-safeguard-20b", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.037, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-12b-v2-vl:free": { - id: "nvidia/nemotron-nano-12b-v2-vl:free", - name: "NVIDIA: Nemotron Nano 12B 2 VL (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "minimax/minimax-m2": { - id: "minimax/minimax-m2", - name: "MiniMax: MiniMax M2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 1, - cacheRead: 0.03, - cacheWrite: 0, - }, - contextWindow: 196608, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "deepcogito/cogito-v2-preview-llama-405b": { - id: "deepcogito/cogito-v2-preview-llama-405b", - name: "Deep Cogito: Cogito V2 Preview Llama 405B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-5-image-mini": { - id: "openai/gpt-5-image-mini", - name: "OpenAI: GPT-5 Image Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2.5, - output: 2, - cacheRead: 0.25, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "anthropic/claude-haiku-4.5": { - id: "anthropic/claude-haiku-4.5", - name: "Anthropic: Claude Haiku 4.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1, - output: 5, - cacheRead: 0.09999999999999999, - cacheWrite: 1.25, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-8b-thinking": { - id: "qwen/qwen3-vl-8b-thinking", - name: "Qwen: Qwen3 VL 8B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.18, - output: 2.0999999999999996, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-8b-instruct": { - id: "qwen/qwen3-vl-8b-instruct", - name: "Qwen: Qwen3 VL 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.064, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "openai/gpt-5-image": { - id: "openai/gpt-5-image", - name: "OpenAI: GPT-5 Image", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 10, - output: 10, - cacheRead: 1.25, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/o3-deep-research": { - id: "openai/o3-deep-research", - name: "OpenAI: o3 Deep Research", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 10, - output: 40, - cacheRead: 2.5, + input: 15, + output: 60, + cacheRead: 7.5, cacheWrite: 0, }, contextWindow: 200000, maxTokens: 100000, - } satisfies Model<"openai-completions">, - "openai/o4-mini-deep-research": { - id: "openai/o4-mini-deep-research", - name: "OpenAI: o4 Mini Deep Research", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + } satisfies Model<"openai-responses">, + "o1-pro": { + id: "o1-pro", + name: "o1-pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 150, + output: 600, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3": { + id: "o3", + name: "o3", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { @@ -3757,329 +2497,127 @@ export const MODELS = { }, contextWindow: 200000, maxTokens: 100000, - } satisfies Model<"openai-completions">, - "nvidia/llama-3.3-nemotron-super-49b-v1.5": { - id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", - name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + } satisfies Model<"openai-responses">, + "o3-deep-research": { + id: "o3-deep-research", + name: "o3-deep-research", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 40, + cacheRead: 2.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3-mini": { + id: "o3-mini", + name: "o3-mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0, + input: 1.1, + output: 4.4, + cacheRead: 0.55, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-30b-a3b-thinking": { - id: "qwen/qwen3-vl-30b-a3b-thinking", - name: "Qwen: Qwen3 VL 30B A3B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o3-pro": { + id: "o3-pro", + name: "o3-pro", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { - input: 0.16, - output: 0.7999999999999999, + input: 20, + output: 80, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-30b-a3b-instruct": { - id: "qwen/qwen3-vl-30b-a3b-instruct", - name: "Qwen: Qwen3 VL 30B A3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-5-pro": { - id: "openai/gpt-5-pro", - name: "OpenAI: GPT-5 Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o4-mini": { + id: "o4-mini", + name: "o4-mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { - input: 15, - output: 120, - cacheRead: 0, + input: 1.1, + output: 4.4, + cacheRead: 0.28, cacheWrite: 0, }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "z-ai/glm-4.6": { - id: "z-ai/glm-4.6", - name: "Z.AI: GLM 4.6", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.35, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 202752, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "z-ai/glm-4.6:exacto": { - id: "z-ai/glm-4.6:exacto", - name: "Z.AI: GLM 4.6 (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.44, - output: 1.76, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 204800, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "anthropic/claude-sonnet-4.5": { - id: "anthropic/claude-sonnet-4.5", - name: "Anthropic: Claude Sonnet 4.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + "o4-mini-deep-research": { + id: "o4-mini-deep-research", + name: "o4-mini-deep-research", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", reasoning: true, input: ["text", "image"], cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 1000000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-v3.2-exp": { - id: "deepseek/deepseek-v3.2-exp", - name: "DeepSeek: DeepSeek V3.2 Exp", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.21, - output: 0.32, - cacheRead: 0, + input: 2, + output: 8, + cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 163840, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-flash-preview-09-2025": { - id: "google/gemini-2.5-flash-preview-09-2025", - name: "Google: Gemini 2.5 Flash Preview 09-2025", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.075, - cacheWrite: 0.3833, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-flash-lite-preview-09-2025": { - id: "google/gemini-2.5-flash-lite-preview-09-2025", - name: "Google: Gemini 2.5 Flash Lite Preview 09-2025", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-235b-a22b-thinking": { - id: "qwen/qwen3-vl-235b-a22b-thinking", - name: "Qwen: Qwen3 VL 235B A22B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "qwen/qwen3-vl-235b-a22b-instruct": { - id: "qwen/qwen3-vl-235b-a22b-instruct", - name: "Qwen: Qwen3 VL 235B A22B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "qwen/qwen3-max": { - id: "qwen/qwen3-max", - name: "Qwen: Qwen3 Max", + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-responses">, + }, + "openrouter": { + "ai21/jamba-large-1.7": { + id: "ai21/jamba-large-1.7", + name: "AI21: Jamba Large 1.7", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 1.2, - output: 6, - cacheRead: 0.24, + input: 2, + output: 8, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 256000, - maxTokens: 32768, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-plus": { - id: "qwen/qwen3-coder-plus", - name: "Qwen: Qwen3 Coder Plus", + "ai21/jamba-mini-1.7": { + id: "ai21/jamba-mini-1.7", + name: "AI21: Jamba Mini 1.7", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], - cost: { - input: 1, - output: 5, - cacheRead: 0.09999999999999999, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "openai/gpt-5-codex": { - id: "openai/gpt-5-codex", - name: "OpenAI: GPT-5 Codex", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-v3.1-terminus:exacto": { - id: "deepseek/deepseek-v3.1-terminus:exacto", - name: "DeepSeek: DeepSeek V3.1 Terminus (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.21, - output: 0.7899999999999999, - cacheRead: 0.16799999999999998, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-v3.1-terminus": { - id: "deepseek/deepseek-v3.1-terminus", - name: "DeepSeek: DeepSeek V3.1 Terminus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.21, - output: 0.7899999999999999, - cacheRead: 0.16799999999999998, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-4-fast": { - id: "x-ai/grok-4-fast", - name: "xAI: Grok 4 Fast", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], cost: { input: 0.19999999999999998, - output: 0.5, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - } satisfies Model<"openai-completions">, - "alibaba/tongyi-deepresearch-30b-a3b:free": { - id: "alibaba/tongyi-deepresearch-30b-a3b:free", - name: "Tongyi DeepResearch 30B A3B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 256000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "alibaba/tongyi-deepresearch-30b-a3b": { id: "alibaba/tongyi-deepresearch-30b-a3b", @@ -4098,94 +2636,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-flash": { - id: "qwen/qwen3-coder-flash", - name: "Qwen: Qwen3 Coder Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 1.5, - cacheRead: 0.08, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "qwen/qwen3-next-80b-a3b-thinking": { - id: "qwen/qwen3-next-80b-a3b-thinking", - name: "Qwen: Qwen3 Next 80B A3B Thinking", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.12, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-next-80b-a3b-instruct": { - id: "qwen/qwen3-next-80b-a3b-instruct", - name: "Qwen: Qwen3 Next 80B A3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09, - output: 1.1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus-2025-07-28": { - id: "qwen/qwen-plus-2025-07-28", - name: "Qwen: Qwen Plus 0728", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus-2025-07-28:thinking": { - id: "qwen/qwen-plus-2025-07-28:thinking", - name: "Qwen: Qwen Plus 0728 (thinking)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-9b-v2:free": { - id: "nvidia/nemotron-nano-9b-v2:free", - name: "NVIDIA: Nemotron Nano 9B V2 (free)", + "alibaba/tongyi-deepresearch-30b-a3b:free": { + id: "alibaba/tongyi-deepresearch-30b-a3b:free", + name: "Tongyi DeepResearch 30B A3B (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -4197,230 +2650,383 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-9b-v2": { - id: "nvidia/nemotron-nano-9b-v2", - name: "NVIDIA: Nemotron Nano 9B V2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.04, - output: 0.16, - cacheRead: 0, - cacheWrite: 0, - }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 131072, } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-0905": { - id: "moonshotai/kimi-k2-0905", - name: "MoonshotAI: Kimi K2 0905", + "allenai/olmo-3-7b-instruct": { + id: "allenai/olmo-3-7b-instruct", + name: "AllenAI: Olmo 3 7B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.39, - output: 1.9, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2-0905:exacto": { - id: "moonshotai/kimi-k2-0905:exacto", - name: "MoonshotAI: Kimi K2 0905 (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.6, - output: 2.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepcogito/cogito-v2-preview-llama-70b": { - id: "deepcogito/cogito-v2-preview-llama-70b", - name: "Deep Cogito: Cogito V2 Preview Llama 70B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.88, - output: 0.88, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepcogito/cogito-v2-preview-llama-109b-moe": { - id: "deepcogito/cogito-v2-preview-llama-109b-moe", - name: "Cogito V2 Preview Llama 109B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.18, - output: 0.59, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32767, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "stepfun-ai/step3": { - id: "stepfun-ai/step3", - name: "StepFun: Step3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5700000000000001, - output: 1.42, + input: 0.09999999999999999, + output: 0.19999999999999998, cacheRead: 0, cacheWrite: 0, }, contextWindow: 65536, maxTokens: 65536, } satisfies Model<"openai-completions">, - "qwen/qwen3-30b-a3b-thinking-2507": { - id: "qwen/qwen3-30b-a3b-thinking-2507", - name: "Qwen: Qwen3 30B A3B Thinking 2507", + "amazon/nova-2-lite-v1": { + id: "amazon/nova-2-lite-v1", + name: "Amazon: Nova 2 Lite", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], - cost: { - input: 0.051, - output: 0.33999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-code-fast-1": { - id: "x-ai/grok-code-fast-1", - name: "xAI: Grok Code Fast 1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 1.5, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 10000, - } satisfies Model<"openai-completions">, - "nousresearch/hermes-4-70b": { - id: "nousresearch/hermes-4-70b", - name: "Nous: Hermes 4 70B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.11, - output: 0.38, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "nousresearch/hermes-4-405b": { - id: "nousresearch/hermes-4-405b", - name: "Nous: Hermes 4 405B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0.3, - output: 1.2, + output: 2.5, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 1000000, + maxTokens: 65535, } satisfies Model<"openai-completions">, - "deepseek/deepseek-chat-v3.1": { - id: "deepseek/deepseek-chat-v3.1", - name: "DeepSeek: DeepSeek V3.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.15, - output: 0.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 7168, - } satisfies Model<"openai-completions">, - "openai/gpt-4o-audio-preview": { - id: "openai/gpt-4o-audio-preview", - name: "OpenAI: GPT-4o Audio", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistralai/mistral-medium-3.1": { - id: "mistralai/mistral-medium-3.1", - name: "Mistral: Mistral Medium 3.1", + "amazon/nova-lite-v1": { + id: "amazon/nova-lite-v1", + name: "Amazon: Nova Lite 1.0", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], cost: { - input: 0.39999999999999997, - output: 2, + input: 0.06, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 300000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "amazon/nova-micro-v1": { + id: "amazon/nova-micro-v1", + name: "Amazon: Nova Micro 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.035, + output: 0.14, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "amazon/nova-premier-v1": { + id: "amazon/nova-premier-v1", + name: "Amazon: Nova Premier 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2.5, + output: 12.5, + cacheRead: 0.625, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "amazon/nova-pro-v1": { + id: "amazon/nova-pro-v1", + name: "Amazon: Nova Pro 1.0", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 3.1999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 300000, + maxTokens: 5120, + } satisfies Model<"openai-completions">, + "anthropic/claude-3-haiku": { + id: "anthropic/claude-3-haiku", + name: "Anthropic: Claude 3 Haiku", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "anthropic/claude-3-opus": { + id: "anthropic/claude-3-opus", + name: "Anthropic: Claude 3 Opus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7999999999999999, + output: 4, + cacheRead: 0.08, + cacheWrite: 1, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.5-sonnet": { + id: "anthropic/claude-3.5-sonnet", + name: "Anthropic: Claude 3.5 Sonnet", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 6, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.7-sonnet": { + id: "anthropic/claude-3.7-sonnet", + name: "Anthropic: Claude 3.7 Sonnet", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-3.7-sonnet:thinking": { + id: "anthropic/claude-3.7-sonnet:thinking", + name: "Anthropic: Claude 3.7 Sonnet (thinking)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-haiku-4.5": { + id: "anthropic/claude-haiku-4.5", + name: "Anthropic: Claude Haiku 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 5, + cacheRead: 0.09999999999999999, + cacheWrite: 1.25, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4": { + id: "anthropic/claude-opus-4", + name: "Anthropic: Claude Opus 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.1": { + id: "anthropic/claude-opus-4.1", + name: "Anthropic: Claude Opus 4.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + contextWindow: 200000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.5": { + id: "anthropic/claude-opus-4.5", + name: "Anthropic: Claude Opus 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "anthropic/claude-sonnet-4": { + id: "anthropic/claude-sonnet-4", + name: "Anthropic: Claude Sonnet 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "anthropic/claude-sonnet-4.5": { + id: "anthropic/claude-sonnet-4.5", + name: "Anthropic: Claude Sonnet 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "arcee-ai/trinity-mini": { + id: "arcee-ai/trinity-mini", + name: "Arcee AI: Trinity Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.045, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "arcee-ai/trinity-mini:free": { + id: "arcee-ai/trinity-mini:free", + name: "Arcee AI: Trinity Mini (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "arcee-ai/virtuoso-large": { + id: "arcee-ai/virtuoso-large", + name: "Arcee AI: Virtuoso Large", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.75, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "baidu/ernie-4.5-21b-a3b": { id: "baidu/ernie-4.5-21b-a3b", name: "Baidu: ERNIE 4.5 21B A3B", @@ -4455,77 +3061,9 @@ export const MODELS = { contextWindow: 30000, maxTokens: 8000, } satisfies Model<"openai-completions">, - "z-ai/glm-4.5v": { - id: "z-ai/glm-4.5v", - name: "Z.AI: GLM 4.5V", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.48, - output: 1.44, - cacheRead: 0.088, - cacheWrite: 0, - }, - contextWindow: 65536, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "ai21/jamba-mini-1.7": { - id: "ai21/jamba-mini-1.7", - name: "AI21: Jamba Mini 1.7", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "ai21/jamba-large-1.7": { - id: "ai21/jamba-large-1.7", - name: "AI21: Jamba Large 1.7", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 8, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-5": { - id: "openai/gpt-5", - name: "OpenAI: GPT-5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-5-mini": { - id: "openai/gpt-5-mini", - name: "OpenAI: GPT-5 Mini", + "bytedance-seed/seed-1.6": { + id: "bytedance-seed/seed-1.6", + name: "ByteDance Seed: Seed 1.6", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -4534,134 +3072,117 @@ export const MODELS = { cost: { input: 0.25, output: 2, - cacheRead: 0.024999999999999998, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 400000, - maxTokens: 128000, + contextWindow: 262144, + maxTokens: 32768, } satisfies Model<"openai-completions">, - "openai/gpt-5-nano": { - id: "openai/gpt-5-nano", - name: "OpenAI: GPT-5 Nano", + "bytedance-seed/seed-1.6-flash": { + id: "bytedance-seed/seed-1.6-flash", + name: "ByteDance Seed: Seed 1.6 Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text", "image"], cost: { - input: 0.049999999999999996, - output: 0.39999999999999997, - cacheRead: 0.005, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b:free": { - id: "openai/gpt-oss-120b:free", - name: "OpenAI: gpt-oss-120b (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, + input: 0.075, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 262144, + maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b": { - id: "openai/gpt-oss-120b", - name: "OpenAI: gpt-oss-120b", + "cohere/command-r-08-2024": { + id: "cohere/command-r-08-2024", + name: "Cohere: Command R (08-2024)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text"], cost: { - input: 0.039, - output: 0.19, + input: 0.15, + output: 0.6, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 128000, + maxTokens: 4000, } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b:exacto": { - id: "openai/gpt-oss-120b:exacto", - name: "OpenAI: gpt-oss-120b (exacto)", + "cohere/command-r-plus-08-2024": { + id: "cohere/command-r-plus-08-2024", + name: "Cohere: Command R+ (08-2024)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text"], cost: { - input: 0.039, - output: 0.19, + input: 2.5, + output: 10, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 128000, + maxTokens: 4000, } satisfies Model<"openai-completions">, - "openai/gpt-oss-20b:free": { - id: "openai/gpt-oss-20b:free", - name: "OpenAI: gpt-oss-20b (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-20b": { - id: "openai/gpt-oss-20b", - name: "OpenAI: gpt-oss-20b", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.03, - output: 0.14, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "anthropic/claude-opus-4.1": { - id: "anthropic/claude-opus-4.1", - name: "Anthropic: Claude Opus 4.1", + "deepcogito/cogito-v2-preview-llama-109b-moe": { + id: "deepcogito/cogito-v2-preview-llama-109b-moe", + name: "Cogito V2 Preview Llama 109B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text", "image"], cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, + input: 0.18, + output: 0.59, + cacheRead: 0, + cacheWrite: 0, }, - contextWindow: 200000, + contextWindow: 32767, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/codestral-2508": { - id: "mistralai/codestral-2508", - name: "Mistral: Codestral 2508", + "deepcogito/cogito-v2-preview-llama-405b": { + id: "deepcogito/cogito-v2-preview-llama-405b", + name: "Deep Cogito: Cogito V2 Preview Llama 405B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepcogito/cogito-v2-preview-llama-70b": { + id: "deepcogito/cogito-v2-preview-llama-70b", + name: "Deep Cogito: Cogito V2 Preview Llama 70B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.88, + output: 0.88, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-chat": { + id: "deepseek/deepseek-chat", + name: "DeepSeek: DeepSeek V3", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -4669,182 +3190,233 @@ export const MODELS = { input: ["text"], cost: { input: 0.3, - output: 0.8999999999999999, + output: 1.2, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-chat-v3-0324": { + id: "deepseek/deepseek-chat-v3-0324", + name: "DeepSeek: DeepSeek V3 0324", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 0.88, + cacheRead: 0.106, + cacheWrite: 0, + }, + contextWindow: 163840, maxTokens: 4096, } satisfies Model<"openai-completions">, - "qwen/qwen3-coder-30b-a3b-instruct": { - id: "qwen/qwen3-coder-30b-a3b-instruct", - name: "Qwen: Qwen3 Coder 30B A3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.07, - output: 0.27, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 160000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "qwen/qwen3-30b-a3b-instruct-2507": { - id: "qwen/qwen3-30b-a3b-instruct-2507", - name: "Qwen: Qwen3 30B A3B Instruct 2507", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.08, - output: 0.33, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "z-ai/glm-4.5": { - id: "z-ai/glm-4.5", - name: "Z.AI: GLM 4.5", + "deepseek/deepseek-chat-v3.1": { + id: "deepseek/deepseek-chat-v3.1", + name: "DeepSeek: DeepSeek V3.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.35, - output: 1.55, + input: 0.15, + output: 0.75, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 65536, + contextWindow: 32768, + maxTokens: 7168, } satisfies Model<"openai-completions">, - "z-ai/glm-4.5-air:free": { - id: "z-ai/glm-4.5-air:free", - name: "Z.AI: GLM 4.5 Air (free)", + "deepseek/deepseek-r1": { + id: "deepseek/deepseek-r1", + name: "DeepSeek: R1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0, - output: 0, + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-r1-0528": { + id: "deepseek/deepseek-r1-0528", + name: "DeepSeek: R1 0528", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 1.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-r1-distill-llama-70b": { + id: "deepseek/deepseek-r1-distill-llama-70b", + name: "DeepSeek: R1 Distill Llama 70B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.03, + output: 0.11, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, - "z-ai/glm-4.5-air": { - id: "z-ai/glm-4.5-air", - name: "Z.AI: GLM 4.5 Air", + "deepseek/deepseek-v3.1-terminus": { + id: "deepseek/deepseek-v3.1-terminus", + name: "DeepSeek: DeepSeek V3.1 Terminus", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.10400000000000001, - output: 0.6799999999999999, - cacheRead: 0, + input: 0.21, + output: 0.7899999999999999, + cacheRead: 0.16799999999999998, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 98304, - } satisfies Model<"openai-completions">, - "qwen/qwen3-235b-a22b-thinking-2507": { - id: "qwen/qwen3-235b-a22b-thinking-2507", - name: "Qwen: Qwen3 235B A22B Thinking 2507", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.11, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "z-ai/glm-4-32b": { - id: "z-ai/glm-4-32b", - name: "Z.AI: GLM 4 32B ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, + contextWindow: 163840, maxTokens: 4096, } satisfies Model<"openai-completions">, - "qwen/qwen3-coder:free": { - id: "qwen/qwen3-coder:free", - name: "Qwen: Qwen3 Coder 480B A35B (free)", + "deepseek/deepseek-v3.1-terminus:exacto": { + id: "deepseek/deepseek-v3.1-terminus:exacto", + name: "DeepSeek: DeepSeek V3.1 Terminus (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.21, + output: 0.7899999999999999, + cacheRead: 0.16799999999999998, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v3.2": { + id: "deepseek/deepseek-v3.2", + name: "DeepSeek: DeepSeek V3.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.224, + output: 0.32, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v3.2-exp": { + id: "deepseek/deepseek-v3.2-exp", + name: "DeepSeek: DeepSeek V3.2 Exp", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.21, + output: 0.32, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-2.0-flash-001": { + id: "google/gemini-2.0-flash-001", + name: "Google: Gemini 2.0 Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, - input: ["text"], + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0.024999999999999998, + cacheWrite: 0.18330000000000002, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "google/gemini-2.0-flash-exp:free": { + id: "google/gemini-2.0-flash-exp:free", + name: "Google: Gemini 2.0 Flash Experimental (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262000, - maxTokens: 262000, + contextWindow: 1048576, + maxTokens: 8192, } satisfies Model<"openai-completions">, - "qwen/qwen3-coder": { - id: "qwen/qwen3-coder", - name: "Qwen: Qwen3 Coder 480B A35B", + "google/gemini-2.0-flash-lite-001": { + id: "google/gemini-2.0-flash-lite-001", + name: "Google: Gemini 2.0 Flash Lite", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-flash": { + id: "google/gemini-2.5-flash", + name: "Google: Gemini 2.5 Flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.22, - output: 0.95, - cacheRead: 0, - cacheWrite: 0, + input: 0.3, + output: 2.5, + cacheRead: 0.03, + cacheWrite: 0.3833, }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "qwen/qwen3-coder:exacto": { - id: "qwen/qwen3-coder:exacto", - name: "Qwen: Qwen3 Coder 480B A35B (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.22, - output: 1.7999999999999998, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, + contextWindow: 1048576, + maxTokens: 65535, } satisfies Model<"openai-completions">, "google/gemini-2.5-flash-lite": { id: "google/gemini-2.5-flash-lite", @@ -4863,40 +3435,533 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65535, } satisfies Model<"openai-completions">, - "qwen/qwen3-235b-a22b-2507": { - id: "qwen/qwen3-235b-a22b-2507", - name: "Qwen: Qwen3 235B A22B Instruct 2507", + "google/gemini-2.5-flash-lite-preview-09-2025": { + id: "google/gemini-2.5-flash-lite-preview-09-2025", + name: "Google: Gemini 2.5 Flash Lite Preview 09-2025", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.071, - output: 0.463, + input: 0.09999999999999999, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-flash-preview-09-2025": { + id: "google/gemini-2.5-flash-preview-09-2025", + name: "Google: Gemini 2.5 Flash Preview 09-2025", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 2.5, + cacheRead: 0.075, + cacheWrite: 0.3833, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro": { + id: "google/gemini-2.5-pro", + name: "Google: Gemini 2.5 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 1.625, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro-preview": { + id: "google/gemini-2.5-pro-preview", + name: "Google: Gemini 2.5 Pro Preview 06-05", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 1.625, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-2.5-pro-preview-05-06": { + id: "google/gemini-2.5-pro-preview-05-06", + name: "Google: Gemini 2.5 Pro Preview 05-06", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.31, + cacheWrite: 1.625, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "google/gemini-3-flash-preview": { + id: "google/gemini-3-flash-preview", + name: "Google: Gemini 3 Flash Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "google/gemini-3-pro-preview": { + id: "google/gemini-3-pro-preview", + name: "Google: Gemini 3 Pro Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 2.375, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemma-3-27b-it": { + id: "google/gemma-3-27b-it", + name: "Google: Gemma 3 27B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.04, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 96000, + maxTokens: 96000, + } satisfies Model<"openai-completions">, + "google/gemma-3-27b-it:free": { + id: "google/gemma-3-27b-it:free", + name: "Google: Gemma 3 27B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "moonshotai/kimi-k2": { - id: "moonshotai/kimi-k2", - name: "MoonshotAI: Kimi K2 0711", + "inception/mercury": { + id: "inception/mercury", + name: "Inception: Mercury", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.456, - output: 1.8399999999999999, + input: 0.25, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "inception/mercury-coder": { + id: "inception/mercury-coder", + name: "Inception: Mercury Coder", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.25, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "kwaipilot/kat-coder-pro:free": { + id: "kwaipilot/kat-coder-pro:free", + name: "Kwaipilot: KAT-Coder-Pro V1 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3-70b-instruct": { + id: "meta-llama/llama-3-70b-instruct", + name: "Meta: Llama 3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3-8b-instruct": { + id: "meta-llama/llama-3-8b-instruct", + name: "Meta: Llama 3 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.03, + output: 0.06, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 10000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-70b-instruct": { + id: "meta-llama/llama-3.1-70b-instruct", + name: "Meta: Llama 3.1 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.2-3b-instruct": { + id: "meta-llama/llama-3.2-3b-instruct", + name: "Meta: Llama 3.2 3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.02, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.3-70b-instruct": { + id: "meta-llama/llama-3.3-70b-instruct", + name: "Meta: Llama 3.3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.32, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-3.3-70b-instruct:free": { + id: "meta-llama/llama-3.3-70b-instruct:free", + name: "Meta: Llama 3.3 70B Instruct (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-maverick": { + id: "meta-llama/llama-4-maverick", + name: "Meta: Llama 4 Maverick", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "meta-llama/llama-4-scout": { + id: "meta-llama/llama-4-scout", + name: "Meta: Llama 4 Scout", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.08, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 327680, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "microsoft/phi-3-medium-128k-instruct": { + id: "microsoft/phi-3-medium-128k-instruct", + name: "Microsoft: Phi-3 Medium 128K Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "microsoft/phi-3-mini-128k-instruct": { + id: "microsoft/phi-3-mini-128k-instruct", + name: "Microsoft: Phi-3 Mini 128K Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "microsoft/phi-3.5-mini-128k-instruct": { + id: "microsoft/phi-3.5-mini-128k-instruct", + name: "Microsoft: Phi-3.5 Mini 128K Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "minimax/minimax-m1": { + id: "minimax/minimax-m1", + name: "MiniMax: MiniMax M1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 2.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 40000, + } satisfies Model<"openai-completions">, + "minimax/minimax-m2": { + id: "minimax/minimax-m2", + name: "MiniMax: MiniMax M2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 1, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 196608, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "minimax/minimax-m2.1": { + id: "minimax/minimax-m2.1", + name: "MiniMax: MiniMax M2.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0.375, + }, + contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, + "mistralai/codestral-2508": { + id: "mistralai/codestral-2508", + name: "Mistral: Codestral 2508", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.8999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/devstral-2512": { + id: "mistralai/devstral-2512", + name: "Mistral: Devstral 2 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.22, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "mistralai/devstral-2512:free": { + id: "mistralai/devstral-2512:free", + name: "Mistral: Devstral 2 2512 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/devstral-medium": { id: "mistralai/devstral-medium", name: "Mistral: Devstral Medium", @@ -4931,277 +3996,192 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "x-ai/grok-4": { - id: "x-ai/grok-4", - name: "xAI: Grok 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "tngtech/deepseek-r1t2-chimera": { - id: "tngtech/deepseek-r1t2-chimera", - name: "TNG: DeepSeek R1T2 Chimera", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "inception/mercury": { - id: "inception/mercury", - name: "Inception: Mercury", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.25, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-3.2-24b-instruct": { - id: "mistralai/mistral-small-3.2-24b-instruct", - name: "Mistral: Mistral Small 3.2 24B", + "mistralai/ministral-14b-2512": { + id: "mistralai/ministral-14b-2512", + name: "Mistral: Ministral 3 14B 2512", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], cost: { - input: 0.06, - output: 0.18, + input: 0.19999999999999998, + output: 0.19999999999999998, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "minimax/minimax-m1": { - id: "minimax/minimax-m1", - name: "MiniMax: MiniMax M1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 2.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 40000, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-flash": { - id: "google/gemini-2.5-flash", - name: "Google: Gemini 2.5 Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 2.5, - cacheRead: 0.03, - cacheWrite: 0.3833, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"openai-completions">, - "google/gemini-2.5-pro": { - id: "google/gemini-2.5-pro", - name: "Google: Gemini 2.5 Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 1.625, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "openai/o3-pro": { - id: "openai/o3-pro", - name: "OpenAI: o3 Pro", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 20, - output: 80, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "x-ai/grok-3-mini": { - id: "x-ai/grok-3-mini", - name: "xAI: Grok 3 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, + contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, - "x-ai/grok-3": { - id: "x-ai/grok-3", - name: "xAI: Grok 3", + "mistralai/ministral-3b": { + id: "mistralai/ministral-3b", + name: "Mistral: Ministral 3B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 3, - output: 15, - cacheRead: 0.75, + input: 0.04, + output: 0.04, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "google/gemini-2.5-pro-preview": { - id: "google/gemini-2.5-pro-preview", - name: "Google: Gemini 2.5 Pro Preview 06-05", + "mistralai/ministral-3b-2512": { + id: "mistralai/ministral-3b-2512", + name: "Mistral: Ministral 3 3B 2512", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text", "image"], cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 1.625, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-r1-0528": { - id: "deepseek/deepseek-r1-0528", - name: "DeepSeek: R1 0528", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "anthropic/claude-opus-4": { - id: "anthropic/claude-opus-4", - name: "Anthropic: Claude Opus 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "anthropic/claude-sonnet-4": { - id: "anthropic/claude-sonnet-4", - name: "Anthropic: Claude Sonnet 4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 1000000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "openai/codex-mini": { - id: "openai/codex-mini", - name: "OpenAI: Codex Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.5, - output: 6, - cacheRead: 0.375, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "nousresearch/deephermes-3-mistral-24b-preview": { - id: "nousresearch/deephermes-3-mistral-24b-preview", - name: "Nous: DeepHermes 3 Mistral 24B Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.02, + input: 0.09999999999999999, output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/ministral-8b": { + id: "mistralai/ministral-8b", + name: "Mistral: Ministral 8B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/ministral-8b-2512": { + id: "mistralai/ministral-8b-2512", + name: "Mistral: Ministral 3 8B 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-7b-instruct": { + id: "mistralai/mistral-7b-instruct", + name: "Mistral: Mistral 7B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.028, + output: 0.054, + cacheRead: 0, + cacheWrite: 0, + }, contextWindow: 32768, - maxTokens: 32768, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/mistral-7b-instruct:free": { + id: "mistralai/mistral-7b-instruct:free", + name: "Mistral: Mistral 7B Instruct (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large": { + id: "mistralai/mistral-large", + name: "Mistral Large", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-2407": { + id: "mistralai/mistral-large-2407", + name: "Mistral Large 2407", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-2411": { + id: "mistralai/mistral-large-2411", + name: "Mistral Large 2411", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-large-2512": { + id: "mistralai/mistral-large-2512", + name: "Mistral: Mistral Large 3 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "mistralai/mistral-medium-3": { id: "mistralai/mistral-medium-3", @@ -5220,43 +4200,145 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "google/gemini-2.5-pro-preview-05-06": { - id: "google/gemini-2.5-pro-preview-05-06", - name: "Google: Gemini 2.5 Pro Preview 05-06", + "mistralai/mistral-medium-3.1": { + id: "mistralai/mistral-medium-3.1", + name: "Mistral: Mistral Medium 3.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, + reasoning: false, input: ["text", "image"], cost: { - input: 1.25, - output: 10, - cacheRead: 0.31, - cacheWrite: 1.625, + input: 0.39999999999999997, + output: 2, + cacheRead: 0, + cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65535, + contextWindow: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "arcee-ai/virtuoso-large": { - id: "arcee-ai/virtuoso-large", - name: "Arcee AI: Virtuoso Large", + "mistralai/mistral-nemo": { + id: "mistralai/mistral-nemo", + name: "Mistral: Mistral Nemo", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.75, - output: 1.2, + input: 0.02, + output: 0.04, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 64000, + maxTokens: 16384, } satisfies Model<"openai-completions">, - "inception/mercury-coder": { - id: "inception/mercury-coder", - name: "Inception: Mercury Coder", + "mistralai/mistral-saba": { + id: "mistralai/mistral-saba", + name: "Mistral: Saba", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-24b-instruct-2501": { + id: "mistralai/mistral-small-24b-instruct-2501", + name: "Mistral: Mistral Small 3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.03, + output: 0.11, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-3.1-24b-instruct": { + id: "mistralai/mistral-small-3.1-24b-instruct", + name: "Mistral: Mistral Small 3.1 24B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.03, + output: 0.11, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-3.1-24b-instruct:free": { + id: "mistralai/mistral-small-3.1-24b-instruct:free", + name: "Mistral: Mistral Small 3.1 24B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-3.2-24b-instruct": { + id: "mistralai/mistral-small-3.2-24b-instruct", + name: "Mistral: Mistral Small 3.2 24B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.06, + output: 0.18, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "mistralai/mistral-small-creative": { + id: "mistralai/mistral-small-creative", + name: "Mistral: Mistral Small Creative", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mistral-tiny": { + id: "mistralai/mistral-tiny", + name: "Mistral Tiny", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5264,16 +4346,288 @@ export const MODELS = { input: ["text"], cost: { input: 0.25, - output: 1, + output: 0.25, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mixtral-8x22b-instruct": { + id: "mistralai/mixtral-8x22b-instruct", + name: "Mistral: Mixtral 8x22B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/mixtral-8x7b-instruct": { + id: "mistralai/mixtral-8x7b-instruct", + name: "Mistral: Mixtral 8x7B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.54, + output: 0.54, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, maxTokens: 16384, } satisfies Model<"openai-completions">, - "qwen/qwen3-4b:free": { - id: "qwen/qwen3-4b:free", - name: "Qwen: Qwen3 4B (free)", + "mistralai/pixtral-12b": { + id: "mistralai/pixtral-12b", + name: "Mistral: Pixtral 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/pixtral-large-2411": { + id: "mistralai/pixtral-large-2411", + name: "Mistral: Pixtral Large 2411", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/voxtral-small-24b-2507": { + id: "mistralai/voxtral-small-24b-2507", + name: "Mistral: Voxtral Small 24B 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2": { + id: "moonshotai/kimi-k2", + name: "MoonshotAI: Kimi K2 0711", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.456, + output: 1.8399999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-0905": { + id: "moonshotai/kimi-k2-0905", + name: "MoonshotAI: Kimi K2 0905", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39, + output: 1.9, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-0905:exacto": { + id: "moonshotai/kimi-k2-0905:exacto", + name: "MoonshotAI: Kimi K2 0905 (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "moonshotai/kimi-k2-thinking": { + id: "moonshotai/kimi-k2-thinking", + name: "MoonshotAI: Kimi K2 Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 1.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + "nex-agi/deepseek-v3.1-nex-n1:free": { + id: "nex-agi/deepseek-v3.1-nex-n1:free", + name: "Nex AGI: DeepSeek V3.1 Nex N1 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "nousresearch/deephermes-3-mistral-24b-preview": { + id: "nousresearch/deephermes-3-mistral-24b-preview", + name: "Nous: DeepHermes 3 Mistral 24B Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.02, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "nousresearch/hermes-4-405b": { + id: "nousresearch/hermes-4-405b", + name: "Nous: Hermes 4 405B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "nousresearch/hermes-4-70b": { + id: "nousresearch/hermes-4-70b", + name: "Nous: Hermes 4 70B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.11, + output: 0.38, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "nvidia/llama-3.1-nemotron-70b-instruct": { + id: "nvidia/llama-3.1-nemotron-70b-instruct", + name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.2, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "nvidia/llama-3.3-nemotron-super-49b-v1.5": { + id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", + name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-30b-a3b": { + id: "nvidia/nemotron-3-nano-30b-a3b", + name: "NVIDIA: Nemotron 3 Nano 30B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-nano-30b-a3b:free": { + id: "nvidia/nemotron-3-nano-30b-a3b:free", + name: "NVIDIA: Nemotron 3 Nano 30B A3B (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5285,144 +4639,212 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, + contextWindow: 256000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "qwen/qwen3-30b-a3b": { - id: "qwen/qwen3-30b-a3b", - name: "Qwen: Qwen3 30B A3B", + "nvidia/nemotron-nano-12b-v2-vl:free": { + id: "nvidia/nemotron-nano-12b-v2-vl:free", + name: "NVIDIA: Nemotron Nano 12B 2 VL (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.06, - output: 0.22, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 40960, - maxTokens: 40960, - } satisfies Model<"openai-completions">, - "qwen/qwen3-8b": { - id: "qwen/qwen3-8b", - name: "Qwen: Qwen3 8B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.028, - output: 0.1104, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 20000, + maxTokens: 128000, } satisfies Model<"openai-completions">, - "qwen/qwen3-14b": { - id: "qwen/qwen3-14b", - name: "Qwen: Qwen3 14B", + "nvidia/nemotron-nano-9b-v2": { + id: "nvidia/nemotron-nano-9b-v2", + name: "NVIDIA: Nemotron Nano 9B V2", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.22, + input: 0.04, + output: 0.16, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, - maxTokens: 40960, + contextWindow: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "qwen/qwen3-32b": { - id: "qwen/qwen3-32b", - name: "Qwen: Qwen3 32B", + "nvidia/nemotron-nano-9b-v2:free": { + id: "nvidia/nemotron-nano-9b-v2:free", + name: "NVIDIA: Nemotron Nano 9B V2 (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, input: ["text"], cost: { - input: 0.08, - output: 0.24, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, - maxTokens: 40960, + contextWindow: 128000, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "qwen/qwen3-235b-a22b": { - id: "qwen/qwen3-235b-a22b", - name: "Qwen: Qwen3 235B A22B", + "openai/codex-mini": { + id: "openai/codex-mini", + name: "OpenAI: Codex Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, + input: ["text", "image"], + cost: { + input: 1.5, + output: 6, + cacheRead: 0.375, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, input: ["text"], cost: { - input: 0.18, - output: 0.54, + input: 0.5, + output: 1.5, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, - maxTokens: 40960, + contextWindow: 16385, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/o4-mini-high": { - id: "openai/o4-mini-high", - name: "OpenAI: o4 Mini High", + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], + reasoning: false, + input: ["text"], cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.275, + input: 1, + output: 2, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 200000, - maxTokens: 100000, + contextWindow: 4095, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/o3": { - id: "openai/o3", - name: "OpenAI: o3", + "openai/gpt-3.5-turbo-16k": { + id: "openai/gpt-3.5-turbo-16k", + name: "OpenAI: GPT-3.5 Turbo 16k", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], + reasoning: false, + input: ["text"], cost: { - input: 2, - output: 8, - cacheRead: 0.5, + input: 3, + output: 4, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 200000, - maxTokens: 100000, + contextWindow: 16385, + maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/o4-mini": { - id: "openai/o4-mini", - name: "OpenAI: o4 Mini", + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], + reasoning: false, + input: ["text"], cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.275, + input: 30, + output: 60, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 200000, - maxTokens: 100000, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-0314": { + id: "openai/gpt-4-0314", + name: "OpenAI: GPT-4 (older v0314)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-1106-preview": { + id: "openai/gpt-4-1106-preview", + name: "OpenAI: GPT-4 Turbo (older v1106)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-turbo": { + id: "openai/gpt-4-turbo", + name: "OpenAI: GPT-4 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-4-turbo-preview": { + id: "openai/gpt-4-turbo-preview", + name: "OpenAI: GPT-4 Turbo Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-4.1": { id: "openai/gpt-4.1", @@ -5475,553 +4897,9 @@ export const MODELS = { contextWindow: 1047576, maxTokens: 32768, } satisfies Model<"openai-completions">, - "x-ai/grok-3-mini-beta": { - id: "x-ai/grok-3-mini-beta", - name: "xAI: Grok 3 Mini Beta", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 0.5, - cacheRead: 0.075, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "x-ai/grok-3-beta": { - id: "x-ai/grok-3-beta", - name: "xAI: Grok 3 Beta", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3, - output: 15, - cacheRead: 0.75, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-maverick": { - id: "meta-llama/llama-4-maverick", - name: "Meta: Llama 4 Maverick", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-4-scout": { - id: "meta-llama/llama-4-scout", - name: "Meta: Llama 4 Scout", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.08, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 327680, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-chat-v3-0324": { - id: "deepseek/deepseek-chat-v3-0324", - name: "DeepSeek: DeepSeek V3 0324", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 0.88, - cacheRead: 0.106, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-3.1-24b-instruct:free": { - id: "mistralai/mistral-small-3.1-24b-instruct:free", - name: "Mistral: Mistral Small 3.1 24B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-3.1-24b-instruct": { - id: "mistralai/mistral-small-3.1-24b-instruct", - name: "Mistral: Mistral Small 3.1 24B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.03, - output: 0.11, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "google/gemma-3-27b-it:free": { - id: "google/gemma-3-27b-it:free", - name: "Google: Gemma 3 27B (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "google/gemma-3-27b-it": { - id: "google/gemma-3-27b-it", - name: "Google: Gemma 3 27B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.04, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 96000, - maxTokens: 96000, - } satisfies Model<"openai-completions">, - "qwen/qwq-32b": { - id: "qwen/qwq-32b", - name: "Qwen: QwQ 32B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.15, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "google/gemini-2.0-flash-lite-001": { - id: "google/gemini-2.0-flash-lite-001", - name: "Google: Gemini 2.0 Flash Lite", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "anthropic/claude-3.7-sonnet:thinking": { - id: "anthropic/claude-3.7-sonnet:thinking", - name: "Anthropic: Claude 3.7 Sonnet (thinking)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "anthropic/claude-3.7-sonnet": { - id: "anthropic/claude-3.7-sonnet", - name: "Anthropic: Claude 3.7 Sonnet", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"openai-completions">, - "mistralai/mistral-saba": { - id: "mistralai/mistral-saba", - name: "Mistral: Saba", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/o3-mini-high": { - id: "openai/o3-mini-high", - name: "OpenAI: o3 Mini High", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.55, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "google/gemini-2.0-flash-001": { - id: "google/gemini-2.0-flash-001", - name: "Google: Gemini 2.0 Flash", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.39999999999999997, - cacheRead: 0.024999999999999998, - cacheWrite: 0.18330000000000002, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-vl-max": { - id: "qwen/qwen-vl-max", - name: "Qwen: Qwen VL Max", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.7999999999999999, - output: 3.1999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-turbo": { - id: "qwen/qwen-turbo", - name: "Qwen: Qwen-Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.049999999999999996, - output: 0.19999999999999998, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-plus": { - id: "qwen/qwen-plus", - name: "Qwen: Qwen-Plus", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.2, - cacheRead: 0.16, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "qwen/qwen-max": { - id: "qwen/qwen-max", - name: "Qwen: Qwen-Max ", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.5999999999999999, - output: 6.3999999999999995, - cacheRead: 0.64, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "openai/o3-mini": { - id: "openai/o3-mini", - name: "OpenAI: o3 Mini", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.1, - output: 4.4, - cacheRead: 0.55, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "mistralai/mistral-small-24b-instruct-2501": { - id: "mistralai/mistral-small-24b-instruct-2501", - name: "Mistral: Mistral Small 3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.11, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-r1-distill-llama-70b": { - id: "deepseek/deepseek-r1-distill-llama-70b", - name: "DeepSeek: R1 Distill Llama 70B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.03, - output: 0.11, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-r1": { - id: "deepseek/deepseek-r1", - name: "DeepSeek: R1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "deepseek/deepseek-chat": { - id: "deepseek/deepseek-chat", - name: "DeepSeek: DeepSeek V3", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 163840, - maxTokens: 163840, - } satisfies Model<"openai-completions">, - "openai/o1": { - id: "openai/o1", - name: "OpenAI: o1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 15, - output: 60, - cacheRead: 7.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-completions">, - "google/gemini-2.0-flash-exp:free": { - id: "google/gemini-2.0-flash-exp:free", - name: "Google: Gemini 2.0 Flash Experimental (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.3-70b-instruct:free": { - id: "meta-llama/llama-3.3-70b-instruct:free", - name: "Meta: Llama 3.3 70B Instruct (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.3-70b-instruct": { - id: "meta-llama/llama-3.3-70b-instruct", - name: "Meta: Llama 3.3 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.32, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "amazon/nova-lite-v1": { - id: "amazon/nova-lite-v1", - name: "Amazon: Nova Lite 1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.06, - output: 0.24, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 300000, - maxTokens: 5120, - } satisfies Model<"openai-completions">, - "amazon/nova-micro-v1": { - id: "amazon/nova-micro-v1", - name: "Amazon: Nova Micro 1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.035, - output: 0.14, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 5120, - } satisfies Model<"openai-completions">, - "amazon/nova-pro-v1": { - id: "amazon/nova-pro-v1", - name: "Amazon: Nova Pro 1.0", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.7999999999999999, - output: 3.1999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 300000, - maxTokens: 5120, - } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-11-20": { - id: "openai/gpt-4o-2024-11-20", - name: "OpenAI: GPT-4o (2024-11-20)", + "openai/gpt-4o": { + id: "openai/gpt-4o", + name: "OpenAI: GPT-4o", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6036,306 +4914,17 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "mistralai/mistral-large-2411": { - id: "mistralai/mistral-large-2411", - name: "Mistral Large 2411", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-large-2407": { - id: "mistralai/mistral-large-2407", - name: "Mistral Large 2407", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/pixtral-large-2411": { - id: "mistralai/pixtral-large-2411", - name: "Mistral: Pixtral Large 2411", + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "thedrummer/unslopnemo-12b": { - id: "thedrummer/unslopnemo-12b", - name: "TheDrummer: UnslopNemo 12B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.7999999999999999, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.7999999999999999, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-sonnet": { - id: "anthropic/claude-3.5-sonnet", - name: "Anthropic: Claude 3.5 Sonnet", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 6, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "mistralai/ministral-8b": { - id: "mistralai/ministral-8b", - name: "Mistral: Ministral 8B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/ministral-3b": { - id: "mistralai/ministral-3b", - name: "Mistral: Ministral 3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.04, - output: 0.04, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "nvidia/llama-3.1-nemotron-70b-instruct": { - id: "nvidia/llama-3.1-nemotron-70b-instruct", - name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.2, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "thedrummer/rocinante-12b": { - id: "thedrummer/rocinante-12b", - name: "TheDrummer: Rocinante 12B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.16999999999999998, - output: 0.43, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.2-3b-instruct": { - id: "meta-llama/llama-3.2-3b-instruct", - name: "Meta: Llama 3.2 3B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.02, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "qwen/qwen-2.5-72b-instruct": { - id: "qwen/qwen-2.5-72b-instruct", - name: "Qwen2.5 72B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.12, - output: 0.39, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistralai/pixtral-12b": { - id: "mistralai/pixtral-12b", - name: "Mistral: Pixtral 12B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "cohere/command-r-08-2024": { - id: "cohere/command-r-08-2024", - name: "Cohere: Command R (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, - "cohere/command-r-plus-08-2024": { - id: "cohere/command-r-plus-08-2024", - name: "Cohere: Command R+ (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, - "sao10k/l3.1-euryale-70b": { - id: "sao10k/l3.1-euryale-70b", - name: "Sao10K: Llama 3.1 Euryale 70B v2.2", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.65, - output: 0.75, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "microsoft/phi-3.5-mini-128k-instruct": { - id: "microsoft/phi-3.5-mini-128k-instruct", - name: "Microsoft: Phi-3.5 Mini 128K Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, + input: 5, + output: 15, cacheRead: 0, cacheWrite: 0, }, @@ -6359,86 +4948,35 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 10000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-70b-instruct": { - id: "meta-llama/llama-3.1-70b-instruct", - name: "Meta: Llama 3.1 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "mistralai/mistral-nemo": { - id: "mistralai/mistral-nemo", - name: "Mistral: Mistral Nemo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.04, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini-2024-07-18": { - id: "openai/gpt-4o-mini-2024-07-18", - name: "OpenAI: GPT-4o-mini (2024-07-18)", + "openai/gpt-4o-2024-11-20": { + id: "openai/gpt-4o-2024-11-20", + name: "OpenAI: GPT-4o (2024-11-20)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.075, + input: 2.5, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-4o-audio-preview": { + id: "openai/gpt-4o-audio-preview", + name: "OpenAI: GPT-4o Audio", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, @@ -6461,120 +4999,18 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "sao10k/l3-euryale-70b": { - id: "sao10k/l3-euryale-70b", - name: "Sao10k: Llama 3 Euryale 70B v2.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.48, - output: 1.48, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, - "mistralai/mistral-7b-instruct:free": { - id: "mistralai/mistral-7b-instruct:free", - name: "Mistral: Mistral 7B Instruct (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "mistralai/mistral-7b-instruct": { - id: "mistralai/mistral-7b-instruct", - name: "Mistral: Mistral 7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.028, - output: 0.054, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, - "microsoft/phi-3-mini-128k-instruct": { - id: "microsoft/phi-3-mini-128k-instruct", - name: "Microsoft: Phi-3 Mini 128K Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.09999999999999999, - output: 0.09999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "microsoft/phi-3-medium-128k-instruct": { - id: "microsoft/phi-3-medium-128k-instruct", - name: "Microsoft: Phi-3 Medium 128K Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", + "openai/gpt-4o-mini-2024-07-18": { + id: "openai/gpt-4o-mini-2024-07-18", + name: "OpenAI: GPT-4o-mini (2024-07-18)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4o": { - id: "openai/gpt-4o", - name: "OpenAI: GPT-4o", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, + input: 0.15, + output: 0.6, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 128000, @@ -6597,94 +5033,366 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", + "openai/gpt-5": { + id: "openai/gpt-5", + name: "OpenAI: GPT-5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 0.3, - output: 0.39999999999999997, - cacheRead: 0, + input: 1.25, + output: 10, + cacheRead: 0.125, cacheWrite: 0, }, - contextWindow: 8192, - maxTokens: 16384, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-8b-instruct": { - id: "meta-llama/llama-3-8b-instruct", - name: "Meta: Llama 3 8B Instruct", + "openai/gpt-5-codex": { + id: "openai/gpt-5-codex", + name: "OpenAI: GPT-5 Codex", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 0.03, - output: 0.06, - cacheRead: 0, + input: 1.25, + output: 10, + cacheRead: 0.125, cacheWrite: 0, }, - contextWindow: 8192, - maxTokens: 16384, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"openai-completions">, - "mistralai/mixtral-8x22b-instruct": { - id: "mistralai/mixtral-8x22b-instruct", - name: "Mistral: Mixtral 8x22B Instruct", + "openai/gpt-5-image": { + id: "openai/gpt-5-image", + name: "OpenAI: GPT-5 Image", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2, - output: 6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 65536, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4-turbo": { - id: "openai/gpt-4-turbo", - name: "OpenAI: GPT-4 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 10, - output: 30, + output: 10, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-image-mini": { + id: "openai/gpt-5-image-mini", + name: "OpenAI: GPT-5 Image Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2.5, + output: 2, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-mini": { + id: "openai/gpt-5-mini", + name: "OpenAI: GPT-5 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-nano": { + id: "openai/gpt-5-nano", + name: "OpenAI: GPT-5 Nano", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.049999999999999996, + output: 0.39999999999999997, + cacheRead: 0.005, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5-pro": { + id: "openai/gpt-5-pro", + name: "OpenAI: GPT-5 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 15, + output: 120, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"openai-completions">, - "anthropic/claude-3-haiku": { - id: "anthropic/claude-3-haiku", - name: "Anthropic: Claude 3 Haiku", + "openai/gpt-5.1": { + id: "openai/gpt-5.1", + name: "OpenAI: GPT-5.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1-chat": { + id: "openai/gpt-5.1-chat", + name: "OpenAI: GPT-5.1 Chat", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], cost: { - input: 0.25, - output: 1.25, - cacheRead: 0.03, - cacheWrite: 0.3, + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, }, - contextWindow: 200000, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1-codex": { + id: "openai/gpt-5.1-codex", + name: "OpenAI: GPT-5.1-Codex", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1-codex-max": { + id: "openai/gpt-5.1-codex-max", + name: "OpenAI: GPT-5.1-Codex-Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.1-codex-mini": { + id: "openai/gpt-5.1-codex-mini", + name: "OpenAI: GPT-5.1-Codex-Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 100000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2": { + id: "openai/gpt-5.2", + name: "OpenAI: GPT-5.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2-chat": { + id: "openai/gpt-5.2-chat", + name: "OpenAI: GPT-5.2 Chat", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "openai/gpt-5.2-pro": { + id: "openai/gpt-5.2-pro", + name: "OpenAI: GPT-5.2 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 21, + output: 168, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "OpenAI: gpt-oss-120b", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.039, + output: 0.19, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3-opus": { - id: "anthropic/claude-3-opus", - name: "Anthropic: Claude 3 Opus", + "openai/gpt-oss-120b:exacto": { + id: "openai/gpt-oss-120b:exacto", + name: "OpenAI: gpt-oss-120b (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.039, + output: 0.19, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b:free": { + id: "openai/gpt-oss-120b:free", + name: "OpenAI: gpt-oss-120b (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b": { + id: "openai/gpt-oss-20b", + name: "OpenAI: gpt-oss-20b", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.03, + output: 0.14, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-20b:free": { + id: "openai/gpt-oss-20b:free", + name: "OpenAI: gpt-oss-20b (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-safeguard-20b": { + id: "openai/gpt-oss-safeguard-20b", + name: "OpenAI: gpt-oss-safeguard-20b", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.075, + output: 0.3, + cacheRead: 0.037, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "openai/o1": { + id: "openai/o1", + name: "OpenAI: o1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6692,182 +5400,148 @@ export const MODELS = { input: ["text", "image"], cost: { input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, + output: 60, + cacheRead: 7.5, + cacheWrite: 0, }, contextWindow: 200000, - maxTokens: 4096, + maxTokens: 100000, } satisfies Model<"openai-completions">, - "mistralai/mistral-large": { - id: "mistralai/mistral-large", - name: "Mistral Large", + "openai/o3": { + id: "openai/o3", + name: "OpenAI: o3", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { input: 2, - output: 6, - cacheRead: 0, + output: 8, + cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-0613": { - id: "openai/gpt-3.5-turbo-0613", - name: "OpenAI: GPT-3.5 Turbo (older v0613)", + "openai/o3-deep-research": { + id: "openai/o3-deep-research", + name: "OpenAI: o3 Deep Research", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-4-turbo-preview": { - id: "openai/gpt-4-turbo-preview", - name: "OpenAI: GPT-4 Turbo Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { input: 10, - output: 30, - cacheRead: 0, + output: 40, + cacheRead: 2.5, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-completions">, - "mistralai/mistral-tiny": { - id: "mistralai/mistral-tiny", - name: "Mistral Tiny", + "openai/o3-mini": { + id: "openai/o3-mini", + name: "OpenAI: o3 Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.25, - output: 0.25, - cacheRead: 0, + input: 1.1, + output: 4.4, + cacheRead: 0.55, cacheWrite: 0, }, - contextWindow: 32768, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-completions">, - "mistralai/mixtral-8x7b-instruct": { - id: "mistralai/mixtral-8x7b-instruct", - name: "Mistral: Mixtral 8x7B Instruct", + "openai/o3-mini-high": { + id: "openai/o3-mini-high", + name: "OpenAI: o3 Mini High", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.54, - output: 0.54, - cacheRead: 0, + input: 1.1, + output: 4.4, + cacheRead: 0.55, cacheWrite: 0, }, - contextWindow: 32768, - maxTokens: 16384, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-completions">, - "openai/gpt-4-1106-preview": { - id: "openai/gpt-4-1106-preview", - name: "OpenAI: GPT-4 Turbo (older v1106)", + "openai/o3-pro": { + id: "openai/o3-pro", + name: "OpenAI: o3 Pro", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 10, - output: 30, + input: 20, + output: 80, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-16k": { - id: "openai/gpt-3.5-turbo-16k", - name: "OpenAI: GPT-3.5 Turbo 16k", + "openai/o4-mini": { + id: "openai/o4-mini", + name: "OpenAI: o4 Mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 3, - output: 4, - cacheRead: 0, + input: 1.1, + output: 4.4, + cacheRead: 0.275, cacheWrite: 0, }, - contextWindow: 16385, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-completions">, - "openai/gpt-4-0314": { - id: "openai/gpt-4-0314", - name: "OpenAI: GPT-4 (older v0314)", + "openai/o4-mini-deep-research": { + id: "openai/o4-mini-deep-research", + name: "OpenAI: o4 Mini Deep Research", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 30, - output: 60, - cacheRead: 0, + input: 2, + output: 8, + cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 8191, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", + "openai/o4-mini-high": { + id: "openai/o4-mini-high", + name: "OpenAI: o4 Mini High", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 30, - output: 60, - cacheRead: 0, + input: 1.1, + output: 4.4, + cacheRead: 0.275, cacheWrite: 0, }, - contextWindow: 8191, - maxTokens: 4096, - } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, + contextWindow: 200000, + maxTokens: 100000, } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", @@ -6886,203 +5560,403 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 30000, } satisfies Model<"openai-completions">, - }, - "google-gemini-cli": { - "gemini-2.5-pro": { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", + "prime-intellect/intellect-3": { + id: "prime-intellect/intellect-3", + name: "Prime Intellect: INTELLECT-3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { - input: 0, - output: 0, + input: 0.19999999999999998, + output: 1.1, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-2.5-flash": { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-2.0-flash": { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "qwen/qwen-2.5-72b-instruct": { + id: "qwen/qwen-2.5-72b-instruct", + name: "Qwen2.5 72B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: false, - input: ["text", "image"], + input: ["text"], cost: { - input: 0, - output: 0, + input: 0.12, + output: 0.39, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1048576, + contextWindow: 32768, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "qwen/qwen-max": { + id: "qwen/qwen-max", + name: "Qwen: Qwen-Max ", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.5999999999999999, + output: 6.3999999999999995, + cacheRead: 0.64, + cacheWrite: 0, + }, + contextWindow: 32768, maxTokens: 8192, - } satisfies Model<"google-gemini-cli">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", - reasoning: true, - input: ["text", "image"], + } satisfies Model<"openai-completions">, + "qwen/qwen-plus": { + id: "qwen/qwen-plus", + name: "Qwen: Qwen-Plus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], cost: { - input: 0, - output: 0, + input: 0.39999999999999997, + output: 1.2, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen-plus-2025-07-28": { + id: "qwen/qwen-plus-2025-07-28", + name: "Qwen: Qwen Plus 0728", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 1.2, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-3-flash-preview": { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: "https://cloudcode-pa.googleapis.com", + contextWindow: 1000000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen-plus-2025-07-28:thinking": { + id: "qwen/qwen-plus-2025-07-28:thinking", + name: "Qwen: Qwen Plus 0728 (thinking)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { - input: 0, - output: 0, + input: 0.39999999999999997, + output: 4, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - }, - "google-antigravity": { - "gemini-3-pro-high": { - id: "gemini-3-pro-high", - name: "Gemini 3 Pro High (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen-turbo": { + id: "qwen/qwen-turbo", + name: "Qwen: Qwen-Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.049999999999999996, + output: 0.19999999999999998, + cacheRead: 0.02, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-3-pro-low": { - id: "gemini-3-pro-low", - name: "Gemini 3 Pro Low (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "gemini-3-flash": { - id: "gemini-3-flash", - name: "Gemini 3 Flash (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65535, - } satisfies Model<"google-gemini-cli">, - "claude-sonnet-4-5": { - id: "claude-sonnet-4-5", - name: "Claude Sonnet 4.5 (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 1000000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen-vl-max": { + id: "qwen/qwen-vl-max", + name: "Qwen: Qwen VL Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text", "image"], cost: { - input: 0, - output: 0, + input: 0.7999999999999999, + output: 3.1999999999999997, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"google-gemini-cli">, - "claude-sonnet-4-5-thinking": { - id: "claude-sonnet-4-5-thinking", - name: "Claude Sonnet 4.5 Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "qwen/qwen3-14b": { + id: "qwen/qwen3-14b", + name: "Qwen: Qwen3 14B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text", "image"], + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.22, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b": { + id: "qwen/qwen3-235b-a22b", + name: "Qwen: Qwen3 235B A22B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.18, + output: 0.54, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b-2507": { + id: "qwen/qwen3-235b-a22b-2507", + name: "Qwen: Qwen3 235B A22B Instruct 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.071, + output: 0.463, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-235b-a22b-thinking-2507": { + id: "qwen/qwen3-235b-a22b-thinking-2507", + name: "Qwen: Qwen3 235B A22B Thinking 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.11, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b": { + id: "qwen/qwen3-30b-a3b", + name: "Qwen: Qwen3 30B A3B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.06, + output: 0.22, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b-instruct-2507": { + id: "qwen/qwen3-30b-a3b-instruct-2507", + name: "Qwen: Qwen3 30B A3B Instruct 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.08, + output: 0.33, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3-30b-a3b-thinking-2507": { + id: "qwen/qwen3-30b-a3b-thinking-2507", + name: "Qwen: Qwen3 30B A3B Thinking 2507", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.051, + output: 0.33999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-32b": { + id: "qwen/qwen3-32b", + name: "Qwen: Qwen3 32B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.08, + output: 0.24, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 40960, + } satisfies Model<"openai-completions">, + "qwen/qwen3-4b:free": { + id: "qwen/qwen3-4b:free", + name: "Qwen: Qwen3 4B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"google-gemini-cli">, - "claude-opus-4-5-thinking": { - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 40960, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-8b": { + id: "qwen/qwen3-8b", + name: "Qwen: Qwen3 8B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { - input: 0, - output: 0, + input: 0.028, + output: 0.1104, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"google-gemini-cli">, - "gpt-oss-120b-medium": { - id: "gpt-oss-120b-medium", - name: "GPT-OSS 120B Medium (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + contextWindow: 128000, + maxTokens: 20000, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder": { + id: "qwen/qwen3-coder", + name: "Qwen: Qwen3 Coder 480B A35B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.22, + output: 0.95, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-30b-a3b-instruct": { + id: "qwen/qwen3-coder-30b-a3b-instruct", + name: "Qwen: Qwen3 Coder 30B A3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.07, + output: 0.27, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 160000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-flash": { + id: "qwen/qwen3-coder-flash", + name: "Qwen: Qwen3 Coder Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 1.5, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder-plus": { + id: "qwen/qwen3-coder-plus", + name: "Qwen: Qwen3 Coder Plus", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 5, + cacheRead: 0.09999999999999999, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder:exacto": { + id: "qwen/qwen3-coder:exacto", + name: "Qwen: Qwen3 Coder 480B A35B (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.22, + output: 1.7999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "qwen/qwen3-coder:free": { + id: "qwen/qwen3-coder:free", + name: "Qwen: Qwen3 Coder 480B A35B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { @@ -7091,8 +5965,1136 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"openai-completions">, + "qwen/qwen3-max": { + id: "qwen/qwen3-max", + name: "Qwen: Qwen3 Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.2, + output: 6, + cacheRead: 0.24, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-next-80b-a3b-instruct": { + id: "qwen/qwen3-next-80b-a3b-instruct", + name: "Qwen: Qwen3 Next 80B A3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09, + output: 1.1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-next-80b-a3b-thinking": { + id: "qwen/qwen3-next-80b-a3b-thinking", + name: "Qwen: Qwen3 Next 80B A3B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.12, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, contextWindow: 131072, maxTokens: 32768, - } satisfies Model<"google-gemini-cli">, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-235b-a22b-instruct": { + id: "qwen/qwen3-vl-235b-a22b-instruct", + name: "Qwen: Qwen3 VL 235B A22B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-235b-a22b-thinking": { + id: "qwen/qwen3-vl-235b-a22b-thinking", + name: "Qwen: Qwen3 VL 235B A22B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-30b-a3b-instruct": { + id: "qwen/qwen3-vl-30b-a3b-instruct", + name: "Qwen: Qwen3 VL 30B A3B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-30b-a3b-thinking": { + id: "qwen/qwen3-vl-30b-a3b-thinking", + name: "Qwen: Qwen3 VL 30B A3B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.16, + output: 0.7999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-8b-instruct": { + id: "qwen/qwen3-vl-8b-instruct", + name: "Qwen: Qwen3 VL 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.064, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwen3-vl-8b-thinking": { + id: "qwen/qwen3-vl-8b-thinking", + name: "Qwen: Qwen3 VL 8B Thinking", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.18, + output: 2.0999999999999996, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "qwen/qwq-32b": { + id: "qwen/qwq-32b", + name: "Qwen: QwQ 32B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "relace/relace-search": { + id: "relace/relace-search", + name: "Relace: Relace Search", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "sao10k/l3-euryale-70b": { + id: "sao10k/l3-euryale-70b", + name: "Sao10k: Llama 3 Euryale 70B v2.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1.48, + output: 1.48, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "sao10k/l3.1-euryale-70b": { + id: "sao10k/l3.1-euryale-70b", + name: "Sao10K: Llama 3.1 Euryale 70B v2.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.65, + output: 0.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "stepfun-ai/step3": { + id: "stepfun-ai/step3", + name: "StepFun: Step3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5700000000000001, + output: 1.42, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "thedrummer/rocinante-12b": { + id: "thedrummer/rocinante-12b", + name: "TheDrummer: Rocinante 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.16999999999999998, + output: 0.43, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "thedrummer/unslopnemo-12b": { + id: "thedrummer/unslopnemo-12b", + name: "TheDrummer: UnslopNemo 12B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "tngtech/deepseek-r1t2-chimera": { + id: "tngtech/deepseek-r1t2-chimera", + name: "TNG: DeepSeek R1T2 Chimera", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "tngtech/tng-r1t-chimera": { + id: "tngtech/tng-r1t-chimera", + name: "TNG: R1T Chimera", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "tngtech/tng-r1t-chimera:free": { + id: "tngtech/tng-r1t-chimera:free", + name: "TNG: R1T Chimera (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "x-ai/grok-3": { + id: "x-ai/grok-3", + name: "xAI: Grok 3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-3-beta": { + id: "x-ai/grok-3-beta", + name: "xAI: Grok 3 Beta", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-3-mini": { + id: "x-ai/grok-3-mini", + name: "xAI: Grok 3 Mini", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 0.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-3-mini-beta": { + id: "x-ai/grok-3-mini-beta", + name: "xAI: Grok 3 Mini Beta", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 0.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-4": { + id: "x-ai/grok-4", + name: "xAI: Grok 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "x-ai/grok-4-fast": { + id: "x-ai/grok-4-fast", + name: "xAI: Grok 4 Fast", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.5, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "x-ai/grok-4.1-fast": { + id: "x-ai/grok-4.1-fast", + name: "xAI: Grok 4.1 Fast", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.5, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "x-ai/grok-code-fast-1": { + id: "x-ai/grok-code-fast-1", + name: "xAI: Grok Code Fast 1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.19999999999999998, + output: 1.5, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 10000, + } satisfies Model<"openai-completions">, + "xiaomi/mimo-v2-flash:free": { + id: "xiaomi/mimo-v2-flash:free", + name: "Xiaomi: MiMo-V2-Flash (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "z-ai/glm-4-32b": { + id: "z-ai/glm-4-32b", + name: "Z.AI: GLM 4 32B ", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5": { + id: "z-ai/glm-4.5", + name: "Z.AI: GLM 4.5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.35, + output: 1.55, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5-air": { + id: "z-ai/glm-4.5-air", + name: "Z.AI: GLM 4.5 Air", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.10400000000000001, + output: 0.6799999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5-air:free": { + id: "z-ai/glm-4.5-air:free", + name: "Z.AI: GLM 4.5 Air (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.5v": { + id: "z-ai/glm-4.5v", + name: "Z.AI: GLM 4.5V", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.48, + output: 1.44, + cacheRead: 0.088, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.6": { + id: "z-ai/glm-4.6", + name: "Z.AI: GLM 4.6", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.35, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.6:exacto": { + id: "z-ai/glm-4.6:exacto", + name: "Z.AI: GLM 4.6 (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.44, + output: 1.76, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.6v": { + id: "z-ai/glm-4.6v", + name: "Z.AI: GLM 4.6V", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 0.8999999999999999, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 24000, + } satisfies Model<"openai-completions">, + "z-ai/glm-4.7": { + id: "z-ai/glm-4.7", + name: "Z.AI: GLM 4.7", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 65535, + } satisfies Model<"openai-completions">, + }, + "xai": { + "grok-2": { + id: "grok-2", + name: "Grok 2", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-2-1212": { + id: "grok-2-1212", + name: "Grok 2 (1212)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-2-latest": { + id: "grok-2-latest", + name: "Grok 2 Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-2-vision": { + id: "grok-2-vision", + name: "Grok 2 Vision", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-2-vision-1212": { + id: "grok-2-vision-1212", + name: "Grok 2 Vision (1212)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-2-vision-latest": { + id: "grok-2-vision-latest", + name: "Grok 2 Vision Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 2, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-3": { + id: "grok-3", + name: "Grok 3", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-fast": { + id: "grok-3-fast", + name: "Grok 3 Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 5, + output: 25, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-fast-latest": { + id: "grok-3-fast-latest", + name: "Grok 3 Fast Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 5, + output: 25, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-latest": { + id: "grok-3-latest", + name: "Grok 3 Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-mini": { + id: "grok-3-mini", + name: "Grok 3 Mini", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 0.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-mini-fast": { + id: "grok-3-mini-fast", + name: "Grok 3 Mini Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 4, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-mini-fast-latest": { + id: "grok-3-mini-fast-latest", + name: "Grok 3 Mini Fast Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 4, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-3-mini-latest": { + id: "grok-3-mini-latest", + name: "Grok 3 Mini Latest", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 0.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "grok-4": { + id: "grok-4", + name: "Grok 4", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 3, + output: 15, + cacheRead: 0.75, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "grok-4-1-fast": { + id: "grok-4-1-fast", + name: "Grok 4.1 Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-4-1-fast-non-reasoning": { + id: "grok-4-1-fast-non-reasoning", + name: "Grok 4.1 Fast (Non-Reasoning)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-4-fast": { + id: "grok-4-fast", + name: "Grok 4 Fast", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-4-fast-non-reasoning": { + id: "grok-4-fast-non-reasoning", + name: "Grok 4 Fast (Non-Reasoning)", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">, + "grok-beta": { + id: "grok-beta", + name: "Grok Beta", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { + input: 5, + output: 15, + cacheRead: 5, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "grok-code-fast-1": { + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 1.5, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 10000, + } satisfies Model<"openai-completions">, + "grok-vision-beta": { + id: "grok-vision-beta", + name: "Grok Vision Beta", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 5, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + }, + "zai": { + "glm-4.5": { + id: "glm-4.5", + name: "GLM-4.5", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"anthropic-messages">, + "glm-4.5-air": { + id: "glm-4.5-air", + name: "GLM-4.5-Air", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.2, + output: 1.1, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"anthropic-messages">, + "glm-4.5-flash": { + id: "glm-4.5-flash", + name: "GLM-4.5-Flash", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 98304, + } satisfies Model<"anthropic-messages">, + "glm-4.5v": { + id: "glm-4.5v", + name: "GLM-4.5V", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 1.8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 64000, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "glm-4.6": { + id: "glm-4.6", + name: "GLM-4.6", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "glm-4.6v": { + id: "glm-4.6v", + name: "GLM-4.6V", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 0.9, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "glm-4.7": { + id: "glm-4.7", + name: "GLM-4.7", + api: "anthropic-messages", + provider: "zai", + baseUrl: "https://api.z.ai/api/anthropic", + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.2, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, }, } as const; diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index 6b285b75..2be0bf3b 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -434,10 +434,8 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi thinkingLevel: "off", tools, }, - messageTransformer, - transport: new ProviderTransport({ - getApiKey: async () => getAnthropicApiKey(authStorage), - }), + convertToLlm, + getApiKey: async () => getAnthropicApiKey(authStorage), }); // Load existing messages From 8ed6f6dd85991ded5e724c94f346026cbf5c8afa Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 00:57:33 +0100 Subject: [PATCH 151/161] Fix pods CLI: remove reference to non-existent stopAllModels --- packages/pods/src/cli.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/pods/src/cli.ts b/packages/pods/src/cli.ts index 150e5dd2..e6a25f55 100644 --- a/packages/pods/src/cli.ts +++ b/packages/pods/src/cli.ts @@ -4,7 +4,7 @@ import { spawn } from "child_process"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { listModels, startModel, stopModel, viewLogs } from "./commands/models.js"; +import { listModels, showKnownModels, startModel, stopAllModels, stopModel, viewLogs } from "./commands/models.js"; import { listPods, removePodCommand, setupPod, switchActivePod } from "./commands/pods.js"; import { promptModel } from "./commands/prompt.js"; import { getActivePod, loadConfig } from "./config.js"; @@ -241,7 +241,6 @@ try { const modelId = args[1]; if (!modelId) { // Show available models - const { showKnownModels } = await import("./commands/models.js"); await showKnownModels(); process.exit(0); } @@ -306,7 +305,6 @@ try { const name = args[1]; if (!name) { // Stop all models on the active pod - const { stopAllModels } = await import("./commands/models.js"); await stopAllModels({ pod: podOverride }); } else { await stopModel(name, { pod: podOverride }); From ecd240f6363e8e605090a80e99197cda595e399f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 01:02:29 +0100 Subject: [PATCH 152/161] Define own GoogleThinkingLevel type instead of importing from @google/genai - Add GoogleThinkingLevel type mirroring Google's ThinkingLevel enum - Update GoogleGeminiCliOptions and GoogleOptions to use our type - Cast to any when assigning to Google SDK's ThinkingConfig --- .../ai/src/providers/google-gemini-cli.ts | 13 +++++-- packages/ai/src/providers/google.ts | 7 ++-- packages/ai/src/stream.ts | 38 +++++++++++-------- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index cbeb60a2..85d644dd 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -4,7 +4,7 @@ * Uses the Cloud Code Assist API endpoint to access Gemini and Claude models. */ -import type { Content, ThinkingConfig, ThinkingLevel } from "@google/genai"; +import type { Content, ThinkingConfig } from "@google/genai"; import { calculateCost } from "../models.js"; import type { Api, @@ -21,6 +21,12 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import { convertMessages, convertTools, mapStopReasonString, mapToolChoice } from "./google-shared.js"; +/** + * Thinking level for Gemini 3 models. + * Mirrors Google's ThinkingLevel enum values. + */ +export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; + export interface GoogleGeminiCliOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; /** @@ -35,7 +41,7 @@ export interface GoogleGeminiCliOptions extends StreamOptions { /** Thinking budget in tokens. Use for Gemini 2.x models. */ budgetTokens?: number; /** Thinking level. Use for Gemini 3 models (LOW/HIGH for Pro, MINIMAL/LOW/MEDIUM/HIGH for Flash). */ - level?: ThinkingLevel; + level?: GoogleThinkingLevel; }; projectId?: string; } @@ -436,7 +442,8 @@ function buildRequest( }; // Gemini 3 models use thinkingLevel, older models use thinkingBudget if (options.thinking.level !== undefined) { - generationConfig.thinkingConfig.thinkingLevel = options.thinking.level; + // Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values + generationConfig.thinkingConfig.thinkingLevel = options.thinking.level as any; } else if (options.thinking.budgetTokens !== undefined) { generationConfig.thinkingConfig.thinkingBudget = options.thinking.budgetTokens; } diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 29370a68..67893eef 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -3,7 +3,6 @@ import { type GenerateContentParameters, GoogleGenAI, type ThinkingConfig, - type ThinkingLevel, } from "@google/genai"; import { calculateCost } from "../models.js"; import { getEnvApiKey } from "../stream.js"; @@ -20,6 +19,7 @@ import type { } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import type { GoogleThinkingLevel } from "./google-gemini-cli.js"; import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js"; export interface GoogleOptions extends StreamOptions { @@ -27,7 +27,7 @@ export interface GoogleOptions extends StreamOptions { thinking?: { enabled: boolean; budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: ThinkingLevel; + level?: GoogleThinkingLevel; }; } @@ -299,7 +299,8 @@ function buildParams( if (options.thinking?.enabled && model.reasoning) { const thinkingConfig: ThinkingConfig = { includeThoughts: true }; if (options.thinking.level !== undefined) { - thinkingConfig.thinkingLevel = options.thinking.level; + // Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values + thinkingConfig.thinkingLevel = options.thinking.level as any; } else if (options.thinking.budgetTokens !== undefined) { thinkingConfig.thinkingBudget = options.thinking.budgetTokens; } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index fb8f5ca3..f68d5f60 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,8 +1,11 @@ -import { ThinkingLevel } from "@google/genai"; import { supportsXhigh } from "./models.js"; import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js"; import { type GoogleOptions, streamGoogle } from "./providers/google.js"; -import { type GoogleGeminiCliOptions, streamGoogleGeminiCli } from "./providers/google-gemini-cli.js"; +import { + type GoogleGeminiCliOptions, + type GoogleThinkingLevel, + streamGoogleGeminiCli, +} from "./providers/google-gemini-cli.js"; import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions.js"; import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses.js"; import type { @@ -256,53 +259,56 @@ function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { return model.id.includes("3-flash"); } -function getGemini3ThinkingLevel(effort: ClampedReasoningEffort, model: Model<"google-generative-ai">): ThinkingLevel { +function getGemini3ThinkingLevel( + effort: ClampedReasoningEffort, + model: Model<"google-generative-ai">, +): GoogleThinkingLevel { if (isGemini3ProModel(model)) { // Gemini 3 Pro only supports LOW/HIGH (for now) switch (effort) { case "minimal": case "low": - return ThinkingLevel.LOW; + return "LOW"; case "medium": case "high": - return ThinkingLevel.HIGH; + return "HIGH"; } } // Gemini 3 Flash supports all four levels switch (effort) { case "minimal": - return ThinkingLevel.MINIMAL; + return "MINIMAL"; case "low": - return ThinkingLevel.LOW; + return "LOW"; case "medium": - return ThinkingLevel.MEDIUM; + return "MEDIUM"; case "high": - return ThinkingLevel.HIGH; + return "HIGH"; } } -function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: string): ThinkingLevel { +function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: string): GoogleThinkingLevel { if (modelId.includes("3-pro")) { // Gemini 3 Pro only supports LOW/HIGH (for now) switch (effort) { case "minimal": case "low": - return ThinkingLevel.LOW; + return "LOW"; case "medium": case "high": - return ThinkingLevel.HIGH; + return "HIGH"; } } // Gemini 3 Flash supports all four levels switch (effort) { case "minimal": - return ThinkingLevel.MINIMAL; + return "MINIMAL"; case "low": - return ThinkingLevel.LOW; + return "LOW"; case "medium": - return ThinkingLevel.MEDIUM; + return "MEDIUM"; case "high": - return ThinkingLevel.HIGH; + return "HIGH"; } } From 2601c26761df91f5c47e009209f9c486720ea58f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 01:08:49 +0100 Subject: [PATCH 153/161] Clean up stream tests --- packages/ai/test/stream.test.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 29d9111f..02229d66 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -752,13 +752,11 @@ describe("Generate E2E Tests", () => { const llm = getModel("google-gemini-cli", "gemini-3-flash-preview"); it.skipIf(!geminiCliToken)("should handle thinking with thinkingLevel", { retry: 3 }, async () => { - const { ThinkingLevel } = await import("@google/genai"); - await handleThinking(llm, { apiKey: geminiCliToken, thinking: { enabled: true, level: ThinkingLevel.LOW } }); + await handleThinking(llm, { apiKey: geminiCliToken, thinking: { enabled: true, level: "LOW" } }); }); it.skipIf(!geminiCliToken)("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - const { ThinkingLevel } = await import("@google/genai"); - await multiTurn(llm, { apiKey: geminiCliToken, thinking: { enabled: true, level: ThinkingLevel.MEDIUM } }); + await multiTurn(llm, { apiKey: geminiCliToken, thinking: { enabled: true, level: "MEDIUM" } }); }); }); @@ -778,17 +776,15 @@ describe("Generate E2E Tests", () => { }); it.skipIf(!antigravityToken)("should handle thinking with thinkingLevel", { retry: 3 }, async () => { - const { ThinkingLevel } = await import("@google/genai"); // gemini-3-flash supports all four levels: MINIMAL, LOW, MEDIUM, HIGH await handleThinking(llm, { apiKey: antigravityToken, - thinking: { enabled: true, level: ThinkingLevel.LOW }, + thinking: { enabled: true, level: "LOW" }, }); }); it.skipIf(!antigravityToken)("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - const { ThinkingLevel } = await import("@google/genai"); - await multiTurn(llm, { apiKey: antigravityToken, thinking: { enabled: true, level: ThinkingLevel.MEDIUM } }); + await multiTurn(llm, { apiKey: antigravityToken, thinking: { enabled: true, level: "MEDIUM" } }); }); it.skipIf(!antigravityToken)("should handle image input", { retry: 3 }, async () => { @@ -800,11 +796,10 @@ describe("Generate E2E Tests", () => { const llm = getModel("google-antigravity", "gemini-3-pro-high"); it.skipIf(!antigravityToken)("should handle thinking with thinkingLevel HIGH", { retry: 3 }, async () => { - const { ThinkingLevel } = await import("@google/genai"); // gemini-3-pro only supports LOW/HIGH await handleThinking(llm, { apiKey: antigravityToken, - thinking: { enabled: true, level: ThinkingLevel.HIGH }, + thinking: { enabled: true, level: "HIGH" }, }); }); }); From 04ce66951e260e0aa9b24010dd043e47ed3b6f6a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 01:34:41 +0100 Subject: [PATCH 154/161] Add tree selector improvement notes to plan --- packages/coding-agent/docs/session-tree-plan.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 3b01e818..21a66418 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -373,6 +373,11 @@ interface BeforeAgentStartResult { - Shows current position - Generates branch summaries when switching branches +### Tree Selector Improvements + +- [ ] Active line highlight (background color) - currently bold but hard to see +- [ ] Filter out tool calls - add as another filter type to cycle through via `^O` + ### Documentation Review and update all docs: From e4df5d14b5a7d47512cdbd798c4712e796e12e81 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 01:40:13 +0100 Subject: [PATCH 155/161] Tree selector improvements: active line highlight and tool filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add inverse background highlight for selected/active line - Add 'no-tools' filter mode to hide tool results - Add isShiftCtrlO to cycle filters backwards - Filter order: default → no-tools → user-only → labeled-only → all --- .../interactive/components/tree-selector.ts | 23 +++++++++++++++---- packages/tui/src/index.ts | 1 + packages/tui/src/keys.ts | 8 +++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 15945373..fc213933 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -11,6 +11,7 @@ import { isCtrlO, isEnter, isEscape, + isShiftCtrlO, Spacer, Text, TruncatedText, @@ -42,7 +43,7 @@ interface FlatNode { } /** Filter mode for tree display */ -type FilterMode = "default" | "user-only" | "labeled-only" | "all"; +type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all"; /** * Tree list component with selection and ASCII art visualization @@ -265,6 +266,9 @@ class TreeList implements Component { passesFilter = (entry.type === "message" && entry.message.role === "user") || (entry.type === "custom_message" && entry.display); + } else if (this.filterMode === "no-tools") { + // Hide tool results + passesFilter = !(entry.type === "message" && entry.message.role === "toolResult"); } else if (this.filterMode === "labeled-only") { passesFilter = flatNode.node.label !== undefined; } else if (this.filterMode !== "all") { @@ -373,6 +377,8 @@ class TreeList implements Component { private getFilterLabel(): string { switch (this.filterMode) { + case "no-tools": + return " [no-tools]"; case "user-only": return " [user]"; case "labeled-only": @@ -456,7 +462,10 @@ class TreeList implements Component { const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); - const line = cursor + theme.fg("dim", prefix) + pathMarker + label + content; + let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content; + if (isSelected) { + line = theme.inverse(line); + } lines.push(truncateToWidth(line, width)); } @@ -663,9 +672,15 @@ class TreeList implements Component { } } else if (isCtrlC(keyData)) { this.onCancel?.(); + } else if (isShiftCtrlO(keyData)) { + // Cycle filter backwards + const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; + const currentIndex = modes.indexOf(this.filterMode); + this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length]; + this.applyFilter(); } else if (isCtrlO(keyData)) { - // Cycle filter: default → user-only → labeled-only → all → default - const modes: FilterMode[] = ["default", "user-only", "labeled-only", "all"]; + // Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default + const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; const currentIndex = modes.indexOf(this.filterMode); this.filterMode = modes[(currentIndex + 1) % modes.length]; this.applyFilter(); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 2c741eb8..4d4d895c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -51,6 +51,7 @@ export { isEscape, isHome, isShiftCtrlD, + isShiftCtrlO, isShiftCtrlP, isShiftEnter, isShiftTab, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 12965fec..da8cf699 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -304,6 +304,14 @@ export function isCtrlO(data: string): boolean { return data === RAW.CTRL_O || data === Keys.CTRL_O || matchesKittySequence(data, CODEPOINTS.o, MODIFIERS.ctrl); } +/** + * Check if input matches Shift+Ctrl+O (Kitty protocol only). + * Ignores lock key bits. + */ +export function isShiftCtrlO(data: string): boolean { + return matchesKittySequence(data, CODEPOINTS.o, MODIFIERS.shift + MODIFIERS.ctrl); +} + /** * Check if input matches Ctrl+P (raw byte or Kitty protocol). * Ignores lock key bits. From e7d438b59d2ab61677a20be23336c9dec4526973 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 01:40:28 +0100 Subject: [PATCH 156/161] Mark tree selector improvements as complete in plan --- packages/coding-agent/docs/session-tree-plan.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 21a66418..e58eab68 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -373,10 +373,11 @@ interface BeforeAgentStartResult { - Shows current position - Generates branch summaries when switching branches -### Tree Selector Improvements +### Tree Selector Improvements ✅ -- [ ] Active line highlight (background color) - currently bold but hard to see -- [ ] Filter out tool calls - add as another filter type to cycle through via `^O` +- [x] Active line highlight (inverse background) - much more visible +- [x] Filter out tool calls - `no-tools` filter mode via `^O` +- [x] `Shift+^O` cycles filters backwards ### Documentation From ae351257b69ac1ebab71ebc9310a87a50a626db7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 01:45:24 +0100 Subject: [PATCH 157/161] Fix tree selector: proper selectedBg theme color, correct filter logic - Add selectedBg theme color for active line highlight - Fix filter modes: - no-tools: default minus tool results (still hides label/custom) - user-only: just user messages - labeled-only: just labeled entries - all: everything - Update theme.md with new color tokens (50 total) --- packages/coding-agent/docs/theme.md | 8 +++- .../interactive/components/tree-selector.ts | 38 ++++++++++++------- .../src/modes/interactive/theme/dark.json | 2 + .../src/modes/interactive/theme/light.json | 2 + .../src/modes/interactive/theme/theme.ts | 12 +++++- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/docs/theme.md b/packages/coding-agent/docs/theme.md index 06f674cb..ac3fa237 100644 --- a/packages/coding-agent/docs/theme.md +++ b/packages/coding-agent/docs/theme.md @@ -21,12 +21,16 @@ Every theme must define all color tokens. There are no optional colors. | `dim` | Very dimmed text | Less important info, placeholders | | `text` | Default text color | Main content (usually `""`) | -### Backgrounds & Content Text (7 colors) +### Backgrounds & Content Text (11 colors) | Token | Purpose | |-------|---------| +| `selectedBg` | Selected/active line background (e.g., tree selector) | | `userMessageBg` | User message background | | `userMessageText` | User message text color | +| `customMessageBg` | Hook custom message background | +| `customMessageText` | Hook custom message text color | +| `customMessageLabel` | Hook custom message label/type text | | `toolPendingBg` | Tool execution box (pending state) | | `toolSuccessBg` | Tool execution box (success state) | | `toolErrorBg` | Tool execution box (error state) | @@ -95,7 +99,7 @@ These create a visual hierarchy: off → minimal → low → medium → high → |-------|---------| | `bashMode` | Editor border color when in bash mode (! prefix) | -**Total: 46 color tokens** (all required) +**Total: 50 color tokens** (all required) ## Theme Format diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index fc213933..77b64ee3 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -262,18 +262,30 @@ class TreeList implements Component { // Apply filter mode let passesFilter = true; - if (this.filterMode === "user-only") { - passesFilter = - (entry.type === "message" && entry.message.role === "user") || - (entry.type === "custom_message" && entry.display); - } else if (this.filterMode === "no-tools") { - // Hide tool results - passesFilter = !(entry.type === "message" && entry.message.role === "toolResult"); - } else if (this.filterMode === "labeled-only") { - passesFilter = flatNode.node.label !== undefined; - } else if (this.filterMode !== "all") { - // Default mode: hide label and custom entries - passesFilter = entry.type !== "label" && entry.type !== "custom"; + switch (this.filterMode) { + case "user-only": + // Just user messages + passesFilter = entry.type === "message" && entry.message.role === "user"; + break; + case "no-tools": + // Default minus tool results (still hide label/custom entries) + passesFilter = + entry.type !== "label" && + entry.type !== "custom" && + !(entry.type === "message" && entry.message.role === "toolResult"); + break; + case "labeled-only": + // Just labeled entries + passesFilter = flatNode.node.label !== undefined; + break; + case "all": + // Show everything + passesFilter = true; + break; + default: + // Default mode: hide label and custom entries + passesFilter = entry.type !== "label" && entry.type !== "custom"; + break; } if (!passesFilter) return false; @@ -464,7 +476,7 @@ class TreeList implements Component { let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content; if (isSelected) { - line = theme.inverse(line); + line = theme.bg("selectedBg", line); } lines.push(truncateToWidth(line, width)); } diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 25c61db6..f55be9f7 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -11,6 +11,7 @@ "dimGray": "#666666", "darkGray": "#505050", "accent": "#8abeb7", + "selectedBg": "#3a3a4a", "userMsgBg": "#343541", "toolPendingBg": "#282832", "toolSuccessBg": "#283228", @@ -29,6 +30,7 @@ "dim": "dimGray", "text": "", + "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", "userMessageText": "", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 36e9c763..a4276853 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -10,6 +10,7 @@ "mediumGray": "#6c6c6c", "dimGray": "#8a8a8a", "lightGray": "#b0b0b0", + "selectedBg": "#d0d0e0", "userMsgBg": "#e8e8e8", "toolPendingBg": "#e8e8f0", "toolSuccessBg": "#e8f0e8", @@ -28,6 +29,7 @@ "dim": "dimGray", "text": "", + "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", "userMessageText": "", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 915182f0..c0d8bf66 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -34,7 +34,8 @@ const ThemeJsonSchema = Type.Object({ muted: ColorValueSchema, dim: ColorValueSchema, text: ColorValueSchema, - // Backgrounds & Content Text (10 colors) + // Backgrounds & Content Text (11 colors) + selectedBg: ColorValueSchema, userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, customMessageBg: ColorValueSchema, @@ -132,7 +133,13 @@ export type ThemeColor = | "thinkingXhigh" | "bashMode"; -export type ThemeBg = "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; +export type ThemeBg = + | "selectedBg" + | "userMessageBg" + | "customMessageBg" + | "toolPendingBg" + | "toolSuccessBg" + | "toolErrorBg"; type ColorMode = "truecolor" | "256color"; @@ -488,6 +495,7 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme { const fgColors: Record = {} as Record; const bgColors: Record = {} as Record; const bgColorKeys: Set = new Set([ + "selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", From ca457bd0ca48af17750cce120db65b7606ecb636 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 01:49:03 +0100 Subject: [PATCH 158/161] Update tree selector help text to show Shift+^O for reverse filter cycling --- .../src/modes/interactive/components/tree-selector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 77b64ee3..80ac924b 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -812,7 +812,7 @@ export class TreeSelectorComponent extends Container { this.addChild(new DynamicBorder()); this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); this.addChild( - new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O: filter. Type to search"), 0, 0), + new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O/⇧^O: filter. Type to search"), 0, 0), ); this.addChild(new SearchLine(this.treeList)); this.addChild(new DynamicBorder()); From c0bf7800d946adb8218d56770cc978843d19096a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 02:16:29 +0100 Subject: [PATCH 159/161] Update session-tree-plan.md: tree selector details, theme.md done, add Before Release section --- .../coding-agent/docs/session-tree-plan.md | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index e58eab68..0a3a50ae 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -375,9 +375,13 @@ interface BeforeAgentStartResult { ### Tree Selector Improvements ✅ -- [x] Active line highlight (inverse background) - much more visible -- [x] Filter out tool calls - `no-tools` filter mode via `^O` -- [x] `Shift+^O` cycles filters backwards +- [x] Active line highlight using `selectedBg` theme color +- [x] Filter modes via `^O` (forward) / `Shift+^O` (backward): + - `default`: hides label/custom entries + - `no-tools`: default minus tool results + - `user-only`: just user messages + - `labeled-only`: just labeled entries + - `all`: everything ### Documentation @@ -402,7 +406,7 @@ Review and update all docs: - [ ] `docs/rpc.md` - Check if hook commands work in RPC mode - [ ] `docs/skills.md` - Review for any hook-related updates - [ ] `docs/extension-loading.md` - Review -- [ ] `docs/theme.md` - Add customMessageBg/Text/Label color tokens +- [x] `docs/theme.md` - Added selectedBg, customMessageBg/Text/Label color tokens (50 total) - [ ] `README.md` - Update hook examples if any ### Examples @@ -419,6 +423,14 @@ Review and update examples: --- +## Before Release + +- [ ] Run full automated test suite: `npm test` +- [ ] Manual testing of tree navigation and branch summarization +- [ ] Verify compaction with file tracking works correctly + +--- + ## Notes - All append methods return the new entry's ID From 1ed8a2556359541019c57c68b99d0d4ff31e0cb7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 02:34:12 +0100 Subject: [PATCH 160/161] Hide model_change and thinking_level_change in tree selector default view --- .../interactive/components/tree-selector.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 80ac924b..bdb61e42 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -262,17 +262,21 @@ class TreeList implements Component { // Apply filter mode let passesFilter = true; + // Entry types hidden in default view (settings/bookkeeping) + const isSettingsEntry = + entry.type === "label" || + entry.type === "custom" || + entry.type === "model_change" || + entry.type === "thinking_level_change"; + switch (this.filterMode) { case "user-only": // Just user messages passesFilter = entry.type === "message" && entry.message.role === "user"; break; case "no-tools": - // Default minus tool results (still hide label/custom entries) - passesFilter = - entry.type !== "label" && - entry.type !== "custom" && - !(entry.type === "message" && entry.message.role === "toolResult"); + // Default minus tool results + passesFilter = !isSettingsEntry && !(entry.type === "message" && entry.message.role === "toolResult"); break; case "labeled-only": // Just labeled entries @@ -283,8 +287,8 @@ class TreeList implements Component { passesFilter = true; break; default: - // Default mode: hide label and custom entries - passesFilter = entry.type !== "label" && entry.type !== "custom"; + // Default mode: hide settings/bookkeeping entries + passesFilter = !isSettingsEntry; break; } From 3d76a210360d75095c38aeeb5211c998b64a158d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 30 Dec 2025 22:43:02 +0100 Subject: [PATCH 161/161] Regenerate models after rebase --- packages/ai/src/models.generated.ts | 49 ++++++++++++++++------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index f3d87fa6..f7fb6aff 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3325,13 +3325,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.224, - output: 0.32, + input: 0.25, + output: 0.38, cacheRead: 0, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 4096, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2-exp": { id: "deepseek/deepseek-v3.2-exp", @@ -6249,8 +6249,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.3, - output: 1.2, + input: 0.25, + output: 0.85, cacheRead: 0, cacheWrite: 0, }, @@ -6266,8 +6266,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.3, - output: 1.2, + input: 0.25, + output: 0.85, cacheRead: 0, cacheWrite: 0, }, @@ -6978,9 +6978,10 @@ export const MODELS = { "glm-4.5": { id: "glm-4.5", name: "GLM-4.5", - api: "anthropic-messages", + api: "openai-completions", provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false}, reasoning: true, input: ["text"], cost: { @@ -6991,13 +6992,14 @@ export const MODELS = { }, contextWindow: 131072, maxTokens: 98304, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "glm-4.5-air": { id: "glm-4.5-air", name: "GLM-4.5-Air", - api: "anthropic-messages", + api: "openai-completions", provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false}, reasoning: true, input: ["text"], cost: { @@ -7008,13 +7010,14 @@ export const MODELS = { }, contextWindow: 131072, maxTokens: 98304, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "glm-4.5-flash": { id: "glm-4.5-flash", name: "GLM-4.5-Flash", - api: "anthropic-messages", + api: "openai-completions", provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false}, reasoning: true, input: ["text"], cost: { @@ -7025,7 +7028,7 @@ export const MODELS = { }, contextWindow: 131072, maxTokens: 98304, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "glm-4.5v": { id: "glm-4.5v", name: "GLM-4.5V", @@ -7047,9 +7050,10 @@ export const MODELS = { "glm-4.6": { id: "glm-4.6", name: "GLM-4.6", - api: "anthropic-messages", + api: "openai-completions", provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false}, reasoning: true, input: ["text"], cost: { @@ -7060,7 +7064,7 @@ export const MODELS = { }, contextWindow: 204800, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "glm-4.6v": { id: "glm-4.6v", name: "GLM-4.6V", @@ -7082,9 +7086,10 @@ export const MODELS = { "glm-4.7": { id: "glm-4.7", name: "GLM-4.7", - api: "anthropic-messages", + api: "openai-completions", provider: "zai", - baseUrl: "https://api.z.ai/api/anthropic", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false}, reasoning: true, input: ["text"], cost: { @@ -7095,6 +7100,6 @@ export const MODELS = { }, contextWindow: 204800, maxTokens: 131072, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, }, } as const;