diff --git a/STATS.md b/STATS.md index e09c57e8f41..9a665612b14 100644 --- a/STATS.md +++ b/STATS.md @@ -202,3 +202,4 @@ | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | | 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | | 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) | +| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) | diff --git a/github/README.md b/github/README.md index 8238bdc42aa..17b24ffb1d6 100644 --- a/github/README.md +++ b/github/README.md @@ -91,8 +91,10 @@ This will walk you through installing the GitHub app, creating the workflow, and uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true ``` 3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys. diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index a93ffc02454..d8dc13e2344 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -122,6 +123,7 @@ export function formatKeybind(config: string): string { export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { + const dialog = useDialog() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) @@ -165,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { - if (suspended()) return + if (suspended() || dialog.active) return const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 74641a0a243..96f8c63eab2 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -379,6 +379,8 @@ function createGlobalSync() { }), ) } + if (event.properties.info.parentID) break + setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index bc62c70232f..eb09b154b9c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -501,7 +501,7 @@ export default function Layout(props: ParentProps) { const [dirStore] = globalSync.child(dir) const dirSessions = dirStore.session .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) result.push(...dirSessions) } @@ -510,7 +510,7 @@ export default function Layout(props: ParentProps) { const [projectStore] = globalSync.child(project.worktree) return projectStore.session .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) }) @@ -1203,7 +1203,7 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) @@ -1349,7 +1349,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(directory) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1358,7 +1358,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(props.project.worktree) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1445,7 +1445,7 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 7a46ba8cde0..8398f457766 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -26,6 +26,18 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { ) } +const isWindows = ostype() === "windows" +if (isWindows) { + const originalGetComputedStyle = window.getComputedStyle + window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { + if (!(elt instanceof Element)) { + // WebView2 can call into Floating UI with non-elements; fall back to a safe element. + return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) + } + return originalGetComputedStyle(elt, pseudoElt ?? undefined) + }) as typeof window.getComputedStyle +} + let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 63f1d9743bf..d058ce54fb3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + const state = { + pending: false, + } function save() { + if (!modelStore.ready) { + state.pending = true + return + } + state.pending = false Bun.write( file, JSON.stringify({ @@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .catch(() => {}) .finally(() => { setModelStore("ready", true) + if (state.pending) save() }) const args = useArgs() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1d64a2ff156..dcd45d51cc7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -147,6 +147,10 @@ export function Session() { const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) + const [compactionMethod, setCompactionMethod] = kv.signal<"standard" | "collapse">( + "compaction_method", + sync.data.config.compaction?.method ?? "standard", + ) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -392,6 +396,15 @@ export function Session() { dialog.clear() }, }, + { + title: compactionMethod() === "collapse" ? "Use standard compaction" : "Use collapse compaction", + value: "session.toggle.compaction_method", + category: "Session", + onSelect: (dialog) => { + setCompactionMethod((prev) => (prev === "standard" ? "collapse" : "standard")) + dialog.clear() + }, + }, { title: "Unshare session", value: "session.unshare", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723..ae9bf23795e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -93,6 +93,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { Context + + compact{" "} + {sync.data.config.compaction?.auto === false + ? "disabled" + : kv.get("compaction_method", sync.data.config.compaction?.method ?? "standard")} + {context()?.tokens ?? 0} tokens {context()?.percentage ?? 0}% used {cost()} spent diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 2c207ecc2f2..5fa2bb42640 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,7 +60,11 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + UI.println( + UI.Style.TEXT_INFO_BOLD + " mDNS: ", + UI.Style.TEXT_NORMAL, + `opencode.local:${server.port}`, + ) } // Open localhost in browser diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1574c644d32..ccb5b9b6ea2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1022,6 +1022,48 @@ export namespace Config { .object({ auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), + method: z + .enum(["standard", "collapse"]) + .optional() + .describe( + "Compaction method: 'standard' summarizes entire conversation, 'collapse' extracts oldest messages and creates summary at breakpoint (default: standard)", + ), + trigger: z + .number() + .min(0) + .max(1) + .optional() + .describe("Trigger compaction at this fraction of total context (default: 0.85 = 85%)"), + extractRatio: z + .number() + .min(0) + .max(1) + .optional() + .describe("For collapse mode: fraction of oldest tokens to extract and summarize (default: 0.65)"), + recentRatio: z + .number() + .min(0) + .max(1) + .optional() + .describe("For collapse mode: fraction of newest tokens to use as reference context (default: 0.15)"), + summaryMaxTokens: z + .number() + .min(1000) + .max(50000) + .optional() + .describe("For collapse mode: target token count for the summary output (default: 10000)"), + previousSummaries: z + .number() + .min(0) + .max(10) + .optional() + .describe("For collapse mode: number of previous summaries to include for context merging (default: 3)"), + insertTriggers: z + .boolean() + .optional() + .describe( + "Whether to insert compaction trigger messages in the stream. Standard compaction needs triggers (default: true), collapse compaction does not (default: false)", + ), }) .optional(), experimental: z diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index db2920b0a45..05098c9a49f 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -18,6 +18,7 @@ export namespace Identifier { } const LENGTH = 26 + const TIME_BYTES = 6 // State for monotonic ID generation let lastTimestamp = 0 @@ -65,12 +66,12 @@ export namespace Identifier { now = descending ? ~now : now - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + const timeBytes = Buffer.alloc(TIME_BYTES) + for (let i = 0; i < TIME_BYTES; i++) { + timeBytes[i] = Number((now >> BigInt((TIME_BYTES - 1 - i) * 8)) & BigInt(0xff)) } - return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) + return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - TIME_BYTES * 2) } /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ @@ -80,4 +81,58 @@ export namespace Identifier { const encoded = BigInt("0x" + hex) return Number(encoded / BigInt(0x1000)) } + + /** + * Insert an ID that sorts after afterId, and optionally before beforeId. + * + * If beforeId is provided and there's a gap, the new ID will sort between them. + * Otherwise, the new ID will sort immediately after afterId. + * + * @param afterId - The ID that the new ID must sort AFTER + * @param beforeId - Optional ID that the new ID should sort BEFORE (if gap exists) + * @param prefix - The prefix for the new ID (e.g., "message", "part") + */ + export function insert(afterId: string, beforeId: string | undefined, prefix: keyof typeof prefixes): string { + const underscoreIndex = afterId.indexOf("_") + if (underscoreIndex === -1) { + throw new Error(`Invalid afterId: ${afterId}`) + } + + const afterHex = afterId.slice(underscoreIndex + 1, underscoreIndex + 1 + TIME_BYTES * 2) + const afterValue = BigInt("0x" + afterHex) + + let newValue: bigint + + if (beforeId) { + const beforeUnderscoreIndex = beforeId.indexOf("_") + if (beforeUnderscoreIndex !== -1) { + const beforeHex = beforeId.slice(beforeUnderscoreIndex + 1, beforeUnderscoreIndex + 1 + TIME_BYTES * 2) + if (/^[0-9a-f]+$/i.test(beforeHex)) { + const beforeValue = BigInt("0x" + beforeHex) + const gap = beforeValue - afterValue + if (gap > BigInt(1)) { + // Insert in the middle of the gap + newValue = afterValue + gap / BigInt(2) + } else { + // Gap too small, create after afterId + newValue = afterValue + BigInt(0x1000) + BigInt(1) + } + } else { + newValue = afterValue + BigInt(0x1000) + BigInt(1) + } + } else { + newValue = afterValue + BigInt(0x1000) + BigInt(1) + } + } else { + // No beforeId, create after afterId + newValue = afterValue + BigInt(0x1000) + BigInt(1) + } + + const timeBytes = Buffer.alloc(TIME_BYTES) + for (let i = 0; i < TIME_BYTES; i++) { + timeBytes[i] = Number((newValue >> BigInt((TIME_BYTES - 1 - i) * 8)) & BigInt(0xff)) + } + + return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - TIME_BYTES * 2) + } } diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 8bddb910503..953269de444 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,15 +7,17 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number, name = "opencode") { + export function publish(port: number) { if (currentPort === port) return if (bonjour) unpublish() try { + const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", + host: "opencode.local", port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f0c64b49f81..28dec7f4043 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -562,7 +562,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, `opencode-${server.port!}`) + MDNS.publish(server.port!) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } diff --git a/packages/opencode/src/session/compaction-extension.ts b/packages/opencode/src/session/compaction-extension.ts new file mode 100644 index 00000000000..4fd5c6e264f --- /dev/null +++ b/packages/opencode/src/session/compaction-extension.ts @@ -0,0 +1,629 @@ +import { Session } from "." +import { Identifier } from "../id/id" +import { Instance } from "../project/instance" +import { Provider } from "../provider/provider" +import { MessageV2 } from "./message-v2" +import { SessionPrompt } from "./prompt" +import { Token } from "../util/token" +import { Log } from "../util/log" +import { SessionProcessor } from "./processor" +import { Agent } from "@/agent/agent" +import { Plugin } from "@/plugin" +import { Config } from "@/config/config" +import { Global } from "@/global" +import path from "path" + +/** + * Compaction Extension Module + * + * This module implements extended compaction modes beyond the standard compaction. + * Currently includes "collapse" mode - future modes can be added here. + * + * Collapse mode features: + * - Selective compression: Only compresses OLD messages, keeps recent work intact + * - Historical summary merging: Merges previous summaries into new ones (no info loss) + * - Breakpoint insertion: Places summary at correct position in message timeline + * + * This file is designed to be self-contained for easy rebasing when upstream changes. + */ + +export namespace CompactionExtension { + const log = Log.create({ service: "session.compaction.extension" }) + + // Default configuration values + export const DEFAULTS = { + method: "standard" as const, + trigger: 0.85, // Trigger at 85% of usable context to leave headroom + extractRatio: 0.65, + recentRatio: 0.15, + summaryMaxTokens: 10000, // Target token count for collapse summary + previousSummaries: 3, // Number of previous summaries to include in collapse + } + + // Build collapse prompt instructions (tokenTarget is optional for estimation) + function collapseInstructions(tokenTarget?: number): string { + const targetClause = tokenTarget ? ` (target: approximately ${tokenTarget} tokens)` : "" + return `You are creating a comprehensive context restoration document. This document will serve as the foundation for continued work - it must preserve critical knowledge that would otherwise be lost. + +Create a detailed summary${targetClause} with these sections: +1. Current Task State - what is being worked on, next steps, blockers +2. Resolved Code & Lessons Learned - working code verbatim, failed approaches, insights +3. User Directives - explicit preferences, style rules, things to always/never do +4. Custom Utilities & Commands - scripts, aliases, debugging commands +5. Design Decisions & Derived Requirements - architecture decisions, API contracts, patterns +6. Technical Facts - file paths, function names, config values, environment details + +Critical rules: +- PRESERVE working code verbatim in fenced blocks +- INCLUDE failed approaches with explanations +- Be specific with paths, line numbers, function names +- Capture the "why" behind decisions +- User directives are sacred - never omit them` + } + + /** + * Get the compaction method. + * Priority: TUI toggle (kv.json) > config file > default + */ + export async function getMethod(): Promise<"standard" | "collapse"> { + const config = await Config.get() + const configMethod = config.compaction?.method + + // Check TUI toggle override + try { + const file = Bun.file(path.join(Global.Path.state, "kv.json")) + if (await file.exists()) { + const kv = await file.json() + const toggle = kv["compaction_method"] + if (toggle === "standard" || toggle === "collapse") { + return toggle + } + } + } catch { + // Ignore KV read errors + } + + return configMethod ?? DEFAULTS.method + } + + /** + * Check if context is overflowing based on collapse trigger threshold. + * Uses configurable trigger ratio instead of fixed context-output calculation. + */ + export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + const config = await Config.get() + if (config.compaction?.auto === false) return false + const context = input.model.limit.context + if (context === 0) return false + + const count = input.tokens.input + input.tokens.cache.read + input.tokens.cache.write + input.tokens.output + const trigger = config.compaction?.trigger ?? DEFAULTS.trigger + const threshold = context * trigger + const isOver = count > threshold + + log.debug("overflow check", { + tokens: input.tokens, + count, + context, + trigger, + threshold, + isOver, + }) + + return isOver + } + + /** + * Collapse compaction: Extract oldest messages, distill with AI, insert summary at breakpoint. + * Messages before the breakpoint are filtered out by filterCompacted(). + */ + export async function process(input: { + parentID: string + messages: MessageV2.WithParts[] + sessionID: string + abort: AbortSignal + auto: boolean + }): Promise<"continue" | "stop"> { + const config = await Config.get() + const extractRatio = config.compaction?.extractRatio ?? DEFAULTS.extractRatio + const recentRatio = config.compaction?.recentRatio ?? DEFAULTS.recentRatio + const summaryMaxTokens = config.compaction?.summaryMaxTokens ?? DEFAULTS.summaryMaxTokens + const previousSummariesLimit = config.compaction?.previousSummaries ?? DEFAULTS.previousSummaries + + // Get the user message to determine which model we'll use + const originalUserMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User + const agent = await Agent.get("compaction") + const model = agent.model + ? await Provider.getModel(agent.model.providerID, agent.model.modelID) + : await Provider.getModel(originalUserMessage.model.providerID, originalUserMessage.model.modelID) + + // Calculate token counts and role counts + const messageTokens: number[] = [] + let totalTokens = 0 + let userCount = 0 + let assistantCount = 0 + for (const msg of input.messages) { + const estimate = estimateMessageTokens(msg) + messageTokens.push(estimate) + totalTokens += estimate + if (msg.info.role === "user") userCount++ + else if (msg.info.role === "assistant") assistantCount++ + } + + // Check if first message is a breakpoint (existing compaction) or new conversation + const firstMessage = input.messages[0] + const isBreakpoint = + firstMessage?.info.role === "assistant" && (firstMessage.info as MessageV2.Assistant).mode === "compaction" + + log.info("collapse context", { + sessionID: input.sessionID, + messages: input.messages.length, + tokens: totalTokens, + user: userCount, + assistant: assistantCount, + firstMessageId: firstMessage?.info.id, + chainType: isBreakpoint ? "breakpoint" : "new", + }) + + // Calculate extraction targets + const extractTarget = Math.floor(totalTokens * extractRatio) + const recentTarget = Math.floor(totalTokens * recentRatio) + + // Helper: if message at index has a parentID, return the parent's index + function findChainStart(index: number): number | undefined { + if (index <= 0 || index >= input.messages.length) return undefined + const msg = input.messages[index] + if (msg.info.role !== "assistant") return undefined + const parentID = (msg.info as MessageV2.Assistant).parentID + if (!parentID) return undefined + const parentIndex = input.messages.findIndex((m) => m.info.id === parentID) + return parentIndex >= 0 && parentIndex < index ? parentIndex : undefined + } + + // Find split points + let extractedTokens = 0 + let extractSplitIndex = 0 + for (let i = 0; i < input.messages.length; i++) { + if (extractedTokens >= extractTarget) break + extractedTokens += messageTokens[i] + extractSplitIndex = i + 1 + } + + // Ensure extract split is not in the middle of a chain + const extractChainStart = findChainStart(extractSplitIndex) + if (extractChainStart !== undefined) { + for (let i = extractChainStart; i < extractSplitIndex; i++) { + extractedTokens -= messageTokens[i] + } + extractSplitIndex = extractChainStart + } + + let recentTokens = 0 + let recentSplitIndex = input.messages.length + for (let i = input.messages.length - 1; i >= 0; i--) { + if (recentTokens >= recentTarget) break + recentTokens += messageTokens[i] + recentSplitIndex = i + } + + // Ensure recent split is not in the middle of a chain + const recentChainStart = findChainStart(recentSplitIndex) + if (recentChainStart !== undefined) { + for (let i = recentChainStart; i < recentSplitIndex; i++) { + recentTokens += messageTokens[i] + } + recentSplitIndex = recentChainStart + } + + // Ensure recent split doesn't overlap with extract + if (recentSplitIndex <= extractSplitIndex) { + recentSplitIndex = extractSplitIndex + } + + const extractedMessages = input.messages.slice(0, extractSplitIndex) + const middleMessages = input.messages.slice(extractSplitIndex, recentSplitIndex) + const recentReferenceMessages = input.messages.slice(recentSplitIndex) + + // Calculate middle section tokens + let middleTokens = 0 + for (let i = extractSplitIndex; i < recentSplitIndex; i++) { + middleTokens += messageTokens[i] + } + + log.info("collapse split", { + sessionID: input.sessionID, + total: { messages: input.messages.length, tokens: totalTokens }, + extract: { messages: extractedMessages.length, tokens: extractedTokens }, + middle: { messages: middleMessages.length, tokens: middleTokens }, + recent: { messages: recentReferenceMessages.length, tokens: recentTokens }, + }) + + if (extractedMessages.length === 0) { + log.info("collapse skipped", { sessionID: input.sessionID, reason: "no messages to extract" }) + return "continue" + } + + // Convert extracted messages to markdown for distillation + const markdownContent = messagesToMarkdown(extractedMessages) + const recentContext = messagesToMarkdown(recentReferenceMessages) + + // Build base prompt (without previous summaries) to calculate token budget + const markdownTokens = Token.estimate(markdownContent) + const recentTokensEstimate = Token.estimate(recentContext) + const templateTokens = Token.estimate(collapseInstructions()) + const basePromptTokens = markdownTokens + recentTokensEstimate + templateTokens + const contextLimit = model.limit.context + const outputReserve = SessionPrompt.OUTPUT_TOKEN_MAX + const previousSummaryBudget = Math.max(0, contextLimit - outputReserve - basePromptTokens) + + // Fetch previous summaries that fit within budget + const previousSummaries = await getPreviousSummaries(input.sessionID, previousSummariesLimit, previousSummaryBudget) + + // Get the last extracted message to determine breakpoint position + const lastExtractedMessage = extractedMessages[extractedMessages.length - 1] + let afterId = lastExtractedMessage.info.id + let beforeId: string | undefined + let breakpointTimestamp = lastExtractedMessage.info.time.created + 1 + + // Check if any message after the split has a parentID (is part of a chain) + // If so, the compaction must sort BEFORE that parent to keep the chain together + const messagesAfterSplit = input.messages.slice(extractSplitIndex) + for (const msg of messagesAfterSplit) { + if (msg.info.role === "assistant") { + const parentID = (msg.info as MessageV2.Assistant).parentID + if (parentID) { + // Find the message that sorts just before the parent + // Use direct string comparison (not localeCompare) for consistent case-sensitive ordering + const sortedMessages = [...input.messages].sort((a, b) => + a.info.id < b.info.id ? -1 : a.info.id > b.info.id ? 1 : 0, + ) + const parentIndex = sortedMessages.findIndex((m) => m.info.id === parentID) + + if (parentIndex > 0) { + afterId = sortedMessages[parentIndex - 1].info.id + beforeId = parentID + + const parent = input.messages.find((m) => m.info.id === parentID) + if (parent) { + breakpointTimestamp = parent.info.time.created - 1 + } + + log.debug("collapse breakpoint adjusted for chain", { + sessionID: input.sessionID, + chainMessageId: msg.info.id, + parentID, + afterId, + beforeId, + }) + } + break + } + } + } + + // Create compaction user message - sorts after afterId, and before beforeId if possible + const compactionUserId = Identifier.insert(afterId, beforeId, "message") + const compactionUserTimestamp = breakpointTimestamp + + log.debug("collapse insert", { + sessionID: input.sessionID, + afterInTime: afterId, + beforeInTime: beforeId ?? "(none)", + breakpointId: compactionUserId, + breakpointTimestamp: compactionUserTimestamp, + }) + + const compactionUserMsg = await Session.updateMessage({ + id: compactionUserId, + role: "user", + model: originalUserMessage.model, + sessionID: input.sessionID, + agent: originalUserMessage.agent, + time: { + created: compactionUserTimestamp, + }, + }) + await Session.updatePart({ + id: Identifier.insert(compactionUserId, undefined, "part"), + messageID: compactionUserMsg.id, + sessionID: input.sessionID, + type: "compaction", + auto: input.auto, + }) + + // Create assistant summary message - sorts after compaction user, before beforeId if possible + const compactionAssistantId = Identifier.insert(compactionUserId, beforeId, "message") + const compactionAssistantTimestamp = compactionUserTimestamp + 1 + + const msg = (await Session.updateMessage({ + id: compactionAssistantId, + role: "assistant", + parentID: compactionUserMsg.id, + sessionID: input.sessionID, + mode: "compaction", + agent: "compaction", + summary: true, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: model.id, + providerID: model.providerID, + time: { + created: compactionAssistantTimestamp, + }, + })) as MessageV2.Assistant + + const processor = SessionProcessor.create({ + assistantMessage: msg, + sessionID: input.sessionID, + model, + abort: input.abort, + }) + + // Allow plugins to inject context + const compacting = await Plugin.trigger( + "experimental.session.compacting", + { sessionID: input.sessionID }, + { context: [], prompt: undefined }, + ) + + // Build prompt sections - only include what we have + const sections: string[] = [] + + // Instructions + sections.push(collapseInstructions(summaryMaxTokens)) + + // Previous summaries + if (previousSummaries.length > 0) { + sections.push(` +IMPORTANT: Merge all information from these previous summaries into your new summary. Do not lose any historical context. + +${previousSummaries.map((summary, i) => `--- Summary ${i + 1} ---\n${summary}`).join("\n\n")} +`) + } + + // Extracted content + sections.push(` +The following conversation content needs to be distilled into the summary: + +${markdownContent} +`) + + // Recent context + sections.push(` +The following is recent context for reference (shows current state): + +${recentContext} +`) + + // Additional plugin context + if (compacting.context.length > 0) { + sections.push(` +${compacting.context.join("\n\n")} +`) + } + + sections.push("Generate the context restoration document now.") + + const collapsePrompt = sections.join("\n\n") + + const result = await processor.process({ + user: originalUserMessage, + agent, + abort: input.abort, + sessionID: input.sessionID, + tools: {}, + system: [], + messages: [ + { + role: "user", + content: [{ type: "text", text: collapsePrompt }], + }, + ], + model, + }) + + // NOTE: We intentionally do NOT add a "Continue if you have next steps" message + // for collapse mode. The collapse summary is just context restoration - the loop + // should exit after the summary is generated so the user can continue naturally. + + if (processor.message.error) return "stop" + + // Update token count on the chronologically last assistant message + // so isOverflow() sees the correct post-collapse state. + const allMessages = await Session.messages({ sessionID: input.sessionID }) + const lastAssistant = allMessages + .filter( + (m): m is MessageV2.WithParts & { info: MessageV2.Assistant } => + m.info.role === "assistant" && m.info.id !== msg.id, + ) + .sort((a, b) => b.info.time.created - a.info.time.created)[0] + + if (lastAssistant) { + const collapseSummaryTokens = processor.message.tokens.output + + const currentTotal = + lastAssistant.info.tokens.input + + lastAssistant.info.tokens.cache.read + + lastAssistant.info.tokens.cache.write + + lastAssistant.info.tokens.output + + const newTotal = Math.max(0, currentTotal - extractedTokens + collapseSummaryTokens) + + lastAssistant.info.tokens = { + input: 0, + output: lastAssistant.info.tokens.output, + reasoning: lastAssistant.info.tokens.reasoning, + cache: { + read: Math.max(0, newTotal - lastAssistant.info.tokens.output), + write: 0, + }, + } + await Session.updateMessage(lastAssistant.info) + + log.debug("tokens adjusted", { + sessionID: input.sessionID, + extracted: extractedTokens, + summary: collapseSummaryTokens, + estimated: newTotal, + }) + } + + // Count messages in the compacted chain (after compaction) + const remainingMessages = input.messages.length - extractedMessages.length + 2 // +2 for compaction user/assistant + const remainingUser = userCount - extractedMessages.filter((m) => m.info.role === "user").length + 1 + const remainingAssistant = assistantCount - extractedMessages.filter((m) => m.info.role === "assistant").length + 1 + + log.info("collapsed", { + sessionID: input.sessionID, + extracted: extractedMessages.length, + remaining: remainingMessages, + user: remainingUser, + assistant: remainingAssistant, + summaryTokens: processor.message.tokens.output, + }) + + // Delete the original trigger message (created by create()) to prevent + // the loop from picking it up again as a pending compaction task. + // The trigger is the message at input.parentID - we've created a new + // compaction user message at the breakpoint position. + if (input.parentID !== compactionUserMsg.id) { + log.debug("cleanup trigger", { sessionID: input.sessionID, id: input.parentID }) + // Delete parts first + const triggerMsg = input.messages.find((m) => m.info.id === input.parentID) + if (triggerMsg) { + for (const part of triggerMsg.parts) { + await Session.removePart({ + sessionID: input.sessionID, + messageID: input.parentID, + partID: part.id, + }) + } + } + await Session.removeMessage({ + sessionID: input.sessionID, + messageID: input.parentID, + }) + } + + // For auto-compaction: return "continue" so the loop processes the user's + // original message that triggered the overflow. The trigger message is deleted, + // so the loop will find the real user message and respond to it. + // For manual compaction: return "stop" - user explicitly requested compaction only. + if (input.auto) { + return "continue" + } + return "stop" + } + + /** + * Estimate tokens for a message (respects compaction state) + */ + function estimateMessageTokens(msg: MessageV2.WithParts): number { + let tokens = 0 + for (const part of msg.parts) { + if (part.type === "text") { + tokens += Token.estimate(part.text) + } else if (part.type === "tool" && part.state.status === "completed") { + // Skip compacted tool outputs + if (part.state.time.compacted) continue + tokens += Token.estimate(JSON.stringify(part.state.input)) + tokens += Token.estimate(part.state.output) + } + } + return tokens + } + + /** + * Convert messages to markdown format for distillation + */ + function messagesToMarkdown(messages: MessageV2.WithParts[]): string { + const lines: string[] = [] + + for (const msg of messages) { + const role = msg.info.role === "user" ? "User" : "Assistant" + lines.push(`### ${role}`) + lines.push("") + + for (const part of msg.parts) { + if (part.type === "text" && part.text) { + // Skip synthetic parts like "Continue if you have next steps" + if (part.synthetic) continue + lines.push(part.text) + lines.push("") + } else if (part.type === "tool" && part.state.status === "completed") { + // Skip compacted tool outputs + if (part.state.time.compacted) continue + lines.push(`**Tool: ${part.tool}**`) + lines.push("```json") + lines.push(JSON.stringify(part.state.input, null, 2)) + lines.push("```") + if (part.state.output) { + lines.push("Output:") + lines.push("```") + lines.push(part.state.output.slice(0, 1000)) + if (part.state.output.length > 1000) lines.push("... (truncated)") + lines.push("```") + } + lines.push("") + } + } + } + + return lines.join("\n") + } + + /** + * Extract summary text from a compaction summary message's parts + */ + function extractSummaryText(msg: MessageV2.WithParts): string { + return msg.parts + .filter((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic) + .map((p) => p.text) + .join("\n") + } + + /** + * Fetch previous compaction summaries from the session (unfiltered). + * Respects token budget to avoid overflowing context window. + */ + async function getPreviousSummaries(sessionID: string, limit: number, tokenBudget: number): Promise { + const allMessages = await Session.messages({ sessionID }) + + const summaryMessages = allMessages + .filter( + (m): m is MessageV2.WithParts & { info: MessageV2.Assistant } => + m.info.role === "assistant" && + (m.info as MessageV2.Assistant).summary === true && + (m.info as MessageV2.Assistant).finish !== undefined, + ) + .sort((a, b) => a.info.time.created - b.info.time.created) // oldest first + .slice(-limit) // take the N most recent + + // Include summaries only if they fit within token budget + // Start from most recent (end of array) since those are most relevant + const result: string[] = [] + let tokensUsed = 0 + + for (let i = summaryMessages.length - 1; i >= 0; i--) { + const text = extractSummaryText(summaryMessages[i]) + if (!text.trim()) continue + + const estimate = Token.estimate(text) + if (tokensUsed + estimate > tokenBudget) break + + result.unshift(text) // prepend to maintain chronological order + tokensUsed += estimate + } + + return result + } +} diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ae69221288f..f85eb2fe1ac 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,6 +14,7 @@ import { fn } from "@/util/fn" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" import { Config } from "@/config/config" +import { CompactionExtension } from "./compaction-extension" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -28,6 +29,13 @@ export namespace SessionCompaction { } export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + // Use collapse overflow check if method is collapse (uses configurable trigger) + const method = await CompactionExtension.getMethod() + if (method === "collapse") { + return CompactionExtension.isOverflow(input) + } + + // Standard overflow check const config = await Config.get() if (config.compaction?.auto === false) return false const context = input.model.limit.context @@ -95,7 +103,17 @@ export namespace SessionCompaction { sessionID: string abort: AbortSignal auto: boolean - }) { + }): Promise<"continue" | "stop"> { + // Route to collapse compaction if configured + const method = await CompactionExtension.getMethod() + log.info("compacting", { method }) + if (method === "collapse") { + const result = await CompactionExtension.process(input) + Bus.publish(Event.Compacted, { sessionID: input.sessionID }) + return result + } + + // Standard compaction const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User const agent = await Agent.get("compaction") const model = agent.model diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d326976f1ae..8284c9f7bfe 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -11,8 +11,10 @@ import { ProviderTransform } from "@/provider/transform" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import { type SystemError } from "bun" +import { Log } from "../util/log" export namespace MessageV2 { + const log = Log.create({ service: "message-v2" }) export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const AuthError = NamedError.create( @@ -604,17 +606,28 @@ export namespace MessageV2 { export async function filterCompacted(stream: AsyncIterable) { const result = [] as MessageV2.WithParts[] const completed = new Set() + for await (const msg of stream) { + const hasCompactionPart = msg.parts.some((part) => part.type === "compaction") + const isAssistantSummary = + msg.info.role === "assistant" && (msg.info as Assistant).summary && (msg.info as Assistant).finish + result.push(msg) - if ( - msg.info.role === "user" && - completed.has(msg.info.id) && - msg.parts.some((part) => part.type === "compaction") - ) + + // Check if this is a compaction breakpoint + if (msg.info.role === "user" && completed.has(msg.info.id) && hasCompactionPart) { + log.debug("breakpoint", { id: msg.info.id }) break - if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish) completed.add(msg.info.parentID) + } + + // If assistant with summary=true and finish, add parentID to completed set + if (isAssistantSummary) { + completed.add((msg.info as Assistant).parentID) + } } + result.reverse() + log.debug("filtered", { count: result.length }) return result } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8327698fd5f..11f6b9c1b76 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -11,6 +11,7 @@ import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai" import { SessionCompaction } from "./compaction" +import { Config } from "../config/config" import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" @@ -498,13 +499,31 @@ export namespace SessionPrompt { lastFinished.summary !== true && (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })) ) { - await SessionCompaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - }) - continue + const config = await Config.get() + const method = config.compaction?.method ?? "standard" + const insertTriggers = config.compaction?.insertTriggers ?? method === "standard" + + if (insertTriggers) { + // Standard compaction: create trigger message, loop will process it + await SessionCompaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + }) + continue + } else { + // Collapse compaction: directly call process without trigger + const result = await SessionCompaction.process({ + messages: msgs, + parentID: lastUser.id, + abort, + sessionID, + auto: true, + }) + if (result === "stop") break + continue + } } // normal processing diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e47c4f5f7f1..24d9b2ad646 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1746,6 +1746,30 @@ export type Config = { * Enable pruning of old tool outputs (default: true) */ prune?: boolean + /** + * Compaction method: 'standard' summarizes entire conversation, 'collapse' extracts oldest messages and creates summary at breakpoint (default: standard) + */ + method?: "standard" | "collapse" + /** + * Trigger compaction at this fraction of total context (default: 0.85 = 85%) + */ + trigger?: number + /** + * For collapse mode: fraction of oldest tokens to extract and summarize (default: 0.65) + */ + extractRatio?: number + /** + * For collapse mode: fraction of newest tokens to use as reference context (default: 0.15) + */ + recentRatio?: number + /** + * For collapse mode: target token count for the summary output (default: 10000) + */ + summaryMaxTokens?: number + /** + * For collapse mode: number of previous summaries to include for context merging (default: 3) + */ + previousSummaries?: number } experimental?: { hook?: { diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 5be7f95aeec..631b3e33a29 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -69,7 +69,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()?.querySelector(`[data-key="${key}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`) element?.scrollIntoView({ block: "center" }) }) }) @@ -81,7 +81,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`) element?.scrollIntoView({ block: "center" }) }) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index b85bd2142fa..22bed7f16a4 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -157,10 +157,10 @@ Configure agents in your `opencode.json` config file: You can also define agents using markdown files. Place them in: -- Global: `~/.config/opencode/agent/` -- Per-project: `.opencode/agent/` +- Global: `~/.config/opencode/agents/` +- Per-project: `.opencode/agents/` -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Reviews code for quality and best practices mode: subagent @@ -419,7 +419,7 @@ You can override these permissions per agent. You can also set permissions in Markdown agents. -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent @@ -637,7 +637,7 @@ Do you have an agent you'd like to share? [Submit a PR](https://github.com/anoma ### Documentation agent -```markdown title="~/.config/opencode/agent/docs-writer.md" +```markdown title="~/.config/opencode/agents/docs-writer.md" --- description: Writes and maintains project documentation mode: subagent @@ -659,7 +659,7 @@ Focus on: ### Security auditor -```markdown title="~/.config/opencode/agent/security-auditor.md" +```markdown title="~/.config/opencode/agents/security-auditor.md" --- description: Performs security audits and identifies vulnerabilities mode: subagent diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/commands.mdx index 92ca08bd2e9..1d7e4f1c21a 100644 --- a/packages/web/src/content/docs/commands.mdx +++ b/packages/web/src/content/docs/commands.mdx @@ -15,11 +15,11 @@ Custom commands are in addition to the built-in commands like `/init`, `/undo`, ## Create command files -Create markdown files in the `command/` directory to define custom commands. +Create markdown files in the `commands/` directory to define custom commands. -Create `.opencode/command/test.md`: +Create `.opencode/commands/test.md`: -```md title=".opencode/command/test.md" +```md title=".opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -42,7 +42,7 @@ Use the command by typing `/` followed by the command name. ## Configure -You can add custom commands through the OpenCode config or by creating markdown files in the `command/` directory. +You can add custom commands through the OpenCode config or by creating markdown files in the `commands/` directory. --- @@ -79,10 +79,10 @@ Now you can run this command in the TUI: You can also define commands using markdown files. Place them in: -- Global: `~/.config/opencode/command/` -- Per-project: `.opencode/command/` +- Global: `~/.config/opencode/commands/` +- Per-project: `.opencode/commands/` -```markdown title="~/.config/opencode/command/test.md" +```markdown title="~/.config/opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -112,7 +112,7 @@ The prompts for the custom commands support several special placeholders and syn Pass arguments to commands using the `$ARGUMENTS` placeholder. -```md title=".opencode/command/component.md" +```md title=".opencode/commands/component.md" --- description: Create a new component --- @@ -138,7 +138,7 @@ You can also access individual arguments using positional parameters: For example: -```md title=".opencode/command/create-file.md" +```md title=".opencode/commands/create-file.md" --- description: Create a new file with content --- @@ -167,7 +167,7 @@ Use _!`command`_ to inject [bash command](/docs/tui#bash-commands) output into y For example, to create a custom command that analyzes test coverage: -```md title=".opencode/command/analyze-coverage.md" +```md title=".opencode/commands/analyze-coverage.md" --- description: Analyze test coverage --- @@ -180,7 +180,7 @@ Based on these results, suggest improvements to increase coverage. Or to review recent changes: -```md title=".opencode/command/review-changes.md" +```md title=".opencode/commands/review-changes.md" --- description: Review recent changes --- @@ -199,7 +199,7 @@ Commands run in your project's root directory and their output becomes part of t Include files in your command using `@` followed by the filename. -```md title=".opencode/command/review-component.md" +```md title=".opencode/commands/review-component.md" --- description: Review component --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 30edbbd2146..1474cb91558 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -51,6 +51,10 @@ Config sources are loaded in this order (later sources override earlier ones): This means project configs can override global defaults, and global configs can override remote organizational defaults. +:::note +The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility. +::: + --- ### Remote @@ -330,7 +334,7 @@ You can configure specialized agents for specific tasks through the `agent` opti } ``` -You can also define agents using markdown files in `~/.config/opencode/agent/` or `.opencode/agent/`. [Learn more here](/docs/agents). +You can also define agents using markdown files in `~/.config/opencode/agents/` or `.opencode/agents/`. [Learn more here](/docs/agents). --- @@ -394,7 +398,7 @@ You can configure custom commands for repetitive tasks through the `command` opt } ``` -You can also define commands using markdown files in `~/.config/opencode/command/` or `.opencode/command/`. [Learn more here](/docs/commands). +You can also define commands using markdown files in `~/.config/opencode/commands/` or `.opencode/commands/`. [Learn more here](/docs/commands). --- @@ -425,6 +429,7 @@ OpenCode will automatically download any new updates when it starts up. You can ``` If you don't want updates but want to be notified when a new version is available, set `autoupdate` to `"notify"`. +Notice that this only works if it was not installed using a package manager such as Homebrew. --- @@ -529,7 +534,7 @@ You can configure MCP servers you want to use through the `mcp` option. [Plugins](/docs/plugins) extend OpenCode with custom tools, hooks, and integrations. -Place plugin files in `.opencode/plugin/` or `~/.config/opencode/plugin/`. You can also load plugins from npm through the `plugin` option. +Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You can also load plugins from npm through the `plugin` option. ```json title="opencode.json" { diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/custom-tools.mdx index 2701be65086..e089a035b4b 100644 --- a/packages/web/src/content/docs/custom-tools.mdx +++ b/packages/web/src/content/docs/custom-tools.mdx @@ -17,8 +17,8 @@ Tools are defined as **TypeScript** or **JavaScript** files. However, the tool d They can be defined: -- Locally by placing them in the `.opencode/tool/` directory of your project. -- Or globally, by placing them in `~/.config/opencode/tool/`. +- Locally by placing them in the `.opencode/tools/` directory of your project. +- Or globally, by placing them in `~/.config/opencode/tools/`. --- @@ -26,7 +26,7 @@ They can be defined: The easiest way to create tools is using the `tool()` helper which provides type-safety and validation. -```ts title=".opencode/tool/database.ts" {1} +```ts title=".opencode/tools/database.ts" {1} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -49,7 +49,7 @@ The **filename** becomes the **tool name**. The above creates a `database` tool. You can also export multiple tools from a single file. Each export becomes **a separate tool** with the name **`_`**: -```ts title=".opencode/tool/math.ts" +```ts title=".opencode/tools/math.ts" import { tool } from "@opencode-ai/plugin" export const add = tool({ @@ -112,7 +112,7 @@ export default { Tools receive context about the current session: -```ts title=".opencode/tool/project.ts" {8} +```ts title=".opencode/tools/project.ts" {8} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -136,7 +136,7 @@ You can write your tools in any language you want. Here's an example that adds t First, create the tool as a Python script: -```python title=".opencode/tool/add.py" +```python title=".opencode/tools/add.py" import sys a = int(sys.argv[1]) @@ -146,7 +146,7 @@ print(a + b) Then create the tool definition that invokes it: -```ts title=".opencode/tool/python-add.ts" {10} +```ts title=".opencode/tools/python-add.ts" {10} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -156,7 +156,7 @@ export default tool({ b: tool.schema.number().describe("Second number"), }, async execute(args) { - const result = await Bun.$`python3 .opencode/tool/add.py ${args.a} ${args.b}`.text() + const result = await Bun.$`python3 .opencode/tools/add.py ${args.a} ${args.b}`.text() return result.trim() }, }) diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 6e8b9de4d79..a31fe1e7be8 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -180,8 +180,10 @@ jobs: - uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true prompt: | Review this pull request: - Check for code quality issues diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index a31a8223b07..57c1c54a956 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -87,10 +87,10 @@ Configure modes in your `opencode.json` config file: You can also define modes using markdown files. Place them in: -- Global: `~/.config/opencode/mode/` -- Project: `.opencode/mode/` +- Global: `~/.config/opencode/modes/` +- Project: `.opencode/modes/` -```markdown title="~/.config/opencode/mode/review.md" +```markdown title="~/.config/opencode/modes/review.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.1 @@ -268,9 +268,9 @@ You can create your own custom modes by adding them to the configuration. Here a ### Using markdown files -Create mode files in `.opencode/mode/` for project-specific modes or `~/.config/opencode/mode/` for global modes: +Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: -```markdown title=".opencode/mode/debug.md" +```markdown title=".opencode/modes/debug.md" --- temperature: 0.1 tools: @@ -294,7 +294,7 @@ Focus on: Do not make any changes to files. Only investigate and report. ``` -```markdown title="~/.config/opencode/mode/refactor.md" +```markdown title="~/.config/opencode/modes/refactor.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.2 diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index b4f0691ced7..4df3841e34a 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -174,7 +174,7 @@ Refer to the [Granular Rules (Object Syntax)](#granular-rules-object-syntax) sec You can also configure agent permissions in Markdown: -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index bf26744f6c4..66a1b3cad95 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -19,8 +19,8 @@ There are two ways to load plugins. Place JavaScript or TypeScript files in the plugin directory. -- `.opencode/plugin/` - Project-level plugins -- `~/.config/opencode/plugin/` - Global plugins +- `.opencode/plugins/` - Project-level plugins +- `~/.config/opencode/plugins/` - Global plugins Files in these directories are automatically loaded at startup. @@ -57,8 +57,8 @@ Plugins are loaded from all sources and all hooks run in sequence. The load orde 1. Global config (`~/.config/opencode/opencode.json`) 2. Project config (`opencode.json`) -3. Global plugin directory (`~/.config/opencode/plugin/`) -4. Project plugin directory (`.opencode/plugin/`) +3. Global plugin directory (`~/.config/opencode/plugins/`) +4. Project plugin directory (`.opencode/plugins/`) Duplicate npm packages with the same name and version are loaded once. However, a local plugin and an npm plugin with similar names are both loaded separately. @@ -85,7 +85,7 @@ Local plugins and custom tools can use external npm packages. Add a `package.jso OpenCode runs `bun install` at startup to install these. Your plugins and tools can then import them. -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" import { escape } from "shescape" export const MyPlugin = async (ctx) => { @@ -103,7 +103,7 @@ export const MyPlugin = async (ctx) => { ### Basic structure -```js title=".opencode/plugin/example.js" +```js title=".opencode/plugins/example.js" export const MyPlugin = async ({ project, client, $, directory, worktree }) => { console.log("Plugin initialized!") @@ -215,7 +215,7 @@ Here are some examples of plugins you can use to extend opencode. Send notifications when certain events occur: -```js title=".opencode/plugin/notification.js" +```js title=".opencode/plugins/notification.js" export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { return { event: async ({ event }) => { @@ -240,7 +240,7 @@ If you’re using the OpenCode desktop app, it can send system notifications aut Prevent opencode from reading `.env` files: -```javascript title=".opencode/plugin/env-protection.js" +```javascript title=".opencode/plugins/env-protection.js" export const EnvProtection = async ({ project, client, $, directory, worktree }) => { return { "tool.execute.before": async (input, output) => { @@ -258,7 +258,7 @@ export const EnvProtection = async ({ project, client, $, directory, worktree }) Plugins can also add custom tools to opencode: -```ts title=".opencode/plugin/custom-tools.ts" +```ts title=".opencode/plugins/custom-tools.ts" import { type Plugin, tool } from "@opencode-ai/plugin" export const CustomToolsPlugin: Plugin = async (ctx) => { @@ -292,7 +292,7 @@ Your custom tools will be available to opencode alongside built-in tools. Use `client.app.log()` instead of `console.log` for structured logging: -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" export const MyPlugin = async ({ client }) => { await client.app.log({ service: "my-plugin", @@ -311,7 +311,7 @@ Levels: `debug`, `info`, `warn`, `error`. See [SDK documentation](https://openco Customize the context included when a session is compacted: -```ts title=".opencode/plugin/compaction.ts" +```ts title=".opencode/plugins/compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CompactionPlugin: Plugin = async (ctx) => { @@ -335,7 +335,7 @@ The `experimental.session.compacting` hook fires before the LLM generates a cont You can also replace the compaction prompt entirely by setting `output.prompt`: -```ts title=".opencode/plugin/custom-compaction.ts" +```ts title=".opencode/plugins/custom-compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CustomCompactionPlugin: Plugin = async (ctx) => { diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index e1d684de00a..6022d174a7d 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -558,6 +558,33 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, --- +### Firmware + +1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. + +2. Run the `/connect` command and search for **Firmware**. + + ```txt + /connect + ``` + +3. Enter your Firmware API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +--- + ### Fireworks AI 1. Head over to the [Fireworks AI console](https://app.fireworks.ai/), create an account, and click **Create API Key**. diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 54c2c9d06ef..553931eec49 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -13,8 +13,8 @@ Skills are loaded on-demand via the native `skill` tool—agents see available s Create one folder per skill name and put a `SKILL.md` inside it. OpenCode searches these locations: -- Project config: `.opencode/skill//SKILL.md` -- Global config: `~/.config/opencode/skill//SKILL.md` +- Project config: `.opencode/skills//SKILL.md` +- Global config: `~/.config/opencode/skills//SKILL.md` - Project Claude-compatible: `.claude/skills//SKILL.md` - Global Claude-compatible: `~/.claude/skills//SKILL.md` @@ -23,9 +23,9 @@ OpenCode searches these locations: ## Understand discovery For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree. -It loads any matching `skill/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. +It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. -Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. +Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. --- @@ -71,7 +71,7 @@ Keep it specific enough for the agent to choose correctly. ## Use an example -Create `.opencode/skill/git-release/SKILL.md` like this: +Create `.opencode/skills/git-release/SKILL.md` like this: ```markdown ---