Merge branch 'main' into feat/cli-local-run

This commit is contained in:
Marian Alexandru Alecu
2026-04-28 10:07:32 +03:00
committed by GitHub
301 changed files with 27767 additions and 1265 deletions
+10
View File
@@ -1,5 +1,15 @@
# @kilocode/cli
## 7.2.26
### Patch Changes
- [#9549](https://github.com/Kilo-Org/kilocode/pull/9549) [`a5bca01`](https://github.com/Kilo-Org/kilocode/commit/a5bca011a16077d4394f9b5650a387f235cc77b2) - Prefer ChatGPT OAuth credentials over inherited OpenAI environment variables and make ChatGPT sign-in easier to find.
- [#9448](https://github.com/Kilo-Org/kilocode/pull/9448) [`73ab363`](https://github.com/Kilo-Org/kilocode/commit/73ab363f9a1592721d4ce4b92d1a083b7bc8176b) - Fix session cost display missing subagent costs. The TUI footer, sidebar, web context panel, and ACP usage reports now include the cost of every subagent the session spawned, including nested ones.
- [#9484](https://github.com/Kilo-Org/kilocode/pull/9484) [`dbf1135`](https://github.com/Kilo-Org/kilocode/commit/dbf113524ed27e2aaac9afc5441e70339edaa164) - Prompt before agents access files outside the active directory when a workspace boundary resolves to a filesystem root.
## 7.2.25
### Patch Changes
+3 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.2.25",
"version": "7.2.26",
"name": "@kilocode/cli",
"type": "module",
"license": "MIT",
@@ -110,6 +110,7 @@
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@kilocode/kilo-gateway": "workspace:*",
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-telemetry": "workspace:*",
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -178,6 +179,7 @@
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"tree-sitter-wasms": "^0.1.12",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.1",
+27 -1
View File
@@ -5,10 +5,12 @@ import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import { createRequire } from "module" // kilocode_change
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
const require = createRequire(import.meta.url) // kilocode_change
process.chdir(dir)
@@ -16,6 +18,7 @@ await import("./generate.ts")
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
import { LanceDBRuntime } from "../src/kilocode/lancedb" // kilocode_change
// Load migrations from migration directories
const migrationDirs = (
@@ -78,6 +81,26 @@ const createEmbeddedWebUIBundle = async () => {
const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle()
// kilocode_change start - codebase indexing
async function copyTreeSitterWasms(outputDir: string) {
const runtimeWasmPath = require.resolve("web-tree-sitter/tree-sitter.wasm")
const languagePackagePath = require.resolve("tree-sitter-wasms/package.json")
const languageWasmDir = path.join(path.dirname(languagePackagePath), "out")
const targetDir = path.join(outputDir, "tree-sitter")
await fs.promises.mkdir(targetDir, { recursive: true })
await fs.promises.copyFile(runtimeWasmPath, path.join(targetDir, "tree-sitter.wasm"))
const languageWasmFiles = (await fs.promises.readdir(languageWasmDir)).filter((file) => file.endsWith(".wasm"))
await Promise.all(
languageWasmFiles.map((file) => fs.promises.copyFile(path.join(languageWasmDir, file), path.join(targetDir, file))),
)
console.log(`copied ${languageWasmFiles.length + 1} tree-sitter wasm files to ${targetDir}`)
}
// kilocode_change end
const allTargets: {
os: string
arch: "arm64" | "x64"
@@ -196,7 +219,8 @@ for (const item of targets) {
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
sourcemap: "external", // kilocode_change
external: ["node-gyp", ...LanceDBRuntime.external], // kilocode_change
format: "esm",
minify: true,
splitting: true,
@@ -223,6 +247,8 @@ for (const item of targets) {
},
})
await copyTreeSitterWasms(path.resolve(dir, `dist/${name}/bin`)) // kilocode_change
// kilocode_change start - fix Nix-specific ELF interpreter paths for Linux binaries
if (item.os === "linux") {
const interpreters: Record<string, string> = {
@@ -19,6 +19,7 @@ import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@/npm"
import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" // kilocode_change
const log = Log.create({ service: "tui.config" })
@@ -146,6 +147,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
// kilocode_change start — inject Kilo default plugins to keep TUI aligned with server config
KilocodeDefaultPlugins.apply(acc.result, { disabled: Flag.KILO_DISABLE_DEFAULT_PLUGINS, log })
// kilocode_change end
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
@@ -34,6 +34,8 @@ import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kiloco
import { useToast } from "@tui/ui/toast" // kilocode_change
import { Log } from "@/util"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import type { IndexingStatus } from "@kilocode/kilo-indexing/status" // kilocode_change
import { KiloIndexing } from "@/kilocode/indexing" // kilocode_change
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -87,6 +89,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
indexing: IndexingStatus // kilocode_change
}>({
provider_next: {
all: [],
@@ -118,6 +121,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp_resource: {},
formatter: [],
vcs: undefined,
indexing: { state: "Disabled", message: "Indexing disabled.", processedFiles: 0, totalFiles: 0, percent: 0 }, // kilocode_change
})
const event = useEvent()
@@ -469,6 +473,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
break
}
case "indexing.status": {
setStore("indexing", reconcile(event.properties.status))
break
}
// kilocode_change end
}
})
@@ -587,6 +595,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
})
.catch(() => {}),
KiloIndexing.current().then((x) => setStore("indexing", reconcile(x))),
// kilocode_change end
]).then(() => {
setStore("status", "complete")
@@ -7,6 +7,23 @@ import { useSDK } from "../../context/sdk" // kilocode_change
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"
import { RemoteIndicator } from "@/kilocode/remote-tui" // kilocode_change
import { formatIndexingLabel } from "@/kilocode/indexing-label" // kilocode_change
import type { IndexingStatusState } from "@kilocode/kilo-indexing/status" // kilocode_change
import { indexingEnabled } from "@/kilocode/indexing-feature" // kilocode_change
// kilocode_change start
function indexingTone(state: IndexingStatusState, theme: ReturnType<typeof useTheme>["theme"]) {
if (state === "Complete") return theme.success
if (state === "Error") return theme.error
if (state === "In Progress") return theme.warning
if (state === "Standby") return theme.textMuted
return theme.textMuted
}
function indexingText(indexing: ReturnType<typeof useSync>["data"]["indexing"]) {
return formatIndexingLabel(indexing)
}
// kilocode_change end
export function Footer() {
const { theme } = useTheme()
@@ -22,6 +39,7 @@ export function Footer() {
const directory = useDirectory()
const connected = useConnected()
const sdk = useSDK() // kilocode_change
const indexing = createMemo(() => sync.data.indexing) // kilocode_change
const [store, setStore] = createStore({
welcome: false,
@@ -73,6 +91,7 @@ export function Footer() {
<text fg={theme.text}>
<span style={{ fg: lsp().length > 0 ? theme.success : theme.textMuted }}></span> {lsp().length} LSP
</text>
{/* kilocode_change start */}
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
@@ -86,6 +105,10 @@ export function Footer() {
{mcp()} MCP
</text>
</Show>
<Show when={indexingEnabled(sync.data.config)}>
<text fg={indexingTone(indexing().state, theme)}>{indexingText(indexing()).slice(0, 48)}</text>
</Show>
{/* kilocode_change end */}
<text fg={theme.textMuted}>/status</text>
</Match>
</Switch>
@@ -43,6 +43,7 @@ import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" // kilocode_change
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -84,6 +85,7 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { formatMarkdownTables } from "../../util/markdown" // kilocode_change
import { bell } from "@/kilocode/bell" // kilocode_change
import { SessionIndexing } from "@/kilocode/components/session-indexing" // kilocode_change
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
@@ -1317,6 +1319,9 @@ export function Session() {
{/* kilocode_change end */}
</box>
</Show>
{/* kilocode_change start */}
<SessionIndexing />
{/* kilocode_change end */}
<Toast />
</box>
<Show when={sidebarVisible()}>
@@ -1675,6 +1680,11 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "grep"}>
<Grep {...toolprops} />
</Match>
{/* kilocode_change start */}
<Match when={props.part.tool === "semantic_search"}>
<SemanticSearch {...toolprops} />
</Match>
{/* kilocode_change end */}
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
@@ -2095,6 +2105,23 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
)
}
// kilocode_change start
function SemanticSearch(props: ToolProps<typeof SemanticSearchTool>) {
const meta = createMemo(() => props.metadata as { results?: { length: number }[] })
const args = createMemo(() => props.input as { query?: string; path?: string })
const count = createMemo(() => meta().results?.length ?? 0)
return (
<InlineTool icon="✱" pending="Searching codebase..." complete={args().query} part={props.part}>
Codebase Search "{args().query}" <Show when={args().path}>in {normalizePath(args().path!)} </Show>
<Show when={count() > 0}>
({count()} {count() === 1 ? "result" : "results"})
</Show>
</InlineTool>
)
}
// kilocode_change end
function Task(props: ToolProps<typeof TaskTool>) {
const { navigate } = useRoute()
const sync = useSync()
+17
View File
@@ -45,6 +45,8 @@ import { ConfigVariable } from "./variable"
import { Npm } from "@/npm"
// kilocode_change start
import { KilocodeConfig } from "../kilocode/config/config"
import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" // kilocode_change
import { IndexingConfig as KiloIndexingConfig } from "@kilocode/kilo-indexing/config" // kilocode_change
import { makeRuntime } from "@/effect/run-service"
import { unique } from "remeda"
// kilocode_change end
@@ -97,12 +99,18 @@ export const Server = ConfigServer.Server.zod
export const Layout = ConfigLayout.Layout.zod
export type Layout = ConfigLayout.Layout
// kilocode_change start - indexing configuration
export const Indexing = KiloIndexingConfig
export type Indexing = z.infer<typeof Indexing>
// kilocode_change end
// Schemas that still live at the zod layer (have .transform / .preprocess /
// .meta not expressible in current Effect Schema) get referenced via a
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
// exact zod directly, preserving component $refs.
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
const IndexingRef = Schema.Any.annotate({ [ZodOverride]: KiloIndexingConfig }) // kilocode_change
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
@@ -162,6 +170,7 @@ export const Info = Schema.Struct({
remote_control: Schema.optional(Schema.Boolean).annotate({
description: "Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup.",
}),
indexing: Schema.optional(IndexingRef).annotate({ description: "Codebase indexing configuration" }), // kilocode_change
// kilocode_change end
// kilocode_change start - nullable for delete sentinel
model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({
@@ -261,6 +270,11 @@ export const Info = Schema.Struct({
disable_paste_summary: Schema.optional(Schema.Boolean),
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), // kilocode_change
// kilocode_change start
semantic_indexing: Schema.optional(Schema.Boolean).annotate({
description: "Enable semantic codebase indexing and the semantic_search tool",
}),
// kilocode_change end
// kilocode_change start - enable telemetry by default
openTelemetry: Schema.Boolean.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(true))).annotate({
description: "Enable telemetry. Set to false to opt-out.",
@@ -861,6 +875,9 @@ export const layer = Layer.effect(
if (Flag.KILO_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
// kilocode_change start — inject Kilo default plugins into both plugin list and origins
KilocodeDefaultPlugins.apply(result, { disabled: Flag.KILO_DISABLE_DEFAULT_PLUGINS, log })
// kilocode_change end
return {
config: result,
+10 -1
View File
@@ -189,6 +189,7 @@ export function telemetryOptions(cfg: Config.Info) {
// - Rename build → code
// - Patch plan with readOnlyBash, mcpRules, .kilo paths
// - Patch explore with codebase_search and conditional prompt
// - Patch appropriate agents with semantic_search
// - Add debug, orchestrator, ask agents
export function patchAgents(
agents: Record<
@@ -219,7 +220,11 @@ export function patchAgents(
) {
// Rename "build" → "code" for backward compatibility
if (agents.build) {
agents.code = { ...agents.build, name: "code" }
agents.code = {
...agents.build,
name: "code",
permission: Permission.merge(defaults, Permission.fromConfig({ semantic_search: "allow" }), user),
}
delete agents.build
}
@@ -245,6 +250,7 @@ export function patchAgents(
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
semantic_search: "allow",
}),
user,
),
@@ -267,6 +273,7 @@ export function patchAgents(
websearch: "allow",
codesearch: "allow",
codebase_search: "allow",
semantic_search: "allow",
read: "allow",
external_directory: {
"*": "ask",
@@ -293,6 +300,7 @@ export function patchAgents(
question: "allow",
suggest: "allow", // kilocode_change
plan_enter: "allow",
semantic_search: "allow",
}),
user,
),
@@ -364,6 +372,7 @@ export function patchAgents(
websearch: "allow",
codesearch: "allow",
codebase_search: "allow",
semantic_search: "allow",
external_directory: {
[Truncate.GLOB]: "allow",
},
@@ -0,0 +1,8 @@
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
import { KiloIndexing } from "@/kilocode/indexing"
export namespace KilocodeBootstrap {
export async function init() {
await Promise.all([KiloSessions.init(), KiloIndexing.init()])
}
}
@@ -0,0 +1,479 @@
/**
* Indexing Configuration Dialog
*
* Menu-driven dialog for configuring codebase indexing settings.
* Allows toggling indexing, selecting embedding providers, configuring
* vector stores, and adjusting tuning parameters.
*/
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { useSync } from "@tui/context/sync"
import { useToast } from "@tui/ui/toast"
import { reconcile } from "solid-js/store"
import type { IndexingConfig, Config } from "@kilocode/sdk/v2"
// These types are OpenCode-internal and imported at runtime
type UseSDK = any
type SDK = any
type EmbeddingProvider = NonNullable<IndexingConfig["provider"]>
const PROVIDER_LABELS: Record<EmbeddingProvider, string> = {
openai: "OpenAI",
ollama: "Ollama (local)",
"openai-compatible": "OpenAI-Compatible",
gemini: "Gemini",
mistral: "Mistral",
"vercel-ai-gateway": "Vercel AI Gateway",
bedrock: "AWS Bedrock",
openrouter: "OpenRouter",
voyage: "Voyage",
}
type ProviderFieldDef = { key: string; label: string; placeholder: string; sensitive?: boolean }
const PROVIDER_FIELDS: Record<EmbeddingProvider, ProviderFieldDef[]> = {
openai: [{ key: "apiKey", label: "API Key", placeholder: "sk-...", sensitive: true }],
ollama: [{ key: "baseUrl", label: "Base URL", placeholder: "http://localhost:11434" }],
"openai-compatible": [
{ key: "baseUrl", label: "Base URL", placeholder: "https://api.example.com/v1" },
{ key: "apiKey", label: "API Key", placeholder: "sk-...", sensitive: true },
],
gemini: [{ key: "apiKey", label: "API Key", placeholder: "AI...", sensitive: true }],
mistral: [{ key: "apiKey", label: "API Key", placeholder: "...", sensitive: true }],
"vercel-ai-gateway": [{ key: "apiKey", label: "API Key", placeholder: "...", sensitive: true }],
bedrock: [
{ key: "region", label: "AWS Region", placeholder: "us-east-1" },
{ key: "profile", label: "AWS Profile", placeholder: "default" },
],
openrouter: [
{ key: "apiKey", label: "API Key", placeholder: "sk-or-...", sensitive: true },
{ key: "specificProvider", label: "Specific Provider", placeholder: "optional" },
],
voyage: [{ key: "apiKey", label: "API Key", placeholder: "pa-...", sensitive: true }],
}
const VECTOR_STORE_LABELS: Record<string, string> = {
qdrant: "Qdrant (default)",
lancedb: "LanceDB",
}
function maskSecret(value: string | undefined): string {
if (!value) return "not set"
if (value.length <= 6) return "***"
return value.slice(0, 3) + "..." + value.slice(-3)
}
function getIndexing(sync: ReturnType<typeof useSync>): IndexingConfig {
return (sync.data.config as Config & { indexing?: IndexingConfig }).indexing ?? {}
}
async function saveIndexing(
sdk: SDK,
sync: ReturnType<typeof useSync>,
indexing: IndexingConfig,
toast: ReturnType<typeof useToast>,
): Promise<boolean> {
const response = await sdk.client.global.config.update({ config: { indexing } })
if (response.error) {
toast.show({ message: "Failed to save indexing config", variant: "error" })
return false
}
// Refresh config in sync store so the dialog shows updated values immediately.
// The server's async bootstrap (via global.disposed) would eventually do this,
// but it races with dialog re-render.
const configResponse = await sdk.client.config.get({})
if (configResponse.data) {
sync.set("config", reconcile(configResponse.data))
}
toast.show({ message: "Indexing config saved", variant: "success" })
return true
}
function providerSettingsDescription(indexing: IndexingConfig, provider: EmbeddingProvider): string {
const fields = PROVIDER_FIELDS[provider]
const settings = indexing[provider] as Record<string, string | undefined> | undefined
if (!settings) return "not configured"
const parts = fields.map((f) => {
const val = settings[f.key]
if (!val) return `${f.label}: not set`
return `${f.label}: ${f.sensitive ? maskSecret(val) : val}`
})
return parts.join(", ")
}
// --- Sub-dialogs ---
interface SubDialogProps {
useSDK: () => UseSDK
}
function ProviderSelect(props: SubDialogProps) {
const dialog = useDialog()
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const indexing = getIndexing(sync)
const options: DialogSelectOption<EmbeddingProvider>[] = (
Object.entries(PROVIDER_LABELS) as [EmbeddingProvider, string][]
).map(([value, title]) => ({
value,
title,
description: value === indexing.provider ? "(current)" : undefined,
}))
return (
<DialogSelect
title="Embedding Provider"
options={options}
current={indexing.provider}
onSelect={async (option) => {
const provider = option.value
const updated = { ...getIndexing(sync), provider }
const saved = await saveIndexing(sdk, sync, updated, toast)
if (!saved) {
dialog.clear()
return
}
showProviderSettings(dialog, sync, sdk, toast, provider, props.useSDK)
}}
/>
)
}
async function showProviderSettings(
dialog: ReturnType<typeof useDialog>,
sync: ReturnType<typeof useSync>,
sdk: SDK,
toast: ReturnType<typeof useToast>,
provider: EmbeddingProvider,
useSDK: () => UseSDK,
) {
const fields = PROVIDER_FIELDS[provider]
const indexing = getIndexing(sync)
const currentSettings = (indexing[provider] as Record<string, string | undefined>) ?? {}
const newSettings: Record<string, string | undefined> = { ...currentSettings }
for (const field of fields) {
const currentValue = currentSettings[field.key] ?? ""
const result = await DialogPrompt.show(dialog, `${PROVIDER_LABELS[provider]}${field.label}`, {
value: currentValue,
placeholder: field.placeholder,
})
// null means user pressed Esc — abort the flow
if (result === null) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
return
}
newSettings[field.key] = result.trim() || undefined
}
const updated = { ...getIndexing(sync), [provider]: newSettings }
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
}
function VectorStoreSelect(props: SubDialogProps) {
const dialog = useDialog()
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const indexing = getIndexing(sync)
const options: DialogSelectOption<string>[] = Object.entries(VECTOR_STORE_LABELS).map(([value, title]) => ({
value,
title,
description: value === (indexing.vectorStore ?? "qdrant") ? "(current)" : undefined,
}))
return (
<DialogSelect
title="Vector Store"
options={options}
current={indexing.vectorStore ?? "qdrant"}
onSelect={async (option) => {
const store = option.value as "lancedb" | "qdrant"
if (store === "lancedb") {
await showLancedbSettings(dialog, sync, sdk, toast, props.useSDK)
} else {
await showQdrantSettings(dialog, sync, sdk, toast, props.useSDK)
}
}}
/>
)
}
async function showLancedbSettings(
dialog: ReturnType<typeof useDialog>,
sync: ReturnType<typeof useSync>,
sdk: SDK,
toast: ReturnType<typeof useToast>,
useSDK: () => UseSDK,
) {
const indexing = getIndexing(sync)
const result = await DialogPrompt.show(dialog, "LanceDB — Directory", {
value: indexing.lancedb?.directory ?? "",
placeholder: "Leave empty for default",
})
if (result === null) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
return
}
const updated: IndexingConfig = {
...getIndexing(sync),
vectorStore: "lancedb",
lancedb: { directory: result.trim() || undefined },
}
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
}
async function showQdrantSettings(
dialog: ReturnType<typeof useDialog>,
sync: ReturnType<typeof useSync>,
sdk: SDK,
toast: ReturnType<typeof useToast>,
useSDK: () => UseSDK,
) {
const indexing = getIndexing(sync)
const currentSettings = indexing.qdrant ?? {}
const url = await DialogPrompt.show(dialog, "Qdrant — URL", {
value: currentSettings.url ?? "",
placeholder: "http://localhost:6333",
})
if (url === null) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
return
}
const apiKey = await DialogPrompt.show(dialog, "Qdrant — API Key", {
value: currentSettings.apiKey ?? "",
placeholder: "Optional API key",
})
if (apiKey === null) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
return
}
const updated: IndexingConfig = {
...getIndexing(sync),
vectorStore: "qdrant",
qdrant: {
url: url.trim() || undefined,
apiKey: apiKey.trim() || undefined,
},
}
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
}
interface TuningParam {
key: keyof Pick<
IndexingConfig,
"searchMinScore" | "searchMaxResults" | "embeddingBatchSize" | "scannerMaxBatchRetries"
>
label: string
defaultValue: number
}
const TUNING_PARAMS: TuningParam[] = [
{ key: "searchMinScore", label: "Search Min Score", defaultValue: 0.4 },
{ key: "searchMaxResults", label: "Search Max Results", defaultValue: 50 },
{ key: "embeddingBatchSize", label: "Embedding Batch Size", defaultValue: 60 },
{ key: "scannerMaxBatchRetries", label: "Scanner Max Batch Retries", defaultValue: 3 },
]
function TuningMenu(props: SubDialogProps) {
const dialog = useDialog()
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const indexing = getIndexing(sync)
const options: DialogSelectOption<string>[] = TUNING_PARAMS.map((param) => {
const value = indexing[param.key]
return {
value: param.key,
title: param.label,
description: value !== undefined ? String(value) : `default (${param.defaultValue})`,
}
})
return (
<DialogSelect
title="Tuning Parameters"
options={options}
onSelect={async (option) => {
const param = TUNING_PARAMS.find((p) => p.key === option.value)!
const currentIndexing = getIndexing(sync)
const currentValue = currentIndexing[param.key]
const result = await DialogPrompt.show(dialog, param.label, {
value: currentValue !== undefined ? String(currentValue) : "",
placeholder: `Default: ${param.defaultValue}`,
})
if (result === null) {
dialog.replace(() => <TuningMenu useSDK={props.useSDK} />)
return
}
const trimmed = result.trim()
let numValue: number | undefined
if (trimmed) {
numValue = Number(trimmed)
if (isNaN(numValue)) {
toast.show({ message: `Invalid number: "${trimmed}"`, variant: "error" })
dialog.replace(() => <TuningMenu useSDK={props.useSDK} />)
return
}
}
const updated = { ...getIndexing(sync), [param.key]: numValue }
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <TuningMenu useSDK={props.useSDK} />)
}}
/>
)
}
// --- Main Dialog ---
interface DialogIndexingProps {
useSDK: () => UseSDK
}
export function DialogIndexing(props: DialogIndexingProps) {
const dialog = useDialog()
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const indexing = getIndexing(sync)
const providerLabel = indexing.provider ? PROVIDER_LABELS[indexing.provider] : "not set"
const storeLabel = indexing.vectorStore
? (VECTOR_STORE_LABELS[indexing.vectorStore] ?? indexing.vectorStore)
: "Qdrant (default)"
const tuningCount = TUNING_PARAMS.filter((p) => indexing[p.key] !== undefined).length
const tuningDesc = tuningCount > 0 ? `${tuningCount} customized` : "defaults"
const options: DialogSelectOption<string>[] = [
{
value: "toggle",
title: "Indexing",
category: "General",
description: indexing.enabled ? "enabled" : "disabled",
},
{
value: "provider",
title: "Embedding Provider",
category: "Embedding",
description: providerLabel,
},
{
value: "model",
title: "Embedding Model",
category: "Embedding",
description: indexing.model ?? "default",
},
{
value: "dimension",
title: "Vector Dimension",
category: "Embedding",
description: indexing.dimension ? String(indexing.dimension) : "auto",
},
{
value: "vectorStore",
title: "Vector Store",
category: "Storage",
description: storeLabel,
},
{
value: "tuning",
title: "Tuning Parameters",
category: "Advanced",
description: tuningDesc,
},
]
// If a provider is selected, add a provider-settings entry below it
if (indexing.provider) {
const settingsDesc = providerSettingsDescription(indexing, indexing.provider)
options.splice(2, 0, {
value: "providerSettings",
title: `${PROVIDER_LABELS[indexing.provider]} Settings`,
category: "Embedding",
description: settingsDesc,
})
}
return (
<DialogSelect
title="Indexing Configuration"
options={options}
skipFilter
onSelect={async (option) => {
switch (option.value) {
case "toggle": {
const updated = { ...getIndexing(sync), enabled: !indexing.enabled }
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
break
}
case "provider":
dialog.replace(() => <ProviderSelect useSDK={props.useSDK} />)
break
case "providerSettings":
if (indexing.provider) {
await showProviderSettings(dialog, sync, sdk, toast, indexing.provider, props.useSDK)
}
break
case "model": {
const result = await DialogPrompt.show(dialog, "Embedding Model", {
value: indexing.model ?? "",
placeholder: "e.g. text-embedding-3-small",
})
if (result !== null) {
const updated = {
...getIndexing(sync),
model: result.trim() || undefined,
}
await saveIndexing(sdk, sync, updated, toast)
}
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
break
}
case "dimension": {
const result = await DialogPrompt.show(dialog, "Vector Dimension", {
value: indexing.dimension ? String(indexing.dimension) : "",
placeholder: "Leave empty for auto-detection",
})
if (result !== null) {
const trimmed = result.trim()
let dim: number | undefined
if (trimmed) {
dim = Number(trimmed)
if (isNaN(dim) || dim <= 0 || !Number.isInteger(dim)) {
toast.show({ message: `Invalid dimension: "${trimmed}"`, variant: "error" })
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
break
}
}
const updated = { ...getIndexing(sync), dimension: dim }
await saveIndexing(sdk, sync, updated, toast)
}
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
break
}
case "vectorStore":
dialog.replace(() => <VectorStoreSelect useSDK={props.useSDK} />)
break
case "tuning":
dialog.replace(() => <TuningMenu useSDK={props.useSDK} />)
break
}
}}
/>
)
}
@@ -0,0 +1,31 @@
// kilocode_change - new file
import { createMemo, Show } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { indexingEnabled } from "../indexing-feature"
import { formatIndexingLabel } from "../indexing-label"
import type { IndexingStatusState } from "@kilocode/kilo-indexing/status"
function tone(state: IndexingStatusState, theme: ReturnType<typeof useTheme>["theme"]) {
if (state === "Complete") return theme.success
if (state === "Error") return theme.error
if (state === "In Progress") return theme.warning
if (state === "Standby") return theme.textMuted
return theme.textMuted
}
export function SessionIndexing() {
const { theme } = useTheme()
const sync = useSync()
const enabled = createMemo(() => indexingEnabled(sync.data.config))
const indexing = createMemo(() => sync.data.indexing)
const label = createMemo(() => formatIndexingLabel(indexing()))
return (
<Show when={enabled()}>
<box flexShrink={0} flexDirection="row" paddingLeft={2} paddingRight={2}>
<text fg={tone(indexing().state, theme)}>{label().slice(0, 48)}</text>
</box>
</Show>
)
}
@@ -84,7 +84,10 @@ export namespace KilocodeConfigInjector {
* Merge permission configs, preserving order and handling duplicates.
* Incoming rules take precedence (kilocode patterns override).
*/
function mergePermissions(existing: ConfigPermission.Info | undefined, incoming: ConfigPermission.Info): ConfigPermission.Info {
function mergePermissions(
existing: ConfigPermission.Info | undefined,
incoming: ConfigPermission.Info,
): ConfigPermission.Info {
if (!existing) return incoming
const result: ConfigPermission.Info = { ...existing }
@@ -0,0 +1,29 @@
import { createRequire } from "module"
import type { ConfigPlugin } from "@/config/plugin"
import { ensureIndexingPlugin, resolveIndexingPlugin } from "@/kilocode/indexing-feature"
type Log = {
debug: (msg: string, data?: Record<string, unknown>) => void
}
const req = createRequire(import.meta.url)
export namespace KilocodeDefaultPlugins {
export function apply<T extends { plugin?: ConfigPlugin.Spec[]; plugin_origins?: ConfigPlugin.Origin[] }>(
cfg: T,
opts: { disabled: boolean; log?: Log },
): T {
const before = cfg.plugin ?? []
const plugin = opts.disabled ? undefined : resolveIndexingPlugin(req, opts.log)
const after = ensureIndexingPlugin(before, plugin)
if (after.length > before.length) {
const added = after[after.length - 1]
cfg.plugin_origins = [
...(cfg.plugin_origins ?? []),
{ spec: added, source: "builtin", scope: "global" as ConfigPlugin.Scope },
]
}
cfg.plugin = after
return cfg
}
}
@@ -200,7 +200,10 @@ export namespace IgnoreMigrator {
* Load .kilocodeignore and return permission config.
* Handles all logging internally.
*/
export async function loadIgnoreConfig(projectDir: string, skipGlobalPaths?: boolean): Promise<ConfigPermission.Info> {
export async function loadIgnoreConfig(
projectDir: string,
skipGlobalPaths?: boolean,
): Promise<ConfigPermission.Info> {
try {
const result = await migrate({ projectDir, skipGlobalPaths })
@@ -0,0 +1,43 @@
import { pathToFileURL } from "url"
import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
export const INDEXING_PLUGIN = "@kilocode/kilo-indexing"
// RATIONALE: Upstream PluginSpec changed from string to string | [string, Record].
// Use a broad input type to accept both forms but return the concrete PluginSpec shape.
type PluginSpec = string | [string, Record<string, unknown>]
type ConfigLike = {
plugin?: readonly PluginSpec[] | null
experimental?: { semantic_indexing?: boolean } | null
}
type Req = {
resolve: (id: string) => string
}
type LogLike = {
debug: (msg: string, data?: Record<string, unknown>) => void
}
export function indexingEnabled(config?: ConfigLike | null): boolean {
return hasIndexingPlugin(config?.plugin ?? []) && config?.experimental?.semantic_indexing === true
}
export function resolveIndexingPlugin(req: Req, log?: LogLike): string {
try {
const file = req.resolve(INDEXING_PLUGIN)
return pathToFileURL(file).href
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
log?.debug("failed to resolve indexing plugin package, using package marker", { error })
return INDEXING_PLUGIN
}
}
export function ensureIndexingPlugin(items: readonly PluginSpec[], plugin?: string): PluginSpec[] {
const plugins = [...items]
if (!plugin) return plugins
if (hasIndexingPlugin(plugins)) return plugins
return [...plugins, plugin]
}
@@ -0,0 +1,18 @@
import type { IndexingStatus } from "@kilocode/kilo-indexing/status"
export function formatIndexingLabel(status: IndexingStatus): string {
if (status.state === "In Progress") {
if (status.totalFiles <= 0) return "IDX In Progress"
return `IDX ${status.percent}% ${status.processedFiles}/${status.totalFiles}`
}
if (status.state === "Error") {
return `IDX ${status.message}`
}
if (status.state === "Standby") {
return "IDX Standby"
}
return `IDX ${status.state}`
}
+302
View File
@@ -0,0 +1,302 @@
import z from "zod"
import path from "path"
import {
CodeIndexManager,
type IndexingTelemetryEvent,
type VectorStoreSearchResult,
} from "@kilocode/kilo-indexing/engine"
import { toIndexingConfigInput } from "@kilocode/kilo-indexing/config"
import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import { IndexingStatus, disabledIndexingStatus, normalizeIndexingStatus } from "@kilocode/kilo-indexing/status"
import { Telemetry } from "@kilocode/kilo-telemetry"
import { Instance } from "@/project/instance"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config"
import { registerDisposer } from "@/effect/instance-registry"
import { Global } from "@/global"
import { Log } from "@/util"
import { LanceDBRuntime } from "./lancedb" // kilocode_change
const log = Log.create({ service: "kilocode-indexing" })
const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.")
function worktreeDisabled(): z.infer<typeof IndexingStatus> {
return {
state: "Disabled",
message: "Indexing is disabled in worktree sessions. Use the main workspace for indexing.",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}
}
function isWorktreePath(dir: string): boolean {
return /(?:\/|\\)\.kilo(?:code)?(?:\/|\\)worktrees(?:\/|\\)/.test(dir)
}
function failed(err: unknown): z.infer<typeof IndexingStatus> {
const msg = err instanceof Error ? err.message : String(err)
const text = msg.startsWith("Failed to initialize:") ? msg : `Failed to initialize: ${msg}`
return {
state: "Error",
message: text,
processedFiles: 0,
totalFiles: 0,
percent: 0,
}
}
function trackTelemetry(event: IndexingTelemetryEvent): void {
if (event.type === "started") {
Telemetry.trackIndexingStarted({
trigger: event.trigger,
source: event.source,
mode: event.mode,
provider: event.provider,
vectorStore: event.vectorStore,
modelId: event.modelId,
})
return
}
if (event.type === "completed") {
Telemetry.trackIndexingCompleted({
trigger: event.trigger,
source: event.source,
mode: event.mode,
provider: event.provider,
vectorStore: event.vectorStore,
modelId: event.modelId,
filesIndexed: event.filesIndexed,
filesDiscovered: event.filesDiscovered,
totalBlocks: event.totalBlocks,
batchErrors: event.batchErrors,
})
return
}
if (event.type === "file_count") {
Telemetry.trackIndexingFileCount({
source: event.source,
mode: event.mode,
provider: event.provider,
vectorStore: event.vectorStore,
modelId: event.modelId,
discovered: event.discovered,
candidate: event.candidate,
})
return
}
if (event.type === "batch_retry") {
Telemetry.trackIndexingBatchRetry({
source: event.source,
mode: event.mode,
provider: event.provider,
vectorStore: event.vectorStore,
modelId: event.modelId,
attempt: event.attempt,
maxRetries: event.maxRetries,
batchSize: event.batchSize,
error: event.error,
})
return
}
Telemetry.trackIndexingError({
source: event.source,
trigger: event.trigger,
mode: event.mode,
provider: event.provider,
vectorStore: event.vectorStore,
modelId: event.modelId,
location: event.location,
error: event.error,
retryCount: event.retryCount,
maxRetries: event.maxRetries,
})
}
export namespace KiloIndexing {
export const Status = IndexingStatus
export type Status = z.infer<typeof Status>
type Entry = {
manager?: CodeIndexManager
current(): Status
publish(): Promise<void>
dispose(): void
}
type Cache = {
promise: Promise<Entry>
entry?: Entry
disposed?: boolean
}
export const Event = BusEvent.define(
"indexing.status",
z.object({
status: Status,
}),
)
const cache = new Map<string, Cache>()
const inert = async (current: () => Status): Promise<Entry> => {
const publish = async () => {
await Bus.publish(Event, { status: current() })
}
await publish()
return {
current,
publish,
dispose() {},
}
}
const boot = async (): Promise<Entry> => {
const dir = Instance.directory
const cfg = await Config.get()
if (!hasIndexingPlugin(cfg.plugin)) {
return inert(() => missing())
}
if (cfg.experimental?.semantic_indexing !== true) {
return inert(() => disabledIndexingStatus("Semantic indexing is disabled. Enable it in the Experimental settings."))
}
if (isWorktreePath(dir)) {
return inert(() => worktreeDisabled())
}
log.info("initializing project indexing", { workspacePath: dir })
const root = path.join(Global.Path.state, "indexing")
const manager = new CodeIndexManager(dir, root)
const input = toIndexingConfigInput(cfg.indexing)
const box = { status: undefined as Status | undefined }
const current = () => box.status ?? normalizeIndexingStatus(manager)
const publish = async () => {
await Bus.publish(Event, { status: current() })
}
const report = async () => {
try {
return await publish()
} catch (err) {
log.error("failed to publish indexing status", { err })
}
}
const unsub = manager.onProgressUpdate.on(() => {
void report()
})
const telemetrySub = manager.onTelemetry.on((event) => {
trackTelemetry(event)
})
const base: Entry = {
current,
publish,
dispose() {
unsub.dispose()
telemetrySub.dispose()
manager.dispose()
},
}
// kilocode_change start
const err = await LanceDBRuntime.ensure(input.vectorStoreProvider)
.then(() => manager.initialize(input))
.then(
() => undefined,
(err) => err,
)
// kilocode_change end
if (err) {
box.status = failed(err)
log.error("project indexing initialization failed", {
err,
workspacePath: dir,
})
await report()
return base
}
log.info("project indexing initialized", {
workspacePath: dir,
featureEnabled: manager.isFeatureEnabled,
featureConfigured: manager.isFeatureConfigured,
state: manager.getCurrentStatus().systemStatus,
})
await report()
return {
...base,
manager,
}
}
const state = async () => {
const dir = Instance.directory
const existing = cache.get(dir)
if (existing) return existing.promise
const hit: Cache = {
promise: boot()
.then((entry) => {
if (hit.disposed) {
entry.dispose()
return entry
}
hit.entry = entry
return entry
})
.catch((err) => {
if (cache.get(dir) === hit) cache.delete(dir)
throw err
}),
}
cache.set(dir, hit)
return hit.promise
}
registerDisposer(async (dir) => {
const hit = cache.get(dir)
cache.delete(dir)
if (hit?.entry) {
hit.entry.dispose()
return
}
if (hit) hit.disposed = true
})
export async function init() {
await state()
}
export async function current(): Promise<Status> {
return (await state()).current()
}
export function ready(): boolean {
const entry = cache.get(Instance.directory)?.entry
if (!entry?.manager) return false
return entry.current().state !== "Disabled"
}
export async function available(): Promise<boolean> {
const entry = await state()
if (!entry.manager) return false
return entry.current().state !== "Disabled"
}
export async function search(query: string, directoryPrefix?: string): Promise<VectorStoreSearchResult[]> {
const entry = await state()
if (!entry.manager) return []
return entry.manager.searchIndex(query, directoryPrefix)
}
}
@@ -17,6 +17,8 @@ import { DialogKiloTeamSelect } from "./components/dialog-kilo-team-select.js"
import { DialogKiloProfile } from "./components/dialog-kilo-profile.js"
import { DialogClawSetup } from "./components/dialog-claw-setup.js"
import { DialogClawUpgrade } from "./components/dialog-claw-upgrade.js"
import { DialogIndexing } from "./components/dialog-indexing.js"
import { indexingEnabled } from "./indexing-feature"
// These types are OpenCode-internal and imported at runtime
type UseSDK = any
@@ -40,6 +42,7 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
const isKiloConnected = createMemo(() => {
return sync.data.provider_next.connected.includes("kilo")
})
const indexing = createMemo(() => indexingEnabled(sync.data.config))
command.register(() => [
// /kiloclaw command
@@ -154,6 +157,21 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
},
},
...(indexing()
? [
{
value: "kilo.indexing",
title: "Indexing",
description: "Configure codebase indexing",
category: "Kilo",
slash: { name: "indexing", aliases: ["index", "embedding"] },
onSelect: () => {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
},
},
]
: []),
// /teams command
{
value: "kilo.teams",
+40
View File
@@ -0,0 +1,40 @@
import { Npm } from "@/npm"
export namespace LanceDBRuntime {
export const env = "KILO_LANCEDB_PATH"
export const pkg = "@lancedb/lancedb"
export const version = "0.26.2"
export const external = [
pkg,
"@lancedb/lancedb-darwin-arm64",
"@lancedb/lancedb-linux-arm64-gnu",
"@lancedb/lancedb-linux-arm64-musl",
"@lancedb/lancedb-linux-x64-gnu",
"@lancedb/lancedb-linux-x64-musl",
"@lancedb/lancedb-win32-arm64-msvc",
"@lancedb/lancedb-win32-x64-msvc",
] as const
const box = { ready: undefined as Promise<void> | undefined }
export function clear() {
delete process.env[env]
box.ready = undefined
}
export async function ensure(store?: string) {
if (store !== "lancedb") return
if (process.env[env]) return
if (box.ready) return box.ready
box.ready = (async () => {
const result = await Npm.add(`${pkg}@${version}`)
if (result.entrypoint) process.env[env] = result.entrypoint
})().catch((err) => {
box.ready = undefined
throw err
})
return box.ready
}
}
@@ -9,6 +9,9 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui"
import { createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { Global } from "@/global"
import { indexingEnabled } from "../indexing-feature"
import { formatIndexingLabel } from "../indexing-label"
import { useSync } from "@/cli/cmd/tui/context/sync"
const id = "internal:kilo-home-footer"
@@ -102,6 +105,18 @@ function Version(props: { api: TuiPluginApi }) {
function View(props: { api: TuiPluginApi }) {
const kilo = createMemo(() => props.api.state.provider.some((p) => p.id === "kilo"))
const theme = () => props.api.theme.current
const sync = useSync()
const indexingOn = createMemo(() => indexingEnabled(sync.data.config))
const indexing = createMemo(() => sync.data.indexing)
const indexingLabel = createMemo(() => formatIndexingLabel(indexing()))
const indexingColor = createMemo(() => {
if (indexing().state === "Complete") return theme().success
if (indexing().state === "Error") return theme().error
if (indexing().state === "In Progress") return theme().warning
if (indexing().state === "Standby") return theme().textMuted
return theme().textMuted
})
return (
<box
@@ -118,6 +133,9 @@ function View(props: { api: TuiPluginApi }) {
<box gap={1} flexDirection="row" flexShrink={0}>
<RemoteIndicator api={props.api} kilo={kilo()} />
<Mcp api={props.api} />
<Show when={indexingOn()}>
<text fg={indexingColor()}>{indexingLabel().slice(0, 48)}</text>
</Show>
</box>
<box flexGrow={1} />
<Version api={props.api} />
@@ -131,7 +149,7 @@ function View(props: { api: TuiPluginApi }) {
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 101,
order: 99,
slots: {
home_footer() {
return <View api={api} />
@@ -13,6 +13,7 @@ import { PermissionKilocodeRoutes } from "../permission/routes"
import { RemoteRoutes } from "../../server/routes/instance/remote"
import { NetworkRoutes } from "../../server/routes/instance/network"
import { SuggestionRoutes } from "../suggestion/routes"
import { IndexingRoutes } from "./routes/indexing"
import { createKiloRoutes } from "@kilocode/kilo-gateway"
import { Auth } from "../../auth"
import { errors } from "../../server/error"
@@ -28,6 +29,7 @@ export function register(app: Hono): Hono {
return app
.route("/permission", PermissionKilocodeRoutes())
.route("/network", NetworkRoutes())
.route("/indexing", IndexingRoutes()) // kilocode_change
.route("/suggestion", SuggestionRoutes())
.route("/telemetry", TelemetryRoutes())
.route("/remote", RemoteRoutes())
@@ -0,0 +1,9 @@
import { lazy } from "@/util/lazy"
import { KiloIndexing } from "@/kilocode/indexing"
import { createIndexingRoutes } from "@kilocode/kilo-indexing/server"
export const IndexingRoutes = lazy(() =>
createIndexingRoutes({
current: () => KiloIndexing.current(),
}),
)
@@ -90,7 +90,7 @@ Scalar form applies to all patterns. Object form maps glob patterns to actions.
Actions: `"allow"`, `"ask"`, `"deny"`. Set `null` to delete an inherited key.
Tool permissions: `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `webfetch`, `websearch`, `codesearch`, `lsp`, `skill`, `external_directory`, `todowrite`, `todoread`, `question`, `doom_loop`.
Tool permissions: `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `webfetch`, `websearch`, `codesearch`, `semantic_search`, `lsp`, `skill`, `external_directory`, `todowrite`, `todoread`, `question`, `doom_loop`.
## MCP Servers
@@ -1,10 +1,11 @@
// kilocode_change - new file
import { CodebaseSearchTool } from "../../tool/warpgrep"
import { RecallTool } from "../../tool/recall"
import { Tool } from "../../tool"
import * as Tool from "../../tool/tool"
import { Flag } from "@/flag/flag"
import { ProviderID } from "../../provider/schema"
import { Effect } from "effect"
import { KiloIndexing } from "@/kilocode/indexing"
import { SemanticSearchTool } from "@/kilocode/tool/semantic-search"
export namespace KiloToolRegistry {
/** Resolve Kilo-specific tool Infos outside any InstanceState, so their Truncate/Agent deps are
@@ -12,16 +13,18 @@ export namespace KiloToolRegistry {
export function infos() {
return Effect.gen(function* () {
const codebase = yield* CodebaseSearchTool
const semantic = yield* SemanticSearchTool
const recall = yield* RecallTool
return { codebase, recall }
return { codebase, semantic, recall }
})
}
/** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect —
* it has no Service deps beyond what Tool.init itself needs. */
export function build(tools: { codebase: Tool.Info; recall: Tool.Info }) {
export function build(tools: { codebase: Tool.Info; semantic: Tool.Info; recall: Tool.Info }) {
return Effect.all({
codebase: Tool.init(tools.codebase),
semantic: Tool.init(tools.semantic),
recall: Tool.init(tools.recall),
})
}
@@ -43,10 +46,15 @@ export namespace KiloToolRegistry {
/** Kilo-specific tools to append to the builtin list */
export function extra(
tools: { codebase: Tool.Def; recall: Tool.Def },
tools: { codebase: Tool.Def; semantic: Tool.Def; recall: Tool.Def },
cfg: { experimental?: { codebase_search?: boolean } },
): Tool.Def[] {
return [...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), tools.recall]
const ready = KiloIndexing.ready()
return [
...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []),
...(ready ? [tools.semantic] : []),
tools.recall,
]
}
/** Check for E2E LLM URL (uses KILO_E2E_LLM_URL env var) */
@@ -0,0 +1,126 @@
import z from "zod"
import { Effect } from "effect"
import path from "path"
import * as Tool from "@/tool/tool"
import { KiloIndexing } from "@/kilocode/indexing"
import { Instance } from "@/project/instance"
import DESCRIPTION from "./semantic-search.txt"
const Parameters = z.object({
query: z.string().describe("The search query, expressed in natural language."),
path: z
.string()
.optional()
.describe(
"Limit search to specific subdirectory (relative to the current workspace directory). Leave empty for entire workspace.",
),
})
type SearchResult = {
filePath: string
score: number
startLine: number
endLine: number
codeChunk: string
}
type Meta = {
results: SearchResult[]
}
export const SemanticSearchTool = Tool.define<typeof Parameters, Meta, never, "semantic_search">(
"semantic_search",
Effect.succeed({
description: DESCRIPTION,
parameters: Parameters,
execute: (params, ctx) =>
Effect.gen(function* () {
if (!params.query) {
throw new Error("query is required")
}
yield* ctx.ask({
permission: "semantic_search",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
path: params.path,
},
})
const prefix = normalizeSearchPath(params.path)
const matches = yield* Effect.promise(() => KiloIndexing.search(params.query, prefix))
const results = matches.flatMap<SearchResult>((item) => {
const payload = item.payload
if (!payload) return []
if (
typeof payload.filePath !== "string" ||
typeof payload.codeChunk !== "string" ||
typeof payload.startLine !== "number" ||
typeof payload.endLine !== "number"
) {
return []
}
return [
{
filePath: normalizePath(payload.filePath),
score: item.score,
startLine: payload.startLine,
endLine: payload.endLine,
codeChunk: payload.codeChunk,
},
]
})
if (results.length === 0) {
return {
title: "Codebase Search",
metadata: {
results,
},
output: `No relevant code found for "${params.query}"${prefix ? ` in ${normalizePath(prefix)}` : ""}.`,
}
}
const output = [
`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${params.query}"${prefix ? ` in ${normalizePath(prefix)}` : ""}.`,
"",
...results.flatMap((item, index) => {
return [
`${index + 1}. ${item.filePath}:${item.startLine}-${item.endLine} (score ${item.score.toFixed(4)})`,
item.codeChunk,
"",
]
}),
]
return {
title: "Codebase Search",
metadata: {
results,
},
output: output.join("\n").trim(),
}
}).pipe(Effect.orDie),
}),
)
function normalizeSearchPath(input?: string): string | undefined {
if (!input) return undefined
const absolute = path.resolve(Instance.directory, input)
const relative = path.relative(Instance.directory, absolute)
if (!relative || relative === ".") return undefined
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error(`path must be within the current workspace: ${input}`)
}
return path.normalize(relative)
}
function normalizePath(value: string): string {
return value.replaceAll("\\", "/")
}
@@ -0,0 +1,12 @@
- Find files most relevant to the search query using semantic search.
- Searches based on meaning rather than exact text matches.
- By default searches entire workspace, with capability to filter by path.
Usage Notes:
- Use this tool any time you start exploring a new area of the codebase. This tool will help discover all areas of the codebase related to the query, even if they do not match an exact symbol name.
- Queries MUST be in English (translate if needed).
- Prefer the Grep tool if you know the exact symbol name to search for and do not need semantic context.
Example Queries:
- "User login and password hashing"
- "database connection pooling"
+2 -1
View File
@@ -10,6 +10,7 @@ import {
} from "./shared"
import { ConfigPlugin } from "@/config/plugin"
import { InstallationVersion } from "@/installation/version"
import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect" // kilocode_change
export namespace PluginLoader {
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
@@ -141,7 +142,7 @@ export namespace PluginLoader {
// Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return
if (isIndexingPlugin(candidate.plan.spec)) return // kilocode_change
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
+2 -2
View File
@@ -10,7 +10,7 @@ import { Command } from "../command"
import { Instance } from "./instance"
import { Log } from "@/util"
import { FileWatcher } from "@/file/watcher"
import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change
import { KilocodeBootstrap } from "@/kilocode/bootstrap" // kilocode_change
import * as Effect from "effect/Effect"
import { Config } from "@/config"
@@ -21,7 +21,7 @@ export const InstanceBootstrap = Effect.gen(function* () {
// Plugin can mutate config so it has to be initialized before anything else.
yield* Plugin.Service.use((svc) => svc.init())
// kilocode_change start - bootstrap Kilo session ingest/remote subscriptions instead of ShareNext
yield* Effect.promise(() => KiloSessions.init()).pipe(Effect.forkDetach)
yield* Effect.promise(() => KilocodeBootstrap.init()).pipe(Effect.forkDetach)
// kilocode_change end
yield* Effect.all(
[
+1 -1
View File
@@ -338,4 +338,4 @@ export const write = <T>(key: string[], content: T) => runPromise((svc) => svc.w
export const remove = (key: string[]) => runPromise((svc) => svc.remove(key))
export const list = (prefix: string[]) => runPromise((svc) => svc.list(prefix))
export const update = <T>(key: string[], fn: (draft: T) => void) => runPromise((svc) => svc.update<T>(key, fn))
// kilocode_change end
// kilocode_change end
+2 -1
View File
@@ -5,4 +5,5 @@
- Returns file paths and line numbers with at least one match sorted by modification time
- Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
- When you are doing an open-ended search where you do not know the exact symbol name, use the SemanticSearch tool instead
- When you are doing a deep search that may require multiple tool invocations, use the Task tool instead
+4
View File
@@ -180,6 +180,7 @@ export const layer: Layer.Layer<
const cfg = yield* config.get()
const questionEnabled = KiloToolRegistry.question() // kilocode_change
// kilocode_change start
const tool = yield* Effect.all({
invalid: Tool.init(invalid),
bash: Tool.init(bash),
@@ -200,9 +201,11 @@ export const layer: Layer.Layer<
plan: Tool.init(plan),
suggest: Tool.init(suggesttool), // kilocode_change
})
// kilocode_change end
const kilo = yield* KiloToolRegistry.build(kiloToolInfos) // kilocode_change
// kilocode_change start
return {
custom,
builtin: [
@@ -229,6 +232,7 @@ export const layer: Layer.Layer<
task: tool.task,
read: tool.read,
}
// kilocode_change end
}),
)
+3 -1
View File
@@ -354,9 +354,11 @@ export const layer: Layer.Layer<
}
function cleanDirectory(target: string) {
const retries = process.platform === "win32" ? 30 : 5 // kilocode_change - Windows may release git worktree handles slowly
const delay = process.platform === "win32" ? 250 : 100 // kilocode_change
return Effect.promise(() =>
import("fs/promises")
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: retries, retryDelay: delay })) // kilocode_change
.catch((error) => {
const message = errorMessage(error)
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import path from "path"
import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import { Account } from "../../../src/account/account"
import { Auth } from "../../../src/auth"
import { Config } from "../../../src/config"
import * as CrossSpawnSpawner from "../../../src/effect/cross-spawn-spawner"
import { Env } from "../../../src/env"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { Filesystem } from "../../../src/util"
import { Instance } from "../../../src/project/instance"
import { Npm } from "../../../src/npm"
import { tmpdir } from "../../fixture/fixture"
const infra = CrossSpawnSpawner.defaultLayer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
)
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
})
const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
const noopNpm = Layer.mock(Npm.Service)({
install: () => Effect.void,
add: () => Effect.die("not implemented"),
outdated: () => Effect.succeed(false),
which: () => Effect.succeed(Option.none()),
})
const layer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(noopNpm),
)
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
describe("kilocode default indexing plugin", () => {
afterEach(async () => {
await Instance.disposeAll()
await clear(true)
})
test("does not hard-enable indexing plugin when default plugins are disabled", async () => {
const prev = process.env["KILO_DISABLE_DEFAULT_PLUGINS"]
process.env["KILO_DISABLE_DEFAULT_PLUGINS"] = "true"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
plugin: ["global-plugin-1"],
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(hasIndexingPlugin(config.plugin ?? [])).toBe(false)
},
})
} finally {
if (prev === undefined) delete process.env["KILO_DISABLE_DEFAULT_PLUGINS"]
else process.env["KILO_DISABLE_DEFAULT_PLUGINS"] = prev
}
})
})
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import {
ensureIndexingPlugin,
indexingEnabled,
INDEXING_PLUGIN,
resolveIndexingPlugin,
} from "../../src/kilocode/indexing-feature"
describe("indexing plugin helpers", () => {
test("detects plugin-enabled configs", () => {
expect(indexingEnabled({ plugin: ["global-plugin"] })).toBe(false)
expect(indexingEnabled({ plugin: [INDEXING_PLUGIN] })).toBe(false)
expect(indexingEnabled({ plugin: [INDEXING_PLUGIN], experimental: { semantic_indexing: false } })).toBe(false)
expect(indexingEnabled({ plugin: [INDEXING_PLUGIN], experimental: { semantic_indexing: true } })).toBe(true)
expect(indexingEnabled({ plugin: ["@kilocode/kilo-indexing@1.0.0"], experimental: { semantic_indexing: true } })).toBe(
true,
)
})
test("adds indexing plugin when present but missing from config", () => {
const list = ensureIndexingPlugin(["global-plugin"], INDEXING_PLUGIN)
expect(list).toContain("global-plugin")
expect(list).toContain(INDEXING_PLUGIN)
})
test("does not add duplicate indexing plugin", () => {
const list = ensureIndexingPlugin(["@kilocode/kilo-indexing@1.0.0"], INDEXING_PLUGIN)
expect(list).toEqual(["@kilocode/kilo-indexing@1.0.0"])
})
test("skips hard-enable when plugin package is unavailable", () => {
const list = ensureIndexingPlugin(["global-plugin"], undefined)
expect(list).toEqual(["global-plugin"])
})
test("falls back to package marker when resolver fails", () => {
const plugin = resolveIndexingPlugin({
resolve() {
throw new Error("missing")
},
})
expect(plugin).toBe(INDEXING_PLUGIN)
})
})
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test"
import { formatIndexingLabel } from "../../src/kilocode/indexing-label"
describe("indexing label", () => {
test("formats in-progress status with counts", () => {
expect(
formatIndexingLabel({
state: "In Progress",
message: "",
processedFiles: 21,
totalFiles: 50,
percent: 42,
}),
).toBe("IDX 42% 21/50")
})
test("formats indeterminate in-progress status without 0/0 counts", () => {
expect(
formatIndexingLabel({
state: "In Progress",
message: "",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}),
).toBe("IDX In Progress")
})
test("formats error status with backend message", () => {
expect(
formatIndexingLabel({
state: "Error",
message: "Indexing failed.",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}),
).toBe("IDX Indexing failed.")
})
test("formats stable states", () => {
expect(
formatIndexingLabel({
state: "Complete",
message: "",
processedFiles: 1,
totalFiles: 1,
percent: 100,
}),
).toBe("IDX Complete")
expect(
formatIndexingLabel({
state: "Disabled",
message: "Indexing disabled.",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}),
).toBe("IDX Disabled")
expect(
formatIndexingLabel({
state: "Standby",
message: "Ready.",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}),
).toBe("IDX Standby")
})
})
@@ -0,0 +1,157 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import { CodeIndexManager } from "@kilocode/kilo-indexing/engine"
import { Hono } from "hono"
import type { Config } from "../../src/config"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { Instance } from "../../src/project/instance"
import { IndexingRoutes } from "../../src/kilocode/server/routes/indexing"
import { Log } from "../../src/util"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const cfg: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
semantic_indexing: true,
},
indexing: {
enabled: true,
provider: "ollama",
vectorStore: "qdrant",
ollama: {
baseUrl: "http://127.0.0.1:1",
},
},
}
const off: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
semantic_indexing: false,
},
indexing: {
enabled: true,
provider: "ollama",
vectorStore: "qdrant",
ollama: {
baseUrl: "http://127.0.0.1:1",
},
},
}
const configDir = process.env["KILO_CONFIG_DIR"]
const error = new Error("test indexing initialization failed")
afterEach(async () => {
if (configDir === undefined) delete process.env["KILO_CONFIG_DIR"]
else process.env["KILO_CONFIG_DIR"] = configDir
await Instance.disposeAll()
})
describe("indexing startup degradation", () => {
test("keeps server routes alive when indexing initialization fails", async () => {
const init = spyOn(CodeIndexManager.prototype, "initialize").mockRejectedValue(error)
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
try {
const app = new Hono().route("/indexing", IndexingRoutes())
await Instance.provide({
directory: tmp.path,
fn: async () => {
const status = await app.request("/indexing/status")
expect(status.status).toBe(200)
const body = await status.json()
expect(body).toMatchObject({
state: "Error",
})
expect(body.message).toContain("Failed to initialize: test indexing initialization failed")
},
})
} finally {
init.mockRestore()
}
})
test("keeps degraded indexing queryable but unavailable", async () => {
const init = spyOn(CodeIndexManager.prototype, "initialize").mockRejectedValue(error)
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const status = await KiloIndexing.current()
expect(status.state).toBe("Error")
expect(status.message).toContain("Failed to initialize: test indexing initialization failed")
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("boot failure")).toEqual([])
},
})
} finally {
init.mockRestore()
}
})
test("reports not ready while initialization is in flight", async () => {
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
const gate = Promise.withResolvers<{ requiresRestart: boolean }>()
const init = spyOn(CodeIndexManager.prototype, "initialize").mockImplementation(() => gate.promise)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const boot = KiloIndexing.init()
await new Promise<void>((resolve, reject) => {
const start = performance.now()
const poll = () => {
if (init.mock.calls.length > 0) return resolve()
if (performance.now() - start > 5000) return reject(new Error("indexing initialization did not start"))
setTimeout(poll, 10)
}
poll()
})
expect(init).toHaveBeenCalled()
expect(KiloIndexing.ready()).toBe(false)
gate.resolve({ requiresRestart: false })
await boot
},
})
} finally {
gate.resolve({ requiresRestart: false })
init.mockRestore()
}
})
test("stays disabled when semantic indexing flag is off", async () => {
await using tmp = await tmpdir({ git: true, config: off })
process.env["KILO_CONFIG_DIR"] = tmp.path
const init = spyOn(CodeIndexManager.prototype, "initialize")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const status = await KiloIndexing.current()
expect(status).toMatchObject({
state: "Disabled",
message: "Semantic indexing is disabled. Enable it in the Experimental settings.",
})
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("flag off")).toEqual([])
expect(init).not.toHaveBeenCalled()
},
})
})
})
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import type { Config } from "../../src/config"
import { AppRuntime } from "../../src/effect/app-runtime"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const cfg: Partial<Config.Info> = {
plugin: ["@kilocode/kilo-indexing"],
experimental: {
semantic_indexing: true,
},
indexing: {
enabled: true,
provider: "ollama",
vectorStore: "qdrant",
ollama: {
baseUrl: "http://127.0.0.1:1",
},
},
}
const configDir = process.env["KILO_CONFIG_DIR"]
afterEach(async () => {
if (configDir === undefined) delete process.env["KILO_CONFIG_DIR"]
else process.env["KILO_CONFIG_DIR"] = configDir
await Instance.disposeAll()
})
describe("indexing worktree disable", () => {
test("returns disabled status in .kilo/worktrees paths", async () => {
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
const dir = `${tmp.path}/.kilo/worktrees/feature`
await mkdir(dir, { recursive: true })
await Instance.provide({
directory: dir,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
const status = await KiloIndexing.current()
expect(status.state).toBe("Disabled")
expect(status.message).toBe("Indexing is disabled in worktree sessions. Use the main workspace for indexing.")
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
expect(await KiloIndexing.search("worktree")).toEqual([])
},
})
})
test("returns disabled status in legacy .kilocode/worktrees paths", async () => {
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
const dir = `${tmp.path}/.kilocode/worktrees/feature`
await mkdir(dir, { recursive: true })
await Instance.provide({
directory: dir,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => {
const status = await KiloIndexing.current()
expect(status.state).toBe("Disabled")
expect(status.message).toBe("Indexing is disabled in worktree sessions. Use the main workspace for indexing.")
expect(await KiloIndexing.available()).toBe(false)
expect(KiloIndexing.ready()).toBe(false)
},
})
})
})
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
const entry = "file:///tmp/kilo-cache/node_modules/@lancedb/lancedb/dist/index.js"
const add = mock(async () => ({ directory: "/tmp/kilo-cache", entrypoint: entry }))
const real = await import("../../src/npm/index")
mock.module("../../src/npm/index", () => ({
...real,
Npm: {
...real.Npm,
add,
},
}))
const env = "KILO_LANCEDB_PATH"
const prev = process.env[env]
describe("LanceDBRuntime", () => {
beforeEach(async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
LanceDBRuntime.clear()
add.mockClear()
add.mockImplementation(async () => ({ directory: "/tmp/kilo-cache", entrypoint: entry }))
})
afterEach(async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
LanceDBRuntime.clear()
if (prev === undefined) delete process.env[env]
if (prev !== undefined) process.env[env] = prev
})
test("skips installation for non-lancedb backends", async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
await LanceDBRuntime.ensure("qdrant")
expect(add).not.toHaveBeenCalled()
expect(process.env[env]).toBeUndefined()
})
test("installs the pinned package and exports a file URL for lancedb", async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
await LanceDBRuntime.ensure("lancedb")
expect(add).toHaveBeenCalledWith("@lancedb/lancedb@0.26.2")
expect(process.env[env]).toBe(entry)
})
test("exposes every LanceDB package that must stay external to bun compile", async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
expect(LanceDBRuntime.external).toEqual([
"@lancedb/lancedb",
"@lancedb/lancedb-darwin-arm64",
"@lancedb/lancedb-linux-arm64-gnu",
"@lancedb/lancedb-linux-arm64-musl",
"@lancedb/lancedb-linux-x64-gnu",
"@lancedb/lancedb-linux-x64-musl",
"@lancedb/lancedb-win32-arm64-msvc",
"@lancedb/lancedb-win32-x64-msvc",
])
})
test("skips install when runtime path is already set", async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
process.env[env] = "file:///already/set.js"
await LanceDBRuntime.ensure("lancedb")
expect(add).not.toHaveBeenCalled()
expect(process.env[env]).toBe("file:///already/set.js")
})
test("dedupes concurrent ensure calls", async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
await Promise.all([
LanceDBRuntime.ensure("lancedb"),
LanceDBRuntime.ensure("lancedb"),
LanceDBRuntime.ensure("lancedb"),
])
expect(add).toHaveBeenCalledTimes(1)
expect(process.env[env]).toBe(entry)
})
test("surfaces install failures without swallowing them", async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
add.mockImplementationOnce(async () => {
throw new Error("registry unavailable")
})
await expect(LanceDBRuntime.ensure("lancedb")).rejects.toThrow("registry unavailable")
expect(process.env[env]).toBeUndefined()
})
test("retries after a failed install", async () => {
const { LanceDBRuntime } = await import("../../src/kilocode/lancedb")
add
.mockImplementationOnce(async () => {
throw new Error("install failed")
})
.mockImplementationOnce(async () => ({ directory: "/tmp/kilo-cache", entrypoint: entry }))
await expect(LanceDBRuntime.ensure("lancedb")).rejects.toThrow("install failed")
expect(process.env[env]).toBeUndefined()
expect(add).toHaveBeenCalledTimes(1)
await LanceDBRuntime.ensure("lancedb")
expect(add).toHaveBeenCalledTimes(2)
expect(process.env[env]).toBe(entry)
})
})
@@ -0,0 +1,178 @@
import { describe, expect, test, spyOn } from "bun:test"
import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect"
import { Agent } from "../../src/agent/agent"
import { SemanticSearchTool } from "../../src/kilocode/tool/semantic-search"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
import { Tool, Truncate } from "../../src/tool"
const rt = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
async function initTool() {
return rt.runPromise(
Effect.gen(function* () {
const info = yield* SemanticSearchTool
return yield* Tool.init(info)
}),
)
}
const baseCtx = {
sessionID: SessionID.make("ses_test-semantic-search"),
messageID: MessageID.make(""),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
} satisfies Tool.Context
describe("tool.semantic_search", () => {
test("throws when query is empty", async () => {
const tool = await initTool()
expect(rt.runPromise(tool.execute({ query: "" }, baseCtx))).rejects.toThrow("query is required")
})
test("asks permission and forwards normalized relative path to indexing search", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
try {
const tool = await initTool()
const result = await rt.runPromise(
tool.execute(
{
query: "authentication middleware",
path: "./src/../src/tool",
},
{
...baseCtx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
return Effect.void
},
},
),
)
expect(requests).toHaveLength(1)
expect(requests[0]?.permission).toBe("semantic_search")
expect(requests[0]?.metadata).toEqual({
query: "authentication middleware",
path: "./src/../src/tool",
})
expect(search).toHaveBeenCalledWith("authentication middleware", path.normalize("src/tool"))
expect(result.output).toBe('No relevant code found for "authentication middleware" in src/tool.')
} finally {
search.mockRestore()
}
},
})
})
test("searches entire workspace when path is omitted", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
try {
const tool = await initTool()
const result = await rt.runPromise(tool.execute({ query: "database connection" }, baseCtx))
expect(search).toHaveBeenCalledWith("database connection", undefined)
expect(result.output).toBe('No relevant code found for "database connection".')
expect(result.metadata.results).toEqual([])
} finally {
search.mockRestore()
}
},
})
})
test("formats and normalizes search results", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const search = spyOn(KiloIndexing, "search").mockResolvedValue([
{
id: "1",
score: 0.812345,
payload: {
filePath: "src\\auth\\index.ts",
codeChunk: "export const verify = () => true",
startLine: 10,
endLine: 18,
},
},
{
id: "2",
score: 0.7,
payload: {
filePath: "src/invalid.ts",
codeChunk: 123,
startLine: 1,
endLine: 2,
},
},
{
id: "3",
score: 0.6,
payload: null,
},
] as never)
try {
const tool = await initTool()
const result = await rt.runPromise(tool.execute({ query: "verify token" }, baseCtx))
expect(result.metadata.results).toEqual([
{
filePath: "src/auth/index.ts",
score: 0.812345,
startLine: 10,
endLine: 18,
codeChunk: "export const verify = () => true",
},
])
expect(result.output).toContain('Found 1 result for "verify token".')
expect(result.output).toContain("1. src/auth/index.ts:10-18 (score 0.8123)")
expect(result.output).toContain("export const verify = () => true")
} finally {
search.mockRestore()
}
},
})
})
test("rejects paths outside the workspace", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
try {
const tool = await initTool()
expect(rt.runPromise(tool.execute({ query: "auth", path: "../outside" }, baseCtx))).rejects.toThrow(
"path must be within the current workspace: ../outside",
)
expect(search).not.toHaveBeenCalled()
} finally {
search.mockRestore()
}
},
})
})
})
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, spyOn } from "bun:test"
import { Effect, Layer } from "effect"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { ToolRegistry } from "../../src/tool"
import { Instance } from "../../src/project/instance"
import { provideTmpdirInstance } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
afterEach(async () => {
await Instance.disposeAll()
})
describe("kilocode tool registry indexing", () => {
it.live("omits semantic_search without waiting for slow indexing startup", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const avail = spyOn(KiloIndexing, "available").mockImplementation(() => new Promise<boolean>(() => {}))
try {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).not.toContain("semantic_search")
expect(avail).not.toHaveBeenCalled()
} finally {
avail.mockRestore()
}
}),
{ git: true },
),
)
it.live("registers semantic_search when indexing is ready", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const ready = spyOn(KiloIndexing, "ready").mockReturnValue(true)
try {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("semantic_search")
} finally {
ready.mockRestore()
}
}),
{ git: true },
),
)
})
+5 -2
View File
@@ -50,9 +50,12 @@ describe("plugin.meta", () => {
expect(two.state).toBe("same")
expect(two.entry.load_count).toBe(2)
// kilocode_change start
// WORKAROUND: bun 1.3.11 fs.utimes produces 32-bit overflow for current-era timestamps.
// Use a real write after a brief sleep so the OS assigns a naturally different mtime.
await Bun.sleep(1100)
// kilocode_change end
await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n")
const stamp = new Date(Date.now() + 10_000)
await fs.utimes(tmp.extra.file, stamp, stamp)
const three = await PluginMeta.touch(spec, spec, "demo.file")
expect(three.state).toBe("updated")
@@ -2530,7 +2530,12 @@ test("plugin config providers persist after instance dispose", async () => {
expect(first[ProviderID.make("demo")]).toBeDefined()
expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
await Instance.disposeAll()
// kilocode_change start
await Instance.provide({
directory: tmp.path,
fn: () => Instance.dispose(),
})
// kilocode_change end
const second = await Instance.provide({
directory: tmp.path,
+10 -3
View File
@@ -202,6 +202,7 @@ function makeHttp() {
const it = testEffect(makeHttp())
const unix = process.platform !== "win32" ? it.live : it.live.skip
const unixSkip = it.live.skip // kilocode_change - TODO(#8990): skip flaky cancel tests on Linux CI
// Config that registers a custom "test" provider with a "test-model" model
// so provider model lookup succeeds inside the loop.
@@ -1306,7 +1307,8 @@ it.live(
3_000,
)
unix(
// kilocode_change start - TODO(#8990): flaky on Linux CI
unixSkip(
"cancel interrupts shell and resolves cleanly",
() =>
withSh(() =>
@@ -1342,8 +1344,10 @@ unix(
),
30_000,
)
// kilocode_change end
unix(
// kilocode_change start - TODO(#8990): flaky on Linux CI
unixSkip(
"cancel persists aborted shell result when shell ignores TERM",
() =>
withSh(() =>
@@ -1374,6 +1378,7 @@ unix(
),
30_000,
)
// kilocode_change end
unix(
"cancel finalizes interrupted bash tool output through normal truncation",
@@ -1426,7 +1431,8 @@ unix(
30_000,
)
unix(
// kilocode_change start - TODO(#8990): flaky on Linux CI
unixSkip(
"cancel interrupts loop queued behind shell",
() =>
provideTmpdirInstance(
@@ -1453,6 +1459,7 @@ unix(
),
30_000,
)
// kilocode_change end
unix(
"shell rejects when another shell is already running",