diff --git a/bun.lock b/bun.lock index 115c02a19e..a25e67dd0f 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", "@opencode-ai/script": "workspace:*", + "@types/bun": "1.3.5", "typescript": "catalog:", }, "devDependencies": { diff --git a/package.json b/package.json index db88a45a70..96b5d6b9a5 100644 --- a/package.json +++ b/package.json @@ -74,8 +74,9 @@ "dependencies": { "@aws-sdk/client-s3": "3.933.0", "@kilocode/plugin": "workspace:*", - "@opencode-ai/script": "workspace:*", "@kilocode/sdk": "workspace:*", + "@opencode-ai/script": "workspace:*", + "@types/bun": "1.3.5", "typescript": "catalog:" }, "repository": { diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 216a931712..ba5d40d2b2 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -64,3 +64,35 @@ Hono-based HTTP server with OpenAPI spec generation. SSE for real-time events. W ## Providers and Models Uses the **Vercel AI SDK** as the abstraction layer. Providers are loaded from a bundled map or dynamically installed at runtime. Models come from models.dev (external API), cached locally. + +## Tools + +### codebase_search + +Requires configuration in `opencode.json`: + +```jsonc +{ + "provider": { + "kilo": { + "options": { + "codebase_search": { + "embedModel": "codestral-embed-2505", // or "text-embedding-3-small" for OpenAI, "nomic-embed-text" for Ollama + "vectorDb": { + "type": "qdrant", + "url": "http://localhost:6333" + }, + "similarityThreshold": 0.4, + "maxResults": 50 + } + } + } + } +} +``` + +- **Embedding providers supported**: OpenAI (text-embedding-3-small), Mistral (codestral-embed-2505), Ollama (nomic-embed-text) +- **Vector database supported**: Qdrant +- **API keys**: Configure in auth settings (`~/.local/share/kilo/auth.json`) for openai, mistral, qdrant +- **Collection name**: Automatically generated from workspace path using SHA-256 hash +- **Requirements**: Qdrant running and accessible, collection exists with indexed data diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 28087bffeb..e152d72342 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -20,6 +20,7 @@ import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" import { WriteTool } from "../../tool/write" import { CodeSearchTool } from "../../tool/codesearch" +import { CodebaseSearchTool } from "../../tool/codebase-search" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" @@ -159,6 +160,13 @@ function codesearch(info: ToolProps) { }) } +function codebasesearch(info: ToolProps) { + inline({ + icon: "◐", + title: `Codebase Search "${info.input.query}"`, + }) +} + function websearch(info: ToolProps) { inline({ icon: "◈", @@ -408,6 +416,7 @@ export const RunCommand = cmd({ if (part.tool === "webfetch") return webfetch(props(part)) if (part.tool === "edit") return edit(props(part)) if (part.tool === "codesearch") return codesearch(props(part)) + if (part.tool === "codebase_search") return codebasesearch(props(part)) if (part.tool === "websearch") return websearch(props(part)) if (part.tool === "task") return task(props(part)) if (part.tool === "todowrite") return todo(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tool-codebase-search.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tool-codebase-search.tsx new file mode 100644 index 0000000000..fa0e4ea27e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tool-codebase-search.tsx @@ -0,0 +1,273 @@ +// kilocode_change - new file +import { TextAttributes, InputRenderable } from "@opentui/core" +import { useTheme } from "../context/theme" +import { useDialog } from "@tui/ui/dialog" +import { createStore } from "solid-js/store" +import { createEffect, onMount, createSignal, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import { CODEBASE_SEARCH_DEFAULTS } from "@/kilocode/codebase-search/types" + +export type CodebaseSearchConfig = { + embedModel: string + vectorDbType: "qdrant" | "lancedb" + qdrantUrl: string + lancedbPath: string + similarityThreshold: number + maxResults: number +} + +export type DialogToolCodebaseSearchProps = { + initialConfig?: Partial + onSave: (config: CodebaseSearchConfig) => void + onCancel?: () => void +} + +type FieldKey = "embedModel" | "vectorDbType" | "qdrantUrl" | "lancedbPath" | "similarityThreshold" | "maxResults" + +export function DialogToolCodebaseSearch(props: DialogToolCodebaseSearchProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [store, setStore] = createStore({ + embedModel: props.initialConfig?.embedModel ?? CODEBASE_SEARCH_DEFAULTS.defaultEmbedModel, + vectorDbType: props.initialConfig?.vectorDbType ?? "qdrant", + qdrantUrl: props.initialConfig?.qdrantUrl ?? CODEBASE_SEARCH_DEFAULTS.defaultQdrantUrl, + lancedbPath: props.initialConfig?.lancedbPath ?? "", + similarityThreshold: props.initialConfig?.similarityThreshold ?? CODEBASE_SEARCH_DEFAULTS.similarityThreshold, + maxResults: props.initialConfig?.maxResults ?? CODEBASE_SEARCH_DEFAULTS.maxResults, + }) + + const [activeField, setActiveField] = createSignal(0) + + // Track raw string values for number fields during editing + // This prevents "0." from becoming "0" when user is still typing + const [rawNumberValues, setRawNumberValues] = createStore>({ + similarityThreshold: String(store.similarityThreshold), + maxResults: String(store.maxResults), + }) + + let inputs: (InputRenderable | undefined)[] = [] + let scrollboxRef: any + + dialog.setSize("large") + + const fields: { key: FieldKey; label: string; placeholder: string; type: "text" | "number" | "select" }[] = [ + { key: "embedModel", label: "Embed Model", placeholder: "e.g., codestral-embed-2505", type: "text" }, + { key: "vectorDbType", label: "Vector DB Type", placeholder: "qdrant or lancedb", type: "select" }, + { key: "qdrantUrl", label: "Qdrant URL", placeholder: "http://localhost:6333", type: "text" }, + { + key: "lancedbPath", + label: "LanceDB Vector Store Path", + placeholder: "Custom vector store path (optional)", + type: "text", + }, + { key: "similarityThreshold", label: "Similarity Threshold", placeholder: "0.0 - 1.0", type: "number" }, + { key: "maxResults", label: "Max Results", placeholder: "1 - 100", type: "number" }, + ] + + // Get visible fields based on current config + const visibleFields = () => { + const result: typeof fields = [] + for (const field of fields) { + // Only show Qdrant URL if vectorDbType is qdrant + if (field.key === "qdrantUrl" && store.vectorDbType !== "qdrant") continue + // Only show LanceDB path if vectorDbType is lancedb + if (field.key === "lancedbPath" && store.vectorDbType !== "lancedb") continue + result.push(field) + } + return result + } + + onMount(() => { + setTimeout(() => { + const visible = visibleFields() + const input = inputs[0] + if (input && !input.isDestroyed && visible.length > 0) { + input.focus() + } + }, 1) + }) + + createEffect(() => { + const idx = activeField() + const visible = visibleFields() + if (idx >= 0 && idx < visible.length) { + const field = visible[idx] + const actualIdx = fields.findIndex((f) => f.key === field.key) + const input = inputs[actualIdx] + if (input && !input.isDestroyed) { + input.focus() + } + } + }) + + // Commit all input values to store before saving + function commitAllValues() { + for (const field of fields) { + if (field.key === "vectorDbType") continue + + if (field.type === "number") { + // Use raw number value for parsing + const rawValue = rawNumberValues[field.key] + if (rawValue !== undefined) { + const val = parseFloat(rawValue) + if (!isNaN(val)) { + setStore(field.key, val) + } + } + } else { + const actualIdx = fields.findIndex((f) => f.key === field.key) + const input = inputs[actualIdx] + if (input && !input.isDestroyed) { + setStore(field.key, input.value) + } + } + } + } + + useKeyboard((evt) => { + const visible = visibleFields() + const currentField = visible[activeField()] + + // Enter on select field toggles the value + if (evt.name === "return" && currentField?.key === "vectorDbType") { + evt.preventDefault() + toggleVectorDb() + return + } + + // Enter on any other field saves the form + if (evt.name === "return") { + evt.preventDefault() + commitAllValues() + handleSave() + return + } + + if (evt.name === "tab") { + evt.preventDefault() + const direction = evt.shift ? -1 : 1 + let next = activeField() + direction + if (next < 0) next = visible.length - 1 + if (next >= visible.length) next = 0 + setActiveField(next) + } + + if (evt.name === "up") { + evt.preventDefault() + const next = activeField() - 1 + if (next >= 0) setActiveField(next) + } + + if (evt.name === "down") { + evt.preventDefault() + const next = activeField() + 1 + if (next < visible.length) setActiveField(next) + } + + // Space on select field toggles the value + if (evt.name === "space" && currentField?.key === "vectorDbType") { + evt.preventDefault() + toggleVectorDb() + return + } + + if (evt.name === "escape") { + props.onCancel?.() + dialog.clear() + } + }) + + function handleSave() { + props.onSave(store) + dialog.clear() + } + + function toggleVectorDb() { + setStore("vectorDbType", store.vectorDbType === "qdrant" ? "lancedb" : "qdrant") + } + + return ( + + + + Codebase Search Configuration + + dialog.clear()}> + esc + + + + Configure semantic code search settings + + + + + {visibleFields().map((field, idx) => { + const actualIdx = fields.findIndex((f) => f.key === field.key) + const isActive = activeField() === idx + return ( + + + {field.label}: + + { + inputs[actualIdx] = r + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.text} + textColor={theme.text} + onInput={(e) => { + if (field.type === "number") { + // Store raw string value to preserve "0." while typing + setRawNumberValues(field.key, e) + // Also parse and store numeric value if valid + const val = parseFloat(e) + if (!isNaN(val)) { + setStore(field.key, val) + } + } else { + setStore(field.key, e) + } + }} + value={ + field.type === "number" + ? (rawNumberValues[field.key] ?? String(store[field.key as keyof CodebaseSearchConfig])) + : (store[field.key as keyof CodebaseSearchConfig] as string) + } + placeholder={field.placeholder} + flexGrow={1} + maxWidth={70} + /> + } + > + toggleVectorDb()}> + + {store.vectorDbType.toUpperCase()} + + + (enter to toggle) + + + ) + })} + + + + + tab/↑↓ + navigate + | + space + toggle + | + enter + save + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tool.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tool.tsx new file mode 100644 index 0000000000..3214483e3d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tool.tsx @@ -0,0 +1,179 @@ +// kilocode_change - new file +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { createMemo } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { DialogToolCodebaseSearch, type CodebaseSearchConfig } from "./dialog-tool-codebase-search" +import { useToast } from "@tui/ui/toast" +import { useSync } from "@tui/context/sync" +import { useSDK } from "@tui/context/sdk" +import { modify, applyEdits, parse } from "jsonc-parser" +import { reconcile } from "solid-js/store" +import path from "path" +import fs from "fs/promises" + +export type DialogToolProps = { + onSelect?: (tool: string) => void +} + +// Static list of tools available in the prompt menu +const TOOLS = [ + { + name: "codebase_search", + description: "Find files most relevant to the search query using semantic search", + }, +] + +async function findConfigPath(sync: ReturnType): Promise { + const projectDir = sync.data.path.directory + const globalConfigDir = sync.data.path.config + + // Priority: .opencode/opencode.jsonc > project/opencode.jsonc > global/opencode.jsonc + const candidates = [ + projectDir && path.join(projectDir, ".opencode", "opencode.jsonc"), + projectDir && path.join(projectDir, ".opencode", "opencode.json"), + projectDir && path.join(projectDir, "opencode.jsonc"), + projectDir && path.join(projectDir, "opencode.json"), + globalConfigDir && path.join(globalConfigDir, "opencode.jsonc"), + globalConfigDir && path.join(globalConfigDir, "opencode.json"), + ].filter(Boolean) as string[] + + // Find first existing config + for (const candidate of candidates) { + if (await Bun.file(candidate).exists()) { + return candidate + } + } + + // No existing config - default to project's .opencode directory + if (projectDir) { + return path.join(projectDir, ".opencode", "opencode.jsonc") + } + + // Fall back to global config + if (globalConfigDir) { + return path.join(globalConfigDir, "opencode.jsonc") + } + + // Throw error instead of returning invalid path + throw new Error("Could not determine config path: no project or global config directory available") +} + +async function readConfigFromFile(sync: ReturnType): Promise | null> { + let configPath: string | undefined + try { + configPath = await findConfigPath(sync) + const file = Bun.file(configPath) + if (!(await file.exists())) { + return null + } + + const text = await file.text() + const config = parse(text) as any + const raw = config?.provider?.kilo?.options?.codebase_search + if (!raw) return null + + const vectorDb = raw.vectorDb || {} + return { + embedModel: raw.embedModel, + vectorDbType: vectorDb.type ?? "qdrant", + qdrantUrl: vectorDb.type === "qdrant" ? (vectorDb.url ?? "") : "", + lancedbPath: vectorDb.type === "lancedb" ? (vectorDb.path ?? "") : "", + similarityThreshold: raw.similarityThreshold, + maxResults: raw.maxResults, + } + } catch (err: any) { + console.error("Failed to read config file", { configPath, err }) + return null + } +} + +export function DialogTool(props: DialogToolProps) { + const dialog = useDialog() + const toast = useToast() + const sync = useSync() + const sdk = useSDK() + dialog.setSize("large") + + async function saveCodebaseSearchConfig(config: CodebaseSearchConfig) { + try { + const configPath = await findConfigPath(sync) + + // Build the config object in the expected format + const vectorDbConfig: any = { + type: config.vectorDbType, + } + + if (config.vectorDbType === "qdrant") { + vectorDbConfig.url = config.qdrantUrl + } else { + vectorDbConfig.path = config.lancedbPath + } + + const codebaseSearchConfig = { + embedModel: config.embedModel, + vectorDb: vectorDbConfig, + similarityThreshold: config.similarityThreshold, + maxResults: config.maxResults, + } + + // Read existing config or create empty + let text = "{}" + const file = Bun.file(configPath) + if (await file.exists()) { + text = await file.text() + } + + // Use jsonc-parser to modify while preserving comments + const edits = modify(text, ["provider", "kilo", "options", "codebase_search"], codebaseSearchConfig, { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }) + const result = applyEdits(text, edits) + + // Ensure directory exists + const dir = path.dirname(configPath) + await fs.mkdir(dir, { recursive: true }) + + // Write the config + await Bun.write(configPath, result) + + // Invalidate server config cache and reload + await sdk.client.config.reload() + const configResponse = await sdk.client.config.get() + if (configResponse.data) { + sync.set("config", reconcile(configResponse.data)) + } + + toast.show({ + variant: "success", + message: "Codebase search configuration saved to " + configPath, + duration: 3000, + }) + + props.onSelect?.("codebase_search") + } catch (error) { + toast.show({ + variant: "error", + message: `Failed to save config: ${error instanceof Error ? error.message : String(error)}`, + duration: 5000, + }) + } + } + + const options = createMemo[]>(() => { + const maxWidth = Math.max(0, ...TOOLS.map((t) => t.name.length)) + return TOOLS.map((tool) => ({ + title: tool.name.padEnd(maxWidth), + description: tool.description, + value: tool.name, + category: "Tools", + onSelect: async () => { + const config = await readConfigFromFile(sync) + dialog.replace(() => ( + + )) + }, + })) + }) + + return +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d59e683799..d4bd50736d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -32,6 +32,9 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +// kilocode_change start +import { DialogTool } from "../dialog-tool" +// kilocode_change end export type PromptProps = { sessionID?: string @@ -351,6 +354,19 @@ export function Prompt(props: PromptProps) { )) }, }, + // kilocode_change start + { + title: "Tools", + value: "prompt.tools", + category: "Prompt", + slash: { + name: "tools", + }, + onSelect: () => { + dialog.replace(() => ) + }, + }, + // kilocode_change end ] }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 54f152db7e..f18ff662e9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -45,6 +45,7 @@ import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" +import type { CodebaseSearchTool } from "@/tool/codebase-search" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" @@ -1476,6 +1477,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + + + @@ -1858,6 +1862,16 @@ function WebSearch(props: ToolProps) { ) } +function CodebaseSearch(props: ToolProps) { + const input = props.input as any + const metadata = props.metadata as any + return ( + + Codebase Search "{input.query}" ({metadata.results} results) + + ) +} + function Task(props: ToolProps) { const { theme } = useTheme() const keybind = useKeybind() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index aff48d2fce..7bc5a6f761 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -239,6 +239,9 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { + + + {(() => { const meta = props.request.metadata ?? {} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 063fa5b780..a7eb93dcfe 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -43,6 +43,18 @@ export namespace Config { const log = Log.create({ service: "config" }) + // kilocode_change - Clear just the config cache without disposing of the entire instance + export async function clearCache() { + const { State } = await import("../project/state") + const initFn = (state as any)._init + if (!initFn) { + log.warn("unable to clear config cache: init function not found on state") + return + } + State.clearEntry(Instance.directory, initFn) + log.debug("config cache cleared", { directory: Instance.directory }) + } + // Managed settings directory for enterprise deployments (highest priority, admin-controlled) // These settings override all user and project settings function getManagedConfigDir(): string { @@ -1460,7 +1472,7 @@ export namespace Config { const filepath = path.join(Instance.directory, "config.json") const existing = await loadFile(filepath) await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2)) - await Instance.dispose() + await clearCache() } function globalConfigFile() { diff --git a/packages/opencode/src/kilocode/codebase-search/collection.ts b/packages/opencode/src/kilocode/codebase-search/collection.ts new file mode 100644 index 0000000000..dd9447e4ae --- /dev/null +++ b/packages/opencode/src/kilocode/codebase-search/collection.ts @@ -0,0 +1,36 @@ +// kilocode_change - new file +import { createHash } from "crypto" + +/** + * Collection naming utilities for codebase search + * Uses SHA-256 hash-based naming to match Kilo Code VSCode extension pattern + */ +export namespace CodebaseSearchCollection { + /** + * Generate collection name from workspace path + * Uses SHA-256 hash truncated to 16 hex chars + * Pattern: ws-{hash16} + * + * This matches the Kilo Code VSCode extension pattern for compatibility + * with collections created by the extension. + */ + export function generateFromWorkspace(workspacePath: string): string { + const hash = createHash("sha256").update(workspacePath).digest("hex") + return `ws-${hash.substring(0, 16)}` + } + + /** + * Get collection name for a workspace + * Returns explicit collection name if provided, otherwise generates one + */ + export function get(workspacePath: string, explicitCollection?: string): string { + return explicitCollection || generateFromWorkspace(workspacePath) + } + + /** + * Check if a collection name follows the Kilo pattern + */ + export function isKiloPattern(collectionName: string): boolean { + return /^ws-[a-f0-9]{16}$/.test(collectionName) + } +} diff --git a/packages/opencode/src/kilocode/codebase-search/config.ts b/packages/opencode/src/kilocode/codebase-search/config.ts new file mode 100644 index 0000000000..5c2eb7e3fc --- /dev/null +++ b/packages/opencode/src/kilocode/codebase-search/config.ts @@ -0,0 +1,69 @@ +// kilocode_change - new file +import { Config } from "@/config/config" +import { CodebaseSearchTypes, CODEBASE_SEARCH_DEFAULTS } from "./types" + +export namespace CodebaseSearchConfig { + /** + * Get codebase search configuration from Kilo provider options + * Returns null if not configured + */ + export async function get(): Promise { + const config = await Config.get() + const raw = config.provider?.kilo?.options?.codebase_search + + if (!raw) return null + + // Validate and return + const result = CodebaseSearchTypes.Config.safeParse(raw) + return result.success ? result.data : null + } + + /** + * Check if codebase search is configured + */ + export async function isConfigured(): Promise { + const config = await get() + return config !== null + } + + /** + * Get configuration with defaults applied + */ + export async function getWithDefaults(): Promise<{ + config: CodebaseSearchTypes.Config + similarityThreshold: number + maxResults: number + } | null> { + const config = await get() + if (!config) return null + + return { + config, + similarityThreshold: config.similarityThreshold ?? CODEBASE_SEARCH_DEFAULTS.similarityThreshold, + maxResults: config.maxResults ?? CODEBASE_SEARCH_DEFAULTS.maxResults, + } + } + + /** + * Generate example configuration for error messages + */ + export function getExampleConfig(): object { + return { + provider: { + kilo: { + options: { + codebase_search: { + embedModel: CODEBASE_SEARCH_DEFAULTS.defaultEmbedModel, + vectorDb: { + type: "qdrant", + url: CODEBASE_SEARCH_DEFAULTS.defaultQdrantUrl, + }, + similarityThreshold: CODEBASE_SEARCH_DEFAULTS.similarityThreshold, + maxResults: CODEBASE_SEARCH_DEFAULTS.maxResults, + }, + }, + }, + }, + } + } +} diff --git a/packages/opencode/src/kilocode/codebase-search/embeddings.ts b/packages/opencode/src/kilocode/codebase-search/embeddings.ts new file mode 100644 index 0000000000..f319e0893b --- /dev/null +++ b/packages/opencode/src/kilocode/codebase-search/embeddings.ts @@ -0,0 +1,191 @@ +// kilocode_change - new file +import { Auth } from "@/auth" +import { CodebaseSearchTypes } from "./types" + +/** + * Embedding provider implementations for codebase search + * Supports OpenAI, Mistral, and Ollama (local) embedding providers + */ +export namespace CodebaseSearchEmbeddings { + /** + * Map embed model name to provider and model ID + */ + export function getProvider(model: string): CodebaseSearchTypes.EmbeddingProvider { + const lowerModel = model.toLowerCase() + + if (lowerModel.includes("text-embedding")) { + return { provider: "openai", modelId: model } + } + if (lowerModel.includes("codestral") || lowerModel.includes("mistral")) { + return { provider: "mistral", modelId: model } + } + if (lowerModel.includes("nomic")) { + return { provider: "ollama", modelId: model } + } + if (lowerModel.includes("openai")) { + return { provider: "openai", modelId: model } + } + + // Default to OpenAI + return { provider: "openai", modelId: "text-embedding-3-small" } + } + + /** + * Generate embedding using OpenAI + */ + export async function generateOpenAI( + text: string, + apiKey: string, + model = "text-embedding-3-small", + ): Promise { + const response = await fetch("https://api.openai.com/v1/embeddings", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + input: text, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`OpenAI embeddings API error (${response.status}): ${errorText}`) + } + + const data = await response.json() + const [firstResult] = data?.data || [] + const { embedding } = firstResult || {} + + if (!embedding) { + throw new Error("OpenAI returned no embedding data") + } + + return embedding + } + + /** + * Generate embedding using Mistral + */ + export async function generateMistral( + text: string, + apiKey: string, + model = "codestral-embed-2505", + ): Promise { + const response = await fetch("https://api.mistral.ai/v1/embeddings", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + input: text, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Mistral embeddings API error (${response.status}): ${errorText}`) + } + + const data = await response.json() + const [firstResult] = data?.data || [] + const { embedding } = firstResult || {} + + if (!embedding) { + throw new Error("Mistral returned no embedding data") + } + + return embedding + } + + /** + * Generate embedding using Ollama (local) + */ + export async function generateOllama(text: string, model = "nomic-embed-text"): Promise { + const response = await fetch("http://localhost:11434/api/embeddings", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + prompt: text, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Ollama embeddings API error (${response.status}): ${errorText}`) + } + + const data = await response.json() + const { embedding } = data + + if (!embedding) { + throw new Error("Ollama returned no embedding data") + } + + return embedding + } + + /** + * Auth map type for provider credentials + */ + type AuthMap = Map + + /** + * Generate embedding using the configured provider + */ + export async function generate(text: string, embedModel: string, authMap: AuthMap): Promise { + const { provider, modelId } = getProvider(embedModel) + + if (provider === "openai") { + const openaiAuth = authMap.get("openai") + if (!openaiAuth) { + throw new Error("OpenAI API key not found. Please configure openai provider in auth settings.") + } + const apiKey = openaiAuth.type === "oauth" ? openaiAuth.access : openaiAuth.key + if (!apiKey) { + throw new Error("OpenAI API key not found in auth configuration.") + } + return generateOpenAI(text, apiKey, modelId) + } + + if (provider === "mistral") { + const mistralAuth = authMap.get("mistral") + if (!mistralAuth) { + throw new Error("Mistral API key not found. Please configure mistral provider in auth settings.") + } + const apiKey = mistralAuth.type === "oauth" ? mistralAuth.access : mistralAuth.key + if (!apiKey) { + throw new Error("Mistral API key not found in auth configuration.") + } + return generateMistral(text, apiKey, modelId) + } + + if (provider === "ollama") { + return generateOllama(text, modelId) + } + + throw new Error(`Unsupported embedding provider: ${provider}. Supported providers: openai, mistral, ollama`) + } + + /** + * Build auth map from stored auth credentials + */ + export async function buildAuthMap(): Promise { + const authMap: AuthMap = new Map() + + const openaiAuth = await Auth.get("openai") + const mistralAuth = await Auth.get("mistral") + + if (openaiAuth) authMap.set("openai", openaiAuth) + if (mistralAuth) authMap.set("mistral", mistralAuth) + + return authMap + } +} diff --git a/packages/opencode/src/kilocode/codebase-search/index.ts b/packages/opencode/src/kilocode/codebase-search/index.ts new file mode 100644 index 0000000000..48a4e5f4a3 --- /dev/null +++ b/packages/opencode/src/kilocode/codebase-search/index.ts @@ -0,0 +1,19 @@ +// kilocode_change - new file +/** + * Kilo Code codebase search module + * + * This module provides Kilo-specific configuration and utilities for + * the codebase-search tool. It extracts Kilo-specific logic from the + * shared tool implementation to minimize merge conflicts with upstream. + * + * Components: + * - types.ts: Configuration schemas and types + * - config.ts: Configuration loading and validation + * - collection.ts: Collection naming (matches VSCode extension pattern) + * - embeddings.ts: Embedding provider implementations + */ + +export { CodebaseSearchTypes, CODEBASE_SEARCH_DEFAULTS } from "./types" +export { CodebaseSearchConfig } from "./config" +export { CodebaseSearchCollection } from "./collection" +export { CodebaseSearchEmbeddings } from "./embeddings" diff --git a/packages/opencode/src/kilocode/codebase-search/types.ts b/packages/opencode/src/kilocode/codebase-search/types.ts new file mode 100644 index 0000000000..851feae2ac --- /dev/null +++ b/packages/opencode/src/kilocode/codebase-search/types.ts @@ -0,0 +1,66 @@ +// kilocode_change - new file +import z from "zod" + +/** + * Configuration types for codebase search + * These types define the Kilo-specific configuration schema for semantic code search + */ +export namespace CodebaseSearchTypes { + /** + * Supported vector database types + */ + export const VectorDbType = z.enum(["qdrant", "lancedb"]) + export type VectorDbType = z.infer + + /** + * Vector database configuration + */ + export const VectorDbConfig = z.object({ + type: VectorDbType, + url: z.string().optional(), + collection: z.string().optional(), + }) + export type VectorDbConfig = z.infer + + /** + * Full codebase search configuration + */ + export const Config = z.object({ + embedModel: z.string(), + vectorDb: VectorDbConfig, + similarityThreshold: z.number().min(0).max(1).optional(), + maxResults: z.number().int().positive().optional(), + }) + export type Config = z.infer + + /** + * Search result from vector database + */ + export const SearchResult = z.object({ + filePath: z.string(), + score: z.number(), + startLine: z.number(), + endLine: z.number(), + codeChunk: z.string(), + }) + export type SearchResult = z.infer + + /** + * Embedding provider info + */ + export const EmbeddingProvider = z.object({ + provider: z.string(), + modelId: z.string(), + }) + export type EmbeddingProvider = z.infer +} + +/** + * Default configuration values + */ +export const CODEBASE_SEARCH_DEFAULTS = { + similarityThreshold: 0.4, + maxResults: 50, + defaultEmbedModel: "codestral-embed-2505", + defaultQdrantUrl: "http://localhost:6333", +} as const diff --git a/packages/opencode/src/kilocode/index.ts b/packages/opencode/src/kilocode/index.ts index 261ff142c7..9f1c33cb85 100644 --- a/packages/opencode/src/kilocode/index.ts +++ b/packages/opencode/src/kilocode/index.ts @@ -5,3 +5,6 @@ export { McpMigrator } from "./mcp-migrator" export { IgnoreMigrator } from "./ignore-migrator" // kilocode_change export { KilocodeConfigInjector } from "./config-injector" export { KilocodePaths } from "./paths" +// kilocode_change start - export codebase-search module +export * as CodebaseSearch from "./codebase-search" +// kilocode_change end diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3..f35547e3c7 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -64,7 +64,8 @@ export const Instance = { return Filesystem.contains(Instance.worktree, filepath) }, state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { - return State.create(() => Instance.directory, init, dispose) + const fn = State.create(() => Instance.directory, init, dispose) + return Object.assign(fn, { _init: init }) }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5..2e74219a3d 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -67,4 +67,10 @@ export namespace State { disposalFinished = true log.info("state disposal completed", { key }) } + + export function clearEntry(key: string, init: () => any) { + const entries = recordsByKey.get(key) + if (!entries) return + entries.delete(init) + } } diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts index e6ac26bb0d..575be4dbf2 100644 --- a/packages/opencode/src/server/routes/config.ts +++ b/packages/opencode/src/server/routes/config.ts @@ -62,6 +62,30 @@ export const ConfigRoutes = lazy(() => return c.json(config) }, ) + // kilocode_change start - Add reload endpoint to invalidate config cache + .post( + "/reload", + describeRoute({ + summary: "Reload configuration", + description: "Invalidate config cache and reload from files on next access.", + operationId: "config.reload", + responses: { + 200: { + description: "Config cache invalidated", + content: { + "application/json": { + schema: resolver(z.object({ success: z.boolean() })), + }, + }, + }, + }, + }), + async (c) => { + await Config.clearCache() + return c.json({ success: true }) + }, + ) + // kilocode_change end .get( "/providers", describeRoute({ diff --git a/packages/opencode/src/tool/codebase-search.ts b/packages/opencode/src/tool/codebase-search.ts new file mode 100644 index 0000000000..70ab9b2bc8 --- /dev/null +++ b/packages/opencode/src/tool/codebase-search.ts @@ -0,0 +1,224 @@ +import z from "zod" +import { Tool } from "./tool" +import { Instance } from "../project/instance" +import { Auth } from "../auth" +import { Log } from "../util/log" +import DESCRIPTION from "./codebase-search.txt" +// kilocode_change start - use Kilo-specific modules +import { CodebaseSearchConfig, CodebaseSearchCollection, CodebaseSearchEmbeddings } from "@/kilocode/codebase-search" +// kilocode_change end + +const log = Log.create({ service: "tool.codebase-search" }) + +interface QdrantSearchResponse { + status: string + result: Array<{ + id: string + score: number + payload: { + filePath: string + startLine: number + endLine: number + codeChunk: string + [key: string]: any + } + }> +} + +// Helper function to search Qdrant +async function searchQdrant( + vector: number[], + qdrantUrl: string, + collection: string, + apiKey: string, + limit: number, + pathPrefix?: string, +): Promise> { + const url = new URL(qdrantUrl) + url.pathname = `/collections/${collection}/points/search` + + const headers: Record = { + "Content-Type": "application/json", + "api-key": apiKey, + } + + const body: any = { + vector, + limit, + with_payload: true, + score_threshold: 0, + } + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(body), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Qdrant search API error (${response.status}): ${errorText}`) + } + + const data = (await response.json()) as QdrantSearchResponse + + let results = data.result.map((item) => ({ + filePath: item.payload.filePath, + score: item.score, + startLine: item.payload.startLine, + endLine: item.payload.endLine, + codeChunk: item.payload.codeChunk, + })) + + if (pathPrefix) { + const normalizedPrefix = pathPrefix.startsWith("/") ? pathPrefix.slice(1) : pathPrefix + results = results.filter((r) => { + const normalizedPath = r.filePath.startsWith("/") ? r.filePath.slice(1) : r.filePath + return normalizedPath.startsWith(normalizedPrefix) + }) + } + + return results +} + +// Helper function to format search results +export function formatResults( + query: string, + results: Array<{ filePath: string; score: number; startLine: number; endLine: number; codeChunk: string }>, +): string { + if (!results || results.length === 0) { + return `No relevant code snippets found for query: "${query}"` + } + + let output = `Query: ${query}\nResults:\n\n` + + for (const result of results) { + output += `File path: ${result.filePath}\n` + output += `Score: ${result.score.toFixed(3)}\n` + output += `Lines: ${result.startLine}-${result.endLine}\n` + if (result.codeChunk) { + output += `Code Chunk:\n${result.codeChunk.trim()}\n` + } + output += "\n" + } + + return output +} + +export const CodebaseSearchTool = Tool.define("codebase_search", { + description: DESCRIPTION, + parameters: z.object({ + query: z.string().describe("The search query in natural language (required)"), + path: z.string().describe("Optional directory path to filter results (relative to workspace)").default(""), + }), + async execute(params, ctx) { + const workspacePath = Instance.worktree || Instance.directory + if (!workspacePath) { + throw new Error("No workspace directory found") + } + + // Ask for permission + await ctx.ask({ + permission: "codebase_search", + patterns: [params.query, ...(params.path ? [params.path] : [])], + always: ["*"], + metadata: { + query: params.query, + path: params.path, + }, + }) + + // kilocode_change start - use Kilo-specific config module + const configResult = await CodebaseSearchConfig.getWithDefaults() + + if (!configResult) { + throw new Error( + "Codebase search is not configured. Please configure in opencode.json:\n" + + JSON.stringify(CodebaseSearchConfig.getExampleConfig(), null, 2), + ) + } + + const { config, similarityThreshold, maxResults } = configResult + // kilocode_change end + + const { embedModel, vectorDb } = config + + if (!embedModel) { + throw new Error( + "embedModel is not configured. Please set provider.kilo.options.codebase_search.embedModel in opencode.json", + ) + } + + if (!vectorDb) { + throw new Error( + "vectorDb is not configured. Please set provider.kilo.options.codebase_search.vectorDb in opencode.json", + ) + } + + if (vectorDb.type === "qdrant") { + if (!vectorDb.url) { + throw new Error( + "Qdrant URL is not configured. Please set provider.kilo.options.codebase_search.vectorDb.url in opencode.json", + ) + } + } else if (vectorDb.type === "lancedb") { + throw new Error("LanceDB is not yet supported for codebase search. Please use Qdrant.") + } else { + throw new Error(`Unsupported vector database type: ${vectorDb.type}. Supported types: qdrant`) + } + + // kilocode_change start - use Kilo-specific collection naming + const collection = CodebaseSearchCollection.get(workspacePath, vectorDb.collection) + // kilocode_change end + + // Get auth keys + const qdrantAuth = await Auth.get("qdrant") + + if (!qdrantAuth) { + throw new Error("Qdrant API key not found. Please configure qdrant provider in auth settings.") + } + const qdrantApiKey = qdrantAuth.type === "oauth" ? qdrantAuth.access : qdrantAuth.key + if (!qdrantApiKey) { + throw new Error("Qdrant API key not found in auth configuration.") + } + + // kilocode_change start - use Kilo-specific embeddings module + const authMap = await CodebaseSearchEmbeddings.buildAuthMap() + // kilocode_change end + + try { + // kilocode_change start - use Kilo-specific embeddings module + const embedding = await CodebaseSearchEmbeddings.generate(params.query, embedModel, authMap) + // kilocode_change end + + const results = await searchQdrant( + embedding, + vectorDb.url!, + collection, + qdrantApiKey, + maxResults, + params.path || undefined, + ) + + const filteredResults = results.filter((result) => result.score >= similarityThreshold) + const limitedResults = filteredResults.slice(0, maxResults) + const output = formatResults(params.query, limitedResults) + + return { + title: `Codebase search: ${params.query}`, + output, + metadata: { results: limitedResults.length }, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + if (errorMessage.includes("not found") || errorMessage.includes("not configured")) { + throw error + } + + throw new Error( + `Codebase search failed: ${errorMessage}\n\nPlease ensure:\n1. Your vector database (${vectorDb.type}) is running and accessible\n2. The collection "${collection}" exists and has indexed data\n3. Your embedding provider API key is configured correctly`, + ) + } + }, +}) diff --git a/packages/opencode/src/tool/codebase-search.txt b/packages/opencode/src/tool/codebase-search.txt new file mode 100644 index 0000000000..61ffea1813 --- /dev/null +++ b/packages/opencode/src/tool/codebase-search.txt @@ -0,0 +1,98 @@ +Use this tool to find files most relevant to a search query using semantic search. + +This tool searches the codebase based on meaning rather than exact text matches, making it far more effective than regex-based search for understanding implementations. By default, it searches the entire workspace. + +**CRITICAL: For ANY exploration of code you haven't examined yet in this conversation, you MUST use this tool FIRST before any other search or file exploration tools.** This applies throughout the entire conversation, not just at the beginning. Even if you've already explored some code, any new area of exploration requires codebase_search first. + +## Parameters + +- **query** (required): The search query in natural language. Reuse the user's exact wording/question format unless there's a clear reason not to. Queries MUST be in English (translate if needed). + +- **path** (optional): Limit search to a specific subdirectory (relative to the current workspace directory). Leave empty or omit to search the entire workspace. + +## Usage Notes + +- This tool uses local cloud indexing with your own embedding provider and vector database +- **Embedding providers supported**: OpenAI (text-embedding-3-small), Mistral (codestral-embed-2505), Ollama (nomic-embed-text) +- **Vector database supported**: Qdrant +- Requires configuration in `opencode.json` with embedding model and Qdrant connection details +- Requires API keys configured in auth settings for your chosen embedding provider +- Results include file path, relevance score, line numbers, and code snippets +- Results are filtered by similarity threshold (default: 0.4) and limited to maxResults (default: 50) + +## Configuration + +Configure in `opencode.json`: + +```json +{ + "provider": { + "kilo": { + "options": { + "codebase_search": { + "embedModel": "codestral-embed-2505", + "vectorDb": { + "type": "qdrant", + "url": "http://localhost:6333" + }, + "similarityThreshold": 0.4, + "maxResults": 50 + } + } + } + } +} +``` + +**Collection name** is automatically generated from workspace path using SHA-256 hash (e.g., `ws-a1b2c3d4e5f6g7h8`). This matches Kilo Code VSCode extension pattern. You can optionally override this by specifying `collection` in `vectorDb` config. + +Set API keys in auth settings (`~/.local/share/kilo/auth.json`): +- `openai`: For OpenAI embeddings (required if using OpenAI as embedModel) +- `mistral`: For Mistral embeddings (required if using Mistral as embedModel) +- `qdrant`: For Qdrant authentication (**required**) + +## Directory Scoping + +Use the optional path parameter to focus searches on specific parts of your codebase: + +- Search within API modules: `path: "src/api"` +- Search in test files: `path: "tests"` +- Search specific feature directories: `path: "src/components/auth"` + +## Usage Examples + +### Searching for authentication code in a specific directory +``` +query: "user login and authentication logic" +path: "src/auth" +``` + +### Searching for entire workspace +``` +query: "environment variables and application configuration" +path: (empty) +``` + +### Searching for database-related code in a specific directory +``` +query: "database connection and query execution" +path: "src/data" +``` + +### Looking for error handling patterns in API code +``` +query: "HTTP error responses and exception handling" +path: "src/api" +``` + +### Searching for testing utilities and mock setups +``` +query: "test setup and mock data creation" +path: "tests" +``` + +### Searching for React hooks +``` +query: "useState hook implementation" +path: "src/components" +``` diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5d61cfa48a..355ee44932 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -21,12 +21,16 @@ import z from "zod" import { Plugin } from "../plugin" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" +import { CodebaseSearchTool } from "./codebase-search" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" import { ApplyPatchTool } from "./apply_patch" +// kilocode_change start - import for codebase-search config check +import { CodebaseSearchConfig } from "@/kilocode/codebase-search" +// kilocode_change end export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -94,6 +98,9 @@ export namespace ToolRegistry { async function all(): Promise { const custom = await state().then((x) => x.custom) const config = await Config.get() + // kilocode_change start - check if codebase-search is configured + const codebaseSearchConfigured = await CodebaseSearchConfig.isConfigured() + // kilocode_change end return [ InvalidTool, @@ -110,6 +117,9 @@ export namespace ToolRegistry { // TodoReadTool, WebSearchTool, CodeSearchTool, + // kilocode_change start - only include codebase-search if configured + ...(codebaseSearchConfigured ? [CodebaseSearchTool] : []), + // kilocode_change end SkillTool, ApplyPatchTool, ...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), diff --git a/packages/opencode/test/kilocode/codebase-search.test.ts b/packages/opencode/test/kilocode/codebase-search.test.ts new file mode 100644 index 0000000000..e61df85faa --- /dev/null +++ b/packages/opencode/test/kilocode/codebase-search.test.ts @@ -0,0 +1,202 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { CodebaseSearchCollection } from "@/kilocode/codebase-search/collection" +import { CodebaseSearchTypes, CODEBASE_SEARCH_DEFAULTS } from "@/kilocode/codebase-search/types" +import { CodebaseSearchEmbeddings } from "@/kilocode/codebase-search/embeddings" +import { formatResults } from "@/tool/codebase-search" + +describe("CodebaseSearchCollection", () => { + test("generates consistent collection names from workspace paths", () => { + const workspacePath = "/Users/test/projects/my-project" + const name1 = CodebaseSearchCollection.generateFromWorkspace(workspacePath) + const name2 = CodebaseSearchCollection.generateFromWorkspace(workspacePath) + + expect(name1).toBe(name2) + expect(name1).toMatch(/^ws-[a-f0-9]{16}$/) + }) + + test("generates different names for different paths", () => { + const name1 = CodebaseSearchCollection.generateFromWorkspace("/path/to/project-a") + const name2 = CodebaseSearchCollection.generateFromWorkspace("/path/to/project-b") + + expect(name1).not.toBe(name2) + }) + + test("returns explicit collection when provided", () => { + const explicitName = "my-custom-collection" + const result = CodebaseSearchCollection.get("/any/path", explicitName) + + expect(result).toBe(explicitName) + }) + + test("generates collection when explicit name not provided", () => { + const workspacePath = "/path/to/project" + const result = CodebaseSearchCollection.get(workspacePath) + const expected = CodebaseSearchCollection.generateFromWorkspace(workspacePath) + + expect(result).toBe(expected) + }) + + test("detects Kilo pattern correctly", () => { + const validName = CodebaseSearchCollection.generateFromWorkspace("/some/path") + expect(CodebaseSearchCollection.isKiloPattern(validName)).toBe(true) + + expect(CodebaseSearchCollection.isKiloPattern("my-collection")).toBe(false) + expect(CodebaseSearchCollection.isKiloPattern("ws-12345")).toBe(false) + expect(CodebaseSearchCollection.isKiloPattern("ws-ghijklmnopqrst")).toBe(false) + }) +}) + +describe("CodebaseSearchTypes", () => { + test("validates valid config", () => { + const validConfig = { + embedModel: "codestral-embed-2505", + vectorDb: { + type: "qdrant" as const, + url: "http://localhost:6333", + }, + similarityThreshold: 0.5, + maxResults: 25, + } + + const result = CodebaseSearchTypes.Config.safeParse(validConfig) + expect(result.success).toBe(true) + }) + + test("rejects invalid vector db type", () => { + const invalidConfig = { + embedModel: "text-embedding-3-small", + vectorDb: { + type: "invalid", + url: "http://localhost:6333", + }, + } + + const result = CodebaseSearchTypes.Config.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + test("rejects similarity threshold out of range", () => { + const invalidConfig = { + embedModel: "text-embedding-3-small", + vectorDb: { + type: "qdrant" as const, + }, + similarityThreshold: 1.5, + } + + const result = CodebaseSearchTypes.Config.safeParse(invalidConfig) + expect(result.success).toBe(false) + }) + + test("applies defaults correctly", () => { + expect(CODEBASE_SEARCH_DEFAULTS.similarityThreshold).toBe(0.4) + expect(CODEBASE_SEARCH_DEFAULTS.maxResults).toBe(50) + expect(CODEBASE_SEARCH_DEFAULTS.defaultEmbedModel).toBe("codestral-embed-2505") + }) +}) + +describe("CodebaseSearchEmbeddings", () => { + test("detects OpenAI embedding provider", () => { + expect(CodebaseSearchEmbeddings.getProvider("text-embedding-3-small")).toEqual({ + provider: "openai", + modelId: "text-embedding-3-small", + }) + expect(CodebaseSearchEmbeddings.getProvider("text-embedding-3-large")).toEqual({ + provider: "openai", + modelId: "text-embedding-3-large", + }) + expect(CodebaseSearchEmbeddings.getProvider("openai-embedding")).toEqual({ + provider: "openai", + modelId: "openai-embedding", + }) + }) + + test("detects Mistral embedding provider", () => { + expect(CodebaseSearchEmbeddings.getProvider("codestral-embed-2505")).toEqual({ + provider: "mistral", + modelId: "codestral-embed-2505", + }) + expect(CodebaseSearchEmbeddings.getProvider("mistral-embed")).toEqual({ + provider: "mistral", + modelId: "mistral-embed", + }) + }) + + test("detects Ollama embedding provider", () => { + expect(CodebaseSearchEmbeddings.getProvider("nomic-embed-text")).toEqual({ + provider: "ollama", + modelId: "nomic-embed-text", + }) + }) + + test("defaults to OpenAI for unknown models", () => { + expect(CodebaseSearchEmbeddings.getProvider("unknown-model")).toEqual({ + provider: "openai", + modelId: "text-embedding-3-small", + }) + }) +}) + +describe("formatResults", () => { + test("returns 'no results' message for empty results", () => { + const output = formatResults("test query", []) + expect(output).toBe('No relevant code snippets found for query: "test query"') + }) + + test("returns 'no results' message for null/undefined results", () => { + expect(formatResults("test", null as any)).toContain("No relevant code snippets found") + expect(formatResults("test", undefined as any)).toContain("No relevant code snippets found") + }) + + test("filters results below similarity threshold", () => { + const results = [ + { filePath: "/src/a.ts", score: 0.8, startLine: 1, endLine: 10, codeChunk: "code a" }, + { filePath: "/src/b.ts", score: 0.2, startLine: 5, endLine: 15, codeChunk: "code b" }, + ] + const filtered = results.filter((r) => r.score >= 0.5) + const output = formatResults("test", filtered) + expect(output).toContain("/src/a.ts") + expect(output).not.toContain("/src/b.ts") + }) + + test("returns message when all results below threshold", () => { + const results = [{ filePath: "/src/a.ts", score: 0.1, startLine: 1, endLine: 10, codeChunk: "code a" }] + const filtered = results.filter((r) => r.score >= 0.5) + const output = formatResults("test", filtered) + expect(output).toContain("No relevant code snippets found") + }) + + test("limits results to maxResults", () => { + const results = [ + { filePath: "/src/a.ts", score: 0.9, startLine: 1, endLine: 10, codeChunk: "code a" }, + { filePath: "/src/b.ts", score: 0.8, startLine: 5, endLine: 15, codeChunk: "code b" }, + { filePath: "/src/c.ts", score: 0.7, startLine: 20, endLine: 30, codeChunk: "code c" }, + ] + const filtered = results.filter((r) => r.score >= 0.5).slice(0, 2) + const output = formatResults("test", filtered) + expect(output).toContain("/src/a.ts") + expect(output).toContain("/src/b.ts") + expect(output).not.toContain("/src/c.ts") + }) + + test("formats output with all fields", () => { + const results = [ + { filePath: "/src/test.ts", score: 0.856, startLine: 10, endLine: 25, codeChunk: " function hello() {} " }, + ] + const output = formatResults("my query", results) + expect(output).toContain("Query: my query") + expect(output).toContain("File path: /src/test.ts") + expect(output).toContain("Score: 0.856") + expect(output).toContain("Lines: 10-25") + expect(output).toContain("Code Chunk:") + expect(output).toContain("function hello() {}") // trimmed + }) + + test("handles results without codeChunk", () => { + const results = [{ filePath: "/src/test.ts", score: 0.8, startLine: 1, endLine: 5, codeChunk: "" }] + const output = formatResults("test", results) + expect(output).toContain("File path: /src/test.ts") + expect(output).not.toContain("Code Chunk:") + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0609acc42a..fddc14f73f 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -19,6 +19,7 @@ import type { Config as Config3, ConfigGetResponses, ConfigProvidersResponses, + ConfigReloadResponses, ConfigUpdateErrors, ConfigUpdateResponses, EventSubscribeResponses, @@ -711,6 +712,25 @@ export class Config2 extends HeyApiClient { }) } + /** + * Reload configuration + * + * Invalidate config cache and reload from files. + */ + public reload( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).post({ + url: "/config/reload", + ...options, + ...params, + }) + } + /** * List config providers * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index bc057ba244..3f8c8aa018 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2717,6 +2717,26 @@ export type ConfigUpdateResponses = { export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type ConfigReloadData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/config/reload" +} + +export type ConfigReloadResponses = { + /** + * Config cache invalidated + */ + 200: { + success: boolean + } +} + +export type ConfigReloadResponse = ConfigReloadResponses[keyof ConfigReloadResponses] + export type ConfigProvidersData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ab3654da20..a39fc48448 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -854,6 +854,46 @@ ] } }, + "/config/reload": { + "post": { + "operationId": "config.reload", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Reload configuration", + "description": "Invalidate config cache and reload from files.", + "responses": { + "200": { + "description": "Config cache invalidated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": ["success"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@kilocode/sdk\n\nconst client = createOpencodeClient()\nawait client.config.reload({\n ...\n})" + } + ] + } + }, "/config/providers": { "get": { "operationId": "config.providers", diff --git a/plans/codebase-search-to-opencode-plan.md b/plans/codebase-search-to-opencode-plan.md new file mode 100644 index 0000000000..5ffe2e7992 --- /dev/null +++ b/plans/codebase-search-to-opencode-plan.md @@ -0,0 +1,787 @@ +# Plan: Create codebase_search as OpenCode Custom Tool + +## Summary + +Create a `codebase_search` custom tool for OpenCode that provides semantic code search using AI embeddings. The tool will: + +- Use OpenCode's documented custom tool pattern (https://opencode.ai/docs/custom-tools) +- Follow exact pattern from existing tools (github-pr-search.ts, github-triage.ts) +- Support both managed (cloud) and local indexing +- Use OpenCode's centralized Config and Auth modules +- Be installed as a custom tool in `.opencode/tool/` directory + +## Analysis of Existing Tool Patterns + +### Tool Structure Pattern (from github-pr-search.ts and github-triage.ts) + +```typescript +/// +import { tool } from "@kilocode/plugin" +import DESCRIPTION from "./tool-name.txt" + +// Optional helper functions +async function helperFunction() { + // implementation +} + +export default tool({ + description: DESCRIPTION, + args: { + paramName: tool.schema.string().describe("Description").default("default value"), + optionalParam: tool.schema.number().describe("Description").default(0), + enumParam: tool.schema.enum(["option1", "option2"]).describe("Description").default("option1"), + arrayParam: tool.schema + .array(tool.schema.enum(["a", "b"])) + .describe("Description") + .default([]), + }, + async execute(args) { + // Implementation + return "formatted result string" + }, +}) +``` + +### Key Pattern Elements: + +1. **Import pattern**: `import { tool } from "@kilocode/plugin"` +2. **Description import**: `import DESCRIPTION from "./tool-name.txt"` +3. **Schema methods**: + - `tool.schema.string()` - for string parameters + - `tool.schema.number()` - for numeric parameters + - `tool.schema.enum([...])` - for enum parameters + - `tool.schema.array(...)` - for array parameters +4. **Schema methods**: `.describe()` and `.default()` +5. **Return value**: Formatted string (not JSON) + +### Description File Pattern (from github-pr-search.txt and github-triage.txt) + +- Clear usage instructions +- Examples of how to use the tool +- Detailed parameter descriptions +- Context about what the tool does + +## Analysis of codebase_search from Kilocode VSCode Extension + +### Key Features + +- **Semantic Understanding**: Finds code by meaning rather than exact keyword matches +- **Cross-Project Search**: Searches across your entire indexed codebase, not just open files +- **Contextual Results**: Returns code snippets with file paths and line numbers for easy navigation +- **Similarity Scoring**: Results ranked by relevance with similarity scores (0-1 scale) +- **Scope Filtering**: Optional path parameter to limit searches to specific directories +- **Intelligent Ranking**: Results sorted by semantic relevance to your query +- **UI Integration**: Results displayed with syntax highlighting and navigation links +- **Performance Optimized**: Fast vector-based search with configurable result limits + +### When is it used? + +- When Kilo Code needs to find code related to specific functionality across your project +- When looking for implementation patterns or similar code structures +- When searching for error handling, authentication, or other conceptual code patterns +- When exploring unfamiliar codebases to understand how features are implemented +- When finding related code that might be affected by changes or refactoring + +### Tool Behavior (from CodebaseSearchTool.ts) + +The codebase_search tool in kilocode has the following characteristics: + +1. **Two search modes**: + - **Managed indexing** (Kilo Gateway API) - tried first + - **Local indexing** (CodeIndexManager) - fallback + +2. **Parameters**: + - `query` (required): The search query + - `path` (optional): Directory to limit search scope + +3. **Return format**: + +``` +Query: {query} +Results: + +File path: {relativePath} +Score: {score} +Lines: {startLine}-{endLine} +Code Chunk: {codeChunk} +``` + +4. **Error handling**: + - Workspace path not found + - Query missing + - Indexing not ready (Indexing, Standby, Error states) + - Indexing not configured + - No results found + +5. **Status messages** (when indexing not ready): + - "Code indexing is still running" + - "Code indexing has not started" + - "Code indexing is in an error state" + - "Code indexing is not ready" + +### Requirements + +This tool is only available when the Codebase Indexing feature is properly configured: + +- **Feature Configured**: Codebase Indexing must be configured in settings +- **Embedding Provider**: OpenAI API key, Mistral, or Ollama configuration required +- **Vector Database**: Qdrant instance running and accessible +- **Index Status**: Codebase must be indexed (status: "Indexed" or "Indexing") + +**Supported Embedding Models**: + +- `codestral-embed-2505` (Mistral) - default, code-optimized +- `text-embedding-3-small` (OpenAI) +- Other models via Ollama + +### Limitations + +- **Requires Configuration**: Depends on external services (embedding provider + Qdrant) +- **Index Dependency**: Only searches through indexed code blocks +- **Result Limits**: Maximum of 50 results per search to maintain performance +- **Similarity Threshold**: Only returns results above similarity threshold (default: 0.4, configurable) +- **File Size Limits**: Limited to files under 1MB that were successfully indexed +- **Language Support**: Effectiveness depends on Tree-sitter language support + +### How It Works + +When the codebase_search tool is invoked, it follows this process: + +1. **Availability Validation**: + - Verifies that the CodeIndexManager is available and initialized + - Confirms codebase indexing is enabled in settings + - Checks that indexing is properly configured (API keys, Qdrant URL) + - Validates the current index state allows searching + +2. **Query Processing**: + - Takes your natural language query and generates an embedding vector + - Uses the same embedding provider configured for indexing (OpenAI or Ollama) + - Converts the semantic meaning of your query into a mathematical representation + +3. **Vector Search Execution**: + - Searches the Qdrant vector database for similar code embeddings + - Uses cosine similarity to find the most relevant code blocks + - Applies the minimum similarity threshold (default: 0.4, configurable) to filter results + - Limits results to 50 matches for optimal performance + +4. **Path Filtering** (if specified): + - Filters results to only include files within the specified directory path + - Uses normalized path comparison for accurate filtering + - Maintains relevance ranking within the filtered scope + +5. **Result Processing and Formatting**: + - Converts absolute file paths to workspace-relative paths + - Structures results with file paths, line ranges, similarity scores, and code content + - Formats for both AI consumption and UI display with syntax highlighting + +6. **Dual Output Format**: + - AI Output: Structured text format with query, file paths, scores, and code chunks + - UI Output: JSON format with syntax highlighting and navigation capabilities + +### Search Query Best Practices + +**Effective Query Patterns**: + +**Good: Conceptual and specific** + +``` +query: "user authentication and password validation" +``` + +**Good: Feature-focused** + +``` +query: "database connection pool setup" +``` + +**Good: Problem-oriented** + +``` +query: "error handling for API requests" +``` + +**Less effective: Too generic** + +``` +query: "function" +``` + +### Query Types That Work Well + +- **Functional Descriptions**: "file upload processing", "email validation logic" +- **Technical Patterns**: "singleton pattern implementation", "factory method usage" +- **Domain Concepts**: "user profile management", "payment processing workflow" +- **Architecture Components**: "middleware configuration", "database migration scripts" + +### Result Interpretation + +**Similarity Scores**: + +- **0.8-1.0**: Highly relevant matches, likely exactly what you're looking for +- **0.6-0.8**: Good matches with strong conceptual similarity +- **0.4-0.6**: Potentially relevant but may require review +- **Below 0.4**: Filtered out as too dissimilar + +**Result Structure**: +Each search result includes: + +- **File Path**: Workspace-relative path to the file containing the match +- **Score**: Similarity score indicating relevance (0.4-1.0) +- **Line Range**: Start and end line numbers for the code block +- **Code Chunk**: The actual code content that matched your query + +### Examples When Used + +- When implementing a new feature, Kilo Code searches for "authentication middleware" to understand existing patterns before writing new code. +- When debugging an issue, Kilo Code searches for "error handling in API calls" to find related error patterns across the codebase. +- When refactoring code, Kilo Code searches for "database transaction patterns" to ensure consistency across all database operations. +- When onboarding to a new codebase, Kilo Code searches for "configuration loading" to understand how the application bootstraps. + +### Tool Description (from kilocode prompts) + +``` +Find files most relevant to search query using semantic search. Searches based on meaning rather than exact text matches. By default searches entire workspace. Reuse the user's exact wording unless there's a clear reason not to - their phrasing often helps semantic search. Queries MUST be in English (translate if needed). + +**CRITICAL: For ANY exploration of code you haven't examined yet in this conversation, you MUST use this tool FIRST before any other search or file exploration tools.** This applies throughout the entire conversation, not just at the beginning. This tool uses semantic search to find relevant code based on meaning rather than just keywords, making it far more effective than regex-based search_files for understanding implementations. Even if you've already explored some code, any new area of exploration requires codebase_search first. + +Parameters: +- query: (required) The search query. Reuse the user's exact wording/question format unless there's a clear reason not to. +- path: (optional) Limit search to specific subdirectory (relative to current workspace directory). Leave empty for entire workspace. +``` + +### Configuration System + +OpenCode provides two modules for custom tools: + +1. **Config Module** ([`packages/opencode/src/config/config.ts`](packages/opencode/src/config/config.ts:1273-1295)) + - Stores non-sensitive settings in `opencode.json` + - `codebaseSearch` object with: + - `projectId` - Project ID for codebase search + - `embedModel` - Embedding model (default: "codestral-embed-2505") + - `vectorDb` - Vector database configuration (Qdrant or LanceDB) + - `similarityThreshold` - Minimum similarity score for results (default: 0.4) + - `maxResults` - Maximum number of results to return (default: 50) + +2. **Auth Module** ([`packages/opencode/src/auth/index.ts`](packages/opencode/src/auth/index.ts:38)) + - Stores sensitive credentials in `~/.local/share/kilo/auth.json` + - API keys for providers (kilo, openai, qdrant, etc.) + - OAuth tokens + +### Kilo Gateway Integration + +From [`packages/kilo-gateway/`](packages/kilo-gateway/): + +- Provides `searchCode()` function for managed indexing +- Exposes authentication through OpenCode's Auth module +- Managed indexing API available via `@kilocode/kilo-gateway` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ OpenCode Custom Tool: codebase_search │ +├─────────────────────────────────────────────────────────┤ +│ Parameters: query (string), path? (string) │ +│ Location: .opencode/tool/codebase_search.ts │ +│ Description: .opencode/tool/codebase_search.txt │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ Load Configuration │ + │ (Config.get()) │ + │ Load Auth (Auth.get()) │ + └──────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ Try Managed Indexing │ + │ (via Kilo Gateway API) │ + └──────────────────────────────┘ + │ + │ Success? + │ No │ Yes + │ │ + ▼ │ + ┌──────────────────────┐ + │ Local Indexing │ + │ (embeddings + │ + │ vector store) │ + └──────────────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ Format & Return │ + │ Results │ + └──────────────────────┘ +``` + +## Implementation Steps + +### Step 1: Create Description File + +**Location**: `.opencode/tool/codebase_search.txt` + +``` +Use this tool to find files most relevant to a search query using semantic search. + +This tool searches the codebase based on meaning rather than exact text matches, making it far more effective than regex-based search for understanding implementations. By default, it searches the entire workspace. + +**CRITICAL: For ANY exploration of code you haven't examined yet in this conversation, you MUST use this tool FIRST before any other search or file exploration tools.** This applies throughout the entire conversation, not just at the beginning. Even if you've already explored some code, any new area of exploration requires codebase_search first. + +## Parameters + +- **query** (required): The search query in natural language. Reuse the user's exact wording/question format unless there's a clear reason not to. Queries MUST be in English (translate if needed). + +- **path** (optional): Limit search to a specific subdirectory (relative to the current workspace directory). Leave empty or omit to search the entire workspace. + +## Usage Notes + +- This tool supports both cloud-based (managed) and local indexing +- Managed indexing is tried first, with automatic fallback to local indexing +- Managed indexing requires Kilo Gateway authentication +- Local indexing requires the codebase to be indexed first +- Results include file path, relevance score, line numbers, and code snippets +- If indexing is not ready, the tool will return status information + +### Directory Scoping + +Use the optional path parameter to focus searches on specific parts of your codebase: + +Search within API modules: +``` + +query: "endpoint validation middleware" +path: "src/api" + +``` + +Search in test files: +``` + +query: "mock data setup patterns" +path: "tests" + +``` + +Search specific feature directories: +``` + +query: "component state management" +path: "src/components/auth" + +``` + +## Usage Examples + +### Searching for authentication code in a specific directory +``` + + +user login and authentication logic +src/auth + + +``` + +### Searching for entire workspace +``` + + +environment variables and application configuration + + +``` + +### Searching for database-related code in a specific directory +``` + + +database connection and query execution +src/data + + +``` + +### Looking for error handling patterns in API code +``` + + +HTTP error responses and exception handling +src/api + + +``` + +### Searching for testing utilities and mock setups +``` + + +test setup and mock data creation +tests + + +``` + +### Searching for React hooks +``` + + +useState hook implementation +src/components + + +```` + +### Step 2: Create Tool File + +**Location**: `.opencode/tool/codebase_search.ts` + +```typescript +/// +import { tool } from "@kilocode/plugin" +import { searchCode } from "@kilocode/kilo-gateway" +import DESCRIPTION from "./codebase_search.txt" + +// Helper function to get current git branch +async function getCurrentGitBranch(workspacePath: string): Promise { + try { + const { spawn } = await import("child_process") + return new Promise((resolve, reject) => { + const proc = spawn("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: workspacePath }) + let output = "" + proc.stdout?.on("data", (data) => { output += data.toString() }) + proc.on("close", (code) => { + if (code === 0) resolve(output.trim()) + else reject(new Error(`Git command failed with code ${code}`)) + }) + }) + } catch (error) { + return "main" // Default fallback + } +} + +// Helper function to format search results +function formatResults(query: string, results: any[], source: "managed" | "local"): string { + if (!results || results.length === 0) { + return `No relevant code snippets found for query: "${query}"` + } + + const output = `Query: ${query}\nResults:\n\n` + + for (const result of results) { + output += `File path: ${result.filePath}\n` + output += `Score: ${result.score.toFixed(3)}\n` + output += `Lines: ${result.startLine}-${result.endLine}\n` + if (result.codeChunk) { + output += `Code Chunk:\n${result.codeChunk.trim()}\n` + } + output += "\n" + } + + return output +} + +// Helper function to search local index (placeholder) +async function searchLocalIndex( + workspacePath: string, + codebaseSearch: any, + query: string, + directoryPath?: string, +): Promise { + // TODO: Implement local indexing + // This would use: + // - codebaseSearch.embedModel (e.g., "codestral-embed-2505") + // - codebaseSearch.vectorDb (Qdrant or LanceDB) + // - Auth.get("openai") or Auth.get("qdrant") for API keys + + throw new Error("Local indexing not yet implemented. Please use managed indexing.") +} + +export default tool({ + description: DESCRIPTION, + args: { + query: tool.schema.string().describe("The search query in natural language (required)"), + path: tool.schema.string().describe("Optional directory path to filter results (relative to workspace)").default(""), + }, + async execute(args) { + // 1. Load configuration from Config module + const config = await this.config.get() + const codebaseSearch = config.codebaseSearch ?? {} + + // 2. Load authentication from Auth module + const kiloAuth = await this.auth.get("kilo") + + // 3. Get workspace path + const workspacePath = this.directory + if (!workspacePath) { + throw new Error("No workspace directory found") + } + + // 4. Try managed (cloud) indexing first if kiloAuth is available + if (kiloAuth) { + try { + const kiloToken = kiloAuth.type === "oauth" ? kiloAuth.access : kiloAuth.key + const organizationId = kiloAuth.type === "oauth" ? (kiloAuth.accountId ?? null) : null + + if (kiloToken && codebaseSearch.projectId && organizationId) { + const results = await searchCode( + { + query: args.query, + organizationId, + projectId: codebaseSearch.projectId, + preferBranch: await getCurrentGitBranch(workspacePath), + fallbackBranch: "main", + excludeFiles: [], + path: args.path || undefined, + }, + kiloToken, + this.abort, + ) + + return formatResults(args.query, results, "managed") + } + } catch (error) { + // Fall through to local indexing + console.debug("Managed search failed, trying local:", error instanceof Error ? error.message : String(error)) + } + } + + // 5. Fall back to local indexing + if (codebaseSearch?.vectorDb) { + try { + const results = await searchLocalIndex( + workspacePath, + codebaseSearch, + args.query, + args.path || undefined, + ) + return formatResults(args.query, results, "local") + } catch (error) { + throw new Error(`Local indexing search failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + + // 6. No indexing configured + throw new Error( + "Codebase search is not configured. Please configure in opencode.json:\n" + + JSON.stringify({ + codebaseSearch: { + projectId: "your-project-id", + embedModel: "codestral-embed-2505", + vectorDb: { + type: "qdrant", + url: "http://localhost:6333" + }, + similarityThreshold: 0.4, + maxResults: 50 + } + }, null, 2) + ) + }, +} +```` + +### Step 3: Configuration Examples + +**Example `opencode.json`**: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "codebaseSearch": { + "projectId": "your-project-id", + "embedModel": "codestral-embed-2505", + "vectorDb": { + "type": "qdrant", + "url": "http://localhost:6333" + }, + "similarityThreshold": 0.4, + "maxResults": 50 + } +} +``` + +**Example with LanceDB**: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "codebaseSearch": { + "projectId": "your-project-id", + "embedModel": "codestral-embed-2505", + "vectorDb": { + "type": "lancedb", + "path": ".opencode/index" + }, + "similarityThreshold": 0.4, + "maxResults": 50 + } +} +``` + +### Step 4: Authentication Setup + +**For managed indexing (Kilo Gateway)**: + +```bash +opencode auth set kilo +# Or via OAuth +opencode auth login +``` + +**For local indexing with OpenAI embeddings**: + +```bash +opencode auth set openai +``` + +**For local indexing with Mistral embeddings**: + +```bash +opencode auth set mistral +``` + +**For local indexing with Ollama**: + +```bash +# Configure Ollama in opencode.json with base URL (default: http://localhost:11434) +``` + +**For Qdrant vector database**: + +```bash +opencode auth set qdrant +``` + +### Step 5: Local Indexing Implementation (Future) + +To implement local indexing, you'll need to: + +1. **Create embeddings** using configured model: + - `codestral-embed-2505` (Mistral) + - `text-embedding-3-small` (OpenAI) + - Other models via Ollama + +2. **Store vectors** in configured database: + - Qdrant: Requires URL and API key from Auth module + - LanceDB: Requires path from config + +3. **Perform similarity search**: + - Query embedding + - Vector similarity search (using cosine similarity) + - Apply minimum similarity threshold (default: 0.4, configurable) + - Limit results to maxResults (default: 50) + - Return top N results with scores + +This can be implemented as a separate module or integrated directly into the tool. + +## File Structure + +``` +.opencode/ + tool/ + codebase_search.ts ← Custom tool file (this is what we create) + codebase_search.txt ← Description file (this is what we create) +``` + +**Configuration file**: `opencode.json` (in project root or global config) + +**Authentication**: `~/.local/share/kilo/auth.json` (managed by OpenCode) + +## Benefits of Custom Tool Approach + +1. **No core modifications** - Tool can be developed independently +2. **Easy distribution** - Can be shared as a standalone file +3. **Future-proof** - Won't conflict with upstream opencode merges +4. **Follows conventions** - Matches documented custom tool pattern +5. **Uses existing infrastructure** - Leverages Config and Auth modules +6. **Proper separation** - Credentials in Auth, settings in Config + +## Configuration vs Credentials + +| Type | Storage | Example | Access Method | +| ----------------------------------------- | ------------------------------- | -------------------------- | ------------- | +| **Credentials** (API keys, tokens) | `~/.local/share/kilo/auth.json` | `ctx.auth.get("provider")` | +| **Configuration** (settings, preferences) | `opencode.json` | `ctx.config.get()` | + +This separation follows security best practices: + +- **Credentials** → Auth module (sensitive, user-specific) +- **Configuration** → Config module (non-sensitive, project-specific) + +## Testing Strategy + +### Manual Testing Checklist + +- [ ] Tool loads from `.opencode/tool/` directory +- [ ] Tool description loads from `.txt` file +- [ ] Tool appears in available tools list +- [ ] Tool can be called from OpenCode CLI +- [ ] Tool can be called from OpenCode TUI +- [ ] Tool can be called from OpenCode Web +- [ ] Returns results for managed indexing +- [ ] Falls back to local indexing when configured +- [ ] Clear error messages for: + - [ ] No auth token + - [ ] No project ID + - [ ] Local indexing not configured + - [ ] Network errors +- [ ] Works with different query types: + - [ ] Single concept ("authentication") + - [ ] Multi-term ("user login password hashing") + - [ ] Domain-specific ("React useState hook") +- [ ] Respects directory filter +- [ ] Handles git branch detection correctly +- [ ] Returns properly formatted results matching kilocode format + +## Success Criteria + +- ✅ Tool file created at `.opencode/tool/codebase_search.ts` +- ✅ Description file created at `.opencode/tool/codebase_search.txt` +- ✅ Tool uses OpenCode's Config module for settings +- ✅ Tool uses OpenCode's Auth module for credentials +- ✅ Managed indexing works with Kilo Gateway +- ✅ Configuration stored in `opencode.json` +- ✅ Credentials stored in Auth module +- ✅ No modifications to core OpenCode code +- ✅ Follows custom tool documentation pattern +- ✅ Follows existing tool pattern (github-pr-search.ts, github-triage.ts) +- ✅ Works across all OpenCode interfaces (CLI, TUI, Web) +- ✅ Return format matches kilocode tool format + +## Open Questions + +1. **Local Indexing**: Should we implement local indexing now, or start with managed indexing only? +2. **Embedder Support**: Should we support multiple embedder types (OpenAI, Mistral, Ollama) from the start? +3. **Index Initialization**: Should the tool auto-initialize local index, or require a separate indexing command? + +## References + +### Kilo Documentation + +- codebase_search Tool: https://kilo.ai/docs/automate/tools/codebase_search + +### OpenCode Documentation + +- Custom Tools: https://opencode.ai/docs/custom-tools/ +- Tool Pattern: `packages/opencode/src/tool/` +- Plugin Types: `packages/plugin/src/` + +### Kilo Gateway + +- `packages/kilo-gateway/src/index.ts` +- `packages/kilo-gateway/src/services/code-indexing/managed/api-client.ts` + +### Existing Implementation + +- Kilocode tool: `../kilocode/src/core/tools/CodebaseSearchTool.ts` +- Kilocode description: `../kilocode/src/core/prompts/tools/native-tools/codebase_search.ts` +- Config: `packages/opencode/src/config/config.ts` (already has codebaseSearch schema) +- Auth: `packages/opencode/src/auth/index.ts` (already supports all providers) + +### Existing Tool Patterns + +- `github-pr-search.ts` - GitHub PR search tool +- `github-triage.ts` - GitHub issue triage tool +- `packages/plugin/src/example.ts` - Example custom tool