mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
Merge branch 'main' into feat/cli-local-run
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user