diff --git a/.changeset/agent-manager-modal-mode-shortcut.md b/.changeset/agent-manager-modal-mode-shortcut.md deleted file mode 100644 index 4efdaa9bc43..00000000000 --- a/.changeset/agent-manager-modal-mode-shortcut.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix Agent Manager mode shortcuts in the New Worktree dialog so the selected mode and its matching model stay in sync. diff --git a/.changeset/bright-files-count.md b/.changeset/bright-files-count.md deleted file mode 100644 index 84e4aa4fa2b..00000000000 --- a/.changeset/bright-files-count.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show aggregate added and removed line counts for multi-file patch tool calls. diff --git a/.changeset/bright-project-dnd.md b/.changeset/bright-project-dnd.md deleted file mode 100644 index 750341b6225..00000000000 --- a/.changeset/bright-project-dnd.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Restore Agent Manager sections and worktree drag-and-drop when multiple projects are shown, with ordering and section moves scoped to the owning project. diff --git a/.changeset/calm-location-watchers.md b/.changeset/calm-location-watchers.md new file mode 100644 index 00000000000..cb9c384b6ee --- /dev/null +++ b/.changeset/calm-location-watchers.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prevent VS Code sessions and Agent Manager worktrees from starting unused file watchers and defer file indexing until search is used. diff --git a/.changeset/compaction-threshold-boundary.md b/.changeset/compaction-threshold-boundary.md deleted file mode 100644 index 2e610dbefb0..00000000000 --- a/.changeset/compaction-threshold-boundary.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Prevent configured compaction thresholds from interrupting active tool sequences. diff --git a/.changeset/explain-tool-auto-approval-tui.md b/.changeset/explain-tool-auto-approval-tui.md deleted file mode 100644 index df3e2f5cd03..00000000000 --- a/.changeset/explain-tool-auto-approval-tui.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -Show why a tool call was auto-approved or denied in the TUI, and record the denial reason on the tool call metadata (visible in `kilo export`) alongside the existing auto-approval reason. diff --git a/.changeset/fair-subagents-route.md b/.changeset/fair-subagents-route.md deleted file mode 100644 index a1d2775fc90..00000000000 --- a/.changeset/fair-subagents-route.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Keep config-defined subagents routable when an installed primary agent uses the same name. diff --git a/.changeset/fast-local-recall.md b/.changeset/fast-local-recall.md deleted file mode 100644 index a2cc81e647c..00000000000 --- a/.changeset/fast-local-recall.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Speed up local session recall searches across large conversation histories. diff --git a/.changeset/faster-macos-speech.md b/.changeset/faster-macos-speech.md deleted file mode 100644 index 2d9bd9cc258..00000000000 --- a/.changeset/faster-macos-speech.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Start voice input faster on macOS with native AVFoundation capture. diff --git a/.changeset/fix-agent-manager-focus.md b/.changeset/fix-agent-manager-focus.md deleted file mode 100644 index f8d5d6c65e3..00000000000 --- a/.changeset/fix-agent-manager-focus.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Restore keyboard focus to the prompt or pending question when switching Agent Manager worktrees and sessions. diff --git a/.changeset/fix-multi-project-navigation.md b/.changeset/fix-multi-project-navigation.md new file mode 100644 index 00000000000..4eddfc5eb17 --- /dev/null +++ b/.changeset/fix-multi-project-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix multi-project Agent Manager keyboard navigation and Cmd/Ctrl shortcut selection when worktrees are grouped in sections. diff --git a/.changeset/fix-multi-project-progress.md b/.changeset/fix-multi-project-progress.md new file mode 100644 index 00000000000..53ccaf57301 --- /dev/null +++ b/.changeset/fix-multi-project-progress.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Agent Manager progress indicators when multiple projects are expanded. diff --git a/.changeset/fix-multi-project-session-scope.md b/.changeset/fix-multi-project-session-scope.md new file mode 100644 index 00000000000..d56c4c93018 --- /dev/null +++ b/.changeset/fix-multi-project-session-scope.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Scope Agent Manager session events and Git status to the active project, including edits inside nested repositories. diff --git a/.changeset/grep-signal-controls.md b/.changeset/grep-signal-controls.md deleted file mode 100644 index 917e6ed2f59..00000000000 --- a/.changeset/grep-signal-controls.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Add bounded, context-aware signal-to-noise controls to grep searches. diff --git a/.changeset/instant-worktree-hover.md b/.changeset/instant-worktree-hover.md deleted file mode 100644 index e95e1af87ba..00000000000 --- a/.changeset/instant-worktree-hover.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show and hide Agent Manager worktree hover cards instantly. diff --git a/.changeset/jetbrains-attachment-button-sync.md b/.changeset/jetbrains-attachment-button-sync.md deleted file mode 100644 index dc86abba24e..00000000000 --- a/.changeset/jetbrains-attachment-button-sync.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Keep the JetBrains prompt send/stop button in sync when attachments are added or removed while a session is busy. diff --git a/.changeset/jetbrains-cli-checksums.md b/.changeset/jetbrains-cli-checksums.md new file mode 100644 index 00000000000..186ae55675e --- /dev/null +++ b/.changeset/jetbrains-cli-checksums.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Avoid GitHub API rate-limit failures when the JetBrains plugin downloads the pinned Kilo CLI. diff --git a/.changeset/jetbrains-diff-view-fixes.md b/.changeset/jetbrains-diff-view-fixes.md deleted file mode 100644 index 9fc6246c69b..00000000000 --- a/.changeset/jetbrains-diff-view-fixes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Fix JetBrains diff views to show compact workspace-relative file paths and keep added-file content visible in large branch diffs. diff --git a/.changeset/jetbrains-file-drop-references.md b/.changeset/jetbrains-file-drop-references.md new file mode 100644 index 00000000000..e801c8ba498 --- /dev/null +++ b/.changeset/jetbrains-file-drop-references.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix dropping files into the JetBrains prompt so code files are added as readable file references and drops anywhere in the session panel feed the prompt attachments. diff --git a/.changeset/jetbrains-session-load-crop.md b/.changeset/jetbrains-session-load-crop.md deleted file mode 100644 index 4e515cb3041..00000000000 --- a/.changeset/jetbrains-session-load-crop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Fix JetBrains chat transcripts rendering cropped when opening existing sessions. diff --git a/.changeset/persist-project-accordion.md b/.changeset/persist-project-accordion.md new file mode 100644 index 00000000000..6fda8a714a6 --- /dev/null +++ b/.changeset/persist-project-accordion.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Allow Agent Manager project headers to collapse or expand their project body, and persist that state across panel opens and VS Code restarts. diff --git a/.changeset/project-local-navigation-hints.md b/.changeset/project-local-navigation-hints.md new file mode 100644 index 00000000000..f6acca80ecd --- /dev/null +++ b/.changeset/project-local-navigation-hints.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show previous and next navigation hints using each project's own Agent Manager sidebar order. diff --git a/.changeset/project-row-isolation.md b/.changeset/project-row-isolation.md new file mode 100644 index 00000000000..fe1129c2cc6 --- /dev/null +++ b/.changeset/project-row-isolation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager worktree rows isolated when projects contain identical raw worktree IDs. diff --git a/.changeset/prompt-training-indicator-height.md b/.changeset/prompt-training-indicator-height.md deleted file mode 100644 index e1842cbfef4..00000000000 --- a/.changeset/prompt-training-indicator-height.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep the prompt controls at a consistent height when the model selector shows the prompt-training indicator. diff --git a/.changeset/quick-model-search.md b/.changeset/quick-model-search.md deleted file mode 100644 index 5b01ee44f97..00000000000 --- a/.changeset/quick-model-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Update model search results instantly and keep the active match visible while typing. diff --git a/.changeset/quiet-agent-sections.md b/.changeset/quiet-agent-sections.md deleted file mode 100644 index b6d8d079c04..00000000000 --- a/.changeset/quiet-agent-sections.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Allow Agent Manager sessions to move their worktree between sections or ungroup it through the `agent_manager` tool. diff --git a/.changeset/quiet-metadata-generators.md b/.changeset/quiet-metadata-generators.md deleted file mode 100644 index 67bb87bcbd9..00000000000 --- a/.changeset/quiet-metadata-generators.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Keep Kilo's persona out of generated conversation titles and Agent Manager branch names. diff --git a/.changeset/quiet-speech-shortcut.md b/.changeset/quiet-speech-shortcut.md deleted file mode 100644 index aa18416e3b6..00000000000 --- a/.changeset/quiet-speech-shortcut.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Bind voice input to Cmd/Ctrl+K in Kilo prompt and review comment fields, with hold-to-talk and release-to-send support. diff --git a/.changeset/quiet-terminals-focus.md b/.changeset/quiet-terminals-focus.md deleted file mode 100644 index 22715b263ae..00000000000 --- a/.changeset/quiet-terminals-focus.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Preserve prompt or Agent Manager terminal focus independently for each session when switching sessions. diff --git a/.changeset/quiet-terminals-recover.md b/.changeset/quiet-terminals-recover.md deleted file mode 100644 index 99c87f0de2f..00000000000 --- a/.changeset/quiet-terminals-recover.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Start a fresh shell in the same Agent Manager terminal tab when the user types after the terminal ends. diff --git a/.changeset/remove-project-selection-indicator.md b/.changeset/remove-project-selection-indicator.md new file mode 100644 index 00000000000..50aee2c720f --- /dev/null +++ b/.changeset/remove-project-selection-indicator.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remove the redundant selected-project indicator from Agent Manager. diff --git a/.changeset/session-resume-import.md b/.changeset/session-resume-import.md new file mode 100644 index 00000000000..5243f666e01 --- /dev/null +++ b/.changeset/session-resume-import.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Import conversation history from Claude Code and OpenAI Codex sessions with the /resume-claude and /resume-codex slash commands. diff --git a/.changeset/single-agent-manager-empty-state.md b/.changeset/single-agent-manager-empty-state.md new file mode 100644 index 00000000000..f3d9d0d35f6 --- /dev/null +++ b/.changeset/single-agent-manager-empty-state.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show only one empty-state panel after removing the last Agent Manager worktree. diff --git a/.changeset/skill-shell-inline-docs.md b/.changeset/skill-shell-inline-docs.md deleted file mode 100644 index a0e1e864dfb..00000000000 --- a/.changeset/skill-shell-inline-docs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Stop treating `` !`cmd` `` shown as an inline code example in skill documentation as a live command, so it no longer triggers a shell permission prompt. diff --git a/.changeset/sync-inspector-width.md b/.changeset/sync-inspector-width.md new file mode 100644 index 00000000000..02eeb4e7239 --- /dev/null +++ b/.changeset/sync-inspector-width.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Persist the Agent Manager inspector width and share it between the terminal and diff viewer. diff --git a/.changeset/tui-paste-expand-placeholder.md b/.changeset/tui-paste-expand-placeholder.md deleted file mode 100644 index 07b963ca0bb..00000000000 --- a/.changeset/tui-paste-expand-placeholder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -A second identical large paste expands its collapsed prompt placeholder. \ No newline at end of file diff --git a/.changeset/vscode-skills-settings-narrow-overflow.md b/.changeset/vscode-skills-settings-narrow-overflow.md deleted file mode 100644 index bbdb87be496..00000000000 --- a/.changeset/vscode-skills-settings-narrow-overflow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix skill folder path and URL rows clipping and pushing the remove (×) button off-screen in narrow Skills settings panels. Long paths and URLs now truncate within their row, and hovering a truncated value shows the full path or URL in a tooltip. diff --git a/.changeset/workflow-model-variants.md b/.changeset/workflow-model-variants.md deleted file mode 100644 index 48b090358ce..00000000000 --- a/.changeset/workflow-model-variants.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": minor -"kilo-code": minor ---- - -Configure a model and reasoning variant for each workflow from Agent Behaviour settings. diff --git a/.changeset/worktree-rename-focus.md b/.changeset/worktree-rename-focus.md new file mode 100644 index 00000000000..fef1fe11e23 --- /dev/null +++ b/.changeset/worktree-rename-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep multi-project worktree rename inputs focused while selection updates are still settling. diff --git a/.github/docs-sync/edit-prompt.md b/.github/docs-sync/edit-prompt.md index c9a2b86abed..0a3b7a98e1d 100644 --- a/.github/docs-sync/edit-prompt.md +++ b/.github/docs-sync/edit-prompt.md @@ -21,6 +21,7 @@ Hard rules: - Never remove or rename pages. Never document unreleased behavior. Never copy internal PR discussion into the docs; write user-facing documentation. - Do not run git commands and do not commit anything; automation handles git. - Keep the change small and precise. Do not rewrite sections that are already accurate. +- Never create, modify, or delete packages/kilo-docs/LEARNINGS.md. Automation owns that file. When finished, write the summary JSON file named in the batch specifics below: a JSON array with exactly one entry per batch PR, consumed by automation (this file is never committed). Use `action` values like `updated `, `created `, or `skipped`. Example: diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 167656eda35..15b89734edc 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -19,6 +19,7 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" +import { readLearningsBlock } from "./learn.mjs" const BATCH_SIZE = 5 const ATTEMPTS = 3 @@ -26,7 +27,7 @@ const OUT_DIR = "docs-sync-out" export const SUMMARY_FILE = ".docs-sync-summary.json" const HERE = path.dirname(fileURLToPath(import.meta.url)) -const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") +const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") + readLearningsBlock("edit") const model = process.env.EDIT_MODEL if (!model) throw new Error("EDIT_MODEL is required") @@ -58,14 +59,7 @@ function editBatch(batch, index, budgetDeadline) { const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json` const summaryFile = `${OUT_DIR}/edit-summary-${index}.json` fs.writeFileSync(batchFile, JSON.stringify(batch, null, 2)) - fs.writeFileSync( - triageFile, - JSON.stringify( - batch.map((d) => priority.get(d.url)).filter(Boolean), - null, - 2, - ), - ) + fs.writeFileSync(triageFile, JSON.stringify(batch.map((d) => priority.get(d.url)).filter(Boolean), null, 2)) const prompt = `${basePrompt} @@ -88,7 +82,21 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} // permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the // required shell patterns are stable (see PR #12605 review thread). const result = runKilo({ - args: ["run", "--auto", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], + args: [ + "run", + "--auto", + prompt, + "-m", + model, + "--variant", + "high", + "--dir", + process.cwd(), + "-f", + batchFile, + "-f", + triageFile, + ], timeoutMs: Math.min(BATCH_TIMEOUT_MS, left), streamStdout: true, label: `edit batch ${index} attempt ${attempt}`, @@ -120,9 +128,7 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`) sleepSync(wait) } else if (wait > 0) { - console.warn( - `batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`, - ) + console.warn(`batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`) } } } diff --git a/.github/docs-sync/learn.mjs b/.github/docs-sync/learn.mjs new file mode 100644 index 00000000000..6baa59124e2 --- /dev/null +++ b/.github/docs-sync/learn.mjs @@ -0,0 +1,937 @@ +// kilocode_change - new file + +/** + * Learns general rules of thumb from maintainer corrections to the docs-sync + * bot's rolling pull request, and writes them into packages/kilo-docs/LEARNINGS.md + * so the triage and edit passes follow them on every subsequent run. + * + * Two modes: + * node learn.mjs — extraction: fetch corrections, call the model, validate + * node learn.mjs --apply — apply: write learnings.json into LEARNINGS.md + * + * Env: TRIAGE_MODEL (provider/model, reused), GH_TOKEN (or GITHUB_TOKEN). + * Budget: LEARNINGS_BUDGET_MINUTES (default 10). + * Test hook: DOCS_SYNC_FIXTURE. When set to a fixture JSON path, skips every + * GitHub API call and writes any marker PATCH to .patched instead of + * the network. The workflow never sets it — only selftests do. + * + * Test hook: DOCS_SYNC_BACKOFF_MS replaces wait between extraction retries, same as + * lib.mjs:138 documents for triage.mjs and edit.mjs. + * + * Patch suppression: DRY_RUN=true or LEARNINGS_NO_PATCH=1 suppress the marker PATCH. + */ + +import { execFileSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const LEARNINGS_FILE = "packages/kilo-docs/LEARNINGS.md" +const OUT_DIR = "docs-sync-out" +const ATTEMPTS = 2 +const LEARNINGS_BUDGET_MINUTES = Number(process.env.LEARNINGS_BUDGET_MINUTES) || 10 +const EXTRACTION_TIMEOUT_MS = LEARNINGS_BUDGET_MINUTES * 60 * 1000 +const COMMENT_BODY_CAP = 5000 + +const HERE = path.dirname(fileURLToPath(import.meta.url)) + +const LINE_RE = + /^- (?.+?) $/ + +const LEARNED_THROUGH_RE = // + +// Agent-generated strings land in the PR body next to machine-read markers. +// Identical to clean() at upsert-pr.mjs:37. +function clean(value) { + return String(value ?? "") + .replaceAll("", "") +} + +function warn(msg) { + console.warn(`::warning::${msg}`) +} + +function log(msg) { + console.log(msg) +} + +// --- pure exports --- + +/** + * Parse the LEARNINGS.md file text into an entry array. + * Drops lines inside the markers that do not match the format. + */ +export function parseLearnings(text) { + const m = String(text ?? "").match( + /([\s\S]*?)/, + ) + if (!m) return [] + const entries = [] + for (const line of m[1].split("\n")) { + const trimmed = line.trim() + if (!trimmed) continue + const parsed = trimmed.match(LINE_RE) + if (!parsed) { + warn(`LEARNINGS.md: dropping unparseable line: ${trimmed.slice(0, 80)}`) + continue + } + entries.push({ + id: parsed.groups.id, + rule: clean(parsed.groups.rule).replaceAll("\n", " "), + scope: parsed.groups.scope, + source: parsed.groups.source, + date: parsed.groups.date, + }) + } + return entries +} + +/** Render the full LEARNINGS.md file text from an entry array. Deterministic order. */ +export function renderLearnings(entries) { + const list = [...entries].sort((a, b) => { + if (a.date !== b.date) return a.date < b.date ? -1 : 1 + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + }) + const lines = list.map( + (e) => + `- ${clean(e.rule).replaceAll("\n", " ")} `, + ) + return [ + "# docs-sync learnings", + "", + "Rules the docs-sync bot learned from maintainer corrections to its rolling pull request.", + "The bot reads this file at the start of every run and follows every rule below.", + "", + "To unlearn a rule, delete its line and commit. The next run reads this file from the", + "branch, so the rule is gone from its input, and the deletion itself is a correction the", + "extraction step is instructed not to undo.", + "", + "", + ...lines, + "", + "", + ].join("\n") +} + +/** Parse the learned-through watermark from a PR body. Returns { commit, comment } with nulls for absent/none. */ +export function parseLearnedThrough(body) { + const m = String(body ?? "").match(LEARNED_THROUGH_RE) + if (!m) return { commit: null, comment: null } + const commit = m[1] === "none" ? null : m[1] + const comment = m[2] === "none" ? null : m[2] + return { commit, comment } +} + +/** Render a single learned-through marker line. */ +export function renderLearnedThrough({ commit, comment }) { + const c = commit ?? "none" + const m = comment ?? "none" + return `` +} + +/** Replace or append the learned-through marker in a PR body. Pure — no API call. */ +export function patchMarkerIntoBody(body, marker) { + const b = String(body ?? "") + if (LEARNED_THROUGH_RE.test(b)) { + return b.replace(LEARNED_THROUGH_RE, marker) + } + return b + "\n" + marker + "\n" +} + +/** + * Extract { add, remove } from raw model stdout. + * Mirrors parseTriageEntries at extract-json.mjs:14-38, adapted for an object. + * `kilo run` prints the assistant message twice; the last copy wins. + * Walk "{" positions from right to left; return the first that parses to an object + * holding an array `add` or an array `remove`. + */ +export function parseDelta(raw) { + const r = String(raw ?? "") + const end = r.lastIndexOf("}") + if (end < 0) return null + + const starts = [] + for (let i = 0; i <= end; i++) { + if (r[i] === "{") starts.push(i) + } + + for (let s = starts.length - 1; s >= 0; s--) { + let parsed + try { + parsed = JSON.parse(r.slice(starts[s], end + 1)) + } catch { + continue + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue + if (Array.isArray(parsed.add) || Array.isArray(parsed.remove)) { + return { + add: Array.isArray(parsed.add) ? parsed.add : [], + remove: Array.isArray(parsed.remove) ? parsed.remove : [], + } + } + } + return null +} + +/** Cap a review comment body so a single long comment cannot dominate extraction input. */ +function capBody(body) { + const b = String(body ?? "") + if (b.length <= COMMENT_BODY_CAP) return b + return b.slice(0, COMMENT_BODY_CAP) + " [truncated]" +} + +/** Normalize rule text for duplicate comparison: lowercase, strip punctuation and whitespace runs. */ +function norm(text) { + return String(text ?? "") + .toLowerCase() + .replace(/[^\w\s]/g, "") + .replace(/\s+/g, " ") + .trim() +} + +/** + * Validate a delta against the existing entries and constraints. + * Returns { add, remove, rejected }. Never throws. + */ +export function validateDelta(delta, { existing, candidateSources, deletedInWindow }) { + const add = Array.isArray(delta.add) ? delta.add : [] + const remove = Array.isArray(delta.remove) ? delta.remove : [] + const ex = Array.isArray(existing) ? existing : [] + const candidates = Array.isArray(candidateSources) ? candidateSources : [] + const deleted = Array.isArray(deletedInWindow) ? deletedInWindow : [] + + const rejected = [] + const valid = [] + const toRemove = [] + const existingIds = new Set(ex.map((e) => e.id)) + // One model response can repeat an id or a rule. Both would render two lines for + // one id, so an accepted addition also blocks the next one. + const acceptedIds = new Set() + const acceptedRules = new Set() + + // Process remove first so toRemove is populated before the add loop checks + // for id collisions with entries listed in remove (criterion 8). + for (const id of remove) { + if (!existingIds.has(id)) { + rejected.push({ entry: { id, remove: id }, reason: `remove target ${id} not in existing entries` }) + } else { + toRemove.push(id) + } + } + + for (const a of add) { + let reason = null + + // Reject null, undefined, and non-object entries before any property access. + if (a === null || a === undefined || typeof a !== "object" || Array.isArray(a)) { + rejected.push({ entry: a, reason: "add entry is null, undefined, or not a plain object" }) + continue + } + + if (!a.rule || String(a.rule).length < 10 || String(a.rule).length > 300) { + reason = "rule text absent, shorter than 10 characters, or longer than 300" + } else if (!["triage", "edit", "both"].includes(a.scope)) { + reason = `invalid scope: ${a.scope}` + } else if (!/^commit:[0-9a-f]{7,40}$/.test(a.source) && !/^comment:\d+$/.test(a.source)) { + reason = `invalid source format: ${a.source}` + } else if (!candidates.includes(a.source)) { + reason = `source ${a.source} not in candidate sources` + } else if (!/^[a-z0-9][a-z0-9-]{2,48}$/.test(a.id)) { + reason = `invalid id format: ${a.id}` + } else if (existingIds.has(a.id) && !toRemove.includes(a.id)) { + reason = `id ${a.id} collides with an existing entry not listed in remove` + } else if (acceptedIds.has(a.id)) { + reason = `id ${a.id} collides with an earlier addition in this delta` + } else if (!/^\d{4}-\d{2}-\d{2}$/.test(a.date)) { + reason = `invalid date format: ${a.date}` + } else { + // Check that date is a real calendar date. + const d = new Date(a.date + "T00:00:00Z") + if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== a.date) { + reason = `invalid calendar date: ${a.date}` + } + } + + if (reason) { + rejected.push({ entry: a, reason }) + continue + } + + const n = norm(a.rule) + + // Duplicate of an existing entry not being removed. + if (ex.some((e) => norm(e.rule) === n && !remove.includes(e.id))) { + reason = `rule text is a duplicate of an existing entry not listed in remove` + rejected.push({ entry: a, reason }) + continue + } + + // Duplicate of an earlier addition in the same response. + if (acceptedRules.has(n)) { + reason = `rule text is a duplicate of an earlier addition in this delta` + rejected.push({ entry: a, reason }) + continue + } + + // Names a PR, URL, person, or docs page. The URL clause keeps docs-check-links.yml green. + if (String(a.rule).match(/#\d{2,}|https?:\/\/|@[A-Za-z0-9-]|packages\/kilo-docs|\.md\b/)) { + reason = "rule names a PR, URL, person, or docs page" + rejected.push({ entry: a, reason }) + continue + } + + // Duplicate of a rule deleted in this window. + if (deleted.some((d) => norm(d) === n)) { + reason = "rule text matches a line a maintainer deleted in this window" + rejected.push({ entry: a, reason }) + continue + } + + acceptedIds.add(a.id) + acceptedRules.add(n) + valid.push({ + id: a.id, + rule: clean(String(a.rule)).replaceAll("\n", " "), + scope: a.scope, + source: a.source, + date: a.date, + }) + } + + return { add: valid, remove: toRemove, rejected } +} + +/** Apply a validated delta to an existing entry array. Drops removed ids, appends adds. */ +export function applyDelta(existing, delta) { + const ex = Array.isArray(existing) ? existing : [] + const remove = new Set(Array.isArray(delta.remove) ? delta.remove : []) + const add = Array.isArray(delta.add) ? delta.add : [] + return [...ex.filter((e) => !remove.has(e.id)), ...add] +} + +/** Trust a review comment whose author_association is OWNER, MEMBER, or COLLABORATOR and is not a bot. */ +export function isTrustedComment(comment) { + if (!comment) return false + const login = String(comment.user?.login ?? "") + if (login.endsWith("[bot]")) return false + return ["OWNER", "MEMBER", "COLLABORATOR"].includes(comment.author_association) +} + +/** Render the prompt block for a given scope. Returns "" when no entry matches. */ +export function promptBlock(entries, scope) { + const matches = (Array.isArray(entries) ? entries : []).filter((e) => e.scope === scope || e.scope === "both") + if (matches.length === 0) return "" + return [ + "## Learnings from maintainer corrections", + "", + "Follow every rule below. Each was extracted from a correction a maintainer made to an", + "earlier run of this bot. A rule here outranks a general instruction above when they conflict.", + "", + ...matches.map((e) => `- ${e.rule}`), + ].join("\n") +} + +/** Read a prompt block artifact from docs-sync-out. Returns the content or "" when absent. */ +export function readLearningsBlock(scope) { + const file = `${OUT_DIR}/learnings-${scope}.md` + try { + return fs.readFileSync(file, "utf8") + } catch { + return "" + } +} + +// --- helpers for main --- + +function git(args) { + return execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }) + .toString() + .trim() +} + +// --- main --- + +async function main() { + // Step 0: ensure docs-sync-out exists. collect.mjs:139 is the only other unconditional + // mkdirSync of this directory, and it runs after the learn step. Without this line the + // empty-candidate path throws ENOENT on its first write, continue-on-error swallows it, + // and the feature silently never works. + fs.mkdirSync(OUT_DIR, { recursive: true }) + + if (process.argv.includes("--apply")) { + await apply() + return + } + + await extract() +} + +// --- apply mode --- + +async function apply() { + const learningsPath = `${OUT_DIR}/learnings.json` + if (!fs.existsSync(learningsPath)) { + log("learnings.json absent — extraction was skipped or failed; nothing to apply") + return + } + const entries = JSON.parse(fs.readFileSync(learningsPath, "utf8")) + const file = renderLearnings(entries) + fs.writeFileSync(LEARNINGS_FILE, file) + log(`wrote ${LEARNINGS_FILE} with ${entries.length} entries`) +} + +// --- extraction mode --- + +async function extract() { + // Step 0: seed the prompt artifacts from the checked-out file before any fallible + // work. Every later step can throw, the workflow step is continue-on-error, and + // triage and edit read only these two files. Without the seed one failed API call + // silently drops every learned rule for the whole run. Later steps replace them + // with the rolling-branch copy and then with the validated delta. + writePromptArtifacts(parseLearnings(readFileOrEmpty(LEARNINGS_FILE))) + + // Load fixture when DOCS_SYNC_FIXTURE is set. + const fixturePath = process.env.DOCS_SYNC_FIXTURE + let fixture = null + let patchFile = null + if (fixturePath) { + fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) + patchFile = fixturePath + ".patched" + } + + const { api, repo, searchIssues, appendOutput, appendSummary, backoffMsForAttempt, runKilo, sleepSync } = + await import("./lib.mjs") + + let prData + let prBody = "" + let prNumber = "" + let branch = "" + + if (fixture) { + // Fixture mode: skip all API calls. + prData = fixture.pr + prBody = prData.body ?? "" + prNumber = String(prData.number ?? 1) + branch = prData.head?.ref ?? "docs/auto-sync" + } else { + // Step 1: resolve the rolling PR. Use prepare-branch.mjs's selection rule so both + // target the same branch. searchIssues takes prs[0] with no author filter (like + // prepare-branch.mjs:69). But trust the body marker only when authored by + // github-actions[bot] (like watermark.mjs:35). The two rules differ on purpose: + // the branch must match what prepare-branch.mjs will check out, but a body is + // editable so its marker needs the author filter. + const r = repo() + const prs = await searchIssues(`repo:${r} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) + if (prs.length === 0) { + log("no open rolling pull request — nothing to learn from") + + // Read existing learnings from main for empty-state artifacts. + let existing = [] + try { + const existingText = git(["show", `origin/main:${LEARNINGS_FILE}`]) + existing = parseLearnings(existingText) + } catch { + existing = [] + } + log(`no-PR existing entries from main: ${existing.length}`) + writeEmptyStateArtifacts(existing) + appendOutput("count", String(existing.length)) + appendSummary("### docs-sync learnings\n\nNo open auto-docs pull request; extraction skipped.") + return + } + prData = await api(`/repos/${r}/pulls/${prs[0].number}`) + prBody = prData.body ?? "" + prNumber = String(prData.number) + branch = prData.head?.ref ?? "docs/auto-sync" + } + + // Step 2: read existing entries. + let existing = [] + let existingText = "" + if (fixture) { + existingText = readFileOrEmpty(LEARNINGS_FILE) + existing = parseLearnings(existingText) + } else { + try { + existingText = git(["show", `origin/${branch}:${LEARNINGS_FILE}`]) + } catch { + // branch copy absent — fall back to main, then empty. + // Required for the first live run: the rolling branch predates the seeded file. + try { + existingText = git(["show", `origin/main:${LEARNINGS_FILE}`]) + } catch { + existingText = "" + } + } + existing = parseLearnings(existingText) + } + log(`existing entries: ${existing.length}`) + + // Replace the seed with the rolling-branch copy. Every step below can throw, and + // these two files are all triage and edit read. + writePromptArtifacts(existing) + + // Step 3: parse marker. Trust only when authored by github-actions[bot] (like watermark.mjs:35). + let commitWm = null + let commentWm = null + const trusted = prData.user?.login === "github-actions[bot]" + if (trusted) { + ;({ commit: commitWm, comment: commentWm } = parseLearnedThrough(prBody)) + } else { + log("PR author is not github-actions[bot]; ignoring body marker") + } + log(`watermark: commit=${commitWm ?? "none"} comment=${commentWm ?? "none"}`) + + // Step 4: fetch and tip SHA. + let tipSha + if (fixture) { + tipSha = git(["rev-parse", "HEAD"]) + } else { + git(["fetch", "origin", "main", branch]) + tipSha = git(["rev-parse", `origin/${branch}`]) + } + + // Step 5: candidate commits. + let rangeArgs = [`origin/main..origin/${branch}`] + if (fixture) { + // In fixture mode, work from the local repo state. + try { + git(["rev-parse", "--verify", branch]) + rangeArgs = [`origin/main..${branch}`] + } catch { + rangeArgs = [`origin/main..HEAD`] + } + } + + if (commitWm) { + let wmExists = false + try { + git(["cat-file", "-e", `${commitWm}^{commit}`]) + wmExists = true + } catch { + wmExists = false + } + if (wmExists) { + rangeArgs.push(`^${commitWm}`) + } + // A missing watermark commit (force-push, rebase) drops the exclusion. + // The duplicate-rule-text rejection in validateDelta blocks the re-added duplicate. + } + + const logOut = git(["log", "--no-merges", "--format=%H|%ae|%cI|%s", ...rangeArgs]) + const rawCommits = logOut ? logOut.split("\n").filter(Boolean) : [] + + const botEmail = "41898282+github-actions[bot]@users.noreply.github.com" + const candidates = [] + const candidateSources = [] + const deletedInWindow = [] + + for (const line of rawCommits) { + const [sha, email, dateIso] = line.split("|") + // Drop commits authored by the sync job itself (criterion 5). + if (email === botEmail) continue + // Everything reachable from main is already excluded by the range (criterion 6). + + // Get the full file list. + let files = [] + try { + const out = git(["show", "--name-only", "--format=", sha]) + files = out + ? out + .split("\n") + .filter(Boolean) + .filter((f) => f) + : [] + } catch { + continue + } + + // Get the docs-scoped diff and message. + let message = "" + let docDiff = "" + try { + message = git(["show", "--format=%B", "--no-patch", sha]).trim() + docDiff = git(["show", "--format=", sha, "--", "packages/kilo-docs"]) + // Cap diff sizes. + if (docDiff.length > 20000) docDiff = docDiff.slice(0, 20000) + "\n[truncated]" + } catch { + // skip on error + } + + // Drop commits whose docs-scoped diff is empty. + if (!docDiff.trim()) continue + + // Collect deleted rule lines from LEARNINGS.md. + for (const dl of docDiff.split("\n")) { + if (!dl.startsWith("-")) continue + const stripped = dl.slice(1).trim() + const parsed = stripped.match(LINE_RE) + if (parsed) { + deletedInWindow.push(clean(parsed.groups.rule).replaceAll("\n", " ")) + } + } + + // Cap total diff data. + const totalDiff = candidates.reduce((n, c) => n + (c.diff ? c.diff.length : 0), 0) + if (totalDiff > 120000) { + log(`diff cap reached at commit ${sha.slice(0, 7)}; truncating`) + candidates.push({ + source: `commit:${sha.slice(0, 7)}`, + iso: dateIso, + date: dateIso.slice(0, 10), + message, + files, + diff: "[truncated]", + }) + candidateSources.push(`commit:${sha.slice(0, 7)}`) + break + } + + candidates.push({ + source: `commit:${sha.slice(0, 7)}`, + iso: dateIso, + date: dateIso.slice(0, 10), + message, + files, + diff: docDiff, + }) + candidateSources.push(`commit:${sha.slice(0, 7)}`) + } + + // Step 6: candidate comments. + let allComments = [] + let maxCommentAt = "none" + + if (fixture && fixture.comments) { + allComments = fixture.comments + } else if (prNumber) { + const pages = [] + for (let page = 1; page <= 5; page++) { + const batch = await api(`/repos/${repo()}/pulls/${prNumber}/comments?per_page=100&page=${page}`) + pages.push(...batch) + if (batch.length < 100) break + } + allComments = pages + } + + if (allComments.length > 0) { + let max = "" + for (const c of allComments) { + if (c.created_at && c.created_at > max) max = c.created_at + } + maxCommentAt = max || "none" + } + + // Filter trusted comments. + const trustedComments = allComments.filter((c) => { + if (!isTrustedComment(c)) return false + if (commentWm && c.created_at <= commentWm) return false + return true + }) + + // Step 7: correlate comments to commits. + // A comment is a commit's trigger when c.path is in that commit's full file list + // and c.created_at < commit date. The earliest such commit claims it. + // Compare parsed timestamps so different timezone offsets do not skew the ordering. + for (const c of trustedComments) { + let best = null + const cTime = Date.parse(c.created_at) + for (const cc of candidates) { + if (!Array.isArray(cc.files) || !cc.files.includes(c.path)) continue + const ccTime = Date.parse(cc.iso) + if (cTime < ccTime) { + if (!best || ccTime < Date.parse(best.iso)) { + best = cc + } + } + } + if (best) { + best.comment = { + author_association: c.author_association, + path: c.path, + body: capBody(c.body), + } + } else { + candidates.push({ + source: `comment:${c.id}`, + date: (c.created_at ?? "").slice(0, 10), + path: c.path, + body: capBody(c.body), + author_association: c.author_association, + }) + candidateSources.push(`comment:${c.id}`) + } + } + + // Step 8: no candidates → empty delta route. + const hasCandidates = candidates.length > 0 + + if (!hasCandidates) { + log("no candidate corrections; advancing marker with no model call") + writeEmptyStateArtifacts(existing) + appendOutput("count", String(existing.length)) + appendSummary( + `### docs-sync learnings\n\nNo new candidate corrections. Entries: ${existing.length}. Marker route: empty (no candidates).`, + ) + + const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) + await patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) + return + } + + // Step 9: write learnings input. + const input = { + existing: existing.map((e) => ({ id: e.id, rule: e.rule, scope: e.scope, source: e.source, date: e.date })), + deleted_in_window: deletedInWindow, + corrections: candidates, + } + const inputFile = `${OUT_DIR}/learnings-input.json` + fs.writeFileSync(inputFile, JSON.stringify(input, null, 2)) + log(`wrote ${inputFile} with ${candidates.length} candidates`) + + // Step 10: call the model. + // Deliberately no --auto. Every input is in the attached file and the output goes to + // stdout, so the agent needs no tool. Omitting --auto makes "the extraction step never + // writes outside LEARNINGS.md" structurally true instead of prompt-deep. triage.mjs:76 + // and edit.mjs:86 carry the opposite comment; do not copy them without updating the reason. + const prompt = fs.readFileSync(path.join(HERE, "learnings-prompt.md"), "utf8") + const model = process.env.TRIAGE_MODEL + if (!model) throw new Error("TRIAGE_MODEL is required") + + const budgetDeadline = Date.now() + EXTRACTION_TIMEOUT_MS + + let raw = null + let lastCause = "extraction failed" + + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + const left = Math.max(0, budgetDeadline - Date.now()) + if (left <= 0) { + log("budget exhausted before extraction attempt") + break + } + + const result = runKilo({ + args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", inputFile], + timeoutMs: Math.min(EXTRACTION_TIMEOUT_MS, left), + streamStdout: false, + label: "learnings extraction", + }) + + if (result.stdout) { + fs.writeFileSync(`${OUT_DIR}/learnings-raw.txt`, result.stdout) + raw = result.stdout + const delta = parseDelta(raw) + if (delta) break + lastCause = `parseDelta returned null (attempt ${attempt})` + } else { + lastCause = result.timedOut ? "timed out" : `exit ${result.exitCode}` + } + + if (attempt < ATTEMPTS) { + const wait = backoffMsForAttempt(1) // 60s, same as the sibling convention + if (wait > 0) { + log(`backing off ${wait / 1000}s before attempt ${attempt + 1}`) + sleepSync(wait) + } + } + } + + // Step 11: parse and validate. + const delta = raw ? parseDelta(raw) : null + + if (!delta) { + // parseDelta null after every try — retryable unhappy. + warn(`extraction failed: ${lastCause}. Leaving learnings untouched.`) + writeEmptyStateArtifacts(existing) + appendOutput("count", String(existing.length)) + appendSummary( + `### docs-sync learnings\n\nExtraction failed: ${lastCause}. Entries unchanged: ${existing.length}. No marker advance.`, + ) + return + } + + const validated = validateDelta(delta, { existing, candidateSources, deletedInWindow }) + + if (validated.rejected.length > 0) { + for (const r of validated.rejected) { + warn(`rejected: ${r.reason}` + (r.entry?.id ? ` (id=${r.entry.id})` : "")) + } + } + + const nonEmpty = validated.add.length > 0 || validated.remove.length > 0 + + // Step 12: route by outcome (G5 table, exact). + if (nonEmpty) { + // Non-empty validated delta. + const newEntries = applyDelta(existing, { add: validated.add, remove: validated.remove }) + fs.writeFileSync(`${OUT_DIR}/learnings.json`, JSON.stringify(newEntries, null, 2)) + const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) + const suppressed = process.env.DRY_RUN === "true" || process.env.LEARNINGS_NO_PATCH === "1" + if (!suppressed) appendOutput("learned_through", marker) + if (suppressed) log(`learned-through output suppressed: ${marker}`) + + const added = validated.add.length + const removed = validated.remove.length + const rejected = validated.rejected.length + log(`delta: +${added} -${removed} (${rejected} rejected)`) + appendSummary( + `### docs-sync learnings\n\n- added: ${added}\n- removed: ${removed}\n- rejected: ${rejected}\n- candidates: ${candidates.length}\n- marker route: upsert (non-empty delta)\n`, + ) + + writePromptArtifacts(newEntries) + appendOutput("count", String(newEntries.length)) + + // Marker rides through LEARNED_THROUGH into upsert-pr.mjs. No direct PATCH. + } else { + // Empty validated delta (nothing added, nothing removed, including every-add-rejected). + log("empty validated delta; advancing marker directly") + fs.writeFileSync(`${OUT_DIR}/learnings.json`, JSON.stringify(existing, null, 2)) + writePromptArtifacts(existing) + appendOutput("count", String(existing.length)) + + const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) + await patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) + + const rejected = validated.rejected.length + appendSummary( + `### docs-sync learnings\n\n- added: 0\n- removed: 0\n- rejected: ${rejected}\n- candidates: ${candidates.length}\n- marker route: direct PATCH (empty delta)\n`, + ) + } +} + +// --- shared helpers --- + +function writeEmptyStateArtifacts(entries) { + fs.writeFileSync(`${OUT_DIR}/learnings.json`, JSON.stringify(entries, null, 2)) + writePromptArtifacts(entries) +} + +// A later call must be able to shrink a seeded block back to nothing, so an empty +// block removes the file instead of leaving the earlier content in place. +function writePromptArtifacts(entries) { + writeOrRemove(`${OUT_DIR}/learnings-triage.md`, promptBlock(entries, "triage")) + writeOrRemove(`${OUT_DIR}/learnings-edit.md`, promptBlock(entries, "edit")) +} + +function writeOrRemove(file, text) { + if (text) fs.writeFileSync(file, text) + else fs.rmSync(file, { force: true }) +} + +function readFileOrEmpty(file) { + try { + return fs.readFileSync(file, "utf8") + } catch { + return "" + } +} + +async function patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) { + const suppressed = process.env.DRY_RUN === "true" || process.env.LEARNINGS_NO_PATCH === "1" + + if (suppressed) { + log( + `marker PATCH suppressed (DRY_RUN=${process.env.DRY_RUN}, LEARNINGS_NO_PATCH=${process.env.LEARNINGS_NO_PATCH})`, + ) + log(`would have written marker: ${marker}`) + return + } + + if (fixture) { + // Write to the fixture patch file instead of the network. + fs.writeFileSync(patchFile, marker) + log(`wrote marker to ${patchFile}`) + return + } + + // Live PATCH: body-only, one line changed. The job already holds pull-requests: write. + // Re-read the body first. The body in hand was fetched before the extraction call, so + // patching that copy would drop any edit made in the minutes since. GitHub has no + // conditional update for a pull request body, so a short fetch-to-PATCH race remains. + const { api, repo } = await import("./lib.mjs") + let latestBody = prBody + try { + const fresh = await api(`/repos/${repo()}/pulls/${prNumber}`) + latestBody = fresh.body ?? "" + } catch (err) { + warn(`could not re-read PR #${prNumber} before the marker PATCH: ${err.message}. Using the earlier body.`) + } + const newBody = patchMarkerIntoBody(latestBody, marker) + await api(`/repos/${repo()}/pulls/${prNumber}`, { + method: "PATCH", + body: { body: newBody }, + }) + log(`PATCHed learned-through marker on PR #${prNumber}`) +} + +// --- entry point --- + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href + +// --- self-test harness (run: node .github/docs-sync/learn.mjs --self-test) --- +if (isMain && process.argv.includes("--self-test")) { + const failures = [] + const check = (label, fn) => { + try { + const ok = fn() + if (!ok) failures.push(label) + } catch (e) { + failures.push(label + " THREW: " + e.message) + } + } + + check("null in add does not throw", () => { + const r = validateDelta({ add: [null], remove: [] }, { existing: [], candidateSources: [], deletedInWindow: [] }) + return r.add.length === 0 && r.rejected.length === 1 && r.rejected[0].reason.includes("not a plain object") + }) + + check("undefined in add does not throw", () => { + const r = validateDelta( + { add: [undefined], remove: [] }, + { existing: [], candidateSources: [], deletedInWindow: [] }, + ) + return r.add.length === 0 && r.rejected.length === 1 && r.rejected[0].reason.includes("not a plain object") + }) + + check("mixed valid and null retains valid", () => { + const r = validateDelta( + { + add: [ + { + id: "valid-a", + rule: "Do not document experimental features", + scope: "both", + source: "commit:bbbbbbb", + date: "2026-08-03", + }, + null, + { + id: "valid-b", + rule: "Keep release notes concise", + scope: "edit", + source: "commit:bbbbbbb", + date: "2026-08-03", + }, + ], + remove: [], + }, + { existing: [], candidateSources: ["commit:bbbbbbb"], deletedInWindow: [] }, + ) + return r.add.length === 2 && r.rejected.length === 1 + }) + + if (failures.length) { + console.error("SELF-TEST FAILURES:", failures) + process.exit(1) + } + console.log("SELF-TEST PASSED (" + 3 + " checks)") + process.exit(0) +} + +if (isMain) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/.github/docs-sync/learnings-prompt.md b/.github/docs-sync/learnings-prompt.md new file mode 100644 index 00000000000..cde193b8d4f --- /dev/null +++ b/.github/docs-sync/learnings-prompt.md @@ -0,0 +1,84 @@ +You are the extraction pass of an automated documentation pipeline for Kilo Code. Your only job: extract general rules of thumb from maintainer corrections to the docs-sync bot's rolling pull request. A correction is a commit or review comment a maintainer made to fix something the bot got wrong, and a learning is the general principle behind it that the bot should follow from now on. + +The attached `learnings-input.json` file contains: + +- `existing`: rules the bot already knows, each with `id`, `rule`, `scope`, `source`, and `date`. +- `deleted_in_window`: rule texts (not ids) a maintainer deleted from the learnings file in this extraction window. A maintainer deleted these on purpose — do not re-add them. +- `corrections`: the maintainer corrections to learn from. Each entry has a `source` (commit or comment id), `date`, and the relevant context. Commit entries have `message`, `files`, and `diff`. Comment entries have `path` and `body`. Some commits also carry an attached inline review `comment` that triggered them. + +Before writing anything: + +1. Read every correction in `corrections` and every rule in `existing`. +2. For each correction, decide whether it implies a general rule of thumb the bot should follow. Not every correction does — returning no new rules is a valid and expected answer. +3. When a correction implies a rule, write it as one imperative sentence stating the general principle, not what the specific correction did. + +Response format: a strict JSON object with no prose, no markdown fences, no comments: + +```json +{ + "add": [ + { + "id": "kebab-case-slug", + "rule": "One imperative sentence.", + "scope": "triage|edit|both", + "source": "commit:|comment:", + "date": "" + } + ], + "remove": [] +} +``` + +- `id`: a short kebab-case slug unique across this response. +- `rule`: one general imperative sentence. Never name a pull request, a number, a URL, a docs page, a file path, or a person. State the rule the correction implies, not what the correction changed. +- `scope`: `triage` when the rule changes which pull requests deserve documentation; `edit` when it changes how a page is written; `both` when it changes both. +- `source`: copied verbatim from the correction's `source` field. Never invent one. +- `date`: the correction's date, copied verbatim. + +The `remove` array lists `id` values of existing entries to drop. Remove an id only when a new rule contradicts or supersedes it. + +Hard rules: + +- The list of `add` entries may be empty. Returning `{"add": [], "remove": []}` is a valid and expected answer when no correction implies a general rule. +- Never re-add a rule listed in `deleted_in_window`, and never add a reworded near-duplicate of one. A maintainer deleted it. +- When a new rule is a near-duplicate of an existing one, merge them into one `add` and list the old id in `remove`. +- When a new rule contradicts an existing rule, `add` the new one and `remove` the contradicted id. +- Every `add` entry must have a `source` that appears in the input's `corrections` list. Never invent a source. +- Do not read files and do not run commands. Every input is already attached. + +Example. Input: + +```json +{ + "existing": [], + "deleted_in_window": [], + "corrections": [ + { + "source": "commit:9dd2c07", + "date": "2026-08-03", + "message": "docs: remove experimental features page", + "files": ["packages/kilo-docs/pages/code-with-ai/experimental-features.md"], + "diff": "- removed the entire experimental features page\n- the page documented features behind unreleased flags" + } + ] +} +``` + +Expected output: + +```json +{ + "add": [ + { + "id": "no-experimental-features", + "rule": "Do not document features that are behind unreleased flags.", + "scope": "both", + "source": "commit:9dd2c07", + "date": "2026-08-03" + } + ], + "remove": [] +} +``` + +The rule is `both` because documenting unreleased features is wrong at triage time (the feature is not docs-worthy yet) and at edit time (the page should not exist). diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index 6aaf9dc6c36..f63155adf66 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -9,7 +9,9 @@ import { spawnSync } from "node:child_process" import fs from "node:fs" -const API = "https://api.github.com" +// Test hook: DOCS_SYNC_API_BASE points the API at a local stub server. The workflow +// never sets it — only selftests do. +const API = process.env.DOCS_SYNC_API_BASE || "https://api.github.com" const MAX_RETRIES = 3 export function token() { diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs index 9af35c58cdf..8341b0ef9ad 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -7,12 +7,13 @@ */ import assert from "node:assert/strict" -import { execFileSync, spawnSync } from "node:child_process" +import { execFileSync, spawn, spawnSync } from "node:child_process" import fs from "node:fs" import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" +import { sleepSync } from "./lib.mjs" import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs" import { applyCap } from "./watermark.mjs" import { @@ -21,6 +22,9 @@ import { routeRows, dropLegacySkipped, noDiffReport, + LEARNINGS_FILE, + nonContentFiles, + resolveLearnedThrough, renderBody, extractSectionRows, } from "./upsert-pr.mjs" @@ -31,11 +35,23 @@ import { applyRevertAnnotations, unannotatedRevertSignals, } from "./reverts.mjs" +import { + parseLearnings, + renderLearnings, + parseLearnedThrough, + patchMarkerIntoBody, + parseDelta, + validateDelta, + applyDelta, + isTrustedComment, + promptBlock, +} from "./learn.mjs" const HERE = path.dirname(fileURLToPath(import.meta.url)) const EDIT_SCRIPT = path.join(HERE, "edit.mjs") const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs") const COLLECT_SCRIPT = path.join(HERE, "collect.mjs") +const LEARN_SCRIPT = path.join(HERE, "learn.mjs") const temps = [] @@ -154,6 +170,16 @@ if (mode === "triage-embed-env-secret") { process.stdout.write(JSON.stringify(entries) + "\\n"); process.exit(0); } +if (mode === "extraction-delta") { + const deltaFile = "docs-sync-out/extraction-delta.json"; + if (fs.existsSync(deltaFile)) { + const delta = JSON.parse(fs.readFileSync(deltaFile, "utf8")); + process.stdout.write(JSON.stringify(delta) + "\\n"); + } else { + process.stdout.write('{"add":[],"remove":[]}' + "\\n"); + } + process.exit(0); +} process.stderr.write("unknown stub mode\\n"); process.exit(1); ` @@ -321,9 +347,9 @@ function setupTriageCwd(digest) { return cwd } -function runNodeScript(scriptPath, { cwd, env = {}, kiloDir }) { +function runNodeScript(scriptPath, { cwd, env = {}, kiloDir, args = [] }) { const pathEnv = [kiloDir, process.env.PATH].filter(Boolean).join(path.delimiter) - const result = spawnSync(process.execPath, [scriptPath], { + const result = spawnSync(process.execPath, [scriptPath, ...args], { cwd, env: { ...process.env, @@ -1067,10 +1093,12 @@ function case4_routing() { // round-trip renderBody → extractSectionRows const through = "2026-07-20T09:59:59.999Z" + const learnedMarker = "" const body = renderBody({ date: "2026-07-27", since: "2026-07-17T00:00:00.000Z", through, + learnedThrough: learnedMarker, changesRows, pendingRows, skippedRows, @@ -1079,6 +1107,7 @@ function case4_routing() { note: "", }) assert.ok(body.includes(``)) + assert.ok(body.includes(learnedMarker), "learned-through marker must appear in rendered body") const extChanges = extractSectionRows(body, "changes") const extPending = extractSectionRows(body, "pending") const extSkipped = extractSectionRows(body, "skipped") @@ -1106,6 +1135,7 @@ function case4_routing() { date: "2026-07-27", since: "s", through: "t", + learnedThrough: "", changesRows: [], pendingRows: [], skippedRows: forgedRows.skippedRows, @@ -1417,7 +1447,7 @@ function case9_reverts() { console.log("case 9: revert interception") // --- revertTitleKind --- - assert.equal(revertTitleKind('revert(cli): restore opt-in stream idle timeouts'), "conventional") + assert.equal(revertTitleKind("revert(cli): restore opt-in stream idle timeouts"), "conventional") assert.equal(revertTitleKind('Revert "feat(cli): default stream watchdog"'), "github-native") assert.equal(revertTitleKind("REVERT: all of it"), "conventional") assert.equal(revertTitleKind("feat(cli): add x"), null) @@ -1824,6 +1854,1603 @@ Reverts #12249 and #12481. } } +// --------------------------------------------------------------------------- +// Case 10 — learnings extraction, validation, injection, upsert safety +// --------------------------------------------------------------------------- +function case10_learnings() { + console.log("case 10: learnings") + + // --- helpers for extraction runs --- + function writeFixture(cwd, data) { + const f = path.join(cwd, "fixture.json") + fs.writeFileSync(f, JSON.stringify(data, null, 2)) + return f + } + + function writeExtractionDelta(cwd, delta) { + fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true }) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "extraction-delta.json"), JSON.stringify(delta, null, 2)) + } + + // Prepare a git repo for learn.mjs tests: set origin refs, create docs-sync-out. + function setupLearnRepo(dir) { + fs.mkdirSync(path.join(dir, "docs-sync-out"), { recursive: true }) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/docs/auto-sync", "docs/auto-sync"]) + return dir + } + + const githubBotEmail = "41898282+github-actions[bot]@users.noreply.github.com" + const kiloconnectBotEmail = "240665456+kiloconnect[bot]@users.noreply.github.com" + + // 10a — three commit classes + { + console.log(" 10a — three commit classes") + const dir = mktemp("docs-sync-learn-a-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + + // Branch + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + // 1. kiloconnect[bot] commit that touches packages/kilo-docs/pages/x.md + gitIn(dir, ["config", "user.email", kiloconnectBotEmail]) + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "docs: add x page"]) + const kiloconnectSha = gitIn(dir, ["rev-parse", "HEAD"]) + // 2. github-actions[bot] commit + gitIn(dir, ["config", "user.email", githubBotEmail]) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "y.md"), "# y\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/y.md"]) + gitIn(dir, ["commit", "-m", "docs: add y page"]) + // 3. Merge commit (non-merge filter) + gitIn(dir, ["config", "user.email", "someone@example.com"]) + gitIn(dir, ["checkout", "-b", "tmp-merge"]) + fs.writeFileSync(path.join(dir, "z.txt"), "z\n") + gitIn(dir, ["add", "z.txt"]) + gitIn(dir, ["commit", "-m", "tmp"]) + gitIn(dir, ["checkout", "docs/auto-sync"]) + gitIn(dir, ["merge", "--no-ff", "tmp-merge", "-m", "merge tmp"]) + // 4. commit reachable from main + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "main-only.txt"), "main only\n") + gitIn(dir, ["add", "main-only.txt"]) + gitIn(dir, ["commit", "-m", "main only"]) + + gitIn(dir, ["checkout", "docs/auto-sync"]) + const tip = gitIn(dir, ["rev-parse", "HEAD"]) + + // Setup origin refs (needed by learn.mjs git commands) + const cwd = setupLearnRepo(dir) + + const fixturePath = writeFixture(dir, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + + const callLog = path.join(dir, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog }) + writeExtractionDelta(dir, { add: [], remove: [] }) + + const result = runNodeScript(LEARN_SCRIPT, { + cwd: dir, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + + // Assert: input file written, exactly one correction — kiloconnect only. + // github-actions[bot] commit excluded (criterion 5), merge commit excluded + // via --no-merges, main-reachable commit excluded by origin/main range (criterion 6). + const inputFile = path.join(dir, "docs-sync-out", "learnings-input.json") + assert.ok(fs.existsSync(inputFile), `expected ${inputFile}`) + const input = JSON.parse(fs.readFileSync(inputFile, "utf8")) + assert.equal(input.corrections.length, 1, "exactly one correction (kiloconnect commit)") + assert.equal( + input.corrections[0].source, + `commit:${kiloconnectSha.slice(0, 7)}`, + "correction must be kiloconnect commit only", + ) + } + + // 10rz — timestamp correlation with different timezone offsets + // A comment must map to the chronologically earliest eligible commit even when + // timestamps use different timezone offsets (Z vs +05:00). + { + console.log(" 10rz — timestamp correlation") + const dir = mktemp("docs-sync-learn-rz-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + + // Commit A: non-UTC offset, chronologically earliest (UTC 08:00) + // iso = 2026-08-03T13:00:00+05:00 + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "first edit x", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`], { + GIT_COMMITTER_DATE: "2026-08-03T13:00:00+05:00", + }) + const shaA = gitIn(dir, ["rev-parse", "HEAD"]) + + // Commit B: UTC offset, chronologically later (UTC 09:00) + // iso = 2026-08-03T09:00:00Z — string comparison would pick this as "earlier" (09 < 13) + // but chronologically A is earlier (08:00 < 09:00) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n## edit\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "second edit x", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`], { + GIT_COMMITTER_DATE: "2026-08-03T09:00:00Z", + }) + const shaB = gitIn(dir, ["rev-parse", "HEAD"]) + + // Seed LEARNINGS.md so existing entries are non-empty but irrelevant + const existing = [ + { id: "pre", rule: "Pre-existing rule.", scope: "both", source: "commit:0000000", date: "2026-01-01" }, + ] + const learningsPath = path.join(dir, "packages", "kilo-docs", "LEARNINGS.md") + fs.mkdirSync(path.dirname(learningsPath), { recursive: true }) + fs.writeFileSync(learningsPath, renderLearnings(existing)) + gitIn(dir, ["add", "packages/kilo-docs/LEARNINGS.md"]) + gitIn(dir, ["commit", "-m", "seed learnings", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`]) + + const cwd = setupLearnRepo(dir) + + // Comment at UTC 05:30 on x.md — before both commits chronologically. + // 05:30 < 08:00 (A) and 05:30 < 09:00 (B) → both eligible + // The chronologically earliest eligible commit is A (08:00). + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [ + { + id: 101, + created_at: "2026-08-03T05:30:00Z", + path: "packages/kilo-docs/pages/x.md", + body: "Please fix the docs.", + author_association: "MEMBER", + user: { login: "maintainer" }, + }, + ], + }) + + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog: path.join(cwd, "kilo-calls.log") }) + writeExtractionDelta(cwd, { add: [], remove: [] }) + + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + + // Read learnings-input.json to inspect the correlation result + const inputFile = path.join(cwd, "docs-sync-out", "learnings-input.json") + assert.ok(fs.existsSync(inputFile), "learnings-input.json must exist") + const input = JSON.parse(fs.readFileSync(inputFile, "utf8")) + + // Both commits must appear as corrections + const commitA = input.corrections.find((c) => c.source === `commit:${shaA.slice(0, 7)}`) + const commitB = input.corrections.find((c) => c.source === `commit:${shaB.slice(0, 7)}`) + assert.ok(commitA, "commit A must be in corrections") + assert.ok(commitB, "commit B must be in corrections") + + // Comment must be associated with commit A (chronologically earliest) + assert.ok(commitA.comment, "commit A must have the comment associated") + assert.equal(commitA.comment.path, "packages/kilo-docs/pages/x.md") + assert.equal(commitB.comment, undefined, "commit B must not have the comment associated") + + // No standalone comment candidate — the comment was correlated, not orphaned + const standalone = input.corrections.filter((c) => c.source && c.source.startsWith("comment:")) + assert.equal(standalone.length, 0, "comment must be associated, not standalone") + } + + // 10b — watermark suppression (no model call when marker covers all) + { + console.log(" 10b — watermark suppression") + const dir = mktemp("docs-sync-learn-b-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + // corrective commit + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "docs update", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`]) + + // Write LEARNINGS.md on the branch first, so the marker can point to the tip after it + const existing = [ + { id: "test", rule: "existing rule", scope: "both", source: "commit:0000000", date: "2026-01-01" }, + ] + const learningsPath = path.join(dir, "packages", "kilo-docs", "LEARNINGS.md") + const learningsContent = renderLearnings(existing) + fs.mkdirSync(path.dirname(learningsPath), { recursive: true }) + fs.writeFileSync(learningsPath, learningsContent) + gitIn(dir, ["add", "packages/kilo-docs/LEARNINGS.md"]) + gitIn(dir, ["commit", "-m", "seed learnings"]) + + const tip = gitIn(dir, ["rev-parse", "HEAD"]) + const tipDate = new Date().toISOString() + + const cwd = setupLearnRepo(dir) + const body = `` + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body, user: { login: "github-actions[bot]" } }, + comments: [], + }) + + const callLog = path.join(cwd, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog }) + writeExtractionDelta(cwd, { add: [], remove: [] }) + + fs.writeFileSync(path.join(cwd, "docs-sync-out", "learnings.json"), JSON.stringify(existing, null, 2)) + + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + + // Assert: stub never invoked, learnings.json unchanged, no model call + const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : "" + assert.equal(calls, "", `kilo must not be invoked when marker covers all; got ${calls}`) + const out = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "learnings.json"), "utf8")) + assert.deepEqual(out, existing, "learnings.json must equal existing entries") + + // Run apply and prove LEARNINGS.md is byte-unchanged + const lp = path.join(cwd, "packages", "kilo-docs", "LEARNINGS.md") + const before = fs.readFileSync(lp, "utf8") + runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + args: ["--apply"], + env: { DOCS_SYNC_BACKOFF_MS: "0" }, + }) + const after = fs.readFileSync(lp, "utf8") + assert.equal(after, before, "LEARNINGS.md must be byte-unchanged after apply") + } + + // 10c — rerun idempotency + { + console.log(" 10c — rerun idempotency") + const dir = mktemp("docs-sync-learn-c-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "docs update", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`]) + let tip = gitIn(dir, ["rev-parse", "HEAD"]) + + const add = [ + { + id: "new-rule", + rule: "A new rule from testing.", + scope: "both", + source: `commit:${tip.slice(0, 7)}`, + date: "2026-08-03", + }, + ] + let firstLEARNINGS + + // First run + { + const cwd = setupLearnRepo(dir) + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog: path.join(cwd, "kilo-calls.log") }) + writeExtractionDelta(cwd, { add, remove: [] }) + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + const out1 = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "learnings.json"), "utf8")) + assert.equal(out1.length, 1) + assert.equal(out1[0].id, "new-rule") + // Write LEARNINGS.md on the branch so second run sees existing entries + const learningsPath = path.join(dir, "packages", "kilo-docs", "LEARNINGS.md") + fs.mkdirSync(path.dirname(learningsPath), { recursive: true }) + fs.writeFileSync(learningsPath, renderLearnings(out1)) + gitIn(dir, ["add", "packages/kilo-docs/LEARNINGS.md"]) + gitIn(dir, ["commit", "-m", "seed learnings"]) + tip = gitIn(dir, ["rev-parse", "HEAD"]) + firstLEARNINGS = fs.readFileSync(path.join(dir, "packages", "kilo-docs", "LEARNINGS.md"), "utf8") + } + + // Second run with marker covering first run's result + { + const cwd = setupLearnRepo(dir) + const body = `` + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body, user: { login: "github-actions[bot]" } }, + comments: [], + }) + const callLog2 = path.join(cwd, "kilo-calls-run2.log") + const kiloDir2 = makeStubKiloDir({ mode: "extraction-delta", callLog: callLog2 }) + writeExtractionDelta(cwd, { add, remove: [] }) + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir: kiloDir2, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + const calls2 = fs.existsSync(callLog2) ? fs.readFileSync(callLog2, "utf8").trim() : "" + assert.equal(calls2, "", "second run must not invoke kilo") + const out2 = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "learnings.json"), "utf8")) + assert.equal(out2.length, 1) + assert.equal(out2[0].id, "new-rule") + // Run apply and prove LEARNINGS.md is byte-identical after idempotent rerun + runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir: kiloDir2, + args: ["--apply"], + env: { DOCS_SYNC_BACKOFF_MS: "0" }, + }) + const afterApply = fs.readFileSync(path.join(dir, "packages", "kilo-docs", "LEARNINGS.md"), "utf8") + assert.equal(afterApply.length, firstLEARNINGS.length, "LEARNINGS.md must be same length after apply") + assert.equal(afterApply, firstLEARNINGS, "LEARNINGS.md must be byte-identical after apply") + } + } + + // 10d — contradiction replacement (applyDelta) + { + console.log(" 10d — contradiction replacement") + const old = [ + { id: "old-rule", rule: "Old rule text.", scope: "both", source: "commit:aaaaaaa", date: "2026-01-01" }, + ] + const add = [ + { id: "new-rule", rule: "New rule text.", scope: "both", source: "commit:bbbbbbb", date: "2026-02-01" }, + ] + const delta = { add, remove: ["old-rule"] } + const result = applyDelta(old, delta) + assert.equal(result.length, 1) + assert.equal(result[0].id, "new-rule") + // Third delta touching neither does not bring old back + const again = applyDelta(result, { add: [], remove: [] }) + assert.equal(again.length, 1) + assert.equal(again[0].id, "new-rule") + } + + // 10e — prompt injection (triage/edit argv carry tagged rules) + { + console.log(" 10e — prompt injection") + const dir = mktemp("docs-sync-learn-e-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + // Add a corrective commit so extraction has a candidate + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "docs update", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`]) + const commitSha = gitIn(dir, ["rev-parse", "HEAD"]) + + const cwd = setupLearnRepo(dir) + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + const callLog = path.join(cwd, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog }) + // Three entries: triage, edit, both — all from the same candidate source + const src = `commit:${commitSha.slice(0, 7)}` + writeExtractionDelta(cwd, { + add: [ + { id: "triage-rule", rule: "Triage-only rule text.", scope: "triage", source: src, date: "2026-08-01" }, + { id: "edit-rule", rule: "Edit-only rule text.", scope: "edit", source: src, date: "2026-08-02" }, + { id: "both-rule", rule: "Both scope rule text.", scope: "both", source: src, date: "2026-08-03" }, + ], + remove: [], + }) + + // Run extraction so it writes learnings-.md blocks (extraction step 13). + // Only extraction writes these files; --apply writes only LEARNINGS.md. + runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + + const triageBlockPath = path.join(cwd, "docs-sync-out", "learnings-triage.md") + const editBlockPath = path.join(cwd, "docs-sync-out", "learnings-edit.md") + assert.ok(fs.existsSync(triageBlockPath), "learnings-triage.md must exist") + assert.ok(fs.existsSync(editBlockPath), "learnings-edit.md must exist") + + // Run triage.mjs against a recording stub. Copy the learnings block into + // its cwd so readLearningsBlock picks it up. + { + const triageCwd = setupTriageCwd([samplePr(1)]) + fs.copyFileSync(triageBlockPath, path.join(triageCwd, "docs-sync-out", "learnings-triage.md")) + const triageCallLog = path.join(triageCwd, "triage-calls.log") + const triageKiloDir = makeStubKiloDir({ mode: "record", callLog: triageCallLog }) + runNodeScript(TRIAGE_SCRIPT, { + cwd: triageCwd, + kiloDir: triageKiloDir, + env: { TRIAGE_MODEL: "test/model", DOCS_SYNC_BACKOFF_MS: "0" }, + }) + const logText = fs.readFileSync(triageCallLog, "utf8") + assert.ok(logText.includes("Triage-only rule text"), "triage argv must contain triage rule") + assert.ok(logText.includes("Both scope rule text"), "triage argv must contain both rule") + assert.ok(!logText.includes("Edit-only rule text"), "triage argv must not contain edit-only rule") + } + + // Run edit.mjs against a recording stub. + { + const triageEntry = { + pr: 1, + url: "https://github.com/Kilo-Org/cloud/pull/1", + docs_worthy: true, + reason: "needs docs", + target_sections: [], + priority: "medium", + } + const editCwd = setupEditCwd([samplePr(1)], [triageEntry]) + fs.copyFileSync(editBlockPath, path.join(editCwd, "docs-sync-out", "learnings-edit.md")) + const editCallLog = path.join(editCwd, "edit-calls.log") + const editKiloDir = makeStubKiloDir({ mode: "record", callLog: editCallLog }) + runNodeScript(EDIT_SCRIPT, { + cwd: editCwd, + kiloDir: editKiloDir, + env: { EDIT_MODEL: "test/model", DOCS_SYNC_BACKOFF_MS: "0" }, + }) + const logText = fs.readFileSync(editCallLog, "utf8") + assert.ok(logText.includes("Edit-only rule text"), "edit argv must contain edit rule") + assert.ok(logText.includes("Both scope rule text"), "edit argv must contain both rule") + assert.ok(!logText.includes("Triage-only rule text"), "edit argv must not contain triage-only rule") + } + } + + // 10f — failure path + { + console.log(" 10f — failure path") + const dir = mktemp("docs-sync-learn-f-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "docs update", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`]) + + const existing = [ + { id: "test", rule: "existing rule", scope: "both", source: "commit:0000000", date: "2026-01-01" }, + ] + // Write LEARNINGS.md on the branch so learn.mjs reads it as existing entries + const learningsPath = path.join(dir, "packages", "kilo-docs", "LEARNINGS.md") + fs.mkdirSync(path.dirname(learningsPath), { recursive: true }) + fs.writeFileSync(learningsPath, renderLearnings(existing)) + gitIn(dir, ["add", "packages/kilo-docs/LEARNINGS.md"]) + gitIn(dir, ["commit", "-m", "seed learnings"]) + + const cwd = setupLearnRepo(dir) + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + + // Stub exits 0 with garbage stdout (stderr-exit0 mode) + const stderrText = "some fake error stream" + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const outputFile = path.join(cwd, "gh-output-f") + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + GITHUB_OUTPUT: outputFile, + }, + }) + + assert.equal(result.status, 0, "learn.mjs must exit 0 on failure") + const out = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "learnings.json"), "utf8")) + assert.deepEqual(out, existing, "learnings.json must equal existing entries on failure") + // No marker PATCH file + assert.ok(!fs.existsSync(`${fixturePath}.patched`), "no marker PATCH on failure") + // No learned_through output + if (fs.existsSync(outputFile)) { + const ghOut = fs.readFileSync(outputFile, "utf8") + assert.ok(!ghOut.includes("learned_through="), "GITHUB_OUTPUT must not contain learned_through on failure") + } + + // Run apply and prove LEARNINGS.md is byte-unchanged + const lp = path.join(cwd, "packages", "kilo-docs", "LEARNINGS.md") + const before = fs.readFileSync(lp, "utf8") + runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + args: ["--apply"], + env: { DOCS_SYNC_BACKOFF_MS: "0" }, + }) + const after = fs.readFileSync(lp, "utf8") + assert.equal(after, before, "LEARNINGS.md must be byte-unchanged after apply on failure") + } + + // 10g — general-rule check (validateDelta rejections) + { + console.log(" 10g — general-rule check") + const existing = [] + const sources = ["commit:aaaaaaa"] + const entryWithPR = { + id: "bad-pr", + rule: "See #12716 for details", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-03", + } + const entryWithURL = { + id: "bad-url", + rule: "Check https://example.com", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-03", + } + const entryWithPerson = { + id: "bad-person", + rule: "Ask @emilieschario", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-03", + } + const entryWithPage = { + id: "bad-page", + rule: "Edit packages/kilo-docs/pages/x.md", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-03", + } + const entryBadSource = { + id: "bad-source", + rule: "A valid sentence.", + scope: "both", + source: "invented", + date: "2026-08-03", + } + const entryBadScope = { + id: "bad-scope", + rule: "A valid sentence.", + scope: "wrong", + source: "commit:aaaaaaa", + date: "2026-08-03", + } + const entryCollide = { + id: "existing-id", + rule: "A valid sentence.", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-03", + } + const entryBadDate = { + id: "bad-date", + rule: "A valid sentence.", + scope: "both", + source: "commit:aaaaaaa", + date: "not-a-date", + } + const entryRemoveUnknown = { + id: "valid", + rule: "A valid sentence.", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-03", + } + + const existingWithId = [ + { id: "existing-id", rule: "Existing rule.", scope: "both", source: "commit:aaaaaaa", date: "2026-01-01" }, + ] + + // PR number + { + const { rejected } = validateDelta( + { add: [entryWithPR], remove: [] }, + { existing, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "PR number must be rejected") + assert.ok(rejected[0].reason, "rejection must carry a reason") + } + + // URL — docs-check-links.yml link-checks LEARNINGS.md with fail:true, so + // a URL in a rule would break CI on every bot commit. + { + const { rejected } = validateDelta( + { add: [entryWithURL], remove: [] }, + { existing, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "URL must be rejected") + } + + // Person + { + const { rejected } = validateDelta( + { add: [entryWithPerson], remove: [] }, + { existing, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "person reference must be rejected") + } + + // Docs page + { + const { rejected } = validateDelta( + { add: [entryWithPage], remove: [] }, + { existing, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "docs page reference must be rejected") + } + + // Bad source + { + const { rejected } = validateDelta( + { add: [entryBadSource], remove: [] }, + { existing, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "invented source must be rejected") + } + + // Bad scope + { + const { rejected } = validateDelta( + { add: [entryBadScope], remove: [] }, + { existing, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "bad scope must be rejected") + } + + // Colliding id + { + const { rejected } = validateDelta( + { add: [entryCollide], remove: [] }, + { existing: existingWithId, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "colliding id must be rejected") + } + + // Remove unknown id + { + const { rejected } = validateDelta( + { add: [entryRemoveUnknown], remove: ["unknown-id"] }, + { existing, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "remove of unknown id must be rejected") + } + + // Bad date + { + const { rejected } = validateDelta( + { add: [entryBadDate], remove: [] }, + { existing, candidateSources: sources, deletedInWindow: [] }, + ) + assert.equal(rejected.length, 1, "bad date must be rejected") + } + } + + // 10h — comment trust (isTrustedComment) + { + console.log(" 10h — comment trust") + assert.equal(isTrustedComment({ author_association: "OWNER", user: { login: "owner-user" } }), true) + assert.equal(isTrustedComment({ author_association: "MEMBER", user: { login: "emilieschario" } }), true) + assert.equal(isTrustedComment({ author_association: "COLLABORATOR", user: { login: "collab-user" } }), true) + assert.equal(isTrustedComment({ author_association: "CONTRIBUTOR", user: { login: "kilo-code-bot[bot]" } }), false) + assert.equal(isTrustedComment({ author_association: "NONE", user: { login: "rando" } }), false) + // MEMBER whose login ends in [bot] + assert.equal(isTrustedComment({ author_association: "MEMBER", user: { login: "some-bot[bot]" } }), false) + } + + // 10i — draft gate (nonContentFiles) + { + console.log(" 10i — draft gate") + const files = ["packages/kilo-docs/LEARNINGS.md", "packages/kilo-docs/pages/a.md"] + const result = nonContentFiles(files) + assert.equal(result.length, 0, "LEARNINGS.md and pages must not trigger the draft gate") + // Still flags non-content + const withConfig = ["packages/kilo-docs/next.config.js"] + const flagged = nonContentFiles(withConfig) + assert.equal(flagged.length, 1, "next.config.js must still trigger the gate") + assert.equal(flagged[0], "packages/kilo-docs/next.config.js") + } + + // 10j — no --auto on extraction call + { + console.log(" 10j — no --auto on extraction call") + const src = fs.readFileSync(LEARN_SCRIPT, "utf8") + + // Find the extraction-mode runKilo args array + const argsStart = src.indexOf("runKilo({") + assert.ok(argsStart >= 0, "runKilo call must exist in learn.mjs") + const argsBlock = src.slice(argsStart, src.indexOf("})", argsStart) + 2) + assert.ok(!argsBlock.includes("--auto"), "extraction runKilo must not include --auto") + assert.ok(argsBlock.includes("-f"), "extraction runKilo must include -f") + } + + // 10k — hand-mangled file (parseLearnings) + { + console.log(" 10k — hand-mangled file") + const text = `# header + +- Valid rule. +- Broken meta. +Just prose, not a rule line. +` + const entries = parseLearnings(text) + assert.equal(entries.length, 1, "only valid entry must parse") + assert.equal(entries[0].id, "valid-rule") + } + + // 10l — first-run fallback (main when branch has none) + // Prove the git commands learn.mjs relies on: when the branch file is absent, + // git show origin/:packages/kilo-docs/LEARNINGS.md fails, and + // git show origin/main:packages/kilo-docs/LEARNINGS.md returns the main's entries. + { + console.log(" 10l — empty file fallback") + const dir = mktemp("docs-sync-learn-l-") + initRepoWithIdentity(dir) + + // Write LEARNINGS.md on main + const entries = [ + { id: "test", rule: "Test rule text.", scope: "both", source: "commit:aaaaaaa", date: "2026-01-01" }, + ] + const lp = path.join(dir, "packages", "kilo-docs", "LEARNINGS.md") + fs.mkdirSync(path.dirname(lp), { recursive: true }) + fs.writeFileSync(lp, renderLearnings(entries)) + gitIn(dir, ["add", "packages/kilo-docs/LEARNINGS.md"]) + gitIn(dir, ["commit", "-m", "main learnings"]) + + // Branch from main, then remove LEARNINGS.md + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + fs.rmSync(lp) + gitIn(dir, ["add", "packages/kilo-docs/LEARNINGS.md"]) + gitIn(dir, ["commit", "-m", "remove learnings on branch"]) + + // Set up origin refs so git show origin/ resolves + setupLearnRepo(dir) + + // git show on branch must fail — file absent at that ref + let branchFailed = false + try { + gitIn(dir, ["show", "origin/docs/auto-sync:packages/kilo-docs/LEARNINGS.md"]) + } catch { + branchFailed = true + } + assert.ok(branchFailed, "git show on branch must fail when LEARNINGS.md absent") + + // git show on main must succeed with the main's entries + const mainContent = gitIn(dir, ["show", "origin/main:packages/kilo-docs/LEARNINGS.md"]) + const parsed = parseLearnings(mainContent) + assert.equal(parsed.length, 1, "main fallback must return the main's entries") + assert.equal(parsed[0].id, "test") + + // Also verify: empty parse and render (unit coverage of the empty case) + const empty = parseLearnings("") + assert.equal(empty.length, 0) + assert.deepEqual(empty, []) + const rendered = renderLearnings([]) + assert.ok(rendered.includes("")) + assert.ok(rendered.includes("")) + } + + // 10m — empty delta advances marker with no file change + { + console.log(" 10m — empty delta marker advance") + const dir = mktemp("docs-sync-learn-m-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "docs update", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`]) + const tip = gitIn(dir, ["rev-parse", "HEAD"]) + + const cwd = setupLearnRepo(dir) + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog: path.join(cwd, "kilo-calls.log") }) + writeExtractionDelta(cwd, { add: [], remove: [] }) + + const existing = [] + fs.writeFileSync(path.join(cwd, "docs-sync-out", "learnings.json"), JSON.stringify(existing, null, 2)) + const learningsPath = path.join(cwd, "packages", "kilo-docs", "LEARNINGS.md") + fs.mkdirSync(path.dirname(learningsPath), { recursive: true }) + fs.writeFileSync(learningsPath, renderLearnings(existing)) + const before = fs.readFileSync(learningsPath, "utf8") + + const outputFile = path.join(cwd, "gh-output-m") + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + GITHUB_OUTPUT: outputFile, + }, + }) + + // learnings.json unchanged + const out = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "learnings.json"), "utf8")) + assert.deepEqual(out, existing) + + // No learned_through output (empty delta route omits it per G5 table) + if (fs.existsSync(outputFile)) { + const ghOut = fs.readFileSync(outputFile, "utf8") + assert.ok(!ghOut.includes("learned_through="), "GITHUB_OUTPUT must not contain learned_through on empty delta") + } + const patched = `${fixturePath}.patched` + assert.ok(fs.existsSync(patched), "marker PATCH file must be written for empty delta") + const markerText = fs.readFileSync(patched, "utf8") + assert.ok(markerText.includes(tip), "marker PATCH must contain tip SHA") + + runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + args: ["--apply"], + env: { DOCS_SYNC_BACKOFF_MS: "0" }, + }) + assert.equal(fs.readFileSync(learningsPath, "utf8"), before, "LEARNINGS.md must be byte-unchanged after apply") + + // patchMarkerIntoBody: existing marker → replaced in place + { + const oldBody = "some text\n\nmore text\n" + const newLine = "" + const patched = patchMarkerIntoBody(oldBody, newLine) + assert.ok(patched.includes(newLine), "new marker must be in body") + assert.ok(!patched.includes("commit=old"), "old marker must be gone") + assert.equal( + (patched.match(/" + const patched = patchMarkerIntoBody(oldBody, newLine) + assert.ok(patched.includes(newLine)) + } + } + + // 10n — non-empty delta routes through upsert (not learn.mjs PATCH) + { + console.log(" 10n — non-empty delta marker route") + const dir = mktemp("docs-sync-learn-n-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "docs update", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`]) + const source = `commit:${gitIn(dir, ["rev-parse", "HEAD"]).slice(0, 7)}` + const tip = gitIn(dir, ["rev-parse", "HEAD"]) + + const cwd = setupLearnRepo(dir) + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog: path.join(cwd, "kilo-calls.log") }) + writeExtractionDelta(cwd, { + add: [ + { id: "new-rule", rule: "A new rule.", scope: "both", source: `commit:${tip.slice(0, 7)}`, date: "2026-08-03" }, + ], + remove: [], + }) + + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + const outputFile = path.join(cwd, "gh-output") + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + GITHUB_STEP_SUMMARY: summaryFile, + GITHUB_OUTPUT: outputFile, + }, + }) + + // No marker PATCH file + assert.ok(!fs.existsSync(`${fixturePath}.patched`), "non-empty delta must not PATCH marker") + + // Assert learned_through was written to GITHUB_OUTPUT (non-empty delta route) + const ghOut = fs.readFileSync(outputFile, "utf8") + assert.ok(ghOut.includes("learned_through="), "GITHUB_OUTPUT must contain learned_through on non-empty delta") + assert.ok(ghOut.includes(`commit=${tip}`), "learned_through must contain tip SHA") + + // renderBody with learnedThrough marker + const marker = "" + const body1 = renderBody({ + date: "2026-08-03", + since: "2026-07-01T00:00:00.000Z", + through: "2026-08-03T00:00:00.000Z", + learnedThrough: marker, + changesRows: [], + pendingRows: [], + skippedRows: [], + verified: true, + draftReasons: [], + note: "", + }) + assert.ok(body1.includes(marker), "renderBody must emit marker when given") + + // renderBody with parameter omitted → no marker + const body2 = renderBody({ + date: "2026-08-03", + since: "2026-07-01T00:00:00.000Z", + through: "2026-08-03T00:00:00.000Z", + changesRows: [], + pendingRows: [], + skippedRows: [], + verified: true, + draftReasons: [], + note: "", + }) + assert.ok(!body2.includes("learned-through"), "renderBody without learnedThrough must emit no marker") + // Still round-trips + const extChanges = extractSectionRows(body2, "changes") + assert.deepEqual(extChanges, []) + } + + // 10o — deleted-in-window rejection + { + console.log(" 10o — deleted-in-window rejection") + // Normalized match catches near-identical wording + { + const delta = { + add: [ + { + id: "dup", + rule: "do not document experimental features!", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-03", + }, + ], + remove: [], + } + const { rejected } = validateDelta(delta, { + existing: [], + candidateSources: ["commit:aaaaaaa"], + deletedInWindow: ["Do not document experimental features."], + }) + assert.equal(rejected.length, 1, "normalized match must reject deleted rule") + } + + // Different meaning on same topic — NOT rejected (accepted limit) + { + const delta = { + add: [ + { + id: "good", + rule: "Document experimental features in a separate section.", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-03", + }, + ], + remove: [], + } + const { rejected } = validateDelta(delta, { + existing: [], + candidateSources: ["commit:aaaaaaa"], + deletedInWindow: ["Do not document experimental features."], + }) + assert.equal(rejected.length, 0, "different rule on same topic must not be rejected") + } + } + + // 10p — resolveLearnedThrough pure function and anti-drift assertions + { + console.log(" 10p — resolveLearnedThrough + anti-drift") + // Unit tests on the pure export + // env value set wins over body marker + assert.equal( + resolveLearnedThrough({ + envValue: "", + prBody: "", + }), + "", + ) + // env unset, body marker present → body wins + assert.equal( + resolveLearnedThrough({ envValue: "", prBody: "" }), + "", + ) + // both absent → empty string + assert.equal(resolveLearnedThrough({ envValue: "", prBody: "" }), "") + // env set to whitespace → treated as unset + assert.equal( + resolveLearnedThrough({ + envValue: " ", + prBody: "", + }), + "", + ) + + // renderBody emits no marker for "" + const bodyEmpty = renderBody({ + date: "d", + since: "s", + through: "t", + learnedThrough: "", + changesRows: [], + pendingRows: [], + skippedRows: [], + verified: true, + draftReasons: [], + note: "", + }) + assert.ok(!bodyEmpty.includes("learned-through"), "empty learnedThrough must emit no marker") + + // Anti-drift: static-source assertions on upsert-pr.mjs + const upsertSrc = fs.readFileSync(path.join(HERE, "upsert-pr.mjs"), "utf8") + + // (a) exactly one resolveLearnedThrough( call passing process.env.LEARNED_THROUGH + const calls = upsertSrc.match(/resolveLearnedThrough\(/g) || [] + // One in the export definition, one in the call site + assert.ok(calls.length >= 2, `expected at least 2 resolveLearnedThrough( occurrences; got ${calls.length}`) + assert.ok( + upsertSrc.includes("process.env.LEARNED_THROUGH"), + "resolveLearnedThrough must receive process.env.LEARNED_THROUGH", + ) + assert.ok(upsertSrc.includes("prBody"), "resolveLearnedThrough must receive prBody") + + // (b) prBody is at function scope (let prBody before the if block) + const prBodyIdx = upsertSrc.indexOf('let prBody = ""') + assert.ok(prBodyIdx >= 0, 'prBody must be declared at function scope with let prBody = ""') + const ifIdx = upsertSrc.indexOf('if (mode === "update"') + assert.ok(prBodyIdx < ifIdx, 'let prBody must appear before if (mode === "update"...)') + + // (c) renderBody({ argument object contains learnedThrough + const renderBodyIdx = upsertSrc.indexOf("const body = renderBody({") + assert.ok(renderBodyIdx >= 0, "renderBody call must exist") + const afterRenderBody = upsertSrc.slice(renderBodyIdx) + const renderBodyArgsEnd = afterRenderBody.indexOf("})") + const renderBodyArgs = afterRenderBody.slice(0, renderBodyArgsEnd) + assert.ok(renderBodyArgs.includes("learnedThrough"), "renderBody call in main() must pass learnedThrough") + } + + // 10q — dry run makes no live write + { + console.log(" 10q — dry run no live write") + const dir = mktemp("docs-sync-learn-q-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + fs.mkdirSync(path.join(dir, "packages", "kilo-docs", "pages"), { recursive: true }) + fs.writeFileSync(path.join(dir, "packages", "kilo-docs", "pages", "x.md"), "# x\n") + gitIn(dir, ["add", "packages/kilo-docs/pages/x.md"]) + gitIn(dir, ["commit", "-m", "docs update", "--author", `kiloconnect[bot] <${kiloconnectBotEmail}>`]) + + // DRY_RUN=true + { + const cwd = setupLearnRepo(dir) + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog: path.join(cwd, "kilo-calls.log") }) + writeExtractionDelta(cwd, { add: [], remove: [] }) + + const outputFile = path.join(cwd, "gh-output-q-dry") + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + DRY_RUN: "true", + GITHUB_OUTPUT: outputFile, + }, + }) + + assert.ok(!fs.existsSync(`${fixturePath}.patched`), "DRY_RUN must suppress marker PATCH") + if (fs.existsSync(outputFile)) { + const ghOut = fs.readFileSync(outputFile, "utf8") + assert.ok(!ghOut.includes("learned_through="), "GITHUB_OUTPUT must not contain learned_through on DRY_RUN") + } + assert.ok(result.stdout.includes("marker PATCH suppressed"), "stdout must log marker suppression for DRY_RUN") + assert.ok( + result.stdout.includes("would have written marker"), + "stdout must log the suppressed marker for DRY_RUN", + ) + } + + // LEARNINGS_NO_PATCH=1 (same mechanism) + { + const cwd = setupLearnRepo(dir) + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog: path.join(cwd, "kilo-calls.log") }) + writeExtractionDelta(cwd, { add: [], remove: [] }) + + const outputFile = path.join(cwd, "gh-output-q-nopatch") + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + LEARNINGS_NO_PATCH: "1", + GITHUB_OUTPUT: outputFile, + }, + }) + + assert.ok(!fs.existsSync(`${fixturePath}.patched`), "LEARNINGS_NO_PATCH must suppress marker PATCH") + if (fs.existsSync(outputFile)) { + const ghOut = fs.readFileSync(outputFile, "utf8") + assert.ok( + !ghOut.includes("learned_through="), + "GITHUB_OUTPUT must not contain learned_through on LEARNINGS_NO_PATCH", + ) + } + assert.ok( + result.stdout.includes("marker PATCH suppressed"), + "stdout must log marker suppression for LEARNINGS_NO_PATCH", + ) + assert.ok( + result.stdout.includes("would have written marker"), + "stdout must log the suppressed marker for LEARNINGS_NO_PATCH", + ) + } + + // DRY_RUN=true with non-empty delta (suppresses learned_through output) + { + const cwd = setupLearnRepo(dir) + const tipSource = `commit:${gitIn(dir, ["rev-parse", "HEAD"]).slice(0, 7)}` + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog: path.join(cwd, "kilo-calls.log") }) + writeExtractionDelta(cwd, { + add: [ + { + id: "dry-suppress", + rule: "A rule suppressed under dry run.", + scope: "both", + source: tipSource, + date: "2026-08-03", + }, + ], + remove: [], + }) + + const outputFile = path.join(cwd, "gh-output-q-dry-nonempty") + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + DRY_RUN: "true", + GITHUB_OUTPUT: outputFile, + }, + }) + + if (fs.existsSync(outputFile)) { + const ghOut = fs.readFileSync(outputFile, "utf8") + assert.ok( + !ghOut.includes("learned_through="), + "GITHUB_OUTPUT must not contain learned_through on DRY_RUN non-empty delta", + ) + } + assert.ok( + result.stdout.includes("learned-through output suppressed"), + "stdout must log learned-through output suppression for DRY_RUN non-empty delta", + ) + } + + // LEARNINGS_NO_PATCH=1 with non-empty delta + { + const cwd = setupLearnRepo(dir) + const tipSource = `commit:${gitIn(dir, ["rev-parse", "HEAD"]).slice(0, 7)}` + const fixturePath = writeFixture(cwd, { + pr: { number: 1, head: { ref: "docs/auto-sync" }, body: "", user: { login: "github-actions[bot]" } }, + comments: [], + }) + + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog: path.join(cwd, "kilo-calls.log") }) + writeExtractionDelta(cwd, { + add: [ + { + id: "nopatch-suppress", + rule: "A rule suppressed under no-patch.", + scope: "both", + source: tipSource, + date: "2026-08-03", + }, + ], + remove: [], + }) + + const outputFile = path.join(cwd, "gh-output-q-nopatch-nonempty") + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + LEARNINGS_NO_PATCH: "1", + GITHUB_OUTPUT: outputFile, + }, + }) + + if (fs.existsSync(outputFile)) { + const ghOut = fs.readFileSync(outputFile, "utf8") + assert.ok( + !ghOut.includes("learned_through="), + "GITHUB_OUTPUT must not contain learned_through on LEARNINGS_NO_PATCH non-empty delta", + ) + } + assert.ok( + result.stdout.includes("learned-through output suppressed"), + "stdout must log learned-through output suppression for LEARNINGS_NO_PATCH non-empty delta", + ) + } + } + + // 10s — a failed API call must not disable the existing learnings + // The learn step is continue-on-error, and triage and edit read only the two prompt + // artifacts. So learn.mjs must write them before the first call that can throw. + { + console.log(" 10s — prompt artifacts survive an API failure") + const dir = mktemp("docs-sync-learn-s-") + const learningsPath = path.join(dir, "packages", "kilo-docs", "LEARNINGS.md") + fs.mkdirSync(path.dirname(learningsPath), { recursive: true }) + const seeded = [ + { + id: "seeded-rule", + rule: "Do not document features behind experimental flags.", + scope: "both", + source: "commit:aaaaaaa", + date: "2026-08-01", + }, + ] + fs.writeFileSync(learningsPath, renderLearnings(seeded)) + + // No DOCS_SYNC_FIXTURE and an empty GITHUB_REPOSITORY: repo() throws inside + // extract(). It stands for any API failure before the artifacts exist. + const failEnv = { + TRIAGE_MODEL: "test/model", + GITHUB_REPOSITORY: "", + GITHUB_OUTPUT: path.join(dir, "gh-output-s"), + GITHUB_STEP_SUMMARY: path.join(dir, "gh-summary-s"), + DOCS_SYNC_BACKOFF_MS: "0", + } + const result = runNodeScript(LEARN_SCRIPT, { cwd: dir, env: failEnv }) + assert.notEqual(result.status, 0, "extraction must fail without GITHUB_REPOSITORY") + + const triagePath = path.join(dir, "docs-sync-out", "learnings-triage.md") + const editPath = path.join(dir, "docs-sync-out", "learnings-edit.md") + for (const f of [triagePath, editPath]) { + assert.ok(fs.existsSync(f), `${path.basename(f)} must survive the failure`) + assert.ok( + fs.readFileSync(f, "utf8").includes("Do not document features behind experimental flags."), + `${path.basename(f)} must carry the checked-out rule`, + ) + } + + // An empty file must clear the stale block, not leave the earlier rule in place. + fs.writeFileSync(learningsPath, renderLearnings([])) + runNodeScript(LEARN_SCRIPT, { cwd: dir, env: failEnv }) + assert.ok(!fs.existsSync(triagePath), "an empty learnings file must remove learnings-triage.md") + assert.ok(!fs.existsSync(editPath), "an empty learnings file must remove learnings-edit.md") + } + + // 10t — the direct marker PATCH must not overwrite a concurrent body edit + // The body read at step 1 predates the extraction call, so learn.mjs must re-read + // the body immediately before the PATCH. + { + console.log(" 10t — marker PATCH preserves a concurrent body edit") + const dir = mktemp("docs-sync-learn-t-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", "docs/auto-sync"]) + + // github-actions[bot] authored the only branch commit, so there is no candidate + // correction and no model call. The run goes straight to the direct marker PATCH. + const learningsPath = path.join(dir, "packages", "kilo-docs", "LEARNINGS.md") + fs.mkdirSync(path.dirname(learningsPath), { recursive: true }) + fs.writeFileSync(learningsPath, renderLearnings([])) + gitIn(dir, ["add", "packages/kilo-docs/LEARNINGS.md"]) + gitIn(dir, ["commit", "-m", "seed learnings", "--author", `github-actions[bot] <${githubBotEmail}>`]) + gitIn(dir, ["remote", "add", "origin", dir]) // learn.mjs fetches origin itself + const cwd = setupLearnRepo(dir) + const tip = gitIn(dir, ["rev-parse", "HEAD"]) + + // Stub GitHub API. The second read of the pull request returns the maintainer edit. + const serverDir = mktemp("docs-sync-api-t-") + const portFile = path.join(serverDir, "port") + const patchFile = path.join(serverDir, "patch.json") + const serverScript = path.join(serverDir, "server.cjs") + fs.writeFileSync( + serverScript, + `const fs = require("node:fs") +const http = require("node:http") +let reads = 0 +const json = (res, data) => { + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify(data)) +} +const server = http.createServer((req, res) => { + let raw = "" + req.on("data", (c) => (raw += c)) + req.on("end", () => { + if (req.method === "PATCH") return fs.writeFileSync(process.env.PATCH_FILE, raw), json(res, {}) + if (req.url.startsWith("/search/issues")) return json(res, { items: [{ number: 1 }] }) + if (req.url.includes("/comments")) return json(res, []) + if (req.url.includes("/pulls/1")) { + const body = reads++ === 0 ? process.env.BODY_BEFORE : process.env.BODY_AFTER + return json(res, { + number: 1, + body, + head: { ref: "docs/auto-sync" }, + user: { login: "github-actions[bot]" }, + }) + } + json(res, {}) + }) +}) +server.listen(0, "127.0.0.1", () => fs.writeFileSync(process.env.PORT_FILE, String(server.address().port))) +`, + ) + + const bodyBefore = "Rolling PR body.\n\n" + const humanEdit = "A maintainer edited the body while extraction ran." + const child = spawn(process.execPath, [serverScript], { + stdio: "ignore", + env: { + ...process.env, + PORT_FILE: portFile, + PATCH_FILE: patchFile, + BODY_BEFORE: bodyBefore, + BODY_AFTER: bodyBefore + humanEdit + "\n", + }, + }) + + try { + let port = "" + for (let i = 0; i < 100 && !port; i++) { + if (fs.existsSync(portFile)) port = fs.readFileSync(portFile, "utf8").trim() + else sleepSync(50) + } + assert.ok(port, "the stub API server must report a port") + + const result = runNodeScript(LEARN_SCRIPT, { + cwd, + env: { + TRIAGE_MODEL: "test/model", + GITHUB_REPOSITORY: "acme/repo", + GH_TOKEN: "stub-token", + DOCS_SYNC_API_BASE: `http://127.0.0.1:${port}`, + GITHUB_OUTPUT: path.join(dir, "gh-output-t"), + GITHUB_STEP_SUMMARY: path.join(dir, "gh-summary-t"), + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + assert.equal(result.status, 0, `learn.mjs must succeed against the stub API: ${result.output}`) + + assert.ok(fs.existsSync(patchFile), "the run must PATCH the pull request body") + const patchedBody = JSON.parse(fs.readFileSync(patchFile, "utf8")).body + assert.ok(patchedBody.includes(humanEdit), "the concurrent body edit must survive the marker PATCH") + assert.ok(patchedBody.includes(tip), "the PATCH must carry the new tip SHA") + assert.ok(!patchedBody.includes("commit=old"), "the old marker must be replaced") + assert.equal( + (patchedBody.match(/\nmore" + const parsed = parseLearnedThrough(body) + assert.equal(parsed.commit, "abc1234") + assert.equal(parsed.comment, "2026-08-03T12:00:00Z") + + // none values → null + const noneBody = "" + const noneParsed = parseLearnedThrough(noneBody) + assert.equal(noneParsed.commit, null) + assert.equal(noneParsed.comment, null) + + // absent → both null + const absent = parseLearnedThrough("no marker") + assert.equal(absent.commit, null) + assert.equal(absent.comment, null) + } + + // renderLearnings deterministic order + { + console.log(" 10 — renderLearnings deterministic order") + const entries = [ + { id: "b", rule: "B rule.", scope: "both", source: "commit:bb", date: "2026-08-02" }, + { id: "a", rule: "A rule.", scope: "both", source: "commit:aa", date: "2026-08-01" }, + { id: "c", rule: "C rule.", scope: "both", source: "commit:cc", date: "2026-08-01" }, + ] + const r1 = renderLearnings(entries) + const r2 = renderLearnings(entries) + assert.equal(r1, r2, "renderLearnings must be deterministic") + // Order: date ascending, then id ascending. So a before b before c (a.date < c.date, both before b) + const aPos = r1.indexOf("A rule") + const cPos = r1.indexOf("C rule") + const bPos = r1.indexOf("B rule") + assert.ok(aPos < cPos, "a (earlier date) must come before c") + assert.ok(cPos < bPos, "c (same date as a but later id) must come before b (later date)") + } +} + +// Case 11 — the created rolling PR gets an assignee and a review request +function case11_prOwner() { + console.log("case 11 — created PR assignee and reviewer") + const src = fs.readFileSync(path.join(HERE, "upsert-pr.mjs"), "utf8") + + assert.ok(/const DOCS_OWNER = "\S+"/.test(src), "DOCS_OWNER must be a module constant") + assert.ok(src.includes("/assignees`, {"), "created PR must POST assignees") + assert.ok(src.includes("/requested_reviewers`, {"), "created PR must POST requested_reviewers") + + // Both calls belong to the create arm, after the PR exists. + const createIdx = src.indexOf("const pr = await api(`/repos/${repo()}/pulls`") + assert.ok(createIdx >= 0, "create-PR call must exist") + assert.ok(src.indexOf("/assignees`, {") > createIdx, "assignees POST must follow PR creation") + assert.ok(src.indexOf("/requested_reviewers`, {") > createIdx, "reviewer POST must follow PR creation") + + // A failure here must not fail the run — the PR is already open. + const ownerIdx = src.indexOf("/assignees`, {") + const tryIdx = src.lastIndexOf("try {", ownerIdx) + const catchIdx = src.indexOf("} catch", ownerIdx) + assert.ok(tryIdx >= 0 && catchIdx > src.indexOf("/requested_reviewers`, {"), "both POSTs must sit in one try/catch") +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -1846,6 +3473,8 @@ function main() { case7_cap, case8_triage, case9_reverts, + case10_learnings, + case11_prOwner, ] let failed = 0 for (const fn of cases) { diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index a01eef0a998..eb680846510 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -21,6 +21,7 @@ import path from "node:path" import { fileURLToPath } from "node:url" import { parseTriageEntries } from "./extract-json.mjs" import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" +import { readLearningsBlock } from "./learn.mjs" const CHUNK_SIZE = 25 const ATTEMPTS = 3 @@ -28,7 +29,7 @@ const OUT_DIR = "docs-sync-out" const CHUNK_TIMEOUT_MS = 10 * 60 * 1000 const HERE = path.dirname(fileURLToPath(import.meta.url)) -const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") +const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") + readLearningsBlock("triage") const model = process.env.TRIAGE_MODEL if (!model) throw new Error("TRIAGE_MODEL is required") @@ -115,9 +116,7 @@ function triageChunk(chunk, index, budgetDeadline) { console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`) sleepSync(wait) } else if (wait > 0) { - console.warn( - `chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`, - ) + console.warn(`chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`) } } } @@ -125,7 +124,12 @@ function triageChunk(chunk, index, budgetDeadline) { console.warn( `::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`, ) - return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`)) + return chunk.map((d) => + pendingEntry( + d, + lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`, + ), + ) } const chunks = [] diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index 1ef25b75964..d4b7d2a21fc 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -27,15 +27,23 @@ const FILE_CAP = 15 const ROW_CAP = 150 const PENDING_DISPLAY_CAP = 60 const SUMMARY_FILE = ".docs-sync-summary.json" +// Owner of the rolling docs PR: assigned and asked for review on creation. +const DOCS_OWNER = "emilieschario" const DOCS_PATH = "packages/kilo-docs" +export const LEARNINGS_FILE = "packages/kilo-docs/LEARNINGS.md" -const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() +const git = (args) => + execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }) + .toString() + .trim() // Agent-generated strings land in the PR body next to machine-read markers. // Strip HTML-comment sequences so a crafted/adversarial value cannot forge // section boundaries or the processed-through watermark. function clean(value) { - return String(value ?? "").replaceAll("", "") + return String(value ?? "") + .replaceAll("", "") } function shortRef(url) { @@ -52,7 +60,9 @@ function skippedRow(e) { } function pendingRow(e) { - const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ") + const reason = clean(e.reason ?? e.cause ?? "") + .replaceAll("|", "\\|") + .replaceAll("\n", " ") return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` } @@ -79,7 +89,18 @@ function section(name, header, rows) { return `\n${body}\n` } -export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) { +export function renderBody({ + date, + since, + through, + learnedThrough = "", + changesRows, + pendingRows, + skippedRows, + verified, + draftReasons, + note, +}) { const pendingDisplay = pendingRows.length > PENDING_DISPLAY_CAP ? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`] @@ -108,7 +129,7 @@ ${section("skipped", "| PR | Reason |", skippedRows)} (bot) Generated by the docs-sync workflow. Humans review and merge; while this PR stays open, the next daily run appends new changes here. Branch: \`${BRANCH}\`. -` +${learnedThrough ? learnedThrough + "\n" : ""}` } function mergeRows(oldRows, newRows) { @@ -242,6 +263,23 @@ export function computeProcessedThrough({ uncovered, digest, now, fallback }) { return new Date(earliest - 1).toISOString() } +/** + * Content gate: legitimate bot edits are docs pages and nav files. Only + * those are built and tested during verify (content-integrity.test.ts + * walks pages/ only), so anything else in the docs package forces human + * review. LEARNINGS.md is a root-level .md file, like the three sibling + * .md files already at that level, so it is not built or tested and is + * safe to exclude from the gate. + */ +export function nonContentFiles(changedFiles) { + return (Array.isArray(changedFiles) ? changedFiles : []).filter( + (f) => + f !== LEARNINGS_FILE && + !f.startsWith("packages/kilo-docs/pages/") && + !f.startsWith("packages/kilo-docs/lib/nav/"), + ) +} + /** * Route summary + triage into the three body sections. * changesRows = action neither skipped nor pending @@ -280,6 +318,22 @@ export function dropLegacySkipped(rows) { }) } +/** + * Resolve the learned-through marker for renderBody. + * + * Order: env LEARNED_THROUGH when set and non-empty; else the marker + * parsed out of the existing PR body; else "". + * The fallback is load-bearing: a run where extraction was skipped, + * failed, or already PATCHed the marker itself must not clobber a good + * marker. + */ +export function resolveLearnedThrough({ envValue, prBody }) { + const fromEnv = String(envValue ?? "").trim() + if (fromEnv) return fromEnv + const m = String(prBody ?? "").match(//) + return m ? m[0] : "" +} + /** * No-diff early-return report. Returns summary markdown and an optional * replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit @@ -347,18 +401,14 @@ async function main() { const through = computeProcessedThrough({ uncovered, digest, now, fallback: since }) // The draft cap bounds the cumulative PR diff, not just this run's commit. - const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH]) - .split("\n") - .filter(Boolean) + const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH]).split("\n").filter(Boolean) const draftReasons = [] if (changedFiles.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${changedFiles.length})`) if (!verified) draftReasons.push("docs build/tests not passing") // Content gate: legitimate bot edits are docs pages and nav files. Anything // else in the docs package (build config, components, tests) executes // during the verify build, so force human review before merge. - const nonContent = changedFiles.filter( - (f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"), - ) + const nonContent = nonContentFiles(changedFiles) if (nonContent.length > 0) { // File paths are agent-chosen; sanitize before they land in the PR body. const listed = nonContent @@ -369,9 +419,17 @@ async function main() { } const draft = draftReasons.length > 0 - git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`]) + git( + mode === "update" + ? ["push", "origin", `HEAD:${BRANCH}`] + : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`], + ) - const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({ + const { + changesRows: changesNew, + pendingRows: pendingNew, + skippedRows: skippedNew, + } = routeRows({ summary: agentSummary, triage, uncovered, @@ -380,8 +438,10 @@ async function main() { let oldChanges = [] let oldSkipped = [] let oldPending = [] + let prBody = "" if (mode === "update" && existingPr) { const pr = await api(`/repos/${repo()}/pulls/${existingPr}`) + prBody = pr.body ?? "" oldChanges = extractSectionRows(pr.body, "changes") oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped")) oldPending = extractSectionRows(pr.body, "pending") @@ -392,10 +452,13 @@ async function main() { // extractSectionRows stays exercised; discarded deliberately. void oldPending + const learnedThrough = resolveLearnedThrough({ envValue: process.env.LEARNED_THROUGH, prBody }) + const body = renderBody({ date, since, through, + learnedThrough, changesRows: mergeRows(oldChanges, changesNew), pendingRows: pendingNew, skippedRows: mergeRows(oldSkipped, skippedNew), @@ -445,6 +508,20 @@ async function main() { prNumber = pr.number prUrl = pr.html_url await api(`/repos/${repo()}/issues/${prNumber}/labels`, { method: "POST", body: { labels: ["auto-docs"] } }) + // Best effort: the PR already exists here, so a non-collaborator or a + // revoked account must not fail the run. + try { + await api(`/repos/${repo()}/issues/${prNumber}/assignees`, { + method: "POST", + body: { assignees: [DOCS_OWNER] }, + }) + await api(`/repos/${repo()}/pulls/${prNumber}/requested_reviewers`, { + method: "POST", + body: { reviewers: [DOCS_OWNER] }, + }) + } catch (err) { + console.warn(`::warning::docs-sync: could not assign or request review from ${DOCS_OWNER}: ${err.message}`) + } if (mode === "conflict" && existingPr) { await api(`/repos/${repo()}/issues/${existingPr}/comments`, { method: "POST", @@ -459,7 +536,9 @@ async function main() { appendSummary( `### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`, ) - console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`) + console.log( + `PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`, + ) } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 147cde8a474..1ae78f3edb4 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -66,13 +66,13 @@ jobs: sync: if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request' runs-on: blacksmith-4vcpu-ubuntu-2404 - # Budget: 4 setup/collect + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 228 min, 12-minute reserve. + # Budget: 4 setup/collect + 10 learn + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 238 min, 12-minute reserve. # These are ceilings, not costs: a caught-up run triages ~2 chunks and edits # ~1 batch and finishes in ~25 min. The old 35/50 pair was the binding # constraint on backlog drain — run 30306629290 deferred 54 PRs untriaged and # 31 unedited purely on budget, with no attempt made. See the throughput note # in the PR description for the arithmetic. - timeout-minutes: 240 + timeout-minutes: 250 env: # Both are required: without KILO_ORG_ID the gateway bills the key # owner's personal balance (402 "Add credits") instead of the org. @@ -110,6 +110,15 @@ jobs: INPUT_SINCE: ${{ inputs.since }} run: node .github/docs-sync/watermark.mjs + - name: Learn from maintainer corrections + id: learn + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + LEARNINGS_BUDGET_MINUTES: "10" + DRY_RUN: ${{ inputs.dry_run }} + run: node .github/docs-sync/learn.mjs + - name: Collect merged PRs id: collect env: @@ -215,10 +224,15 @@ jobs: echo "ok=false" >> "$GITHUB_OUTPUT" fi + - name: Write the learnings file + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + run: node .github/docs-sync/learn.mjs --apply + - name: Upsert rolling PR if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true env: GH_TOKEN: ${{ github.token }} + LEARNED_THROUGH: ${{ steps.learn.outputs.learned_through }} PROCESSED_THROUGH: ${{ steps.wm.outputs.now }} SINCE: ${{ steps.wm.outputs.since }} SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} diff --git a/bun.lock b/bun.lock index e2b68afdf4f..67811a9f0ef 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.4.18", + "version": "7.4.20", "bin": { "opencode": "./bin/opencode", }, @@ -127,7 +127,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -141,7 +141,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "effect": "catalog:", }, @@ -153,7 +153,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node-shared": "4.0.0-beta.74", @@ -174,7 +174,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-web-ui": "workspace:*", @@ -197,7 +197,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -227,7 +227,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.82", @@ -263,7 +263,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.4.18", + "version": "7.4.20", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -273,7 +273,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -309,7 +309,7 @@ }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -323,7 +323,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -338,7 +338,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -352,7 +352,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -390,7 +390,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -459,7 +459,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -476,7 +476,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -494,7 +494,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.4.18", + "version": "7.4.20", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -661,7 +661,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -689,7 +689,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -703,7 +703,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "semver": "^7.6.3", }, @@ -714,7 +714,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "cross-spawn": "catalog:", }, @@ -729,7 +729,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -743,7 +743,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.4.18", + "version": "7.4.20", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -766,7 +766,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -793,7 +793,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.4.18", + "version": "7.4.20", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index cb7a4a91b07..e55aa0cf85f 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,6 @@ "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.4.18", + "version": "7.4.20", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index e07c580a5de..a5e0eb65ff1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.18", + "version": "7.4.20", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 93833414265..647470686af 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -1,7 +1,7 @@ export * as FileSystemSearch from "./search" import path from "path" -import { Context, Effect, Layer, Scope } from "effect" +import { Context, Duration, Effect, Layer, Scope } from "effect" import { Fff } from "#fff" import fuzzysort from "fuzzysort" import { FileSystem } from "../filesystem" @@ -77,7 +77,8 @@ export const ripgrepLayer = Layer.effect( }) .pipe( Effect.map((result) => - result.items.map( // kilocode_change + result.items.map( + // kilocode_change (entry) => new FileSystem.Entry({ ...entry, @@ -105,7 +106,8 @@ export const ripgrepLayer = Layer.effect( }) .pipe( Effect.map((result) => - result.items.map( // kilocode_change + result.items.map( + // kilocode_change (match) => new FileSystem.Match({ ...match, @@ -166,27 +168,65 @@ export const fffLayer = Layer.effect( return real !== undefined && FSUtil.contains(root.path, real) }) // kilocode_change end - const result = yield* Effect.try({ - try: () => - Fff.create({ - basePath: location.directory, - aiMode: true, - ...scanning(location.directory), // kilocode_change - permit broad scanning only at the exact boundary. - }), - catch: (cause) => cause, - }).pipe(Effect.orDie) - if (!result.ok) return yield* Effect.die(result.error) - yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore)) + // kilocode_change start - defer FFF until search because other location consumers do not need its native index. + const scope = yield* Scope.Scope + const release = (entry: { finder: { destroy(): void }; closed: boolean }) => + Effect.sync(() => { + if (entry.closed) return + entry.closed = true + entry.finder.destroy() + }).pipe(Effect.ignore) + const make = Effect.uninterruptible( + Effect.gen(function* () { + const result = yield* Effect.try({ + try: () => + Fff.create({ + basePath: location.directory, + aiMode: true, + disableMmapCache: true, + disableContentIndexing: true, + ...scanning(location.directory), + }), + catch: (cause) => cause, + }).pipe(Effect.orDie) + if (!result.ok) return yield* Effect.die(result.error) + const entry = { finder: result.value, closed: false } + yield* Scope.addFinalizer(scope, release(entry)) + return entry + }), + ) + const [load, invalidate] = yield* Effect.cachedInvalidateWithTTL( + make.pipe( + Effect.flatMap((entry) => + Effect.promise(() => entry.finder.waitForScan()) + .pipe( + Effect.orDie, + Effect.onError(() => release(entry)), + ) + .pipe( + Effect.flatMap((scan) => { + if (!scan.ok || !scan.value) return Effect.die(new Error("FFF initial scan did not complete")) + return Effect.succeed(entry) + }), + ), + ), + ), + Duration.infinity, + ) + const get = load.pipe(Effect.onError(() => invalidate)) + yield* Scope.addFinalizer(scope, invalidate) + // kilocode_change end return Service.of({ glob: (input) => // kilocode_change start Effect.gen(function* () { const { root, target } = yield* inspect(input.path) - // kilocode_change end + const result = yield* get + // kilocode_change end const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") // kilocode_change start const found = yield* Effect.sync(() => - result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { + result.finder.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { pageIndex: 0, pageSize: input.limit, }), @@ -197,7 +237,7 @@ export const fffLayer = Layer.effect( yield* SearchTarget.validate(fs, target).pipe(Effect.orDie) const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath)) return items.map((item) => { - // kilocode_change end + // kilocode_change end const absolute = path.resolve(location.directory, item.relativePath) return new FileSystem.Entry({ path: RelativePath.make(item.relativePath.replaceAll("\\", "/")), @@ -210,24 +250,26 @@ export const fffLayer = Layer.effect( // kilocode_change start Effect.gen(function* () { const { root, target } = yield* inspect(input.path) - // kilocode_change end + const result = yield* get + // kilocode_change end const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") // kilocode_change start - const found = yield* Effect.sync(() => - result.value.grep( - [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] - .filter((value) => value !== undefined) - .join(" "), - { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, - ), - // kilocode_change end + const found = yield* Effect.sync( + () => + result.finder.grep( + [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] + .filter((value) => value !== undefined) + .join(" "), + { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, + ), + // kilocode_change end ) if (!found.ok) throw found.error // kilocode_change start yield* SearchTarget.validate(fs, target).pipe(Effect.orDie) const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath)) return items.map((match) => { - // kilocode_change end + // kilocode_change end const bytes = Buffer.from(match.lineContent) return new FileSystem.Match({ entry: new FileSystem.Entry({ @@ -247,11 +289,13 @@ export const fffLayer = Layer.effect( }) }), find: (input) => - Effect.sync(() => { + Effect.gen(function* () { + // kilocode_change - load the native index only for an actual search. + const result = yield* get // kilocode_change const options = { pageIndex: 0, pageSize: input.limit ?? 50 } const items = (() => { if (input.type === "file") { - const found = result.value.fileSearch(input.query.trim(), options) + const found = result.finder.fileSearch(input.query.trim(), options) if (!found.ok) throw found.error return found.value.items.map((item, index) => ({ path: item.relativePath, @@ -260,7 +304,7 @@ export const fffLayer = Layer.effect( })) } if (input.type === "directory") { - const found = result.value.directorySearch(input.query.trim(), options) + const found = result.finder.directorySearch(input.query.trim(), options) if (!found.ok) throw found.error return found.value.items.map((item, index) => ({ path: item.relativePath, @@ -268,7 +312,7 @@ export const fffLayer = Layer.effect( score: found.value.scores[index]?.total ?? 0, })) } - const found = result.value.mixedSearch(input.query.trim(), options) + const found = result.finder.mixedSearch(input.query.trim(), options) if (!found.ok) throw found.error return found.value.items.map((item, index) => ({ path: item.item.relativePath, diff --git a/packages/core/src/kilocode/ripgrep-grep.ts b/packages/core/src/kilocode/ripgrep-grep.ts deleted file mode 100644 index f92cc210f99..00000000000 --- a/packages/core/src/kilocode/ripgrep-grep.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Match } from "../filesystem/schema" - -export interface Options { - readonly context?: number - readonly literal?: boolean - readonly ignoreCase?: boolean -} - -export type GrepMatch = Match & { - readonly context: boolean - readonly textTruncated: boolean -} - -export const flags = (input: Options) => [ - ...(input.literal ? ["--fixed-strings"] : []), - ...(input.ignoreCase ? ["--ignore-case"] : []), - ...(input.context ? [`--context=${input.context}`] : []), -] - -export const stop = (limit: number) => { - let matches = 0 - return (row: { readonly context: boolean }) => !row.context && ++matches > limit -} - -export const select = < - A extends { - readonly context: boolean - readonly path: { readonly text: string } - readonly line_number: number - }, ->( - input: { readonly limit: number; readonly context?: number }, - items: readonly A[], -) => { - let count = 0 - const overflow = items.findIndex((row) => !row.context && ++count > input.limit) - const selected = items.slice(0, overflow === -1 ? items.length : overflow) - const matches = selected.filter((row) => !row.context) - return selected.filter( - (row) => - !row.context || - matches.some( - (match) => - match.path.text === row.path.text && Math.abs(match.line_number - row.line_number) <= (input.context ?? 0), - ), - ) -} - -export const decorate = (match: Match, context: boolean, textTruncated: boolean): GrepMatch => ({ - ...match, - context, - textTruncated, -}) diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 5ee202a861d..e1545195606 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -6,7 +6,6 @@ import path from "path" import { LayerNode } from "./effect/layer-node" import { Entry, Match } from "./filesystem/schema" import { FSUtil } from "./fs-util" -import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" @@ -24,7 +23,7 @@ const MAX_RECORD_BYTES = 64 * 1024 const MAX_SUBMATCHES = 100 const RawMatch = Schema.Struct({ - type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records + type: Schema.Literal("match"), data: Schema.Struct({ path: Schema.Struct({ text: Schema.String }), lines: Schema.Struct({ text: Schema.String }), @@ -40,7 +39,7 @@ const RawMatch = Schema.Struct({ }), }) -type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change +type RawMatchData = (typeof RawMatch.Type)["data"] export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, @@ -72,8 +71,7 @@ export interface GlobInput { readonly validate?: Effect.Effect // kilocode_change - bind approved searches at spawn } -export interface GrepInput extends KiloGrep.Options { - // kilocode_change +export interface GrepInput { readonly cwd: string readonly pattern: string readonly file?: string @@ -86,7 +84,7 @@ export interface GrepInput extends KiloGrep.Options { export interface Interface { readonly find: (input: FindInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect, Error> // kilocode_change - readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change + readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change } // kilocode_change start - retain truncation state through model-facing tools @@ -118,7 +116,6 @@ export const layer = Layer.effect( readonly parse: (line: string) => Effect.Effect readonly pattern?: string readonly onItem?: (item: A) => Effect.Effect - readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match readonly validate?: Effect.Effect // kilocode_change - spawn-bound target validation }) => { const program = Effect.scoped( @@ -139,13 +136,6 @@ export const layer = Layer.effect( Effect.forkScoped, ) let observed = 0 - let stopped = false // kilocode_change - const take = input.stop // kilocode_change start - ? Stream.takeUntil((row) => { - stopped = input.stop?.(row) ?? false - return stopped - }) - : Stream.take(input.limit + 1) // kilocode_change end const rows = yield* Stream.decodeText(handle.stdout).pipe( Stream.splitLines, Stream.filter((line) => line.length > 0), @@ -155,12 +145,11 @@ export const layer = Layer.effect( if (!input.onItem || observed++ >= input.limit) return Effect.void return input.onItem(row) }), - take, // kilocode_change + Stream.take(input.limit + 1), Stream.runCollect, Effect.map((chunk) => [...chunk]), ) - if (stopped) return { items: rows, truncated: true, partial: false } // kilocode_change - const truncated = input.stop ? false : rows.length > input.limit // kilocode_change - custom stop predicates own truncation + const truncated = rows.length > input.limit if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false } const code = yield* handle.exitCode @@ -260,13 +249,11 @@ export const layer = Layer.effect( grep: (input) => run({ ...input, - stop: KiloGrep.stop(input.limit), // kilocode_change args: [ "--no-config", "--json", "--hidden", "--no-messages", - ...KiloGrep.flags(input), // kilocode_change ...(input.include ? [`--glob=${input.include}`] : []), "--glob=!**/.git/**", "--", @@ -282,19 +269,13 @@ export const layer = Layer.effect( }) ).pipe( Effect.flatMap((json) => { - if ( - !json || - typeof json !== "object" || - !("type" in json) || - (json.type !== "match" && json.type !== "context") // kilocode_change - ) + if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") return Effect.succeed(undefined) return Schema.decodeUnknownEffect(RawMatch)(json).pipe( Effect.map((match) => ({ ...match.data, path: { text: match.data.path.text.replace(/^\.[\\/]/, "") }, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), - context: match.type === "context", // kilocode_change })), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), ) @@ -304,13 +285,13 @@ export const layer = Layer.effect( // kilocode_change start - retain spawn metadata after mapping matches Effect.map((result) => ({ ...result, - items: KiloGrep.select(input, result.items).map((match) => { + items: result.items.map((match) => { const relative = match.path.text .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") const absolute = path.resolve(input.cwd, relative) - const item = new Match({ + return new Match({ entry: new Entry({ path: RelativePath.make(relative), type: "file", @@ -325,7 +306,6 @@ export const layer = Layer.effect( end: submatch.end, })), }) - return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000) }), })), // kilocode_change end diff --git a/packages/core/src/v1/config/permission.ts b/packages/core/src/v1/config/permission.ts index 36d2468bbec..6d6caa02abd 100644 --- a/packages/core/src/v1/config/permission.ts +++ b/packages/core/src/v1/config/permission.ts @@ -25,6 +25,7 @@ const InputObject = Schema.StructWithRest( bash: Schema.optional(Rule), task: Schema.optional(Rule), external_directory: Schema.optional(Rule), + markdown_source: Schema.optional(Rule), // kilocode_change - explicitly authorize external agent/command sources todowrite: Schema.optional(Action), question: Schema.optional(Action), webfetch: Schema.optional(Action), diff --git a/packages/core/test/kilocode/fff.test.ts b/packages/core/test/kilocode/fff.test.ts index 6be70c3dc95..686e0171bb3 100644 --- a/packages/core/test/kilocode/fff.test.ts +++ b/packages/core/test/kilocode/fff.test.ts @@ -1,7 +1,16 @@ import { describe, expect, test } from "bun:test" +import { FileFinder, type InitOptions } from "@ff-labs/fff-bun" +import "@opencode-ai/core/filesystem" +import { Fff } from "@opencode-ai/core/filesystem/fff.bun" +import { FSUtil } from "@opencode-ai/core/fs-util" import os from "os" import path from "path" +import { Context, Effect, Layer, Scope } from "effect" import { scanning } from "@opencode-ai/core/kilocode/fff" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" +import { tmpdir } from "../fixture/tmpdir" describe("FFF scanning boundaries", () => { test("enables filesystem-root scanning only at the exact root", () => { @@ -25,3 +34,81 @@ describe("FFF scanning boundaries", () => { }) }) }) + +describe("FFF lifecycle", () => { + test("retries a failed first search and reuses one picker", async () => { + if (!Fff.available()) return + + const dir = await tmpdir() + const create = FileFinder.create + const calls = { create: 0, destroy: 0, opts: undefined as InitOptions | undefined } + try { + FileFinder.create = (opts) => { + calls.create++ + if (calls.create === 1) return { ok: false, error: "transient failure" } + calls.opts = opts + const result = create(opts) + if (!result.ok) return result + const destroy = result.value.destroy.bind(result.value) + result.value.destroy = () => { + calls.destroy++ + destroy() + } + return result + } + + await Effect.runPromise( + Effect.acquireUseRelease( + Scope.make(), + (scope) => + Effect.gen(function* () { + const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search")) + const layer = FileSystemSearch.fffLayer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(dir.path) }, + { vcs: { type: "git", store: AbsolutePath.make(path.join(dir.path, ".git")) } }, + ), + ), + ), + ), + ) + const context = yield* Layer.buildWithScope(layer, scope) + const service = Context.get(context, FileSystemSearch.Service) + expect(calls.create).toBe(0) + + const first = yield* Effect.exit( + Effect.all( + [ + service.find({ query: "", type: "file", limit: 1 }), + service.find({ query: "", type: "file", limit: 1 }), + ], + { concurrency: "unbounded" }, + ), + ) + expect(first._tag).toBe("Failure") + expect(calls.create).toBe(1) + + yield* service.find({ query: "", type: "file", limit: 1 }) + expect(calls.create).toBe(2) + expect(calls.opts?.disableMmapCache).toBe(true) + expect(calls.opts?.disableContentIndexing).toBe(true) + + yield* service.find({ query: "", type: "file", limit: 1 }) + expect(calls.create).toBe(2) + expect(calls.destroy).toBe(0) + }), + (scope, exit) => Scope.close(scope, exit), + ), + ) + expect(calls.destroy).toBe(1) + } finally { + FileFinder.create = create + await dir[Symbol.asyncDispose]() + } + }) +}) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index f74abf88a80..a238ffb579c 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -80,17 +80,19 @@ const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* return { attachment, output, ended } }) -const waitForOutput = (output: Queue.Queue, text: string) => - Effect.gen(function* () { - let received = "" +const waitForOutput = (output: Queue.Queue, text: string) => { + let received = "" + const pull = Effect.gen(function* () { while (!received.includes(text)) received += yield* Queue.take(output) return received - }).pipe( + }) + return pull.pipe( Effect.timeoutOrElse({ duration: PTY_TEST_TIMEOUT, // kilocode_change - orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)), + orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}, received ${JSON.stringify(received)}`)), }), ) +} describe("pty", () => { it.live("returns typed not found errors for missing sessions", () => @@ -143,11 +145,13 @@ describe("pty", () => { ) // (script terminals forward raw output to xterm without transcoding). + // The child must outlive its output: an immediate exit can race bun-pty's + // reader thread and drop trailing bytes under load, so print then sleep. ptyTest("round-trips non-ASCII output byte-identically", () => Effect.gen(function* () { const pty = yield* Pty.Service const marker = "café-über-北京-🚀" - const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'"]) + const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'; sleep 5"]) const attached = yield* attachCollecting(info.id) expect(yield* waitForOutput(attached.output, marker)).toContain(marker) }), diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 7303d76d7ca..907db1f9a39 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.18", + "version": "7.4.20", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 2b297b5ecb3..4af05664a09 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.18", + "version": "7.4.20", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 5321bca1b32..50fe224ed2a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.4.18" +version = "7.4.20" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ff408eb4302..e931dce3e17 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.18", + "version": "7.4.20", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index c9097a403ce..2a024ace453 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.18", + "version": "7.4.20", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/LEARNINGS.md b/packages/kilo-docs/LEARNINGS.md new file mode 100644 index 00000000000..44bb6a77430 --- /dev/null +++ b/packages/kilo-docs/LEARNINGS.md @@ -0,0 +1,11 @@ +# docs-sync learnings + +Rules the docs-sync bot learned from maintainer corrections to its rolling pull request. +The bot reads this file at the start of every run and follows every rule below. + +To unlearn a rule, delete its line and commit. The next run reads this file from the +branch, so the rule is gone from its input, and the deletion itself is a correction the +extraction step is instructed not to undo. + + + diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 5f296eedeaf..f1c085a55f5 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.4.18", + "version": "7.4.20", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/customize/agents-md.md b/packages/kilo-docs/pages/customize/agents-md.md index 88d231e4ec0..4f2a58235c7 100644 --- a/packages/kilo-docs/pages/customize/agents-md.md +++ b/packages/kilo-docs/pages/customize/agents-md.md @@ -18,7 +18,7 @@ If you'd like to migrate your memory bank content to AGENTS.md: 1. Examine the contents in `.kilocode/rules/memory-bank/` 2. Move that content into your project's `AGENTS.md` file (or ask Kilo to do it for you) - {% /callout %} +{% /callout %} ## What is AGENTS.md? diff --git a/packages/kilo-docs/pages/customize/custom-subagents.md b/packages/kilo-docs/pages/customize/custom-subagents.md index 99c889db7f5..5c00ea8d77c 100644 --- a/packages/kilo-docs/pages/customize/custom-subagents.md +++ b/packages/kilo-docs/pages/customize/custom-subagents.md @@ -93,6 +93,20 @@ Define agents as markdown files with YAML frontmatter. Place them in: The **filename** (without `.md`) becomes the agent name. +If `.kilo/agents/` is a symlink to a directory outside the project, allow that exact source in your global `~/.config/kilo/kilo.jsonc`: + +```jsonc +{ + "permission": { + "markdown_source": { + "/path/to/shared/agents/*": "allow" + } + } +} +``` + +Project configuration cannot grant this permission. External agent files remain untrusted: `{env:...}` substitutions are blocked and `{file:...}` substitutions remain confined to the project. + ```markdown --- description: Reviews code for quality and best practices diff --git a/packages/kilo-docs/pages/customize/workflows.md b/packages/kilo-docs/pages/customize/workflows.md index cb55800c87d..9d6da762ff3 100644 --- a/packages/kilo-docs/pages/customize/workflows.md +++ b/packages/kilo-docs/pages/customize/workflows.md @@ -17,6 +17,20 @@ Workflows are Markdown files stored as **slash commands** in `.kilo/commands/`: - **Global commands**: `~/.config/kilo/commands/` (available in all projects) - **Project commands**: `[project]/.kilo/commands/` (project-specific) +If `.kilo/commands/` is a symlink to a directory outside the project, allow that exact source in your global `~/.config/kilo/kilo.jsonc`: + +```jsonc +{ + "permission": { + "markdown_source": { + "/path/to/shared/commands/*": "allow" + } + } +} +``` + +Project configuration cannot grant this permission. External command files remain untrusted: `{env:...}` substitutions are blocked and `{file:...}` substitutions remain confined to the project. + ### Basic Setup 1. Create a `.md` file with step-by-step instructions diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png index 6a4e3e8ba0b..e571fdb6845 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd794db4d2c038984f9e753ba70b8b0473d97a16713a9ff7255f0d2cf505d41a -size 758306 +oid sha256:033960b68a915d0b05ce539784998d0f84603f206689dbbd14005771d112faa4 +size 762429 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index e55bbbdd5df..bb5e6796df1 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.4.18", + "version": "7.4.20", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index d0bb964ca51..8188c1f825b 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { getKiloUrlFromToken } from "../auth/token.js" import { getDefaultHeaders, buildKiloHeaders } from "../headers.js" +import { resolveKiloGatewayBaseUrl } from "./url.js" import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js" export type KiloModelsResult = { @@ -121,6 +122,16 @@ export type KiloImageModelsResult = { error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } } +export type KiloTranscriptionModel = { + id: string + name: string +} + +export type KiloTranscriptionModelsResult = { + models: KiloTranscriptionModel[] + error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } +} + /** * Fetch image-capable models from Kilo API (OpenRouter-compatible endpoint). * Uses the same raw fetch as {@link fetchKiloModels} but keeps only models @@ -145,6 +156,52 @@ export async function fetchKiloImageModels(options?: { return { models } } +export async function fetchKiloTranscriptionModels(options?: { + kilocodeToken?: string + kilocodeOrganizationId?: string + baseURL?: string +}): Promise { + const token = options?.kilocodeToken + const organizationId = options?.kilocodeOrganizationId + const url = new URL("transcription-models", resolveKiloGatewayBaseUrl({ baseURL: options?.baseURL, token })) + const response = await fetch(url, { + headers: { + ...getDefaultHeaders(), + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), + }).catch((err: unknown) => err as Error) + + if (response instanceof Error) return { models: [], error: { kind: "network" } } + if (!response.ok) { + const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http" + return { models: [], error: { kind, status: response.status } } + } + + const json = await response.json().catch(() => null) + if (!json || !Array.isArray(json.data)) return { models: [], error: { kind: "schema" } } + + const data: unknown[] = json.data + const models = data.filter(isTranscriptionModel).map((model) => ({ + id: model.id, + name: model.name, + })) + if (models.length === 0) return { models: [], error: { kind: "schema" } } + return { models } +} + +type TranscriptionModelResponse = { + id: string + name: string +} + +function isTranscriptionModel(value: unknown): value is TranscriptionModelResponse { + if (!value || typeof value !== "object") return false + const model = value as Record + return typeof model.id === "string" && typeof model.name === "string" +} + /** * Shared raw fetch + validate used by both {@link fetchKiloModels} and {@link fetchKiloImageModels}. */ diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index cee10c2f781..4df0e8a7681 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -41,6 +41,9 @@ export { fetchKiloImageModels, type KiloImageModel, type KiloImageModelsResult, + fetchKiloTranscriptionModels, + type KiloTranscriptionModel, + type KiloTranscriptionModelsResult, } from "./api/models.js" export { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 656d27eaf05..0833ec11f30 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -1,7 +1,7 @@ // Verifies fetchKiloModels typed result and 401 fallback behaviour. import { test, expect } from "bun:test" -import { fetchKiloModels } from "../../src/api/models.js" +import { fetchKiloModels, fetchKiloTranscriptionModels } from "../../src/api/models.js" const VALID_RESPONSE = JSON.stringify({ data: [ @@ -330,3 +330,56 @@ test("keeps image-output models with tools and drops models without tools", asyn expect(result.models["test/model-a"]).toBeDefined() expect(result.models["test/no-tools"]).toBeUndefined() }) + +test("fetches and filters the transcription catalog", async () => { + const orig = globalThis.fetch + const calls: string[] = [] + stubFetch(async (input) => { + calls.push(String(input)) + return new Response( + JSON.stringify({ + data: [ + { + id: "fish-audio/transcribe-1", + name: "Fish Audio: Transcribe 1", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) + + const result = await fetchKiloTranscriptionModels({ kilocodeToken: "token" }) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(result.models).toEqual([ + { + id: "fish-audio/transcribe-1", + name: "Fish Audio: Transcribe 1", + }, + ]) + expect(calls[0]).toContain("/api/gateway/transcription-models") +}) + +test("keeps organization catalog errors from silently falling back to personal models", async () => { + const orig = globalThis.fetch + const calls: string[] = [] + const headers: Headers[] = [] + stubFetch(async (input, init) => { + calls.push(String(input)) + headers.push(new Headers(init?.headers)) + return new Response("Forbidden", { status: 403 }) + }) + + const result = await fetchKiloTranscriptionModels({ kilocodeToken: "token", kilocodeOrganizationId: "org-1" }) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual([]) + expect(result.error?.kind).toBe("unauthorized") + expect(calls).toHaveLength(1) + expect(calls[0]).toContain("/api/gateway/transcription-models") + expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1") +}) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index b74de939f7d..ec845e49b51 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.4.18", + "version": "7.4.20", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 7f2e39eea54..aab1178e92f 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.4.18", + "version": "7.4.20", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 58f12c85b35..d000b8245ae 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 7.4.18 + +### Patch Changes + +- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`1a506a7`](https://github.com/Kilo-Org/kilocode/commit/1a506a712c43d317a5a34b250df16845b641eff8) - Keep the JetBrains prompt send/stop button in sync when attachments are added or removed while a session is busy. + +- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`64f0373`](https://github.com/Kilo-Org/kilocode/commit/64f0373056b75546a015816dc0f18b1e380ad93f) - Fix JetBrains diff views to show compact workspace-relative file paths and keep added-file content visible in large branch diffs. + +- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`c1f6a75`](https://github.com/Kilo-Org/kilocode/commit/c1f6a75377b438edfc5c3b5dd85ebdc301302e7a) - Fix JetBrains chat transcripts rendering cropped when opening existing sessions. + ## 7.5.0 ### Minor Changes @@ -150,6 +160,7 @@ ## [7.0.12-rc.4] - 2026-08-01 ### Fixed + - Improve large branch diff performance by capping huge inline diff previews, compacting diff tree paths, and allowing horizontal scrolling for long file names. - Reflow existing long chat sessions after they load so transcripts lay out at the correct width without needing to resize the tool window. - Keep the prompt send/stop button synchronized when attachments are added, removed, or cleared while a session is busy. diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 77d014030d1..e09355e19f2 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -20,9 +20,11 @@ val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.jso val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json") val generatedProps = layout.buildDirectory.dir("generated/kilo-props") val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res") +val generatedChecksums = layout.buildDirectory.dir("generated/kilo-cli-checksums") val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true) val repoCli = pinned.map { !it } val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false) +val downloadsCli = repoCli.zip(bundled) { repo, bundle -> !repo && !bundle } val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode") val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text -> @@ -33,6 +35,7 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector sourceSets { main { resources.srcDir(generatedProps) + if (downloadsCli.get()) resources.srcDir(generatedChecksums) if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli) kotlin.srcDir(generatedApi) } @@ -104,6 +107,16 @@ val stageBundledCli by tasks.registering(StageBundledCliTask::class) { archive.set(generatedCli.map { it.file("kilo-cli.zip") }) } +val writeCliChecksums by tasks.registering(WriteCliChecksumsTask::class) { + description = "Write pinned Kilo CLI checksums" + cliVersion.set(pinnedCliVersion) + token.set( + providers.environmentVariable("GH_TOKEN") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + ) + checksums.set(generatedChecksums.map { it.file("kilo-cli-checksums.properties") }) +} + val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) { description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation" dependsOn(generateOpenApiSpec) @@ -158,6 +171,7 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) { tasks.named("compileKotlin") { dependsOn(fixGeneratedApi, writeKiloProperties) + if (downloadsCli.get()) dependsOn(writeCliChecksums) if (repoCli.get()) dependsOn(stageRepoCli) if (bundled.get()) dependsOn(stageBundledCli) inputs.dir(generatedApi) @@ -165,6 +179,7 @@ tasks.named("compileKotlin") { tasks.named("processResources") { dependsOn(writeKiloProperties) + if (downloadsCli.get()) dependsOn(writeCliChecksums) if (repoCli.get()) dependsOn(stageRepoCli) if (bundled.get()) dependsOn(stageBundledCli) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliChecksums.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliChecksums.kt new file mode 100644 index 00000000000..a4c60eacf16 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliChecksums.kt @@ -0,0 +1,19 @@ +package ai.kilocode.backend.cli + +import java.util.Properties + +object KiloCliChecksums { + private const val RESOURCE = "kilo-cli-checksums.properties" + + private val values by lazy { + val stream = KiloCliChecksums::class.java.classLoader.getResourceAsStream(RESOURCE) + ?: return@lazy emptyMap() + stream.use { + Properties().apply { load(it) } + .entries + .associate { item -> item.key.toString() to item.value.toString() } + } + } + + fun load(): Map = values +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index 97ebcf0bcb1..bee74c12bb4 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -35,6 +35,7 @@ class KiloCliDownloader( private val root: File = File(PathManager.getSystemPath(), "kilo/cli"), private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download", private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags", + private val digests: Map = KiloCliChecksums.load(), private val lockTimeoutMs: Long = LOCK_TIMEOUT_MS, ) { companion object { @@ -64,7 +65,7 @@ class KiloCliDownloader( cached(version, platform, exe, done)?.let { return@locked it } } - val digest = asset(version, platform, ext) + val digest = digest(version, platform, ext) val stage = stage(version, platform) try { val archive = File(stage, "kilo-$platform.$ext") @@ -198,6 +199,17 @@ class KiloCliDownloader( throw IllegalStateException(message) } + private fun digest(version: String, platform: String, ext: String): String { + val digest = digests[platform] + if (digest == null) return asset(version, platform, ext) + if (digest.matches(DIGEST)) { + log.info("Using bundled Kilo CLI checksum for $version $platform") + return digest + } + log.warn("Ignoring malformed bundled Kilo CLI checksum for $platform: $digest") + return asset(version, platform, ext) + } + private fun asset(version: String, platform: String, ext: String): String { val name = "kilo-$platform.$ext" val url = "${api.trimEnd('/')}/v$version" diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt index 86b494da6a9..d1a38e4a6c3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliPlatform.kt @@ -4,6 +4,7 @@ import com.intellij.openapi.util.SystemInfo import com.intellij.util.system.CpuArch internal object KiloCliPlatform { + // Keep supported OS/architecture pairs in sync with Gradle CLI staging task platform lists. fun current(): String { val os = when { SystemInfo.isMac -> "darwin" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index f1971c8d981..8c489813a65 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -30,8 +30,8 @@ class KiloCliDownloaderTest { fun `downloads extracts and caches pinned cli`() = runBlocking { MockWebServer().use { server -> val bytes = archive() - server.enqueue(metadata(bytes)) server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val digests = digests(bytes) val seen = mutableListOf() val log = TestLog() val cli = KiloCliDownloader( @@ -39,13 +39,13 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = digests, ).resolve("1.2.3", onProgress = { seen.add(it) }) assertTrue(cli.isFile) assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath) assertEquals("#!/bin/sh\n", cli.readText()) assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile) - assertEquals("/api/v1.2.3", server.takeRequest().path) assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path) assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first()) assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() }) @@ -65,34 +65,83 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = digests, ).resolve("1.2.3", onProgress = { cachedProgress.add(it) }) assertEquals(cli.absolutePath, cached.absolutePath) - assertEquals(2, server.requestCount) + assertEquals(1, server.requestCount) assertTrue(cachedProgress.isEmpty()) assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}") File(cli.parentFile.parentFile, ".complete").writeText("ok\n") - server.enqueue(metadata(bytes)) server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) val stale = KiloCliDownloader( log = log, root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = digests, ).resolve("1.2.3") assertEquals(cli.absolutePath, stale.absolutePath) - assertEquals(4, server.requestCount) + assertEquals(2, server.requestCount) - server.enqueue(metadata(bytes)) server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) val forced = KiloCliDownloader( log = log, root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = digests, ).resolve("1.2.3", force = true) assertEquals(cli.absolutePath, forced.absolutePath) - assertEquals(6, server.requestCount) + assertEquals(3, server.requestCount) + } + } + + @Test + fun `falls back to github metadata when bundled checksum is missing`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + + val cli = KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + digests = mapOf("other" to "sha256:${"a".repeat(64)}"), + ).resolve("1.2.3") + + assertTrue(cli.isFile) + assertEquals("/api/v1.2.3", server.takeRequest().path) + assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path) + assertEquals(2, server.requestCount) + } + } + + @Test + fun `falls back to github metadata when bundled checksum is malformed`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + server.enqueue(metadata(bytes)) + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + val log = TestLog() + + val cli = KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + digests = mapOf(KiloCliPlatform.current() to "not-a-digest"), + ).resolve("1.2.3") + + assertTrue(cli.isFile) + assertContains( + log.messages, + "WARN: Ignoring malformed bundled Kilo CLI checksum for ${KiloCliPlatform.current()}: not-a-digest" + ) + assertEquals("/api/v1.2.3", server.takeRequest().path) + assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path) + assertEquals(2, server.requestCount) } } @@ -110,6 +159,7 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = emptyMap(), ).resolve("1.2.3") assertTrue(cli.isFile) @@ -132,6 +182,7 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = emptyMap(), ) val old = cli.resolve("1.2.3") assertEquals("#!/bin/old\n", old.readText()) @@ -158,12 +209,14 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = emptyMap(), ).resolve("1.2.3") val ex = assertFailsWith { KiloCliDownloader( root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = emptyMap(), ).resolve("1.2.3", force = true) } @@ -186,6 +239,7 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = emptyMap(), ).resolve("1.2.3") } @@ -194,6 +248,28 @@ class KiloCliDownloaderTest { } } + @Test + fun `rejects cli archive with mismatched bundled checksum without fetching metadata`() = runBlocking { + MockWebServer().use { server -> + val bytes = archive() + server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) + + val ex = assertFailsWith { + KiloCliDownloader( + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + digests = mapOf(KiloCliPlatform.current() to "sha256:${sha256("different".toByteArray())}"), + ).resolve("1.2.3") + } + + assertContains(ex.message.orEmpty(), "digest mismatch") + assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path) + assertEquals(1, server.requestCount) + assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists()) + } + } + @Test fun `fails clearly and logs when the release has no matching asset`() = runBlocking { MockWebServer().use { server -> @@ -209,6 +285,7 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = emptyMap(), ).resolve("1.2.3") } val name = "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}" @@ -233,6 +310,7 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = emptyMap(), ).resolve("1.2.3") } assertContains(ex.message.orEmpty(), "has no digest yet") @@ -261,6 +339,7 @@ class KiloCliDownloaderTest { root = dir, baseUrl = server.url("/release").toString(), api = server.url("/api").toString(), + digests = emptyMap(), ).resolve("1.2.3") } @@ -284,6 +363,7 @@ class KiloCliDownloaderTest { KiloCliDownloader( log = log, root = dir, + digests = emptyMap(), lockTimeoutMs = 50, ).resolve("1.2.3") } @@ -307,6 +387,8 @@ class KiloCliDownloaderTest { private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}") + private fun digests(bytes: ByteArray) = mapOf(KiloCliPlatform.current() to "sha256:${sha256(bytes)}") + private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody( """{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}""" ) diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt index 1c25aaec60f..2413ce39c74 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/BuildTasksPlugin.kt @@ -7,8 +7,8 @@ import org.gradle.api.Project * `id("build-tasks")` resolves in `backend/build.gradle.kts`. * * The real value lives in the custom task classes this composite build - * provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], and - * [GenerateOpenApiSpecTask]. + * provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], + * [GenerateOpenApiSpecTask], and [WriteCliChecksumsTask]. */ class BuildTasksPlugin : Plugin { override fun apply(target: Project) {} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt new file mode 100644 index 00000000000..f5d6d8727a0 --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/WriteCliChecksumsTask.kt @@ -0,0 +1,128 @@ +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.net.HttpURLConnection +import java.net.URI +import java.time.Instant + +abstract class WriteCliChecksumsTask : DefaultTask() { + companion object { + private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") + private val JSON = Json { ignoreUnknownKeys = true } + private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags" + // Keep in sync with KiloCliPlatform.current() and StageBundledCliTask.PLATFORMS. + private val PLATFORMS = listOf( + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "windows-arm64", + "windows-x64", + ) + } + + @get:Input + abstract val cliVersion: Property + + @get:Internal + abstract val token: Property + + @get:OutputFile + abstract val checksums: RegularFileProperty + + @TaskAction + fun run() { + val ver = cliVersion.get() + val assets = assets(ver) + val values = PLATFORMS.associateWith { platform -> + val name = "kilo-$platform.${ext(platform)}" + assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name") + } + + val out = checksums.get().asFile + out.parentFile.mkdirs() + out.writeText( + values.entries + .sortedBy { it.key } + .joinToString(separator = "\n", postfix = "\n") { item -> "${item.key}=${item.value}" } + ) + } + + private fun assets(ver: String): Map { + val url = "$API/v$ver" + logger.lifecycle("Fetching pinned Kilo CLI release checksums from $url") + val conn = connect(url) + try { + val code = conn.responseCode + if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release checksums") + val body = conn.inputStream.bufferedReader().use { it.readText() } + return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray + ?.associate { item -> + val obj = item.jsonObject + val name = obj["name"]?.jsonPrimitive?.contentOrNull + val digest = obj["digest"]?.jsonPrimitive?.contentOrNull + if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to "" + name to digest + } + ?.filter { it.key.isNotEmpty() } + ?.mapValues { item -> + val digest = item.value + if (!digest.matches(DIGEST)) { + throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest") + } + digest + } + ?: emptyMap() + } finally { + conn.disconnect() + } + } + + private fun connect(url: String): HttpURLConnection { + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.instanceFollowRedirects = true + conn.setRequestProperty("Accept", "application/vnd.github+json") + token.getOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { conn.setRequestProperty("Authorization", "Bearer $it") } + return conn + } + + private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing { + val info = rate(conn) + val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } } + .getOrNull() + ?.take(500) + val detail = if (body.isNullOrBlank()) "" else ": $body" + if (limited(conn, code)) { + throw GradleException("GitHub API rate limit exceeded while fetching Kilo CLI checksums ($info)$detail") + } + throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail") + } + + private fun rate(conn: HttpURLConnection): String { + val reset = conn.getHeaderField("X-RateLimit-Reset") + ?.toLongOrNull() + ?.let { Instant.ofEpochSecond(it).toString() } + return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " + + "used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}" + } + + private fun limited(conn: HttpURLConnection, code: Int) = + code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0") + + private fun ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt index ffb642a7e96..214df0ab6a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt @@ -18,35 +18,55 @@ data class PromptAttachment( val mime: String, val url: String, val path: Path? = null, + val reference: Boolean = false, ) { - fun part() = PromptPartDto( - type = "file", - mime = mime, - url = path?.let { data(it, mime) } ?: url, - filename = name, - ) + fun part(): PromptPartDto { + if (reference) { + return PromptPartDto( + type = "file", + mime = mime, + url = url, + filename = name, + ) + } + return PromptPartDto( + type = "file", + mime = mime, + url = path?.let { data(it, mime) } ?: url, + filename = name, + ) + } } object PromptAttachmentExtractor { private const val MAX_BYTES = 10 * 1024 * 1024 fun files(files: List): List = files - .filter { it.exists() && it.isFile && it.canRead() && it.length() <= MAX_BYTES } - .map { file -> + .filter { it.exists() && it.canRead() } + .mapNotNull { file -> val path = file.toPath() val mime = mime(file) - if (!media(mime)) return@map null - PromptAttachment( + if (image(mime)) { + if (!file.isFile || file.length() > MAX_BYTES) return@mapNotNull null + return@mapNotNull PromptAttachment( + id = path.toAbsolutePath().normalize().toString(), + name = path.fileName?.toString() ?: path.name, + mime = mime, + url = path.toUri().toString(), + path = path, + ) + } + return@mapNotNull PromptAttachment( id = path.toAbsolutePath().normalize().toString(), name = path.fileName?.toString() ?: path.name, - mime = mime, + mime = if (file.isDirectory) mime else "text/plain", url = path.toUri().toString(), path = path, + reference = true, ) } - .filterNotNull() - fun media(mime: String): Boolean = mime.startsWith("image/") || mime == "text/plain" + fun image(mime: String): Boolean = mime.startsWith("image/") fun image(raw: Any): PromptAttachment? { val image = when (raw) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index ba7c2a03530..dbea7374493 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -726,7 +726,7 @@ class PromptPanel( @RequiresEdt private fun addAttachment(item: PromptAttachment) { - if (!attachment && PromptAttachmentExtractor.media(item.mime)) { + if (!attachment && !item.reference && PromptAttachmentExtractor.image(item.mime)) { LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=unsupported-model" } notify(KiloBundle.message("prompt.attachment.unsupported.model")) return @@ -813,7 +813,15 @@ class PromptPanel( val items = PromptAttachmentExtractor.files(list) + listOfNotNull(image) val ms = elapsedMs(start) LOG.debug { "kind=$kind extract area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" } - if (items.isEmpty()) return@executeOnPooledThread + if (items.isEmpty()) { + if (list.isNotEmpty()) { + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + notify(KiloBundle.message("prompt.attachment.drop.empty")) + } + } + return@executeOnPooledThread + } ApplicationManager.getApplication().invokeLater { if (project.isDisposed) return@invokeLater LOG.debug { "kind=$kind attach area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" } @@ -827,7 +835,9 @@ class PromptPanel( private fun dropFiles(event: DnDEvent): List { if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) return emptyList() - return FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject).orEmpty() + val files = FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject) + if (files.isNotEmpty()) return files + return FileCopyPasteUtil.getFileList(event).orEmpty() } private fun elapsedMs(start: Long) = (System.nanoTime() - start) / 1_000_000 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 838d66d0ecd..0140ed4d7bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -217,7 +217,8 @@ prompt.attachment.remove=Remove {0} prompt.attachment.open=Open {0} prompt.attachment.tooltip=Name: {0}\nType: {1}\nLocation: {2} prompt.attachment.embedded=Embedded content -prompt.attachment.unsupported.model=The selected model does not support image or PDF attachments. +prompt.attachment.unsupported.model=The selected model does not support image attachments. +prompt.attachment.drop.empty=No files could be added. prompt.attachment.missing=Attachment no longer exists: {0} prompt.attachment.send.failed=Failed to send attachment: {0} session.attachment.title=Attachment diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptAttachmentExtractorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptAttachmentExtractorTest.kt new file mode 100644 index 00000000000..8b476859313 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptAttachmentExtractorTest.kt @@ -0,0 +1,83 @@ +package ai.kilocode.client.session.model + +import junit.framework.TestCase +import java.io.File +import kotlin.io.path.createTempDirectory + +class PromptAttachmentExtractorTest : TestCase() { + + fun `test code file becomes reference attachment`() { + val file = File.createTempFile("kilo-drop", ".php") + file.writeText(" sent = files.single() }, {}, { _, _ -> }) val file = File.createTempFile("kilo-paste", ".txt") @@ -990,8 +991,7 @@ class PromptPanelTest : BasePlatformTestCase() { val item = sent!! assertEquals("text/plain", item.mime) - assertTrue(item.url.orEmpty().startsWith("data:text/plain;base64,")) - assertFalse(item.url.orEmpty().startsWith("file://")) + assertEquals(file.toPath().toUri().toString(), item.url) } fun `test raw image paste adds attachment`() { @@ -1038,6 +1038,18 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(0, panel.attachmentCountForTest()) } + fun `test disabled media model allows file reference attachment`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + val file = File.createTempFile("kilo-paste", ".php") + file.writeText(" }, onAbort = {}, onEnhance = { _, _ -> }) diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 57a8e11ef2d..e5637fdf3df 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.4.17", + "version": "7.4.20", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index 4989cfc0f2c..24b2e775f62 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.4.18", + "version": "7.4.20", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index 1355fc2e076..0f992f992ab 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.4.18", + "version": "7.4.20", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 63a01315655..4ce691dbb55 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.4.18", + "version": "7.4.20", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index 939bb5dd243..c86a249f51f 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -85,6 +85,19 @@ describe("TelemetryEvent", () => { }) describe("Telemetry", () => { + test("skips identity updates when disabled", async () => { + const enabled = spyOn(Client, "isEnabled").mockReturnValue(false) + const update = spyOn(Identity, "updateFromKiloAuth").mockResolvedValue() + + try { + await Telemetry.updateIdentity("token") + expect(update).not.toHaveBeenCalled() + } finally { + enabled.mockRestore() + update.mockRestore() + } + }) + test("includes host OS properties", () => { const capture = spyOn(Client, "capture").mockImplementation(() => {}) diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 496bf1ad4c9..9e819e25519 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -105,6 +105,8 @@ export namespace Telemetry { } export async function updateIdentity(token: string | null, accountId?: string): Promise { + if (!isEnabled()) return + const previousId = Identity.getDistinctId() await Identity.updateFromKiloAuth(token, accountId) diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 8724dc548f9..6948cbadbac 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.18", + "version": "7.4.20", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 0a61e75f6c6..4281174d125 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -2799,18 +2799,9 @@ ToolRegistry.register({ > {(file) => { - const active = createMemo(() => expanded().includes(file.filePath)) - const [visible, setVisible] = createSignal(false) - createEffect(() => { - if (!active()) { - setVisible(false) - return - } - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) - }) + // Diff defers its own expensive render; mounting the container + // here avoids dropping the last item during batch expansion. + const active = createMemo(() => allExpanded().includes(file.filePath)) return ( @@ -2864,7 +2855,7 @@ ToolRegistry.register({ - + {(diff) => (
) => Promise | null>) | null = null @@ -1021,6 +1024,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID), copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), + speechToTextModels: () => this.fetchAndSendSpeechToTextModels(), }) ) { return @@ -1661,6 +1665,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Subscribe to SSE events for this webview (filtered by tracked sessions) this.unsubscribeEvent = this.connectionService.onEventFiltered( (payload, directory) => { + if (directory && !this.isCurrentProjectDirectory(directory)) return false + if (!directory && isEventFromForeignProject(payload, this.projectID)) return false const event = unwrapSyncEvent(payload) if (!event) return false @@ -1672,6 +1678,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // message.part.* events are always session-scoped; drop if session unknown. if (!sessionId) return !isSessionScopedPartEvent(event.type) + if (!directory && !this.isCurrentProjectSession(sessionId)) return false if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) { return true @@ -1818,15 +1825,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.memory.fetch(), this.seedSessionStatusMap(), ]) - this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory()) - this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo }) + await this.refreshGitStatus(this.getWorkspaceDirectory()) this.sendNotificationSettings() this.sendTimelineSetting() this.postMessage(buildThroughputSettingMessage()) this.postMessage({ type: "extensionDataReady" }) - if (this.cachedGitRepo) this.startStatsPolling() - console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error) @@ -1887,6 +1891,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Non-blocking: refresh session metadata + status for the webview after switching. */ private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void { if (!this.client) return + void this.refreshGitStatus(dir) const revision = this.revisions.get(sessionID) const refresh = (this.refreshes.get(sessionID) ?? 0) + 1 this.refreshes.set(sessionID, refresh) @@ -2102,10 +2107,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async flushPendingSessionRefresh(reason: string): Promise { if (!this.pendingSessionRefresh) return console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason }) + const scope = this.opts.projectQualifier?.()?.projectId + if (scope !== undefined) this.projectID = undefined const ctx = this.sessionRefreshContext try { const resolved = await flushPendingSessionRefreshUtil(ctx) - if (resolved) this.projectID = resolved + if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved } catch (error) { console.error("[Kilo New] KiloProvider: Failed to flush session refresh:", error) } @@ -2116,10 +2123,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Handle loading all sessions. */ private async handleLoadSessions(): Promise { + const scope = this.opts.projectQualifier?.()?.projectId + if (scope !== undefined) this.projectID = undefined const ctx = this.sessionRefreshContext try { const resolved = await loadSessionsUtil(ctx) - if (resolved) this.projectID = resolved + if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved } catch (error) { console.error("[Kilo New] KiloProvider: Failed to load sessions:", error) this.postMessage({ @@ -2701,6 +2710,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(message) } + private async fetchAndSendSpeechToTextModels(): Promise { + const result = await fetchSpeechToTextModels(this.connectionService, this.getWorkspaceDirectory()) + if (!result.ok) { + this.postMessage({ type: "speechToTextModelsLoaded" as const, models: [...SPEECH_TO_TEXT_MODELS] }) + return + } + this.postMessage({ type: "speechToTextModelsLoaded" as const, models: result.models }) + } + /** * Seed sessionStatusMap with current session statuses on connect. * Without this, the Settings panel (which has no tracked sessions) would see @@ -3944,6 +3962,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper disposeGlobal: () => this.disposeGlobal(), fetchAndSendProviders: () => this.fetchAndSendProviders(), fetchAndSendAgents: () => this.fetchAndSendAgents(), + fetchAndSendSpeechToTextModels: () => this.fetchAndSendSpeechToTextModels(), } } @@ -4293,9 +4312,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return + if (directory && !this.isCurrentProjectDirectory(directory)) return if ( this.projectID && + (!this.opts.projectQualifier || !directory) && (event.type === "session.created" || event.type === "session.updated") && event.properties.info.projectID !== undefined && event.properties.info.projectID !== null && @@ -4354,6 +4374,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) return + if (event.type === "message.part.updated") this.refreshGitStatusFromPart(event, sessionID) + if (event.type === "session.updated" && typeof event.properties.info.cost === "number") { const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost) this.requestCostAlert(event.properties.sessionID, cost) @@ -4724,6 +4746,79 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return undefined } + private isCurrentProjectDirectory(directory: string): boolean { + if (!this.opts.projectQualifier?.()) return true + const dirs = [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])] + return dirs.some((dir) => sameDirectory(dir, directory)) + } + + private isCurrentProjectSession(sessionID: string): boolean { + if (!this.opts.projectQualifier || !this.opts.routeService) return true + const directory = this.opts.routeService.trySessionDirectory(sessionID) + return !directory || this.isCurrentProjectDirectory(directory) + } + + private refreshGitStatusFromPart( + event: Extract, + sessionID?: string, + ) { + const part = event.properties.part as { + type?: string + metadata?: Record + state?: { status?: string; input?: Record; metadata?: Record } + } + if (part.type !== "tool" || part.state?.status !== "completed") return + const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath] + const file = values.find((value): value is string => typeof value === "string" && value.length > 0) + if (!file) return + const base = this.getWorkspaceDirectory(sessionID) + const value = file.split(",")[0].trim() + const pathName = path.isAbsolute(value) ? value : path.resolve(base, value) + const directory = path.dirname(pathName) + if (!this.isCurrentProjectGitDirectory(directory, sessionID)) return + void this.refreshGitStatus(directory) + } + + private isCurrentProjectGitDirectory(directory: string, sessionID?: string): boolean { + const roots = this.opts.projectQualifier?.() + ? [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])] + : [this.getWorkspaceDirectory(sessionID)] + return roots.some((root) => { + const rel = path.relative(canonicalizePath(root), canonicalizePath(directory)) + return rel === "" || (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${path.sep}`)) + }) + } + + public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise { + const client = this.client + if (!client) return + const revision = ++this.gitStatusRevision + const repo = await hasGit(client, directory) + const root = await this.resolveGitRoot(directory) + if (revision !== this.gitStatusRevision) return + const found = repo || root !== undefined + const target = root ?? directory + if (!this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target)) this.cachedStats = null + this.cachedGitDirectory = target + this.cachedGitRepo = found + this.postMessage({ type: "gitStatus", repo: found }) + if (found) { + if (!this.statsPoller) this.startStatsPolling() + return + } + this.statsPoller?.stop() + this.statsGitOps?.dispose() + this.statsPoller = null + this.statsGitOps = null + } + + private async resolveGitRoot(directory: string): Promise { + const git = this.statsGitOps ?? new GitOps({ log: () => {} }) + const root = await git.root(directory) + if (!this.statsGitOps) git.dispose() + return root + } + private getContextDirectory(): string { return resolveContextDirectory({ currentSessionID: this.currentSession?.id, @@ -4797,7 +4892,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")), title: "Kilo Code", port: this.connectionService.getServerInfo()?.port, - extraStyles: `.container { height: 100%; display: flex; flex-direction: column; height: 100vh; border-right: 1px solid var(--border-weak-base); }`, + extraStyles: `.container { height: 100vh; }`, }) } @@ -4833,7 +4928,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsGitOps = git this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], - getWorkspaceRoot: () => getWorkspaceRoot(), + getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id), localDiff: (dir, base) => localDiffSummary(git, dir, base), git, onStats: () => {}, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 39b844f9894..069783c53fc 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -395,6 +395,7 @@ export class AgentManagerProvider implements Disposable { this.panelSessions.clear() void ctx.sessions.abortSessions(ids).catch((err) => this.log("Failed to abort sessions on panel close:", err)) this.statsPoller.stop() + this.projectPollers.dispose() this.prBridge.poller.stop() this.diffs.stop() this.activeSessionId = undefined @@ -450,12 +451,14 @@ export class AgentManagerProvider implements Disposable { /** Initialize an expanded background project and push its state (no panel wiring). */ private initExpanded(ctx: ProjectContext): void { - void initContextState(ctx, (...args) => this.log(...args)).then((result) => { - if (!result.current) return - registerProjectSessions(ctx, this.panel?.sessions) - this.pushState(ctx) - this.projectPollers.sync(this.contexts) - }) + void initContextState(ctx, (...args) => this.log(...args)) + .then((result) => { + if (!result.current) return + registerProjectSessions(ctx, this.panel?.sessions) + this.pushState(ctx) + this.projectPollers.sync(this.contexts) + }) + .catch((err) => this.log("Failed to initialize expanded project:", err)) } // Message interceptor @@ -1047,6 +1050,7 @@ export class AgentManagerProvider implements Disposable { if (!req) return if (directory) { req.directory = directory + req.projectId ??= this.contexts.byDirectory(directory)?.id } void this.startToolRequest(req) } @@ -1555,7 +1559,11 @@ export class AgentManagerProvider implements Disposable { void this.sendRepoInfo() if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c))) this.stateReady = this.initializeState() - else this.projectPollers.sync(this.contexts) + else { + this.panel?.sessions.refreshSessions() + this.projectPollers.sync(this.contexts) + } + this.panel?.sessions.refreshGitStatus?.() } private onWorkspaceChanged(): void { if (this.contexts.syncPinned()) { @@ -1567,11 +1575,20 @@ export class AgentManagerProvider implements Disposable { } private pushProjects(): void { + const projects = this.contexts.snapshots() this.postToWebview({ type: "agentManager.projects", multiProject: this.host.multiProject(), - projects: this.contexts.snapshots(), + projects, }) + if (this.panel) { + for (const project of projects) { + if (project.active || !project.expanded || !project.trusted || project.missing) continue + if (this.contexts.get(project.id)) continue + const ctx = this.contexts.expand(project.id) + if (ctx) this.initExpanded(ctx) + } + } this.projectPollers.sync(this.contexts) } diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 92a2984b5a9..ea26906b893 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -171,6 +171,10 @@ export class GitOps { return this.raw(["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => "") } + async root(cwd: string): Promise { + return this.raw(["rev-parse", "--show-toplevel"], cwd).catch(() => undefined) + } + /** * Resolve the remote name for a branch. Checks (in order): * 1. The configured upstream's remote (e.g. upstream from `upstream/main`) diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index f8e8cee3a64..296cb70caf3 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -66,6 +66,8 @@ export interface SessionProvider { isSessionRouteAmbiguous?(sessionId: string): boolean /** Exact directory for a project-qualified session ref, or undefined. */ routeSessionDirectoryFor?(ref: SessionRef): string | undefined + /** Re-check Git capability for the active project/session directory. */ + refreshGitStatus?(): void dispose(): void } diff --git a/packages/kilo-vscode/src/agent-manager/project/contexts.ts b/packages/kilo-vscode/src/agent-manager/project/contexts.ts index a5ed3f34451..95a16d7050a 100644 --- a/packages/kilo-vscode/src/agent-manager/project/contexts.ts +++ b/packages/kilo-vscode/src/agent-manager/project/contexts.ts @@ -32,6 +32,7 @@ interface ContextsOptions { registry: { list(): StoredProject[] get(id: string): StoredProject | undefined + expanded?(id: string): boolean | undefined } /** Registry trust lookup for non-pinned projects. */ trusted: (id: string) => boolean @@ -44,7 +45,7 @@ interface ContextsOptions { export class ProjectContexts { private readonly contexts = new Map() private activeId: string | undefined - private readonly expanded = new Set() + private readonly expansion = new Map() constructor(private readonly opts: ContextsOptions) {} @@ -83,7 +84,7 @@ export class ProjectContexts { const pinned = this.pinned() if (pinned) { this.activeId = pinned.id - this.expanded.add(pinned.id) + this.rememberExpansion(pinned.id, true) return pinned } if (!this.opts.enabled()) return undefined @@ -91,7 +92,7 @@ export class ProjectContexts { if (!first) return undefined const ctx = this.ensure(first.id, first.root, false) this.activeId = ctx.id - this.expanded.add(ctx.id) + this.rememberExpansion(ctx.id, false) return ctx } @@ -140,7 +141,12 @@ export class ProjectContexts { } isExpanded(id: string): boolean { - return this.expanded.has(id) + const value = this.expansion.get(id) + if (value !== undefined) return value + const stored = this.opts.registry.expanded?.(id) + if (stored !== undefined) return stored + const root = this.opts.workspaceRoot() + return root !== undefined && projectIdFor(canonicalizePath(root)) === id } /** Make a project the active context and expand it. Returns undefined when not allowed. */ @@ -148,6 +154,7 @@ export class ProjectContexts { const ctx = this.usableCtx(id) if (!ctx) return undefined this.activeId = id + this.rememberExpansion(id, false) return ctx } @@ -155,12 +162,12 @@ export class ProjectContexts { expand(id: string): ProjectContext | undefined { const ctx = this.usableCtx(id) if (!ctx) return undefined - this.expanded.add(id) + this.expansion.set(id, true) return ctx } collapse(id: string): void { - this.expanded.delete(id) + this.expansion.set(id, false) if (this.isActive(id)) return this.contexts.get(id)?.suspend() } @@ -169,10 +176,10 @@ export class ProjectContexts { disable(): ProjectContext | undefined { const pinned = this.pinned() this.activeId = pinned?.id - if (pinned) this.expanded.add(pinned.id) + if (pinned) this.expansion.set(pinned.id, true) for (const ctx of this.contexts.values()) { if (ctx.pinned) continue - this.expanded.delete(ctx.id) + this.expansion.set(ctx.id, false) ctx.suspend() // Match remove()/syncPinned(): drop the routes too, otherwise the shared // route service accumulates entries for every disabled project. @@ -194,7 +201,7 @@ export class ProjectContexts { async remove(id: string): Promise { const ctx = this.contexts.get(id) if (!ctx || ctx.pinned) return false - this.expanded.delete(id) + this.expansion.delete(id) if (this.activeId === id) this.activeId = undefined this.contexts.delete(id) this.opts.remove?.(id) @@ -215,7 +222,7 @@ export class ProjectContexts { for (const [id, ctx] of [...this.contexts]) { if (!ctx.pinned) continue this.contexts.delete(id) - this.expanded.delete(id) + this.expansion.delete(id) if (this.activeId === id) this.activeId = undefined this.opts.remove?.(id) ctx.suspend() @@ -241,26 +248,32 @@ export class ProjectContexts { const id = ctx?.id ?? stored!.id const root = ctx?.root ?? stored!.root const pinned = ctx?.pinned ?? false + const missing = ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root) return { id, root, label: stored?.label || path.basename(root) || root, pinned, active: this.isActive(id), - expanded: this.isExpanded(id), + expanded: !missing && this.isExpanded(id), initialized: ctx?.loaded ?? false, trusted: pinned || (stored?.trusted ?? false), - missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root), + missing, } } + private rememberExpansion(id: string, fallback: boolean): void { + if (this.expansion.has(id)) return + this.expansion.set(id, this.opts.registry.expanded?.(id) ?? fallback) + } + async dispose(): Promise { for (const ctx of this.contexts.values()) { this.opts.remove?.(ctx.id) await ctx.dispose() } this.contexts.clear() - this.expanded.clear() + this.expansion.clear() this.activeId = undefined } } diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 9bf8c542f8a..4349f709c4a 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -189,9 +189,15 @@ function selectProject(id: string, deps: ProjectMessageDeps): void { async function setExpanded(id: string, expanded: boolean, deps: ProjectMessageDeps): Promise { if (disabled(deps)) return + const ctx = expanded ? deps.contexts.usable(id) : deps.contexts.resolve(id) + if (!ctx) { + deps.push() + return + } + await deps.registry.setExpanded(id, expanded) if (expanded) { - const ctx = deps.contexts.expand(id) - if (ctx) deps.expand(ctx) + const next = deps.contexts.expand(id) + if (next) deps.expand(next) } if (!expanded) deps.contexts.collapse(id) deps.push() diff --git a/packages/kilo-vscode/src/agent-manager/project/registry.ts b/packages/kilo-vscode/src/agent-manager/project/registry.ts index 9b11a115136..1a727db40be 100644 --- a/packages/kilo-vscode/src/agent-manager/project/registry.ts +++ b/packages/kilo-vscode/src/agent-manager/project/registry.ts @@ -1,10 +1,10 @@ /** * ProjectRegistry — global catalog of additional Agent Manager projects. * - * The registry persists only *additional* projects: repositories the user - * explicitly added through the Agent Manager project picker. The pinned - * default project is always derived from the current VS Code workspace at - * runtime and is never stored here. + * The registry persists *additional* projects: repositories the user explicitly + * added through the Agent Manager project picker. The pinned default project is + * always derived from the current VS Code workspace at runtime; only its + * accordion preference is stored by id. * * Storage is injected so the registry stays free of VS Code imports and can * be unit-tested with an in-memory store. The file is versioned; corrupt or @@ -31,11 +31,20 @@ export interface StoredProject { /** Whether project-controlled scripts may execute for this project. */ trusted: boolean addedAt: string + /** Whether this project accordion should render its body. */ + expanded?: boolean } interface RegistryFile { version: 1 projects: StoredProject[] + /** Expansion state for the pinned workspace project, which is not a catalog entry. */ + pinnedExpanded?: Record +} + +interface ParsedRegistry { + projects: StoredProject[] + pinnedExpanded: Record } export interface RegistryStorage { @@ -53,16 +62,17 @@ function valid(entry: unknown): entry is StoredProject { typeof e.root === "string" && typeof e.order === "number" && typeof e.trusted === "boolean" && - typeof e.addedAt === "string" + typeof e.addedAt === "string" && + (e.expanded === undefined || typeof e.expanded === "boolean") ) } -function parse(raw: unknown, log: (msg: string) => void): StoredProject[] { - if (!raw || typeof raw !== "object") return [] +function parse(raw: unknown, log: (msg: string) => void): ParsedRegistry { + if (!raw || typeof raw !== "object") return { projects: [], pinnedExpanded: {} } const file = raw as Partial if (file.version !== VERSION || !Array.isArray(file.projects)) { if (file.version !== undefined) log("project registry has an unsupported shape, starting empty") - return [] + return { projects: [], pinnedExpanded: {} } } const seen = new Set() const out: StoredProject[] = [] @@ -72,11 +82,18 @@ function parse(raw: unknown, log: (msg: string) => void): StoredProject[] { seen.add(entry.id) out.push(entry) } - return out.sort((a, b) => a.order - b.order) + const pinnedExpanded: Record = {} + if (file.pinnedExpanded && typeof file.pinnedExpanded === "object") { + for (const [id, expanded] of Object.entries(file.pinnedExpanded)) { + if (typeof expanded === "boolean") pinnedExpanded[id] = expanded + } + } + return { projects: out.sort((a, b) => a.order - b.order), pinnedExpanded } } export class ProjectRegistry { private projects: StoredProject[] | undefined + private pinnedExpanded: Record | undefined private queue: Promise = Promise.resolve() constructor( @@ -85,20 +102,26 @@ export class ProjectRegistry { ) {} private load(): StoredProject[] { - this.projects ??= parse(this.storage.read(), this.log) + if (!this.projects) { + const parsed = parse(this.storage.read(), this.log) + this.projects = parsed.projects + this.pinnedExpanded = parsed.pinnedExpanded + } return this.projects } /** Fresh re-read + validate/dedupe/sort, used by every mutation. */ - private fresh(): StoredProject[] { + private fresh(): ParsedRegistry { return parse(this.storage.read(), this.log) } /** Persist the next catalog and update the cache only after the write succeeds. */ - private async write(next: StoredProject[]): Promise { + private async write(next: StoredProject[], pinnedExpanded: Record): Promise { const file: RegistryFile = { version: VERSION, projects: next } + if (Object.keys(pinnedExpanded).length > 0) file.pinnedExpanded = pinnedExpanded await this.storage.write(file) this.projects = next + this.pinnedExpanded = pinnedExpanded } /** Serialize mutations within this instance; a failed mutation does not poison the queue. */ @@ -119,6 +142,14 @@ export class ProjectRegistry { return this.load().find((p) => p.id === id) } + /** Return explicit expansion state, if one has been persisted. */ + expanded(id: string): boolean | undefined { + const project = this.get(id) + if (project) return project.expanded + this.load() + return this.pinnedExpanded?.[id] + } + /** Register an additional project. Throws when the id is already registered. */ add(input: { id: string; root: string; label?: string }): Promise { return this.run(() => this.doAdd(input)) @@ -126,8 +157,9 @@ export class ProjectRegistry { private async doAdd(input: { id: string; root: string; label?: string }): Promise { const current = this.fresh() - if (current.find((p) => p.id === input.id)) throw new Error("That repository is already registered as a project.") - const order = current.reduce((max, p) => Math.max(max, p.order), 0) + 1 + if (current.projects.find((p) => p.id === input.id)) + throw new Error("That repository is already registered as a project.") + const order = current.projects.reduce((max, p) => Math.max(max, p.order), 0) + 1 const project: StoredProject = { id: input.id, root: input.root, @@ -136,7 +168,7 @@ export class ProjectRegistry { trusted: false, addedAt: new Date().toISOString(), } - await this.write([...current, project]) + await this.write([...current.projects, project], current.pinnedExpanded) return project } @@ -147,9 +179,11 @@ export class ProjectRegistry { private async doRemove(id: string): Promise { const current = this.fresh() - const next = current.filter((p) => p.id !== id) - if (next.length === current.length) return false - await this.write(next) + const next = current.projects.filter((p) => p.id !== id) + if (next.length === current.projects.length) return false + const pinnedExpanded = { ...current.pinnedExpanded } + delete pinnedExpanded[id] + await this.write(next, pinnedExpanded) return true } @@ -159,8 +193,11 @@ export class ProjectRegistry { private async doSetTrusted(id: string, trusted: boolean): Promise { const current = this.fresh() - if (!current.find((p) => p.id === id)) return false - await this.write(current.map((p) => (p.id === id ? { ...p, trusted } : p))) + if (!current.projects.find((p) => p.id === id)) return false + await this.write( + current.projects.map((p) => (p.id === id ? { ...p, trusted } : p)), + current.pinnedExpanded, + ) return true } @@ -170,8 +207,29 @@ export class ProjectRegistry { private async doSetLabel(id: string, label: string | undefined): Promise { const current = this.fresh() - if (!current.find((p) => p.id === id)) return false - await this.write(current.map((p) => (p.id === id ? { ...p, label } : p))) + if (!current.projects.find((p) => p.id === id)) return false + await this.write( + current.projects.map((p) => (p.id === id ? { ...p, label } : p)), + current.pinnedExpanded, + ) + return true + } + + setExpanded(id: string, expanded: boolean): Promise { + return this.run(() => this.doSetExpanded(id, expanded)) + } + + private async doSetExpanded(id: string, expanded: boolean): Promise { + const current = this.fresh() + if (current.projects.some((p) => p.id === id)) { + await this.write( + current.projects.map((p) => (p.id === id ? { ...p, expanded } : p)), + current.pinnedExpanded, + ) + return true + } + const pinnedExpanded = { ...current.pinnedExpanded, [id]: expanded } + await this.write(current.projects, pinnedExpanded) return true } } diff --git a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts index 37a9fe93a14..41cc02049eb 100644 --- a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts +++ b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts @@ -50,6 +50,7 @@ export async function createMultiVersion( // Notify webview that multi-version creation has started host.post({ type: "agentManager.multiVersionProgress", + projectId: ctx.id, status: "creating", total: versions, completed: 0, @@ -78,6 +79,7 @@ export async function createMultiVersion( // Update progress host.post({ type: "agentManager.multiVersionProgress", + projectId: ctx.id, status: "creating", total: versions, completed: created.length, @@ -86,11 +88,19 @@ export async function createMultiVersion( } // Phase 2: Send the initial prompt to all sessions, or clear busy state if no text. - await sendInitialPrompts(host, created, models, { providerID, modelID }, { text, agent, variant: msg.variant, files }) + await sendInitialPrompts( + host, + ctx.id, + created, + models, + { providerID, modelID }, + { text, agent, variant: msg.variant, files }, + ) // Notify completion host.post({ type: "agentManager.multiVersionProgress", + projectId: ctx.id, status: "done", total: versions, completed: created.length, @@ -172,6 +182,7 @@ async function createVersion( if (earlyProviderID && earlyModelID) { host.post({ type: "agentManager.setSessionModel", + projectId: ctx.id, sessionId: session.id, providerID: earlyProviderID, modelID: earlyModelID, @@ -232,6 +243,7 @@ async function reconcileSandbox( /** Fan the initial prompt out to every created session, throttled between sends. */ async function sendInitialPrompts( host: MultiVersionHost, + projectId: string, created: CreatedVersion[], models: VersionSpec["models"], resolved: { providerID: string | undefined; modelID: string | undefined }, @@ -254,7 +266,7 @@ async function sendInitialPrompts( modelID: msg.modelID, }) } - host.post({ type: "agentManager.sendInitialMessage", ...msg }) + host.post({ type: "agentManager.sendInitialMessage", projectId, ...msg }) if (input.text && i < messages.length - 1) { await new Promise((resolve) => setTimeout(resolve, 300)) } diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts index 927a6495370..91af753f991 100644 --- a/packages/kilo-vscode/src/agent-manager/tool-start.ts +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -19,6 +19,7 @@ export interface ToolTask { export interface ToolRequest { requestID: string + projectId?: string sessionID?: string directory?: string sandboxInheritanceToken?: string @@ -234,7 +235,14 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise provider.unregisterSessionRoute(ref), isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId), routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref), + refreshGitStatus: () => void provider.refreshGitStatus(), dispose: () => provider.dispose(), } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c9825cb7713..41ea85380de 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -661,7 +661,10 @@ export function mapCloudSessionMessageToWebviewMessage(message: CloudSessionMess * Returns true when the event carries a projectID that does not match the expected one. * When expectedProjectID is undefined (not yet resolved), nothing is filtered. */ -export function isEventFromForeignProject(event: StreamEvent, expectedProjectID: string | undefined): boolean { +export function isEventFromForeignProject( + event: StreamEvent | SyncPayload, + expectedProjectID: string | undefined, +): boolean { if (!expectedProjectID || event.type !== "sync") return false if (event.name === "session.created.1" || event.name === "session.deleted.1") { return event.data.info.projectID !== expectedProjectID diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index f67a2144fa1..abf79b49a02 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -17,6 +17,7 @@ type Ctx = { exportTranscript: (sessionID: string) => Promise copy: (text: string) => PromiseLike openSessions: (ids: string[]) => void + speechToTextModels: () => Promise } export async function routeEarlyMessage( @@ -64,6 +65,10 @@ export async function routeEarlyMessage( ctx.post(buildThroughputSettingMessage()) return true } + if (message.type === "requestSpeechToTextModels") { + await ctx.speechToTextModels() + return true + } if (message.type === "requestBrowserSettings") { ctx.browserSettings() return true diff --git a/packages/kilo-vscode/src/kilo-provider/git-status.ts b/packages/kilo-vscode/src/kilo-provider/git-status.ts index 12e324e529a..e4ef22b5e45 100644 --- a/packages/kilo-vscode/src/kilo-provider/git-status.ts +++ b/packages/kilo-vscode/src/kilo-provider/git-status.ts @@ -1,8 +1,8 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" export async function hasGit(client: KiloClient, directory: string): Promise { - return client.project - .current({ directory }) + return Promise.resolve() + .then(() => client.project.current({ directory })) .then((r) => r.data?.vcs === "git") .catch(() => false) } diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/auth.ts b/packages/kilo-vscode/src/kilo-provider/handlers/auth.ts index db6e9e6d7df..07ae712b902 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/auth.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/auth.ts @@ -15,6 +15,7 @@ export interface AuthContext { disposeGlobal(): Promise fetchAndSendProviders(): Promise fetchAndSendAgents(): Promise + fetchAndSendSpeechToTextModels(): Promise } /** @@ -137,6 +138,11 @@ export async function handleSetOrganization(ctx: AuthContext, organizationId: st } catch (error) { console.error("[Kilo New] KiloProvider: Failed to refresh agents after org switch:", error) } + try { + await ctx.fetchAndSendSpeechToTextModels() + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to refresh speech-to-text models after org switch:", error) + } } /** Handle profile refresh request. */ diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 9481897cc5b..76cb5125719 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -29,7 +29,12 @@ export function resolveIndexingEnv(folders: readonly WorkspaceFolderLike[] | und } export function resolveManagedServerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - return { ...env, KILO_DISABLE_CHANNEL_DB: "true" } + return { + ...env, + KILO_DISABLE_CHANNEL_DB: "true", + // VS Code does not consume the backend's file.watcher.updated events. + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + } } export class ServerManager { diff --git a/packages/kilo-vscode/src/speech-to-text/catalog.ts b/packages/kilo-vscode/src/speech-to-text/catalog.ts new file mode 100644 index 00000000000..fbb24114f74 --- /dev/null +++ b/packages/kilo-vscode/src/speech-to-text/catalog.ts @@ -0,0 +1,62 @@ +import type { KiloConnectionService } from "../services/cli-backend/connection-service" +import { getErrorMessage } from "../kilo-provider-utils" +import { type SpeechToTextModelDef } from "./models" + +const PATH = "/kilo/models/transcriptions" + +type CatalogModel = { + id: string + name: string +} + +export type SpeechToTextCatalogResult = { ok: true; models: SpeechToTextModelDef[] } | { ok: false; error: string } + +export async function fetchSpeechToTextModels( + connection: KiloConnectionService, + dir: string, + signal?: AbortSignal, +): Promise { + const cfg = connection.getServerConfig() + if (!cfg) return fail("Not connected to the Kilo backend") + + const auth = Buffer.from(`kilo:${cfg.password}`).toString("base64") + const url = new URL(PATH, cfg.baseUrl) + if (dir) url.searchParams.set("directory", dir) + + try { + const res = await fetch(url, { signal, headers: { Authorization: `Basic ${auth}` } }) + if (!res.ok) return fail(`Failed to fetch speech-to-text models (HTTP ${res.status})`) + + const models = parseSpeechToTextCatalog(await res.json()) + if (!models) return fail("Invalid speech-to-text model catalog") + return { ok: true, models } + } catch (err) { + return fail(getErrorMessage(err)) + } +} + +function fail(error: string): SpeechToTextCatalogResult { + return { ok: false, error } +} + +export function parseSpeechToTextCatalog(body: unknown): SpeechToTextModelDef[] | undefined { + if (!Array.isArray(body)) return undefined + const models = body.filter(isCatalogModel).map(toModel) + return models.length > 0 ? models : undefined +} + +function isCatalogModel(value: unknown): value is CatalogModel { + if (!value || typeof value !== "object") return false + const model = value as Record + return typeof model.id === "string" && typeof model.name === "string" +} + +function toModel(model: CatalogModel): SpeechToTextModelDef { + const index = model.name.indexOf(":") + const provider = index === -1 ? model.id.split("/", 1)[0] || "Kilo Gateway" : model.name.slice(0, index).trim() + return { + id: model.id, + label: index === -1 ? model.name : model.name.slice(index + 1).trim(), + provider, + } +} diff --git a/packages/kilo-vscode/src/speech-to-text/transcribe.ts b/packages/kilo-vscode/src/speech-to-text/transcribe.ts index 1e978bf028c..bfa0daf590f 100644 --- a/packages/kilo-vscode/src/speech-to-text/transcribe.ts +++ b/packages/kilo-vscode/src/speech-to-text/transcribe.ts @@ -54,7 +54,7 @@ export async function transcribeSpeech( "Content-Type": "application/json", }, body: JSON.stringify({ - model: model.id, + model: input.model || model.id, input_audio: { data: input.data, format: input.format, diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 0219e54e36c..9b3ef8d5815 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.18", + "version": "7.4.20", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 353070c085b..b4a24df88a7 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -38,6 +38,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), + path.join(ROOT, "webview-ui/agent-manager/SidebarSectionHeader.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"), path.join(ROOT, "webview-ui/agent-manager/SidebarToggleButton.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"), diff --git a/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts index 1c63c08b4fa..c5c33eea67f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test" import { Window } from "happy-dom" -import { focusQuestionOption, hasQuestionOption } from "../../webview-ui/agent-manager/focus" +import { focusQuestionOption, hasQuestionOption, preservesTextFocus } from "../../webview-ui/agent-manager/focus" +import { isTextControl } from "../../webview-ui/src/utils/focus" describe("Agent Manager focus", () => { it("focuses the first enabled question option", () => { @@ -53,4 +54,21 @@ describe("Agent Manager focus", () => { dock.setAttribute("inert", "") expect(hasQuestionOption(root)).toBe(false) }) + + it("preserves focus for an active editable control", () => { + const window = new Window() + const rename = window.document.createElement("input") + rename.className = "am-worktree-rename-input" + const prompt = window.document.createElement("textarea") + prompt.className = "prompt-input" + const editor = window.document.createElement("div") + editor.contentEditable = "plaintext-only" + const button = window.document.createElement("button") + + expect(isTextControl(rename)).toBe(true) + expect(preservesTextFocus(rename)).toBe(true) + expect(preservesTextFocus(prompt)).toBe(false) + expect(isTextControl(editor)).toBe(true) + expect(isTextControl(button)).toBe(false) + }) }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts index cba84607892..87714a5cac6 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-initial-message.test.ts @@ -5,6 +5,7 @@ describe("Agent Manager initial message", () => { it("forwards the selected variant to sendMessage", () => { const msg = initialMessage({ type: "agentManager.sendInitialMessage", + projectId: "project-a", sessionId: "session-a", worktreeId: "wt-a", text: "Fix it", @@ -16,6 +17,7 @@ describe("Agent Manager initial message", () => { expect(msg).toEqual({ type: "sendMessage", + projectId: "project-a", text: "Fix it", sessionID: "session-a", providerID: "anthropic", diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index f30ba800e52..a38ffbb7b99 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -1,8 +1,10 @@ import { expect, test } from "bun:test" import { readFileSync } from "node:fs" import { resolve } from "node:path" +import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "../../webview-ui/agent-manager/side-panel-layout" const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8") +const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8") test("xterm owns the padding used by FitAddon", () => { const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1] @@ -13,3 +15,22 @@ test("xterm owns the padding used by FitAddon", () => { expect(host).not.toMatch(/\bpadding\s*:/) expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/) }) + +test("uses one persisted width for the diff and terminal inspector", () => { + expect(app).toContain("persisted?.sidePanelWidth") + expect(app).toContain("setPanelWidth(pendingSideWidth!)") + expect(app).not.toContain("diffWidth") + expect(app).not.toContain("terminalWidth") +}) + +test("clamps the restored inspector width to the shared layout bounds", () => { + expect(clampPanelWidth(undefined, 1200)).toBe(600) + expect(clampPanelWidth(500, 1200)).toBe(500) + expect(clampPanelWidth(1000, 1000)).toBe(maxPanelWidth(1000)) + expect(clampPanelWidth(100, 1200)).toBe(minPanelWidth(1200)) + expect(clampPanelWidth("invalid", 1200)).toBe(600) + expect(minPanelWidth(400)).toBe(200) + expect(maxPanelWidth(400)).toBe(320) + expect(clampPanelWidth(undefined, 400)).toBe(200) + expect(clampPanelWidth(360, 400)).toBe(320) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts index 97cb9265f96..9c8b6239e91 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts @@ -17,11 +17,14 @@ function stored(id: string, trusted = false): StoredProject { } } -function setup(opts: { workspace?: string; enabled?: boolean; projects?: StoredProject[] } = {}) { +function setup( + opts: { workspace?: string; enabled?: boolean; projects?: StoredProject[]; expanded?: Record } = {}, +) { const registryProjects = opts.projects ?? [] const registry = { list: () => registryProjects, get: (id: string) => registryProjects.find((p) => p.id === id), + expanded: (id: string) => opts.expanded?.[id], } const created: string[] = [] const contexts = new ProjectContexts({ @@ -207,6 +210,34 @@ describe("ProjectContexts", () => { expect(list[1]!.initialized).toBe(false) }) + it("keeps the pinned project expanded before active state is initialized", () => { + const { contexts } = setup({ workspace: WORKSPACE }) + + expect(contexts.isExpanded(PINNED)).toBe(true) + }) + + it("hydrates persisted project expansion without initializing the project", () => { + const extra = stored("prj-extra", true) + const { contexts } = setup({ + workspace: WORKSPACE, + enabled: true, + projects: [extra], + expanded: { [extra.id]: true }, + }) + + const list = contexts.snapshots() + + expect(list[1]!.expanded).toBe(true) + expect(list[1]!.initialized).toBe(false) + expect(contexts.get(extra.id)).toBeUndefined() + }) + + it("hydrates a persisted collapsed pinned project", () => { + const { contexts } = setup({ workspace: WORKSPACE, expanded: { [PINNED]: false } }) + + expect(contexts.snapshots()[0]!.expanded).toBe(false) + }) + it("hides registry projects from snapshots when the flag is off", () => { const extra = stored("prj-extra", true) const { contexts } = setup({ workspace: WORKSPACE, enabled: false, projects: [extra] }) diff --git a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts index 762268d78d0..c4a88084a9f 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts @@ -4,7 +4,7 @@ import * as os from "os" import * as path from "path" import { execFileSync } from "child_process" import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages" -import { ProjectRegistry } from "../../src/agent-manager/project/registry" +import { ProjectRegistry, type RegistryStorage } from "../../src/agent-manager/project/registry" import { ProjectContexts } from "../../src/agent-manager/project/contexts" import { projectIdFor } from "../../src/agent-manager/project/paths" import type { AgentManagerInMessage } from "../../src/agent-manager/types" @@ -20,12 +20,13 @@ function gitRepo(): string { function setup(opts: { enabled?: boolean; workspace?: string } = {}) { let stored: unknown let pickResult: string | undefined - const registry = new ProjectRegistry({ + const storage: RegistryStorage = { read: () => stored, write: (value) => { stored = value }, - }) + } + const registry = new ProjectRegistry(storage) const contexts = new ProjectContexts({ workspaceRoot: () => opts.workspace ?? WORKSPACE, registry, @@ -63,7 +64,7 @@ function setup(opts: { enabled?: boolean; workspace?: string } = {}) { const pick = (dir: string | undefined) => { pickResult = dir } - return { registry, contexts, deps, calls, pick } + return { registry, contexts, deps, calls, pick, storage } } function msg(type: string, extra: Record = {}): AgentManagerInMessage { @@ -170,6 +171,36 @@ describe("handleProjectMessage", () => { expect(calls.expand).toEqual([id]) }) + it("persists project expansion state across registry instances", async () => { + const repo = gitRepo() + const { deps, registry, storage, calls } = setup() + const id = projectIdFor(repo) + await registry.add({ id, root: repo }) + await registry.setTrusted(id, true) + + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: true }), deps) + + const restored = new ProjectRegistry(storage) + expect(restored.expanded(id)).toBe(true) + expect(restored.get(id)?.expanded).toBe(true) + + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: false }), deps) + + expect(new ProjectRegistry(storage).expanded(id)).toBe(false) + expect(calls.push).toBe(2) + }) + + it("persists the pinned project expansion state without adding it to the catalog", async () => { + const { deps, registry, storage } = setup() + const id = projectIdFor(WORKSPACE) + + await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: false }), deps) + + const restored = new ProjectRegistry(storage) + expect(restored.expanded(id)).toBe(false) + expect(registry.list()).toEqual([]) + }) + it("does not initialize untrusted projects on expand", async () => { const repo = gitRepo() const { deps, registry, calls } = setup() diff --git a/packages/kilo-vscode/tests/unit/agent-project-progress.test.ts b/packages/kilo-vscode/tests/unit/agent-project-progress.test.ts new file mode 100644 index 00000000000..0caddba5c5f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-project-progress.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test" +import { createProjectStore } from "../../webview-ui/agent-manager/project/store" +import { clearMultiVersionBusy, markMultiVersionBusy } from "../../webview-ui/agent-manager/project/progress" + +const state = (projectId: string) => ({ + type: "agentManager.state" as const, + projectId, + worktrees: [ + { + id: "same", + branch: `${projectId}-same`, + path: `/repo/${projectId}/same`, + parentBranch: "main", + createdAt: "2026-01-01", + groupId: "group", + }, + ], + sessions: [{ id: `${projectId}-session`, worktreeId: "same", createdAt: "2026-01-01" }], + sections: [], +}) + +describe("multi-project progress state", () => { + it("updates only the owning project's grouped worktrees", () => { + const first = createProjectStore("a") + const second = createProjectStore("b") + first.applyState(state("a")) + second.applyState(state("b")) + first.setBusy(new Map([["same", { reason: "setting-up" as const }]])) + second.setBusy(new Map([["same", { reason: "setting-up" as const }]])) + + clearMultiVersionBusy(second, "group") + + expect(first.busy().has("same")).toBe(true) + expect(second.busy().has("same")).toBe(false) + + second.setBusy(new Map([["same", { reason: "deleting" as const }]])) + clearMultiVersionBusy(second, "group") + expect(second.busy().get("same")?.reason).toBe("deleting") + }) + + it("marks a newly created grouped worktree as busy in its project store", () => { + const store = createProjectStore("a") + store.applyState(state("a")) + + markMultiVersionBusy(store, "a-session") + + expect(store.busy().get("same")?.reason).toBe("setting-up") + }) + + it("does not replace deletion progress when marking a grouped worktree", () => { + const store = createProjectStore("a") + store.applyState(state("a")) + store.setBusy(new Map([["same", { reason: "deleting" as const }]])) + + markMultiVersionBusy(store, "a-session") + + expect(store.busy().get("same")?.reason).toBe("deleting") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index bd56efa8274..563620705d5 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -58,6 +58,23 @@ describe("GitOps", () => { }) }) + describe("root", () => { + it("resolves the nearest enclosing repository", async () => { + const git = ops(async (args) => { + if (args[0] === "rev-parse" && args[1] === "--show-toplevel") return "/workspace/frontend" + return "" + }) + expect(await git.root("/workspace/frontend/src")).toBe("/workspace/frontend") + }) + + it("returns undefined outside a repository", async () => { + const git = ops(async () => { + throw new Error("not a git repo") + }) + expect(await git.root("/workspace")).toBeUndefined() + }) + }) + describe("resolveRemote", () => { it("uses upstream remote when upstream is configured", async () => { const git = ops(async (args) => { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 1965da54521..29be0ad5b20 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -8,8 +8,12 @@ type Internals = { webview: { postMessage: (message: unknown) => Promise } | null trackedSessionIds: Set currentSession: Session | null + projectID: string | undefined + isWebviewReady: boolean pendingFollowup: { dir: string; time: number } | null handleLoadMessages: (sessionID: string) => Promise + handleEvent: (event: Event, directory?: string) => void + refreshGitStatus: (directory?: string) => Promise initializeConnection: () => Promise syncWebviewState: () => Promise flushPendingSessionRefresh: () => Promise @@ -43,6 +47,18 @@ function created(input: { id: string; directory: string; parentID?: string }): E } as Event } +function info(input: { id: string; projectID: string; directory: string }): Session { + return { + id: input.id, + slug: `${input.id}-slug`, + projectID: input.projectID, + directory: input.directory, + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + } +} + function connection() { let filter: ((event: Event) => boolean) | undefined let listener: ((event: Event) => void) | undefined @@ -80,6 +96,135 @@ function connection() { } describe("KiloProvider follow-up sessions", () => { + it("scopes shared session events to the active project directory", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/repo/project-b", + projectQualifier: () => ({ projectId: "project-b" }), + }) + const internal = provider as unknown as Internals + const sent: unknown[] = [] + const sharedID = "ses-shared" + + internal.webview = { + postMessage: async (message: unknown) => { + sent.push(message) + return true + }, + } + internal.isWebviewReady = true + internal.currentSession = info({ id: sharedID, projectID: "backend-project-b", directory: "/repo/project-b" }) + internal.projectID = "backend-project-a" + internal.trackedSessionIds.add(sharedID) + + // A background project's event must not overwrite the active project's + // transcript when both instances expose the same raw session key. + internal.handleEvent( + { + type: "message.updated", + properties: { + sessionID: sharedID, + info: { + id: "msg-project-a", + sessionID: sharedID, + role: "assistant", + time: { created: 1 }, + }, + }, + } as Event, + "/repo/project-a", + ) + expect(sent).toEqual([]) + + // Switching projects can briefly leave the backend project identity stale; + // the active directory is the authoritative scope during that transition. + internal.handleEvent( + { + type: "session.created", + properties: { sessionID: sharedID, info: internal.currentSession }, + } as Event, + "/repo/project-b", + ) + expect(sent).toContainEqual({ + type: "sessionCreated", + session: { + id: sharedID, + title: "Session", + createdAt: new Date(1).toISOString(), + updatedAt: new Date(1).toISOString(), + parentID: null, + revert: null, + summary: null, + }, + }) + }) + + it("refreshes Git from the file path in a completed edit tool part", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/workspace", + projectQualifier: () => ({ projectId: "workspace" }), + }) + const internal = provider as unknown as Internals + const dirs: string[] = [] + const sessionID = "ses-edit" + internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" }) + internal.trackedSessionIds.add(sessionID) + internal.refreshGitStatus = async (directory) => { + if (directory) dirs.push(directory) + } + + internal.handleEvent( + { + type: "message.part.updated", + properties: { + sessionID, + part: { + type: "tool", + state: { status: "completed" }, + metadata: { filepath: "/workspace/frontend/src/app.ts" }, + }, + }, + } as Event, + "/workspace", + ) + + expect(dirs).toEqual(["/workspace/frontend/src"]) + }) + + it("ignores completed tool paths outside the active project", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/workspace", + projectQualifier: () => ({ projectId: "workspace" }), + }) + const internal = provider as unknown as Internals + const dirs: string[] = [] + const sessionID = "ses-external-edit" + internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" }) + internal.trackedSessionIds.add(sessionID) + internal.refreshGitStatus = async (directory) => { + if (directory) dirs.push(directory) + } + + internal.handleEvent( + { + type: "message.part.updated", + properties: { + sessionID, + part: { + type: "tool", + state: { status: "completed" }, + metadata: { filepath: "/other-repo/src/app.ts" }, + }, + }, + } as Event, + "/workspace", + ) + + expect(dirs).toEqual([]) + }) + it("ignores subagents before adopting pending follow-up sessions", async () => { const service = connection() const provider = new KiloProvider({} as never, service as never) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts index b8053f7abe3..475607e5fdd 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect } from "bun:test" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" import { ProjectRouteService } from "../../src/agent-manager/project/route" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) @@ -11,8 +14,9 @@ type SessionGetParams = { sessionID: string; directory: string } * session.get records every call so tests can assert which directory was * queried. Mirrors the shape used by kilo-provider-session-refresh.test.ts. */ -function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { +function mockConnection(getImpl?: (p: SessionGetParams) => Promise, vcs = "git") { const calls: SessionGetParams[] = [] + const projectCalls: string[] = [] const client = { session: { get: async (p: SessionGetParams) => { @@ -32,6 +36,12 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { }, list: async () => ({ data: [] }), }, + project: { + current: async (p: { directory: string }) => { + projectCalls.push(p.directory) + return { data: { vcs } } + }, + }, provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) }, app: { agents: async () => ({ data: [] }), @@ -47,6 +57,7 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { let current: typeof client | null = client return { calls, + projectCalls, connection: { connect: async () => { current = client @@ -77,11 +88,27 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { } } +async function withNestedRepo(run: (root: string) => Promise): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-nested-repo-")) + const root = path.join(base, "frontend") + await fs.mkdir(root) + const result = Bun.spawnSync({ cmd: ["git", "init"], cwd: root, stdout: "pipe", stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString()) + try { + await run(root) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } +} + type ProviderInternals = { client: unknown connectionState: "connecting" | "connected" | "disconnected" | "error" initConnectionPromise: Promise | null + isWebviewReady: boolean webview: { postMessage: (message: unknown) => Promise } | null + startStatsPolling: () => void + refreshGitStatus: (directory?: string) => Promise handleSendCommand: ( command: string, args: string, @@ -111,6 +138,49 @@ function connect(internal: ProviderInternals): void { } describe("KiloProvider route integration", () => { + it("finds a nested Git root when the workspace parent is not a repo", async () => { + await withNestedRepo(async (root) => { + const source = path.join(root, "src") + await fs.mkdir(source) + const { connection, projectCalls } = mockConnection(undefined, "none") + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => source, + }) + const internal = provider as unknown as ProviderInternals + const sent: unknown[] = [] + internal.connectionState = "connected" + internal.initConnectionPromise = Promise.resolve() + internal.isWebviewReady = true + internal.startStatsPolling = () => {} + internal.webview = { postMessage: async (message) => sent.push(message) } + + await internal.refreshGitStatus(source) + + expect(projectCalls).toEqual([source]) + expect(sent).toContainEqual({ type: "gitStatus", repo: true }) + }) + }) + + it("checks Git capability in the active project directory", async () => { + const { connection, projectCalls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => "/workspace/parent/project-b", + projectQualifier: () => ({ projectId: "project-b" }), + }) + const internal = provider as unknown as ProviderInternals + const sent: unknown[] = [] + internal.connectionState = "connected" + internal.initConnectionPromise = Promise.resolve() + internal.isWebviewReady = true + internal.startStatsPolling = () => {} + internal.webview = { postMessage: async (message) => sent.push(message) } + + await internal.refreshGitStatus() + + expect(projectCalls).toEqual(["/workspace/parent/project-b"]) + expect(sent).toContainEqual({ type: "gitStatus", repo: true }) + }) + it("resolves a unique Local session route to its exact project root", async () => { const routes = new ProjectRouteService() routes.registerProject("a", "/repo/a", 1) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index 3cb7d11ed69..e96a67d6b71 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -9,11 +9,20 @@ type State = "connecting" | "connected" | "disconnected" | "error" type ProviderInternals = { connectionState: State pendingSessionRefresh: boolean + projectID: string | undefined webview: { postMessage: (message: unknown) => Promise } | null initializeConnection: () => Promise handleLoadSessions: () => Promise } +function deferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + function createContext(overrides?: Partial): SessionRefreshContext & { sent: unknown[] } { const sent: unknown[] = [] return { @@ -100,6 +109,40 @@ function createConnection(client: ReturnType) { } describe("KiloProvider pending session refresh", () => { + it("does not let a late listing restore the previous project's identity", async () => { + const client = createClient() + const pending = new Map>>() + client.session.list = async (params: { directory: string }) => { + const next = deferred<{ data: unknown[] }>() + pending.set(params.directory, next) + return next.promise as never + } + const connection = createConnection(client) + await connection.connect() + let active = "a" + const provider = new KiloProvider({} as never, connection as never, undefined, { + rootDirectory: () => `/repo/${active}`, + projectQualifier: () => ({ projectId: active }), + }) + const internal = provider as unknown as ProviderInternals + internal.connectionState = "connected" + + const first = internal.handleLoadSessions() + active = "b" + const second = internal.handleLoadSessions() + + pending.get("/repo/b")!.resolve({ + data: [{ id: "ses-b", projectID: "backend-b", time: { created: 1, updated: 1 } }], + }) + await second + pending.get("/repo/a")!.resolve({ + data: [{ id: "ses-a", projectID: "backend-a", time: { created: 1, updated: 1 } }], + }) + await first + + expect(internal.projectID).toBe("backend-b") + }) + it("keeps worktree sessions with legacy project ids", async () => { const sent: unknown[] = [] const ctx = createContext({ diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index be6e5d816e1..3f23206ec69 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -401,7 +401,7 @@ describe("buildProjectNavOrder", () => { expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2")]) }) - it("renders section members before ungrouped worktrees (matching the project body)", () => { + it("renders ungrouped worktrees before section members (matching the project body)", () => { const order = buildProjectNavOrder([ project({ id: "A", @@ -411,7 +411,76 @@ describe("buildProjectNavOrder", () => { unassigned: [], }), ]) - expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1"), worktreeNavId("A", "aw2")]) + expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2"), worktreeNavId("A", "aw1")]) + }) + + it("follows persisted top-level section order and worktree order", () => { + const order = buildProjectNavOrder([ + project({ + id: "A", + expanded: true, + worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }, { id: "aw3", sectionId: "s2" }], + worktreeOrder: ["aw2", "s2", "s1", "aw3", "aw1"], + sections: [ + { id: "s1", collapsed: false }, + { id: "s2", collapsed: false }, + ], + unassigned: [], + }), + ]) + + expect(order.map((e) => e.id)).toEqual([ + localNavId("A"), + worktreeNavId("A", "aw2"), + worktreeNavId("A", "aw3"), + worktreeNavId("A", "aw1"), + ]) + }) + + it("keeps multi-version worktrees adjacent", () => { + const order = buildProjectNavOrder([ + project({ + id: "A", + expanded: true, + worktrees: [{ id: "aw1", groupId: "g" }, { id: "aw2" }, { id: "aw3", groupId: "g" }], + worktreeOrder: ["aw1", "aw2", "aw3"], + sections: [], + unassigned: [], + }), + ]) + + expect(order.map((e) => e.id)).toEqual([ + localNavId("A"), + worktreeNavId("A", "aw1"), + worktreeNavId("A", "aw3"), + worktreeNavId("A", "aw2"), + ]) + }) + + it("matches raw ungrouped order when sections are present", () => { + const order = buildProjectNavOrder([ + project({ + id: "A", + expanded: true, + worktrees: [ + { id: "aw1", groupId: "g" }, + { id: "aw2" }, + { id: "aw3", groupId: "g" }, + { id: "aw4", sectionId: "s1" }, + ], + worktreeOrder: ["aw1", "aw2", "aw3", "s1", "aw4"], + sections: [{ id: "s1", collapsed: false }], + unassigned: [], + }), + ]) + + expect(order.map((e) => e.id)).toEqual([ + localNavId("A"), + worktreeNavId("A", "aw1"), + worktreeNavId("A", "aw2"), + worktreeNavId("A", "aw3"), + worktreeNavId("A", "aw4"), + ]) }) it("excludes unassigned sessions when the sessions section is collapsed", () => { diff --git a/packages/kilo-vscode/tests/unit/project-local-navigation.test.ts b/packages/kilo-vscode/tests/unit/project-local-navigation.test.ts new file mode 100644 index 00000000000..7f5247b181d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/project-local-navigation.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test" +import { worktreeNavId } from "../../webview-ui/agent-manager/navigate" +import { projectAdjacentHint, projectWorktreeRow } from "../../webview-ui/agent-manager/project-local-navigation" + +describe("projectAdjacentHint", () => { + it("does not leak a hint to another project with the same raw ID", () => { + expect(projectAdjacentHint("project-a", "project-a", "shared", "local", ["local", "shared"], "prev", "next")).toBe( + "next", + ) + expect(projectAdjacentHint("project-b", "project-a", "shared", "local", ["local", "shared"], "prev", "next")).toBe( + "", + ) + }) + + it("uses the active project's local sidebar order", () => { + expect(projectAdjacentHint("project-a", "project-a", "shared", "local", ["local", "shared"], "prev", "next")).toBe( + "next", + ) + expect(projectAdjacentHint("project-a", "project-a", "local", "shared", ["local", "shared"], "prev", "next")).toBe( + "prev", + ) + expect( + projectAdjacentHint("project-b", "project-b", "shared", "local", ["local", "other", "shared"], "prev", "next"), + ).toBe("") + }) + + it("keeps shortcut and worktree keybinding values scoped for duplicate raw IDs", () => { + const bindings = { + previousSession: "Ctrl+Alt+Up", + nextSession: "Ctrl+Alt+Down", + closeWorktree: "Ctrl+Shift+W", + openWorktree: "Ctrl+Shift+O", + } + const shortcuts = new Map([ + [worktreeNavId("project-a", "shared"), 2], + [worktreeNavId("project-b", "shared"), 4], + ]) + const first = projectWorktreeRow({ + projectId: "project-a", + activeProjectId: "project-a", + worktreeId: "shared", + activeId: "local", + flatIds: ["local", "shared"], + bindings, + shortcuts, + }) + const second = projectWorktreeRow({ + projectId: "project-b", + activeProjectId: "project-a", + worktreeId: "shared", + activeId: "local", + flatIds: ["local", "shared"], + bindings, + shortcuts, + }) + + expect(first.navHint).toBe(bindings.nextSession) + expect(second.navHint).toBe("") + expect(first.shortcut).toBe(2) + expect(second.shortcut).toBe(4) + expect(first.closeKeybind).toBe(bindings.closeWorktree) + expect(first.openKeybind).toBe(bindings.openWorktree) + expect(second.closeKeybind).toBe(bindings.closeWorktree) + expect(second.openKeybind).toBe(bindings.openWorktree) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-store.test.ts b/packages/kilo-vscode/tests/unit/project-store.test.ts index 84dbe9e710c..7f0c7d070e5 100644 --- a/packages/kilo-vscode/tests/unit/project-store.test.ts +++ b/packages/kilo-vscode/tests/unit/project-store.test.ts @@ -42,4 +42,16 @@ describe("project stores", () => { same: { worktreeId: "same", state: "running" }, }) }) + + it("keeps busy worktrees isolated between projects", () => { + const first = createProjectStore("a") + const second = createProjectStore("b") + first.applyState(state("a", ["same"])) + second.applyState(state("b", ["same"])) + + first.setBusy(new Map([["same", { reason: "setting-up" as const }]])) + + expect(first.busy().has("same")).toBe(true) + expect(second.busy().has("same")).toBe(false) + }) }) diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts index db9ac71a419..ac811c0532b 100644 --- a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -315,10 +315,17 @@ describe("server workspace helpers", () => { expect(resolveIndexingEnv([{ uri: { fsPath: "/repo" } }])).toEqual({}) }) - it("uses the shared database for the managed backend while preserving the environment", () => { - expect(resolveManagedServerEnv({ PATH: "/usr/bin", KILO_DISABLE_CHANNEL_DB: "false" })).toEqual({ + it("disables unused managed-backend services while preserving the environment", () => { + expect( + resolveManagedServerEnv({ + PATH: "/usr/bin", + KILO_DISABLE_CHANNEL_DB: "false", + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), + ).toEqual({ PATH: "/usr/bin", KILO_DISABLE_CHANNEL_DB: "true", + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", }) }) }) diff --git a/packages/kilo-vscode/tests/unit/speech-to-text-availability.test.ts b/packages/kilo-vscode/tests/unit/speech-to-text-availability.test.ts index 83848168963..1361f8f5166 100644 --- a/packages/kilo-vscode/tests/unit/speech-to-text-availability.test.ts +++ b/packages/kilo-vscode/tests/unit/speech-to-text-availability.test.ts @@ -23,9 +23,11 @@ describe("speech-to-text availability", () => { }) it("normalizes configured and unknown transcription models", () => { - expect(selectedSpeechToTextModel({ experimental: { speech_to_text_model: "google/chirp-3" } })).toBe( - "google/chirp-3", - ) + expect( + selectedSpeechToTextModel({ experimental: { speech_to_text_model: "google/chirp-3" } }, [ + { id: "google/chirp-3", label: "Chirp 3", provider: "Google" }, + ]), + ).toBe("google/chirp-3") expect(selectedSpeechToTextModel({ experimental: { speech_to_text_model: "unknown/model" } })).toBe( DEFAULT_SPEECH_TO_TEXT_MODEL.id, ) diff --git a/packages/kilo-vscode/tests/unit/speech-to-text-catalog.test.ts b/packages/kilo-vscode/tests/unit/speech-to-text-catalog.test.ts new file mode 100644 index 00000000000..a4e0a8029f8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/speech-to-text-catalog.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test" +import { parseSpeechToTextCatalog } from "../../src/speech-to-text/catalog" +import { DEFAULT_SPEECH_TO_TEXT_MODEL } from "../../src/speech-to-text/models" + +describe("speech-to-text discovery", () => { + it("keeps transcription catalog metadata authoritative and exposes additions", () => { + const models = parseSpeechToTextCatalog([ + { + id: "fish-audio/transcribe-1", + name: "Fish Audio: Transcribe 1", + }, + { + id: "openai/gpt-4o-mini-transcribe", + name: "OpenAI: GPT-4o Mini Transcribe", + }, + { + id: "openai/whisper-1", + name: "Whisper 1", + }, + ]) + + expect(models).toEqual([ + { id: "fish-audio/transcribe-1", label: "Transcribe 1", provider: "Fish Audio" }, + { + id: "openai/gpt-4o-mini-transcribe", + label: "GPT-4o Mini Transcribe", + provider: "OpenAI", + }, + { id: "openai/whisper-1", label: "Whisper 1", provider: "openai" }, + ]) + }) + + it("rejects empty or malformed catalogs so callers can use the static fallback", () => { + expect(parseSpeechToTextCatalog([])).toBeUndefined() + expect(parseSpeechToTextCatalog({ data: [] })).toBeUndefined() + expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("openai/whisper-large-v3-turbo") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 939a8e505be..c2a0dcdfda5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -89,6 +89,7 @@ import type { WorktreeBusyState } from "./project/store" import { rememberTarget, restoreProjectTarget } from "./project/restore" import { createProjectStateRouter } from "./project/state" import { applyRunStatus } from "./project/run-status" +import { clearMultiVersionBusy, markMultiVersionBusy } from "./project/progress" import { selectLocalAction, selectWorktreeAction } from "./selection-actions" import { DataBridge } from "../src/App" import { LanguageBridge } from "../src/context/language-bridge" @@ -103,7 +104,7 @@ import { focusChatSearch, LOCAL, } from "./navigate" -import { createProjectNav } from "./project-nav" +import { buildProjectNavEntries, createProjectNav } from "./project-nav" import { addPendingTab as addLocalPendingTab, nextTabAfterClose, @@ -173,6 +174,7 @@ import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" import { SidebarToggleButton } from "./SidebarToggleButton" import { setTabWidths } from "./tab-widths" +import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "./side-panel-layout" import { buildShortcutCategories } from "./shortcuts" import { tracker } from "./telemetry" import { createChatFocus, hasQuestionOption } from "./focus" @@ -273,7 +275,7 @@ const AgentManagerContent: Component = () => { const MAX_SIDEBAR_WIDTH_RATIO = 0.4 // Recover persisted local session IDs from webview state - const persisted = vscode.getState() + const persisted = vscode.getState() const registry = createProjectRegistry({ persisted: persisted ?? {}, activeId: () => currentProjectId() ?? "single", @@ -313,26 +315,15 @@ const AgentManagerContent: Component = () => { const diffLoading = diffs.diffLoading const setDiffLoading = diffs.setDiffLoading const diffNotices = diffs.diffNotices - // The diff and terminal panels each remember their own width: a diff - // benefits from half the window, a terminal only needs about a third. - const TERMINAL_MIN_WIDTH = 360 - const TERMINAL_MAX_WIDTH = 640 - const [diffWidth, setDiffWidth] = createSignal(Math.round(window.innerWidth * 0.5)) - const [terminalWidth, setTerminalWidth] = createSignal( - Math.min(TERMINAL_MAX_WIDTH, Math.max(TERMINAL_MIN_WIDTH, Math.round(window.innerWidth / 3))), - ) - // The hidden-but-mounted host still fits the terminal, so pick the - // terminal's width whenever one is alive and no other mode is showing. - const widthMode = () => sidePanel() ?? (terms.sides().length > 0 ? "terminal" : null) - const hostWidth = () => (widthMode() === "terminal" ? terminalWidth() : diffWidth()) - const sideMin = () => (widthMode() === "terminal" ? TERMINAL_MIN_WIDTH : 200) + // Diff and terminal views share one inspector width, restored from webview + // state so the user's divider position survives panel reloads. + const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth)) const resizeSide = (width: number) => { - pendingSideWidth = Math.max(sideMin(), Math.min(width, window.innerWidth * 0.8)) + pendingSideWidth = clampPanelWidth(width, window.innerWidth) if (sideRaf !== undefined) return sideRaf = requestAnimationFrame(() => { sideRaf = undefined - if (widthMode() === "terminal") setTerminalWidth(pendingSideWidth!) - else setDiffWidth(pendingSideWidth!) + setPanelWidth(pendingSideWidth!) }) } const showSideTerminal = () => { @@ -642,6 +633,7 @@ const AgentManagerContent: Component = () => { }, key: () => registry.active().id, width: sidebarWidth, + panelWidth, get: () => vscode.getState>(), set: (value) => vscode.setState(value), }) @@ -865,6 +857,16 @@ const AgentManagerContent: Component = () => { /** True when a local session is actively working. */ const isLocalBusy = (): boolean => isAnySessionBusy(localSessionIDs()) + const projectBusy = (projectId: string, worktreeId: string | null): boolean => { + if (projectId === activeProjectId()) { + return worktreeId === null ? isLocalBusy() : isAgentBusy(worktreeId) + } + const ids = (projectSessionsLive()[projectId] ?? []) + .filter((item) => item.worktreeId === worktreeId) + .map((item) => item.id) + return isAnySessionBusy(ids) + } + const isSessionBusy = (id: string): boolean => isAnySessionBusy([id]) /** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */ @@ -882,6 +884,11 @@ const AgentManagerContent: Component = () => { ) /** Map from sidebar item id → 1-based shortcut number (⌘1 for LOCAL, ⌘2 for first worktree, etc.) */ const shortcutMap = createMemo(() => buildShortcutMap(sidebarOrder())) + const projectShortcutMap = createMemo(() => + buildShortcutMap( + buildProjectNavEntries(projectList(), projectStates(), projectLive.sessions()).map((entry) => ({ id: entry.id })), + ), + ) const moveToSection = (ids: string[], sec: string | null) => vscode.postMessage({ type: "agentManager.moveToSection", worktreeIds: ids, sectionId: sec }) @@ -1447,13 +1454,8 @@ const AgentManagerContent: Component = () => { const ev = msg as unknown as AgentManagerMultiVersionProgressMessage if (ev.status === "done" && ev.groupId) { // Clear busy state for all worktrees in this group - setBusyWorktrees((prev) => { - const next = new Map(prev) - for (const wt of worktrees()) { - if (wt.groupId === ev.groupId) next.delete(wt.id) - } - return next - }) + const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() + clearMultiVersionBusy(store, ev.groupId) } } @@ -1462,11 +1464,8 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.worktreeSetup") { const ev = msg as AgentManagerWorktreeSetupMessage if (ev.status === "ready" && ev.sessionId) { - const ms = managedSessions().find((s) => s.id === ev.sessionId) - const wt = ms?.worktreeId ? worktrees().find((w) => w.id === ms.worktreeId) : undefined - if (wt?.groupId) { - setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "setting-up" as const }]])) - } + const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() + markMultiVersionBusy(store, ev.sessionId) } } @@ -1503,7 +1502,8 @@ const AgentManagerContent: Component = () => { // Clear busy state — use worktreeId from the message directly // to avoid race condition where managedSessions() hasn't updated yet if (ev.worktreeId) { - setBusyWorktrees((prev) => { + const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() + store.setBusy((prev) => { const next = new Map(prev) next.delete(ev.worktreeId) return next @@ -2335,6 +2335,9 @@ const AgentManagerContent: Component = () => { projects={projectList()} states={projectStates()} store={(id) => registry.ensure(id)} + busy={(projectId, id) => registry.ensure(projectId).busy().has(id)} + working={(projectId, id) => projectBusy(projectId, id)} + localBusy={(projectId) => projectBusy(projectId, null)} stats={projectLive.stats()} local={projectLive.local()} prs={projectLive.prs()} @@ -2347,6 +2350,7 @@ const AgentManagerContent: Component = () => { t={t} onSearchRef={(ref) => (sidebarSearchMenu = ref)} onShortcuts={handleShowKeyboardShortcuts} + shortcutMap={projectShortcutMap} /> @@ -2459,20 +2463,6 @@ const AgentManagerContent: Component = () => { track={metrics.click} /> - {/* Empty worktree state */} - -
-
- -
-
{t("agentManager.session.noSessions")}
- -
-
- {(state) => (
@@ -2637,16 +2627,16 @@ const AgentManagerContent: Component = () => { 0}>
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 083d892d557..ae15fa3cf6b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -21,6 +21,7 @@ import { useProvider } from "../src/context/provider" import { useConfig } from "../src/context/config" import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" +import { useSpeechToTextModels } from "../src/context/speech-to-text-models" import { getDirectory, getFilename, @@ -111,8 +112,9 @@ export const DiffPanel: Component = (props) => { const provider = useProvider() const { config } = useConfig() const speech = useSpeechToText(vscode, server, { t }) + const speechModels = useSpeechToTextModels() const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates()) - const speechModel = () => selectedSpeechToTextModel(config()) + const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models()) const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) const sendAllKeybind = () => isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index bdcba1c8453..72a68c57ef4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -39,6 +39,7 @@ import { import { useLanguage } from "../src/context/language" import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments" import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" +import { useSpeechToTextModels } from "../src/context/speech-to-text-models" import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut" import { convertToMentionPath } from "../src/utils/path-mentions" import { insertSpacedText } from "../src/components/chat/prompt-input-utils" @@ -131,8 +132,9 @@ export const NewWorktreeDialog: Component<{ const sandboxRequestID = crypto.randomUUID() const sandboxVisible = () => features().sandboxControls && globalConfig().sandbox?.enabled === true const speech = useSpeechToText(vscode, server, { t }) + const speechModels = useSpeechToTextModels() const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates()) - const speechModel = () => selectedSpeechToTextModel(config()) + const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models()) let prior: string | null = null let request: string | undefined const cancel = () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 3fb1220c166..1575e4e4074 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -34,11 +34,14 @@ interface Props { selection?: string currentSessionID?: () => string | undefined mode: ModeRouter - busy?: (id: string) => boolean + busy?: (projectId: string, id: string) => boolean + working?: (projectId: string, id: string) => boolean + localBusy?: (projectId: string) => boolean bindings: Record t: LanguageContextValue["t"] onSearchRef: (ref: SidebarSearchMenuRef) => void onShortcuts: () => void + shortcutMap?: () => Map } export const ProjectList: Component = (props) => { @@ -211,12 +214,16 @@ export const ProjectList: Component = (props) => { project={project} state={props.states[project.id]} store={props.store?.(project.id)} + busy={(id) => props.busy?.(project.id, id) ?? false} + working={(id) => props.working?.(project.id, id) ?? false} + localBusy={() => props.localBusy?.(project.id) ?? false} stats={props.stats[project.id]} local={props.local[project.id]} prs={props.prs[project.id]} sessions={props.sessions[project.id]} selectedProject={props.selectedProject} selection={props.selection} + currentSessionID={props.currentSessionID} bindings={props.bindings} t={props.t} onSelectLocal={(projectId) => select({ projectId, kind: "local" })} @@ -224,6 +231,7 @@ export const ProjectList: Component = (props) => { onSelectSession={(projectId, sessionId) => select({ projectId, kind: "session", sessionId })} onNewWorktree={newWorktree} onDefaultBranch={defaultBranch} + shortcutMap={props.shortcutMap} /> )} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 40b3cd82cb4..e3e2679321f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -21,6 +21,7 @@ import type { import type { LanguageContextValue } from "../src/context/language" import { useVSCode } from "../src/context/vscode" import SectionHeader from "./SectionHeader" +import { SidebarSectionHeader } from "./SidebarSectionHeader" import { WorktreeItem } from "./WorktreeItem" import { UnassignedSessionsSection } from "./UnassignedSessionsSection" import { ProjectActions } from "./ProjectActions" @@ -30,18 +31,24 @@ import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" import { createProjectStore, type ProjectStore } from "./project/store" import { randomColor } from "./section-colors" +import { projectSidebarOrder, projectWorktreeRow } from "./project-local-navigation" + +const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) interface Props { project: AgentProjectSnapshot state?: AgentManagerStateMessage store?: ProjectStore busy?: (id: string) => boolean + working?: (id: string) => boolean + localBusy?: () => boolean stats?: Record local?: LocalGitStats prs?: Record sessions?: ProjectSessionInfo[] selectedProject?: string selection?: string + currentSessionID?: () => string | undefined bindings: Record t: LanguageContextValue["t"] onSelectLocal: (projectId: string) => void @@ -49,6 +56,7 @@ interface Props { onSelectSession: (projectId: string, sessionId: string) => void onNewWorktree: (projectId: string) => void onDefaultBranch: (projectId: string, selected?: string, detected?: string) => void + shortcutMap?: () => Map } /** Permanent real sidebar body for one expanded project. */ @@ -97,9 +105,23 @@ export const ProjectSidebarBody: Component = (props) => { const members = (sectionId: string) => sorted().filter((wt) => wt.sectionId === sectionId) const ungrouped = createMemo(() => sorted().filter((wt) => !wt.sectionId)) const top = createMemo(() => buildTopLevelItems(sections(), ungrouped(), sorted(), order())) + const sidebarOrder = createMemo(() => + projectSidebarOrder(top(), sorted(), sections(), members, state()?.sessionsCollapsed ? [] : localSessions()), + ) const post = (message: Record) => vscode.postMessage({ ...message, projectId: props.project.id } as never) + const row = (id: string) => + projectWorktreeRow({ + projectId: props.project.id, + activeProjectId: props.selectedProject, + worktreeId: id, + activeId: props.selection ?? props.currentSessionID?.(), + flatIds: sidebarOrder(), + bindings: props.bindings, + shortcuts: props.shortcutMap?.(), + }) + const scope = (kind: "section" | "worktree", id: string) => `${props.project.id}:${kind}:${id}` const parse = (kind: "section" | "worktree", value: unknown) => { if (typeof value !== "string") return @@ -207,6 +229,7 @@ export const ProjectSidebarBody: Component = (props) => { const renderWorktree = (worktree: WorktreeState, idx: () => number, list: WorktreeState[]) => { const label = () => firstOrderedTitle(sessions(worktree.id), store.tabOrder()[worktree.id], worktree.branch) const subtitle = () => (label() !== worktree.branch ? worktree.branch : undefined) + const values = () => row(worktree.id) const sortable = createSortable(scope("worktree", worktree.id)) void sortable return ( @@ -219,9 +242,11 @@ export const ProjectSidebarBody: Component = (props) => { active={active() && props.selection === worktree.id} pendingDelete={pending() === worktree.id} busy={props.busy?.(worktree.id) ?? false} - working={runs()[worktree.id]?.state === "running"} + working={props.working?.(worktree.id) || runs()[worktree.id]?.state === "running"} stale={state()?.staleWorktreeIds?.includes(worktree.id) === true} stats={props.stats?.[worktree.id]} + shortcut={values().shortcut} + navHint={values().navHint} sessions={sessions(worktree.id).length} grouped={isGrouped(worktree)} groupStart={isGroupStart(worktree, idx(), list)} @@ -229,8 +254,8 @@ export const ProjectSidebarBody: Component = (props) => { groupSize={worktree.groupId ? sorted().filter((item) => item.groupId === worktree.groupId).length : 0} renaming={renaming() === worktree.id} renameValue={name()} - closeKeybind="" - openKeybind="" + closeKeybind={values().closeKeybind} + openKeybind={values().openKeybind} pr={props.prs?.[worktree.id] ?? undefined} runStatus={runs()[worktree.id]} sections={sections()} @@ -279,11 +304,21 @@ export const ProjectSidebarBody: Component = (props) => { data-sidebar-id={`${props.project.id}:local`} onClick={() => props.onSelectLocal(props.project.id)} > - - - - - + }> + + + + + + + + {(shortcut) => ( + + {isMac ? "⌘" : "Ctrl+"} + {shortcut()} + + )} +
{props.t("agentManager.local")} @@ -313,20 +348,25 @@ export const ProjectSidebarBody: Component = (props) => {
-
- - post({ type: "agentManager.createWorktree" })} - onNew={() => props.onNewWorktree(props.project.id)} - onSection={() => createSection()} - onSetup={() => post({ type: "agentManager.configureSetupScript" })} - onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)} - /> -
+ {props.t("agentManager.section.worktrees")}} + actions={ + post({ type: "agentManager.createWorktree" })} + onNew={() => props.onNewWorktree(props.project.id)} + onSection={() => createSection()} + onSetup={() => post({ type: "agentManager.configureSetupScript" })} + onBranch={() => + props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch) + } + /> + } + />
= (props) => (
-
- -
- {props.tools} - -
-
+ {props.t("agentManager.projects")}} + actions={ +
+ {props.tools} + +
+ } + />
project.id)}> {(id) => { const project = () => props.projects.find((item) => item.id === id)! return ( -
-
- - - props.onRemove(project().id)} - /> - - -
+ } + onToggle={() => { + if (project().missing) return + if (!project().trusted) { + props.onTrust(project().id) + return + } + const expanded = !project().expanded + props.onExpand(project().id, expanded) + if (!project().active && project().trusted) props.onSelect(project().id) + }} + /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx index 823b3c4800a..3bcd0ecbcec 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SectionHeader.tsx @@ -5,6 +5,7 @@ import { Icon } from "@kilocode/kilo-ui/icon" import type { SectionState } from "../src/types/messages" import { SECTION_COLORS, colorCss } from "./section-colors" import { useLanguage } from "../src/context/language" +import { SidebarSectionHeader } from "./SidebarSectionHeader" interface Props { section: SectionState @@ -30,16 +31,23 @@ const SectionHeader: Component = (props) => { const { t } = useLanguage() const [renaming, setRenaming] = createSignal(false) const [value, setValue] = createSignal("") + let cancelled = false const border = () => colorCss(props.section.color) ?? "var(--vscode-panel-border)" const startRename = () => { + cancelled = false setValue(props.section.name) setRenaming(true) } const commit = () => { + if (cancelled) { + cancelled = false + return + } const trimmed = value().trim() + cancelled = true setRenaming(false) props.onRenameEnd?.() if (trimmed && trimmed !== props.section.name) { @@ -48,6 +56,7 @@ const SectionHeader: Component = (props) => { } const cancel = () => { + cancelled = true setRenaming(false) props.onRenameEnd?.() } @@ -56,11 +65,6 @@ const SectionHeader: Component = (props) => { if (props.autoRename && !renaming()) startRename() }) - const handleClick = (e: MouseEvent) => { - if (e.button !== 0 || renaming()) return - props.onToggle() - } - const droppable = createDroppable(props.dropId ?? props.section.id) return ( @@ -70,41 +74,44 @@ const SectionHeader: Component = (props) => { style={{ "--section-color": border() }} > - -
- - setValue(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") commit() - if (e.key === "Escape") cancel() - }} - onBlur={commit} - onClick={(e) => e.stopPropagation()} - ref={(el) => - requestAnimationFrame(() => - requestAnimationFrame(() => { - el.focus() - el.select() - }), - ) - } - /> - } - > - {props.section.name} - -
- {props.count} + + setValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commit() + if (e.key === "Escape") cancel() + }} + onBlur={commit} + onClick={(e) => e.stopPropagation()} + ref={(el) => + requestAnimationFrame(() => + requestAnimationFrame(() => { + el.focus() + el.select() + }), + ) + } + /> + } + > + {props.section.name} + + } + count={props.count} + onToggle={() => { + if (!renaming()) props.onToggle() + }} + /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index a48c5c643c2..840ec8cfba6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -28,6 +28,7 @@ import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" import { useVSCode } from "../src/context/vscode" import SectionHeader from "./SectionHeader" +import { SidebarSectionHeader } from "./SidebarSectionHeader" import { WorktreeItem } from "./WorktreeItem" import { WorktreeSectionActions } from "./WorktreeSectionActions" import { UnassignedSessionsSection } from "./UnassignedSessionsSection" @@ -183,26 +184,29 @@ export const SidebarBody: Component = (props) => { {/* WORKTREES section */}
-
- - props.onSearchRef(value)} - onSelect={props.onSearchSelect} - onCreate={props.onCreateWorktree} - onNew={props.onNewWorktree} - onSection={props.onNewSection} - onShortcuts={props.onShortcuts} - onSetup={props.onSetup} - onBranch={props.onBranch} - /> -
+ {props.t("agentManager.section.worktrees")}} + actions={ + props.onSearchRef(value)} + onSelect={props.onSearchSelect} + onCreate={props.onCreateWorktree} + onNew={props.onNewWorktree} + onSection={props.onNewSection} + onShortcuts={props.onShortcuts} + onSetup={props.onSetup} + onBranch={props.onBranch} + /> + } + />
void + count?: JSX.Element + actions?: JSX.Element + class?: string + title?: string + ariaLabel?: string +} + +/** Shared layout for sidebar headings with a fixed leading control column. */ +export const SidebarSectionHeader: Component = (props) => { + return ( +
{ + if (event.button === 0) props.onToggle?.() + }} + > +
+ + + +
{props.label}
+
+ + {props.count} + + +
{props.actions}
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx index 966fee8234e..625780bc1cc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx @@ -6,6 +6,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import type { SessionInfo } from "../src/types/messages" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" +import { SidebarSectionHeader } from "./SidebarSectionHeader" interface Props { sessions: Accessor @@ -29,12 +30,13 @@ export const UnassignedSessionsSection: Component = (props) => { return (
- + {t("agentManager.section.sessions")}} + onToggle={props.onToggle} + />
+ active !== null && isTextControl(active) && !active.classList.contains("prompt-input") + export function createChatFocus(deps: { term: () => string | undefined history: () => boolean @@ -7,6 +13,7 @@ export function createChatFocus(deps: { }) { const focus = (force: boolean) => { if ((!force && !document.hasFocus()) || deps.term() || deps.history() || deps.review()) return + if (preservesTextFocus(document.activeElement)) return if (!force && document.activeElement?.matches('[role="tab"]')) return if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return if (focusQuestionOption()) return diff --git a/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts index 15049eec698..7f210d4a560 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts @@ -9,6 +9,7 @@ export function initialMessage(ev: AgentManagerSendInitialMessage): SendMessageR if (!ev.text) return undefined return { type: "sendMessage", + ...(ev.projectId ? { projectId: ev.projectId } : {}), text: ev.text, sessionID: ev.sessionId, providerID: ev.providerID, diff --git a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts index 6fad2144f44..8596666cf91 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts @@ -7,6 +7,8 @@ * Returns the action to take: select a session by ID, go to local, or do nothing. */ +import { sortWorktrees } from "./section-helpers" + /** Sentinel value for the local repo selection. */ export const LOCAL = "local" as const @@ -137,8 +139,8 @@ export function focusChatSearch(reset: { history(v: boolean): void; review(v: bo * Multi-project navigation. * * In multi-project mode the sidebar shows an accordion of projects; each - * expanded project renders its own Local item, worktrees (sections first, - * then ungrouped), and an unassigned-sessions list. Keyboard previous/next + * expanded project renders its own Local item, ungrouped worktrees, section + * members, and an unassigned-sessions list. Keyboard previous/next * and numeric shortcuts must traverse every expanded project in visual * order, not just the active one. * @@ -160,7 +162,9 @@ export interface NavEntry { export interface ProjectNavInput { id: string expanded: boolean - worktrees: { id: string; sectionId?: string }[] + worktrees: { id: string; sectionId?: string; groupId?: string }[] + /** Persisted top-level order containing worktree and section IDs. */ + worktreeOrder?: string[] sections: { id: string; collapsed: boolean }[] sessionsCollapsed: boolean /** Visible unassigned (root, no worktree) sessions in render order. */ @@ -174,10 +178,10 @@ export const sessionNavId = (projectId: string, sessionId: string) => `${project /** * Build one global visual order across expanded projects. * - * For each expanded project (in input order): Local, then worktrees in the - * order the multi-project body renders them (each non-collapsed section's - * members, then ungrouped), then visible unassigned sessions (when the - * sessions section is not collapsed). Collapsed projects contribute nothing. + * For each expanded project (in input order): Local, then ungrouped worktrees, + * then members of each non-collapsed section in top-level order, then visible + * unassigned sessions. This matches `buildTopLevelItems` and the project body. + * Collapsed projects contribute nothing. */ export function buildProjectNavOrder(projects: ProjectNavInput[]): NavEntry[] { const order: NavEntry[] = [] @@ -185,19 +189,28 @@ export function buildProjectNavOrder(projects: ProjectNavInput[]): NavEntry[] { if (!p.expanded) continue const pid = p.id order.push({ id: localNavId(pid), target: { projectId: pid, kind: "local" } }) - for (const sec of p.sections) { + const worktrees = sortWorktrees(p.worktrees, p.worktreeOrder ?? []) + const rank = new Map((p.worktreeOrder ?? []).map((id, index) => [id, index] as const)) + const ungrouped = worktrees.filter((w) => !w.sectionId) + if (p.sections.length > 0) { + ungrouped.sort( + (a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ) + } + const secs = [...p.sections].sort( + (a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ) + for (const w of ungrouped) { + order.push({ id: worktreeNavId(pid, w.id), target: { projectId: pid, kind: "worktree", worktreeId: w.id } }) + } + for (const sec of secs) { if (sec.collapsed) continue - for (const w of p.worktrees) { + for (const w of worktrees) { if (w.sectionId === sec.id) { order.push({ id: worktreeNavId(pid, w.id), target: { projectId: pid, kind: "worktree", worktreeId: w.id } }) } } } - for (const w of p.worktrees) { - if (!w.sectionId) { - order.push({ id: worktreeNavId(pid, w.id), target: { projectId: pid, kind: "worktree", worktreeId: w.id } }) - } - } if (!p.sessionsCollapsed) { for (const s of p.unassigned) { order.push({ id: sessionNavId(pid, s.id), target: { projectId: pid, kind: "session", sessionId: s.id } }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project-local-navigation.ts b/packages/kilo-vscode/webview-ui/agent-manager/project-local-navigation.ts new file mode 100644 index 00000000000..8e963f62f81 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project-local-navigation.ts @@ -0,0 +1,47 @@ +import { adjacentHint, worktreeNavId } from "./navigate" +import { buildSidebarOrder } from "./section-helpers" + +export function projectSidebarOrder(...args: Parameters): string[] { + return buildSidebarOrder(...args).map((item) => item.id) +} + +export function projectAdjacentHint( + projectId: string, + activeProjectId: string | undefined, + itemId: string, + activeId: string | undefined, + flatIds: string[], + prev: string, + next: string, +): string { + if (projectId !== activeProjectId) return "" + return adjacentHint(itemId, activeId, flatIds, prev, next) +} + +interface Input { + projectId: string + activeProjectId?: string + worktreeId: string + activeId?: string + flatIds: string[] + bindings: Record + shortcuts?: Map +} + +/** Resolve the project-scoped values rendered by one worktree row. */ +export function projectWorktreeRow(input: Input) { + return { + shortcut: input.shortcuts?.get(worktreeNavId(input.projectId, input.worktreeId)), + navHint: projectAdjacentHint( + input.projectId, + input.activeProjectId, + input.worktreeId, + input.activeId, + input.flatIds, + input.bindings.previousSession ?? "", + input.bindings.nextSession ?? "", + ), + closeKeybind: input.bindings.closeWorktree ?? "", + openKeybind: input.bindings.openWorktree ?? "", + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts b/packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts index beae37660cc..2ff2fdbe84f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts @@ -19,7 +19,7 @@ import type { AgentManagerStateMessage, AgentProjectSnapshot, ProjectSessionInfo * * Handles previous/next (⌘⌥↑/↓) and numeric-shortcut (⌘1-9) navigation across * the sidebar. In multi-project mode it builds one global visual order across - * every expanded project — Local, worktrees (sections first, then ungrouped), + * every expanded project — Local, ungrouped worktrees, then section members, * and visible unassigned sessions — using stable project-qualified composite * ids, and activates each target with a single atomic * `agentManager.activateSelection` dispatch. In single-project mode it keeps @@ -48,6 +48,31 @@ export interface ProjectNav { jump: (index: number) => void } +/** Build the same global order used by keyboard navigation and shortcut badges. */ +export function buildProjectNavEntries( + projects: AgentProjectSnapshot[], + states: Record, + sessions: Record, +): NavEntry[] { + return buildProjectNavOrder( + projects.map((p) => { + const st = states[p.id] + if (!st) { + return { id: p.id, expanded: false, worktrees: [], sections: [], sessionsCollapsed: false, unassigned: [] } + } + return { + id: p.id, + expanded: p.expanded, + worktrees: (st.worktrees ?? []).map((w) => ({ id: w.id, sectionId: w.sectionId, groupId: w.groupId })), + worktreeOrder: st.worktreeOrder, + sections: (st.sections ?? []).map((s) => ({ id: s.id, collapsed: s.collapsed })), + sessionsCollapsed: st.sessionsCollapsed === true, + unassigned: (sessions[p.id] ?? []).filter((s) => s.worktreeId === null).map((s) => ({ id: s.id })), + } + }), + ) +} + /** DOM selector for the sidebar element backing a nav target. */ export const navSelector = (target: NavTarget): string => { if (target.kind === "local") return `[data-sidebar-id="${target.projectId}:local"]` @@ -74,24 +99,7 @@ export function createProjectNav( if (!deps.multiProject()) return [] const states = deps.states() const live = deps.sessions() - return buildProjectNavOrder( - deps.projects().map((p) => { - const st = states[p.id] - // Until a project's state payload arrives its body shows a spinner - // (no Local/worktrees/sessions) — exclude it from the nav order. - if (!st) { - return { id: p.id, expanded: false, worktrees: [], sections: [], sessionsCollapsed: false, unassigned: [] } - } - return { - id: p.id, - expanded: p.expanded, - worktrees: (st.worktrees ?? []).map((w) => ({ id: w.id, sectionId: w.sectionId })), - sections: (st.sections ?? []).map((s) => ({ id: s.id, collapsed: s.collapsed })), - sessionsCollapsed: st.sessionsCollapsed === true, - unassigned: (live[p.id] ?? []).filter((s) => s.worktreeId === null).map((s) => ({ id: s.id })), - } - }), - ) + return buildProjectNavEntries(deps.projects(), states, live) }) const currentId = createMemo((): string | undefined => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/local-tabs.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/local-tabs.ts index ea57612e34e..9bcf91a378b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/local-tabs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/local-tabs.ts @@ -2,13 +2,14 @@ import { createEffect, createMemo, onCleanup } from "solid-js" import type { SessionInfo } from "../../src/types/messages/sessions" /** - * Persist open tabs and the sidebar width to webview state for recovery. + * Persist open tabs and panel widths to webview state for recovery. * Debounced so a resize drag does not serialize state on every pixel. */ export function persistLocalTabs(opts: { tabs: () => Record key: () => string width: () => number + panelWidth?: () => number get: () => Record | undefined set: (value: Record) => void }): void { @@ -18,9 +19,16 @@ export function persistLocalTabs(opts: { const tabs = opts.tabs() const key = opts.key() const width = opts.width() + const panel = opts.panelWidth?.() clearTimeout(timer) timer = setTimeout(() => { - opts.set({ ...(opts.get() ?? {}), localTabs: tabs, localSessionIDs: tabs[key] ?? [], sidebarWidth: width }) + opts.set({ + ...(opts.get() ?? {}), + localTabs: tabs, + localSessionIDs: tabs[key] ?? [], + sidebarWidth: width, + ...(panel === undefined ? {} : { sidePanelWidth: panel }), + }) }, 300) }) onCleanup(() => clearTimeout(timer)) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts new file mode 100644 index 00000000000..0a626885c7b --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts @@ -0,0 +1,26 @@ +import type { ProjectStore } from "./store" + +/** Clear setup indicators for every worktree in one multi-version group. */ +export function clearMultiVersionBusy(store: ProjectStore, groupId: string): void { + const ids = new Set( + store + .worktrees() + .filter((wt) => wt.groupId === groupId) + .map((wt) => wt.id), + ) + if (ids.size === 0) return + store.setBusy((prev) => new Map([...prev].filter(([id, busy]) => !ids.has(id) || busy.reason === "deleting"))) +} + +/** Keep a newly created grouped worktree showing progress until its prompt starts. */ +export function markMultiVersionBusy(store: ProjectStore, sessionId: string): void { + const session = store.managedSessions().find((item) => item.id === sessionId) + const id = session?.worktreeId + if (!id) return + const worktree = store.worktrees().find((item) => item.id === id) + if (!worktree?.groupId) return + store.setBusy((prev) => { + if (prev.get(id)?.reason === "deleting") return prev + return new Map([...prev, [id, { reason: "setting-up" as const }]]) + }) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts index c79eb3c5378..fba922c053d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts @@ -10,11 +10,11 @@ export type TopLevelItem = { kind: "section"; section: SectionState } | { kind: export type SidebarItem = { type: "local" | "wt" | "session"; id: string } /** Apply persisted order while keeping multi-version worktrees adjacent. */ -export function sortWorktrees(all: WorktreeState[], order: string[]): WorktreeState[] { +export function sortWorktrees(all: T[], order: string[]): T[] { const ordered = applyTabOrder(all, order) if (ordered.length === 0) return [] - const groups = new Map() + const groups = new Map() for (const wt of ordered) { if (!wt.groupId) continue const group = groups.get(wt.groupId) ?? [] @@ -22,7 +22,7 @@ export function sortWorktrees(all: WorktreeState[], order: string[]): WorktreeSt groups.set(wt.groupId, group) } - const result: WorktreeState[] = [] + const result: T[] = [] const placed = new Set() for (const wt of ordered) { if (placed.has(wt.id)) continue @@ -145,7 +145,7 @@ export function buildSidebarOrder( } /** Build a map from sidebar item id → 1-based shortcut number (1 for LOCAL, 2+ for worktrees). */ -export function buildShortcutMap(order: SidebarItem[]): Map { +export function buildShortcutMap(order: { id: string }[]): Map { const map = new Map() for (let i = 0; i < order.length && i < 9; i++) { map.set(order[i]!.id, i + 1) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts new file mode 100644 index 00000000000..8cfaafe4f9e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts @@ -0,0 +1,25 @@ +export const MIN_PANEL_WIDTH = 360 +const DEFAULT_PANEL_WIDTH_RATIO = 0.5 +const MAX_PANEL_WIDTH_RATIO = 0.8 + +function viewportWidth(viewport: number): number { + return Number.isFinite(viewport) && viewport > 0 ? viewport : MIN_PANEL_WIDTH +} + +export function minPanelWidth(viewport: number): number { + const width = viewportWidth(viewport) + return Math.min(MIN_PANEL_WIDTH, Math.round(width * DEFAULT_PANEL_WIDTH_RATIO)) +} + +export function maxPanelWidth(viewport: number): number { + const width = viewportWidth(viewport) + return Math.max(minPanelWidth(width), Math.round(width * MAX_PANEL_WIDTH_RATIO)) +} + +/** Restore or constrain the shared inspector width without trusting saved state. */ +export function clampPanelWidth(value: unknown, viewport: number): number { + const width = viewportWidth(viewport) + const fallback = Math.round(width * DEFAULT_PANEL_WIDTH_RATIO) + const candidate = typeof value === "number" && Number.isFinite(value) ? value : fallback + return Math.round(Math.max(minPanelWidth(width), Math.min(candidate, maxPanelWidth(width)))) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 8ae231cacb4..606c8fa47ec 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -158,39 +158,6 @@ export const TerminalTab: Component = (props) => { }) const fit = new FitAddon() term.loadAddon(fit) - // Clickable URLs in terminal output (Cmd/Ctrl+click to open). - // WebLinksAddon's default handler calls `window.open`, which VS Code - // webviews intercept and silently drop — so we pass an explicit - // handler that posts an `openExternal` message. The message falls - // through `AgentManagerProvider.onMessage` to the underlying - // `KiloProvider.handleWebviewMessage` path which already calls - // `vscode.env.openExternal` for sidebar + settings links. - term.loadAddon( - new WebLinksAddon((_event, url) => { - vscode.postMessage({ type: "openExternal", url }) - }), - ) - // OSC 52 clipboard support — lets shell programs (tmux, neovim, etc.) - // copy to the system clipboard via escape sequences. Writes always - // work in the webview; reads require the clipboard-read permission, - // which VS Code does not grant by default, so paste-from-escape - // silently falls back to no-op. Acceptable trade-off. - term.loadAddon(new ClipboardAddon()) - // Unicode 15 grapheme-aware width tables. Fixes cell width for - // emoji introduced in Unicode 12-15 (🫠 melting face, 🫡 salute, - // 🧌 troll, and ~400 others) plus ZWJ grapheme sequences like - // 👨‍👩‍👧‍👦 and 🏳️‍🌈. The older `@xterm/addon-unicode11` (which VS - // Code's integrated terminal still uses) stops at Unicode 11 - // (2018), leaving all post-2020 emoji rendered with wrong width — - // the canvas cuts them off in the DOM renderer and cursor math - // drifts by one cell per emoji. VS Code hides this visually with - // WebGL; in a webview we don't have that fallback, so we fix it - // at the buffer-width layer instead. Addon is marked - // "experimental" in its README but has been stable on npm since - // 2023, is shipped by the same maintainer as the core xterm.js - // package, and has no open bugs as of v0.4.0. - term.loadAddon(new UnicodeGraphemesAddon()) - term.unicode.activeVersion = "15-graphemes" term.open(host) // Fit on the next frame — `host` might still have 0px dimensions // during the initial layout pass otherwise. @@ -232,6 +199,8 @@ export const TerminalTab: Component = (props) => { let fallbackTimer: ReturnType | undefined let streamed = false let socketEnded = false + let frame: number | undefined + let deferred: number | undefined // The failure line must not depend on event ordering: the stream can // close before the exited snapshot lands (fast failures), or stay open // when a background child outlives the script. Write it exactly once, @@ -358,6 +327,34 @@ export const TerminalTab: Component = (props) => { } const disposeData = term.onData(send) open(props.wsUrl) + + // These addons are not needed to paint the initial prompt. Defer them + // until after the first frame so their startup work, especially the + // Unicode 15 width tables, does not delay the shell connection. + const loadAddons = () => { + deferred = undefined + if (closed) return + + // Clickable URLs in terminal output (Cmd/Ctrl+click to open). + // WebLinksAddon's default handler calls `window.open`, which VS Code + // webviews intercept and silently drop, so post an explicit message. + term.loadAddon( + new WebLinksAddon((_event, url) => { + vscode.postMessage({ type: "openExternal", url }) + }), + ) + // OSC 52 clipboard support for shell programs such as tmux and neovim. + term.loadAddon(new ClipboardAddon()) + // Use grapheme-aware width tables for newer emoji and ZWJ sequences. + term.loadAddon(new UnicodeGraphemesAddon()) + term.unicode.activeVersion = "15-graphemes" + term.refresh(0, Math.max(0, term.rows - 1)) + } + frame = requestAnimationFrame(() => { + frame = undefined + deferred = requestAnimationFrame(loadAddons) + }) + const restarted = (url: string) => { open(url) } @@ -525,6 +522,8 @@ export const TerminalTab: Component = (props) => { onCleanup(() => { closed = true if (pendingFrame !== null) cancelAnimationFrame(pendingFrame) + if (frame !== undefined) cancelAnimationFrame(frame) + if (deferred !== undefined) cancelAnimationFrame(deferred) document.removeEventListener("visibilitychange", onVisibilityChange) window.removeEventListener("focus", onWindowFocus) host.removeEventListener("focusin", onFocusIn) diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index 5ab642292e1..08115371429 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -24,6 +24,7 @@ import type { DiffViewerNotice } from "../src/types/messages/extension-messages" import { DiffPickerHeader } from "./DiffPickerHeader" import { BaseBranchPicker } from "./BaseBranchPicker" import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm" +import { SpeechToTextModelsProvider } from "../src/context/speech-to-text-models" const NOTICE_KEYS: Record = { "snapshots-disabled": "diffViewer.notice.snapshotsDisabled", @@ -306,8 +307,10 @@ export const DiffViewerApp: Component = () => { - - + + + + diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx index 73ba90e60f5..6f708f8bd91 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/FullScreenDiffView.tsx @@ -27,6 +27,7 @@ import { useProvider } from "../src/context/provider" import { useConfig } from "../src/context/config" import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" +import { useSpeechToTextModels } from "../src/context/speech-to-text-models" import { FileTree } from "./FileTree" import { treeOrder } from "./file-tree-utils" import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" @@ -113,8 +114,9 @@ export const FullScreenDiffView: Component = (props) => const provider = useProvider() const { config } = useConfig() const speech = useSpeechToText(vscode, server, { t }) + const speechModels = useSpeechToTextModels() const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates()) - const speechModel = () => selectedSpeechToTextModel(config()) + const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models()) const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) const sendAllKeybind = () => isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 530e4f89874..075b925b73b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -10,6 +10,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { FileIcon } from "@kilocode/kilo-ui/file-icon" import { Icon } from "@kilocode/kilo-ui/icon" import { showToast } from "@kilocode/kilo-ui/toast" +import { isTextControl } from "../../utils/focus" import { useSession } from "../../context/session" import { useLocalTabs } from "../../context/local-tabs" import { useServer } from "../../context/server" @@ -34,6 +35,7 @@ import { hasGitChangesMention } from "../../hooks/git-changes-context-utils" import { useSlashCommand } from "../../hooks/useSlashCommand" import { useGhostText } from "../../hooks/useGhostText" import { useSpeechToText } from "../speech-to-text/useSpeechToText" +import { useSpeechToTextModels } from "../../context/speech-to-text-models" import { createSpeechShortcut } from "../speech-to-text/shortcut" import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments" import { convertToMentionPath } from "../../utils/path-mentions" @@ -348,6 +350,7 @@ export const PromptInput: Component = (props) => { const ghost = useGhostText(vscode, text, () => server.isConnected()) const speech = useSpeechToText(vscode, server, language) + const speechModels = useSpeechToTextModels() const replaceReviewComments = (next: ReviewComment[]) => { setReviewComments(next) @@ -416,8 +419,12 @@ export const PromptInput: Component = (props) => { const onFocusPrompt = (event: Event) => { const defer = () => event instanceof CustomEvent && event.detail?.deferFocusToQuestion && props.deferFocusToQuestion?.() + const ownsFocus = () => { + const active = document.activeElement + return active !== textareaRef && isTextControl(active) + } const focus = () => { - if (defer()) return + if (defer() || ownsFocus()) return const ref = textareaRef if (!ref) return ref.focus({ preventScroll: true }) @@ -425,7 +432,7 @@ export const PromptInput: Component = (props) => { focus() if (!(event instanceof CustomEvent) || !event.detail?.restore) return const restore = () => { - if (defer()) return + if (defer() || ownsFocus()) return window.focus() focus() } @@ -511,7 +518,7 @@ export const PromptInput: Component = (props) => { ) const isDisabled = () => !server.isConnected() const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates()) - const speechModel = () => selectedSpeechToTextModel(config()) + const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models()) const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 const canSend = () => !isDisabled() && diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx index 81f57c39682..619cf17bd13 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx @@ -7,13 +7,14 @@ import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" import { useProvider } from "../../context/provider" import { useSession } from "../../context/session" +import { useSpeechToTextModels } from "../../context/speech-to-text-models" import { parseModelString } from "../../../../src/shared/provider-model" import { ModelSelectorBase } from "../shared/ModelSelector" import { ThinkingSelectorBase } from "../shared/ThinkingSelector" import SettingsRow from "./SettingsRow" import { DEFAULT_SPEECH_TO_TEXT_MODEL } from "../../../../src/speech-to-text/models" import { hasSpeechToTextAccess, selectedSpeechToTextModel } from "../speech-to-text/availability" -import { SPEECH_TO_TEXT_MODEL_OPTIONS } from "../speech-to-text/model-selector" +import { speechToTextModelOptions } from "../speech-to-text/model-selector" import { AUTOCOMPLETE_SELECTOR_MODELS, getAutocompleteSelection } from "./autocomplete-model-selector" const ModelsTab: Component = () => { @@ -21,6 +22,7 @@ const ModelsTab: Component = () => { const language = useLanguage() const provider = useProvider() const session = useSession() + const speechModels = useSpeechToTextModels() const autocompleteProvider = () => { const v = settings()["autocomplete.provider"] @@ -42,8 +44,9 @@ const ModelsTab: Component = () => { } const subagentModel = createMemo(() => parseModelString(config().subagent_model ?? undefined)) - const speechModel = createMemo(() => selectedSpeechToTextModel(config())) - const speechOption = createMemo(() => SPEECH_TO_TEXT_MODEL_OPTIONS.find((item) => item.value === speechModel())) + const speechModel = createMemo(() => selectedSpeechToTextModel(config(), speechModels.models())) + const speechOptions = createMemo(() => speechToTextModelOptions(speechModels.models())) + const speechOption = createMemo(() => speechOptions().find((item) => item.value === speechModel())) const kiloReady = createMemo(() => hasSpeechToTextAccess(config(), provider.authStates())) const variantKey = createMemo(() => config().subagent_model ?? undefined) const subagentVariants = createMemo(() => Object.keys(provider.findModel(subagentModel())?.variants ?? {})) @@ -190,7 +193,7 @@ const ModelsTab: Component = () => { inactive={kiloReady()} >