mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/main' into puddle-barometer
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix Agent Manager mode shortcuts in the New Worktree dialog so the selected mode and its matching model stay in sync.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show aggregate added and removed line counts for multi-file patch tool calls.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore Agent Manager sections and worktree drag-and-drop when multiple projects are shown, with ordering and section moves scoped to the owning project.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent VS Code sessions and Agent Manager worktrees from starting unused file watchers and defer file indexing until search is used.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent configured compaction thresholds from interrupting active tool sequences.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Show why a tool call was auto-approved or denied in the TUI, and record the denial reason on the tool call metadata (visible in `kilo export`) alongside the existing auto-approval reason.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep config-defined subagents routable when an installed primary agent uses the same name.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Speed up local session recall searches across large conversation histories.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Start voice input faster on macOS with native AVFoundation capture.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore keyboard focus to the prompt or pending question when switching Agent Manager worktrees and sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix multi-project Agent Manager keyboard navigation and Cmd/Ctrl shortcut selection when worktrees are grouped in sections.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix Agent Manager progress indicators when multiple projects are expanded.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Scope Agent Manager session events and Git status to the active project, including edits inside nested repositories.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Add bounded, context-aware signal-to-noise controls to grep searches.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show and hide Agent Manager worktree hover cards instantly.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Keep the JetBrains prompt send/stop button in sync when attachments are added or removed while a session is busy.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Avoid GitHub API rate-limit failures when the JetBrains plugin downloads the pinned Kilo CLI.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix JetBrains diff views to show compact workspace-relative file paths and keep added-file content visible in large branch diffs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix dropping files into the JetBrains prompt so code files are added as readable file references and drops anywhere in the session panel feed the prompt attachments.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix JetBrains chat transcripts rendering cropped when opening existing sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow Agent Manager project headers to collapse or expand their project body, and persist that state across panel opens and VS Code restarts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show previous and next navigation hints using each project's own Agent Manager sidebar order.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep Agent Manager worktree rows isolated when projects contain identical raw worktree IDs.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep the prompt controls at a consistent height when the model selector shows the prompt-training indicator.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update model search results instantly and keep the active match visible while typing.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow Agent Manager sessions to move their worktree between sections or ungroup it through the `agent_manager` tool.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep Kilo's persona out of generated conversation titles and Agent Manager branch names.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Bind voice input to Cmd/Ctrl+K in Kilo prompt and review comment fields, with hold-to-talk and release-to-send support.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve prompt or Agent Manager terminal focus independently for each session when switching sessions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Start a fresh shell in the same Agent Manager terminal tab when the user types after the terminal ends.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Remove the redundant selected-project indicator from Agent Manager.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Import conversation history from Claude Code and OpenAI Codex sessions with the /resume-claude and /resume-codex slash commands.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show only one empty-state panel after removing the last Agent Manager worktree.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Stop treating `` !`cmd` `` shown as an inline code example in skill documentation as a live command, so it no longer triggers a shell permission prompt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Persist the Agent Manager inspector width and share it between the terminal and diff viewer.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
A second identical large paste expands its collapsed prompt placeholder.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix skill folder path and URL rows clipping and pushing the remove (×) button off-screen in narrow Skills settings panels. Long paths and URLs now truncate within their row, and hovering a truncated value shows the full path or URL in a tooltip.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Configure a model and reasoning variant for each workflow from Agent Behaviour settings.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep multi-project worktree rename inputs focused while selection updates are still settling.
|
||||
@@ -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 <path>`, `created <path>`, or `skipped`. Example:
|
||||
|
||||
|
||||
+19
-13
@@ -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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <fixture>.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 =
|
||||
/^- (?<rule>.+?) <!-- id=(?<id>[a-z0-9][a-z0-9-]{2,48}) scope=(?<scope>triage|edit|both) source=(?<source>commit:[0-9a-f]{7,40}|comment:\d+) date=(?<date>\d{4}-\d{2}-\d{2}) -->$/
|
||||
|
||||
const LEARNED_THROUGH_RE = /<!--\s*docs-sync:\s*learned-through\s+commit=(\S+)\s+comment=(\S+)\s*-->/
|
||||
|
||||
// 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("<!--", "")
|
||||
.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*docs-sync:learnings:start\s*-->([\s\S]*?)<!--\s*docs-sync:learnings:end\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", " ")} <!-- id=${e.id} scope=${e.scope} source=${e.source} date=${e.date} -->`,
|
||||
)
|
||||
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.",
|
||||
"",
|
||||
"<!-- docs-sync:learnings:start -->",
|
||||
...lines,
|
||||
"<!-- docs-sync:learnings:end -->",
|
||||
"",
|
||||
].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 `<!-- docs-sync: learned-through commit=${c} comment=${m} -->`
|
||||
}
|
||||
|
||||
/** 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)
|
||||
})
|
||||
}
|
||||
@@ -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:<sha>|comment:<id>",
|
||||
"date": "<yyyy-mm-dd>"
|
||||
}
|
||||
],
|
||||
"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).
|
||||
@@ -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() {
|
||||
|
||||
+1633
-4
File diff suppressed because it is too large
Load Diff
@@ -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 = []
|
||||
|
||||
@@ -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("<!--", "").replaceAll("-->", "")
|
||||
return String(value ?? "")
|
||||
.replaceAll("<!--", "")
|
||||
.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 `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
|
||||
}
|
||||
|
||||
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}\`.
|
||||
<!-- docs-sync: processed-through ${through} -->
|
||||
`
|
||||
${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(/<!--\s*docs-sync:\s*learned-through\s+commit=\S+\s+comment=\S+\s*-->/)
|
||||
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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -127,7 +127,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -141,7 +141,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -153,7 +153,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
@@ -174,7 +174,7 @@
|
||||
},
|
||||
"packages/kilo-console": {
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-indexing": "workspace:*",
|
||||
"@kilocode/kilo-web-ui": "workspace:*",
|
||||
@@ -197,7 +197,7 @@
|
||||
},
|
||||
"packages/kilo-docs": {
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@docsearch/css": "^4",
|
||||
"@docsearch/js": "^4",
|
||||
@@ -227,7 +227,7 @@
|
||||
},
|
||||
"packages/kilo-gateway": {
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
@@ -263,7 +263,7 @@
|
||||
},
|
||||
"packages/kilo-i18n": {
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -273,7 +273,7 @@
|
||||
},
|
||||
"packages/kilo-indexing": {
|
||||
"name": "@kilocode/kilo-indexing",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
|
||||
"@aws-sdk/credential-provider-ini": "3.972.31",
|
||||
@@ -309,7 +309,7 @@
|
||||
},
|
||||
"packages/kilo-memory": {
|
||||
"name": "@kilocode/kilo-memory",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -323,7 +323,7 @@
|
||||
},
|
||||
"packages/kilo-sandbox": {
|
||||
"name": "@kilocode/sandbox",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"posthog-node": "4.4.0",
|
||||
@@ -352,7 +352,7 @@
|
||||
},
|
||||
"packages/kilo-ui": {
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -390,7 +390,7 @@
|
||||
},
|
||||
"packages/kilo-vscode": {
|
||||
"name": "kilo-code",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
@@ -459,7 +459,7 @@
|
||||
},
|
||||
"packages/kilo-web-ui": {
|
||||
"name": "@kilocode/kilo-web-ui",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -476,7 +476,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -494,7 +494,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "@kilocode/cli",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"bin": {
|
||||
"kilo": "./bin/kilo",
|
||||
"kilocode": "./bin/kilo",
|
||||
@@ -661,7 +661,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -689,7 +689,7 @@
|
||||
},
|
||||
"packages/plugin-atomic-chat": {
|
||||
"name": "@kilocode/plugin-atomic-chat",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
},
|
||||
@@ -703,7 +703,7 @@
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3",
|
||||
},
|
||||
@@ -714,7 +714,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -729,7 +729,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -743,7 +743,7 @@
|
||||
},
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -766,7 +766,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
@@ -793,7 +793,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
|
||||
+1
-1
@@ -173,6 +173,6 @@
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
|
||||
},
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as FileSystemSearch from "./search"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { Context, Duration, Effect, Layer, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { FileSystem } from "../filesystem"
|
||||
@@ -77,7 +77,8 @@ export const ripgrepLayer = Layer.effect(
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
result.items.map( // kilocode_change
|
||||
result.items.map(
|
||||
// kilocode_change
|
||||
(entry) =>
|
||||
new FileSystem.Entry({
|
||||
...entry,
|
||||
@@ -105,7 +106,8 @@ export const ripgrepLayer = Layer.effect(
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
result.items.map( // kilocode_change
|
||||
result.items.map(
|
||||
// kilocode_change
|
||||
(match) =>
|
||||
new FileSystem.Match({
|
||||
...match,
|
||||
@@ -166,27 +168,65 @@ export const fffLayer = Layer.effect(
|
||||
return real !== undefined && FSUtil.contains(root.path, real)
|
||||
})
|
||||
// kilocode_change end
|
||||
const result = yield* Effect.try({
|
||||
try: () =>
|
||||
Fff.create({
|
||||
basePath: location.directory,
|
||||
aiMode: true,
|
||||
...scanning(location.directory), // kilocode_change - permit broad scanning only at the exact boundary.
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(Effect.orDie)
|
||||
if (!result.ok) return yield* Effect.die(result.error)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
||||
// kilocode_change start - defer FFF until search because other location consumers do not need its native index.
|
||||
const scope = yield* Scope.Scope
|
||||
const release = (entry: { finder: { destroy(): void }; closed: boolean }) =>
|
||||
Effect.sync(() => {
|
||||
if (entry.closed) return
|
||||
entry.closed = true
|
||||
entry.finder.destroy()
|
||||
}).pipe(Effect.ignore)
|
||||
const make = Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const result = yield* Effect.try({
|
||||
try: () =>
|
||||
Fff.create({
|
||||
basePath: location.directory,
|
||||
aiMode: true,
|
||||
disableMmapCache: true,
|
||||
disableContentIndexing: true,
|
||||
...scanning(location.directory),
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(Effect.orDie)
|
||||
if (!result.ok) return yield* Effect.die(result.error)
|
||||
const entry = { finder: result.value, closed: false }
|
||||
yield* Scope.addFinalizer(scope, release(entry))
|
||||
return entry
|
||||
}),
|
||||
)
|
||||
const [load, invalidate] = yield* Effect.cachedInvalidateWithTTL(
|
||||
make.pipe(
|
||||
Effect.flatMap((entry) =>
|
||||
Effect.promise(() => entry.finder.waitForScan())
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.onError(() => release(entry)),
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap((scan) => {
|
||||
if (!scan.ok || !scan.value) return Effect.die(new Error("FFF initial scan did not complete"))
|
||||
return Effect.succeed(entry)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Duration.infinity,
|
||||
)
|
||||
const get = load.pipe(Effect.onError(() => invalidate))
|
||||
yield* Scope.addFinalizer(scope, invalidate)
|
||||
// kilocode_change end
|
||||
return Service.of({
|
||||
glob: (input) =>
|
||||
// kilocode_change start
|
||||
Effect.gen(function* () {
|
||||
const { root, target } = yield* inspect(input.path)
|
||||
// kilocode_change end
|
||||
const result = yield* get
|
||||
// kilocode_change end
|
||||
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
||||
// kilocode_change start
|
||||
const found = yield* Effect.sync(() =>
|
||||
result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
|
||||
result.finder.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
|
||||
pageIndex: 0,
|
||||
pageSize: input.limit,
|
||||
}),
|
||||
@@ -197,7 +237,7 @@ export const fffLayer = Layer.effect(
|
||||
yield* SearchTarget.validate(fs, target).pipe(Effect.orDie)
|
||||
const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath))
|
||||
return items.map((item) => {
|
||||
// kilocode_change end
|
||||
// kilocode_change end
|
||||
const absolute = path.resolve(location.directory, item.relativePath)
|
||||
return new FileSystem.Entry({
|
||||
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
||||
@@ -210,24 +250,26 @@ export const fffLayer = Layer.effect(
|
||||
// kilocode_change start
|
||||
Effect.gen(function* () {
|
||||
const { root, target } = yield* inspect(input.path)
|
||||
// kilocode_change end
|
||||
const result = yield* get
|
||||
// kilocode_change end
|
||||
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
||||
// kilocode_change start
|
||||
const found = yield* Effect.sync(() =>
|
||||
result.value.grep(
|
||||
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(" "),
|
||||
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
|
||||
),
|
||||
// kilocode_change end
|
||||
const found = yield* Effect.sync(
|
||||
() =>
|
||||
result.finder.grep(
|
||||
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(" "),
|
||||
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
|
||||
),
|
||||
// kilocode_change end
|
||||
)
|
||||
if (!found.ok) throw found.error
|
||||
// kilocode_change start
|
||||
yield* SearchTarget.validate(fs, target).pipe(Effect.orDie)
|
||||
const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath))
|
||||
return items.map((match) => {
|
||||
// kilocode_change end
|
||||
// kilocode_change end
|
||||
const bytes = Buffer.from(match.lineContent)
|
||||
return new FileSystem.Match({
|
||||
entry: new FileSystem.Entry({
|
||||
@@ -247,11 +289,13 @@ export const fffLayer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
find: (input) =>
|
||||
Effect.sync(() => {
|
||||
Effect.gen(function* () {
|
||||
// kilocode_change - load the native index only for an actual search.
|
||||
const result = yield* get // kilocode_change
|
||||
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
|
||||
const items = (() => {
|
||||
if (input.type === "file") {
|
||||
const found = result.value.fileSearch(input.query.trim(), options)
|
||||
const found = result.finder.fileSearch(input.query.trim(), options)
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((item, index) => ({
|
||||
path: item.relativePath,
|
||||
@@ -260,7 +304,7 @@ export const fffLayer = Layer.effect(
|
||||
}))
|
||||
}
|
||||
if (input.type === "directory") {
|
||||
const found = result.value.directorySearch(input.query.trim(), options)
|
||||
const found = result.finder.directorySearch(input.query.trim(), options)
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((item, index) => ({
|
||||
path: item.relativePath,
|
||||
@@ -268,7 +312,7 @@ export const fffLayer = Layer.effect(
|
||||
score: found.value.scores[index]?.total ?? 0,
|
||||
}))
|
||||
}
|
||||
const found = result.value.mixedSearch(input.query.trim(), options)
|
||||
const found = result.finder.mixedSearch(input.query.trim(), options)
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((item, index) => ({
|
||||
path: item.item.relativePath,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { Match } from "../filesystem/schema"
|
||||
|
||||
export interface Options {
|
||||
readonly context?: number
|
||||
readonly literal?: boolean
|
||||
readonly ignoreCase?: boolean
|
||||
}
|
||||
|
||||
export type GrepMatch = Match & {
|
||||
readonly context: boolean
|
||||
readonly textTruncated: boolean
|
||||
}
|
||||
|
||||
export const flags = (input: Options) => [
|
||||
...(input.literal ? ["--fixed-strings"] : []),
|
||||
...(input.ignoreCase ? ["--ignore-case"] : []),
|
||||
...(input.context ? [`--context=${input.context}`] : []),
|
||||
]
|
||||
|
||||
export const stop = (limit: number) => {
|
||||
let matches = 0
|
||||
return (row: { readonly context: boolean }) => !row.context && ++matches > limit
|
||||
}
|
||||
|
||||
export const select = <
|
||||
A extends {
|
||||
readonly context: boolean
|
||||
readonly path: { readonly text: string }
|
||||
readonly line_number: number
|
||||
},
|
||||
>(
|
||||
input: { readonly limit: number; readonly context?: number },
|
||||
items: readonly A[],
|
||||
) => {
|
||||
let count = 0
|
||||
const overflow = items.findIndex((row) => !row.context && ++count > input.limit)
|
||||
const selected = items.slice(0, overflow === -1 ? items.length : overflow)
|
||||
const matches = selected.filter((row) => !row.context)
|
||||
return selected.filter(
|
||||
(row) =>
|
||||
!row.context ||
|
||||
matches.some(
|
||||
(match) =>
|
||||
match.path.text === row.path.text && Math.abs(match.line_number - row.line_number) <= (input.context ?? 0),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const decorate = (match: Match, context: boolean, textTruncated: boolean): GrepMatch => ({
|
||||
...match,
|
||||
context,
|
||||
textTruncated,
|
||||
})
|
||||
@@ -6,7 +6,6 @@ import path from "path"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Entry, Match } from "./filesystem/schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change
|
||||
import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change
|
||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
@@ -24,7 +23,7 @@ const MAX_RECORD_BYTES = 64 * 1024
|
||||
const MAX_SUBMATCHES = 100
|
||||
|
||||
const RawMatch = Schema.Struct({
|
||||
type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records
|
||||
type: Schema.Literal("match"),
|
||||
data: Schema.Struct({
|
||||
path: Schema.Struct({ text: Schema.String }),
|
||||
lines: Schema.Struct({ text: Schema.String }),
|
||||
@@ -40,7 +39,7 @@ const RawMatch = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change
|
||||
type RawMatchData = (typeof RawMatch.Type)["data"]
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
|
||||
message: Schema.String,
|
||||
@@ -72,8 +71,7 @@ export interface GlobInput {
|
||||
readonly validate?: Effect.Effect<void, unknown> // kilocode_change - bind approved searches at spawn
|
||||
}
|
||||
|
||||
export interface GrepInput extends KiloGrep.Options {
|
||||
// kilocode_change
|
||||
export interface GrepInput {
|
||||
readonly cwd: string
|
||||
readonly pattern: string
|
||||
readonly file?: string
|
||||
@@ -86,7 +84,7 @@ export interface GrepInput extends KiloGrep.Options {
|
||||
export interface Interface {
|
||||
readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<SearchResult<Entry>, Error> // kilocode_change
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<KiloGrep.GrepMatch>, Error | InvalidPatternError> // kilocode_change
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<Match>, Error | InvalidPatternError> // kilocode_change
|
||||
}
|
||||
|
||||
// kilocode_change start - retain truncation state through model-facing tools
|
||||
@@ -118,7 +116,6 @@ export const layer = Layer.effect(
|
||||
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
|
||||
readonly pattern?: string
|
||||
readonly onItem?: (item: A) => Effect.Effect<void>
|
||||
readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match
|
||||
readonly validate?: Effect.Effect<void, unknown> // kilocode_change - spawn-bound target validation
|
||||
}) => {
|
||||
const program = Effect.scoped(
|
||||
@@ -139,13 +136,6 @@ export const layer = Layer.effect(
|
||||
Effect.forkScoped,
|
||||
)
|
||||
let observed = 0
|
||||
let stopped = false // kilocode_change
|
||||
const take = input.stop // kilocode_change start
|
||||
? Stream.takeUntil<A>((row) => {
|
||||
stopped = input.stop?.(row) ?? false
|
||||
return stopped
|
||||
})
|
||||
: Stream.take(input.limit + 1) // kilocode_change end
|
||||
const rows = yield* Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
@@ -155,12 +145,11 @@ export const layer = Layer.effect(
|
||||
if (!input.onItem || observed++ >= input.limit) return Effect.void
|
||||
return input.onItem(row)
|
||||
}),
|
||||
take, // kilocode_change
|
||||
Stream.take(input.limit + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
if (stopped) return { items: rows, truncated: true, partial: false } // kilocode_change
|
||||
const truncated = input.stop ? false : rows.length > input.limit // kilocode_change - custom stop predicates own truncation
|
||||
const truncated = rows.length > input.limit
|
||||
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
|
||||
|
||||
const code = yield* handle.exitCode
|
||||
@@ -260,13 +249,11 @@ export const layer = Layer.effect(
|
||||
grep: (input) =>
|
||||
run<RawMatchData>({
|
||||
...input,
|
||||
stop: KiloGrep.stop(input.limit), // kilocode_change
|
||||
args: [
|
||||
"--no-config",
|
||||
"--json",
|
||||
"--hidden",
|
||||
"--no-messages",
|
||||
...KiloGrep.flags(input), // kilocode_change
|
||||
...(input.include ? [`--glob=${input.include}`] : []),
|
||||
"--glob=!**/.git/**",
|
||||
"--",
|
||||
@@ -282,19 +269,13 @@ export const layer = Layer.effect(
|
||||
})
|
||||
).pipe(
|
||||
Effect.flatMap((json) => {
|
||||
if (
|
||||
!json ||
|
||||
typeof json !== "object" ||
|
||||
!("type" in json) ||
|
||||
(json.type !== "match" && json.type !== "context") // kilocode_change
|
||||
)
|
||||
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
|
||||
return Effect.succeed(undefined)
|
||||
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
||||
Effect.map((match) => ({
|
||||
...match.data,
|
||||
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
|
||||
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
|
||||
context: match.type === "context", // kilocode_change
|
||||
})),
|
||||
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
|
||||
)
|
||||
@@ -304,13 +285,13 @@ export const layer = Layer.effect(
|
||||
// kilocode_change start - retain spawn metadata after mapping matches
|
||||
Effect.map((result) => ({
|
||||
...result,
|
||||
items: KiloGrep.select(input, result.items).map((match) => {
|
||||
items: result.items.map((match) => {
|
||||
const relative = match.path.text
|
||||
.replace(/^(?:\.[\\/])+/u, "")
|
||||
.replace(/^[\\/]+/u, "")
|
||||
.replaceAll("\\", "/")
|
||||
const absolute = path.resolve(input.cwd, relative)
|
||||
const item = new Match({
|
||||
return new Match({
|
||||
entry: new Entry({
|
||||
path: RelativePath.make(relative),
|
||||
type: "file",
|
||||
@@ -325,7 +306,6 @@ export const layer = Layer.effect(
|
||||
end: submatch.end,
|
||||
})),
|
||||
})
|
||||
return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000)
|
||||
}),
|
||||
})),
|
||||
// kilocode_change end
|
||||
|
||||
@@ -25,6 +25,7 @@ const InputObject = Schema.StructWithRest(
|
||||
bash: Schema.optional(Rule),
|
||||
task: Schema.optional(Rule),
|
||||
external_directory: Schema.optional(Rule),
|
||||
markdown_source: Schema.optional(Rule), // kilocode_change - explicitly authorize external agent/command sources
|
||||
todowrite: Schema.optional(Action),
|
||||
question: Schema.optional(Action),
|
||||
webfetch: Schema.optional(Action),
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { FileFinder, type InitOptions } from "@ff-labs/fff-bun"
|
||||
import "@opencode-ai/core/filesystem"
|
||||
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { scanning } from "@opencode-ai/core/kilocode/fff"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
describe("FFF scanning boundaries", () => {
|
||||
test("enables filesystem-root scanning only at the exact root", () => {
|
||||
@@ -25,3 +34,81 @@ describe("FFF scanning boundaries", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("FFF lifecycle", () => {
|
||||
test("retries a failed first search and reuses one picker", async () => {
|
||||
if (!Fff.available()) return
|
||||
|
||||
const dir = await tmpdir()
|
||||
const create = FileFinder.create
|
||||
const calls = { create: 0, destroy: 0, opts: undefined as InitOptions | undefined }
|
||||
try {
|
||||
FileFinder.create = (opts) => {
|
||||
calls.create++
|
||||
if (calls.create === 1) return { ok: false, error: "transient failure" }
|
||||
calls.opts = opts
|
||||
const result = create(opts)
|
||||
if (!result.ok) return result
|
||||
const destroy = result.value.destroy.bind(result.value)
|
||||
result.value.destroy = () => {
|
||||
calls.destroy++
|
||||
destroy()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.acquireUseRelease(
|
||||
Scope.make(),
|
||||
(scope) =>
|
||||
Effect.gen(function* () {
|
||||
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
|
||||
const layer = FileSystemSearch.fffLayer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(dir.path) },
|
||||
{ vcs: { type: "git", store: AbsolutePath.make(path.join(dir.path, ".git")) } },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(layer, scope)
|
||||
const service = Context.get(context, FileSystemSearch.Service)
|
||||
expect(calls.create).toBe(0)
|
||||
|
||||
const first = yield* Effect.exit(
|
||||
Effect.all(
|
||||
[
|
||||
service.find({ query: "", type: "file", limit: 1 }),
|
||||
service.find({ query: "", type: "file", limit: 1 }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
)
|
||||
expect(first._tag).toBe("Failure")
|
||||
expect(calls.create).toBe(1)
|
||||
|
||||
yield* service.find({ query: "", type: "file", limit: 1 })
|
||||
expect(calls.create).toBe(2)
|
||||
expect(calls.opts?.disableMmapCache).toBe(true)
|
||||
expect(calls.opts?.disableContentIndexing).toBe(true)
|
||||
|
||||
yield* service.find({ query: "", type: "file", limit: 1 })
|
||||
expect(calls.create).toBe(2)
|
||||
expect(calls.destroy).toBe(0)
|
||||
}),
|
||||
(scope, exit) => Scope.close(scope, exit),
|
||||
),
|
||||
)
|
||||
expect(calls.destroy).toBe(1)
|
||||
} finally {
|
||||
FileFinder.create = create
|
||||
await dir[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,17 +80,19 @@ const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function*
|
||||
return { attachment, output, ended }
|
||||
})
|
||||
|
||||
const waitForOutput = (output: Queue.Queue<string>, text: string) =>
|
||||
Effect.gen(function* () {
|
||||
let received = ""
|
||||
const waitForOutput = (output: Queue.Queue<string>, text: string) => {
|
||||
let received = ""
|
||||
const pull = Effect.gen(function* () {
|
||||
while (!received.includes(text)) received += yield* Queue.take(output)
|
||||
return received
|
||||
}).pipe(
|
||||
})
|
||||
return pull.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: PTY_TEST_TIMEOUT, // kilocode_change
|
||||
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
|
||||
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}, received ${JSON.stringify(received)}`)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("pty", () => {
|
||||
it.live("returns typed not found errors for missing sessions", () =>
|
||||
@@ -143,11 +145,13 @@ describe("pty", () => {
|
||||
)
|
||||
|
||||
// (script terminals forward raw output to xterm without transcoding).
|
||||
// The child must outlive its output: an immediate exit can race bun-pty's
|
||||
// reader thread and drop trailing bytes under load, so print then sleep.
|
||||
ptyTest("round-trips non-ASCII output byte-identically", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const marker = "café-über-北京-🚀"
|
||||
const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'"])
|
||||
const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'; sleep 5"])
|
||||
const attached = yield* attachCollecting(info.id)
|
||||
expect(yield* waitForOutput(attached.output, marker)).toContain(marker)
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "kilo"
|
||||
name = "Kilo"
|
||||
description = "The open source coding agent."
|
||||
version = "7.4.18"
|
||||
version = "7.4.20"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/Kilo-Org/kilocode"
|
||||
@@ -11,26 +11,26 @@ name = "Kilo"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.18/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.20/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# docs-sync learnings
|
||||
|
||||
Rules the docs-sync bot learned from maintainer corrections to its rolling pull request.
|
||||
The bot reads this file at the start of every run and follows every rule below.
|
||||
|
||||
To unlearn a rule, delete its line and commit. The next run reads this file from the
|
||||
branch, so the rule is gone from its input, and the deletion itself is a correction the
|
||||
extraction step is instructed not to undo.
|
||||
|
||||
<!-- docs-sync:learnings:start -->
|
||||
<!-- docs-sync:learnings:end -->
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -18,7 +18,7 @@ If you'd like to migrate your memory bank content to AGENTS.md:
|
||||
|
||||
1. Examine the contents in `.kilocode/rules/memory-bank/`
|
||||
2. Move that content into your project's `AGENTS.md` file (or ask Kilo to do it for you)
|
||||
{% /callout %}
|
||||
{% /callout %}
|
||||
|
||||
## What is AGENTS.md?
|
||||
|
||||
|
||||
@@ -93,6 +93,20 @@ Define agents as markdown files with YAML frontmatter. Place them in:
|
||||
|
||||
The **filename** (without `.md`) becomes the agent name.
|
||||
|
||||
If `.kilo/agents/` is a symlink to a directory outside the project, allow that exact source in your global `~/.config/kilo/kilo.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"markdown_source": {
|
||||
"/path/to/shared/agents/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Project configuration cannot grant this permission. External agent files remain untrusted: `{env:...}` substitutions are blocked and `{file:...}` substitutions remain confined to the project.
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews code for quality and best practices
|
||||
|
||||
@@ -17,6 +17,20 @@ Workflows are Markdown files stored as **slash commands** in `.kilo/commands/`:
|
||||
- **Global commands**: `~/.config/kilo/commands/` (available in all projects)
|
||||
- **Project commands**: `[project]/.kilo/commands/` (project-specific)
|
||||
|
||||
If `.kilo/commands/` is a symlink to a directory outside the project, allow that exact source in your global `~/.config/kilo/kilo.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permission": {
|
||||
"markdown_source": {
|
||||
"/path/to/shared/commands/*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Project configuration cannot grant this permission. External command files remain untrusted: `{env:...}` substitutions are blocked and `{file:...}` substitutions remain confined to the project.
|
||||
|
||||
### Basic Setup
|
||||
|
||||
1. Create a `.md` file with step-by-step instructions
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fd794db4d2c038984f9e753ba70b8b0473d97a16713a9ff7255f0d2cf505d41a
|
||||
size 758306
|
||||
oid sha256:033960b68a915d0b05ce539784998d0f84603f206689dbbd14005771d112faa4
|
||||
size 762429
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { getKiloUrlFromToken } from "../auth/token.js"
|
||||
import { getDefaultHeaders, buildKiloHeaders } from "../headers.js"
|
||||
import { resolveKiloGatewayBaseUrl } from "./url.js"
|
||||
import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js"
|
||||
|
||||
export type KiloModelsResult = {
|
||||
@@ -121,6 +122,16 @@ export type KiloImageModelsResult = {
|
||||
error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
|
||||
}
|
||||
|
||||
export type KiloTranscriptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type KiloTranscriptionModelsResult = {
|
||||
models: KiloTranscriptionModel[]
|
||||
error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch image-capable models from Kilo API (OpenRouter-compatible endpoint).
|
||||
* Uses the same raw fetch as {@link fetchKiloModels} but keeps only models
|
||||
@@ -145,6 +156,52 @@ export async function fetchKiloImageModels(options?: {
|
||||
return { models }
|
||||
}
|
||||
|
||||
export async function fetchKiloTranscriptionModels(options?: {
|
||||
kilocodeToken?: string
|
||||
kilocodeOrganizationId?: string
|
||||
baseURL?: string
|
||||
}): Promise<KiloTranscriptionModelsResult> {
|
||||
const token = options?.kilocodeToken
|
||||
const organizationId = options?.kilocodeOrganizationId
|
||||
const url = new URL("transcription-models", resolveKiloGatewayBaseUrl({ baseURL: options?.baseURL, token }))
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...getDefaultHeaders(),
|
||||
...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
|
||||
}).catch((err: unknown) => err as Error)
|
||||
|
||||
if (response instanceof Error) return { models: [], error: { kind: "network" } }
|
||||
if (!response.ok) {
|
||||
const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http"
|
||||
return { models: [], error: { kind, status: response.status } }
|
||||
}
|
||||
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!json || !Array.isArray(json.data)) return { models: [], error: { kind: "schema" } }
|
||||
|
||||
const data: unknown[] = json.data
|
||||
const models = data.filter(isTranscriptionModel).map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
}))
|
||||
if (models.length === 0) return { models: [], error: { kind: "schema" } }
|
||||
return { models }
|
||||
}
|
||||
|
||||
type TranscriptionModelResponse = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function isTranscriptionModel(value: unknown): value is TranscriptionModelResponse {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const model = value as Record<string, unknown>
|
||||
return typeof model.id === "string" && typeof model.name === "string"
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared raw fetch + validate used by both {@link fetchKiloModels} and {@link fetchKiloImageModels}.
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,9 @@ export {
|
||||
fetchKiloImageModels,
|
||||
type KiloImageModel,
|
||||
type KiloImageModelsResult,
|
||||
fetchKiloTranscriptionModels,
|
||||
type KiloTranscriptionModel,
|
||||
type KiloTranscriptionModelsResult,
|
||||
} from "./api/models.js"
|
||||
export {
|
||||
EMPTY_KILO_EMBEDDING_MODEL_CATALOG,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Verifies fetchKiloModels typed result and 401 fallback behaviour.
|
||||
|
||||
import { test, expect } from "bun:test"
|
||||
import { fetchKiloModels } from "../../src/api/models.js"
|
||||
import { fetchKiloModels, fetchKiloTranscriptionModels } from "../../src/api/models.js"
|
||||
|
||||
const VALID_RESPONSE = JSON.stringify({
|
||||
data: [
|
||||
@@ -330,3 +330,56 @@ test("keeps image-output models with tools and drops models without tools", asyn
|
||||
expect(result.models["test/model-a"]).toBeDefined()
|
||||
expect(result.models["test/no-tools"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("fetches and filters the transcription catalog", async () => {
|
||||
const orig = globalThis.fetch
|
||||
const calls: string[] = []
|
||||
stubFetch(async (input) => {
|
||||
calls.push(String(input))
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "fish-audio/transcribe-1",
|
||||
name: "Fish Audio: Transcribe 1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchKiloTranscriptionModels({ kilocodeToken: "token" })
|
||||
|
||||
;(globalThis as any).fetch = orig
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.models).toEqual([
|
||||
{
|
||||
id: "fish-audio/transcribe-1",
|
||||
name: "Fish Audio: Transcribe 1",
|
||||
},
|
||||
])
|
||||
expect(calls[0]).toContain("/api/gateway/transcription-models")
|
||||
})
|
||||
|
||||
test("keeps organization catalog errors from silently falling back to personal models", async () => {
|
||||
const orig = globalThis.fetch
|
||||
const calls: string[] = []
|
||||
const headers: Headers[] = []
|
||||
stubFetch(async (input, init) => {
|
||||
calls.push(String(input))
|
||||
headers.push(new Headers(init?.headers))
|
||||
return new Response("Forbidden", { status: 403 })
|
||||
})
|
||||
|
||||
const result = await fetchKiloTranscriptionModels({ kilocodeToken: "token", kilocodeOrganizationId: "org-1" })
|
||||
|
||||
;(globalThis as any).fetch = orig
|
||||
|
||||
expect(result.models).toEqual([])
|
||||
expect(result.error?.kind).toBe("unauthorized")
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toContain("/api/gateway/transcription-models")
|
||||
expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-indexing",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Standalone indexing engine and host helpers for Kilo Code",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 7.4.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`1a506a7`](https://github.com/Kilo-Org/kilocode/commit/1a506a712c43d317a5a34b250df16845b641eff8) - Keep the JetBrains prompt send/stop button in sync when attachments are added or removed while a session is busy.
|
||||
|
||||
- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`64f0373`](https://github.com/Kilo-Org/kilocode/commit/64f0373056b75546a015816dc0f18b1e380ad93f) - Fix JetBrains diff views to show compact workspace-relative file paths and keep added-file content visible in large branch diffs.
|
||||
|
||||
- [#12746](https://github.com/Kilo-Org/kilocode/pull/12746) [`c1f6a75`](https://github.com/Kilo-Org/kilocode/commit/c1f6a75377b438edfc5c3b5dd85ebdc301302e7a) - Fix JetBrains chat transcripts rendering cropped when opening existing sessions.
|
||||
|
||||
## 7.5.0
|
||||
|
||||
### Minor Changes
|
||||
@@ -150,6 +160,7 @@
|
||||
## [7.0.12-rc.4] - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve large branch diff performance by capping huge inline diff previews, compacting diff tree paths, and allowing horizontal scrolling for long file names.
|
||||
- Reflow existing long chat sessions after they load so transcripts lay out at the correct width without needing to resize the tool window.
|
||||
- Keep the prompt send/stop button synchronized when attachments are added, removed, or cleared while a session is busy.
|
||||
|
||||
@@ -20,9 +20,11 @@ val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.jso
|
||||
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
|
||||
val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
|
||||
val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res")
|
||||
val generatedChecksums = layout.buildDirectory.dir("generated/kilo-cli-checksums")
|
||||
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true)
|
||||
val repoCli = pinned.map { !it }
|
||||
val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false)
|
||||
val downloadsCli = repoCli.zip(bundled) { repo, bundle -> !repo && !bundle }
|
||||
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
|
||||
|
||||
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
|
||||
@@ -33,6 +35,7 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
|
||||
sourceSets {
|
||||
main {
|
||||
resources.srcDir(generatedProps)
|
||||
if (downloadsCli.get()) resources.srcDir(generatedChecksums)
|
||||
if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli)
|
||||
kotlin.srcDir(generatedApi)
|
||||
}
|
||||
@@ -104,6 +107,16 @@ val stageBundledCli by tasks.registering(StageBundledCliTask::class) {
|
||||
archive.set(generatedCli.map { it.file("kilo-cli.zip") })
|
||||
}
|
||||
|
||||
val writeCliChecksums by tasks.registering(WriteCliChecksumsTask::class) {
|
||||
description = "Write pinned Kilo CLI checksums"
|
||||
cliVersion.set(pinnedCliVersion)
|
||||
token.set(
|
||||
providers.environmentVariable("GH_TOKEN")
|
||||
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
|
||||
)
|
||||
checksums.set(generatedChecksums.map { it.file("kilo-cli-checksums.properties") })
|
||||
}
|
||||
|
||||
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
|
||||
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
|
||||
dependsOn(generateOpenApiSpec)
|
||||
@@ -158,6 +171,7 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
|
||||
|
||||
tasks.named("compileKotlin") {
|
||||
dependsOn(fixGeneratedApi, writeKiloProperties)
|
||||
if (downloadsCli.get()) dependsOn(writeCliChecksums)
|
||||
if (repoCli.get()) dependsOn(stageRepoCli)
|
||||
if (bundled.get()) dependsOn(stageBundledCli)
|
||||
inputs.dir(generatedApi)
|
||||
@@ -165,6 +179,7 @@ tasks.named("compileKotlin") {
|
||||
|
||||
tasks.named("processResources") {
|
||||
dependsOn(writeKiloProperties)
|
||||
if (downloadsCli.get()) dependsOn(writeCliChecksums)
|
||||
if (repoCli.get()) dependsOn(stageRepoCli)
|
||||
if (bundled.get()) dependsOn(stageBundledCli)
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import java.util.Properties
|
||||
|
||||
object KiloCliChecksums {
|
||||
private const val RESOURCE = "kilo-cli-checksums.properties"
|
||||
|
||||
private val values by lazy {
|
||||
val stream = KiloCliChecksums::class.java.classLoader.getResourceAsStream(RESOURCE)
|
||||
?: return@lazy emptyMap()
|
||||
stream.use {
|
||||
Properties().apply { load(it) }
|
||||
.entries
|
||||
.associate { item -> item.key.toString() to item.value.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
fun load(): Map<String, String> = values
|
||||
}
|
||||
+13
-1
@@ -35,6 +35,7 @@ class KiloCliDownloader(
|
||||
private val root: File = File(PathManager.getSystemPath(), "kilo/cli"),
|
||||
private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download",
|
||||
private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags",
|
||||
private val digests: Map<String, String> = KiloCliChecksums.load(),
|
||||
private val lockTimeoutMs: Long = LOCK_TIMEOUT_MS,
|
||||
) {
|
||||
companion object {
|
||||
@@ -64,7 +65,7 @@ class KiloCliDownloader(
|
||||
cached(version, platform, exe, done)?.let { return@locked it }
|
||||
}
|
||||
|
||||
val digest = asset(version, platform, ext)
|
||||
val digest = digest(version, platform, ext)
|
||||
val stage = stage(version, platform)
|
||||
try {
|
||||
val archive = File(stage, "kilo-$platform.$ext")
|
||||
@@ -198,6 +199,17 @@ class KiloCliDownloader(
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
|
||||
private fun digest(version: String, platform: String, ext: String): String {
|
||||
val digest = digests[platform]
|
||||
if (digest == null) return asset(version, platform, ext)
|
||||
if (digest.matches(DIGEST)) {
|
||||
log.info("Using bundled Kilo CLI checksum for $version $platform")
|
||||
return digest
|
||||
}
|
||||
log.warn("Ignoring malformed bundled Kilo CLI checksum for $platform: $digest")
|
||||
return asset(version, platform, ext)
|
||||
}
|
||||
|
||||
private fun asset(version: String, platform: String, ext: String): String {
|
||||
val name = "kilo-$platform.$ext"
|
||||
val url = "${api.trimEnd('/')}/v$version"
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.util.system.CpuArch
|
||||
|
||||
internal object KiloCliPlatform {
|
||||
// Keep supported OS/architecture pairs in sync with Gradle CLI staging task platform lists.
|
||||
fun current(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
|
||||
+89
-7
@@ -30,8 +30,8 @@ class KiloCliDownloaderTest {
|
||||
fun `downloads extracts and caches pinned cli`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val digests = digests(bytes)
|
||||
val seen = mutableListOf<CliDownload>()
|
||||
val log = TestLog()
|
||||
val cli = KiloCliDownloader(
|
||||
@@ -39,13 +39,13 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = digests,
|
||||
).resolve("1.2.3", onProgress = { seen.add(it) })
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath)
|
||||
assertEquals("#!/bin/sh\n", cli.readText())
|
||||
assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile)
|
||||
assertEquals("/api/v1.2.3", server.takeRequest().path)
|
||||
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
|
||||
assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first())
|
||||
assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() })
|
||||
@@ -65,34 +65,83 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = digests,
|
||||
).resolve("1.2.3", onProgress = { cachedProgress.add(it) })
|
||||
assertEquals(cli.absolutePath, cached.absolutePath)
|
||||
assertEquals(2, server.requestCount)
|
||||
assertEquals(1, server.requestCount)
|
||||
assertTrue(cachedProgress.isEmpty())
|
||||
assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}")
|
||||
|
||||
File(cli.parentFile.parentFile, ".complete").writeText("ok\n")
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val stale = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = digests,
|
||||
).resolve("1.2.3")
|
||||
assertEquals(cli.absolutePath, stale.absolutePath)
|
||||
assertEquals(4, server.requestCount)
|
||||
assertEquals(2, server.requestCount)
|
||||
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val forced = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = digests,
|
||||
).resolve("1.2.3", force = true)
|
||||
assertEquals(cli.absolutePath, forced.absolutePath)
|
||||
assertEquals(6, server.requestCount)
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to github metadata when bundled checksum is missing`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = mapOf("other" to "sha256:${"a".repeat(64)}"),
|
||||
).resolve("1.2.3")
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals("/api/v1.2.3", server.takeRequest().path)
|
||||
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
|
||||
assertEquals(2, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to github metadata when bundled checksum is malformed`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val log = TestLog()
|
||||
|
||||
val cli = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = mapOf(KiloCliPlatform.current() to "not-a-digest"),
|
||||
).resolve("1.2.3")
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertContains(
|
||||
log.messages,
|
||||
"WARN: Ignoring malformed bundled Kilo CLI checksum for ${KiloCliPlatform.current()}: not-a-digest"
|
||||
)
|
||||
assertEquals("/api/v1.2.3", server.takeRequest().path)
|
||||
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
|
||||
assertEquals(2, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +159,7 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = emptyMap(),
|
||||
).resolve("1.2.3")
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
@@ -132,6 +182,7 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = emptyMap(),
|
||||
)
|
||||
val old = cli.resolve("1.2.3")
|
||||
assertEquals("#!/bin/old\n", old.readText())
|
||||
@@ -158,12 +209,14 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = emptyMap(),
|
||||
).resolve("1.2.3")
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = emptyMap(),
|
||||
).resolve("1.2.3", force = true)
|
||||
}
|
||||
|
||||
@@ -186,6 +239,7 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = emptyMap(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
|
||||
@@ -194,6 +248,28 @@ class KiloCliDownloaderTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects cli archive with mismatched bundled checksum without fetching metadata`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = mapOf(KiloCliPlatform.current() to "sha256:${sha256("different".toByteArray())}"),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "digest mismatch")
|
||||
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
|
||||
assertEquals(1, server.requestCount)
|
||||
assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails clearly and logs when the release has no matching asset`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
@@ -209,6 +285,7 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = emptyMap(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
val name = "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"
|
||||
@@ -233,6 +310,7 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = emptyMap(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
assertContains(ex.message.orEmpty(), "has no digest yet")
|
||||
@@ -261,6 +339,7 @@ class KiloCliDownloaderTest {
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
digests = emptyMap(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
|
||||
@@ -284,6 +363,7 @@ class KiloCliDownloaderTest {
|
||||
KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
digests = emptyMap(),
|
||||
lockTimeoutMs = 50,
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
@@ -307,6 +387,8 @@ class KiloCliDownloaderTest {
|
||||
|
||||
private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}")
|
||||
|
||||
private fun digests(bytes: ByteArray) = mapOf(KiloCliPlatform.current() to "sha256:${sha256(bytes)}")
|
||||
|
||||
private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody(
|
||||
"""{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}"""
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import org.gradle.api.Project
|
||||
* `id("build-tasks")` resolves in `backend/build.gradle.kts`.
|
||||
*
|
||||
* The real value lives in the custom task classes this composite build
|
||||
* provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], and
|
||||
* [GenerateOpenApiSpecTask].
|
||||
* provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask],
|
||||
* [GenerateOpenApiSpecTask], and [WriteCliChecksumsTask].
|
||||
*/
|
||||
class BuildTasksPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.Internal
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.time.Instant
|
||||
|
||||
abstract class WriteCliChecksumsTask : DefaultTask() {
|
||||
companion object {
|
||||
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
|
||||
// Keep in sync with KiloCliPlatform.current() and StageBundledCliTask.PLATFORMS.
|
||||
private val PLATFORMS = listOf(
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-arm64",
|
||||
"linux-x64",
|
||||
"windows-arm64",
|
||||
"windows-x64",
|
||||
)
|
||||
}
|
||||
|
||||
@get:Input
|
||||
abstract val cliVersion: Property<String>
|
||||
|
||||
@get:Internal
|
||||
abstract val token: Property<String>
|
||||
|
||||
@get:OutputFile
|
||||
abstract val checksums: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val ver = cliVersion.get()
|
||||
val assets = assets(ver)
|
||||
val values = PLATFORMS.associateWith { platform ->
|
||||
val name = "kilo-$platform.${ext(platform)}"
|
||||
assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name")
|
||||
}
|
||||
|
||||
val out = checksums.get().asFile
|
||||
out.parentFile.mkdirs()
|
||||
out.writeText(
|
||||
values.entries
|
||||
.sortedBy { it.key }
|
||||
.joinToString(separator = "\n", postfix = "\n") { item -> "${item.key}=${item.value}" }
|
||||
)
|
||||
}
|
||||
|
||||
private fun assets(ver: String): Map<String, String> {
|
||||
val url = "$API/v$ver"
|
||||
logger.lifecycle("Fetching pinned Kilo CLI release checksums from $url")
|
||||
val conn = connect(url)
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release checksums")
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
|
||||
?.associate { item ->
|
||||
val obj = item.jsonObject
|
||||
val name = obj["name"]?.jsonPrimitive?.contentOrNull
|
||||
val digest = obj["digest"]?.jsonPrimitive?.contentOrNull
|
||||
if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to ""
|
||||
name to digest
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?.mapValues { item ->
|
||||
val digest = item.value
|
||||
if (!digest.matches(DIGEST)) {
|
||||
throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest")
|
||||
}
|
||||
digest
|
||||
}
|
||||
?: emptyMap()
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect(url: String): HttpURLConnection {
|
||||
val conn = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
token.getOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
||||
return conn
|
||||
}
|
||||
|
||||
private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing {
|
||||
val info = rate(conn)
|
||||
val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }
|
||||
.getOrNull()
|
||||
?.take(500)
|
||||
val detail = if (body.isNullOrBlank()) "" else ": $body"
|
||||
if (limited(conn, code)) {
|
||||
throw GradleException("GitHub API rate limit exceeded while fetching Kilo CLI checksums ($info)$detail")
|
||||
}
|
||||
throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail")
|
||||
}
|
||||
|
||||
private fun rate(conn: HttpURLConnection): String {
|
||||
val reset = conn.getHeaderField("X-RateLimit-Reset")
|
||||
?.toLongOrNull()
|
||||
?.let { Instant.ofEpochSecond(it).toString() }
|
||||
return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " +
|
||||
"used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}"
|
||||
}
|
||||
|
||||
private fun limited(conn: HttpURLConnection, code: Int) =
|
||||
code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0")
|
||||
|
||||
private fun ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip"
|
||||
}
|
||||
+33
-13
@@ -18,35 +18,55 @@ data class PromptAttachment(
|
||||
val mime: String,
|
||||
val url: String,
|
||||
val path: Path? = null,
|
||||
val reference: Boolean = false,
|
||||
) {
|
||||
fun part() = PromptPartDto(
|
||||
type = "file",
|
||||
mime = mime,
|
||||
url = path?.let { data(it, mime) } ?: url,
|
||||
filename = name,
|
||||
)
|
||||
fun part(): PromptPartDto {
|
||||
if (reference) {
|
||||
return PromptPartDto(
|
||||
type = "file",
|
||||
mime = mime,
|
||||
url = url,
|
||||
filename = name,
|
||||
)
|
||||
}
|
||||
return PromptPartDto(
|
||||
type = "file",
|
||||
mime = mime,
|
||||
url = path?.let { data(it, mime) } ?: url,
|
||||
filename = name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object PromptAttachmentExtractor {
|
||||
private const val MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
fun files(files: List<java.io.File>): List<PromptAttachment> = files
|
||||
.filter { it.exists() && it.isFile && it.canRead() && it.length() <= MAX_BYTES }
|
||||
.map { file ->
|
||||
.filter { it.exists() && it.canRead() }
|
||||
.mapNotNull { file ->
|
||||
val path = file.toPath()
|
||||
val mime = mime(file)
|
||||
if (!media(mime)) return@map null
|
||||
PromptAttachment(
|
||||
if (image(mime)) {
|
||||
if (!file.isFile || file.length() > MAX_BYTES) return@mapNotNull null
|
||||
return@mapNotNull PromptAttachment(
|
||||
id = path.toAbsolutePath().normalize().toString(),
|
||||
name = path.fileName?.toString() ?: path.name,
|
||||
mime = mime,
|
||||
url = path.toUri().toString(),
|
||||
path = path,
|
||||
)
|
||||
}
|
||||
return@mapNotNull PromptAttachment(
|
||||
id = path.toAbsolutePath().normalize().toString(),
|
||||
name = path.fileName?.toString() ?: path.name,
|
||||
mime = mime,
|
||||
mime = if (file.isDirectory) mime else "text/plain",
|
||||
url = path.toUri().toString(),
|
||||
path = path,
|
||||
reference = true,
|
||||
)
|
||||
}
|
||||
.filterNotNull()
|
||||
|
||||
fun media(mime: String): Boolean = mime.startsWith("image/") || mime == "text/plain"
|
||||
fun image(mime: String): Boolean = mime.startsWith("image/")
|
||||
|
||||
fun image(raw: Any): PromptAttachment? {
|
||||
val image = when (raw) {
|
||||
|
||||
+13
-3
@@ -726,7 +726,7 @@ class PromptPanel(
|
||||
|
||||
@RequiresEdt
|
||||
private fun addAttachment(item: PromptAttachment) {
|
||||
if (!attachment && PromptAttachmentExtractor.media(item.mime)) {
|
||||
if (!attachment && !item.reference && PromptAttachmentExtractor.image(item.mime)) {
|
||||
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=unsupported-model" }
|
||||
notify(KiloBundle.message("prompt.attachment.unsupported.model"))
|
||||
return
|
||||
@@ -813,7 +813,15 @@ class PromptPanel(
|
||||
val items = PromptAttachmentExtractor.files(list) + listOfNotNull(image)
|
||||
val ms = elapsedMs(start)
|
||||
LOG.debug { "kind=$kind extract area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
|
||||
if (items.isEmpty()) return@executeOnPooledThread
|
||||
if (items.isEmpty()) {
|
||||
if (list.isNotEmpty()) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
notify(KiloBundle.message("prompt.attachment.drop.empty"))
|
||||
}
|
||||
}
|
||||
return@executeOnPooledThread
|
||||
}
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
LOG.debug { "kind=$kind attach area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
|
||||
@@ -827,7 +835,9 @@ class PromptPanel(
|
||||
|
||||
private fun dropFiles(event: DnDEvent): List<java.io.File> {
|
||||
if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) return emptyList()
|
||||
return FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject).orEmpty()
|
||||
val files = FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject)
|
||||
if (files.isNotEmpty()) return files
|
||||
return FileCopyPasteUtil.getFileList(event).orEmpty()
|
||||
}
|
||||
|
||||
private fun elapsedMs(start: Long) = (System.nanoTime() - start) / 1_000_000
|
||||
|
||||
@@ -217,7 +217,8 @@ prompt.attachment.remove=Remove {0}
|
||||
prompt.attachment.open=Open {0}
|
||||
prompt.attachment.tooltip=Name: {0}\nType: {1}\nLocation: {2}
|
||||
prompt.attachment.embedded=Embedded content
|
||||
prompt.attachment.unsupported.model=The selected model does not support image or PDF attachments.
|
||||
prompt.attachment.unsupported.model=The selected model does not support image attachments.
|
||||
prompt.attachment.drop.empty=No files could be added.
|
||||
prompt.attachment.missing=Attachment no longer exists: {0}
|
||||
prompt.attachment.send.failed=Failed to send attachment: {0}
|
||||
session.attachment.title=Attachment
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package ai.kilocode.client.session.model
|
||||
|
||||
import junit.framework.TestCase
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
|
||||
class PromptAttachmentExtractorTest : TestCase() {
|
||||
|
||||
fun `test code file becomes reference attachment`() {
|
||||
val file = File.createTempFile("kilo-drop", ".php")
|
||||
file.writeText("<?php echo 'hello';")
|
||||
|
||||
val item = PromptAttachmentExtractor.files(listOf(file)).single()
|
||||
val part = item.part()
|
||||
|
||||
assertTrue(item.reference)
|
||||
assertEquals("text/plain", item.mime)
|
||||
assertEquals(file.toPath().toUri().toString(), item.url)
|
||||
assertEquals(file.toPath(), item.path)
|
||||
assertEquals(file.name, item.name)
|
||||
assertEquals("file", part.type)
|
||||
assertEquals("text/plain", part.mime)
|
||||
assertEquals(file.toPath().toUri().toString(), part.url)
|
||||
assertFalse(part.url.orEmpty().startsWith("data:"))
|
||||
}
|
||||
|
||||
fun `test text file becomes reference attachment`() {
|
||||
val file = File.createTempFile("kilo-drop", ".txt")
|
||||
file.writeText("hello")
|
||||
|
||||
val item = PromptAttachmentExtractor.files(listOf(file)).single()
|
||||
|
||||
assertTrue(item.reference)
|
||||
assertEquals("text/plain", item.mime)
|
||||
assertEquals(file.toPath().toUri().toString(), item.part().url)
|
||||
}
|
||||
|
||||
fun `test directory becomes reference attachment`() {
|
||||
val dir = createTempDirectory(prefix = "kilo-drop").toFile()
|
||||
|
||||
val item = PromptAttachmentExtractor.files(listOf(dir)).single()
|
||||
val part = item.part()
|
||||
|
||||
assertTrue(item.reference)
|
||||
assertEquals("application/x-directory", item.mime)
|
||||
assertEquals(dir.toPath().toUri().toString(), item.url)
|
||||
assertEquals(dir.toPath(), item.path)
|
||||
assertEquals(dir.name, item.name)
|
||||
assertEquals("file", part.type)
|
||||
assertEquals("application/x-directory", part.mime)
|
||||
assertEquals(dir.toPath().toUri().toString(), part.url)
|
||||
}
|
||||
|
||||
fun `test image file remains embedded attachment`() {
|
||||
val file = File.createTempFile("kilo-drop", ".png")
|
||||
file.writeBytes(byteArrayOf(1, 2, 3))
|
||||
|
||||
val item = PromptAttachmentExtractor.files(listOf(file)).single()
|
||||
|
||||
assertFalse(item.reference)
|
||||
assertEquals("image/png", item.mime)
|
||||
assertEquals(file.toPath().toUri().toString(), item.url)
|
||||
assertTrue(item.part().url.orEmpty().startsWith("data:image/png;base64,"))
|
||||
}
|
||||
|
||||
fun `test oversized image is skipped but oversized code is referenced`() {
|
||||
val image = File.createTempFile("kilo-drop", ".png")
|
||||
image.writeBytes(ByteArray(10 * 1024 * 1024 + 1))
|
||||
val code = File.createTempFile("kilo-drop", ".php")
|
||||
code.writeBytes(ByteArray(10 * 1024 * 1024 + 1))
|
||||
|
||||
val items = PromptAttachmentExtractor.files(listOf(image, code))
|
||||
|
||||
assertEquals(listOf(code.name), items.map { it.name })
|
||||
assertTrue(items.single().reference)
|
||||
}
|
||||
|
||||
fun `test nonexistent file is ignored`() {
|
||||
val file = File(File(System.getProperty("java.io.tmpdir")), "kilo-missing-${System.nanoTime()}.php")
|
||||
|
||||
assertTrue(PromptAttachmentExtractor.files(listOf(file)).isEmpty())
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -966,17 +966,18 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
assertEquals(1, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test frontend file attachment defers data url encoding until send`() {
|
||||
fun `test frontend text file attachment stays file reference`() {
|
||||
val file = File.createTempFile("kilo-paste", ".txt")
|
||||
file.writeText("hello")
|
||||
|
||||
val item = ai.kilocode.client.session.model.PromptAttachmentExtractor.files(listOf(file)).single()
|
||||
|
||||
assertTrue(item.reference)
|
||||
assertTrue(item.url.startsWith("file://"))
|
||||
assertTrue(item.part().url.orEmpty().startsWith("data:text/plain;base64,"))
|
||||
assertEquals(item.url, item.part().url)
|
||||
}
|
||||
|
||||
fun `test pasted frontend file sends data url payload`() {
|
||||
fun `test pasted frontend file sends reference payload`() {
|
||||
var sent: ai.kilocode.rpc.dto.PromptPartDto? = null
|
||||
val panel = PromptPanel(project, { _, files -> sent = files.single() }, {}, { _, _ -> })
|
||||
val file = File.createTempFile("kilo-paste", ".txt")
|
||||
@@ -990,8 +991,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
|
||||
val item = sent!!
|
||||
assertEquals("text/plain", item.mime)
|
||||
assertTrue(item.url.orEmpty().startsWith("data:text/plain;base64,"))
|
||||
assertFalse(item.url.orEmpty().startsWith("file://"))
|
||||
assertEquals(file.toPath().toUri().toString(), item.url)
|
||||
}
|
||||
|
||||
fun `test raw image paste adds attachment`() {
|
||||
@@ -1038,6 +1038,18 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
assertEquals(0, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test disabled media model allows file reference attachment`() {
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
val file = File.createTempFile("kilo-paste", ".php")
|
||||
file.writeText("<?php echo 'hello';")
|
||||
val item = ai.kilocode.client.session.model.PromptAttachmentExtractor.files(listOf(file)).single()
|
||||
panel.setAttachmentEnabled(false)
|
||||
|
||||
panel.addAttachmentForTest(item)
|
||||
|
||||
assertEquals(1, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test prompt button switches between send and stop state`() {
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"test": "./gradlew test",
|
||||
"test:ci": "bun script/test-ci.ts"
|
||||
},
|
||||
"version": "7.4.17",
|
||||
"version": "7.4.20",
|
||||
"dependencies": {},
|
||||
"devDependencies": {},
|
||||
"peerDependencies": {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-memory",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Project memory storage, indexing, recall, and command helpers for Kilo Code",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/sandbox",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -85,6 +85,19 @@ describe("TelemetryEvent", () => {
|
||||
})
|
||||
|
||||
describe("Telemetry", () => {
|
||||
test("skips identity updates when disabled", async () => {
|
||||
const enabled = spyOn(Client, "isEnabled").mockReturnValue(false)
|
||||
const update = spyOn(Identity, "updateFromKiloAuth").mockResolvedValue()
|
||||
|
||||
try {
|
||||
await Telemetry.updateIdentity("token")
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
enabled.mockRestore()
|
||||
update.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("includes host OS properties", () => {
|
||||
const capture = spyOn(Client, "capture").mockImplementation(() => {})
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export namespace Telemetry {
|
||||
}
|
||||
|
||||
export async function updateIdentity(token: string | null, accountId?: string): Promise<void> {
|
||||
if (!isEnabled()) return
|
||||
|
||||
const previousId = Identity.getDistinctId()
|
||||
await Identity.updateFromKiloAuth(token, accountId)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2799,18 +2799,9 @@ ToolRegistry.register({
|
||||
>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const active = createMemo(() => expanded().includes(file.filePath))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
})
|
||||
// Diff defers its own expensive render; mounting the container
|
||||
// here avoids dropping the last item during batch expansion.
|
||||
const active = createMemo(() => allExpanded().includes(file.filePath))
|
||||
|
||||
return (
|
||||
<Accordion.Item value={file.filePath} data-type={file.type}>
|
||||
@@ -2864,7 +2855,7 @@ ToolRegistry.register({
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible() && view(file)}>
|
||||
<Show when={active() && view(file)}>
|
||||
{(diff) => (
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# kilo-code
|
||||
|
||||
## 7.4.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12841](https://github.com/Kilo-Org/kilocode/pull/12841) [`744c5c4`](https://github.com/Kilo-Org/kilocode/commit/744c5c4a6b244d2aac38b7acc08f98a4086544ad) - Discover available speech-to-text models from the Kilo Gateway while retaining offline fallback support and organization model restrictions.
|
||||
|
||||
- [#12842](https://github.com/Kilo-Org/kilocode/pull/12842) [`2613ff9`](https://github.com/Kilo-Org/kilocode/commit/2613ff93c6446eb3a662ed6d4a4b063131a6a371) - Show the first Agent Manager terminal prompt sooner by deferring optional terminal enhancements until after initial paint.
|
||||
|
||||
- [#12839](https://github.com/Kilo-Org/kilocode/pull/12839) [`4af1d1c`](https://github.com/Kilo-Org/kilocode/commit/4af1d1c8abd15881f5c06554cca8e2f6133003ee) - Keep the final file visible when expanding multi-file patch results.
|
||||
|
||||
## 7.4.19
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#12729](https://github.com/Kilo-Org/kilocode/pull/12729) [`ce7984f`](https://github.com/Kilo-Org/kilocode/commit/ce7984fc4247fb2805990ef62e1b1d9f286de9d9) - Configure a model and reasoning variant for each workflow from Agent Behaviour settings.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12796](https://github.com/Kilo-Org/kilocode/pull/12796) [`20d1648`](https://github.com/Kilo-Org/kilocode/commit/20d1648e7a10f981fde09fc4d5e9de5c89b1dda8) - Fix Agent Manager mode shortcuts in the New Worktree dialog so the selected mode and its matching model stay in sync.
|
||||
|
||||
- [#12725](https://github.com/Kilo-Org/kilocode/pull/12725) [`f239f36`](https://github.com/Kilo-Org/kilocode/commit/f239f36f65a1edb5ef89748933b49b3f21639a45) - Show aggregate added and removed line counts for multi-file patch tool calls.
|
||||
|
||||
- [#12803](https://github.com/Kilo-Org/kilocode/pull/12803) [`9819c1c`](https://github.com/Kilo-Org/kilocode/commit/9819c1c315bd05631862ad6f21bbef21d73b7bdc) - Restore Agent Manager sections and worktree drag-and-drop when multiple projects are shown, with ordering and section moves scoped to the owning project.
|
||||
|
||||
- [#12836](https://github.com/Kilo-Org/kilocode/pull/12836) [`4f7dfe6`](https://github.com/Kilo-Org/kilocode/commit/4f7dfe65cc85ef7d103a9d0d93f86b030f7142e0) - Remove the duplicate border along the Kilo Code sidebar edge.
|
||||
|
||||
- [#12814](https://github.com/Kilo-Org/kilocode/pull/12814) [`cd7d053`](https://github.com/Kilo-Org/kilocode/commit/cd7d053f03fa7b5434b6fcae9e2c68e415b54331) - Start voice input faster on macOS with native AVFoundation capture.
|
||||
|
||||
- [#12795](https://github.com/Kilo-Org/kilocode/pull/12795) [`37559f8`](https://github.com/Kilo-Org/kilocode/commit/37559f8643ef8ecb68ee04eae770c9b47feee88a) - Restore keyboard focus to the prompt or pending question when switching Agent Manager worktrees and sessions.
|
||||
|
||||
- [#12799](https://github.com/Kilo-Org/kilocode/pull/12799) [`7cfaeb2`](https://github.com/Kilo-Org/kilocode/commit/7cfaeb2c8dbe7355b1d92d9a8f62d6bdfb7f6d46) - Show and hide Agent Manager worktree hover cards instantly.
|
||||
|
||||
- [#12805](https://github.com/Kilo-Org/kilocode/pull/12805) [`8490124`](https://github.com/Kilo-Org/kilocode/commit/84901241c0c6dcb735d7e229bd9bce2c0f7e5c36) - Keep the prompt controls at a consistent height when the model selector shows the prompt-training indicator.
|
||||
|
||||
- [#12810](https://github.com/Kilo-Org/kilocode/pull/12810) [`e04f653`](https://github.com/Kilo-Org/kilocode/commit/e04f6531bf3ef2eed5cde6e1d81a4983f276b9ca) - Update model search results instantly and keep the active match visible while typing.
|
||||
|
||||
- [#12815](https://github.com/Kilo-Org/kilocode/pull/12815) [`3d4294e`](https://github.com/Kilo-Org/kilocode/commit/3d4294e3bb50630159233c8063b253dc5f3da8d3) - Allow Agent Manager sessions to move their worktree between sections or ungroup it through the `agent_manager` tool.
|
||||
|
||||
- [#12798](https://github.com/Kilo-Org/kilocode/pull/12798) [`dfc1607`](https://github.com/Kilo-Org/kilocode/commit/dfc16076934decb6578730ea41f9705e2acc0923) - Bind voice input to Cmd/Ctrl+K in Kilo prompt and review comment fields, with hold-to-talk and release-to-send support.
|
||||
|
||||
- [#12801](https://github.com/Kilo-Org/kilocode/pull/12801) [`4810766`](https://github.com/Kilo-Org/kilocode/commit/4810766d058f53084b8a6cb0fe4d1ff42973e89b) - Preserve prompt or Agent Manager terminal focus independently for each session when switching sessions.
|
||||
|
||||
- [#12812](https://github.com/Kilo-Org/kilocode/pull/12812) [`6ae16f9`](https://github.com/Kilo-Org/kilocode/commit/6ae16f9d6272b3bfba998bb516cba7667935d6e4) - Start a fresh shell in the same Agent Manager terminal tab when the user types after the terminal ends.
|
||||
|
||||
- [#12733](https://github.com/Kilo-Org/kilocode/pull/12733) [`63220e0`](https://github.com/Kilo-Org/kilocode/commit/63220e019c048d6df639a4b2fbd4c5c3f124547f) Thanks [@rakshith1928](https://github.com/rakshith1928)! - Fix skill folder path and URL rows clipping and pushing the remove (×) button off-screen in narrow Skills settings panels. Long paths and URLs now truncate within their row, and hovering a truncated value shows the full path or URL in a tooltip.
|
||||
|
||||
## 7.4.18
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "kilo-code",
|
||||
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
|
||||
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
|
||||
"version": "7.4.18",
|
||||
"version": "7.4.20",
|
||||
"icon": "assets/icons/logo-outline-black.png",
|
||||
"galleryBanner": {
|
||||
"color": "#FFFFFF",
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
import { GitOps } from "./agent-manager/GitOps"
|
||||
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
|
||||
import { diffSummary as localDiffSummary } from "./agent-manager/local-diff"
|
||||
import { getWorkspaceRoot } from "./review-utils"
|
||||
import { createMarketplaceRemover, removeMcp } from "./kilo-provider/remove-config-item"
|
||||
import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller"
|
||||
import type { RemoteStatusService } from "./services/RemoteStatusService"
|
||||
@@ -169,6 +168,8 @@ import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/projec
|
||||
import { indexingConsentStore, registeredProjects } from "./indexing-consent"
|
||||
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
|
||||
import { fetchImageModels } from "./image-generation/models"
|
||||
import { fetchSpeechToTextModels } from "./speech-to-text/catalog"
|
||||
import { SPEECH_TO_TEXT_MODELS } from "./speech-to-text/models"
|
||||
import { stopSessionProcesses } from "./kilo-provider/background-process"
|
||||
import { sandboxDefault, sandboxSessionMetadata } from "./shared/sandbox-session"
|
||||
import {
|
||||
@@ -439,6 +440,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private statsGitOps: GitOps | null = null
|
||||
private cachedStats: unknown = null
|
||||
private cachedGitRepo = false
|
||||
private cachedGitDirectory: string | undefined
|
||||
private gitStatusRevision = 0
|
||||
|
||||
private onBeforeMessage: ((msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>) | null = null
|
||||
|
||||
@@ -1021,6 +1024,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID),
|
||||
copy: (text) => vscode.env.clipboard.writeText(text),
|
||||
openSessions: (ids) => this.trackOpenSessions(ids),
|
||||
speechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
|
||||
})
|
||||
) {
|
||||
return
|
||||
@@ -1661,6 +1665,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// Subscribe to SSE events for this webview (filtered by tracked sessions)
|
||||
this.unsubscribeEvent = this.connectionService.onEventFiltered(
|
||||
(payload, directory) => {
|
||||
if (directory && !this.isCurrentProjectDirectory(directory)) return false
|
||||
if (!directory && isEventFromForeignProject(payload, this.projectID)) return false
|
||||
const event = unwrapSyncEvent(payload)
|
||||
if (!event) return false
|
||||
|
||||
@@ -1672,6 +1678,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
// message.part.* events are always session-scoped; drop if session unknown.
|
||||
if (!sessionId) return !isSessionScopedPartEvent(event.type)
|
||||
if (!directory && !this.isCurrentProjectSession(sessionId)) return false
|
||||
|
||||
if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) {
|
||||
return true
|
||||
@@ -1818,15 +1825,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.memory.fetch(),
|
||||
this.seedSessionStatusMap(),
|
||||
])
|
||||
this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory())
|
||||
this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo })
|
||||
await this.refreshGitStatus(this.getWorkspaceDirectory())
|
||||
this.sendNotificationSettings()
|
||||
this.sendTimelineSetting()
|
||||
this.postMessage(buildThroughputSettingMessage())
|
||||
this.postMessage({ type: "extensionDataReady" })
|
||||
|
||||
if (this.cachedGitRepo) this.startStatsPolling()
|
||||
|
||||
console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully")
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error)
|
||||
@@ -1887,6 +1891,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
/** Non-blocking: refresh session metadata + status for the webview after switching. */
|
||||
private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void {
|
||||
if (!this.client) return
|
||||
void this.refreshGitStatus(dir)
|
||||
const revision = this.revisions.get(sessionID)
|
||||
const refresh = (this.refreshes.get(sessionID) ?? 0) + 1
|
||||
this.refreshes.set(sessionID, refresh)
|
||||
@@ -2102,10 +2107,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private async flushPendingSessionRefresh(reason: string): Promise<void> {
|
||||
if (!this.pendingSessionRefresh) return
|
||||
console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason })
|
||||
const scope = this.opts.projectQualifier?.()?.projectId
|
||||
if (scope !== undefined) this.projectID = undefined
|
||||
const ctx = this.sessionRefreshContext
|
||||
try {
|
||||
const resolved = await flushPendingSessionRefreshUtil(ctx)
|
||||
if (resolved) this.projectID = resolved
|
||||
if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to flush session refresh:", error)
|
||||
}
|
||||
@@ -2116,10 +2123,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
* Handle loading all sessions.
|
||||
*/
|
||||
private async handleLoadSessions(): Promise<void> {
|
||||
const scope = this.opts.projectQualifier?.()?.projectId
|
||||
if (scope !== undefined) this.projectID = undefined
|
||||
const ctx = this.sessionRefreshContext
|
||||
try {
|
||||
const resolved = await loadSessionsUtil(ctx)
|
||||
if (resolved) this.projectID = resolved
|
||||
if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to load sessions:", error)
|
||||
this.postMessage({
|
||||
@@ -2701,6 +2710,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage(message)
|
||||
}
|
||||
|
||||
private async fetchAndSendSpeechToTextModels(): Promise<void> {
|
||||
const result = await fetchSpeechToTextModels(this.connectionService, this.getWorkspaceDirectory())
|
||||
if (!result.ok) {
|
||||
this.postMessage({ type: "speechToTextModelsLoaded" as const, models: [...SPEECH_TO_TEXT_MODELS] })
|
||||
return
|
||||
}
|
||||
this.postMessage({ type: "speechToTextModelsLoaded" as const, models: result.models })
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed sessionStatusMap with current session statuses on connect.
|
||||
* Without this, the Settings panel (which has no tracked sessions) would see
|
||||
@@ -3944,6 +3962,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
disposeGlobal: () => this.disposeGlobal(),
|
||||
fetchAndSendProviders: () => this.fetchAndSendProviders(),
|
||||
fetchAndSendAgents: () => this.fetchAndSendAgents(),
|
||||
fetchAndSendSpeechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4293,9 +4312,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// Drop session events from other projects before any tracking logic.
|
||||
// This must come first: the trackedSessionIds guard below would otherwise
|
||||
// let a foreign session through if it was accidentally tracked.
|
||||
if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return
|
||||
if (directory && !this.isCurrentProjectDirectory(directory)) return
|
||||
if (
|
||||
this.projectID &&
|
||||
(!this.opts.projectQualifier || !directory) &&
|
||||
(event.type === "session.created" || event.type === "session.updated") &&
|
||||
event.properties.info.projectID !== undefined &&
|
||||
event.properties.info.projectID !== null &&
|
||||
@@ -4354,6 +4374,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
)
|
||||
return
|
||||
|
||||
if (event.type === "message.part.updated") this.refreshGitStatusFromPart(event, sessionID)
|
||||
|
||||
if (event.type === "session.updated" && typeof event.properties.info.cost === "number") {
|
||||
const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost)
|
||||
this.requestCostAlert(event.properties.sessionID, cost)
|
||||
@@ -4724,6 +4746,79 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return undefined
|
||||
}
|
||||
|
||||
private isCurrentProjectDirectory(directory: string): boolean {
|
||||
if (!this.opts.projectQualifier?.()) return true
|
||||
const dirs = [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])]
|
||||
return dirs.some((dir) => sameDirectory(dir, directory))
|
||||
}
|
||||
|
||||
private isCurrentProjectSession(sessionID: string): boolean {
|
||||
if (!this.opts.projectQualifier || !this.opts.routeService) return true
|
||||
const directory = this.opts.routeService.trySessionDirectory(sessionID)
|
||||
return !directory || this.isCurrentProjectDirectory(directory)
|
||||
}
|
||||
|
||||
private refreshGitStatusFromPart(
|
||||
event: Extract<ProviderEvent, { type: "message.part.updated" }>,
|
||||
sessionID?: string,
|
||||
) {
|
||||
const part = event.properties.part as {
|
||||
type?: string
|
||||
metadata?: Record<string, unknown>
|
||||
state?: { status?: string; input?: Record<string, unknown>; metadata?: Record<string, unknown> }
|
||||
}
|
||||
if (part.type !== "tool" || part.state?.status !== "completed") return
|
||||
const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath]
|
||||
const file = values.find((value): value is string => typeof value === "string" && value.length > 0)
|
||||
if (!file) return
|
||||
const base = this.getWorkspaceDirectory(sessionID)
|
||||
const value = file.split(",")[0].trim()
|
||||
const pathName = path.isAbsolute(value) ? value : path.resolve(base, value)
|
||||
const directory = path.dirname(pathName)
|
||||
if (!this.isCurrentProjectGitDirectory(directory, sessionID)) return
|
||||
void this.refreshGitStatus(directory)
|
||||
}
|
||||
|
||||
private isCurrentProjectGitDirectory(directory: string, sessionID?: string): boolean {
|
||||
const roots = this.opts.projectQualifier?.()
|
||||
? [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])]
|
||||
: [this.getWorkspaceDirectory(sessionID)]
|
||||
return roots.some((root) => {
|
||||
const rel = path.relative(canonicalizePath(root), canonicalizePath(directory))
|
||||
return rel === "" || (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${path.sep}`))
|
||||
})
|
||||
}
|
||||
|
||||
public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise<void> {
|
||||
const client = this.client
|
||||
if (!client) return
|
||||
const revision = ++this.gitStatusRevision
|
||||
const repo = await hasGit(client, directory)
|
||||
const root = await this.resolveGitRoot(directory)
|
||||
if (revision !== this.gitStatusRevision) return
|
||||
const found = repo || root !== undefined
|
||||
const target = root ?? directory
|
||||
if (!this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target)) this.cachedStats = null
|
||||
this.cachedGitDirectory = target
|
||||
this.cachedGitRepo = found
|
||||
this.postMessage({ type: "gitStatus", repo: found })
|
||||
if (found) {
|
||||
if (!this.statsPoller) this.startStatsPolling()
|
||||
return
|
||||
}
|
||||
this.statsPoller?.stop()
|
||||
this.statsGitOps?.dispose()
|
||||
this.statsPoller = null
|
||||
this.statsGitOps = null
|
||||
}
|
||||
|
||||
private async resolveGitRoot(directory: string): Promise<string | undefined> {
|
||||
const git = this.statsGitOps ?? new GitOps({ log: () => {} })
|
||||
const root = await git.root(directory)
|
||||
if (!this.statsGitOps) git.dispose()
|
||||
return root
|
||||
}
|
||||
|
||||
private getContextDirectory(): string {
|
||||
return resolveContextDirectory({
|
||||
currentSessionID: this.currentSession?.id,
|
||||
@@ -4797,7 +4892,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")),
|
||||
title: "Kilo Code",
|
||||
port: this.connectionService.getServerInfo()?.port,
|
||||
extraStyles: `.container { height: 100%; display: flex; flex-direction: column; height: 100vh; border-right: 1px solid var(--border-weak-base); }`,
|
||||
extraStyles: `.container { height: 100vh; }`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4833,7 +4928,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.statsGitOps = git
|
||||
this.statsPoller = new GitStatsPoller({
|
||||
getWorktrees: () => [],
|
||||
getWorkspaceRoot: () => getWorkspaceRoot(),
|
||||
getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id),
|
||||
localDiff: (dir, base) => localDiffSummary(git, dir, base),
|
||||
git,
|
||||
onStats: () => {},
|
||||
|
||||
@@ -395,6 +395,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.panelSessions.clear()
|
||||
void ctx.sessions.abortSessions(ids).catch((err) => this.log("Failed to abort sessions on panel close:", err))
|
||||
this.statsPoller.stop()
|
||||
this.projectPollers.dispose()
|
||||
this.prBridge.poller.stop()
|
||||
this.diffs.stop()
|
||||
this.activeSessionId = undefined
|
||||
@@ -450,12 +451,14 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
/** Initialize an expanded background project and push its state (no panel wiring). */
|
||||
private initExpanded(ctx: ProjectContext): void {
|
||||
void initContextState(ctx, (...args) => this.log(...args)).then((result) => {
|
||||
if (!result.current) return
|
||||
registerProjectSessions(ctx, this.panel?.sessions)
|
||||
this.pushState(ctx)
|
||||
this.projectPollers.sync(this.contexts)
|
||||
})
|
||||
void initContextState(ctx, (...args) => this.log(...args))
|
||||
.then((result) => {
|
||||
if (!result.current) return
|
||||
registerProjectSessions(ctx, this.panel?.sessions)
|
||||
this.pushState(ctx)
|
||||
this.projectPollers.sync(this.contexts)
|
||||
})
|
||||
.catch((err) => this.log("Failed to initialize expanded project:", err))
|
||||
}
|
||||
|
||||
// Message interceptor
|
||||
@@ -1047,6 +1050,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (!req) return
|
||||
if (directory) {
|
||||
req.directory = directory
|
||||
req.projectId ??= this.contexts.byDirectory(directory)?.id
|
||||
}
|
||||
void this.startToolRequest(req)
|
||||
}
|
||||
@@ -1555,7 +1559,11 @@ export class AgentManagerProvider implements Disposable {
|
||||
void this.sendRepoInfo()
|
||||
if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c)))
|
||||
this.stateReady = this.initializeState()
|
||||
else this.projectPollers.sync(this.contexts)
|
||||
else {
|
||||
this.panel?.sessions.refreshSessions()
|
||||
this.projectPollers.sync(this.contexts)
|
||||
}
|
||||
this.panel?.sessions.refreshGitStatus?.()
|
||||
}
|
||||
private onWorkspaceChanged(): void {
|
||||
if (this.contexts.syncPinned()) {
|
||||
@@ -1567,11 +1575,20 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
|
||||
private pushProjects(): void {
|
||||
const projects = this.contexts.snapshots()
|
||||
this.postToWebview({
|
||||
type: "agentManager.projects",
|
||||
multiProject: this.host.multiProject(),
|
||||
projects: this.contexts.snapshots(),
|
||||
projects,
|
||||
})
|
||||
if (this.panel) {
|
||||
for (const project of projects) {
|
||||
if (project.active || !project.expanded || !project.trusted || project.missing) continue
|
||||
if (this.contexts.get(project.id)) continue
|
||||
const ctx = this.contexts.expand(project.id)
|
||||
if (ctx) this.initExpanded(ctx)
|
||||
}
|
||||
}
|
||||
this.projectPollers.sync(this.contexts)
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,10 @@ export class GitOps {
|
||||
return this.raw(["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => "")
|
||||
}
|
||||
|
||||
async root(cwd: string): Promise<string | undefined> {
|
||||
return this.raw(["rev-parse", "--show-toplevel"], cwd).catch(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the remote name for a branch. Checks (in order):
|
||||
* 1. The configured upstream's remote (e.g. upstream from `upstream/main`)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user