From 40d5ea1cf1b8029b1163a367275b3d2d9574e46c Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 9 May 2026 01:50:08 +0530 Subject: [PATCH] feat(core): add scout agent for repo research (#24149) Co-authored-by: Dax Raad --- packages/core/src/flag/flag.ts | 1 + packages/core/src/global.ts | 4 + packages/opencode/src/acp/agent.ts | 6 + packages/opencode/src/agent/agent.ts | 116 ++++++++- packages/opencode/src/agent/prompt/scout.txt | 36 +++ packages/opencode/src/cli/cmd/github.ts | 14 +- packages/opencode/src/config/config.ts | 5 + packages/opencode/src/config/permission.ts | 3 + packages/opencode/src/config/reference.ts | 27 ++ packages/opencode/src/tool/codesearch.ts | 63 +++++ packages/opencode/src/tool/codesearch.txt | 12 + packages/opencode/src/tool/mcp-websearch.ts | 5 + packages/opencode/src/tool/registry.ts | 13 + packages/opencode/src/tool/repo_clone.ts | 209 +++++++++++++++ packages/opencode/src/tool/repo_clone.txt | 5 + packages/opencode/src/tool/repo_overview.ts | 238 ++++++++++++++++++ packages/opencode/src/tool/repo_overview.txt | 4 + packages/opencode/src/util/repository.ts | 139 ++++++++++ packages/opencode/test/agent/agent.test.ts | 126 ++++++++-- .../opencode/test/cli/github-remote.test.ts | 10 + packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + packages/opencode/test/tool/registry.test.ts | 29 +++ .../opencode/test/tool/repo_clone.test.ts | 226 +++++++++++++++++ .../opencode/test/tool/repo_overview.test.ts | 150 +++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 31 +++ packages/web/src/content/docs/agents.mdx | 12 +- packages/web/src/content/docs/ar/agents.mdx | 12 +- packages/web/src/content/docs/bs/agents.mdx | 12 +- packages/web/src/content/docs/da/agents.mdx | 12 +- packages/web/src/content/docs/de/agents.mdx | 8 + packages/web/src/content/docs/es/agents.mdx | 12 +- packages/web/src/content/docs/fr/agents.mdx | 12 +- packages/web/src/content/docs/it/agents.mdx | 12 +- packages/web/src/content/docs/ja/agents.mdx | 12 +- packages/web/src/content/docs/ko/agents.mdx | 12 +- packages/web/src/content/docs/nb/agents.mdx | 12 +- packages/web/src/content/docs/pl/agents.mdx | 12 +- .../web/src/content/docs/pt-br/agents.mdx | 12 +- packages/web/src/content/docs/ru/agents.mdx | 12 +- packages/web/src/content/docs/th/agents.mdx | 12 +- packages/web/src/content/docs/tr/agents.mdx | 12 +- .../web/src/content/docs/zh-cn/agents.mdx | 12 +- .../web/src/content/docs/zh-tw/agents.mdx | 12 +- 44 files changed, 1622 insertions(+), 66 deletions(-) create mode 100644 packages/opencode/src/agent/prompt/scout.txt create mode 100644 packages/opencode/src/config/reference.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/repo_clone.ts create mode 100644 packages/opencode/src/tool/repo_clone.txt create mode 100644 packages/opencode/src/tool/repo_overview.ts create mode 100644 packages/opencode/src/tool/repo_overview.txt create mode 100644 packages/opencode/src/util/repository.ts create mode 100644 packages/opencode/test/tool/repo_clone.test.ts create mode 100644 packages/opencode/test/tool/repo_overview.test.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index f55c14bd05..9aa4a568da 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -76,6 +76,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"), OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), + OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 6560d308c1..5f9799c252 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -20,6 +20,7 @@ const paths = { data, bin: path.join(cache, "bin"), log: path.join(data, "log"), + repos: path.join(data, "repos"), cache, config, state, @@ -37,6 +38,7 @@ await Promise.all([ fs.mkdir(Path.tmp, { recursive: true }), fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }), + fs.mkdir(Path.repos, { recursive: true }), ]) export class Service extends Context.Service()("@opencode/Global") {} @@ -50,6 +52,7 @@ export interface Interface { readonly tmp: string readonly bin: string readonly log: string + readonly repos: string } export function make(input: Partial = {}): Interface { @@ -62,6 +65,7 @@ export function make(input: Partial = {}): Interface { tmp: Path.tmp, bin: Path.bin, log: Path.log, + repos: Path.repos, ...input, } } diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index ad930680d1..1d941c6b92 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1619,6 +1619,8 @@ function toToolKind(toolName: string): ToolKind { case "grep": case "glob": + case "repo_clone": + case "repo_overview": case "context7_resolve_library_id": case "context7_get_library_docs": return "search" @@ -1642,6 +1644,10 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] + case "repo_clone": + return input["path"] ? [{ path: input["path"] }] : [] + case "repo_overview": + return input["path"] ? [{ path: input["path"] }] : [] case ShellID.ToolID: return [] default: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b38b0cc5dd..8584682412 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -10,11 +10,13 @@ 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_SCOUT from "./prompt/scout.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { Permission } from "@/permission" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" @@ -25,6 +27,9 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@/util/effect-zod" import { withStatics, type DeepMutable } from "@/util/schema" +type ReferenceEntry = NonNullable[string] +type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -86,6 +91,10 @@ export const layer = Layer.effect( path.join(Global.Path.tmp, "*"), ...skillDirs.map((dir) => path.join(dir, "*")), ] + const readonlyExternalDirectory = { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + } satisfies Record const defaults = Permission.fromConfig({ "*": "allow", @@ -97,6 +106,8 @@ export const layer = Layer.effect( question: "deny", plan_enter: "deny", plan_exit: "deny", + repo_clone: "deny", + repo_overview: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -174,10 +185,7 @@ export const layer = Layer.effect( webfetch: "allow", websearch: "allow", read: "allow", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, + external_directory: readonlyExternalDirectory, }), user, ), @@ -187,6 +195,37 @@ export const layer = Layer.effect( mode: "subagent", native: true, }, + ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT + ? { + scout: { + name: "scout", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + repo_clone: "allow", + repo_overview: "allow", + external_directory: { + ...readonlyExternalDirectory, + [path.join(Global.Path.repos, "*")]: "allow", + }, + }), + user, + ), + description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`, + prompt: PROMPT_SCOUT, + options: {}, + mode: "subagent" as const, + native: true, + }, + } + : {}), compaction: { name: "compaction", mode: "primary", @@ -264,6 +303,75 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } + function referencePath(value: string) { + if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2)) + return path.isAbsolute(value) + ? value + : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value) + } + + function resolveReference(reference: ReferenceEntry): ResolvedReference { + if (typeof reference === "string") { + if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) { + return { kind: "local", path: referencePath(reference) } + } + return { kind: "git", repository: reference } + } + if ("path" in reference) return { kind: "local", path: referencePath(reference.path) } + return { kind: "git", repository: reference.repository, branch: reference.branch } + } + + function referencePrompt(name: string, reference: ResolvedReference) { + if (reference.kind === "local") { + return [ + PROMPT_SCOUT, + `You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`, + `Local directory: ${reference.path}`, + `When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + ].join("\n\n") + } + + return [ + PROMPT_SCOUT, + `You are Scout reference @${name}. This reference points to a git repository.`, + `Repository: ${reference.repository}`, + ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), + `When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`, + ].join("\n\n") + } + + if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { + for (const [name, reference] of Object.entries(cfg.reference ?? {})) { + if (agents[name]) continue + const resolved = resolveReference(reference) + const localPath = resolved.kind === "local" ? resolved.path : undefined + agents[name] = { + name, + description: + resolved.kind === "local" + ? `Scout reference for local directory ${resolved.path}` + : `Scout reference for repository ${resolved.repository}`, + permission: Permission.merge( + agents.scout.permission, + Permission.fromConfig( + localPath + ? { + external_directory: { + [localPath]: "allow", + [path.join(localPath, "*")]: "allow", + }, + } + : {}, + ), + ), + prompt: referencePrompt(name, resolved), + options: { reference }, + mode: "subagent", + native: false, + } + } + } + // Ensure Truncate.GLOB is allowed unless explicitly configured for (const name in agents) { const agent = agents[name] diff --git a/packages/opencode/src/agent/prompt/scout.txt b/packages/opencode/src/agent/prompt/scout.txt new file mode 100644 index 0000000000..c315cc5a6b --- /dev/null +++ b/packages/opencode/src/agent/prompt/scout.txt @@ -0,0 +1,36 @@ +You are `scout`, a read-only research agent for external libraries, dependency source, and documentation. + +Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace. + +Use this agent when asked to: +- inspect dependency repositories or library source +- compare local code against upstream implementations +- research public GitHub repositories the environment can clone +- explain how a library or framework works by reading its source and docs +- investigate third-party APIs, workflows, or behavior outside the current workspace + +Working style: +1. When the task involves a GitHub repository or dependency source, use `repo_clone` first. +2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository. +3. Use `WebFetch` for official documentation pages when source alone is not enough. +4. Prefer direct code and documentation evidence over assumptions. +5. If multiple external repositories are relevant, inspect each one before drawing conclusions. + +Research standards: +- cite exact absolute file paths and line references whenever possible +- separate what is verified from what is inferred +- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise +- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available +- call out uncertainty clearly instead of smoothing over gaps + +Output expectations: +- start with the direct answer +- then explain the evidence repository by repository or source by source +- include file references when relevant +- keep the explanation organized and easy to scan + +Constraints: +- do not modify files or run tools that change the user's workspace +- return absolute file paths for cloned-repo findings in your final response + +Complete the user's research request efficiently and report your findings clearly. diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index ea5b35ef78..a6754ec2df 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -32,6 +32,7 @@ import { SessionPrompt } from "@/session/prompt" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" +import { parseGitHubRemote } from "@/util/repository" import { Effect } from "effect" type GitHubAuthor = { @@ -151,18 +152,7 @@ const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const type UserEvent = (typeof USER_EVENTS)[number] type RepoEvent = (typeof REPO_EVENTS)[number] -// Parses GitHub remote URLs in various formats: -// - https://github.com/owner/repo.git -// - https://github.com/owner/repo -// - git@github.com:owner/repo.git -// - git@github.com:owner/repo -// - ssh://git@github.com/owner/repo.git -// - ssh://git@github.com/owner/repo -export function parseGitHubRemote(url: string): { owner: string; repo: string } | null { - const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) - if (!match) return null - return { owner: match[1], repo: match[2] } -} +export { parseGitHubRemote } /** * Extracts displayable text from assistant response parts. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index fcdb4e7b1c..ebf6ecc591 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -37,6 +37,7 @@ import { ConfigPaths } from "./paths" import { ConfigPermission } from "./permission" import { ConfigPlugin } from "./plugin" import { ConfigProvider } from "./provider" +import { ConfigReference } from "./reference" import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" @@ -142,6 +143,9 @@ export const Info = Schema.Struct({ description: "Command configuration, see https://opencode.ai/docs/commands", }), skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), + reference: Schema.optional(ConfigReference.Info).annotate({ + description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents", + }), watcher: Schema.optional( Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), @@ -201,6 +205,7 @@ export const Info = Schema.Struct({ // subagent general: Schema.optional(ConfigAgent.Info), explore: Schema.optional(ConfigAgent.Info), + scout: Schema.optional(ConfigAgent.Info), // specialized title: Schema.optional(ConfigAgent.Info), summary: Schema.optional(ConfigAgent.Info), diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 9513951c29..5326b007bd 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -35,6 +35,9 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), + codesearch: Schema.optional(Action), + repo_clone: Schema.optional(Rule), + repo_overview: Schema.optional(Rule), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts new file mode 100644 index 0000000000..eea3d998c1 --- /dev/null +++ b/packages/opencode/src/config/reference.ts @@ -0,0 +1,27 @@ +export * as ConfigReference from "./reference" + +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" + +const Git = Schema.Struct({ + repository: Schema.String.annotate({ + description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand", + }), + branch: Schema.optional(Schema.String).annotate({ + description: "Branch or ref Scout should clone and inspect", + }), +}) + +const Local = Schema.Struct({ + path: Schema.String.annotate({ + description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory", + }), +}) + +export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" }) + +export const Info = Schema.Record(Schema.String, Entry) + .annotate({ identifier: "ReferenceConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts new file mode 100644 index 0000000000..4616d5900a --- /dev/null +++ b/packages/opencode/src/tool/codesearch.ts @@ -0,0 +1,63 @@ +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" + +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.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + .check(Schema.isLessThanOrEqualTo(50000)) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) + .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: { query: string; tokensNum: number }, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }) + + const result = yield* McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "get_code_context_exa", + McpWebSearch.CodeArgs, + { + query: params.query, + tokensNum: params.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 0000000000..4187f08d12 --- /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/mcp-websearch.ts b/packages/opencode/src/tool/mcp-websearch.ts index 208924cba5..42b864c6fa 100644 --- a/packages/opencode/src/tool/mcp-websearch.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -48,6 +48,11 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) +export const CodeArgs = Schema.Struct({ + query: Schema.String, + tokensNum: Schema.Number, +}) + export const ParallelSearchArgs = Schema.Struct({ objective: Schema.String, search_queries: Schema.Array(Schema.String), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index b288bf7ae5..c8a91c1de1 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,6 +22,9 @@ import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" +import { CodeSearchTool } from "./codesearch" +import { RepoCloneTool } from "./repo_clone" +import { RepoOverviewTool } from "./repo_overview" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { LspTool } from "./lsp" @@ -44,6 +47,7 @@ import { Instruction } from "../session/instruction" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Bus } from "../bus" import { Agent } from "../agent/agent" +import { Git } from "@/git" import { Skill } from "../skill" import { Permission } from "@/permission" @@ -86,6 +90,7 @@ export const layer: Layer.Layer< | Skill.Service | Session.Service | Provider.Service + | Git.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -113,6 +118,9 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool + const codesearch = yield* CodeSearchTool + const repoClone = yield* RepoCloneTool + const repoOverview = yield* RepoOverviewTool const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool @@ -212,6 +220,9 @@ export const layer: Layer.Layer< fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), + code: Tool.init(codesearch), + repo_clone: Tool.init(repoClone), + repo_overview: Tool.init(repoOverview), skill: Tool.init(skilltool), patch: Tool.init(patchtool), question: Tool.init(question), @@ -234,6 +245,7 @@ export const layer: Layer.Layer< tool.fetch, tool.todo, tool.search, + ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.code, tool.repo_clone, tool.repo_overview] : []), tool.skill, tool.patch, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), @@ -348,6 +360,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts new file mode 100644 index 0000000000..969a3e66dd --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.ts @@ -0,0 +1,209 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" +import { Git } from "@/git" +import DESCRIPTION from "./repo_clone.txt" +import * as Tool from "./tool" +import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" + +export const Parameters = Schema.Struct({ + repository: Schema.String.annotate({ + description: "Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + refresh: Schema.optional(Schema.Boolean).annotate({ + description: "When true, fetches the latest remote state into the managed cache", + }), + branch: Schema.optional(Schema.String).annotate({ + description: "Branch or ref to clone and inspect", + }), +}) + +type Metadata = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + +function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { + if (!input.reuse) return "cloned" as const + if (input.branchMatches === false) return "refreshed" as const + if (input.refresh) return "refreshed" as const + return "cached" as const +} + +function resetTarget(input: { + requestedBranch?: string + remoteHead: { code: number; stdout: string } + branch: { code: number; stdout: string } +}) { + if (input.requestedBranch) return `origin/${input.requestedBranch}` + if (input.remoteHead.code === 0 && input.remoteHead.stdout) { + return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") + } + if (input.branch.code === 0 && input.branch.stdout) { + return `origin/${input.branch.stdout}` + } + return "HEAD" +} + +function validateBranch(branch: string) { + if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { + throw new Error( + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + ) + } +} + +export const RepoCloneTool = Tool.define( + "repo_clone", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const reference = parseRepositoryReference(params.repository) + if (!reference) + throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") + if (params.branch) validateBranch(params.branch) + + const repository = reference.label + const remote = reference.remote + const localPath = repositoryCachePath(reference) + const cloneTarget = parseRepositoryReference(remote) ?? reference + + yield* ctx.ask({ + permission: "repo_clone", + patterns: [repository], + always: [repository], + metadata: { + repository, + remote, + path: localPath, + refresh: Boolean(params.refresh), + branch: params.branch, + }, + }) + + return yield* Effect.acquireUseRelease( + Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })), + () => + Effect.gen(function* () { + yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) + + const exists = yield* fs.existsSafe(localPath) + const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git")) + const origin = hasGitDir + ? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) + : undefined + const originReference = + origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined + const reuse = + hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) + if (exists && !reuse) { + yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) + } + + const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined + const status = statusForRepository({ + reuse, + refresh: params.refresh, + branchMatches: params.branch ? currentBranch === params.branch : undefined, + }) + + if (status === "cloned") { + const clone = yield* git.run( + [ + "clone", + "--depth", + "100", + ...(params.branch ? ["--branch", params.branch] : []), + "--", + remote, + localPath, + ], + { cwd: path.dirname(localPath) }, + ) + if (clone.exitCode !== 0) { + throw new Error( + clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`, + ) + } + } + + if (status === "refreshed") { + const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath }) + if (fetch.exitCode !== 0) { + throw new Error( + fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`, + ) + } + + if (params.branch) { + const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], { + cwd: localPath, + }) + if (checkout.exitCode !== 0) { + throw new Error( + checkout.stderr.toString().trim() || + checkout.text().trim() || + `Failed to checkout ${params.branch}`, + ) + } + } + + const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) + const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) + const target = resetTarget({ + requestedBranch: params.branch, + remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, + branch: { code: branch.exitCode, stdout: branch.text().trim() }, + }) + + const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath }) + if (reset.exitCode !== 0) { + throw new Error( + reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`, + ) + } + } + + const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath }) + const branch = yield* git.branch(localPath) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + return { + title: repository, + metadata: { + repository, + host: reference.host, + remote, + localPath, + status, + head: headText, + branch, + }, + output: [ + `Repository ready: ${repository}`, + `Status: ${status}`, + `Local path: ${localPath}`, + ...(branch ? [`Branch: ${branch}`] : []), + ...(headText ? [`HEAD: ${headText}`] : []), + ].join("\n"), + } + }), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + ) + }).pipe(Effect.orDie), + } satisfies Tool.DefWithoutID + }), +) diff --git a/packages/opencode/src/tool/repo_clone.txt b/packages/opencode/src/tool/repo_clone.txt new file mode 100644 index 0000000000..7944015506 --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.txt @@ -0,0 +1,5 @@ +- Clone or refresh a repository into OpenCode's managed cache under the data directory +- Accepts git URLs, forge host/path references, or GitHub owner/repo shorthand +- Returns the cached absolute local path so other tools can explore the cloned source +- Use this before Read, Glob, or Grep when the code you need lives outside the current workspace +- This tool is intended for dependency and documentation research workflows, not for modifying the user's workspace diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts new file mode 100644 index 0000000000..bb788dc084 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.ts @@ -0,0 +1,238 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Git } from "@/git" +import { assertExternalDirectoryEffect } from "./external-directory" +import DESCRIPTION from "./repo_overview.txt" +import * as Tool from "./tool" +import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" +import { Instance } from "@/project/instance" + +export const Parameters = Schema.Struct({ + repository: Schema.optional(Schema.String).annotate({ + description: "Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + path: Schema.optional(Schema.String).annotate({ + description: "Directory path to inspect instead of a cached repository", + }), + depth: Schema.optional(Schema.Number).annotate({ + description: "Maximum structure depth to include. Defaults to 3.", + }) +}) + +type Metadata = { + path: string + repository?: string + branch?: string + head?: string + package_manager?: string + ecosystems: string[] + dependency_files: string[] + entrypoints: string[] + depth: number + truncated: boolean +} + +const IGNORED_DIRS = new Set([".git", "node_modules", "__pycache__", ".venv", "dist", "build", ".next", "target", "vendor"]) +const STRUCTURE_LIMIT = 200 +const DEPENDENCY_FILES = [ + "package.json", + "package-lock.json", + "bun.lock", + "bun.lockb", + "pnpm-lock.yaml", + "yarn.lock", + "requirements.txt", + "pyproject.toml", + "go.mod", + "Cargo.toml", + "Gemfile", + "build.gradle", + "build.gradle.kts", + "pom.xml", + "composer.json", +] + +function packageManager(files: Set) { + if (files.has("bun.lock") || files.has("bun.lockb")) return "bun" + if (files.has("pnpm-lock.yaml")) return "pnpm" + if (files.has("yarn.lock")) return "yarn" + if (files.has("package-lock.json")) return "npm" +} + +function ecosystems(files: Set) { + return [ + ...(files.has("package.json") ? ["Node.js"] : []), + ...(files.has("pyproject.toml") || files.has("requirements.txt") ? ["Python"] : []), + ...(files.has("go.mod") ? ["Go"] : []), + ...(files.has("Cargo.toml") ? ["Rust"] : []), + ...(files.has("Gemfile") ? ["Ruby"] : []), + ...(files.has("build.gradle") || files.has("build.gradle.kts") || files.has("pom.xml") ? ["Java/Kotlin"] : []), + ...(files.has("composer.json") ? ["PHP"] : []), + ] +} + +function commonEntrypoints(files: Set) { + return ["index.ts", "index.tsx", "index.js", "index.mjs", "main.ts", "main.js", "src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"].filter((file) => files.has(file)) +} + +export const RepoOverviewTool = Tool.define( + "repo_overview", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Schema.Schema.Type) { + if (params.path) { + const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) + return { path: full, repository: params.repository } + } + + if (!params.repository) throw new Error("Either repository or path is required") + + const parsed = parseRepositoryReference(params.repository) + if (!parsed) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + + const repository = parsed.label + return { + repository, + path: repositoryCachePath(parsed), + } + }) + + const structure = Effect.fn("RepoOverviewTool.structure")(function* (root: string, depth: number) { + let truncated = false + const lines: string[] = [] + + const visit: (dir: string, level: number) => Effect.Effect = Effect.fnUntraced(function* (dir: string, level: number) { + if (level >= depth || lines.length >= STRUCTURE_LIMIT) { + truncated = truncated || lines.length >= STRUCTURE_LIMIT + return + } + + const entries = yield* fs.readDirectoryEntries(dir).pipe(Effect.orElseSucceed(() => [])) + const sorted = yield* Effect.forEach( + entries, + Effect.fnUntraced(function* (entry) { + if (IGNORED_DIRS.has(entry.name)) return undefined + const full = path.join(dir, entry.name) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) return undefined + return { name: entry.name, full, directory: info.type === "Directory" } + }), + { concurrency: 16 }, + ).pipe( + Effect.map((items) => + items + .filter((item): item is { name: string; full: string; directory: boolean } => Boolean(item)) + .sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)), + ), + ) + + for (const entry of sorted) { + if (lines.length >= STRUCTURE_LIMIT) { + truncated = true + return + } + + lines.push(`${" ".repeat(level)}${entry.name}${entry.directory ? "/" : ""}`) + if (entry.directory) yield* visit(entry.full, level + 1) + } + }) + + yield* visit(root, 0) + return { lines, truncated } + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const target = yield* resolveTarget(params) + const depth = !params.depth || !Number.isInteger(params.depth) || params.depth < 1 || params.depth > 6 ? 3 : params.depth + + yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" }) + yield* ctx.ask({ + permission: "repo_overview", + patterns: [target.repository ?? target.path], + always: [target.repository ?? target.path], + metadata: { + repository: target.repository, + path: target.path, + depth, + }, + }) + + const info = yield* fs.stat(target.path).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) { + if (target.repository) throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`) + throw new Error(`Directory not found: ${target.path}`) + } + if (info.type !== "Directory") throw new Error(`Path is not a directory: ${target.path}`) + + const entries = yield* fs.readDirectoryEntries(target.path).pipe(Effect.orElseSucceed(() => [])) + const topLevel = new Set(entries.map((entry) => entry.name)) + const dependencyFiles = DEPENDENCY_FILES.filter((file) => topLevel.has(file)) + const packageJson = topLevel.has("package.json") + ? (yield* fs.readJson(path.join(target.path, "package.json")).pipe(Effect.orElseSucceed(() => ({})))) as Record + : {} + + const entrypoints = [ + ...(typeof packageJson.main === "string" ? [`main: ${packageJson.main}`] : []), + ...(typeof packageJson.module === "string" ? [`module: ${packageJson.module}`] : []), + ...(typeof packageJson.types === "string" ? [`types: ${packageJson.types}`] : []), + ...(typeof packageJson.bin === "string" ? [`bin: ${packageJson.bin}`] : []), + ...(packageJson.bin && typeof packageJson.bin === "object" && !Array.isArray(packageJson.bin) + ? Object.keys(packageJson.bin as Record).map((name) => `bin: ${name}`) + : []), + ...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports) + ? Object.keys(packageJson.exports as Record).slice(0, 10).map((name) => `exports: ${name}`) + : []), + ] + + const common = commonEntrypoints(new Set([ + ...topLevel, + ...entries + .filter((entry) => entry.name === "src") + .flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]), + ])) + const structureResult = yield* structure(target.path, depth) + const branch = yield* git.branch(target.path) + const head = yield* git.run(["rev-parse", "HEAD"], { cwd: target.path }) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + const metadata: Metadata = { + path: target.path, + repository: target.repository, + branch, + head: headText, + package_manager: packageManager(topLevel), + ecosystems: ecosystems(topLevel), + dependency_files: dependencyFiles, + entrypoints: [...entrypoints, ...common.map((file) => `file: ${file}`)], + depth, + truncated: structureResult.truncated, + } + + return { + title: target.repository ?? path.basename(target.path), + metadata, + output: [ + `Path: ${target.path}`, + ...(target.repository ? [`Repository: ${target.repository}`] : []), + ...(branch ? [`Branch: ${branch}`] : []), + ...(headText ? [`HEAD: ${headText}`] : []), + ...(metadata.ecosystems.length ? [`Ecosystems: ${metadata.ecosystems.join(", ")}`] : []), + ...(metadata.package_manager ? [`Package manager: ${metadata.package_manager}`] : []), + ...(metadata.dependency_files.length ? [`Dependency files: ${metadata.dependency_files.join(", ")}`] : []), + ...(metadata.entrypoints.length ? ["Likely entrypoints:", ...metadata.entrypoints.map((entry) => `- ${entry}`)] : []), + "Top-level structure:", + ...structureResult.lines, + ...(structureResult.truncated ? ["(Structure truncated)"] : []), + ].join("\n"), + } + }).pipe(Effect.orDie), + } satisfies Tool.DefWithoutID + }), +) diff --git a/packages/opencode/src/tool/repo_overview.txt b/packages/opencode/src/tool/repo_overview.txt new file mode 100644 index 0000000000..2109838746 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.txt @@ -0,0 +1,4 @@ +- Summarize the structure and likely entrypoints of a cloned repository or local directory +- Accepts either a cached repository reference or a directory path +- Reports detected ecosystems, dependency files, package manager, likely entrypoints, and a compact structure tree +- Use this after repo_clone to orient quickly before deeper Read, Glob, or Grep investigation diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts new file mode 100644 index 0000000000..279e5af0e4 --- /dev/null +++ b/packages/opencode/src/util/repository.ts @@ -0,0 +1,139 @@ +import path from "path" +import { fileURLToPath } from "url" +import { Global } from "@opencode-ai/core/global" + +export type Reference = { + host: string + path: string + segments: string[] + owner?: string + repo: string + remote: string + label: string + protocol?: string +} + +function normalize(input: string) { + return input.trim().replace(/^git\+/, "").replace(/#.*$/, "").replace(/\/+$/, "") +} + +function trimGitSuffix(input: string) { + return input.replace(/\.git$/, "") +} + +function parts(input: string) { + return input + .split("/") + .map((item) => trimGitSuffix(item.trim())) + .filter(Boolean) +} + +function safeHost(input: string) { + return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input) +} + +function safeSegment(input: string) { + return input !== "." && input !== ".." && !input.includes(":") && !/[\s/\\]/.test(input) +} + +function hostLike(input: string) { + return input.includes(".") || input.includes(":") || input === "localhost" +} + +function withSlash(input: string) { + return input.endsWith("/") ? input : `${input}/` +} + +function githubRemote(pathname: string) { + const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + if (!base) return `https://github.com/${pathname}.git` + return new URL(`${pathname}.git`, withSlash(base)).href +} + +function build(input: { host: string; segments: string[]; remote?: string; protocol?: string }) { + const segments = input.segments.map(trimGitSuffix).filter(Boolean) + if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null + const pathname = segments.join("/") + const repo = segments[segments.length - 1] + const host = input.host.toLowerCase() + return { + host, + path: pathname, + segments, + owner: segments.length === 2 ? segments[0] : undefined, + repo, + remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`), + label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`, + protocol: input.protocol, + } satisfies Reference +} + +function buildFile(input: { url: URL; remote: string }) { + const filePath = path.normalize(fileURLToPath(input.url)) + const segments = filePath.split(/[\\/]+/).filter(Boolean) + if (!segments.length) return null + return { + host: "file", + path: filePath, + segments: segments.map((segment) => segment.replace(/:$/, "")), + owner: undefined, + repo: trimGitSuffix(segments[segments.length - 1]), + remote: input.remote, + label: filePath, + protocol: "file:", + } satisfies Reference +} + +export function parseRepositoryReference(input: string) { + const cleaned = normalize(input) + if (!cleaned) return null + + const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) + if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] }) + + if (!cleaned.includes("://")) { + const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/) + if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned }) + + const direct = parts(cleaned) + if (direct.length >= 2 && hostLike(direct[0])) { + return build({ host: direct[0], segments: direct.slice(1) }) + } + + if (direct.length === 2) { + return build({ host: "github.com", segments: direct }) + } + } + + try { + const url = new URL(cleaned) + if (url.protocol === "file:") return buildFile({ url, remote: cleaned }) + const pathname = parts(url.pathname) + const host = url.host + return build({ + host, + segments: pathname, + remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned, + protocol: url.protocol, + }) + } catch { + return null + } +} + +export function parseGitHubRemote(input: string) { + const cleaned = normalize(input) + if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null + + const parsed = parseRepositoryReference(cleaned) + if (!parsed || parsed.host !== "github.com" || !parsed.owner || parsed.segments.length !== 2) return null + return { owner: parsed.owner, repo: parsed.repo } +} + +export function repositoryCachePath(input: Reference) { + return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments) +} + +export function sameRepositoryReference(left: Reference, right: Reference) { + return left.host === right.host && left.path === right.path +} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 6996e54b47..7f29860cfe 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -2,11 +2,11 @@ import { afterEach, test, expect } from "bun:test" import { Effect } from "effect" import path from "path" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" -import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Permission } from "../../src/permission" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -18,25 +18,38 @@ function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) } +async function withExperimentalScout(enabled: boolean, fn: () => Promise) { + const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled + try { + await fn() + } finally { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = original + } +} + afterEach(async () => { await disposeAllInstances() }) test("returns default native agents when no config", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await load(tmp.path, (svc) => svc.list()) - const names = agents.map((a) => a.name) - expect(names).toContain("build") - expect(names).toContain("plan") - expect(names).toContain("general") - expect(names).toContain("explore") - expect(names).toContain("compaction") - expect(names).toContain("title") - expect(names).toContain("summary") - }, + await withExperimentalScout(false, async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await load(tmp.path, (svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).toContain("build") + expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).not.toContain("scout") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") + }, + }) }) }) @@ -51,6 +64,8 @@ test("build agent has correct default properties", async () => { expect(build?.native).toBe(true) expect(evalPerm(build, "edit")).toBe("allow") expect(evalPerm(build, "bash")).toBe("allow") + expect(evalPerm(build, "repo_clone")).toBe("deny") + expect(evalPerm(build, "repo_overview")).toBe("deny") }, }) }) @@ -102,6 +117,85 @@ test("explore agent asks for external directories and allows whitelisted externa }) }) +test("scout agent allows repo cloning and repo cache reads", async () => { + await withExperimentalScout(true, async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const scout = await load(tmp.path, (svc) => svc.get("scout")) + expect(scout).toBeDefined() + expect(scout?.mode).toBe("subagent") + expect(evalPerm(scout, "repo_clone")).toBe("allow") + expect(evalPerm(scout, "repo_overview")).toBe("allow") + expect(evalPerm(scout, "edit")).toBe("deny") + expect( + Permission.evaluate( + "external_directory", + path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), + scout!.permission, + ).action, + ).toBe("allow") + }, + }) + }) +}) + +test("reference config creates scout-backed subagents", async () => { + await withExperimentalScout(true, async () => { + await using tmp = await tmpdir({ + config: { + reference: { + effect: "github.com/effect/effect-smol", + effectFull: { + repository: "Effect-TS/effect", + branch: "main", + }, + localdocs: "../docs", + localdocsFull: { + path: "../local-docs", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const effect = await load(tmp.path, (svc) => svc.get("effect")) + const effectFull = await load(tmp.path, (svc) => svc.get("effectFull")) + const local = await load(tmp.path, (svc) => svc.get("localdocs")) + const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull")) + + expect(effect).toBeDefined() + expect(effect?.mode).toBe("subagent") + expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") + expect(evalPerm(effect, "repo_clone")).toBe("allow") + + expect(effectFull).toBeDefined() + expect(effectFull?.mode).toBe("subagent") + expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect") + expect(effectFull?.prompt).toContain("Branch/ref: main") + expect(evalPerm(effectFull, "repo_clone")).toBe("allow") + + expect(local).toBeDefined() + expect(local?.mode).toBe("subagent") + expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`) + expect( + Permission.evaluate( + "external_directory", + path.join(path.resolve(tmp.path, "../docs"), "README.md"), + local!.permission, + ).action, + ).toBe("allow") + + expect(localFull).toBeDefined() + expect(localFull?.mode).toBe("subagent") + expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`) + }, + }) + }) +}) + test("general agent denies todo tools", async () => { await using tmp = await tmpdir() await WithInstance.provide({ diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts index 80102d986e..ed37b92d41 100644 --- a/packages/opencode/test/cli/github-remote.test.ts +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -25,6 +25,16 @@ test("parses ssh:// URL without .git suffix", () => { expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) }) +test("parses git protocol URLs from package metadata", () => { + expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) +}) + +test("parses npm-style github shorthand", () => { + expect(parseGitHubRemote("github:facebook/react")).toBeNull() +}) + test("parses http URL", () => { expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index c5170f3464..3b0009d2b3 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -15,6 +15,7 @@ import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" +import { Git } from "../../src/git" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -178,6 +179,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index ab5a3ab7ed..82b88a72fd 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -30,6 +30,7 @@ import { TestLLMServer } from "../lib/llm-server" // Same layer setup as prompt-effect.test.ts import { NodeFileSystem } from "@effect/platform-node" import { Agent as AgentSvc } from "../../src/agent/agent" +import { Git } from "../../src/git" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "@/config/config" @@ -128,6 +129,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c33981ddff..e42d4d59b3 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" +import { Flag } from "@opencode-ai/core/flag/flag" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" @@ -15,6 +16,7 @@ import { Skill } from "@/skill" import { Agent } from "@/agent/agent" import { Session } from "@/session/session" import { Provider } from "@/provider/provider" +import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Instruction } from "@/session/instruction" import { Bus } from "@/bus" @@ -25,6 +27,7 @@ import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" const node = CrossSpawnSpawner.defaultLayer +const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), }) @@ -38,6 +41,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), @@ -52,10 +56,35 @@ const registryLayer = ToolRegistry.layer.pipe( const it = testEffect(Layer.mergeAll(registryLayer, node)) afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout await disposeAllInstances() }) describe("tool.registry", () => { + it.instance("hides repo research tools unless experimental", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = false + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).not.toContain("codesearch") + expect(ids).not.toContain("repo_clone") + expect(ids).not.toContain("repo_overview") + }), + ) + + it.instance("shows repo research tools when experimental scout is enabled", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).toContain("codesearch") + expect(ids).toContain("repo_clone") + expect(ids).toContain("repo_overview") + }), + ) + it.instance("loads tools from .opencode/tool (singular)", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts new file mode 100644 index 0000000000..e32c6ee439 --- /dev/null +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { pathToFileURL } from "node:url" +import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Agent } from "../../src/agent/agent" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Git } from "../../src/git" +import { Global } from "@opencode-ai/core/global" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "../../src/tool/truncate" +import { RepoCloneTool } from "../../src/tool/repo_clone" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "scout", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Truncate.defaultLayer, + ), +) + +const init = Effect.fn("RepoCloneToolTest.init")(function* () { + const info = yield* RepoCloneTool + return yield* info.init() +}) + +const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) { + throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + } + return stdout.trim() + }) +}) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +describe("tool.repo_clone", () => { + it.live("clones a repo into the managed cache and reuses it on subsequent calls", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const tool = yield* init() + const cloned = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) + const cached = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx), + ) + + expect(cloned.metadata.status).toBe("cloned") + expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) + expect(cached.metadata.status).toBe("cached") + expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n") + }), + ), + ) + + it.live("refresh updates an existing cached clone", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`]) + + const tool = yield* init() + const first = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + const refreshed = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", refresh: true }, ctx), + ) + + expect(first.metadata.status).toBe("cloned") + expect(refreshed.metadata.status).toBe("refreshed") + expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n") + }), + ), + ) + + it.live("clones a configured branch", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "main\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* git(source, ["checkout", "-b", "docs"]) + yield* Effect.promise(() => Bun.write(path.join(source, "DOCS.md"), "docs\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add docs"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const tool = yield* init() + const result = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", branch: "docs" }, ctx), + ) + + expect(result.metadata.status).toBe("cloned") + expect(result.metadata.branch).toBe("docs") + expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n") + }), + ), + ) + + it.live("rejects invalid repository inputs", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const tool = yield* init() + const inputs = [ + { repository: "not-a-repo", message: "git URL" }, + { repository: "git@github.com:../../../etc/passwd", message: "git URL" }, + { repository: "-u:foo/bar", message: "git URL" }, + { repository: pathToFileURL(path.join(_dir, "local.git")).href, message: "Local file" }, + ] + + yield* Effect.forEach( + inputs, + (input) => + Effect.gen(function* () { + const result = yield* tool.execute({ repository: input.repository }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain(input.message) + } + }), + { discard: true }, + ) + }), + ), + ) + + it.live("rejects local file repository URLs", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const source = yield* tmpdirScoped({ git: true }) + const tool = yield* init() + const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Local file") + } + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts new file mode 100644 index 0000000000..b4214b7af4 --- /dev/null +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Agent } from "../../src/agent/agent" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Git } from "../../src/git" +import { Global } from "@opencode-ai/core/global" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "../../src/tool/truncate" +import { RepoOverviewTool } from "../../src/tool/repo_overview" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "scout", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Truncate.defaultLayer, + ), +) + +const init = Effect.fn("RepoOverviewToolTest.init")(function* () { + const info = yield* RepoOverviewTool + return yield* info.init() +}) + +describe("tool.repo_overview", () => { + it.live("summarizes a local repository path", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const repo = yield* tmpdirScoped({ git: true }) + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs( + path.join(repo, "package.json"), + JSON.stringify( + { + name: "example-repo", + main: "dist/index.js", + module: "dist/index.mjs", + types: "dist/index.d.ts", + exports: { + ".": "./dist/index.js", + "./server": "./dist/server.js", + }, + bin: { + example: "./bin/example.js", + }, + }, + null, + 2, + ), + ) + yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "") + yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n") + yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n") + + const tool = yield* init() + const result = yield* tool.execute({ path: repo }, ctx) + + expect(result.metadata.path).toBe(repo) + expect(result.metadata.ecosystems).toContain("Node.js") + expect(result.metadata.package_manager).toBe("bun") + expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"])) + expect(result.metadata.entrypoints).toEqual( + expect.arrayContaining([ + "main: dist/index.js", + "module: dist/index.mjs", + "types: dist/index.d.ts", + "exports: .", + "exports: ./server", + "bin: example", + "file: src/index.ts", + ]), + ) + expect(result.output).toContain("Top-level structure:") + expect(result.output).toContain("src/") + expect(result.output).toContain("README.md") + }), + ), + ) + + it.live("resolves a cached repository from repository shorthand", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "github.com", "owner", "repo") + yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2)) + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + + const tool = yield* init() + const result = yield* tool.execute({ repository: "owner/repo" }, ctx) + + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("owner/repo") + expect(result.output).toContain("Repository: owner/repo") + expect(result.output).toContain(`Path: ${cached}`) + }), + ), + ) + + it.live("fails clearly when a repository is not cloned", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const tool = yield* init() + const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first") + } + }), + ), + ) + + it.live("resolves cached repositories from host/path references", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo") + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + + const tool = yield* init() + const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx) + + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("gitlab.com/group/repo") + expect(result.output).toContain("Repository: gitlab.com/group/repo") + }), + ), + ) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 08051ba917..ba1b671c64 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -901,6 +901,32 @@ export type ServerConfig = { cors?: Array } +export type ReferenceConfigEntry = + | string + | { + /** + * Git repository URL, host/path reference, or GitHub owner/repo shorthand + */ + repository: string + /** + * Branch or ref Scout should clone and inspect + */ + branch?: string + } + | { + /** + * Absolute path, ~/ path, or workspace-relative path to a local reference directory + */ + path: string + } + +/** + * Named git or local directory references that can be @ mentioned as Scout-backed subagents + */ +export type ReferenceConfig = { + [key: string]: ReferenceConfigEntry +} + export type PermissionActionConfig = "ask" | "allow" | "deny" export type PermissionObjectConfig = { @@ -924,6 +950,9 @@ export type PermissionConfig = question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig + codesearch?: PermissionActionConfig + repo_clone?: PermissionRuleConfig + repo_overview?: PermissionRuleConfig lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig @@ -1127,6 +1156,7 @@ export type Config = { paths?: Array urls?: Array } + reference?: ReferenceConfig watcher?: { ignore?: Array } @@ -1162,6 +1192,7 @@ export type Config = { build?: AgentConfig general?: AgentConfig explore?: AgentConfig + scout?: AgentConfig title?: AgentConfig summary?: AgentConfig compaction?: AgentConfig diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index d7c85bc517..53048b7927 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -36,13 +36,13 @@ look at these below. Subagents are specialized assistants that primary agents can invoke for specific tasks. You can also manually invoke them by **@ mentioning** them in your messages. -OpenCode comes with two built-in subagents, **General** and **Explore**. We'll look at this below. +OpenCode comes with three built-in subagents, **General**, **Explore**, and **Scout**. We'll look at this below. --- ## Built-in -OpenCode comes with two built-in primary agents and two built-in subagents. +OpenCode comes with two built-in primary agents and three built-in subagents. --- @@ -84,6 +84,14 @@ A fast, read-only agent for exploring codebases. Cannot modify files. Use this w --- +### Use scout + +_Mode_: `subagent` + +A read-only agent for external docs and dependency research. Use this when you need to clone a dependency repository into OpenCode's managed cache, inspect library source, or cross-reference local code against upstream implementations without modifying your workspace. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ar/agents.mdx b/packages/web/src/content/docs/ar/agents.mdx index 01e13fda89..af12a67691 100644 --- a/packages/web/src/content/docs/ar/agents.mdx +++ b/packages/web/src/content/docs/ar/agents.mdx @@ -35,13 +35,13 @@ description: هيّئ الوكلاء المتخصصين واستخدمهم. الوكلاء الفرعيون هم مساعدين متخصصين يمكن للوكلاء الأساسيين استدعاؤهم لمهام محددة. يمكنك أيضا استدعاؤهم يدويا عبر **الإشارة بـ @** في رسائلك. -يأتي OpenCode مع وكيلين فرعيين مدمجين: **General** و **Explore**. سنلقي نظرة على ذلك أدناه. +يأتي OpenCode مع ثلاثة وكلاء فرعيين مدمجين: **General** و **Explore** و **Scout**. سنلقي نظرة على ذلك أدناه. --- ## المدمجة -يأتي OpenCode مع وكيلين أساسيين مدمجين ووكيلين فرعيين مدمجين. +يأتي OpenCode مع وكيلين أساسيين مدمجين وثلاثة وكلاء فرعيين مدمجين. --- @@ -83,6 +83,14 @@ _الوضع_: `subagent` --- +### استخدام Scout + +_الوضع_: `subagent` + +وكيل للقراءة فقط مخصص للوثائق الخارجية وأبحاث التبعيات. استخدمه عندما تحتاج إلى استنساخ مستودع تبعية داخل ذاكرة التخزين المؤقت المُدارة في OpenCode، أو فحص الشفرة المصدرية لمكتبة، أو إجراء مراجع متقاطعة بين الشفرة المحلية والتنفيذات upstream بدون تعديل مساحة العمل الخاصة بك. + +--- + ### استخدام Compaction _الوضع_: `primary` diff --git a/packages/web/src/content/docs/bs/agents.mdx b/packages/web/src/content/docs/bs/agents.mdx index 8ff674ae67..a2e211b19a 100644 --- a/packages/web/src/content/docs/bs/agents.mdx +++ b/packages/web/src/content/docs/bs/agents.mdx @@ -35,13 +35,13 @@ OpenCode dolazi sa dva ugrađena primarna agenta, **Build** i **Plan**. Pogledat Subagenti su specijalizovani pomoćnici koje primarni agenti mogu pozvati za određene zadatke. Možete ih i ručno pozvati **@ spominjanjem** u svojim porukama. -OpenCode dolazi sa dva ugrađena subagenta, **General** i **Explore**. Ovo ćemo pogledati u nastavku. +OpenCode dolazi sa tri ugrađena subagenta, **General**, **Explore** i **Scout**. Ovo ćemo pogledati u nastavku. --- ## Ugrađeni -OpenCode dolazi sa dva ugrađena primarna agenta i dva ugrađena subagenta. +OpenCode dolazi sa dva ugrađena primarna agenta i tri ugrađena subagenta. --- @@ -83,6 +83,14 @@ Brzi agent samo za čitanje za istraživanje kodnih baza. Nije moguće mijenjati --- +### Scout agent + +_Režim_: `subagent` + +Agent samo za čitanje za istraživanje eksterne dokumentacije i zavisnosti. Koristite ga kada trebate klonirati repozitorij zavisnosti u OpenCode-ov upravljani cache, pregledati izvorni kod biblioteke ili uporediti lokalni kod sa upstream implementacijama bez mijenjanja vašeg radnog prostora. + +--- + ### Compaction agent _Režim_: `primary` diff --git a/packages/web/src/content/docs/da/agents.mdx b/packages/web/src/content/docs/da/agents.mdx index 6ab2e7c39d..058f9eec6e 100644 --- a/packages/web/src/content/docs/da/agents.mdx +++ b/packages/web/src/content/docs/da/agents.mdx @@ -36,13 +36,13 @@ se på disse nedenfor. Subagenter er specialiserede assistenter, som primære agenter kan påbegynde sig til specifikke opgaver. Du kan også kalde dem manuelt ved at **@ nævne** dem i dine beskeder. -OpenCode leveres med to indbyggede underagenter, **Generelt** og **Udforsk**. Vi vil se på dette nedenfor. +OpenCode leveres med tre indbyggede subagenter, **General**, **Explore** og **Scout**. Vi ser nærmere på dem nedenfor. --- ## Indbyggede -OpenCode leveres med to indbyggede primære agenter og to indbyggede subagenter. +OpenCode leveres med to indbyggede primære agenter og tre indbyggede subagenter. --- @@ -84,6 +84,14 @@ En hurtig, skrivebeskyttet agent til at udforske kodebaser. Kan ikke ændre file --- +### Scout-agenten + +_Tilstand_: `subagent` + +En skrivebeskyttet agent til eksterne docs og research af dependencies. Brug denne, når du har brug for at klone et dependency-repository ind i OpenCode's administrerede cache, inspicere kildekoden i et bibliotek eller krydstjekke lokal kode mod upstream-implementeringer uden at ændre dit workspace. + +--- + ### Compact-agenten _Tilstand_: `primary` diff --git a/packages/web/src/content/docs/de/agents.mdx b/packages/web/src/content/docs/de/agents.mdx index 289b113cf6..6bca53488d 100644 --- a/packages/web/src/content/docs/de/agents.mdx +++ b/packages/web/src/content/docs/de/agents.mdx @@ -70,6 +70,14 @@ Ein schneller, schreibgeschützter Agent zum Erkunden von Codebasen. Dateien kö --- +### Scout + +_Modus_: `subagent` + +Ein schreibgeschützter Agent für externe Dokumentation und Dependency-Recherche. Verwenden Sie ihn, wenn Sie ein Dependency-Repository in den von OpenCode verwalteten Cache klonen, den Quellcode einer Bibliothek untersuchen oder lokalen Code mit Upstream-Implementierungen abgleichen müssen, ohne Ihren Workspace zu verändern. + +--- + ### Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/es/agents.mdx b/packages/web/src/content/docs/es/agents.mdx index 0b2736ac37..c98a4eb99e 100644 --- a/packages/web/src/content/docs/es/agents.mdx +++ b/packages/web/src/content/docs/es/agents.mdx @@ -36,13 +36,13 @@ mira estos a continuación. Los subagentes son asistentes especializados que los agentes principales pueden invocar para tareas específicas. También puedes invocarlos manualmente **@ mencionándolos** en tus mensajes. -OpenCode viene con dos subagentes integrados, **General** y **Explore**. Veremos esto a continuación. +OpenCode viene con tres subagentes integrados, **General**, **Explore** y **Scout**. Veremos esto a continuación. --- ## Integrados -OpenCode viene con dos agentes primarios integrados y dos subagentes integrados. +OpenCode viene con dos agentes primarios integrados y tres subagentes integrados. --- @@ -84,6 +84,14 @@ Un agente rápido y de solo lectura para explorar bases de código. No se pueden --- +### Scout + +_Modo_: `subagent` + +Un agente de solo lectura para investigar documentación externa y dependencias. Úsalo cuando necesites clonar el repositorio de una dependencia en la caché administrada de OpenCode, inspeccionar el código fuente de una librería o contrastar el código local con implementaciones upstream sin modificar tu espacio de trabajo. + +--- + ### Compactación _Modo_: `primary` diff --git a/packages/web/src/content/docs/fr/agents.mdx b/packages/web/src/content/docs/fr/agents.mdx index b18d335394..a6b323dfc8 100644 --- a/packages/web/src/content/docs/fr/agents.mdx +++ b/packages/web/src/content/docs/fr/agents.mdx @@ -36,13 +36,13 @@ Nous les verrons ci-dessous. Les sous-agents sont des assistants spécialisés que les agents primaires peuvent appeler pour des tâches spécifiques. Vous pouvez également les invoquer manuellement en **@ les mentionnant** dans vos messages. -OpenCode est livré avec deux sous-agents intégrés, **General** et **Explore**. Nous verrons cela ci-dessous. +OpenCode est livré avec trois sous-agents intégrés, **General**, **Explore** et **Scout**. Nous les verrons ci-dessous. --- ## Agents intégrés -OpenCode est livré avec deux agents primaires intégrés et deux sous-agents intégrés. +OpenCode est livré avec deux agents primaires intégrés et trois sous-agents intégrés. --- @@ -84,6 +84,14 @@ Un agent rapide en lecture seule pour explorer les bases de code. Impossible de --- +### Agent Scout + +_Mode_ : `subagent` + +Un agent en lecture seule pour la recherche sur la documentation externe et les dépendances. Utilisez-le lorsque vous devez cloner le dépôt d'une dépendance dans le cache géré d'OpenCode, inspecter le code source d'une bibliothèque ou recouper le code local avec les implémentations upstream sans modifier votre espace de travail. + +--- + ### Agent Compaction _Mode_ : `primary` diff --git a/packages/web/src/content/docs/it/agents.mdx b/packages/web/src/content/docs/it/agents.mdx index 4ecc9fc2a2..70aea57533 100644 --- a/packages/web/src/content/docs/it/agents.mdx +++ b/packages/web/src/content/docs/it/agents.mdx @@ -35,13 +35,13 @@ OpenCode include due agenti primari integrati: **Build** e **Plan**. Li vediamo I subagenti sono assistenti specializzati che gli agenti primari possono invocare per task specifici. Puoi anche invocarli manualmente **menzionandoli con @** nei tuoi messaggi. -OpenCode include due subagenti integrati: **General** e **Explore**. Li vediamo sotto. +OpenCode include tre subagenti integrati: **General**, **Explore** e **Scout**. Li vediamo sotto. --- ## Integrati -OpenCode include due agenti primari integrati e due subagenti integrati. +OpenCode include due agenti primari integrati e tre subagenti integrati. --- @@ -83,6 +83,14 @@ Un agente rapido in sola lettura per esplorare codebase. Non può modificare fil --- +### Scout + +_Mode_: `subagent` + +Un agente in sola lettura per la ricerca su documentazione esterna e dipendenze. Usalo quando devi clonare il repository di una dipendenza nella cache gestita di OpenCode, ispezionare il codice sorgente di una libreria o confrontare il codice locale con implementazioni upstream senza modificare il tuo workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ja/agents.mdx b/packages/web/src/content/docs/ja/agents.mdx index 879a43b057..539d30faf8 100644 --- a/packages/web/src/content/docs/ja/agents.mdx +++ b/packages/web/src/content/docs/ja/agents.mdx @@ -35,13 +35,13 @@ OpenCode には、**Build** と **Plan** という 2 つの組み込みプライ サブエージェントは、プライマリエージェントが特定のタスクのために呼び出すことができる特殊なアシスタントです。メッセージ内で **@ メンション**することで、手動で呼び出すこともできます。 -OpenCode には、**General** と **Explore** という 2 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 +OpenCode には、**General**、**Explore**、**Scout** という 3 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 --- ## 組み込み -OpenCode には、2 つの組み込みプライマリエージェントと 2 つの組み込みサブエージェントが付属しています。 +OpenCode には、2 つの組み込みプライマリエージェントと 3 つの組み込みサブエージェントが付属しています。 --- @@ -83,6 +83,14 @@ _モード_: `subagent` --- +### Scout + +_モード_: `subagent` + +外部ドキュメントや依存関係の調査を行うための読み取り専用エージェントです。依存関係のリポジトリを OpenCode の管理キャッシュにクローンしたいとき、ライブラリのソースコードを調べたいとき、あるいはワークスペースを変更せずにローカルコードを upstream の実装と突き合わせたいときに使用します。 + +--- + ### Compact _モード_: `primary` diff --git a/packages/web/src/content/docs/ko/agents.mdx b/packages/web/src/content/docs/ko/agents.mdx index 34de6250d1..02f31c5b62 100644 --- a/packages/web/src/content/docs/ko/agents.mdx +++ b/packages/web/src/content/docs/ko/agents.mdx @@ -35,13 +35,13 @@ OpenCode에는 기본 제공 primary agent인 **Build**와 **Plan**이 포함되 subagent는 primary agent가 특정 작업을 위해 호출하는 전문 assistant입니다. 메시지에서 **@ mention**으로 직접 호출할 수도 있습니다. -OpenCode에는 기본 제공 subagent인 **General**과 **Explore**가 포함되어 있습니다. 아래에서 살펴보겠습니다. +OpenCode에는 기본 제공 subagent인 **General**, **Explore**, **Scout**가 포함되어 있습니다. 아래에서 살펴보겠습니다. --- ## 기본 제공 -OpenCode는 기본적으로 primary agent 2개와 subagent 2개를 제공합니다. +OpenCode는 기본적으로 primary agent 2개와 subagent 3개를 제공합니다. --- @@ -83,6 +83,14 @@ _Mode_: `subagent` --- +### Use Scout + +_Mode_: `subagent` + +외부 docs와 dependency 리서치를 위한 읽기 전용 agent입니다. dependency repository를 OpenCode의 관리형 cache에 clone하거나, 라이브러리 소스를 살펴보거나, workspace를 수정하지 않고 로컬 코드를 upstream 구현과 교차 확인해야 할 때 사용하세요. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/nb/agents.mdx b/packages/web/src/content/docs/nb/agents.mdx index d7831e3387..f9971758d5 100644 --- a/packages/web/src/content/docs/nb/agents.mdx +++ b/packages/web/src/content/docs/nb/agents.mdx @@ -35,13 +35,13 @@ OpenCode kommer med to innebygde primære agenter, **Build** og **Plan**. Vi ser Underagenter er spesialiserte assistenter som primære agenter kan påkalle for spesifikke oppgaver. Du kan også starte dem manuelt ved å **@ nevne** dem i meldingene dine. -OpenCode kommer med to innebygde underagenter, **General** og **Explore**. Vi skal se på dette nedenfor. +OpenCode kommer med tre innebygde underagenter, **General**, **Explore** og **Scout**. Vi skal se på dette nedenfor. --- ## Innebygd -OpenCode kommer med to innebygde primære agenter og to innebygde underagenter. +OpenCode kommer med to innebygde primære agenter og tre innebygde underagenter. --- @@ -83,6 +83,14 @@ En rask, skrivebeskyttet agent for å utforske kodebaser. Kan ikke endre filer. --- +### Bruk av Scout + +_Modus_: `subagent` + +En skrivebeskyttet agent for ekstern dokumentasjon og forskning på avhengigheter. Bruk denne når du trenger å klone et avhengighetsrepo inn i OpenCode sin administrerte cache, inspisere kildekoden til et bibliotek eller kryssjekke lokal kode mot upstream-implementasjoner uten å endre arbeidsområdet ditt. + +--- + ### Bruk av Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/pl/agents.mdx b/packages/web/src/content/docs/pl/agents.mdx index 7a4d7a9960..8cf9561e16 100644 --- a/packages/web/src/content/docs/pl/agents.mdx +++ b/packages/web/src/content/docs/pl/agents.mdx @@ -35,13 +35,13 @@ OpenCode zawiera dwa wbudowane agenty główne: **Build** i **Plan**. Przyjrzymy Subagenci to asystenci pomocniczy, których mogą przywoływać agenci główni w celu wykonania konkretnych zadań. Możesz także wywoływać ich ręcznie, **wzmiankując ich (@)** w swoich wiadomościach. -OpenCode ma dwóch wbudowanych subagentów: **General** i **Explore**. Przyjrzymy się im poniżej. +OpenCode ma trzech wbudowanych subagentów: **General**, **Explore** i **Scout**. Przyjrzymy się im poniżej. --- ## Wbudowane -OpenCode ma dwa wbudowane agenty główne i dwa wbudowane subagenty. +OpenCode ma dwa wbudowane agenty główne i trzech wbudowanych subagentów. --- @@ -83,6 +83,14 @@ Szybki agent tylko do odczytu do eksploracji baz kodu. Nie może modyfikować pl --- +### Scout + +_Mode_: `subagent` + +Agent tylko do odczytu do pracy z zewnętrzną dokumentacją i badaniem zależności. Używaj go, gdy chcesz sklonować repozytorium zależności do zarządzanej pamięci podręcznej OpenCode, przejrzeć kod źródłowy biblioteki albo porównać lokalny kod z implementacjami upstream bez modyfikowania swojego workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/pt-br/agents.mdx b/packages/web/src/content/docs/pt-br/agents.mdx index 9a831e8048..815264d840 100644 --- a/packages/web/src/content/docs/pt-br/agents.mdx +++ b/packages/web/src/content/docs/pt-br/agents.mdx @@ -36,13 +36,13 @@ ver isso abaixo. Subagentes são assistentes especializados que agentes primários podem invocar para tarefas específicas. Você também pode invocá-los manualmente mencionando-os com **@** em suas mensagens. -opencode vem com dois subagentes integrados, **General** e **Explore**. Vamos ver isso abaixo. +OpenCode vem com três subagentes integrados, **General**, **Explore** e **Scout**. Vamos ver isso abaixo. --- ## Integrados -opencode vem com dois agentes primários integrados e dois subagentes integrados. +OpenCode vem com dois agentes primários integrados e três subagentes integrados. --- @@ -84,6 +84,14 @@ Um agente rápido e somente leitura para explorar bases de código. Não pode mo --- +### Scout + +_Modo_: `subagent` + +Um agente somente leitura para pesquisa em documentação externa e dependências. Use-o quando você precisar clonar o repositório de uma dependência para o cache gerenciado do OpenCode, inspecionar o código-fonte de uma biblioteca ou cruzar o código local com implementações upstream sem modificar seu workspace. + +--- + ### compaction _Modo_: `primary` diff --git a/packages/web/src/content/docs/ru/agents.mdx b/packages/web/src/content/docs/ru/agents.mdx index f515c15d7b..767cbf862f 100644 --- a/packages/web/src/content/docs/ru/agents.mdx +++ b/packages/web/src/content/docs/ru/agents.mdx @@ -35,13 +35,13 @@ opencode поставляется с двумя встроенными осно Субагенты — это специализированные помощники, которых основные агенты могут вызывать для выполнения определенных задач. Вы также можете вызвать их вручную, **@ упомянув** их в своих сообщениях. -opencode поставляется с двумя встроенными субагентами: **General** и **Explore**. Мы рассмотрим это ниже. +OpenCode поставляется с тремя встроенными субагентами: **General**, **Explore** и **Scout**. Мы рассмотрим их ниже. --- ## Встроенные агенты -opencode поставляется с двумя встроенными основными агентами и двумя встроенными субагентами. +OpenCode поставляется с двумя встроенными основными агентами и тремя встроенными субагентами. --- @@ -83,6 +83,14 @@ _Режим_: `subagent` --- +### Использование Scout + +_Режим_: `subagent` + +Агент только для чтения для работы с внешней документацией и исследования зависимостей. Используйте его, когда нужно клонировать репозиторий зависимости в управляемый кэш OpenCode, изучить исходный код библиотеки или сверить локальный код с upstream-реализациями без изменений в рабочем пространстве. + +--- + ### Использование Compact _Режим_: `primary` diff --git a/packages/web/src/content/docs/th/agents.mdx b/packages/web/src/content/docs/th/agents.mdx index 567125aced..e37df6ce47 100644 --- a/packages/web/src/content/docs/th/agents.mdx +++ b/packages/web/src/content/docs/th/agents.mdx @@ -36,13 +36,13 @@ OpenCode มีเอเจนต์หลักในตัวได้แก Subagent คือผู้ช่วยเฉพาะทางที่ Primary Agent สามารถเรียกใช้งานได้ หรือคุณสามารถเรียกใช้โดยตรงโดยพิมพ์ **@** ตามด้วยชื่อเอเจนต์ในข้อความของคุณ -OpenCode มี subagent ในตัวได้แก่ **General** และ **Explore** +OpenCode มี subagent ในตัวได้แก่ **General**, **Explore** และ **Scout** ดูรายละเอียดด้านล่าง --- ## บิวท์อิน -OpenCode มาพร้อมกับเอเจนต์หลักและ subagent ในตัวดังนี้ +OpenCode มาพร้อมกับเอเจนต์หลัก 2 ตัวและ subagent ในตัว 3 ตัว --- @@ -84,6 +84,14 @@ _Mode_: `subagent` --- +### Scout + +_Mode_: `subagent` + +เอเจนต์แบบอ่านอย่างเดียวสำหรับค้นคว้าเอกสารภายนอกและ dependency ใช้สิ่งนี้เมื่อคุณต้องการ clone repository ของ dependency เข้าไปใน cache ที่ OpenCode จัดการให้, ตรวจสอบ source code ของไลบรารี, หรือเทียบโค้ดในเครื่องกับ implementation จาก upstream โดยไม่แก้ไข workspace ของคุณ + +--- + ### Compact _Mode_: `primary` diff --git a/packages/web/src/content/docs/tr/agents.mdx b/packages/web/src/content/docs/tr/agents.mdx index 1f582511be..c523b2b3bf 100644 --- a/packages/web/src/content/docs/tr/agents.mdx +++ b/packages/web/src/content/docs/tr/agents.mdx @@ -35,13 +35,13 @@ opencode, **Build** ve **Plan** olmak üzere iki yerleşik birincil agent ile bi Alt agent'lar, birincil agent'ların belirli görevler için çağırabileceği uzman yardımcılardır. Ayrıca mesajlarınızda **@ bahsederek** bunları manuel olarak da çağırabilirsiniz. -opencode, **General** ve **Explore** olmak üzere iki yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. +OpenCode, **General**, **Explore** ve **Scout** olmak üzere üç yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. --- ## Yerleşik -opencode iki yerleşik birincil agent ve iki yerleşik alt agent ile birlikte gelir. +OpenCode iki yerleşik birincil agent ve üç yerleşik alt agent ile birlikte gelir. --- @@ -83,6 +83,14 @@ Kod tabanlarını keşfetmeye yönelik hızlı, salt okunur bir agent. Dosyalar --- +### Scout Kullanımı + +_Mod_: `subagent` + +Harici dokümanlar ve bağımlılık araştırmaları için salt okunur bir agent. Bir bağımlılık repository'sini OpenCode'un yönetilen cache'ine clone etmeniz, kütüphane kaynak kodunu incelemeniz veya workspace'inizi değiştirmeden yerel kodu upstream implementasyonlarla karşılaştırmanız gerektiğinde bunu kullanın. + +--- + ### Compaction Kullanımı _Mod_: `primary` diff --git a/packages/web/src/content/docs/zh-cn/agents.mdx b/packages/web/src/content/docs/zh-cn/agents.mdx index 2087c68366..6f821ff7f8 100644 --- a/packages/web/src/content/docs/zh-cn/agents.mdx +++ b/packages/web/src/content/docs/zh-cn/agents.mdx @@ -35,13 +35,13 @@ OpenCode 内置了两个主代理:**Build** 和 **Plan**。我们将在下面 子代理是主代理可以调用来执行特定任务的专业助手。您也可以通过在消息中 **@ 提及**它们来手动调用。 -OpenCode 内置了两个子代理:**General** 和 **Explore**。我们将在下面介绍它们。 +OpenCode 内置了三个子代理:**General**、**Explore** 和 **Scout**。我们将在下面介绍它们。 --- ## 内置代理 -OpenCode 内置了两个主代理和两个子代理。 +OpenCode 内置了两个主代理和三个子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一个用于外部文档和依赖研究的只读代理。当您需要将某个依赖仓库克隆到 OpenCode 的托管缓存中、检查库的源代码,或在不修改工作区的情况下将本地代码与 upstream 实现进行交叉对照时,请使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` diff --git a/packages/web/src/content/docs/zh-tw/agents.mdx b/packages/web/src/content/docs/zh-tw/agents.mdx index fa8f102543..a9c7bbadbf 100644 --- a/packages/web/src/content/docs/zh-tw/agents.mdx +++ b/packages/web/src/content/docs/zh-tw/agents.mdx @@ -35,13 +35,13 @@ OpenCode 內建了兩個主代理:**Build** 和 **Plan**。我們將在下面 子代理是主代理可以呼叫來執行特定任務的專業助手。您也可以透過在訊息中 **@ 提及**它們來手動呼叫。 -OpenCode 內建了兩個子代理:**General** 和 **Explore**。我們將在下面介紹它們。 +OpenCode 內建了三個子代理:**General**、**Explore** 和 **Scout**。我們將在下面介紹它們。 --- ## 內建代理 -OpenCode 內建了兩個主代理和兩個子代理。 +OpenCode 內建了兩個主代理和三個子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一個用於外部文件與依賴研究的唯讀代理。當您需要將某個依賴儲存庫 clone 到 OpenCode 的託管快取中、檢查函式庫的原始碼,或在不修改工作區的情況下將本機程式碼與 upstream 實作交叉比對時,請使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary`