diff --git a/package-lock.json b/package-lock.json index a4b23abf5..bfa142ffd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@clack/prompts": "^1.2.0", "@grammyjs/auto-retry": "^2.0.2", "@grammyjs/transformer-throttler": "^1.2.1", + "@kilocode/sdk": "^7.2.22", "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/sdk": "^1.4.0", "@playwright/mcp": "^0.0.75", @@ -895,6 +896,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kilocode/sdk": { + "version": "7.2.22", + "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.22.tgz", + "integrity": "sha512-2t4VuK5rVY9o/Pck/oRJ+CxAAqnwLhRAD/i91uSabWw4POGlOHHsq2etQKFAX8kJ5zdTk/I1DLvffh7bFPPXZw==", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", diff --git a/package.json b/package.json index 61cae4f19..a77938154 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@clack/prompts": "^1.2.0", "@grammyjs/auto-retry": "^2.0.2", "@grammyjs/transformer-throttler": "^1.2.1", + "@kilocode/sdk": "^7.2.22", "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/sdk": "^1.4.0", "@playwright/mcp": "^0.0.75", diff --git a/src/backend/kilo/handler.ts b/src/backend/kilo/handler.ts new file mode 100644 index 000000000..f72f0673a --- /dev/null +++ b/src/backend/kilo/handler.ts @@ -0,0 +1,198 @@ +/** + * OpenCode main message handler — orchestrates server, sessions, and models. + */ + +import type { QueryParams, QueryResult } from "../../core/types.js"; +import { + getSession, + incrementTurns, + recordUsage, + setSessionName, + resetSession, +} from "../../storage/sessions.js"; +import { getChatSettings } from "../../storage/chat-settings.js"; +import { classify } from "../../core/errors.js"; +import { log, logError, logWarn } from "../../util/log.js"; +import { traceMessage } from "../../util/trace.js"; +import { + ensureServer, + ensureSession, + ensureChatMcpServer, + ensurePluginMcpServers, + buildToolOverrides, + disconnectChatMcpServer, + resolveProviderID, + parseStoredOpenCodeModelSelection, + getConfig, + OPENCODE_SYSTEM_PROMPT_SUFFIX, +} from "./server.js"; +import { + extractPartsSummary, + extractAssistantUsage, + waitForPromptWithQuestionGuard, + waitForAssistantReply, + getOpenCodeTurnSummary, + type OpenCodeAssistantInfo, +} from "./sessions.js"; + +export async function handleMessage( + params: QueryParams, + _retried = false, +): Promise { + const config = getConfig(); + if (!config) throw new Error("OpenCode agent not initialized"); + + const { chatId, text, senderName, isGroup, onTextBlock } = params; + const t0 = Date.now(); + const previousTurns = getSession(chatId).turns; + + const chatSettings = getChatSettings(chatId); + const activeModel = chatSettings.model ?? config.model; + const { providerID: selectedProviderID, modelID } = + parseStoredOpenCodeModelSelection(activeModel); + + const oc = await ensureServer(); + const providerID = + selectedProviderID ?? (await resolveProviderID(oc, modelID)); + const sessionId = await ensureSession(oc, chatId); + const chatMcpServerName = await ensureChatMcpServer(oc, chatId); + await ensurePluginMcpServers(oc, chatId); + const toolOverrides = await buildToolOverrides(oc, chatMcpServerName); + const seenQuestionIds = new Set(); + + const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : ""; + const prompt = isGroup + ? `[${senderName}]${msgIdHint}: ${text}` + : `${text}${msgIdHint}`; + + log("agent", `[${chatId}] <- (${text.length} chars)`); + traceMessage(chatId, "in", text, { senderName, isGroup }); + + try { + const promptStartedAt = Date.now(); + const resp = await waitForPromptWithQuestionGuard( + oc, + { + sessionID: sessionId, + parts: [{ type: "text", text: prompt }], + model: { providerID, modelID }, + system: config.systemPrompt + OPENCODE_SYSTEM_PROMPT_SUFFIX, + ...(toolOverrides ? { tools: toolOverrides } : {}), + }, + chatId, + seenQuestionIds, + ); + + const data = resp.data as Record | undefined; + const parts = Array.isArray(data?.parts) + ? (data.parts as Array>) + : []; + let assistantInfo = + data?.info && typeof data.info === "object" + ? (data.info as OpenCodeAssistantInfo) + : undefined; + + let { text: responseText, toolCalls } = extractPartsSummary(parts); + + if (!responseText) { + const fallbackReply = await waitForAssistantReply( + oc, + sessionId, + promptStartedAt, + chatId, + seenQuestionIds, + ); + responseText = fallbackReply.text; + toolCalls = Math.max(toolCalls, fallbackReply.toolCalls); + assistantInfo = fallbackReply.info ?? assistantInfo; + } + + const turnSummary = await getOpenCodeTurnSummary( + oc, + sessionId, + promptStartedAt, + ); + const fallbackUsage = extractAssistantUsage(assistantInfo); + const usage = + turnSummary.usage.assistantMessages > 0 + ? { + inputTokens: turnSummary.usage.inputTokens, + outputTokens: turnSummary.usage.outputTokens, + cacheRead: turnSummary.usage.cacheRead, + cacheWrite: turnSummary.usage.cacheWrite, + costUsd: turnSummary.usage.costUsd, + providerID: + turnSummary.latestAssistant?.info?.providerID ?? + fallbackUsage.providerID, + modelID: + turnSummary.latestAssistant?.info?.modelID ?? + fallbackUsage.modelID, + } + : fallbackUsage; + + if (!responseText) { + logWarn( + "agent", + `[${chatId}] Kilo returned no assistant text for ${providerID}/${modelID}`, + ); + responseText = + "Sorry \u2014 I got an empty response from Kilo. Please try again."; + } + + if (responseText && onTextBlock) { + await onTextBlock(responseText); + } + + const durationMs = Date.now() - t0; + + incrementTurns(chatId); + recordUsage(chatId, { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + durationMs, + model: usage.modelID ?? activeModel, + costUsd: usage.costUsd, + }); + + if (previousTurns === 0 && text) { + const cleanText = text + .replace(/^\[.*?\]\s*/g, "") + .replace(/\[msg_id:\d+\]\s*/g, "") + .trim(); + if (cleanText) { + setSessionName( + chatId, + cleanText.length > 30 ? cleanText.slice(0, 30) + "..." : cleanText, + ); + } + } + + log( + "agent", + `[${chatId}] -> (${durationMs}ms${toolCalls > 0 ? ` tools=${toolCalls}` : ""})`, + ); + traceMessage(chatId, "out", responseText, { durationMs, toolCalls }); + + return { + text: responseText.trim(), + durationMs, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + }; + } catch (err) { + const classified = classify(err); + if (classified.reason === "session_expired" && !_retried) { + logWarn("agent", `[${chatId}] Kilo session expired, retrying`); + resetSession(chatId); + return handleMessage(params, true); + } + logError("agent", `[${chatId}] Kilo error: ${classified.message}`); + throw classified; + } finally { + await disconnectChatMcpServer(oc, chatMcpServerName); + } +} diff --git a/src/backend/kilo/index.ts b/src/backend/kilo/index.ts new file mode 100644 index 000000000..7fa59c349 --- /dev/null +++ b/src/backend/kilo/index.ts @@ -0,0 +1,44 @@ +/** + * OpenCode backend — uses the OpenCode SDK as an alternative to Claude Agent SDK. + * + * Re-exports from focused sub-modules: + * models.ts — model catalog, search, resolution, presentation + * sessions.ts — message parsing, usage summaries, snapshots + * server.ts — server lifecycle, MCP, session management + * handler.ts — main message handler + */ + +export { + type OpenCodeModelCatalogEntry, + type OpenCodeModelCatalog, + type OpenCodeModelResolution, + type ModelButton, + getOpenCodeModelCatalog, + getOpenCodeModelInfo, + getOpenCodeModelSelectionValue, + resolveOpenCodeModelInput, + getOpenCodeQuickPickModels, + getOpenCodeSettingsPresentation, + renderOpenCodeModelSummary, + renderOpenCodeModelList, + formatOpenCodeSelectionError, + formatOpenCodeUnavailableModel, +} from "./models.js"; + +export { + summarizeOpenCodeAssistantMessages, + getOpenCodeSessionSnapshot, +} from "./sessions.js"; + +export { initOpenCodeAgent, stopOpenCodeServer } from "./server.js"; + +export { handleMessage } from "./handler.js"; + +export { + resolveModel, + getModelInfo, + getSettingsPresentation, + getProviders, + getProviderModels, + formatModelError, +} from "./model-provider.js"; diff --git a/src/backend/kilo/model-provider.ts b/src/backend/kilo/model-provider.ts new file mode 100644 index 000000000..0d7f536a3 --- /dev/null +++ b/src/backend/kilo/model-provider.ts @@ -0,0 +1,179 @@ +/** + * OpenCode model provider — adapts the internal catalog to the QueryBackend model interface. + * + * Each export matches a method on QueryBackend (resolveModel, getModelInfo, etc.). + * Delegates to the existing catalog functions in models.ts. + */ + +import type { + UnifiedModelInfo, + UnifiedModelResolution, + UnifiedProviderInfo, + ModelButton, +} from "../../core/types.js"; + +import { + getOpenCodeModelCatalog, + getOpenCodeModelInfo, + getOpenCodeModelSelectionValue, + resolveOpenCodeModelInput, + getOpenCodeSettingsPresentation, + formatOpenCodeSelectionError, + formatOpenCodeUnavailableModel, + type OpenCodeModelCatalogEntry, + type OpenCodeModelCatalog, + type OpenCodeModelResolution as InternalResolution, +} from "./models.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function toUnifiedModelInfo( + model: OpenCodeModelCatalogEntry, +): UnifiedModelInfo { + const info: UnifiedModelInfo = { + id: model.id, + displayName: model.name, + provider: model.providerID, + providerName: model.providerName, + selectable: model.selectable, + free: model.free, + contextWindow: model.contextWindow, + reasoning: model.reasoning, + }; + + if (!model.selectable) { + info.unavailableReason = formatOpenCodeUnavailableModel(model); + } + + return info; +} + +function toUnifiedResolution( + internal: InternalResolution, + catalog: OpenCodeModelCatalog, +): UnifiedModelResolution { + switch (internal.kind) { + case "exact": + return { + kind: "exact", + model: toUnifiedModelInfo(internal.model), + storedValue: getOpenCodeModelSelectionValue(internal.model, catalog), + }; + case "ambiguous": + return { + kind: "ambiguous", + matches: internal.matches.map(toUnifiedModelInfo), + }; + case "missing": + return { kind: "missing" }; + } +} + +// --------------------------------------------------------------------------- +// QueryBackend model methods +// --------------------------------------------------------------------------- + +export async function resolveModel( + query: string, +): Promise { + const catalog = await getOpenCodeModelCatalog(); + const internal = resolveOpenCodeModelInput(query, catalog); + return toUnifiedResolution(internal, catalog); +} + +export async function getModelInfo( + id: string, +): Promise { + const entry = await getOpenCodeModelInfo(id); + return entry ? toUnifiedModelInfo(entry) : undefined; +} + +export async function getSettingsPresentation( + activeModel: string, + callbackPrefix = "settings:model:", +): Promise<{ modelButtons: ModelButton[]; modelDetails: string[] }> { + return getOpenCodeSettingsPresentation(activeModel, callbackPrefix); +} + +export async function getProviders(): Promise { + const catalog = await getOpenCodeModelCatalog(); + const seen = new Set(); + const result: UnifiedProviderInfo[] = []; + + for (const p of catalog.connectedProviders) { + seen.add(p.id); + result.push({ + id: p.id, + name: p.name, + connected: true, + modelCount: p.modelCount, + }); + } + + for (const p of catalog.loginProviders) { + if (seen.has(p.id)) continue; + seen.add(p.id); + result.push({ + id: p.id, + name: p.name, + connected: false, + modelCount: p.modelCount, + }); + } + + return result; +} + +export async function getProviderModels( + providerId: string, + page = 0, + pageSize = 8, +): Promise<{ models: UnifiedModelInfo[]; total: number }> { + const catalog = await getOpenCodeModelCatalog(); + const filtered = catalog.models.filter((m) => m.providerID === providerId); + const start = page * pageSize; + const slice = filtered.slice(start, start + pageSize); + return { + models: slice.map(toUnifiedModelInfo), + total: filtered.length, + }; +} + +export async function listModels( + filter?: "free" | "all", +): Promise<{ models: UnifiedModelInfo[]; total: number }> { + const catalog = await getOpenCodeModelCatalog(); + const source = + filter === "free" ? catalog.connectedFreeModels : catalog.connectedModels; + return { + models: source.map(toUnifiedModelInfo), + total: source.length, + }; +} + +export function formatModelError( + query: string, + resolution: UnifiedModelResolution, +): string { + if (resolution.kind === "exact") return ""; + + // For "missing", delegate to the catalog error formatter with an empty matches array + // For "ambiguous", we need to re-resolve against the internal catalog to get the + // OpenCode-specific formatting (provider labels, selection values, etc.) + // + // We can't call async getOpenCodeModelCatalog here (sync function), so we build + // a lightweight message for ambiguous and delegate missing to the existing formatter. + + if (resolution.kind === "missing") { + return `No OpenCode model matched "${query}".`; + } + + // ambiguous — list the matches with their provider info + const preview = resolution.matches + .slice(0, 6) + .map((m) => `${m.id} (${m.providerName})`) + .join(", "); + return `Model query "${query}" is ambiguous. Try one of: ${preview}`; +} diff --git a/src/backend/kilo/models.ts b/src/backend/kilo/models.ts new file mode 100644 index 000000000..59153003a --- /dev/null +++ b/src/backend/kilo/models.ts @@ -0,0 +1,758 @@ +/** + * OpenCode model catalog — types, resolution, formatting, and cache. + * + * Extracted from index.ts to keep model-catalog concerns in one module. + */ + +import type { KiloClient } from "@kilocode/sdk/v2"; +import { ensureServer } from "./server.js"; + +// --------------------------------------------------------------------------- +// Types (private) +// --------------------------------------------------------------------------- + +type OpenCodeAuthMethod = { + type?: string; + label?: string; +}; + +type OpenCodeRawModel = { + id?: string; + providerID?: string; + name?: string; + family?: string; + status?: string; + cost?: { + input?: number; + output?: number; + cache?: { + read?: number; + write?: number; + }; + }; + limit?: { + context?: number; + input?: number; + output?: number; + }; + capabilities?: { + reasoning?: boolean; + attachment?: boolean; + toolcall?: boolean; + }; +}; + +type OpenCodeRawProvider = { + id?: string; + name?: string; + source?: string; + env?: Array; + key?: string; + models?: Record; +}; + +type OpenCodeProviderCatalogEntry = { + id: string; + name: string; + source: string; + connected: boolean; + envKeys: Array; + authMethods: Array; + defaultModel?: string; + modelCount: number; + loginRequired: boolean; + envRequired: boolean; +}; + +// --------------------------------------------------------------------------- +// Types (exported) +// --------------------------------------------------------------------------- + +export type OpenCodeModelCatalogEntry = { + id: string; + name: string; + family?: string; + providerID: string; + providerName: string; + providerSource: string; + connected: boolean; + selectable: boolean; + loginRequired: boolean; + envRequired: boolean; + authMethods: Array; + free: boolean; + status: string; + contextWindow: number; + inputWindow?: number; + outputWindow: number; + reasoning: boolean; + attachment: boolean; + toolcall: boolean; + costInput: number; + costOutput: number; + costCacheRead: number; + costCacheWrite: number; +}; + +export type OpenCodeModelCatalog = { + generatedAt: number; + providers: Array; + models: Array; + connectedProviders: Array; + loginProviders: Array; + connectedModels: Array; + connectedFreeModels: Array; +}; + +export type OpenCodeModelResolution = + | { kind: "exact"; model: OpenCodeModelCatalogEntry } + | { kind: "ambiguous"; matches: Array } + | { kind: "missing"; matches: Array }; + +export type ModelButton = { text: string; callback_data: string }; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let modelCatalogCache: { + expiresAt: number; + value: OpenCodeModelCatalog; +} | null = null; + +export function clearModelCatalogCache(): void { + modelCatalogCache = null; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PROVIDER_PATTERNS: Array<[RegExp, string]> = [ + [/gpt|^o[134]/, "openai"], + [/gemini/, "google"], + [/claude/, "anthropic"], +]; + +const BUCKET_PRIORITY: Record = { + connected: 0, + configured: 1, + available: 2, + all: 3, +}; + +const OPENCODE_MODEL_CATALOG_TTL_MS = 60_000; + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +export function guessProviderID(modelID: string): string { + const lower = modelID.toLowerCase(); + return PROVIDER_PATTERNS.find(([re]) => re.test(lower))?.[1] ?? "opencode"; +} + +export function getBucketPriority(name: string): number { + return BUCKET_PRIORITY[name] ?? 4; +} + +export function normalizeModelLookup(value: string): string { + return value.trim().toLowerCase().replace(/[`"']/g, "").replace(/\s+/g, "-"); +} + +export function parseOpenCodeModelQuery(value: string): { + providerQuery?: string; + modelQuery: string; +} { + const trimmed = value.trim(); + const slashIndex = trimmed.indexOf("/"); + const colonIndex = trimmed.indexOf(":"); + const separatorIndex = + slashIndex > 0 && colonIndex > 0 + ? Math.min(slashIndex, colonIndex) + : Math.max(slashIndex, colonIndex); + + if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) { + return { modelQuery: trimmed }; + } + + const providerQuery = trimmed.slice(0, separatorIndex).trim(); + const modelQuery = trimmed.slice(separatorIndex + 1).trim(); + if (!providerQuery || !modelQuery) { + return { modelQuery: trimmed }; + } + + return { providerQuery, modelQuery }; +} + +function matchesLookupQuery(value: string, normalizedQuery: string): boolean { + return ( + value === normalizedQuery || + value.startsWith(normalizedQuery) || + value.includes(normalizedQuery) + ); +} + +function matchesProviderQuery( + model: OpenCodeModelCatalogEntry, + normalizedProviderQuery: string, +): boolean { + const providerId = normalizeModelLookup(model.providerID); + const providerName = normalizeModelLookup(model.providerName); + return ( + matchesLookupQuery(providerId, normalizedProviderQuery) || + matchesLookupQuery(providerName, normalizedProviderQuery) + ); +} + +function hasProviderExactMatch( + model: OpenCodeModelCatalogEntry, + normalizedProviderQuery: string, +): boolean { + return ( + normalizeModelLookup(model.providerID) === normalizedProviderQuery || + normalizeModelLookup(model.providerName) === normalizedProviderQuery + ); +} + +function hasModelIDCollision( + catalog: OpenCodeModelCatalog, + model: OpenCodeModelCatalogEntry, +): boolean { + return catalog.models.some( + (candidate) => + candidate.id === model.id && candidate.providerID !== model.providerID, + ); +} + +export function getOpenCodeModelSelectionValue( + model: OpenCodeModelCatalogEntry, + catalog: OpenCodeModelCatalog, +): string { + return hasModelIDCollision(catalog, model) + ? `${model.providerID}/${model.id}` + : model.id; +} + +function isFreeModel(model: { + id: string; + name: string; + costInput: number; + costOutput: number; +}): boolean { + const id = model.id.toLowerCase(); + const name = model.name.toLowerCase(); + return ( + model.costInput === 0 || + model.costOutput === 0 || + id.includes("free") || + name.includes("free") + ); +} + +function sortCatalogModels( + left: OpenCodeModelCatalogEntry, + right: OpenCodeModelCatalogEntry, +): number { + if (left.selectable !== right.selectable) return left.selectable ? -1 : 1; + if (left.free !== right.free) return left.free ? -1 : 1; + if (left.providerID !== right.providerID) { + if (left.providerID === "opencode") return -1; + if (right.providerID === "opencode") return 1; + return left.providerID.localeCompare(right.providerID); + } + if (left.contextWindow !== right.contextWindow) { + return right.contextWindow - left.contextWindow; + } + return left.name.localeCompare(right.name); +} + +function parseCatalogProvider( + rawProvider: OpenCodeRawProvider, + connectedProviders: Set, + defaultModels: Record, + authMap: Record>, +): OpenCodeProviderCatalogEntry | null { + const id = rawProvider.id; + if (!id) return null; + + const authMethods = (authMap[id] ?? []) + .map((method) => method.label?.trim()) + .filter((label): label is string => Boolean(label)); + const connected = connectedProviders.has(id); + const envKeys = Array.isArray(rawProvider.env) ? rawProvider.env : []; + const modelCount = Object.keys(rawProvider.models ?? {}).length; + + return { + id, + name: rawProvider.name ?? id, + source: rawProvider.source ?? "unknown", + connected, + envKeys, + authMethods, + defaultModel: defaultModels[id], + modelCount, + loginRequired: !connected && authMethods.length > 0, + envRequired: !connected && authMethods.length === 0 && envKeys.length > 0, + }; +} + +function parseCatalogModel( + rawModel: OpenCodeRawModel, + provider: OpenCodeProviderCatalogEntry, +): OpenCodeModelCatalogEntry | null { + const id = rawModel.id; + if (!id) return null; + + const costInput = rawModel.cost?.input ?? 0; + const costOutput = rawModel.cost?.output ?? 0; + const costCacheRead = rawModel.cost?.cache?.read ?? 0; + const costCacheWrite = rawModel.cost?.cache?.write ?? 0; + + const model: OpenCodeModelCatalogEntry = { + id, + name: rawModel.name ?? id, + family: rawModel.family, + providerID: provider.id, + providerName: provider.name, + providerSource: provider.source, + connected: provider.connected, + selectable: provider.connected, + loginRequired: provider.loginRequired, + envRequired: provider.envRequired, + authMethods: provider.authMethods, + free: false, + status: rawModel.status ?? "unknown", + contextWindow: rawModel.limit?.context ?? 0, + inputWindow: rawModel.limit?.input, + outputWindow: rawModel.limit?.output ?? 0, + reasoning: rawModel.capabilities?.reasoning ?? false, + attachment: rawModel.capabilities?.attachment ?? false, + toolcall: rawModel.capabilities?.toolcall ?? false, + costInput, + costOutput, + costCacheRead, + costCacheWrite, + }; + + model.free = isFreeModel(model); + return model; +} + +function buildModelCatalog( + providersData: { + all?: Array; + connected?: Array; + default?: Record; + }, + authMap: Record>, +): OpenCodeModelCatalog { + const connectedProviders = new Set( + Array.isArray(providersData.connected) ? providersData.connected : [], + ); + const defaultModels = providersData.default ?? {}; + const providers = (Array.isArray(providersData.all) ? providersData.all : []) + .map((rawProvider) => + parseCatalogProvider( + rawProvider, + connectedProviders, + defaultModels, + authMap, + ), + ) + .filter((provider): provider is OpenCodeProviderCatalogEntry => + Boolean(provider), + ) + .sort((left, right) => { + if (left.connected !== right.connected) return left.connected ? -1 : 1; + return left.name.localeCompare(right.name); + }); + + const providerById = new Map( + providers.map((provider) => [provider.id, provider]), + ); + const models: Array = []; + + for (const rawProvider of providersData.all ?? []) { + const provider = rawProvider.id + ? providerById.get(rawProvider.id) + : undefined; + if (!provider) continue; + + for (const rawModel of Object.values(rawProvider.models ?? {})) { + const model = parseCatalogModel(rawModel, provider); + if (model) models.push(model); + } + } + + models.sort(sortCatalogModels); + + return { + generatedAt: Date.now(), + providers, + models, + connectedProviders: providers.filter((provider) => provider.connected), + loginProviders: providers.filter((provider) => provider.loginRequired), + connectedModels: models.filter((model) => model.selectable), + connectedFreeModels: models.filter( + (model) => model.selectable && model.free, + ), + }; +} + +// --------------------------------------------------------------------------- +// Exported catalog functions +// --------------------------------------------------------------------------- + +export async function getOpenCodeModelCatalog( + forceRefresh = false, +): Promise { + const now = Date.now(); + if (!forceRefresh && modelCatalogCache && modelCatalogCache.expiresAt > now) { + return modelCatalogCache.value; + } + + const oc = await ensureServer(); + const [providersResp, authResp] = await Promise.all([ + oc.provider.list(), + oc.provider.auth(), + ]); + + const providersData = + (providersResp.data as + | { + all?: Array; + connected?: Array; + default?: Record; + } + | undefined) ?? {}; + const authMap = + (authResp.data as Record> | undefined) ?? + {}; + + const catalog = buildModelCatalog(providersData, authMap); + modelCatalogCache = { + expiresAt: now + OPENCODE_MODEL_CATALOG_TTL_MS, + value: catalog, + }; + return catalog; +} + +export async function getOpenCodeModelInfo( + modelID: string, +): Promise { + const catalog = await getOpenCodeModelCatalog(); + const resolution = resolveOpenCodeModelInput(modelID, catalog); + if (resolution.kind === "missing") return undefined; + return resolution.kind === "exact" ? resolution.model : resolution.matches[0]; +} + +function getSearchCandidates( + query: string, + catalog: OpenCodeModelCatalog, +): Array { + const { providerQuery, modelQuery } = parseOpenCodeModelQuery(query); + const normalizedModel = normalizeModelLookup(modelQuery); + const normalizedProvider = providerQuery + ? normalizeModelLookup(providerQuery) + : undefined; + const matches = catalog.models.filter((model) => { + const modelMatches = + matchesLookupQuery(normalizeModelLookup(model.id), normalizedModel) || + matchesLookupQuery(normalizeModelLookup(model.name), normalizedModel); + if (!modelMatches) return false; + return normalizedProvider + ? matchesProviderQuery(model, normalizedProvider) + : true; + }); + + return matches.sort((left, right) => { + if (normalizedProvider) { + const leftProviderExact = hasProviderExactMatch(left, normalizedProvider); + const rightProviderExact = hasProviderExactMatch( + right, + normalizedProvider, + ); + if (leftProviderExact !== rightProviderExact) { + return leftProviderExact ? -1 : 1; + } + } + + const leftExact = + normalizeModelLookup(left.id) === normalizedModel || + normalizeModelLookup(left.name) === normalizedModel; + const rightExact = + normalizeModelLookup(right.id) === normalizedModel || + normalizeModelLookup(right.name) === normalizedModel; + if (leftExact !== rightExact) return leftExact ? -1 : 1; + return sortCatalogModels(left, right); + }); +} + +export function resolveOpenCodeModelInput( + query: string, + catalog: OpenCodeModelCatalog, +): OpenCodeModelResolution { + // Fast path: try the full query as an exact model.id match first. + // Kilo IDs frequently contain "/" and ":" (e.g. "inclusionai/ling-2.6-1t:free"), + // and the provider/model splitter would otherwise mis-parse them as a + // "/" query and fail. + const trimmedQuery = query.trim(); + const fullIdMatch = catalog.models.find((m) => m.id === trimmedQuery); + if (fullIdMatch) { + return { kind: "exact", model: fullIdMatch }; + } + + const matches = getSearchCandidates(query, catalog); + if (matches.length === 0) return { kind: "missing", matches: [] }; + + const bestMatch = matches[0]; + const { providerQuery, modelQuery } = parseOpenCodeModelQuery(query); + const normalizedModel = normalizeModelLookup(modelQuery); + const normalizedProvider = providerQuery + ? normalizeModelLookup(providerQuery) + : undefined; + const exactMatches = matches.filter((model) => { + const exactModelMatch = + normalizeModelLookup(model.id) === normalizedModel || + normalizeModelLookup(model.name) === normalizedModel; + if (!exactModelMatch) return false; + + return normalizedProvider + ? hasProviderExactMatch(model, normalizedProvider) + : true; + }); + const selectableExactMatches = exactMatches.filter( + (model) => model.selectable, + ); + + if (exactMatches.length === 1) { + return { kind: "exact", model: exactMatches[0] }; + } + + if (selectableExactMatches.length === 1) { + return { kind: "exact", model: selectableExactMatches[0] }; + } + + if (matches.length === 1 && bestMatch) { + return { kind: "exact", model: bestMatch }; + } + + return { kind: "ambiguous", matches: matches.slice(0, 8) }; +} + +function isCallbackSafeModelID(modelID: string): boolean { + // Discord StringSelectMenu values cap at 100 chars and accept any chars. + // Kilo model IDs use "/" and ":" routinely (e.g. "inclusionai/ling-2.6-1t:free"), + // so we only enforce a length budget that leaves room for the "model:" prefix. + return modelID.length <= 90; +} + +export function getOpenCodeQuickPickModels( + catalog: OpenCodeModelCatalog, + currentModelID?: string, +): Array { + const picks: Array = []; + const seen = new Set(); + + const tryAdd = (model: OpenCodeModelCatalogEntry | undefined) => { + if (!model || seen.has(model.id) || !isCallbackSafeModelID(model.id)) + return; + picks.push(model); + seen.add(model.id); + }; + + if (currentModelID) { + const currentModel = resolveOpenCodeModelInput(currentModelID, catalog); + if (currentModel.kind === "exact") { + tryAdd(currentModel.model); + } else if (currentModel.kind === "ambiguous") { + tryAdd(currentModel.matches[0]); + } + } + + // Discord StringSelectMenu allows up to 25 options; we leave one slot for + // "Reset" and keep the rest for free + connected models. The original + // OpenCode backend caps at 4 because Telegram inline keyboards are tight. + const PICK_BUDGET = 24; + + for (const model of catalog.connectedFreeModels) { + tryAdd(model); + if (picks.length >= PICK_BUDGET) break; + } + + if (picks.length < PICK_BUDGET) { + for (const model of catalog.connectedModels) { + tryAdd(model); + if (picks.length >= PICK_BUDGET) break; + } + } + + return picks; +} + +function getAvailabilityLabel(model: OpenCodeModelCatalogEntry) { + if (model.selectable) return model.free ? "ready \u00B7 free" : "ready"; + if (model.loginRequired) return "login required"; + if (model.envRequired) return "credentials required"; + return "not connected"; +} + +function formatCtxWindow(n: number) { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return String(n); +} + +export async function getOpenCodeSettingsPresentation( + activeModel: string, + callbackPrefix = "settings:model:", +): Promise<{ modelButtons: Array; modelDetails: Array }> { + const catalog = await getOpenCodeModelCatalog(); + const current = await getOpenCodeModelInfo(activeModel); + const picks = getOpenCodeQuickPickModels(catalog, activeModel); + + const modelButtons: Array = picks.map((m) => { + const label = + m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id; + const txt = m.free ? `${label} \u2605` : label; + const sel = + current && m.id === current.id && m.providerID === current.providerID; + return { + text: sel ? `\u2713 ${txt}` : txt, + callback_data: `${callbackPrefix}${m.id}`, + }; + }); + modelButtons.push({ text: "Reset", callback_data: `${callbackPrefix}reset` }); + + const details: Array = []; + if (current) { + details.push( + `Provider: ${current.providerName} \u00B7 ${getAvailabilityLabel(current)}`, + ); + details.push( + `Context: ${formatCtxWindow(current.contextWindow)} \u00B7 reasoning ${current.reasoning ? "yes" : "no"} \u00B7 tools ${current.toolcall ? "yes" : "no"}`, + ); + } + const np = catalog.connectedProviders.length; + const nm = catalog.connectedModels.length; + details.push( + `Kilo: ${np} provider${np === 1 ? "" : "s"} connected \u00B7 ${nm} model${nm === 1 ? "" : "s"} usable`, + ); + if (catalog.loginProviders.length > 0) { + const preview = catalog.loginProviders + .slice(0, 4) + .map((p) => p.name) + .join(", "); + details.push( + `Login available: ${preview}${catalog.loginProviders.length > 4 ? "\u2026" : ""}`, + ); + } + details.push("Hint: use `/model ` to switch."); + return { modelButtons, modelDetails: details }; +} + +export async function renderOpenCodeModelSummary( + activeModel: string, + defaultModel: string, +): Promise<{ text: string; quickButtons: Array }> { + const { modelButtons, modelDetails } = + await getOpenCodeSettingsPresentation(activeModel); + const catalog = await getOpenCodeModelCatalog(); + const current = await getOpenCodeModelInfo(activeModel); + const currentLabel = current + ? getOpenCodeModelSelectionValue(current, catalog) + : activeModel; + const freePreview = catalog.connectedFreeModels.slice(0, 8); + + const lines = [ + `Model: ${currentLabel}${activeModel === defaultModel ? " (default)" : ""}`, + ...modelDetails, + ]; + if (freePreview.length > 0) { + lines.push("", "Free now"); + for (const m of freePreview) { + const tags = [ + m.providerName, + m.free ? "free" : `$${m.costInput}/${m.costOutput}`, + `${formatCtxWindow(m.contextWindow)} ctx`, + getAvailabilityLabel(m), + ]; + lines.push( + `\u2022 ${getOpenCodeModelSelectionValue(m, catalog)} \u2014 ${m.name} (${tags.join(" \u00B7 ")})`, + ); + } + } + return { text: lines.join("\n"), quickButtons: modelButtons }; +} + +export async function renderOpenCodeModelList( + mode: "free" | "all" | "providers", +): Promise { + const catalog = await getOpenCodeModelCatalog(); + if (mode === "providers") { + const lines = ["Kilo Providers"]; + for (const p of catalog.providers.slice(0, 24)) { + const detail = p.connected + ? "connected" + : p.loginRequired + ? `login: ${p.authMethods.join(", ")}` + : p.envRequired + ? `env: ${p.envKeys.join(", ")}` + : p.source; + lines.push( + `\u2022 ${p.name} (${p.id}) \u2014 ${detail} \u00B7 ${p.modelCount} models`, + ); + } + if (catalog.providers.length > 24) + lines.push(`\u2026and ${catalog.providers.length - 24} more`); + return lines.join("\n"); + } + const source = + mode === "free" ? catalog.connectedFreeModels : catalog.connectedModels; + const title = mode === "free" ? "Connected Free Models" : "Connected Models"; + const lines = [title]; + for (const m of source.slice(0, 24)) { + const tags = [ + m.providerName, + m.free ? "free" : `$${m.costInput}/${m.costOutput}`, + `${formatCtxWindow(m.contextWindow)} ctx`, + getAvailabilityLabel(m), + ]; + lines.push( + `\u2022 ${getOpenCodeModelSelectionValue(m, catalog)} \u2014 ${m.name} (${tags.join(" \u00B7 ")})`, + ); + } + if (source.length > 24) lines.push(`\u2026and ${source.length - 24} more`); + return lines.join("\n"); +} + +export function formatOpenCodeSelectionError( + input: string, + resolution: Exclude, + catalog: OpenCodeModelCatalog, +) { + if (resolution.kind === "missing") + return `No OpenCode model matched "${input}".`; + const preview = resolution.matches + .slice(0, 6) + .map((m) => { + const provider = + m.providerName === m.providerID + ? m.providerName + : `${m.providerName} / ${m.providerID}`; + return `${getOpenCodeModelSelectionValue(m, catalog)} \u2014 ${provider} (${getAvailabilityLabel(m)})`; + }) + .join(", "); + return `Model query "${input}" is ambiguous. Try one of: ${preview}`; +} + +export function formatOpenCodeUnavailableModel( + model: OpenCodeModelCatalogEntry, +) { + if (model.loginRequired) + return `${model.providerName} isn't connected yet. Login methods: ${model.authMethods.join(", ")}.`; + if (model.envRequired) + return `${model.providerName} needs credentials/env setup before ${model.id} can be used.`; + return `${model.providerName} isn't connected, so ${model.id} can't be selected yet.`; +} diff --git a/src/backend/kilo/server.ts b/src/backend/kilo/server.ts new file mode 100644 index 000000000..3aabb4dfb --- /dev/null +++ b/src/backend/kilo/server.ts @@ -0,0 +1,386 @@ +/** + * Kilo server lifecycle — manages the Kilo server process, + * MCP server registration, session management, and provider resolution. + * + * Extracted from index.ts to keep the main module focused on query handling. + */ + +import { + createKiloClient, + createKiloServer, + type KiloClient, +} from "@kilocode/sdk/v2"; +import type { TalonConfig } from "../../util/config.js"; +import { + getSession, + resetSession, + setSessionId, +} from "../../storage/sessions.js"; +import { log, logWarn } from "../../util/log.js"; +import { clearModelCatalogCache } from "./models.js"; +import { + guessProviderID, + getBucketPriority, + normalizeModelLookup, + parseOpenCodeModelQuery, +} from "./models.js"; + +let config: TalonConfig; +let client: KiloClient | null = null; +let clientPromise: Promise | null = null; +let serverHandle: { url: string; close(): void } | null = null; +let gatewayPortFn: () => number = () => 19876; +let frontendName: "telegram" | "terminal" | "teams" | "discord" = "telegram"; +const modelProviderCache = new Map(); + +const OPENCODE_HOSTNAME = "127.0.0.1"; +const OPENCODE_PORT = 4097; +const OPENCODE_BASE_URL = `http://${OPENCODE_HOSTNAME}:${OPENCODE_PORT}`; +const TALON_MCP_SERVER_NAME = "talon-tools"; +const OPENCODE_SYSTEM_PROMPT_SUFFIX = ` + +## Kilo Delivery Override + +- You are running through Talon's Kilo backend (a fork of OpenCode). +- Return your normal user-facing reply as plain assistant text. +- Do not rely on the Telegram send tool for ordinary replies. +- Use tools only when they are genuinely needed for side effects or extra capabilities. +`; + +const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +function createStrictOpencodeClient(baseUrl: string): KiloClient { + return createKiloClient({ + baseUrl, + throwOnError: true, + }); +} + +export function initOpenCodeAgent( + cfg: TalonConfig, + getGatewayPort?: () => number, + frontend?: "telegram" | "terminal" | "teams" | "discord", +): void { + config = cfg; + if (getGatewayPort) gatewayPortFn = getGatewayPort; + if (frontend) frontendName = frontend; +} + +export async function ensureServer(): Promise { + if (client) return client; + if (clientPromise) return clientPromise; + + clientPromise = (async () => { + const existingClient = await reuseExistingServer(); + if (existingClient) { + client = existingClient; + return existingClient; + } + + log("agent", "Starting Kilo server..."); + + try { + const server = await createKiloServer({ + hostname: OPENCODE_HOSTNAME, + port: OPENCODE_PORT, + timeout: 10_000, + }); + client = createStrictOpencodeClient(server.url); + serverHandle = server; + log("agent", `Kilo server running at ${server.url}`); + } catch (err) { + const reusedClient = await reuseExistingServer(); + if (!reusedClient) throw err; + + client = reusedClient; + logWarn( + "agent", + `Kilo server already became available at ${OPENCODE_BASE_URL}; reusing it`, + ); + } + + return client; + })(); + + try { + return await clientPromise; + } finally { + clientPromise = null; + } +} + +async function reuseExistingServer(): Promise { + try { + const response = await fetch(`${OPENCODE_BASE_URL}/global/health`); + if (!response.ok) return null; + + const existingClient = createStrictOpencodeClient(OPENCODE_BASE_URL); + log("agent", `Reusing Kilo server at ${OPENCODE_BASE_URL}`); + return existingClient; + } catch { + return null; + } +} + +function getChatMcpServerName(chatId: string): string { + const safeChatId = chatId.replace(/[^a-zA-Z0-9_-]+/g, "_") || "chat"; + return `${TALON_MCP_SERVER_NAME}-${safeChatId}`; +} + +function isTalonToolID(toolID: string): boolean { + return ( + toolID.startsWith(`${TALON_MCP_SERVER_NAME}_`) || + toolID.startsWith(`${TALON_MCP_SERVER_NAME}-`) + ); +} + +export async function ensureChatMcpServer( + oc: KiloClient, + chatId: string, +): Promise { + const serverName = getChatMcpServerName(chatId); + + try { + const statusResp = await oc.mcp.status(); + const mcpServers = + (statusResp.data as Record | undefined) ?? + {}; + const talonTools = mcpServers[serverName]; + + if (talonTools?.status === "connected") { + return serverName; + } + + const toolsPath = new URL("../../core/tools/mcp-server.ts", import.meta.url) + .pathname; + await oc.mcp.add({ + name: serverName, + config: { + type: "local", + command: ["node", "--import", "tsx", toolsPath], + environment: { + TALON_BRIDGE_URL: `http://127.0.0.1:${gatewayPortFn()}`, + TALON_CHAT_ID: chatId, + TALON_FRONTEND: frontendName, + }, + }, + }); + log("agent", `Registered ${serverName} MCP server with OpenCode`); + } catch (err) { + logWarn( + "agent", + `MCP registration failed for ${serverName} (tools may not be available): ${errMsg(err)}`, + ); + } + + return serverName; +} + +export async function ensurePluginMcpServers( + oc: KiloClient, + chatId: string, +): Promise { + const { getPluginMcpServers } = await import("../../core/plugin.js"); + const bridgeUrl = `http://127.0.0.1:${gatewayPortFn()}`; + const pluginServers = getPluginMcpServers(bridgeUrl, chatId); + const registered: string[] = []; + + // Check which are already connected + let existingServers: Record = {}; + try { + const statusResp = await oc.mcp.status(); + existingServers = + (statusResp.data as Record | undefined) ?? + {}; + } catch { + // status check failed — try to register anyway + } + + for (const [name, cfg] of Object.entries(pluginServers)) { + if (existingServers[name]?.status === "connected") { + registered.push(name); + continue; + } + try { + await oc.mcp.add({ + name, + config: { + type: "local", + command: [cfg.command, ...cfg.args], + environment: cfg.env ?? {}, + }, + }); + registered.push(name); + log("agent", `Registered plugin MCP server: ${name}`); + } catch (err) { + logWarn( + "agent", + `Plugin MCP registration failed for ${name}: ${errMsg(err)}`, + ); + } + } + + return registered; +} + +export async function buildToolOverrides( + oc: KiloClient, + chatServerName: string, +): Promise | undefined> { + try { + const toolIdsResp = await oc.tool.ids(); + const toolIds = Array.isArray(toolIdsResp.data) ? toolIdsResp.data : []; + const overrides: Record = {}; + const chatToolPrefix = `${chatServerName}_`; + let matchedChatTool = false; + + for (const toolId of toolIds) { + if (typeof toolId !== "string" || !isTalonToolID(toolId)) continue; + + const enabled = toolId.startsWith(chatToolPrefix); + overrides[toolId] = enabled; + matchedChatTool ||= enabled; + } + + return matchedChatTool ? overrides : undefined; + } catch (err) { + logWarn( + "agent", + `Failed to build OpenCode tool overrides for ${chatServerName}: ${errMsg(err)}`, + ); + return undefined; + } +} + +export async function disconnectChatMcpServer( + oc: KiloClient, + serverName: string, +): Promise { + try { + await oc.mcp.disconnect({ name: serverName }); + } catch (err) { + logWarn("agent", `Failed to disconnect ${serverName}: ${errMsg(err)}`); + } +} + +export function stopOpenCodeServer(): void { + clientPromise = null; + modelProviderCache.clear(); + clearModelCatalogCache(); + if (serverHandle) { + serverHandle.close(); + serverHandle = null; + client = null; + log("agent", "Kilo server stopped"); + } +} + +export async function ensureSession( + oc: KiloClient, + chatId: string, +): Promise { + const session = getSession(chatId); + + if (session.sessionId) { + try { + await oc.session.get({ sessionID: session.sessionId }); + return session.sessionId; + } catch { + logWarn( + "agent", + `[${chatId}] Session ${session.sessionId} expired, creating new`, + ); + resetSession(chatId); + } + } + + const resp = await oc.session.create({ title: `Chat ${chatId}` }); + const data = resp.data as Record | undefined; + const newId = (data?.id as string) ?? String(Date.now()); + setSessionId(chatId, newId); + log("agent", `[${chatId}] Created Kilo session: ${newId}`); + return newId; +} + +export async function resolveProviderID( + oc: KiloClient, + modelID: string, +): Promise { + const cachedProviderID = modelProviderCache.get(modelID); + if (cachedProviderID) return cachedProviderID; + + const providerResp = await oc.provider.list(); + const providerBuckets = + (providerResp.data as Record | undefined) ?? {}; + const guessedProviderID = guessProviderID(modelID); + const matches: Array<{ providerID: string; bucketName: string }> = []; + + for (const [bucketName, bucket] of Object.entries(providerBuckets)) { + if (!Array.isArray(bucket)) continue; + + for (const provider of bucket) { + if (!provider || typeof provider !== "object") continue; + + const providerData = provider as { + id?: string; + models?: Record; + }; + + const modelEntry = providerData.models?.[modelID]; + if (!modelEntry) continue; + + const providerID = modelEntry.providerID ?? providerData.id; + if (!providerID) continue; + + matches.push({ providerID, bucketName }); + } + } + + if (matches.length > 0) { + const score = (m: (typeof matches)[0]) => + (m.providerID === guessedProviderID ? 0 : 2) + + (m.providerID === "opencode" ? 0 : 1) + + getBucketPriority(m.bucketName) * 0.1; + matches.sort((a, b) => score(a) - score(b)); + + const resolvedProviderID = matches[0].providerID; + modelProviderCache.set(modelID, resolvedProviderID); + return resolvedProviderID; + } + + const fallbackProviderID = guessProviderID(modelID); + modelProviderCache.set(modelID, fallbackProviderID); + logWarn( + "agent", + `Could not resolve provider for model ${modelID}; falling back to ${fallbackProviderID}`, + ); + return fallbackProviderID; +} + +export function parseStoredOpenCodeModelSelection(value: string): { + providerID?: string; + modelID: string; +} { + // Kilo IDs frequently contain "/" and ":" inside the model.id itself + // (e.g. "inclusionai/ling-2.6-1t:free"). The OpenCode-style splitter would + // mis-treat the "/" prefix as the provider, so we always return the + // whole string as the model ID and let resolveProviderID look up the real + // provider from the live catalog. + return { + providerID: undefined, + modelID: value.trim(), + }; +} + +export function getConfig(): TalonConfig { + return config; +} + +export { + OPENCODE_HOSTNAME, + OPENCODE_PORT, + OPENCODE_BASE_URL, + TALON_MCP_SERVER_NAME, + OPENCODE_SYSTEM_PROMPT_SUFFIX, + errMsg, +}; diff --git a/src/backend/kilo/sessions.ts b/src/backend/kilo/sessions.ts new file mode 100644 index 000000000..1ee8b6f30 --- /dev/null +++ b/src/backend/kilo/sessions.ts @@ -0,0 +1,492 @@ +/** + * OpenCode session helpers — message parsing, usage summarization, + * snapshot retrieval, and the question-rejection guard used during prompts. + */ + +import { setTimeout as sleep } from "node:timers/promises"; +import type { KiloClient } from "@kilocode/sdk/v2"; +import { logWarn } from "../../util/log.js"; +import { ensureServer } from "./server.js"; + +// --------------------------------------------------------------------------- +// Local utility +// --------------------------------------------------------------------------- + +const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const OPENCODE_SESSION_MESSAGE_LIMIT = 5000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type OpenCodeAssistantInfo = { + role?: string; + finish?: string; + time?: { + created?: number; + completed?: number; + }; + cost?: number; + tokens?: { + total?: number; + input?: number; + output?: number; + reasoning?: number; + cache?: { + read?: number; + write?: number; + }; + }; + providerID?: string; + modelID?: string; +}; + +type OpenCodeSessionSnapshot = { + sessionId: string; + createdAt?: number; + updatedAt?: number; + assistant?: { + providerID?: string; + modelID?: string; + createdAt?: number; + completedAt?: number; + costUsd: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheRead: number; + cacheWrite: number; + }; + usage?: { + assistantMessages: number; + totalInputTokens: number; + totalOutputTokens: number; + totalReasoningTokens: number; + totalCacheRead: number; + totalCacheWrite: number; + totalCostUsd: number; + }; +}; + +type ParsedAssistantMessage = { + createdAt: number; + info?: OpenCodeAssistantInfo; + parts: Array>; +}; + +type OpenCodeUsageSummary = { + assistantMessages: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheRead: number; + cacheWrite: number; + costUsd: number; +}; + +// --------------------------------------------------------------------------- +// Functions +// --------------------------------------------------------------------------- + +export function extractPartsSummary(parts: Array>): { + text: string; + toolCalls: number; +} { + const textParts: string[] = []; + let toolCalls = 0; + + for (const part of parts) { + if (part.type === "text" && typeof part.text === "string") { + textParts.push(part.text); + } else if (part.type === "tool") { + toolCalls++; + } + } + + return { + text: textParts.join("\n\n").trim(), + toolCalls, + }; +} + +export function extractAssistantUsage( + info: OpenCodeAssistantInfo | undefined, +): { + inputTokens: number; + outputTokens: number; + cacheRead: number; + cacheWrite: number; + costUsd: number; + providerID?: string; + modelID?: string; +} { + return { + inputTokens: info?.tokens?.input ?? 0, + outputTokens: info?.tokens?.output ?? 0, + cacheRead: info?.tokens?.cache?.read ?? 0, + cacheWrite: info?.tokens?.cache?.write ?? 0, + costUsd: info?.cost ?? 0, + providerID: info?.providerID, + modelID: info?.modelID, + }; +} + +function hasAssistantUsage(info: OpenCodeAssistantInfo | undefined): boolean { + return Boolean( + info?.tokens?.input || + info?.tokens?.output || + info?.tokens?.reasoning || + info?.tokens?.cache?.read || + info?.tokens?.cache?.write || + info?.cost, + ); +} + +function createEmptyUsageSummary(): OpenCodeUsageSummary { + return { + assistantMessages: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheRead: 0, + cacheWrite: 0, + costUsd: 0, + }; +} + +function parseAssistantMessage( + message: unknown, +): ParsedAssistantMessage | null { + if (!message || typeof message !== "object") return null; + + const data = message as { + info?: OpenCodeAssistantInfo; + parts?: Array>; + }; + + if (data.info?.role !== "assistant") return null; + + return { + createdAt: data.info?.time?.created ?? 0, + info: data.info, + parts: Array.isArray(data.parts) ? data.parts : [], + }; +} + +function isMeaningfulAssistantMessage( + message: ParsedAssistantMessage, +): boolean { + return Boolean( + message.parts.length > 0 || + message.info?.time?.completed || + hasAssistantUsage(message.info), + ); +} + +export function summarizeOpenCodeAssistantMessages( + messages: Array, + minCreatedAt = 0, +): { + latestAssistant?: ParsedAssistantMessage; + usage: OpenCodeUsageSummary; +} { + const usage = createEmptyUsageSummary(); + const assistants = messages + .map((message) => parseAssistantMessage(message)) + .filter((message): message is ParsedAssistantMessage => Boolean(message)) + .filter( + (message) => + message.createdAt >= minCreatedAt && + isMeaningfulAssistantMessage(message), + ); + + for (const assistant of assistants) { + const assistantUsage = extractAssistantUsage(assistant.info); + usage.assistantMessages += 1; + usage.inputTokens += assistantUsage.inputTokens; + usage.outputTokens += assistantUsage.outputTokens; + usage.reasoningTokens += assistant.info?.tokens?.reasoning ?? 0; + usage.cacheRead += assistantUsage.cacheRead; + usage.cacheWrite += assistantUsage.cacheWrite; + usage.costUsd += assistantUsage.costUsd; + } + + const latestAssistant = assistants.sort( + (left, right) => right.createdAt - left.createdAt, + )[0]; + + return { latestAssistant, usage }; +} + +async function listSessionMessages( + oc: KiloClient, + sessionId: string, + limit = OPENCODE_SESSION_MESSAGE_LIMIT, +): Promise> { + const resp = await oc.session.messages({ + sessionID: sessionId, + limit, + }); + const page = Array.isArray(resp.data) ? resp.data : []; + const messages: Array = []; + const seenMessageIds = new Set(); + + for (const message of page) { + const messageId = (message as Record)?.info?.id as + | string + | undefined; + if (messageId && seenMessageIds.has(messageId)) continue; + if (messageId) seenMessageIds.add(messageId); + messages.push(message); + } + + return messages; +} + +export async function getOpenCodeTurnSummary( + oc: KiloClient, + sessionId: string, + minCreatedAt: number, +): Promise<{ + latestAssistant?: ParsedAssistantMessage; + usage: OpenCodeUsageSummary; +}> { + const messages = await listSessionMessages(oc, sessionId); + return summarizeOpenCodeAssistantMessages(messages, minCreatedAt); +} + +export async function getOpenCodeSessionSnapshot( + sessionId: string, +): Promise { + if (!sessionId) return undefined; + + const oc = await ensureServer(); + const [sessionResp, messages] = await Promise.all([ + oc.session.get({ sessionID: sessionId }), + listSessionMessages(oc, sessionId), + ]); + + const sessionInfo = + (sessionResp.data as + | { + time?: { + created?: number; + updated?: number; + }; + } + | undefined) ?? {}; + const summary = summarizeOpenCodeAssistantMessages(messages); + const latestAssistant = summary.latestAssistant; + const usage = extractAssistantUsage(latestAssistant?.info); + + return { + sessionId, + createdAt: sessionInfo.time?.created, + updatedAt: sessionInfo.time?.updated, + assistant: latestAssistant + ? { + providerID: usage.providerID, + modelID: usage.modelID, + createdAt: latestAssistant.info?.time?.created, + completedAt: latestAssistant.info?.time?.completed, + costUsd: usage.costUsd, + totalTokens: latestAssistant.info?.tokens?.total ?? 0, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + reasoningTokens: latestAssistant.info?.tokens?.reasoning ?? 0, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + } + : undefined, + usage: { + assistantMessages: summary.usage.assistantMessages, + totalInputTokens: summary.usage.inputTokens, + totalOutputTokens: summary.usage.outputTokens, + totalReasoningTokens: summary.usage.reasoningTokens, + totalCacheRead: summary.usage.cacheRead, + totalCacheWrite: summary.usage.cacheWrite, + totalCostUsd: summary.usage.costUsd, + }, + }; +} + +function summarizeQuestionHeaders( + questions: Array>, +): string { + return questions + .map((question) => { + if (typeof question.header === "string" && question.header.trim()) { + return question.header.trim(); + } + + if (typeof question.question === "string" && question.question.trim()) { + return question.question.trim(); + } + + return null; + }) + .filter((value): value is string => Boolean(value)) + .join(" | "); +} + +function isToolApprovalQuestion( + questions: Array>, +): boolean { + return questions.some((q) => { + const header = String(q.header ?? q.question ?? "").toLowerCase(); + return ( + header.includes("tool") || + header.includes("approve") || + header.includes("permission") || + header.includes("allow") + ); + }); +} + +async function rejectPendingQuestions( + oc: KiloClient, + sessionId: string, + chatId: string, + seenQuestionIds: Set, +): Promise { + const questionsResp = await oc.question.list(); + const pendingQuestions = Array.isArray(questionsResp.data) + ? questionsResp.data + : []; + + for (const request of pendingQuestions) { + if (!request || typeof request !== "object") continue; + + const data = request as { + id?: string; + sessionID?: string; + questions?: Array>; + }; + + const requestId = data.id; + if (!requestId || data.sessionID !== sessionId) continue; + if (seenQuestionIds.has(requestId)) continue; + + seenQuestionIds.add(requestId); + const questions = Array.isArray(data.questions) ? data.questions : []; + const summary = summarizeQuestionHeaders(questions); + + try { + if (isToolApprovalQuestion(questions)) { + // Auto-approve tool usage — Talon manages its own tool access + const answers = questions.map(() => ["always"]); + await oc.question.reply({ requestID: requestId, answers }); + logWarn( + "agent", + `[${chatId}] Auto-approved OpenCode tool question ${requestId}${summary ? `: ${summary}` : ""}`, + ); + } else { + await oc.question.reject({ requestID: requestId }); + logWarn( + "agent", + `[${chatId}] Rejected OpenCode question ${requestId}${summary ? `: ${summary}` : ""}`, + ); + } + } catch (err) { + logWarn( + "agent", + `[${chatId}] Failed to handle OpenCode question ${requestId}: ${errMsg(err)}`, + ); + } + } +} + +export async function waitForPromptWithQuestionGuard( + oc: KiloClient, + parameters: Parameters[0], + chatId: string, + seenQuestionIds: Set, +) { + let finished = false; + + const watchdog = (async () => { + while (!finished) { + try { + await rejectPendingQuestions( + oc, + parameters.sessionID, + chatId, + seenQuestionIds, + ); + } catch (err) { + logWarn( + "agent", + `[${chatId}] Failed while polling OpenCode questions: ${errMsg(err)}`, + ); + } + + if (!finished) { + await sleep(350); + } + } + })(); + + try { + return await oc.session.prompt(parameters); + } finally { + finished = true; + await watchdog; + await rejectPendingQuestions( + oc, + parameters.sessionID, + chatId, + seenQuestionIds, + ); + } +} + +export async function waitForAssistantReply( + oc: KiloClient, + sessionId: string, + minCreatedAt: number, + chatId: string, + seenQuestionIds: Set, +): Promise<{ + text: string; + toolCalls: number; + info?: OpenCodeAssistantInfo; +}> { + const deadline = Date.now() + 10_000; + + while (Date.now() < deadline) { + await rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds); + + const messagesResp = await oc.session.messages({ + sessionID: sessionId, + limit: 20, + }); + const messages = Array.isArray(messagesResp.data) ? messagesResp.data : []; + + const assistantMessages = messages + .map((message) => parseAssistantMessage(message)) + .filter((message): message is ParsedAssistantMessage => Boolean(message)) + .sort((left, right) => right.createdAt - left.createdAt); + + for (const message of assistantMessages) { + if (message.createdAt < minCreatedAt) continue; + + const summary = extractPartsSummary(message.parts); + if (summary.text || summary.toolCalls > 0) { + return { + ...summary, + info: message.info, + }; + } + } + + await sleep(500); + } + + return { text: "", toolCalls: 0 }; +} diff --git a/src/bootstrap.ts b/src/bootstrap.ts index c7b9ce95a..f554f2fdf 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -119,14 +119,19 @@ export async function initBackendAndDispatcher( ): Promise { let backend: QueryBackend; - if (config.backend === "opencode") { - const { initOpenCodeAgent, handleMessage: opencodeHandleMessage } = - await import("./backend/opencode/index.js"); - const ocModelProvider = - await import("./backend/opencode/model-provider.js"); + if (config.backend === "opencode" || config.backend === "kilo") { + const isKilo = config.backend === "kilo"; + const label = isKilo ? "Kilo" : "OpenCode"; + const mod = isKilo + ? await import("./backend/kilo/index.js") + : await import("./backend/opencode/index.js"); + const ocModelProvider = isKilo + ? await import("./backend/kilo/model-provider.js") + : await import("./backend/opencode/model-provider.js"); + const { initOpenCodeAgent, handleMessage: ocHandleMessage } = mod; initOpenCodeAgent(config, frontend.getBridgePort, frontend.name); backend = { - query: (params) => opencodeHandleMessage(params), + query: (params) => ocHandleMessage(params), resolveModel: (q) => ocModelProvider.resolveModel(q), getModelInfo: (id) => ocModelProvider.getModelInfo(id), getSettingsPresentation: (m, prefix) => @@ -136,11 +141,9 @@ export async function initBackendAndDispatcher( ocModelProvider.getProviderModels(p, pg, ps), formatModelError: (q, r) => ocModelProvider.formatModelError(q, r), listModels: (f) => ocModelProvider.listModels(f), - backendLabel: "OpenCode", + backendLabel: label, getSessionSnapshot: async (sessionId) => { - const { getOpenCodeSessionSnapshot } = - await import("./backend/opencode/index.js"); - const snap = await getOpenCodeSessionSnapshot(sessionId); + const snap = await mod.getOpenCodeSessionSnapshot(sessionId); if (!snap) return undefined; return { inputTokens: snap.usage?.totalInputTokens, @@ -151,7 +154,7 @@ export async function initBackendAndDispatcher( }; }, }; - log("bot", "Backend: OpenCode"); + log("bot", `Backend: ${label}`); } else { const { initAgent: initClaudeAgent, diff --git a/src/util/config.ts b/src/util/config.ts index 7e35b156a..0e25311aa 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -115,7 +115,7 @@ const playwrightConfigSchema = z.object({ const configSchema = z.object({ frontend: z.union([frontendEnum, z.array(frontendEnum)]).default("telegram"), botToken: z.string().optional(), - backend: z.enum(["claude", "opencode"]).default("claude"), + backend: z.enum(["claude", "opencode", "kilo"]).default("claude"), claudeBinary: z.string().optional(), model: z.string().default("default"), dreamModel: z.string().optional(), // Model used for background memory consolidation (defaults to main model)