diff --git a/.changeset/filter-relevant-marketplace.md b/.changeset/filter-relevant-marketplace.md new file mode 100644 index 00000000000..65d1fb644b8 --- /dev/null +++ b/.changeset/filter-relevant-marketplace.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Filter marketplace items to those relevant to the current workspace's files and installed VS Code extensions. diff --git a/.changeset/native-notebook-tools.md b/.changeset/native-notebook-tools.md new file mode 100644 index 00000000000..3d47f0ef1e0 --- /dev/null +++ b/.changeset/native-notebook-tools.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support safely reading, editing, and executing Jupyter notebook cells directly from Kilo in VS Code, including absolute workspace paths and content-based edit conflict detection. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png index 9bf8b91cdb8..fe7821d7e80 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/empty-list-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6eb9d7670f55c5529dce90d11fceb5c9da7a0d3a5f7270fdfd27e66ca1f3d72f -size 9851 +oid sha256:3545994fd7a126ea860b31e2ac07f9cbeaa03e872f805e3fd2ec7012556c6ea8 +size 12665 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png index 9556137ca56..0e841eadf3e 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mixed-list-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a7bcef3102838812625393f0cead3a10067ae32e67dd3f174d0e74d03116c8c -size 54735 +oid sha256:10adc41427bfb41fb25fb25e14c771443c1b3cf23a546b05665370974fbc2209 +size 54839 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/relevant-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/relevant-items-chromium-linux.png new file mode 100644 index 00000000000..b842681355b --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/relevant-items-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:572b5a2bfd530b743a8ebaaed2ddacc9611aaa31f450df0f2904f314d8cfff6d +size 52660 diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 6a90119a1eb..d9e3bcfa0b4 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "بحث...", "marketplace.filter.all": "جميع العناصر", "marketplace.filter.notInstalled": "غير مثبت", + "marketplace.filter.relevant": "ذات صلة بمساحة العمل الخاصة بي", "marketplace.empty": "لم يتم العثور على عناصر", + "marketplace.empty.relevant": "لم يتم العثور على عناصر سوق ذات صلة بمساحة العمل هذه.", "marketplace.badge.mcpServer": "خادم MCP", "marketplace.badge.mode": "وضع", "marketplace.card.by": "بواسطة {{author}}", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 916f93b0dbe..6f388cba3aa 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Pesquisar...", "marketplace.filter.all": "Todos os Itens", "marketplace.filter.notInstalled": "Não Instalado", + "marketplace.filter.relevant": "Relevantes para meu espaço de trabalho", "marketplace.empty": "Nenhum item encontrado", + "marketplace.empty.relevant": "Nenhum item relevante do marketplace foi encontrado para este espaço de trabalho.", "marketplace.badge.mcpServer": "Servidor MCP", "marketplace.badge.mode": "Modo", "marketplace.card.by": "por {{author}}", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 6cbbb36e1d3..174956f59ee 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -64,7 +64,9 @@ export const dict = { "marketplace.search": "Pretraži...", "marketplace.filter.all": "Sve stavke", "marketplace.filter.notInstalled": "Nije instalirano", + "marketplace.filter.relevant": "Relevantno za moj radni prostor", "marketplace.empty": "Nema pronađenih stavki", + "marketplace.empty.relevant": "Nisu pronađene relevantne stavke marketplacea za ovaj radni prostor.", "marketplace.badge.mcpServer": "MCP Server", "marketplace.badge.mode": "Režim", "marketplace.card.by": "od {{author}}", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index c4a176c2b3d..1c76d9c2df2 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Søg...", "marketplace.filter.all": "Alle elementer", "marketplace.filter.notInstalled": "Ikke installeret", + "marketplace.filter.relevant": "Relevante for mit arbejdsområde", "marketplace.empty": "Ingen elementer fundet", + "marketplace.empty.relevant": "Der blev ikke fundet relevante marketplace-elementer for dette arbejdsområde.", "marketplace.badge.mcpServer": "MCP-server", "marketplace.badge.mode": "Tilstand", "marketplace.card.by": "af {{author}}", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index aa77ae3035b..0a127f11d15 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Suchen...", "marketplace.filter.all": "Alle Elemente", "marketplace.filter.notInstalled": "Nicht installiert", + "marketplace.filter.relevant": "Relevant für meinen Arbeitsbereich", "marketplace.empty": "Keine Elemente gefunden", + "marketplace.empty.relevant": "Keine relevanten Marketplace-Elemente für diesen Arbeitsbereich gefunden.", "marketplace.badge.mcpServer": "MCP-Server", "marketplace.badge.mode": "Modus", "marketplace.card.by": "von {{author}}", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 459ec98a10d..312c76d545d 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -61,7 +61,9 @@ export const dict = { "marketplace.search": "Search...", "marketplace.filter.all": "All Items", "marketplace.filter.notInstalled": "Not Installed", + "marketplace.filter.relevant": "Relevant to my workspace", "marketplace.empty": "No items found", + "marketplace.empty.relevant": "No relevant marketplace items found for this workspace.", "marketplace.badge.mcpServer": "MCP Server", "marketplace.badge.mode": "Mode", "marketplace.card.by": "by {{author}}", diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 8f572f79637..f777cd9b4a8 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Buscar...", "marketplace.filter.all": "Todos los elementos", "marketplace.filter.notInstalled": "No instalado", + "marketplace.filter.relevant": "Relevantes para mi espacio de trabajo", "marketplace.empty": "No se encontraron elementos", + "marketplace.empty.relevant": "No se encontraron elementos relevantes del marketplace para este espacio de trabajo.", "marketplace.badge.mcpServer": "Servidor MCP", "marketplace.badge.mode": "Modo", "marketplace.card.by": "por {{author}}", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 9d23df435d9..715ff06bfc1 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Rechercher...", "marketplace.filter.all": "Tous les éléments", "marketplace.filter.notInstalled": "Non installé", + "marketplace.filter.relevant": "Pertinents pour mon espace de travail", "marketplace.empty": "Aucun élément trouvé", + "marketplace.empty.relevant": "Aucun élément pertinent du marketplace trouvé pour cet espace de travail.", "marketplace.badge.mcpServer": "Serveur MCP", "marketplace.badge.mode": "Mode", "marketplace.card.by": "par {{author}}", diff --git a/packages/kilo-i18n/src/it.ts b/packages/kilo-i18n/src/it.ts index adff571865d..757c4a5caac 100644 --- a/packages/kilo-i18n/src/it.ts +++ b/packages/kilo-i18n/src/it.ts @@ -60,7 +60,9 @@ export const dict = { "marketplace.search": "Cerca...", "marketplace.filter.all": "Tutti gli elementi", "marketplace.filter.notInstalled": "Non installati", + "marketplace.filter.relevant": "Rilevanti per il mio spazio di lavoro", "marketplace.empty": "Nessun elemento trovato", + "marketplace.empty.relevant": "Nessun elemento rilevante del marketplace trovato per questo spazio di lavoro.", "marketplace.badge.mcpServer": "Server MCP", "marketplace.badge.mode": "Modalità", "marketplace.card.by": "di {{author}}", diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index 25e4263ef3b..6b6ecc44edd 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -57,7 +57,9 @@ export const dict = { "marketplace.search": "検索...", "marketplace.filter.all": "すべてのアイテム", "marketplace.filter.notInstalled": "未インストール", + "marketplace.filter.relevant": "自分のワークスペースに関連", "marketplace.empty": "アイテムが見つかりません", + "marketplace.empty.relevant": "このワークスペースに関連するマーケットプレイスのアイテムが見つかりませんでした。", "marketplace.badge.mcpServer": "MCPサーバー", "marketplace.badge.mode": "モード", "marketplace.card.by": "作成者: {{author}}", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 52c674ceb57..e905fbdfbbe 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -57,7 +57,9 @@ export const dict = { "marketplace.search": "검색...", "marketplace.filter.all": "모든 항목", "marketplace.filter.notInstalled": "설치되지 않음", + "marketplace.filter.relevant": "내 작업 영역과 관련됨", "marketplace.empty": "항목을 찾을 수 없음", + "marketplace.empty.relevant": "이 작업 영역과 관련된 마켓플레이스 항목을 찾을 수 없습니다.", "marketplace.badge.mcpServer": "MCP 서버", "marketplace.badge.mode": "모드", "marketplace.card.by": "제작: {{author}}", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index cb602379f6e..4ad67883c99 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -61,7 +61,9 @@ export const dict = { "marketplace.search": "Zoeken...", "marketplace.filter.all": "Alle items", "marketplace.filter.notInstalled": "Niet geïnstalleerd", + "marketplace.filter.relevant": "Relevant voor mijn werkruimte", "marketplace.empty": "Geen items gevonden", + "marketplace.empty.relevant": "Geen relevante marketplace-items gevonden voor deze werkruimte.", "marketplace.badge.mcpServer": "MCP Server", "marketplace.badge.mode": "Modus", "marketplace.card.by": "door {{author}}", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index 7a14230d3ba..d6d2d33d465 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Søk...", "marketplace.filter.all": "Alle elementer", "marketplace.filter.notInstalled": "Ikke installert", + "marketplace.filter.relevant": "Relevant for arbeidsområdet mitt", "marketplace.empty": "Ingen elementer funnet", + "marketplace.empty.relevant": "Ingen relevante marketplace-elementer funnet for dette arbeidsområdet.", "marketplace.badge.mcpServer": "MCP-server", "marketplace.badge.mode": "Modus", "marketplace.card.by": "av {{author}}", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 90244403794..6a9a617cbe8 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Szukaj...", "marketplace.filter.all": "Wszystkie elementy", "marketplace.filter.notInstalled": "Nie zainstalowano", + "marketplace.filter.relevant": "Istotne dla mojego obszaru roboczego", "marketplace.empty": "Nie znaleziono elementów", + "marketplace.empty.relevant": "Nie znaleziono elementów marketplace istotnych dla tego obszaru roboczego.", "marketplace.badge.mcpServer": "Serwer MCP", "marketplace.badge.mode": "Tryb", "marketplace.card.by": "autor: {{author}}", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 53ed2cd440e..04e3fc2fb57 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Поиск...", "marketplace.filter.all": "Все элементы", "marketplace.filter.notInstalled": "Не установлено", + "marketplace.filter.relevant": "Подходящие для моего рабочего пространства", "marketplace.empty": "Элементы не найдены", + "marketplace.empty.relevant": "Для этого рабочего пространства не найдено подходящих элементов маркетплейса.", "marketplace.badge.mcpServer": "MCP-сервер", "marketplace.badge.mode": "Режим", "marketplace.card.by": "от {{author}}", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 48448e548ec..130ec6ba84d 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "ค้นหา...", "marketplace.filter.all": "รายการทั้งหมด", "marketplace.filter.notInstalled": "ยังไม่ได้ติดตั้ง", + "marketplace.filter.relevant": "เกี่ยวข้องกับพื้นที่ทำงานของฉัน", "marketplace.empty": "ไม่พบรายการ", + "marketplace.empty.relevant": "ไม่พบรายการในมาร์เก็ตเพลสที่เกี่ยวข้องกับพื้นที่ทำงานนี้", "marketplace.badge.mcpServer": "เซิร์ฟเวอร์ MCP", "marketplace.badge.mode": "โหมด", "marketplace.card.by": "โดย {{author}}", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 5714c919c7b..fc87ffcc3d8 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Ara...", "marketplace.filter.all": "Tüm Öğeler", "marketplace.filter.notInstalled": "Yüklü Değil", + "marketplace.filter.relevant": "Çalışma alanımla ilgili", "marketplace.empty": "Öğe bulunamadı", + "marketplace.empty.relevant": "Bu çalışma alanıyla ilgili marketplace öğesi bulunamadı.", "marketplace.badge.mcpServer": "MCP Sunucusu", "marketplace.badge.mode": "Mod", "marketplace.card.by": "{{author}} tarafından", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index 323bd314feb..05c759a9b46 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -59,7 +59,9 @@ export const dict = { "marketplace.search": "Пошук...", "marketplace.filter.all": "Усі елементи", "marketplace.filter.notInstalled": "Не встановлено", + "marketplace.filter.relevant": "Відповідні моєму робочому простору", "marketplace.empty": "Елементів не знайдено", + "marketplace.empty.relevant": "Для цього робочого простору не знайдено відповідних елементів маркетплейсу.", "marketplace.badge.mcpServer": "MCP-сервер", "marketplace.badge.mode": "Режим", "marketplace.card.by": "від {{author}}", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index ec0da59e6ed..25fb70a41ae 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -56,7 +56,9 @@ export const dict = { "marketplace.search": "搜索...", "marketplace.filter.all": "所有项目", "marketplace.filter.notInstalled": "未安装", + "marketplace.filter.relevant": "与我的工作区相关", "marketplace.empty": "未找到项目", + "marketplace.empty.relevant": "未找到与此工作区相关的市场项目。", "marketplace.badge.mcpServer": "MCP 服务器", "marketplace.badge.mode": "模式", "marketplace.card.by": "作者:{{author}}", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index d6f1ccd27e9..dd4ebefaef3 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -56,7 +56,9 @@ export const dict = { "marketplace.search": "搜尋...", "marketplace.filter.all": "所有項目", "marketplace.filter.notInstalled": "未安裝", + "marketplace.filter.relevant": "與我的工作區相關", "marketplace.empty": "找不到項目", + "marketplace.empty.relevant": "找不到與此工作區相關的市集項目。", "marketplace.badge.mcpServer": "MCP 伺服器", "marketplace.badge.mode": "模式", "marketplace.card.by": "作者:{{author}}", diff --git a/packages/kilo-vscode/src/MarketplacePanelProvider.ts b/packages/kilo-vscode/src/MarketplacePanelProvider.ts index cbdd5f53de4..bd36dfd8454 100644 --- a/packages/kilo-vscode/src/MarketplacePanelProvider.ts +++ b/packages/kilo-vscode/src/MarketplacePanelProvider.ts @@ -33,6 +33,8 @@ export class MarketplacePanelProvider implements vscode.Disposable { private panel: vscode.WebviewPanel | undefined private project: string | null = null private ready = false + private generation = 0 + private refresh: ReturnType | undefined private statuses = new Map() private disposables: vscode.Disposable[] = [] private subscriptions: Array<() => void> = [] @@ -60,6 +62,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { if (this.panel) { this.setProjectDirectory(project) this.panel.reveal(vscode.ViewColumn.One) + this.scheduleRefresh() return } @@ -105,6 +108,10 @@ export class MarketplacePanelProvider implements vscode.Disposable { panel.webview.onDidReceiveMessage((msg) => void this.handle(msg as MarketplaceMessage)), panel.onDidDispose(() => this.cleanup()), watchFontSizeConfig((msg) => this.post(msg)), + vscode.extensions.onDidChange(() => this.scheduleRefresh()), + vscode.workspace.onDidCreateFiles(() => this.scheduleRefresh()), + vscode.workspace.onDidDeleteFiles(() => this.scheduleRefresh()), + vscode.workspace.onDidRenameFiles(() => this.scheduleRefresh()), ) this.subscriptions.push( this.connection.onStateChange((state, err) => { @@ -123,12 +130,15 @@ export class MarketplacePanelProvider implements vscode.Disposable { } private cleanup(): void { + if (this.refresh) clearTimeout(this.refresh) + this.refresh = undefined for (const disposable of this.disposables) disposable.dispose() for (const unsubscribe of this.subscriptions) unsubscribe() this.disposables = [] this.subscriptions = [] this.panel = undefined this.ready = false + this.generation++ this.statuses.clear() } @@ -198,19 +208,32 @@ export class MarketplacePanelProvider implements vscode.Disposable { } } + private scheduleRefresh(): void { + if (!this.ready) return + if (this.refresh) clearTimeout(this.refresh) + this.refresh = setTimeout(() => { + this.refresh = undefined + void this.fetchData() + }, 250) + } + private async fetchData(): Promise { + const generation = ++this.generation try { const project = this.project ?? undefined - const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory()) + const data = await fetchMarketplaceData(this.marketplaceCtx, project, this.directory(), this.relevanceRoots()) + if (generation !== this.generation) return const dismissed = this.context.globalState.get("kilo.agentMigrationBannerDismissed") ?? false this.post({ type: "marketplaceData", ...data, showAgentMigrationBanner: !dismissed }) } catch (err) { + if (generation !== this.generation) return const error = err instanceof Error ? err.message : String(err) console.warn("[Kilo New] Marketplace data fetch failed:", err) this.post({ type: "marketplaceData", marketplaceItems: [], marketplaceInstalledMetadata: { project: {}, global: {} }, + marketplaceRelevance: {}, errors: [error], }) } @@ -247,6 +270,7 @@ export class MarketplacePanelProvider implements vscode.Disposable { private setProjectDirectory(project: string | null): void { if (this.project === project) return + this.generation++ this.project = project this.post({ type: "workspaceDirectoryChanged", directory: project ?? "" }) } @@ -264,6 +288,12 @@ export class MarketplacePanelProvider implements vscode.Disposable { return this.project ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir() } + private relevanceRoots(): vscode.Uri[] { + if (!this.project) return vscode.workspace.workspaceFolders?.map((folder) => folder.uri) ?? [] + const folder = vscode.workspace.workspaceFolders?.find((item) => item.uri.fsPath === this.project) + return [folder?.uri ?? vscode.Uri.file(this.project)] + } + private openExternal(raw: unknown): void { if (typeof raw !== "string") return const uri = vscode.Uri.parse(raw) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 55cd1480d91..1a22505abd7 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -23,6 +23,7 @@ import { registerToggleAutoApprove } from "./commands/toggle-auto-approve" import { registerHeapSnapshot } from "./commands/heap-snapshot" import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" +import { createNotebookBridge } from "./services/notebook" let agentManager: AgentManagerProvider | undefined let shuttingDown = false @@ -49,6 +50,7 @@ export function activate(context: vscode.ExtensionContext) { // Create shared connection service (one server for all webviews) const connectionService = new KiloConnectionService(context) + const notebookBridge = createNotebookBridge(connectionService) let restore = context.workspaceState.get(RESTORE_KEY) ?? {} const remember = (patch: RestoreState) => { const next = { ...restore, ...patch } @@ -541,6 +543,7 @@ export function activate(context: vscode.ExtensionContext) { attention.dispose() browserAutomationService.dispose() provider.dispose() + notebookBridge.dispose() connectionService.dispose() }, }) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts index 11b3f490cbf..18c8681586c 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import * as vscode from "vscode" import { KiloConnectionService } from "./connection-service" function state(value: boolean) { @@ -19,6 +20,25 @@ describe("KiloConnectionService sandbox preference", () => { }) }) +describe("KiloConnectionService clients", () => { + test("returns a connected client without a workspace folder", async () => { + const service = new KiloConnectionService({} as any) + const client = {} + const workspace = vscode.workspace as { workspaceFolders?: readonly vscode.WorkspaceFolder[] } + const folders = workspace.workspaceFolders + + ;(service as any).client = client + ;(service as any).state = "connected" + workspace.workspaceFolders = undefined + + try { + expect(await service.getClientAsync()).toBe(client) + } finally { + workspace.workspaceFolders = folders + } + }) +}) + describe("KiloConnectionService viewed sessions", () => { test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => { const service = new KiloConnectionService({} as any) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index d2c4f9bd65c..c2f0f5b6f7c 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -72,6 +72,8 @@ export class KiloConnectionService { private readonly favoritesChangeListeners: Set = new Set() private readonly clearPendingPromptsListeners: Set = new Set() private readonly directoryProviders: Set = new Set() + private rootDirectory: string | undefined = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + private currentDirectory: string | undefined private readonly permissionDirectories: Map = new Map() private readonly questionDirectories: Map = new Map() private questionRevision = 0 @@ -106,6 +108,7 @@ export class KiloConnectionService { * Lazily start server + SSE. Multiple callers share the same promise. */ async connect(workspaceDir: string): Promise { + this.trackDirectory(workspaceDir) if (this.connectPromise) { return this.connectPromise } @@ -145,13 +148,28 @@ export class KiloConnectionService { * or if the connection fails. */ async getClientAsync(dir?: string): Promise { + if (dir) this.trackDirectory(dir) if (this.client && this.state === "connected") return this.client const root = dir ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath if (!root) throw new Error("No workspace folder open") + this.trackDirectory(root) await this.connect(root) return this.getClient() } + /** Directories that may own directory-scoped requests on the shared backend. */ + getKnownDirectories(): string[] { + const dirs = new Set() + if (this.rootDirectory) dirs.add(this.rootDirectory) + if (this.currentDirectory) dirs.add(this.currentDirectory) + for (const provider of this.directoryProviders) { + for (const dir of provider()) { + if (dir) dirs.add(dir) + } + } + return [...dirs] + } + /** * Get server info (port). Returns null if not connected. */ @@ -434,6 +452,12 @@ export class KiloConnectionService { } } + private trackDirectory(dir: string): void { + if (!dir) return + this.rootDirectory ??= dir + this.currentDirectory = dir + } + /** * Reject all pending permission requests and questions across every * directory known to any currently-mounted KiloProvider. @@ -580,6 +604,8 @@ export class KiloConnectionService { this.favoritesChangeListeners.clear() this.clearPendingPromptsListeners.clear() this.directoryProviders.clear() + this.rootDirectory = undefined + this.currentDirectory = undefined this.messageSessionIdsByMessageId.clear() this.permissionDirectories.clear() this.questionDirectories.clear() diff --git a/packages/kilo-vscode/src/services/marketplace/actions.ts b/packages/kilo-vscode/src/services/marketplace/actions.ts index 84b1d34fc5c..9237d54ecac 100644 --- a/packages/kilo-vscode/src/services/marketplace/actions.ts +++ b/packages/kilo-vscode/src/services/marketplace/actions.ts @@ -28,9 +28,10 @@ export async function fetchMarketplaceData( ctx: MarketplaceActionContext, project: string | undefined, dir: string | undefined, + roots: readonly vscode.Uri[], ): Promise { const skills = dir ? await fetchSkills(ctx, dir) : undefined - return ctx.marketplace.fetchData(project, skills) + return ctx.marketplace.fetchData(project, skills, roots) } export async function installMarketplaceItem( diff --git a/packages/kilo-vscode/src/services/marketplace/api.ts b/packages/kilo-vscode/src/services/marketplace/api.ts index 8dbd3c5a9b8..9ee6eb64b04 100644 --- a/packages/kilo-vscode/src/services/marketplace/api.ts +++ b/packages/kilo-vscode/src/services/marketplace/api.ts @@ -38,6 +38,7 @@ function transformSkill(raw: RawSkill): SkillMarketplaceItem { displayCategory: kebabToTitleCase(raw.category), githubUrl: raw.githubUrl, content: raw.content, + suggest_for: raw.suggest_for, } } diff --git a/packages/kilo-vscode/src/services/marketplace/index.ts b/packages/kilo-vscode/src/services/marketplace/index.ts index 148ec9e6d89..67522658612 100644 --- a/packages/kilo-vscode/src/services/marketplace/index.ts +++ b/packages/kilo-vscode/src/services/marketplace/index.ts @@ -3,10 +3,12 @@ import { MarketplaceApiClient } from "./api" import { MarketplacePaths } from "./paths" import { InstallationDetector, type CliSkill } from "./detection" import { MarketplaceInstaller } from "./installer" +import { detectMarketplaceRelevance } from "./relevance" import type { MarketplaceItem, InstallMarketplaceItemOptions, MarketplaceDataResponse, + MarketplaceRelevanceMetadata, InstallResult, RemoveResult, } from "./types" @@ -16,6 +18,7 @@ export class MarketplaceService { private paths: MarketplacePaths private detector: InstallationDetector private installer: MarketplaceInstaller + private scans = new Map>() constructor() { this.paths = new MarketplacePaths() @@ -24,16 +27,30 @@ export class MarketplaceService { this.installer = new MarketplaceInstaller(this.paths) } - async fetchData(workspace?: string, skills?: CliSkill[]): Promise { - const [fetched, metadata] = await Promise.all([this.api.fetchAll(), this.detector.detect(workspace, skills)]) + async fetchData(workspace: string | undefined, skills: CliSkill[] | undefined, roots: readonly vscode.Uri[]) { + const fetched = this.api.fetchAll() + const metadata = this.detector.detect(workspace, skills) + const relevance = fetched.then((result) => this.relevance(result.items, roots)) + const [items, installed, matches] = await Promise.all([fetched, metadata, relevance]) return { - marketplaceItems: fetched.items, - marketplaceInstalledMetadata: metadata, - errors: fetched.errors.length > 0 ? fetched.errors : undefined, + marketplaceItems: items.items, + marketplaceInstalledMetadata: installed, + marketplaceRelevance: matches, + errors: items.errors.length > 0 ? items.errors : undefined, } } + private relevance(items: MarketplaceItem[], roots: readonly vscode.Uri[]): Promise { + const key = `${roots.map((root) => root.toString()).join(",")}:${items.map((item) => `${item.type}:${item.id}`).join(",")}` + const current = this.scans.get(key) + if (current) return current + + const scan = detectMarketplaceRelevance(items, roots).finally(() => this.scans.delete(key)) + this.scans.set(key, scan) + return scan + } + async install( item: MarketplaceItem, options: InstallMarketplaceItemOptions, @@ -59,6 +76,7 @@ export class MarketplaceService { } dispose(): void { + this.scans.clear() this.api.dispose() } } diff --git a/packages/kilo-vscode/src/services/marketplace/relevance.ts b/packages/kilo-vscode/src/services/marketplace/relevance.ts new file mode 100644 index 00000000000..2807d4821aa --- /dev/null +++ b/packages/kilo-vscode/src/services/marketplace/relevance.ts @@ -0,0 +1,72 @@ +import * as vscode from "vscode" +import type { MarketplaceItem, MarketplaceRelevanceMetadata } from "./types" + +const EXCLUDE = "**/{node_modules,.git,dist,build,out,.kilo,.opencode,.kilocode}/**" + +function strings(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === "string") +} + +interface RelevanceHost { + extensions: readonly string[] + find: (root: vscode.Uri, pattern: string) => Promise +} + +function context(): RelevanceHost { + return { + extensions: vscode.extensions.all.map((extension) => extension.id), + find: async (root, pattern) => { + const glob = new vscode.RelativePattern(root, `**/${pattern}`) + return (await vscode.workspace.findFiles(glob, EXCLUDE, 1)).length > 0 + }, + } +} + +export async function detectMarketplaceRelevance( + items: MarketplaceItem[], + roots: readonly vscode.Uri[], + source: RelevanceHost = context(), +): Promise { + const patterns = Array.from(new Set(items.flatMap((item) => strings(item.suggest_for?.filename)))) + const files = new Map() + + const batches = Array.from({ length: Math.ceil(patterns.length / 4) }, (_, index) => + patterns.slice(index * 4, index * 4 + 4), + ) + for (const batch of batches) { + await Promise.all( + batch.map(async (pattern) => { + const found = await Promise.all( + roots.map((root) => + source.find(root, pattern).catch((err: unknown) => { + console.warn(`[Kilo New] Marketplace relevance scan failed for ${pattern}:`, err) + return false + }), + ), + ) + files.set(pattern, found.some(Boolean)) + }), + ) + } + + const extensions = new Set(source.extensions.map((id) => id.toLowerCase())) + return Object.fromEntries( + items.flatMap((item) => { + const filename = strings(item.suggest_for?.filename).filter((pattern) => files.get(pattern)) + const vscodeExtension = strings(item.suggest_for?.vscode_extension).filter((id) => + extensions.has(id.toLowerCase()), + ) + if (!filename?.length && !vscodeExtension?.length) return [] + return [ + [ + `${item.type}:${item.id}`, + { + ...(filename?.length && { filename }), + ...(vscodeExtension?.length && { vscodeExtension }), + }, + ], + ] + }), + ) +} diff --git a/packages/kilo-vscode/src/services/marketplace/types.ts b/packages/kilo-vscode/src/services/marketplace/types.ts index 1f544860b01..3e380e50544 100644 --- a/packages/kilo-vscode/src/services/marketplace/types.ts +++ b/packages/kilo-vscode/src/services/marketplace/types.ts @@ -12,6 +12,11 @@ export interface McpInstallationMethod { prerequisites?: string[] } +export interface MarketplaceSuggestFor { + filename?: string[] + vscode_extension?: string[] +} + export interface MarketplaceItemBase { id: string name: string @@ -20,6 +25,7 @@ export interface MarketplaceItemBase { author?: string authorUrl?: string prerequisites?: string[] + suggest_for?: MarketplaceSuggestFor } export interface McpMarketplaceItem extends MarketplaceItemBase { @@ -48,6 +54,7 @@ export interface RawSkill { category: string githubUrl: string content: string + suggest_for?: MarketplaceSuggestFor } export interface SkillMarketplaceItem extends MarketplaceItemBase { @@ -71,9 +78,17 @@ export interface MarketplaceInstalledMetadata { global: Record } +export interface MarketplaceRelevance { + filename?: string[] + vscodeExtension?: string[] +} + +export type MarketplaceRelevanceMetadata = Record + export interface MarketplaceDataResponse { marketplaceItems: MarketplaceItem[] marketplaceInstalledMetadata: MarketplaceInstalledMetadata + marketplaceRelevance: MarketplaceRelevanceMetadata errors?: string[] } diff --git a/packages/kilo-vscode/src/services/notebook/adapter.ts b/packages/kilo-vscode/src/services/notebook/adapter.ts new file mode 100644 index 00000000000..1cad2e9eee3 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/adapter.ts @@ -0,0 +1,460 @@ +import path from "node:path" +import * as vscode from "vscode" +import { normalizeOutputs, normalizeSource } from "./output" +import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path" +import { cellFingerprint, fingerprint, notebookState, sameCell, type NotebookState } from "./revision" +import { + NOTEBOOK_LIMITS, + type NotebookAccess, + type NotebookAdapterDeps, + type NotebookCell, + type NotebookEditRequest, + type NotebookEditResult, + type NotebookExecuteRequest, + type NotebookExecuteResult, + type NotebookExecution, + type NotebookReadRequest, + type NotebookReadResult, +} from "./types" + +const RETAINED_REVISIONS = 1_000 +const revisions = new Map() +const locks = new Map>() + +function revisionKey(target: string, revision: string): string { + return `${target}\0${revision}` +} + +export interface NotebookAdapterOptions { + deps?: NotebookAdapterDeps + paths?: NotebookPathDeps + timeout?: number +} + +function execution(summary: vscode.NotebookCellExecutionSummary | undefined): NotebookExecution | undefined { + if (!summary) { + return undefined + } + return { + order: summary.executionOrder, + success: summary.success, + started: summary.timing?.startTime, + ended: summary.timing?.endTime, + } +} + +function changed( + base: vscode.NotebookCellExecutionSummary | undefined, + summary: vscode.NotebookCellExecutionSummary | undefined, +): boolean { + if (!summary) return false + return ( + summary.executionOrder !== base?.executionOrder || + summary.success !== base?.success || + summary.timing?.startTime !== base?.timing?.startTime || + summary.timing?.endTime !== base?.timing?.endTime + ) +} + +function defaults(): NotebookAdapterDeps { + return { + documents: () => vscode.workspace.notebookDocuments, + open: (uri) => Promise.resolve(vscode.workspace.openNotebookDocument(uri)), + apply: (edit) => Promise.resolve(vscode.workspace.applyEdit(edit)), + execute: (command, ...args) => Promise.resolve(vscode.commands.executeCommand(command, ...args)), + change: (listener) => vscode.workspace.onDidChangeNotebookDocument(listener), + close: (listener) => vscode.workspace.onDidCloseNotebookDocument(listener), + uri: vscode.Uri.file, + edit: (uri, edits) => { + const edit = new vscode.WorkspaceEdit() + edit.set(uri, edits) + return edit + }, + insert: (index, cells) => vscode.NotebookEdit.insertCells(index, cells), + replace: (index, cells) => vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(index, index + 1), cells), + delete: (index) => vscode.NotebookEdit.deleteCells(new vscode.NotebookRange(index, index + 1)), + cell: (input) => + new vscode.NotebookCellData( + input.kind === "code" ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup, + input.source, + input.language ?? (input.kind === "code" ? "plaintext" : "markdown"), + ), + } +} + +export class NotebookAdapter { + private readonly deps: NotebookAdapterDeps + private readonly timeout: number + + constructor( + private readonly access: NotebookAccess, + private readonly options: NotebookAdapterOptions = {}, + ) { + this.deps = options.deps ?? defaults() + this.timeout = options.timeout ?? 9 * 60_000 + } + + private async document( + directory: string, + input: string, + ): Promise<{ document: vscode.NotebookDocument; path: string; target: string }> { + const resolved = await resolveNotebookPath(directory, input, this.access, this.options.paths) + const open = this.deps + .documents() + .find((document) => path.resolve(document.uri.fsPath) === path.resolve(resolved.target)) + const document = open ?? (await this.deps.open(this.deps.uri(resolved.target))) + if (document.isClosed) { + throw new NotebookError("closed", `Notebook ${JSON.stringify(resolved.relative)} is closed`, { + path: resolved.relative, + }) + } + return { document, path: resolved.relative, target: resolved.target } + } + + async read(request: NotebookReadRequest): Promise { + const loaded = await this.document(request.directory, request.path) + const cells: NotebookCell[] = [] + const budget = { sources: 0, outputs: 0 } + const flags = { sources: false, outputs: false } + + const items = loaded.document.getCells() + const state = this.remember(loaded.document, loaded.target) + if (items.length > 2_000) { + flags.sources = true + } + for (const [index, cell] of items.slice(0, 2_000).entries()) { + const limit = Math.max(0, Math.min(NOTEBOOK_LIMITS.source, NOTEBOOK_LIMITS.sources - budget.sources)) + const source = (() => { + if (limit === 0) { + flags.sources = true + return { text: "", bytes: 0 } + } + const end = cell.document.positionAt(limit + 2) + const range = new vscode.Range(new vscode.Position(0, 0), end) + return normalizeSource(cell.document.getText(range), limit) + })() + budget.sources += source.bytes + flags.sources ||= source.truncated === true + const value: NotebookCell = { + index, + kind: cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown", + language: cell.document.languageId.slice(0, 200), + source: source.text, + execution: execution(cell.executionSummary), + } + if (request.includeOutputs) { + const normalized = normalizeOutputs( + cell.outputs, + Math.max(0, Math.min(NOTEBOOK_LIMITS.output, NOTEBOOK_LIMITS.outputs - budget.outputs)), + ) + value.outputs = normalized.outputs + budget.outputs += normalized.bytes + if (normalized.truncated) { + flags.outputs = true + } + } + cells.push(value) + } + + return { + operation: "read", + path: loaded.path, + requestPath: request.path, + revision: state.revision, + cells, + ...(flags.sources || flags.outputs ? { truncated: true } : {}), + } + } + + async edit(request: NotebookEditRequest): Promise { + const loaded = await this.document(request.directory, request.path) + return this.lock(loaded.target, async () => { + const before = this.remember(loaded.document, loaded.target) + this.revision(before, request.expectedRevision, loaded.path, request.index) + const count = loaded.document.cellCount + const max = request.edit.action === "insert" ? count : count - 1 + if (!Number.isInteger(request.index) || request.index < 0 || request.index > max) { + throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`, { + path: loaded.path, + index: request.index, + }) + } + + const expected = [...before.cells] + const edits = (() => { + if (request.edit.action === "delete") { + expected.splice(request.index, 1) + return [this.deps.delete(request.index)] + } + const language = request.edit.language ?? (request.edit.kind === "code" ? "plaintext" : "markdown") + const cell = this.deps.cell({ + kind: request.edit.kind, + language: request.edit.language, + source: request.edit.source, + }) + const value = fingerprint(request.edit.kind, language, request.edit.source) + if (request.edit.action === "insert") { + expected.splice(request.index, 0, value) + return [this.deps.insert(request.index, [cell])] + } + expected.splice(request.index, 1, value) + return [this.deps.replace(request.index, [cell])] + })() + this.revision(this.remember(loaded.document, loaded.target), request.expectedRevision, loaded.path, request.index) + if (!(await this.deps.apply(this.deps.edit(loaded.document.uri, edits)))) { + throw new NotebookError("unsupported", "VS Code rejected the notebook edit", { + path: loaded.path, + index: request.index, + }) + } + const after = this.remember(loaded.document, loaded.target) + if (after.cells.length !== expected.length || after.cells.some((value, index) => value !== expected[index])) { + throw this.stale(loaded.path, request.index, after.revision, "Notebook content changed while applying the edit") + } + const result: NotebookEditResult = { + operation: "edit", + path: loaded.path, + requestPath: request.path, + revision: after.revision, + index: request.index, + action: request.edit.action, + } + if (request.edit.action !== "delete" && request.index < loaded.document.cellCount) { + result.cell = this.cell(loaded.document.cellAt(request.index), request.index) + } + return result + }) + } + + async execute(request: NotebookExecuteRequest): Promise { + const loaded = await this.document(request.directory, request.path) + const state = this.remember(loaded.document, loaded.target) + if (!Number.isInteger(request.index) || request.index < 0 || request.index >= loaded.document.cellCount) { + throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`, { + path: loaded.path, + index: request.index, + }) + } + const expected = revisions.get(revisionKey(loaded.target, request.expectedRevision)) + if ( + state.revision !== request.expectedRevision && + !sameCell(expected?.cells[request.index], state.cells[request.index]) + ) { + throw this.stale(loaded.path, request.index, state.revision, "The targeted notebook cell changed") + } + const cell = loaded.document.cellAt(request.index) + if (cell.kind !== vscode.NotebookCellKind.Code) { + throw new NotebookError("invalid_cell", `Cell ${request.index} is not a code cell`, { + path: loaded.path, + index: request.index, + }) + } + + const result = this.wait(loaded.document, cell, loaded.target, loaded.path, state.cells[request.index]!, request) + void this.deps + .execute("notebook.cell.execute", { + ranges: [{ start: request.index, end: request.index + 1 }], + document: loaded.document.uri, + }) + .catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + result.reject( + new NotebookError("execution_failed", `Notebook execution could not start: ${detail}`, { + path: loaded.path, + index: request.index, + }), + ) + }) + return result.promise + } + + private wait( + document: vscode.NotebookDocument, + cell: vscode.NotebookCell, + target: string, + path: string, + fingerprint: NotebookState["cells"][number], + request: NotebookExecuteRequest, + ) { + const state: { + done: boolean + timer?: ReturnType + startup?: ReturnType + } = { done: false } + const base = cell.executionSummary + const disposables: vscode.Disposable[] = [] + const cleanup = () => { + if (state.done) { + return false + } + state.done = true + if (state.timer) { + clearTimeout(state.timer) + } + if (state.startup) { + clearTimeout(state.startup) + } + for (const disposable of disposables) { + disposable.dispose() + } + request.signal?.removeEventListener("abort", abort) + return true + } + const holder: { + resolve?: (value: NotebookExecuteResult) => void + reject?: (error: Error) => void + } = {} + const promise = new Promise((resolve, reject) => { + holder.resolve = resolve + holder.reject = reject + }) + const reject = (error: Error) => { + if (cleanup()) { + holder.reject?.(error) + } + } + const resolve = () => { + if (!cleanup()) { + return + } + const normalized = normalizeOutputs(cell.outputs) + holder.resolve?.({ + operation: "execute", + path, + requestPath: request.path, + revision: this.remember(document, target).revision, + index: request.index, + status: cell.executionSummary?.success === false ? "error" : "success", + outputs: normalized.outputs, + ...(normalized.truncated ? { truncated: true } : {}), + }) + } + const stop = () => + void this.deps.execute("notebook.cell.cancelExecution", { + ranges: [{ start: request.index, end: request.index + 1 }], + document: document.uri, + }) + const abort = () => { + stop() + reject( + new NotebookError("cancelled", "Notebook execution cancellation was requested", { + path, + index: request.index, + }), + ) + } + + disposables.push( + this.deps.change((event) => { + if (event.notebook !== document) { + return + } + const live = document.cellCount > request.index ? cellFingerprint(document.cellAt(request.index)) : undefined + if ( + document.isClosed || + document.cellCount <= request.index || + document.cellAt(request.index) !== cell || + !sameCell(fingerprint, live) + ) { + const current = this.remember(document, target) + reject( + this.stale(path, request.index, current.revision, "The targeted notebook cell changed during execution"), + ) + stop() + return + } + const change = event.cellChanges.find((item) => item.cell === cell) + if (!change) { + return + } + if (state.startup && (change.outputs !== undefined || change.executionSummary !== undefined)) { + clearTimeout(state.startup) + state.startup = undefined + } + const summary = change.executionSummary + if (!summary || !changed(base, summary)) return + if (summary.success !== undefined || summary.timing?.endTime !== undefined) resolve() + }), + this.deps.close((closed) => { + if (closed === document) { + reject(new NotebookError("closed", "Notebook closed during execution", { path, index: request.index })) + } + }), + ) + const timeout = request.timeout ?? this.timeout + state.startup = setTimeout( + () => { + stop() + reject( + new NotebookError( + "execution_failed", + "The execution command was dispatched, but VS Code reported no execution activity before the startup timeout. Ensure a kernel is selected and try again", + { path, index: request.index }, + ), + ) + }, + Math.min(timeout, 60_000), + ) + state.timer = setTimeout(() => { + stop() + reject(new NotebookError("timeout", "Notebook execution timed out", { path, index: request.index })) + }, timeout) + if (request.signal?.aborted) { + abort() + } else { + request.signal?.addEventListener("abort", abort, { once: true }) + } + return { promise, reject } + } + + private cell(cell: vscode.NotebookCell, index: number): NotebookCell { + return { + index, + kind: cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown", + language: cell.document.languageId.slice(0, 200), + source: cell.document.getText().slice(0, 200_000), + execution: execution(cell.executionSummary), + } + } + + private remember(document: vscode.NotebookDocument, target: string): NotebookState { + const state = notebookState(document) + const key = revisionKey(target, state.revision) + revisions.delete(key) + revisions.set(key, state) + if (revisions.size > RETAINED_REVISIONS) { + const oldest = revisions.keys().next().value + if (oldest !== undefined) revisions.delete(oldest) + } + return state + } + + private revision(state: NotebookState, expected: string, path: string, index: number): void { + if (state.revision !== expected) { + throw this.stale(path, index, state.revision, "Notebook content changed") + } + } + + private stale(path: string, index: number, revision: string, detail: string): NotebookError { + return new NotebookError( + "stale_revision", + `${detail}. Re-read ${JSON.stringify(path)} before retrying; do not blindly replay an index-based edit`, + { path, index, currentRevision: revision }, + ) + } + + private async lock(key: string, task: () => Promise): Promise { + const prior = locks.get(key) ?? Promise.resolve() + const gate: { release?: () => void } = {} + const current = new Promise((resolve) => (gate.release = resolve)) + const next = prior.then(() => current) + locks.set(key, next) + await prior + try { + return await task() + } finally { + gate.release?.() + if (locks.get(key) === next) locks.delete(key) + } + } +} diff --git a/packages/kilo-vscode/src/services/notebook/bridge.ts b/packages/kilo-vscode/src/services/notebook/bridge.ts new file mode 100644 index 00000000000..7b4f5644d04 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/bridge.ts @@ -0,0 +1,368 @@ +import { realpath } from "node:fs/promises" +import type { + EventKilocodeNotebookCancelled, + EventKilocodeNotebookRequested, + KiloClient, + NotebookFailure, + NotebookRequest, + NotebookResult, +} from "@kilocode/sdk/v2/client" +import { FileIgnoreController } from "../autocomplete/shims/FileIgnoreController" +import type { ConnectionState, KiloConnectionService } from "../cli-backend/connection-service" +import type { SSEPayload } from "../cli-backend/sdk-sse-adapter" +import { NotebookAdapter } from "./adapter" +import { NotebookError } from "./path" + +const RETAINED_REQUESTS = 1_000 +const CODES = new Set([ + "cancelled", + "closed", + "disconnected", + "execution_failed", + "invalid_cell", + "invalid_path", + "no_kernel", + "not_found", + "stale_revision", + "timeout", + "unsupported", +]) + +type NotebookAdapterLike = Pick + +export interface NotebookBridgeContext { + adapter: NotebookAdapterLike + refresh?(): Promise + dispose(): void +} + +export interface NotebookBridgeOptions { + create?: (directory: string) => Promise + canonical?: (directory: string) => Promise +} + +interface NotebookConnection { + onEvent(listener: (event: SSEPayload, directory?: string) => void): () => void + onStateChange(listener: (state: ConnectionState, error?: Error) => void): () => void + getClient(): KiloClient + getKnownDirectories(): string[] +} + +interface ActiveRequest { + controller: AbortController + cancelled: boolean +} + +interface RequestOrigin { + directory: string + root: string + sessionID: string +} + +type NotebookOutcome = { result: NotebookResult } | { error: NotebookFailure } + +async function createContext(directory: string): Promise { + const controller = new FileIgnoreController(directory) + await controller.initialize() + return { + adapter: new NotebookAdapter(controller), + refresh: () => controller.initialize(), + dispose: () => controller.dispose(), + } +} + +function failure(error: unknown): NotebookFailure { + const detail = error instanceof Error ? error.message : String(error) + const message = (detail || "Notebook operation failed without an error message").slice(0, 10_000) + if (error instanceof NotebookError && CODES.has(error.code as NotebookFailure["code"])) { + return { + code: error.code as NotebookFailure["code"], + message, + ...(error.path !== undefined ? { path: error.path } : {}), + ...(error.index !== undefined ? { index: error.index } : {}), + ...(error.currentRevision !== undefined ? { currentRevision: error.currentRevision } : {}), + } + } + return { code: "execution_failed", message } +} + +export class NotebookBridge { + private readonly contexts = new Map>() + private readonly active = new Map() + private readonly admitting = new Set() + private readonly origins = new Map() + private readonly outcomes = new Map() + private readonly settled = new Set() + private readonly unsubscribeEvent: () => void + private readonly unsubscribeState: () => void + private readonly create: (directory: string) => Promise + private readonly canonical: (directory: string) => Promise + private disposed = false + private revision = 0 + private backend: KiloClient | undefined + + constructor( + private readonly connection: NotebookConnection, + options: NotebookBridgeOptions = {}, + ) { + this.create = options.create ?? createContext + this.canonical = options.canonical ?? realpath + this.unsubscribeEvent = connection.onEvent((event, directory) => this.event(event, directory)) + this.unsubscribeState = connection.onStateChange((state) => { + if (state !== "connected") return + const backend = connection.getClient() + if (this.backend && this.backend !== backend) this.reset() + this.backend = backend + const revision = ++this.revision + void this.recover(revision).catch((error: unknown) => { + console.error("[Kilo New] NotebookBridge: pending request recovery failed:", error) + }) + }) + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.revision += 1 + this.unsubscribeEvent() + this.unsubscribeState() + for (const request of this.active.values()) { + request.cancelled = true + request.controller.abort() + } + this.active.clear() + this.admitting.clear() + for (const context of this.contexts.values()) { + void context + .then((value) => value.dispose()) + .catch((error: unknown) => console.error("[Kilo New] NotebookBridge: context disposal failed:", error)) + } + this.contexts.clear() + this.origins.clear() + this.outcomes.clear() + this.settled.clear() + } + + private reset(): void { + for (const request of this.active.values()) { + request.cancelled = true + request.controller.abort() + } + this.active.clear() + this.admitting.clear() + this.origins.clear() + this.outcomes.clear() + this.settled.clear() + } + + private event(event: SSEPayload, directory?: string): void { + if (event.type === "kilocode.notebook.requested") { + this.request(event as EventKilocodeNotebookRequested, directory) + return + } + if (event.type === "kilocode.notebook.cancelled") { + this.cancel(event as EventKilocodeNotebookCancelled, directory) + } + } + + private request(event: EventKilocodeNotebookRequested, directory?: string): void { + const request = event.properties + const origin = this.origins.get(request.id) + if (origin) { + if (origin.sessionID !== request.sessionID || (directory && origin.directory !== directory)) return + this.start(request, origin) + return + } + if (!directory || this.disposed || this.admitting.has(request.id) || this.settled.has(request.id)) return + this.admitting.add(request.id) + void this.admit(request, directory).finally(() => this.admitting.delete(request.id)) + } + + private async admit(request: NotebookRequest, directory: string): Promise { + const root = await this.allowed(directory) + if (this.disposed || this.settled.has(request.id)) return + if (!root) { + const accepted = await this.reject(request.id, directory, { + code: "invalid_path", + message: "Notebook request directory is not an active VS Code workspace", + }) + if (accepted) this.remember(this.settled, request.id) + return + } + const origin = { directory, root, sessionID: request.sessionID } + this.rememberOrigin(request.id, origin) + this.start(request, origin) + } + + private start(request: NotebookRequest, origin: RequestOrigin): void { + if (this.disposed || this.active.has(request.id) || this.settled.has(request.id)) return + const active = { controller: new AbortController(), cancelled: false } + this.active.set(request.id, active) + void this.run(request, origin, active).catch((error: unknown) => { + console.error(`[Kilo New] NotebookBridge: request ${request.id} failed:`, error) + }) + } + + private cancel(event: EventKilocodeNotebookCancelled, directory?: string): void { + const id = event.properties.requestID + const origin = this.origins.get(id) + if (origin && (origin.sessionID !== event.properties.sessionID || (directory && origin.directory !== directory))) + return + this.remember(this.settled, id) + const active = this.active.get(id) + if (!active) return + active.cancelled = true + active.controller.abort() + } + + private async run(request: NotebookRequest, origin: RequestOrigin, active: ActiveRequest): Promise { + try { + const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, origin.root, active)) + if (!outcome || this.disposed || active.cancelled) return + this.rememberOutcome(request.id, outcome) + const accepted = + "result" in outcome + ? await this.reply(request.id, origin.directory, outcome.result) + : await this.reject(request.id, origin.directory, outcome.error) + if (accepted) { + this.outcomes.delete(request.id) + this.remember(this.settled, request.id) + } + } finally { + if (this.active.get(request.id) === active) this.active.delete(request.id) + } + } + + private async execute( + request: NotebookRequest, + directory: string, + active: ActiveRequest, + ): Promise { + try { + const context = await this.context(directory) + if (this.disposed || active.cancelled) return undefined + await context.refresh?.() + if (this.disposed || active.cancelled) return undefined + const result = await this.dispatch(context.adapter, request, directory, active.controller.signal) + return { result } + } catch (error) { + if (this.disposed || active.cancelled) return undefined + return { error: failure(error) } + } + } + + private dispatch( + adapter: NotebookAdapterLike, + request: NotebookRequest, + directory: string, + signal: AbortSignal, + ): Promise { + if (request.operation === "read") { + return adapter.read({ path: request.path, directory, includeOutputs: request.includeOutputs }) + } + if (request.operation === "edit") { + return adapter.edit({ + path: request.path, + directory, + expectedRevision: request.expectedRevision, + index: request.index, + edit: request.edit, + }) + } + return adapter.execute({ + path: request.path, + directory, + expectedRevision: request.expectedRevision, + index: request.index, + signal, + }) + } + + private async allowed(directory: string): Promise { + const root = await this.canonical(directory).catch(() => undefined) + if (!root) return undefined + const known = await Promise.all( + this.connection.getKnownDirectories().map((dir) => this.canonical(dir).catch(() => undefined)), + ) + return known.includes(root) ? root : undefined + } + + private context(directory: string): Promise { + const existing = this.contexts.get(directory) + if (existing) return existing + const context = this.create(directory).catch((error: unknown) => { + this.contexts.delete(directory) + throw error + }) + this.contexts.set(directory, context) + return context + } + + private async reply(requestID: string, directory: string, result: NotebookResult): Promise { + try { + const response = await this.connection.getClient().kilocode.notebook.reply({ requestID, directory, result }) + if (!response.error) return true + console.error(`[Kilo New] NotebookBridge: reply ${requestID} failed:`, response.error) + return false + } catch (error) { + console.error(`[Kilo New] NotebookBridge: reply ${requestID} failed:`, error) + return false + } + } + + private async reject(requestID: string, directory: string, error: NotebookFailure): Promise { + try { + const response = await this.connection.getClient().kilocode.notebook.reject({ requestID, directory, error }) + if (!response.error) return true + console.error(`[Kilo New] NotebookBridge: rejection ${requestID} failed:`, response.error) + return false + } catch (cause) { + console.error(`[Kilo New] NotebookBridge: rejection ${requestID} failed:`, cause) + return false + } + } + + private async recover(revision: number): Promise { + const client = this.connection.getClient() + for (const directory of this.connection.getKnownDirectories()) { + try { + const response = await client.kilocode.notebook.list({ directory }) + if (this.disposed || revision !== this.revision) return + if (response.error) { + console.error(`[Kilo New] NotebookBridge: could not list requests for ${directory}:`, response.error) + continue + } + for (const request of response.data ?? []) { + this.request({ id: request.id, type: "kilocode.notebook.requested", properties: request }, directory) + } + } catch (error) { + console.error(`[Kilo New] NotebookBridge: could not list requests for ${directory}:`, error) + } + } + } + + private rememberOutcome(id: string, outcome: NotebookOutcome): void { + this.outcomes.set(id, outcome) + if (this.outcomes.size <= RETAINED_REQUESTS) return + const oldest = this.outcomes.keys().next().value + if (oldest !== undefined) this.outcomes.delete(oldest) + } + + private rememberOrigin(id: string, origin: RequestOrigin): void { + this.origins.set(id, origin) + if (this.origins.size <= RETAINED_REQUESTS) return + const oldest = this.origins.keys().next().value + if (oldest !== undefined) this.origins.delete(oldest) + } + + private remember(set: Set, id: string): void { + set.add(id) + if (set.size <= RETAINED_REQUESTS) return + const oldest = set.keys().next().value + if (oldest !== undefined) set.delete(oldest) + } +} + +export function createNotebookBridge(connection: KiloConnectionService): NotebookBridge { + return new NotebookBridge(connection) +} diff --git a/packages/kilo-vscode/src/services/notebook/index.ts b/packages/kilo-vscode/src/services/notebook/index.ts new file mode 100644 index 00000000000..76bdf10c7a5 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/index.ts @@ -0,0 +1,5 @@ +export { NotebookBridge, createNotebookBridge, type NotebookBridgeContext, type NotebookBridgeOptions } from "./bridge" +export { NotebookAdapter, type NotebookAdapterOptions } from "./adapter" +export { normalizeOutputs, normalizeSource } from "./output" +export { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path" +export * from "./types" diff --git a/packages/kilo-vscode/src/services/notebook/output.ts b/packages/kilo-vscode/src/services/notebook/output.ts new file mode 100644 index 00000000000..643748f35d3 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/output.ts @@ -0,0 +1,95 @@ +import type * as vscode from "vscode" +import { NOTEBOOK_LIMITS, type NotebookOutput, type NotebookText } from "./types" + +const decoder = new TextDecoder() +const encoder = new TextEncoder() +const ERROR_MIME = "application/vnd.code.notebook.error" +const TEXT_MIMES = new Set([ + "text/plain", + "text/markdown", + "application/json", + "application/vnd.code.notebook.stdout", + "application/vnd.code.notebook.stderr", +]) + +function slice(data: Uint8Array, limit: number): NotebookText { + const bytes = data.byteLength + if (bytes <= limit) return { text: decoder.decode(data), bytes } + const end = (() => { + let index = limit + while (index > 0 && (data[index] & 0xc0) === 0x80) index-- + return index + })() + return { text: decoder.decode(data.subarray(0, end)), bytes, truncated: true } +} + +export function normalizeSource(source: string, limit = NOTEBOOK_LIMITS.source): NotebookText { + return slice(encoder.encode(source), limit) +} + +function field(value: unknown, limit: number): string | undefined { + if (typeof value !== "string") return undefined + return value.slice(0, limit) +} + +function error(item: vscode.NotebookCellOutputItem, limit: number): NotebookOutput { + const value = slice(item.data, limit) + const parsed = (() => { + try { + return JSON.parse(value.text) as unknown + } catch (err) { + void err + return undefined + } + })() + const data = typeof parsed === "object" && parsed !== null ? (parsed as Record) : {} + return { + mime: item.mime.slice(0, 200), + text: value.text, + name: field(data.name, 500), + message: field(data.message, 10_000), + stack: field(data.stack, 50_000), + truncated: value.truncated, + } +} + +export function normalizeOutputs( + outputs: readonly vscode.NotebookCellOutput[], + limit = NOTEBOOK_LIMITS.output, +): { outputs: NotebookOutput[]; truncated: boolean; bytes: number } { + const result: NotebookOutput[] = [] + let used = 0 + let truncated = false + + for (const output of outputs) { + for (const item of output.items) { + if (result.length >= 100) { + truncated = true + continue + } + const text = TEXT_MIMES.has(item.mime) || item.mime.startsWith("text/") + if (item.mime !== ERROR_MIME && !text) { + result.push({ mime: item.mime.slice(0, 200), omitted: true }) + continue + } + const available = Math.max(0, Math.min(NOTEBOOK_LIMITS.item, limit - used)) + if (available === 0) { + truncated = true + continue + } + if (item.mime === ERROR_MIME) { + const value = error(item, available) + result.push(value) + used += Math.min(item.data.byteLength, available) + truncated ||= value.truncated === true + continue + } + const value = slice(item.data, available) + result.push({ mime: item.mime.slice(0, 200), text: value.text, truncated: value.truncated }) + used += Math.min(item.data.byteLength, available) + truncated ||= value.truncated === true + } + } + + return { outputs: result, truncated, bytes: used } +} diff --git a/packages/kilo-vscode/src/services/notebook/path.ts b/packages/kilo-vscode/src/services/notebook/path.ts new file mode 100644 index 00000000000..f165899dff5 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/path.ts @@ -0,0 +1,86 @@ +import fs from "node:fs/promises" +import path from "node:path" +import type { NotebookAccess } from "./types" + +const WINDOWS_ABSOLUTE = /^[a-zA-Z]:[/\\]/ + +export interface NotebookErrorDetails { + path?: string + index?: number + currentRevision?: string +} + +export class NotebookError extends Error { + readonly path?: string + readonly index?: number + readonly currentRevision?: string + + constructor( + public readonly code: string, + message: string, + details: NotebookErrorDetails = {}, + ) { + super(message) + this.name = "NotebookError" + this.path = details.path + this.index = details.index + this.currentRevision = details.currentRevision + } +} + +export interface NotebookPathDeps { + realpath(path: string): Promise +} + +export interface NotebookPath { + target: string + relative: string +} + +const defaults: NotebookPathDeps = { realpath: fs.realpath } + +function contained(root: string, target: string): boolean { + const relative = path.relative(root, target) + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) +} + +function invalid(input: string, reason: string): NotebookError { + return new NotebookError( + "invalid_path", + `Invalid notebook path ${JSON.stringify(input)}: ${reason}. Use a path relative to the request directory or an absolute path inside it`, + { path: input }, + ) +} + +export async function resolveNotebookPath( + directory: string, + input: string, + access: NotebookAccess, + deps: NotebookPathDeps = defaults, +): Promise { + if (!input || input.length > 4_096 || input.includes("\0")) { + throw invalid(input, "the path is empty, too long, or malformed") + } + if (WINDOWS_ABSOLUTE.test(input) && !path.win32.isAbsolute(directory)) { + throw invalid(input, "the absolute path uses a different platform format") + } + + const base = path.resolve(directory) + const root = await deps.realpath(base) + const candidate = path.resolve(base, input) + if (!path.isAbsolute(input) && !contained(base, candidate)) { + throw invalid(input, "it is outside the request directory") + } + + const target = await deps.realpath(candidate).catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error) + throw new NotebookError("not_found", `Cannot resolve notebook ${JSON.stringify(input)}: ${detail}`, { path: input }) + }) + if (!contained(root, target)) { + throw invalid(input, "it resolves through a symlink outside the request directory") + } + if (!(await access.validateAccess(target))) { + throw invalid(input, "it is excluded by workspace access or ignore rules") + } + return { target, relative: path.relative(root, target).split(path.sep).join("/") } +} diff --git a/packages/kilo-vscode/src/services/notebook/revision.ts b/packages/kilo-vscode/src/services/notebook/revision.ts new file mode 100644 index 00000000000..aa820a86e10 --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/revision.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto" +import * as vscode from "vscode" + +export interface NotebookState { + revision: string + cells: string[] +} + +function hash(parts: string[]): string { + const value = createHash("sha256") + for (const part of parts) { + value.update(String(Buffer.byteLength(part))) + value.update(":") + value.update(part) + } + return value.digest("base64url") +} + +export function fingerprint(kind: "code" | "markdown", language: string, source: string): string { + return hash([kind, language, source]) +} + +export function cellFingerprint(cell: vscode.NotebookCell): string { + return fingerprint( + cell.kind === vscode.NotebookCellKind.Code ? "code" : "markdown", + cell.document.languageId, + cell.document.getText(), + ) +} + +export function notebookState(document: vscode.NotebookDocument): NotebookState { + const cells = document.getCells().map(cellFingerprint) + return { revision: `content:${hash(cells)}`, cells } +} + +export function sameCell(left: string | undefined, right: string | undefined): boolean { + return left !== undefined && left === right +} diff --git a/packages/kilo-vscode/src/services/notebook/types.ts b/packages/kilo-vscode/src/services/notebook/types.ts new file mode 100644 index 00000000000..d195675994f --- /dev/null +++ b/packages/kilo-vscode/src/services/notebook/types.ts @@ -0,0 +1,126 @@ +import type * as vscode from "vscode" + +export const NOTEBOOK_LIMITS = { + source: 64 * 1024, + sources: 256 * 1024, + item: 16 * 1024, + output: 64 * 1024, + outputs: 256 * 1024, +} as const + +export type NotebookCellKind = "code" | "markdown" + +export interface NotebookText { + text: string + bytes: number + truncated?: true +} + +export interface NotebookOutput { + mime: string + text?: string + name?: string + message?: string + stack?: string + omitted?: boolean + truncated?: boolean +} + +export interface NotebookExecution { + order?: number + success?: boolean + started?: number + ended?: number +} + +export interface NotebookCell { + index: number + kind: NotebookCellKind + language: string + source: string + execution?: NotebookExecution + outputs?: NotebookOutput[] +} + +export interface NotebookReadResult { + operation: "read" + path: string + requestPath: string + revision: string + cells: NotebookCell[] + truncated?: boolean +} + +export interface NotebookEditResult { + operation: "edit" + path: string + requestPath: string + revision: string + index: number + action: "insert" | "replace" | "delete" + cell?: NotebookCell +} + +export interface NotebookExecuteResult { + operation: "execute" + path: string + requestPath: string + revision: string + index: number + status: "success" | "error" + outputs: NotebookOutput[] + truncated?: boolean +} + +export interface NotebookCellInput { + kind: NotebookCellKind + language?: string + source: string +} + +export type NotebookEdit = + | ({ action: "insert" } & NotebookCellInput) + | ({ action: "replace" } & NotebookCellInput) + | { action: "delete" } + +export interface NotebookReadRequest { + path: string + directory: string + includeOutputs: boolean +} + +export interface NotebookEditRequest { + path: string + directory: string + expectedRevision: string + index: number + edit: NotebookEdit +} + +export interface NotebookExecuteRequest { + path: string + directory: string + expectedRevision: string + index: number + signal?: AbortSignal + timeout?: number +} + +export interface NotebookAccess { + validateAccess(path: string): boolean | Promise +} + +export interface NotebookAdapterDeps { + documents(): readonly vscode.NotebookDocument[] + open(uri: vscode.Uri): Promise + apply(edit: vscode.WorkspaceEdit): Promise + execute(command: string, ...args: unknown[]): Promise + change(listener: (event: vscode.NotebookDocumentChangeEvent) => void): vscode.Disposable + close(listener: (document: vscode.NotebookDocument) => void): vscode.Disposable + uri(path: string): vscode.Uri + edit(uri: vscode.Uri, edits: vscode.NotebookEdit[]): vscode.WorkspaceEdit + insert(index: number, cells: vscode.NotebookCellData[]): vscode.NotebookEdit + replace(index: number, cells: vscode.NotebookCellData[]): vscode.NotebookEdit + delete(index: number): vscode.NotebookEdit + cell(input: NotebookCellInput): vscode.NotebookCellData +} diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index f778f9b1f0f..fc47330c300 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -7,7 +7,12 @@ import { type MarketplaceRemoveContext, } from "../../src/services/marketplace/actions" import type { McpMarketplaceItem } from "../../src/services/marketplace/types" -import { filterItems, installedScopes, retain } from "../../webview-ui/src/components/marketplace/utils" +import { + filterItems, + hasRelevantItems, + installedScopes, + retain, +} from "../../webview-ui/src/components/marketplace/utils" import type { MarketplaceItem } from "../../webview-ui/src/types/marketplace" const project = "/repo" @@ -127,6 +132,16 @@ describe("Marketplace installation metadata", () => { ]) expect(filterItems(items, metadata, "", "installed", [], []).map((item) => item.id)).toEqual(["warehouse"]) expect(filterItems(items, metadata, "", "all", [], ["mcp"]).map((item) => item.id)).toEqual(["warehouse"]) + expect( + filterItems(items, metadata, "", "all", [], [], {}, true, { + "agent:reviewer": { filename: ["*.review.ts"] }, + "mcp:warehouse": { vscodeExtension: ["data.warehouse"] }, + }).map((item) => item.id), + ).toEqual(["reviewer", "warehouse"]) + const relevance = { "agent:reviewer": { filename: ["*.review.ts"] } } + expect(filterItems(items, metadata, "warehouse", "all", [], [], {}, true, relevance)).toEqual([]) + expect(hasRelevantItems(items, relevance)).toBe(true) + expect(hasRelevantItems(items, {})).toBe(false) }) }) diff --git a/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts new file mode 100644 index 00000000000..c0ef1168683 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/marketplace-relevance.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, mock } from "bun:test" +import * as vscode from "vscode" +import { detectMarketplaceRelevance } from "../../src/services/marketplace/relevance" +import type { MarketplaceItem } from "../../src/services/marketplace/types" + +const items: MarketplaceItem[] = [ + { + type: "agent", + id: "angular", + name: "Angular", + description: "Angular specialist", + category: "development", + content: { mode: "all", description: "Angular specialist", prompt: "Help with Angular" }, + suggest_for: { filename: ["*.component.ts"] }, + }, + { + type: "mcp", + id: "jupyter", + name: "Jupyter", + description: "Jupyter notebooks", + category: "data", + url: "https://example.com", + content: "{}", + suggest_for: { + filename: ["*.component.ts", "*.ipynb"], + vscode_extension: ["ms-toolsai.jupyter"], + }, + }, + { + type: "skill", + id: "unmatched", + name: "Unmatched", + displayName: "Unmatched", + description: "No matching context", + category: "development", + displayCategory: "Development", + githubUrl: "https://example.com", + content: "https://example.com/skill.tar.gz", + suggest_for: { filename: ["*.rs"] }, + }, +] + +describe("Marketplace relevance", () => { + it("matches workspace files and installed extensions with deduplicated bounded searches", async () => { + const root = vscode.Uri.file("/repo") + const find = mock(async (_root: vscode.Uri, pattern: string) => pattern === "*.component.ts") + + const relevance = await detectMarketplaceRelevance(items, [root], { + extensions: ["MS-ToolsAI.Jupyter"], + find, + }) + + expect(relevance).toEqual({ + "agent:angular": { filename: ["*.component.ts"] }, + "mcp:jupyter": { + filename: ["*.component.ts"], + vscodeExtension: ["ms-toolsai.jupyter"], + }, + }) + expect(find).toHaveBeenCalledTimes(3) + expect(find.mock.calls).toContainEqual([root, "*.component.ts"]) + expect(find.mock.calls).toContainEqual([root, "*.ipynb"]) + expect(find.mock.calls).toContainEqual([root, "*.rs"]) + }) + + it("searches every workspace root and preserves remote URIs", async () => { + const local = vscode.Uri.file("/repo") + const remote = vscode.Uri.parse("vscode-remote://ssh-remote+host/workspace") + const find = mock(async (root: vscode.Uri, pattern: string) => root === remote && pattern === "*.ipynb") + + const relevance = await detectMarketplaceRelevance(items, [local, remote], { extensions: [], find }) + + expect(relevance).toEqual({ "mcp:jupyter": { filename: ["*.ipynb"] } }) + expect(find.mock.calls).toContainEqual([remote, "*.ipynb"]) + }) + + it("ignores malformed suggestion metadata", async () => { + const malformed = { + ...items[0], + suggest_for: { filename: "*.component.ts", vscode_extension: [42] }, + } as unknown as MarketplaceItem + const find = mock(async () => true) + + const relevance = await detectMarketplaceRelevance([malformed], [vscode.Uri.file("/repo")], { + extensions: ["test.extension"], + find, + }) + + expect(relevance).toEqual({}) + expect(find).not.toHaveBeenCalled() + }) + + it("still matches installed extensions without a workspace", async () => { + const find = mock(async () => true) + + const relevance = await detectMarketplaceRelevance(items, [], { + extensions: ["ms-toolsai.jupyter"], + find, + }) + + expect(relevance).toEqual({ "mcp:jupyter": { vscodeExtension: ["ms-toolsai.jupyter"] } }) + expect(find).not.toHaveBeenCalled() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts new file mode 100644 index 00000000000..8fa3e413fdc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/notebook-bridge.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it, mock } from "bun:test" +import type { NotebookRequest } from "@kilocode/sdk/v2/client" +import * as vscode from "vscode" +import { KiloConnectionService } from "../../src/services/cli-backend/connection-service" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { NotebookBridge, type NotebookBridgeContext } from "../../src/services/notebook/bridge" +import { NotebookError } from "../../src/services/notebook/path" + +const read: NotebookRequest = { + id: "notebook-1", + sessionID: "session-1", + operation: "read", + path: "book.ipynb", + includeOutputs: true, +} + +function deferred() { + const state: { resolve?: (value: T) => void; reject?: (error: Error) => void } = {} + const promise = new Promise((resolve, reject) => { + state.resolve = resolve + state.reject = reject + }) + return { promise, resolve: state.resolve!, reject: state.reject! } +} + +async function flush(): Promise { + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) +} + +function harness(context: NotebookBridgeContext, dirs = ["/repo"]) { + const replies: unknown[] = [] + const rejections: unknown[] = [] + const lists = new Map() + const state = { failReply: false } + const handlers: { + event?: (event: SSEPayload, directory?: string) => void + state?: (state: "connecting" | "connected" | "disconnected" | "error") => void + } = {} + const client = { + kilocode: { + notebook: { + list: async ({ directory }: { directory?: string }) => ({ data: lists.get(directory ?? "") ?? [] }), + reply: async (input: unknown) => { + replies.push(input) + return state.failReply ? { error: "offline" } : { data: true } + }, + reject: async (input: unknown) => { + rejections.push(input) + return { data: true } + }, + }, + }, + } + const connection = { + onEvent: (listener: typeof handlers.event) => { + handlers.event = listener + return () => { + handlers.event = undefined + } + }, + onStateChange: (listener: typeof handlers.state) => { + handlers.state = listener + return () => { + handlers.state = undefined + } + }, + getClient: () => client, + getKnownDirectories: () => dirs, + } + const create = mock(async () => context) + const bridge = new NotebookBridge(connection as never, { create, canonical: async (directory) => directory }) + const request = (value: NotebookRequest = read, directory = "/repo") => + handlers.event?.( + { id: `event-${value.id}`, type: "kilocode.notebook.requested", properties: value } as SSEPayload, + directory, + ) + const cancel = (id = read.id, directory = "/repo") => + handlers.event?.( + { + id: `cancel-${id}`, + type: "kilocode.notebook.cancelled", + properties: { requestID: id, sessionID: "session-1", reason: "cancelled" }, + } as SSEPayload, + directory, + ) + return { bridge, cancel, client, connection, create, handlers, lists, rejections, replies, request, state } +} + +function context(overrides: Partial = {}) { + const dispose = mock(() => undefined) + const adapter = { + read: mock(async () => ({ + operation: "read" as const, + path: "book.ipynb", + requestPath: "book.ipynb", + revision: "content:2", + cells: [], + })), + edit: mock(async () => ({ + operation: "edit" as const, + path: "book.ipynb", + requestPath: "book.ipynb", + revision: "content:2", + index: 0, + action: "replace" as const, + })), + execute: mock(async () => ({ + operation: "execute" as const, + path: "book.ipynb", + requestPath: "book.ipynb", + revision: "content:2", + index: 0, + status: "success" as const, + outputs: [], + })), + ...overrides, + } + return { value: { adapter, dispose }, adapter, dispose } +} + +describe("NotebookBridge", () => { + it("deduplicates requests, retains their directory, and posts replies", async () => { + const ctx = context() + const test = harness(ctx.value) + + test.request() + test.request() + await flush() + + expect(test.create).toHaveBeenCalledTimes(1) + expect(test.create).toHaveBeenCalledWith("/repo") + expect(ctx.adapter.read).toHaveBeenCalledTimes(1) + expect(ctx.adapter.read).toHaveBeenCalledWith({ path: "book.ipynb", directory: "/repo", includeOutputs: true }) + expect(test.replies).toEqual([ + { + requestID: "notebook-1", + directory: "/repo", + result: { operation: "read", path: "book.ipynb", requestPath: "book.ipynb", revision: "content:2", cells: [] }, + }, + ]) + const bridge = test.bridge as unknown as { outcomes: Map; settled: Set } + expect(bridge.outcomes.size).toBe(0) + expect(bridge.settled.has("notebook-1")).toBe(true) + + test.bridge.dispose() + await flush() + expect(ctx.dispose).toHaveBeenCalledTimes(1) + expect(test.handlers.event).toBeUndefined() + expect(test.handlers.state).toBeUndefined() + }) + + it("rejects live requests outside known VS Code directories", async () => { + const ctx = context() + const test = harness(ctx.value, ["/repo"]) + + test.request(read, "/outside") + await flush() + + expect(test.create).not.toHaveBeenCalled() + expect(ctx.adapter.read).not.toHaveBeenCalled() + expect(test.rejections).toEqual([ + { + requestID: "notebook-1", + directory: "/outside", + error: { + code: "invalid_path", + message: "Notebook request directory is not an active VS Code workspace", + }, + }, + ]) + test.bridge.dispose() + }) + + it("retries context initialization after a transient failure", async () => { + const ctx = context() + const test = harness(ctx.value) + test.create.mockImplementationOnce(async () => { + throw new Error("unreadable ignore file") + }) + + test.request() + await flush() + expect(test.rejections).toHaveLength(1) + + test.request({ ...read, id: "notebook-2" }) + await flush() + expect(test.create).toHaveBeenCalledTimes(2) + expect(ctx.adapter.read).toHaveBeenCalledTimes(1) + expect(test.replies).toHaveLength(1) + test.bridge.dispose() + }) + + it("retries a failed reply without repeating the adapter operation", async () => { + const ctx = context() + const test = harness(ctx.value) + test.state.failReply = true + + test.request() + await flush() + test.state.failReply = false + test.request() + await flush() + + expect(ctx.adapter.read).toHaveBeenCalledTimes(1) + expect(test.replies).toHaveLength(2) + test.bridge.dispose() + }) + + it("aborts cancelled execution without posting a late completion", async () => { + const pending = deferred() + const signals: AbortSignal[] = [] + const execute = mock((request: { signal?: AbortSignal }) => { + if (request.signal) { + signals.push(request.signal) + request.signal.addEventListener("abort", () => pending.reject(new NotebookError("cancelled", "cancelled"))) + } + return pending.promise + }) + const ctx = context({ execute: execute as never }) + const test = harness(ctx.value) + test.request({ + id: "execute-1", + sessionID: "session-1", + operation: "execute", + path: "book.ipynb", + expectedRevision: "content:1", + index: 0, + }) + await flush() + + test.cancel("execute-1") + await flush() + + expect(signals[0]?.aborted).toBe(true) + expect(test.replies).toEqual([]) + expect(test.rejections).toEqual([]) + test.bridge.dispose() + }) + + it("bounds unexpected adapter failures for the protocol", async () => { + const ctx = context({ + read: mock(async () => { + throw new Error("x".repeat(20_000)) + }), + }) + const test = harness(ctx.value) + test.request() + await flush() + expect((test.rejections[0] as { error: { code: string; message: string } }).error).toMatchObject({ + code: "execution_failed", + message: "x".repeat(10_000), + }) + test.bridge.dispose() + + const empty = context({ + read: mock(async () => { + throw new Error("") + }), + }) + const fallback = harness(empty.value) + fallback.request() + await flush() + expect((fallback.rejections[0] as { error: { message: string } }).error.message).toBe( + "Notebook operation failed without an error message", + ) + fallback.bridge.dispose() + }) + + it("recovers pending requests for known directories and maps adapter failures", async () => { + const ctx = context({ + read: mock(async () => { + throw new NotebookError("stale_revision", "Notebook changed", { + path: "book.ipynb", + index: 0, + currentRevision: "content:current", + }) + }), + }) + const test = harness(ctx.value, ["/root", "/worktree"]) + test.lists.set("/worktree", [read]) + + test.handlers.state?.("connected") + await flush() + + expect(test.rejections).toEqual([ + { + requestID: "notebook-1", + directory: "/worktree", + error: { + code: "stale_revision", + message: "Notebook changed", + path: "book.ipynb", + index: 0, + currentRevision: "content:current", + }, + }, + ]) + test.bridge.dispose() + }) +}) + +describe("KiloConnectionService notebook directories", () => { + it("tracks the workspace root, current request directory, and provider directories", async () => { + const descriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders") + Object.defineProperty(vscode.workspace, "workspaceFolders", { + configurable: true, + value: [{ uri: { fsPath: "/root" } }], + }) + const service = new KiloConnectionService({} as vscode.ExtensionContext) + const internals = service as unknown as { client: object; state: string } + internals.client = {} + internals.state = "connected" + const unregister = service.registerDirectoryProvider(() => ["/worktree", "/root"]) + + await service.getClientAsync("/current") + + expect(service.getKnownDirectories()).toEqual(["/root", "/current", "/worktree"]) + unregister() + expect(service.getKnownDirectories()).toEqual(["/root", "/current"]) + service.dispose() + if (descriptor) Object.defineProperty(vscode.workspace, "workspaceFolders", descriptor) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/notebook-core.test.ts b/packages/kilo-vscode/tests/unit/notebook-core.test.ts new file mode 100644 index 00000000000..e225d2ca266 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/notebook-core.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it, mock } from "bun:test" +import * as vscode from "vscode" +import { NotebookAdapter } from "../../src/services/notebook/adapter" +import { normalizeOutputs, normalizeSource } from "../../src/services/notebook/output" +import { NotebookError, resolveNotebookPath } from "../../src/services/notebook/path" +import type { NotebookAdapterDeps, NotebookCellInput } from "../../src/services/notebook/types" + +interface CellState { + source: string + kind: vscode.NotebookCellKind + language: string + outputs: vscode.NotebookCellOutput[] + execution?: vscode.NotebookCellExecutionSummary +} + +function uri(path: string): vscode.Uri { + return { scheme: "file", fsPath: path, path, toString: () => `file://${path}` } as vscode.Uri +} + +function cell(source = "print('hi')", kind = vscode.NotebookCellKind.Code, language?: string) { + const state: CellState = { + source, + kind, + language: language ?? (kind === vscode.NotebookCellKind.Code ? "python" : "markdown"), + outputs: [], + } + const getText = mock((range?: vscode.Range) => { + const offset = (range?.end as (vscode.Position & { offset?: number }) | undefined)?.offset + return state.source.slice(0, offset ?? state.source.length) + }) + const value = { + get kind() { + return state.kind + }, + document: { + getText, + positionAt: (offset: number) => ({ line: 0, character: offset, offset }) as vscode.Position, + get languageId() { + return state.language + }, + }, + get outputs() { + return state.outputs + }, + get executionSummary() { + return state.execution + }, + } as unknown as vscode.NotebookCell + return { value, state, getText } +} + +function notebook(cells: vscode.NotebookCell[], file = "/repo/book.ipynb"): vscode.NotebookDocument { + return { + uri: uri(file), + version: 1, + isClosed: false, + get cellCount() { + return cells.length + }, + getCells: () => cells, + cellAt: (index: number) => cells[index]!, + } as unknown as vscode.NotebookDocument +} + +function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[]) { + const changes = new Set<(event: vscode.NotebookDocumentChangeEvent) => void>() + const closes = new Set<(document: vscode.NotebookDocument) => void>() + const calls = { open: 0, apply: 0, command: 0, commandArgs: [] as unknown[], edit: undefined as unknown } + const deps: NotebookAdapterDeps = { + documents: () => [document], + open: async () => { + calls.open++ + return document + }, + apply: async (edit) => { + calls.apply++ + const item = ( + edit as unknown as { edits: Array<{ type: string; index: number; cells?: vscode.NotebookCellData[] }> } + ).edits[0]! + if (item.type === "delete") cells.splice(item.index, 1) + if (item.type !== "delete") { + const input = (item.cells?.[0] as unknown as { input: NotebookCellInput }).input + const next = cell( + input.source, + input.kind === "code" ? vscode.NotebookCellKind.Code : vscode.NotebookCellKind.Markup, + input.language ?? (input.kind === "code" ? "plaintext" : "markdown"), + ).value + if (item.type === "insert") cells.splice(item.index, 0, next) + if (item.type === "replace") cells.splice(item.index, 1, next) + } + Object.assign(document, { version: document.version + 1 }) + return true + }, + execute: async (...args) => { + calls.command++ + calls.commandArgs = args + }, + change: (listener) => { + changes.add(listener) + return { dispose: () => changes.delete(listener) } + }, + close: (listener) => { + closes.add(listener) + return { dispose: () => closes.delete(listener) } + }, + uri, + edit: (_uri, edits) => { + calls.edit = edits + return { edits } as unknown as vscode.WorkspaceEdit + }, + insert: (index, items) => ({ type: "insert", index, cells: items }) as unknown as vscode.NotebookEdit, + replace: (index, items) => ({ type: "replace", index, cells: items }) as unknown as vscode.NotebookEdit, + delete: (index) => ({ type: "delete", index }) as unknown as vscode.NotebookEdit, + cell: (input: NotebookCellInput) => ({ input }) as unknown as vscode.NotebookCellData, + } + return { deps, changes, closes, calls } +} + +const paths = { realpath: async (value: string) => value } +const access = { validateAccess: mock(() => true) } + +function adapter(items: ReturnType[], file = "/repo/book.ipynb") { + const cells = items.map((item) => item.value) + const document = notebook(cells, file) + const ctx = harness(document, cells) + return { + adapter: new NotebookAdapter(access, { deps: ctx.deps, paths, timeout: 50 }), + document, + cells, + ...ctx, + } +} + +async function revision(core: NotebookAdapter, path = "book.ipynb") { + return (await core.read({ directory: "/repo", path, includeOutputs: false })).revision +} + +function event(document: vscode.NotebookDocument, cell: vscode.NotebookCell, change: object) { + return { + notebook: document, + contentChanges: [], + cellChanges: [{ cell, ...change }], + } as unknown as vscode.NotebookDocumentChangeEvent +} + +describe("notebook path security", () => { + it("accepts relative and contained absolute paths and normalizes results", async () => { + await expect(resolveNotebookPath("/repo", "nested/book.ipynb", access, paths)).resolves.toEqual({ + target: "/repo/nested/book.ipynb", + relative: "nested/book.ipynb", + }) + await expect(resolveNotebookPath("/repo", "/repo/nested/book.ipynb", access, paths)).resolves.toEqual({ + target: "/repo/nested/book.ipynb", + relative: "nested/book.ipynb", + }) + const ctx = adapter([cell()], "/repo/nested/book.ipynb") + expect( + (await ctx.adapter.read({ directory: "/repo", path: "/repo/nested/book.ipynb", includeOutputs: false })).path, + ).toBe("nested/book.ipynb") + }) + + it("rejects outside absolute paths and relative or absolute symlink escapes", async () => { + const guard = { validateAccess: mock(() => true) } + await expect(resolveNotebookPath("/repo", "/outside/secret.ipynb", guard, paths)).rejects.toMatchObject({ + code: "invalid_path", + message: expect.stringContaining("/outside/secret.ipynb"), + }) + for (const input of ["linked.ipynb", "/repo/linked.ipynb"]) { + await expect( + resolveNotebookPath("/repo", input, guard, { + realpath: async (value) => (value.endsWith("linked.ipynb") ? "/outside/secret.ipynb" : value), + }), + ).rejects.toMatchObject({ code: "invalid_path", message: expect.stringContaining(input) }) + } + expect(guard.validateAccess).not.toHaveBeenCalled() + }) + + it("enforces ignore access on the canonical target", async () => { + const guard = { validateAccess: mock(() => false) } + await expect(resolveNotebookPath("/repo", "book.ipynb", guard, paths)).rejects.toMatchObject({ + code: "invalid_path", + }) + expect(guard.validateAccess).toHaveBeenCalledWith("/repo/book.ipynb") + }) +}) + +describe("notebook normalization", () => { + it("bounds UTF-8 source and text outputs and omits rich bodies", () => { + expect(normalizeSource("abcdef", 3)).toEqual({ text: "abc", bytes: 6, truncated: true }) + expect(normalizeSource("a€b", 3)).toEqual({ text: "a", bytes: 5, truncated: true }) + const normalized = normalizeOutputs( + [ + { + items: [ + { mime: "text/plain", data: new TextEncoder().encode("abcdef") }, + { mime: "image/png", data: new Uint8Array(40) }, + ], + } as vscode.NotebookCellOutput, + ], + 3, + ) + expect(normalized).toEqual({ + outputs: [ + { mime: "text/plain", text: "abc", truncated: true }, + { mime: "image/png", omitted: true }, + ], + truncated: true, + bytes: 3, + }) + }) + + it("extracts and bounds standard notebook errors", () => { + const data = new TextEncoder().encode( + JSON.stringify({ name: "N".repeat(600), message: "M".repeat(11_000), stack: "trace" }), + ) + const normalized = normalizeOutputs([ + { items: [{ mime: "application/vnd.code.notebook.error", data }] } as vscode.NotebookCellOutput, + ]) + expect(normalized.outputs[0]).toMatchObject({ stack: "trace" }) + expect(normalized.outputs[0]?.name).toHaveLength(500) + expect(normalized.outputs[0]?.message).toHaveLength(10_000) + }) +}) + +describe("notebook content revisions", () => { + it("ignores outputs and execution summaries", async () => { + const item = cell("one") + const ctx = adapter([item]) + const before = await revision(ctx.adapter) + item.state.outputs = [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("result") }] } as never] + item.state.execution = { success: true, executionOrder: 1 } + expect(await revision(ctx.adapter)).toBe(before) + }) + + it("changes for source, language, kind, insertion, deletion, replacement, and reordering", async () => { + const first = cell("one") + const second = cell("two") + const ctx = adapter([first, second]) + const revisions = [await revision(ctx.adapter)] + first.state.source = "changed" + revisions.push(await revision(ctx.adapter)) + first.state.language = "javascript" + revisions.push(await revision(ctx.adapter)) + first.state.kind = vscode.NotebookCellKind.Markup + revisions.push(await revision(ctx.adapter)) + const added = cell("added") + ctx.cells.splice(1, 0, added.value) + revisions.push(await revision(ctx.adapter)) + ctx.cells.splice(1, 1) + revisions.push(await revision(ctx.adapter)) + ctx.cells.splice(0, 1, cell("replacement").value) + revisions.push(await revision(ctx.adapter)) + ctx.cells.reverse() + revisions.push(await revision(ctx.adapter)) + for (const [index, value] of revisions.entries()) { + if (index > 0) expect(value).not.toBe(revisions[index - 1]) + } + }) +}) + +describe("notebook adapter", () => { + it("prefers an open document and opens missing notebooks in the background", async () => { + const ctx = adapter([cell("unsaved"), cell("# title", vscode.NotebookCellKind.Markup)]) + const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false }) + expect(ctx.calls.open).toBe(0) + expect(result).toMatchObject({ + path: "book.ipynb", + cells: [ + { index: 0, kind: "code", language: "python", source: "unsaved" }, + { index: 1, kind: "markdown", language: "markdown", source: "# title" }, + ], + }) + ctx.deps.documents = () => [] + await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false }) + expect(ctx.calls.open).toBe(1) + }) + + it("bounds source reads and skips extraction after the aggregate budget", async () => { + const items = [ + cell("a".repeat(64 * 1024)), + cell("b".repeat(64 * 1024)), + cell("c".repeat(64 * 1024)), + cell("d".repeat(64 * 1024)), + cell("ignored"), + ] + const ctx = adapter(items) + const result = await ctx.adapter.read({ directory: "/repo", path: "book.ipynb", includeOutputs: false }) + + expect(items[0]!.getText).toHaveBeenCalledTimes(2) + expect(items[0]!.getText.mock.calls[1]?.[0]).toBeInstanceOf(vscode.Range) + expect(items[4]!.getText).toHaveBeenCalledTimes(1) + expect(result.cells[4]?.source).toBe("") + expect(result.truncated).toBe(true) + }) + + it("chains sequential edits with returned revisions and affected cells", async () => { + const ctx = adapter([cell("one")]) + const initial = await revision(ctx.adapter) + const first = await ctx.adapter.edit({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision: initial, + edit: { action: "replace", kind: "code", language: "python", source: "two" }, + }) + expect(first.cell).toMatchObject({ index: 0, source: "two" }) + const second = await ctx.adapter.edit({ + directory: "/repo", + path: "book.ipynb", + index: 1, + expectedRevision: first.revision, + edit: { action: "insert", kind: "markdown", language: "markdown", source: "three" }, + }) + expect(second.revision).not.toBe(first.revision) + expect(second.cell).toMatchObject({ index: 1, kind: "markdown", source: "three" }) + }) + + it("returns a structured stale-revision error for genuine content conflicts", async () => { + const item = cell("one") + const ctx = adapter([item]) + const expectedRevision = await revision(ctx.adapter) + item.state.source = "user edit" + await expect( + ctx.adapter.edit({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision, + edit: { action: "delete" }, + }), + ).rejects.toMatchObject({ + code: "stale_revision", + path: "book.ipynb", + index: 0, + currentRevision: expect.stringContaining("content:"), + message: expect.stringContaining("Re-read"), + }) + expect(ctx.calls.apply).toBe(0) + }) + + it("serializes concurrent edits and rejects a stale queued mutation", async () => { + const ctx = adapter([cell("one")]) + const expectedRevision = await revision(ctx.adapter) + const first = ctx.adapter.edit({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision, + edit: { action: "replace", kind: "code", source: "first" }, + }) + const other = new NotebookAdapter(access, { deps: ctx.deps, paths, timeout: 50 }) + const second = other.edit({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision, + edit: { action: "replace", kind: "code", source: "second" }, + }) + const results = await Promise.allSettled([first, second]) + expect(results[0]).toMatchObject({ status: "fulfilled", value: { action: "replace" } }) + expect(results[1]).toMatchObject({ status: "rejected", reason: { code: "stale_revision" } }) + expect(ctx.cells[0]?.document.getText()).toBe("first") + }) + + it("executes the same target when unrelated cell content or outputs change", async () => { + const target = cell("target") + const other = cell("other") + const ctx = adapter([target, other]) + const expectedRevision = await revision(ctx.adapter) + other.state.source = "changed elsewhere" + other.state.outputs = [{ items: [{ mime: "text/plain", data: new TextEncoder().encode("noise") }] } as never] + ctx.deps.execute = async (...args) => { + ctx.calls.command++ + ctx.calls.commandArgs = args + target.state.outputs = [ + { items: [{ mime: "text/plain", data: new TextEncoder().encode("done") }] } as vscode.NotebookCellOutput, + ] + target.state.execution = { success: true, executionOrder: 2, timing: { startTime: 10, endTime: 20 } } + for (const listener of ctx.changes) + listener(event(ctx.document, target.value, { executionSummary: target.state.execution })) + } + const result = await ctx.adapter.execute({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision, + }) + expect(result).toMatchObject({ operation: "execute", index: 0, status: "success", outputs: [{ text: "done" }] }) + expect(result.revision).not.toBe(expectedRevision) + expect(ctx.changes.size).toBe(0) + expect(ctx.closes.size).toBe(0) + }) + + it("does not authorize another notebook with a cached revision", async () => { + const first = adapter([cell("target"), cell("first")], "/repo/first.ipynb") + const expectedRevision = await revision(first.adapter, "first.ipynb") + const second = adapter([cell("target"), cell("second")], "/repo/second.ipynb") + await expect( + second.adapter.execute({ + directory: "/repo", + path: "second.ipynb", + index: 0, + expectedRevision, + }), + ).rejects.toMatchObject({ code: "stale_revision", path: "second.ipynb" }) + expect(second.calls.command).toBe(0) + }) + + it("reports an unobserved startup as execution_failed rather than no_kernel", async () => { + const ctx = adapter([cell()]) + const expectedRevision = await revision(ctx.adapter) + await expect( + ctx.adapter.execute({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision, + timeout: 5, + }), + ).rejects.toMatchObject({ + code: "execution_failed", + path: "book.ipynb", + index: 0, + message: expect.stringContaining("command was dispatched"), + }) + expect(ctx.calls.commandArgs).toEqual([ + "notebook.cell.cancelExecution", + { ranges: [{ start: 0, end: 1 }], document: ctx.document.uri }, + ]) + }) + + it("cancels execution and disposes listeners", async () => { + const ctx = adapter([cell()]) + const expectedRevision = await revision(ctx.adapter) + const controller = new AbortController() + const pending = ctx.adapter.execute({ + directory: "/repo", + path: "book.ipynb", + index: 0, + expectedRevision, + signal: controller.signal, + }) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: "cancelled", path: "book.ipynb", index: 0 }) + expect(ctx.changes.size).toBe(0) + expect(ctx.closes.size).toBe(0) + }) + + it("rejects execution if the targeted cell changes", async () => { + const target = cell("before") + const ctx = adapter([target]) + const expectedRevision = await revision(ctx.adapter) + ctx.deps.execute = async (...args) => { + ctx.calls.command++ + ctx.calls.commandArgs = args + if (args[0] !== "notebook.cell.execute") return + target.state.source = "after" + for (const listener of ctx.changes) + listener(event(ctx.document, target.value, { document: target.value.document })) + } + await expect( + ctx.adapter.execute({ directory: "/repo", path: "book.ipynb", index: 0, expectedRevision }), + ).rejects.toMatchObject({ code: "stale_revision" }) + expect(ctx.calls.commandArgs).toEqual([ + "notebook.cell.cancelExecution", + { ranges: [{ start: 0, end: 1 }], document: ctx.document.uri }, + ]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx index c3fe6136a65..4735d7c6b16 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -3,14 +3,16 @@ import { TextField } from "@kilocode/kilo-ui/text-field" import { Select } from "@kilocode/kilo-ui/select" import { Tag } from "@kilocode/kilo-ui/tag" import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Checkbox } from "@kilocode/kilo-ui/checkbox" import type { MarketplaceItem, McpMarketplaceItem, SkillMarketplaceItem, MarketplaceInstalledMetadata, + MarketplaceRelevanceMetadata, } from "../../types/marketplace" import { useLanguage } from "../../context/language" -import { filterItems, retain } from "./utils" +import { filterItems, hasRelevantItems, retain } from "./utils" import { ItemCard } from "./ItemCard" import { MarketplaceContribute } from "./MarketplaceContribute" @@ -22,9 +24,12 @@ interface StatusOption { interface Props { items: MarketplaceItem[] metadata: MarketplaceInstalledMetadata + relevance: MarketplaceRelevanceMetadata fetching: boolean searchPlaceholder: string emptyMessage: string + relevantEmptyMessage: string + initialRelevant?: boolean onInstall: (item: MarketplaceItem) => void onRemove: (item: MarketplaceItem, scope: "project" | "global") => void } @@ -35,6 +40,7 @@ export const MarketplaceListView = (props: Props) => { const [status, setStatus] = createSignal({ value: "all", label: t("marketplace.filter.all") }) const [types, setTypes] = createSignal([]) const [categories, setCategories] = createSignal([]) + const [relevant, setRelevant] = createSignal(props.initialRelevant ?? false) const options = (): StatusOption[] => [ { value: "all", label: t("marketplace.filter.all") }, @@ -84,11 +90,21 @@ export const MarketplaceListView = (props: Props) => { } const filtered = createMemo(() => - filterItems(props.items, props.metadata, search(), status().value, categories(), types(), { - agent: typeLabel("agent"), - mcp: typeLabel("mcp"), - skill: typeLabel("skill"), - }), + filterItems( + props.items, + props.metadata, + search(), + status().value, + categories(), + types(), + { + agent: typeLabel("agent"), + mcp: typeLabel("mcp"), + skill: typeLabel("skill"), + }, + relevant(), + props.relevance, + ), ) return ( @@ -105,6 +121,11 @@ export const MarketplaceListView = (props: Props) => { onSelect={(v: StatusOption | undefined) => v && setStatus(v)} /> +
+ + {t("marketplace.filter.relevant")} + +
1}>
@@ -149,7 +170,11 @@ export const MarketplaceListView = (props: Props) => { when={filtered().length > 0} fallback={
- {props.emptyMessage} + + {relevant() && !hasRelevantItems(props.items, props.relevance) + ? props.relevantEmptyMessage + : props.emptyMessage} +
} diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx index 8892ec1aa11..fa6e8b9b031 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -5,7 +5,11 @@ import { useVSCode } from "../../context/vscode" import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useDialog } from "@kilocode/kilo-ui/context/dialog" -import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../../types/marketplace" +import type { + MarketplaceItem, + MarketplaceInstalledMetadata, + MarketplaceRelevanceMetadata, +} from "../../types/marketplace" import { TelemetryEventName } from "../../../../src/services/telemetry/types" import { MarketplaceListView } from "./MarketplaceListView" import { InstallModal } from "./InstallModal" @@ -13,6 +17,7 @@ import { RemoveDialog } from "./RemoveDialog" import "./marketplace.css" const EMPTY_METADATA: MarketplaceInstalledMetadata = { project: {}, global: {} } +const EMPTY_RELEVANCE: MarketplaceRelevanceMetadata = {} export const MarketplaceView = () => { const vscode = useVSCode() @@ -22,6 +27,7 @@ export const MarketplaceView = () => { const [items, setItems] = createSignal([]) const [metadata, setMetadata] = createSignal(EMPTY_METADATA) + const [relevance, setRelevance] = createSignal(EMPTY_RELEVANCE) const [fetching, setFetching] = createSignal(true) const [errors, setErrors] = createSignal([]) const [pending, setPending] = createSignal<{ item: MarketplaceItem; scope: "project" | "global" } | null>(null) @@ -38,6 +44,7 @@ export const MarketplaceView = () => { if (msg.type === "marketplaceData") { setItems(msg.marketplaceItems ?? []) setMetadata(msg.marketplaceInstalledMetadata ?? EMPTY_METADATA) + setRelevance(msg.marketplaceRelevance ?? EMPTY_RELEVANCE) setErrors(msg.errors ?? []) setFetching(false) setShowMigrationBanner(msg.showAgentMigrationBanner ?? false) @@ -157,9 +164,11 @@ export const MarketplaceView = () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css index e5f8cdcb8b8..a22b1f51168 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/marketplace.css @@ -27,6 +27,11 @@ flex: 1; } +.marketplace-relevance-filter { + display: flex; + align-items: center; +} + /* Type and category filters */ .marketplace-types, diff --git a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts index 0f616cc0c42..e8f63e82225 100644 --- a/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/marketplace/utils.ts @@ -1,4 +1,12 @@ -import type { MarketplaceInstalledMetadata, MarketplaceItem } from "../../types/marketplace" +import type { + MarketplaceInstalledMetadata, + MarketplaceItem, + MarketplaceRelevanceMetadata, +} from "../../types/marketplace" + +export function hasRelevantItems(items: MarketplaceItem[], relevance: MarketplaceRelevanceMetadata): boolean { + return items.some((item) => !!relevance[`${item.type}:${item.id}`]) +} export function retain(selected: T[], available: T[]): T[] { const values = new Set(available) @@ -48,6 +56,8 @@ export function filterItems( categories: string[], types: MarketplaceItem["type"][], labels: Partial> = {}, + relevant = false, + relevance: MarketplaceRelevanceMetadata = {}, ): MarketplaceItem[] { const query = search.trim().toLowerCase() return items @@ -56,6 +66,7 @@ export function filterItems( if (status === "notInstalled" && isInstalled(item.id, item.type, metadata)) return false if (types.length > 0 && !types.includes(item.type)) return false if (categories.length > 0 && !categories.includes(item.category)) return false + if (relevant && !relevance[`${item.type}:${item.id}`]) return false if (!query) return true return matches(item, query, labels) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index 5244ccddd7b..a641b50eff6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -154,6 +154,19 @@ const ExperimentalTab: Component = () => { + + updateExperimental("native_notebook_tools", checked)} + hideLabel + > + {language.t("settings.experimental.nativeNotebookTools.title")} + + + +
+ + ), +} + +export const RelevantItems: Story = { + name: "Mixed list — relevant to workspace", + render: () => ( + +
+ @@ -327,9 +357,11 @@ export const EmptyList: Story = { diff --git a/packages/kilo-vscode/webview-ui/src/types/marketplace.ts b/packages/kilo-vscode/webview-ui/src/types/marketplace.ts index 954c4b6c159..a56afcfd226 100644 --- a/packages/kilo-vscode/webview-ui/src/types/marketplace.ts +++ b/packages/kilo-vscode/webview-ui/src/types/marketplace.ts @@ -12,6 +12,11 @@ export interface McpInstallationMethod { prerequisites?: string[] } +export interface MarketplaceSuggestFor { + filename?: string[] + vscode_extension?: string[] +} + export interface MarketplaceItemBase { id: string name: string @@ -20,6 +25,7 @@ export interface MarketplaceItemBase { author?: string authorUrl?: string prerequisites?: string[] + suggest_for?: MarketplaceSuggestFor } export interface McpMarketplaceItem extends MarketplaceItemBase { @@ -62,6 +68,13 @@ export interface MarketplaceInstalledMetadata { global: Record } +export interface MarketplaceRelevance { + filename?: string[] + vscodeExtension?: string[] +} + +export type MarketplaceRelevanceMetadata = Record + export interface MarketplaceFilters { type?: string search?: string diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 65ce65617ef..311bbe32734 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -40,6 +40,7 @@ export interface WatcherConfig { export interface ExperimentalConfig { batch_tool?: boolean codebase_search?: boolean + native_notebook_tools?: boolean speech_to_text_model?: string primary_tools?: string[] continue_loop_on_deny?: boolean diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 0f3c88b3d56..f65fb10ab1d 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -2,7 +2,7 @@ import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sd import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../../../src/diff/sources/types" import type { PartBatch, PartRemove, PartUpdate } from "../../../../src/shared/stream-messages" import type { SessionMode } from "../../context/worktree-mode" -import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../marketplace" +import type { MarketplaceItem, MarketplaceInstalledMetadata, MarketplaceRelevanceMetadata } from "../marketplace" import type { ConnectionState, ServerInfo, SessionStatus } from "./connection" import type { FileAttachment, Part } from "./parts" import type { @@ -919,6 +919,7 @@ export interface MarketplaceDataMessage { type: "marketplaceData" marketplaceItems: MarketplaceItem[] marketplaceInstalledMetadata: MarketplaceInstalledMetadata + marketplaceRelevance: MarketplaceRelevanceMetadata errors?: string[] showAgentMigrationBanner?: boolean } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b9747674338..d760f0d4d90 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -390,6 +390,9 @@ export const Info = Schema.Struct({ batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), // kilocode_change start codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), + native_notebook_tools: Schema.optional(Schema.Boolean).annotate({ + description: "Enable native tools for reading, editing, and executing VS Code notebooks", + }), speech_to_text_model: Schema.optional(Schema.String).annotate({ description: "Speech-to-text transcription model ID to use for voice input", }), diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index a144a4c5ab4..16f9b1bb16f 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -34,6 +34,11 @@ const InputObject = Schema.StructWithRest( doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), agent_manager: Schema.optional(Rule), // kilocode_change + // kilocode_change start + notebook_read: Schema.optional(Rule), + notebook_edit: Schema.optional(Rule), + notebook_execute: Schema.optional(Rule), + // kilocode_change end }), [Schema.Record(Schema.String, Rule)], ) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 3f486c59f2e..2bc48e4de67 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -59,6 +59,7 @@ import { DataMigration } from "@/data-migration" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Notebook } from "@/kilocode/notebook/service" // kilocode_change const CoreLayer = Layer.mergeAll( Npm.defaultLayer, @@ -85,6 +86,7 @@ const CoreLayer = Layer.mergeAll( const SessionLayer = Layer.mergeAll( Question.defaultLayer, + Notebook.defaultLayer, // kilocode_change Permission.defaultLayer, Todo.defaultLayer, Session.defaultLayer, diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 71baa481d5c..15859313516 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -8,6 +8,7 @@ import type { Info as AgentInfo } from "../../agent/agent" import { Schema } from "effect" import path from "path" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" import PROMPT_DEBUG from "../../agent/prompt/debug.txt" import PROMPT_ORCHESTRATOR from "../../agent/prompt/orchestrator.txt" @@ -228,7 +229,13 @@ export interface KiloData { // Prepare kilo-specific data derived from config. Call once per state initialization. export function prepare(cfg: Config.Info): KiloData { const mcpRules = getMcpRules(cfg) - const defaultsPatch = Permission.fromConfig({ bash, recall: "ask" }) + const defaultsPatch = Permission.fromConfig({ + bash, + recall: "ask", + ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.native_notebook_tools === true + ? { notebook_read: "ask" as const, notebook_edit: "ask" as const, notebook_execute: "ask" as const } + : {}), + }) return { mcpRules, defaultsPatch } } @@ -239,6 +246,7 @@ export function cacheKey(cfg: Config.Info) { mcp: cfg.mcp, mode: cfg.mode, permission: cfg.permission, + native_notebook_tools: cfg.experimental?.native_notebook_tools, }) } diff --git a/packages/opencode/src/kilocode/notebook/protocol.ts b/packages/opencode/src/kilocode/notebook/protocol.ts new file mode 100644 index 00000000000..c893a57f89c --- /dev/null +++ b/packages/opencode/src/kilocode/notebook/protocol.ts @@ -0,0 +1,177 @@ +import { BusEvent } from "@/bus/bus-event" +import { SessionID } from "@/session/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" + +export const RequestID = Schema.String.pipe(Schema.brand("NotebookRequestID")).annotate({ + identifier: "NotebookRequestID", +}) +export type RequestID = Schema.Schema.Type + +export const Path = Schema.String.check( + Schema.isMinLength(1), + Schema.isMaxLength(4096), + Schema.makeFilter((value: string) => + value.includes("\0") + ? "Notebook path must be a request-directory-relative path or an absolute path inside the request directory" + : undefined, + ), +).annotate({ + description: + "Notebook path relative to the request directory, or an absolute path that resolves inside the request directory", +}) +const Source = Schema.String.check(Schema.isMaxLength(200_000)) +const Text = Schema.String.check(Schema.isMaxLength(100_000)) +const Revision = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)).annotate({ + description: "Opaque notebook content revision; pass it back unchanged and do not parse or increment it", +}) +const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" }) + +export const Output = Schema.Struct({ + mime: Schema.String.check(Schema.isMaxLength(200)), + text: Schema.optional(Text), + name: Schema.optional(Schema.String.check(Schema.isMaxLength(500))), + message: Schema.optional(Schema.String.check(Schema.isMaxLength(10_000))), + stack: Schema.optional(Schema.String.check(Schema.isMaxLength(50_000))), + omitted: Schema.optional(Schema.Boolean), + truncated: Schema.optional(Schema.Boolean), +}).annotate({ identifier: "NotebookOutput" }) +export type Output = Schema.Schema.Type + +export const Cell = Schema.Struct({ + index: Index, + kind: Schema.Literals(["code", "markdown"]), + language: Schema.String.check(Schema.isMaxLength(200)), + source: Source, + execution: Schema.optional( + Schema.Struct({ + order: Schema.optional(NonNegativeInt), + success: Schema.optional(Schema.Boolean), + started: Schema.optional(NonNegativeInt), + ended: Schema.optional(NonNegativeInt), + }), + ), + outputs: Schema.optional(Schema.Array(Output).check(Schema.isMaxLength(100))), +}).annotate({ identifier: "NotebookCell" }) +export type Cell = Schema.Schema.Type + +const Base = { id: RequestID, sessionID: SessionID, path: Path } + +export const ReadRequest = Schema.Struct({ + ...Base, + operation: Schema.Literal("read"), + includeOutputs: Schema.Boolean, +}).annotate({ identifier: "NotebookReadRequest" }) + +const CellEdit = { + kind: Schema.Literals(["code", "markdown"]), + language: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), + source: Source, +} + +export const EditRequest = Schema.Struct({ + ...Base, + operation: Schema.Literal("edit"), + expectedRevision: Revision, + index: Index, + edit: Schema.Union([ + Schema.Struct({ action: Schema.Literal("insert"), ...CellEdit }), + Schema.Struct({ action: Schema.Literal("replace"), ...CellEdit }), + Schema.Struct({ action: Schema.Literal("delete") }), + ]), +}).annotate({ identifier: "NotebookEditRequest" }) + +export const ExecuteRequest = Schema.Struct({ + ...Base, + operation: Schema.Literal("execute"), + expectedRevision: Revision, + index: Index, +}).annotate({ identifier: "NotebookExecuteRequest" }) + +export const Request = Schema.Union([ReadRequest, EditRequest, ExecuteRequest]).annotate({ + identifier: "NotebookRequest", +}) +export type Request = Schema.Schema.Type + +export const ReadResult = Schema.Struct({ + operation: Schema.Literal("read"), + path: Path, + requestPath: Path, + revision: Revision, + cells: Schema.Array(Cell).check(Schema.isMaxLength(2_000)), + truncated: Schema.optional(Schema.Boolean), +}) + .check( + Schema.makeFilter((value) => + JSON.stringify(value).length <= 2_000_000 ? undefined : "Notebook read result exceeds the aggregate output limit", + ), + ) + .annotate({ identifier: "NotebookReadResult" }) + +export const EditResult = Schema.Struct({ + operation: Schema.Literal("edit"), + path: Path, + requestPath: Path, + revision: Revision, + index: Index, + action: Schema.Literals(["insert", "replace", "delete"]), + cell: Schema.optional(Cell), +}).annotate({ identifier: "NotebookEditResult" }) + +export const ExecuteResult = Schema.Struct({ + operation: Schema.Literal("execute"), + path: Path, + requestPath: Path, + revision: Revision, + index: Index, + status: Schema.Literals(["success", "error"]), + outputs: Schema.Array(Output).check(Schema.isMaxLength(100)), + truncated: Schema.optional(Schema.Boolean), +}) + .check( + Schema.makeFilter((value) => + JSON.stringify(value).length <= 2_000_000 + ? undefined + : "Notebook execution result exceeds the aggregate output limit", + ), + ) + .annotate({ identifier: "NotebookExecuteResult" }) + +export const Result = Schema.Union([ReadResult, EditResult, ExecuteResult]).annotate({ identifier: "NotebookResult" }) +export type Result = Schema.Schema.Type + +export const ErrorCode = Schema.Literals([ + "cancelled", + "closed", + "disconnected", + "execution_failed", + "invalid_cell", + "invalid_path", + "no_kernel", + "not_found", + "stale_revision", + "timeout", + "unsupported", +]) +export type ErrorCode = Schema.Schema.Type + +export const Failure = Schema.Struct({ + code: ErrorCode, + message: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(10_000)), + path: Schema.optional(Path), + index: Schema.optional(Index), + currentRevision: Schema.optional(Revision), +}).annotate({ identifier: "NotebookFailure" }) +export type Failure = Schema.Schema.Type + +export const Event = { + Requested: BusEvent.define("kilocode.notebook.requested", Request), + Cancelled: BusEvent.define( + "kilocode.notebook.cancelled", + Schema.Struct({ + requestID: RequestID, + sessionID: SessionID, + reason: Schema.Literals(["cancelled", "disposed", "timeout"]), + }), + ), +} diff --git a/packages/opencode/src/kilocode/notebook/service.ts b/packages/opencode/src/kilocode/notebook/service.ts new file mode 100644 index 00000000000..f3d2c8f35d8 --- /dev/null +++ b/packages/opencode/src/kilocode/notebook/service.ts @@ -0,0 +1,162 @@ +import { Bus } from "@/bus" +import { InstanceState } from "@/effect/instance-state" +import { Identifier } from "@/id/id" +import { Deferred, Duration, Effect, Layer, Schema, Context } from "effect" +import * as Log from "@opencode-ai/core/util/log" +import { ErrorCode, Event, type Failure, type Request, RequestID, type Result } from "./protocol" + +const log = Log.create({ service: "notebook-host" }) +type WithoutID = T extends unknown ? Omit : never +export type Input = WithoutID + +export class HostError extends Schema.TaggedErrorClass()("NotebookHostError", { + code: ErrorCode, + detail: Schema.String, + path: Schema.optional(Schema.String), + index: Schema.optional(Schema.Number), + currentRevision: Schema.optional(Schema.String), +}) { + override get message() { + return this.detail + } +} + +export class NotFoundError extends Schema.TaggedErrorClass()("Notebook.NotFoundError", { + requestID: RequestID, +}) {} + +export class InvalidReplyError extends Schema.TaggedErrorClass()("Notebook.InvalidReplyError", { + requestID: RequestID, +}) {} + +interface Entry { + info: Request + deferred: Deferred.Deferred +} +interface State { + pending: Map +} + +function matches(request: Request, result: Result) { + if (request.path !== result.requestPath) return false + if (request.operation === "read") return result.operation === "read" + if (request.operation === "execute") return result.operation === "execute" && request.index === result.index + return result.operation === "edit" && request.index === result.index && request.edit.action === result.action +} + +export interface Interface { + readonly request: (input: Input) => Effect.Effect + readonly list: () => Effect.Effect> + readonly reply: (input: { + requestID: RequestID + result: Result + }) => Effect.Effect + readonly reject: (input: { requestID: RequestID; error: Failure }) => Effect.Effect +} + +export class Service extends Context.Service()("@kilocode/Notebook") {} + +export function layer(timeout: Duration.Input = "10 minutes") { + return Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const state = yield* InstanceState.make( + Effect.fn("Notebook.state")(function* () { + const state = { pending: new Map() } + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const entry of state.pending.values()) { + yield* bus.publish(Event.Cancelled, { + requestID: entry.info.id, + sessionID: entry.info.sessionID, + reason: "disposed", + }) + yield* Deferred.fail( + entry.deferred, + new HostError({ code: "disconnected", detail: "The notebook host disconnected" }), + ) + } + state.pending.clear() + }), + ) + return state + }), + ) + + const cancel = Effect.fn("Notebook.cancel")(function* (id: RequestID, reason: "cancelled" | "timeout") { + const pending = (yield* InstanceState.get(state)).pending + const entry = pending.get(id) + if (!entry) return + pending.delete(id) + yield* bus.publish(Event.Cancelled, { requestID: id, sessionID: entry.info.sessionID, reason }) + yield* Deferred.fail( + entry.deferred, + new HostError({ + code: reason, + detail: + reason === "timeout" ? "The notebook host request timed out" : "The notebook host request was cancelled", + }), + ) + }) + + const request: Interface["request"] = Effect.fn("Notebook.request")(function* (input) { + const pending = (yield* InstanceState.get(state)).pending + const id = RequestID.make(Identifier.create("nbr", "ascending")) + const deferred = yield* Deferred.make() + const info = { ...input, id } as Request + pending.set(id, { info, deferred }) + return yield* Effect.gen(function* () { + yield* bus.publish(Event.Requested, info) + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => cancel(id, "timeout").pipe(Effect.andThen(Deferred.await(deferred))), + }), + ) + }).pipe(Effect.ensuring(cancel(id, "cancelled"))) + }) + + const list: Interface["list"] = Effect.fn("Notebook.list")(function* () { + return Array.from((yield* InstanceState.get(state)).pending.values(), (entry) => entry.info) + }) + + const reply: Interface["reply"] = Effect.fn("Notebook.reply")(function* (input) { + const pending = (yield* InstanceState.get(state)).pending + const entry = pending.get(input.requestID) + if (!entry) { + log.warn("reply for unknown request", { requestID: input.requestID }) + return yield* new NotFoundError({ requestID: input.requestID }) + } + if (!matches(entry.info, input.result)) return yield* new InvalidReplyError({ requestID: input.requestID }) + pending.delete(input.requestID) + yield* Deferred.succeed(entry.deferred, input.result) + }) + + const reject: Interface["reject"] = Effect.fn("Notebook.reject")(function* (input) { + const pending = (yield* InstanceState.get(state)).pending + const entry = pending.get(input.requestID) + if (!entry) { + log.warn("rejection for unknown request", { requestID: input.requestID }) + return yield* new NotFoundError({ requestID: input.requestID }) + } + pending.delete(input.requestID) + yield* Deferred.fail( + entry.deferred, + new HostError({ + code: input.error.code, + detail: input.error.message, + path: input.error.path, + index: input.error.index, + currentRevision: input.error.currentRevision, + }), + ) + }) + + return Service.of({ request, list, reply, reject }) + }), + ) +} + +export const defaultLayer = layer().pipe(Layer.provide(Bus.layer)) +export * as Notebook from "./service" diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index 5af99a9c83d..25520335619 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -7,6 +7,12 @@ import { WorkspaceRoutingQuery, } from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" +import { + Failure as NotebookFailure, + Request as NotebookRequest, + RequestID as NotebookRequestID, + Result as NotebookResult, +} from "@/kilocode/notebook/protocol" const root = "/kilocode" @@ -18,10 +24,16 @@ export const RemoveAgentPayload = Schema.Struct({ name: Schema.String, }) +export const NotebookReplyPayload = Schema.Struct({ result: NotebookResult }) +export const NotebookRejectPayload = Schema.Struct({ error: NotebookFailure }) + export const KilocodePaths = { heapSnapshot: `${root}/heap/snapshot`, removeSkill: `${root}/skill/remove`, removeAgent: `${root}/agent/remove`, + notebookList: `${root}/notebook`, + notebookReply: `${root}/notebook/:requestID/reply`, + notebookReject: `${root}/notebook/:requestID/reject`, } as const export const KilocodeApi = HttpApi.make("kilocode") @@ -64,6 +76,42 @@ export const KilocodeApi = HttpApi.make("kilocode") "Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state.", }), ), + HttpApiEndpoint.get("notebookList", KilocodePaths.notebookList, { + query: WorkspaceRoutingQuery, + success: described(Schema.Array(NotebookRequest), "Pending notebook host requests"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.notebook.list", + summary: "List pending notebook requests", + description: "List pending native notebook requests for the routed workspace.", + }), + ), + HttpApiEndpoint.post("notebookReply", KilocodePaths.notebookReply, { + params: { requestID: NotebookRequestID }, + query: WorkspaceRoutingQuery, + payload: NotebookReplyPayload, + success: described(Schema.Boolean, "Notebook reply accepted"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.notebook.reply", + summary: "Reply to a notebook request", + description: "Complete a pending native notebook request with a structured result.", + }), + ), + HttpApiEndpoint.post("notebookReject", KilocodePaths.notebookReject, { + params: { requestID: NotebookRequestID }, + query: WorkspaceRoutingQuery, + payload: NotebookRejectPayload, + success: described(Schema.Boolean, "Notebook rejection accepted"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.notebook.reject", + summary: "Reject a notebook request", + description: "Complete a pending native notebook request with a structured host error.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 88e364e51a7..a742c10e408 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -7,10 +7,12 @@ import { Config } from "@/config/config" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot" +import { Notebook } from "@/kilocode/notebook/service" +import type { RequestID as NotebookRequestID } from "@/kilocode/notebook/protocol" import { InstanceStore } from "@/project/instance-store" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import { Skill } from "@/skill" -import { RemoveAgentPayload, RemoveSkillPayload } from "../groups/kilocode" +import { NotebookRejectPayload, NotebookReplyPayload, RemoveAgentPayload, RemoveSkillPayload } from "../groups/kilocode" export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) => Effect.gen(function* () { @@ -18,6 +20,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const skills = yield* Skill.Service const config = yield* Config.Service const store = yield* InstanceStore.Service + const notebook = yield* Notebook.Service const heapSnapshot = Effect.fn("KilocodeHttpApi.heapSnapshot")(function* () { return yield* Effect.sync(() => HeapSnapshot.write()) @@ -49,9 +52,37 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" return true }) + const notebookList = Effect.fn("KilocodeHttpApi.notebookList")(function* () { + return yield* notebook.list() + }) + + const notebookReply = Effect.fn("KilocodeHttpApi.notebookReply")(function* (ctx: { + params: { requestID: NotebookRequestID } + payload: typeof NotebookReplyPayload.Type + }) { + yield* notebook.reply({ requestID: ctx.params.requestID, result: ctx.payload.result }).pipe( + Effect.catchTag("Notebook.NotFoundError", () => Effect.fail(new HttpApiError.NotFound({}))), + Effect.catchTag("Notebook.InvalidReplyError", () => Effect.fail(new HttpApiError.BadRequest({}))), + ) + return true + }) + + const notebookReject = Effect.fn("KilocodeHttpApi.notebookReject")(function* (ctx: { + params: { requestID: NotebookRequestID } + payload: typeof NotebookRejectPayload.Type + }) { + yield* notebook + .reject({ requestID: ctx.params.requestID, error: ctx.payload.error }) + .pipe(Effect.catchTag("Notebook.NotFoundError", () => Effect.fail(new HttpApiError.NotFound({})))) + return true + }) + return handlers .handle("heapSnapshot", heapSnapshot) .handle("removeSkill", removeSkill) .handle("removeAgent", removeAgent) + .handle("notebookList", notebookList) + .handle("notebookReply", notebookReply) + .handle("notebookReject", notebookReject) }), ) diff --git a/packages/opencode/src/kilocode/tool/notebook-host.ts b/packages/opencode/src/kilocode/tool/notebook-host.ts new file mode 100644 index 00000000000..f50f8e78c06 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/notebook-host.ts @@ -0,0 +1,217 @@ +import { Notebook, HostError } from "@/kilocode/notebook/service" +import { Path, type Result } from "@/kilocode/notebook/protocol" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import * as Tool from "@/tool/tool" +import { Effect, Schema } from "effect" + +const Source = Schema.String.check(Schema.isMaxLength(200_000)) +const Revision = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)).annotate({ + description: "Opaque content revision returned by notebook_read or the previous successful notebook_edit", +}) +const Index = NonNegativeInt.annotate({ description: "Zero-based cell index" }) +const LIMIT = 20_000 + +function render(value: unknown) { + const text = JSON.stringify(value, (key, item) => (key === "requestPath" ? undefined : item), 2) + if (text.length <= LIMIT) return text + const preview = text.slice(0, 3_000) + return JSON.stringify( + { + truncated: true, + omittedCharacters: text.length - preview.length, + preview, + }, + null, + 2, + ) +} + +function abort(signal: AbortSignal) { + return Effect.callback((resume) => { + const err = () => new HostError({ code: "cancelled", detail: "The notebook tool call was cancelled" }) + if (signal.aborted) return resume(Effect.fail(err())) + const handler = () => resume(Effect.fail(err())) + signal.addEventListener("abort", handler, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", handler)) + }) +} + +function run(effect: Effect.Effect, signal: AbortSignal) { + return effect.pipe(Effect.raceFirst(abort(signal)), Effect.orDie) +} + +const ReadParams = Schema.Struct({ + path: Path, + include_outputs: Schema.optional(Schema.Boolean).annotate({ + description: "Include bounded text and error outputs. Defaults to false.", + }), +}) + +export const NotebookReadTool = Tool.define< + typeof ReadParams, + { path: string; revision: string }, + Notebook.Service, + "notebook_read" +>( + "notebook_read", + Effect.gen(function* () { + const notebook = yield* Notebook.Service + return { + description: + "Read a live, possibly unsaved VS Code notebook using a request-directory-relative path or a safe absolute path inside that directory. Returns an opaque content revision for edits; outputs are omitted unless include_outputs is true.", + parameters: ReadParams, + execute: (params, ctx) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "notebook_read", + patterns: [params.path], + always: [params.path], + metadata: { path: params.path, includeOutputs: params.include_outputs === true }, + }) + const result = yield* run( + notebook.request({ + operation: "read", + sessionID: ctx.sessionID, + path: params.path, + includeOutputs: params.include_outputs === true, + }), + ctx.abort, + ) + if (result.operation !== "read") + return yield* Effect.die(new Error("Notebook host returned the wrong result type")) + return { + title: `Notebook: ${params.path}`, + output: render(result), + metadata: { path: result.path, revision: result.revision }, + } + }), + } + }), +) + +const EditParams = Schema.Struct({ + path: Path, + expected_revision: Revision, + index: Index, + action: Schema.Literals(["insert", "replace", "delete"]).annotate({ + description: "insert and replace require kind and source; delete ignores cell fields", + }), + kind: Schema.optional(Schema.Literals(["code", "markdown"])).annotate({ + description: "Cell kind. Required for insert and replace.", + }), + language: Schema.optional(Schema.String.check(Schema.isMaxLength(200))), + source: Schema.optional(Source).annotate({ description: "Cell source. Required for insert and replace." }), +}) +type EditInput = Schema.Schema.Type + +function cellEdit(params: EditInput) { + if (params.action === "delete") return Effect.succeed({ action: params.action } as const) + if (params.kind === undefined || params.source === undefined) + return Effect.die( + new Tool.InvalidArgumentsError({ + tool: "notebook_edit", + detail: `the "${params.action}" action requires both "kind" and "source"`, + }), + ) + return Effect.succeed({ + action: params.action, + kind: params.kind, + language: params.language, + source: params.source, + }) +} + +export const NotebookEditTool = Tool.define< + typeof EditParams, + { path: string; revision: string; index: number }, + Notebook.Service, + "notebook_edit" +>( + "notebook_edit", + Effect.gen(function* () { + const notebook = yield* Notebook.Service + return { + description: + "Insert, replace, or delete one cell in a live VS Code notebook. Paths may be request-directory-relative or safe absolute paths. Pass the latest opaque revision from notebook_read or the previous successful edit unchanged. A stale_revision error requires a fresh read; never blindly retry an index-based edit. Leaves the document dirty.", + parameters: EditParams, + execute: (params, ctx) => + Effect.gen(function* () { + const edit = yield* cellEdit(params) + yield* ctx.ask({ + permission: "notebook_edit", + patterns: [params.path], + always: [params.path], + metadata: { + path: params.path, + action: params.action, + index: params.index, + expectedRevision: params.expected_revision, + }, + }) + const result = yield* run( + notebook.request({ + operation: "edit", + sessionID: ctx.sessionID, + path: params.path, + expectedRevision: params.expected_revision, + index: params.index, + edit, + }), + ctx.abort, + ) + if (result.operation !== "edit") + return yield* Effect.die(new Error("Notebook host returned the wrong result type")) + return { + title: `${result.action} notebook cell ${result.index}`, + output: render(result), + metadata: { path: result.path, revision: result.revision, index: result.index }, + } + }), + } + }), +) + +const ExecuteParams = Schema.Struct({ path: Path, expected_revision: Revision, index: Index }) + +export const NotebookExecuteTool = Tool.define< + typeof ExecuteParams, + { path: string; revision: string; index: number }, + Notebook.Service, + "notebook_execute" +>( + "notebook_execute", + Effect.gen(function* () { + const notebook = yield* Notebook.Service + return { + description: + "Execute one explicit code cell in a live VS Code notebook using a request-directory-relative or safe absolute path. Pass the latest opaque content revision unchanged. Execution requires a kernel already selected by the user; the tool never reveals the notebook or opens a kernel picker.", + parameters: ExecuteParams, + execute: (params, ctx) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "notebook_execute", + patterns: [params.path], + always: [params.path], + metadata: { path: params.path, index: params.index, expectedRevision: params.expected_revision }, + }) + const result = yield* run( + notebook.request({ + operation: "execute", + sessionID: ctx.sessionID, + path: params.path, + expectedRevision: params.expected_revision, + index: params.index, + }), + ctx.abort, + ) + if (result.operation !== "execute") + return yield* Effect.die(new Error("Notebook host returned the wrong result type")) + return { + title: `Executed notebook cell ${result.index}`, + output: render(result), + metadata: { path: result.path, revision: result.revision, index: result.index }, + } + }), + } + }), +) diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index fcbbf4ba908..3e124b8d7bb 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -3,9 +3,11 @@ import { CodebaseSearchTool } from "../../tool/warpgrep" import { RecallTool } from "../../tool/recall" import { AgentManagerTool } from "./agent-manager" import { BackgroundProcessTool } from "./background-process" +import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host" import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect } from "effect" +import { Notebook } from "@/kilocode/notebook/service" import * as Log from "@opencode-ai/core/util/log" import { Agent } from "@/agent/agent" import * as Truncate from "@/tool/truncate" @@ -31,20 +33,34 @@ export namespace KiloToolRegistry { /** Resolve Kilo-specific tool Infos outside any InstanceState, so their Truncate/Agent deps are * satisfied at the outer registry scope instead of leaking into InstanceState's Effect. */ - export function infos() { + export function infos(notebook?: Notebook.Interface) { return Effect.gen(function* () { const codebase = yield* CodebaseSearchTool const recall = yield* RecallTool const manager = yield* AgentManagerTool const process = yield* BackgroundProcessTool - return { codebase, recall, manager, process } + if (!notebook) return { codebase, recall, manager, process } + const tools = yield* Effect.all({ + notebookRead: NotebookReadTool, + notebookEdit: NotebookEditTool, + notebookExecute: NotebookExecuteTool, + }).pipe(Effect.provideService(Notebook.Service, notebook)) + return { codebase, recall, manager, process, ...tools } }) } /** 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; manager: Tool.Info; process: Tool.Info }, + tools: { + codebase: Tool.Info + recall: Tool.Info + manager: Tool.Info + process: Tool.Info + notebookRead?: Tool.Info + notebookEdit?: Tool.Info + notebookExecute?: Tool.Info + }, deps: Deps, loaders: Loaders = {}, ) { @@ -55,8 +71,16 @@ export namespace KiloToolRegistry { manager: Tool.init(tools.manager), process: Tool.init(tools.process), }) + const notebooks = + tools.notebookRead && tools.notebookEdit && tools.notebookExecute + ? yield* Effect.all({ + notebookRead: Tool.init(tools.notebookRead), + notebookEdit: Tool.init(tools.notebookEdit), + notebookExecute: Tool.init(tools.notebookExecute), + }) + : {} const semantic = yield* semanticTool(deps, loaders) - return { ...base, semantic } + return { ...base, ...notebooks, semantic } }) } @@ -99,8 +123,17 @@ export namespace KiloToolRegistry { /** Kilo-specific tools to append to the builtin list */ export function extra( - tools: { codebase: Tool.Def; semantic?: Tool.Def; recall: Tool.Def; manager: Tool.Def; process: Tool.Def }, - cfg: { experimental?: { codebase_search?: boolean } }, + tools: { + codebase: Tool.Def + semantic?: Tool.Def + recall: Tool.Def + manager: Tool.Def + process: Tool.Def + notebookRead?: Tool.Def + notebookEdit?: Tool.Def + notebookExecute?: Tool.Def + }, + cfg: { experimental?: { codebase_search?: boolean; native_notebook_tools?: boolean } }, ): Tool.Def[] { return [ ...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), @@ -109,6 +142,13 @@ export namespace KiloToolRegistry { ...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []), // The extension is the only client that can consume the Agent Manager start event. ...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []), + ...(Flag.KILO_CLIENT === "vscode" && + cfg.experimental?.native_notebook_tools === true && + tools.notebookRead && + tools.notebookEdit && + tools.notebookExecute + ? [tools.notebookRead, tools.notebookEdit, tools.notebookExecute] + : []), ] } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index bee7fe31bc5..872d5a3594e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -37,6 +37,7 @@ import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { PtyTicket } from "@/pty/ticket" import { Question } from "@/question" +import { Notebook } from "@/kilocode/notebook/service" // kilocode_change import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" import { SessionPrompt } from "@/session/prompt" @@ -225,6 +226,7 @@ export function createRoutes( Pty.defaultLayer, PtyTicket.defaultLayer, Question.defaultLayer, + Notebook.defaultLayer, // kilocode_change Ripgrep.defaultLayer, RuntimeFlags.defaultLayer, Session.defaultLayer, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c184b6e77eb..709af95a942 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -27,6 +27,7 @@ import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" import { KiloToolRegistry } from "../kilocode/tool/registry" // kilocode_change +import { Notebook } from "@/kilocode/notebook/service" // kilocode_change import { RepoCloneTool } from "./repo_clone" import { RepoOverviewTool } from "./repo_overview" import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change @@ -38,7 +39,7 @@ import { ApplyPatchTool } from "./apply_patch" import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Option } from "effect" // kilocode_change import { HttpClient } from "effect/unstable/http" // kilocode_change import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -150,7 +151,8 @@ export const layer: Layer.Layer< const agent = yield* Agent.Service // kilocode_change start const suggesttool = yield* SuggestTool - const kiloToolInfos = yield* KiloToolRegistry.infos() + const notebook = Option.getOrUndefined(yield* Effect.serviceOption(Notebook.Service)) + const kiloToolInfos = yield* KiloToolRegistry.infos(notebook) // kilocode_change end const state = yield* InstanceState.make( @@ -450,6 +452,7 @@ export const defaultLayer = Layer.suspend( // kilocode_change start - provide Kilo-owned registry dependencies .pipe( Layer.provide(Command.defaultLayer), + Layer.provide(Notebook.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(SessionStatus.defaultLayer), ), diff --git a/packages/opencode/test/kilocode/notebook-service.test.ts b/packages/opencode/test/kilocode/notebook-service.test.ts new file mode 100644 index 00000000000..1f831f2b892 --- /dev/null +++ b/packages/opencode/test/kilocode/notebook-service.test.ts @@ -0,0 +1,209 @@ +import { expect } from "bun:test" +import { Bus } from "@/bus" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Notebook, HostError } from "@/kilocode/notebook/service" +import { EditRequest, Event, ReadRequest, ReadResult, type Request } from "@/kilocode/notebook/protocol" +import { SessionID } from "@/session/schema" +import { Effect, Fiber, Layer, Queue, Schema } from "effect" +import { TestInstance } from "../fixture/fixture" +import { disposeInstance } from "@/effect/instance-registry" +import { testEffect } from "../lib/effect" + +const it = testEffect(Notebook.layer("20 millis").pipe(Layer.provideMerge(Bus.layer))) +const sessionID = SessionID.make("ses_notebook_test") + +function request(notebook: Notebook.Interface) { + return notebook.request({ operation: "read", sessionID, path: "analysis.ipynb", includeOutputs: false }) +} + +it.instance( + "publishes, lists, and completes a correlated request", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const bus = yield* Bus.Service + const instance = yield* TestInstance + const events = yield* Queue.unbounded<{ properties: Request }>() + const global = yield* Queue.unbounded() + const off = yield* bus.subscribeCallback(Event.Requested, (event) => Queue.offerUnsafe(events, event)) + const handler = (event: GlobalEvent) => { + if (event.payload?.type === Event.Requested.type) Queue.offerUnsafe(global, event) + } + GlobalBus.on("event", handler) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + off() + GlobalBus.off("event", handler) + }), + ) + + const fiber = yield* request(notebook).pipe(Effect.forkChild) + const event = yield* Queue.take(events).pipe(Effect.timeout("2 seconds")) + expect(event.properties.sessionID).toBe(sessionID) + expect(event.properties.path).toBe("analysis.ipynb") + expect((yield* Queue.take(global).pipe(Effect.timeout("2 seconds"))).directory).toBe(instance.directory) + expect(yield* notebook.list()).toEqual([event.properties]) + + yield* notebook.reply({ + requestID: event.properties.id, + result: { operation: "read", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:3", cells: [] }, + }) + expect(yield* Fiber.join(fiber)).toEqual({ + operation: "read", + path: "analysis.ipynb", + requestPath: "analysis.ipynb", + revision: "content:3", + cells: [], + }) + expect(yield* notebook.list()).toEqual([]) + + const late = yield* notebook + .reply({ + requestID: event.properties.id, + result: { operation: "read", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:3", cells: [] }, + }) + .pipe(Effect.flip) + expect(late._tag).toBe("Notebook.NotFoundError") + }), + { git: true }, +) + +it.instance( + "propagates structured host rejection and removes pending state", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const fiber = yield* request(notebook).pipe(Effect.forkChild) + const pending = yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 })) + yield* notebook.reject({ + requestID: pending[0].id, + error: { + code: "stale_revision", + message: "Notebook content changed; re-read before retrying", + path: "analysis.ipynb", + index: 0, + currentRevision: "content:current", + }, + }) + const err = yield* Fiber.join(fiber).pipe(Effect.flip) + expect(err).toBeInstanceOf(HostError) + expect(err.code).toBe("stale_revision") + expect(err.message).toContain("re-read") + expect(yield* notebook.list()).toEqual([]) + }), + { git: true }, +) + +it.instance( + "cancels interrupted requests and rejects operation-mismatched replies", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const bus = yield* Bus.Service + const cancelled = yield* Queue.unbounded() + const off = yield* bus.subscribeCallback(Event.Cancelled, (event) => + Queue.offerUnsafe(cancelled, event.properties.reason), + ) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + const fiber = yield* request(notebook).pipe(Effect.forkChild) + const pending = yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 })) + const mismatch = yield* notebook + .reply({ + requestID: pending[0].id, + result: { operation: "edit", path: "analysis.ipynb", requestPath: "analysis.ipynb", revision: "content:2", index: 0, action: "delete" }, + }) + .pipe(Effect.flip) + expect(mismatch._tag).toBe("Notebook.InvalidReplyError") + const wrongPath = yield* notebook + .reply({ + requestID: pending[0].id, + result: { + operation: "read", + path: "other.ipynb", + requestPath: "other.ipynb", + revision: "content:2", + cells: [], + }, + }) + .pipe(Effect.flip) + expect(wrongPath._tag).toBe("Notebook.InvalidReplyError") + yield* Fiber.interrupt(fiber) + expect(yield* Queue.take(cancelled).pipe(Effect.timeout("2 seconds"))).toBe("cancelled") + expect(yield* notebook.list()).toEqual([]) + }), + { git: true }, +) + +it.instance( + "times out pending requests", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const err = yield* request(notebook).pipe(Effect.flip) + expect(err.code).toBe("timeout") + expect(yield* notebook.list()).toEqual([]) + }), + { git: true }, +) + +it.instance( + "accepts both path forms, requires opaque revisions, and bounds aggregate results", + () => + Effect.gen(function* () { + const absolute = yield* Schema.decodeUnknownEffect(ReadRequest)({ + id: "nbr_test", + sessionID, + operation: "read", + path: "/workspace/analysis.ipynb", + includeOutputs: false, + }) + expect(absolute.path).toBe("/workspace/analysis.ipynb") + const relative = yield* Schema.decodeUnknownEffect(ReadRequest)({ + ...absolute, + path: "nested/analysis.ipynb", + }) + expect(relative.path).toBe("nested/analysis.ipynb") + const invalid = yield* Schema.decodeUnknownEffect(EditRequest)({ + id: "nbr_edit", + sessionID, + operation: "edit", + path: "analysis.ipynb", + expectedRevision: 4, + index: 0, + edit: { action: "delete" }, + }).pipe(Effect.flip) + expect(String(invalid)).toContain("string") + + const cells = Array.from({ length: 11 }, (_, index) => ({ + index, + kind: "code" as const, + language: "python", + source: "x".repeat(200_000), + })) + const output = yield* Schema.decodeUnknownEffect(ReadResult)({ + operation: "read", + path: "analysis.ipynb", + requestPath: "analysis.ipynb", + revision: "content:1", + cells, + }).pipe(Effect.flip) + expect(String(output)).toContain("aggregate output limit") + }), + { git: true }, +) + +it.instance( + "fails pending requests when the instance is disposed", + () => + Effect.gen(function* () { + const notebook = yield* Notebook.Service + const instance = yield* TestInstance + const fiber = yield* request(notebook).pipe(Effect.forkChild) + yield* notebook.list().pipe(Effect.repeat({ until: (items) => items.length === 1 })) + yield* Effect.promise(() => disposeInstance(instance.directory)) + const err = yield* Fiber.join(fiber).pipe(Effect.flip) + expect(err.code).toBe("disconnected") + }), + { git: true }, +) diff --git a/packages/opencode/test/kilocode/notebook-tools.test.ts b/packages/opencode/test/kilocode/notebook-tools.test.ts new file mode 100644 index 00000000000..616349b4236 --- /dev/null +++ b/packages/opencode/test/kilocode/notebook-tools.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test" +import { Agent } from "@/agent/agent" +import { Notebook } from "@/kilocode/notebook/service" +import * as KiloAgent from "@/kilocode/agent" +import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "@/kilocode/tool/notebook-host" +import { MessageID, SessionID } from "@/session/schema" +import * as Tool from "@/tool/tool" +import { ToolJsonSchema } from "@/tool/json-schema" +import { Truncate } from "@/tool/truncate" +import { Effect, Layer } from "effect" +import { testEffect } from "../lib/effect" + +const calls: Notebook.Input[] = [] +const notebook = Layer.mock(Notebook.Service, { + request: (input) => { + calls.push(input) + if (input.operation === "read") + return Effect.succeed({ + operation: "read" as const, + path: input.path, + requestPath: input.path, + revision: "content:read", + cells: [{ index: 0, kind: "code" as const, language: "python", source: "x".repeat(200_000) }], + }) + if (input.operation === "edit") + return Effect.succeed({ + operation: "edit" as const, + path: input.path, + requestPath: input.path, + revision: "content:edit", + index: input.index, + action: input.edit.action, + }) + return Effect.succeed({ + operation: "execute" as const, + path: input.path, + requestPath: input.path, + revision: "content:execute", + index: input.index, + status: "success" as const, + outputs: [], + }) + }, +}) +const it = testEffect(Layer.mergeAll(notebook, Agent.defaultLayer, Truncate.defaultLayer)) + +function context(asks: Parameters[0][]): Tool.Context { + return { + sessionID: SessionID.make("ses_notebook_tools"), + messageID: MessageID.make("msg_notebook_tools"), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: (input) => Effect.sync(() => asks.push(input)), + } +} + +describe("native notebook tools", () => { + it.instance( + "uses dedicated permissions and bounded structured output", + () => + Effect.gen(function* () { + calls.length = 0 + const asks: Parameters[0][] = [] + const read = yield* NotebookReadTool.pipe(Effect.flatMap(Tool.init)) + const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init)) + const execute = yield* NotebookExecuteTool.pipe(Effect.flatMap(Tool.init)) + const ctx = context(asks) + + const readResult = yield* read.execute({ path: "analysis.ipynb", include_outputs: true }, ctx) + const editResult = yield* edit.execute( + { + path: "analysis.ipynb", + expected_revision: "content:read", + index: 0, + action: "replace", + kind: "code", + language: "python", + source: "print(42)", + }, + ctx, + ) + const executeResult = yield* execute.execute( + { path: "/workspace/analysis.ipynb", expected_revision: "content:edit", index: 0 }, + ctx, + ) + + expect(asks.map((item) => item.permission)).toEqual(["notebook_read", "notebook_edit", "notebook_execute"]) + expect(asks.map((item) => item.patterns[0])).toEqual([ + "analysis.ipynb", + "analysis.ipynb", + "/workspace/analysis.ipynb", + ]) + expect(calls.map((item) => item.operation)).toEqual(["read", "edit", "execute"]) + expect(calls[1]).toMatchObject({ expectedRevision: "content:read" }) + expect(calls[2]).toMatchObject({ expectedRevision: "content:edit" }) + expect(readResult.output.length).toBeLessThanOrEqual(20_000) + const rendered = JSON.parse(readResult.output) + expect(rendered).toMatchObject({ truncated: true, omittedCharacters: expect.any(Number) }) + expect(rendered.preview).toContain('"revision"') + expect(rendered.preview).not.toContain("requestPath") + expect(editResult.metadata.revision).toBe("content:edit") + expect(executeResult.metadata.index).toBe(0) + }), + { git: true }, + ) + + it.instance( + "exposes notebook_edit without a top-level schema union", + () => + Effect.gen(function* () { + const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init)) + const schema = ToolJsonSchema.fromTool(edit) + expect(schema.type).toBe("object") + expect(schema.anyOf).toBeUndefined() + expect(schema.oneOf).toBeUndefined() + expect(schema.allOf).toBeUndefined() + }), + { git: true }, + ) + + it.instance( + "rejects insert without kind and source before asking for permission", + () => + Effect.gen(function* () { + const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init)) + const asks: Parameters[0][] = [] + const ctx = context(asks) + const exit = yield* edit + .execute({ path: "analysis.ipynb", expected_revision: "content:read", index: 0, action: "insert" }, ctx) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + expect(asks).toEqual([]) + }), + { git: true }, + ) +}) + +test("uses dedicated VS Code notebook permission defaults only when enabled", () => { + const prev = process.env.KILO_CLIENT + try { + process.env.KILO_CLIENT = "vscode" + const disabled = KiloAgent.prepare({}).defaultsPatch + expect(disabled.some((rule) => rule.permission.startsWith("notebook_"))).toBe(false) + + const rules = KiloAgent.prepare({ experimental: { native_notebook_tools: true } }).defaultsPatch + expect(rules.findLast((rule) => rule.permission === "notebook_read")?.action).toBe("ask") + expect(rules.findLast((rule) => rule.permission === "notebook_edit")?.action).toBe("ask") + expect(rules.findLast((rule) => rule.permission === "notebook_execute")?.action).toBe("ask") + } finally { + if (prev === undefined) delete process.env.KILO_CLIENT + if (prev !== undefined) process.env.KILO_CLIENT = prev + } +}) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 26da3cd254f..4f4446b97fe 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -39,6 +39,9 @@ function infos() { recall: info("recall"), manager: info("agent_manager"), process: info("background_process"), + notebookRead: info("notebook_read"), + notebookEdit: info("notebook_edit"), + notebookExecute: info("notebook_execute"), } } diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index afcd23c1ba4..420b359bafa 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -203,6 +203,9 @@ describe("kilocode tool registry indexing", () => { recall: def("recall"), manager: def("agent_manager"), process: def("background_process"), + notebookRead: def("notebook_read"), + notebookEdit: def("notebook_edit"), + notebookExecute: def("notebook_execute"), } try { @@ -220,6 +223,20 @@ describe("kilocode tool registry indexing", () => { expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual( ["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"], ) + expect( + KiloToolRegistry.extra(tools, { + experimental: { codebase_search: true, native_notebook_tools: true }, + }).map((tool) => tool.id), + ).toEqual([ + "codebase_search", + "semantic_search", + "recall", + "background_process", + "agent_manager", + "notebook_read", + "notebook_edit", + "notebook_execute", + ]) expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([ "recall", "background_process", diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index 252e703b540..dac68266507 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -51,6 +51,9 @@ function infos() { recall: info("recall"), manager: info("agent_manager"), process: info("background_process"), + notebookRead: info("notebook_read"), + notebookEdit: info("notebook_edit"), + notebookExecute: info("notebook_execute"), } } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 407b924b0d6..68e288bd3bb 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -236,6 +236,33 @@ const scenarios: Scenario[] = [ })) .json(404, object, "status"), http.protected.get("/question", "question.list").json(200, array), + // kilocode_change start + http.protected.get("/kilocode/notebook", "kilocode.notebook.list").json(200, array), + http.protected + .post("/kilocode/notebook/{requestID}/reply", "kilocode.notebook.reply") + .at((ctx) => ({ + path: route("/kilocode/notebook/{requestID}/reply", { requestID: "nbr_httpapi_reply" }), + headers: ctx.headers(), + body: { + result: { + operation: "read", + path: "notebook.ipynb", + requestPath: "notebook.ipynb", + revision: "content:1", + cells: [], + }, + }, + })) + .json(404, object, "status"), + http.protected + .post("/kilocode/notebook/{requestID}/reject", "kilocode.notebook.reject") + .at((ctx) => ({ + path: route("/kilocode/notebook/{requestID}/reject", { requestID: "nbr_httpapi_reject" }), + headers: ctx.headers(), + body: { error: { code: "not_found", message: "Notebook not found" } }, + })) + .json(404, object, "status"), + // kilocode_change end http.protected .post("/question/{requestID}/reply", "question.reply.invalid") .at((ctx) => ({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 15ba3467d45..db6abada187 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -142,6 +142,12 @@ import type { KiloCloudSessionsResponses, KilocodeHeapSnapshotErrors, KilocodeHeapSnapshotResponses, + KilocodeNotebookListErrors, + KilocodeNotebookListResponses, + KilocodeNotebookRejectErrors, + KilocodeNotebookRejectResponses, + KilocodeNotebookReplyErrors, + KilocodeNotebookReplyResponses, KilocodeRemoveAgentErrors, KilocodeRemoveAgentResponses, KilocodeRemoveSkillErrors, @@ -192,6 +198,9 @@ import type { NetworkRejectResponses, NetworkReplyErrors, NetworkReplyResponses, + NotebookFailure, + NotebookRequestId, + NotebookResult, OutputFormat, Part as Part2, PartDeleteErrors, @@ -6964,6 +6973,128 @@ export class Heap extends HeyApiClient { } } +export class Notebook extends HeyApiClient { + /** + * List pending notebook requests + * + * List pending native notebook requests for the routed workspace. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + KilocodeNotebookListResponses, + KilocodeNotebookListErrors, + ThrowOnError + >({ + url: "/kilocode/notebook", + ...options, + ...params, + }) + } + + /** + * Reply to a notebook request + * + * Complete a pending native notebook request with a structured result. + */ + public reply( + parameters: { + requestID: NotebookRequestId + directory?: string + workspace?: string + result?: NotebookResult + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "result" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeNotebookReplyResponses, + KilocodeNotebookReplyErrors, + ThrowOnError + >({ + url: "/kilocode/notebook/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reject a notebook request + * + * Complete a pending native notebook request with a structured host error. + */ + public reject( + parameters: { + requestID: NotebookRequestId + directory?: string + workspace?: string + error?: NotebookFailure + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "error" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeNotebookRejectResponses, + KilocodeNotebookRejectErrors, + ThrowOnError + >({ + url: "/kilocode/notebook/{requestID}/reject", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class SessionImport extends HeyApiClient { /** * Insert project for session import @@ -7432,6 +7563,11 @@ export class Kilocode extends HeyApiClient { return (this._heap ??= new Heap({ client: this.client })) } + private _notebook?: Notebook + get notebook(): Notebook { + return (this._notebook ??= new Notebook({ client: this.client })) + } + private _sessionImport?: SessionImport get sessionImport(): SessionImport { return (this._sessionImport ??= new SessionImport({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d792b4096fa..0045862b2e4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -14,6 +14,8 @@ export type Event = | EventTuiSessionSelect | EventSandboxStatusChanged | EventKilocodeAgentManagerStart + | EventKilocodeNotebookRequested + | EventKilocodeNotebookCancelled | EventIndexingStatus | EventIndexingWarning | EventServerInstanceDisposed @@ -192,6 +194,64 @@ export type EventTuiSessionSelect = { } } +export type NotebookRequestId = string + +export type NotebookReadRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "read" + includeOutputs: boolean +} + +export type NotebookEditRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "edit" + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + expectedRevision: string + /** + * Zero-based cell index + */ + index: number + edit: + | { + action: "insert" + kind: "code" | "markdown" + language?: string + source: string + } + | { + action: "replace" + kind: "code" | "markdown" + language?: string + source: string + } + | { + action: "delete" + } +} + +export type NotebookExecuteRequest = { + id: NotebookRequestId + sessionID: string + path: string + operation: "execute" + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + expectedRevision: string + /** + * Zero-based cell index + */ + index: number +} + +export type NotebookRequest = NotebookReadRequest | NotebookEditRequest | NotebookExecuteRequest + export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" export type IndexingStatus = { @@ -933,6 +993,8 @@ export type GlobalEvent = { | EventTuiSessionSelect | EventSandboxStatusChanged | EventKilocodeAgentManagerStart + | EventKilocodeNotebookRequested + | EventKilocodeNotebookCancelled | EventIndexingStatus | EventIndexingWarning | EventServerInstanceDisposed @@ -1183,6 +1245,9 @@ export type PermissionConfig = doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig agent_manager?: PermissionRuleConfig + notebook_read?: PermissionRuleConfig + notebook_edit?: PermissionRuleConfig + notebook_execute?: PermissionRuleConfig [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined } @@ -1542,6 +1607,7 @@ export type Config = { disable_paste_summary?: boolean batch_tool?: boolean codebase_search?: boolean + native_notebook_tools?: boolean speech_to_text_model?: string openTelemetry?: boolean primary_tools?: Array @@ -2422,6 +2488,105 @@ export type EffectHttpApiErrorServiceUnavailable = { _tag: "ServiceUnavailable" } +export type NotebookOutput = { + mime: string + text?: string + name?: string + message?: string + stack?: string + omitted?: boolean + truncated?: boolean +} + +export type NotebookCell = { + /** + * Zero-based cell index + */ + index: number + kind: "code" | "markdown" + language: string + source: string + execution?: { + order?: number + success?: boolean + started?: number + ended?: number + } + outputs?: Array +} + +export type NotebookReadResult = { + operation: "read" + path: string + requestPath: string + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + revision: string + cells: Array + truncated?: boolean +} + +export type NotebookEditResult = { + operation: "edit" + path: string + requestPath: string + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + revision: string + /** + * Zero-based cell index + */ + index: number + action: "insert" | "replace" | "delete" + cell?: NotebookCell +} + +export type NotebookExecuteResult = { + operation: "execute" + path: string + requestPath: string + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + revision: string + /** + * Zero-based cell index + */ + index: number + status: "success" | "error" + outputs: Array + truncated?: boolean +} + +export type NotebookResult = NotebookReadResult | NotebookEditResult | NotebookExecuteResult + +export type NotebookFailure = { + code: + | "cancelled" + | "closed" + | "disconnected" + | "execution_failed" + | "invalid_cell" + | "invalid_path" + | "no_kernel" + | "not_found" + | "stale_revision" + | "timeout" + | "unsupported" + message: string + path?: string + /** + * Zero-based cell index + */ + index?: number + /** + * Opaque notebook content revision; pass it back unchanged and do not parse or increment it + */ + currentRevision?: string +} + export type KilocodeSessionImportResult = { ok: boolean id: string @@ -3020,6 +3185,22 @@ export type EventKilocodeAgentManagerStart = { } } +export type EventKilocodeNotebookRequested = { + id: string + type: "kilocode.notebook.requested" + properties: NotebookRequest +} + +export type EventKilocodeNotebookCancelled = { + id: string + type: "kilocode.notebook.cancelled" + properties: { + requestID: NotebookRequestId + sessionID: string + reason: "cancelled" | "disposed" | "timeout" + } +} + export type EventIndexingStatus = { id: string type: "indexing.status" @@ -10546,6 +10727,106 @@ export type KilocodeRemoveAgentResponses = { export type KilocodeRemoveAgentResponse = KilocodeRemoveAgentResponses[keyof KilocodeRemoveAgentResponses] +export type KilocodeNotebookListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/notebook" +} + +export type KilocodeNotebookListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeNotebookListError = KilocodeNotebookListErrors[keyof KilocodeNotebookListErrors] + +export type KilocodeNotebookListResponses = { + /** + * Pending notebook host requests + */ + 200: Array +} + +export type KilocodeNotebookListResponse = KilocodeNotebookListResponses[keyof KilocodeNotebookListResponses] + +export type KilocodeNotebookReplyData = { + body?: { + result: NotebookResult + } + path: { + requestID: NotebookRequestId + } + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/notebook/{requestID}/reply" +} + +export type KilocodeNotebookReplyErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type KilocodeNotebookReplyError = KilocodeNotebookReplyErrors[keyof KilocodeNotebookReplyErrors] + +export type KilocodeNotebookReplyResponses = { + /** + * Notebook reply accepted + */ + 200: boolean +} + +export type KilocodeNotebookReplyResponse = KilocodeNotebookReplyResponses[keyof KilocodeNotebookReplyResponses] + +export type KilocodeNotebookRejectData = { + body?: { + error: NotebookFailure + } + path: { + requestID: NotebookRequestId + } + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/notebook/{requestID}/reject" +} + +export type KilocodeNotebookRejectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type KilocodeNotebookRejectError = KilocodeNotebookRejectErrors[keyof KilocodeNotebookRejectErrors] + +export type KilocodeNotebookRejectResponses = { + /** + * Notebook rejection accepted + */ + 200: boolean +} + +export type KilocodeNotebookRejectResponse = KilocodeNotebookRejectResponses[keyof KilocodeNotebookRejectResponses] + export type NetworkListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a7de036e880..b97594d1670 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5713,6 +5713,9 @@ "required": ["id", "providerID"], "additionalProperties": false }, + "metadata": { + "type": "object" + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -6040,6 +6043,9 @@ "title": { "type": "string" }, + "metadata": { + "type": "object" + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -10708,11 +10714,14 @@ } }, "400": { - "description": "BadRequest | InvalidRequestError", + "description": "WorkspaceCreateError | BadRequest | InvalidRequestError", "content": { "application/json": { "schema": { "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceCreateError" + }, { "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" }, @@ -14764,6 +14773,249 @@ ] } }, + "/kilocode/notebook": { + "get": { + "tags": ["kilocode"], + "operationId": "kilocode.notebook.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Pending notebook host requests", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotebookRequest" + }, + "description": "Pending notebook host requests" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "List pending native notebook requests for the routed workspace.", + "summary": "List pending notebook requests", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.notebook.list({\n ...\n})" + } + ] + } + }, + "/kilocode/notebook/{requestID}/reply": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.notebook.reply", + "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Notebook reply accepted", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Notebook reply accepted" + } + } + } + }, + "400": { + "description": "BadRequest | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Complete a pending native notebook request with a structured result.", + "summary": "Reply to a notebook request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/components/schemas/NotebookResult" + } + }, + "required": ["result"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.notebook.reply({\n ...\n})" + } + ] + } + }, + "/kilocode/notebook/{requestID}/reject": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.notebook.reject", + "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Notebook rejection accepted", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Notebook rejection accepted" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Complete a pending native notebook request with a structured host error.", + "summary": "Reject a notebook request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "$ref": "#/components/schemas/NotebookFailure" + } + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.notebook.reject({\n ...\n})" + } + ] + } + }, "/network": { "get": { "tags": ["network"], @@ -15179,6 +15431,71 @@ ] } }, + "/sandbox/support": { + "get": { + "tags": ["sandbox"], + "operationId": "sandbox.support", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Sandbox backend support", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + }, + "required": ["available"], + "additionalProperties": false, + "description": "Sandbox backend support" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Get sandbox backend availability without creating a session.", + "summary": "Get sandbox backend support", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sandbox.support({\n ...\n})" + } + ] + } + }, "/session/{sessionID}/sandbox": { "get": { "tags": ["sandbox"], @@ -15355,7 +15672,7 @@ } } }, - "description": "Toggle the ephemeral sandbox override for one session.", + "description": "Toggle and persist the sandbox state for one session.", "summary": "Toggle session sandbox", "x-codeSamples": [ { @@ -16589,20 +16906,32 @@ "required": true }, { - "name": "directory", "in": "query", + "name": "directory", "schema": { "type": "string" - }, - "required": false + } }, { - "name": "workspace", "in": "query", + "name": "workspace", "schema": { "type": "string" - }, - "required": false + } + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "ticket", + "schema": { + "type": "string" + } } ], "responses": { @@ -16680,6 +17009,12 @@ { "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookRequested" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" + }, { "$ref": "#/components/schemas/EventIndexingStatus" }, @@ -16920,6 +17255,9 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventPluginAdded" + }, { "$ref": "#/components/schemas/EventCatalogModelUpdated" }, @@ -17257,6 +17595,177 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "NotebookRequestID": { + "type": "string" + }, + "NotebookReadRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["read"] + }, + "includeOutputs": { + "type": "boolean" + } + }, + "required": ["id", "sessionID", "path", "operation", "includeOutputs"], + "additionalProperties": false + }, + "NotebookEditRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["edit"] + }, + "expectedRevision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "edit": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["insert"] + }, + "kind": { + "type": "string", + "enum": ["code", "markdown"] + }, + "language": { + "type": "string", + "maxLength": 200 + }, + "source": { + "type": "string", + "maxLength": 200000 + } + }, + "required": ["action", "kind", "source"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["replace"] + }, + "kind": { + "type": "string", + "enum": ["code", "markdown"] + }, + "language": { + "type": "string", + "maxLength": 200 + }, + "source": { + "type": "string", + "maxLength": 200000 + } + }, + "required": ["action", "kind", "source"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["delete"] + } + }, + "required": ["action"], + "additionalProperties": false + } + ] + } + }, + "required": ["id", "sessionID", "path", "operation", "expectedRevision", "index", "edit"], + "additionalProperties": false + }, + "NotebookExecuteRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "operation": { + "type": "string", + "enum": ["execute"] + }, + "expectedRevision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + } + }, + "required": ["id", "sessionID", "path", "operation", "expectedRevision", "index"], + "additionalProperties": false + }, + "NotebookRequest": { + "anyOf": [ + { + "$ref": "#/components/schemas/NotebookReadRequest" + }, + { + "$ref": "#/components/schemas/NotebookEditRequest" + }, + { + "$ref": "#/components/schemas/NotebookExecuteRequest" + } + ] + }, "IndexingStatusState": { "type": "string", "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] @@ -18058,7 +18567,7 @@ }, "pid": { "type": "integer", - "exclusiveMinimum": 0 + "minimum": 0 }, "sessionID": { "anyOf": [ @@ -19347,6 +19856,9 @@ "version": { "type": "string" }, + "metadata": { + "type": "object" + }, "time": { "type": "object", "properties": { @@ -19466,6 +19978,12 @@ { "$ref": "#/components/schemas/EventKilocodeAgent_managerStart" }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookRequested" + }, + { + "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" + }, { "$ref": "#/components/schemas/EventIndexingStatus" }, @@ -19706,6 +20224,9 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventPluginAdded" + }, { "$ref": "#/components/schemas/EventCatalogModelUpdated" }, @@ -20246,6 +20767,15 @@ }, "agent_manager": { "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "notebook_read": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "notebook_edit": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "notebook_execute": { + "$ref": "#/components/schemas/PermissionRuleConfig" } }, "additionalProperties": { @@ -20380,7 +20910,20 @@ "enum": [false] } ], - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout." + "description": "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout." + }, + "headerTimeout": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "boolean", + "enum": [false] + } + ], + "description": "Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout." }, "chunkTimeout": { "type": "integer", @@ -20529,7 +21072,6 @@ } } }, - "required": ["input", "output"], "additionalProperties": false }, "experimental": { @@ -21204,6 +21746,9 @@ "codebase_search": { "type": "boolean" }, + "native_notebook_tools": { + "type": "boolean" + }, "speech_to_text_model": { "type": "string" }, @@ -21220,16 +21765,20 @@ "type": "boolean" }, "sandbox": { - "type": "boolean", - "description": "Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access" + "type": "boolean" }, "sandbox_restrict_network": { - "type": "boolean", - "description": "Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)" + "type": "boolean" }, "mcp_timeout": { "type": "integer", "exclusiveMinimum": 0 + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigV2ExperimentalPolicy" + } } }, "additionalProperties": false @@ -21905,6 +22454,9 @@ "version": { "type": "string" }, + "metadata": { + "type": "object" + }, "time": { "type": "object", "properties": { @@ -23254,6 +23806,27 @@ "required": ["id", "type", "name", "projectID", "timeUsed"], "additionalProperties": false }, + "WorkspaceCreateError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["WorkspaceCreateError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "WorkspaceWarpError": { "type": "object", "properties": { @@ -23858,6 +24431,270 @@ "required": ["_tag"], "additionalProperties": false }, + "NotebookOutput": { + "type": "object", + "properties": { + "mime": { + "type": "string", + "maxLength": 200 + }, + "text": { + "type": "string", + "maxLength": 100000 + }, + "name": { + "type": "string", + "maxLength": 500 + }, + "message": { + "type": "string", + "maxLength": 10000 + }, + "stack": { + "type": "string", + "maxLength": 50000 + }, + "omitted": { + "type": "boolean" + }, + "truncated": { + "type": "boolean" + } + }, + "required": ["mime"], + "additionalProperties": false + }, + "NotebookCell": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "kind": { + "type": "string", + "enum": ["code", "markdown"] + }, + "language": { + "type": "string", + "maxLength": 200 + }, + "source": { + "type": "string", + "maxLength": 200000 + }, + "execution": { + "type": "object", + "properties": { + "order": { + "type": "integer", + "minimum": 0 + }, + "success": { + "type": "boolean" + }, + "started": { + "type": "integer", + "minimum": 0 + }, + "ended": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotebookOutput" + }, + "maxItems": 100 + } + }, + "required": ["index", "kind", "language", "source"], + "additionalProperties": false + }, + "NotebookReadResult": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["read"] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "requestPath": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" + }, + "cells": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotebookCell" + }, + "maxItems": 2000 + }, + "truncated": { + "type": "boolean" + } + }, + "required": ["operation", "path", "requestPath", "revision", "cells"], + "additionalProperties": false + }, + "NotebookEditResult": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["edit"] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "requestPath": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "action": { + "type": "string", + "enum": ["insert", "replace", "delete"] + }, + "cell": { + "$ref": "#/components/schemas/NotebookCell" + } + }, + "required": ["operation", "path", "requestPath", "revision", "index", "action"], + "additionalProperties": false + }, + "NotebookExecuteResult": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["execute"] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "requestPath": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "status": { + "type": "string", + "enum": ["success", "error"] + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotebookOutput" + }, + "maxItems": 100 + }, + "truncated": { + "type": "boolean" + } + }, + "required": ["operation", "path", "requestPath", "revision", "index", "status", "outputs"], + "additionalProperties": false + }, + "NotebookResult": { + "anyOf": [ + { + "$ref": "#/components/schemas/NotebookReadResult" + }, + { + "$ref": "#/components/schemas/NotebookEditResult" + }, + { + "$ref": "#/components/schemas/NotebookExecuteResult" + } + ] + }, + "NotebookFailure": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "cancelled", + "closed", + "disconnected", + "execution_failed", + "invalid_cell", + "invalid_path", + "no_kernel", + "not_found", + "stale_revision", + "timeout", + "unsupported" + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 10000 + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based cell index" + }, + "currentRevision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque notebook content revision; pass it back unchanged and do not parse or increment it" + } + }, + "required": ["code", "message"], + "additionalProperties": false + }, "KilocodeSessionImportResult": { "type": "object", "properties": { @@ -24338,6 +25175,16 @@ } ] }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, "time": { "type": "object", "properties": { @@ -24561,7 +25408,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -24793,7 +25640,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "snapshot": { @@ -25888,6 +26735,55 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventKilocodeNotebookRequested": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.notebook.requested"] + }, + "properties": { + "$ref": "#/components/schemas/NotebookRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventKilocodeNotebookCancelled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["kilocode.notebook.cancelled"] + }, + "properties": { + "type": "object", + "properties": { + "requestID": { + "$ref": "#/components/schemas/NotebookRequestID" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "reason": { + "type": "string", + "enum": ["cancelled", "disposed", "timeout"] + } + }, + "required": ["requestID", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventIndexingStatus": { "type": "object", "properties": { @@ -27394,7 +28290,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -27657,7 +28553,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "snapshot": { @@ -28473,6 +29369,30 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventPluginAdded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "ModelV2Info": { "type": "object", "properties": { @@ -28990,6 +29910,27 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "PolicyEffect": { + "type": "string", + "enum": ["allow", "deny"] + }, + "ConfigV2ExperimentalPolicy": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["provider.use"] + }, + "effect": { + "$ref": "#/components/schemas/PolicyEffect" + }, + "resource": { + "type": "string" + } + }, + "required": ["action", "effect", "resource"], + "additionalProperties": false + }, "SessionInfo": { "type": "object", "properties": { @@ -29027,7 +29968,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "cost": { @@ -29155,7 +30096,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false } }, @@ -29534,7 +30475,7 @@ "type": "string" } }, - "required": ["id", "providerID", "variant"], + "required": ["id", "providerID"], "additionalProperties": false }, "content": {