diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index e2350c907b..88a3e93827 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -3,6 +3,7 @@ // "enterprise": { // "url": "https://enterprise.dev.opencode.ai", // }, + "references": ["git@github.com:Effect-TS/effect.git"], "provider": { "opencode": { "options": {}, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e338559be7..5e806568c7 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -11,6 +11,7 @@ import { ProviderTransform } from "../provider/transform" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" +import PROMPT_REFERENCE from "./prompt/reference.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { PermissionNext } from "@/permission/next" @@ -19,6 +20,7 @@ import { Global } from "@/global" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" +import { Reference } from "@/reference" export namespace Agent { export const Info = z @@ -153,6 +155,37 @@ export namespace Agent { mode: "subagent", native: true, }, + ...(cfg.references?.length + ? { + reference: { + name: "reference", + description: `Search across referenced projects configured in opencode.json under "references". Use this to query code in external repositories.\n\nAvailable references:\n${cfg.references.map((r) => `- ${r}`).join("\n")}`, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + lsp: "allow", + external_directory: { + [Truncate.GLOB]: "allow", + [path.join(Global.Path.reference, "*")]: "allow", + }, + }), + user, + ), + options: {}, + mode: "subagent", + native: true, + }, + } + : {}), compaction: { name: "compaction", mode: "primary", @@ -250,7 +283,20 @@ export namespace Agent { }) export async function get(agent: string) { - return state().then((x) => x[agent]) + const result = await state().then((x) => x[agent]) + if (!result) return result + if (agent === "reference") { + const refs = await Reference.list() + const fresh = await Promise.all(refs.map((r) => Reference.ensureFresh(r))) + const valid = fresh.filter(Boolean) as Reference.Info[] + const info = + valid.length > 0 ? valid.map((r) => `- ${r.url} at ${r.path}`).join("\n") : "No references available." + return { + ...result, + prompt: PROMPT_REFERENCE + "\n\nAvailable references:\n" + info, + } + } + return result } export async function list() { diff --git a/packages/opencode/src/agent/prompt/reference.txt b/packages/opencode/src/agent/prompt/reference.txt new file mode 100644 index 0000000000..95a04b09c8 --- /dev/null +++ b/packages/opencode/src/agent/prompt/reference.txt @@ -0,0 +1,18 @@ +You are a multi-project code search specialist. You search across referenced projects to answer questions about external codebases. + +Your strengths: +- Rapidly finding files using glob patterns across multiple repositories +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents from any referenced project + +Guidelines: +- Search across ALL referenced projects unless the user specifies one +- Report which project(s) contained relevant findings by mentioning the repository URL +- Use Glob for broad file pattern matching across all references +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path you need to read +- Return file paths as absolute paths so users can locate findings +- For clear communication, avoid using emojis +- Do not create any files, or run bash commands that modify the system state in any way + +Complete the user's search request efficiently and report your findings clearly. \ No newline at end of file diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 261731b8b0..73b95dbe84 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1192,6 +1192,10 @@ export namespace Config { .describe("Timeout in milliseconds for model context protocol (MCP) requests"), }) .optional(), + references: z + .array(z.string()) + .optional() + .describe("Git repositories or local paths to reference from subagents"), }) .strict() .meta({ diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 10b6125a6a..548def7271 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -20,6 +20,7 @@ export namespace Global { bin: path.join(data, "bin"), log: path.join(data, "log"), cache, + reference: path.join(cache, "references"), config, state, } @@ -31,6 +32,7 @@ await Promise.all([ fs.mkdir(Global.Path.state, { recursive: true }), fs.mkdir(Global.Path.log, { recursive: true }), fs.mkdir(Global.Path.bin, { recursive: true }), + fs.mkdir(Global.Path.reference, { recursive: true }), ]) const CACHE_VERSION = "21" diff --git a/packages/opencode/src/reference/index.ts b/packages/opencode/src/reference/index.ts new file mode 100644 index 0000000000..c4da227c67 --- /dev/null +++ b/packages/opencode/src/reference/index.ts @@ -0,0 +1,131 @@ +import path from "path" +import { mkdir, stat } from "fs/promises" +import { createHash } from "crypto" +import { Global } from "../global" +import { Config } from "../config/config" +import { Log } from "../util/log" +import { git } from "../util/git" +import { Instance } from "../project/instance" + +export namespace Reference { + const log = Log.create({ service: "reference" }) + + const STALE_THRESHOLD_MS = 60 * 60 * 1000 + + export interface Info { + url: string + path: string + branch?: string + type: "git" | "local" + } + + function hashUrl(url: string): string { + return createHash("sha256").update(url).digest("hex").slice(0, 16) + } + + export function parse(url: string): Info { + if (url.startsWith("/") || url.startsWith("~") || url.startsWith(".")) { + const resolved = url.startsWith("~") + ? path.join(Global.Path.home, url.slice(1)) + : url.startsWith(".") + ? path.resolve(Instance.worktree, url) + : url + return { + url, + path: resolved, + type: "local", + } + } + + const branchMatch = url.match(/^(.+)#(.+)$/) + const gitUrl = branchMatch ? branchMatch[1] : url + const branch = branchMatch ? branchMatch[2] : undefined + + return { + url: gitUrl, + path: path.join(Global.Path.reference, hashUrl(gitUrl)), + branch, + type: "git", + } + } + + export async function isStale(ref: Info): Promise { + if (ref.type === "local") return false + + const fetchHead = path.join(ref.path, ".git", "FETCH_HEAD") + const s = await stat(fetchHead).catch(() => null) + if (!s) return true + + return Date.now() - s.mtime.getTime() > STALE_THRESHOLD_MS + } + + export async function fetch(ref: Info): Promise { + if (ref.type === "local") { + const exists = await stat(ref.path).catch(() => null) + if (!exists?.isDirectory()) { + log.error("local reference not found", { path: ref.path }) + return false + } + return true + } + + await mkdir(path.dirname(ref.path), { recursive: true }) + + const isCloned = await stat(path.join(ref.path, ".git")).catch(() => null) + + if (!isCloned) { + log.info("cloning reference", { url: ref.url, branch: ref.branch }) + const args = ["clone", "--depth", "1"] + if (ref.branch) { + args.push("--branch", ref.branch) + } + args.push(ref.url, ref.path) + + const result = await git(args, { cwd: Global.Path.reference }) + if (result.exitCode !== 0) { + log.error("failed to clone", { url: ref.url, stderr: result.stderr.toString() }) + return false + } + return true + } + + log.info("fetching reference", { url: ref.url }) + const fetchResult = await git(["fetch"], { cwd: ref.path }) + if (fetchResult.exitCode !== 0) { + log.warn("failed to fetch, using cached", { url: ref.url }) + return true + } + + if (ref.branch) { + const checkoutResult = await git(["checkout", ref.branch], { cwd: ref.path }) + if (checkoutResult.exitCode !== 0) { + log.warn("failed to checkout branch, using current", { url: ref.url, branch: ref.branch }) + } + } + + return true + } + + export async function ensureFresh(ref: Info): Promise { + if (await isStale(ref)) { + const success = await fetch(ref) + if (!success && ref.type === "git") { + const exists = await stat(ref.path).catch(() => null) + if (!exists) return null + } + } + return ref + } + + export async function list(): Promise { + const cfg = await Config.get() + const urls = cfg.references ?? [] + return urls.map(parse) + } + + export async function directories(): Promise { + const refs = await list() + const fresh = await Promise.all(refs.map(ensureFresh)) + return fresh.filter(Boolean).map((r) => r!.path) + } +}