From 6707a0ef3b19398aa0ca9710e4b2372d991a12aa Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 27 Jun 2026 17:54:20 -0700 Subject: [PATCH 1/3] feat(tools): port MiMo tools and subsystems to opencode New tools: multiedit, notebook_edit, codesearch, memory, history, change_directory (per-session cwd wired into read/edit/write/glob/grep/notebook). New DB-free subsystems: memory (BM25 over markdown) and history (BM25 over session storage). Session todo endpoint enriched with subagent task jobs (merged with todowrite). Verified: typecheck + build + smoke test green. --- packages/opencode/src/history/extract.ts | 95 ++++++++ packages/opencode/src/history/index.ts | 1 + packages/opencode/src/history/service.ts | 181 ++++++++++++++ packages/opencode/src/memory/bm25.ts | 109 +++++++++ packages/opencode/src/memory/index.ts | 1 + packages/opencode/src/memory/paths.ts | 62 +++++ packages/opencode/src/memory/service.ts | 90 +++++++ packages/opencode/src/memory/tokenize.ts | 27 +++ .../instance/httpapi/handlers/session.ts | 37 ++- .../opencode/src/tool/change-directory.ts | 70 ++++++ packages/opencode/src/tool/codesearch.ts | 80 +++++++ packages/opencode/src/tool/codesearch.txt | 12 + packages/opencode/src/tool/edit.ts | 3 +- packages/opencode/src/tool/glob.ts | 6 +- packages/opencode/src/tool/grep.ts | 8 +- packages/opencode/src/tool/history.ts | 147 ++++++++++++ packages/opencode/src/tool/history.txt | 18 ++ packages/opencode/src/tool/memory.ts | 79 ++++++ packages/opencode/src/tool/memory.txt | 38 +++ packages/opencode/src/tool/multiedit.ts | 60 +++++ packages/opencode/src/tool/multiedit.txt | 41 ++++ packages/opencode/src/tool/notebook-edit.ts | 226 ++++++++++++++++++ packages/opencode/src/tool/notebook-edit.txt | 10 + packages/opencode/src/tool/read.ts | 3 +- packages/opencode/src/tool/registry.ts | 32 ++- packages/opencode/src/tool/session-cwd.ts | 22 ++ packages/opencode/src/tool/write.ts | 3 +- packages/opencode/test/memory/search.test.ts | 59 +++++ packages/opencode/test/session/prompt.test.ts | 4 + 29 files changed, 1513 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/src/history/extract.ts create mode 100644 packages/opencode/src/history/index.ts create mode 100644 packages/opencode/src/history/service.ts create mode 100644 packages/opencode/src/memory/bm25.ts create mode 100644 packages/opencode/src/memory/index.ts create mode 100644 packages/opencode/src/memory/paths.ts create mode 100644 packages/opencode/src/memory/service.ts create mode 100644 packages/opencode/src/memory/tokenize.ts create mode 100644 packages/opencode/src/tool/change-directory.ts create mode 100644 packages/opencode/src/tool/codesearch.ts create mode 100644 packages/opencode/src/tool/codesearch.txt create mode 100644 packages/opencode/src/tool/history.ts create mode 100644 packages/opencode/src/tool/history.txt create mode 100644 packages/opencode/src/tool/memory.ts create mode 100644 packages/opencode/src/tool/memory.txt create mode 100644 packages/opencode/src/tool/multiedit.ts create mode 100644 packages/opencode/src/tool/multiedit.txt create mode 100644 packages/opencode/src/tool/notebook-edit.ts create mode 100644 packages/opencode/src/tool/notebook-edit.txt create mode 100644 packages/opencode/src/tool/session-cwd.ts create mode 100644 packages/opencode/test/memory/search.test.ts diff --git a/packages/opencode/src/history/extract.ts b/packages/opencode/src/history/extract.ts new file mode 100644 index 000000000000..048dd7486f16 --- /dev/null +++ b/packages/opencode/src/history/extract.ts @@ -0,0 +1,95 @@ +import type { SessionV1 } from "@opencode-ai/core/v1/session" + +export type Kind = "user_text" | "assistant_text" | "tool_input" | "tool_error" | "reasoning" | "tool_output" + +export const ALL_KINDS: ReadonlyArray = [ + "user_text", + "assistant_text", + "tool_input", + "tool_error", + "reasoning", + "tool_output", +] + +export const DEFAULT_KINDS: ReadonlyArray = ["user_text", "assistant_text", "tool_input", "tool_error"] + +export type Extracted = { kind: Kind; body: string; tool_name: string | null } + +/** + * Flatten one message part into a searchable text body tagged by kind, or null + * if the part type/state isn't indexable or its kind is disabled. + */ +export function extract( + part: SessionV1.Part, + messageRole: "user" | "assistant", + enabledKinds: ReadonlySet, +): Extracted | null { + switch (part.type) { + case "text": { + const kind: Kind = messageRole === "user" ? "user_text" : "assistant_text" + if (!enabledKinds.has(kind)) return null + if (!part.text) return null + return { kind, body: part.text, tool_name: null } + } + case "reasoning": { + if (!enabledKinds.has("reasoning")) return null + if (!part.text) return null + return { kind: "reasoning", body: part.text, tool_name: null } + } + case "tool": { + const state = part.state + if (state.status === "pending" || state.status === "running") return null + + if (state.status === "error" && enabledKinds.has("tool_error")) { + return { + kind: "tool_error", + body: `${part.tool} ${JSON.stringify(state.input ?? {})} ${state.error ?? ""}`, + tool_name: part.tool, + } + } + if (state.status === "completed" && enabledKinds.has("tool_output")) { + return { + kind: "tool_output", + body: `${part.tool} ${JSON.stringify(state.input ?? {})} ${JSON.stringify(state.output ?? "")}`, + tool_name: part.tool, + } + } + if (enabledKinds.has("tool_input")) { + return { + kind: "tool_input", + body: `${part.tool} ${JSON.stringify(state.input ?? {})}`, + tool_name: part.tool, + } + } + return null + } + default: + return null + } +} + +/** Render a part to human-readable text for the `around` context view. */ +export function renderPart(part: SessionV1.Part): { type: string; tool_name: string | null; text: string } { + switch (part.type) { + case "text": + case "reasoning": + return { type: part.type, tool_name: null, text: part.text ?? "" } + case "tool": { + const state = part.state + const input = "input" in state ? state.input : {} + const tail = + state.status === "error" + ? `error: ${state.error ?? ""}` + : state.status === "completed" + ? `output: ${JSON.stringify(state.output ?? "")}` + : `status: ${state.status}` + return { + type: "tool", + tool_name: part.tool ?? null, + text: `tool: ${part.tool ?? ""}\ninput: ${JSON.stringify(input ?? {})}\n${tail}`, + } + } + default: + return { type: part.type, tool_name: null, text: `[${part.type}]` } + } +} diff --git a/packages/opencode/src/history/index.ts b/packages/opencode/src/history/index.ts new file mode 100644 index 000000000000..8381c4a88295 --- /dev/null +++ b/packages/opencode/src/history/index.ts @@ -0,0 +1 @@ +export * as History from "./service" diff --git a/packages/opencode/src/history/service.ts b/packages/opencode/src/history/service.ts new file mode 100644 index 000000000000..193559f30d98 --- /dev/null +++ b/packages/opencode/src/history/service.ts @@ -0,0 +1,181 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Effect, Layer } from "effect" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { Session } from "@/session/session" +import { InstanceState } from "@/effect/instance-state" +import type { SessionID } from "../session/schema" +import * as Bm25 from "../memory/bm25" +import { ALL_KINDS, extract, renderPart, type Kind } from "./extract" + +export type SearchHit = { + part_id: string + session_id: string + message_id: string + project_id: string + kind: Kind + tool_name: string | null + snippet: string + score: number + time_created: number +} + +export type MessagePart = { + part_id: string + type: string + role: "user" | "assistant" + tool_name: string | null + text: string +} + +export type MessageContext = { + message_id: string + matched: boolean + time_created: number + parts: MessagePart[] +} + +export interface SearchInput { + query: string + scope?: "project" | "global" + session_id?: string + kind?: Kind | Kind[] + tool_name?: string + time_after?: number + time_before?: number + limit?: number +} + +export interface AroundInput { + message_id: string + session_id?: string + before?: number + after?: number +} + +export interface Interface { + readonly search: (input: SearchInput) => Effect.Effect + readonly around: (input: AroundInput) => Effect.Effect<{ session_id: string; messages: MessageContext[] }> +} + +export class Service extends Context.Service()("@opencode/History") {} + +const HARD_CAP = 50 + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const session = yield* Session.Service + + const sessionIds = Effect.fn("History.sessionIds")(function* (scope: "project" | "global") { + if (scope === "global") { + const list = yield* session.listGlobal().pipe(Effect.catch(() => Effect.succeed([]))) + return list.map((s) => s.id as SessionID) + } + const list = yield* session.list().pipe(Effect.catch(() => Effect.succeed([]))) + return list.map((s) => s.id) + }) + + const loadMessages = Effect.fn("History.loadMessages")(function* (sid: SessionID) { + return yield* session + .messages({ sessionID: sid }) + .pipe(Effect.catch(() => Effect.succeed([] as SessionV1.WithParts[]))) + }) + + const search = Effect.fn("History.search")(function* (input: SearchInput) { + const limit = Math.min(input.limit ?? 10, HARD_CAP) + const scope = input.scope ?? "project" + const enabled = new Set( + input.kind ? (Array.isArray(input.kind) ? input.kind : [input.kind]) : ALL_KINDS, + ) + + const ctx = yield* InstanceState.context + let ids = yield* sessionIds(scope) + if (input.session_id) ids = ids.filter((id) => id === input.session_id) + + const docs: Bm25.Doc[] = [] + const meta = new Map>() + + for (const sid of ids) { + const msgs = yield* loadMessages(sid) + for (const m of msgs) { + const role = m.info.role + const created = m.info.time.created + if (input.time_after !== undefined && created < input.time_after) continue + if (input.time_before !== undefined && created > input.time_before) continue + for (const part of m.parts) { + const ex = extract(part, role, enabled) + if (!ex) continue + if (input.tool_name && ex.tool_name !== input.tool_name) continue + const partId = part.id + docs.push({ path: partId, body: ex.body }) + meta.set(partId, { + session_id: sid, + message_id: m.info.id, + project_id: scope === "project" ? ctx.project.id : "", + kind: ex.kind, + tool_name: ex.tool_name, + time_created: created, + }) + } + } + } + + const ranked = Bm25.search(docs, input.query, { limit }) + return ranked.map((r) => { + const mm = meta.get(r.path)! + return { part_id: r.path, ...mm, snippet: r.snippet, score: r.score } + }) + }) + + const locateSession = Effect.fn("History.locateSession")(function* (messageId: string) { + for (const scope of ["project", "global"] as const) { + const ids = yield* sessionIds(scope) + for (const sid of ids) { + const msgs = yield* loadMessages(sid) + if (msgs.some((m) => m.info.id === messageId)) return sid + } + } + return undefined + }) + + const around = Effect.fn("History.around")(function* (input: AroundInput) { + const before = input.before ?? 5 + const after = input.after ?? 5 + + const sessionId = (input.session_id as SessionID | undefined) ?? (yield* locateSession(input.message_id)) + if (!sessionId) return { session_id: "", messages: [] as MessageContext[] } + + const msgs = yield* loadMessages(sessionId) + const idx = msgs.findIndex((m) => m.info.id === input.message_id) + if (idx === -1) return { session_id: sessionId, messages: [] as MessageContext[] } + + const start = Math.max(0, idx - before) + const end = Math.min(msgs.length, idx + after + 1) + const out: MessageContext[] = msgs.slice(start, end).map((m) => ({ + message_id: m.info.id, + matched: m.info.id === input.message_id, + time_created: m.info.time.created, + parts: m.parts.map((p) => { + const r = renderPart(p) + return { + part_id: p.id, + type: r.type, + role: m.info.role, + tool_name: r.tool_name, + text: r.text, + } + }), + })) + + return { session_id: sessionId, messages: out } + }) + + return Service.of({ search, around }) + }), +) + +export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(Session.defaultLayer))) + +export const node = LayerNode.make({ service: Service, layer, deps: [Session.node] }) + +export * as History from "./service" diff --git a/packages/opencode/src/memory/bm25.ts b/packages/opencode/src/memory/bm25.ts new file mode 100644 index 000000000000..04bf2e31b280 --- /dev/null +++ b/packages/opencode/src/memory/bm25.ts @@ -0,0 +1,109 @@ +import { queryTokens, tokenize } from "./tokenize" + +export interface Doc { + path: string + body: string +} + +export interface ScoredDoc { + path: string + score: number + snippet: string +} + +// Standard BM25 tuning constants. +const K1 = 1.2 +const B = 0.75 + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** + * Build a truncated snippet around the first matching query term, wrapping + * matches in `<<...>>` (mirrors MiMo's FTS5 snippet()). + */ +function snippet(body: string, terms: string[]): string { + const lower = body.toLowerCase() + let pos = -1 + for (const term of terms) { + const re = new RegExp(`\\b${escapeRegex(term)}\\b`, "i") + const m = lower.match(re) + if (m && m.index !== undefined && (pos === -1 || m.index < pos)) pos = m.index + } + if (pos === -1) pos = 0 + + const radius = 120 + const start = Math.max(0, pos - radius) + const end = Math.min(body.length, pos + radius) + let frag = body.slice(start, end).replace(/\s+/g, " ").trim() + for (const term of terms) { + frag = frag.replace(new RegExp(`\\b(${escapeRegex(term)})\\b`, "gi"), "<<$1>>") + } + const prefix = start > 0 ? "..." : "" + const suffix = end < body.length ? "..." : "" + return `${prefix}${frag}${suffix}` +} + +/** + * BM25 ranking over an in-memory corpus, replacing MiMo's SQLite FTS5 index. + * Query terms are OR-joined (a doc matches if it contains ANY term) and ranked + * by BM25. A relative score floor drops common-word-only noise: the top hit is + * always kept, trailing hits scoring below `floorRatio` of the top are dropped. + */ +export function search( + docs: Doc[], + query: string, + opts?: { limit?: number; floorRatio?: number }, +): ScoredDoc[] { + const limit = opts?.limit ?? 10 + const floorRatio = opts?.floorRatio ?? 0.15 + const terms = queryTokens(query) + if (terms.length === 0 || docs.length === 0) return [] + + const tokenized = docs.map((doc) => { + const tokens = tokenize(doc.body) + const tf = new Map() + for (const tok of tokens) tf.set(tok, (tf.get(tok) ?? 0) + 1) + return { doc, len: tokens.length, tf } + }) + + const N = tokenized.length + const avgdl = tokenized.reduce((sum, t) => sum + t.len, 0) / N || 1 + + const df = new Map() + for (const term of terms) { + let count = 0 + for (const t of tokenized) if (t.tf.has(term)) count++ + df.set(term, count) + } + const idf = (term: string) => { + const n = df.get(term) ?? 0 + return Math.log(1 + (N - n + 0.5) / (n + 0.5)) + } + + const scored = tokenized + .map(({ doc, len, tf }) => { + let score = 0 + let matched = false + for (const term of terms) { + const f = tf.get(term) ?? 0 + if (f === 0) continue + matched = true + const denom = f + K1 * (1 - B + B * (len / avgdl)) + score += idf(term) * ((f * (K1 + 1)) / denom) + } + return { doc, score, matched } + }) + .filter((s) => s.matched) + + if (scored.length === 0) return [] + scored.sort((a, b) => b.score - a.score) + + const top = scored[0].score + const cutoff = floorRatio > 0 ? top * floorRatio : -Infinity + return scored + .filter((s, i) => i === 0 || s.score >= cutoff) + .slice(0, limit) + .map((s) => ({ path: s.doc.path, score: s.score, snippet: snippet(s.doc.body, terms) })) +} diff --git a/packages/opencode/src/memory/index.ts b/packages/opencode/src/memory/index.ts new file mode 100644 index 000000000000..9bb8bc06a99b --- /dev/null +++ b/packages/opencode/src/memory/index.ts @@ -0,0 +1 @@ +export * as Memory from "./service" diff --git a/packages/opencode/src/memory/paths.ts b/packages/opencode/src/memory/paths.ts new file mode 100644 index 000000000000..9dfa256d4d83 --- /dev/null +++ b/packages/opencode/src/memory/paths.ts @@ -0,0 +1,62 @@ +import path from "path" +import { createHash } from "crypto" + +export type Scope = "global" | "projects" | "sessions" +export type MemoryType = "free" | "memory" | "checkpoint" | "progress" | "notes" + +export interface MemoryLocator { + scope: Scope + scope_id: string + type: MemoryType + key: string +} + +const TYPE_PATTERNS: Array<{ match: RegExp; type: MemoryType }> = [ + // Only `memory` is case-insensitive: it's the one file renamed lowercase + // memory.md -> MEMORY.md, so detection must bridge both casings. + { match: /^memory$/i, type: "memory" }, + { match: /^memory-/i, type: "memory" }, + { match: /^checkpoint$/, type: "checkpoint" }, + { match: /^checkpoint-/, type: "checkpoint" }, + { match: /^tasks\/[^/]+\/progress$/, type: "progress" }, + { match: /^tasks\/[^/]+\/notes$/, type: "notes" }, +] + +function detectType(key: string): MemoryType { + for (const p of TYPE_PATTERNS) if (p.match.test(key)) return p.type + return "free" +} + +/** + * Parse an absolute memory file path into its locator. The path is expected to + * use forward slashes (normalize on Windows before calling). + * Layout: /memory///.md + */ +export function parsePath(absPath: string): MemoryLocator | null { + const m = absPath.match(/\/memory\/(global|projects|sessions)(?:\/([^/]+))?\/(.+)\.md$/) + if (!m) return null + const [, scope, idMaybe, keyRaw] = m + const scope_id = scope === "global" ? "" : (idMaybe ?? "") + const key = keyRaw + return { scope: scope as Scope, scope_id, type: detectType(key), key } +} + +function assertSafeComponent(value: string) { + for (const segment of value.split("/")) { + if (segment === "..") throw new Error(`buildPath: invalid path component: ${value}`) + } + if (value.startsWith("/")) throw new Error(`buildPath: invalid path component: ${value}`) +} + +export function buildPath(input: { root: string; scope: Scope; scope_id?: string; key: string }): string { + if (input.scope_id !== undefined) assertSafeComponent(input.scope_id) + assertSafeComponent(input.key) + const parts = [input.root, input.scope] + if (input.scope !== "global") parts.push(input.scope_id ?? "") + parts.push(`${input.key}.md`) + return path.join(...parts) +} + +export function resolveProjectId(absRepoPath: string): string { + return createHash("sha256").update(absRepoPath).digest("hex").slice(0, 12) +} diff --git a/packages/opencode/src/memory/service.ts b/packages/opencode/src/memory/service.ts new file mode 100644 index 000000000000..704c63afb297 --- /dev/null +++ b/packages/opencode/src/memory/service.ts @@ -0,0 +1,90 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Effect, Layer } from "effect" +import path from "path" +import { Global } from "@opencode-ai/core/global" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Glob } from "@opencode-ai/core/util/glob" +import * as Bm25 from "./bm25" +import { parsePath } from "./paths" + +export interface SearchInput { + query: string + scope?: string + scope_id?: string + type?: string + limit?: number +} + +export interface SearchResult { + path: string + snippet: string + score: number + scope: string + scope_id: string + type: string +} + +export interface Interface { + readonly root: () => Effect.Effect + readonly search: (input: SearchInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Memory") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const root = path.join(Global.Path.data, "memory") + + const rootEff = Effect.fn("Memory.root")(function* () { + return root + }) + + const search = Effect.fn("Memory.search")(function* (input: SearchInput) { + const exists = yield* fs.existsSafe(root) + if (!exists) return [] as SearchResult[] + + const files = Glob.scanSync("**/*.md", { cwd: root, absolute: true, dot: true }) + if (files.length === 0) return [] as SearchResult[] + + // Read bodies + derive locator from path; apply scope/scope_id/type + // filters before ranking (mirrors MiMo's SQL WHERE clause semantics). + const docs: Bm25.Doc[] = [] + const meta = new Map() + for (const file of files) { + const normalized = file.replaceAll("\\", "/") + const loc = parsePath(normalized) + if (!loc) continue + if (input.scope && loc.scope !== input.scope) continue + if (input.scope_id && loc.scope_id !== input.scope_id) continue + if (input.type && loc.type !== input.type) continue + const body = yield* fs.readFileStringSafe(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (body === undefined) continue + docs.push({ path: file, body }) + meta.set(file, { scope: loc.scope, scope_id: loc.scope_id, type: loc.type }) + } + + const ranked = Bm25.search(docs, input.query, { limit: input.limit ?? 10 }) + return ranked.map((r) => { + const m = meta.get(r.path)! + return { + path: r.path, + snippet: r.snippet, + score: r.score, + scope: m.scope, + scope_id: m.scope_id, + type: m.type, + } + }) + }) + + return Service.of({ root: rootEff, search }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer)) + +export const node = LayerNode.make({ service: Service, layer, deps: [FSUtil.node] }) + +export * as Memory from "./service" diff --git a/packages/opencode/src/memory/tokenize.ts b/packages/opencode/src/memory/tokenize.ts new file mode 100644 index 000000000000..335e37a51f5f --- /dev/null +++ b/packages/opencode/src/memory/tokenize.ts @@ -0,0 +1,27 @@ +// Tokenize a free-form string into lowercase alphanumeric runs. +// +// Punctuation (`.`, `-`, `/`, `:`, quotes, etc.) becomes a separator, and each +// contiguous run of Unicode letters/numbers/underscore becomes one token. +// `\p{L}` includes CJK letters for non-latin recall. This mirrors the +// tokenization MiMo fed to SQLite FTS5 so query/body see the same token forms +// (e.g. `T5.3` -> `t5`, `3`; `postgres://host:5433` -> `postgres`, `host`, `5433`). +export function tokenize(raw: string): string[] { + return ( + raw + .toLowerCase() + .match(/[\p{L}\p{N}_]+/gu) + ?.filter(Boolean) ?? [] + ) +} + +/** Unique query tokens, preserving first-seen order. */ +export function queryTokens(raw: string): string[] { + const seen = new Set() + const out: string[] = [] + for (const t of tokenize(raw)) { + if (seen.has(t)) continue + seen.add(t) + out.push(t) + } + return out +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 2ebf0cf16f3b..9b4396fd9602 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -14,6 +14,7 @@ import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" +import { BackgroundJob } from "@/background/job" import { MessageID, PartID, SessionID } from "@/session/schema" import { NamedError } from "@opencode-ai/core/util/error" import { Cause, Effect, Option, Schema, Scope } from "effect" @@ -44,6 +45,22 @@ const tryParseJson = (text: string) => catch: () => new HttpApiError.BadRequest({}), }) +// Map a background subagent job status to a todo status, mirroring MiMo's +// taskToTodo (in_progress / completed / cancelled / pending). +function backgroundJobStatusToTodo(status: BackgroundJob.Status): string { + switch (status) { + case "running": + return "in_progress" + case "completed": + return "completed" + case "error": + case "cancelled": + return "cancelled" + default: + return "pending" + } +} + export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service @@ -56,6 +73,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const permissionSvc = yield* Permission.Service const statusSvc = yield* SessionStatus.Service const todoSvc = yield* Todo.Service + const backgroundSvc = yield* BackgroundJob.Service const summary = yield* SessionSummary.Service const events = yield* EventV2Bridge.Service const scope = yield* Scope.Scope @@ -91,7 +109,24 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) { yield* requireSession(ctx.params.sessionID) - return yield* todoSvc.get(ctx.params.sessionID) + const stored = yield* todoSvc.get(ctx.params.sessionID) + // MiMo-style enrichment: surface this session's subagent task jobs as todos + // derived from their live status, appended to the LLM-maintained todowrite + // list. Unlike MiMo (which replaces the list), we merge so opencode's native + // todowrite todos are preserved. + const jobs = yield* backgroundSvc.list().pipe(Effect.catch(() => Effect.succeed([]))) + const derived = jobs + .filter( + (job) => + job.type === "task" && + (job.metadata as { parentSessionId?: string } | undefined)?.parentSessionId === ctx.params.sessionID, + ) + .map((job) => ({ + content: job.title ?? job.id, + status: backgroundJobStatusToTodo(job.status), + priority: "medium", + })) + return [...stored, ...derived] }) const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: { diff --git a/packages/opencode/src/tool/change-directory.ts b/packages/opencode/src/tool/change-directory.ts new file mode 100644 index 000000000000..a449dbd3390e --- /dev/null +++ b/packages/opencode/src/tool/change-directory.ts @@ -0,0 +1,70 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { assertExternalDirectoryEffect } from "./external-directory" +import { SessionCwd } from "./session-cwd" +import * as Tool from "./tool" + +const DESCRIPTION = [ + "Switch the working directory for the current session (like cd in a terminal).", + "", + "Use this when the user asks to switch, change, or cd into a directory,", + "or when you need to work extensively within a subdirectory (e.g., a monorepo package).", + "", + "After calling this tool, all subsequent file operations (read, edit, write, glob, grep)", + "resolve relative paths from the new directory.", + "", + "Pass an absolute path, or a relative path (resolved from the current working directory).", + 'Pass "~" to reset back to the project root.', +].join("\n") + +export const Parameters = Schema.Struct({ + path: Schema.String.annotate({ + description: + "The directory to switch to. Absolute or relative to current working directory. Use '~' to reset to project root.", + }), +}) + +export const ChangeDirectoryTool = Tool.define( + "change_directory", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const currentCwd = SessionCwd.get(ctx.sessionID, ins.directory) + + if (params.path === "~" || params.path === "") { + SessionCwd.clear(ctx.sessionID) + return { + title: "reset", + metadata: { from: currentCwd, to: ins.directory }, + output: `Working directory reset to project root: ${ins.directory}`, + } + } + + const resolved = path.isAbsolute(params.path) ? params.path : path.resolve(currentCwd, params.path) + const normalized = path.normalize(resolved) + + const stat = yield* fs.stat(normalized).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat) throw new Error(`Directory does not exist: ${normalized}`) + if (stat.type !== "Directory") throw new Error(`Path is not a directory: ${normalized}`) + + yield* assertExternalDirectoryEffect(ctx, normalized, { kind: "directory" }) + + SessionCwd.set(ctx.sessionID, normalized) + + return { + title: path.relative(ins.worktree, normalized) || ".", + metadata: { from: currentCwd, to: normalized }, + output: `Working directory changed: ${currentCwd} -> ${normalized}`, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts new file mode 100644 index 000000000000..1c1e77dcc325 --- /dev/null +++ b/packages/opencode/src/tool/codesearch.ts @@ -0,0 +1,80 @@ +import { Effect, Schema } from "effect" +import { HttpClient } from "effect/unstable/http" +import * as Tool from "./tool" +import * as McpWebSearch from "./mcp-websearch" +import DESCRIPTION from "./codesearch.txt" + +const CodeArgs = Schema.Struct({ + query: Schema.String, + tokensNum: Schema.Number, +}) + +const CODE_TOOL = "get_code_context_exa" + +// `get_code_context_exa` is a deprecated Exa MCP tool: it is no longer enabled +// by default on the hosted endpoint (only web_search_exa / web_fetch_exa are), +// so calling it on the bare URL returns JSON-RPC -32602 "Tool not found". It is +// still available for backwards compatibility when explicitly enabled via the +// `tools` query param, so we request it on a code-specific URL. +function codeContextUrl(): string { + const params = new URLSearchParams() + if (process.env.EXA_API_KEY) params.set("exaApiKey", process.env.EXA_API_KEY) + params.set("tools", CODE_TOOL) + return `https://mcp.exa.ai/mcp?${params.toString()}` +} + +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ + description: + "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", + }), + tokensNum: Schema.optional(Schema.Number).annotate({ + description: + "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + }), +}) + +export const CodeSearchTool = Tool.define( + "codesearch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const tokensNum = params.tokensNum || 5000 + yield* ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum, + }, + }) + + const result = yield* McpWebSearch.call( + http, + codeContextUrl(), + CODE_TOOL, + CodeArgs, + { + query: params.query, + tokensNum, + }, + "30 seconds", + ) + + return { + output: + result ?? + "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", + title: `Code search: ${params.query}`, + metadata: {}, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/codesearch.txt b/packages/opencode/src/tool/codesearch.txt new file mode 100644 index 000000000000..4187f08d12ad --- /dev/null +++ b/packages/opencode/src/tool/codesearch.txt @@ -0,0 +1,12 @@ +- Search and get relevant context for any programming task using Exa Code API +- Provides the highest quality and freshest context for libraries, SDKs, and APIs +- Use this tool for ANY question or task related to programming +- Returns comprehensive code examples, documentation, and API references +- Optimized for finding specific programming patterns and solutions + +Usage notes: + - Adjustable token count (1000-50000) for focused or comprehensive results + - Default 5000 tokens provides balanced context for most queries + - Use lower values for specific questions, higher values for comprehensive documentation + - Supports queries about frameworks, libraries, APIs, and programming concepts + - Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware' diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a92e4720c0fc..4a3b5acb6593 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -18,6 +18,7 @@ import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { FSUtil } from "@opencode-ai/core/fs-util" import * as Bom from "@/util/bom" +import { SessionCwd } from "./session-cwd" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") @@ -79,7 +80,7 @@ export const EditTool = Tool.define( const instance = yield* InstanceState.context const filePath = path.isAbsolute(params.filePath) ? params.filePath - : path.join(instance.directory, params.filePath) + : path.join(SessionCwd.get(ctx.sessionID, instance.directory), params.filePath) yield* assertExternalDirectoryEffect(ctx, filePath) let diff = "" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 40d3a27d3cba..484443b13873 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -6,6 +6,7 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" +import { SessionCwd } from "./session-cwd" export const Parameters = Schema.Struct({ pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), @@ -25,6 +26,7 @@ export const GlobTool = Tool.define( execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => Effect.gen(function* () { const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) yield* ctx.ask({ permission: "glob", patterns: [params.pattern], @@ -35,8 +37,8 @@ export const GlobTool = Tool.define( }, }) - let search = params.path ?? ins.directory - search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search) + let search = params.path ?? cwd + search = path.isAbsolute(search) ? search : path.resolve(cwd, search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) if (info?.type === "File") { throw new Error(`glob path must be a directory: ${search}`) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index e44b8e89fe29..a827a0563cbd 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -6,6 +6,7 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" +import { SessionCwd } from "./session-cwd" export const Parameters = Schema.Struct({ pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), @@ -48,9 +49,10 @@ export const GrepTool = Tool.define( }) const ins = yield* InstanceState.context - const requested = path.isAbsolute(params.path ?? ins.directory) - ? (params.path ?? ins.directory) - : path.join(ins.directory, params.path ?? ".") + const base = SessionCwd.get(ctx.sessionID, ins.directory) + const requested = path.isAbsolute(params.path ?? base) + ? (params.path ?? base) + : path.join(base, params.path ?? ".") const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined))) yield* assertExternalDirectoryEffect(ctx, requested, { bypass: false, diff --git a/packages/opencode/src/tool/history.ts b/packages/opencode/src/tool/history.ts new file mode 100644 index 000000000000..c8a942a344c0 --- /dev/null +++ b/packages/opencode/src/tool/history.ts @@ -0,0 +1,147 @@ +import { Effect, Schema } from "effect" +import { History } from "@/history" +import DESCRIPTION from "./history.txt" +import * as Tool from "./tool" +import * as Truncate from "./truncate" +import { Agent } from "@/agent/agent" + +const KIND = Schema.Literals(["user_text", "assistant_text", "tool_input", "tool_error", "reasoning", "tool_output"]) + +// around() output can easily be tens of KB (multi-message contexts with full +// part bodies). Capping below the global MAX_BYTES nudges agents toward +// "search -> message_id -> targeted Read" instead of one giant inline dump. +const AROUND_MAX_BYTES = 20 * 1024 + +export const Parameters = Schema.Struct({ + operation: Schema.Literals(["search", "around"]).annotate({ + description: "search: BM25; around: pull message context", + }), + // search params + query: Schema.optional(Schema.String).annotate({ + description: "BM25 query over text/tool bodies. Required for operation=search.", + }), + scope: Schema.optional(Schema.Literals(["project", "global"])).annotate({ description: "Default project." }), + session_id: Schema.optional(Schema.String).annotate({ + description: "Filter to one session (search), or the anchor's session (around).", + }), + kind: Schema.optional(Schema.Array(KIND)).annotate({ description: "Filter to specific message/tool kinds." }), + tool_name: Schema.optional(Schema.String).annotate({ description: "Filter to a specific tool (e.g. shell, read)" }), + time_after: Schema.optional(Schema.Number).annotate({ description: "Unix ms" }), + time_before: Schema.optional(Schema.Number).annotate({ description: "Unix ms" }), + limit: Schema.optional(Schema.Number).annotate({ description: "Max 50, default 10" }), + // around params + message_id: Schema.optional(Schema.String).annotate({ + description: "Anchor message id. Required for operation=around.", + }), + before: Schema.optional(Schema.Number).annotate({ description: "Default 5" }), + after: Schema.optional(Schema.Number).annotate({ description: "Default 5" }), +}) + +export const HistoryTool = Tool.define( + "history", + Effect.gen(function* () { + const history = yield* History.Service + const truncate = yield* Truncate.Service + const agents = yield* Agent.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (args.operation === "search") { + if (!args.query) { + return { + title: "History search: missing query", + output: "operation=search requires a `query` argument.", + metadata: { count: 0 }, + } + } + const hits = yield* history.search({ + query: args.query, + scope: args.scope, + session_id: args.session_id, + kind: args.kind ? [...args.kind] : undefined, + tool_name: args.tool_name, + time_after: args.time_after, + time_before: args.time_before, + limit: args.limit, + }) + if (hits.length === 0) { + return { + title: "History search: 0 matches", + output: `0 matches for "${args.query}". Try memory search if you haven't, or broaden the query.`, + metadata: { count: 0 }, + } + } + const lines = [`Found ${hits.length} match${hits.length === 1 ? "" : "es"}:`, ""] + for (const h of hits) { + const kindLabel = h.tool_name ? `${h.kind} - ${h.tool_name}` : h.kind + lines.push(`### ${h.session_id} ${h.message_id} (${kindLabel})`) + lines.push(`Time: ${new Date(h.time_created).toISOString()}, Score: ${h.score.toFixed(3)}`) + lines.push(h.snippet) + lines.push("") + } + return { + title: `History search: ${hits.length} match${hits.length === 1 ? "" : "es"}`, + output: lines.join("\n"), + metadata: { count: hits.length }, + } + } + + // operation=around + if (!args.message_id) { + return { + title: "History around: missing message_id", + output: "operation=around requires a `message_id` argument.", + metadata: { count: 0 }, + } + } + const around = yield* history.around({ + message_id: args.message_id, + session_id: args.session_id, + before: args.before, + after: args.after, + }) + if (around.messages.length === 0) { + return { + title: "History around: anchor not found", + output: `No message with id ${args.message_id}.`, + metadata: { count: 0 }, + } + } + const lines = [ + `Session ${around.session_id}, ${around.messages.length} messages (anchor ${args.message_id}):`, + "", + ] + for (const m of around.messages) { + const prefix = m.matched ? ">>>" : "---" + lines.push(`${prefix} ${m.message_id} (${new Date(m.time_created).toISOString()})`) + for (const p of m.parts) { + const head = p.tool_name ? `${p.type} (${p.tool_name})` : p.type + lines.push(` ${p.role} - ${head}:`) + lines.push( + p.text + .split("\n") + .map((l) => ` ${l}`) + .join("\n"), + ) + } + lines.push("") + } + const rawOutput = lines.join("\n") + const agent = yield* agents.get(ctx.agent) + const truncated = yield* truncate.output(rawOutput, { maxBytes: AROUND_MAX_BYTES }, agent) + return { + title: `History around ${args.message_id}`, + output: truncated.content, + metadata: { + count: around.messages.length, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + } + }), + } + }), +) diff --git a/packages/opencode/src/tool/history.txt b/packages/opencode/src/tool/history.txt new file mode 100644 index 000000000000..08549eee3519 --- /dev/null +++ b/packages/opencode/src/tool/history.txt @@ -0,0 +1,18 @@ +Search RAW conversation trajectory: prior user/assistant messages, tool inputs, tool errors. + +USE ONLY WHEN MEMORY SEARCH RETURNS NOTHING USEFUL. + +memory is your curated notebook - small, fast, semantically organized. ALWAYS try `memory` first. +history is the unindexed firehose of your past sessions: bigger, noisier, slower, and the tool +result cap will likely truncate `around` output forcing a follow-up Grep/Read. Reach for it as a +last resort, e.g.: + - memory had no entry, but you suspect this came up in a prior session + - you need verbatim recall of something the memory summary glossed over + - debugging "did I already try X" across the project + +Two operations: + - search: BM25 over text/tool kinds (returns snippets + session_id + message_id) + - around: given a search hit's message_id (pass its session_id too when you have it), pull +/-N + surrounding messages from the raw store + +Default scope is current project; pass scope="global" to search every project on this machine. diff --git a/packages/opencode/src/tool/memory.ts b/packages/opencode/src/tool/memory.ts new file mode 100644 index 000000000000..4c630ac4961a --- /dev/null +++ b/packages/opencode/src/tool/memory.ts @@ -0,0 +1,79 @@ +import { Effect, Schema } from "effect" +import { Memory } from "@/memory" +import DESCRIPTION from "./memory.txt" +import * as Tool from "./tool" + +export const Parameters = Schema.Struct({ + operation: Schema.optional(Schema.Literal("search")).annotate({ + description: "Memory operation to perform (currently only 'search')", + }), + query: Schema.String.annotate({ description: "Search query (BM25 over markdown bodies)" }), + scope: Schema.optional(Schema.Literals(["global", "projects", "sessions"])).annotate({ + description: "Filter by memory scope", + }), + scope_id: Schema.optional(Schema.String).annotate({ + description: "Filter by scope id (e.g., session id, task id, project id hash)", + }), + type: Schema.optional(Schema.String).annotate({ + description: "Filter by memory type (memory, checkpoint, progress, notes, free, ...)", + }), + limit: Schema.optional(Schema.Number).annotate({ description: "Max results (default 10)" }), +}) + +export const MemoryTool = Tool.define( + "memory", + Effect.gen(function* () { + const memory = yield* Memory.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type) => + Effect.gen(function* () { + const results = yield* memory.search({ + query: args.query, + scope: args.scope, + scope_id: args.scope_id, + type: args.type, + limit: args.limit, + }) + if (results.length === 0) { + return { + title: `Memory search: 0 results`, + output: [ + `No matches for "${args.query}".`, + ``, + `0 results does NOT mean it was never recorded. Escalate before giving up:`, + `1. Retry with FEWER / more distinctive terms - queries are OR-joined and`, + ` ranked, so 1-2 rare words (an exact ID, function name, flag) beat a long`, + ` descriptive phrase. Drop generic words ("config", "params", "database").`, + `2. For a LITERAL string the tokenizer splits (URLs like postgres://..., ports`, + ` like 5433, paths) - Grep the memory dir directly.`, + `Widen scope progressively: session -> project -> global.`, + ].join("\n"), + metadata: { count: 0 }, + } + } + const lines = [ + `Found ${results.length} match${results.length === 1 ? "" : "es"} (BM25-ranked, best first).`, + `A hit here is authoritative - use it even if a parallel/sibling query returned nothing.`, + `If you need the FULL body (snippets are truncated), Read the path.`, + ``, + ] + for (const r of results) { + lines.push(`### ${r.path}`) + lines.push( + `Scope: ${r.scope}${r.scope_id ? `/${r.scope_id}` : ""}, Type: ${r.type}, Score: ${r.score.toFixed(3)}`, + ) + lines.push(r.snippet) + lines.push("") + } + return { + title: `Memory search: ${results.length} result${results.length === 1 ? "" : "s"}`, + output: lines.join("\n"), + metadata: { count: results.length }, + } + }), + } + }), +) diff --git a/packages/opencode/src/tool/memory.txt b/packages/opencode/src/tool/memory.txt new file mode 100644 index 000000000000..c48bcd781fcf --- /dev/null +++ b/packages/opencode/src/tool/memory.txt @@ -0,0 +1,38 @@ +Search session/project/global memory using BM25 over markdown bodies. Use this +to recall content the agent persisted previously: project memory, session +checkpoints, task narratives (under sessions//tasks/), project notes, +global preferences. + +Memory layout: /memory///.md +Scopes: global | projects | sessions + +QUERY GUIDELINES: +- Queries are OR'd and BM25-ranked: a document matches if it contains ANY query + word, ordered by relevance (how many / how rare the matched words are). + Low-relevance common-word-only matches are dropped by a score floor. +- Prefer 1-3 distinctive terms (function name, task ID, exact phrase from a + directive, a rare word from the snippet you want). Long lists of generic + words ("config params database connection") just add noise and bury the real + hit - pick the rarest, most specific word. +- "T5.3 closure" works. So does "permission deadlock". Avoid padding with + generic descriptors. +- Punctuation (`.`, `-`, `/`, `:`) is stripped during tokenization. Both query + and indexed body see only alphanumeric runs, so `T5.3` matches `T5 3`. A + literal like `postgres://host:5433` is tokenized as `postgres`, `host`, + `5433` - search one of those, not the full URL. + +A HIT IS AUTHORITATIVE. If search returns a result, trust it - even when a +different/sibling query you ran returned nothing. + +WHEN SEARCH RETURNS 0 (escalate, do not give up): +1. Retry with fewer / rarer terms (see guidelines above). +2. For a literal string the tokenizer splits (URL, port, path) - Grep the memory + dir directly. +Widen scope progressively: session -> project -> global. + +Actions: +- search: OR-joined BM25 query, optional scope/scope_id/type filters + +After search returns paths, use Read on the most relevant ones to load full +content (snippets are truncated). Use Glob on `/memory/**/*.md` to inspect +the tree if you need to find files by name pattern instead of body. diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts new file mode 100644 index 000000000000..99d582807448 --- /dev/null +++ b/packages/opencode/src/tool/multiedit.ts @@ -0,0 +1,60 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { EditTool } from "./edit" +import DESCRIPTION from "./multiedit.txt" +import { InstanceState } from "@/effect/instance-state" + +const EditEntry = Schema.Struct({ + oldString: Schema.String.annotate({ description: "The text to replace" }), + newString: Schema.String.annotate({ + description: "The text to replace it with (must be different from oldString)", + }), + replaceAll: Schema.optional(Schema.Boolean).annotate({ + description: "Replace all occurrences of oldString (default false)", + }), +}) + +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + edits: Schema.Array(EditEntry).annotate({ + description: "Array of edit operations to perform sequentially on the file", + }), +}) + +export const MultiEditTool = Tool.define( + "multiedit", + Effect.gen(function* () { + const editInfo = yield* EditTool + const edit = yield* editInfo.init() + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const instance = yield* InstanceState.context + const results: Tool.ExecuteResult[] = [] + for (const entry of params.edits) { + const result = yield* edit.execute( + { + filePath: params.filePath, + oldString: entry.oldString, + newString: entry.newString, + replaceAll: entry.replaceAll, + }, + ctx, + ) + results.push(result) + } + return { + title: path.relative(instance.worktree, params.filePath), + metadata: { + results: results.map((r) => r.metadata), + }, + output: results.at(-1)!.output, + } + }), + } + }), +) diff --git a/packages/opencode/src/tool/multiedit.txt b/packages/opencode/src/tool/multiedit.txt new file mode 100644 index 000000000000..fc1860ef30ab --- /dev/null +++ b/packages/opencode/src/tool/multiedit.txt @@ -0,0 +1,41 @@ +This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file. + +Before using this tool: + +1. Use the `read` tool to understand the file's contents and context +2. Verify the directory path is correct + +To make multiple file edits, provide the following: +1. filePath: The absolute path to the file to modify (must be absolute, not relative) +2. edits: An array of edit operations to perform, where each edit contains: + - oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation) + - newString: The edited text to replace the oldString + - replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false. + +IMPORTANT: +- All edits are applied in sequence, in the order they are provided +- Each edit operates on the result of the previous edit +- All edits must be valid for the operation to succeed - if any edit fails, none will be applied +- This tool is ideal when you need to make several changes to different parts of the same file + +CRITICAL REQUIREMENTS: +1. All edits follow the same requirements as the single Edit tool +2. The edits are atomic - either all succeed or none are applied +3. Plan your edits carefully to avoid conflicts between sequential operations + +WARNING: +- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace) +- The tool will fail if edits.oldString and edits.newString are the same +- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find + +When making edits: +- Ensure all edits result in idiomatic, correct code +- Do not leave the code in a broken state +- Always use absolute file paths (starting with /) +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +If you want to create a new file, use: +- A new file path, including dir name if needed +- First edit: empty oldString and the new file's contents as newString +- Subsequent edits: normal edit operations on the created content diff --git a/packages/opencode/src/tool/notebook-edit.ts b/packages/opencode/src/tool/notebook-edit.ts new file mode 100644 index 000000000000..2e75c55b3802 --- /dev/null +++ b/packages/opencode/src/tool/notebook-edit.ts @@ -0,0 +1,226 @@ +import * as path from "path" +import { Effect, Schema } from "effect" +import { createTwoFilesPatch } from "diff" +import * as Tool from "./tool" +import DESCRIPTION from "./notebook-edit.txt" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import { trimDiff } from "./edit" +import { SessionCwd } from "./session-cwd" + +export const Parameters = Schema.Struct({ + notebookPath: Schema.String.annotate({ description: "The absolute path to the .ipynb file to modify" }), + cellId: Schema.optional(Schema.String).annotate({ + description: + 'Cell id from the Read tool\'s output. Required for replace/delete; for insert, the new cell is added after this cell (or at the beginning if omitted).', + }), + newSource: Schema.optional(Schema.String).annotate({ + description: "The cell's new content. Required for replace and insert; ignored for delete.", + }), + cellType: Schema.optional(Schema.Literals(["code", "markdown"])).annotate({ + description: "Cell type. Required for insert; for replace, defaults to the existing cell's type.", + }), + editMode: Schema.optional(Schema.Literals(["replace", "insert", "delete"])).annotate({ + description: "Operation to perform. Defaults to replace.", + }), +}) + +type NotebookCell = { + cell_type: "code" | "markdown" | "raw" + id?: string + source: string | string[] + metadata?: Record + outputs?: unknown[] + execution_count?: number | null +} + +type Notebook = { + cells: NotebookCell[] + metadata?: Record + nbformat?: number + nbformat_minor?: number +} + +function stringToCellSource(content: string): string[] { + if (content === "") return [] + const lines = content.split("\n") + return lines.map((line, i) => (i < lines.length - 1 ? line + "\n" : line)).filter((line) => line !== "") +} + +function generateCellId(existing: Set): string { + for (let i = 0; i < 1000; i++) { + const id = crypto.randomUUID().slice(0, 8) + if (!existing.has(id)) return id + } + return crypto.randomUUID() +} + +function buildCell(cellType: "code" | "markdown", source: string, id: string): NotebookCell { + const sourceLines = stringToCellSource(source) + if (cellType === "code") { + return { + cell_type: "code", + id, + source: sourceLines, + metadata: {}, + outputs: [], + execution_count: null, + } + } + return { + cell_type: "markdown", + id, + source: sourceLines, + metadata: {}, + } +} + +export const NotebookEditTool = Tool.define( + "notebook_edit", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const events = yield* EventV2Bridge.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const editMode = params.editMode ?? "replace" + + if (!params.notebookPath) throw new Error("notebookPath is required") + if (path.extname(params.notebookPath) !== ".ipynb") { + throw new Error("notebookPath must point to a .ipynb file") + } + + const instance = yield* InstanceState.context + const notebookPath = path.isAbsolute(params.notebookPath) + ? params.notebookPath + : path.join(SessionCwd.get(ctx.sessionID, instance.directory), params.notebookPath) + + if (editMode !== "insert" && !params.cellId) { + throw new Error(`cellId is required when editMode is "${editMode}"`) + } + if ((editMode === "replace" || editMode === "insert") && params.newSource === undefined) { + throw new Error(`newSource is required when editMode is "${editMode}"`) + } + if (editMode === "insert" && !params.cellType) { + throw new Error('cellType is required when editMode is "insert"') + } + + yield* assertExternalDirectoryEffect(ctx, notebookPath) + + const exists = yield* fs.existsSafe(notebookPath) + if (!exists) throw new Error(`Notebook not found: ${notebookPath}`) + + const contentOld = yield* fs.readFileString(notebookPath) + const notebook = ((): Notebook => { + try { + return JSON.parse(contentOld) as Notebook + } catch (err) { + throw new Error(`Failed to parse notebook JSON: ${(err as Error).message}`) + } + })() + + if (!Array.isArray(notebook.cells)) { + throw new Error("Notebook is missing a `cells` array — file does not look like a valid Jupyter notebook") + } + + // Older notebooks (nbformat_minor < 5) have no cell ids. Backfill UUIDs + // for any cell missing one so cellId lookup is stable, and bump + // nbformat_minor to 5 to match what Jupyter does on save. + const existingIds = new Set() + for (const cell of notebook.cells) if (cell.id) existingIds.add(cell.id) + let backfilled = false + for (const cell of notebook.cells) { + if (cell.id) continue + const id = generateCellId(existingIds) + cell.id = id + existingIds.add(id) + backfilled = true + } + if (backfilled && (notebook.nbformat_minor ?? 0) < 5) { + notebook.nbformat_minor = 5 + } + + // Accept either the real cell id or a positional reference like "#0". + const findIndex = (ref: string) => { + if (ref.startsWith("#")) { + const idx = Number.parseInt(ref.slice(1), 10) + if (Number.isInteger(idx) && idx >= 0 && idx < notebook.cells.length) return idx + return -1 + } + return notebook.cells.findIndex((c) => c.id === ref) + } + const cellNotFound = (ref: string) => { + const ids = notebook.cells.map((c, i) => c.id ?? `#${i}`).join(", ") + return new Error(`Cell not found: ${ref}. Available cells: ${ids || "(none)"}`) + } + + let title = "" + + if (editMode === "replace") { + const idx = findIndex(params.cellId!) + if (idx === -1) throw cellNotFound(params.cellId!) + const target = notebook.cells[idx] + const nextType = params.cellType ?? (target.cell_type === "raw" ? "code" : target.cell_type) + const replaced = buildCell(nextType, params.newSource ?? "", target.id ?? params.cellId!) + if (target.cell_type === "code" && nextType === "code") { + replaced.outputs = target.outputs ?? [] + replaced.execution_count = target.execution_count ?? null + replaced.metadata = target.metadata ?? {} + } else { + replaced.metadata = target.metadata ?? {} + } + notebook.cells[idx] = replaced + title = `replace cell ${target.id ?? `#${idx}`}` + } else if (editMode === "delete") { + const idx = findIndex(params.cellId!) + if (idx === -1) throw cellNotFound(params.cellId!) + notebook.cells.splice(idx, 1) + title = `delete cell ${params.cellId}` + } else { + // insert + const newId = generateCellId(existingIds) + const cell = buildCell(params.cellType!, params.newSource ?? "", newId) + if (!params.cellId) { + notebook.cells.unshift(cell) + title = `insert cell at start` + } else { + const idx = findIndex(params.cellId) + if (idx === -1) throw cellNotFound(params.cellId) + notebook.cells.splice(idx + 1, 0, cell) + title = `insert cell after ${params.cellId}` + } + } + + const contentNew = JSON.stringify(notebook, null, 1) + (contentOld.endsWith("\n") ? "\n" : "") + + const diff = trimDiff(createTwoFilesPatch(notebookPath, notebookPath, contentOld, contentNew)) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(instance.worktree, notebookPath)], + always: ["*"], + metadata: { + filepath: notebookPath, + diff, + }, + }) + + yield* fs.writeWithDirs(notebookPath, contentNew) + yield* events.publish(FileSystem.Event.Edited, { file: notebookPath }) + yield* events.publish(Watcher.Event.Updated, { file: notebookPath, event: "change" }) + + return { + title: `${path.relative(instance.worktree, notebookPath)} — ${title}`, + metadata: { diff, edit_mode: editMode, cell_id: params.cellId }, + output: `Notebook updated: ${editMode} on ${path.relative(instance.worktree, notebookPath)}.`, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/notebook-edit.txt b/packages/opencode/src/tool/notebook-edit.txt new file mode 100644 index 000000000000..3654c4baab0c --- /dev/null +++ b/packages/opencode/src/tool/notebook-edit.txt @@ -0,0 +1,10 @@ +Replaces, inserts, or deletes a single cell in a Jupyter notebook (.ipynb file). + +Usage: +- Prefer using your `read` tool on the notebook before editing so you know the cell ids and contents. +- `notebookPath` should be an absolute path to a `.ipynb` file. +- `cellId` is the `id` attribute shown in the `read` tool's `` output. It is required for `replace` and `delete`. For older notebooks (nbformat_minor < 5) where cells have no real `id`, you can pass a positional reference like `#0`, `#1`, etc. (0-based index). On first edit, missing ids are auto-generated and the notebook is upgraded to nbformat_minor 5. +- `editMode` defaults to `replace`. Use `insert` to add a new cell after the cell with the given `cellId` (or at the beginning of the notebook if `cellId` is omitted) — `cellType` is required when inserting. Use `delete` to remove the cell. +- For `replace`, the cell type is preserved unless `cellType` is explicitly provided. +- `newSource` is the cell's full new content (required for `replace` and `insert`, ignored for `delete`). +- This tool understands the Jupyter notebook cell structure and modifies cells in place — prefer it over `write`/`edit` for `.ipynb` files so the surrounding JSON, outputs, and metadata stay intact. diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 678ed4451048..bef27e4dcc89 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -9,6 +9,7 @@ import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { SessionCwd } from "./session-cwd" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -233,7 +234,7 @@ export const ReadTool = Tool.define< const instance = yield* InstanceState.context let filepath = params.filePath if (!path.isAbsolute(filepath)) { - filepath = path.resolve(instance.directory, filepath) + filepath = path.resolve(SessionCwd.get(ctx.sessionID, instance.directory), filepath) } if (process.platform === "win32") { filepath = FSUtil.normalizePath(filepath) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 553fa1ebaf5c..f3fdd93325ea 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -6,6 +6,8 @@ import { Session } from "@/session/session" import { QuestionTool } from "./question" import { ShellTool } from "./shell" import { EditTool } from "./edit" +import { MultiEditTool } from "./multiedit" +import { NotebookEditTool } from "./notebook-edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ReadTool } from "./read" @@ -26,9 +28,13 @@ import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { WebSearchTool } from "./websearch" +import { CodeSearchTool } from "./codesearch" +import { MemoryTool } from "./memory" +import { HistoryTool } from "./history" import { LspTool } from "./lsp" import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" +import { ChangeDirectoryTool } from "./change-directory" import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" @@ -47,6 +53,8 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { EventV2Bridge } from "@/event-v2-bridge" import { Agent } from "../agent/agent" import { Skill } from "../skill" +import { Memory } from "@/memory" +import { History } from "@/history" import { Permission } from "@/permission" import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -98,12 +106,18 @@ export const layer = Layer.effect( const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool + const codesearch = yield* CodeSearchTool + const memorytool = yield* MemoryTool + const historytool = yield* HistoryTool const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool + const multiedit = yield* MultiEditTool + const notebookedit = yield* NotebookEditTool const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool + const changedirtool = yield* ChangeDirectoryTool const skilltool = yield* SkillTool const agent = yield* Agent.Service @@ -202,13 +216,19 @@ export const layer = Layer.effect( glob: Tool.init(globtool), grep: Tool.init(greptool), edit: Tool.init(edit), + multiedit: Tool.init(multiedit), + notebookedit: Tool.init(notebookedit), write: Tool.init(writetool), task: Tool.init(task), fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), + code: Tool.init(codesearch), + memory: Tool.init(memorytool), + history: Tool.init(historytool), skill: Tool.init(skilltool), patch: Tool.init(patchtool), + changedir: Tool.init(changedirtool), question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), @@ -224,13 +244,19 @@ export const layer = Layer.effect( tool.glob, tool.grep, tool.edit, + tool.multiedit, + tool.notebookedit, tool.write, tool.task, tool.fetch, tool.todo, tool.search, + tool.code, + tool.memory, + tool.history, tool.skill, tool.patch, + tool.changedir, ...(flags.experimentalLspTool ? [tool.lsp] : []), ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), ], @@ -266,7 +292,7 @@ export const layer = Layer.effect( const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { - if (tool.id === WebSearchTool.id) { + if (tool.id === WebSearchTool.id || tool.id === CodeSearchTool.id) { return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } @@ -336,7 +362,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Truncate.defaultLayer), ) - .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)), + .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(Memory.defaultLayer), Layer.provide(History.defaultLayer)), ) function isZodType(value: unknown): value is z.ZodType { @@ -438,6 +464,8 @@ export const node = LayerNode.make({ Truncate.node, RuntimeFlags.node, Database.node, + Memory.node, + History.node, ], }) diff --git a/packages/opencode/src/tool/session-cwd.ts b/packages/opencode/src/tool/session-cwd.ts new file mode 100644 index 000000000000..db08dfeff3d5 --- /dev/null +++ b/packages/opencode/src/tool/session-cwd.ts @@ -0,0 +1,22 @@ +// Per-session working directory, set by the change_directory tool. +// +// opencode tools normally resolve relative paths against the instance +// directory. This module lets a session override that base (like `cd` in a +// terminal) without rewiring InstanceState: tools call `SessionCwd.get(id, fallback)` +// and get the session's override or the fallback when none is set. Backward +// compatible — an unset session always resolves to the fallback (instance dir). +const store = new Map() + +export function get(sessionID: string, fallback: string): string { + return store.get(sessionID) ?? fallback +} + +export function set(sessionID: string, dir: string): void { + store.set(sessionID, dir) +} + +export function clear(sessionID: string): void { + store.delete(sessionID) +} + +export * as SessionCwd from "./session-cwd" diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 37be6d8c47bc..a0ac688c86ee 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -14,6 +14,7 @@ import { InstanceState } from "@/effect/instance-state" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" import * as Bom from "@/util/bom" +import { SessionCwd } from "./session-cwd" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -40,7 +41,7 @@ export const WriteTool = Tool.define( const instance = yield* InstanceState.context const filepath = path.isAbsolute(params.filePath) ? params.filePath - : path.join(instance.directory, params.filePath) + : path.join(SessionCwd.get(ctx.sessionID, instance.directory), params.filePath) yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) diff --git a/packages/opencode/test/memory/search.test.ts b/packages/opencode/test/memory/search.test.ts new file mode 100644 index 000000000000..c21da8aaac82 --- /dev/null +++ b/packages/opencode/test/memory/search.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import * as Bm25 from "@/memory/bm25" +import { tokenize, queryTokens } from "@/memory/tokenize" +import { parsePath } from "@/memory/paths" + +describe("memory/tokenize", () => { + test("splits on punctuation into lowercase alnum runs", () => { + expect(tokenize("postgres://host:5433")).toEqual(["postgres", "host", "5433"]) + expect(tokenize("T5.3 closure")).toEqual(["t5", "3", "closure"]) + }) + + test("queryTokens dedupes preserving order", () => { + expect(queryTokens("foo foo bar")).toEqual(["foo", "bar"]) + }) +}) + +describe("memory/paths", () => { + test("parses global scope", () => { + const loc = parsePath("/data/memory/global/MEMORY.md") + expect(loc).toEqual({ scope: "global", scope_id: "", type: "memory", key: "MEMORY" }) + }) + + test("parses session checkpoint", () => { + const loc = parsePath("/data/memory/sessions/ses_123/checkpoint.md") + expect(loc).toMatchObject({ scope: "sessions", scope_id: "ses_123", type: "checkpoint", key: "checkpoint" }) + }) + + test("returns null for non-memory paths", () => { + expect(parsePath("/some/other/file.md")).toBeNull() + }) +}) + +describe("memory/bm25", () => { + const docs: Bm25.Doc[] = [ + { path: "a", body: "the deadlock happened in the permission system" }, + { path: "b", body: "general notes about configuration and database" }, + { path: "c", body: "permission checks run before every tool call" }, + ] + + test("ranks the most relevant doc first", () => { + const res = Bm25.search(docs, "permission deadlock", { limit: 10 }) + expect(res.length).toBeGreaterThan(0) + expect(res[0].path).toBe("a") + }) + + test("returns empty for no token overlap", () => { + expect(Bm25.search(docs, "kubernetes helm chart", { limit: 10 })).toEqual([]) + }) + + test("snippet highlights matched terms", () => { + const res = Bm25.search(docs, "deadlock", { limit: 1 }) + expect(res[0].snippet).toContain("<>") + }) + + test("respects limit", () => { + const res = Bm25.search(docs, "permission", { limit: 1, floorRatio: 0 }) + expect(res.length).toBe(1) + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index bb98a867faee..51582c791fa6 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -42,6 +42,8 @@ import { SessionStatus } from "../../src/session/status" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { Skill } from "../../src/skill" +import { Memory } from "@/memory" +import { History } from "@/history" import { SystemPrompt } from "../../src/session/system" import { Shell } from "@opencode-ai/core/shell" import { Snapshot } from "../../src/snapshot" @@ -198,6 +200,8 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provide(Memory.defaultLayer), + Layer.provide(History.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), From 12f83ac72b46fdaf4bc67de7915b8511c991a2d9 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 27 Jun 2026 18:13:05 -0700 Subject: [PATCH 2/3] feat(tools): add git, format and diagnostics tools git: read-only structured git (status/diff/log/show/blame/branch) via Git.Service. format: run configured formatters on files via Format.Service. diagnostics: report LSP diagnostics for a file or the whole project. All resolve relative paths against the session cwd (change_directory). Verified: typecheck + build + smoke test green. --- packages/opencode/src/tool/diagnostics.ts | 90 +++++++++++++++++++ packages/opencode/src/tool/format.ts | 69 +++++++++++++++ packages/opencode/src/tool/git.ts | 102 ++++++++++++++++++++++ packages/opencode/src/tool/registry.ts | 16 +++- 4 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/tool/diagnostics.ts create mode 100644 packages/opencode/src/tool/format.ts create mode 100644 packages/opencode/src/tool/git.ts diff --git a/packages/opencode/src/tool/diagnostics.ts b/packages/opencode/src/tool/diagnostics.ts new file mode 100644 index 000000000000..4936c86f570f --- /dev/null +++ b/packages/opencode/src/tool/diagnostics.ts @@ -0,0 +1,90 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { LSP } from "@/lsp/lsp" +import { InstanceState } from "@/effect/instance-state" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { assertExternalDirectoryEffect } from "./external-directory" +import { SessionCwd } from "./session-cwd" + +const DESCRIPTION = [ + "Report compile/type/lint diagnostics from the language servers (LSP).", + "", + "Pass `filePath` to refresh and report diagnostics for a single file (the server is", + "asked to recompute first). Omit it to report all currently-known diagnostics across", + "the project. Relative paths resolve against the session working directory.", + "", + "Use this to check for errors after edits instead of running the project's typecheck via shell.", +].join("\n") + +export const Parameters = Schema.Struct({ + filePath: Schema.optional(Schema.String).annotate({ + description: "Optional file to refresh and report. Omit to report all known project diagnostics.", + }), +}) + +export const DiagnosticsTool = Tool.define( + "diagnostics", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const fs = yield* FSUtil.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const instance = yield* InstanceState.context + const base = SessionCwd.get(ctx.sessionID, instance.directory) + + yield* ctx.ask({ + permission: "diagnostics", + patterns: ["*"], + always: ["*"], + metadata: { filePath: args.filePath }, + }) + + if (args.filePath) { + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(base, args.filePath) + yield* assertExternalDirectoryEffect(ctx, file) + const exists = yield* fs.existsSafe(file) + if (!exists) throw new Error(`File not found: ${file}`) + const available = yield* lsp.hasClients(file) + if (!available) { + return { + title: path.relative(instance.worktree, file), + metadata: { count: 0 }, + output: "No LSP server available for this file type.", + } + } + yield* lsp.touchFile(file, "full") + const all = yield* lsp.diagnostics() + const normalized = FSUtil.normalizePath(file) + const block = LSP.Diagnostic.report(file, all[normalized] ?? []) + return { + title: path.relative(instance.worktree, file), + metadata: { count: (all[normalized] ?? []).length }, + output: block || "No diagnostics for this file.", + } + } + + const all = yield* lsp.diagnostics() + const blocks: string[] = [] + let total = 0 + for (const [file, issues] of Object.entries(all)) { + if (!issues.length) continue + const block = LSP.Diagnostic.report(file, issues) + if (!block) continue + total += issues.length + blocks.push(block) + } + + return { + title: `diagnostics: ${total} issue${total === 1 ? "" : "s"}`, + metadata: { count: total }, + output: blocks.length ? blocks.join("\n\n") : "No diagnostics reported across the project.", + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/format.ts b/packages/opencode/src/tool/format.ts new file mode 100644 index 000000000000..8c6ad98aab14 --- /dev/null +++ b/packages/opencode/src/tool/format.ts @@ -0,0 +1,69 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { Format } from "../format" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import { SessionCwd } from "./session-cwd" + +const DESCRIPTION = [ + "Format one or more files using the project's configured formatters (prettier, ruff, gofmt, etc.).", + "", + "Relative paths resolve against the session working directory (see change_directory).", + "Returns which files were formatted and which had no matching formatter configured.", + "Use this after editing when you want to normalize style without running the formatter via shell.", +].join("\n") + +export const Parameters = Schema.Struct({ + paths: Schema.Array(Schema.String).annotate({ + description: "Absolute or relative file paths to format", + }), +}) + +export const FormatTool = Tool.define( + "format", + Effect.gen(function* () { + const format = yield* Format.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const instance = yield* InstanceState.context + const base = SessionCwd.get(ctx.sessionID, instance.directory) + + const resolved = params.paths.map((p) => (path.isAbsolute(p) ? p : path.join(base, p))) + for (const filepath of resolved) { + yield* assertExternalDirectoryEffect(ctx, filepath) + } + + yield* ctx.ask({ + permission: "format", + patterns: resolved.map((f) => path.relative(instance.worktree, f)), + always: ["*"], + metadata: { paths: resolved }, + }) + + const formatted: string[] = [] + const skipped: string[] = [] + for (const filepath of resolved) { + const did = yield* format.file(filepath) + const rel = path.relative(instance.worktree, filepath) + if (did) formatted.push(rel) + else skipped.push(rel) + } + + const lines: string[] = [] + if (formatted.length) lines.push(`Formatted (${formatted.length}):`, ...formatted.map((f) => ` ${f}`)) + if (skipped.length) lines.push(`No formatter configured (${skipped.length}):`, ...skipped.map((f) => ` ${f}`)) + + return { + title: `format ${params.paths.length} file${params.paths.length === 1 ? "" : "s"}`, + metadata: { formatted, skipped }, + output: lines.length ? lines.join("\n") : "Nothing to format.", + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/git.ts b/packages/opencode/src/tool/git.ts new file mode 100644 index 000000000000..14077eaf3908 --- /dev/null +++ b/packages/opencode/src/tool/git.ts @@ -0,0 +1,102 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { Git } from "@/git" +import { InstanceState } from "@/effect/instance-state" +import { SessionCwd } from "./session-cwd" + +const operations = ["status", "diff", "log", "show", "blame", "branch"] as const + +const DESCRIPTION = [ + "Inspect the git state of the repository with structured, read-only operations.", + "Prefer this over running git through the shell: output is cleaner and cheaper on context.", + "", + "Operations:", + "- status: working tree status (short + branch)", + "- diff: changes vs working tree, or vs `ref` when provided; scope with `path`", + "- log: recent commits (oneline); limit with `limit`, scope with `ref`/`path`", + "- show: a commit/object (defaults to HEAD); scope with `path`", + "- blame: line-by-line authorship for a file (`path` required)", + "- branch: list local and remote branches", + "", + "Relative `path` resolves against the session working directory (see change_directory).", + "This tool does not mutate the repository (no commit/push/checkout); use shell for that.", +].join("\n") + +export const Parameters = Schema.Struct({ + operation: Schema.Literals(operations).annotate({ description: "The git operation to perform" }), + ref: Schema.optional(Schema.String).annotate({ + description: "Git ref/commit/branch for diff/show/log (e.g. HEAD, main, a commit SHA)", + }), + path: Schema.optional(Schema.String).annotate({ + description: "Limit the operation to a file or directory (required for blame)", + }), + limit: Schema.optional(Schema.Number).annotate({ description: "Max entries for log (default 20)" }), +}) + +export const GitTool = Tool.define( + "git", + Effect.gen(function* () { + const git = yield* Git.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const instance = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, instance.directory) + + yield* ctx.ask({ + permission: "git", + patterns: [args.operation], + always: ["*"], + metadata: { operation: args.operation, ref: args.ref, path: args.path }, + }) + + const argv: string[] = (() => { + switch (args.operation) { + case "status": + return ["status", "--short", "--branch"] + case "diff": + return [ + "diff", + "--no-ext-diff", + ...(args.ref ? [args.ref] : []), + ...(args.path ? ["--", args.path] : []), + ] + case "log": + return [ + "log", + "--oneline", + "--decorate", + `--max-count=${args.limit ?? 20}`, + ...(args.ref ? [args.ref] : []), + ...(args.path ? ["--", args.path] : []), + ] + case "show": + return ["show", "--no-ext-diff", args.ref ?? "HEAD", ...(args.path ? ["--", args.path] : [])] + case "blame": + if (!args.path) throw new Error("git blame requires a `path` argument") + return ["blame", "--", args.path] + case "branch": + return ["branch", "--all", "--verbose"] + } + })() + + const result = yield* git.run(argv, { cwd }) + const text = result.text().trim() + const err = result.stderr.toString("utf8").trim() + const output = + result.exitCode === 0 + ? text || "(no output)" + : `git ${args.operation} failed (exit ${result.exitCode}):\n${err || text}` + + return { + title: `git ${args.operation}`, + metadata: { operation: args.operation, exitCode: result.exitCode, truncated: result.truncated }, + output, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f3fdd93325ea..7fd9276851cb 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -35,6 +35,10 @@ import { LspTool } from "./lsp" import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" import { ChangeDirectoryTool } from "./change-directory" +import { FormatTool } from "./format" +import { GitTool } from "./git" +import { DiagnosticsTool } from "./diagnostics" +import { Git } from "@/git" import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" @@ -118,6 +122,9 @@ export const layer = Layer.effect( const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const changedirtool = yield* ChangeDirectoryTool + const formattool = yield* FormatTool + const gittool = yield* GitTool + const diagnosticstool = yield* DiagnosticsTool const skilltool = yield* SkillTool const agent = yield* Agent.Service @@ -229,6 +236,9 @@ export const layer = Layer.effect( skill: Tool.init(skilltool), patch: Tool.init(patchtool), changedir: Tool.init(changedirtool), + format: Tool.init(formattool), + git: Tool.init(gittool), + diagnostics: Tool.init(diagnosticstool), question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), @@ -257,6 +267,9 @@ export const layer = Layer.effect( tool.skill, tool.patch, tool.changedir, + tool.format, + tool.git, + tool.diagnostics, ...(flags.experimentalLspTool ? [tool.lsp] : []), ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), ], @@ -362,7 +375,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Truncate.defaultLayer), ) - .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(Memory.defaultLayer), Layer.provide(History.defaultLayer)), + .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(Memory.defaultLayer), Layer.provide(History.defaultLayer), Layer.provide(Git.defaultLayer)), ) function isZodType(value: unknown): value is z.ZodType { @@ -466,6 +479,7 @@ export const node = LayerNode.make({ Database.node, Memory.node, History.node, + Git.node, ], }) From 891f11782a258e255576b2951345bcadff3a24af Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 27 Jun 2026 18:51:12 -0700 Subject: [PATCH 3/3] feat(tools): add 15 tools + fix TUI spinner registration Tools: rename_symbol & code_actions (LSP rename/codeAction + WorkspaceEdit applier), test (runner detection), git_commit, patch_apply, bulk_edit, todo_scan, tree, json_query, memory_write, deps_add/deps_outdated, process_start/logs/stop. All resolve paths against the session cwd. TUI: register the opentui spinner component explicitly (registerSpinner()) so the compiled binary no longer crashes with 'Unknown component type: spinner'; animations re-enabled. Verified: typecheck 0 errors, build + smoke test green, unit tests pass. --- packages/opencode/src/lsp/lsp.ts | 49 +++++++ .../opencode/src/tool/apply-workspace-edit.ts | 100 ++++++++++++++ packages/opencode/src/tool/bulk-edit.ts | 111 ++++++++++++++++ packages/opencode/src/tool/code-actions.ts | 113 ++++++++++++++++ packages/opencode/src/tool/deps.ts | 121 +++++++++++++++++ packages/opencode/src/tool/git-commit.ts | 63 +++++++++ packages/opencode/src/tool/json-query.ts | 103 +++++++++++++++ packages/opencode/src/tool/memory-write.ts | 79 +++++++++++ packages/opencode/src/tool/package-manager.ts | 31 +++++ packages/opencode/src/tool/patch-apply.ts | 53 ++++++++ .../opencode/src/tool/process-registry.ts | 88 ++++++++++++ packages/opencode/src/tool/process.ts | 125 ++++++++++++++++++ packages/opencode/src/tool/registry.ts | 61 ++++++++- packages/opencode/src/tool/rename-symbol.ts | 98 ++++++++++++++ packages/opencode/src/tool/test.ts | 100 ++++++++++++++ packages/opencode/src/tool/todo-scan.ts | 77 +++++++++++ packages/opencode/src/tool/tree.ts | 101 ++++++++++++++ packages/opencode/test/session/prompt.test.ts | 4 + .../test/session/snapshot-tool-race.test.ts | 2 + packages/opencode/test/tool/lsp.test.ts | 2 + packages/tui/src/component/prompt/index.tsx | 3 +- packages/tui/src/component/spinner.tsx | 10 +- 22 files changed, 1491 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/tool/apply-workspace-edit.ts create mode 100644 packages/opencode/src/tool/bulk-edit.ts create mode 100644 packages/opencode/src/tool/code-actions.ts create mode 100644 packages/opencode/src/tool/deps.ts create mode 100644 packages/opencode/src/tool/git-commit.ts create mode 100644 packages/opencode/src/tool/json-query.ts create mode 100644 packages/opencode/src/tool/memory-write.ts create mode 100644 packages/opencode/src/tool/package-manager.ts create mode 100644 packages/opencode/src/tool/patch-apply.ts create mode 100644 packages/opencode/src/tool/process-registry.ts create mode 100644 packages/opencode/src/tool/process.ts create mode 100644 packages/opencode/src/tool/rename-symbol.ts create mode 100644 packages/opencode/src/tool/test.ts create mode 100644 packages/opencode/src/tool/todo-scan.ts create mode 100644 packages/opencode/src/tool/tree.ts diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 16fb10f7bfd3..515546f06086 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -126,6 +126,14 @@ export interface Interface { readonly definition: (input: LocInput) => Effect.Effect readonly references: (input: LocInput) => Effect.Effect readonly implementation: (input: LocInput) => Effect.Effect + readonly rename: (input: LocInput & { newName: string }) => Effect.Effect + readonly codeAction: (input: { + file: string + line?: number + character?: number + endLine?: number + endCharacter?: number + }) => Effect.Effect readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]> readonly workspaceSymbol: (query: string) => Effect.Effect readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect @@ -422,6 +430,45 @@ export const layer = Layer.effect( return results.flat().filter(Boolean) }) + const rename = Effect.fn("LSP.rename")(function* (input: LocInput & { newName: string }) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/rename", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + newName: input.newName, + }) + .catch(() => null), + ) + return results.find(Boolean) ?? null + }) + + const codeAction = Effect.fn("LSP.codeAction")(function* (input: { + file: string + line?: number + character?: number + endLine?: number + endCharacter?: number + }) { + const startLine = input.line ?? 0 + const startChar = input.character ?? 0 + const endLine = input.endLine ?? startLine + const endChar = input.endCharacter ?? startChar + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/codeAction", { + textDocument: { uri: pathToFileURL(input.file).href }, + range: { + start: { line: startLine, character: startChar }, + end: { line: endLine, character: endChar }, + }, + context: { diagnostics: [] }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { const file = fileURLToPath(uri) const results = yield* run(file, (client) => @@ -487,6 +534,8 @@ export const layer = Layer.effect( definition, references, implementation, + rename, + codeAction, documentSymbol, workspaceSymbol, prepareCallHierarchy, diff --git a/packages/opencode/src/tool/apply-workspace-edit.ts b/packages/opencode/src/tool/apply-workspace-edit.ts new file mode 100644 index 000000000000..22dd3c081302 --- /dev/null +++ b/packages/opencode/src/tool/apply-workspace-edit.ts @@ -0,0 +1,100 @@ +import { fileURLToPath } from "url" +import { Effect } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" + +type Pos = { line: number; character: number } +type TextEdit = { range: { start: Pos; end: Pos }; newText: string } + +/** Minimal publisher shape (compatible with EventV2Bridge.Service). */ +type EventPublisher = { publish: (event: any, data: any) => Effect.Effect } + +function computeLineStarts(content: string): number[] { + const starts = [0] + for (let i = 0; i < content.length; i++) { + if (content[i] === "\n") starts.push(i + 1) + } + return starts +} + +function offsetAt(starts: number[], pos: Pos, length: number): number { + const base = pos.line >= 0 && pos.line < starts.length ? starts[pos.line] : length + return Math.max(0, Math.min(base + Math.max(0, pos.character), length)) +} + +function applyEdits(content: string, edits: TextEdit[]): string { + const starts = computeLineStarts(content) + const resolved = edits + .map((edit) => ({ + start: offsetAt(starts, edit.range.start, content.length), + end: offsetAt(starts, edit.range.end, content.length), + newText: edit.newText ?? "", + })) + // Apply from the end backwards so earlier offsets stay valid. + .sort((a, b) => b.start - a.start || b.end - a.end) + + let result = content + for (const edit of resolved) { + result = result.slice(0, edit.start) + edit.newText + result.slice(edit.end) + } + return result +} + +/** + * Collect the per-file edits from an LSP WorkspaceEdit (both `changes` and + * `documentChanges` shapes). + */ +function collect(edit: unknown): Map { + const out = new Map() + if (!edit || typeof edit !== "object") return out + const we = edit as { + changes?: Record + documentChanges?: Array<{ textDocument?: { uri?: string }; edits?: TextEdit[] }> + } + if (we.changes) { + for (const [uri, edits] of Object.entries(we.changes)) { + if (Array.isArray(edits)) out.set(uri, [...(out.get(uri) ?? []), ...edits]) + } + } + if (Array.isArray(we.documentChanges)) { + for (const dc of we.documentChanges) { + const uri = dc?.textDocument?.uri + if (uri && Array.isArray(dc.edits)) out.set(uri, [...(out.get(uri) ?? []), ...dc.edits]) + } + } + return out +} + +/** + * Apply an LSP WorkspaceEdit to disk via FSUtil and emit file-change events. + * Returns the list of changed absolute file paths. + */ +export const applyWorkspaceEdit = Effect.fn("Tool.applyWorkspaceEdit")(function* ( + edit: unknown, + fs: FSUtil.Interface, + events: EventPublisher, +) { + const fileEdits = collect(edit) + const changed: string[] = [] + for (const [uri, edits] of fileEdits) { + if (edits.length === 0) continue + const file = (() => { + try { + return fileURLToPath(uri) + } catch { + return undefined + } + })() + if (!file) continue + const content = yield* fs.readFileStringSafe(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (content === undefined) continue + const next = applyEdits(content, edits) + if (next === content) continue + yield* fs.writeWithDirs(file, next).pipe(Effect.orDie) + yield* events.publish(FileSystem.Event.Edited, { file }) + yield* events.publish(Watcher.Event.Updated, { file, event: "change" }) + changed.push(file) + } + return changed +}) diff --git a/packages/opencode/src/tool/bulk-edit.ts b/packages/opencode/src/tool/bulk-edit.ts new file mode 100644 index 000000000000..1c78651850ba --- /dev/null +++ b/packages/opencode/src/tool/bulk-edit.ts @@ -0,0 +1,111 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { EventV2Bridge } from "@/event-v2-bridge" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import { SessionCwd } from "./session-cwd" + +const DESCRIPTION = [ + "Find-and-replace across many files in one call (regex or literal).", + "", + "Finds candidate files with ripgrep (respecting .gitignore), then applies the replacement to each.", + "Use `include` to limit by glob (e.g. \"*.ts\"), `literal=true` to treat `search` as plain text,", + "and `dryRun=true` to preview affected files without writing.", + "For a single targeted edit prefer `edit`/`multiedit`; use this for repo-wide renames/replacements.", +].join("\n") + +export const Parameters = Schema.Struct({ + search: Schema.String.annotate({ description: "Regex (or literal text if literal=true) to find" }), + replace: Schema.String.annotate({ description: "Replacement text. Supports $1, $2 capture groups for regex." }), + include: Schema.optional(Schema.String).annotate({ description: 'Glob to limit files (e.g. "*.ts")' }), + literal: Schema.optional(Schema.Boolean).annotate({ description: "Treat search as literal text (default false)" }), + dryRun: Schema.optional(Schema.Boolean).annotate({ description: "Preview affected files without writing" }), +}) + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +export const BulkEditTool = Tool.define( + "bulk_edit", + Effect.gen(function* () { + const ripgrep = yield* Ripgrep.Service + const fs = yield* FSUtil.Service + const events = yield* EventV2Bridge.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (!args.search) throw new Error("search is required") + const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) + + const finder = args.literal ? escapeRegex(args.search) : args.search + const regex = ((): RegExp => { + try { + return new RegExp(args.literal ? escapeRegex(args.search) : args.search, "g") + } catch (err) { + throw new Error(`Invalid regex: ${(err as Error).message}`) + } + })() + + yield* ctx.ask({ + permission: "edit", + patterns: [args.search], + always: ["*"], + metadata: { search: args.search, replace: args.replace, include: args.include, dryRun: args.dryRun === true }, + }) + + const matches = yield* ripgrep + .grep({ cwd, pattern: finder, include: args.include, limit: 1000 }) + .pipe(Effect.catch(() => Effect.succeed([]))) + + const files = [...new Set(matches.map((m) => path.resolve(cwd, m.entry.path)))] + if (files.length === 0) { + return { title: "bulk_edit: 0 files", metadata: { changed: 0, candidates: 0 }, output: "No files matched." } + } + + if (args.dryRun) { + const rels = files.map((f) => path.relative(ins.worktree, f)) + return { + title: `bulk_edit (dry-run): ${files.length} file(s)`, + metadata: { changed: 0, candidates: files.length }, + output: ["Would edit:", ...rels.map((r) => ` ${r}`)].join("\n"), + } + } + + let changedCount = 0 + const changed: string[] = [] + for (const file of files) { + yield* assertExternalDirectoryEffect(ctx, file) + const original = yield* fs.readFileStringSafe(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (original === undefined) continue + regex.lastIndex = 0 + const next = original.replace(regex, args.replace) + if (next === original) continue + yield* fs.writeWithDirs(file, next).pipe(Effect.orDie) + yield* events.publish(FileSystem.Event.Edited, { file }) + yield* events.publish(Watcher.Event.Updated, { file, event: "change" }) + changedCount++ + changed.push(path.relative(ins.worktree, file)) + } + + return { + title: `bulk_edit: ${changedCount} file(s)`, + metadata: { changed: changedCount, candidates: files.length }, + output: + changedCount === 0 + ? "No files were modified (matches found but replacement produced no change)." + : ["Edited:", ...changed.map((r) => ` ${r}`)].join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/code-actions.ts b/packages/opencode/src/tool/code-actions.ts new file mode 100644 index 000000000000..38f437be4f7b --- /dev/null +++ b/packages/opencode/src/tool/code-actions.ts @@ -0,0 +1,113 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { LSP } from "@/lsp/lsp" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import { SessionCwd } from "./session-cwd" +import { applyWorkspaceEdit } from "./apply-workspace-edit" + +const DESCRIPTION = [ + "List and apply language-server code actions (quick-fixes, refactors, auto-imports) for a file/range.", + "", + "Without `apply`: lists the available actions with their index, title and kind.", + "With `apply=`: applies that action's edit (auto-import, fix, etc.). Actions that are pure", + "commands (no inline edit) are reported as not directly applicable.", + "Positions are 1-based; omit the end position to target a single point.", + "Requires an LSP server for the file's language.", +].join("\n") + +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "Absolute or relative path to the file" }), + line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ description: "Start line (1-based)" }), + character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ description: "Start character (1-based)" }), + endLine: Schema.optional(Schema.Int).annotate({ description: "End line (1-based). Defaults to start line." }), + endCharacter: Schema.optional(Schema.Int).annotate({ + description: "End character (1-based). Defaults to start character.", + }), + apply: Schema.optional(Schema.Int).annotate({ description: "Index of the action to apply (from the list)." }), +}) + +type Action = { title?: string; kind?: string; edit?: unknown; command?: unknown } + +export const CodeActionsTool = Tool.define( + "code_actions", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const fs = yield* FSUtil.Service + const events = yield* EventV2Bridge.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const file = path.isAbsolute(args.filePath) + ? args.filePath + : path.join(SessionCwd.get(ctx.sessionID, ins.directory), args.filePath) + + yield* assertExternalDirectoryEffect(ctx, file) + yield* ctx.ask({ + permission: args.apply !== undefined ? "edit" : "lsp", + patterns: ["*"], + always: ["*"], + metadata: { filePath: file, line: args.line, character: args.character, apply: args.apply }, + }) + + const exists = yield* fs.existsSafe(file) + if (!exists) throw new Error(`File not found: ${file}`) + const available = yield* lsp.hasClients(file) + if (!available) throw new Error("No LSP server available for this file type.") + + yield* lsp.touchFile(file, "document") + + const actions = (yield* lsp.codeAction({ + file, + line: args.line - 1, + character: args.character - 1, + endLine: (args.endLine ?? args.line) - 1, + endCharacter: (args.endCharacter ?? args.character) - 1, + })) as Action[] + + if (actions.length === 0) { + return { title: "code_actions: 0", metadata: { count: 0, changed: 0 }, output: "No code actions available here." } + } + + if (args.apply === undefined) { + const lines = [`${actions.length} code action(s):`, ""] + actions.forEach((a, i) => { + const kind = a.kind ? ` [${a.kind}]` : "" + const applicable = a.edit ? "" : " (command — not directly applicable)" + lines.push(`${i}. ${a.title ?? "(untitled)"}${kind}${applicable}`) + }) + lines.push("", "Re-run with apply= to apply one with an edit.") + return { title: `code_actions: ${actions.length}`, metadata: { count: actions.length, changed: 0 }, output: lines.join("\n") } + } + + const chosen = actions[args.apply] + if (!chosen) throw new Error(`Invalid action index ${args.apply} (have ${actions.length}).`) + if (!chosen.edit) { + return { + title: `code_actions: not applicable`, + metadata: { count: actions.length, changed: 0 }, + output: `Action "${chosen.title ?? args.apply}" has no inline edit (it is a command) and cannot be applied directly.`, + } + } + + const changed = yield* applyWorkspaceEdit(chosen.edit, fs, events) + const rels = changed.map((f) => path.relative(ins.worktree, f)) + return { + title: `code_actions: applied "${chosen.title ?? args.apply}"`, + metadata: { count: actions.length, changed: changed.length }, + output: + changed.length === 0 + ? "Action applied but produced no file changes." + : [`Applied "${chosen.title ?? args.apply}" to ${changed.length} file(s):`, ...rels.map((r) => ` ${r}`)].join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/deps.ts b/packages/opencode/src/tool/deps.ts new file mode 100644 index 000000000000..79e8a3abe224 --- /dev/null +++ b/packages/opencode/src/tool/deps.ts @@ -0,0 +1,121 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { ChildProcess } from "effect/unstable/process" +import * as Tool from "./tool" +import { AppProcess } from "@opencode-ai/core/process" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { InstanceState } from "@/effect/instance-state" +import { SessionCwd } from "./session-cwd" +import { detect, addArgs, outdatedArgs } from "./package-manager" + +const runProcess = ( + proc: AppProcess.Interface, + cmd: string, + args: string[], + cwd: string, + maxBytes = 128 * 1024, +) => + proc + .run( + ChildProcess.make(cmd, args, { cwd, extendEnv: true, stdin: "ignore", stdout: "pipe", stderr: "pipe" }), + { combineOutput: true, maxOutputBytes: maxBytes }, + ) + .pipe(Effect.catch((err) => Effect.fail(new Error(err.message)))) + +const ADD_DESCRIPTION = [ + "Add one or more dependencies using the project's package manager (auto-detected: bun/pnpm/yarn/npm).", + "Set `dev=true` for devDependencies. This mutates package.json and the lockfile.", +].join("\n") + +export const AddParameters = Schema.Struct({ + packages: Schema.Array(Schema.String).annotate({ description: "Package names (optionally with @version) to add" }), + dev: Schema.optional(Schema.Boolean).annotate({ description: "Add as devDependencies (default false)" }), +}) + +export const DepsAddTool = Tool.define( + "deps_add", + Effect.gen(function* () { + const proc = yield* AppProcess.Service + const fs = yield* FSUtil.Service + + return { + description: ADD_DESCRIPTION, + parameters: AddParameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (args.packages.length === 0) throw new Error("at least one package is required") + const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) + if (!(yield* fs.existsSafe(path.join(cwd, "package.json")))) { + throw new Error("No package.json found in the working directory.") + } + const pm = yield* detect(fs, cwd) + const argv = addArgs(pm, [...args.packages], args.dev === true) + + yield* ctx.ask({ + permission: "deps_add", + patterns: [...args.packages], + always: ["*"], + metadata: { packageManager: pm, packages: args.packages, dev: args.dev === true }, + }) + + const result = yield* runProcess(proc, pm, argv, cwd) + const text = (result.output ?? Buffer.alloc(0)).toString("utf8").trim() + if (result.exitCode !== 0) { + throw new Error(`${pm} ${argv.join(" ")} failed (exit ${result.exitCode}):\n${text}`) + } + return { + title: `deps_add (${pm})`, + metadata: { exitCode: 0, packageManager: pm }, + output: `$ ${pm} ${argv.join(" ")}\n${text || "(done)"}`, + } + }).pipe(Effect.orDie), + } + }), +) + +const OUTDATED_DESCRIPTION = [ + "List outdated dependencies using the project's package manager (auto-detected).", + "Read-only: reports current vs latest versions; does not modify anything.", +].join("\n") + +export const OutdatedParameters = Schema.Struct({}) + +export const DepsOutdatedTool = Tool.define( + "deps_outdated", + Effect.gen(function* () { + const proc = yield* AppProcess.Service + const fs = yield* FSUtil.Service + + return { + description: OUTDATED_DESCRIPTION, + parameters: OutdatedParameters, + execute: (_args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) + if (!(yield* fs.existsSafe(path.join(cwd, "package.json")))) { + throw new Error("No package.json found in the working directory.") + } + const pm = yield* detect(fs, cwd) + const argv = outdatedArgs(pm) + + yield* ctx.ask({ + permission: "deps_outdated", + patterns: ["*"], + always: ["*"], + metadata: { packageManager: pm }, + }) + + // `outdated` returns a non-zero exit code when updates exist; that's not an error here. + const result = yield* runProcess(proc, pm, argv, cwd) + const text = (result.output ?? Buffer.alloc(0)).toString("utf8").trim() + return { + title: `deps_outdated (${pm})`, + metadata: { exitCode: result.exitCode, packageManager: pm }, + output: text || "All dependencies are up to date.", + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/git-commit.ts b/packages/opencode/src/tool/git-commit.ts new file mode 100644 index 000000000000..7a5c0f1ce71a --- /dev/null +++ b/packages/opencode/src/tool/git-commit.ts @@ -0,0 +1,63 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { Git } from "@/git" +import { InstanceState } from "@/effect/instance-state" +import { SessionCwd } from "./session-cwd" + +const DESCRIPTION = [ + "Stage changes and create a git commit. Scoped and safe: it only stages + commits.", + "It never pushes, force-pushes, rebases, amends, or rewrites history — use shell for those.", + "", + "Pass `paths` to stage specific files, or omit it to stage all changes (git add -A).", + "Relative paths resolve against the session working directory (see change_directory).", +].join("\n") + +export const Parameters = Schema.Struct({ + message: Schema.String.annotate({ description: "The commit message" }), + paths: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Files to stage. Omit to stage all changes (git add -A).", + }), +}) + +export const GitCommitTool = Tool.define( + "git_commit", + Effect.gen(function* () { + const git = yield* Git.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (!args.message.trim()) throw new Error("commit message is required") + const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) + + yield* ctx.ask({ + permission: "git_commit", + patterns: ["*"], + always: ["*"], + metadata: { message: args.message, paths: args.paths }, + }) + + const addArgs = + args.paths && args.paths.length > 0 ? ["add", "--", ...args.paths] : ["add", "-A"] + const add = yield* git.run(addArgs, { cwd }) + if (add.exitCode !== 0) { + throw new Error(`git add failed (exit ${add.exitCode}): ${add.stderr.toString("utf8").trim()}`) + } + + const commit = yield* git.run(["commit", "-m", args.message], { cwd }) + const stdout = commit.text().trim() + const stderr = commit.stderr.toString("utf8").trim() + const output = stdout || stderr || "(no output)" + + return { + title: commit.exitCode === 0 ? "git commit" : "git commit (nothing to commit?)", + metadata: { exitCode: commit.exitCode }, + output, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/json-query.ts b/packages/opencode/src/tool/json-query.ts new file mode 100644 index 000000000000..74d9ffb9c110 --- /dev/null +++ b/packages/opencode/src/tool/json-query.ts @@ -0,0 +1,103 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import { SessionCwd } from "./session-cwd" + +const DESCRIPTION = [ + "Query a JSON file with a simple path and return the matched value as JSON.", + "", + "Path syntax: dot/bracket access, e.g. `dependencies`, `scripts.build`, `items[0].name`, `a.b[2]`.", + "Pass an empty path (or omit it) to return the whole document.", + "Cheaper and more precise than reading a large JSON file and parsing it by eye.", +].join("\n") + +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "Absolute or relative path to the JSON file" }), + path: Schema.optional(Schema.String).annotate({ + description: "Path into the document (e.g. 'scripts.build', 'items[0].name'). Omit for the whole document.", + }), +}) + +function tokenize(query: string): (string | number)[] { + const tokens: (string | number)[] = [] + // Normalize bracket indices (`[0]` -> `.0`) then split on dots. + for (const raw of query.replace(/\[(\d+)\]/g, ".$1").split(".")) { + const seg = raw.trim() + if (!seg) continue + tokens.push(/^\d+$/.test(seg) ? Number.parseInt(seg, 10) : seg) + } + return tokens +} + +function resolve(value: unknown, tokens: (string | number)[]): unknown { + let current = value + for (const token of tokens) { + if (current === null || current === undefined) return undefined + if (typeof token === "number") { + if (!Array.isArray(current)) return undefined + current = current[token] + } else { + if (typeof current !== "object" || Array.isArray(current)) return undefined + current = (current as Record)[token] + } + } + return current +} + +export const JsonQueryTool = Tool.define( + "json_query", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const file = path.isAbsolute(args.filePath) + ? args.filePath + : path.join(SessionCwd.get(ctx.sessionID, ins.directory), args.filePath) + + yield* assertExternalDirectoryEffect(ctx, file) + yield* ctx.ask({ + permission: "read", + patterns: [path.relative(ins.worktree, file)], + always: ["*"], + metadata: { path: args.path }, + }) + + const raw = yield* fs.readFileStringSafe(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (raw === undefined) throw new Error(`File not found: ${file}`) + + const parsed = ((): unknown => { + try { + return JSON.parse(raw) + } catch (err) { + throw new Error(`Invalid JSON in ${file}: ${(err as Error).message}`) + } + })() + + const tokens = args.path ? tokenize(args.path) : [] + const result = tokens.length === 0 ? parsed : resolve(parsed, tokens) + + if (result === undefined) { + return { + title: `json_query: no match`, + metadata: { found: false }, + output: `No value at path "${args.path ?? ""}".`, + } + } + + return { + title: `json_query ${args.path ?? "(root)"}`, + metadata: { found: true }, + output: JSON.stringify(result, null, 2), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/memory-write.ts b/packages/opencode/src/tool/memory-write.ts new file mode 100644 index 000000000000..146a4b1fe933 --- /dev/null +++ b/packages/opencode/src/tool/memory-write.ts @@ -0,0 +1,79 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { InstanceState } from "@/effect/instance-state" +import { buildPath, resolveProjectId, type Scope } from "../memory/paths" + +const DESCRIPTION = [ + "Persist a markdown note into memory so it can be recalled later with the `memory` tool (BM25 search).", + "", + "Layout: /memory///.md", + "Scopes:", + "- global: shared across all projects/sessions (scope_id is empty)", + "- projects: scoped to the current project (scope_id derived automatically)", + "- sessions: scoped to the current session (scope_id is the session id)", + "", + "Use a stable `key` (e.g. 'memory', 'checkpoint', 'notes/db-setup') so related notes group together.", + "Set `append=true` to add to an existing note instead of overwriting it.", +].join("\n") + +export const Parameters = Schema.Struct({ + key: Schema.String.annotate({ + description: "File key under the scope, without extension (e.g. 'memory', 'notes/db-setup')", + }), + content: Schema.String.annotate({ description: "Markdown content to write" }), + scope: Schema.optional(Schema.Literals(["global", "projects", "sessions"])).annotate({ + description: "Memory scope. Defaults to 'global'.", + }), + append: Schema.optional(Schema.Boolean).annotate({ + description: "Append to the existing note instead of overwriting (default false).", + }), +}) + +export const MemoryWriteTool = Tool.define( + "memory_write", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const scope: Scope = args.scope ?? "global" + const scope_id = + scope === "global" ? "" : scope === "sessions" ? String(ctx.sessionID) : resolveProjectId(ins.worktree) + + const root = path.join(Global.Path.data, "memory") + const target = buildPath({ root, scope, scope_id, key: args.key }) + + yield* ctx.ask({ + permission: "memory_write", + patterns: [`${scope}/${args.key}`], + always: ["*"], + metadata: { scope, scope_id, key: args.key }, + }) + + let content = args.content + if (args.append) { + const existing = yield* fs.readFileStringSafe(target).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (existing !== undefined && existing.length > 0) { + content = existing.replace(/\s*$/, "") + "\n\n" + args.content + } + } + if (!content.endsWith("\n")) content += "\n" + + yield* fs.writeWithDirs(target, content).pipe(Effect.orDie) + + return { + title: `memory ${scope}/${args.key}`, + metadata: { path: target, scope, scope_id, key: args.key, append: args.append === true }, + output: `${args.append ? "Appended to" : "Wrote"} memory note: ${target}`, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/package-manager.ts b/packages/opencode/src/tool/package-manager.ts new file mode 100644 index 000000000000..b13dd979f851 --- /dev/null +++ b/packages/opencode/src/tool/package-manager.ts @@ -0,0 +1,31 @@ +import path from "path" +import { Effect } from "effect" +import type { FSUtil } from "@opencode-ai/core/fs-util" + +export type PackageManager = "bun" | "pnpm" | "yarn" | "npm" + +/** Detect the JS package manager for a directory by its lockfile. Defaults to npm. */ +export const detect = Effect.fn("PackageManager.detect")(function* (fs: FSUtil.Interface, cwd: string) { + if (yield* fs.existsSafe(path.join(cwd, "bun.lock"))) return "bun" as PackageManager + if (yield* fs.existsSafe(path.join(cwd, "bun.lockb"))) return "bun" as PackageManager + if (yield* fs.existsSafe(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm" as PackageManager + if (yield* fs.existsSafe(path.join(cwd, "yarn.lock"))) return "yarn" as PackageManager + return "npm" as PackageManager +}) + +export function addArgs(pm: PackageManager, packages: string[], dev: boolean): string[] { + switch (pm) { + case "bun": + return ["add", ...(dev ? ["-d"] : []), ...packages] + case "pnpm": + return ["add", ...(dev ? ["-D"] : []), ...packages] + case "yarn": + return ["add", ...(dev ? ["-D"] : []), ...packages] + case "npm": + return ["install", dev ? "--save-dev" : "--save", ...packages] + } +} + +export function outdatedArgs(pm: PackageManager): string[] { + return ["outdated"] +} diff --git a/packages/opencode/src/tool/patch-apply.ts b/packages/opencode/src/tool/patch-apply.ts new file mode 100644 index 000000000000..d7975305cdae --- /dev/null +++ b/packages/opencode/src/tool/patch-apply.ts @@ -0,0 +1,53 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { Git } from "@/git" +import { InstanceState } from "@/effect/instance-state" +import { SessionCwd } from "./session-cwd" + +const DESCRIPTION = [ + "Apply a standard unified diff (git-style patch) to the working tree via `git apply`.", + "Use this when you have a unified diff (--- / +++ / @@ hunks) to apply across one or more files.", + "The patch is applied relative to the session working directory (see change_directory).", + "Fails cleanly if the patch does not apply; nothing is committed.", +].join("\n") + +export const Parameters = Schema.Struct({ + patch: Schema.String.annotate({ description: "The unified diff text to apply" }), +}) + +export const PatchApplyTool = Tool.define( + "patch_apply", + Effect.gen(function* () { + const git = yield* Git.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (!args.patch.trim()) throw new Error("patch is empty") + const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) + + yield* ctx.ask({ + permission: "edit", + patterns: ["*"], + always: ["*"], + metadata: { patch: args.patch.slice(0, 4000) }, + }) + + const patch = args.patch.endsWith("\n") ? args.patch : args.patch + "\n" + const result = yield* git.applyPatch(cwd, patch) + if (result.exitCode !== 0) { + throw new Error(`git apply failed (exit ${result.exitCode}): ${result.stderr.toString("utf8").trim()}`) + } + + return { + title: "patch applied", + metadata: { exitCode: 0 }, + output: "Patch applied successfully.", + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/process-registry.ts b/packages/opencode/src/tool/process-registry.ts new file mode 100644 index 000000000000..9bd91482a938 --- /dev/null +++ b/packages/opencode/src/tool/process-registry.ts @@ -0,0 +1,88 @@ +import { spawn, type ChildProcess } from "child_process" + +// In-memory registry of background processes launched by the process_* tools. +// Output is buffered as a bounded ring of lines so process_logs can read recent +// output without blocking on a long-running process (dev servers, watchers). + +export type ProcStatus = "running" | "exited" | "error" + +export interface Proc { + id: string + command: string + cwd: string + status: ProcStatus + exitCode: number | null + startedAt: number + output: string[] + child: ChildProcess +} + +const MAX_LINES = 2000 +const procs = new Map() +let counter = 0 + +export function start(command: string, cwd: string): Proc { + counter += 1 + const id = `proc_${Date.now().toString(36)}_${counter}` + const child = spawn(command, { + cwd, + shell: true, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }) + + const proc: Proc = { + id, + command, + cwd, + status: "running", + exitCode: null, + startedAt: Date.now(), + output: [], + child, + } + + const push = (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8") + for (const line of text.split("\n")) proc.output.push(line) + while (proc.output.length > MAX_LINES) proc.output.shift() + } + + child.stdout?.on("data", push) + child.stderr?.on("data", push) + child.on("exit", (code) => { + proc.status = "exited" + proc.exitCode = code ?? null + }) + child.on("error", (err) => { + proc.status = "error" + push(`[process error] ${err instanceof Error ? err.message : String(err)}`) + }) + + procs.set(id, proc) + return proc +} + +export function get(id: string): Proc | undefined { + return procs.get(id) +} + +export function list(): Proc[] { + return [...procs.values()] +} + +export function stop(id: string): boolean { + const proc = procs.get(id) + if (!proc) return false + try { + proc.child.kill("SIGTERM") + } catch { + // ignore + } + return true +} + +export function recent(proc: Proc, lines: number): string { + const slice = proc.output.slice(Math.max(0, proc.output.length - lines)) + return slice.join("\n") +} diff --git a/packages/opencode/src/tool/process.ts b/packages/opencode/src/tool/process.ts new file mode 100644 index 000000000000..d8a2744ee792 --- /dev/null +++ b/packages/opencode/src/tool/process.ts @@ -0,0 +1,125 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { InstanceState } from "@/effect/instance-state" +import { SessionCwd } from "./session-cwd" +import * as Registry from "./process-registry" + +const START_DESCRIPTION = [ + "Start a long-running process in the background (dev server, build watcher, etc.) and return immediately.", + "Returns a process id; read its output later with process_logs and stop it with process_stop.", + "Use this instead of the blocking shell for commands that don't exit on their own.", + "The command runs via the system shell, relative to the session working directory (see change_directory).", +].join("\n") + +export const StartParameters = Schema.Struct({ + command: Schema.String.annotate({ description: "The command to run (executed via the system shell)" }), +}) + +export const ProcessStartTool = Tool.define( + "process_start", + Effect.gen(function* () { + return { + description: START_DESCRIPTION, + parameters: StartParameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (!args.command.trim()) throw new Error("command is required") + const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) + + yield* ctx.ask({ + permission: "process_start", + patterns: [args.command], + always: ["*"], + metadata: { command: args.command }, + }) + + const proc = yield* Effect.sync(() => Registry.start(args.command, cwd)) + // Give it a moment to emit early output / fail fast. + yield* Effect.sleep("400 millis") + const early = Registry.recent(proc, 30) + + return { + title: `process_start ${proc.id}`, + metadata: { id: proc.id, status: proc.status }, + output: [ + `Started background process: ${proc.id}`, + `Command: ${args.command}`, + `Status: ${proc.status}${proc.exitCode !== null ? ` (exit ${proc.exitCode})` : ""}`, + early ? `\nEarly output:\n${early}` : "", + `\nUse process_logs id=${proc.id} to read more, process_stop id=${proc.id} to stop.`, + ] + .filter(Boolean) + .join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) + +const LOGS_DESCRIPTION = [ + "Read recent output from a background process started with process_start.", + "Returns the process status and the last `lines` lines of combined stdout/stderr.", +].join("\n") + +export const LogsParameters = Schema.Struct({ + id: Schema.String.annotate({ description: "The process id returned by process_start" }), + lines: Schema.optional(Schema.Number).annotate({ description: "How many recent lines to return (default 100)" }), +}) + +export const ProcessLogsTool = Tool.define( + "process_logs", + Effect.gen(function* () { + return { + description: LOGS_DESCRIPTION, + parameters: LogsParameters, + execute: (args: Schema.Schema.Type, _ctx: Tool.Context) => + Effect.gen(function* () { + const proc = yield* Effect.sync(() => Registry.get(args.id)) + if (!proc) { + const known = Registry.list() + .map((p) => p.id) + .join(", ") + throw new Error(`No process with id ${args.id}. Known: ${known || "(none)"}.`) + } + const text = Registry.recent(proc, args.lines ?? 100) + return { + title: `process_logs ${proc.id} (${proc.status})`, + metadata: { id: proc.id, status: proc.status, exitCode: proc.exitCode }, + output: [ + `Process ${proc.id} — ${proc.status}${proc.exitCode !== null ? ` (exit ${proc.exitCode})` : ""}`, + `Command: ${proc.command}`, + "", + text || "(no output yet)", + ].join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) + +const STOP_DESCRIPTION = "Stop a background process started with process_start (sends SIGTERM)." + +export const StopParameters = Schema.Struct({ + id: Schema.String.annotate({ description: "The process id returned by process_start" }), +}) + +export const ProcessStopTool = Tool.define( + "process_stop", + Effect.gen(function* () { + return { + description: STOP_DESCRIPTION, + parameters: StopParameters, + execute: (args: Schema.Schema.Type, _ctx: Tool.Context) => + Effect.gen(function* () { + const ok = yield* Effect.sync(() => Registry.stop(args.id)) + if (!ok) throw new Error(`No process with id ${args.id}.`) + return { + title: `process_stop ${args.id}`, + metadata: { id: args.id }, + output: `Sent SIGTERM to ${args.id}.`, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 7fd9276851cb..bc3298278ae6 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -38,7 +38,20 @@ import { ChangeDirectoryTool } from "./change-directory" import { FormatTool } from "./format" import { GitTool } from "./git" import { DiagnosticsTool } from "./diagnostics" +import { GitCommitTool } from "./git-commit" +import { PatchApplyTool } from "./patch-apply" +import { TodoScanTool } from "./todo-scan" +import { TreeTool } from "./tree" +import { MemoryWriteTool } from "./memory-write" +import { JsonQueryTool } from "./json-query" +import { BulkEditTool } from "./bulk-edit" +import { TestTool } from "./test" +import { DepsAddTool, DepsOutdatedTool } from "./deps" +import { RenameSymbolTool } from "./rename-symbol" +import { CodeActionsTool } from "./code-actions" +import { ProcessStartTool, ProcessLogsTool, ProcessStopTool } from "./process" import { Git } from "@/git" +import { AppProcess } from "@opencode-ai/core/process" import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" @@ -125,6 +138,21 @@ export const layer = Layer.effect( const formattool = yield* FormatTool const gittool = yield* GitTool const diagnosticstool = yield* DiagnosticsTool + const gitcommittool = yield* GitCommitTool + const patchapplytool = yield* PatchApplyTool + const todoscantool = yield* TodoScanTool + const treetool = yield* TreeTool + const memorywritetool = yield* MemoryWriteTool + const jsonquerytool = yield* JsonQueryTool + const bulkedittool = yield* BulkEditTool + const testtool = yield* TestTool + const depsaddtool = yield* DepsAddTool + const depsoutdatedtool = yield* DepsOutdatedTool + const renamesymboltool = yield* RenameSymbolTool + const codeactionstool = yield* CodeActionsTool + const processstarttool = yield* ProcessStartTool + const processlogstool = yield* ProcessLogsTool + const processstoptool = yield* ProcessStopTool const skilltool = yield* SkillTool const agent = yield* Agent.Service @@ -239,6 +267,21 @@ export const layer = Layer.effect( format: Tool.init(formattool), git: Tool.init(gittool), diagnostics: Tool.init(diagnosticstool), + gitcommit: Tool.init(gitcommittool), + patchapply: Tool.init(patchapplytool), + todoscan: Tool.init(todoscantool), + tree: Tool.init(treetool), + memorywrite: Tool.init(memorywritetool), + jsonquery: Tool.init(jsonquerytool), + bulkedit: Tool.init(bulkedittool), + test: Tool.init(testtool), + depsadd: Tool.init(depsaddtool), + depsoutdated: Tool.init(depsoutdatedtool), + renamesymbol: Tool.init(renamesymboltool), + codeactions: Tool.init(codeactionstool), + processstart: Tool.init(processstarttool), + processlogs: Tool.init(processlogstool), + processstop: Tool.init(processstoptool), question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), @@ -270,6 +313,21 @@ export const layer = Layer.effect( tool.format, tool.git, tool.diagnostics, + tool.gitcommit, + tool.patchapply, + tool.todoscan, + tool.tree, + tool.memorywrite, + tool.jsonquery, + tool.bulkedit, + tool.test, + tool.depsadd, + tool.depsoutdated, + tool.renamesymbol, + tool.codeactions, + tool.processstart, + tool.processlogs, + tool.processstop, ...(flags.experimentalLspTool ? [tool.lsp] : []), ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), ], @@ -375,7 +433,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Truncate.defaultLayer), ) - .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(Memory.defaultLayer), Layer.provide(History.defaultLayer), Layer.provide(Git.defaultLayer)), + .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(Memory.defaultLayer), Layer.provide(History.defaultLayer), Layer.provide(Git.defaultLayer), Layer.provide(AppProcess.defaultLayer)), ) function isZodType(value: unknown): value is z.ZodType { @@ -480,6 +538,7 @@ export const node = LayerNode.make({ Memory.node, History.node, Git.node, + AppProcess.node, ], }) diff --git a/packages/opencode/src/tool/rename-symbol.ts b/packages/opencode/src/tool/rename-symbol.ts new file mode 100644 index 000000000000..edf8ab119d90 --- /dev/null +++ b/packages/opencode/src/tool/rename-symbol.ts @@ -0,0 +1,98 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { LSP } from "@/lsp/lsp" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import { SessionCwd } from "./session-cwd" +import { applyWorkspaceEdit } from "./apply-workspace-edit" + +const DESCRIPTION = [ + "Rename a symbol everywhere it is used, via the language server (LSP rename).", + "", + "Point at any occurrence of the symbol with filePath + line + character (1-based, as shown in editors).", + "The LSP computes a workspace-wide edit and this tool applies it across all affected files.", + "Safer and more complete than find/replace: it understands scopes and only renames real references.", + "Requires an LSP server for the file's language.", +].join("\n") + +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "Absolute or relative path to a file containing the symbol" }), + line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ description: "Line of the symbol (1-based)" }), + character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "Character offset of the symbol (1-based)", + }), + newName: Schema.String.annotate({ description: "The new name for the symbol" }), +}) + +export const RenameSymbolTool = Tool.define( + "rename_symbol", + Effect.gen(function* () { + const lsp = yield* LSP.Service + const fs = yield* FSUtil.Service + const events = yield* EventV2Bridge.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (!args.newName.trim()) throw new Error("newName is required") + const ins = yield* InstanceState.context + const file = path.isAbsolute(args.filePath) + ? args.filePath + : path.join(SessionCwd.get(ctx.sessionID, ins.directory), args.filePath) + + yield* assertExternalDirectoryEffect(ctx, file) + yield* ctx.ask({ + permission: "edit", + patterns: ["*"], + always: ["*"], + metadata: { + filePath: file, + line: args.line, + character: args.character, + newName: args.newName, + }, + }) + + const exists = yield* fs.existsSafe(file) + if (!exists) throw new Error(`File not found: ${file}`) + const available = yield* lsp.hasClients(file) + if (!available) throw new Error("No LSP server available for this file type.") + + yield* lsp.touchFile(file, "document") + + const edit = yield* lsp.rename({ + file, + line: args.line - 1, + character: args.character - 1, + newName: args.newName, + }) + if (!edit) { + throw new Error("The language server returned no rename edit (symbol not renamable at that position).") + } + + const changed = yield* applyWorkspaceEdit(edit, fs, events) + if (changed.length === 0) { + return { + title: `rename_symbol: no changes`, + metadata: { changed: 0 }, + output: "The rename produced no file changes.", + } + } + + const rels = changed.map((f) => path.relative(ins.worktree, f)) + return { + title: `rename_symbol → ${args.newName} (${changed.length} file${changed.length === 1 ? "" : "s"})`, + metadata: { changed: changed.length }, + output: [`Renamed to "${args.newName}" across ${changed.length} file(s):`, ...rels.map((r) => ` ${r}`)].join( + "\n", + ), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/test.ts b/packages/opencode/src/tool/test.ts new file mode 100644 index 000000000000..8eed0f4b0683 --- /dev/null +++ b/packages/opencode/src/tool/test.ts @@ -0,0 +1,100 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { ChildProcess } from "effect/unstable/process" +import * as Tool from "./tool" +import { AppProcess } from "@opencode-ai/core/process" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { InstanceState } from "@/effect/instance-state" +import { SessionCwd } from "./session-cwd" +import { detect } from "./package-manager" + +const DESCRIPTION = [ + "Run the project's test suite and return the result.", + "", + "Auto-detects the runner: a `test` script in package.json (run via the detected package manager),", + "otherwise Go (go.mod), Rust (Cargo.toml), or Python (pyproject.toml/pytest).", + "Pass `path` to scope to a file/dir and `filter` to pass a name filter to the runner.", + "Prefer this over running tests through the shell so the command + output are structured.", +].join("\n") + +export const Parameters = Schema.Struct({ + path: Schema.optional(Schema.String).annotate({ description: "File or directory to scope the tests to" }), + filter: Schema.optional(Schema.String).annotate({ description: "Test name filter passed to the runner" }), +}) + +export const TestTool = Tool.define( + "test", + Effect.gen(function* () { + const proc = yield* AppProcess.Service + const fs = yield* FSUtil.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) + + const resolved = yield* (() => + Effect.gen(function* () { + if (yield* fs.existsSafe(path.join(cwd, "package.json"))) { + const pm = yield* detect(fs, cwd) + const base = pm === "npm" ? ["test", "--"] : ["test"] + const extra = [...(args.filter ? [args.filter] : []), ...(args.path ? [args.path] : [])] + return { cmd: pm, args: [...base, ...extra] } + } + if (yield* fs.existsSafe(path.join(cwd, "go.mod"))) { + return { cmd: "go", args: ["test", ...(args.filter ? ["-run", args.filter] : []), args.path ?? "./..."] } + } + if (yield* fs.existsSafe(path.join(cwd, "Cargo.toml"))) { + return { cmd: "cargo", args: ["test", ...(args.filter ? [args.filter] : [])] } + } + if ( + (yield* fs.existsSafe(path.join(cwd, "pyproject.toml"))) || + (yield* fs.existsSafe(path.join(cwd, "pytest.ini"))) + ) { + return { + cmd: "pytest", + args: [...(args.filter ? ["-k", args.filter] : []), ...(args.path ? [args.path] : [])], + } + } + return undefined + }))() + + if (!resolved) { + throw new Error("Could not detect a test runner (no package.json/go.mod/Cargo.toml/pyproject.toml).") + } + + yield* ctx.ask({ + permission: "test", + patterns: [resolved.cmd], + always: ["*"], + metadata: { command: `${resolved.cmd} ${resolved.args.join(" ")}` }, + }) + + const result = yield* proc + .run( + ChildProcess.make(resolved.cmd, resolved.args, { + cwd, + extendEnv: true, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + { combineOutput: true, maxOutputBytes: 256 * 1024 }, + ) + .pipe(Effect.catch((err) => Effect.fail(new Error(err.message)))) + + const text = (result.output ?? Buffer.alloc(0)).toString("utf8").trim() + const header = `$ ${resolved.cmd} ${resolved.args.join(" ")}\n(exit ${result.exitCode})\n` + + return { + title: `test ${resolved.cmd}${result.exitCode === 0 ? "" : " ✗"}`, + metadata: { exitCode: result.exitCode, command: resolved.cmd }, + output: header + (text || "(no output)"), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/todo-scan.ts b/packages/opencode/src/tool/todo-scan.ts new file mode 100644 index 000000000000..2488de736176 --- /dev/null +++ b/packages/opencode/src/tool/todo-scan.ts @@ -0,0 +1,77 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { InstanceState } from "@/effect/instance-state" +import { SessionCwd } from "./session-cwd" + +const DESCRIPTION = [ + "Scan the codebase for code-comment markers (TODO, FIXME, HACK, XXX, BUG) and list them grouped by file.", + "Respects .gitignore. Use this to find outstanding work or tech-debt notes across the repo.", + "Optionally narrow with an `include` glob (e.g. \"*.ts\").", +].join("\n") + +export const Parameters = Schema.Struct({ + include: Schema.optional(Schema.String).annotate({ + description: 'Glob to limit which files are scanned (e.g. "*.ts", "*.{ts,tsx}")', + }), + markers: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Markers to search for. Defaults to TODO, FIXME, HACK, XXX, BUG.", + }), +}) + +export const TodoScanTool = Tool.define( + "todo_scan", + Effect.gen(function* () { + const ripgrep = yield* Ripgrep.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const cwd = SessionCwd.get(ctx.sessionID, ins.directory) + const markers = args.markers && args.markers.length > 0 ? args.markers : ["TODO", "FIXME", "HACK", "XXX", "BUG"] + const pattern = `\\b(${markers.join("|")})\\b` + + yield* ctx.ask({ + permission: "grep", + patterns: [pattern], + always: ["*"], + metadata: { markers, include: args.include }, + }) + + const matches = yield* ripgrep + .grep({ cwd, pattern, include: args.include, limit: 500 }) + .pipe(Effect.catch(() => Effect.succeed([]))) + + if (matches.length === 0) { + return { title: "todo_scan: 0", metadata: { count: 0, files: 0 }, output: "No markers found." } + } + + const byFile = new Map() + for (const m of matches) { + const abs = path.resolve(cwd, m.entry.path) + const rel = path.relative(ins.worktree, abs) + const list = byFile.get(rel) ?? [] + list.push({ line: m.line, text: m.text.trim() }) + byFile.set(rel, list) + } + + const lines = [`Found ${matches.length} marker${matches.length === 1 ? "" : "s"} in ${byFile.size} file(s):`, ""] + for (const [file, items] of byFile) { + lines.push(`${file}:`) + for (const item of items) lines.push(` ${item.line}: ${item.text}`) + lines.push("") + } + + return { + title: `todo_scan: ${matches.length}`, + metadata: { count: matches.length, files: byFile.size }, + output: lines.join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/tree.ts b/packages/opencode/src/tool/tree.ts new file mode 100644 index 000000000000..ecac1da6be06 --- /dev/null +++ b/packages/opencode/src/tool/tree.ts @@ -0,0 +1,101 @@ +import path from "path" +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import { SessionCwd } from "./session-cwd" + +const DESCRIPTION = [ + "Render a directory tree (respecting .gitignore) in a single call.", + "Cheaper than multiple glob/read calls when you want a quick structural overview.", + "Relative `path` resolves against the session working directory (see change_directory).", +].join("\n") + +export const Parameters = Schema.Struct({ + path: Schema.optional(Schema.String).annotate({ + description: "Directory to render. Defaults to the session working directory.", + }), + depth: Schema.optional(Schema.Number).annotate({ description: "Max depth to display (default 4)" }), +}) + +type Node = { dirs: Map; files: string[] } + +function emptyNode(): Node { + return { dirs: new Map(), files: [] } +} + +function render(node: Node, prefix: string, depth: number, maxDepth: number, out: string[]): void { + if (depth > maxDepth) return + const dirNames = [...node.dirs.keys()].sort((a, b) => a.localeCompare(b)) + const files = [...node.files].sort((a, b) => a.localeCompare(b)) + const entries = [...dirNames.map((n) => ({ name: n, dir: true })), ...files.map((n) => ({ name: n, dir: false }))] + entries.forEach((entry, idx) => { + const last = idx === entries.length - 1 + const branch = last ? "└── " : "├── " + out.push(`${prefix}${branch}${entry.name}${entry.dir ? "/" : ""}`) + if (entry.dir) { + const child = node.dirs.get(entry.name)! + render(child, prefix + (last ? " " : "│ "), depth + 1, maxDepth, out) + } + }) +} + +export const TreeTool = Tool.define( + "tree", + Effect.gen(function* () { + const ripgrep = yield* Ripgrep.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const ins = yield* InstanceState.context + const base = SessionCwd.get(ctx.sessionID, ins.directory) + const root = args.path + ? path.isAbsolute(args.path) + ? args.path + : path.resolve(base, args.path) + : base + const maxDepth = args.depth ?? 4 + + yield* assertExternalDirectoryEffect(ctx, root, { kind: "directory" }) + + const limit = 1000 + const entries = yield* ripgrep + .glob({ cwd: root, pattern: "**/*", limit }) + .pipe(Effect.catch(() => Effect.succeed([]))) + + const tree = emptyNode() + for (const entry of entries) { + const rel = entry.path.replaceAll("\\", "/") + const segments = rel.split("/").filter(Boolean) + if (segments.length === 0) continue + let cur = tree + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i] + let next = cur.dirs.get(seg) + if (!next) { + next = emptyNode() + cur.dirs.set(seg, next) + } + cur = next + } + cur.files.push(segments[segments.length - 1]) + } + + const out: string[] = [path.relative(ins.worktree, root) || "."] + render(tree, "", 1, maxDepth, out) + const truncated = entries.length === limit + if (truncated) out.push("", `(truncated at ${limit} entries)`) + + return { + title: path.relative(ins.worktree, root) || ".", + metadata: { count: entries.length, truncated }, + output: out.join("\n"), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 51582c791fa6..086318d55e16 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -21,6 +21,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Git } from "../../src/git" +import { AppProcess } from "@opencode-ai/core/process" import { Image } from "../../src/image/image" import { Question } from "../../src/question" @@ -150,6 +151,8 @@ const lsp = Layer.succeed( definition: () => Effect.succeed([]), references: () => Effect.succeed([]), implementation: () => Effect.succeed([]), + rename: () => Effect.succeed(null), + codeAction: () => Effect.succeed([]), documentSymbol: () => Effect.succeed([]), workspaceSymbol: () => Effect.succeed([]), prepareCallHierarchy: () => Effect.succeed([]), @@ -197,6 +200,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(AppProcess.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index b7cb5b72de28..d71b3fe8eeaa 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -69,6 +69,8 @@ const lsp = Layer.succeed( definition: () => Effect.succeed([]), references: () => Effect.succeed([]), implementation: () => Effect.succeed([]), + rename: () => Effect.succeed(null), + codeAction: () => Effect.succeed([]), documentSymbol: () => Effect.succeed([]), workspaceSymbol: () => Effect.succeed([]), prepareCallHierarchy: () => Effect.succeed([]), diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index ddcf14e9ae39..ebcbfbb14cad 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -43,6 +43,8 @@ const lsp = Layer.succeed( definition: () => Effect.succeed([]), references: () => Effect.succeed([]), implementation: () => Effect.succeed([]), + rename: () => Effect.succeed(null), + codeAction: () => Effect.succeed([]), documentSymbol: () => Effect.succeed([]), workspaceSymbol: (query) => Effect.sync(() => { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0b87df9241b6..a04b05ed4554 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -10,7 +10,8 @@ import { } from "@opentui/core" import type { CommandContext } from "@opentui/keymap" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" -import "opentui-spinner/solid" +import { registerSpinner } from "opentui-spinner/solid" +registerSpinner() import path from "path" import { fileURLToPath } from "url" import { useLocal } from "../../context/local" diff --git a/packages/tui/src/component/spinner.tsx b/packages/tui/src/component/spinner.tsx index 700780314131..85420e773187 100644 --- a/packages/tui/src/component/spinner.tsx +++ b/packages/tui/src/component/spinner.tsx @@ -3,7 +3,15 @@ import { useTheme } from "../context/theme" import { useKV } from "../context/kv" import type { JSX } from "@opentui/solid" import type { RGBA } from "@opentui/core" -import "opentui-spinner/solid" +import { registerSpinner } from "opentui-spinner/solid" + +// Register the `spinner` custom element explicitly rather than relying on the +// bare side-effect import (`import "opentui-spinner/solid"`). The compiled +// binary's bundler tree-shakes the side-effect-only import, which left the +// `spinner` component unregistered and crashed the TUI with +// "[Reconciler] Unknown component type: spinner". An explicit call uses the +// imported binding, so it can't be dropped. +registerSpinner() export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]