Skip to content

Commit 1e0246c

Browse files
committed
feat(scout): add repo research tools
1 parent aed0307 commit 1e0246c

20 files changed

Lines changed: 1004 additions & 16 deletions

packages/opencode/src/acp/agent.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,6 +1561,8 @@ function toToolKind(toolName: string): ToolKind {
15611561

15621562
case "grep":
15631563
case "glob":
1564+
case "repo_clone":
1565+
case "repo_overview":
15641566
case "context7_resolve_library_id":
15651567
case "context7_get_library_docs":
15661568
return "search"
@@ -1583,6 +1585,10 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
15831585
case "glob":
15841586
case "grep":
15851587
return input["path"] ? [{ path: input["path"] }] : []
1588+
case "repo_clone":
1589+
return input["path"] ? [{ path: input["path"] }] : []
1590+
case "repo_overview":
1591+
return input["path"] ? [{ path: input["path"] }] : []
15861592
case "bash":
15871593
return []
15881594
default:

packages/opencode/src/agent/agent.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ProviderTransform } from "../provider"
1111
import PROMPT_GENERATE from "./generate.txt"
1212
import PROMPT_COMPACTION from "./prompt/compaction.txt"
1313
import PROMPT_EXPLORE from "./prompt/explore.txt"
14+
import PROMPT_SCOUT from "./prompt/scout.txt"
1415
import PROMPT_SUMMARY from "./prompt/summary.txt"
1516
import PROMPT_TITLE from "./prompt/title.txt"
1617
import { Permission } from "@/permission"
@@ -83,6 +84,10 @@ export const layer = Layer.effect(
8384
const cfg = yield* config.get()
8485
const skillDirs = yield* skill.dirs()
8586
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
87+
const readonlyExternalDirectory = {
88+
"*": "ask",
89+
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
90+
} satisfies Record<string, "allow" | "ask" | "deny">
8691

8792
const defaults = Permission.fromConfig({
8893
"*": "allow",
@@ -94,6 +99,8 @@ export const layer = Layer.effect(
9499
question: "deny",
95100
plan_enter: "deny",
96101
plan_exit: "deny",
102+
repo_clone: "deny",
103+
repo_overview: "deny",
97104
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
98105
read: {
99106
"*": "allow",
@@ -172,10 +179,7 @@ export const layer = Layer.effect(
172179
websearch: "allow",
173180
codesearch: "allow",
174181
read: "allow",
175-
external_directory: {
176-
"*": "ask",
177-
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
178-
},
182+
external_directory: readonlyExternalDirectory,
179183
}),
180184
user,
181185
),
@@ -185,6 +189,33 @@ export const layer = Layer.effect(
185189
mode: "subagent",
186190
native: true,
187191
},
192+
scout: {
193+
name: "scout",
194+
permission: Permission.merge(
195+
defaults,
196+
Permission.fromConfig({
197+
"*": "deny",
198+
grep: "allow",
199+
glob: "allow",
200+
webfetch: "allow",
201+
websearch: "allow",
202+
codesearch: "allow",
203+
read: "allow",
204+
repo_clone: "allow",
205+
repo_overview: "allow",
206+
external_directory: {
207+
...readonlyExternalDirectory,
208+
[path.join(Global.Path.repos, "*")]: "allow",
209+
},
210+
}),
211+
user,
212+
),
213+
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.`,
214+
prompt: PROMPT_SCOUT,
215+
options: {},
216+
mode: "subagent",
217+
native: true,
218+
},
188219
compaction: {
189220
name: "compaction",
190221
mode: "primary",
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
You are `scout`, a read-only research agent for external libraries, dependency source, and documentation.
2+
3+
Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace.
4+
5+
Use this agent when asked to:
6+
- inspect dependency repositories or library source
7+
- compare local code against upstream implementations
8+
- research public GitHub repositories the environment can clone
9+
- explain how a library or framework works by reading its source and docs
10+
- investigate third-party APIs, workflows, or behavior outside the current workspace
11+
12+
Working style:
13+
1. When the task involves a GitHub repository or dependency source, use `repo_clone` first.
14+
2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository.
15+
3. Use `WebFetch` for official documentation pages when source alone is not enough.
16+
4. Prefer direct code and documentation evidence over assumptions.
17+
5. If multiple external repositories are relevant, inspect each one before drawing conclusions.
18+
19+
Research standards:
20+
- cite exact absolute file paths and line references whenever possible
21+
- separate what is verified from what is inferred
22+
- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise
23+
- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available
24+
- call out uncertainty clearly instead of smoothing over gaps
25+
26+
Output expectations:
27+
- start with the direct answer
28+
- then explain the evidence repository by repository or source by source
29+
- include file references when relevant
30+
- keep the explanation organized and easy to scan
31+
32+
Constraints:
33+
- do not modify files or run tools that change the user's workspace
34+
- return absolute file paths for cloned-repo findings in your final response
35+
36+
Complete the user's research request efficiently and report your findings clearly.

packages/opencode/src/cli/cmd/github.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
3333
import { Git } from "@/git"
3434
import { setTimeout as sleep } from "node:timers/promises"
3535
import { Process } from "@/util"
36+
import { parseGitHubRemote } from "@/util/github-remote"
3637
import { Effect } from "effect"
3738

3839
type GitHubAuthor = {
@@ -152,18 +153,7 @@ const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const
152153
type UserEvent = (typeof USER_EVENTS)[number]
153154
type RepoEvent = (typeof REPO_EVENTS)[number]
154155

155-
// Parses GitHub remote URLs in various formats:
156-
// - https://github.com/owner/repo.git
157-
// - https://github.com/owner/repo
158-
// - git@github.com:owner/repo.git
159-
// - git@github.com:owner/repo
160-
// - ssh://git@github.com/owner/repo.git
161-
// - ssh://git@github.com/owner/repo
162-
export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
163-
const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
164-
if (!match) return null
165-
return { owner: match[1], repo: match[2] }
166-
}
156+
export { parseGitHubRemote }
167157

168158
/**
169159
* Extracts displayable text from assistant response parts.

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export const Info = Schema.Struct({
170170
// subagent
171171
general: Schema.optional(AgentRef),
172172
explore: Schema.optional(AgentRef),
173+
scout: Schema.optional(AgentRef),
173174
// specialized
174175
title: Schema.optional(AgentRef),
175176
summary: Schema.optional(AgentRef),

packages/opencode/src/config/permission.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const InputObject = Schema.StructWithRest(
4444
webfetch: Schema.optional(Action),
4545
websearch: Schema.optional(Action),
4646
codesearch: Schema.optional(Action),
47+
repo_clone: Schema.optional(Rule),
48+
repo_overview: Schema.optional(Rule),
4749
lsp: Schema.optional(Rule),
4850
doom_loop: Schema.optional(Action),
4951
skill: Schema.optional(Rule),

packages/opencode/src/global/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const Path = {
2020
data,
2121
bin: path.join(cache, "bin"),
2222
log: path.join(data, "log"),
23+
repos: path.join(data, "repos"),
2324
cache,
2425
config,
2526
state,
@@ -34,6 +35,7 @@ await Promise.all([
3435
fs.mkdir(Path.state, { recursive: true }),
3536
fs.mkdir(Path.log, { recursive: true }),
3637
fs.mkdir(Path.bin, { recursive: true }),
38+
fs.mkdir(Path.repos, { recursive: true }),
3739
])
3840

3941
const CACHE_VERSION = "21"

packages/opencode/src/tool/registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { Provider } from "../provider"
2121
import { ProviderID, type ModelID } from "../provider/schema"
2222
import { WebSearchTool } from "./websearch"
2323
import { CodeSearchTool } from "./codesearch"
24+
import { RepoCloneTool } from "./repo_clone"
25+
import { RepoOverviewTool } from "./repo_overview"
2426
import { Flag } from "@/flag/flag"
2527
import { Log } from "@/util"
2628
import { LspTool } from "./lsp"
@@ -43,6 +45,7 @@ import { Instruction } from "../session/instruction"
4345
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
4446
import { Bus } from "../bus"
4547
import { Agent } from "../agent/agent"
48+
import { Git } from "@/git"
4649
import { Skill } from "../skill"
4750
import { Permission } from "@/permission"
4851

@@ -78,6 +81,7 @@ export const layer: Layer.Layer<
7881
| Skill.Service
7982
| Session.Service
8083
| Provider.Service
84+
| Git.Service
8185
| LSP.Service
8286
| Instruction.Service
8387
| AppFileSystem.Service
@@ -107,6 +111,8 @@ export const layer: Layer.Layer<
107111
const websearch = yield* WebSearchTool
108112
const bash = yield* BashTool
109113
const codesearch = yield* CodeSearchTool
114+
const repoClone = yield* RepoCloneTool
115+
const repoOverview = yield* RepoOverviewTool
110116
const globtool = yield* GlobTool
111117
const writetool = yield* WriteTool
112118
const edit = yield* EditTool
@@ -189,6 +195,8 @@ export const layer: Layer.Layer<
189195
todo: Tool.init(todo),
190196
search: Tool.init(websearch),
191197
code: Tool.init(codesearch),
198+
repo_clone: Tool.init(repoClone),
199+
repo_overview: Tool.init(repoOverview),
192200
skill: Tool.init(skilltool),
193201
patch: Tool.init(patchtool),
194202
question: Tool.init(question),
@@ -212,6 +220,8 @@ export const layer: Layer.Layer<
212220
tool.todo,
213221
tool.search,
214222
tool.code,
223+
tool.repo_clone,
224+
tool.repo_overview,
215225
tool.skill,
216226
tool.patch,
217227
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
@@ -326,6 +336,7 @@ export const defaultLayer = Layer.suspend(() =>
326336
Layer.provide(Agent.defaultLayer),
327337
Layer.provide(Session.defaultLayer),
328338
Layer.provide(Provider.defaultLayer),
339+
Layer.provide(Git.defaultLayer),
329340
Layer.provide(LSP.defaultLayer),
330341
Layer.provide(Instruction.defaultLayer),
331342
Layer.provide(AppFileSystem.defaultLayer),
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import path from "path"
2+
import z from "zod"
3+
import { Effect } from "effect"
4+
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
5+
import { Flock } from "@opencode-ai/shared/util/flock"
6+
import { Git } from "@/git"
7+
import DESCRIPTION from "./repo_clone.txt"
8+
import * as Tool from "./tool"
9+
import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository"
10+
11+
const parameters = z.object({
12+
repository: z
13+
.string()
14+
.describe("Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand"),
15+
refresh: z.boolean().optional().describe("When true, fetches the latest remote state into the managed cache"),
16+
})
17+
18+
function statusForRepository(input: { reuse: boolean; refresh?: boolean }) {
19+
if (!input.reuse) return "cloned" as const
20+
if (input.refresh) return "refreshed" as const
21+
return "cached" as const
22+
}
23+
24+
function resetTarget(input: {
25+
remoteHead: { code: number; stdout: string }
26+
branch: { code: number; stdout: string }
27+
}) {
28+
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
29+
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
30+
}
31+
if (input.branch.code === 0 && input.branch.stdout) {
32+
return `origin/${input.branch.stdout}`
33+
}
34+
return "HEAD"
35+
}
36+
37+
export const RepoCloneTool = Tool.define(
38+
"repo_clone",
39+
Effect.gen(function* () {
40+
const fs = yield* AppFileSystem.Service
41+
const git = yield* Git.Service
42+
43+
return {
44+
description: DESCRIPTION,
45+
parameters,
46+
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
47+
Effect.gen(function* () {
48+
const reference = parseRepositoryReference(params.repository)
49+
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
50+
51+
const repository = reference.label
52+
const remote = reference.remote
53+
const localPath = repositoryCachePath(reference)
54+
const cloneTarget = parseRepositoryReference(remote) ?? reference
55+
56+
yield* ctx.ask({
57+
permission: "repo_clone",
58+
patterns: [repository],
59+
always: [repository],
60+
metadata: {
61+
repository,
62+
remote,
63+
path: localPath,
64+
refresh: Boolean(params.refresh),
65+
},
66+
})
67+
68+
return yield* Effect.acquireUseRelease(
69+
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
70+
() =>
71+
Effect.gen(function* () {
72+
yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
73+
74+
const exists = yield* fs.existsSafe(localPath)
75+
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
76+
const origin = hasGitDir
77+
? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
78+
: undefined
79+
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
80+
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
81+
if (exists && !reuse) {
82+
yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
83+
}
84+
85+
const status = statusForRepository({ reuse, refresh: params.refresh })
86+
87+
if (status === "cloned") {
88+
const clone = yield* git.run(["clone", "--depth", "100", remote, localPath], { cwd: path.dirname(localPath) })
89+
if (clone.exitCode !== 0) {
90+
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
91+
}
92+
}
93+
94+
if (status === "refreshed") {
95+
const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath })
96+
if (fetch.exitCode !== 0) {
97+
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
98+
}
99+
100+
const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
101+
const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
102+
const target = resetTarget({
103+
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
104+
branch: { code: branch.exitCode, stdout: branch.text().trim() },
105+
})
106+
107+
const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath })
108+
if (reset.exitCode !== 0) {
109+
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
110+
}
111+
}
112+
113+
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath })
114+
const branch = yield* git.branch(localPath)
115+
const headText = head.exitCode === 0 ? head.text().trim() : undefined
116+
117+
return {
118+
title: repository,
119+
metadata: {
120+
repository,
121+
host: reference.host,
122+
remote,
123+
localPath,
124+
status,
125+
head: headText,
126+
branch,
127+
},
128+
output: [
129+
`Repository ready: ${repository}`,
130+
`Status: ${status}`,
131+
`Local path: ${localPath}`,
132+
...(branch ? [`Branch: ${branch}`] : []),
133+
...(headText ? [`HEAD: ${headText}`] : []),
134+
].join("\n"),
135+
}
136+
}),
137+
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
138+
)
139+
}).pipe(Effect.orDie),
140+
}
141+
}),
142+
)

0 commit comments

Comments
 (0)