From e0baa7a52165c4ca48e91d145a8bb08bda32d3d2 Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Thu, 5 Mar 2026 23:39:05 -0800 Subject: [PATCH 01/14] feat: add WarpGrep AI-powered codebase search tool Add WarpGrep integration as a new tool for intelligent multi-step codebase search. WarpGrep delegates search to Morph's RL-trained search agent, which runs parallel tool calls across multiple turns and returns only relevant code spans. - Add WarpGrep core client with multi-turn API loop, local tool executors (ripgrep, file read, directory list), and XML parsing - Add tool definition gated behind KILO_ENABLE_WARPGREP flag - Register tool in registry with feature flag gating - Add warpgrep permission to orchestrator, ask, and explore agents - Conditionally enhance explore agent prompt when WarpGrep available - Add unit tests for parsing, file ops, and tool registration --- packages/opencode/src/agent/agent.ts | 12 +- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/kilocode/index.ts | 1 + packages/opencode/src/kilocode/warpgrep.ts | 212 ++++++++++++++++++ packages/opencode/src/tool/registry.ts | 6 + packages/opencode/src/tool/warpgrep.ts | 54 +++++ packages/opencode/src/tool/warpgrep.txt | 1 + .../opencode/test/kilocode/warpgrep.test.ts | 184 +++++++++++++++ packages/opencode/test/tool/warpgrep.test.ts | 32 +++ 9 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/kilocode/warpgrep.ts create mode 100644 packages/opencode/src/tool/warpgrep.ts create mode 100644 packages/opencode/src/tool/warpgrep.txt create mode 100644 packages/opencode/test/kilocode/warpgrep.test.ts create mode 100644 packages/opencode/test/tool/warpgrep.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e65d9c9e0d9..00309558e9d 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -16,6 +16,8 @@ import PROMPT_ASK from "./prompt/ask.txt" import PROMPT_ORCHESTRATOR from "./prompt/orchestrator.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" +import { Flag } from "@/flag/flag" // kilocode_change +import { WarpGrep } from "@/kilocode/warpgrep" // kilocode_change import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" @@ -157,6 +159,7 @@ export namespace Agent { webfetch: "allow", websearch: "allow", codesearch: "allow", + warpgrep: "allow", // kilocode_change external_directory: { [Truncate.GLOB]: "allow", }, @@ -189,6 +192,7 @@ export namespace Agent { webfetch: "allow", websearch: "allow", codesearch: "allow", + warpgrep: "allow", // kilocode_change external_directory: { [Truncate.GLOB]: "allow", }, @@ -226,6 +230,7 @@ export namespace Agent { webfetch: "allow", websearch: "allow", codesearch: "allow", + warpgrep: "allow", // kilocode_change read: "allow", external_directory: { "*": "ask", @@ -235,7 +240,12 @@ export namespace Agent { user, ), description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - prompt: PROMPT_EXPLORE, + // kilocode_change start + prompt: + Flag.KILO_ENABLE_WARPGREP && WarpGrep.key() + ? `Prefer using the WarpGrep tool for codebase searches — it performs intelligent multi-step code search and returns the most relevant code spans.\n\n${PROMPT_EXPLORE}` + : PROMPT_EXPLORE, + // kilocode_change end options: {}, mode: "subagent", native: true, diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 6cfc347626d..1adc64fa09f 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -30,6 +30,7 @@ export namespace Flag { export const KILO_SERVER_PASSWORD = process.env["KILO_SERVER_PASSWORD"] export const KILO_SERVER_USERNAME = process.env["KILO_SERVER_USERNAME"] export const KILO_ENABLE_QUESTION_TOOL = truthy("KILO_ENABLE_QUESTION_TOOL") + export const KILO_ENABLE_WARPGREP = truthy("KILO_ENABLE_WARPGREP") // kilocode_change // Experimental export const KILO_EXPERIMENTAL = truthy("KILO_EXPERIMENTAL") diff --git a/packages/opencode/src/kilocode/index.ts b/packages/opencode/src/kilocode/index.ts index e1a741ca489..401d6dccdd4 100644 --- a/packages/opencode/src/kilocode/index.ts +++ b/packages/opencode/src/kilocode/index.ts @@ -5,3 +5,4 @@ export { McpMigrator } from "./mcp-migrator" export { IgnoreMigrator } from "./ignore-migrator" export { KilocodeConfigInjector } from "./config-injector" export { KilocodePaths } from "./paths" +export { WarpGrep } from "./warpgrep" diff --git a/packages/opencode/src/kilocode/warpgrep.ts b/packages/opencode/src/kilocode/warpgrep.ts new file mode 100644 index 00000000000..68fc5d33c7b --- /dev/null +++ b/packages/opencode/src/kilocode/warpgrep.ts @@ -0,0 +1,212 @@ +import { Ripgrep } from "../file/ripgrep" +import { Process } from "../util/process" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import { text } from "node:stream/consumers" +import fs from "node:fs/promises" +import path from "node:path" + +const log = Log.create({ service: "warpgrep" }) + +const API_URL = "https://api.morphllm.com/v1/chat/completions" +const MODEL = "morph-warp-grep-v2" +const MAX_TURNS = 4 +const MAX_TOOL_CALLS_PER_TURN = 8 +const CONTEXT_BUDGET = 160000 + +interface Message { + role: "user" | "assistant" + content: string +} + +interface ToolCall { + name: string + params: string +} + +export namespace WarpGrep { + export function parseToolCalls(content: string): ToolCall[] { + return [...content.matchAll(/([\s\S]*?)<\/function><\/tool_call>/g)].map((m) => ({ + name: m[1], + params: m[2], + })) + } + + export async function readFile(filepath: string, ranges?: string): Promise { + const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(Instance.directory, filepath) + const file = Bun.file(resolved) + const exists = await file.exists() + if (!exists) return `Error: file not found: ${filepath}` + + const content = await file.text() + const lines = content.split("\n") + + if (!ranges) return content + + const parts: string[] = [] + for (const range of ranges.split(",")) { + const trimmed = range.trim() + const [startStr, endStr] = trimmed.split("-") + const start = Math.max(1, parseInt(startStr, 10)) + const end = endStr ? Math.min(lines.length, parseInt(endStr, 10)) : start + parts.push(lines.slice(start - 1, end).join("\n")) + } + return parts.join("\n...\n") + } + + export async function listDirectory(dir: string): Promise { + const resolved = path.isAbsolute(dir) ? dir : path.resolve(Instance.directory, dir) + const entries = await fs.readdir(resolved, { withFileTypes: true }) + return entries.map((e) => (e.isDirectory() ? e.name + "/" : e.name)).join("\n") + } + + async function executeRipgrep(pattern: string, include?: string, signal?: AbortSignal): Promise { + const rg = await Ripgrep.filepath() + const args = [rg, "--no-heading", "--line-number", "--max-count=50", "--hidden", "--glob=!.git/*"] + if (include) args.push(`--glob=${include}`) + args.push("--", pattern, Instance.directory) + + const proc = Process.spawn(args, { + cwd: Instance.directory, + stdout: "pipe", + stderr: "pipe", + abort: signal, + }) + + if (!proc.stdout) return "Error: process output not available" + + const output = await text(proc.stdout) + await proc.exited + return output.trim() || "No matches found" + } + + async function executeTool(call: ToolCall, signal?: AbortSignal): Promise { + try { + if (call.name === "ripgrep") { + const parsed = JSON.parse(call.params) + return executeRipgrep(parsed.pattern, parsed.include, signal) + } + + if (call.name === "read") { + const parsed = JSON.parse(call.params) + return readFile(parsed.path, parsed.lines) + } + + if (call.name === "list_directory") { + const parsed = JSON.parse(call.params) + return listDirectory(parsed.path || ".") + } + + return `Unknown tool: ${call.name}` + } catch (e) { + if (e instanceof SyntaxError) return `Error parsing tool params: ${e.message}` + throw e + } + } + + export async function handleFinish(params: string): Promise { + let parsed: Record + try { + parsed = JSON.parse(params) + } catch { + return `Error parsing finish params: invalid JSON` + } + const files = parsed.files as string + if (!files) return "No files specified" + + const results: string[] = [] + for (const line of files.split("\n")) { + const trimmed = line.trim() + if (!trimmed) continue + + const colonIdx = trimmed.indexOf(":") + if (colonIdx === -1) { + const content = await readFile(trimmed) + results.push(`--- ${trimmed} ---\n${content}`) + continue + } + + const filepath = trimmed.slice(0, colonIdx) + const ranges = trimmed.slice(colonIdx + 1) + const content = await readFile(filepath, ranges) + results.push(`--- ${filepath} ---\n${content}`) + } + + return results.join("\n\n") + } + + export function key(): string | undefined { + return process.env["WARPGREP_API_KEY"] || process.env["MORPH_API_KEY"] + } + + export async function search(query: string, apiKey: string, signal?: AbortSignal): Promise { + const tree = await Ripgrep.tree({ cwd: Instance.directory, limit: 500, signal }) + + const messages: Message[] = [ + { + role: "user", + content: `\n${tree}\n\n\n\n${query}\n`, + }, + ] + + let totalChars = 0 + + for (let turn = 0; turn < MAX_TURNS; turn++) { + log.info("warpgrep turn", { turn: turn + 1, messages: messages.length }) + + const response = await fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: MODEL, + messages, + }), + signal, + }) + + if (!response.ok) { + const err = await response.text() + throw new Error(`WarpGrep API error (${response.status}): ${err}`) + } + + const data: { choices: { message: { content: string } }[] } = await response.json() + const content = data.choices[0]?.message?.content + if (!content) throw new Error("WarpGrep API returned empty response") + + messages.push({ role: "assistant", content }) + + const calls = parseToolCalls(content) + if (calls.length === 0) return content + + const bounded = calls.slice(0, MAX_TOOL_CALLS_PER_TURN) + + // Handle finish immediately if present + const finish = bounded.find((c) => c.name === "finish") + if (finish) return handleFinish(finish.params) + + // Execute tool calls in parallel + const results = await Promise.all(bounded.map((call) => executeTool(call, signal))) + + const responses: string[] = [] + for (const result of results) { + totalChars += result.length + const truncated = + totalChars > CONTEXT_BUDGET + ? result.slice(0, Math.max(0, CONTEXT_BUDGET - totalChars + result.length)) + "\n[truncated]" + : result + responses.push(`\n${truncated}\n`) + } + + const turnInfo = `[Turn ${turn + 1}/${MAX_TURNS}]` + messages.push({ + role: "user", + content: `${responses.join("\n")}\n${turnInfo}`, + }) + } + + return messages[messages.length - 1]?.content || "WarpGrep search completed without results" + } +} diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f1cbf280f24..caf5934b68a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,6 +22,7 @@ import z from "zod" import { Plugin } from "../plugin" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" +import { WarpGrepTool } from "./warpgrep" // kilocode_change import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" @@ -115,6 +116,7 @@ export namespace ToolRegistry { // TodoReadTool, WebSearchTool, CodeSearchTool, + WarpGrepTool, // kilocode_change SkillTool, ApplyPatchTool, ...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), @@ -146,6 +148,10 @@ export namespace ToolRegistry { } // kilocode_change end + // kilocode_change start + if (t.id === "warpgrep") return Flag.KILO_ENABLE_WARPGREP + // kilocode_change end + // use apply tool in same format as codex const usePatch = model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") diff --git a/packages/opencode/src/tool/warpgrep.ts b/packages/opencode/src/tool/warpgrep.ts new file mode 100644 index 00000000000..c8eedbd0ff8 --- /dev/null +++ b/packages/opencode/src/tool/warpgrep.ts @@ -0,0 +1,54 @@ +import z from "zod" +import { Tool } from "./tool" +import { WarpGrep } from "../kilocode/warpgrep" +import { abortAfterAny } from "../util/abort" +import DESCRIPTION from "./warpgrep.txt" + +export const WarpGrepTool = Tool.define("warpgrep", { + description: DESCRIPTION, + parameters: z.object({ + query: z + .string() + .describe("Search query describing what code you are looking for. Be specific and descriptive for best results."), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "warpgrep", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + }, + }) + + const apiKey = WarpGrep.key() + if (!apiKey) { + return { + title: `WarpGrep: ${params.query}`, + output: "Set MORPH_API_KEY or WARPGREP_API_KEY environment variable to use WarpGrep", + metadata: {}, + } + } + + const { signal, clearTimeout } = abortAfterAny(60000, ctx.abort) + + try { + const result = await WarpGrep.search(params.query, apiKey, signal) + + clearTimeout() + + return { + title: `WarpGrep: ${params.query}`, + output: result, + metadata: {}, + } + } catch (error) { + clearTimeout() + + if (error instanceof Error && error.name === "AbortError") { + throw new Error("WarpGrep search request timed out") + } + throw error + } + }, +}) diff --git a/packages/opencode/src/tool/warpgrep.txt b/packages/opencode/src/tool/warpgrep.txt new file mode 100644 index 00000000000..477b9af3fda --- /dev/null +++ b/packages/opencode/src/tool/warpgrep.txt @@ -0,0 +1 @@ +Search the codebase using WarpGrep, an AI-powered code search agent. WarpGrep performs intelligent multi-step searches across the repository using parallel tool calls, returning only the most relevant code spans. Use this for complex semantic queries where simple grep patterns would be insufficient. Requires MORPH_API_KEY or WARPGREP_API_KEY. \ No newline at end of file diff --git a/packages/opencode/test/kilocode/warpgrep.test.ts b/packages/opencode/test/kilocode/warpgrep.test.ts new file mode 100644 index 00000000000..9f379f44433 --- /dev/null +++ b/packages/opencode/test/kilocode/warpgrep.test.ts @@ -0,0 +1,184 @@ +import { describe, test, expect } from "bun:test" +import { WarpGrep } from "../../src/kilocode/warpgrep" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" +import { Instance } from "../../src/project/instance" + +describe("WarpGrep", () => { + describe("parseToolCalls", () => { + test("parses single tool call", () => { + const xml = `{"pattern": "foo", "include": "*.ts"}` + const calls = WarpGrep.parseToolCalls(xml) + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe("ripgrep") + expect(JSON.parse(calls[0].params)).toEqual({ pattern: "foo", include: "*.ts" }) + }) + + test("parses multiple tool calls", () => { + const xml = [ + `{"pattern": "foo"}`, + `{"path": "src/main.ts"}`, + ].join("\n") + const calls = WarpGrep.parseToolCalls(xml) + expect(calls).toHaveLength(2) + expect(calls[0].name).toBe("ripgrep") + expect(calls[1].name).toBe("read") + }) + + test("parses finish tool call", () => { + const xml = `{"files": "src/main.ts:1-10"}` + const calls = WarpGrep.parseToolCalls(xml) + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe("finish") + expect(JSON.parse(calls[0].params).files).toBe("src/main.ts:1-10") + }) + + test("returns empty array for no tool calls", () => { + const calls = WarpGrep.parseToolCalls("Just some text without any tool calls") + expect(calls).toHaveLength(0) + }) + + test("returns empty array for malformed XML", () => { + const calls = WarpGrep.parseToolCalls("unclosed") + expect(calls).toHaveLength(0) + }) + }) + + describe("readFile", () => { + test("reads full file when no ranges specified", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "test.txt") + await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const content = await WarpGrep.readFile(filepath) + expect(content).toBe("line1\nline2\nline3\nline4\nline5") + }, + }) + }) + + test("reads single range", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "test.txt") + await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const content = await WarpGrep.readFile(filepath, "2-4") + expect(content).toBe("line2\nline3\nline4") + }, + }) + }) + + test("reads multiple ranges", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "test.txt") + await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const content = await WarpGrep.readFile(filepath, "1-2,4-5") + expect(content).toBe("line1\nline2\n...\nline4\nline5") + }, + }) + }) + + test("returns error for file not found", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const content = await WarpGrep.readFile(path.join(tmp.path, "nonexistent.txt")) + expect(content).toContain("Error: file not found") + }, + }) + }) + }) + + describe("listDirectory", () => { + test("lists directory contents", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "file1.ts"), "content1") + await Bun.write(path.join(dir, "file2.ts"), "content2") + await fs.mkdir(path.join(dir, "subdir")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await WarpGrep.listDirectory(tmp.path) + expect(result).toContain("file1.ts") + expect(result).toContain("file2.ts") + expect(result).toContain("subdir/") + }, + }) + }) + + test("returns error for nonexistent directory", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(WarpGrep.listDirectory(path.join(tmp.path, "nonexistent"))).rejects.toThrow() + }, + }) + }) + }) + + describe("handleFinish", () => { + test("reads single file with ranges", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "src.ts") + await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await WarpGrep.handleFinish(JSON.stringify({ files: `${filepath}:1-3` })) + expect(result).toContain("--- " + filepath + " ---") + expect(result).toContain("line1\nline2\nline3") + }, + }) + }) + + test("reads multiple files", async () => { + await using tmp = await tmpdir() + const file1 = path.join(tmp.path, "a.ts") + const file2 = path.join(tmp.path, "b.ts") + await Bun.write(file1, "alpha\nbeta") + await Bun.write(file2, "gamma\ndelta") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await WarpGrep.handleFinish(JSON.stringify({ files: `${file1}:1-2\n${file2}:1-2` })) + expect(result).toContain("alpha") + expect(result).toContain("gamma") + }, + }) + }) + + test("reads whole file when no range specified", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "whole.ts") + await Bun.write(filepath, "entire\nfile\ncontent") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await WarpGrep.handleFinish(JSON.stringify({ files: filepath })) + expect(result).toContain("entire\nfile\ncontent") + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/tool/warpgrep.test.ts b/packages/opencode/test/tool/warpgrep.test.ts new file mode 100644 index 00000000000..27dee89f6d6 --- /dev/null +++ b/packages/opencode/test/tool/warpgrep.test.ts @@ -0,0 +1,32 @@ +import { describe, test, expect } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { ToolRegistry } from "../../src/tool/registry" + +describe("WarpGrepTool", () => { + // KILO_ENABLE_WARPGREP is a compile-time const in Flag — setting process.env + // at runtime does NOT change it. We test that the tool definition is always + // registered in the full list, and that tools() filters it based on the flag. + test("tool definition is always registered", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ids = await ToolRegistry.ids() + expect(ids).toContain("warpgrep") + }, + }) + }) + + test("tool is filtered by tools() when flag is not set", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tools = await ToolRegistry.tools({ providerID: "test", modelID: "test" }) + const ids = tools.map((t) => t.id) + expect(ids).not.toContain("warpgrep") + }, + }) + }) +}) From 5c623aafcc13d42abf34045634ca56d3c953defc Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Fri, 6 Mar 2026 00:29:22 -0800 Subject: [PATCH 02/14] fix: align warpgrep with Morph XML protocol --- packages/opencode/src/kilocode/warpgrep.ts | 400 +++++++++++++----- .../opencode/test/kilocode/warpgrep.test.ts | 286 ++++++------- 2 files changed, 435 insertions(+), 251 deletions(-) diff --git a/packages/opencode/src/kilocode/warpgrep.ts b/packages/opencode/src/kilocode/warpgrep.ts index 68fc5d33c7b..b250c795c2f 100644 --- a/packages/opencode/src/kilocode/warpgrep.ts +++ b/packages/opencode/src/kilocode/warpgrep.ts @@ -11,8 +11,10 @@ const log = Log.create({ service: "warpgrep" }) const API_URL = "https://api.morphllm.com/v1/chat/completions" const MODEL = "morph-warp-grep-v2" const MAX_TURNS = 4 -const MAX_TOOL_CALLS_PER_TURN = 8 -const CONTEXT_BUDGET = 160000 +const MAX_OUTPUT_LINES = 200 +const MAX_READ_LINES = 800 +const MAX_CONTEXT_CHARS = 540_000 +const TRUNCATED_MARKER = "[truncated for context limit]" interface Message { role: "user" | "assistant" @@ -21,118 +23,306 @@ interface Message { interface ToolCall { name: string - params: string + arguments: Record +} + +interface FinishFile { + path: string + lines: "*" | Array<[number, number]> } export namespace WarpGrep { export function parseToolCalls(content: string): ToolCall[] { - return [...content.matchAll(/([\s\S]*?)<\/function><\/tool_call>/g)].map((m) => ({ - name: m[1], - params: m[2], - })) + const stripped = content.replace(/[\s\S]*?<\/think>/gi, "") + const calls: ToolCall[] = [] + const regex = /\s*([\s\S]*?)<\/function>\s*<\/tool_call>/gi + let match + + while ((match = regex.exec(stripped)) !== null) { + const name = match[1].toLowerCase() + const body = match[2] + + const params: Record = {} + const paramRegex = /([\s\S]*?)<\/parameter>/gi + let paramMatch + while ((paramMatch = paramRegex.exec(body)) !== null) { + params[paramMatch[1].toLowerCase()] = paramMatch[2].trim() + } + + if (name === "ripgrep") { + if (!params.pattern) continue + calls.push({ + name: "grep", + arguments: { + pattern: params.pattern, + path: params.path || ".", + ...(params.glob && { glob: params.glob }), + ...(params.context_lines && { context_lines: parseInt(params.context_lines, 10) }), + ...(params.case_sensitive && { case_sensitive: params.case_sensitive === "true" }), + }, + }) + } else if (name === "list_directory") { + calls.push({ name: "list_directory", arguments: { path: params.path || "." } }) + } else if (name === "read") { + if (!params.path) continue + const args: Record = { path: params.path } + if (params.lines) { + const ranges: Array<[number, number]> = [] + for (const r of params.lines.split(",")) { + const t = r.trim() + if (!t) continue + const [s, e] = t.split("-").map((v) => parseInt(v.trim(), 10)) + if (Number.isFinite(s) && Number.isFinite(e)) ranges.push([s, e]) + else if (Number.isFinite(s)) ranges.push([s, s]) + } + if (ranges.length === 1) { + args.start = ranges[0][0] + args.end = ranges[0][1] + } else if (ranges.length > 1) { + args.lines = ranges + } + } + calls.push({ name: "read", arguments: args }) + } else if (name === "finish") { + if (params.result && !params.files) { + calls.push({ name: "finish", arguments: { files: [], textResult: params.result } }) + continue + } + if (!params.files) { + calls.push({ name: "finish", arguments: { files: [], textResult: "No relevant code found." } }) + continue + } + const files: FinishFile[] = [] + for (const line of params.files.split("\n")) { + const trimmed = line.trim() + if (!trimmed) continue + const colonIdx = trimmed.indexOf(":") + if (colonIdx === -1) { + files.push({ path: trimmed, lines: "*" }) + continue + } + const fp = trimmed.slice(0, colonIdx) + const rangesPart = trimmed.slice(colonIdx + 1) + const ranges: Array<[number, number]> = [] + let whole = false + for (const r of rangesPart.split(",")) { + const rt = r.trim() + if (!rt || rt === "*") { + whole = true + break + } + const [s, e] = rt.split("-").map((v) => parseInt(v.trim(), 10)) + if (Number.isFinite(s) && Number.isFinite(e)) ranges.push([s, e]) + else if (Number.isFinite(s)) ranges.push([s, s]) + } + if (whole) files.push({ path: fp, lines: "*" }) + else if (ranges.length > 0) files.push({ path: fp, lines: ranges }) + else if (!files.some((f) => f.path === fp)) files.push({ path: fp, lines: "*" }) + } + calls.push({ + name: "finish", + arguments: files.length > 0 ? { files } : { files: [], textResult: params.files }, + }) + } + } + + return calls } - export async function readFile(filepath: string, ranges?: string): Promise { - const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(Instance.directory, filepath) - const file = Bun.file(resolved) - const exists = await file.exists() - if (!exists) return `Error: file not found: ${filepath}` + function resolvePath(filepath: string): string { + return path.isAbsolute(filepath) ? filepath : path.resolve(Instance.directory, filepath) + } - const content = await file.text() - const lines = content.split("\n") + function formatTurnMessage(turn: number): string { + const remaining = MAX_TURNS - turn + if (remaining === 1) + return `\nYou have used ${turn} turns, you only have 1 turn remaining. You have run out of turns to explore the code base and MUST call the finish tool now` + return `\nYou have used ${turn} turn${turn === 1 ? "" : "s"} and have ${remaining} remaining` + } - if (!ranges) return content + function calcContextBudget(messages: Message[]): string { + const total = messages.reduce((sum, m) => sum + m.content.length, 0) + const percent = Math.round((total / MAX_CONTEXT_CHARS) * 100) + const usedK = Math.round(total / 1000) + const maxK = Math.round(MAX_CONTEXT_CHARS / 1000) + return `${percent}% (${usedK}K/${maxK}K chars)` + } - const parts: string[] = [] - for (const range of ranges.split(",")) { - const trimmed = range.trim() - const [startStr, endStr] = trimmed.split("-") - const start = Math.max(1, parseInt(startStr, 10)) - const end = endStr ? Math.min(lines.length, parseInt(endStr, 10)) : start - parts.push(lines.slice(start - 1, end).join("\n")) + function enforceContextLimit(messages: Message[]) { + const total = () => messages.reduce((sum, m) => sum + m.content.length, 0) + if (total() <= MAX_CONTEXT_CHARS) return + let first = true + for (let i = 0; i < messages.length; i++) { + if (messages[i].role !== "user") continue + if (first) { + first = false + continue + } + if (total() <= MAX_CONTEXT_CHARS) break + if (messages[i].content !== TRUNCATED_MARKER) messages[i] = { role: "user", content: TRUNCATED_MARKER } } - return parts.join("\n...\n") } - export async function listDirectory(dir: string): Promise { - const resolved = path.isAbsolute(dir) ? dir : path.resolve(Instance.directory, dir) - const entries = await fs.readdir(resolved, { withFileTypes: true }) - return entries.map((e) => (e.isDirectory() ? e.name + "/" : e.name)).join("\n") - } + async function execGrep(args: Record, signal?: AbortSignal): Promise { + const pattern = args.pattern as string + const target = (args.path as string) || "." + const glob = args.glob as string | undefined + const ctx = args.context_lines !== undefined ? String(args.context_lines) : "1" - async function executeRipgrep(pattern: string, include?: string, signal?: AbortSignal): Promise { const rg = await Ripgrep.filepath() - const args = [rg, "--no-heading", "--line-number", "--max-count=50", "--hidden", "--glob=!.git/*"] - if (include) args.push(`--glob=${include}`) - args.push("--", pattern, Instance.directory) + const cmd = [ + rg, + "--no-heading", + "--with-filename", + "--line-number", + "--color=never", + "--trim", + "--max-columns=400", + "-C", + ctx, + ...(args.case_sensitive === false ? ["--ignore-case"] : []), + ...(glob ? ["--glob", glob] : []), + "-g", + "!.git", + "-g", + "!node_modules", + "-g", + "!dist", + "-g", + "!build", + "-g", + "!*.min.js", + "-g", + "!*.min.css", + "-g", + "!*.map", + "-g", + "!*.lock", + "-g", + "!bun.lockb", + pattern, + target, + ] - const proc = Process.spawn(args, { + const proc = Process.spawn(cmd, { cwd: Instance.directory, stdout: "pipe", stderr: "pipe", abort: signal, }) - - if (!proc.stdout) return "Error: process output not available" + if (!proc.stdout) return "no matches" const output = await text(proc.stdout) await proc.exited - return output.trim() || "No matches found" + + const lines = output.trim().split("\n").filter(Boolean) + if (lines.length === 0) return "no matches" + if (lines.length > MAX_OUTPUT_LINES) { + const truncated = lines.slice(0, MAX_OUTPUT_LINES) + truncated.push(`... (output truncated at ${MAX_OUTPUT_LINES} of ${lines.length} lines)`) + return truncated.join("\n") + } + return lines.join("\n") } - async function executeTool(call: ToolCall, signal?: AbortSignal): Promise { - try { - if (call.name === "ripgrep") { - const parsed = JSON.parse(call.params) - return executeRipgrep(parsed.pattern, parsed.include, signal) + async function execRead(args: Record): Promise { + const filepath = args.path as string + const resolved = resolvePath(filepath) + const file = Bun.file(resolved) + if (!(await file.exists())) return `[FILE NOT FOUND] No file at "${filepath}"` + + const allLines = (await file.text()).split("\n") + const total = allLines.length + + const ranges = args.lines as Array<[number, number]> | undefined + if (ranges && Array.isArray(ranges)) { + const chunks: string[] = [] + for (const [s, e] of ranges) { + const start = Math.max(1, s) + const end = Math.min(total, e) + const out: string[] = [] + for (let i = start; i <= end; i++) out.push(`${i}|${allLines[i - 1] ?? ""}`) + chunks.push(out.join("\n")) } + const result = chunks.join("\n...\n") + const resultLines = result.split("\n") + if (resultLines.length > MAX_READ_LINES) + return resultLines.slice(0, MAX_READ_LINES).join("\n") + `\n... (truncated at ${MAX_READ_LINES} lines)` + return result + } - if (call.name === "read") { - const parsed = JSON.parse(call.params) - return readFile(parsed.path, parsed.lines) - } + const start = Math.max(1, (args.start as number) ?? 1) + const end = Math.min(total, (args.end as number) ?? total) + const out: string[] = [] + for (let i = start; i <= end; i++) out.push(`${i}|${allLines[i - 1] ?? ""}`) + if (out.length > MAX_READ_LINES) + return out.slice(0, MAX_READ_LINES).join("\n") + `\n... (truncated at ${MAX_READ_LINES} lines)` + return out.join("\n") + } - if (call.name === "list_directory") { - const parsed = JSON.parse(call.params) - return listDirectory(parsed.path || ".") - } + async function execListDir(args: Record): Promise { + const dir = (args.path as string) || "." + const resolved = resolvePath(dir) + const stat = await fs.stat(resolved).catch(() => null) + if (!stat?.isDirectory()) return "empty" - return `Unknown tool: ${call.name}` - } catch (e) { - if (e instanceof SyntaxError) return `Error parsing tool params: ${e.message}` - throw e + const entries = await fs.readdir(resolved, { withFileTypes: true }) + const lines = entries + .filter((e) => !e.name.startsWith(".") && !["node_modules", "dist", "build", "__pycache__"].includes(e.name)) + .map((e) => (e.isDirectory() ? e.name + "/" : e.name)) + if (lines.length === 0) return "empty" + if (lines.length > MAX_OUTPUT_LINES) { + return lines.slice(0, MAX_OUTPUT_LINES).join("\n") + `\n... (truncated at ${MAX_OUTPUT_LINES} entries)` } + return lines.join("\n") } - export async function handleFinish(params: string): Promise { - let parsed: Record + async function executeTool(call: ToolCall, signal?: AbortSignal): Promise { try { - parsed = JSON.parse(params) - } catch { - return `Error parsing finish params: invalid JSON` + if (call.name === "grep") return await execGrep(call.arguments, signal) + if (call.name === "read") return await execRead(call.arguments) + if (call.name === "list_directory") return await execListDir(call.arguments) + return "" + } catch (e) { + return e instanceof Error ? e.message : String(e) + } + } + + async function resolveFinish(files: FinishFile[]): Promise { + const parts: string[] = ["Morph Fast Context subagent performed search on repository:\n", "Relevant context found:"] + for (const f of files) { + const rangeStr = f.lines === "*" ? "*" : f.lines.map(([s, e]) => `${s}-${e}`).join(",") + parts.push(`- ${f.path}:${rangeStr}`) } - const files = parsed.files as string - if (!files) return "No files specified" - - const results: string[] = [] - for (const line of files.split("\n")) { - const trimmed = line.trim() - if (!trimmed) continue - - const colonIdx = trimmed.indexOf(":") - if (colonIdx === -1) { - const content = await readFile(trimmed) - results.push(`--- ${trimmed} ---\n${content}`) + parts.push("\nHere is the content of files:\n") + + for (const f of files) { + const resolved = resolvePath(f.path) + const file = Bun.file(resolved) + if (!(await file.exists())) { + parts.push(``, `[couldn't find: ${f.path}]`, "\n") continue } - const filepath = trimmed.slice(0, colonIdx) - const ranges = trimmed.slice(colonIdx + 1) - const content = await readFile(filepath, ranges) - results.push(`--- ${filepath} ---\n${content}`) + const allLines = (await file.text()).split("\n") + if (f.lines === "*") { + parts.push(``, allLines.join("\n"), "\n") + continue + } + + const rangeStr = f.lines.map(([s, e]) => `${s}-${e}`).join(",") + parts.push(``) + const chunks: string[] = [] + for (let i = 0; i < f.lines.length; i++) { + const [s, e] = f.lines[i] + if (i === 0 && s > 1) chunks.push(`// ... existing code, block starting at line ${s} ...`) + else if (i > 0) chunks.push(`// ... existing code, block starting at line ${s} ...`) + chunks.push(allLines.slice(Math.max(0, s - 1), Math.min(allLines.length, e)).join("\n")) + } + parts.push(chunks.join("\n"), "\n") } - return results.join("\n\n") + return parts.join("\n") } export function key(): string | undefined { @@ -141,18 +331,16 @@ export namespace WarpGrep { export async function search(query: string, apiKey: string, signal?: AbortSignal): Promise { const tree = await Ripgrep.tree({ cwd: Instance.directory, limit: 500, signal }) + const messages: Message[] = [] + const budget = calcContextBudget(messages) + messages.push({ + role: "user", + content: `\n${tree}\n\n\n\n${query}\n\n${budget}\nTurn 0/${MAX_TURNS}`, + }) - const messages: Message[] = [ - { - role: "user", - content: `\n${tree}\n\n\n\n${query}\n`, - }, - ] - - let totalChars = 0 - - for (let turn = 0; turn < MAX_TURNS; turn++) { - log.info("warpgrep turn", { turn: turn + 1, messages: messages.length }) + for (let turn = 1; turn <= MAX_TURNS; turn++) { + log.info("warpgrep turn", { turn, messages: messages.length }) + enforceContextLimit(messages) const response = await fetch(API_URL, { method: "POST", @@ -162,6 +350,8 @@ export namespace WarpGrep { }, body: JSON.stringify({ model: MODEL, + temperature: 0, + max_tokens: 2048, messages, }), signal, @@ -181,32 +371,26 @@ export namespace WarpGrep { const calls = parseToolCalls(content) if (calls.length === 0) return content - const bounded = calls.slice(0, MAX_TOOL_CALLS_PER_TURN) - - // Handle finish immediately if present - const finish = bounded.find((c) => c.name === "finish") - if (finish) return handleFinish(finish.params) + const finishCall = calls.find((c) => c.name === "finish") + if (finishCall) { + const { files, textResult } = finishCall.arguments as { files: FinishFile[]; textResult?: string } + if (!files || files.length === 0) return textResult || "No relevant code found." + return resolveFinish(files) + } - // Execute tool calls in parallel - const results = await Promise.all(bounded.map((call) => executeTool(call, signal))) + const results = await Promise.all(calls.map((call) => executeTool(call, signal))) + const responses = results + .map((r) => r.trim()) + .filter(Boolean) + .map((r) => `\n${r}\n`) - const responses: string[] = [] - for (const result of results) { - totalChars += result.length - const truncated = - totalChars > CONTEXT_BUDGET - ? result.slice(0, Math.max(0, CONTEXT_BUDGET - totalChars + result.length)) + "\n[truncated]" - : result - responses.push(`\n${truncated}\n`) + if (responses.length > 0) { + const turnMsg = formatTurnMessage(turn) + const ctxBudget = calcContextBudget(messages) + messages.push({ role: "user", content: responses.join("\n") + turnMsg + "\n" + ctxBudget }) } - - const turnInfo = `[Turn ${turn + 1}/${MAX_TURNS}]` - messages.push({ - role: "user", - content: `${responses.join("\n")}\n${turnInfo}`, - }) } - return messages[messages.length - 1]?.content || "WarpGrep search completed without results" + return "WarpGrep search completed without results" } } diff --git a/packages/opencode/test/kilocode/warpgrep.test.ts b/packages/opencode/test/kilocode/warpgrep.test.ts index 9f379f44433..f7b0e7302d9 100644 --- a/packages/opencode/test/kilocode/warpgrep.test.ts +++ b/packages/opencode/test/kilocode/warpgrep.test.ts @@ -1,184 +1,184 @@ import { describe, test, expect } from "bun:test" import { WarpGrep } from "../../src/kilocode/warpgrep" -import { tmpdir } from "../fixture/fixture" -import path from "path" -import fs from "fs/promises" -import { Instance } from "../../src/project/instance" describe("WarpGrep", () => { describe("parseToolCalls", () => { - test("parses single tool call", () => { - const xml = `{"pattern": "foo", "include": "*.ts"}` + test("parses ripgrep call with XML parameters", () => { + const xml = ` + +authenticate +src/ + +` const calls = WarpGrep.parseToolCalls(xml) expect(calls).toHaveLength(1) - expect(calls[0].name).toBe("ripgrep") - expect(JSON.parse(calls[0].params)).toEqual({ pattern: "foo", include: "*.ts" }) + expect(calls[0].name).toBe("grep") + expect(calls[0].arguments).toEqual({ pattern: "authenticate", path: "src/" }) + }) + + test("parses ripgrep with optional params", () => { + const xml = ` + +foo|bar +src/ +*.ts +3 + +` + const calls = WarpGrep.parseToolCalls(xml) + expect(calls).toHaveLength(1) + expect(calls[0].arguments).toEqual({ + pattern: "foo|bar", + path: "src/", + glob: "*.ts", + context_lines: 3, + }) }) test("parses multiple tool calls", () => { - const xml = [ - `{"pattern": "foo"}`, - `{"path": "src/main.ts"}`, - ].join("\n") + const xml = ` + +authenticate +src/ + + + + + +src/auth/login.ts +1-50 + + + + + +src/auth + +` const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(2) - expect(calls[0].name).toBe("ripgrep") + expect(calls).toHaveLength(3) + expect(calls[0].name).toBe("grep") expect(calls[1].name).toBe("read") + expect(calls[1].arguments).toEqual({ path: "src/auth/login.ts", start: 1, end: 50 }) + expect(calls[2].name).toBe("list_directory") + expect(calls[2].arguments).toEqual({ path: "src/auth" }) }) - test("parses finish tool call", () => { - const xml = `{"files": "src/main.ts:1-10"}` + test("parses finish with file ranges", () => { + const xml = ` + +src/auth/login.ts:1-50,80-120 +src/middleware/auth.ts:* + +` const calls = WarpGrep.parseToolCalls(xml) expect(calls).toHaveLength(1) expect(calls[0].name).toBe("finish") - expect(JSON.parse(calls[0].params).files).toBe("src/main.ts:1-10") + const args = calls[0].arguments as { files: Array<{ path: string; lines: unknown }> } + expect(args.files).toHaveLength(2) + expect(args.files[0].path).toBe("src/auth/login.ts") + expect(args.files[0].lines).toEqual([[1, 50], [80, 120]]) + expect(args.files[1].path).toBe("src/middleware/auth.ts") + expect(args.files[1].lines).toBe("*") }) - test("returns empty array for no tool calls", () => { - const calls = WarpGrep.parseToolCalls("Just some text without any tool calls") - expect(calls).toHaveLength(0) - }) - - test("returns empty array for malformed XML", () => { - const calls = WarpGrep.parseToolCalls("unclosed") - expect(calls).toHaveLength(0) + test("parses finish with text result fallback", () => { + const xml = ` + +No relevant authentication code found in this repository. + +` + const calls = WarpGrep.parseToolCalls(xml) + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe("finish") + expect((calls[0].arguments as { textResult: string }).textResult).toContain("No relevant authentication") }) - }) - describe("readFile", () => { - test("reads full file when no ranges specified", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const content = await WarpGrep.readFile(filepath) - expect(content).toBe("line1\nline2\nline3\nline4\nline5") - }, + test("parses read with multiple line ranges", () => { + const xml = ` + +src/main.ts +1-20,45-80 + +` + const calls = WarpGrep.parseToolCalls(xml) + expect(calls).toHaveLength(1) + expect(calls[0].arguments).toEqual({ + path: "src/main.ts", + lines: [[1, 20], [45, 80]], }) }) - test("reads single range", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const content = await WarpGrep.readFile(filepath, "2-4") - expect(content).toBe("line2\nline3\nline4") - }, - }) - }) + test("strips think blocks before parsing", () => { + const xml = ` +Looking for auth middleware. Let me search for relevant patterns. + - test("reads multiple ranges", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const content = await WarpGrep.readFile(filepath, "1-2,4-5") - expect(content).toBe("line1\nline2\n...\nline4\nline5") - }, - }) + + +middleware + +` + const calls = WarpGrep.parseToolCalls(xml) + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe("grep") }) - test("returns error for file not found", async () => { - await using tmp = await tmpdir() - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const content = await WarpGrep.readFile(path.join(tmp.path, "nonexistent.txt")) - expect(content).toContain("Error: file not found") - }, - }) + test("returns empty array for no tool calls", () => { + expect(WarpGrep.parseToolCalls("Just some text")).toHaveLength(0) }) - }) - describe("listDirectory", () => { - test("lists directory contents", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "file1.ts"), "content1") - await Bun.write(path.join(dir, "file2.ts"), "content2") - await fs.mkdir(path.join(dir, "subdir")) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await WarpGrep.listDirectory(tmp.path) - expect(result).toContain("file1.ts") - expect(result).toContain("file2.ts") - expect(result).toContain("subdir/") - }, - }) + test("returns empty array for malformed XML", () => { + expect(WarpGrep.parseToolCalls("unclosed")).toHaveLength(0) }) - test("returns error for nonexistent directory", async () => { - await using tmp = await tmpdir() + test("skips ripgrep without pattern", () => { + const xml = ` + +src/ + +` + expect(WarpGrep.parseToolCalls(xml)).toHaveLength(0) + }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(WarpGrep.listDirectory(path.join(tmp.path, "nonexistent"))).rejects.toThrow() - }, - }) + test("skips read without path", () => { + const xml = ` + +1-50 + +` + expect(WarpGrep.parseToolCalls(xml)).toHaveLength(0) }) - }) - describe("handleFinish", () => { - test("reads single file with ranges", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "src.ts") - await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await WarpGrep.handleFinish(JSON.stringify({ files: `${filepath}:1-3` })) - expect(result).toContain("--- " + filepath + " ---") - expect(result).toContain("line1\nline2\nline3") - }, - }) + test("defaults ripgrep path to .", () => { + const xml = ` + +foo + +` + const calls = WarpGrep.parseToolCalls(xml) + expect(calls[0].arguments.path).toBe(".") }) - test("reads multiple files", async () => { - await using tmp = await tmpdir() - const file1 = path.join(tmp.path, "a.ts") - const file2 = path.join(tmp.path, "b.ts") - await Bun.write(file1, "alpha\nbeta") - await Bun.write(file2, "gamma\ndelta") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await WarpGrep.handleFinish(JSON.stringify({ files: `${file1}:1-2\n${file2}:1-2` })) - expect(result).toContain("alpha") - expect(result).toContain("gamma") - }, - }) + test("defaults list_directory path to .", () => { + const xml = ` + + +` + const calls = WarpGrep.parseToolCalls(xml) + expect(calls).toHaveLength(1) + expect(calls[0].arguments.path).toBe(".") }) - test("reads whole file when no range specified", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "whole.ts") - await Bun.write(filepath, "entire\nfile\ncontent") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await WarpGrep.handleFinish(JSON.stringify({ files: filepath })) - expect(result).toContain("entire\nfile\ncontent") - }, - }) + test("parses finish with whole file (no colon)", () => { + const xml = ` + +src/main.ts + +` + const calls = WarpGrep.parseToolCalls(xml) + const args = calls[0].arguments as { files: Array<{ path: string; lines: unknown }> } + expect(args.files[0]).toEqual({ path: "src/main.ts", lines: "*" }) }) }) }) From 72b2bab91dcce2bd88de3b5b98de8fc75448bb0c Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Fri, 6 Mar 2026 00:29:39 -0800 Subject: [PATCH 03/14] chore: drop warpgrep test changes from branch --- .../opencode/test/kilocode/warpgrep.test.ts | 286 +++++++++--------- 1 file changed, 143 insertions(+), 143 deletions(-) diff --git a/packages/opencode/test/kilocode/warpgrep.test.ts b/packages/opencode/test/kilocode/warpgrep.test.ts index f7b0e7302d9..9f379f44433 100644 --- a/packages/opencode/test/kilocode/warpgrep.test.ts +++ b/packages/opencode/test/kilocode/warpgrep.test.ts @@ -1,184 +1,184 @@ import { describe, test, expect } from "bun:test" import { WarpGrep } from "../../src/kilocode/warpgrep" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" +import { Instance } from "../../src/project/instance" describe("WarpGrep", () => { describe("parseToolCalls", () => { - test("parses ripgrep call with XML parameters", () => { - const xml = ` - -authenticate -src/ - -` + test("parses single tool call", () => { + const xml = `{"pattern": "foo", "include": "*.ts"}` const calls = WarpGrep.parseToolCalls(xml) expect(calls).toHaveLength(1) - expect(calls[0].name).toBe("grep") - expect(calls[0].arguments).toEqual({ pattern: "authenticate", path: "src/" }) - }) - - test("parses ripgrep with optional params", () => { - const xml = ` - -foo|bar -src/ -*.ts -3 - -` - const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(1) - expect(calls[0].arguments).toEqual({ - pattern: "foo|bar", - path: "src/", - glob: "*.ts", - context_lines: 3, - }) + expect(calls[0].name).toBe("ripgrep") + expect(JSON.parse(calls[0].params)).toEqual({ pattern: "foo", include: "*.ts" }) }) test("parses multiple tool calls", () => { - const xml = ` - -authenticate -src/ - - - - - -src/auth/login.ts -1-50 - - - - - -src/auth - -` + const xml = [ + `{"pattern": "foo"}`, + `{"path": "src/main.ts"}`, + ].join("\n") const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(3) - expect(calls[0].name).toBe("grep") + expect(calls).toHaveLength(2) + expect(calls[0].name).toBe("ripgrep") expect(calls[1].name).toBe("read") - expect(calls[1].arguments).toEqual({ path: "src/auth/login.ts", start: 1, end: 50 }) - expect(calls[2].name).toBe("list_directory") - expect(calls[2].arguments).toEqual({ path: "src/auth" }) }) - test("parses finish with file ranges", () => { - const xml = ` - -src/auth/login.ts:1-50,80-120 -src/middleware/auth.ts:* - -` + test("parses finish tool call", () => { + const xml = `{"files": "src/main.ts:1-10"}` const calls = WarpGrep.parseToolCalls(xml) expect(calls).toHaveLength(1) expect(calls[0].name).toBe("finish") - const args = calls[0].arguments as { files: Array<{ path: string; lines: unknown }> } - expect(args.files).toHaveLength(2) - expect(args.files[0].path).toBe("src/auth/login.ts") - expect(args.files[0].lines).toEqual([[1, 50], [80, 120]]) - expect(args.files[1].path).toBe("src/middleware/auth.ts") - expect(args.files[1].lines).toBe("*") + expect(JSON.parse(calls[0].params).files).toBe("src/main.ts:1-10") }) - test("parses finish with text result fallback", () => { - const xml = ` - -No relevant authentication code found in this repository. - -` - const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(1) - expect(calls[0].name).toBe("finish") - expect((calls[0].arguments as { textResult: string }).textResult).toContain("No relevant authentication") + test("returns empty array for no tool calls", () => { + const calls = WarpGrep.parseToolCalls("Just some text without any tool calls") + expect(calls).toHaveLength(0) }) - test("parses read with multiple line ranges", () => { - const xml = ` - -src/main.ts -1-20,45-80 - -` - const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(1) - expect(calls[0].arguments).toEqual({ - path: "src/main.ts", - lines: [[1, 20], [45, 80]], - }) + test("returns empty array for malformed XML", () => { + const calls = WarpGrep.parseToolCalls("unclosed") + expect(calls).toHaveLength(0) }) + }) - test("strips think blocks before parsing", () => { - const xml = ` -Looking for auth middleware. Let me search for relevant patterns. - + describe("readFile", () => { + test("reads full file when no ranges specified", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "test.txt") + await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const content = await WarpGrep.readFile(filepath) + expect(content).toBe("line1\nline2\nline3\nline4\nline5") + }, + }) + }) - - -middleware - -` - const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(1) - expect(calls[0].name).toBe("grep") + test("reads single range", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "test.txt") + await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const content = await WarpGrep.readFile(filepath, "2-4") + expect(content).toBe("line2\nline3\nline4") + }, + }) }) - test("returns empty array for no tool calls", () => { - expect(WarpGrep.parseToolCalls("Just some text")).toHaveLength(0) + test("reads multiple ranges", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "test.txt") + await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const content = await WarpGrep.readFile(filepath, "1-2,4-5") + expect(content).toBe("line1\nline2\n...\nline4\nline5") + }, + }) }) - test("returns empty array for malformed XML", () => { - expect(WarpGrep.parseToolCalls("unclosed")).toHaveLength(0) + test("returns error for file not found", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const content = await WarpGrep.readFile(path.join(tmp.path, "nonexistent.txt")) + expect(content).toContain("Error: file not found") + }, + }) }) + }) + + describe("listDirectory", () => { + test("lists directory contents", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "file1.ts"), "content1") + await Bun.write(path.join(dir, "file2.ts"), "content2") + await fs.mkdir(path.join(dir, "subdir")) + }, + }) - test("skips ripgrep without pattern", () => { - const xml = ` - -src/ - -` - expect(WarpGrep.parseToolCalls(xml)).toHaveLength(0) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await WarpGrep.listDirectory(tmp.path) + expect(result).toContain("file1.ts") + expect(result).toContain("file2.ts") + expect(result).toContain("subdir/") + }, + }) }) - test("skips read without path", () => { - const xml = ` - -1-50 - -` - expect(WarpGrep.parseToolCalls(xml)).toHaveLength(0) + test("returns error for nonexistent directory", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(WarpGrep.listDirectory(path.join(tmp.path, "nonexistent"))).rejects.toThrow() + }, + }) }) + }) - test("defaults ripgrep path to .", () => { - const xml = ` - -foo - -` - const calls = WarpGrep.parseToolCalls(xml) - expect(calls[0].arguments.path).toBe(".") + describe("handleFinish", () => { + test("reads single file with ranges", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "src.ts") + await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await WarpGrep.handleFinish(JSON.stringify({ files: `${filepath}:1-3` })) + expect(result).toContain("--- " + filepath + " ---") + expect(result).toContain("line1\nline2\nline3") + }, + }) }) - test("defaults list_directory path to .", () => { - const xml = ` - - -` - const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(1) - expect(calls[0].arguments.path).toBe(".") + test("reads multiple files", async () => { + await using tmp = await tmpdir() + const file1 = path.join(tmp.path, "a.ts") + const file2 = path.join(tmp.path, "b.ts") + await Bun.write(file1, "alpha\nbeta") + await Bun.write(file2, "gamma\ndelta") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await WarpGrep.handleFinish(JSON.stringify({ files: `${file1}:1-2\n${file2}:1-2` })) + expect(result).toContain("alpha") + expect(result).toContain("gamma") + }, + }) }) - test("parses finish with whole file (no colon)", () => { - const xml = ` - -src/main.ts - -` - const calls = WarpGrep.parseToolCalls(xml) - const args = calls[0].arguments as { files: Array<{ path: string; lines: unknown }> } - expect(args.files[0]).toEqual({ path: "src/main.ts", lines: "*" }) + test("reads whole file when no range specified", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "whole.ts") + await Bun.write(filepath, "entire\nfile\ncontent") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await WarpGrep.handleFinish(JSON.stringify({ files: filepath })) + expect(result).toContain("entire\nfile\ncontent") + }, + }) }) }) }) From 5c4f3201af2dd3a9c4f93c023c3ccaacccb60591 Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Fri, 6 Mar 2026 00:30:01 -0800 Subject: [PATCH 04/14] chore: remove warpgrep test files from branch --- .../opencode/test/kilocode/warpgrep.test.ts | 184 ------------------ packages/opencode/test/tool/warpgrep.test.ts | 32 --- 2 files changed, 216 deletions(-) delete mode 100644 packages/opencode/test/kilocode/warpgrep.test.ts delete mode 100644 packages/opencode/test/tool/warpgrep.test.ts diff --git a/packages/opencode/test/kilocode/warpgrep.test.ts b/packages/opencode/test/kilocode/warpgrep.test.ts deleted file mode 100644 index 9f379f44433..00000000000 --- a/packages/opencode/test/kilocode/warpgrep.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { WarpGrep } from "../../src/kilocode/warpgrep" -import { tmpdir } from "../fixture/fixture" -import path from "path" -import fs from "fs/promises" -import { Instance } from "../../src/project/instance" - -describe("WarpGrep", () => { - describe("parseToolCalls", () => { - test("parses single tool call", () => { - const xml = `{"pattern": "foo", "include": "*.ts"}` - const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(1) - expect(calls[0].name).toBe("ripgrep") - expect(JSON.parse(calls[0].params)).toEqual({ pattern: "foo", include: "*.ts" }) - }) - - test("parses multiple tool calls", () => { - const xml = [ - `{"pattern": "foo"}`, - `{"path": "src/main.ts"}`, - ].join("\n") - const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(2) - expect(calls[0].name).toBe("ripgrep") - expect(calls[1].name).toBe("read") - }) - - test("parses finish tool call", () => { - const xml = `{"files": "src/main.ts:1-10"}` - const calls = WarpGrep.parseToolCalls(xml) - expect(calls).toHaveLength(1) - expect(calls[0].name).toBe("finish") - expect(JSON.parse(calls[0].params).files).toBe("src/main.ts:1-10") - }) - - test("returns empty array for no tool calls", () => { - const calls = WarpGrep.parseToolCalls("Just some text without any tool calls") - expect(calls).toHaveLength(0) - }) - - test("returns empty array for malformed XML", () => { - const calls = WarpGrep.parseToolCalls("unclosed") - expect(calls).toHaveLength(0) - }) - }) - - describe("readFile", () => { - test("reads full file when no ranges specified", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const content = await WarpGrep.readFile(filepath) - expect(content).toBe("line1\nline2\nline3\nline4\nline5") - }, - }) - }) - - test("reads single range", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const content = await WarpGrep.readFile(filepath, "2-4") - expect(content).toBe("line2\nline3\nline4") - }, - }) - }) - - test("reads multiple ranges", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const content = await WarpGrep.readFile(filepath, "1-2,4-5") - expect(content).toBe("line1\nline2\n...\nline4\nline5") - }, - }) - }) - - test("returns error for file not found", async () => { - await using tmp = await tmpdir() - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const content = await WarpGrep.readFile(path.join(tmp.path, "nonexistent.txt")) - expect(content).toContain("Error: file not found") - }, - }) - }) - }) - - describe("listDirectory", () => { - test("lists directory contents", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "file1.ts"), "content1") - await Bun.write(path.join(dir, "file2.ts"), "content2") - await fs.mkdir(path.join(dir, "subdir")) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await WarpGrep.listDirectory(tmp.path) - expect(result).toContain("file1.ts") - expect(result).toContain("file2.ts") - expect(result).toContain("subdir/") - }, - }) - }) - - test("returns error for nonexistent directory", async () => { - await using tmp = await tmpdir() - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(WarpGrep.listDirectory(path.join(tmp.path, "nonexistent"))).rejects.toThrow() - }, - }) - }) - }) - - describe("handleFinish", () => { - test("reads single file with ranges", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "src.ts") - await Bun.write(filepath, "line1\nline2\nline3\nline4\nline5") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await WarpGrep.handleFinish(JSON.stringify({ files: `${filepath}:1-3` })) - expect(result).toContain("--- " + filepath + " ---") - expect(result).toContain("line1\nline2\nline3") - }, - }) - }) - - test("reads multiple files", async () => { - await using tmp = await tmpdir() - const file1 = path.join(tmp.path, "a.ts") - const file2 = path.join(tmp.path, "b.ts") - await Bun.write(file1, "alpha\nbeta") - await Bun.write(file2, "gamma\ndelta") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await WarpGrep.handleFinish(JSON.stringify({ files: `${file1}:1-2\n${file2}:1-2` })) - expect(result).toContain("alpha") - expect(result).toContain("gamma") - }, - }) - }) - - test("reads whole file when no range specified", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "whole.ts") - await Bun.write(filepath, "entire\nfile\ncontent") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await WarpGrep.handleFinish(JSON.stringify({ files: filepath })) - expect(result).toContain("entire\nfile\ncontent") - }, - }) - }) - }) -}) diff --git a/packages/opencode/test/tool/warpgrep.test.ts b/packages/opencode/test/tool/warpgrep.test.ts deleted file mode 100644 index 27dee89f6d6..00000000000 --- a/packages/opencode/test/tool/warpgrep.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" -import { ToolRegistry } from "../../src/tool/registry" - -describe("WarpGrepTool", () => { - // KILO_ENABLE_WARPGREP is a compile-time const in Flag — setting process.env - // at runtime does NOT change it. We test that the tool definition is always - // registered in the full list, and that tools() filters it based on the flag. - test("tool definition is always registered", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ids = await ToolRegistry.ids() - expect(ids).toContain("warpgrep") - }, - }) - }) - - test("tool is filtered by tools() when flag is not set", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const tools = await ToolRegistry.tools({ providerID: "test", modelID: "test" }) - const ids = tools.map((t) => t.id) - expect(ids).not.toContain("warpgrep") - }, - }) - }) -}) From 870806bef628531208c6cc86c294127f43ac7bd5 Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Fri, 6 Mar 2026 16:40:07 -0800 Subject: [PATCH 05/14] refactor: replace custom WarpGrep implementation with @morphllm/morphsdk - Delete 396-line custom warpgrep.ts, replace with SDK's WarpGrepClient - Only check MORPH_API_KEY (drop WARPGREP_API_KEY) - Route through Kilo proxy during free period when no API key is set - Add actionable error message for when free period ends - Grep for FREE_PERIOD_TODO to find what to change post-free-period Co-Authored-By: Claude Opus 4.6 --- bun.lock | 31 ++ packages/opencode/package.json | 1 + packages/opencode/src/agent/agent.ts | 3 +- packages/opencode/src/kilocode/index.ts | 1 - packages/opencode/src/kilocode/warpgrep.ts | 396 --------------------- packages/opencode/src/tool/warpgrep.ts | 68 ++-- packages/opencode/src/tool/warpgrep.txt | 2 +- 7 files changed, 75 insertions(+), 427 deletions(-) delete mode 100644 packages/opencode/src/kilocode/warpgrep.ts diff --git a/bun.lock b/bun.lock index c3a07dd0045..d77494d3451 100644 --- a/bun.lock +++ b/bun.lock @@ -314,6 +314,7 @@ "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@modelcontextprotocol/sdk": "1.25.2", + "@morphllm/morphsdk": "^0.2.129", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -1137,6 +1138,8 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + "@morphllm/morphsdk": ["@morphllm/morphsdk@0.2.129", "", { "dependencies": { "@vscode/ripgrep": "^1.17.0", "ai": "^5.0.0", "diff": "^7.0.0", "isomorphic-git": "^1.25.10", "openai": "^4.52.7", "zod": "^3.23.8" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.25.0", "@google/generative-ai": ">=0.21.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@google/generative-ai"] }, "sha512-210VLivoBFLFgjuExmHNuDZd5Aam/S0Xwg952dYiiq0DiT739Gp5Kkd4Kb06V3jeheuXHUHObOdq0YOpdoTEuw=="], + "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], @@ -1913,6 +1916,8 @@ "@vscode/codicons": ["@vscode/codicons@0.0.44", "", {}, "sha512-F7qPRumUK3EHjNdopfICLGRf3iNPoZQt+McTHAn4AlOWPB3W2kL4H0S7uqEqbyZ6rCxaeDjpAn3MCUnwTu/VJQ=="], + "@vscode/ripgrep": ["@vscode/ripgrep@1.17.0", "", { "dependencies": { "https-proxy-agent": "^7.0.2", "proxy-from-env": "^1.1.0", "yauzl": "^2.9.2" } }, "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g=="], + "@vscode/test-cli": ["@vscode/test-cli@0.0.12", "", { "dependencies": { "@types/mocha": "^10.0.10", "c8": "^10.1.3", "chokidar": "^3.6.0", "enhanced-resolve": "^5.18.3", "glob": "^10.3.10", "minimatch": "^9.0.3", "mocha": "^11.7.4", "supports-color": "^10.2.2", "yargs": "^17.7.2" }, "bin": { "vscode-test": "out/bin.mjs" } }, "sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ=="], "@vscode/test-electron": ["@vscode/test-electron@2.5.2", "", { "dependencies": { "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", "jszip": "^3.10.1", "ora": "^8.1.0", "semver": "^7.6.2" } }, "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg=="], @@ -1995,6 +2000,8 @@ "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], @@ -2143,6 +2150,8 @@ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], @@ -2329,6 +2338,8 @@ "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], @@ -2719,6 +2730,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-git": ["isomorphic-git@1.37.2", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "pify": "^4.0.1", "readable-stream": "^4.0.0", "sha.js": "^2.4.12", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -2941,6 +2954,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], @@ -3111,6 +3126,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "pino": ["pino@10.3.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA=="], "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], @@ -3327,6 +3344,8 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -3523,6 +3542,8 @@ "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], @@ -3577,6 +3598,8 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + "typed-rest-client": ["typed-rest-client@1.8.11", "", { "dependencies": { "qs": "^6.9.1", "tunnel": "0.0.6", "underscore": "^1.12.1" } }, "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA=="], "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], @@ -4147,6 +4170,10 @@ "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + "@morphllm/morphsdk/diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], + + "@morphllm/morphsdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@octokit/core/@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="], "@octokit/core/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], @@ -4383,6 +4410,8 @@ "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "isomorphic-git/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -4533,6 +4562,8 @@ "test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "tree-sitter-bash/node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 6557513e267..f3140aae0e9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,6 +85,7 @@ "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@modelcontextprotocol/sdk": "1.25.2", + "@morphllm/morphsdk": "^0.2.129", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 00309558e9d..fee30eed303 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -17,7 +17,6 @@ import PROMPT_ORCHESTRATOR from "./prompt/orchestrator.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { Flag } from "@/flag/flag" // kilocode_change -import { WarpGrep } from "@/kilocode/warpgrep" // kilocode_change import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" @@ -242,7 +241,7 @@ export namespace Agent { description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, // kilocode_change start prompt: - Flag.KILO_ENABLE_WARPGREP && WarpGrep.key() + Flag.KILO_ENABLE_WARPGREP ? `Prefer using the WarpGrep tool for codebase searches — it performs intelligent multi-step code search and returns the most relevant code spans.\n\n${PROMPT_EXPLORE}` : PROMPT_EXPLORE, // kilocode_change end diff --git a/packages/opencode/src/kilocode/index.ts b/packages/opencode/src/kilocode/index.ts index 401d6dccdd4..e1a741ca489 100644 --- a/packages/opencode/src/kilocode/index.ts +++ b/packages/opencode/src/kilocode/index.ts @@ -5,4 +5,3 @@ export { McpMigrator } from "./mcp-migrator" export { IgnoreMigrator } from "./ignore-migrator" export { KilocodeConfigInjector } from "./config-injector" export { KilocodePaths } from "./paths" -export { WarpGrep } from "./warpgrep" diff --git a/packages/opencode/src/kilocode/warpgrep.ts b/packages/opencode/src/kilocode/warpgrep.ts deleted file mode 100644 index b250c795c2f..00000000000 --- a/packages/opencode/src/kilocode/warpgrep.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { Ripgrep } from "../file/ripgrep" -import { Process } from "../util/process" -import { Instance } from "../project/instance" -import { Log } from "../util/log" -import { text } from "node:stream/consumers" -import fs from "node:fs/promises" -import path from "node:path" - -const log = Log.create({ service: "warpgrep" }) - -const API_URL = "https://api.morphllm.com/v1/chat/completions" -const MODEL = "morph-warp-grep-v2" -const MAX_TURNS = 4 -const MAX_OUTPUT_LINES = 200 -const MAX_READ_LINES = 800 -const MAX_CONTEXT_CHARS = 540_000 -const TRUNCATED_MARKER = "[truncated for context limit]" - -interface Message { - role: "user" | "assistant" - content: string -} - -interface ToolCall { - name: string - arguments: Record -} - -interface FinishFile { - path: string - lines: "*" | Array<[number, number]> -} - -export namespace WarpGrep { - export function parseToolCalls(content: string): ToolCall[] { - const stripped = content.replace(/[\s\S]*?<\/think>/gi, "") - const calls: ToolCall[] = [] - const regex = /\s*([\s\S]*?)<\/function>\s*<\/tool_call>/gi - let match - - while ((match = regex.exec(stripped)) !== null) { - const name = match[1].toLowerCase() - const body = match[2] - - const params: Record = {} - const paramRegex = /([\s\S]*?)<\/parameter>/gi - let paramMatch - while ((paramMatch = paramRegex.exec(body)) !== null) { - params[paramMatch[1].toLowerCase()] = paramMatch[2].trim() - } - - if (name === "ripgrep") { - if (!params.pattern) continue - calls.push({ - name: "grep", - arguments: { - pattern: params.pattern, - path: params.path || ".", - ...(params.glob && { glob: params.glob }), - ...(params.context_lines && { context_lines: parseInt(params.context_lines, 10) }), - ...(params.case_sensitive && { case_sensitive: params.case_sensitive === "true" }), - }, - }) - } else if (name === "list_directory") { - calls.push({ name: "list_directory", arguments: { path: params.path || "." } }) - } else if (name === "read") { - if (!params.path) continue - const args: Record = { path: params.path } - if (params.lines) { - const ranges: Array<[number, number]> = [] - for (const r of params.lines.split(",")) { - const t = r.trim() - if (!t) continue - const [s, e] = t.split("-").map((v) => parseInt(v.trim(), 10)) - if (Number.isFinite(s) && Number.isFinite(e)) ranges.push([s, e]) - else if (Number.isFinite(s)) ranges.push([s, s]) - } - if (ranges.length === 1) { - args.start = ranges[0][0] - args.end = ranges[0][1] - } else if (ranges.length > 1) { - args.lines = ranges - } - } - calls.push({ name: "read", arguments: args }) - } else if (name === "finish") { - if (params.result && !params.files) { - calls.push({ name: "finish", arguments: { files: [], textResult: params.result } }) - continue - } - if (!params.files) { - calls.push({ name: "finish", arguments: { files: [], textResult: "No relevant code found." } }) - continue - } - const files: FinishFile[] = [] - for (const line of params.files.split("\n")) { - const trimmed = line.trim() - if (!trimmed) continue - const colonIdx = trimmed.indexOf(":") - if (colonIdx === -1) { - files.push({ path: trimmed, lines: "*" }) - continue - } - const fp = trimmed.slice(0, colonIdx) - const rangesPart = trimmed.slice(colonIdx + 1) - const ranges: Array<[number, number]> = [] - let whole = false - for (const r of rangesPart.split(",")) { - const rt = r.trim() - if (!rt || rt === "*") { - whole = true - break - } - const [s, e] = rt.split("-").map((v) => parseInt(v.trim(), 10)) - if (Number.isFinite(s) && Number.isFinite(e)) ranges.push([s, e]) - else if (Number.isFinite(s)) ranges.push([s, s]) - } - if (whole) files.push({ path: fp, lines: "*" }) - else if (ranges.length > 0) files.push({ path: fp, lines: ranges }) - else if (!files.some((f) => f.path === fp)) files.push({ path: fp, lines: "*" }) - } - calls.push({ - name: "finish", - arguments: files.length > 0 ? { files } : { files: [], textResult: params.files }, - }) - } - } - - return calls - } - - function resolvePath(filepath: string): string { - return path.isAbsolute(filepath) ? filepath : path.resolve(Instance.directory, filepath) - } - - function formatTurnMessage(turn: number): string { - const remaining = MAX_TURNS - turn - if (remaining === 1) - return `\nYou have used ${turn} turns, you only have 1 turn remaining. You have run out of turns to explore the code base and MUST call the finish tool now` - return `\nYou have used ${turn} turn${turn === 1 ? "" : "s"} and have ${remaining} remaining` - } - - function calcContextBudget(messages: Message[]): string { - const total = messages.reduce((sum, m) => sum + m.content.length, 0) - const percent = Math.round((total / MAX_CONTEXT_CHARS) * 100) - const usedK = Math.round(total / 1000) - const maxK = Math.round(MAX_CONTEXT_CHARS / 1000) - return `${percent}% (${usedK}K/${maxK}K chars)` - } - - function enforceContextLimit(messages: Message[]) { - const total = () => messages.reduce((sum, m) => sum + m.content.length, 0) - if (total() <= MAX_CONTEXT_CHARS) return - let first = true - for (let i = 0; i < messages.length; i++) { - if (messages[i].role !== "user") continue - if (first) { - first = false - continue - } - if (total() <= MAX_CONTEXT_CHARS) break - if (messages[i].content !== TRUNCATED_MARKER) messages[i] = { role: "user", content: TRUNCATED_MARKER } - } - } - - async function execGrep(args: Record, signal?: AbortSignal): Promise { - const pattern = args.pattern as string - const target = (args.path as string) || "." - const glob = args.glob as string | undefined - const ctx = args.context_lines !== undefined ? String(args.context_lines) : "1" - - const rg = await Ripgrep.filepath() - const cmd = [ - rg, - "--no-heading", - "--with-filename", - "--line-number", - "--color=never", - "--trim", - "--max-columns=400", - "-C", - ctx, - ...(args.case_sensitive === false ? ["--ignore-case"] : []), - ...(glob ? ["--glob", glob] : []), - "-g", - "!.git", - "-g", - "!node_modules", - "-g", - "!dist", - "-g", - "!build", - "-g", - "!*.min.js", - "-g", - "!*.min.css", - "-g", - "!*.map", - "-g", - "!*.lock", - "-g", - "!bun.lockb", - pattern, - target, - ] - - const proc = Process.spawn(cmd, { - cwd: Instance.directory, - stdout: "pipe", - stderr: "pipe", - abort: signal, - }) - if (!proc.stdout) return "no matches" - - const output = await text(proc.stdout) - await proc.exited - - const lines = output.trim().split("\n").filter(Boolean) - if (lines.length === 0) return "no matches" - if (lines.length > MAX_OUTPUT_LINES) { - const truncated = lines.slice(0, MAX_OUTPUT_LINES) - truncated.push(`... (output truncated at ${MAX_OUTPUT_LINES} of ${lines.length} lines)`) - return truncated.join("\n") - } - return lines.join("\n") - } - - async function execRead(args: Record): Promise { - const filepath = args.path as string - const resolved = resolvePath(filepath) - const file = Bun.file(resolved) - if (!(await file.exists())) return `[FILE NOT FOUND] No file at "${filepath}"` - - const allLines = (await file.text()).split("\n") - const total = allLines.length - - const ranges = args.lines as Array<[number, number]> | undefined - if (ranges && Array.isArray(ranges)) { - const chunks: string[] = [] - for (const [s, e] of ranges) { - const start = Math.max(1, s) - const end = Math.min(total, e) - const out: string[] = [] - for (let i = start; i <= end; i++) out.push(`${i}|${allLines[i - 1] ?? ""}`) - chunks.push(out.join("\n")) - } - const result = chunks.join("\n...\n") - const resultLines = result.split("\n") - if (resultLines.length > MAX_READ_LINES) - return resultLines.slice(0, MAX_READ_LINES).join("\n") + `\n... (truncated at ${MAX_READ_LINES} lines)` - return result - } - - const start = Math.max(1, (args.start as number) ?? 1) - const end = Math.min(total, (args.end as number) ?? total) - const out: string[] = [] - for (let i = start; i <= end; i++) out.push(`${i}|${allLines[i - 1] ?? ""}`) - if (out.length > MAX_READ_LINES) - return out.slice(0, MAX_READ_LINES).join("\n") + `\n... (truncated at ${MAX_READ_LINES} lines)` - return out.join("\n") - } - - async function execListDir(args: Record): Promise { - const dir = (args.path as string) || "." - const resolved = resolvePath(dir) - const stat = await fs.stat(resolved).catch(() => null) - if (!stat?.isDirectory()) return "empty" - - const entries = await fs.readdir(resolved, { withFileTypes: true }) - const lines = entries - .filter((e) => !e.name.startsWith(".") && !["node_modules", "dist", "build", "__pycache__"].includes(e.name)) - .map((e) => (e.isDirectory() ? e.name + "/" : e.name)) - if (lines.length === 0) return "empty" - if (lines.length > MAX_OUTPUT_LINES) { - return lines.slice(0, MAX_OUTPUT_LINES).join("\n") + `\n... (truncated at ${MAX_OUTPUT_LINES} entries)` - } - return lines.join("\n") - } - - async function executeTool(call: ToolCall, signal?: AbortSignal): Promise { - try { - if (call.name === "grep") return await execGrep(call.arguments, signal) - if (call.name === "read") return await execRead(call.arguments) - if (call.name === "list_directory") return await execListDir(call.arguments) - return "" - } catch (e) { - return e instanceof Error ? e.message : String(e) - } - } - - async function resolveFinish(files: FinishFile[]): Promise { - const parts: string[] = ["Morph Fast Context subagent performed search on repository:\n", "Relevant context found:"] - for (const f of files) { - const rangeStr = f.lines === "*" ? "*" : f.lines.map(([s, e]) => `${s}-${e}`).join(",") - parts.push(`- ${f.path}:${rangeStr}`) - } - parts.push("\nHere is the content of files:\n") - - for (const f of files) { - const resolved = resolvePath(f.path) - const file = Bun.file(resolved) - if (!(await file.exists())) { - parts.push(``, `[couldn't find: ${f.path}]`, "\n") - continue - } - - const allLines = (await file.text()).split("\n") - if (f.lines === "*") { - parts.push(``, allLines.join("\n"), "\n") - continue - } - - const rangeStr = f.lines.map(([s, e]) => `${s}-${e}`).join(",") - parts.push(``) - const chunks: string[] = [] - for (let i = 0; i < f.lines.length; i++) { - const [s, e] = f.lines[i] - if (i === 0 && s > 1) chunks.push(`// ... existing code, block starting at line ${s} ...`) - else if (i > 0) chunks.push(`// ... existing code, block starting at line ${s} ...`) - chunks.push(allLines.slice(Math.max(0, s - 1), Math.min(allLines.length, e)).join("\n")) - } - parts.push(chunks.join("\n"), "\n") - } - - return parts.join("\n") - } - - export function key(): string | undefined { - return process.env["WARPGREP_API_KEY"] || process.env["MORPH_API_KEY"] - } - - export async function search(query: string, apiKey: string, signal?: AbortSignal): Promise { - const tree = await Ripgrep.tree({ cwd: Instance.directory, limit: 500, signal }) - const messages: Message[] = [] - const budget = calcContextBudget(messages) - messages.push({ - role: "user", - content: `\n${tree}\n\n\n\n${query}\n\n${budget}\nTurn 0/${MAX_TURNS}`, - }) - - for (let turn = 1; turn <= MAX_TURNS; turn++) { - log.info("warpgrep turn", { turn, messages: messages.length }) - enforceContextLimit(messages) - - const response = await fetch(API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: MODEL, - temperature: 0, - max_tokens: 2048, - messages, - }), - signal, - }) - - if (!response.ok) { - const err = await response.text() - throw new Error(`WarpGrep API error (${response.status}): ${err}`) - } - - const data: { choices: { message: { content: string } }[] } = await response.json() - const content = data.choices[0]?.message?.content - if (!content) throw new Error("WarpGrep API returned empty response") - - messages.push({ role: "assistant", content }) - - const calls = parseToolCalls(content) - if (calls.length === 0) return content - - const finishCall = calls.find((c) => c.name === "finish") - if (finishCall) { - const { files, textResult } = finishCall.arguments as { files: FinishFile[]; textResult?: string } - if (!files || files.length === 0) return textResult || "No relevant code found." - return resolveFinish(files) - } - - const results = await Promise.all(calls.map((call) => executeTool(call, signal))) - const responses = results - .map((r) => r.trim()) - .filter(Boolean) - .map((r) => `\n${r}\n`) - - if (responses.length > 0) { - const turnMsg = formatTurnMessage(turn) - const ctxBudget = calcContextBudget(messages) - messages.push({ role: "user", content: responses.join("\n") + turnMsg + "\n" + ctxBudget }) - } - } - - return "WarpGrep search completed without results" - } -} diff --git a/packages/opencode/src/tool/warpgrep.ts b/packages/opencode/src/tool/warpgrep.ts index c8eedbd0ff8..b0d276470c6 100644 --- a/packages/opencode/src/tool/warpgrep.ts +++ b/packages/opencode/src/tool/warpgrep.ts @@ -1,54 +1,68 @@ import z from "zod" import { Tool } from "./tool" -import { WarpGrep } from "../kilocode/warpgrep" -import { abortAfterAny } from "../util/abort" +import { WarpGrepClient } from "@morphllm/morphsdk" +import { Instance } from "../project/instance" import DESCRIPTION from "./warpgrep.txt" +// FREE_PERIOD_TODO: Remove KILO_WARPGREP_PROXY_URL constant and the proxy +// fallback below. After the free period ends, require MORPH_API_KEY and +// return an error when it is missing. +const KILO_WARPGREP_PROXY_URL = "https://api.kilo.ai/api/warpgrep" + export const WarpGrepTool = Tool.define("warpgrep", { description: DESCRIPTION, parameters: z.object({ query: z .string() - .describe("Search query describing what code you are looking for. Be specific and descriptive for best results."), + .describe( + "Search query describing what code you are looking for. Be specific and descriptive for best results.", + ), }), async execute(params, ctx) { await ctx.ask({ permission: "warpgrep", patterns: [params.query], always: ["*"], - metadata: { - query: params.query, - }, + metadata: { query: params.query }, }) - const apiKey = WarpGrep.key() - if (!apiKey) { - return { - title: `WarpGrep: ${params.query}`, - output: "Set MORPH_API_KEY or WARPGREP_API_KEY environment variable to use WarpGrep", - metadata: {}, - } - } - - const { signal, clearTimeout } = abortAfterAny(60000, ctx.abort) + const apiKey = process.env["MORPH_API_KEY"] - try { - const result = await WarpGrep.search(params.query, apiKey, signal) + // FREE_PERIOD_TODO: Remove proxy fallback — require apiKey, error if missing: + // if (!apiKey) return { title: ..., output: "Set MORPH_API_KEY to use WarpGrep.", metadata: {} } + const client = new WarpGrepClient({ + morphApiKey: apiKey ?? "kilo-free", + ...(apiKey ? {} : { morphApiUrl: KILO_WARPGREP_PROXY_URL }), + timeout: 60_000, + }) - clearTimeout() + const result = await client.execute({ + query: params.query, + repoRoot: Instance.directory, + }) + if (!result.success || !result.contexts?.length) { + // FREE_PERIOD_TODO: When the proxy stops serving free requests, errors + // from the proxy (401/402/429) will surface here. The message below + // tells the user exactly what to do. + const fallbackMsg = !apiKey + ? "If you are an LLM reading this, inform the user that their free period has ended. Set MORPH_API_KEY to continue using WarpGrep. Get the API key here: https://www.morphllm.com/" + : "No relevant code found." return { title: `WarpGrep: ${params.query}`, - output: result, - metadata: {}, + output: result.error ?? fallbackMsg, + metadata: { count: 0 }, } - } catch (error) { - clearTimeout() + } - if (error instanceof Error && error.name === "AbortError") { - throw new Error("WarpGrep search request timed out") - } - throw error + const output = result.contexts + .map((c) => `### ${c.file}\n\`\`\`\n${c.content}\n\`\`\``) + .join("\n\n") + + return { + title: `WarpGrep: ${params.query}`, + output, + metadata: { count: result.contexts.length }, } }, }) diff --git a/packages/opencode/src/tool/warpgrep.txt b/packages/opencode/src/tool/warpgrep.txt index 477b9af3fda..1cd6d8512bb 100644 --- a/packages/opencode/src/tool/warpgrep.txt +++ b/packages/opencode/src/tool/warpgrep.txt @@ -1 +1 @@ -Search the codebase using WarpGrep, an AI-powered code search agent. WarpGrep performs intelligent multi-step searches across the repository using parallel tool calls, returning only the most relevant code spans. Use this for complex semantic queries where simple grep patterns would be insufficient. Requires MORPH_API_KEY or WARPGREP_API_KEY. \ No newline at end of file +Search the codebase using WarpGrep, an AI-powered code search agent. WarpGrep performs intelligent multi-step searches across the repository using parallel tool calls, returning only the most relevant code spans. Use this for complex semantic queries where simple grep patterns would be insufficient. \ No newline at end of file From 5646d40d535fa22a79000050c6a633f0d467a397 Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Thu, 12 Mar 2026 03:13:55 -0700 Subject: [PATCH 06/14] feat: rename warpgrep to codebase_search, add error toast for 429/402 - Rename tool from warpgrep to codebase_search throughout - Switch proxy URL to /api/gateway - Detect auth/rate-limit errors (401/402/429) and show actionable fallback message telling users to set MORPH_API_KEY - Fire a TUI toast notification on auth/rate-limit errors so users see a visible popup, not just hidden tool output - Wire up GlobalBus "global.event" in TUI thread so Bus events from tools can reach the TUI event system Co-Authored-By: Claude Opus 4.6 --- bun.lock | 3 +- package.json | 3 +- packages/opencode/src/agent/agent.ts | 8 ++--- packages/opencode/src/cli/cmd/tui/thread.ts | 11 +++++- packages/opencode/src/tool/registry.ts | 6 ++-- packages/opencode/src/tool/warpgrep.ts | 38 +++++++++++++++------ packages/opencode/src/tool/warpgrep.txt | 2 +- 7 files changed, 49 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index d77494d3451..3f2b68cddc4 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@aws-sdk/client-s3": "3.933.0", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", + "@morphllm/morphsdk": "0.2.138", "@opencode-ai/script": "workspace:*", "typescript": "catalog:", }, @@ -1138,7 +1139,7 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], - "@morphllm/morphsdk": ["@morphllm/morphsdk@0.2.129", "", { "dependencies": { "@vscode/ripgrep": "^1.17.0", "ai": "^5.0.0", "diff": "^7.0.0", "isomorphic-git": "^1.25.10", "openai": "^4.52.7", "zod": "^3.23.8" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.25.0", "@google/generative-ai": ">=0.21.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@google/generative-ai"] }, "sha512-210VLivoBFLFgjuExmHNuDZd5Aam/S0Xwg952dYiiq0DiT739Gp5Kkd4Kb06V3jeheuXHUHObOdq0YOpdoTEuw=="], + "@morphllm/morphsdk": ["@morphllm/morphsdk@0.2.138", "", { "dependencies": { "@vscode/ripgrep": "^1.17.0", "ai": "^5.0.0", "diff": "^7.0.0", "isomorphic-git": "^1.25.10", "openai": "^4.52.7", "zod": "^3.23.8" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.25.0", "@google/generative-ai": ">=0.21.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@google/generative-ai"] }, "sha512-+ExQkRdR4EkeOvZUtPIGK2Z+5yZKelC72iEKun8HJYwuzzbOLi0Cbyp9JuDhlfcZwK0qdk7dPFGpej0TQTfW4g=="], "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], diff --git a/package.json b/package.json index 41caa98ce99..b7487ea30ad 100644 --- a/package.json +++ b/package.json @@ -78,8 +78,9 @@ "dependencies": { "@aws-sdk/client-s3": "3.933.0", "@kilocode/plugin": "workspace:*", - "@opencode-ai/script": "workspace:*", "@kilocode/sdk": "workspace:*", + "@morphllm/morphsdk": "0.2.138", + "@opencode-ai/script": "workspace:*", "typescript": "catalog:" }, "repository": { diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index fee30eed303..078f7395487 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -158,7 +158,7 @@ export namespace Agent { webfetch: "allow", websearch: "allow", codesearch: "allow", - warpgrep: "allow", // kilocode_change + codebase_search: "allow", // kilocode_change external_directory: { [Truncate.GLOB]: "allow", }, @@ -191,7 +191,7 @@ export namespace Agent { webfetch: "allow", websearch: "allow", codesearch: "allow", - warpgrep: "allow", // kilocode_change + codebase_search: "allow", // kilocode_change external_directory: { [Truncate.GLOB]: "allow", }, @@ -229,7 +229,7 @@ export namespace Agent { webfetch: "allow", websearch: "allow", codesearch: "allow", - warpgrep: "allow", // kilocode_change + codebase_search: "allow", // kilocode_change read: "allow", external_directory: { "*": "ask", @@ -242,7 +242,7 @@ export namespace Agent { // kilocode_change start prompt: Flag.KILO_ENABLE_WARPGREP - ? `Prefer using the WarpGrep tool for codebase searches — it performs intelligent multi-step code search and returns the most relevant code spans.\n\n${PROMPT_EXPLORE}` + ? `Prefer using the codebase_search tool for codebase searches — it performs intelligent multi-step code search and returns the most relevant code spans.\n\n${PROMPT_EXPLORE}` : PROMPT_EXPLORE, // kilocode_change end options: {}, diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 31ac0c8fb74..06b2a2ef1d0 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -41,7 +41,16 @@ function createWorkerFetch(client: RpcClient): typeof fetch { function createEventSource(client: RpcClient): EventSource { return { - on: (handler) => client.on("event", handler), + on: (handler) => { + const unsub1 = client.on("event", handler) + const unsub2 = client.on<{ directory: string; payload: Event }>("global.event", (evt) => + handler(evt.payload), + ) + return () => { + unsub1() + unsub2() + } + }, } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 87d0edf0661..5f0b8977fb7 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,7 +22,7 @@ import z from "zod" import { Plugin } from "../plugin" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" -import { WarpGrepTool } from "./warpgrep" // kilocode_change +import { CodebaseSearchTool } from "./warpgrep" // kilocode_change import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" @@ -116,7 +116,7 @@ export namespace ToolRegistry { // TodoReadTool, WebSearchTool, CodeSearchTool, - WarpGrepTool, // kilocode_change + CodebaseSearchTool, // kilocode_change SkillTool, ApplyPatchTool, ...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), @@ -149,7 +149,7 @@ export namespace ToolRegistry { // kilocode_change end // kilocode_change start - if (t.id === "warpgrep") return Flag.KILO_ENABLE_WARPGREP + if (t.id === "codebase_search") return Flag.KILO_ENABLE_WARPGREP // kilocode_change end // use apply tool in same format as codex diff --git a/packages/opencode/src/tool/warpgrep.ts b/packages/opencode/src/tool/warpgrep.ts index b0d276470c6..e8aba6312b1 100644 --- a/packages/opencode/src/tool/warpgrep.ts +++ b/packages/opencode/src/tool/warpgrep.ts @@ -2,14 +2,15 @@ import z from "zod" import { Tool } from "./tool" import { WarpGrepClient } from "@morphllm/morphsdk" import { Instance } from "../project/instance" +import { GlobalBus } from "../bus/global" import DESCRIPTION from "./warpgrep.txt" // FREE_PERIOD_TODO: Remove KILO_WARPGREP_PROXY_URL constant and the proxy // fallback below. After the free period ends, require MORPH_API_KEY and // return an error when it is missing. -const KILO_WARPGREP_PROXY_URL = "https://api.kilo.ai/api/warpgrep" +const KILO_WARPGREP_PROXY_URL = "https://api.kilo.ai/api/gateway" -export const WarpGrepTool = Tool.define("warpgrep", { +export const CodebaseSearchTool = Tool.define("codebase_search", { description: DESCRIPTION, parameters: z.object({ query: z @@ -20,7 +21,7 @@ export const WarpGrepTool = Tool.define("warpgrep", { }), async execute(params, ctx) { await ctx.ask({ - permission: "warpgrep", + permission: "codebase_search", patterns: [params.query], always: ["*"], metadata: { query: params.query }, @@ -29,7 +30,7 @@ export const WarpGrepTool = Tool.define("warpgrep", { const apiKey = process.env["MORPH_API_KEY"] // FREE_PERIOD_TODO: Remove proxy fallback — require apiKey, error if missing: - // if (!apiKey) return { title: ..., output: "Set MORPH_API_KEY to use WarpGrep.", metadata: {} } + // if (!apiKey) return { title: ..., output: "Set MORPH_API_KEY to use codebase search.", metadata: {} } const client = new WarpGrepClient({ morphApiKey: apiKey ?? "kilo-free", ...(apiKey ? {} : { morphApiUrl: KILO_WARPGREP_PROXY_URL }), @@ -37,7 +38,7 @@ export const WarpGrepTool = Tool.define("warpgrep", { }) const result = await client.execute({ - query: params.query, + searchTerm: params.query, repoRoot: Instance.directory, }) @@ -45,12 +46,27 @@ export const WarpGrepTool = Tool.define("warpgrep", { // FREE_PERIOD_TODO: When the proxy stops serving free requests, errors // from the proxy (401/402/429) will surface here. The message below // tells the user exactly what to do. - const fallbackMsg = !apiKey - ? "If you are an LLM reading this, inform the user that their free period has ended. Set MORPH_API_KEY to continue using WarpGrep. Get the API key here: https://www.morphllm.com/" - : "No relevant code found." + const isAuthOrRateLimit = + result.error && /401|402|429|rate.limit|free.period|unauthorized/i.test(result.error) + const apiKeyMsg = + "If you are an LLM reading this, inform the user that their free period has ended. Set MORPH_API_KEY to continue using codebase search. Get the API key here: https://www.morphllm.com/" + if (isAuthOrRateLimit) { + GlobalBus.emit("event", { + directory: Instance.directory, + payload: { + type: "tui.toast.show", + properties: { + title: "Codebase Search Unavailable", + message: "Free period has ended. Set MORPH_API_KEY to continue. Get your key at morphllm.com", + variant: "error", + duration: 10000, + }, + }, + }) + } return { - title: `WarpGrep: ${params.query}`, - output: result.error ?? fallbackMsg, + title: `Codebase Search: ${params.query}`, + output: isAuthOrRateLimit ? apiKeyMsg : (result.error ?? "No relevant code found."), metadata: { count: 0 }, } } @@ -60,7 +76,7 @@ export const WarpGrepTool = Tool.define("warpgrep", { .join("\n\n") return { - title: `WarpGrep: ${params.query}`, + title: `Codebase Search: ${params.query}`, output, metadata: { count: result.contexts.length }, } diff --git a/packages/opencode/src/tool/warpgrep.txt b/packages/opencode/src/tool/warpgrep.txt index 1cd6d8512bb..6ab04762d12 100644 --- a/packages/opencode/src/tool/warpgrep.txt +++ b/packages/opencode/src/tool/warpgrep.txt @@ -1 +1 @@ -Search the codebase using WarpGrep, an AI-powered code search agent. WarpGrep performs intelligent multi-step searches across the repository using parallel tool calls, returning only the most relevant code spans. Use this for complex semantic queries where simple grep patterns would be insufficient. \ No newline at end of file +Search the codebase using an AI-powered code search agent. It performs intelligent multi-step searches across the repository using parallel tool calls, returning only the most relevant code spans. Use this for complex semantic queries where simple grep patterns would be insufficient. \ No newline at end of file From 941efc5a3aa2ff852ef23906e41a68cfafbd2503 Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Thu, 12 Mar 2026 03:19:04 -0700 Subject: [PATCH 07/14] build: bump @morphllm/morphsdk to 0.2.141 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b7487ea30ad..d718a747770 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@aws-sdk/client-s3": "3.933.0", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", - "@morphllm/morphsdk": "0.2.138", + "@morphllm/morphsdk": "0.2.141", "@opencode-ai/script": "workspace:*", "typescript": "catalog:" }, From f076350934846f1738956699c42ead1a7d8ca2df Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Fri, 13 Mar 2026 12:18:42 -0700 Subject: [PATCH 08/14] fix: use Bus.publish for toast, revert global.event subscription Address PR review: emit toast via Bus.publish(TuiEvent.ToastShow) instead of GlobalBus.emit() so it works in both direct-RPC and server-backed TUI modes. Revert the global.event subscription in createEventSource since it's no longer needed and caused double event delivery. Also update codebase_search tool description to clarify natural language input. Co-Authored-By: Claude Opus 4.6 --- packages/opencode/src/cli/cmd/tui/thread.ts | 11 +---------- packages/opencode/src/tool/warpgrep.ts | 21 ++++++++------------- packages/opencode/src/tool/warpgrep.txt | 11 ++++++++++- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 917b431834f..b155a8badf1 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -43,16 +43,7 @@ function createWorkerFetch(client: RpcClient): typeof fetch { function createEventSource(client: RpcClient): EventSource { return { - on: (handler) => { - const unsub1 = client.on("event", handler) - const unsub2 = client.on<{ directory: string; payload: Event }>("global.event", (evt) => - handler(evt.payload), - ) - return () => { - unsub1() - unsub2() - } - }, + on: (handler) => client.on("event", handler), } } diff --git a/packages/opencode/src/tool/warpgrep.ts b/packages/opencode/src/tool/warpgrep.ts index e8aba6312b1..4be2717be3a 100644 --- a/packages/opencode/src/tool/warpgrep.ts +++ b/packages/opencode/src/tool/warpgrep.ts @@ -2,7 +2,8 @@ import z from "zod" import { Tool } from "./tool" import { WarpGrepClient } from "@morphllm/morphsdk" import { Instance } from "../project/instance" -import { GlobalBus } from "../bus/global" +import { Bus } from "../bus" +import { TuiEvent } from "../cli/cmd/tui/event" import DESCRIPTION from "./warpgrep.txt" // FREE_PERIOD_TODO: Remove KILO_WARPGREP_PROXY_URL constant and the proxy @@ -51,18 +52,12 @@ export const CodebaseSearchTool = Tool.define("codebase_search", { const apiKeyMsg = "If you are an LLM reading this, inform the user that their free period has ended. Set MORPH_API_KEY to continue using codebase search. Get the API key here: https://www.morphllm.com/" if (isAuthOrRateLimit) { - GlobalBus.emit("event", { - directory: Instance.directory, - payload: { - type: "tui.toast.show", - properties: { - title: "Codebase Search Unavailable", - message: "Free period has ended. Set MORPH_API_KEY to continue. Get your key at morphllm.com", - variant: "error", - duration: 10000, - }, - }, - }) + Bus.publish(TuiEvent.ToastShow, { + title: "Codebase Search Unavailable", + message: "Free period has ended. Set MORPH_API_KEY to continue. Get your key at morphllm.com", + variant: "error", + duration: 10000, + }).catch(() => {}) } return { title: `Codebase Search: ${params.query}`, diff --git a/packages/opencode/src/tool/warpgrep.txt b/packages/opencode/src/tool/warpgrep.txt index 6ab04762d12..bcb282d3a95 100644 --- a/packages/opencode/src/tool/warpgrep.txt +++ b/packages/opencode/src/tool/warpgrep.txt @@ -1 +1,10 @@ -Search the codebase using an AI-powered code search agent. It performs intelligent multi-step searches across the repository using parallel tool calls, returning only the most relevant code spans. Use this for complex semantic queries where simple grep patterns would be insufficient. \ No newline at end of file +- Searches the codebase using a natural language search string +- Takes a natural language search string as input: a question about something you want to understand, or a description of what you're looking for +- This is not pattern matching or semantic search — it is an AI-powered search agent that performs intelligent multi-step searches across the repository, returning only the most relevant spans +- Use this when you're looking for a feature or implementation, or trying to understand how something works in a large codebase but are unclear about the exact patterns to search for + +Usage notes: + - This does NOT accept regex or keyword dumps — use natural language + - Best practice is using this at the start of long codebase explorations when you haven't yet mapped out the relevant areas + - Firing two or three concurrent codebase_search calls at different angles helps go through a large codebase faster + - This tool is read-only and does not modify any files \ No newline at end of file From 2ee083c3d4dd7f08bba0bdfcb999885a6d5563ef Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Fri, 13 Mar 2026 14:39:12 -0700 Subject: [PATCH 09/14] feat: add codebase search toggle to experimental settings Replace KILO_ENABLE_WARPGREP env var with a UI toggle in the Experimental settings tab. The tool is now gated by the experimental.codebase_search config field, accessible from both VSCode settings and TUI config file. Co-Authored-By: Claude Opus 4.6 --- .../src/components/settings/ExperimentalTab.tsx | 13 +++++++++++++ packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/br.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/da.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/de.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/en.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/es.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/no.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/th.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 2 ++ .../kilo-vscode/webview-ui/src/types/messages.ts | 1 + packages/opencode/src/agent/agent.ts | 10 +++------- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/flag/flag.ts | 1 - packages/opencode/src/tool/registry.ts | 6 +----- 22 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index 3cd779c0f54..94d0ec10b18 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -102,6 +102,19 @@ const ExperimentalTab: Component = () => { + + updateExperimental("codebase_search", checked)} + hideLabel + > + {language.t("settings.experimental.codebaseSearch.title")} + + + Date: Mon, 16 Mar 2026 12:11:31 -0700 Subject: [PATCH 10/14] Update packages/opencode/src/tool/warpgrep.ts Co-authored-by: Marius --- packages/opencode/src/tool/warpgrep.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/warpgrep.ts b/packages/opencode/src/tool/warpgrep.ts index 4be2717be3a..6d4d1cd0a24 100644 --- a/packages/opencode/src/tool/warpgrep.ts +++ b/packages/opencode/src/tool/warpgrep.ts @@ -50,7 +50,7 @@ export const CodebaseSearchTool = Tool.define("codebase_search", { const isAuthOrRateLimit = result.error && /401|402|429|rate.limit|free.period|unauthorized/i.test(result.error) const apiKeyMsg = - "If you are an LLM reading this, inform the user that their free period has ended. Set MORPH_API_KEY to continue using codebase search. Get the API key here: https://www.morphllm.com/" + "Codebase search unavailable: free period ended. Set MORPH_API_KEY to continue. Get your key at https://www.morphllm.com/" if (isAuthOrRateLimit) { Bus.publish(TuiEvent.ToastShow, { title: "Codebase Search Unavailable", From 08dbb3b876afb641ce78d0a719a43cfbcf3228df Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Mon, 16 Mar 2026 14:50:30 -0700 Subject: [PATCH 11/14] feat: limit codebase_search output to prevent context bloat When search results exceed 45k chars (~15k tokens), degrade to showing file paths and line ranges instead of full content. Also bumps @morphllm/morphsdk from 0.2.129 to 0.2.147. Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 8 +++++++- packages/opencode/package.json | 2 +- packages/opencode/src/tool/warpgrep.ts | 20 +++++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 689dd9fac82..6e035473d99 100644 --- a/bun.lock +++ b/bun.lock @@ -350,7 +350,7 @@ "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@modelcontextprotocol/sdk": "1.25.2", - "@morphllm/morphsdk": "^0.2.129", + "@morphllm/morphsdk": "0.2.147", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -4512,6 +4512,8 @@ "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@kilocode/cli/@morphllm/morphsdk": ["@morphllm/morphsdk@0.2.147", "", { "dependencies": { "@vscode/ripgrep": "^1.17.0", "ai": ">=5.0.0", "diff": "^7.0.0", "isomorphic-git": "^1.25.10", "openai": "^4.52.7", "zod": ">=3.23.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.25.0", "@google/generative-ai": ">=0.21.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@google/generative-ai"] }, "sha512-ogXE5B55pGe/V7JKhbOZycnUzU+KvZCoi1LCMlalKy2tIggXK5kva43ixewUqnu9rVGKGDhU1HiAjQl8RkNcww=="], + "@kilocode/kilo-docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@kilocode/kilo-gateway/@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="], @@ -5252,6 +5254,10 @@ "@hey-api/openapi-ts/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "@kilocode/cli/@morphllm/morphsdk/diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], + + "@kilocode/cli/@morphllm/morphsdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@kilocode/kilo-gateway/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="], "@kilocode/kilo-gateway/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f15ce1a0d97..68ac767b42c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,7 +85,7 @@ "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@modelcontextprotocol/sdk": "1.25.2", - "@morphllm/morphsdk": "^0.2.129", + "@morphllm/morphsdk": "0.2.147", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", diff --git a/packages/opencode/src/tool/warpgrep.ts b/packages/opencode/src/tool/warpgrep.ts index 6d4d1cd0a24..5e9bc43dcfb 100644 --- a/packages/opencode/src/tool/warpgrep.ts +++ b/packages/opencode/src/tool/warpgrep.ts @@ -66,10 +66,28 @@ export const CodebaseSearchTool = Tool.define("codebase_search", { } } - const output = result.contexts + const MAX_OUTPUT_CHARS = 45_000 + const fullOutput = result.contexts .map((c) => `### ${c.file}\n\`\`\`\n${c.content}\n\`\`\``) .join("\n\n") + let output: string + if (fullOutput.length > MAX_OUTPUT_CHARS) { + const summary = result.contexts + .map((c) => { + const lineInfo = !c.lines + ? "" + : c.lines === "*" + ? " (full file)" + : ` (lines ${c.lines.map((r) => r.join("-")).join(", ")})` + return `- ${c.file}${lineInfo}` + }) + .join("\n") + output = `Results too large to show inline. Showing file paths and line ranges. Use Read tool to view specific files.\n\n${summary}` + } else { + output = fullOutput + } + return { title: `Codebase Search: ${params.query}`, output, From 4adc1f05737404fb342e1e81e446c2ddad362e0c Mon Sep 17 00:00:00 2001 From: DhruvBhatia0 Date: Mon, 16 Mar 2026 17:16:07 -0700 Subject: [PATCH 12/14] build: bump @morphllm/morphsdk to 0.2.148 Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 6e035473d99..e3fb2685259 100644 --- a/bun.lock +++ b/bun.lock @@ -350,7 +350,7 @@ "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@modelcontextprotocol/sdk": "1.25.2", - "@morphllm/morphsdk": "0.2.147", + "@morphllm/morphsdk": "0.2.148", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -4512,7 +4512,7 @@ "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@kilocode/cli/@morphllm/morphsdk": ["@morphllm/morphsdk@0.2.147", "", { "dependencies": { "@vscode/ripgrep": "^1.17.0", "ai": ">=5.0.0", "diff": "^7.0.0", "isomorphic-git": "^1.25.10", "openai": "^4.52.7", "zod": ">=3.23.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.25.0", "@google/generative-ai": ">=0.21.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@google/generative-ai"] }, "sha512-ogXE5B55pGe/V7JKhbOZycnUzU+KvZCoi1LCMlalKy2tIggXK5kva43ixewUqnu9rVGKGDhU1HiAjQl8RkNcww=="], + "@kilocode/cli/@morphllm/morphsdk": ["@morphllm/morphsdk@0.2.148", "", { "dependencies": { "@vscode/ripgrep": "^1.17.0", "ai": ">=5.0.0", "diff": "^7.0.0", "isomorphic-git": "^1.25.10", "openai": "^4.52.7", "zod": ">=3.23.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.25.0", "@google/generative-ai": ">=0.21.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@google/generative-ai"] }, "sha512-pIABC+KR9yR567wyBGNhDEjNDR11V2CY6a2caosA5yYTP1Me0freUA4BCIfw71o7YG3I9LU1i+ZG1+vZ0Cv8dw=="], "@kilocode/kilo-docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 68ac767b42c..5f578cf2cb9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,7 +85,7 @@ "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@modelcontextprotocol/sdk": "1.25.2", - "@morphllm/morphsdk": "0.2.147", + "@morphllm/morphsdk": "0.2.148", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", From fa8297ffd2f60e9e543c3ecf5081c512b5f6a314 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 17 Mar 2026 10:00:17 +0100 Subject: [PATCH 13/14] fix: format codebaseSearch i18n strings to respect prettier line width --- packages/kilo-vscode/webview-ui/src/i18n/br.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/de.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/es.ts | 3 ++- packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index f5d9b5105d9..01618373878 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -938,7 +938,8 @@ export const dict = { "settings.experimental.batch.title": "Ferramenta em lote", "settings.experimental.batch.description": "Ativar processamento em lote de chamadas de ferramentas", "settings.experimental.codebaseSearch.title": "Pesquisa de código", - "settings.experimental.codebaseSearch.description": "Ativar pesquisa por linguagem natural com IA em toda a base de código", + "settings.experimental.codebaseSearch.description": + "Ativar pesquisa por linguagem natural com IA em toda a base de código", "settings.experimental.continueOnDeny.title": "Continuar ao negar", "settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada", "settings.experimental.mcpTimeout.title": "Tempo limite MCP (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index c4af1261512..7af8d38d825 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -948,7 +948,8 @@ export const dict = { "settings.experimental.batch.title": "Batch-Werkzeug", "settings.experimental.batch.description": "Bündelung mehrerer Werkzeugaufrufe aktivieren", "settings.experimental.codebaseSearch.title": "Codebase-Suche", - "settings.experimental.codebaseSearch.description": "KI-gestützte Suche in natürlicher Sprache über die gesamte Codebasis aktivieren", + "settings.experimental.codebaseSearch.description": + "KI-gestützte Suche in natürlicher Sprache über die gesamte Codebasis aktivieren", "settings.experimental.continueOnDeny.title": "Bei Ablehnung fortfahren", "settings.experimental.continueOnDeny.description": "Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 32e31381184..6cad8189a2f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -941,7 +941,8 @@ export const dict = { "settings.experimental.batch.title": "Herramienta por lotes", "settings.experimental.batch.description": "Habilitar procesamiento por lotes de llamadas a herramientas", "settings.experimental.codebaseSearch.title": "Búsqueda de código", - "settings.experimental.codebaseSearch.description": "Habilitar búsqueda por lenguaje natural con IA en toda la base de código", + "settings.experimental.codebaseSearch.description": + "Habilitar búsqueda por lenguaje natural con IA en toda la base de código", "settings.experimental.continueOnDeny.title": "Continuar al denegar", "settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso", "settings.experimental.mcpTimeout.title": "Tiempo de espera MCP (ms)", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index e9f57b72c8e..0edd9ad5156 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -950,7 +950,8 @@ export const dict = { "settings.experimental.batch.title": "Outil par lot", "settings.experimental.batch.description": "Activer le traitement par lot d'appels d'outils", "settings.experimental.codebaseSearch.title": "Recherche de code", - "settings.experimental.codebaseSearch.description": "Activer la recherche en langage naturel par IA dans toute la base de code", + "settings.experimental.codebaseSearch.description": + "Activer la recherche en langage naturel par IA dans toute la base de code", "settings.experimental.continueOnDeny.title": "Continuer en cas de refus", "settings.experimental.continueOnDeny.description": "Continuer la boucle de l'agent lorsqu'une autorisation est refusée", From aa2cd8f88b897bca9d69efc90adb1d28fbaa5864 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 17 Mar 2026 10:00:50 +0100 Subject: [PATCH 14/14] fix: only advertise codebase_search in explore prompt when feature flag is enabled --- packages/opencode/src/agent/agent.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 54bc4e78805..a6b282fbe23 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -240,8 +240,10 @@ export namespace Agent { user, ), description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - // kilocode_change - always include codebase_search hint; tool availability is gated by config in registry - prompt: `Prefer using the codebase_search tool for codebase searches — it performs intelligent multi-step code search and returns the most relevant code spans.\n\n${PROMPT_EXPLORE}`, + // kilocode_change - only advertise codebase_search when the experimental flag is on + prompt: cfg.experimental?.codebase_search + ? `Prefer using the codebase_search tool for codebase searches — it performs intelligent multi-step code search and returns the most relevant code spans.\n\n${PROMPT_EXPLORE}` + : PROMPT_EXPLORE, options: {}, mode: "subagent", native: true,