diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1d4d1916ab..e3b64495f3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,4 +2,4 @@ blank_issues_enabled: false contact_links: - name: 💬 Join our Discord url: https://kilo.ai/discord - about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question. + about: For support, troubleshooting, how-to questions, and real-time discussion. diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index b8c9ce5479..421bc0e5ca 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -8,6 +8,13 @@ inputs: runs: using: "composite" steps: + # node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22; + # some runner images ship an older system Node on PATH + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + - name: Get baseline download URL id: bun-url shell: bash @@ -33,8 +40,8 @@ runs: shell: bash run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - # Restoring and extracting the ~1 GB cache took 2m23s on Windows, while a # kilocode_change - # fresh install took 1m27s. Keep Windows off this cache until that reverses. # kilocode_change + # Restoring the ~1 GB cache buys nothing on Windows even with Defender exclusions # kilocode_change + # (measured 83-102s Setup Bun with cache vs ~86s fresh install); keep Windows off it. # kilocode_change - name: Restore Bun dependencies if: runner.os != 'Windows' # kilocode_change id: bun-cache @@ -46,13 +53,6 @@ runs: ${{ runner.os }}-bun- # kilocode_change start - - name: Setup Node for native dependency builds - if: runner.os != 'Windows' - uses: actions/setup-node@v6 - with: - node-version: "24" - package-manager-cache: false - - name: Configure node-gyp Node headers if: runner.os != 'Windows' id: node-gyp diff --git a/.github/docs-sync/edit-prompt.md b/.github/docs-sync/edit-prompt.md index c9a2b86abe..0a3b7a98e1 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 167656eda3..15b89734ed 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 0000000000..6baa59124e --- /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 0000000000..cde193b8d4 --- /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 6aaf9dc6c3..f63155adf6 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 9af35c58cd..8341b0ef9a 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 a01eef0a99..eb68084651 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 1ef25b7596..d4b7d2a21f 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/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index 81536eccd2..60f5501cbf 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -6,12 +6,19 @@ on: - ".github/**" - "github/**" - "packages/extensions/**" + - "packages/kilo-*/**" + - "packages/*/src/kilocode/**" + - "packages/*/src/kilo-*/**" + - "packages/plugin-atomic-chat/**" - "packages/opencode/**" - "packages/script/**" - "packages/shared/**" - "packages/storybook/**" - "packages/ui/**" - "script/**" + - "package.json" + - "bun.lock" + - "!packages/kilo-jetbrains/**" workflow_dispatch: jobs: @@ -48,6 +55,16 @@ jobs: - name: Check Effect Promise facade allowlist run: bun run script/check-opencode-promise-facades.ts + - name: Check domain architecture boundaries and ratchets + run: bun run script/check-architecture.ts + + - name: Test the Kilo duplication ratchet + working-directory: packages/script + run: bun test ./tests/check-kilocode-duplication.test.ts + + - name: Check Kilo-owned code duplication + run: bun run check:duplication + - name: Check model tool network boundary run: bun run script/check-model-tool-network.ts diff --git a/.github/workflows/disabled/compliance-close.yml.disabled b/.github/workflows/disabled/compliance-close.yml.disabled index 14e68701e5..a83824e5cf 100644 --- a/.github/workflows/disabled/compliance-close.yml.disabled +++ b/.github/workflows/disabled/compliance-close.yml.disabled @@ -34,10 +34,48 @@ jobs: const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; + const orgMemberAssociations = new Set(['OWNER', 'MEMBER']); + const agentLogin = 'opencode-agent[bot]'; + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'dev', + }); + const teamMembers = new Set( + Buffer.from(file.content, 'base64') + .toString() + .split('\n') + .map((line) => line.trim().toLowerCase()) + .filter(Boolean) + ); + + function isExempt(item) { + const login = item.user?.login?.toLowerCase(); + return ( + login === agentLogin || + orgMemberAssociations.has(item.author_association) || + (login && teamMembers.has(login)) + ); + } for (const item of items) { const isPR = !!item.pull_request; const kind = isPR ? 'PR' : 'issue'; + const login = item.user?.login; + + if (isExempt(item)) { + core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + name: 'needs:compliance', + }); + } catch (e) {} + continue; + } const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, diff --git a/.github/workflows/disabled/duplicate-issues.yml.disabled b/.github/workflows/disabled/duplicate-issues.yml.disabled index c90320bd45..f0e6820248 100644 --- a/.github/workflows/disabled/duplicate-issues.yml.disabled +++ b/.github/workflows/disabled/duplicate-issues.yml.disabled @@ -17,12 +17,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://kilo.ai/install | bash - name: Check duplicates and compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -38,6 +57,7 @@ jobs: opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: Issue number: ${{ github.event.issue.number }} + Issue author association: ${{ github.event.issue.author_association }} Lookup this issue with gh issue view ${{ github.event.issue.number }}. @@ -49,6 +69,8 @@ jobs: Check whether the issue follows our contributing guidelines and issue templates. + If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues. + This project has three issue templates that every issue MUST use one of: 1. Bug Report - requires a Description field with real content @@ -83,7 +105,7 @@ jobs: Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: - If the issue is NOT compliant, start the comment with: + If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with: Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance @@ -129,12 +151,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://kilo.ai/install | bash - name: Recheck compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -148,9 +189,12 @@ jobs: } run: | opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. + Issue author association: ${{ github.event.issue.author_association }} Lookup this issue with gh issue view ${{ github.event.issue.number }}. + If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment. + Re-check whether the issue now follows our contributing guidelines and issue templates. This project has three issue templates that every issue MUST use one of: diff --git a/.github/workflows/disabled/storybook.yml.disabled b/.github/workflows/disabled/storybook.yml.disabled index 1e652104d6..be2e099d0e 100644 --- a/.github/workflows/disabled/storybook.yml.disabled +++ b/.github/workflows/disabled/storybook.yml.disabled @@ -9,6 +9,7 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" + - "packages/session-ui/**" pull_request: branches: [dev] paths: @@ -17,6 +18,7 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" + - "packages/session-ui/**" workflow_dispatch: concurrency: diff --git a/.github/workflows/disabled/triage.yml.disabled b/.github/workflows/disabled/triage.yml.disabled index 0b2c9d89a1..a1dfc0840b 100644 --- a/.github/workflows/disabled/triage.yml.disabled +++ b/.github/workflows/disabled/triage.yml.disabled @@ -16,13 +16,32 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Bun + if: steps.author.outputs.skip != 'true' uses: ./.github/actions/setup-bun - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://kilo.ai/install | bash - name: Triage issue + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index cf25f78470..7a8ee26238 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -13,7 +13,7 @@ on: jobs: build: name: Build docs site - runs-on: blacksmith-4vcpu-ubuntu-2404 # kilocode_change + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change steps: - name: Checkout repository uses: actions/checkout@v6 # kilocode_change diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 147cde8a47..1ae78f3edb 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/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml index 5908b25e73..fa0046ad27 100644 --- a/.github/workflows/publish-jetbrains-bundled.yml +++ b/.github/workflows/publish-jetbrains-bundled.yml @@ -116,17 +116,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - - name: Install build tools - run: | - sudo apt-get update - sudo apt-get install -y patchelf zip unzip - curl --fail --location \ - https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \ - --output "$RUNNER_TEMP/zig.tar.xz" - echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status - tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP" - echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH" - - name: Validate signing secrets run: | missing=0 diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index cb976193ac..0de42df9ee 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -99,17 +99,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - - name: Install build tools - run: | - sudo apt-get update - sudo apt-get install -y patchelf zip - curl --fail --location \ - https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \ - --output "$RUNNER_TEMP/zig.tar.xz" - echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status - tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP" - echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH" - - name: Validate publishing secrets run: | missing=0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9d8ad01d2f..01fa5f7f23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -211,10 +211,12 @@ jobs: export XDG_CACHE_HOME="$root/cache" export XDG_CONFIG_HOME="$root/config" export XDG_STATE_HOME="$root/state" + export KILO_PTY_SMOKE=1 # kilocode_change export KILO_DISABLE_MODELS_FETCH=1 export KILO_DISABLE_PROJECT_CONFIG=1 export KILO_CONFIG_CONTENT='{"enabled_providers":["anthropic"]}' export ANTHROPIC_API_KEY=dummy + "$binary" --pure __pty-smoke # kilocode_change "$binary" --pure models anthropic | grep -q '^anthropic/' ) } @@ -311,6 +313,11 @@ jobs: $env:KILO_DISABLE_PROJECT_CONFIG = "1" $env:KILO_CONFIG_CONTENT = '{"enabled_providers":["anthropic"]}' $env:ANTHROPIC_API_KEY = "dummy" + if ("${{ matrix.arch }}" -eq "x64") { + $env:KILO_PTY_SMOKE = "1" + & $binary --pure __pty-smoke + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } $output = & $binary --pure models anthropic if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not ($output -match "(?m)^anthropic/")) { diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 55f29db1a9..7e69df2dee 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -70,31 +70,25 @@ jobs: exit 1 fi - - name: Resolve CLI asset URL + - name: Download CLI archive id: cli env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ inputs.cli_version }} run: | - VERSION="${{ inputs.cli_version }}" if [[ -z "$VERSION" ]]; then - echo "cli_url=" >> "$GITHUB_OUTPUT" + echo "cli_path=" >> "$GITHUB_OUTPUT" echo "::notice::Testing latest CLI from npm" exit 0 fi - # Resolve the release asset via the API so this works for draft - # releases too (browser /releases/download/... URLs 404 on drafts). - # The installer passes Accept: application/octet-stream + bearer - # auth, which is what the API asset endpoint expects. - # The tag_name filter yields at most one release and one asset, - # so --jq returns a single URL without needing head/pipefail games. - URL=$(gh api "repos/${{ github.repository }}/releases" \ - --jq ".[] | select(.tag_name == \"v${VERSION}\") | .assets[] | select(.name == \"kilo-linux-x64.tar.gz\") | .url") - if [[ -z "$URL" ]]; then - echo "::error::asset kilo-linux-x64.tar.gz not found for v${VERSION}" - exit 1 - fi - echo "cli_url=$URL" >> "$GITHUB_OUTPUT" - echo "::notice::Testing CLI v${VERSION} via asset API: $URL" + ARCHIVE="$RUNNER_TEMP/kilo-cli.tar.gz" + gh release download "v$VERSION" \ + --repo "$GH_REPO" \ + --pattern kilo-linux-x64.tar.gz \ + --output "$ARCHIVE" + echo "cli_path=$ARCHIVE" >> "$GITHUB_OUTPUT" + echo "::notice::Testing CLI v${VERSION} from local archive" # Harbor's default agent-setup timeout is 360s. The hello-world container # (FROM ubuntu:24.04) needs apt-get update + apt-get install + a NodeSource @@ -108,8 +102,7 @@ jobs: # limits so this is harmless. - name: Run smoke test — hello-world env: - KILO_CLI_URL: ${{ steps.cli.outputs.cli_url }} - KILO_CLI_GITHUB_TOKEN: ${{ github.token }} + KILO_CLI_PATH: ${{ steps.cli.outputs.cli_path }} run: | ./scripts/run_eval.sh \ -m kilo/anthropic/claude-sonnet-4.6 \ @@ -119,8 +112,7 @@ jobs: - name: Run smoke test — log-summary-date-ranges env: - KILO_CLI_URL: ${{ steps.cli.outputs.cli_url }} - KILO_CLI_GITHUB_TOKEN: ${{ github.token }} + KILO_CLI_PATH: ${{ steps.cli.outputs.cli_path }} run: | ./scripts/run_eval.sh \ -m kilo/anthropic/claude-sonnet-4.6 \ diff --git a/.github/workflows/test-jetbrains.yml b/.github/workflows/test-jetbrains.yml index cec4ca2dbd..fe01dd286e 100644 --- a/.github/workflows/test-jetbrains.yml +++ b/.github/workflows/test-jetbrains.yml @@ -16,7 +16,7 @@ env: jobs: changes: name: detect JetBrains changes - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change outputs: jetbrains: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.jetbrains }} steps: @@ -34,6 +34,8 @@ jobs: jetbrains: - '**' - '!.changeset/**' + - '!.kilo/plans/**' + - '!packages/opencode/**' - '!packages/kilo-vscode/**' - '!packages/kilo-docs/**' @@ -41,7 +43,7 @@ jobs: name: jetbrains needs: changes if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change defaults: run: shell: bash @@ -102,7 +104,7 @@ jobs: - changes - unit if: always() - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change steps: - name: Verify JetBrains jobs passed run: | diff --git a/.github/workflows/test-vscode.yml b/.github/workflows/test-vscode.yml index a19617ef32..12c717a7e5 100644 --- a/.github/workflows/test-vscode.yml +++ b/.github/workflows/test-vscode.yml @@ -21,7 +21,7 @@ on: jobs: unit: name: unit tests - runs-on: blacksmith-4vcpu-ubuntu-2404 # kilocode_change + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change defaults: run: shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e02e5b705..5a83c537f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,6 +46,7 @@ jobs: general: - '**' - '!.changeset/**' + - '!.kilo/plans/**' - '!packages/kilo-jetbrains/**' - '!packages/kilo-vscode/**' - '!packages/kilo-docs/**' @@ -60,13 +61,15 @@ jobs: run: | if [ "$GENERAL" != "true" ]; then echo 'general=false' >> "$GITHUB_OUTPUT" - echo 'settings=[{"os":"linux","index":1,"total":1,"host":"blacksmith-4vcpu-ubuntu-2404","run":false,"packages":false}]' >> "$GITHUB_OUTPUT" + echo 'settings=[{"os":"linux","index":1,"total":1,"host":"blacksmith-4vcpu-ubuntu-2404","run":false,"packages":false,"pty":false}]' >> "$GITHUB_OUTPUT" exit 0 fi echo 'general=true' >> "$GITHUB_OUTPUT" - echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT" + # kilocode_change - Windows runs 6 shards at KILO_TEST_CONCURRENCY=2. Measured: a + # 5-shard matrix was worse on both wall-clock (slowest job 9.3m vs 6.7m) and machine + # minutes (43 vs 37) — with 2 workers per shard, packing more work per shard loses. + echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true,"pty":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false,"pty":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true,"pty":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true,"pty":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false}]' >> "$GITHUB_OUTPUT" # kilocode_change end - unit: # kilocode_change start name: ${{ !matrix.settings.run && 'unit (unchanged)' || matrix.settings.total > 1 && format('unit ({0}, {1}/{2})', matrix.settings.os, matrix.settings.index, matrix.settings.total) || format('unit ({0})', matrix.settings.os) }} @@ -78,6 +81,12 @@ jobs: settings: ${{ fromJSON(needs.changes.outputs.settings) }} # kilocode_change runs-on: ${{ matrix.settings.host }} timeout-minutes: 45 # kilocode_change + # kilocode_change start - manual dispatches exist to validate real execution (soak runs, + # timing measurements); every turbo step in this job bypasses the cache on dispatch + # instead of replaying a same-commit hit and executing nothing. + env: + TURBO_FORCE: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }} + # kilocode_change end defaults: run: shell: bash @@ -88,6 +97,27 @@ jobs: run: echo "Only isolated product, documentation, or metadata files changed; general unit tests are unchanged." # kilocode_change end + # kilocode_change start - Defender real-time scanning taxes every process spawn and + # temp-file write on Windows; the suite spawns hundreds of test processes and builds + # temp git repos, so exclude the runner's work and temp paths up front. Best effort by + # design: on images where Defender is absent or locked down, the suite runs unchanged. + - name: Exclude runner work dirs from Defender scanning + if: matrix.settings.run && runner.os == 'Windows' + continue-on-error: true + shell: pwsh + run: | + $applied = 0 + foreach ($dir in @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, "$env:USERPROFILE\.bun")) { + if (-not $dir) { continue } + try { + Add-MpPreference -ExclusionPath $dir -ErrorAction Stop + $applied++ + } catch { + Write-Host "Defender exclusion failed for ${dir}: $($_.Exception.Message)" + } + } + Write-Host "Defender path exclusions applied: $applied" + # kilocode_change end - name: Checkout repository if: matrix.settings.run # kilocode_change uses: actions/checkout@v6 # kilocode_change @@ -143,11 +173,40 @@ jobs: # kilocode_change end # kilocode_change start - test non-CLI packages separately from sharded CLI tests + - name: Verify package test scheduling + if: matrix.settings.run && matrix.settings.packages && matrix.settings.os == 'linux' + run: bun run script/check-test-ci.ts + + - name: Run root tooling unit tests + if: matrix.settings.run && matrix.settings.packages && matrix.settings.os == 'linux' + run: bun run test:script:ci + - name: Run non-CLI unit tests if: matrix.settings.run && matrix.settings.packages run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains' - # kilocode_change end + env: + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - non-CLI tests use the watcher-free unit-test profile + # Keep this outside the CLI shard and OS profiles so one real PTY contract + # always runs on Linux, macOS, and Windows. + - name: Run cross-platform PTY service tests + if: matrix.settings.run && matrix.settings.pty + working-directory: packages/core + run: bun test test/kilocode/pty-platform.test.ts --timeout 60000 + + - name: Run cross-platform PTY route and TUI smoke tests + if: matrix.settings.run && matrix.settings.pty + working-directory: packages/opencode + run: | + bun test test/kilocode/pty-smoke.test.ts --timeout 60000 + bun test test/server/httpapi-pty.test.ts --test-name-pattern "serves Agent Manager regular terminal" --timeout 60000 + bun test test/server/httpapi-v2-pty.test.ts --test-name-pattern "serves Agent Manager script terminal" --timeout 60000 + + - name: Run Windows worktree cleanup regression + if: matrix.settings.run && matrix.settings.pty && runner.os == 'Windows' + working-directory: packages/kilo-vscode + run: bun test tests/unit/worktree-manager.test.ts --test-name-pattern "keeps a Windows worktree tracked while a live process locks its directory" --timeout 30000 + # kilocode_change end # kilocode_change start - ensure the Darwin profile cannot suppress its own validation - name: Validate Darwin CLI test profile if: matrix.settings.run && matrix.settings.os == 'macos' @@ -163,6 +222,16 @@ jobs: KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - was Windows-only; the CLI now starts a watcher per instance, too heavy/racy for unit tests. Watcher tests opt back in. KILO_TEST_PROFILE: ${{ matrix.settings.os == 'macos' && 'darwin' || '' }} KILO_TEST_SHARD: ${{ format('{0}/{1}', matrix.settings.index, matrix.settings.total) }} + # kilocode_change - cap parallelism on the 4-vCPU Windows runner. At the default + # min(4, cpus)=4, four heavy real-server test files share 4 vCPUs (~1 each) and blow + # their per-test timeouts; 2 gives each process real CPU headroom. Windows grows to + # 6 shards to absorb the lower per-shard parallelism. Linux/macOS (not timeout + # offenders; macOS is a single unsharded job) keep the default. + KILO_TEST_CONCURRENCY: ${{ matrix.settings.os == 'windows' && '2' || '' }} + # kilocode_change - 600s on every OS: the deadline guards against hangs, and a + # saturated shard can legitimately hold a heavy real-subprocess file past 300s + # (seen on Linux: run-process passing every test, killed at the deadline twice). + KILO_TEST_FILE_TIMEOUT: "600000" # kilocode_change end # kilocode_change start @@ -170,7 +239,9 @@ jobs: if: always() && matrix.settings.run uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: - report_paths: packages/*/.artifacts/unit/junit.xml + report_paths: | + .artifacts/unit/junit.xml + packages/*/.artifacts/unit/junit.xml annotate_only: true detailed_summary: true include_time_in_summary: true @@ -178,13 +249,19 @@ jobs: - name: Upload unit artifacts if: always() && matrix.settings.run + # kilocode_change - diagnostics only: a runner-side network blip here must not + # fail a job whose tests already passed (seen as ECONNREFUSED on Blacksmith). + continue-on-error: true uses: actions/upload-artifact@v7 with: name: unit-${{ matrix.settings.os }}-${{ matrix.settings.index }}-${{ github.run_attempt }} include-hidden-files: true if-no-files-found: ignore retention-days: 7 - path: packages/*/.artifacts/unit/junit.xml + path: | + .artifacts/unit/junit.xml + packages/*/.artifacts/unit/junit.xml + packages/opencode/.artifacts/unit/startup.json # kilocode_change end # kilocode_change start @@ -222,10 +299,15 @@ jobs: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}- turbo-${{ runner.os }}- + - name: Check generated client + if: runner.os == 'Linux' + working-directory: packages/client + run: bun run check:generated - name: Run HttpApi exerciser gates run: bun turbo test:httpapi --filter='@kilocode/cli' env: - KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - same rationale as the CLI unit step: a watcher per scenario instance is too heavy for the exerciser + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - a watcher per scenario instance is too heavy for the exerciser + # kilocode_change end # kilocode_change start @@ -251,7 +333,6 @@ jobs: echo "unit=${{ needs.unit.result }}" test "${{ needs.unit.result }}" = "success" # kilocode_change end - # kilocode_change start required: name: test (linux) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index e309225701..e4a2df3eb2 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -9,53 +9,65 @@ on: workflow_dispatch: jobs: - typecheck-js: - name: typecheck-js - runs-on: blacksmith-4vcpu-ubuntu-2404 # kilocode_change - steps: - - name: Checkout repository - uses: actions/checkout@v6 # kilocode_change - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - # kilocode_change start - - name: Run TypeScript typecheck - run: bun turbo typecheck --filter='!@kilocode/kilo-jetbrains' - - - name: Build Kilo Console - run: bun turbo build --filter=@kilocode/kilo-console - # kilocode_change end - # kilocode_change start - jetbrains-changes: - name: detect JetBrains changes - runs-on: blacksmith-4vcpu-ubuntu-2404 + changes: + name: detect typecheck changes + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change outputs: + javascript: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.javascript }} jetbrains: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.jetbrains }} steps: - name: Checkout repository if: github.event_name != 'workflow_dispatch' uses: actions/checkout@v6 - - name: Detect JetBrains changes + - name: Detect typecheck changes if: github.event_name != 'workflow_dispatch' id: filter uses: Kilo-Org/paths-filter@668c092af3649c4b664c54e4b704aa46782f6f7c # v3 with: predicate-quantifier: every filters: | + javascript: + - '**' + - '!.changeset/**' + - '!.kilo/plans/**' + - '!packages/kilo-jetbrains/**' jetbrains: - '**' - '!.changeset/**' + - '!.kilo/plans/**' + - '!packages/opencode/**' - '!packages/kilo-vscode/**' - '!packages/kilo-docs/**' + # kilocode_change end + typecheck-js: + name: typecheck-js + needs: changes # kilocode_change + if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.javascript == 'true' # kilocode_change + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change + steps: + - name: Checkout repository + uses: actions/checkout@v6 # kilocode_change + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + # kilocode_change start + - name: Run TypeScript typecheck + run: bun turbo typecheck --filter='!@kilocode/kilo-jetbrains' + + - name: Build Kilo Console + run: bun turbo build --filter=@kilocode/kilo-console + # kilocode_change end + + # kilocode_change start typecheck-jetbrains: name: typecheck-jetbrains - needs: jetbrains-changes - if: github.event_name == 'workflow_dispatch' || needs.jetbrains-changes.outputs.jetbrains == 'true' - runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: changes + if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true' + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change steps: - name: Checkout repository uses: actions/checkout@v6 @@ -80,24 +92,30 @@ jobs: required: name: typecheck - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change needs: - typecheck-js - - jetbrains-changes + - changes - typecheck-jetbrains if: always() steps: - name: Verify typecheck jobs passed run: | + echo "changes=${{ needs.changes.result }}" + echo "javascript=${{ needs.changes.outputs.javascript }}" echo "typecheck-js=${{ needs.typecheck-js.result }}" - echo "jetbrains-changes=${{ needs.jetbrains-changes.result }}" echo "typecheck-jetbrains=${{ needs.typecheck-jetbrains.result }}" - test "${{ needs.typecheck-js.result }}" = "success" - test "${{ needs.jetbrains-changes.result }}" = "success" - if [ "${{ needs.jetbrains-changes.outputs.jetbrains }}" = "true" ]; then + test "${{ needs.changes.result }}" = "success" + if [ "${{ needs.changes.outputs.javascript }}" = "true" ]; then + test "${{ needs.typecheck-js.result }}" = "success" + else + test "${{ needs.changes.outputs.javascript }}" = "false" + test "${{ needs.typecheck-js.result }}" = "skipped" + fi + if [ "${{ needs.changes.outputs.jetbrains }}" = "true" ]; then test "${{ needs.typecheck-jetbrains.result }}" = "success" else - test "${{ needs.jetbrains-changes.outputs.jetbrains }}" = "false" + test "${{ needs.changes.outputs.jetbrains }}" = "false" test "${{ needs.typecheck-jetbrains.result }}" = "skipped" fi # kilocode_change end diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index dbe2a07c24..aa0293b7d9 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -59,7 +59,7 @@ jobs: needs: check-paths if: needs.check-paths.outputs.matched == 'true' name: Visual Regression (kilo-ui) # kilocode_change - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change timeout-minutes: 15 steps: @@ -221,7 +221,7 @@ jobs: needs: check-paths if: needs.check-paths.outputs.matched == 'true' name: Visual Regression (kilo-vscode webview) # kilocode_change - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change timeout-minutes: 15 env: NODE_OPTIONS: --max-old-space-size=4096 diff --git a/.gitignore b/.gitignore index ad3b43d63d..86e7e67c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ tsconfig.tsbuildinfo .kilo/yarn.lock .kilo/node_modules .kilo/plans/*upstream-merge-report-*.md +**/.kilo/jetbrains.json .kilocode/.gitignore .kilocode/package.json .kilocode/package-lock.json @@ -67,6 +68,11 @@ tsconfig.tsbuildinfo .kilocode/yarn.lock .kilocode/node_modules +# Runtime-generated per-directory TUI config (e.g. written by `PATCH /tui/config` +# during local testing); should never be committed. +packages/*/.kilo/tui.json +packages/*/.kilo/tui.jsonc + # Test Artifacts packages/app/.artifacts/ packages/opencode/.artifacts/ diff --git a/.kilo/plans/1786990849107-jetbrains-devcontainer-unsupported-notice.md b/.kilo/plans/1786990849107-jetbrains-devcontainer-unsupported-notice.md new file mode 100644 index 0000000000..e740e52a0d --- /dev/null +++ b/.kilo/plans/1786990849107-jetbrains-devcontainer-unsupported-notice.md @@ -0,0 +1,103 @@ +# JetBrains: graceful "Dev Container not supported" workspace notice + +## Goal + +When a JetBrains project is opened so that its directory is a **local-IDE + virtual (IJent) path** — e.g. `/$devcontainer.ij/@…podman.sock/…` (the "Model 2" case) — the host-side Kilo CLI cannot resolve the directory, so agent resolution returns HTTP 500 and the workspace fails to load. Today this surfaces as a generic red "Workspace loading failed" banner with a futile "Try again". + +Replace that with a clear, non-error notice that: +- explains Kilo can't access the project because it's opened through a Dev Container / remote virtual filesystem, and +- recommends running Kilo **inside the container** via JetBrains Remote Development (backend-in-container, the validated "Model 1" flow) as the preferred way, and +- offers a "Learn more" link. + +Detection short-circuits workspace load **before** the 3× `/agent` 500 retries. + +## Background (verified in code) + +- Backend workspace load: `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt` (`load()` fetches agents/providers/commands/skills; failure → `KiloWorkspaceState.Error`). +- Backend state model: `.../backend/workspace/KiloWorkspaceState.kt` (`Pending/Loading/Ready/Error`). +- RPC DTO: `packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt` (`KiloWorkspaceStatusDto = PENDING/LOADING/READY/ERROR`). +- DTO mapping (exhaustive `when` over sealed state): `.../backend/rpc/KiloWorkspaceRpcApiImpl.kt` `dto(state)` (~line 493). Directory resolution `resolveProjectDirectory` returns `project.basePath`; `localConfig` (~line 343) already throws `InvalidPathException` for IJent paths. +- Frontend mapping to UI: `.../frontend/.../session/controller/SessionController.kt` `resolveConnectionState()` (~line 2270) maps `workspace.status == ERROR` → `ConnectionChanged.ShowError`; `retryConnection` reloads the workspace when status is ERROR (~line 622). +- Connection banner UI: `.../frontend/.../session/ui/ConnectionPanel.kt` (red/warning label + expandable details + "Try again" `ActionLink`). Events defined in `.../session/controller/SessionControllerEvent.kt` (`ConnectionChanged.{Hide,ShowConnecting,ShowDownloading,ShowError,ShowWarning}`). +- Strings: `.../frontend/src/main/resources/messages/KiloBundle.properties` (`session.connection.*`). Only the base bundle needs new keys; other locales fall back. +- This builds on the committed fix (`fix(jetbrains): don't surface workspace fetch failures as IDE errors`); genuine 500s from *real* directories keep the existing Error path. + +## Design decisions + +1. **Representation: dedicated non-error state (recommended).** Add a new workspace status rather than reusing `ERROR`, so the UI is informational (not red), retry is suppressed, and there is no 500/log/retry spam. +2. **Detection lives in the backend workspace load path**, keyed on the directory string, run before any fetch. Global app load stays `READY` (providers/config are global and unaffected). +3. **Detection signals (low false-positive):** + - Directory contains the marker `/$devcontainer.ij/`, or + - starts with a WSL root `\\wsl$\` or `\\wsl.localhost\`, or + - `java.nio.file.Path.of(directory)` (or normalize) throws `InvalidPathException`. + Do **not** trigger solely on "path doesn't exist" (avoids false positives on transient FS states / real paths). Real container paths (Model 1, e.g. `/workspaces/podman`) and normal local paths never match. +4. **Retry:** hidden for the unsupported state (deterministic; reload would re-detect). The workspace re-evaluates naturally when the directory changes (new workspace instance). +5. **Link:** a single "Learn more" hyperlink. Default target `https://kilo.ai/docs/jetbrains/dev-containers` (see Open items — confirm/replace). Keep the URL as one constant so it's trivial to change. +6. **Localization:** add keys to base `KiloBundle.properties` only. + +## Implementation tasks (ordered) + +### 1. Shared DTO +- `shared/.../rpc/dto/KiloWorkspaceStateDto.kt`: add `UNSUPPORTED` to `KiloWorkspaceStatusDto`. Reuse the existing `error: String?` field to carry a short reason code/message (no new field required); keep `errors` empty for this state. + +### 2. Backend state + detection +- `backend/.../workspace/KiloWorkspaceState.kt`: add `data class Unsupported(val reason: String) : KiloWorkspaceState()`. +- Add a small pure, unit-testable helper (single-word-friendly names), e.g. `RemoteDirectory.detect(directory: String): String?` returning a reason string when the directory is an unsupported virtual/IJent path, else `null`. Place it in `backend/.../workspace/` (backend-owned; no `kilocode_change` marker needed — new Kilo file in the Kilo plugin). +- `backend/.../workspace/KiloBackendWorkspace.kt`: at the very start of `load()`, if `RemoteDirectory.detect(directory) != null`, set `_state.value = KiloWorkspaceState.Unsupported(reason)`, log a single `info`/`warn` line (not `error`), and return without fetching. Ensure no fetch/retry runs. + +### 3. Backend RPC mapping +- `backend/.../rpc/KiloWorkspaceRpcApiImpl.kt` `dto(state)`: add branch `is KiloWorkspaceState.Unsupported -> KiloWorkspaceStateDto(status = UNSUPPORTED, error = state.reason)`. + +### 4. Frontend event + controller +- `frontend/.../session/controller/SessionControllerEvent.kt`: add `data class ShowNotice(val summary: String, val detail: String?, val learnMoreUrl: String? = null) : ConnectionChanged()`. +- `frontend/.../session/controller/SessionController.kt`: + - `resolveConnectionState()`: add a branch for `workspace.status == KiloWorkspaceStatusDto.UNSUPPORTED` (place before the generic ERROR branch) returning `ShowNotice(summary=…, detail=…, learnMoreUrl=…)` using new bundle keys. + - `retryConnection()` / retry path (~line 622): do **not** call `workspace.reload()` when status is `UNSUPPORTED`. + - Confirm `WorkspaceChanged` handling (~line 933) already no-ops for non-READY (it does). + +### 5. Frontend rendering +- `frontend/.../session/ui/ConnectionPanel.kt`: handle `ConnectionChanged.ShowNotice`: + - Info styling (secondary/label foreground, not `errorLabelForeground`). + - Show the guidance text; keep `detail` in the expandable area if used. + - Render a "Learn more" link via platform `HyperlinkLabel`/`ActionLink` that opens `learnMoreUrl` with `BrowserUtil.browse(url)`. + - Hide the "Try again" retry link for this event. + +### 6. Strings +- `frontend/.../messages/KiloBundle.properties`: add keys, e.g.: + - `session.connection.notice.devcontainer.summary=Kilo can't access this Dev Container project` + - `session.connection.notice.devcontainer.detail=This project is opened through a Dev Container/remote virtual filesystem that the Kilo runtime on your machine can't reach. Run Kilo inside the container using JetBrains Remote Development (the IDE backend runs in the container) — that's the recommended way. Local projects also work.` + - `session.connection.notice.learnMore=Learn more` +- Store the docs URL as a Kotlin constant referenced by the controller (single source), not in the bundle. + +### 7. Tests +- Backend `backend/src/test/.../workspace/KiloBackendWorkspaceTest.kt`: + - Given directory `/$devcontainer.ij/abc@…podman.sock/IdeaProjects/x`, workspace state becomes `Unsupported`; assert **no** `/agent` request hit the mock CLI (via `MockCliServer` request log), no retries, and no `ERROR`/500 log lines. + - Add a `\\wsl$\Ubuntu\home\x` case and an `InvalidPathException`-triggering case. + - Negative: a normal local dir and a real `/workspaces/...`-style dir still load normally (existing tests cover normal load). +- Add/extend a mapping assertion: `Unsupported` → `KiloWorkspaceStateDto(status=UNSUPPORTED, error=reason)`. +- Frontend `frontend/src/test/.../session/ui/ConnectionPanelTest.kt`: `ShowNotice` renders info label (not error color), shows the summary, exposes the "Learn more" link, and hides retry. +- Frontend controller test (`session/controller/…`): `KiloWorkspaceStatusDto.UNSUPPORTED` state produces a `ConnectionChanged.ShowNotice`, and `retryConnection()` does not call `workspace.reload()` for UNSUPPORTED. + +### 8. Changeset +- Add `.changeset/.md` (`"@kilocode/kilo-jetbrains": patch`) describing the user-facing behavior: "Show a clear notice (with guidance to run in a Dev Container) instead of a generic error when a project is opened through a Dev Container/remote virtual filesystem Kilo can't access." + +## Out of scope +- Actually supporting Model 2 (running the CLI in-container via Eel/IJent, port forwarding, path translation). This plan only adds graceful communication. +- Hardening every directory-scoped RPC (`models`, file search, git) for virtual paths; they already fail soft. The banner communicates the root cause. + +## Failure modes / edge cases +- **False positive** on a legitimate directory: mitigated by marker-only + `InvalidPathException` detection (not "not exists"). +- **Model 1 unaffected:** backend runs in the container, directory is real (`/workspaces/...`), markers don't match → normal load. +- **App stays READY:** only the workspace is Unsupported; global providers/config still load, so the rest of the UI (settings, providers) remains usable. +- **New enum value:** update the exhaustive `when` in backend `dto()` (compile-enforced). Frontend status checks are equality-based; add the UNSUPPORTED branch in `resolveConnectionState` and confirm no other exhaustive `when(status)` needs a branch (grep `KiloWorkspaceStatusDto.`). + +## Validation +- From `packages/kilo-jetbrains/`: `./gradlew :backend:test --tests ai.kilocode.backend.workspace.KiloBackendWorkspaceTest` and the new frontend tests (`./gradlew :frontend:test --tests …ConnectionPanelTest` and the controller test). +- From `packages/kilo-jetbrains/`: `bun run typecheck` (or `./gradlew typecheck`) and `./gradlew test`. +- Run inspection "Plugin DevKit | Code | Frontend and Backend API Usage" since split-mode code (shared DTO + frontend event) changes. +- Manual (optional): reproduce Model 2 on Linux+rootless Podman (local IDE + IJent path) and confirm the info banner + link appears instead of the red error, with no IDE internal-error popup. + +## Open items (non-blocking; recommended defaults chosen) +1. **Docs URL** for "Learn more": default `https://kilo.ai/docs/jetbrains/dev-containers`. Confirm the final path or point to an existing page; may require creating that docs page (in `packages/kilo-docs/`) separately. If source URLs under `packages/kilo-vscode`/`opencode` change this doesn't apply, but if a docs page is added, run `bun run script/extract-source-links.ts` only if a tracked source URL changes. +2. **Optional telemetry:** capture a "Dev Container Unsupported Shown" event via the existing `capture(...)` pattern in `SessionController` when the notice is first shown. Recommended: include it; low cost. +3. **Copy review:** finalize the exact wording of the summary/detail strings. diff --git a/.kilo/plans/1787073472111-jetbrains-devcontainer-outcome-card.md b/.kilo/plans/1787073472111-jetbrains-devcontainer-outcome-card.md new file mode 100644 index 0000000000..d54ad3b48e --- /dev/null +++ b/.kilo/plans/1787073472111-jetbrains-devcontainer-outcome-card.md @@ -0,0 +1,177 @@ +# JetBrains: surface "Dev Container unsupported" via the existing turn‑outcome card + +## Goal + +When a JetBrains project is opened through a local‑IDE + virtual (IJent) path (Model 2 — +`/$devcontainer.ij/…podman.sock/…`, `\\wsl$\…`, or an `InvalidPathException` path), the host CLI +can't resolve the directory and the workspace can't load. Communicate this **using the existing +in‑chat outcome card** (`SessionOutcomeView`), shown **immediately** on open — not with a new +ConnectionPanel banner. Extract the outcome card so it renders both inside a session transcript +(existing use) and standalone for this workspace‑level condition. + +Decision from planning: **show immediately when the workspace is `UNSUPPORTED`, and extract the +common outcome‑card UI so it is reused in the session context and outside it.** + +## Precondition (blocking) + +- The outcome UI (`model/TurnOutcome.kt`, `views/SessionOutcomeView.kt`, `SessionState.TurnEnded`, + `SessionMessageListPanel.outcome` wiring, `SessionUi.outcome`) exists on **`origin/main`** (commits + `b1a8893f14`, `0116b63641`, `58b6edd04f`, `5ad8db8a6d`) but **NOT** on this branch + (`investigate-podman-container-crash`, ~176 commits behind main). +- **Task 1 must be merging/rebasing `origin/main` into this branch** so the outcome UI is present. + Cherry‑picking just the four commits is a fallback but rebase/merge is required before the PR + merges anyway. Do this first; reconcile the existing partial work (below) during the merge. + +## What already exists on this branch (from earlier work) and how to reconcile + +- **Backend (KEEP):** `KiloWorkspaceStatusDto.UNSUPPORTED`, `KiloWorkspaceState.Unsupported(reason)`, + `RemoteDirectory.detect(...)` short‑circuit at the top of `KiloBackendWorkspace.load()`, `dto()` + mapping branch, the `kilo.dev.forceUnsupportedWorkspace` dev flag, and their tests. No change. +- **Frontend (REPLACE):** the `ConnectionChanged.ShowNotice` event + `ConnectionPanel.showNotice`/ + "Learn more" link + `resolveConnectionState` UNSUPPORTED→ShowNotice branch + `session.connection.notice.*` + bundle keys were the "new way to communicate" the user rejected. These must be removed/repurposed. + +## Design + +1. **Backend stays the source of truth.** Workspace load short‑circuits to `Unsupported(reason)` + before any fetch (already implemented); app stays `READY`; DTO carries `status = UNSUPPORTED`, + `error = reason` (`devcontainer_virtual_filesystem` | `wsl_virtual_filesystem` | `invalid_virtual_path`). +2. **Reuse the outcome card, don't invent UI.** `SessionOutcomeView` (a `DialogView`/`SessionView` + showing header icon + title + description, plus an optional scrollable error body and a + DialogView action footer) is the single card class. It is already constructed in `SessionUi` and + injected into `SessionMessageListPanel`; it does not depend on the transcript, so it can be reused + standalone. Add one small method for the informational/notice case. +3. **Show immediately, no session required.** The transcript body only renders when + `model.showSession == true`. For the UNSUPPORTED case there is no session, so route a dedicated + body (a standalone `SessionOutcomeView`) as the session content the moment the workspace is + UNSUPPORTED, taking precedence over the empty/recents view. +4. **No connection banner for this case.** `resolveConnectionState()` returns `Hide` for UNSUPPORTED + (connection is healthy); retry ignores UNSUPPORTED. + +## Implementation tasks (ordered) + +### 1. Bring in the outcome UI +- Merge/rebase `origin/main` into this branch. Verify `SessionOutcomeView`, `TurnOutcome`, + `SessionState.TurnEnded`, and the `SessionMessageListPanel.outcome`/`SessionUi.outcome` wiring are + present. Keep backend UNSUPPORTED work; drop the ConnectionPanel notice work (tasks 2–3). + +### 2. Remove the ConnectionPanel notice approach +- `frontend/.../session/controller/SessionControllerEvent.kt`: delete `ConnectionChanged.ShowNotice`. +- `frontend/.../session/ui/ConnectionPanel.kt`: remove `showNotice`, the `learn` ActionLink, the + `actions` `Stack`, the `url` field, and `learnVisible()/learnText()`; restore `retry` directly at + `BorderLayout.EAST`; remove the `ShowNotice` branch in `onEvent` and the `BrowserUtil`/`Stack` imports. +- `frontend/.../session/controller/SessionController.kt`: + - `resolveConnectionState()`: change the `workspace.status == UNSUPPORTED` branch to return + `ConnectionChanged.Hide` (place before the READY/warning branches). This prevents the perpetual + "Loading…" that would otherwise occur because `workspace != READY`. + - `retryConnection()`: keep the guard that returns early (no `workspace.reload()`) when + `workspace.status == UNSUPPORTED`. + - `setConnectionTargetState()`: remove the `ShowNotice` immediate‑state branch. +- `frontend/.../messages/KiloBundle.properties`: remove `session.connection.notice.*` keys. + +### 3. Extract/extend the reusable outcome card +- `frontend/.../session/views/SessionOutcomeView.kt`: add an EDT method that reuses the existing + card rendering for an informational notice, e.g.: + `fun showNotice(title: String, description: String, tone: OutcomeTone, actions: List = emptyList())` + — sets header icon (WARNING for informational), `setHeader(title, description)`, `setContent(null)`, + `setActions(actions)`, `isVisible = true`, `refresh()`. This reuses the same visual card the + transcript uses; it is not a new surface. Confirm `DialogView` exposes `setActions`/`Action` (it + does — used by `LoginRequiredView`/`RevertBanner`). +- Do not fork the card; the same `SessionOutcomeView` class is used in the transcript and standalone. + +### 4. Route the standalone card in `SessionUi` +- `frontend/.../session/SessionUi.kt`: + - Own a standalone `SessionOutcomeView` for the workspace notice (separate instance from the + transcript's `outcome`), plus a body container following the `blankBody`/`progressBody` pattern, + e.g. `unsupportedBody` hosting the standalone view. Register it with `applyStyle`. + - `body(state)`: at the top, if `controller.model.workspace.status == KiloWorkspaceStatusDto.UNSUPPORTED`, + return `unsupportedBody` (and populate the card via `showNotice(...)` mapping the workspace `error` + reason to copy). This wins over `showSession`/empty/progress. + - React to workspace changes: in the `SessionControllerEvent.WorkspaceChanged` handler (and on + the new `ShowUnsupported` view event, task 5), re‑evaluate the body and populate the card. Keep + `prompt.setReady(controller.model.isReady())` — prompt stays disabled since `isReady()` is false + when workspace ≠ READY, so the card explains why input is unavailable. + +### 5. Controller view routing for the unsupported case +- `frontend/.../session/controller/SessionControllerEvent.kt`: add + `data class ShowUnsupported(val reason: String) : ViewChanged()` with a stable `toString()`. +- `frontend/.../session/controller/SessionController.kt`: + - Add a precedence check so that when `model.workspace.status == UNSUPPORTED` (app READY), the + controller emits `ViewChanged.ShowUnsupported(reason)` via `setControllerViewState(...)` instead + of `ShowRecents`/`ShowSession`. Gate `canUseRecents()` (and the recents refresh at ~line 992 / + `refreshRecents`) to return false when UNSUPPORTED so recents don't clobber the notice. + - Handle `ShowUnsupported` in `setControllerViewState` similarly to `ShowRecents` + (e.g. `hideAccountOverlay()` or leave account overlay untouched; do not set `model.showSession`). +- `SessionUi` handles `ViewChanged.ShowUnsupported` by clearing `empty`, populating the standalone + `SessionOutcomeView` from `reason`, and `scroll.show(unsupportedBody)`. + +### 6. Copy / content +- `frontend/.../messages/KiloBundle.properties`: add notice copy reused for all virtual‑fs reasons + (single title + description is sufficient; the recommendation is the same), e.g.: + - `session.unsupported.devcontainer.title=Kilo can't access this Dev Container project` + - `session.unsupported.devcontainer.description=This project is opened through a Dev Container or remote virtual filesystem that the Kilo runtime on your machine can't reach. Run Kilo inside the container using JetBrains Remote Development, where the IDE backend runs in the container. Local projects also work.` + - Optional: `session.unsupported.learnMore=Learn more` + - Note: no‑argument bundle values must use a single apostrophe (`can't`), not `''` (MessageFormat + only collapses `''` when args are passed). +- Keep the docs URL as one Kotlin constant (reuse the existing `DEVCONTAINER_URL`, + default `https://kilo.ai/docs/jetbrains/dev-containers`). If a "Learn more" action is included, + wire it as a `DialogView.Action` that calls `BrowserUtil.browse(url)`. +- Reason→copy mapping: map all three reasons to the same devcontainer copy for now (a `when(reason)` + helper), so wsl/invalid paths also get a clear message. Tone = `OutcomeTone.WARNING` (informational). + +### 7. Tests +- Backend (unchanged, keep passing where the env allows): `RemoteDirectoryTest` (pure, incl. forced + flag), `KiloBackendWorkspaceTest` UNSUPPORTED cases (no `/agent` fetch), `KiloWorkspaceRpcApiImplTest` + mapping. Note: the full backend suite can't run in this worktree because the fake‑CLI `connect()` + helper times out here (environmental); `RemoteDirectoryTest` runs fine and should be the primary + backend guard. +- Frontend: + - Remove the obsolete `ConnectionPanelTest` notice test and the `ConnectionDelayTest` + unsupported‑notice/retry tests. Replace with: UNSUPPORTED makes `resolveConnectionState` resolve + to `Hide` (no connection banner), and `retryConnection()` does not call `projectRpc.reload`. + - `SessionOutcomeViewTest`: add a case for the new `showNotice(...)` — asserts header icon/title/ + description render and (if included) the Learn‑more action is present. + - Controller test: workspace `UNSUPPORTED` emits `ViewChanged.ShowUnsupported(reason)` and does + **not** emit `ShowRecents`/`ShowSession`; `canUseRecents()` is false. + - SessionUi/body test (or `SessionMessageListPanel`‑style test): when workspace is UNSUPPORTED the + standalone outcome card body is shown with the devcontainer title/description. + +### 8. Changeset +- Update the existing `.changeset/jetbrains-devcontainer-notice.md` (`"@kilocode/kilo-jetbrains": patch`) + to describe the final behavior: "When a JetBrains project is opened through a Dev Container / remote + virtual filesystem Kilo can't access, show a clear in‑chat notice (with guidance to run Kilo inside + the container) instead of a generic loading/error state." + +## Out of scope +- Actually supporting Model 2 (running the CLI in‑container via Eel/IJent, path translation). This is + communication‑only. +- Reworking the transcript's existing failed/interrupted turn outcomes. + +## Risks / edge cases +- **Merge scope:** rebasing ~176 commits may conflict on `SessionController.kt`/`ConnectionPanel.kt`/ + `SessionUi.kt`. Reverting the ConnectionPanel notice (task 2) reduces overlap with main's outcome + changes; do task 2 as part of resolving conflicts. +- **State‑machine hygiene:** drive the notice from **workspace status** (view routing), not by + faking a per‑session `SessionState`, to avoid corrupting the session state machine when there is no + session. +- **Body precedence:** ensure UNSUPPORTED beats empty/recents/progress and that recents refresh does + not overwrite it (`canUseRecents()` guard). +- **App stays READY:** global providers/config still load; only the workspace is Unsupported, so the + rest of the UI (settings/providers) remains usable and no red connection error appears. +- **Manual repro:** `-Pkilo.dev.forceUnsupportedWorkspace=true` on a dev IDE run forces every + workspace into UNSUPPORTED to exercise the card without a real IJent path. + +## Validation +- From `packages/kilo-jetbrains/`: `./gradlew :backend:test --tests ai.kilocode.backend.workspace.RemoteDirectoryTest` + and the frontend tests `./gradlew :frontend:test --tests …SessionOutcomeViewTest --tests …ConnectionDelayTest` + plus the new controller/body tests. (Use module‑scoped `:backend:`/`:frontend:` task filters.) +- `./gradlew typecheck` and, where the env permits, `./gradlew test`. +- Run inspection "Plugin DevKit | Code | Frontend and Backend API Usage" (shared DTO + new frontend + view event). +- Manual: launch split mode with `-Pkilo.dev.forceUnsupportedWorkspace=true`; confirm the in‑chat + outcome card appears immediately, the prompt is disabled, and no red connection banner shows. + +## Open items +- Confirm/replace the "Learn more" docs URL (`https://kilo.ai/docs/jetbrains/dev-containers`) and + whether to include the Learn‑more action at all (recommended: include it as a `DialogView.Action`). +- Final copy review for the notice title/description. diff --git a/.kilo/plans/1787183917922-jetbrains-unsupported-workspace-banner.md b/.kilo/plans/1787183917922-jetbrains-unsupported-workspace-banner.md new file mode 100644 index 0000000000..2c908112ef --- /dev/null +++ b/.kilo/plans/1787183917922-jetbrains-unsupported-workspace-banner.md @@ -0,0 +1,149 @@ +# Show unsupported workspace via the standard session error banner + fit-to-transcript error height (JetBrains) + +## Goal + +1. When a JetBrains workspace directory is unsupported for the host-side CLI + runtime (devcontainer / WSL / invalid virtual path), surface it through the + existing in-session connection banner (`ConnectionPanel`) instead of silently + showing "Loading…" forever. It gets the **same** recovery options as other + connection errors (Try again → Retry / Restart / Reinstall Core). +2. Change the expanded error/detail area so it **fits the whole detail text**, + capped to the **available height of the session transcript area** (instead of + the current fixed 10-line cap). This becomes the default behavior for **all** + connection error/warning banners, not just unsupported. + +## Background / Current State + +- Detection already works end to end. `RemoteDirectory.detect()` returns a reason + code and `KiloBackendWorkspace` sets `KiloWorkspaceState.Unsupported(reason)`, + mapped to `KiloWorkspaceStateDto(status = UNSUPPORTED, error = reason)` + (`KiloWorkspaceRpcApiImpl.kt:507`). Reason string is in the DTO `error` field. + Reason codes: `devcontainer_virtual_filesystem`, `wsl_virtual_filesystem`, + `invalid_virtual_path`. +- The DTO reaches `SessionModel.workspace`; `syncConnectionState()` already runs + on every `WorkspaceChanged` (`SessionController.kt:982-984`). +- **Gap:** `SessionController.resolveConnectionState()` (`SessionController.kt:2353-2393`) + has no `UNSUPPORTED` branch, so it falls through to `ShowConnecting` + (line 2392) → banner reads "Loading…" indefinitely. +- Banner renderer is `ConnectionPanel` (`session/ui/ConnectionPanel.kt`), driven + by `ConnectionChanged.ShowError/ShowWarning/…`. Its "Try again" link opens a + popup with Retry / Restart / Reinstall (`recoveryGroup()`). +- Height cap today: `DETAILS_LINES = 10` (line 45). `scrollHeight()` coerces the + row count to `1..DETAILS_LINES` (line 304), `getPreferredSize()` uses it + (line 295-301), and `maxExpandedHeight()` (line 345) exposes the fixed cap. +- Overlay placement: `SessionUi.kt:442-452` anchors the banner just above the + prompt using `child.preferredSize.height` as the banner height; bottom edge is + `promptTop - gap`, and it grows upward. + +All touched files are Kilo-owned JetBrains frontend paths — no `kilocode_change` +markers required. No backend / shared DTO changes needed. + +## Design Decisions + +- **Unsupported reuses the standard `ShowError` path unchanged** — same red + banner, same "Try again" popup (Retry / Restart / Reinstall). No new event + type and no `retry` flag. (Reverses the earlier "hide retry" idea per user.) +- **Detail area fits the full text, capped to available transcript height.** + Remove the fixed 10-line cap in `ConnectionPanel` so preferred height reflects + the whole detail text; clamp the rendered banner height to the transcript space + above the prompt in the overlay layout (where pane geometry is known). The + existing internal `JBScrollPane` (`VERTICAL_SCROLLBAR_AS_NEEDED`) scrolls the + overflow. This applies to every error/warning banner uniformly. + +## Implementation Tasks + +1. **`SessionController.resolveConnectionState()`** (`SessionController.kt:2353`) + - Add a branch for `workspace.status == KiloWorkspaceStatusDto.UNSUPPORTED` + (place it next to the workspace `ERROR` branch, before the READY branches): + - summary = `KiloBundle.message("session.connection.unsupported")` + - detail = reason mapped to a localized string (helper below), falling back + to the raw `workspace.error` + - `source = "workspace"` (retry link shows by default; no flag change) + - Add a private helper (single-word name, e.g. `unsupported`) mapping the + `workspace.error` reason code to a bundle string: + - `devcontainer_virtual_filesystem` → `session.connection.unsupported.devcontainer` + - `wsl_virtual_filesystem` → `session.connection.unsupported.wsl` + - `invalid_virtual_path` → `session.connection.unsupported.invalid` + - else → `session.connection.unsupported.unknown` (or raw reason) + +2. **`KiloBundle.properties`** (`resources/messages/`, after line 17) + - Add (finalize exact copy during implementation): + - `session.connection.unsupported=Workspace not supported` + - `session.connection.unsupported.devcontainer=Dev Container virtual filesystem paths can't be reached by the host-side Kilo runtime.` + - `session.connection.unsupported.wsl=WSL virtual filesystem paths aren't supported by the host-side Kilo runtime.` + - `session.connection.unsupported.invalid=This workspace path can't be resolved on the local filesystem.` + - `session.connection.unsupported.unknown=This workspace isn't supported by the host-side Kilo runtime.` + +3. **`ConnectionPanel.kt`** (`session/ui/`) — remove the fixed cap so details fit + the whole text: + - Delete `DETAILS_LINES` (line 45) usage in `scrollHeight()` (line 303-306): + compute rows from the full logical line count (`coerceAtLeast(1)`), no upper + bound. `getPreferredSize()` (line 295-301) then reports the full detail + height when expanded. + - Remove `maxExpandedHeight()` (line 345) or repurpose it; it encodes the + 10-line cap and is only used by the outgoing test. + - Leave the `JBScrollPane` policies as-is so overflow scrolls when the overlay + clamps the banner shorter than preferred. + - Note (known limitation, keep behavior parity): row count uses logical lines, + not wrapped visual lines, so a wrapped long line may under-estimate height; + the scrollbar still covers overflow. Optional follow-up only. + +4. **`SessionUi.kt`** overlay layout for `connection` (lines 442-452) — clamp the + banner height to the transcript area above the prompt: + - `full = child.preferredSize.height` + - `avail = (point.y - gap).coerceAtLeast(0)` (space from pane top to just above + the prompt) + - `h = full.coerceAtMost(avail)` + - Rectangle: `x = point.x + gap`, `y = point.y - h - gap`, + `width = (prompt.width - gap*2).coerceAtLeast(0)`, `height = h` + - This keeps the bottom anchored at `promptTop - gap` (unchanged) while + preventing the top from overflowing above the transcript region; the panel's + internal scroll pane handles the remainder. Applies to every banner state. + +## Tests + +- **`ConnectionDelayTest.kt`** (`session/controller/`): add a test mirroring + `test persistent workspace error is delayed` — set + `projectRpc.state.value = KiloWorkspaceStateDto(status = UNSUPPORTED, error = "wsl_virtual_filesystem")`, + assert a `ShowError` with summary "Workspace not supported", the mapped WSL + detail, and `source == "workspace"`; assert it no longer resolves to + `ShowConnecting`. +- **`ConnectionPanelTest.kt`** (`session/ui/`): + - Replace `test expanded details height is capped at ten lines` (lines 140-150) + with a test asserting the expanded preferred height grows with the full text + (e.g. 30 lines yields a preferred height clearly larger than the old + 10-line height / a computed full-text height), i.e. no fixed cap. + - Optionally add a small test that an unsupported-style `ShowError` still shows + the retry link and uses the Core recovery group (parity with existing + `test retry popup group uses core recovery actions`). +- **`SessionUiLayoutTest.kt`**: add a test that with a large detail body and a + constrained root/pane height, the expanded banner is clamped to the transcript + area — `connection.y >= 0` (does not overflow above the transcript), bottom + still anchored at `promptTop - gap`, `detailsVisible()` true, and the internal + `JBScrollPane` shows/needs its vertical scrollbar. Reuse the anchoring + assertions from `test expanded connection panel remains anchored above prompt` + (lines 261-276). + +## Validation + +From `packages/kilo-jetbrains/`: +- `./gradlew typecheck` +- `./gradlew test` (or targeted: `ConnectionPanelTest`, `ConnectionDelayTest`, + `SessionUiLayoutTest`) + +Requires Java 21; only check Java if Gradle fails with a Java-version error. + +## Risks / Notes + +- Removing the fixed cap changes sizing for **all** connection banners; the + overlay clamp is what bounds it, so verify very long errors scroll rather than + push the banner off the top of the transcript. +- No backend / shared DTO changes; `UNSUPPORTED` + `error=reason` already reach + the frontend. +- Reason codes live only in `RemoteDirectory.kt` today; the new bundle keys are + the first human-readable mapping. New reason codes fall back to `unknown`. + +## Open Questions + +- Exact user-facing wording for the five `session.connection.unsupported*` + strings (placeholder copy above). diff --git a/.kilo/plans/agent-manager-multi-project-implementation-handoff.md b/.kilo/plans/agent-manager-multi-project-implementation-handoff.md deleted file mode 100644 index 510a5e1c38..0000000000 --- a/.kilo/plans/agent-manager-multi-project-implementation-handoff.md +++ /dev/null @@ -1,459 +0,0 @@ -# Agent Manager multi-project implementation handoff - -**Status:** Implementation-ready handoff from the current branch - -**Date:** 2026-07-22 - -This document tells the next implementation model exactly how to continue from branch `abalone-bactrosaurus` at commit `8f48da2278` plus the uncommitted plan files. It is the execution checklist. Detailed decisions live in: - -- [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md) for configuration ownership, bindings, revisions, indexing consent, and settings tests; -- [`agent-manager-multi-project-uniform-ui.md`](./agent-manager-multi-project-uniform-ui.md) for runtime, routing, lifecycle, uniform sidebar UI, and acceptance criteria. - -If this handoff conflicts with either architecture document, stop and resolve the contradiction before coding. - -## Goal - -Finish the current prototype into a safe, complete multi-project Agent Manager behind a default-off VS Code experimental flag. - -The feature is done only when: - -- multiple expanded projects show the same full Agent Manager sidebar behavior; -- every action uses explicit project/worktree/session ownership; -- Settings cannot write the wrong project's config; -- indexing requires machine-local consent per canonical project; -- project identity is canonical, Git-root-aware, common-dir-safe, and remote-authority-aware; -- single-project behavior remains unchanged when the flag is off. - -## Current branch baseline - -### Implemented and worth keeping - -- persistent additional-project registry with serialized/re-read mutations; -- separate `ProjectContext` objects and per-project Agent Manager state files; -- project-stamped state, stats, PR, and session payloads; -- canonical worktree presence comparison for symlink/`/tmp` aliases; -- per-project session listing from project root and worktree directories; -- partial production `ProjectRouteService` integration and raw-session ambiguity rejection; -- project-qualified sidebar DOM IDs and cross-project previous/next navigation; -- atomic selection validation and same-project activation fast path; -- flag-off return to pinned project; -- Experimental Settings toggle and translations; -- focused unit tests for registry, contexts, paths, pollers, routing, sessions, selection, and navigation. - -### Not implemented or incomplete - -- immutable/revisioned Settings bindings; -- machine-local indexing consent; -- canonical pinned Git toplevel, `commonGitDir`, URI scheme/authority identity; -- strict project envelopes for all Agent Manager operations; -- complete route-service use for every session/terminal/diff/share/permission operation; -- one full sidebar body shared by single- and multi-project modes; -- uniform per-context poller and stale-state ownership; -- all repository mutations through `ProjectContext.run()`; -- complete real two-project E2E verification. - -### Known diff cleanup - -- remove unrelated `packages/opencode/package.json` ordering/dependency drift; -- restore unrelated `bun.lock` ordering/version drift unless required by a retained change; -- remove `experimental.multi_project` from the CLI config schema and generated SDK once the VS Code flag is the sole source of truth; -- rewrite the changeset after final behavior is complete. - -Do not reset or discard unrelated user changes. Inspect every cleanup diff before applying it. - -## Non-negotiable invariants - -1. Agent Manager activation is not a Settings write target. -2. No identified operation falls back to the active project or workspace root in multi-project mode. -3. A project config write targets the explicitly selected registered project root, never an active worktree. -4. Runtime config remains directory-correct: project Local uses root, worktree sessions use exact worktree directory. -5. Repository config cannot grant indexing consent. -6. New projects default to indexing disabled on this machine. -7. Provider/API credentials and indexing infrastructure never enter project config through Settings. -8. Raw session/worktree IDs are never sufficient UI/runtime identity when multiple projects are exposed. -9. The full existing sidebar behavior is reused; do not maintain a simplified multi-project copy. -10. Do not raise `AgentManagerProvider.ts` or `AgentManagerApp.tsx` line caps. - -## Work order - -Complete slices in order. Do not start the next slice while the current slice's tests or checks fail. - -## Slice 0A: consolidate the feature flag - -### Required result - -Use one source of truth: VS Code application setting `kilo-code.new.experimental.multiProject`. - -### Required changes - -1. Remove `experimental.multi_project` from: - - `packages/core/src/v1/config/config.ts`; - - `packages/sdk/js/src/v2/gen/types.gen.ts` by regenerating the SDK after schema removal; - - `packages/kilo-vscode/webview-ui/src/types/messages/config.ts`. -2. Do not hand-edit generated SDK files. Run `./script/generate.ts` from repository root after endpoint/schema changes. -3. Change `ExperimentalTab.tsx` so the toggle reads the VS Code setting delivered in `configLoaded.settings`, not `config().experimental`. -4. Add `multiProject` to the extension settings payload sent by `KiloProvider.fetchAndSendConfig()`. -5. Toggling it sends only `updateSetting("experimental.multiProject", checked)`. -6. Keep `VscodeHost.multiProject()` and its change listener reading the same VS Code setting. - -### Tests - -- toggle initial state reflects VS Code setting when CLI config disagrees; -- toggling updates the VS Code setting and Agent Manager reacts; -- no CLI/project config file gains `experimental.multi_project`. - -### Stop gate - -Run from `packages/kilo-vscode/`: - -```sh -bun run format -bun run format:check -bun run typecheck -bun run lint -bun run test:unit -``` - -Do not continue if any command fails. - -## Slice 0B: machine-local indexing consent - -### Required result - -Indexing enablement is explicit machine-local consent keyed by canonical `ProjectId`, default false. Project config controls indexing rules but cannot enable indexing. - -### Required changes - -1. Add a versioned machine-local consent store in the extension, not repository config and not synced across machines. -2. Use canonical project identity as the key. The pinned project and registered projects use the same identity resolver. -3. Remove hidden routing of `indexing.enabled` to project config from `webview-ui/src/utils/config-scope.ts`. -4. Remove project/global inheritance logic for `indexing.enabled` from `indexing-tab-state.ts`. -5. The Indexing UI requires an explicit project selector for enablement and reads/writes consent through dedicated messages. -6. Keep these in User config only: - - provider; - - model and dimension; - - credentials/API keys/base URLs; - - vector-store type and connection/storage settings; - - machine/infrastructure tuning defaults. -7. Keep these available in explicit Project scope: - - file extensions; - - repository include/ignore rules; - - deliberate repository chunking/tuning overrides. -8. Indexing startup/status must require both valid User indexing configuration and consent for the routed project. -9. A repository containing `indexing.enabled: true` must not enable indexing. - -### Suggested files - -- new VS Code-free consent store under `src/indexing/` or `src/agent-manager/`; -- `src/KiloProvider.ts` only as a thin protocol adapter; -- `webview-ui/src/components/settings/IndexingTab.tsx`; -- `webview-ui/src/components/settings/indexing-tab-state.ts`; -- `webview-ui/src/context/config.tsx` only if necessary; -- focused tests beside existing indexing tests. - -### Tests - -- unknown project defaults off; -- A enabled does not enable B; -- symlink/path alias resolves to the same consent; -- repository config cannot grant consent; -- removing/re-adding behavior follows the documented store policy; -- no credentials/storage settings are emitted in a Project-scope patch. - -## Slice 0C: revisioned config overlay backend - -### Required result - -Config reads return authoritative target descriptors and revisions. Writes use compare-and-swap and cannot overwrite external changes or a changed target. - -### Required changes - -1. Extend Kilo-owned config overlay code: - - `packages/opencode/src/kilocode/config/overlay.ts`; - - a new Kilo-owned writer under `packages/opencode/src/kilocode/config/`; - - `packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts`; - - `packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts`. -2. Read response includes for global and project targets: - - canonical path; - - scope; - - exists/writable; - - SHA-256 revision of canonical path, existence marker, and exact bytes; - - raw parsed target layer; - - effective config and source metadata. -3. Write request accepts exactly one scope, `set`, `unset`, and expected path/revision. -4. Server re-resolves the authoritative target. Never trust client path as a destination. -5. Lock by canonical target path, re-read inside lock, compare revision, patch raw JSONC, validate, and atomically replace. -6. Return a fresh authoritative snapshot. -7. Return typed 409 conflicts for target/revision changes; preserve drafts client-side. -8. Keep Kilo logic in `packages/opencode/src/kilocode/`. Shared upstream files get minimal marked delegation only when unavoidable. -9. Regenerate the SDK after endpoint changes. - -### Tests - -Add/extend: - -- `packages/opencode/test/kilocode/server/config-overlay.test.ts`; -- `packages/opencode/test/kilocode/project-config-update.test.ts`. - -Cover: - -- exact raw target layer; -- comment-only external edit conflict; -- missing-file target revision; -- newly created higher-priority target conflict; -- concurrent writers: one success, one 409; -- symlink escape rejection; -- global and project writes remain separate; -- atomic write failure never exposes partial content. - -### Stop gate - -From `packages/opencode/`: - -```sh -bun run typecheck -bun test test/kilocode/server/config-overlay.test.ts -bun test test/kilocode/project-config-update.test.ts -``` - -From repository root: - -```sh -./script/generate.ts -bun run script/check-opencode-annotations.ts -bun run script/check-opencode-promise-facades.ts -``` - -## Slice 0D: immutable Settings bindings - -### Required result - -Settings has explicit `User | Project` scope. Project scope requires an explicit trusted project selector. Drafts and saves never follow Agent Manager activation. - -### Required changes - -1. Split runtime-effective config from Settings editor config. -2. Add a binding controller under `packages/kilo-vscode/src/kilo-provider/`; keep `KiloProvider.ts` as a thin adapter. -3. Replace unqualified config protocol with: - - read request carrying request ID, scope, and optional project ID; - - snapshot carrying opaque binding ID and source/target metadata; - - write request carrying request ID, binding ID, set, and unset. -4. The extension stores authoritative bindings. The webview never supplies a writable path. -5. Project binding resolution: - - resolve registered `ProjectContext.root`; - - require project existence, matching generation, and trust; - - never use active worktree/session or `getWorkspaceDirectory()` after binding creation. -6. Bindings expire on successful save, reconnect, project removal, generation change, or trust revocation. -7. Separate drafts by scope/project binding. -8. Dirty selector changes prompt Save, Discard, or Stay. -9. Out-of-order reads/writes update only matching request/binding. -10. External config events refresh clean drafts and mark dirty drafts stale without overwriting them. -11. Remove `splitConfigByScope()` after all controls declare scope explicitly. -12. Audit direct config mutators outside the save bar, including provider disconnect, imports/resets, custom providers, work styles, Permission Dock, MCP actions, and indexing. -13. Open Project Config takes explicit `ProjectRef`, checks trust, and resolves the registered root. - -### UI scope policy - -Use the table in `agent-manager-multi-project-configuration.md`. Do not invent another policy. - -### Blocking tests - -- load A, activate B, save A: only A target changes; -- same while selecting worktrees/sessions; -- User save changes only user config; -- Project save changes only selected trusted project's root config; -- dirty draft cannot migrate to another project; -- project removal/trust revocation expires binding; -- external edit returns conflict and preserves draft; -- no save-time active-directory lookup occurs. - -## Slice 1: canonical project identity - -### Required result - -Every project is a canonical Git repository root with URI authority and common Git directory identity. - -### Required changes - -1. Replace path-only project descriptors with: - -```ts -{ - id, - uri, - scheme, - authority, - root, - commonGitDir, - label, - order, - trusted, - addedAt -} -``` - -2. Pinned and added projects use the same resolver: - - validate URI host scope; - - `git rev-parse --show-toplevel`; - - `git rev-parse --path-format=absolute --git-common-dir`; - - realpath/normalize both; - - derive ID from scheme, authority, and canonical root. -3. Opening VS Code in a repository subdirectory still uses the Git toplevel and root state file. -4. Reject another exposed project sharing canonical `commonGitDir`. -5. Key Git mutation locks by common Git directory. -6. Preserve remote URI scheme/authority in picker, storage, and open-folder operations. -7. Migrate or safely read the current registry version without dropping valid entries. - -### Tests - -- workspace subdirectory resolves to repository root; -- symlink aliases dedupe; -- linked worktree/common-dir duplicate rejected; -- other remote authority hidden but preserved; -- missing/non-Git entry remains removable and never falls back to pinned root. - -## Slice 2: strict routing and lifecycle - -### Required result - -Every repository-bound operation is explicitly project-qualified once multiple projects are exposed. - -### Required changes - -1. Introduce one project envelope at the Agent Manager boundary with project ID and generation. -2. Compatibility adapter injects pinned identity only in single-project mode. -3. Multi-project mode rejects missing/mismatched identity with typed errors. -4. Wire `ProjectRouteService` into every existing-session operation: - - transcript/messages; - - prompt/abort; - - share/unshare; - - fork/continuation; - - permission/question responses; - - terminal; - - file/context operations; - - diff/apply/revert. -5. Route global SSE events once by exact directory/session ownership. -6. Preserve non-Agent-Manager KiloProvider behavior. -7. Execute repository mutations through `ctx.run()` and generation-check commits. -8. Removal waits for mutation queue, stops pollers/watchers, flushes state, detaches routes, then removes registry entry. -9. Flag-off activates pinned Local and suspends secondaries without aborting sessions. - -### Tests - -- same raw session ID across projects is ambiguous without qualifier; -- B Local/share/unshare/permission never routes through A; -- unknown IDs produce no terminal/file/diff operation; -- project switch during mutation cannot change operation ownership; -- late completion after removal cannot mutate replacement context. - -## Slice 3: uniform context-owned state and polling - -### Required result - -Every initialized expanded project owns the same live-state services. Active selection changes cadence/detail only, not ownership. - -### Required changes - -1. Move stats and PR pollers into `ProjectContext`; remove active singleton/background split. -2. Each context owns stale/presence state, cached stats, local stats, PR state, run/setup state, and generation. -3. Background presence updates project stale state rather than emitting empty arrays. -4. All project outputs include project ID and generation; webview discards stale generations. -5. Session-created/deleted/updated events refresh the owning project store without active-project filtering. -6. Panel visibility and expansion uniformly control polling. - -### Tests - -- two expanded projects both receive stats/presence/PR updates; -- collapse stops only that project's pollers; -- stale late poll result is dropped; -- background session deletion updates only its project. - -## Slice 4: one full sidebar body - -### Required result - -Single- and multi-project modes render the same full `ProjectSidebarBody` implementation. - -### Required changes - -1. Extract the current full `renderBody()` from `AgentManagerApp.tsx` intact. -2. Parameterize it with one project store and project-qualified actions. -3. Preserve: - - busy state; - - drag/drop and grouping; - - section actions; - - search and keyboard hints; - - run/setup state; - - New Worktree dialog; - - worktree menus and PR actions; - - complete managed/unassigned session behavior. -4. Delete the simplified duplicate body path. -5. Render project rows keyed by stable project ID and keep bodies mounted across active changes. -6. New Worktree receives explicit target project, defaulting to Settings/detail selection policy as specified. - -### Tests - -- single-project behavior unchanged with flag off; -- two expanded projects render two full bodies; -- active selection changes no body mount identity; -- drag/drop, sections, worktree actions, sessions, and keyboard navigation work across projects. - -## Slice 5: cleanup and release validation - -1. Remove unrelated manifest/lockfile drift. -2. Update changeset to final user-visible behavior. -3. Run all extension and affected CLI checks. -4. Build/package the extension. -5. Use the VS Code self-test fixture with two repositories and multiple worktrees/sessions. -6. Keep feature default off until every blocking test passes. - -### Required extension checks - -From `packages/kilo-vscode/`: - -```sh -bun run format -bun run format:check -bun run typecheck -bun run lint -bun run test:unit -bun run knip -bun run check-kilocode-change -bun run compile -``` - -From repository root: - -```sh -bun run script/check-opencode-annotations.ts -bun run script/check-opencode-promise-facades.ts -bun run script/check-md-table-padding.ts -``` - -### Manual scenarios - -1. Flag off: current single-project Agent Manager is unchanged. -2. Enable flag in Experimental Settings; restart; value persists without CLI/project config changes. -3. Add/trust/expand two repositories; restart; pinned remains first. -4. Both projects show Local, worktrees, sessions, stats, PR state, and full actions. -5. Navigate previous/next across projects. -6. Create/delete/rename worktrees in both projects. -7. Send prompts and answer permission/question requests in both projects. -8. Share/unshare B while A is active; exact B directory is used. -9. Load Project Settings A, activate B, save A; B is unchanged. -10. Modify A config externally while draft is dirty; conflict preserves draft. -11. Indexing defaults off for a new project; enabling A does not enable B; repo config cannot enable either. -12. Disable flag while B is active; pinned Local becomes active and B remains registered/suspended. -13. Repeat key routing and identity checks in a remote VS Code window. - -## Rules for the implementation model - -- Work on one slice at a time. -- Read the referenced architecture section before editing. -- Add focused tests before or with behavior changes. -- Use `apply_patch` for manual edits. -- Do not hand-edit generated SDK files. -- Do not raise file-size caps. -- Do not add fallback compatibility code unless the plan explicitly requires the single-project adapter. -- Do not claim a slice complete until its stop gate passes. -- If a required API or ownership decision is missing, stop and update this plan instead of guessing. diff --git a/.kilo/plans/agent-manager-multi-project-shipping-gaps.md b/.kilo/plans/agent-manager-multi-project-shipping-gaps.md deleted file mode 100644 index 8301929bbd..0000000000 --- a/.kilo/plans/agent-manager-multi-project-shipping-gaps.md +++ /dev/null @@ -1,210 +0,0 @@ -# Agent Manager Multi-Project — Shipping Gaps - -Status as of 2026-07-27. The multi-project sidebar renders and works end to end in the -self-test harness (both projects list worktrees/sessions/sections, section creation -persists, worktree delete works, project switching restores selection, live session -upsert verified). Full unit suite green (3444 pass / 0 fail), typecheck, lint, knip, -arch caps all pass. Everything is uncommitted in the worktree. - -This document lists what is still missing, ranked by whether it should block shipping. - -Two audiences matter for the blocking decision: - -- **All users**: the branch changes shared surfaces (legacy sidebar refactor, config - write path, indexing consent, pollers). Regressions here ship to everyone, flag off - or not. -- **Experimental users**: multi-project mode is gated behind - `kilo-code.new.experimental.multiProject`, default off. Rough edges here are - acceptable if they cannot corrupt data. - ---- - -## 1. Blocks shipping (all-user surfaces) - -### 1.0 Empty-state skeleton regression (fixed, must stay fixed) - -`initializeState` and `onRequestState` only called `refreshSessions()` when the -managed state contained at least one session. With zero managed sessions the -backend listing never ran, `sessionsLoaded` never reached the webview, and both -the WORKTREES and SESSIONS sections stayed on skeletons forever (the worktree -gate requires `worktreesLoaded() && sessionsLoaded()`). Any user whose state file -lost its sessions — exactly what the earlier session-persist bugs caused — hit a -permanently empty-looking sidebar. Reproduced in the harness on a worktree-root -workspace and fixed by making both refreshes unconditional. Root-caused via -stage-by-stage init logging on 2026-07-27; do not reintroduce a content-based -guard here. - -### 1.1 Config write path can fail or wipe drafts for existing callers — P0-6 / P1-7 - -The config binding rework requires every `updateConfig` sender to carry a binding id, -and `configUpdateFailed` currently wipes the draft for the failed scope. Any existing -sender without a binding (permission dock, model picker, auto-approve, onboarding) -now fails or loses the user's unsaved edits. - -- Backend half is done: `expected` is optional in the overlay schema, writer, and - handler, so a client without a binding writes unconditionally again instead of - getting a 400. The webview half (draft retention on `configUpdateFailed`, and the - audit of every `updateConfig` sender) is still open. -- Work: the code change is small; the real work is auditing every webview - `updateConfig` sender and classifying it. -- Also: this change is orthogonal to multi-project. Split it into its own commit - (`feat(vscode): config write revision bindings`) so it can be reverted alone. - -### 1.2 Indexing status read silently revokes consent — P0-3 / P0-4 - -Partially addressed: untrusted projects are now filtered out of the consent list -(P0-4), and config scope switching plus project-scoped `indexing.enabled` writes -were restored after the rework had hardcoded the tab to global scope (the earlier -P1-8 gap). The remaining blocker is the read path below. - - -`fetchAndSendIndexingStatus` issues `PUT /indexing/consent` (a write) on a plain -status refresh, defaulting to `enabled: false` for any project not in local -`globalState`. On a fresh machine/profile, the first status read turns indexing off -for users who had it on. A refresh can also target the wrong project -(`requestIndexingStatus` resolves from the current session's directory). Separately, -the Indexing tab lists untrusted projects and would let a user enable indexing for a -repo the trust system has not approved. - -- Fix: read status with a GET; PUT only from the explicit setter; seed consent from - the effective config on first read instead of defaulting false; require an explicit - project on refresh; filter the project list to trusted projects. -- Dependency: needs a read-only status endpoint. If none exists, this escapes into - `packages/opencode` (shared upstream code, needs `kilocode_change` markers) or the - cloud repo. -- Also orthogonal to multi-project; split into its own commit - (`feat(vscode): per-project indexing consent`). - -### 1.3 Land the current work as separate, revertable commits - -Everything currently sits uncommitted in one worktree. The review's recommendation -stands: `git reset --soft origin/main` and stage by path into three commits — -multi-project, config bindings, indexing consent. The multi-project commit message -must not promise per-project config/indexing behavior that lands in the other two. - ---- - -## 2. Should fix soon after (experimental surface, data-integrity relevant) - -### 2.1 Same repo can register twice — P0-5 - -`projectIdFor` hashes the canonical path verbatim while `samePath` folds case, so two -casings of one repo produce two project ids, two contexts, and two state managers -racing to write the same `.kilo/agent-manager.json` (last write wins, worktrees and -sessions vanish). - -- Fix: fold case inside `projectIdFor` on darwin/win32; reject `addProject` when - `samePath` matches any registered root. -- Migration: existing registry keys were built from the old hash, so re-key them on - load or dual-lookup on read. Note `canonicalizePath` already realpaths existing - paths; the fallback branch must fold case too. -- Why not blocking: requires an unusual casing mismatch at registration time, and the - feature is flag-gated. - -### 2.2 Route service is shared across panels but versioned per context — P0-7 - -Two VS Code windows each have their own `ProjectContexts` with independent generation -counters feeding one shared `ProjectRouteService`. Panel B registering a project at -generation 0 while panel A is at 2 unregisters panel A's routes; closing one panel -drops routes the other still needs. - -- Fix: panel-qualified keys (`panelId + projectId + sessionId`), generations issued - by the service, ambiguity computed across all panels. -- Why not blocking: needs two windows running Agent Manager against the same repos. - The fallback already refuses ambiguous raw ids instead of resolving them wrong. -- Note: no two-panel test harness exists today; this fix should create one. - -### 2.3 `gh pr` noise in repos without remotes — Bug 5 - -`gh pr view` fails every 15s per worktree forever, logs before the dedupe, and -`pollOnce` rejections are unhandled. - -- Fix: log after the `lastHash` dedupe; `void this.pollOnce().catch(log)` in - `schedule`; per-root remote probe with a single error emission; skip PR pollers for - remote-less projects in `ProjectPollers.sync`. - ---- - -## 3. UX papercuts in experimental mode (fix opportunistically) - -- **Legacy tabs orphaned on upgrade** (P1-1): `createLocalTabs` migrates persisted - `localSessionIDs` into a `single` bucket that `tabKey()` never reads once the - catalog arrives. Migrate `single` to the pinned project id on first state apply. -- **`restoreProjectTarget` skips tab bookkeeping** (P1-3): a restored session has no - tab. Call `selectLocal()` first, add the tab, then select. -- **`ensurePendingTab` runs before restore** (P1-4): switch adds a "New Session" - draft that restore may contradict. Move it after restore. -- **Per-keystroke state saves**: `setActiveTarget` writes - `.kilo/agent-manager.json` on every selection change. Debounce or persist on - deactivation/dispose only. -- **Untranslated Indexing tab strings** (P1-9), **unused-ish composite id schemes** - (P1-12), **stats messages tagged at emit time** (P1-14), **no presence sync for - background projects** (P1-15), **registry read cache** (P1-16/17), **realpath - syscall churn** (P1-18), **`resolveProjectRoot` process spawning** (P1-19). - ---- - -## 4. Structural debt (schedule, don't block) - -### 4.1 Two sidebar implementations — Arch 1.1 - -Tracked as #12685 (section parity) and #12686 (sidebar drag-and-drop). - -`AgentManagerApp` keeps the legacy `renderBody` (now `SidebarBody.tsx`) and -`ProjectSidebarBody.tsx` as a reduced reimplementation. The reimplementation already -caused one full outage of multi-project mode (missing `DragDropProvider` crashed the -webview render). Missing versus legacy: worktree ordering, drag-and-drop reorder and -move-to-section, grouping, busy/navHint/shortcut badges, section auto-rename, stats -skeletons. - -- Direction: single-project becomes the degenerate case of multi-project (one - implicit pinned project, header row hidden), `SidebarBody.tsx` is deleted. -- Do this last: it churns the same message-stamping code as 2.2, needs per-project - DnD state, and must keep legacy pixel-identical for the default-off population. - Use the visual-regression skill to cover both modes before merging it. - -### 4.2 Ambient project scope via AsyncLocalStorage — Arch 1.3 - -`ProjectScope` plus the `this.state`/`this.context` getters make the target project -invisible at call sites; any continuation escaping ALS silently falls back to the -active project. `provider-lifecycle.ts` shows the intended end state (explicit deps). -Keep threading `ctx` explicitly into the remaining handler groups; add a dev-mode log -in the `context` getter when a project-stamped message resolves without a scope. - -### 4.3 Per-project webview store — Arch 1.2 - -`worktrees()`, `managedSessions()`, `selection()` etc. are single-valued with -`memKey()`/`tabKey()` and two "current project" accessors that disagree during the -switch window. Long-term: one `createProjectStore(projectId)` per project. For now -the gating added to the remember effect contains the known race. - ---- - -## 5. Verification gaps to close before the PR - -- **SSE session upsert** is unit-tested (`upsertSession`, `byDirectory`, fresh-skip - re-post) but not E2E-verified: the harness backend's basic-auth credentials could - not be re-extracted after a window reload, so external session creation was not - exercised live. Verify manually: `kilo` a new session in a registered repo from a - terminal and watch it appear in that project's sidebar. -- **Tab isolation across projects** (per-project buckets) is unit-tested but not - E2E-verified with real sessions open in two projects. -- **Legacy mode parity**: legacy sidebar and tab bar were smoke-tested (render, - select, section create + inline rename), but not the full matrix (delete, DnD - reorder, move-to-section, review tab, terminals). The extraction moved ~700 lines - of JSX; a visual-regression pass over `SidebarBody`/`TabBar` stories is the - cheapest safety net. -- **Harness instability**: the isolated VS Code window crashed repeatedly during this - work. If it keeps failing on the next pass, say so in the PR rather than claiming - coverage that does not exist. - ---- - -## Proposed landing sequence - -1. Quick independent fixes: 2.3 (gh noise), P0-4 (trust filter), 1.1's audit + 1.2's - fallback (config), 1.2's indexing read/write split if the GET endpoint exists. -2. 2.1 (case-fold + registry migration). -3. 2.2 (route service, with a two-panel test harness). -4. 1.1's config/indexing commits split out and merged separately. -5. Arch 1.1 (sidebar collapse) with visual-regression coverage; then the P1 batch. diff --git a/.kilo/plans/agent-manager-multi-project-sidebar-density.md b/.kilo/plans/agent-manager-multi-project-sidebar-density.md new file mode 100644 index 0000000000..28790c6a3b --- /dev/null +++ b/.kilo/plans/agent-manager-multi-project-sidebar-density.md @@ -0,0 +1,437 @@ +# Plan: Agent Manager sidebar — shortcut badge fix + reclaim title width + +Worktree: `/Users/marius/Documents/git/kilocode/.kilo/worktrees/mewing-profit` +All paths below are relative to `packages/kilo-vscode/`. + +Rules: + +- Do exactly these edits. Do not refactor anything else. +- Solid.js, not React: `class=`, not `className=`. +- Do **not** add `kilocode_change` markers. This package is Kilo-owned and CI fails if you do. +- Do **not** add new i18n strings. None are needed. +- Out of scope: the per-project `SESSIONS` list in the tree (tracked in + https://github.com/Kilo-Org/kilocode/issues/12928). Do not touch `UnassignedSessionsSection.tsx`. + +Do task A, verify, then task B, verify, then task C. + +## Task A — Shortcut badge on the right, hidden until hover or ⌘ held + +The `⌘1` badge on "local" rows is always visible, and in multi-project mode it renders on the +*left*, between the icon and the label. Worktree rows already behave correctly. Cause: +`.am-shortcut-badge` has no default `opacity: 0` — worktree badges are only hidden because their +container `.am-wt-hover-actions` is hidden, and local rows never got that container. + +### A1 — `webview-ui/agent-manager/ProjectSidebarBody.tsx` + +Replace the whole ` +``` + +The `−` in `am-stat-deletions` is U+2212 MINUS SIGN, not a hyphen. Copy it verbatim. + +### A2 — `webview-ui/agent-manager/SidebarBody.tsx` + +Same bug in legacy single-project mode, but the badge is already last, so only the wrapper is +missing. + +Insert **before** line **127** (``): + +```tsx +
+``` + +Replace line **182**: + +```tsx + {isMac ? "⌘" : "Ctrl+"}1 +``` + +with: + +```tsx +
+ {isMac ? "⌘" : "Ctrl+"}1 +
+
+``` + +Result: one `am-wt-actions-cell` div containing the skeleton ``, the stats ``, and the +hover-actions div, closing before ``. Prettier fixes indentation in task D. + +### A3 — `webview-ui/agent-manager/agent-manager.css` + +Delete lines **569-575** and replace with the rule that actually works: + +```css +.am-local-item .am-shortcut-badge { + right: 8px; +} + +.am-local-item:hover .am-shortcut-badge { + opacity: 1; +} +``` + +becomes: + +```css +.am-local-item:hover .am-wt-hover-actions { + opacity: 1; + visibility: visible; +} +``` + +(`right: 8px` never applied — the badge is `position: static`. The `opacity: 1` never did anything +because nothing set `opacity: 0` first.) + +### A4 — same file, delete lines **609-611** entirely, add nothing: + +```css +.am-show-shortcuts .am-local-item .am-shortcut-badge { + opacity: 1; +} +``` + +The rule at lines 597-600 is not scoped to worktree items, so after A1/A2 it already covers local +rows. + +### A5 — same file, lines **986-989**, add `visibility: hidden` to match worktree behaviour: + +```css +.am-local-item:hover .am-worktree-stats, +.am-local-item:hover .am-worktree-stats-skeleton { + opacity: 0; + visibility: hidden; +} +``` + +### Verify A + +```bash +cd packages/kilo-vscode && bun run typecheck && bun run lint +``` + +## Task B — Stop reserving width for invisible content + +`.am-wt-actions-cell` is a grid whose children all stack in cell 1/1, so the cell is permanently as +wide as its widest child. `visibility: hidden` does not remove layout, so the invisible hover +actions (~42px) and the loading skeleton (~48px) reserve width on every row forever. That is why +titles truncate ~40px before the right edge. + +All edits in `webview-ui/agent-manager/agent-manager.css`. + +### B1 — Take hover actions out of grid flow + +Lines **475-482**. Add the four `position` lines at the top, keep everything else: + +```css +.am-wt-hover-actions { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + opacity: 0; + visibility: hidden; +} +``` + +Absolutely positioned children never size grid tracks. `.am-wt-actions-cell` is already +`position: relative` (line 464). Do not edit the `.am-wt-actions-cell > *` rule at lines 469-472. + +### B2 — Same for the loading skeleton + +Add immediately after the rule from B1: + +```css +/* Skeleton is a placeholder, not real content — it must not reserve title width. */ +.am-wt-actions-cell > .am-worktree-stats-skeleton { + position: absolute; + top: 0; + right: 0; + bottom: 0; +} +``` + +Do not edit the base `.am-worktree-stats-skeleton` rule (lines 744-748) or `.am-pr-badge-skeleton`. + +After B1+B2 the cell is sized only by `.am-worktree-stats` and `.am-worktree-delete-hint`, which +are the only things that should reserve space. + +### B3 — Fade the title under the overlaying actions + +The actions now overlay the row instead of sitting in reserved space, so a long title would render +behind them. Same fix the codebase already uses for the local branch at lines 577-580. Add after +B2: + +```css +.am-worktree-item:hover .am-worktree-branch { + mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); +} +``` + +### B4 — Remove one layer of nested padding + +Lines **365-370**, change `padding: 0 6px;` to `padding: 0;`: + +```css +.am-project-body { + display: flex; + flex-direction: column; + min-height: 0; + padding: 0; +} +``` + +Gains 6px per side on every row in a project. Rows keep their own 10px inset +(`.am-local-item` line 106, `.am-worktree-item` line 425) so they stay indented under the project +header. Do not change those, and leave `.am-project-body .am-section-header` (lines 273-275) alone. + +### B5 — Remove the remaining outer list gutters in multi-project mode + +The sidebar keeps 8px horizontal padding for the header controls, but the project list should use +the full width up to its scrollbar. In the same CSS file, extend `.am-projects-list` with: + +```css +.am-projects-list { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + margin-inline: -8px; +} +``` + +Do not remove padding from `.am-sidebar` itself. This makes project cards and rows reach the +scrollbar without moving the `PROJECTS` header and its controls to the edge. + +### Verify B + +```bash +cd packages/kilo-vscode && bun run typecheck && bun run lint +``` + +## Task C — Show where a palette session lives + +In the ⌘F search palette, multi-project session results show only the project name, so you cannot +tell whether a session is in a worktree or at the project root. + +File: `webview-ui/agent-manager/ProjectList.tsx`. In the session loop (lines **90-106**), add the +two `const` lines and change `meta` and `search`: + +```tsx + for (const session of props.sessions[project.id] ?? []) { + const wt = session.worktreeId ? state.worktrees.find((item) => item.id === session.worktreeId) : undefined + const where = wt ? wt.label || wt.branch : props.t("agentManager.local") + items.push({ + key: `${project.id}:session:${session.id}`, + projectId: project.id, + kind: "session", + group: "sessions", + title: session.title || props.t("agentManager.session.untitled"), + meta: [project.label, where], + search: [project.label, where, wt?.branch, session.title, session.id].filter(Boolean).join(" "), + updatedAt: session.updatedAt, + state: "idle", + visible: project.expanded, + sessionId: session.id, + location: session.worktreeId ? "worktree" : "local", + worktreeId: session.worktreeId ?? undefined, + }) + } +``` + +`state` is already in scope (line 55) and already null-checked (line 56). `meta` is joined with +` · ` by the renderer (`SidebarSearchMenu.tsx:143`), so no separator work is needed. + +## Task E — Align the project heading with the row icons + +Every row in the projects tree used a different left inset, which made the sidebar look busy and +indented for no reason. Measured against a full-bleed `.am-projects-list` (starting at x=0): + +| Element | Before | After | +|---|---|---| +| `PROJECTS` label | 16px | 10px | +| Project chevron | 12px | 10px | +| `WORKTREES` / `SESSIONS` label | 6px | 10px | +| `.am-local-item` icon | 10px | 10px | +| `.am-worktree-item` icon | 10px | 10px | + +Changes in `agent-manager.css`: + +- `.am-local-item` padding `8px 10px` → `8px 6px` +- `.am-worktree-item` padding `6px 10px` → `6px 6px` +- `.am-project-item` padding `6px 8px 6px 12px` → `6px 6px` +- `.am-project-body .am-section-header` padding-left `6px` → unchanged at `6px` +- `.am-projects > .am-section-header` gets `padding-left: 2px`, because that heading sits inside + `.am-sidebar`'s own 8px padding rather than in the pulled-out list + +The leading columns carry no padding of their own (`.am-sidebar-header-toggle` and +`.am-sidebar-header-chevron` are bare 16px boxes), so row padding is the only lever. + +### Symmetric gutter + +`.am-projects-list` uses `margin-left: -4px; margin-right: -4px`, pulling out of `.am-sidebar`'s +8px padding to an even 4px gutter. An earlier attempt used `-8px` on the left for true full bleed, +but that is wrong twice over: the resize handle's inner half then covered every card, so card +clicks started a resize, and a selected row's `border-radius: var(--radius-sm)` background clipped +flat against the window edge while still being inset on the right, which read as a bar bleeding off +the sidebar rather than a card. + +4px is also the most reclaimable on the right. The handle's hit area is 8px wide centered on the +border, reaching 5px back into the content area (255-263 in a 260px sidebar). At this gutter the +row's hover actions end at 249, leaving 6px of clearance; anything tighter puts row buttons under +the handle and turns clicks into resize drags. + +### Two tab stops + +Reducing the insets exposed that labels sat at four different offsets: the non-collapsible +`WORKTREES` heading at 6px (it passes no `onToggle`, so `SidebarSectionHeader` renders no chevron), +collapsible headings at 28px, worktree card text at 30px, and local card text at 32px because +`.am-local-icon` was 18px wide while `.am-wt-icon` held a 16px glyph. + +Normalized to one 16px leading column with an 8px gap, giving exactly two tab stops: + +- `.am-sidebar-header-main` gap `6px` → `8px`, matching the card icon gap +- new `.am-project-body .am-sidebar-header:not(.am-sidebar-header-toggleable) .am-sidebar-header-main { padding-left: 24px }` + so a heading with no chevron still reserves the column +- `.am-local-icon` `18px` → `16px` +- `.am-wt-icon` gains `width: 16px` and `justify-content: center` + +Measured result at 260px: + +| Tab stop | Elements | +|---|---| +| 10px | `PROJECTS` label, project chevron, local icon, worktree icon, `SESSIONS` chevron | +| 34px | project name, `WORKTREES` label, `SESSIONS` label, local text, worktree text | + +``` +cardLeftGap=4 | cardRightGap=4 | actionsRight=249 | handleZoneFrom=255 +``` + +`PROJECTS` intentionally stays at the 10px glyph stop rather than being pushed to 34px: it is the +root heading, so indenting it further than the projects beneath it would invert the hierarchy. + +### Rejected: a per-project icon in the heading + +Superset shows a GitHub **owner** avatar per project (`https://github.com/{owner}.png?size=64`, +falling back to the project's initial). Our webview CSP already allows `https:` images, so it was +feasible, but it does not fit this repo set: of the local projects, ~17 are `Kilo-Org` remotes and +would share one identical Kilo logo, and ~20 have no git remote at all and would show nothing. +An owner avatar answers "who owns this", which is not the question the sidebar needs answered. +Superset's repo-file scanner (`favicon-discovery.ts`) is dead code there, so it was not an option +worth copying either. Left out entirely as out of scope. + +## Task D — Final checks + +From `packages/kilo-vscode`: + +```bash +bun run format +bun run typecheck +bun run lint +bun run test:unit +bun run check-kilocode-change +bun run compile +``` + +Then create `.changeset/agent-manager-sidebar-density.md`: + +```md +--- +"kilo-code": patch +--- + +Fix the Agent Manager sidebar keyboard shortcut badge so it appears on the right edge of local rows and only while hovered or holding the jump modifier, give worktree titles more room by no longer reserving space for hidden row actions, and show which worktree a session belongs to in the search palette. +``` + +## Visual regression baselines + +Task A and B change existing snapshots: `WorktreeItemDefault`, `WorktreeItemActive`, +`WorktreeItemPendingDelete`, `WorktreeItemStale`, `WorktreeItemWithStats`, `WorktreeItemGrouped`, +`SidebarSearchOpen`, `MultiProjectSidebar` in `webview-ui/src/stories/agent-manager.stories.tsx`. + +**Do not run or update visual regression tests.** `tests/visual-regression.spec.ts` is skipped on +macOS, so you cannot produce valid Linux baselines locally. CI regenerates them and may push a +baseline commit. If a push is then rejected, do **not** `git pull --rebase`; run +`git fetch && git push --force-with-lease`. + +## Manual test + +Enable `kilo-code.new.experimental.multiProject` in Kilo Settings → Experimental, add a second +project, open Agent Manager (`Cmd+Shift+M`). + +1. Nothing hovered: no `⌘N` badge visible anywhere. +2. Hover a `local` row: badge appears at the **right** edge, git stats fade out. +3. Hold ⌘: badges appear on all local and worktree rows, all right-aligned. Release: all gone. +4. Worktree rows with no git changes show noticeably longer titles than before. Hover one with a + long title: it fades under the badge and trash button instead of colliding. +5. ⌘F: session results read ` · `. +6. Turn the experimental setting off: the `local` row's `⌘1` badge is hidden until hover. + +## Known issue, not fixed here + +`⌘1`–`⌘9` index the full nav order, which includes session rows that render no badge +(`navigate.ts:214-218` + `section-helpers.ts:147-153`). So with 4 worktrees you see `⌘1`–`⌘5` and +`⌘6`–`⌘9` silently land on unlabelled session rows. Deliberately left alone: fixing it means +changing jump semantics and rewriting `tests/unit/navigate.test.ts:745-757`, and it becomes moot +once #12928 moves sessions out of the tree. diff --git a/.kilo/plans/agent-manager-new-worktree-project-selector.md b/.kilo/plans/agent-manager-new-worktree-project-selector.md new file mode 100644 index 0000000000..5f98fd086f --- /dev/null +++ b/.kilo/plans/agent-manager-new-worktree-project-selector.md @@ -0,0 +1,834 @@ +# Agent Manager — New Worktree Project Selector + +Status: implemented 2026-08-06. The implementation is uncommitted. + +This is Slice 4 item 6 ("Add project-aware New Worktree targeting") from +`agent-manager-multi-project-uniform-ui.md`, the last unimplemented item of that slice. +`agent-manager-multi-project-runtime.md:29` deferred it out of the backend-first scope. +Everything the extension side needs already exists; this is almost entirely a webview +change. + +Implementation notes: + +- The project catalog is passed into the dialog as an accessor so the picker reflects + registry changes while it remains open. +- The per-project default-base resolver returns `undefined` when the project has no + configured/local branch, allowing the backend-detected branch response to remain the + fallback instead of being replaced by a hardcoded `main`. +- Branch, import-result, and worktree-ready messages carry `projectId` in multi-project + mode, which makes fast project changes and cross-project creation activation safe. +- The slash-command hook accepts optional caller-owned commands; `/project` is scoped to + this dialog and is hidden when multi-project mode is unavailable. + +--- + +## Problem + +With `kilo-code.new.experimental.multiProject` enabled, the New Worktree dialog has no +notion of which repository it targets, and the user cannot see or change it. + +`Cmd+N` opens the dialog with no project at all: + +```tsx +// AgentManagerApp.tsx:1870-1876 +const showNewWorktreeDialog = () => { + if (!loaded()) return + expandSidebar() + dialog.show(() => ( + dialog.close()} defaultBaseBranch={repoDefaultBranch()} /> + )) +} +``` + +`projectId` is `undefined`, so every message the dialog sends omits it +(`agentManager.requestBranches` at `NewWorktreeDialog.tsx:321`, +`agentManager.createMultiVersion` at `:373`, `agentManager.importFromPR` at `:566`, +`agentManager.importFromBranch` at `:575`). The extension then silently falls back to the +active project in `messageProject()` (`AgentManagerProvider.ts:474`) before running the +message inside `ProjectScope`. + +The result is correct but opaque: + +- The dialog never shows which repository the worktree lands in. +- The only way to target a specific project is the per-project `+` button + (`ProjectList.tsx:136-146`), which does pass `projectId` explicitly. +- `defaultBaseBranch` is resolved from the *active* project + (`AgentManagerApp.tsx:261,272`), so even Advanced options' base-branch list and default + badge are implicitly single-project. + +The desired behavior, per the original request: the dialog should show the assigned +project and let the user change it. Defaulting to the last selected project is fine as a +default; it just must be visible and overridable. + +--- + +## Design decisions + +### Placement: inline with the tab switcher + +The selector renders inside the New/Import pill row (`NewWorktreeDialog.tsx:620-699`), +right-aligned with a constrained width, so it is shared by both tabs without adding a +full-width form row. + +``` +┌─ New Worktree ──────────────────────────────┐ +│ [ New ] [ Import ] [ folder kilocode ▾ ] │ ← inline, multiProject only +│ [ Worktree name (optional) ] │ +│ [ prompt … ] Code ▾ GPT-5.6 ▾ None ▾ │ +│ › Advanced options │ +│ VERSIONS 1 2 3 4 ⧉ Compare Models │ +│ [ Create Worktree ] │ +└──────────────────────────────────────────────┘ +``` + +Rejected alternatives and why: + +- **Inside Advanced options.** Wrong category. Advanced options holds refinements of a + known target (branch name, base branch). The project *is* the target: changing it + invalidates the branch list, base branch, default-branch badge, and setup scripts. + Hiding it also fails the stated requirement of seeing which project is assigned. +- **A full-width row directly beneath the tabs.** It covered both tabs, but consumed + unnecessary vertical space and made the project control look like a primary form field. +- **In the dialog title** (`New Worktree in [kilocode ▾]`). Reads nicely but requires + widening `Dialog`'s `title` prop to accept JSX, which touches kilo-ui for one caller. + +### Visibility + +Render the row only when `multiProject` is true. With the flag off (the default) the +dialog stays byte-identical to today, so there is no regression surface for the +all-user path. When the flag is on but only the pinned project exists, still render it: +showing the target is informative and the requirement is explicitly about seeing the +assignment. + +### Default value + +`props.projectId ?? activeProjectId()`. `activeProjectId()` already exists at +`AgentManagerApp.tsx:268` (`projectList().find((p) => p.active)?.id ?? currentProjectId()`). + +No new persistence. The active project is already the durable "last selected" state +(persisted per project as `activeTarget` in each repo's `.kilo/agent-manager.json`, plus +the registry's ordering). Adding a separate "last dialog project" key would create a +second source of truth that can disagree with the sidebar. + +### Reuse, no new CSS + +The row uses the existing `am-advanced-field` + `am-nv-config-label` + +`am-selector-wrapper` + `am-selector-trigger` markup, i.e. exactly the structure the base +branch selector already uses at `NewWorktreeDialog.tsx:813-905`, with `DeferredPopover` +(already imported) instead of `BranchSelectPopover`. + +### Component extraction + +`NewWorktreeDialog.tsx` is already 1136 lines. The selector and its popover list go into a +new `webview-ui/agent-manager/ProjectSelect.tsx` (roughly `BranchSelect.tsx`'s role): +presentational, takes `projects`, `value`, `onSelect`, and labels, and owns nothing but +its own list rendering. The dialog keeps only the signal, the popover trigger, and the +effects. + +Note: `webview-ui/agent-manager/NewWorktreeDialog.tsx` is not under a `maxLines` cap +(`tests/unit/agent-manager-arch.test.ts` caps `src/agent-manager/*.ts` only), but the file +is on the arch test's watched list and the caps exist to discourage exactly this kind of +growth. + +--- + +## Changes + +### 1. `webview-ui/agent-manager/ProjectSelect.tsx` (new) + +Presentational popover body listing projects: + +- Row = project label + dimmed root path (tooltip on the full root, matching + `ProjectsSection.tsx:60`). +- Check mark on the selected project. +- Untrusted and missing projects are disabled and carry the same affordances the accordion + uses: `lock` icon + trust hint, `warning` icon + missing hint + (`ProjectsSection.tsx:67-75`). Selecting them is not possible; trust happens in the + sidebar, not in this dialog. Keeps the dialog free of trust-flow branching. +- There is intentionally no Add project action in this picker. Project registration and + trust management stay in the Agent Manager Projects toolbar. + +### 2. `webview-ui/agent-manager/NewWorktreeDialog.tsx` + +Props change: + +```ts +export const NewWorktreeDialog: Component<{ + onClose: () => void + projectId?: string // now: initial value, not fixed target + projects?: AgentProjectSnapshot[] // omitted / empty => single-project, row hidden + activeProjectId?: string + defaultBase?: (projectId: string) => string // replaces defaultBaseBranch?: string + mode: ModeRouter +}> +``` + +`defaultBaseBranch?: string` must become a per-project lookup because each project has its +own configured default and its own local branch. `ProjectList.tsx:142` already computes +that expression (`state?.defaultBaseBranch ?? props.local[projectId]?.branch`); hoist it +into the callback so both call sites share it. + +New state and derived values: + +```ts +const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId) +const [projectOpen, setProjectOpen] = createSignal(false) +const selectable = () => (props.projects ?? []).filter((p) => p.trusted && !p.missing) +const showProject = () => (props.projects?.length ?? 0) > 0 +``` + +Every outbound message switches from `props.projectId` to `project()`: +`:321` `requestBranches`, `:373` `createMultiVersion`, `:566` `importFromPR`, +`:575` `importFromBranch`. + +Reload branch data on project change, replacing the one-shot `onMount` request at `:319-321`: + +```ts +createEffect( + on(project, (id) => { + setBranches([]) + setBranchSearch("") + setHighlightedIndex(0) + setBaseBranch(null) // custom base is project-specific + setDefaultBranch(props.defaultBase?.(id) ?? "main") + setBranchesLoading(true) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: id }) + }), +) +``` + +Drop stale branch replies in the `agentManager.branches` handler (`:520-525`). +`AgentManagerBranchesMessage` **already declares an optional `projectId`** +(`extension-messages.ts:939-945`, `src/agent-manager/types.ts:293-298`); the field is +simply never populated or read today. Without this guard, switching projects twice quickly +can race a wrong branch list into the base-branch popover: + +```ts +if (ev.projectId && ev.projectId !== project()) return +``` + +Also replace the `if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)` guard +at `:523` — with a per-project lookup, the guard must consult +`props.defaultBase?.(project())` instead of a fixed prop. + +Preserved across a project change (all project-agnostic): prompt text and its +`advancedDialogPrompt` persistence, images, name, agent, model, variant, versions, compare +allocations, sandbox override. + +Keyboard: add `project` to `WORKTREE_PROMPT_COMMANDS` so `/project` opens the popover, +consistent with mode/model/variant/sandbox already being reachable from the dialog's slash +menu (`:302-314`). Hide it from the list when `showProject()` is false, using the same +`hidden` set mechanism already used for `agents` / `variant` / `sandbox`. + +### 3. `webview-ui/agent-manager/AgentManagerApp.tsx` + +`showNewWorktreeDialog` (`:1870-1876`) passes the catalog and a per-project default +resolver instead of a single branch string: + +```tsx + dialog.close()} + projects={multiProject() ? projectList() : undefined} + activeProjectId={activeProjectId()} + defaultBase={defaultBase} +/> +``` + +where `defaultBase(id)` reads `registry.ensure(id).defaultBaseBranch() ?? registry.ensure(id).localStats()?.branch ?? repoDetectedBranch() ?? "main"`. +`registry.ensure` and both store fields already exist +(`project/registry.ts:39`, `project/store.ts:62,67,113,123`). + +### 4. `webview-ui/agent-manager/ProjectList.tsx` + +`newWorktree(projectId)` (`:136-146`) passes the same `projects` / `activeProjectId` / +`defaultBase` props with `projectId` as the initial value, so the per-project `+` button +opens the dialog pre-scoped but still switchable. Its current inline +`state?.defaultBaseBranch ?? props.local[projectId]?.branch` expression is replaced by the +shared resolver passed down from `AgentManagerApp`. + +### 5. `src/agent-manager/worktree-importer.ts` + +Stamp `projectId` on all three `agentManager.branches` posts (`:27`, `:48`, `:54`). The +field is already in the type; the value is available from the ambient `ProjectScope` +context the message runs in (`AgentManagerProvider.ts:479`). Without this the stale-reply +guard in the webview is inert. + +### 6. Activate the created worktree when the project differs + +Creating in a non-active project currently leaves the sidebar where it is: +`createMultiVersion` never activates (no activation call in `provider-multi-version.ts`), +and the new worktree just appears in that project's accordion. That is right for the +per-project `+` button, but for `Cmd+N` where the user deliberately switched projects, +landing in the new worktree is what the flow implies. + +Post an `agentManager.activateSelection` for the first created worktree when the chosen +project differs from the active one. `activateSelection` already handles readiness, trust, +and stale-target fallback (`project/messages.ts:76-99`), so this is one message, not new +machinery. Hook it to the existing `agentManager.worktreeSetup` / `multiVersionProgress` +handling in `AgentManagerApp.tsx:1453-1471`, which already carries `projectId`. + +### 7. i18n + +New keys in `webview-ui/agent-manager/i18n/en.ts` (near the existing +`agentManager.dialog.*` block at `:130`): + +- `agentManager.dialog.project.select` — "Select project" +- `agentManager.dialog.project.untrusted` — "Trust this project in the sidebar first" +- `agentManager.dialog.project.missing` — "Repository not found" + +Then translate the four keys into the other 20 locale files in that directory via the +`translator` subagent. + +--- + +## Implementation order + +1. `ProjectSelect.tsx` with the presentational list, plus i18n keys in `en.ts`. +2. Dialog: `project` signal, prop rename to `defaultBase`, route all four outbound + messages through `project()`, render the row behind `showProject()`. +3. Dialog: `createEffect(on(project, …))` branch reload, base-branch reset, stale-reply + guard. +4. Call-site updates in `AgentManagerApp.tsx` and `ProjectList.tsx`, shared `defaultBase` + resolver. +5. `projectId` stamp in `worktree-importer.ts`. +6. `/project` slash command. +7. Post-create activation when the target project differs. +8. Locale fan-out. +9. Changeset (`minor`, user-facing): worktree creation targets an explicit project. + +--- + +## Tests + +Existing source-text unit tests already assert against this dialog and will need to stay +green: `tests/unit/new-worktree-dialog-sandbox.test.ts`, +`tests/unit/prompt-input-bidirectional.test.ts`, and the dialog entry in +`tests/unit/agent-manager-arch.test.ts`. + +New coverage: + +- The dialog posts `createMultiVersion` / `requestBranches` / `importFromBranch` / + `importFromPR` with the *selected* project id, not the prop, after a project change. +- A `agentManager.branches` reply carrying a non-current `projectId` does not mutate the + branch list (the race guard). +- Changing project clears `baseBranch` and re-derives `defaultBranch` from `defaultBase`. +- Prompt text survives a project change (no accidental reset through the shared + `advancedDialogPrompt` cache). +- The row does not render when `projects` is empty, so the single-project dialog is + unchanged. +- Untrusted and missing projects are not selectable. + +Checks to run before declaring done, from `packages/kilo-vscode/`: +`bun run typecheck`, `bun run lint`, `bun run test:unit`, `bun run knip`. + +--- + +## Manual verification + +In the isolated harness (`bun run extension:isolated`) with +`kilo-code.new.experimental.multiProject` enabled and two repositories registered: + +1. `Cmd+N` from project A shows "Project: A". Switch to B, create, and confirm the + worktree lands in B's accordion and the sidebar activates it. +2. Switch project with Advanced options open and confirm the base-branch list and default + badge follow the new project rather than showing A's branches. +3. Switch project rapidly back and forth and confirm the branch list matches the selected + project (the race guard). +4. Type a prompt, switch project, confirm the prompt is retained. +5. Use the Import tab after switching project and confirm branches and PR import target + the selected repository. +6. Turn the flag off and confirm the dialog is visually identical to today. + +--- + +## Out of scope + +- Trusting or removing a project from inside the dialog. Trust stays in the sidebar; the + dialog only disables untrusted entries. +- Any change to how the active project is persisted. +- The quick-create path (`Cmd+Shift+N` → `agentManager.createWorktree`, + `AgentManagerApp.tsx:1863-1867`). It has no dialog, so it keeps targeting the active + project. Worth revisiting only if the explicit-target rule should apply there too. +- Per-project setup-script or agent selection in the dialog. + +## Risks + +- **Stale branch data** is the only real correctness risk, and it is why the `projectId` + stamp plus the reply guard are mandatory rather than optional polish. +- **Prop signature change** (`defaultBaseBranch: string` → `defaultBase: (id) => string`) + touches both call sites; a partial migration would silently show one project's default + branch while creating in another. +- **Dialog file growth**; mitigated by extracting `ProjectSelect.tsx`. + +--- + +# Appendix: exact UI and styling specification + +Everything below is copy-paste ready. Class names, tokens, and icon names are all verified +against the current tree. Do not invent new tokens or new class names beyond the ones +listed here. + +## A. Visual layout + +``` +┌─ New Worktree ─────────────────────────────────────────── X ─┐ +│ │ +│ [ New ][ Import ] [ 📁 kilocode ⌃⌄ ] │ +│ └────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Worktree name (optional) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ prompt … │ │ +│ │ Code ▾ OpenAI / GPT-5.6 ▾ None ▾ ✨ 🔒 🎤 │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ › Advanced options │ +│ VERSIONS [1][2][3][4] [⧉ Compare Models] │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Create Worktree │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +Open dropdown (anchored under the trigger, same width as the trigger): + +``` + ┌────────────────────────────────────────────┐ + │ 📁 kilocode ~/Documents/git/kilocode ✓│ ← .am-project-option-active + │ 📁 cloud ~/Documents/git/cloud │ + │ 🔒 sample-app ~/dev/sample-app │ ← disabled, 50% opacity + │ ⚠ old-repo ~/dev/old-repo │ ← disabled, 50% opacity + └────────────────────────────────────────────┘ +``` + +Rules: + +- The selector is inline with the New/Import buttons inside the tab-switcher flex row, so + it applies to New and Import alike. +- The project name and folder icon identify the scope without a separate visible label. +- The trigger is the same control as the Advanced options base-branch trigger + (`.am-selector-trigger`), so the dialog has one visual language for "pick a thing". +- The row is **not rendered at all** when `props.projects` is empty or undefined. That is + the single-project / flag-off case, which must stay pixel-identical to today. + +## B. Exact icon names + +Only these, from `packages/ui/src/components/icon.tsx`: + +| Where | `Icon name` | Notes | +|---|---|---| +| Trigger left | `folder` | Always, regardless of project state. | +| Trigger right | `selector` | Same as every other `.am-selector-trigger`. | +| Option row, normal | `folder` | | +| Option row, untrusted | `lock` | Matches the sidebar accordion affordance. | +| Option row, missing | `warning` | Matches the sidebar accordion affordance. | +| Option row, selected | `check-small` | Right-aligned. | + +All at `size="small"`. Do not use `folder-add-left`, `check`, or `plus`. + +## C. New file: `webview-ui/agent-manager/ProjectSelect.tsx` + +```tsx +// Project picker list for the New Worktree dialog + +/** @jsxImportSource solid-js */ + +import { For, Show, type Component } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { AgentProjectSnapshot } from "../src/types/messages" + +interface ProjectSelectProps { + projects: AgentProjectSnapshot[] + selected?: string + onSelect: (id: string) => void + labels: { untrusted: string; missing: string } +} + +export const ProjectSelect: Component = (props) => ( +
+ + {(project) => { + const blocked = () => !project.trusted || project.missing + const hint = () => { + if (project.missing) return props.labels.missing + if (!project.trusted) return props.labels.untrusted + return project.root + } + const icon = () => { + if (project.missing) return "warning" as const + if (!project.trusted) return "lock" as const + return "folder" as const + } + return ( + + ) + }} + +
+) +``` + +Notes for the implementer: + +- Wrap in `.am-dropdown-list`, not a bare fragment: that class supplies the scroll cap and + 4px padding, and `.am-dropdown [data-slot="popover-body"]` zeroes the popover padding. +- No search input. A project list is short; adding one would need keyboard nav plumbing for + no benefit. +- `props.labels` is passed in rather than calling `useLanguage()` here, matching how + `BranchSelect` and `SidebarSearchMenu` take label props. + +## D. Exact JSX inserted into `NewWorktreeDialog.tsx` + +### D.1 Imports + +Add to the existing type import block at lines 6-12: + +```ts + AgentProjectSnapshot, +``` + +Add after line 48 (`import { BranchSelect, BranchSelectPopover } …`): + +```ts +import { ProjectSelect } from "./ProjectSelect" +``` + +`Icon`, `Show`, `DeferredPopover`, `createSignal`, `createEffect` are already imported. +`on` from `solid-js` must be added to the line 5 import list. + +### D.2 Props + +Replace the component signature at lines 84-89 with: + +```tsx +export const NewWorktreeDialog: Component<{ + onClose: () => void + /** Resolves the default base branch for one project. */ + defaultBase?: (projectId: string) => string | undefined + /** Initial target project. The user can change it while the dialog is open. */ + projectId?: string + /** Full project catalog. Empty or undefined hides the project row entirely. */ + projects?: () => AgentProjectSnapshot[] + /** Project the sidebar currently has active; used as the default target. */ + activeProjectId?: string + mode: ModeRouter +}> = (props) => { +``` + +### D.3 State + +Immediately after line 101 (`const [tab, setTab] = createSignal("new")`): + +```tsx +const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId) +const [projectOpen, setProjectOpen] = createSignal(false) +const projects = () => props.projects?.() ?? [] +const showProject = () => projects().length > 0 +const projectLabel = () => projects().find((p) => p.id === project())?.label ?? "" +``` + +`defaultBranch` (line 106) changes from `props.defaultBaseBranch ?? "main"` to: + +```tsx +const [defaultBranch, setDefaultBranch] = createSignal( + (project() && props.defaultBase?.(project()!)) || "main", +) +``` + +### D.4 The inline selector + +Insert inside the tab switcher after the Import button: + +```tsx +{/* Project scope — applies to both tabs. Hidden unless multi-project is on. */} + +
+
+ + + + + {t("agentManager.dialog.project.select")} + + } + > + {projectLabel()} + + + + + + + } + > + { + track("project_select", { changed: id !== props.activeProjectId }) + setProject(id) + setProjectOpen(false) + }} + labels={{ + untrusted: t("agentManager.dialog.project.untrusted"), + missing: t("agentManager.dialog.project.missing"), + }} + /> + +
+
+
+``` + +Critical details, in order of how easily they get wrong: + +1. `placement="bottom-start"`, **not** `top-start`. The rest of this dialog uses + `top-start` because those triggers sit near the bottom of the panel. This one sits at + the top, so it must open downward. +2. `portal={false}` plus the escape CSS in section E.3. Do not switch to a portal unless + the clipping fallback in E.3 is needed. +3. `sameWidth` so the dropdown matches the trigger width, consistent with the base-branch + and compare-models popovers. +4. Never send the project label, root, or id as a telemetry property. `track` takes only + the boolean shown above. + +### D.5 Reactive reload on project change + +Delete the one-shot request at lines 319-321 inside `onMount` and replace it with an effect +placed next to the other `createEffect` calls: + +```tsx +// Project scope owns the branch data, the base branch, and the default badge. +// Prompt, name, model, agent, versions and attachments are project-agnostic and survive. +createEffect( + on(project, (id) => { + if (!id) return + setBranches([]) + setBranchSearch("") + setHighlightedIndex(0) + setBaseBranch(null) + setDefaultBranch(props.defaultBase?.(id) ?? "main") + setBranchesLoading(true) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: id }) + }), +) +``` + +`on(project, …)` without `{ defer: true }` runs immediately, which replaces the removed +`onMount` request. Keep the textarea focus logic in `onMount` untouched. + +In the `agentManager.branches` handler (lines 520-525), replace the body with: + +```tsx +if (msg.type === "agentManager.branches") { + const ev = msg as AgentManagerBranchesMessage + if (ev.projectId && ev.projectId !== project()) return + setBranches(ev.branches) + const id = project() + if (!id || !props.defaultBase?.(id)) setDefaultBranch(ev.defaultBranch) + setBranchesLoading(false) +} +``` + +### D.6 Outbound project id + +Four call sites change from `props.projectId` to `project()`: + +| Line | Message | +|---|---| +| 321 (now inside the effect) | `agentManager.requestBranches` | +| 373 | `agentManager.createMultiVersion` | +| 566 | `agentManager.importFromPR` | +| 575 | `agentManager.importFromBranch` | + +Grep afterwards: `props.projectId` must appear exactly once in the file, in the `project` +signal initializer. + +## E. Exact CSS + +All of it goes into `webview-ui/agent-manager/agent-manager.css`. No changes to kilo-ui. + +### E.1 The inline selector + +Insert directly after the `.am-tab-switcher-pill-active` rule (agent-manager.css:3614-3617), +before the `/* Import tab layout */` comment at line 3619: + +```css +/* Project scope selector — inline with the New/Import tabs */ + +.am-nv-project-inline { + display: flex; + align-items: center; + flex-shrink: 0; + flex: 0 1 260px; + min-width: 0; + margin-left: auto; +} + +.am-nv-project-inline .am-selector-wrapper { + width: 100%; + min-width: 0; +} +``` + +The `flex: 0 1 260px` cap keeps the project control compact while allowing long project +names to truncate. `margin-left: auto` keeps it aligned to the right of the New/Import +buttons. + +### E.2 The dropdown rows + +Insert after the `.am-dropdown-empty` rule (agent-manager.css:3863-3868), before the +`/* Import empty state */` comment at line 3870: + +```css +/* Project option rows in the New Worktree project dropdown. + Deliberately distinct from .am-project-item, which styles the sidebar accordion. */ + +.am-project-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-base); + font-size: var(--font-size-base); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.am-project-option:hover:not(:disabled) { + background: var(--surface-inset-base-hover); +} + +.am-project-option-active { + background: var(--surface-inset-base); +} + +.am-project-option:disabled { + opacity: 0.5; + cursor: default; +} + +.am-project-option-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.am-project-option-left [data-component="icon"] { + color: var(--text-weaker); + flex-shrink: 0; +} + +.am-project-option-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; + max-width: 45%; +} + +.am-project-option-root { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-size: var(--kilo-font-size-11); + color: var(--text-weaker); +} + +``` + +Why not reuse `.am-branch-item`: it is defined twice (lines 3211 and 3810) and the earlier +definition sets `font-family: var(--font-mono, monospace)` on `.am-branch-item-name`, which +would render project labels in monospace. Reusing it also couples project rows to future +branch-row changes. `.am-project-item` is likewise off limits: it already styles the +sidebar project accordion header (line 325). + +### E.3 Popover clipping escape + +`[data-slot="dialog-body"]` is `overflow: hidden` in `packages/ui/src/components/dialog.css:99-105`, +and `[data-slot="dialog-content"]` is `overflow: auto` (line 38). The existing escape rules +at agent-manager.css:2822-2828 only match popovers **inside** `.am-nv-dialog`, and this row +is deliberately outside it. Without the following, the dropdown is clipped by the dialog. + +Add to that same rule group (extend the existing selector list rather than duplicating the +declaration): + +```css +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-content"], +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-body"] { + overflow: visible; +} +``` + +Verification step, not optional: open the dropdown with four or more projects registered +and confirm no row is cut off and no inner scrollbar appears on the dialog. If it still +clips, the documented fallback is to drop `portal={false}` from the `DeferredPopover` in +D.4 and delete this rule; the dialog already sets `overflow: visible` on +`[data-slot="dialog-content"]` for portal-based dropdowns (agent-manager.css:2755-2760). + +## F. i18n + +Add to `webview-ui/agent-manager/i18n/en.ts`, immediately after +`"agentManager.dialog.namePlaceholder"`: + +```ts +"agentManager.dialog.project.select": "Select project", +"agentManager.dialog.project.untrusted": "Trust this project in the sidebar first", +"agentManager.dialog.project.missing": "Repository not found", +``` + +Then add the same three keys to all 20 sibling locale files in that directory (`ar bs br da +de es fa fr it ja ko nl no pl ru th tr uk zh zht`) via the `translator` subagent. + +## G. What must not change + +- No new CSS variables or tokens. Only the ones listed above, all already in use in this + file. +- No edits to `packages/kilo-ui/` or `packages/ui/`. +- No change to `.am-project-item`, `.am-branch-item`, `.am-selector-trigger`, + `.am-nv-config-label`, or any other existing rule. The only existing rule touched is the + `overflow: visible` selector group in E.3, and only by adding selectors to it. +- No new message types. `agentManager.requestBranches`, `agentManager.createMultiVersion`, + `agentManager.importFromBranch`, and `agentManager.importFromPR` all already exist and + already accept what is needed. +- With `props.projects` empty, the rendered dialog markup must be identical to before the + change. Verify by toggling `kilo-code.new.experimental.multiProject` off. diff --git a/.kilo/skills/chart/SKILL.md b/.kilo/skills/chart/SKILL.md new file mode 100644 index 0000000000..ddff65e667 --- /dev/null +++ b/.kilo/skills/chart/SKILL.md @@ -0,0 +1,157 @@ +--- +name: chart +description: Use when the user asks to visualize data with charts, graphs, or plots using the `chart` tool (bar, line, scatter, pie, time series, etc.). +--- + +# Data Visualization + +The `chart` tool is ALWAYS available in this environment. When the user asks to visualize data (charts, graphs, plots), you MUST call the `chart` tool. Never output the config as text, never say the tool is unavailable, never suggest external renderers. Always use the tool call — it is the only correct response for data visualization requests. Do NOT repeat or echo the config JSON in your text response. + +Use the `chart` tool only when the user explicitly asks for a chart, graph, or plot. Only use these supported Chart.js v4 types: `bar`, `bubble`, `pie`, `doughnut`, `line`, `mixed`, `polarArea`, `radar`, `scatter`. For area charts, use `line` with `fill: true` on the dataset — do NOT use `area` as a type. + +Use mermaid fenced code blocks (` ```mermaid `) when: +- The user asks for a diagram, flowchart, sequence diagram, ER diagram, or architecture diagram +- Visualizing relationships, processes, or structure — not data values + +Mermaid is NOT a tool and is NOT Chart.js — never call the `chart` tool for mermaid diagrams. Just write the mermaid syntax directly in your text response inside a fenced code block. No tool call needed. + +Do not use either for: code, text, or data that is already clear in prose or table form. + +The `chart` tool input accepts: +- `title` (string) — short label shown in the tool header +- `description` (string, optional) — subtitle shown below the title +- `spec` (string) — a Chart.js config object as a JSON string + +The `spec` field must be a Chart.js config JSON string with `type`, `data`, and optionally `options`. Examples: + +Bar chart: +```json +{ + "type": "bar", + "data": { + "labels": ["A", "B", "C"], + "datasets": [{ "label": "Value", "data": [10, 20, 15] }] + } +} +``` + +Area chart (line with fill): +```json +{ + "type": "line", + "data": { + "labels": ["Jan", "Feb", "Mar", "Apr"], + "datasets": [{ "label": "Value", "data": [10, 28, 19, 45], "fill": true }] + } +} +``` + +Line chart: +```json +{ + "type": "line", + "data": { + "labels": ["Jan", "Feb", "Mar", "Apr"], + "datasets": [{ "label": "Value", "data": [10, 28, 19, 45], "fill": false }] + } +} +``` + +Scatter plot: +```json +{ + "type": "scatter", + "data": { + "datasets": [{ + "label": "Points", + "data": [{ "x": 1, "y": 5 }, { "x": 2, "y": 8 }, { "x": 3, "y": 3 }] + }] + } +} +``` + +Time series: +```json +{ + "type": "line", + "data": { + "labels": ["2024-01", "2024-02", "2024-03", "2024-04"], + "datasets": [{ "label": "Value", "data": [120, 145, 132, 178], "fill": true }] + } +} +``` + +Pie chart: +```json +{ + "type": "pie", + "data": { + "labels": ["A", "B", "C"], + "datasets": [{ "data": [30, 50, 20] }] + } +} +``` + +Doughnut chart: +```json +{ + "type": "doughnut", + "data": { + "labels": ["A", "B", "C"], + "datasets": [{ "data": [30, 50, 20] }] + } +} +``` + +Radar chart: +```json +{ + "type": "radar", + "data": { + "labels": ["Speed", "Power", "Agility", "Stamina"], + "datasets": [{ "label": "Player", "data": [80, 60, 90, 70] }] + } +} +``` + +Bubble chart: +```json +{ + "type": "bubble", + "data": { + "datasets": [{ + "label": "Group A", + "data": [{ "x": 10, "y": 20, "r": 8 }, { "x": 15, "y": 10, "r": 5 }] + }] + } +} +``` + +Polar area chart: +```json +{ + "type": "polarArea", + "data": { + "labels": ["A", "B", "C", "D"], + "datasets": [{ "data": [11, 16, 7, 14] }] + } +} +``` + +Mixed chart (bar + line): +```json +{ + "type": "bar", + "data": { + "labels": ["Jan", "Feb", "Mar"], + "datasets": [ + { "type": "bar", "label": "Revenue", "data": [100, 120, 90] }, + { "type": "line", "label": "Trend", "data": [95, 115, 100] } + ] + } +} +``` + +You may customize colors by setting `backgroundColor` and `borderColor` arrays on datasets. The renderer handles sizing — do not set width or height. + +Only include `scales` in `options` for cartesian chart types: `bar`, `line`, `scatter`, `bubble`. Do NOT include `scales` for `pie`, `doughnut`, `polarArea`, `radar`, or `mixed` — it will cause them to fail. diff --git a/.kilo/skills/icon-vscode/SKILL.md b/.kilo/skills/icon-vscode/SKILL.md new file mode 100644 index 0000000000..7eb5b38874 --- /dev/null +++ b/.kilo/skills/icon-vscode/SKILL.md @@ -0,0 +1,63 @@ +--- +name: icon-vscode +description: Create or review icons for Kilo's VS Code extension, webviews, and shared icon registry. +--- + +# VS Code Icons + +Use this skill for icon work in `packages/kilo-vscode/`, `packages/kilo-ui/`, and the shared `packages/ui/` icon registry. Keep official VS Code workbench rules separate from Kilo's webview design system. + +## Choose the icon system first + +| Surface | Use | Source of truth | Theme handling | +|---|---|---|---| +| VS Code commands, menus, and editor actions | Codicon, for example `$(add)`, or a 16x16 single-color SVG only when needed | `packages/kilo-vscode/package.json` records usages; [VS Code command icons](https://code.visualstudio.com/api/references/contribution-points#contributes.commands) defines the contract | VS Code themes Codicons; SVGs use light/dark contribution fields | +| Activity bar and view containers | 24x24 centered single-color icon by the VS Code convention; existing Kilo branding is an intentional asset exception | `packages/kilo-vscode/package.json`, `packages/kilo-vscode/assets/icons/` | Follow the contribution point; do not redesign the existing brand mark | +| Marketplace and extension branding | Packaged brand asset | `packages/kilo-vscode/package.json`, `packages/kilo-vscode/assets/icons/` | Existing Kilo assets define their own palette and variants | +| Webview buttons and UI | `Icon` or `IconButton` from `@kilocode/kilo-ui` | `packages/kilo-ui/src/components/icon.tsx`, then `packages/ui/src/components/icon.tsx` | `currentColor` and the VS Code theme bridge | +| Extension-contributed product icon | Existing WOFF2 font entry, for example `$(kilo-logo)` | `contributes.icons` in `packages/kilo-vscode/package.json` and its usages | VS Code product-icon theming | + +Do not draw a custom SVG when an appropriate Codicon or existing registry icon already exists. Do not use a webview icon directly in `package.json`, or a VS Code contribution icon directly in the webview. + +## Existing repository conventions + +- Search `packages/kilo-ui/src/components/icon.tsx` first for Kilo-only icons, then `packages/ui/src/components/icon.tsx` for shared icons. Preserve the existing key spelling, which is mostly kebab-case, and match a visual sibling before adding a new one. +- Webview registry icons are inline SVG path strings, not standalone files. They use `fill="currentColor"` or `stroke="currentColor"`; do not add theme duplicates or literal palettes to registry entries. Standalone brand artwork may use light/dark variants. +- Match the closest registry sibling's `viewBox`. The shared set is mostly `20 20`, with existing `16 16` entries; Kilo-only entries also intentionally use `24 24`. Icons render at 16px (`small`), 20px (`normal`), or 24px (`medium`/`large`). Never paste a path onto a different canvas without rebalancing it. +- The extension's current brand assets are `kilo-light.svg`, `kilo-dark.svg`, `kilo-light.png`, `kilo-dark.png`, and `logo-outline-black.png`. The WOFF2 file is a packaged contribution font, not an editable icon source. +- Registry icons are decorative by default. Icon buttons need an `aria-label` or visible button text; a tooltip or arbitrary `label` attribute is not sufficient unless the wrapper maps it to an accessible name. + +## Geometry rules + +1. Give each icon one clear semantic meaning. A small `+`, status mark, or active-state fill is an acceptable modifier. +2. Start from at least one existing sibling with the same role and match its `viewBox`, visual weight, caps, joins, and padding. Official command SVGs use a 16x16 canvas with 1px padding; official view-container icons use a centered 24x24 canvas. +3. Keep round-capped endpoints away from the edge so caps are not clipped. For registry icons, use the sibling's bounds rather than imposing a new universal padding rule. +4. Use static SVG geometry such as `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`, and `g`. No raster images, external resources, gradients, filters, embedded fonts, or ` + + + ${input.body}${input.script ? `\n ` : ""} + +` +} + +const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)` + +function bootstrapScript(options: BootstrapOptions) { + return `var PROVIDER=${scriptString(options.provider ?? "")}; +var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href; +(function(){ + var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote"); + function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."} + function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)} + try{ + var hash=new URLSearchParams((window.location.hash||"").slice(1)); + var search=new URLSearchParams(window.location.search||""); + var err=hash.get("error")||search.get("error"); + var errDescription=hash.get("error_description")||search.get("error_description"); + var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""}; + fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){ + if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))}); + if(err){fail(errDescription||err);return} + ok(); + }).catch(function(e){fail(String(e&&e.message?e.message:e))}); + }catch(e){fail(String(e&&e.message?e.message:e))} +})()` +} + +function scriptString(value: string) { + return JSON.stringify(value).replaceAll("<", "\\u003c") +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + +// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is +// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a +// host force a scheme without changing the default. +const LIGHT_VARS = ` + --oc-bg: #f8f8f8; + --oc-card: #fcfcfc; + --oc-text-strong: #171717; + --oc-text-base: #6f6f6f; + --oc-text-weak: #8f8f8f; + --oc-border-weak: #e5e5e5; + --oc-icon-strong: #171717; + --oc-icon-base: #8f8f8f; + --oc-icon-weak: #dbdbdb; + --oc-success: #2dba26; + --oc-error: #ed4831; + --oc-detail-bg: #fff8f6; + --oc-detail-border: #fdc3b7; + --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);` + +const DARK_VARS = ` + --oc-bg: #101010; + --oc-card: #161616; + --oc-text-strong: rgba(255,255,255,.936); + --oc-text-base: rgba(255,255,255,.618); + --oc-text-weak: rgba(255,255,255,.422); + --oc-border-weak: #282828; + --oc-icon-strong: #ededed; + --oc-icon-base: #7e7e7e; + --oc-icon-weak: #343434; + --oc-success: #12c905; + --oc-error: #fc533a; + --oc-detail-bg: #28110c; + --oc-detail-border: #6a1206; + --oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);` + +const STYLES = ` + :root { color-scheme: light dark;${LIGHT_VARS} + --oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } } + :root[data-theme="dark"] {${DARK_VARS} } + :root[data-theme="light"] {${LIGHT_VARS} } + + * { box-sizing: border-box; } + html, body { margin: 0; height: 100%; } + body { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--oc-bg); + color: var(--oc-text-base); + font-family: var(--oc-font-sans); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + .card { + width: min(100%, 28rem); + padding: 2.25rem 2rem 1.75rem; + background: var(--oc-card); + border: 1px solid var(--oc-border-weak); + border-radius: 14px; + box-shadow: var(--oc-shadow); + text-align: center; + } + .brand { display: flex; justify-content: center; margin-bottom: 1.75rem; } + .brand svg { height: 19px; width: auto; } + .status { display: flex; justify-content: center; margin-bottom: 1.125rem; } + .icon { display: none; line-height: 0; } + .icon svg { display: block; } + .card[data-status="pending"] .icon-pending, + .card[data-status="success"] .icon-success, + .card[data-status="error"] .icon-error { display: block; } + .icon-success { color: var(--oc-success); } + .icon-error { color: var(--oc-error); } + .icon-pending { color: var(--oc-text-weak); } + .headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); } + .message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); } + .detail { + margin: 1.25rem 0 0; + padding: 0.75rem 0.875rem; + text-align: left; + font-family: var(--oc-font-mono); + font-size: 0.8125rem; + line-height: 1.55; + color: var(--oc-text-strong); + background: var(--oc-detail-bg); + border: 1px solid var(--oc-detail-border); + border-radius: 8px; + white-space: pre-wrap; + word-break: break-word; + max-height: 9.5rem; + overflow: auto; + } + .detail[hidden] { display: none; } + .footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); } + .spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; } + @keyframes oc-spin { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } +` + +// OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo). +const WORDMARK = ` + + + + + + + + + + + + + + + + + ` + +const ICON_CHECK = `` + +const ICON_CROSS = `` + +const ICON_SPINNER = `` diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index faffb27333..22285974d8 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -1,6 +1,7 @@ export * as Observability from "./observability" import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "./effect/layer-node" import { Effect, Layer, Logger, References } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { OtlpSerialization } from "effect/unstable/observability" @@ -19,3 +20,5 @@ export const layer = Layer.unwrap( return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer)) }), ) + +export const node = LayerNode.make({ name: "observability", layer, deps: [] }) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index bbfc6014e8..3f28632a03 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,54 +1,38 @@ export * as PermissionV2 from "./permission" +import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" +import { Permission } from "@opencode-ai/schema/permission" import { EventV2 } from "./event" import { Location } from "./location" import { AgentV2 } from "./agent" import { SessionV2 } from "./session" import { SessionStore } from "./session/store" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" import { Wildcard } from "./util/wildcard" -import { PermissionSchema } from "./permission/schema" import { PermissionSaved } from "./permission/saved" -export { Effect, Rule, Ruleset } from "./permission/schema" -type Effect = PermissionSchema.Effect -type Rule = PermissionSchema.Rule -type Ruleset = PermissionSchema.Ruleset -const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }] +export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission" +const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }] -export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( - Schema.brand("PermissionV2.ID"), - withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), -) +export const ID = Permission.ID export type ID = typeof ID.Type -export const Source = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("tool"), - messageID: Schema.String, - callID: Schema.String, - }), -]).annotate({ identifier: "PermissionV2.Source" }) +export const Source = Permission.Source export type Source = typeof Source.Type const RequestFields = { - sessionID: SessionV2.ID, - action: Schema.String, - resources: Schema.Array(Schema.String), - save: Schema.Array(Schema.String).pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - source: Source.pipe(Schema.optional), + sessionID: Permission.Request.fields.sessionID, + action: Permission.Request.fields.action, + resources: Permission.Request.fields.resources, + save: Permission.Request.fields.save, + metadata: Permission.Request.fields.metadata, + source: Permission.Request.fields.source, } -export const Request = Schema.Struct({ - id: ID, - ...RequestFields, -}).annotate({ identifier: "PermissionV2.Request" }) +export const Request = Permission.Request export type Request = typeof Request.Type -export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export const Reply = Permission.Reply export type Reply = typeof Reply.Type export const AssertInput = Schema.Struct({ @@ -67,39 +51,29 @@ export type ReplyInput = typeof ReplyInput.Type export const AskResult = Schema.Struct({ id: ID, - effect: PermissionSchema.Effect, + effect: Permission.Effect, }).annotate({ identifier: "PermissionV2.AskResult" }) export type AskResult = typeof AskResult.Type -export const Event = { - Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }), - Replied: EventV2.define({ - type: "permission.v2.replied", - schema: { - sessionID: SessionV2.ID, - requestID: ID, - reply: Reply, - }, - }), -} +export const Event = Permission.Event -export class RejectedError extends Schema.TaggedErrorClass()("PermissionV2.RejectedError", {}) {} +export class DeclinedError extends Schema.TaggedErrorClass()("PermissionV2.DeclinedError", {}) {} export class CorrectedError extends Schema.TaggedErrorClass()("PermissionV2.CorrectedError", { feedback: Schema.String, }) {} -export class DeniedError extends Schema.TaggedErrorClass()("PermissionV2.DeniedError", { - rules: PermissionSchema.Ruleset, +export class BlockedError extends Schema.TaggedErrorClass()("PermissionV2.BlockedError", { + rules: Permission.Ruleset, }) {} export class NotFoundError extends Schema.TaggedErrorClass()("PermissionV2.NotFoundError", { requestID: ID, }) {} -export type Error = DeniedError | RejectedError | CorrectedError +export type Error = BlockedError | CorrectedError -export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule { +export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule { return ( rulesets .flat() @@ -111,7 +85,7 @@ export function evaluate(action: string, resource: string, ...rulesets: Ruleset[ ) } -export function merge(...rulesets: Ruleset[]): Ruleset { +export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset { return rulesets.flat() } @@ -129,10 +103,10 @@ export class Service extends Context.Service()("@opencode/v2 interface Pending { readonly request: Request readonly agent?: AgentV2.ID - readonly deferred: Deferred.Deferred + readonly deferred: Deferred.Deferred } -export const layer = Layer.effect( +const layer = Layer.effect( Service, EffectRuntime.gen(function* () { const events = yield* EventV2.Service @@ -143,7 +117,7 @@ export const layer = Layer.effect( const pending = new Map() yield* EffectRuntime.addFinalizer(() => - EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), { + EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), { discard: true, }).pipe( EffectRuntime.ensuring( @@ -156,7 +130,7 @@ export const layer = Layer.effect( const savedRules = EffectRuntime.fnUntraced(function* () { return (yield* saved.list({ projectID: location.project.id })).map( - (item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), + (item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), ) }) @@ -170,11 +144,11 @@ export const layer = Layer.effect( return agent?.permissions ?? missingAgentPermissions }) - function denied(input: AssertInput, rules: Ruleset) { + function denied(input: AssertInput, rules: Permission.Ruleset) { return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny") } - function relevant(input: AssertInput, rules: Ruleset) { + function relevant(input: AssertInput, rules: Permission.Ruleset) { return rules.filter((rule) => Wildcard.match(input.action, rule.action)) } @@ -183,7 +157,7 @@ export const layer = Layer.effect( if (denied(input, rules)) return { effect: "deny" as const, rules } const all = [...rules, ...(yield* savedRules())] const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect) - const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow" + const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow" return { effect, rules: all } }) @@ -202,7 +176,7 @@ export const layer = Layer.effect( const create = (request: Request, agent?: AgentV2.ID) => EffectRuntime.uninterruptible( EffectRuntime.gen(function* () { - const deferred = yield* Deferred.make() + const deferred = yield* Deferred.make() const item = { request, agent, deferred } if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`) pending.set(request.id, item) @@ -225,13 +199,14 @@ export const layer = Layer.effect( EffectRuntime.gen(function* () { const result = yield* evaluateInput(input) if (result.effect === "deny") { - return yield* new DeniedError({ + return yield* new BlockedError({ rules: relevant(input, result.rules), }) } if (result.effect === "allow") return const item = yield* create(request(input), input.agent) return yield* restore(Deferred.await(item.deferred)).pipe( + EffectRuntime.catchTag("PermissionV2.DeclinedError", (error) => EffectRuntime.die(error)), EffectRuntime.ensuring( EffectRuntime.sync(() => { pending.delete(item.request.id) @@ -256,7 +231,7 @@ export const layer = Layer.effect( if (input.reply === "reject") { yield* Deferred.fail( existing.deferred, - input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), + input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(), ) pending.delete(input.requestID) for (const [id, item] of pending) { @@ -266,7 +241,7 @@ export const layer = Layer.effect( requestID: item.request.id, reply: "reject", }) - yield* Deferred.fail(item.deferred, new RejectedError()) + yield* Deferred.fail(item.deferred, new DeclinedError()) pending.delete(id) } return @@ -327,3 +302,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node], +}) diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts index 4c57ef2aa0..ffc4559afe 100644 --- a/packages/core/src/permission/saved.ts +++ b/packages/core/src/permission/saved.ts @@ -3,23 +3,15 @@ export * as PermissionSaved from "./saved" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/app-node" import { ProjectV2 } from "../project" -import { withStatics } from "../schema" -import { Identifier } from "../util/identifier" import { PermissionTable } from "./sql" +import { PermissionSaved } from "@opencode-ai/schema/permission-saved" -export const ID = Schema.String.pipe( - Schema.brand("PermissionSaved.ID"), - withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })), -) +export const ID = PermissionSaved.ID export type ID = typeof ID.Type -export const Info = Schema.Struct({ - id: ID, - projectID: ProjectV2.ID, - action: Schema.String, - resource: Schema.String, -}).annotate({ identifier: "PermissionSaved.Info" }) +export const Info = PermissionSaved.Info export type Info = typeof Info.Type export const ListInput = Schema.Struct({ @@ -42,7 +34,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/PermissionSaved") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -84,4 +76,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/permission/schema.ts b/packages/core/src/permission/schema.ts deleted file mode 100644 index 2d806dbd8c..0000000000 --- a/packages/core/src/permission/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -export * as PermissionSchema from "./schema" - -import { Schema } from "effect" - -export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" }) -export type Effect = typeof Effect.Type - -export const Rule = Schema.Struct({ - action: Schema.String, - resource: Schema.String, - effect: Effect, -}).annotate({ identifier: "PermissionV2.Rule" }) -export type Rule = typeof Rule.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) -export type Ruleset = typeof Ruleset.Type diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index aaef65d322..f6c071bca6 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,186 +1,167 @@ export * as PluginV2 from "./plugin" -import { createDraft, finishDraft, type Draft } from "immer" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" -import type { ModelV2 } from "./model" -import type { Catalog } from "./catalog" +import { makeLocationNode } from "./effect/app-node" +import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" +import type { Plugin as PluginRuntime } from "@kilocode/plugin/v2/effect" +import { Plugin } from "@opencode-ai/schema/plugin" +import { AgentV2 } from "./agent" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" import { EventV2 } from "./event" +import { Integration } from "./integration" import { KeyedMutex } from "./effect/keyed-mutex" +import { PluginHost } from "./plugin/host" +import { Reference } from "./reference" +import { SkillV2 } from "./skill" +import { State } from "./state" -export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export const ID = Plugin.ID export type ID = typeof ID.Type - -export const Event = { - Added: EventV2.define({ - type: "plugin.added", - schema: { - id: ID, - }, - }), -} - -type HookSpec = { - "catalog.transform": { - input: Catalog.Editor - output: {} - } - "aisdk.language": { - input: { - model: ModelV2.Info - sdk: any - options: Record - } - output: { - language?: LanguageModelV3 - } - } - "aisdk.sdk": { - input: { - model: ModelV2.Info - package: string - options: Record - } - output: { - sdk?: any - } - } -} - -export type Hooks = { - [Name in keyof HookSpec]: Readonly & { - -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object - ? Draft - : HookSpec[Name]["output"][Field] - } -} - -export type HookFunctions = { - [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect -} - -export type HookInput = HookSpec[Name]["input"] -export type HookOutput = HookSpec[Name]["output"] - -export type Effect = Effect.Effect - -export function define(input: { id: ID; effect: Effect.Effect }) { - return input -} +export const Event = Plugin.Event export interface Interface { - readonly add: (input: { - id: ID - effect: Effect.Effect - }) => Effect.Effect + readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect readonly remove: (id: ID) => Effect.Effect - readonly triggerFor: ( - id: ID, - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> - readonly trigger: ( - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> + readonly wait: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - let hooks: { - id: ID - hooks: HookFunctions - scope: Scope.Closeable - }[] = [] const events = yield* EventV2.Service - const scope = yield* Scope.Scope const locks = KeyedMutex.makeUnsafe() - - const svc = Service.of({ - add: Effect.fn("Plugin.add")(function* (input) { - yield* locks.withLock(input.id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === input.id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) - const childScope = yield* Scope.fork(scope) - const result = yield* input.effect.pipe( - Scope.provide(childScope), - Effect.withSpan("Plugin.load", { - attributes: { - "plugin.id": input.id, - }, + const scope = yield* Scope.make() + const active = new Map() + const loading = new Set() + const waiters = new Map>>() + const failures = new Map>() + let host: Parameters[0] + + const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) { + if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) + + yield* locks.withLock(id)( + Effect.sync(() => { + loading.add(id) + failures.delete(id) + }).pipe( + Effect.andThen( + State.batch( + Effect.gen(function* () { + const existing = active.get(id) + active.delete(id) + if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + + const child = yield* Scope.fork(scope) + yield* effect(host).pipe( + Scope.provide(child), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + ) + yield* events.publish(Event.Added, { id }) + active.set(id, child) + yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { + discard: true, + }) + waiters.delete(id) }), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)), - ) - hooks = [ - ...hooks.filter((item) => item.id !== input.id), - { - id: input.id, - hooks: result ?? {}, - scope: childScope, - }, - ] - yield* events.publish(Event.Added, { id: input.id }) + ), + ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return Effect.void + failures.set(id, exit) + return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), { + discard: true, + }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id)))) }), - ) - }), - trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { - return yield* svc.triggerFor(ID.make("*"), name, input, output) - }), - triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) { - const draftEntries = new Map>() - const event = { - ...input, - ...output, - } as Record - - for (const [field, value] of Object.entries(output)) { - if (value && typeof value === "object") { - draftEntries.set(field, createDraft(value)) - event[field] = draftEntries.get(field) - } - } - - for (const item of hooks) { - if (id !== ID.make("*") && item.id !== id) continue - const match = item.hooks[name] - if (!match) continue - yield* match(event as any).pipe( - Effect.withSpan(`Plugin.hook.${name}`, { - attributes: { - plugin: item.id, - hook: name, - }, - }), - ) - } + Effect.ensuring(Effect.sync(() => loading.delete(id))), + ), + ) + }) - for (const [field, draft] of draftEntries) { - event[field] = finishDraft(draft) - } + const remove = Effect.fn("Plugin.remove")(function* (id: ID) { + if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) - return event as any - }), - remove: Effect.fn("Plugin.remove")(function* (id) { - yield* locks.withLock(id)( + yield* locks.withLock(id)( + State.batch( Effect.gen(function* () { - const existing = hooks.find((item) => item.id === id) - hooks = hooks.filter((item) => item.id !== id) - if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore) + const current = active.get(id) + active.delete(id) + failures.delete(id) + if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) }), - ) + ), + ) + }) + + const wait = Effect.fn("Plugin.wait")(function* (id: ID) { + const waiter = yield* Deferred.make() + const pending = yield* locks.withLock(id)( + Effect.sync(() => { + if (active.has(id)) return false + const failure = failures.get(id) + if (failure) return failure + const current = waiters.get(id) ?? new Set() + current.add(waiter) + waiters.set(id, current) + return true + }), + ) + if (!pending) return + if (typeof pending !== "boolean") return yield* pending + yield* Deferred.await(waiter).pipe( + Effect.ensuring( + locks.withLock(id)( + Effect.sync(() => { + const current = waiters.get(id) + current?.delete(waiter) + if (current?.size === 0) waiters.delete(id) + }), + ), + ), + ) + }) + + yield* Effect.addFinalizer((exit) => + Effect.gen(function* () { + active.clear() + yield* State.batch(Scope.close(scope, exit)) }), + ) + + const service = Service.of({ + add, + remove, + wait, }) - return svc + host = yield* PluginHost.make(service) + return service }), ) -export const locationLayer = layer +export const locationLayer = layer.pipe( + Layer.provideMerge(AgentV2.locationLayer), + Layer.provideMerge(AISDK.locationLayer), + Layer.provideMerge(Catalog.locationLayer), + Layer.provideMerge(CommandV2.locationLayer), + Layer.provideMerge(Integration.locationLayer), + Layer.provideMerge(Reference.locationLayer), +) -// opencode -// sdcok +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + AgentV2.node, + AISDK.node, + Catalog.node, + CommandV2.node, + Integration.node, + Reference.node, + SkillV2.node, + ], +}) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index e8a8d8bc9d..9a763c7ea9 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,12 +1,12 @@ export * as AgentPlugin from "./agent" import path from "path" +import { define } from "./internal" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" import { Location } from "../location" import { PermissionV2 } from "../permission" -import { PluginV2 } from "../plugin" const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") const BUILD_SYSTEM = @@ -97,10 +97,9 @@ Rules: - If the conversation ends with an unanswered question to the user, preserve that exact question - If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("agent"), - effect: Effect.gen(function* () { - const agent = yield* AgentV2.Service +export const Plugin = define({ + id: "agent", + effect: Effect.fn(function* (ctx) { const location = yield* Location.Service const worktree = location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] @@ -122,8 +121,8 @@ export const Plugin = PluginV2.define({ { action: "read", resource: "*.env.example", effect: "allow" }, ] - yield* agent.update((editor) => { - editor.update(AgentV2.defaultID, (item) => { + yield* ctx.agent.transform((draft) => { + draft.update(AgentV2.defaultID, (item) => { item.description = "The default agent. Executes tools based on configured permissions." item.system ??= BUILD_SYSTEM item.mode = "primary" @@ -135,7 +134,7 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("plan"), (item) => { + draft.update(AgentV2.ID.make("plan"), (item) => { item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( @@ -154,14 +153,14 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("general"), (item) => { + draft.update(AgentV2.ID.make("general"), (item) => { item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("explore"), (item) => { + draft.update(AgentV2.ID.make("explore"), (item) => { item.description = 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' item.system = PROMPT_EXPLORE @@ -182,21 +181,21 @@ export const Plugin = PluginV2.define({ ) }) - editor.update(AgentV2.ID.make("compaction"), (item) => { + draft.update(AgentV2.ID.make("compaction"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("title"), (item) => { + draft.update(AgentV2.ID.make("title"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) - editor.update(AgentV2.ID.make("summary"), (item) => { + draft.update(AgentV2.ID.make("summary"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts deleted file mode 100644 index 694b0bc564..0000000000 --- a/packages/core/src/plugin/boot.ts +++ /dev/null @@ -1,134 +0,0 @@ -export * as PluginBoot from "./boot" - -import { Context, Deferred, Effect, Layer } from "effect" -import { Credential } from "../credential" -import { Integration } from "../integration" -import { AgentV2 } from "../agent" -import { Catalog } from "../catalog" -import { CommandV2 } from "../command" -import { Config } from "../config" -import { ConfigAgentPlugin } from "../config/plugin/agent" -import { ConfigCommandPlugin } from "../config/plugin/command" -import { ConfigSkillPlugin } from "../config/plugin/skill" -import { ConfigReferencePlugin } from "../config/plugin/reference" -import { EventV2 } from "../event" -import { FSUtil } from "../fs-util" -import { Global } from "../global" -import { Location } from "../location" -import { ModelsDev } from "../models-dev" -import { Npm } from "../npm" -import { PluginV2 } from "../plugin" -import { AgentPlugin } from "./agent" -import { CommandPlugin } from "./command" -import { ConfigProviderPlugin } from "../config/plugin/provider" -import { ModelsDevPlugin } from "./models-dev" -import { ProviderPlugins } from "./provider" -import { SkillV2 } from "../skill" -import { Reference } from "../reference" - -type Plugin = { - id: PluginV2.ID - effect: PluginV2.Effect< - | Catalog.Service - | CommandV2.Service - | Credential.Service - | Integration.Service - | AgentV2.Service - | Npm.Service - | EventV2.Service - | FSUtil.Service - | Global.Service - | Location.Service - | PluginV2.Service - | Config.Service - | ModelsDev.Service - | SkillV2.Service - | Reference.Service - > -} - -export interface Interface { - readonly wait: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/PluginBoot") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const commands = yield* CommandV2.Service - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const integrations = yield* Integration.Service - const agents = yield* AgentV2.Service - const config = yield* Config.Service - const location = yield* Location.Service - const modelsDev = yield* ModelsDev.Service - const npm = yield* Npm.Service - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const skill = yield* SkillV2.Service - const references = yield* Reference.Service - const done = yield* Deferred.make() - - const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { - yield* plugin.add({ - id: input.id, - effect: input.effect.pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Credential.Service, credentials), - Effect.provideService(Integration.Service, integrations), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, references), - Effect.provideService(PluginV2.Service, plugin), - ), - }) - }) - - const boot = Effect.gen(function* () { - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - // kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill. - for (const item of ProviderPlugins) { - yield* add(item) - } - yield* add(ModelsDevPlugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) - }).pipe(Effect.withSpan("PluginBoot.boot")) - - yield* boot.pipe( - Effect.exit, - Effect.flatMap((exit) => Deferred.done(done, exit)), - Effect.forkScoped, - ) - - return Service.of({ - wait: () => Deferred.await(done), - }) - }), -) - -export const locationLayer = layer.pipe( - Layer.provideMerge(Integration.locationLayer), - Layer.provideMerge(Catalog.locationLayer), - Layer.provideMerge(CommandV2.locationLayer), - Layer.provideMerge(Config.locationLayer), - Layer.provideMerge(AgentV2.locationLayer), - Layer.provideMerge(SkillV2.locationLayer), - Layer.provideMerge(Reference.locationLayer), -) diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 66386a2128..cbafd68b50 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,25 +1,21 @@ export * as CommandPlugin from "./command" +import { define } from "./internal" import { Effect } from "effect" -import { CommandV2 } from "../command" import { Location } from "../location" -import { PluginV2 } from "../plugin" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("command"), - effect: Effect.gen(function* () { - const command = yield* CommandV2.Service +export const Plugin = define({ + id: "command", + effect: Effect.fn(function* (ctx) { const location = yield* Location.Service - const transform = yield* command.transform() - - yield* transform((editor) => { - editor.update("init", (command) => { + yield* ctx.command.transform((draft) => { + draft.update("init", (command) => { command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) command.description = "guided AGENTS.md setup" }) - editor.update("review", (command) => { + draft.update("review", (command) => { command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts new file mode 100644 index 0000000000..13663e33e5 --- /dev/null +++ b/packages/core/src/plugin/host.ts @@ -0,0 +1,219 @@ +export * as PluginHost from "./host" + +import type { PluginContext as Interface } from "@kilocode/plugin/v2/effect" +import { Effect, Schema } from "effect" +import { AgentV2 } from "../agent" +import { AISDK } from "../aisdk" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { Credential } from "../credential" +import { Integration } from "../integration" +import { ModelV2 } from "../model" +import { PluginV2 } from "../plugin" +import { ProviderV2 } from "../provider" +import { Reference } from "../reference" +import type { DeepMutable } from "../schema" +import { SkillV2 } from "../skill" + +const mutable = (value: T) => value as DeepMutable + +export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { + const agents = yield* AgentV2.Service + const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const integration = yield* Integration.Service + const reference = yield* Reference.Service + const skill = yield* SkillV2.Service + + return { + options: {}, + agent: { + reload: agents.reload, + transform: (callback) => + agents.transform((draft) => + callback({ + list: () => mutable(draft.list()), + get: (id) => mutable(draft.get(AgentV2.ID.make(id))), + default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), + update: (id, update) => draft.update(AgentV2.ID.make(id), update), + remove: (id) => draft.remove(AgentV2.ID.make(id)), + }), + ), + }, + aisdk: { + sdk: (callback) => + aisdk.hook.sdk((event) => { + const output = { + model: mutable(event.model), + package: event.package, + options: event.options, + sdk: event.sdk, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }), + language: (callback) => + aisdk.hook.language((event) => { + const output = { + model: mutable(event.model), + sdk: event.sdk, + options: event.options, + language: event.language, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.language = output.language))), + ) + }), + }, + catalog: { + reload: catalog.reload, + transform: (callback) => + catalog.transform((draft) => + callback({ + provider: { + list: () => mutable(draft.provider.list()), + get: (id) => mutable(draft.provider.get(ProviderV2.ID.make(id))), + update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update), + remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)), + }, + model: { + get: (providerID, modelID) => + mutable(draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))), + update: (providerID, modelID, update) => + draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update), + remove: (providerID, modelID) => + draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + default: { + get: draft.model.default.get, + set: (providerID, modelID) => + draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + }, + }, + }), + ), + }, + command: { + reload: commands.reload, + transform: commands.transform, + }, + integration: { + reload: integration.reload, + connection: { + active: (id) => integration.connection.active(Integration.ID.make(id)), + resolve: (connection) => + integration.connection.resolve( + connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection, + ), + }, + transform: (callback) => + integration.transform((draft) => + callback({ + list: () => mutable(draft.list()), + get: (id) => mutable(draft.get(Integration.ID.make(id))), + update: (id, update) => draft.update(Integration.ID.make(id), update), + remove: (id) => draft.remove(Integration.ID.make(id)), + method: { + list: (id) => mutable(draft.method.list(Integration.ID.make(id))), + update: (input) => { + if ("authorize" in input) { + const methodID = Integration.MethodID.make(input.method.id) + const refresh = input.refresh + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { ...input.method, id: methodID }, + authorize: (inputs) => + input.authorize(inputs).pipe( + Effect.map((authorization) => { + if (authorization.mode === "auto") { + return { + ...authorization, + callback: authorization.callback.pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + } + return { + ...authorization, + callback: (code: string) => + authorization.callback(code).pipe( + Effect.map((credential) => + Credential.OAuth.make({ + ...credential, + methodID: Integration.MethodID.make(credential.methodID), + }), + ), + ), + } + }), + ), + ...(refresh + ? { + refresh: (value: Credential.OAuth) => + refresh(value).pipe( + Effect.map((next) => + Credential.OAuth.make({ + ...next, + methodID: Integration.MethodID.make(next.methodID), + }), + ), + ), + } + : {}), + ...(input.label ? { label: input.label } : {}), + }) + return + } + if (input.method.type === "env") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "env", names: input.method.names }, + }) + return + } + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { type: "key", label: input.method.label }, + }) + }, + remove: (id, method) => + draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), + }, + }), + ), + }, + plugin: { + add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), + remove: (id) => plugin.remove(PluginV2.ID.make(id)), + }, + reference: { + reload: reference.reload, + transform: (callback) => + reference.transform((draft) => + callback({ + add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)), + remove: draft.remove, + list: draft.list, + }), + ), + }, + skill: { + reload: skill.reload, + transform: (callback) => + skill.transform((draft) => + callback({ + source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), + list: draft.list, + }), + ), + }, + } satisfies Interface +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts new file mode 100644 index 0000000000..8019345299 --- /dev/null +++ b/packages/core/src/plugin/internal.ts @@ -0,0 +1,152 @@ +export * as PluginInternal from "./internal" + +import { makeLocationNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" +import type { PluginContext } from "@kilocode/plugin/v2/effect" +import { Effect, Layer, Scope } from "effect" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { Config } from "../config" +import { ConfigAgentPlugin } from "../config/plugin/agent" +import { ConfigCommandPlugin } from "../config/plugin/command" +import { ConfigExternalPlugin } from "../config/plugin/external" +import { ConfigProviderPlugin } from "../config/plugin/provider" +import { ConfigReferencePlugin } from "../config/plugin/reference" +import { ConfigSkillPlugin } from "../config/plugin/skill" +import { EventV2 } from "../event" +import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { Integration } from "../integration" +import { Location } from "../location" +import { ModelsDev } from "../models-dev" +import { Npm } from "../npm" +import { PluginV2 } from "../plugin" +import { Reference } from "../reference" +import { SkillV2 } from "../skill" +import { State } from "../state" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { AgentPlugin } from "./agent" +import { CommandPlugin } from "./command" +import { ModelsDevPlugin } from "./models-dev" +import { ProviderPlugins } from "./provider" +import { VariantPlugin } from "./variant" + +export type Requirements = + | AgentV2.Service + | Catalog.Service + | CommandV2.Service + | Config.Service + | EventV2.Service + | FileSystem.Service + | FSUtil.Service + | Global.Service + | HttpClient.HttpClient + | Integration.Service + | Location.Service + | ModelsDev.Service + | Npm.Service + | Reference.Service + | SkillV2.Service + +export interface Plugin { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} + +const layer = Layer.effectDiscard( + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const plugin = yield* PluginV2.Service + const integration = yield* Integration.Service + const agents = yield* AgentV2.Service + const config = yield* Config.Service + const location = yield* Location.Service + const modelsDev = yield* ModelsDev.Service + const npm = yield* Npm.Service + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const filesystem = yield* FileSystem.Service + const global = yield* Global.Service + const http = yield* HttpClient.HttpClient + const skill = yield* SkillV2.Service + const reference = yield* Reference.Service + const add = (input: Plugin) => { + const loaded = { + id: input.id, + effect: (context: PluginContext) => + input + .effect(context) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(HttpClient.HttpClient, http), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), + } + return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) + } + + yield* State.batch( + Effect.gen(function* () { + yield* add(ConfigReferencePlugin.Plugin) + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + // kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill. + yield* add(ModelsDevPlugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(VariantPlugin.Plugin) + }), + ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + }), +) + +export const locationLayer = layer.pipe( + Layer.provideMerge(Config.locationLayer), + Layer.provideMerge(FetchHttpClient.layer), +) + +export const node = makeLocationNode({ + name: "plugin-internal", + layer, + deps: [ + Catalog.node, + CommandV2.node, + PluginV2.node, + Integration.node, + AgentV2.node, + Config.node, + Location.node, + ModelsDev.node, + Npm.node, + EventV2.node, + FSUtil.node, + FileSystem.node, + Global.node, + httpClient, + SkillV2.node, + Reference.node, + ], +}) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index a212d013ad..eda21e39b0 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,19 +1,16 @@ -import { DateTime, Effect, Scope, Stream } from "effect" -import { Catalog } from "../catalog" -import { Integration } from "../integration" +import { define } from "./internal" +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import { Effect, Stream } from "effect" import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" -import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" function released(date: string) { const time = Date.parse(date) - return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0) + return Number.isFinite(time) ? time : 0 } -function cost(input: ModelsDev.Model["cost"]) { +function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { const base = { input: input?.input ?? 0, output: input?.output ?? 0, @@ -22,51 +19,114 @@ function cost(input: ModelsDev.Model["cost"]) { write: input?.cache_write ?? 0, }, } - if (!input?.context_over_200k) return [base] return [ base, - { - tier: { - type: "context" as const, - size: 200_000, - }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, + ...(input?.tiers?.map((item) => ({ + tier: item.tier, + input: item.input, + output: item.output, cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: item.cache_read ?? 0, + write: item.cache_write ?? 0, }, - }, + })) ?? []), + ...(input?.context_over_200k + ? [ + { + tier: { + type: "context" as const, + size: 200_000, + }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? 0, + write: input.context_over_200k.cache_write ?? 0, + }, + }, + ] + : []), ] } -function variants(model: ModelsDev.Model, packageName?: string) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { - const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {}) - return { - id: ModelV2.VariantID.make(id), - headers: { ...(item.provider?.headers ?? {}) }, - ...request, - } +function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) { + if (!override) return base + const next = cost(override) + const [baseDefault, ...baseTiers] = base + const [nextDefault, ...nextTiers] = next + const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({ + ...left, + ...right, + tier: right.tier ?? left.tier, + cache: { ...left.cache, ...right.cache }, }) + const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) + for (const item of nextTiers) { + const current = tiers.get(tierKey(item)) + tiers.set(tierKey(item), current ? merge(current, item) : item) + } + return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] } -export const ModelsDevPlugin = PluginV2.define({ - id: PluginV2.ID.make("models-dev"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service +function modeName(model: ModelsDev.Model, mode: string) { + return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` +} + +function applyModel( + draft: ModelV2Info, + model: ModelsDev.Model, + input: { + readonly name?: string + readonly cost?: ModelV2Info["cost"] + readonly request?: NonNullable["modes"]>[string]["provider"] + } = {}, +) { + draft.name = input.name ?? model.name + draft.family = model.family + draft.api = model.provider?.npm + ? { + id: model.id, + type: "aisdk", + package: model.provider.npm, + url: model.provider.api, + } + : { + id: model.id, + type: "native", + url: model.provider?.api, + settings: {}, + } + draft.capabilities = { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + } + draft.variants = [] + draft.time.released = released(model.release_date) + draft.cost = input.cost ?? cost(model.cost) + draft.status = model.status ?? "active" + draft.enabled = true + draft.limit = { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + } + Object.assign(draft.request.headers, input.request?.headers ?? {}) + Object.assign(draft.request.body, input.request?.body ?? {}) +} + +export const ModelsDevPlugin = define({ + id: "models-dev", + effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service - const scope = yield* Scope.Scope - const transform = yield* catalog.transform() - const integrationTransform = yield* integrations.transform() - const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { - const data = yield* modelsDev.get() - yield* integrationTransform((integrations) => { + yield* ctx.integration.transform( + Effect.fn(function* (integrations) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { if (item.env.length === 0) continue - const integrationID = Integration.ID.make(item.id) + const integrationID = item.id integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({ integrationID, @@ -77,8 +137,11 @@ export const ModelsDevPlugin = PluginV2.define({ method: { type: "env", names: [...item.env] }, }) } - }) - yield* transform((catalog) => { + }), + ) + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const data = yield* modelsDev.get() for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { @@ -97,46 +160,23 @@ export const ModelsDevPlugin = PluginV2.define({ }) for (const model of Object.values(item.models)) { - const modelID = ModelV2.ID.make(model.id) - catalog.model.update(providerID, modelID, (draft) => { - draft.name = model.name - draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.api = model.provider?.npm - ? { - id: draft.api.id, - type: "aisdk", - package: model.provider?.npm, - url: model.provider.api, - } - : { - id: draft.api.id, - type: "native", - url: model.provider?.api, - settings: {}, - } - draft.capabilities = { - tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } - draft.variants = variants(model, model.provider?.npm ?? item.npm) - draft.time.released = released(model.release_date) - draft.cost = cost(model.cost) - draft.status = model.status ?? "active" - draft.enabled = true - draft.limit = { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - } - }) + const baseCost = cost(model.cost) + catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => + applyModel(draft, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + }), + ) + } } } - }) - }) - yield* refresh() + }), + ) yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach(() => refresh()), + Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts new file mode 100644 index 0000000000..01928b4165 --- /dev/null +++ b/packages/core/src/plugin/promise.ts @@ -0,0 +1,93 @@ +export * as PluginPromise from "./promise" + +import { define } from "@kilocode/plugin/v2/effect" +import type { Plugin, PluginContext, Registration } from "@kilocode/plugin/v2/promise" +import { Effect, Scope } from "effect" + +// The Effect host hands back this registration shape; mirror it structurally so +// we do not have to alias the Effect package's `Registration` against the Promise one. +type HostRegistration = { readonly dispose: Effect.Effect } + +/** + * Adapts a Promise plugin into an Effect plugin so the existing Effect-only + * loader (`PluginV2` / `PluginInternal`) can run it unchanged. + * + * Hook registrations created during the async `setup` attach to the plugin's + * scope, so unloading the plugin disposes them. The captured fiber context + * preserves boot-time batching, so Promise-plugin transforms still coalesce + * into one reload per domain. + */ +export function fromPromise(plugin: Plugin) { + return define({ + id: plugin.id, + effect: (host) => + Effect.gen(function* () { + const scope = yield* Scope.Scope + const context = yield* Effect.context() + + // Run a hook registration on the plugin scope and resolve once it is registered. + const register = (effect: Effect.Effect): Promise => + Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({ + dispose: () => Effect.runPromiseWith(context)(registration.dispose), + })) + + const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) + + const transform = + (domain: { + transform: ( + callback: (draft: Draft) => Effect.Effect | void, + ) => Effect.Effect + }) => + (callback: (draft: Draft) => Promise | void) => + register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft))))) + + const context2: PluginContext = { + options: host.options, + agent: { + transform: transform(host.agent), + reload: () => run(host.agent.reload()), + }, + aisdk: { + sdk: (callback) => + register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), + language: (callback) => + register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, + catalog: { + transform: transform(host.catalog), + reload: () => run(host.catalog.reload()), + }, + command: { + transform: transform(host.command), + reload: () => run(host.command.reload()), + }, + integration: { + transform: transform(host.integration), + reload: () => run(host.integration.reload()), + connection: { + active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)), + resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)), + }, + }, + plugin: { + add: (input) => { + const child = fromPromise(input) + return run(host.plugin.add(child)) + }, + remove: (id) => run(host.plugin.remove(id)), + }, + reference: { + transform: transform(host.reference), + reload: () => run(host.reference.reload()), + }, + skill: { + transform: transform(host.skill), + reload: () => run(host.skill.reload()), + }, + } + + yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) + }), + }) +} diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index ea3939b750..1749b474ed 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel" import { VenicePlugin } from "./provider/venice" import { XAIPlugin } from "./provider/xai" import { ZenmuxPlugin } from "./provider/zenmux" +import type { PluginInternal } from "./internal" +import type { Scope } from "effect" -export const ProviderPlugins = [ +export const ProviderPlugins: PluginInternal.Plugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index fa5c0a91cf..c5c4be0d0b 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const AlibabaPlugin = PluginV2.define({ - id: PluginV2.ID.make("alibaba"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const AlibabaPlugin = define({ + id: "alibaba", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) evt.sdk = mod.createAlibaba(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 9c7fd65665..0995cf1c17 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) { return sdk.responses(modelID) } -export const AmazonBedrockPlugin = PluginV2.define({ - id: PluginV2.ID.make("amazon-bedrock"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AmazonBedrockPlugin = define({ + id: "amazon-bedrock", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue @@ -77,7 +77,9 @@ export const AmazonBedrockPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE @@ -108,7 +110,9 @@ export const AmazonBedrockPlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) evt.sdk = mod.createAmazonBedrock(options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { evt.language = selectMantleModel(evt.sdk, evt.model.api.id) @@ -117,6 +121,6 @@ export const AmazonBedrockPlugin = PluginV2.define({ const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 9bd69fe036..cf883a0687 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const AnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AnthropicPlugin = define({ + id: "anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/anthropic") continue @@ -15,11 +15,13 @@ export const AnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) evt.sdk = mod.createAnthropic(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 173fd36621..2e1f9d9b48 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { return sdk.languageModel(modelID) } -export const AzurePlugin = PluginV2.define({ - id: PluginV2.ID.make("azure"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzurePlugin = define({ + id: "azure", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/azure") continue @@ -27,7 +27,9 @@ export const AzurePlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { if ( @@ -43,19 +45,21 @@ export const AzurePlugin = PluginV2.define({ const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) evt.sdk = mod.createAzure(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) -export const AzureCognitiveServicesPlugin = PluginV2.define({ - id: PluginV2.ID.make("azure-cognitive-services"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const AzureCognitiveServicesPlugin = define({ + id: "azure-cognitive-services", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return for (const item of evt.provider.list()) { @@ -67,10 +71,12 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({ }) } }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index f871943687..0fd651160f 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,24 +1,26 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CerebrasPlugin = PluginV2.define({ - id: PluginV2.ID.make("cerebras"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (ctx) { - for (const item of ctx.provider.list()) { +export const CerebrasPlugin = define({ + id: "cerebras", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/cerebras") continue - ctx.provider.update(item.provider.id, (provider) => { + evt.provider.update(item.provider.id, (provider) => { provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) evt.sdk = mod.createCerebras(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index ba7856b635..d416f6f19d 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,13 +1,13 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CloudflareAIGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-ai-gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CloudflareAIGatewayPlugin = define({ + id: "cloudflare-ai-gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return @@ -31,7 +31,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({ }, } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 10f3f5200a..1a1c533eb5 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,16 +1,16 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") -export const CloudflareWorkersAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("cloudflare-workers-ai"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const CloudflareWorkersAIPlugin = define({ + id: "cloudflare-workers-ai", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { @@ -20,7 +20,9 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ if (accountId) provider.api.url = workersEndpoint(accountId) }) }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -34,11 +36,13 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ }) as any, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 991c370d17..0ca0708577 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const CoherePlugin = PluginV2.define({ - id: PluginV2.ID.make("cohere"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const CoherePlugin = define({ + id: "cohere", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) evt.sdk = mod.createCohere(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index bbd42f6e28..1b23e08ba4 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const DeepInfraPlugin = PluginV2.define({ - id: PluginV2.ID.make("deepinfra"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const DeepInfraPlugin = define({ + id: "deepinfra", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) evt.sdk = mod.createDeepInfra(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index e5abc7009e..c84a6ed51f 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,19 +1,19 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" +import { Npm } from "../../npm" -export const DynamicProviderPlugin = PluginV2.define({ - id: PluginV2.ID.make("dynamic-provider"), - effect: Effect.gen(function* () { +export const DynamicProviderPlugin = define({ + id: "dynamic-provider", + effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.sdk) return const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -26,6 +26,6 @@ export const DynamicProviderPlugin = PluginV2.define({ evt.sdk = mod[match](evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 5b08ad9ef5..f097dcaca3 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("gateway"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GatewayPlugin = define({ + id: "gateway", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) evt.sdk = mod.createGateway(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 1fc7c0c799..ce210a6756 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,44 +1,52 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" +import type { PluginContext } from "@kilocode/plugin/v2/effect" -function shouldUseResponses(modelID: string) { - // Copilot supports Responses for GPT-5 class models, except mini variants - // which still need the chat-completions endpoint. - const match = /^gpt-(\d+)/.exec(modelID) - if (!match) return false - return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") -} - -export const GithubCopilotPlugin = PluginV2.define({ - id: PluginV2.ID.make("github-copilot"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GithubCopilotPlugin = { + id: "github-copilot", + effect: Effect.fn(function* (ctx: PluginContext) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { + const item = evt.provider.get(ProviderV2.ID.githubCopilot) + if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + }), + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { evt.language = evt.sdk.languageModel(evt.model.api.id) return } - evt.language = shouldUseResponses(evt.model.api.id) - ? evt.sdk.responses(evt.model.api.id) - : evt.sdk.chat(evt.model.api.id) - }), - "catalog.transform": Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.githubCopilot) - if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) + if (evt.options.endpoint === "responses" && evt.sdk.responses) { + evt.language = evt.sdk.responses(evt.model.api.id) + return + } + if (evt.options.endpoint === "chat" && evt.sdk.chat) { + evt.language = evt.sdk.chat(evt.model.api.id) + return + } + const match = /^gpt-(\d+)/.exec(evt.model.api.id) + // Copilot supports Responses for GPT-5 class models, except mini variants + // which still need the chat-completions endpoint. + evt.language = + match && Number(match[1]) >= 5 && !evt.model.api.id.startsWith("gpt-5-mini") && evt.sdk.responses + ? evt.sdk.responses(evt.model.api.id) + : evt.sdk.chat(evt.model.api.id) }), - } + ) }), -}) +} diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 9de090a95d..8723cdaac2 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,14 +1,14 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" -export const GitLabPlugin = PluginV2.define({ - id: PluginV2.ID.make("gitlab"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GitLabPlugin = define({ + id: "gitlab", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) evt.sdk = mod.createGitLab({ @@ -30,7 +30,9 @@ export const GitLabPlugin = PluginV2.define({ }, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} @@ -58,6 +60,6 @@ export const GitLabPlugin = PluginV2.define({ featureFlags, }) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index a7168d59ad..4e643c9f51 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { } } -export const GoogleVertexPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexPlugin = define({ + id: "google-vertex", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if ( @@ -83,7 +83,9 @@ export const GoogleVertexPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) return @@ -100,19 +102,21 @@ export const GoogleVertexPlugin = PluginV2.define({ location, }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) -export const GoogleVertexAnthropicPlugin = PluginV2.define({ - id: PluginV2.ID.make("google-vertex-anthropic"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const GoogleVertexAnthropicPlugin = define({ + id: "google-vertex-anthropic", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue @@ -132,7 +136,9 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) const project = @@ -156,10 +162,12 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({ : {}), }) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 47e29c6b5d..476af5b912 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GooglePlugin = PluginV2.define({ - id: PluginV2.ID.make("google"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GooglePlugin = define({ + id: "google", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) evt.sdk = mod.createGoogleGenerativeAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index f2052afd1a..0bddb44309 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const GroqPlugin = PluginV2.define({ - id: PluginV2.ID.make("groq"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const GroqPlugin = define({ + id: "groq", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) evt.sdk = mod.createGroq(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index bf6666c252..5b7404947c 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,15 +1,15 @@ import { createKilo, KILO_OPENROUTER_BASE } from "@kilocode/kilo-gateway" // kilocode_change import { Effect } from "effect" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" // kilocode_change +import { define } from "../internal" -const id = ProviderV2.ID.make("kilo") // kilocode_change +const id = ProviderV2.ID.kilo // kilocode_change -export const KiloPlugin = PluginV2.define({ - id: PluginV2.ID.make("kilo"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const KiloPlugin = define({ + id: "kilo", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.id !== id) continue // kilocode_change evt.provider.update(item.provider.id, (provider) => { @@ -34,12 +34,14 @@ export const KiloPlugin = PluginV2.define({ }) } }), - // kilocode_change start - "aisdk.sdk": Effect.fn(function* (evt) { + ) + // kilocode_change start + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== id) return evt.sdk = createKilo(evt.options) }), - // kilocode_change end - } + ) + // kilocode_change end }), }) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index b416abd284..12416fd63c 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,21 +1,21 @@ import { Effect } from "effect" +import { define } from "../internal" import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" // kilocode_change -export const LLMGatewayPlugin = PluginV2.define({ - id: PluginV2.ID.make("llmgateway"), - effect: Effect.gen(function* () { +export const LLMGatewayPlugin = define({ + id: "llmgateway", + effect: Effect.fn(function* (ctx) { const integrations = yield* Integration.Service - return { - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.disabled) continue - if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue if (item.provider.id !== ProviderV2.ID.make("llmgateway")) continue // kilocode_change + if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change start @@ -25,6 +25,6 @@ export const LLMGatewayPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index e7f0decb79..a731975659 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const MistralPlugin = PluginV2.define({ - id: PluginV2.ID.make("mistral"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const MistralPlugin = define({ + id: "mistral", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) evt.sdk = mod.createMistral(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 94e8163a5d..d27db3ad06 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" // kilocode_change -export const NvidiaPlugin = PluginV2.define({ - id: PluginV2.ID.make("nvidia"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const NvidiaPlugin = define({ + id: "nvidia", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -21,6 +21,6 @@ export const NvidiaPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts deleted file mode 100644 index fb9e8d300a..0000000000 --- a/packages/core/src/plugin/provider/openai-auth.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { createServer } from "node:http" -import { Deferred, Effect } from "effect" -import { Integration } from "../../integration" -import { Credential } from "../../credential" -import { InstallationVersion } from "../../installation/version" - -const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" -const issuer = "https://auth.openai.com" -const callbackPort = 1455 -const pollingSafetyMargin = 3000 - -type Pkce = { - verifier: string - challenge: string -} - -type TokenResponse = { - id_token: string - access_token: string - refresh_token: string - expires_in?: number -} - -type Claims = { - chatgpt_account_id?: string - organizations?: Array<{ id: string }> - "https://api.openai.com/auth"?: { chatgpt_account_id?: string } -} - -const browserMethodID = Integration.MethodID.make("chatgpt-browser") -const headlessMethodID = Integration.MethodID.make("chatgpt-headless") - -export const browser = { - integrationID: Integration.ID.make("openai"), - method: { - id: browserMethodID, - type: "oauth", - label: "ChatGPT Pro/Plus (browser)", - }, - authorize: () => - Effect.gen(function* () { - const pkce = yield* Effect.promise(generatePKCE) - const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) - const code = yield* Deferred.make() - const redirect = `http://localhost:${callbackPort}/auth/callback` - const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) - if (url.pathname !== "/auth/callback") { - response.writeHead(404).end("Not found") - return - } - // kilocode_change start - unrelated localhost requests must not terminate the active OAuth attempt - if (url.searchParams.get("state") !== state) { - response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage("Invalid OAuth state")) - return - } - // kilocode_change end - const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") - const value = url.searchParams.get("code") - if (error) { - Effect.runFork(Deferred.fail(code, new Error(error))) - response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error)) - return - } - if (!value) { - const message = "Missing authorization code" - Effect.runFork(Deferred.fail(code, new Error(message))) - response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message)) - return - } - Effect.runFork(Deferred.succeed(code, value)) - response.writeHead(200, { "Content-Type": "text/html" }).end(successPage) - }) - yield* Effect.callback((resume) => { - server.once("error", (error) => resume(Effect.fail(error))) - server.listen(callbackPort, "localhost", () => resume(Effect.void)) - }) - yield* Effect.addFinalizer(() => - Effect.sync(() => { - server.close() - }), - ) - return { - mode: "auto" as const, - url: authorizeURL(redirect, pkce, state), - instructions: "Complete authorization in your browser. This window will close automatically.", - callback: Deferred.await(code).pipe( - Effect.flatMap((value) => exchange(value, redirect, pkce)), - Effect.map((tokens) => credential(browserMethodID, tokens)), - ), - } - }), - refresh: (value) => refresh(value), -} satisfies Integration.OAuthImplementation - -export const headless = { - integrationID: Integration.ID.make("openai"), - method: { - id: headlessMethodID, - type: "oauth", - label: "ChatGPT Pro/Plus (headless)", - }, - authorize: () => - Effect.gen(function* () { - const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( - `${issuer}/api/accounts/deviceauth/usercode`, - { - method: "POST", - headers: headers("application/json"), - body: JSON.stringify({ client_id: clientID }), - }, - ) - const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 - return { - mode: "auto" as const, - url: `${issuer}/codex/device`, - instructions: `Enter code: ${device.user_code}`, - callback: Effect.gen(function* () { - while (true) { - const response = yield* Effect.tryPromise({ - try: (signal) => - fetch(`${issuer}/api/accounts/deviceauth/token`, { - method: "POST", - headers: headers("application/json"), - body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), - signal, - }), - catch: (cause) => cause, - }) - if (response.ok) { - const data = (yield* Effect.promise(() => response.json())) as { - authorization_code: string - code_verifier: string - } - return credential( - headlessMethodID, - yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { - verifier: data.code_verifier, - challenge: "", - }), - ) - } - if (response.status !== 403 && response.status !== 404) { - return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) - } - yield* Effect.sleep(interval + pollingSafetyMargin) - } - }), - } - }), - refresh: (value) => refresh(value), -} satisfies Integration.OAuthImplementation - -function headers(contentType: string) { - return { "Content-Type": contentType, "User-Agent": `kilo/${InstallationVersion}` } // kilocode_change -} - -function exchange(code: string, redirect: string, pkce: Pkce) { - return request(`${issuer}/oauth/token`, { - method: "POST", - headers: headers("application/x-www-form-urlencoded"), - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirect, - client_id: clientID, - code_verifier: pkce.verifier, - }).toString(), - }) -} - -function refresh(value: Credential.OAuth) { - return request(`${issuer}/oauth/token`, { - method: "POST", - headers: headers("application/x-www-form-urlencoded"), - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: value.refresh, - client_id: clientID, - }).toString(), - }).pipe( - Effect.map((tokens) => { - const next = credential(value.methodID, tokens) - return new Credential.OAuth({ - ...next, - metadata: next.metadata ?? value.metadata, - }) - }), - ) -} - -function request(url: string, init: RequestInit) { - return Effect.tryPromise({ - try: async (signal) => { - const response = await fetch(url, { ...init, signal }) - if (!response.ok) throw new Error(`Request failed: ${response.status}`) - return response.json() as Promise - }, - catch: (cause) => cause, - }) -} - -function credential(methodID: Integration.MethodID, tokens: TokenResponse) { - const accountID = extractAccountID(tokens) - return new Credential.OAuth({ - type: "oauth", - methodID, - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - metadata: accountID ? { accountID } : undefined, - }) -} - -async function generatePKCE(): Promise { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") - const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) - return { verifier, challenge } -} - -function base64UrlEncode(buffer: ArrayBuffer) { - return Buffer.from(buffer).toString("base64url") -} - -function authorizeURL(redirect: string, pkce: Pkce, state: string) { - return `${issuer}/oauth/authorize?${new URLSearchParams({ - response_type: "code", - client_id: clientID, - redirect_uri: redirect, - scope: "openid profile email offline_access", - code_challenge: pkce.challenge, - code_challenge_method: "S256", - id_token_add_organizations: "true", - codex_cli_simplified_flow: "true", - state, - originator: "kilo", // kilocode_change - })}` -} - -function extractAccountID(tokens: TokenResponse) { - return claim(tokens.id_token) ?? claim(tokens.access_token) -} - -function claim(token: string) { - const part = token.split(".")[1] - if (!part) return - try { - const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims - return ( - claims.chatgpt_account_id ?? - claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? - claims.organizations?.[0]?.id - ) - } catch { - return - } -} - -const successPage = - "Kilo

Authorization successful

You can close this window.

" -const errorPage = (message: string) => - `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index 76c3373706..d602ed0ff9 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,17 +1,17 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const OpenAICompatiblePlugin = PluginV2.define({ - id: PluginV2.ID.make("openai-compatible"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const OpenAICompatiblePlugin = define({ + id: "openai-compatible", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (evt.options.includeUsage !== false) evt.options.includeUsage = true const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible(evt.options as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d58bd784f5..2981dc0b9f 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,29 +1,173 @@ -import { Effect } from "effect" +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@kilocode/plugin/v2/effect/integration" +import { define } from "@kilocode/plugin/v2/effect/plugin" +import { Deferred, Effect } from "effect" +import type { Scope } from "effect" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" +import { KiloOauthCallbackPage } from "../../kilocode/oauth/page" // kilocode_change import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" -import { Integration } from "../../integration" -import { browser, headless } from "./openai-auth" - -export const OpenAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("openai"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service - yield* integrations.update((editor) => { - editor.method.update(browser) - editor.method.update(headless) +import type { PluginInternal } from "../internal" + +const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" +const issuer = "https://auth.openai.com" +const callbackPort = 1455 +const pollingSafetyMargin = 3000 +const browserMethodID = Integration.MethodID.make("chatgpt-browser") +const headlessMethodID = Integration.MethodID.make("chatgpt-headless") + +type Pkce = { + verifier: string + challenge: string +} + +type TokenResponse = { + id_token: string + access_token: string + refresh_token: string + expires_in?: number +} + +type Claims = { + chatgpt_account_id?: string + organizations?: Array<{ id: string }> + "https://api.openai.com/auth"?: { chatgpt_account_id?: string } +} + +const browser = { + integrationID: Integration.ID.make("openai"), + method: { + id: browserMethodID, + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }, + authorize: () => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const code = yield* Deferred.make() + const redirect = `http://localhost:${callbackPort}/auth/callback` + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) + if (url.pathname !== "/auth/callback") { + response.writeHead(404).end("Not found") + return + } + // kilocode_change start - unrelated localhost requests must not terminate the active OAuth attempt + if (url.searchParams.get("state") !== state) { + response.writeHead(400, { "Content-Type": "text/html" }).end(KiloOauthCallbackPage.error("Invalid OAuth state")) + return + } + // kilocode_change end + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(KiloOauthCallbackPage.error(error, { provider: "ChatGPT" })) // kilocode_change + return + } + if (!value) { + const message = "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(KiloOauthCallbackPage.error(message, { provider: "ChatGPT" })) // kilocode_change + return + } + Effect.runFork(Deferred.succeed(code, value)) + response + .writeHead(200, { "Content-Type": "text/html" }) + .end(KiloOauthCallbackPage.success({ provider: "ChatGPT" })) // kilocode_change + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, "localhost", () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + return { + mode: "auto" as const, + url: authorizeURL(redirect, pkce, state), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, redirect, pkce)), + Effect.map((tokens) => credential(browserMethodID, tokens)), + ), + } + }), + refresh: (value) => refresh(browserMethodID, value), +} satisfies IntegrationOAuthMethodRegistration + +const headless = { + integrationID: Integration.ID.make("openai"), + method: { + id: headlessMethodID, + type: "oauth", + label: "ChatGPT Pro/Plus (headless)", + }, + authorize: () => + Effect.gen(function* () { + const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( + `${issuer}/api/accounts/deviceauth/usercode`, + { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ client_id: clientID }), + }, + ) + const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 + return { + mode: "auto" as const, + url: `${issuer}/codex/device`, + instructions: `Enter code: ${device.user_code}`, + callback: Effect.gen(function* () { + while (true) { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(`${issuer}/api/accounts/deviceauth/token`, { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), + signal, + }), + catch: (cause) => cause, + }) + if (response.ok) { + const data = (yield* Effect.promise(() => response.json())) as { + authorization_code: string + code_verifier: string + } + return credential( + headlessMethodID, + yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { + verifier: data.code_verifier, + challenge: "", + }), + ) + } + if (response.status !== 403 && response.status !== 404) { + return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) + } + yield* Effect.sleep(interval + pollingSafetyMargin) + } + }), + } + }), + refresh: (value) => refresh(headlessMethodID, value), +} satisfies IntegrationOAuthMethodRegistration + +export const OpenAIPlugin = define({ + id: "openai", + effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.method.update(browser) + draft.method.update(headless) }) - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/openai") return - const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) - evt.sdk = mod.createOpenAI(evt.options) - }), - "aisdk.language": Effect.fn(function* (evt) { - if (evt.model.providerID !== ProviderV2.ID.openai) return - evt.language = evt.sdk.responses(evt.model.api.id) - }), - "catalog.transform": Effect.fn(function* (evt) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai") continue @@ -35,6 +179,122 @@ export const OpenAIPlugin = PluginV2.define({ }) } }), - } + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/openai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) + evt.sdk = mod.createOpenAI(evt.options) + }), + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + evt.language = evt.sdk.responses(evt.model.api.id) + }), + ) }), -}) +} satisfies PluginInternal.Plugin) + +function headers(contentType: string) { + return { "Content-Type": contentType, "User-Agent": `kilo/${InstallationVersion}` } // kilocode_change +} + +function exchange(code: string, redirect: string, pkce: Pkce) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirect, + client_id: clientID, + code_verifier: pkce.verifier, + }).toString(), + }) +} + +function refresh(methodID: Integration.MethodID, value: Pick) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).toString(), + }).pipe( + Effect.map((tokens) => { + const next = credential(methodID, tokens) + return Credential.OAuth.make({ ...next, metadata: next.metadata ?? value.metadata }) + }), + ) +} + +function request
(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) throw new Error(`Request failed: ${response.status}`) + return response.json() as Promise + }, + catch: (cause) => cause, + }) +} + +function credential(methodID: Integration.MethodID, tokens: TokenResponse) { + const accountID = extractAccountID(tokens) + return Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + metadata: accountID ? { accountID } : undefined, + }) +} + +async function generatePKCE(): Promise { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} + +function authorizeURL(redirect: string, pkce: Pkce, state: string) { + return `${issuer}/oauth/authorize?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + redirect_uri: redirect, + scope: "openid profile email offline_access", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + state, + originator: "kilo", // kilocode_change + })}` +} + +function extractAccountID(tokens: TokenResponse) { + return claim(tokens.id_token) ?? claim(tokens.access_token) +} + +function claim(token: string) { + const part = token.split(".")[1] + if (!part) return + try { + const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claims.organizations?.[0]?.id + ) + } catch { + return + } +} diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 56e71f822d..3653e2cb8b 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,32 +1,44 @@ import { Effect } from "effect" -import { Integration } from "../../integration" -import { PluginV2 } from "../../plugin" +import { define } from "@kilocode/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" -export const OpencodePlugin = PluginV2.define({ - id: PluginV2.ID.make("opencode"), - effect: Effect.gen(function* () { - const integrations = yield* Integration.Service - let hasKey = false - return { - "catalog.transform": Effect.fn(function* (evt) { - const item = evt.provider.get(ProviderV2.ID.opencode) +// kilocode_change start - Kilo keeps only the free-tier catalog gate from upstream's opencode plugin. +// +// Upstream turned this plugin into a full identity + remote-config integration: an OAuth device +// flow against https://console.opencode.ai (client id "opencode-cli"), an "OpenCode Console +// account" login method, an "API key (service account)" method, and a fetch that lets that console +// drive Kilo's provider/model catalog. Kilo routes providers through the Kilo gateway and does not +// offer a competitor's account system as a sign-in option, so none of that is registered here. +// +// What remains is the behavior Kilo actually relies on and shipped before the v1.17.13 merge: +// gate the opencode ("zen") provider's paid models unless the user supplies a key, and mark the +// provider as "public" otherwise. The provider itself still reaches the catalog through models.dev +// sync (see catalog.ts), so this gate stays live. +// +// Do not restore the console auth flow on future merges without a product decision. +export const OpencodePlugin = define({ + id: "opencode", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (catalog) { + const item = catalog.provider.get(ProviderV2.ID.opencode) if (!item) return - const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) - hasKey = Boolean( - process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, - ) - evt.provider.update(item.provider.id, (provider) => { + // Read inside the transform so catalog reloads see current credentials, not a boot-time snapshot. + // A connection (env method, service-account key, ...) counts as credentials, exactly as before the merge. + const connected = (yield* ctx.integration.connection.active("opencode")) !== undefined + const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey) + catalog.provider.update(item.provider.id, (provider) => { if (!hasKey) provider.request.body.apiKey = "public" }) if (hasKey) return for (const model of item.models.values()) { if (!model.cost.some((cost) => cost.input > 0)) continue - evt.model.update(item.provider.id, model.id, (draft) => { + catalog.model.update(item.provider.id, model.id, (draft) => { draft.enabled = false }) } }), - } + ) }), }) +// kilocode_change end diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index a27e4288da..6f671aad5b 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,13 +1,13 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" // kilocode_change -export const OpenRouterPlugin = PluginV2.define({ - id: PluginV2.ID.make("openrouter"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const OpenRouterPlugin = define({ + id: "openrouter", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue @@ -26,11 +26,13 @@ export const OpenRouterPlugin = PluginV2.define({ } } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) evt.sdk = mod.createOpenRouter(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 2415ab7c1a..44c1ef2fc0 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const PerplexityPlugin = PluginV2.define({ - id: PluginV2.ID.make("perplexity"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const PerplexityPlugin = define({ + id: "perplexity", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) evt.sdk = mod.createPerplexity(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 47c8b7eaa8..8c668d8b41 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,15 +1,15 @@ -import { Npm } from "../../npm" -import { Effect, Option } from "effect" +import { Effect } from "effect" import { pathToFileURL } from "url" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" +import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" -export const SapAICorePlugin = PluginV2.define({ - id: PluginV2.ID.make("sap-ai-core"), - effect: Effect.gen(function* () { +export const SapAICorePlugin = define({ + id: "sap-ai-core", + effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - return { - "aisdk.sdk": Effect.fn(function* (evt) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = process.env.AICORE_SERVICE_KEY ?? @@ -18,7 +18,7 @@ export const SapAICorePlugin = PluginV2.define({ const installedPath = evt.package.startsWith("file://") ? evt.package - : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -35,10 +35,12 @@ export const SapAICorePlugin = PluginV2.define({ : {}, ) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 0971f3518d..788ac63eb0 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -64,11 +64,11 @@ export function cortexFetch(upstream: FetchLike = fetch) { } } -export const SnowflakeCortexPlugin = PluginV2.define({ - id: PluginV2.ID.make("snowflake-cortex"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const SnowflakeCortexPlugin = define({ + id: "snowflake-cortex", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = process.env.SNOWFLAKE_CORTEX_TOKEN ?? @@ -84,6 +84,6 @@ export const SnowflakeCortexPlugin = PluginV2.define({ fetch: cortexFetch(upstream) as typeof fetch, } as any) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index b1870f2662..8022e0de66 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const TogetherAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("togetherai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const TogetherAIPlugin = define({ + id: "togetherai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) evt.sdk = mod.createTogetherAI(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 8a3b950245..1a602ffd50 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,15 +1,15 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" -export const VenicePlugin = PluginV2.define({ - id: PluginV2.ID.make("venice"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const VenicePlugin = define({ + id: "venice", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) evt.sdk = mod.createVenice(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 7334f1804f..1f07f702a4 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" // kilocode_change -export const VercelPlugin = PluginV2.define({ - id: PluginV2.ID.make("vercel"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const VercelPlugin = define({ + id: "vercel", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/vercel") continue @@ -17,11 +17,13 @@ export const VercelPlugin = PluginV2.define({ }) } }), - "aisdk.sdk": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) evt.sdk = mod.createVercel(evt.options) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 4e9d53e47a..8145a3480a 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,20 +1,22 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" -export const XAIPlugin = PluginV2.define({ - id: PluginV2.ID.make("xai"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { +export const XAIPlugin = define({ + id: "xai", + effect: Effect.fn(function* (ctx) { + yield* ctx.aisdk.sdk( + Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), - "aisdk.language": Effect.fn(function* (evt) { + ) + yield* ctx.aisdk.language( + Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.api.id) }), - } + ) }), }) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 4b505875f6..327fb1f161 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,12 +1,12 @@ import { Effect } from "effect" -import { PluginV2 } from "../../plugin" +import { define } from "../internal" import { ProviderV2 } from "../../provider" // kilocode_change -export const ZenmuxPlugin = PluginV2.define({ - id: PluginV2.ID.make("zenmux"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { +export const ZenmuxPlugin = define({ + id: "zenmux", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform( + Effect.fn(function* (evt) { for (const item of evt.provider.list()) { if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue @@ -18,6 +18,6 @@ export const ZenmuxPlugin = PluginV2.define({ }) } }), - } + ) }), }) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 620fdc8b9a..ea723dd89d 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,25 +2,22 @@ export * as SkillPlugin from "./skill" +import { define } from "./internal" import { Effect } from "effect" -import { PluginV2 } from "../plugin" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("skill"), - effect: Effect.gen(function* () { - const skill = yield* SkillV2.Service - const transform = yield* skill.transform() - - yield* transform((editor) => { - editor.source( - new SkillV2.EmbeddedSource({ +export const Plugin = define({ + id: "skill", + effect: Effect.fn(function* (ctx) { + yield* ctx.skill.transform((draft) => { + draft.source( + SkillV2.EmbeddedSource.make({ type: "embedded", - skill: new SkillV2.Info({ + skill: SkillV2.Info.make({ name: "customize-opencode", description: "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index f5235cc23f..6d8beba6e0 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -112,7 +112,7 @@ Every field is optional. "type": "local", "command": ["npx", "-y", "@playwright/mcp"], "enabled": true, - "env": {} + "environment": {} }, "remote-thing": { "type": "remote", @@ -371,7 +371,7 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, "type": "local", "command": ["npx", "-y", "@playwright/mcp"], "enabled": true, - "env": { "BROWSER": "chromium" } + "environment": { "BROWSER": "chromium" } }, "github": { "type": "remote", @@ -384,7 +384,8 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, } ``` -`command` is an array of strings. `type` is required. Use `enabled: false` to +`command` is an array of strings. `environment` sets environment variables for +a local MCP server. `type` is required. Use `enabled: false` to disable a server inherited from a parent config. String values such as header tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style `${VAR}` is not substituted. diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts new file mode 100644 index 0000000000..8576c4304b --- /dev/null +++ b/packages/core/src/plugin/variant.ts @@ -0,0 +1,39 @@ +export * as VariantPlugin from "./variant" + +import type { ModelV2Info } from "@kilocode/sdk/v2/types" +import { Effect } from "effect" +import { define } from "./internal" + +export const Plugin = define({ + id: "variant", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform((catalog) => { + for (const record of catalog.provider.list()) { + for (const model of record.models.values()) { + catalog.model.update(model.providerID, model.id, (draft) => { + const generated = generate(draft) + if (generated.length === 0) return + + const explicit = new Map(draft.variants.map((variant) => [variant.id, variant])) + const generatedIDs = new Set(generated.map((variant) => variant.id)) + draft.variants = [ + ...generated.map((variant) => explicit.get(variant.id) ?? variant), + ...draft.variants.filter((variant) => !generatedIDs.has(variant.id)), + ] + }) + } + } + }) + }), +}) + +export function generate(model: ModelV2Info): ModelV2Info["variants"] { + if (model.api.type !== "aisdk" || model.api.package !== "@ai-sdk/openai-compatible") return [] + const ids = `${model.id} ${model.api.id}`.toLowerCase() + if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return [] + return ["high", "max"].map((id) => ({ + id, + headers: {}, + body: { reasoning_effort: id }, + })) +} diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 9b7438f4ff..a2adebb54e 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -1,5 +1,6 @@ export * as Policy from "./policy" +import { makeLocationNode } from "./effect/app-node" import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" import { Wildcard } from "./util/wildcard" import { Location } from "./location" @@ -21,7 +22,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Policy") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, EffectRuntime.gen(function* () { let statements: Info[] = [] @@ -44,3 +45,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index 44418d74c1..16e9118d1f 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -3,16 +3,24 @@ import type { PlatformError } from "effect/PlatformError" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "./cross-spawn-spawner" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { command: Schema.String, exitCode: Schema.optional(Schema.Number), stderr: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect), -}) {} + cause: Schema.optional(Schema.Defect()), +}) { + override get message() { + const detail = + this.stderr?.trim() || (this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause)) + const status = this.exitCode === undefined ? "" : ` (exit ${this.exitCode})` + return `Command failed${status}: ${this.command}${detail ? `: ${detail}` : ""}` + } +} export interface RunOptions { + readonly combineOutput?: boolean readonly maxOutputBytes?: number readonly maxErrorBytes?: number readonly signal?: AbortSignal @@ -30,8 +38,10 @@ export interface RunStreamOptions { export interface RunResult { readonly command: string readonly exitCode: number + readonly output?: Buffer readonly stdout: Buffer readonly stderr: Buffer + readonly outputTruncated?: boolean readonly stdoutTruncated: boolean readonly stderrTruncated: boolean } @@ -126,7 +136,7 @@ export const collectStream = (stream: Stream.Stream, }, ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner @@ -136,6 +146,22 @@ export const layer = Layer.effect( const collect = Effect.scoped( Effect.gen(function* () { const handle = yield* spawner.spawn(command) + if (options?.combineOutput) { + const [output, exitCode] = yield* Effect.all( + [collectStream(handle.all, options.maxOutputBytes), handle.exitCode], + { concurrency: "unbounded" }, + ) + return { + command: description, + exitCode, + output: output.buffer, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + outputTruncated: output.truncated, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies RunResult + } const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStream(handle.stdout, options?.maxOutputBytes), @@ -230,7 +256,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) -export const node = LayerNode.make(layer, [CrossSpawnSpawner.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) export * as AppProcess from "./process" diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 4f94193098..d08222a724 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -6,7 +6,7 @@ import path from "path" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" import { Git } from "./git" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/app-node" import { Hash } from "./util/hash" import { ProjectDirectories } from "./project/directories" import { ProjectSchema } from "./project/schema" @@ -51,7 +51,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ProjectV2") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -70,8 +70,8 @@ export const layer = Layer.effect( ) }) - const remote = Effect.fnUntraced(function* (repo: Git.Repo) { - const origin = yield* git.remote(repo) + const remote = Effect.fnUntraced(function* (repo: Git.Repository) { + const origin = yield* git.remote.get(repo) if (!origin) return undefined const normalized = url(origin) if (!normalized) return undefined @@ -102,22 +102,22 @@ export const layer = Layer.effect( return `${host.toLowerCase()}/${pathname}` } - const root = Effect.fnUntraced(function* (repo: Git.Repo) { - const root = (yield* git.roots(repo))[0] + const root = Effect.fnUntraced(function* (repo: Git.Repository) { + const root = (yield* git.history.rootCommits(repo))[0] return root ? ID.make(root) : undefined }) const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { - const repo = yield* git.find(input) + const repo = yield* git.repo.discover(input) if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } - const previous = yield* cached(repo.store) + const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) return { previous, id: id ?? ID.global, - directory: repo.directory, - vcs: { type: "git" as const, store: repo.store }, + directory: repo.worktree, + vcs: { type: "git" as const, store: repo.commonDirectory }, } }) @@ -129,9 +129,8 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provideMerge(ProjectDirectories.defaultLayer), -) -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node]) +export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, Git.node, ProjectDirectories.node], +}) diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts index 1199964f6c..59466b9901 100644 --- a/packages/core/src/project/copy-strategies.ts +++ b/packages/core/src/project/copy-strategies.ts @@ -1,4 +1,3 @@ -import path from "path" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { Git } from "../git" @@ -8,28 +7,26 @@ export function makeGitWorktreeStrategy(input: { git: Git.Interface canonical: (directory: AbsolutePath) => Effect.Effect }) { - const repo = (sourceDirectory: AbsolutePath) => - ({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo - return { id: StrategyID.make("git_worktree"), create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) { - yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory }) + const repository = yield* input.git.repo.discover(options.sourceDirectory) + if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory }) + yield* input.git.worktree.create({ repository, directory: options.directory }) return { directory: yield* input.canonical(options.directory) } }), remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) { - const found = yield* input.git.find(options.directory) + const found = yield* input.git.repo.discover(options.directory) if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory }) - yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force }) + yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force }) }), list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) { - const found = yield* input.git.find(directory) + const found = yield* input.git.repo.discover(directory) if (!found) return yield* new DirectoryUnavailableError({ directory }) - const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store - const entries = yield* input.git.worktreeList(found) + const entries = yield* input.git.worktree.list(found) return yield* Effect.forEach(entries, (entry) => - input.canonical(entry).pipe( - Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const), + input.canonical(entry.directory).pipe( + Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const), Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)), ), ).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined))) diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 0e3246b3b2..a1a5cc84db 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -5,7 +5,7 @@ import path from "path" import { AbsolutePath } from "../schema" import { FSUtil } from "../fs-util" import { Git } from "../git" -import { LayerNode } from "../effect/layer-node" +import { makeLocationNode } from "../effect/app-node" import { Project } from "../project" import { ProjectDirectories } from "./directories" import { makeGitWorktreeStrategy } from "./copy-strategies" @@ -13,25 +13,16 @@ import { Slug } from "../util/slug" import { EventV2 } from "../event" import { Database } from "../database/database" import { Location } from "../location" -import { PluginBoot } from "../plugin/boot" +import { Event } from "@opencode-ai/schema/project-directories" +import { ProjectCopy } from "@opencode-ai/schema/project-copy" -export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) +export const StrategyID = ProjectCopy.StrategyID export type StrategyID = typeof StrategyID.Type -export const CreateInput = Schema.Struct({ - projectID: Project.ID, - strategy: StrategyID, - sourceDirectory: AbsolutePath, - directory: AbsolutePath, - name: Schema.optional(Schema.String), -}).annotate({ identifier: "ProjectCopy.CreateInput" }) +export const CreateInput = ProjectCopy.CreateInput export type CreateInput = typeof CreateInput.Type -export const RemoveInput = Schema.Struct({ - projectID: Project.ID, - directory: AbsolutePath, - force: Schema.Boolean, -}).annotate({ identifier: "ProjectCopy.RemoveInput" }) +export const RemoveInput = ProjectCopy.RemoveInput export type RemoveInput = typeof RemoveInput.Type export const RefreshInput = Schema.Struct({ @@ -45,9 +36,7 @@ export const RefreshResult = Schema.Struct({ }).annotate({ identifier: "ProjectCopy.RefreshResult" }) export type RefreshResult = typeof RefreshResult.Type -export const Copy = Schema.Struct({ - directory: AbsolutePath, -}).annotate({ identifier: "ProjectCopy.Copy" }) +export const Copy = ProjectCopy.Copy export type Copy = typeof Copy.Type export const ListEntry = Schema.Struct({ @@ -107,12 +96,7 @@ export interface Strategy { readonly list: (directory: AbsolutePath) => Effect.Effect } -export const Event = { - Updated: EventV2.define({ - type: "project.directories.updated", - schema: { projectID: Project.ID }, - }), -} +export { Event } export interface Interface { readonly register: (strategy: Strategy) => Effect.Effect @@ -125,10 +109,8 @@ export class Service extends Context.Service()("@opencode/Pr export const refreshAfterBoot = Effect.gen(function* () { const location = yield* Location.Service - const boot = yield* PluginBoot.Service const copies = yield* Service yield* Effect.gen(function* () { - yield* boot.wait() yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id }) const result = yield* copies.refresh({ projectID: location.project.id }) yield* Effect.logInfo("project copy refresh done", { @@ -143,7 +125,7 @@ export const refreshAfterBoot = Effect.gen(function* () { ) }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -157,7 +139,7 @@ export const layer = Layer.effect( }) const canonical = Effect.fnUntraced(function* (input: AbsolutePath) { - const resolved = AbsolutePath.make(FSUtil.resolve(input)) + const resolved = AbsolutePath.make(yield* fs.resolve(input)) if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input }) return resolved }) @@ -297,4 +279,14 @@ export const layer = Layer.effect( ) export const locationLayer = layer -export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node]) +export const node = makeLocationNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node], +}) + +export const refreshNode = makeLocationNode({ + name: "project-copy-refresh", + layer: Layer.effectDiscard(refreshAfterBoot), + deps: [node, Location.node], +}) diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts index 7c0522107a..6c9ad2e515 100644 --- a/packages/core/src/project/directories.ts +++ b/packages/core/src/project/directories.ts @@ -3,8 +3,8 @@ export * as ProjectDirectories from "./directories" import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" -import { LayerNode } from "../effect/layer-node" -import { AbsolutePath, optionalOmitUndefined } from "../schema" +import { makeGlobalNode } from "../effect/app-node" +import { AbsolutePath, optional } from "../schema" import { ProjectSchema } from "./schema" import { ProjectDirectoryTable } from "./sql" import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" @@ -39,7 +39,7 @@ export type ListInput = typeof ListInput.Type export const ListOutput = Schema.Array( Schema.Struct({ directory: AbsolutePath, - strategy: optionalOmitUndefined(Schema.String), + strategy: optional(Schema.String), }), ).annotate({ identifier: "Project.Directories" }) export type ListOutput = typeof ListOutput.Type @@ -57,7 +57,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ProjectDirectories") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const db = (yield* Database.Service).db @@ -155,5 +155,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make(layer, [Database.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 51d9581cc6..eed359abad 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -1,14 +1,10 @@ export * as ProjectSchema from "./schema" import { Schema } from "effect" -import { AbsolutePath, withStatics } from "../schema" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath } from "../schema" -export const ID = Schema.String.pipe( - Schema.brand("Project.ID"), - withStatics((schema) => ({ - global: schema.make("global"), - })), -) +export const ID = Project.ID export type ID = typeof ID.Type export const Vcs = Schema.Union([ diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 0d6084257b..bbc481c97b 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,69 +1,26 @@ export * as ProviderV2 from "./provider" -import { withStatics } from "./schema" -import { Schema } from "effect" +import { Types } from "effect" +import { Provider } from "@opencode-ai/schema/provider" -export const ID = Schema.String.pipe( - Schema.brand("ProviderV2.ID"), - withStatics((schema) => ({ - // Well-known providers - kilo: schema.make("kilo"), // kilocode_change - Kilo well-known provider id - opencode: schema.make("opencode"), - anthropic: schema.make("anthropic"), - openai: schema.make("openai"), - google: schema.make("google"), - googleVertex: schema.make("google-vertex"), - githubCopilot: schema.make("github-copilot"), - amazonBedrock: schema.make("amazon-bedrock"), - azure: schema.make("azure"), - openrouter: schema.make("openrouter"), - mistral: schema.make("mistral"), - gitlab: schema.make("gitlab"), - })), -) +// kilocode_change - preserve Kilo's well-known routing ID without forking the shared schema +export const ID = Object.assign(Provider.ID, { kilo: Provider.ID.make("kilo") }) export type ID = typeof ID.Type -export const AISDK = Schema.Struct({ - type: Schema.Literal("aisdk"), - package: Schema.String, - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) +export const AISDK = Provider.AISDK -export const Native = Schema.Struct({ - type: Schema.Literal("native"), - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown), -}) +export const Native = Provider.Native -export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) -export type Api = typeof Api.Type +export const Api = Provider.Api +export type Api = Provider.Api +export type MutableApi = T extends Api + ? Omit, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any }) + : never -export const Request = Schema.Struct({ - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.Record(Schema.String, Schema.Any), -}) -export type Request = typeof Request.Type +export const Request = Provider.Request +export type Request = Provider.Request -export class Info extends Schema.Class("ProviderV2.Info")({ - id: ID, - name: Schema.String, - disabled: Schema.Boolean.pipe(Schema.optional), - api: Api, - request: Request, -}) { - static empty(providerID: ID): Info { - return new Info({ - id: providerID, - name: providerID, - api: { - type: "native", - settings: {}, - }, - request: { - headers: {}, - body: {}, - }, - }) - } -} +export const Info = Provider.Info +export type Info = Provider.Info + +export type MutableInfo = Omit, "api"> & { api: MutableApi } diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 0157c0d8d1..25741eacf5 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -1,17 +1,18 @@ export * as Pty from "./pty" -import type { Disp, Proc } from "#pty" +import { makeGlobalNode, makeLocationNode } from "./effect/app-node" // kilocode_change import { Context, Effect, Layer, Schema, Types } from "effect" +import { Pty } from "@opencode-ai/schema/pty" import { Config } from "./config" import { EventV2 } from "./event" import { Location } from "./location" -import { NonNegativeInt, PositiveInt } from "./schema" import { PtyID } from "./pty/schema" import { SessionSchema } from "./session/schema" // kilocode_change import { Shell } from "./shell" import { lazy } from "./util/lazy" import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change -import { KiloPtyTermination } from "./kilocode/pty/termination" // kilocode_change +import * as KiloPtyRegistry from "./kilocode/pty/registry" // kilocode_change +import type { Active, Subscriber } from "./kilocode/pty/registry" // kilocode_change const BUFFER_LIMIT = 1024 * 1024 * 2 // Exited sessions stay observable (status, exit code, retained output) until removed explicitly. @@ -19,65 +20,26 @@ const BUFFER_LIMIT = 1024 * 1024 * 2 const EXITED_LIMIT = 25 const pty = lazy(() => import("#pty")) -type Subscriber = { - readonly onData: (chunk: string) => void - readonly onEnd: (event: { exitCode?: number }) => void - active: boolean - detached: boolean - pending: string[] - end?: { exitCode?: number } -} - -type Active = { - info: Info - process: Proc - buffer: string - bufferCursor: number - cursor: number - subscribers: Map - listeners: Disp[] - stopping: boolean // kilocode_change -} - -export const Info = Schema.Struct({ - id: PtyID, - title: Schema.String, - command: Schema.String, - args: Schema.Array(Schema.String), - cwd: Schema.String, - status: Schema.Literals(["running", "exited"]), - // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time. - pid: NonNegativeInt, - // Present once status is "exited". - exitCode: Schema.optional(NonNegativeInt), - sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change -}).annotate({ identifier: "Pty" }) - +// kilocode_change - the Kilo `sessionID` field now lives on the canonical shared schema (see +// packages/schema/src/pty.ts) so the generated SDK carries it; reuse that schema verbatim here. +export const Info = Pty.Info export type Info = Types.DeepMutable -export const CreateInput = Schema.Struct({ - command: Schema.optional(Schema.String), - args: Schema.optional(Schema.Array(Schema.String)), - cwd: Schema.optional(Schema.String), - title: Schema.optional(Schema.String), - env: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) +export const CreateInput = Pty.CreateInput export type CreateInput = Types.DeepMutable export const UpdateInput = Schema.Struct({ - title: Schema.optional(Schema.String), + ...Pty.UpdateInput.fields, sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change - size: Schema.optional( - Schema.Struct({ - rows: PositiveInt, - cols: PositiveInt, - }), - ), }) export type UpdateInput = Types.DeepMutable +// kilocode_change - the shared events already carry Kilo's extended Info (see packages/schema/src/pty.ts), +// so reuse them verbatim instead of redefining pty.created/pty.updated here. +export const Event = Pty.Event + export type AttachInput = { // Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer. readonly cursor?: number @@ -108,26 +70,20 @@ export class ExitedError extends Schema.TaggedErrorClass()("Pty.Exi ptyID: PtyID, }) {} -export const Event = { - Created: EventV2.define({ type: "pty.created", schema: { info: Info } }), - Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }), - Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }), - Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }), -} - export interface Interface { readonly list: () => Effect.Effect readonly get: (id: PtyID) => Effect.Effect readonly create: (input: CreateInput) => Effect.Effect readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect readonly remove: (id: PtyID) => Effect.Effect + readonly removeDirectory: (location: Location.Ref) => Effect.Effect // kilocode_change readonly write: (id: PtyID, data: string) => Effect.Effect readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Pty") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -135,8 +91,7 @@ export const layer = Layer.effect( const config = yield* Config.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) - const sessions = new Map() - const exitOrder: PtyID[] = [] + const sessions = KiloPtyRegistry.sessions // kilocode_change function notifyEnd(session: Active, event: { exitCode?: number }) { for (const subscriber of session.subscribers.values()) { @@ -146,50 +101,36 @@ export const layer = Layer.effect( } try { subscriber.onEnd(event) - } catch {} + } catch (error) { + Effect.runSync(Effect.logDebug("PTY subscriber end callback failed", { id: session.info.id, error })) + } } session.subscribers.clear() } - // kilocode_change start - terminate the complete PTY tree before reporting removal. - async function teardown(session: Active) { - session.stopping = true - if (session.info.status === "running") await KiloPtyTermination.terminate(session.process) - for (const listener of session.listeners) listener.dispose() - session.listeners.length = 0 - notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {}) - } - // kilocode_change end - - yield* Effect.addFinalizer( - () => - // kilocode_change start - wait for process-tree termination during async service teardown. - Effect.promise(async () => { - await Promise.all(Array.from(sessions.values()).map(teardown)) - sessions.clear() - exitOrder.length = 0 - }), - // kilocode_change end - ) - const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) { const session = sessions.get(id) - if (!session) return yield* new NotFoundError({ ptyID: id }) + const owner = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }) + if (!session || !KiloPtyRegistry.sameLocation(session.location, owner)) + return yield* new NotFoundError({ ptyID: id }) return session }) const removeSession = Effect.fnUntraced(function* (id: PtyID) { // kilocode_change start - removal and its deleted event are one uninterruptible lifecycle transition. - yield* Effect.gen(function* () { - const session = sessions.get(id) - if (!session) return - yield* Effect.logInfo("removing session", { id }) - yield* Effect.promise(() => teardown(session)) - sessions.delete(id) - const index = exitOrder.indexOf(id) - if (index !== -1) exitOrder.splice(index, 1) - yield* events.publish(Event.Deleted, { id: session.info.id }) - }).pipe(Effect.uninterruptible) + const session = sessions.get(id) + if (!session || !KiloPtyRegistry.claimRemoval(id)) return + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* Effect.logInfo("removing session", { id }) + yield* Effect.promise(() => KiloPtyRegistry.teardown(session)) + sessions.delete(id) + KiloPtyRegistry.removeExited(session) + yield* events + .publish(Event.Deleted, { id: session.info.id }, { location: session.location }) + .pipe(Effect.catch((error) => Effect.logWarning("failed to publish PTY deleted event", { id, error }))) + }).pipe(Effect.ensuring(Effect.sync(() => KiloPtyRegistry.releaseRemoval(id)))), + ) // kilocode_change end }) @@ -198,15 +139,27 @@ export const layer = Layer.effect( yield* removeSession(id) }) + const removeDirectory = Effect.fn("Pty.removeDirectory")(function* (target: Location.Ref) { + const owned = Array.from(sessions.values()).filter( + (session) => + KiloPtyRegistry.sameDirectory(session.location.directory, target.directory) && + session.location.workspaceID === target.workspaceID, + ) + yield* Effect.forEach(owned, (session) => removeSession(session.info.id), { concurrency: 4, discard: true }) + }) + const list = Effect.fn("Pty.list")(function* () { - return Array.from(sessions.values()).map((session) => session.info) + const owner = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }) + return Array.from(sessions.values()) + .filter((session) => KiloPtyRegistry.sameLocation(session.location, owner)) + .map((session) => session.info) }) const get = Effect.fn("Pty.get")(function* (id: PtyID) { return (yield* requireSession(id)).info }) - const create = Effect.fn("Pty.create")(function* (input: CreateInput) { + const createBody = Effect.fn("Pty.createBody")(function* (input: CreateInput, owner: Location.Ref) { const id = PtyID.ascending() // kilocode_change start - resolve Kilo self-commands to the real binary, arguments, and project cwd const resolved = KiloPtySelfCommand.resolve({ @@ -239,7 +192,17 @@ export const layer = Layer.effect( } yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd }) const { spawn } = yield* Effect.promise(() => pty()) - const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env })) + // kilocode_change start - spawn with initial terminal dimensions + const proc = yield* Effect.sync(() => + spawn(command, args, { + name: "xterm-256color", + cwd, + env, + cols: input.size?.cols, + rows: input.size?.rows, + }), + ) + // kilocode_change end const info: Info = { id, title: input.title || `Terminal ${id.slice(-4)}`, @@ -251,6 +214,7 @@ export const layer = Layer.effect( } const session: Active = { info, + location: owner, // kilocode_change process: proc, buffer: "", bufferCursor: 0, @@ -258,6 +222,7 @@ export const layer = Layer.effect( subscribers: new Map(), listeners: [], stopping: false, // kilocode_change + terminated: false, } sessions.set(id, session) session.listeners.push( @@ -281,28 +246,45 @@ export const layer = Layer.effect( session.bufferCursor += excess }), proc.onExit(({ exitCode }) => { - if (session.info.status === "exited" || session.stopping) return // kilocode_change + if (session.info.status === "exited") return + if (session.stopping) { + session.info.status = "exited" + session.info.exitCode = exitCode + return + } session.info.status = "exited" session.info.exitCode = exitCode notifyEnd(session, { exitCode }) - exitOrder.push(id) + KiloPtyRegistry.markExited(session) runFork( Effect.gen(function* () { yield* Effect.logInfo("session exited", { id, exitCode }) - yield* events.publish(Event.Exited, { id, exitCode }) - while (exitOrder.length > EXITED_LIMIT) { - const oldest = exitOrder[0] + yield* events + .publish(Event.Exited, { id, exitCode }, { location: session.location }) + .pipe(Effect.catch((error) => Effect.logWarning("failed to publish PTY exited event", { id, error }))) + while (KiloPtyRegistry.exitedCount(session.location) > EXITED_LIMIT) { + const oldest = KiloPtyRegistry.oldestExited(session.location) if (!oldest) break yield* removeSession(oldest) + if (sessions.has(oldest)) break + KiloPtyRegistry.removeExitedID(session.location, oldest) } }), ) }), ) - yield* events.publish(Event.Created, { info }) + yield* events + .publish(Event.Created, { info }, { location: session.location }) + .pipe(Effect.catch((error) => Effect.logWarning("failed to publish PTY created event", { id, error }))) return info }) + const create = Effect.fn("Pty.create")(function* (input: CreateInput) { + const owner = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }) + const release = KiloPtyRegistry.beginCreate(owner) + return yield* createBody(input, owner).pipe(Effect.ensuring(Effect.sync(release))) + }) + const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) { const session = yield* requireSession(id) if (input.title) session.info.title = input.title @@ -310,7 +292,7 @@ export const layer = Layer.effect( if ("sessionID" in input) session.info.sessionID = input.sessionID ?? undefined // kilocode_change end if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows) - yield* events.publish(Event.Updated, { info: session.info }) + yield* events.publish(Event.Updated, { info: session.info }, { location: session.location }) return session.info }) @@ -373,8 +355,26 @@ export const layer = Layer.effect( } }) - return Service.of({ list, get, create, update, remove, write, attach }) + return Service.of({ list, get, create, update, remove, removeDirectory, write, attach }) // kilocode_change }), ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) + +export const shutdown = KiloPtyRegistry.shutdown // kilocode_change +export const terminateDirectory = KiloPtyRegistry.terminateDirectory // kilocode_change + +export const shutdownNode = makeGlobalNode({ + name: "pty-shutdown", + layer: Layer.effectDiscard( + Effect.gen(function* () { + const release = yield* Effect.promise(() => KiloPtyRegistry.acquireOwner()) + yield* Effect.addFinalizer(() => + Effect.promise(release).pipe(Effect.catch((error) => Effect.logError("failed to shut down PTYs", { error }))), + ) + }), + ), + deps: [], +}) // kilocode_change + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] }) diff --git a/packages/core/src/pty/driver.ts b/packages/core/src/pty/driver.ts index 4fab47ff97..17b70b94ce 100644 --- a/packages/core/src/pty/driver.ts +++ b/packages/core/src/pty/driver.ts @@ -1,2 +1,2 @@ -// kilocode_change - expose the conditional PTY driver to Kilo's legacy interactive terminal +// kilocode_change export * from "#pty" diff --git a/packages/core/src/pty/pty.node.ts b/packages/core/src/pty/pty.node.ts index 76f415f4cd..54691d1e84 100644 --- a/packages/core/src/pty/pty.node.ts +++ b/packages/core/src/pty/pty.node.ts @@ -4,7 +4,10 @@ import type { Opts, Proc } from "./pty" export type { Disp, Exit, Opts, Proc } from "./pty" export function spawn(file: string, args: string[], opts: Opts): Proc { - const proc = pty.spawn(file, args, opts) + const proc = pty.spawn(file, args, { + ...opts, + ...(process.platform === "win32" ? { useConptyDll: true } : {}), + }) return { pid: proc.pid, onData(listener) { diff --git a/packages/core/src/pty/schema.ts b/packages/core/src/pty/schema.ts index b8c973862f..ab0c40521b 100644 --- a/packages/core/src/pty/schema.ts +++ b/packages/core/src/pty/schema.ts @@ -1,13 +1 @@ -import { Schema } from "effect" -import { Identifier } from "../id/id" -import { withStatics } from "../schema" - -const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) - -export type PtyID = typeof ptyIdSchema.Type - -export const PtyID = ptyIdSchema.pipe( - withStatics((schema: typeof ptyIdSchema) => ({ - ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), - })), -) +export { ID as PtyID } from "@opencode-ai/schema/pty" diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index c625390be0..07838b1415 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -1,18 +1,15 @@ export * as PtyTicket from "./ticket" import { WorkspaceV2 } from "../workspace" -import { PositiveInt } from "../schema" +import { PtyTicket } from "@opencode-ai/schema/pty-ticket" import { PtyID } from "./schema" -import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" -import { LayerNode } from "../effect/layer-node" +import { Cache, Context, Duration, Effect, Layer } from "effect" +import { makeGlobalNode } from "../effect/app-node" const DEFAULT_TTL = Duration.seconds(60) const CAPACITY = 10_000 -export const ConnectToken = Schema.Struct({ - ticket: Schema.String, - expires_in: PositiveInt, -}) +export const ConnectToken = PtyTicket.ConnectToken export type Scope = { readonly ptyID: PtyID @@ -54,7 +51,6 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) => }) }) -export const layer = Layer.effect(Service, make()) +const layer = Layer.effect(Service, make()) -export const defaultLayer = layer -export const node = LayerNode.make(layer, []) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts new file mode 100644 index 0000000000..11b84f7905 --- /dev/null +++ b/packages/core/src/public-event-manifest.ts @@ -0,0 +1,7 @@ +export * as PublicEventManifest from "./public-event-manifest" + +import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" + +export const Definitions = EventManifest.ServerDefinitions +export const Latest = Event.latest(Definitions) diff --git a/packages/core/src/public/agent.ts b/packages/core/src/public/agent.ts deleted file mode 100644 index ade2096f89..0000000000 --- a/packages/core/src/public/agent.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Agent from "./agent" - -import { AgentV2 } from "../agent" - -export const ID = AgentV2.ID -export type ID = AgentV2.ID diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts deleted file mode 100644 index 2229039b9a..0000000000 --- a/packages/core/src/public/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */ -export { Agent } from "./agent" -export { Model } from "./model" -export { OpenCode } from "./opencode" -export { Session } from "./session" -export { Tool } from "./tool" -export { Location } from "./location" -export { Prompt } from "../session/prompt" -export { AbsolutePath } from "../schema" diff --git a/packages/core/src/public/location.ts b/packages/core/src/public/location.ts deleted file mode 100644 index aab15181d1..0000000000 --- a/packages/core/src/public/location.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as Location from "./location" - -import { Location } from "../location" - -export const Ref = Location.Ref -export type Ref = Location.Ref diff --git a/packages/core/src/public/model.ts b/packages/core/src/public/model.ts deleted file mode 100644 index ab92b8dfe7..0000000000 --- a/packages/core/src/public/model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * as Model from "./model" - -import { ModelV2 } from "../model" - -export const ID = ModelV2.ID -export type ID = ModelV2.ID - -export const Ref = ModelV2.Ref -export type Ref = ModelV2.Ref diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts deleted file mode 100644 index 7388705d8c..0000000000 --- a/packages/core/src/public/opencode.ts +++ /dev/null @@ -1,129 +0,0 @@ -export * as OpenCode from "./opencode" - -import { Context, Effect, Layer } from "effect" -import { Catalog } from "../catalog" -import { Database } from "../database/database" -import { EventV2 } from "../event" -import { LocationServiceMap } from "../location-layer" -import { PluginBoot } from "../plugin/boot" -import { ProjectV2 } from "../project" -import { SessionV2 } from "../session" -import * as SessionExecutionLocal from "../session/execution/local" -import { SessionProjector } from "../session/projector" -import { SessionStore } from "../session/store" -import { ApplicationTools } from "../tool/application-tools" -import { Session } from "./session" -import { Tool } from "./tool" - -export interface Interface { - readonly sessions: Session.Interface - readonly tools: Tool.Interface -} - -/** Intentional public native API for Effect applications embedding OpenCode. */ -export class Service extends Context.Service()("@opencode/public/OpenCode") {} - -class SessionModelValidation extends Context.Service< - SessionModelValidation, - { - readonly validate: ( - input: Session.SwitchModelInput & { readonly location: Session.Info["location"] }, - ) => Effect.Effect - } ->()("@opencode/public/OpenCode/SessionModelValidation") {} - -const ApplicationToolsLayer = ApplicationTools.layer -const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer)) -const SessionModelValidationLayer = Layer.effect( - SessionModelValidation, - Effect.gen(function* () { - const locations = yield* LocationServiceMap - return SessionModelValidation.of({ - validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) { - yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() - const catalog = yield* Catalog.Service - const model = (yield* catalog.model.available()).find( - (model) => model.providerID === input.model.providerID && model.id === input.model.id, - ) - if (!model) - return yield* new Session.ModelUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - }) - if ( - input.model.variant !== undefined && - input.model.variant !== "default" && - !model.variants.some((variant) => variant.id === input.model.variant) - ) - return yield* new Session.VariantUnavailableError({ - providerID: input.model.providerID, - modelID: input.model.id, - variant: input.model.variant, - }) - }).pipe(Effect.provide(locations.get(input.location))) - }), - }) - }), -) - -const SessionsLayer = Layer.merge( - SessionV2.layer.pipe( - Layer.provide(SessionProjector.layer), - Layer.provide(SessionExecutionLocal.layer), - Layer.provide(SessionStore.layer), - Layer.provide(EventV2.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, - ), - SessionModelValidationLayer, -).pipe(Layer.provide(LocationServicesLayer)) -// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sessions = yield* SessionV2.Service - const tools = yield* ApplicationTools.Service - const validation = yield* SessionModelValidation - return Service.of({ - tools: { register: tools.register }, - sessions: { - create: (input) => - sessions.create({ - id: input.id, - agent: input.agent, - model: input.model, - location: input.location, - }), - get: sessions.get, - list: sessions.list, - switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { - const session = yield* sessions.get(input.sessionID) - yield* validation.validate({ ...input, location: session.location }) - yield* sessions.switchModel(input) - }), - interrupt: sessions.interrupt, - prompt: (input) => - sessions.prompt({ - id: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, - }), - messages: (input) => - sessions.messages({ - sessionID: input.sessionID, - limit: input.limit, - order: input.order, - cursor: input.cursor, - }), - message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), - context: sessions.context, - events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), - }, - }) - }), -).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer))) - -// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts deleted file mode 100644 index 6c61aff3b6..0000000000 --- a/packages/core/src/public/session.ts +++ /dev/null @@ -1,119 +0,0 @@ -export * as Session from "./session" - -import { Effect, Schema, Stream } from "effect" -import { EventV2 } from "../event" -import { ModelV2 } from "../model" -import { SessionV2 } from "../session" -import { MessageDecodeError } from "../session/error" -import { SessionEvent } from "../session/event" -import { SessionInput } from "../session/input" -import { SessionMessage } from "../session/message" -import { Prompt } from "../session/prompt" -import { Agent } from "./agent" -import { Location } from "./location" -import { Model } from "./model" - -export const ID = SessionV2.ID -export type ID = SessionV2.ID - -export const Info = SessionV2.Info -export type Info = SessionV2.Info - -export const MessageID = SessionMessage.ID -export type MessageID = SessionMessage.ID - -export const Message = SessionMessage.Message -export type Message = SessionMessage.Message - -export const Admission = SessionInput.Admitted -export type Admission = SessionInput.Admitted - -export const Delivery = SessionInput.Delivery -export type Delivery = SessionInput.Delivery - -export const ListInput = SessionV2.ListInput -export type ListInput = SessionV2.ListInput - -export const EventCursor = EventV2.Cursor -export type EventCursor = EventV2.Cursor -export type Event = EventV2.CursorEvent - -export const NotFoundError = SessionV2.NotFoundError -export type NotFoundError = SessionV2.NotFoundError - -export const PromptConflictError = SessionV2.PromptConflictError -export type PromptConflictError = SessionV2.PromptConflictError - -export class ModelUnavailableError extends Schema.TaggedErrorClass()( - "Session.ModelUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - }, -) {} - -export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "Session.VariantUnavailableError", - { - providerID: Model.Ref.fields.providerID, - modelID: Model.Ref.fields.id, - variant: ModelV2.VariantID, - }, -) {} - -export { MessageDecodeError } - -export interface CreateInput { - readonly id?: ID - readonly agent?: Agent.ID - readonly model?: Model.Ref - readonly location: Location.Ref -} - -export interface PromptInput { - readonly id?: MessageID - readonly sessionID: ID - readonly prompt: Prompt - readonly delivery?: Delivery -} - -export interface SwitchModelInput { - readonly sessionID: ID - readonly model: Model.Ref -} - -export interface MessagesInput { - readonly sessionID: ID - readonly limit?: number - readonly order?: "asc" | "desc" - readonly cursor?: { - readonly id: MessageID - readonly direction: "previous" | "next" - } -} - -export interface MessageInput { - readonly sessionID: ID - readonly messageID: MessageID -} - -export interface EventsInput { - readonly sessionID: ID - readonly after?: EventCursor -} - -export interface Interface { - readonly create: (input: CreateInput) => Effect.Effect - readonly get: (sessionID: ID) => Effect.Effect - readonly list: (input?: ListInput) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: ( - input: SwitchModelInput, - ) => Effect.Effect - /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */ - readonly interrupt: (sessionID: ID) => Effect.Effect - readonly messages: (input: MessagesInput) => Effect.Effect - readonly message: (input: MessageInput) => Effect.Effect - readonly context: (sessionID: ID) => Effect.Effect - readonly events: (input: EventsInput) => Stream.Stream -} diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts deleted file mode 100644 index 97b436fed9..0000000000 --- a/packages/core/src/public/tool.ts +++ /dev/null @@ -1,17 +0,0 @@ -export * as Tool from "./tool" - -import { Effect, Scope } from "effect" -import type { AnyTool, RegistrationError } from "../tool/tool" - -export { Failure, RegistrationError, make } from "../tool/tool" -export type { AnyTool, Content, Context, Definition } from "../tool/tool" - -export interface Interface { - /** - * Register same-process tools on this OpenCode instance for the current Scope. - * Location tools with the same name take precedence where they are installed. - * Closing the Scope removes the tools immediately, so calls that have not - * started settling may fail because the tool is no longer available. - */ - readonly register: (tools: Readonly>) => Effect.Effect -} diff --git a/packages/core/src/question.ts b/packages/core/src/question.ts index a489fb9aac..79e0ea5e03 100644 --- a/packages/core/src/question.ts +++ b/packages/core/src/question.ts @@ -1,83 +1,36 @@ export * as QuestionV2 from "./question" +import { makeLocationNode } from "./effect/app-node" import { Context, Deferred, Effect, Layer, Schema } from "effect" +import { Question } from "@opencode-ai/schema/question" import { EventV2 } from "./event" -import { Identifier } from "./id/id" -import { withStatics } from "./schema" import { SessionSchema } from "./session/schema" -export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe( - Schema.brand("QuestionV2.ID"), - withStatics((schema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("question", id)) })), -) +export const ID = Question.ID export type ID = typeof ID.Type -export const Option = Schema.Struct({ - label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }), - description: Schema.String.annotate({ description: "Explanation of choice" }), -}).annotate({ identifier: "QuestionV2.Option" }) +export const Option = Question.Option export type Option = typeof Option.Type -const base = { - question: Schema.String.annotate({ description: "Complete question" }), - header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), - options: Schema.Array(Option).annotate({ description: "Available choices" }), - multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }), -} - -export const Info = Schema.Struct({ - ...base, - custom: Schema.Boolean.pipe(Schema.optional).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}).annotate({ identifier: "QuestionV2.Info" }) +export const Info = Question.Info export type Info = typeof Info.Type -export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" }) +export const Prompt = Question.Prompt export type Prompt = typeof Prompt.Type -export const Tool = Schema.Struct({ - messageID: Schema.String, - callID: Schema.String, -}).annotate({ identifier: "QuestionV2.Tool" }) +export const Tool = Question.Tool export type Tool = typeof Tool.Type -export const Request = Schema.Struct({ - id: ID, - sessionID: SessionSchema.ID, - questions: Schema.Array(Info).annotate({ description: "Questions to ask" }), - tool: Tool.pipe(Schema.optional), -}).annotate({ identifier: "QuestionV2.Request" }) +export const Request = Question.Request export type Request = typeof Request.Type -export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" }) +export const Answer = Question.Answer export type Answer = typeof Answer.Type -export const Reply = Schema.Struct({ - answers: Schema.Array(Answer).annotate({ - description: "User answers in order of questions (each answer is an array of selected labels)", - }), -}).annotate({ identifier: "QuestionV2.Reply" }) +export const Reply = Question.Reply export type Reply = typeof Reply.Type -export const Event = { - Asked: EventV2.define({ type: "question.v2.asked", schema: Request.fields }), - Replied: EventV2.define({ - type: "question.v2.replied", - schema: { - sessionID: SessionSchema.ID, - requestID: ID, - answers: Schema.Array(Answer), - }, - }), - Rejected: EventV2.define({ - type: "question.v2.rejected", - schema: { - sessionID: SessionSchema.ID, - requestID: ID, - }, - }), -} +export const Event = Question.Event export class RejectedError extends Schema.TaggedErrorClass()("QuestionV2.RejectedError", {}) { override get message() { @@ -119,7 +72,7 @@ interface Pending { * layer once per embedded Location so replies cannot settle another Location's * deferred request. */ -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -196,3 +149,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index a7890243c5..ddf53f751d 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -1,7 +1,8 @@ export * as Reference from "./reference" -import { Context, Effect, Layer, Schema, Scope } from "effect" -import { castDraft } from "immer" +import { makeLocationNode } from "./effect/app-node" +import { Context, Effect, Layer, Scope, Types } from "effect" +import { Reference } from "@opencode-ai/schema/reference" import { Global } from "./global" import { EventV2 } from "./event" import { Repository } from "./repository" @@ -9,55 +10,38 @@ import { RepositoryCache } from "./repository-cache" import { AbsolutePath } from "./schema" import { State } from "./state" -export class LocalSource extends Schema.Class("Reference.LocalSource")({ - type: Schema.Literal("local"), - path: AbsolutePath, - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), -}) {} +export const LocalSource = Reference.LocalSource +export type LocalSource = Reference.LocalSource -export class GitSource extends Schema.Class("Reference.GitSource")({ - type: Schema.Literal("git"), - repository: Schema.String, - branch: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), -}) {} +export const GitSource = Reference.GitSource +export type GitSource = Reference.GitSource -export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type")) -export type Source = typeof Source.Type +export const Source = Reference.Source +export type Source = Reference.Source -export const Event = { - Updated: EventV2.define({ type: "reference.updated", schema: {} }), -} +export const Event = Reference.Event -export class Info extends Schema.Class("Reference.Info")({ - name: Schema.String, - path: AbsolutePath, - description: Schema.String.pipe(Schema.optional), - hidden: Schema.Boolean.pipe(Schema.optional), - source: Source, -}) {} +export const Info = Reference.Info +export type Info = Reference.Info type Data = { - sources: Map + sources: Map> } -type Editor = { +type Draft = { add(name: string, source: Source): void remove(name: string): void list(): readonly [string, Source][] } -export interface Interface { - readonly transform: State.Interface["transform"] +export interface Interface extends State.Transformable { readonly replace: (sources: readonly (readonly [string, Source])[]) => Effect.Effect // kilocode_change readonly list: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Reference") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const global = yield* Global.Service @@ -65,26 +49,25 @@ export const layer = Layer.effect( const cache = yield* RepositoryCache.Service const scope = yield* Scope.Scope const materialized = new Map() - const state = State.create({ + const state = State.create({ initial: () => ({ sources: new Map() }), - editor: (draft) => ({ - add: (name, source) => draft.sources.set(name, castDraft(source)), + draft: (draft) => ({ + add: (name, source) => draft.sources.set(name, source as Types.DeepMutable), remove: (name) => draft.sources.delete(name), list: () => Array.from(draft.sources.entries()) as [string, Source][], }), - finalize: (editor) => + finalize: (draft) => Effect.gen(function* () { materialized.clear() - const seen = new Map() - for (const [name, source] of editor.list()) { + for (const [name, source] of draft.list()) { if (source.type === "local") { materialized.set( name, new Info({ name, path: source.path, - description: source.description, - hidden: source.hidden, + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), source, }), ) @@ -99,16 +82,13 @@ export const layer = Layer.effect( continue } } - const target = Repository.cachePath(global.repos, repository) - if (seen.has(target) && seen.get(target) !== source.branch) continue - seen.set(target, source.branch) materialized.set( name, new Info({ name, - path: AbsolutePath.make(target), - description: source.description, - hidden: source.hidden, + path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)), + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.hidden === undefined ? {} : { hidden: source.hidden }), source, }), ) @@ -138,11 +118,18 @@ export const layer = Layer.effect( }), ), // kilocode_change end + reload: state.reload, list: Effect.fn("Reference.list")(function* () { - return Array.from(materialized.values()) - }), + return Array.from(materialized.values()) + }), }) }), ) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Global.node, EventV2.node, RepositoryCache.node], +}) diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index f567264768..25566e2f2f 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -1,7 +1,7 @@ export * as ReferenceGuidance from "./guidance" +import { makeLocationNode } from "../effect/app-node" import { Context, Effect, Layer, Schema } from "effect" -import { PluginBoot } from "../plugin/boot" import { Reference } from "../reference" import { SystemContext } from "../system-context/index" @@ -31,15 +31,13 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/ReferenceGuidance") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const references = yield* Reference.Service return Service.of({ load: Effect.fn("ReferenceGuidance.load")(function* () { - yield* boot.wait() const available = (yield* references.list()) .filter((reference) => reference.description !== undefined) .map((reference) => ({ @@ -67,3 +65,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] }) diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index 894dc38faa..baa9a93a08 100644 --- a/packages/core/src/repository-cache.ts +++ b/packages/core/src/repository-cache.ts @@ -1,9 +1,18 @@ +/** + * Local tracking checkouts for remote Git references, one per remote and + * branch. Each checkout permanently tracks a single ref: the requested branch + * when the cache key has one, otherwise the remote default branch. Content + * follows "newest wins": refresh fetches and hard-resets, so readers may + * observe the checkout move underneath them. + */ import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" import { Git } from "./git" import { Global } from "./global" import { Repository } from "./repository" +import { AbsolutePath } from "./schema" +import { makeGlobalNode } from "./effect/app-node" import { EffectFlock } from "./util/effect-flock" export type Result = { @@ -119,7 +128,7 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi }) }) -export const layer: Layer.Layer = +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { @@ -133,7 +142,7 @@ export const layer: Layer.Layer new CloneFailedError({ repository, message: errorMessage(error) })), - ) - if (result.exitCode !== 0) { - return yield* new CloneFailedError({ - repository, - message: resultMessage(result, `Failed to clone ${repository}`), + yield* git.repo + .clone({ + remote: input.reference.remote, + directory: AbsolutePath.make(localPath), + branch: input.branch, }) - } + .pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message }))) } if (status === "refreshed") { - const fetch = yield* git - .fetch(localPath) - .pipe( - Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })), - ) - if (fetch.exitCode !== 0) { - return yield* new FetchFailedError({ - repository, - message: resultMessage(fetch, `Failed to refresh ${repository}`), - }) - } + if (!existing) + return yield* new FetchFailedError({ repository, message: "Repository is unavailable" }) + yield* git.sync + .fetchRemotes(existing) + .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message }))) if (input.branch) { - const requestedBranch = input.branch - const fetchBranch = yield* git - .fetchBranch(localPath, requestedBranch) + yield* git.sync + .fetchBranch(existing, { branch: input.branch }) + .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message }))) + } + + // Checking out the tracked ref before resetting keeps the + // checkout self-healing even if it was left on another + // branch. + const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing)) + if (branch) { + yield* git.sync + .checkoutRemoteBranch(existing, { branch }) .pipe( - Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })), + Effect.mapError( + (error) => new CheckoutFailedError({ repository, branch, message: error.message }), + ), ) - if (fetchBranch.exitCode !== 0) { - return yield* new FetchFailedError({ - repository, - message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`), - }) - } - - const checkout = yield* git.checkout(localPath, requestedBranch).pipe( - Effect.mapError( - (error) => - new CheckoutFailedError({ - repository, - branch: requestedBranch, - message: errorMessage(error), - }), - ), - ) - if (checkout.exitCode !== 0) { - return yield* new CheckoutFailedError({ - repository, - branch: requestedBranch, - message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`), - }) - } } - const reset = yield* git - .reset(localPath, yield* resetTarget(git, localPath, input.branch)) - .pipe( - Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })), - ) - if (reset.exitCode !== 0) { - return yield* new ResetFailedError({ - repository, - message: resultMessage(reset, `Failed to reset ${repository}`), - }) - } + const target = branch ?? (yield* git.history.branch(existing)) + yield* git.sync + .resetHard(existing, target ? `origin/${target}` : "HEAD") + .pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message }))) } + const checkout = yield* git.repo.discover(AbsolutePath.make(localPath)) + return { repository, host: input.reference.host, remote: input.reference.remote, localPath, status, - head: yield* git.head(localPath), - branch: yield* git.branch(localPath), + head: checkout ? yield* git.history.head(checkout) : undefined, + branch: checkout ? yield* git.history.branch(checkout) : undefined, } satisfies Result }), `repository-cache:${localPath}`, @@ -252,18 +245,11 @@ export const layer: Layer.Layer = layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Global.defaultLayer), -) - -function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { - if (!input.reuse) return "cloned" as const - if (input.branchMatches === false || input.refresh) return "refreshed" as const - return "cached" as const -} +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node], +}) function errorMessage(error: unknown) { return error instanceof globalThis.Error ? error.message : String(error) @@ -275,17 +261,4 @@ function cacheOperation(effect: Effect.Effect, operation: stri ) } -const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) { - if (requestedBranch) return `origin/${requestedBranch}` - const remoteHead = yield* git.remoteHead(cwd) - if (remoteHead) return remoteHead - const currentBranch = yield* git.branch(cwd) - if (currentBranch) return `origin/${currentBranch}` - return "HEAD" -}) - -function resultMessage(result: Git.Result, fallback: string) { - return result.stderr.trim() || result.text.trim() || fallback -} - export * as RepositoryCache from "./repository-cache" diff --git a/packages/core/src/repository.ts b/packages/core/src/repository.ts index ec5d743966..c9b892733f 100644 --- a/packages/core/src/repository.ts +++ b/packages/core/src/repository.ts @@ -118,8 +118,14 @@ export function isRemote(reference: Reference): reference is RemoteReference { return !isFile(reference) } -export function cachePath(root: string, reference: Reference): string { - return path.join(root, ...reference.host.split(":"), ...reference.segments) +/** + * Checkouts are keyed by remote and branch: a branch-specific reference gets + * its own directory so branchless refreshes can never move it. The branch is + * percent-encoded because valid branch names may contain `/`. + */ +export function cachePath(root: string, reference: Reference, branch?: string): string { + const base = path.join(root, ...reference.host.split(":"), ...reference.segments) + return branch ? `${base}@${encodeURIComponent(branch)}` : base } export function cacheIdentity(reference: Reference): string { diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index e154519560..9beaace29e 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -1,11 +1,11 @@ export * as Ripgrep from "./ripgrep" -import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" -import path from "path" -import { LayerNode } from "./effect/layer-node" -import { Entry, Match } from "./filesystem/schema" -import { FSUtil } from "./fs-util" +import { makeGlobalNode } from "./effect/app-node" +import { Entry, Match } from "@opencode-ai/schema/filesystem" +import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change +import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" @@ -23,7 +23,7 @@ const MAX_RECORD_BYTES = 64 * 1024 const MAX_SUBMATCHES = 100 const RawMatch = Schema.Struct({ - type: Schema.Literal("match"), + type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records data: Schema.Struct({ path: Schema.Struct({ text: Schema.String }), lines: Schema.Struct({ text: Schema.String }), @@ -39,11 +39,11 @@ const RawMatch = Schema.Struct({ }), }) -type RawMatchData = (typeof RawMatch.Type)["data"] +type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, - cause: Schema.optional(Schema.Defect), + cause: Schema.optional(Schema.Defect()), }) {} export class InvalidPatternError extends Schema.TaggedErrorClass()("Ripgrep.InvalidPatternError", { @@ -71,7 +71,8 @@ export interface GlobInput { readonly validate?: Effect.Effect // kilocode_change - bind approved searches at spawn } -export interface GrepInput { +export interface GrepInput extends KiloGrep.Options { + // kilocode_change readonly cwd: string readonly pattern: string readonly file?: string @@ -84,7 +85,7 @@ export interface GrepInput { 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 @@ -102,7 +103,7 @@ const failure = (message: string, cause?: unknown) => new Error({ message, cause const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex") -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const process = yield* AppProcess.Service @@ -113,9 +114,11 @@ export const layer = Layer.effect( readonly args: string[] readonly limit: number readonly signal?: AbortSignal + readonly timeout?: number // kilocode_change 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( @@ -126,41 +129,63 @@ export const layer = Layer.effect( cwd: input.cwd, extendEnv: true, stdin: "ignore", + forceKillAfter: input.stop || input.timeout != null ? Duration.seconds(1) : undefined, // kilocode_change - bound search interruption }) - const handle = yield* process.spawn( - input.validate ? SpawnValidation.attach(command, input.validate) : command, - ) - // kilocode_change end - const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( - Effect.map((output) => output.buffer.toString("utf8")), - Effect.forkScoped, - ) - let observed = 0 - const rows = yield* Stream.decodeText(handle.stdout).pipe( - Stream.splitLines, - Stream.filter((line) => line.length > 0), - Stream.mapEffect(input.parse), - Stream.filter((row): row is A => row !== undefined), - Stream.tap((row) => { - if (!input.onItem || observed++ >= input.limit) return Effect.void - return input.onItem(row) - }), - Stream.take(input.limit + 1), - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ) - const truncated = rows.length > input.limit - if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false } + const validated = input.validate ? SpawnValidation.attach(command, input.validate) : command + const spawned = input.stop || input.timeout != null ? SpawnExit.attach(validated) : validated // kilocode_change + const handle = yield* process.spawn(spawned) + const search = Effect.gen(function* () { + // kilocode_change end + const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( + Effect.map((output) => output.buffer.toString("utf8")), + 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), + Stream.mapEffect(input.parse), + Stream.filter((row): row is A => row !== undefined), + Stream.tap((row) => { + if (!input.onItem || observed++ >= input.limit) return Effect.void + return input.onItem(row) + }), + take, // kilocode_change + 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 owns truncation + if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false } - const code = yield* handle.exitCode - const stderr = yield* Fiber.join(stderrFiber) - if (input.pattern && code === 2 && isInvalidPattern(stderr)) { - return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() }) - } - if (code !== 0 && code !== 1 && code !== 2) { - return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`) - } - return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 } + const code = yield* handle.exitCode + const stderr = yield* Fiber.join(stderrFiber) + if (input.pattern && code === 2 && isInvalidPattern(stderr)) { + return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() }) + } + if (code !== 0 && code !== 1 && code !== 2) { + return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`) + } + return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 } + // kilocode_change start + }) + return yield* input.timeout == null + ? search + : search.pipe( + Effect.timeoutOrElse({ + duration: input.timeout, + orElse: () => + Effect.fail(failure("Glob search timed out after 2 minutes. Narrow the search path or pattern.")), + }), + ) + // kilocode_change end }), ) const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program @@ -181,6 +206,7 @@ export const layer = Layer.effect( cwd: input.cwd, limit: input.limit, signal: input.signal, + timeout: 2 * 60 * 1000, // kilocode_change validate: input.validate, // kilocode_change - preserve spawn-bound target validation args: [ "--no-config", @@ -202,14 +228,12 @@ export const layer = Layer.effect( // kilocode_change start - retain spawn metadata after mapping paths Effect.map((result) => ({ ...result, - items: result.items.map((relative) => { - const absolute = path.resolve(input.cwd, relative) - return new Entry({ + items: result.items.map((relative) => + Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(absolute), - }) - }), + }), + ), })), // kilocode_change end Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), @@ -234,10 +258,9 @@ export const layer = Layer.effect( .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") return Effect.succeed( - new Entry({ + Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(path.resolve(input.cwd, relative)), }), ) }, @@ -249,11 +272,13 @@ 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/**", "--", @@ -269,13 +294,19 @@ export const layer = Layer.effect( }) ).pipe( Effect.flatMap((json) => { - if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") + if ( + !json || + typeof json !== "object" || + !("type" in json) || + (json.type !== "match" && json.type !== "context") // kilocode_change + ) 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)), ) @@ -285,17 +316,15 @@ export const layer = Layer.effect( // kilocode_change start - retain spawn metadata after mapping matches Effect.map((result) => ({ ...result, - items: result.items.map((match) => { + items: KiloGrep.select(input, result.items).map((match) => { const relative = match.path.text .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") - const absolute = path.resolve(input.cwd, relative) - return new Match({ - entry: new Entry({ + const item = Match.make({ + entry: Entry.make({ path: RelativePath.make(relative), type: "file", - mime: FSUtil.mimeType(absolute), }), line: match.line_number, offset: match.absolute_offset, @@ -306,6 +335,7 @@ export const layer = Layer.effect( end: submatch.end, })), }) + return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000) }), })), // kilocode_change end @@ -314,5 +344,4 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer))) -export const node = LayerNode.make(layer, [RipgrepBinary.node, AppProcess.node]) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts index 4d34a7d4bd..e1c7ec664b 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -4,8 +4,8 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "../cross-spawn-spawner" -import { LayerNode } from "../effect/layer-node" -import { httpClient } from "../effect/layer-node-platform" +import { makeGlobalNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" import { FSUtil } from "../fs-util" import { Global } from "../global" import { which } from "../util/which" @@ -28,7 +28,7 @@ export namespace RipgrepBinary { export class Service extends Context.Service()("@opencode/RipgrepBinary") {} - export const layer = Layer.effect( + const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -125,11 +125,9 @@ export namespace RipgrepBinary { }), ) - export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - ) - - export const node = LayerNode.make(layer, [FSUtil.node, httpClient, CrossSpawnSpawner.node]) + export const node = makeGlobalNode({ + service: Service, + layer: layer, + deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], + }) } diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 97b24dbda8..345cc22245 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,47 +1,20 @@ -import { Option, Schema, SchemaGetter } from "effect" -import { Hash } from "./util/hash" +import { Schema } from "effect" +import { + AbsolutePath, + DateTimeUtcFromMillis, + NonNegativeInt, + optional, + PositiveInt, + RelativePath, + statics, +} from "@opencode-ai/schema/schema" -export type ExternalID = { - readonly namespace: string - readonly key: string -} - -export const externalID = (prefix: string, input: ExternalID) => - `${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}` +export { AbsolutePath, DateTimeUtcFromMillis, NonNegativeInt, optional, PositiveInt, RelativePath, statics } -/** - * Integer greater than zero. - */ -export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) - -/** - * Integer greater than or equal to zero. - */ -export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) - -/** - * Relative file path (e.g., `src/components/Button.tsx`). - */ -export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) -export type RelativePath = Schema.Schema.Type - -/** - * Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`). - */ -export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) -export type AbsolutePath = Schema.Schema.Type - -/** - * Optional public JSON field that can hold explicit `undefined` on the type - * side but encodes it as an omitted key, matching legacy `JSON.stringify`. - */ -export const optionalOmitUndefined = (schema: S) => - Schema.optionalKey(schema).pipe( - Schema.decodeTo(Schema.optional(schema), { - decode: SchemaGetter.passthrough({ strict: false }), - encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), - }), - ) +// kilocode_change start - compatibility aliases for Kilo-owned schemas +export const optionalOmitUndefined = optional +export const withStatics = statics +// kilocode_change end /** * Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable` @@ -71,22 +44,6 @@ export type DeepMutable = T extends string | number | boolean | bigint | symb ? { -readonly [K in keyof T]: DeepMutable } : T -/** - * Attach static methods to a schema object. Designed to be used with `.pipe()`: - * - * @example - * export const Foo = fooSchema.pipe( - * withStatics((schema) => ({ - * zero: schema.make(0), - * from: Schema.decodeUnknownOption(schema), - * })) - * ) - */ -export const withStatics = - >(methods: (schema: S) => M) => - (schema: S): S & M => - Object.assign(schema, methods(schema)) - /** * Nominal wrapper for scalar types. The class itself is a valid schema — * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e629108994..cb8bd1b4ce 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,8 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" +import { ListAnchor } from "@opencode-ai/schema/session" import { and, asc, desc, eq, gt, isNotNull, like, lt, or, type SQL } from "drizzle-orm" // kilocode_change import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" @@ -9,6 +10,7 @@ import { ModelV2 } from "./model" import { Location } from "./location" import { SessionMessage } from "./session/message" import { Prompt } from "./session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { EventV2 } from "./event" import { Database } from "./database/database" import { SessionProjector } from "./session/projector" @@ -25,11 +27,20 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" -import { logFailure } from "./session/logging" +import { makeGlobalNode } from "./effect/app-node" +import { LocationServiceMap } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" import { normalize } from "./kilocode/session-message" // kilocode_change +import { Snapshot } from "./snapshot" +import { SessionRevert } from "./session/revert" +import { Revert } from "@opencode-ai/schema/revert" +import { FSUtil } from "./fs-util" +import { SessionDurable, type SessionDurableEvent } from "@opencode-ai/schema/durable-event-manifest" // kilocode_change + +export const RevertState = Revert.State +export type RevertState = Revert.State // get project -> project.locations // @@ -40,12 +51,7 @@ import { normalize } from "./kilocode/session-message" // kilocode_change // - by subpath // - by workspace (home is special) -export const ListAnchor = Schema.Struct({ - id: SessionSchema.ID, - time: Schema.Finite, - direction: Schema.Literals(["previous", "next"]), -}) -export type ListAnchor = typeof ListAnchor.Type +export { ListAnchor } const ListInputBase = { workspaceID: WorkspaceV2.ID.pipe(Schema.optional), @@ -100,6 +106,8 @@ export class PromptConflictError extends Schema.TaggedErrorClass Effect.Effect readonly events: (input: { sessionID: SessionSchema.ID - after?: EventV2.Cursor - }) => Stream.Stream, NotFoundError> - readonly switchAgent: (input: { + after?: number + }) => Stream.Stream // kilocode_change - released durable event compatibility + readonly history: (input: { sessionID: SessionSchema.ID - agent: string - }) => Effect.Effect + after?: number + limit: number + }) => Effect.Effect<{ events: ReadonlyArray; hasMore: boolean }, NotFoundError> // kilocode_change + readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -138,7 +148,7 @@ export interface Interface { readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID - prompt: Prompt + prompt: PromptInput.Prompt delivery?: SessionInput.Delivery resume?: boolean }) => Effect.Effect @@ -156,36 +166,34 @@ export interface Interface { }) => Effect.Effect readonly compact: (input: CompactInput) => Effect.Effect readonly wait: (id: SessionSchema.ID) => Effect.Effect + readonly active: Effect.Effect> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect + readonly revert: { + readonly stage: (input: { + sessionID: SessionSchema.ID + messageID: SessionMessage.ID + files?: boolean + }) => Effect.Effect + readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect + readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect + } } export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { - const db = (yield* Database.Service).db + const database = yield* Database.Service + const db = database.db const events = yield* EventV2.Service const projects = yield* ProjectV2.Service const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service + const locations = yield* LocationServiceMap.Service const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) - const isDurableSessionEvent = Schema.is(SessionEvent.Durable) - const scope = yield* Effect.scope - - const enqueueWake = (admitted: SessionInput.Admitted) => - execution.wake(admitted.sessionID, admitted.admittedSeq).pipe( - Effect.tapCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : logFailure("Failed to wake Session", admitted.sessionID, cause), - ), - Effect.ignore, - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ) - + const isDurableSessionEvent = Schema.is(SessionDurable.schema) // kilocode_change - include released storage keys const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage(normalize({ ...row.data, id: row.id, type: row.type })).pipe( // kilocode_change - normalize released tool content on paginated reads @@ -316,11 +324,12 @@ export const layer = Layer.effect( : undefined const seq = anchor?.seq if (input.cursor && seq == null) return [] - const boundary = seq != null - ? order === "asc" - ? gt(SessionMessageTable.seq, seq) - : lt(SessionMessageTable.seq, seq) - : undefined + const boundary = + seq != null + ? order === "asc" + ? gt(SessionMessageTable.seq, seq) + : lt(SessionMessageTable.seq, seq) + : undefined const where = boundary ? and(eq(SessionMessageTable.session_id, input.sessionID), isNotNull(SessionMessageTable.seq), boundary) : and(eq(SessionMessageTable.session_id, input.sessionID), isNotNull(SessionMessageTable.seq)) // kilocode_change @@ -346,27 +355,28 @@ export const layer = Layer.effect( Stream.unwrap( result .get(input.sessionID) - .pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))), - ).pipe( - Stream.filter((event): event is EventV2.CursorEvent => - isDurableSessionEvent(event.event), - ), - ), + .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), + ).pipe(Stream.filter((event): event is SessionDurableEvent => isDurableSessionEvent(event))), // kilocode_change + history: Effect.fn("V2Session.history")(function* (input) { + yield* result.get(input.sessionID) + return yield* EventV2.readAggregate(db, { + ...input, + aggregateID: input.sessionID, + manifest: SessionDurable, + }) + }), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { yield* result.get(input.sessionID) - const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) { - if (input.resume !== false) yield* enqueueWake(admitted) - return admitted - }, Effect.uninterruptible) + const prompt = resolvePrompt(input.prompt) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" - const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } + const expected = { sessionID: input.sessionID, messageID, prompt, delivery } const admitted = yield* SessionInput.admit(db, events, { id: messageID, sessionID: input.sessionID, - prompt: input.prompt, + prompt, delivery, }).pipe( Effect.catchDefect((defect) => @@ -377,7 +387,8 @@ export const layer = Layer.effect( ) if (!SessionInput.equivalent(admitted, expected)) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - return yield* returnPrompt(admitted) + if (input.resume !== false) yield* execution.wake(admitted.sessionID) + return admitted }), ), ), @@ -387,11 +398,23 @@ export const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* () { return yield* new OperationUnavailableError({ operation: "skill" }) }), - switchAgent: Effect.fn("V2Session.switchAgent")(function* () { - return yield* new OperationUnavailableError({ operation: "switchAgent" }) + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: input.agent, + }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* result.get(input.sessionID) + const session = yield* result.get(input.sessionID) + if ( + session.model?.providerID === input.model.providerID && + session.model.id === input.model.id && + (session.model.variant ?? "default") === (input.model.variant ?? "default") + ) + return yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, messageID: SessionMessage.ID.create(), @@ -407,38 +430,65 @@ export const layer = Layer.effect( yield* result.get(sessionID) return yield* new OperationUnavailableError({ operation: "wait" }) }), + active: execution.active, resume: Effect.fn("V2Session.resume")(function* (sessionID) { yield* result.get(sessionID) yield* execution.resume(sessionID) }), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => - Effect.uninterruptible( - Effect.gen(function* () { - const session = yield* store.get(sessionID) - if (!session) return yield* execution.interrupt(sessionID) - // kilocode_change start - keep interrupt operational while preserving released durable event compatibility. - const seq = yield* SessionInput.latestSeq(db, sessionID) - yield* events.publish(SessionEvent.InterruptRequested, { - sessionID, - timestamp: yield* DateTime.now, - }) - yield* execution.interrupt(sessionID, seq) - // kilocode_change end - }), - ), + Effect.uninterruptible(execution.interrupt(sessionID)), ), + revert: { + stage: Effect.fn("V2Session.revert.stage")(function* (input) { + const session = yield* result.get(input.sessionID) + return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( + Effect.provideService(Database.Service, database), + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.clear(session).pipe( + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) + }), + }, }) return result }), ) -export const defaultLayer = layer.pipe( - Layer.provide(SessionExecution.noopLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, -) +const resolvePrompt = (input: PromptInput.Prompt) => + Prompt.make({ + text: input.text, + agents: input.agents, + files: input.files?.map((file) => { + const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1] + const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri) + return { + ...file, + mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)), + } + }), + }) + +export const node = makeGlobalNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [ + Database.node, + EventV2.node, + ProjectV2.node, + SessionExecution.node, + SessionStore.node, + LocationServiceMap.node, + SessionProjector.node, + ], +}) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 1c0cc62fea..0f9342e258 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -15,30 +15,25 @@ const TOOL_OUTPUT_MAX_CHARS = 2_000 const SUMMARY_OUTPUT_TOKENS = 4_096 const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside