From b8a246b684a9c465edcfb67a5750839710e5be3b Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 28 Aug 2026 15:03:29 +0530 Subject: [PATCH 1/4] Port 6.8.2 coding-harness update from the Orbital extension - search_files: ripgrep-first with FFF fallback, one-shot bounded results (default max_results 100); cursor pagination removed from the model-facing schema and output - agent loop: leading read-only tool calls execute concurrently (max 4 workers) with results committed in model order - malformed tool-call JSON returns a corrective tool result with the raw arguments so the model can re-issue the call - strict-mode schema tightening across native tool schemas (required-with-nullable optionals, execute_command safety classification guidance) - system prompt search_files guidance updated for one-shot behavior --- src/core/agent.ts | 55 ++++++++++- src/prompts/system.ts | 10 +- src/tools/executors/searchFiles.ts | 24 +++-- src/tools/executors/searchFiles/format.ts | 9 +- src/tools/executors/searchFiles/types.ts | 5 +- src/tools/schemas/ask_followup_question.ts | 2 +- src/tools/schemas/browser_action.ts | 2 +- src/tools/schemas/check_past_chat_memories.ts | 4 +- src/tools/schemas/codebase_search.ts | 2 +- src/tools/schemas/execute_command.ts | 21 ++-- src/tools/schemas/file_edit.ts | 12 +-- src/tools/schemas/generate_image.ts | 2 +- src/tools/schemas/list_files.ts | 2 +- src/tools/schemas/lsp.ts | 7 +- src/tools/schemas/multi_file_edit.ts | 12 +-- src/tools/schemas/new_task.ts | 2 +- src/tools/schemas/read_file.ts | 2 +- src/tools/schemas/run_slash_command.ts | 2 +- src/tools/schemas/search_files.ts | 96 ++++++++++--------- src/tools/schemas/switch_mode.ts | 2 +- test/search-files.test.ts | 87 ++++++++--------- 21 files changed, 202 insertions(+), 158 deletions(-) diff --git a/src/core/agent.ts b/src/core/agent.ts index 625d1c8..dcf0cec 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -50,6 +50,20 @@ import { const MAX_STEPS_PER_TURN = 50 const RESULT_PREVIEW_LINES = 6 +/** Maximum number of independent read-only tools started at once. */ +const MAX_PARALLEL_READ_ONLY_TOOLS = 4 + +/** These tools only observe repository state, so a leading run of them in one + * assistant response can execute concurrently. Mutating, interactive, and + * external tools stay serialized. */ +const PARALLEL_READ_ONLY_TOOLS = new Set([ + "read_file", + "search_files", + "list_files", + "list_code_definition_names", + "codebase_search", + "lsp", +]) /** How many times to automatically re-establish a model request that fails * before producing any output (transient/connection errors). */ const MAX_STREAM_RETRIES = 3 @@ -1147,13 +1161,46 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` } let completed = false - for (const toolCall of toolCalls) { + const runToolCall = async (toolCall: PendingToolCall): Promise => { const resultText = await this.handleToolCall(toolCall) this.messages.push({ role: "tool", tool_call_id: toolCall.id, content: resultText }) if (toolCall.name === "attempt_completion") { completed = true } } + + // Independent read-only calls (the leading run of the response) execute + // concurrently, at most MAX_PARALLEL_READ_ONLY_TOOLS at a time. Results are + // committed in model order so tool_call/tool_result pairing stays intact; + // mutating and interactive calls remain on the serialized path. + let batchEnd = 0 + while (batchEnd < toolCalls.length && PARALLEL_READ_ONLY_TOOLS.has(toolCalls[batchEnd].name)) { + batchEnd++ + } + + if (batchEnd > 1) { + const batch = toolCalls.slice(0, batchEnd) + const results = new Array(batch.length) + let nextIndex = 0 + await Promise.all( + Array.from({ length: Math.min(MAX_PARALLEL_READ_ONLY_TOOLS, batch.length) }, async () => { + while (nextIndex < batch.length) { + const index = nextIndex++ + results[index] = await this.handleToolCall(batch[index]) + } + }), + ) + for (const [index, toolCall] of batch.entries()) { + this.messages.push({ role: "tool", tool_call_id: toolCall.id, content: results[index] }) + } + } else { + for (let index = 0; index < batchEnd; index++) { + await runToolCall(toolCalls[index]) + } + } + for (let index = batchEnd; index < toolCalls.length; index++) { + await runToolCall(toolCalls[index]) + } return completed } @@ -1164,7 +1211,11 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` try { args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {} } catch (error) { - const message = `Invalid JSON arguments for ${toolCall.name}: ${(error as Error).message}` + // Recover instead of dead-ending: the error result carries the raw + // arguments so the model can re-issue the call with valid, complete JSON. + const rawArgs = toolCall.arguments.trim() + const preview = rawArgs.length > 500 ? `${rawArgs.slice(0, 500)}...(truncated)` : rawArgs + const message = `Malformed tool call JSON for ${toolCall.name}: ${(error as Error).message}. The raw arguments were:\n\n${preview}\n\nPlease re-issue the tool call with valid, complete JSON arguments.` onEvent({ type: "tool-end", id: toolCall.id, diff --git a/src/prompts/system.ts b/src/prompts/system.ts index e5e23f7..ec27bfd 100644 --- a/src/prompts/system.ts +++ b/src/prompts/system.ts @@ -185,19 +185,17 @@ Command validity rules: a command is never empty, never just \`:\`, never a bare ## search_files -Search file contents using a Rust-compatible regex. Results are compact, limited to three matches per file, and paginated. +Search file contents using a Rust-compatible regex. Results are compact and bounded to the first 100 matches; refine the query instead of paginating. ### Parameters 1. **path** (string, required): Directory to search recursively, relative to workspace 2. **regex** (string, required): Rust-compatible regular expression pattern 3. **file_pattern** (string or null, required): Glob pattern to filter files OR null -4. **cursor** (string or null, required): Copy the opaque cursor from the same search exactly, or pass JSON null without quotes for the first page -5. **max_results** (integer or null, required): Target 1-100 results; null defaults to 50 -6. **context_lines** (integer or null, required): 0-2 surrounding lines; null defaults to 0 +4. **max_results** (integer or null, required): Target 1-100 results; null defaults to 100 +5. **context_lines** (integer or null, required): 0-2 surrounding lines; null defaults to 0 -Use zero context for discovery, then read the relevant file region. Reuse a cursor only with the same path, regex, and file pattern; never invent or edit one. -If \`Next cursor\` is \`none\`, the search is complete: stop and never pass the word \`none\`. If a result says \`Restarted: yes\`, the FFF continuation failed and ripgrep restarted at page one, so account for repeated matches and continue only with the new cursor. +Use zero context for discovery, then read the relevant file region. If results are capped, refine the path, regex, or file pattern. ### Search Hygiene diff --git a/src/tools/executors/searchFiles.ts b/src/tools/executors/searchFiles.ts index b4a0435..d8352b2 100644 --- a/src/tools/executors/searchFiles.ts +++ b/src/tools/executors/searchFiles.ts @@ -63,33 +63,31 @@ export async function searchFiles(args: Record, context: ToolCo const options = parseSearchOptions(args, fingerprint) let page: SearchPage - if (options.cursor?.engine === "ripgrep") { - page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, options) + if (options.cursor?.engine === "fff") { + page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, options) } else { try { - page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, options) + // Ripgrep is the fast, deterministic default used by coding agents. FFF + // remains available as a fallback for installations where the bundled + // ripgrep binary is unavailable or cannot execute the requested pattern. + page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, options) } catch (error) { const message = error instanceof Error ? error.message : String(error) - const restarted = options.cursor?.engine === "fff" - page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, { + page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, { ...options, cursor: null, }) - page.warning = restarted - ? `FFF continuation failed; ripgrep fallback restarted from the first page and may repeat earlier results (${message})` - : `FFF failed; used ripgrep fallback (${message})` - page.restarted = restarted + page.warning = `ripgrep failed; used FFF fallback (${message})` } } const output = formatSearchPage(page) - // forked_change: append guidance when a search returns no matches, - // steering the model toward tightening/loosening the regex or scoping - // the path instead of blindly retrying with a slightly different pattern. + // Append guidance when a search returns no matches so the model changes + // the query instead of repeating the same search unchanged. if (page.matches.length === 0) { return { - text: output + "\n\nNo matches found. Before retrying:\n- Tighten or simplify the regex (e.g. use a shorter, more specific pattern).\n- Widen the path scope (e.g. search from the repo root instead of a subdirectory).\n- Try a different file_pattern glob.\n- If you have already searched 2+ times with no results, stop searching and reason from what you already know.", + text: output + "\n\nNo matches found. Change the regex, path, or file_pattern before retrying; do not repeat this unchanged search.", } } diff --git a/src/tools/executors/searchFiles/format.ts b/src/tools/executors/searchFiles/format.ts index 9702bd0..7572a7a 100644 --- a/src/tools/executors/searchFiles/format.ts +++ b/src/tools/executors/searchFiles/format.ts @@ -25,7 +25,7 @@ export function stripSearchPageMetadataForDisplay(text: string): string { let firstVisibleLine = 0 while ( firstVisibleLine < lines.length && - /^(?:Engine|Matches|Next cursor|Restarted|Warning):/.test(lines[firstVisibleLine]) + /^(?:Engine|Matches|Next cursor|Restarted|Warning):|^Additional matches omitted/.test(lines[firstVisibleLine]) ) { firstVisibleLine++ } @@ -35,9 +35,10 @@ export function stripSearchPageMetadataForDisplay(text: string): string { export function formatSearchPage(page: SearchPage): string { const cursor = serializeSearchCursor(page.nextCursor) - const nextCursor = cursor ?? "none (search complete; do not continue)" - const header = [`Engine: ${page.engine}`, `Matches: ${page.matches.length}`, `Next cursor: ${nextCursor}`] - if (page.restarted) header.push("Restarted: yes") + const header = [`Engine: ${page.engine}`, `Matches: ${page.matches.length}`] + if (cursor) { + header.push("Additional matches omitted; refine the search pattern or path instead of paginating.") + } if (page.warning) header.push(`Warning: ${page.warning}`) if (page.matches.length === 0) return header.join("\n") diff --git a/src/tools/executors/searchFiles/types.ts b/src/tools/executors/searchFiles/types.ts index 2eda538..87d1077 100644 --- a/src/tools/executors/searchFiles/types.ts +++ b/src/tools/executors/searchFiles/types.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto" import * as path from "node:path" -export const DEFAULT_SEARCH_RESULTS = 50 +// Keep the model-facing search operation one-shot. A larger first page is +// cheaper than forcing the model through cursor continuation turns. +export const DEFAULT_SEARCH_RESULTS = 100 export const MAX_SEARCH_RESULTS = 100 export const MAX_MATCHES_PER_FILE = 3 export const MAX_SEARCH_CONTEXT_LINES = 2 @@ -48,7 +50,6 @@ export interface SearchPage { matches: SearchMatch[] nextCursor: SearchCursor | null warning?: string - restarted?: boolean } export interface SearchOptions { diff --git a/src/tools/schemas/ask_followup_question.ts b/src/tools/schemas/ask_followup_question.ts index 89ce524..d2ef7df 100644 --- a/src/tools/schemas/ask_followup_question.ts +++ b/src/tools/schemas/ask_followup_question.ts @@ -38,7 +38,7 @@ export default { maxItems: 4, }, }, - required: ["question"], + required: ["question", "follow_up"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/browser_action.ts b/src/tools/schemas/browser_action.ts index 5e3378a..6f5df50 100644 --- a/src/tools/schemas/browser_action.ts +++ b/src/tools/schemas/browser_action.ts @@ -57,7 +57,7 @@ export default { description: "Text to type when performing the type action", }, }, - required: ["action"], + required: ["action", "url", "coordinate", "size", "text"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/check_past_chat_memories.ts b/src/tools/schemas/check_past_chat_memories.ts index 6b17d8a..a41297f 100644 --- a/src/tools/schemas/check_past_chat_memories.ts +++ b/src/tools/schemas/check_past_chat_memories.ts @@ -15,11 +15,11 @@ export default { description: "Regular expression pattern to search memory contents", }, workspace: { - type: "string", + type: ["string", "null"], description: "Filter by workspace directory (optional, defaults to current workspace)", }, }, - required: ["regex"], + required: ["regex", "workspace"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/codebase_search.ts b/src/tools/schemas/codebase_search.ts index 2764fe2..6ca1692 100644 --- a/src/tools/schemas/codebase_search.ts +++ b/src/tools/schemas/codebase_search.ts @@ -19,7 +19,7 @@ export default { description: "Optional subdirectory (relative to the workspace) to limit the search scope", }, }, - required: ["query"], + required: ["query", "path"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/execute_command.ts b/src/tools/schemas/execute_command.ts index e6210e8..a6cd0b5 100644 --- a/src/tools/schemas/execute_command.ts +++ b/src/tools/schemas/execute_command.ts @@ -5,7 +5,7 @@ export default { function: { name: "execute_command", description: - "Run a CLI command on the user's system. Tailor the command to the environment, explain what it does, and prefer relative paths or shell-appropriate chaining. Use the cwd parameter only when directed to run in a different directory.", + "Run one CLI command. Provide a short user-facing message and explicitly classify whether it may modify or delete data. Prefer commands scoped to the workspace.", strict: true, parameters: { type: "object", @@ -14,22 +14,21 @@ export default { type: "string", description: "Shell command to execute", }, - cwd: { - type: ["string", "null"], - description: "Optional working directory for the command, relative or absolute", - }, - message: { - type: "string", - description: - "A clear, concise one-line description of what the command does, shown to the user for approval (e.g. 'Install project dependencies with npm')", - }, + cwd: { + type: ["string", "null"], + description: "Working directory, or null for the workspace directory", + }, + message: { + type: "string", + description: "Clear one-line description shown to the user for approval", + }, isDangerous: { type: "boolean", description: "Set true when the command is potentially destructive or irreversible — e.g. deletes/overwrites files (rm, mv over existing paths), force-pushes or resets git history, drops/migrates databases, changes system/network/permission state, installs globally, or sends data to external services. Set false for safe read-only or routine commands (ls, cat, build, test, install local deps). The user's selected approval mode may auto-approve only commands marked false.", }, }, - required: ["command"], + required: ["command", "cwd", "message", "isDangerous"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/file_edit.ts b/src/tools/schemas/file_edit.ts index 3e01651..2b7504e 100644 --- a/src/tools/schemas/file_edit.ts +++ b/src/tools/schemas/file_edit.ts @@ -24,13 +24,13 @@ export default { description: "Replacement text. This will be inserted in place of the matched section. Can be an empty string to delete the match.", }, - replace_all: { - type: "boolean", - description: - "Set to true to replace every occurrence of the matched text. Defaults to false (replace a single uniquely identified occurrence).", - }, + replace_all: { + type: ["boolean", "null"], + description: + "Pass false (or null) unless the requested change intentionally applies to every occurrence. Never use it to bypass an ambiguity error.", }, - required: ["file_path", "old_string", "new_string"], + }, + required: ["file_path", "old_string", "new_string", "replace_all"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/generate_image.ts b/src/tools/schemas/generate_image.ts index 5a437c5..fb5fef8 100644 --- a/src/tools/schemas/generate_image.ts +++ b/src/tools/schemas/generate_image.ts @@ -25,7 +25,7 @@ export default { "Optional path (relative to the workspace) to an existing image to edit; supports PNG, JPG, JPEG, GIF, and WEBP", }, }, - required: ["prompt", "path"], + required: ["prompt", "path", "image"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/list_files.ts b/src/tools/schemas/list_files.ts index 163981b..5a2f9a8 100644 --- a/src/tools/schemas/list_files.ts +++ b/src/tools/schemas/list_files.ts @@ -19,7 +19,7 @@ export default { description: "Set true to list contents recursively; omit or false to show only the top level", }, }, - required: ["path"], + required: ["path", "recursive"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/lsp.ts b/src/tools/schemas/lsp.ts index 94e34c8..707cfd5 100644 --- a/src/tools/schemas/lsp.ts +++ b/src/tools/schemas/lsp.ts @@ -4,7 +4,7 @@ export default { type: "function", function: { name: "lsp", - description: `Interact with Language Server Protocol (LSP) servers to get code intelligence features like go-to-definition, find-references, hover information, and symbol search. + description: `Use language-server code intelligence when textual search is ambiguous. Supported operations are go_to_definition, find_references, hover, document_symbol, and workspace_symbol. Supported operations: - go_to_definition: Find where a symbol is defined @@ -13,10 +13,7 @@ Supported operations: - document_symbol: Get all symbols (functions, classes, variables) in a document - workspace_symbol: Search for symbols across the entire workspace -All operations require: -- file_path: The absolute path to the file to operate on -- line: The line number (1-based, as shown in editors) -- character: The character offset (1-based, as shown in editors) +Position-based operations require file_path, line, and character. document_symbol and workspace_symbol also accept a position to identify the document or symbol query. Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.`, strict: true, diff --git a/src/tools/schemas/multi_file_edit.ts b/src/tools/schemas/multi_file_edit.ts index c60cd90..7cb4d7d 100644 --- a/src/tools/schemas/multi_file_edit.ts +++ b/src/tools/schemas/multi_file_edit.ts @@ -32,13 +32,13 @@ export default { description: "Replacement text. This will be inserted in place of the matched section. Can be an empty string to delete the match.", }, - replace_all: { - type: "boolean", - description: - "Set to true to replace every occurrence of the matched text. Defaults to false (replace a single uniquely identified occurrence).", - }, + replace_all: { + type: ["boolean", "null"], + description: + "Pass false (or null) unless the requested change intentionally applies to every occurrence. Never use it to bypass an ambiguity error.", }, - required: ["file_path", "old_string", "new_string"], + }, + required: ["file_path", "old_string", "new_string", "replace_all"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/new_task.ts b/src/tools/schemas/new_task.ts index 3f48e51..7bcb386 100644 --- a/src/tools/schemas/new_task.ts +++ b/src/tools/schemas/new_task.ts @@ -24,7 +24,7 @@ export default { "Optional initial todo list written as a markdown checklist; required when the workspace mandates todos", }, }, - required: ["mode", "message"], + required: ["mode", "message", "todos"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/read_file.ts b/src/tools/schemas/read_file.ts index 2afb1e3..e1fca26 100644 --- a/src/tools/schemas/read_file.ts +++ b/src/tools/schemas/read_file.ts @@ -37,7 +37,7 @@ export const read_file = { "Lines to read from offset. Prefer 500-1000. Use null to read from offset up to the 1000-line cap.", }, }, - required: ["file_path"], + required: ["file_path", "offset", "limit"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/run_slash_command.ts b/src/tools/schemas/run_slash_command.ts index 68ba2d4..7a3e78e 100644 --- a/src/tools/schemas/run_slash_command.ts +++ b/src/tools/schemas/run_slash_command.ts @@ -19,7 +19,7 @@ export default { description: "Optional additional context or arguments for the command", }, }, - required: ["command"], + required: ["command", "args"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/search_files.ts b/src/tools/schemas/search_files.ts index 7af4569..da612fb 100644 --- a/src/tools/schemas/search_files.ts +++ b/src/tools/schemas/search_files.ts @@ -1,48 +1,52 @@ -import type OpenAI from "openai" +import type OpenAI from "openai"; export default { - type: "function", - function: { - name: "search_files", - description: - "Search file contents recursively under a directory using a Rust-compatible regex and optional file glob. Returns a compact, paginated page with at most three matches per file; use context_lines 0 for discovery, then read the relevant file. Continue only with the opaque cursor returned by the same path, regex, and file_pattern; pass JSON null without quotes for the first page. If Next cursor is none, the search is complete: stop and never pass the word none. A rare FFF continuation failure may restart with ripgrep and is marked Restarted: yes. Scope path to the narrowest plausible directory instead of searching from the repository root. If a search returns 0 matches, tighten or simplify the regex rather than retrying with a slightly different pattern. After 2+ searches with no results, stop and reason from what you already know.", - strict: true, - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Directory to search recursively, relative to the workspace", - }, - regex: { - type: "string", - minLength: 1, - description: "Rust-compatible regular expression pattern to match", - }, - file_pattern: { - type: ["string", "null"], - description: "Glob limiting searched files (e.g. '*.ts'), or null for all files", - }, - cursor: { - type: ["string", "null"], - description: - "Opaque continuation cursor copied exactly from a previous identical search, or JSON null for the first page", - }, - max_results: { - type: ["integer", "null"], - minimum: 1, - maximum: 100, - description: "Target results for this page; null uses 50", - }, - context_lines: { - type: ["integer", "null"], - minimum: 0, - maximum: 2, - description: "Context lines before and after each match; null uses 0", - }, - }, - required: ["path", "regex", "file_pattern", "cursor", "max_results", "context_lines"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool + type: "function", + function: { + name: "search_files", + description: + "Search file contents recursively with a Rust-compatible regex. Returns up to 100 matching lines with file and line numbers; additional matches are omitted, so refine the pattern or path instead of repeating the same search. Use the narrowest plausible path and an optional file glob. Use read_file for surrounding context.", + strict: true, + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Directory to search recursively, relative to the workspace", + }, + regex: { + type: "string", + minLength: 1, + description: "Rust-compatible regular expression pattern to match", + }, + file_pattern: { + type: ["string", "null"], + description: + "Glob limiting searched files (e.g. '*.ts'), or null for all files", + }, + max_results: { + type: ["integer", "null"], + minimum: 1, + maximum: 100, + description: + "Target result count; null uses 100. Results are bounded, so refine the query if the target is too broad.", + }, + context_lines: { + type: ["integer", "null"], + minimum: 0, + maximum: 2, + description: "Context lines before and after each match; null uses 0", + }, + }, + required: [ + "path", + "regex", + "file_pattern", + "max_results", + "context_lines", + ], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool; diff --git a/src/tools/schemas/switch_mode.ts b/src/tools/schemas/switch_mode.ts index 8c8fca0..0a9ab18 100644 --- a/src/tools/schemas/switch_mode.ts +++ b/src/tools/schemas/switch_mode.ts @@ -19,7 +19,7 @@ export default { description: "Optional explanation for why the mode switch is needed", }, }, - required: ["mode_slug"], + required: ["mode_slug", "reason"], additionalProperties: false, }, }, diff --git a/test/search-files.test.ts b/test/search-files.test.ts index d412427..7826876 100644 --- a/test/search-files.test.ts +++ b/test/search-files.test.ts @@ -31,7 +31,7 @@ after(async () => { await Promise.all(roots.map((root) => fs.rm(root, { recursive: true, force: true }))) }) -test("uses FFF by default with compact file filtering", async () => { +test("uses ripgrep by default with compact file filtering", async () => { const cwd = await fixture() const result = await searchFiles( { path: "src", regex: "needle", file_pattern: "*.ts", cursor: "null", max_results: 50, context_lines: 0 }, @@ -39,33 +39,27 @@ test("uses FFF by default with compact file filtering", async () => { ) assert.equal(result.isError, undefined) - assert.match(result.text, /^Engine: fff/m) + assert.match(result.text, /^Engine: ripgrep/m) assert.match(result.text, /# src\/alpha\.ts/) assert.doesNotMatch(result.text, /notes\.md/) assert.doesNotMatch(result.text, /outside\.ts/) }) -test("continues native FFF pagination without dropping matches", async () => { +test("returns one-shot bounded results without model-facing cursors", async () => { const cwd = await fixture() await fs.writeFile(path.join(cwd, "src", "beta.ts"), "const needle = 2\n") const context = { cwd, token: "", getTodos: () => "", setTodos: () => {} } - const args = { - path: "src", - regex: "needle", - file_pattern: "*.ts", - cursor: null as string | null, - max_results: 1, - context_lines: 0, - } - const first = await searchFiles(args, context) - assert.match(first.text, /^Engine: fff/m) - const cursor = /^Next cursor: (fff:\S+)$/m.exec(first.text)?.[1] - assert.ok(cursor) + const result = await searchFiles( + { path: "src", regex: "needle", file_pattern: "*.ts", cursor: null, max_results: 1, context_lines: 0 }, + context, + ) - const second = await searchFiles({ ...args, cursor }, context) - assert.match(second.text, /^Engine: fff/m) - assert.match(`${first.text}\n${second.text}`, /src\/alpha\.ts/) - assert.match(`${first.text}\n${second.text}`, /src\/beta\.ts/) + assert.equal(result.isError, undefined) + assert.match(result.text, /^Engine: ripgrep/m) + assert.match(result.text, /^Matches: 1$/m) + assert.equal((result.text.match(/# src\//g) ?? []).length, 1) + assert.match(result.text, /Additional matches omitted; refine the search pattern or path instead of paginating\./) + assert.doesNotMatch(result.text, /Next cursor:/) }) test("treats route-directory metacharacters literally and anchors nested globs to path", async () => { @@ -104,7 +98,7 @@ test("treats route-directory metacharacters literally and anchors nested globs t ) assert.equal(result.isError, undefined) - assert.match(result.text, /^Engine: fff/m) + assert.match(result.text, /^Engine: ripgrep/m) assert.match(result.text, /components\/view\.ts/) assert.doesNotMatch(result.text, /other\.ts/) }) @@ -206,8 +200,16 @@ test("keeps generated-directory exclusions and negative globs consistent across await fs.writeFile(path.join(cwd, "src", "negative.md"), "negativeNeedle markdown\n") const context = { cwd, token: "", getTodos: () => "", setTodos: () => {} } + const fffFingerprint = createSearchFingerprint(cwd, "excludedNeedle", "*.ts") const fff = await searchFiles( - { path: ".", regex: "excludedNeedle", file_pattern: "*.ts", cursor: null, max_results: 50, context_lines: 0 }, + { + path: ".", + regex: "excludedNeedle", + file_pattern: "*.ts", + cursor: `fff:0:${fffFingerprint}`, + max_results: 50, + context_lines: 0, + }, context, ) assert.match(fff.text, /^Engine: fff/m) @@ -233,7 +235,7 @@ test("keeps generated-directory exclusions and negative globs consistent across { path: "dist", regex: "excludedNeedle", file_pattern: "*.ts", cursor: null, max_results: 50, context_lines: 0 }, context, ) - assert.match(explicitDist.text, /^Engine: fff/m) + assert.match(explicitDist.text, /^Engine: ripgrep/m) assert.match(explicitDist.text, /dist\/generated\.ts/) const explicitDistFingerprint = createSearchFingerprint(path.join(cwd, "dist"), "excludedNeedle", "*.ts") const explicitDistRg = await searchFiles( @@ -249,12 +251,13 @@ test("keeps generated-directory exclusions and negative globs consistent across ) assert.match(explicitDistRg.text, /dist\/generated\.ts/) + const negationPathFingerprint = createSearchFingerprint(path.join(cwd, "src"), "negativeNeedle", "!components/**") const pathNegation = await searchFiles( { path: "src", regex: "negativeNeedle", file_pattern: "!components/**", - cursor: null, + cursor: `fff:0:${negationPathFingerprint}`, max_results: 50, context_lines: 0, }, @@ -301,25 +304,27 @@ test("ripgrep pagination preserves an adjacent match on the next page", async () assert.equal(first.isError, undefined) assert.match(first.text, /^Engine: ripgrep/m) assert.match(first.text, /> 1:1 /) - const cursor = /^Next cursor: (\S+)$/m.exec(first.text)?.[1] - assert.ok(cursor) - const second = await searchFiles({ ...args, cursor }, context) + // Cursors are no longer model-facing; construct the continuation cursor for + // the same search to verify the adjacent match survives pagination. + const second = await searchFiles({ ...args, cursor: `ripgrep:1:${fingerprint}` }, context) assert.equal(second.isError, undefined) assert.match(second.text, /> 2:1 /) }) test("makes completed searches and tool summaries unambiguous", () => { const completed = formatSearchPage({ engine: "fff", matches: [], nextCursor: null }) - assert.match(completed, /Next cursor: none \(search complete; do not continue\)/) + assert.doesNotMatch(completed, /Next cursor:|Additional matches omitted/) assert.equal(stripSearchPageMetadataForDisplay(completed), "") const page = formatSearchPage({ engine: "fff", matches: [{ file: "src/a.ts", line: 4, column: 2, text: "needle" }], nextCursor: { engine: "fff", offset: 2, fingerprint: "0123456789abcdef" }, }) + assert.doesNotMatch(page, /Next cursor:/) + assert.match(page, /Additional matches omitted; refine the search pattern or path instead of paginating\./) const visiblePage = stripSearchPageMetadataForDisplay(page) - assert.doesNotMatch(visiblePage, /Engine:|Matches:|Next cursor:/) + assert.doesNotMatch(visiblePage, /Engine:|Matches:|Additional matches omitted/) assert.match(visiblePage, /# src\/a\.ts\n> 4:2 \| needle/) assert.equal( describeToolCall("search_files", { path: "src", regex: "eido", file_pattern: "*.ts" }), @@ -328,38 +333,28 @@ test("makes completed searches and tool summaries unambiguous", () => { assert.equal(describeToolCall("search_files", { path: "src", regex: "eido", file_pattern: "null" }), "/eido/ in src") }) -test("marks and safely drains an FFF continuation fallback during cleanup", async () => { +test("settles a search that overlaps session cleanup", async () => { const cwd = await fixture() await fs.writeFile(path.join(cwd, "space file.ts"), "needle spaced\n") - const filePattern = "space *.ts" - const fingerprint = createSearchFingerprint(cwd, "needle", filePattern) const pending = searchFiles( - { - path: ".", - regex: "needle", - file_pattern: filePattern, - cursor: `fff:1:${fingerprint}`, - max_results: 50, - context_lines: 0, - }, + { path: ".", regex: "needle", file_pattern: null, cursor: null, max_results: 50, context_lines: 0 }, { cwd, token: "", getTodos: () => "", setTodos: () => {} }, ) const disposing = disposeSearchFiles() const result = await pending await disposing - assert.equal(result.isError, undefined) - assert.match(result.text, /^Engine: ripgrep/m) - assert.match(result.text, /^Restarted: yes$/m) - assert.match(result.text, /space file\.ts/) + // The search either completes after cleanup or is cancelled by it; either + // way it must settle with a result instead of hanging. + assert.ok(result.text.length > 0) }) -test("can initialize FFF again after session cleanup", async () => { +test("can initialize search engines again after session cleanup", async () => { const cwd = await fixture() const args = { path: "src", regex: "needle", file_pattern: "*.ts", cursor: null, max_results: 50, context_lines: 0 } const context = { cwd, token: "", getTodos: () => "", setTodos: () => {} } const first = await searchFiles(args, context) - assert.match(first.text, /^Engine: fff/m) + assert.match(first.text, /^Engine: ripgrep/m) await disposeSearchFiles() const second = await searchFiles(args, context) - assert.match(second.text, /^Engine: fff/m) + assert.match(second.text, /^Engine: ripgrep/m) }) From 88de4f8d91e279b249116a4c450c2bcc0966f555 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 28 Aug 2026 15:03:29 +0530 Subject: [PATCH 2/4] release: v6.8.0 --- CHANGELOG.md | 11 ++++++++++- package.json | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7594c3..60ecde1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [6.8.0] - 2026-08-28 + +### Changed + +- **Ported the 6.8.2 coding-harness update from the Orbital extension.** + - `search_files` is now one-shot: ripgrep-first with FFF fallback, results bounded to the first 100 matches (default `max_results` 100), and cursor pagination removed from the model-facing schema and output. Capped results tell the model to refine the query instead of paginating. + - Independent read-only tool calls (`read_file`, `search_files`, `list_files`, `list_code_definition_names`, `codebase_search`, `lsp`) at the start of an assistant response now execute concurrently (max 4) with results committed in model order; mutating and interactive tools stay serialized. + - Malformed tool-call JSON now returns a corrective tool result that includes the raw arguments, so the model can re-issue the call with valid JSON instead of dead-ending. + - Native tool schemas tightened for strict mode: optional parameters are now required with nullable types (`replace_all`, `recursive`, `follow_up`, `offset`/`limit`, `cwd`/`message`/`isDangerous`, and the inactive-in-CLI tool schemas), and `execute_command` guidance asks for an explicit safety classification. + - System-prompt `search_files` guidance updated to the bounded one-shot behavior. ### Added diff --git a/package.json b/package.json index 1ae4b5f..7dce595 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.7.9", + "version": "6.8.0", "description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI", "type": "module", "bin": { From d99ce1e0b953afbd32dcd6cbbfa75bb9b005480a Mon Sep 17 00:00:00 2001 From: code-crusher Date: Thu, 3 Sep 2026 15:47:54 +0530 Subject: [PATCH 3/4] feat(models): dynamic OSS catalog sync with provider badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync the model catalog from /v1/models at startup and on usage refresh; register new backend models and prune retired ones (empty/failed fetches never wipe the offline fallback, default model is never pruned) - Capture the catalog's iconUrl and costMultiplier on each model - Static fallback updated to the current 7-model OSS catalog (muse-spark-1.3, gpt-5.6-luna, gpt-5.6-sol, gemini-3.8-flash added) - Model picker shows provider badges ([Z.ai], [Meta], [DeepSeek], [OpenAI], [Google]) — the TUI stand-in for the webapp's provider logos - orbcode usage command + per-model usage in /status and /usage - README/CHANGELOG updated --- CHANGELOG.md | 27 ++++ README.md | 55 +++---- src/api/models.ts | 246 ++++++++++++++++++------------ src/auth/auth.ts | 10 ++ src/commands/usage.ts | 103 +++++++++++++ src/headless.ts | 10 +- src/index.tsx | 8 + src/ui/App.tsx | 19 ++- src/ui/components/ModelPicker.tsx | 37 +++-- 9 files changed, 374 insertions(+), 141 deletions(-) create mode 100644 src/commands/usage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ecde1..23ea9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Dynamic model catalog synchronization.** OrbCode now fetches the active model catalog dynamically from the backend (`/v1/models`) on startup and when refreshing usage (`fetchDynamicModels`), registering returned OSS models into `BUILTIN_AXON_MODELS` and `AXON_MODELS` so newly added models appear in the picker without requiring hardcoded updates. Models the backend retires are pruned after a successful fetch (empty or failed responses never wipe the offline fallback), and the catalog's `iconUrl` / `costMultiplier` fields are captured on each model. +- **Provider badges in the model picker.** Terminals can't render the catalog's SVG provider icons, so picker rows show a text badge (`[Z.ai]`, `[Meta]`, `[DeepSeek]`, `[OpenAI]`, `[Google]`) — the TUI equivalent of the webapp's provider logos. +- **`orbcode usage` command.** Prints the weekly/monthly plan usage windows + (percentage bars with reset times) and each tracked OSS model's share of + the shared plan pool as weekly/monthly percentages, alongside the model's + plan-cost multiplier (e.g. `5x cost`). The TUI's `/usage` and `/status` + commands show the same per-model block. Percentages only — no credit + amounts are exposed. Requires a logged-in token (`orbcode login`). + +### Changed + +- **Built-in model catalog is now OSS-first.** The built-in registry replaces + the Axon models with seven OSS models served through the MatterAI gateway: + `zai/glm-5.3-flash` (the new default), `zai/glm-5.3`, + `deepseek/deepseek-v4-flash-0731`, `meta/muse-spark-1.3-contributor`, + `gpt-5.6-luna`, `gpt-5.6-sol`, and `gemini-3.8-flash`. All seven expose a + 232K context window with 64K max output, are available on every plan, and + carry their published per-token pricing. The 400K + context variants and `axon-auto` are gone from the picker; a stored Axon + model selection auto-resets to the new default on next launch, and a + requested Axon id (`--model` / `MATTERAI_MODEL`) now warns and falls back + to the default. + ## [6.8.0] - 2026-08-28 ### Changed diff --git a/README.md b/README.md index faa4d10..17e8347 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ runtime — bumping the version there is all that's needed. orbcode start an interactive session in the current directory orbcode "" start an interactive session with an initial prompt orbcode login sign in to MatterAI (browser device flow) +orbcode usage show plan usage windows and per-model usage orbcode -p "" run a single prompt non-interactively, print only the final response orbcode -p "…" --yolo non-interactive with edits/commands auto-approved orbcode --model use a specific model for this run (also -m) @@ -177,40 +178,30 @@ Sign out with `/logout` (removes the saved token). ## Models -The built-in Axon models are listed below; `/model` opens a scroll-and-select -picker (`/model ` still selects directly). Additional models can be -declared via `customModels` in settings.json. The choice persists across -sessions. - -| id | context | max output | pricing | -| ------------------------------ | ------- | ---------- | ---------------------- | -| `axon-auto-232k` | 232k | 64k | dynamic pricing | -| `axon-auto-400k` | 400k | 64k | dynamic pricing | -| `axon-eido-3.2-flash` | 232k | 64k | $0.6/M in · $1.8/M out | -| `axon-eido-3.2-flash-400k` | 400k | 64k | $0.6/M in · $1.8/M out | -| `axon-eido-3.2-code-232k` | 232k | 64k | $2/M in · $6/M out | -| `axon-eido-3.2-code-400k` | 400k | 64k | $2/M in · $6/M out | -| `axon-eido-3.2-code-pro-232k` | 232k | 64k | $3/M in · $9/M out | -| `axon-eido-3.2-code-pro-400k` | 400k | 64k | $3/M in · $9/M out | -| `axon-lumen-4-code-232k` | 232k | 128k | $5/M in · $25/M out | -| `axon-lumen-4-code-400k` | 400k | 128k | $5/M in · $25/M out | - -`axon-auto-232k` is the default. The context suffix controls OrbCode's local -context window; requests still send the underlying base model ID to the -MatterAI gateway. Plan gating: - -- **Free**: `axon-eido-3.2-flash` (232K) only. -- **Pro**: adds `axon-eido-3.2-code-{232k,400k}` and `axon-eido-3.2-code-pro-{232k,400k}`. -- **Pro Plus / Ultra**: adds `axon-lumen-4-code-{232k,400k}` and unlocks every 400K variant (including `axon-eido-3.2-flash-400k`). - -Every 400K option — `axon-eido-3.2-flash-400k` included — is gated to Pro Plus -and Ultra. All five options support native JSON tool calls and image input. -Cost comes from the API's usage chunks (`is_byok`-aware) and is shown in the -status bar. +The built-in models are listed below; `/model` opens a scroll-and-select +picker (`/model ` still selects directly). The live catalog is fetched +from the backend at startup and kept in sync while the session runs — the +table below is the offline fallback. Additional models can be declared via +`customModels` in settings.json. The choice persists across sessions. + +| id | context | max output | pricing | +| --------------------------------- | ------- | ---------- | ------------------------ | +| `zai/glm-5.3-flash` | 232k | 64k | $0.15/M in · $0.5/M out | +| `zai/glm-5.3` | 232k | 64k | $1.4/M in · $4.4/M out | +| `deepseek/deepseek-v4-flash-0731` | 232k | 64k | $0.14/M in · $0.28/M out | +| `meta/muse-spark-1.3-contributor` | 232k | 64k | $0.1/M in · $0.2/M out | +| `gpt-5.6-luna` | 232k | 64k | $0.2/M in · $1.2/M out | +| `gpt-5.6-sol` | 232k | 64k | $5/M in · $30/M out | +| `gemini-3.8-flash` | 232k | 64k | $0.75/M in · $3.75/M out | + +`zai/glm-5.3-flash` is the default. Every model is available on every plan, +supports native JSON tool calls and image input, and is served through the +MatterAI gateway. Cost comes from the API's usage chunks (`is_byok`-aware) +and is shown in the status bar. ### Other providers (Anthropic, OpenAI-compatible) -The Axon models go through the MatterAI gateway as before. A `customModels` +The built-in models go through the MatterAI gateway as before. A `customModels` entry that sets a `provider` is instead served through the [Vercel AI SDK](https://sdk.vercel.ai), reusing the same agent loop, tools, and approvals — auth is the provider's own key (env var or `apiKey`), not the @@ -412,7 +403,7 @@ Two kinds of files under `~/.orbcode/`: ``` All keys are optional. `customModels` entries appear in the `/model` picker -alongside the built-in Axon models; `baseUrl` points the chat client at any +alongside the built-in models; `baseUrl` points the chat client at any OpenAI-compatible gateway; `env` is applied to the process at startup; `hooks` configures lifecycle hooks (see [Hooks](#hooks)). Precedence: env vars > project settings.json > user settings.json > config.json. diff --git a/src/api/models.ts b/src/api/models.ts index a9219ad..ba6b3a6 100644 --- a/src/api/models.ts +++ b/src/api/models.ts @@ -22,6 +22,10 @@ export interface AxonModel { free: boolean; /** Human-readable pricing shown when the gateway chooses the billable model dynamically. */ pricingLabel?: string; + /** Provider icon URL from the backend catalog. The TUI shows a text badge instead (terminals can't render SVGs). */ + iconUrl?: string; + /** Plan-pool cost multiplier from the backend catalog (e.g. 4 = 4x plan cost). */ + costMultiplier?: number; /** * Which transport serves this model. Absent (or "matterai"/"axon") routes * through the MatterAI gateway (OpenAI `/chat/completions`). Any other value @@ -136,142 +140,95 @@ export const ANTHROPIC_MODELS: Record = { }; /** - * Axon's own models (MatterAI gateway). These are the only models shown in - * the TUI's `/model` picker and are the supported defaults. Third-party - * providers (Anthropic, OpenAI-compatible) are registered under + * The OSS models served through the MatterAI gateway. These are the only + * models shown in the TUI's `/model` picker and are the supported defaults. + * Third-party providers (Anthropic, OpenAI-compatible) are registered under * `AXON_MODELS` for `-p --model` runs but are intentionally hidden from the * interactive picker for now. */ export const BUILTIN_AXON_MODELS: Record = { - "axon-auto-232k": { - id: "axon-auto-232k", - gatewayModelId: "axon-auto", - name: "Axon Auto (232K context)", + "meta/muse-spark-1.3-contributor": { + id: "meta/muse-spark-1.3-contributor", + name: "Muse Spark 1.3 Contributor", description: - "Starts with Code Flash, then dynamically selects Code Flash, Code, or Pro as the task develops.", + "Meta Muse Spark 1.3 Contributor is an open general purpose model for everyday coding tasks.", contextWindow: 232000, maxOutputTokens: 64000, supportsImages: true, - inputPrice: 0.0000005, - outputPrice: 0.0000015, + inputPrice: 0.0000001, + outputPrice: 0.0000002, free: false, - pricingLabel: "dynamic pricing", }, - "axon-auto-400k": { - id: "axon-auto-400k", - gatewayModelId: "axon-auto", - name: "Axon Auto (400K context)", + "deepseek/deepseek-v4-flash-0731": { + id: "deepseek/deepseek-v4-flash-0731", + name: "DeepSeek V4 Flash", description: - "Starts with Code Flash, then dynamically selects Code Flash, Code, or Pro as the task develops.", - contextWindow: 400000, - maxOutputTokens: 64000, - supportsImages: true, - inputPrice: 0.0000005, - outputPrice: 0.0000015, - free: false, - pricingLabel: "dynamic pricing", - }, - "axon-eido-3.2-flash": { - id: "axon-eido-3.2-flash", - name: "Axon Eido 3.2 Flash", - description: - "Axon Eido 3.2 is a fast and low cost general purpose model for low-effort day-to-day tasks", + "DeepSeek V4 Flash is a fast, low cost open model for low-effort day-to-day coding tasks.", contextWindow: 232000, maxOutputTokens: 64000, supportsImages: true, - inputPrice: 0.0000006, - outputPrice: 0.0000018, - free: false, - }, - "axon-eido-3.2-flash-400k": { - id: "axon-eido-3.2-flash-400k", - gatewayModelId: "axon-eido-3.2-flash", - name: "Axon Eido 3.2 Flash (400K context)", - description: - "Axon Eido 3.2 is a fast and low cost general purpose model for low-effort day-to-day tasks", - contextWindow: 400000, - maxOutputTokens: 64000, - supportsImages: true, - inputPrice: 0.0000006, - outputPrice: 0.0000018, + inputPrice: 0.00000014, + outputPrice: 0.00000028, free: false, }, - "axon-eido-3.2-code-pro-232k": { - id: "axon-eido-3.2-code-pro-232k", - gatewayModelId: "axon-eido-3.2-code-pro", - name: "Axon Eido 3.2 Pro (232K context)", + "zai/glm-5.3": { + id: "zai/glm-5.3", + name: "GLM 5.3", description: - "Axon Eido 3.2 Pro is the frontier Axon Code model for coding tasks, long running agents and general intelligence, fine-tuned on open source models.", + "GLM 5.3 is Z.ai's frontier open model for complex coding tasks and long running agents.", contextWindow: 232000, maxOutputTokens: 64000, supportsImages: true, - inputPrice: 0.000003, - outputPrice: 0.000009, + inputPrice: 0.0000014, + outputPrice: 0.0000044, free: false, }, - "axon-eido-3.2-code-pro-400k": { - id: "axon-eido-3.2-code-pro-400k", - gatewayModelId: "axon-eido-3.2-code-pro", - name: "Axon Eido 3.2 Pro (400K context)", + "zai/glm-5.3-flash": { + id: "zai/glm-5.3-flash", + name: "GLM 5.3 Flash", description: - "Axon Eido 3.2 Pro is the frontier Axon Code model for coding tasks, long running agents and general intelligence, fine-tuned on open source models.", - contextWindow: 400000, - maxOutputTokens: 64000, - supportsImages: true, - inputPrice: 0.000003, - outputPrice: 0.000009, - free: false, - }, - "axon-eido-3.2-code-232k": { - id: "axon-eido-3.2-code-232k", - gatewayModelId: "axon-eido-3.2-code", - name: "Axon Eido 3.2 Code (232K context)", - description: - "Axon Eido 3.2 Code is a general purpose super intelligent LLM coding model for high-effort day-to-day tasks", + "GLM 5.3 Flash is a fast, low cost open model for everyday coding tasks.", contextWindow: 232000, maxOutputTokens: 64000, supportsImages: true, - inputPrice: 0.000002, - outputPrice: 0.000006, + inputPrice: 0.00000015, + outputPrice: 0.0000005, free: false, }, - "axon-eido-3.2-code-400k": { - id: "axon-eido-3.2-code-400k", - gatewayModelId: "axon-eido-3.2-code", - name: "Axon Eido 3.2 Code (400K context)", + "gpt-5.6-luna": { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", description: - "Axon Eido 3.2 Code is a general purpose super intelligent LLM coding model for high-effort day-to-day tasks", - contextWindow: 400000, + "GPT-5.6 Luna is a fast, low cost open model for everyday coding tasks.", + contextWindow: 232000, maxOutputTokens: 64000, supportsImages: true, - inputPrice: 0.000002, - outputPrice: 0.000006, + inputPrice: 0.0000002, + outputPrice: 0.0000012, free: false, }, - "axon-lumen-4-code-232k": { - id: "axon-lumen-4-code-232k", - gatewayModelId: "axon-lumen-4-code", - name: "Axon Lumen 4 (232K context)", + "gpt-5.6-sol": { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", description: - "Axon Lumen 4 Code is the ultra-intelligent frontier model for complex agentic coding tasks and general intelligence.", + "GPT-5.6 Sol is an open reasoning model for complex coding tasks and long running agents.", contextWindow: 232000, - maxOutputTokens: 128000, + maxOutputTokens: 64000, supportsImages: true, inputPrice: 0.000005, - outputPrice: 0.000025, + outputPrice: 0.00003, free: false, }, - "axon-lumen-4-code-400k": { - id: "axon-lumen-4-code-400k", - gatewayModelId: "axon-lumen-4-code", - name: "Axon Lumen 4 (400K context)", + "gemini-3.8-flash": { + id: "gemini-3.8-flash", + name: "Gemini 3.8 Flash", description: - "Axon Lumen 4 Code is the ultra-intelligent frontier model for complex agentic coding tasks and general intelligence.", - contextWindow: 400000, - maxOutputTokens: 128000, + "Gemini 3.8 Flash is a fast, low cost model by Google for everyday coding tasks.", + contextWindow: 232000, + maxOutputTokens: 64000, supportsImages: true, - inputPrice: 0.000005, - outputPrice: 0.000025, + inputPrice: 0.00000075, + outputPrice: 0.00000375, free: false, }, }; @@ -287,7 +244,15 @@ export const AXON_MODELS: Record = { ...ANTHROPIC_MODELS, }; -export const DEFAULT_MODEL_ID = "axon-auto-232k"; +export const DEFAULT_MODEL_ID = "zai/glm-5.3-flash"; + +/** + * Model ids owned by the static catalog or a previous dynamic fetch. A + * successful (non-empty) catalog fetch prunes any of these the backend no + * longer serves, so retired models disappear from the picker instead of + * lingering next to their replacement. + */ +const managedModelIds = new Set(Object.keys(BUILTIN_AXON_MODELS)); const EXTENDED_CONTEXT_PLANS = new Set(["proplus", "ultra"]); const LUMEN_MODEL_PLANS = new Set(["proplus", "ultra"]); @@ -413,3 +378,92 @@ export function getModel(modelId: string): AxonModel { export function getGatewayModelId(model: AxonModel): string { return model.gatewayModelId ?? model.id; } + +/** + * Fetches dynamic models from the MatterAI backend (/v1/models) and registers them + * into BUILTIN_AXON_MODELS and AXON_MODELS so the model picker and agent loops can + * dynamically use newly added models without hardcoding. + */ +export async function fetchDynamicModels( + token?: string, +): Promise> { + try { + const { getUrlFromToken } = await import("../auth/auth.js"); + const targetUrl = token + ? getUrlFromToken("https://api.matterai.so/v1/models", token) + : "https://api.matterai.so/v1/models"; + + const headers: Record = { + Accept: "application/json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const res = await fetch(targetUrl, { + headers, + signal: AbortSignal.timeout(4000), + }); + if (!res.ok) { + return BUILTIN_AXON_MODELS; + } + + const json = (await res.json()) as any; + const items = Array.isArray(json?.data) ? json.data : []; + + const fetched: AxonModel[] = []; + for (const item of items) { + if (!item?.id || typeof item.id !== "string" || item.id.startsWith("axon-")) { + continue; + } + fetched.push({ + id: item.id, + name: item.name || item.id, + description: item.description || `${item.name || item.id} open model`, + contextWindow: item.context_length || 232000, + maxOutputTokens: item.max_output_length || 64000, + supportsImages: Array.isArray(item.input_modalities) + ? item.input_modalities.includes("image") + : true, + inputPrice: + typeof item.pricing?.prompt === "string" + ? parseFloat(item.pricing.prompt) || 0 + : typeof item.pricing?.prompt === "number" + ? item.pricing.prompt + : 0, + outputPrice: + typeof item.pricing?.completion === "string" + ? parseFloat(item.pricing.completion) || 0 + : typeof item.pricing?.completion === "number" + ? item.pricing.completion + : 0, + free: false, + iconUrl: typeof item.iconUrl === "string" ? item.iconUrl : undefined, + costMultiplier: + typeof item.costMultiplier === "number" ? item.costMultiplier : undefined, + }); + } + + // Reconcile only when the backend returned a usable catalog — an empty or + // failed response must never wipe the offline fallback. Retired models + // (e.g. a version bump the static fallback still lists) are pruned so they + // don't linger in the picker next to their replacement. + if (fetched.length > 0) { + const fetchedIds = new Set(fetched.map((model) => model.id)); + for (const id of managedModelIds) { + if (id === DEFAULT_MODEL_ID || fetchedIds.has(id)) continue; + delete BUILTIN_AXON_MODELS[id]; + delete AXON_MODELS[id]; + } + managedModelIds.clear(); + for (const model of fetched) { + managedModelIds.add(model.id); + BUILTIN_AXON_MODELS[model.id] = model; + AXON_MODELS[model.id] = model; + } + } + return BUILTIN_AXON_MODELS; + } catch { + return BUILTIN_AXON_MODELS; + } +} diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 2139059..303ae47 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -134,6 +134,13 @@ export interface AxonCodeWeeklyResetAvailability { nextAvailableAt: string | null; } +export interface AxonCodeModelUsage { + model: string; + multiplier: number; + weeklyPercentage: number; + monthlyPercentage: number; +} + export interface ProfileData { user?: { name?: string; email?: string; image?: string }; organizations?: Array<{ id: string; name: string; role?: string }>; @@ -144,6 +151,9 @@ export interface ProfileData { creditsResetDate?: string; // Tiered usage windows (weekly / monthly). tieredUsage?: AxonCodeTieredUsage; + // Per-model usage for the tracked OSS models (share of the shared plan + // pool, as percentages — no credit amounts are exposed). + modelUsage?: AxonCodeModelUsage[]; weeklyReset?: AxonCodeWeeklyResetAvailability; [key: string]: unknown; } diff --git a/src/commands/usage.ts b/src/commands/usage.ts new file mode 100644 index 0000000..7271618 --- /dev/null +++ b/src/commands/usage.ts @@ -0,0 +1,103 @@ +import { fetchProfile, type AxonCodeWindowUsage } from "../auth/auth.js"; +import { getAuthToken, loadSettings } from "../config/settings.js"; + +/** + * `orbcode usage` — print plan usage windows and per-model usage. + * + * Shows the weekly/monthly plan windows and each tracked OSS model's share + * of the shared plan pool as percentages (no credit amounts are exposed by + * the backend for public consumption). + */ + +const BAR_WIDTH = 20; + +function bar(percentage: number): string { + const filled = Math.round( + (Math.max(0, Math.min(100, percentage)) / 100) * BAR_WIDTH, + ); + return "[" + "█".repeat(filled) + " ".repeat(BAR_WIDTH - filled) + "]"; +} + +function formatPercentage(value: number | undefined): string { + return `${Math.max(0, Math.min(100, value || 0)).toFixed(1)}%`; +} + +function formatReset(iso: string | undefined): string { + if (!iso) return ""; + const target = new Date(iso).getTime(); + if (Number.isNaN(target)) return ""; + const diff = target - Date.now(); + if (diff <= 0) return "resets now"; + const sec = Math.floor(diff / 1000); + const min = Math.floor(sec / 60); + const hrs = Math.floor(min / 60); + const days = Math.floor(hrs / 24); + if (days >= 1) return `resets in ${days}d ${hrs % 24}h`; + if (hrs >= 1) return `resets in ${hrs}h ${min % 60}m`; + if (min >= 1) return `resets in ${min}m`; + return "resets soon"; +} + +function printWindow( + label: string, + window: AxonCodeWindowUsage | undefined, +): void { + if (!window) return; + const reset = formatReset(window.resetsAt); + console.log( + ` ${label.padEnd(8)} ${formatPercentage(window.percentage).padStart(6)} used ${bar(window.percentage)} ${reset}`, + ); +} + +/** Handle `orbcode usage`. Returns the process exit code. */ +export async function runUsageCommand(): Promise { + const settings = loadSettings(); + const token = getAuthToken(settings); + if (!token) { + console.error("Not logged in. Run `orbcode login` first."); + return 1; + } + + let profile; + try { + profile = await fetchProfile(token); + } catch (error) { + console.error((error as Error).message); + return 1; + } + + const tiered = profile.tieredUsage; + console.log(`Plan usage${profile.plan ? ` (${profile.plan})` : ""}`); + if (tiered) { + printWindow("Weekly", tiered.weekly); + printWindow("Monthly", tiered.monthly); + } else if (profile.usagePercentage !== undefined) { + printWindow("Monthly", { + used: 0, + limit: 0, + remaining: 0, + percentage: profile.usagePercentage, + resetsAt: profile.creditsResetDate ?? "", + windowStart: "", + }); + } else { + console.log(" No usage data available."); + } + + const modelUsage = profile.modelUsage ?? []; + console.log(""); + if (modelUsage.length === 0) { + console.log("Model usage: none recorded in this cycle yet."); + return 0; + } + + console.log("Model usage (share of the shared plan pool)"); + for (const entry of modelUsage) { + const name = entry.model.padEnd(34); + const multiplier = `${entry.multiplier}x cost`.padEnd(9); + console.log( + ` ${name} ${multiplier} weekly ${formatPercentage(entry.weeklyPercentage).padStart(6)} monthly ${formatPercentage(entry.monthlyPercentage).padStart(6)}`, + ); + } + return 0; +} diff --git a/src/headless.ts b/src/headless.ts index 8db994a..72a3dd6 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1,8 +1,11 @@ import { + AXON_MODELS, + DEFAULT_MODEL_ID, canUse400kContext, canUseEidoBaseModels, canUseEidoProModels, canUseLumenModels, + fetchDynamicModels, getModel, is400kAxonModel, isEidoBaseAxonModel, @@ -24,6 +27,11 @@ export async function runHeadless( systemPromptOverride?: string, ): Promise { const settings = loadSettings() + const token = getAuthToken(settings) + + if (token) { + await fetchDynamicModels(token).catch(() => {}) + } // An unknown --model (or MATTERAI_MODEL) silently resolves to the default; say // so on stderr instead of quietly running a different model than requested. @@ -34,8 +42,6 @@ export async function runHeadless( `Add it under "customModels" in settings.json (with a "provider") to use it.\n`, ) } - - const token = getAuthToken(settings) // MatterAI/Axon models authenticate with the login token. AI-SDK providers // (Anthropic, etc.) authenticate with their own key — resolved by the // provider from the env (e.g. ANTHROPIC_API_KEY) or the model's `apiKey` — diff --git a/src/index.tsx b/src/index.tsx index 536b99a..4455d51 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import { PRODUCT_NAME, VERSION } from "./branding.js" import { runHeadless } from "./headless.js" import { runMcpCommand } from "./commands/mcp.js" import { runPluginCommand } from "./commands/plugin.js" +import { runUsageCommand } from "./commands/usage.js" import { loadSessionById, type SessionData } from "./core/sessions.js" import { loadSettings } from "./config/settings.js" import { @@ -23,6 +24,7 @@ Usage: orbcode start an interactive session orbcode "" start an interactive session with an initial prompt orbcode login sign in to MatterAI + orbcode usage show plan usage windows and per-model usage orbcode update install the latest version from npm orbcode update --force force a global install even if this CLI doesn't look global orbcode mcp add ... add an MCP server (see: orbcode mcp help) @@ -153,6 +155,12 @@ async function main(): Promise { process.exit(code) } + // `orbcode usage` — print plan windows and per-model usage (read-only). + if (args[0] === "usage") { + const code = await runUsageCommand() + process.exit(code) + } + const model = takeFlagValue(args, "model") ?? takeFlagValue(args, "m") if (model) { // loadSettings() treats MATTERAI_MODEL as the highest-precedence override, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ba8cf57..bf81601 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -30,6 +30,7 @@ import { canUseEidoBaseModels, canUseEidoProModels, canUseLumenModels, + fetchDynamicModels, get232kAxonFallback, getModel, is400kAxonModel, @@ -227,6 +228,19 @@ function usageLines(profile: ProfileData): string[] { } else if (profile.creditsResetDate) { lines.push(`Resets ${profile.creditsResetDate}`); } + // Per-model usage: each tracked OSS model's share of the shared plan pool + // (percentages only — the backend never exposes credit amounts publicly). + const modelUsage = profile.modelUsage ?? []; + if (modelUsage.length > 0) { + lines.push("Models share of the shared plan pool"); + for (const entry of modelUsage) { + const weekly = Math.max(0, Math.min(100, entry.weeklyPercentage || 0)); + const monthly = Math.max(0, Math.min(100, entry.monthlyPercentage || 0)); + lines.push( + ` ${entry.model.padEnd(30)} ${entry.multiplier}x cost · wk ${weekly}% · mo ${monthly}%`, + ); + } + } return lines; } @@ -465,10 +479,12 @@ export function App({ const hasEidoProAccess = canUseEidoProModels(activePlan); const hasLumenAccess = canUseLumenModels(activePlan); - // Refresh plan/usage from /axoncode/profile (shown below the chat box). + // Refresh plan/usage from /axoncode/profile (shown below the chat box) + // and sync the dynamic model catalog from /v1/models. const refreshUsage = useCallback(() => { const token = getAuthToken(loadSettings()); if (!token) return; + fetchDynamicModels(token).catch(() => {}); fetchProfile(token) .then((profile) => setUsage({ @@ -1615,6 +1631,7 @@ export function App({ saveSettings(updated); agentRef.current = null; setView("chat"); + fetchDynamicModels(token).catch(() => {}); setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage, diff --git a/src/ui/components/ModelPicker.tsx b/src/ui/components/ModelPicker.tsx index 2325703..d119093 100644 --- a/src/ui/components/ModelPicker.tsx +++ b/src/ui/components/ModelPicker.tsx @@ -6,17 +6,32 @@ import { BUILTIN_AXON_MODELS, isEidoBaseAxonModel, isEidoProAxonModel, isLumenAx import { PopoverBox } from "./PopoverBox.js" const VISIBLE_ROWS = 6 -const CONTEXT_WINDOW_ORDER = [232000, 400000] -// Display order within a context-window group: Auto, Flash, Pro, Code, Lumen. -// Pro must precede Code because `axon-eido-3.2-code-` is a prefix of -// `axon-eido-3.2-code-pro-`, so the more specific prefix needs to win. +const CONTEXT_WINDOW_ORDER = [232000] +// Display order: the default (GLM 5.3 Flash) first, then the rest. const DISPLAY_ORDER = [ - "axon-auto", - "axon-eido-3.2-flash", - "axon-eido-3.2-code-pro", - "axon-eido-3.2-code", - "axon-lumen-4-code", + "zai/glm-5.3-flash", + "zai/glm-5.3", + "deepseek/deepseek-v4-flash-0731", + "meta/muse-spark-1.3-contributor", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gemini-3.8-flash", ] +// Terminals can't render the catalog's SVG provider icons, so rows carry a +// provider badge instead — the TUI equivalent of the webapp's provider logos. +const PROVIDER_LABELS: Array<[prefix: string, label: string]> = [ + ["meta/", "Meta"], + ["deepseek/", "DeepSeek"], + ["zai/", "Z.ai"], + ["gpt-", "OpenAI"], + ["gemini-", "Google"], +] +function providerLabel(modelId: string): string { + for (const [prefix, label] of PROVIDER_LABELS) { + if (modelId.startsWith(prefix)) return label + } + return "" +} function displayRank(modelId: string): number { for (let i = 0; i < DISPLAY_ORDER.length; i += 1) { if (modelId === DISPLAY_ORDER[i] || modelId.startsWith(`${DISPLAY_ORDER[i]}-`)) return i @@ -114,6 +129,7 @@ export function ModelPicker({ currentId, canUse400k, canUseEidoBase, canUseEidoP const isSelected = index === selected const isCurrent = model.id === currentId const locked = isLocked(model) + const providerTag = providerLabel(model.id) // The 400k group header already carries the plan note, so only badge // rows whose lock isn't explained by the context header. const planRestricted = @@ -140,7 +156,8 @@ export function ModelPicker({ currentId, canUse400k, canUseEidoBase, canUseEidoP {isSelected ? "❯ " : " "} - {index + 1}. {displayName(model)} + {index + 1}. {providerTag ? [{providerTag}] : null} + {displayName(model)} {isCurrent && ✓ current} · {formatPrice(model)} {showPlanBadge && · {planBadgeText}} From a838431397f7a5e567f2062a97fffc560c39a2f6 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Thu, 3 Sep 2026 16:24:58 +0530 Subject: [PATCH 4/4] feat(ui): double model picker visible rows to 12 --- src/ui/components/ModelPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/ModelPicker.tsx b/src/ui/components/ModelPicker.tsx index d119093..c7d8c68 100644 --- a/src/ui/components/ModelPicker.tsx +++ b/src/ui/components/ModelPicker.tsx @@ -5,7 +5,7 @@ import { COLORS } from "../../branding.js" import { BUILTIN_AXON_MODELS, isEidoBaseAxonModel, isEidoProAxonModel, isLumenAxonModel, type AxonModel } from "../../api/models.js" import { PopoverBox } from "./PopoverBox.js" -const VISIBLE_ROWS = 6 +const VISIBLE_ROWS = 12 const CONTEXT_WINDOW_ORDER = [232000] // Display order: the default (GLM 5.3 Flash) first, then the rest. const DISPLAY_ORDER = [