Skip to content

Commit d92b47f

Browse files
authored
feat(core): add project reference guidance (anomalyco#31601)
1 parent 253d076 commit d92b47f

37 files changed

Lines changed: 744 additions & 163 deletions

packages/core/src/config/plugin/reference.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,15 @@ export const Plugin = {
3333
path: AbsolutePath.make(
3434
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
3535
),
36+
description: typeof entry === "string" ? undefined : entry.description,
37+
hidden: typeof entry === "string" ? undefined : entry.hidden,
3638
})
3739
: new Reference.GitSource({
3840
type: "git",
3941
repository: typeof entry === "string" ? entry : entry.repository,
4042
branch: typeof entry === "string" ? undefined : entry.branch,
43+
description: typeof entry === "string" ? undefined : entry.description,
44+
hidden: typeof entry === "string" ? undefined : entry.hidden,
4145
}),
4246
)
4347
}

packages/core/src/config/reference.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ import { Schema } from "effect"
55
export class Git extends Schema.Class<Git>("ConfigV2.Reference.Git")({
66
repository: Schema.String,
77
branch: Schema.String.pipe(Schema.optional),
8+
description: Schema.String.pipe(Schema.optional),
9+
hidden: Schema.Boolean.pipe(Schema.optional),
810
}) {}
911

1012
export class Local extends Schema.Class<Local>("ConfigV2.Reference.Local")({
1113
path: Schema.String,
14+
description: Schema.String.pipe(Schema.optional),
15+
hidden: Schema.Boolean.pipe(Schema.optional),
1216
}) {}
1317

1418
export const Entry = Schema.Union([Schema.String, Git, Local])

packages/core/src/location-layer.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Layer, LayerMap } from "effect"
1+
import { Effect, Layer, LayerMap } from "effect"
22
import { Location } from "./location"
33
import { Policy } from "./policy"
44
import { Config } from "./config"
@@ -23,6 +23,7 @@ import { Watcher } from "./filesystem/watcher"
2323
import { LocationMutation } from "./location-mutation"
2424
import { FileMutation } from "./file-mutation"
2525
import { Reference } from "./reference"
26+
import { ReferenceGuidance } from "./reference/guidance"
2627
import { RepositoryCache } from "./repository-cache"
2728
import { Pty } from "./pty"
2829
import { SkillV2 } from "./skill"
@@ -45,6 +46,9 @@ import { FetchHttpClient } from "effect/unstable/http"
4546

4647
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
4748
lookup: (ref: Location.Ref) => {
49+
const boot = Layer.effectDiscard(
50+
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
51+
)
4852
const location = Location.layer(ref)
4953
const systemContext = SystemContextBuiltIns.locationLayer
5054
const base = Layer.mergeAll(
@@ -74,6 +78,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
7478
const image = Image.layer.pipe(Layer.provide(services))
7579
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
7680
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
81+
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
7782
const todos = SessionTodo.layer.pipe(Layer.provide(services))
7883
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
7984
const builtInTools = BuiltInTools.locationLayer.pipe(
@@ -89,10 +94,21 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
8994
Layer.provide(services),
9095
Layer.provide(model),
9196
Layer.provide(skillGuidance),
97+
Layer.provide(referenceGuidance),
9298
)
93-
return Layer.mergeAll(services, image, mutation, resources, todos, questions, model, runner, builtInTools).pipe(
94-
Layer.fresh,
95-
)
99+
return Layer.mergeAll(
100+
boot,
101+
services,
102+
image,
103+
mutation,
104+
resources,
105+
todos,
106+
questions,
107+
model,
108+
runner,
109+
builtInTools,
110+
referenceGuidance,
111+
).pipe(Layer.fresh)
96112
},
97113
idleTimeToLive: "60 minutes",
98114
dependencies: [

packages/core/src/location.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ import { WorkspaceV2 } from "./workspace"
55

66
export * as Location from "./location"
77

8-
export const Ref = Schema.Struct({
8+
export class Ref extends Schema.Class<Ref>("Location.Ref")({
99
directory: AbsolutePath,
10-
workspaceID: Schema.optional(WorkspaceV2.ID),
11-
}).annotate({ identifier: "Location.Ref" })
12-
export type Ref = typeof Ref.Type
10+
workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
11+
}) {}
1312

1413
export class Info extends Schema.Class<Info>("Location.Info")({
1514
directory: AbsolutePath,

packages/core/src/plugin/skill/customize-opencode.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ Every field is optional.
7373
"urls": ["https://example.com/.well-known/skills/"]
7474
},
7575

76+
"references": {
77+
"docs": {
78+
"path": "../docs",
79+
"description": "Use for product behavior and documentation conventions"
80+
},
81+
"sdk": {
82+
"repository": "owner/sdk",
83+
"branch": "main",
84+
"description": "Use for SDK implementation details",
85+
"hidden": true
86+
}
87+
},
88+
7689
"agent": {
7790
"my-agent": {
7891
"model": "anthropic/claude-sonnet-4-6",
@@ -136,6 +149,7 @@ Shape notes worth being explicit about:
136149

137150
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
138151
- `skills` is an object with `paths` and/or `urls`, not an array.
152+
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
139153
- `agent` is an object keyed by agent name, not an array.
140154
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
141155
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
@@ -172,6 +186,38 @@ Register skills from non-default locations via `skills.paths` (scanned
172186
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
173187
skills).
174188

189+
## References
190+
191+
References make local directories and Git repositories outside the active
192+
project available as supporting context. Configure them under `references`,
193+
keyed by the alias used in `@` autocomplete:
194+
195+
```json
196+
{
197+
"references": {
198+
"docs": {
199+
"path": "../product-docs",
200+
"description": "Use for product behavior and terminology"
201+
},
202+
"effect": {
203+
"repository": "Effect-TS/effect",
204+
"branch": "main",
205+
"description": "Use for Effect implementation details"
206+
}
207+
}
208+
}
209+
```
210+
211+
Local `path` values may be relative to the declaring config, absolute, or use
212+
`~/`. Git `repository` values accept Git URLs, host/path references, and GitHub
213+
`owner/repo` shorthand; `branch` is optional. Both forms support optional
214+
`description` and `hidden` fields.
215+
216+
- Only references with a `description` are advertised to agents in system context.
217+
- `hidden: true` removes a reference from TUI `@` autocomplete only. It remains available to agents and by direct path.
218+
- Reference directories are automatically allowed through the external-directory boundary; normal read/edit/tool permissions still apply.
219+
- String shorthand is supported: use `"docs": "../docs"` for local paths or `"effect": "Effect-TS/effect"` for Git repositories.
220+
175221
## Agents
176222

177223
Two ways to define an agent. Use the file form for anything non-trivial.

packages/core/src/reference.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,19 @@ import { RepositoryCache } from "./repository-cache"
99
import { AbsolutePath } from "./schema"
1010
import { State } from "./state"
1111

12-
export class Info extends Schema.Class<Info>("Reference.Info")({
13-
name: Schema.String,
14-
path: AbsolutePath,
15-
source: Schema.suspend(() => Source),
16-
}) {}
17-
1812
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
1913
type: Schema.Literal("local"),
2014
path: AbsolutePath,
15+
description: Schema.String.pipe(Schema.optional),
16+
hidden: Schema.Boolean.pipe(Schema.optional),
2117
}) {}
2218

2319
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
2420
type: Schema.Literal("git"),
2521
repository: Schema.String,
2622
branch: Schema.String.pipe(Schema.optional),
23+
description: Schema.String.pipe(Schema.optional),
24+
hidden: Schema.Boolean.pipe(Schema.optional),
2725
}) {}
2826

2927
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
@@ -33,6 +31,14 @@ export const Event = {
3331
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
3432
}
3533

34+
export class Info extends Schema.Class<Info>("Reference.Info")({
35+
name: Schema.String,
36+
path: AbsolutePath,
37+
description: Schema.String.pipe(Schema.optional),
38+
hidden: Schema.Boolean.pipe(Schema.optional),
39+
source: Source,
40+
}) {}
41+
3642
type Data = {
3743
sources: Map<string, Source>
3844
}
@@ -71,7 +77,16 @@ export const layer = Layer.effect(
7177
const seen = new Map<string, string | undefined>()
7278
for (const [name, source] of editor.list()) {
7379
if (source.type === "local") {
74-
materialized.set(name, new Info({ name, path: source.path, source }))
80+
materialized.set(
81+
name,
82+
new Info({
83+
name,
84+
path: source.path,
85+
description: source.description,
86+
hidden: source.hidden,
87+
source,
88+
}),
89+
)
7590
continue
7691
}
7792
const repository = Repository.parse(source.repository)
@@ -86,7 +101,16 @@ export const layer = Layer.effect(
86101
const target = Repository.cachePath(global.repos, repository)
87102
if (seen.has(target) && seen.get(target) !== source.branch) continue
88103
seen.set(target, source.branch)
89-
materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source }))
104+
materialized.set(
105+
name,
106+
new Info({
107+
name,
108+
path: AbsolutePath.make(target),
109+
description: source.description,
110+
hidden: source.hidden,
111+
source,
112+
}),
113+
)
90114
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
91115
Effect.catchCause((cause) =>
92116
Effect.logWarning("failed to materialize reference", {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
export * as ReferenceGuidance from "./guidance"
2+
3+
import { Context, Effect, Layer, Schema } from "effect"
4+
import { PluginBoot } from "../plugin/boot"
5+
import { Reference } from "../reference"
6+
import { SystemContext } from "../system-context/index"
7+
8+
const Summary = Schema.Struct({
9+
name: Schema.String,
10+
path: Schema.String,
11+
description: Schema.String.pipe(Schema.optional),
12+
})
13+
14+
const render = (references: ReadonlyArray<typeof Summary.Type>) =>
15+
[
16+
"Project references provide additional directories that can be accessed when relevant.",
17+
"<available_references>",
18+
...references.flatMap((reference) => [
19+
" <reference>",
20+
` <name>${reference.name}</name>`,
21+
` <path>${reference.path}</path>`,
22+
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
23+
" </reference>",
24+
]),
25+
"</available_references>",
26+
].join("\n")
27+
28+
export interface Interface {
29+
readonly load: () => Effect.Effect<SystemContext.SystemContext>
30+
}
31+
32+
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ReferenceGuidance") {}
33+
34+
export const layer = Layer.effect(
35+
Service,
36+
Effect.gen(function* () {
37+
const boot = yield* PluginBoot.Service
38+
const references = yield* Reference.Service
39+
40+
return Service.of({
41+
load: Effect.fn("ReferenceGuidance.load")(function* () {
42+
yield* boot.wait()
43+
const available = (yield* references.list())
44+
.filter((reference) => reference.description !== undefined)
45+
.map((reference) => ({
46+
name: reference.name,
47+
path: reference.path,
48+
description: reference.description,
49+
}))
50+
.toSorted((a, b) => a.name.localeCompare(b.name))
51+
if (available.length === 0) return SystemContext.empty
52+
return SystemContext.make({
53+
key: SystemContext.Key.make("core/reference-guidance"),
54+
codec: Schema.toCodecJson(Schema.Array(Summary)),
55+
load: Effect.succeed(available),
56+
baseline: render,
57+
update: (_previous, current) =>
58+
[
59+
"The available project references have changed. This list supersedes the previous reference list.",
60+
render(current),
61+
].join("\n"),
62+
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
63+
})
64+
}),
65+
})
66+
}),
67+
)
68+
69+
export const locationLayer = layer

packages/core/src/ripgrep.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,10 @@ export const layer = Layer.effect(
161161
args: [
162162
"--no-config",
163163
"--files",
164-
"--glob=!**/.git/**",
165164
...(input.hidden ? ["--hidden"] : []),
166165
...(input.follow ? ["--follow"] : []),
167166
`--glob=${input.pattern}`,
167+
"--glob=!**/.git/**",
168168
".",
169169
],
170170
parse: (line) =>
@@ -195,10 +195,10 @@ export const layer = Layer.effect(
195195
args: [
196196
"--no-config",
197197
"--files",
198-
"--glob=!**/.git/**",
199198
...(input.hidden ? ["--hidden"] : []),
200199
...(input.follow ? ["--follow"] : []),
201-
`--glob=${input.pattern}`,
200+
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
201+
"--glob=!**/.git/**",
202202
".",
203203
],
204204
parse: (line) => {
@@ -226,9 +226,9 @@ export const layer = Layer.effect(
226226
"--no-config",
227227
"--json",
228228
"--hidden",
229-
"--glob=!**/.git/**",
230229
"--no-messages",
231230
...(input.include ? [`--glob=${input.include}`] : []),
231+
"--glob=!**/.git/**",
232232
"--",
233233
input.pattern,
234234
input.file ?? ".",

packages/core/src/session/runner/llm.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { QuestionV2 } from "../../question"
1919
import { SystemContext } from "../../system-context/index"
2020
import { SystemContextRegistry } from "../../system-context/registry"
2121
import { SkillGuidance } from "../../skill/guidance"
22+
import { ReferenceGuidance } from "../../reference/guidance"
2223
import { ToolRegistry } from "../../tool/registry"
2324
import { ToolOutputStore } from "../../tool-output-store"
2425
import { SessionContextEpoch } from "../context-epoch"
@@ -98,6 +99,7 @@ export const layer = Layer.effect(
9899
const location = yield* Location.Service
99100
const systemContext = yield* SystemContextRegistry.Service
100101
const skillGuidance = yield* SkillGuidance.Service
102+
const referenceGuidance = yield* ReferenceGuidance.Service
101103
const config = yield* Config.Service
102104
const db = (yield* Database.Service).db
103105
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
@@ -166,9 +168,9 @@ export const layer = Layer.effect(
166168

167169
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
168170
const loadSystemContext = (agent: AgentV2.Selection) =>
169-
Effect.all([systemContext.load(), skillGuidance.load(agent)], { concurrency: "unbounded" }).pipe(
170-
Effect.map(SystemContext.combine),
171-
)
171+
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
172+
concurrency: "unbounded",
173+
}).pipe(Effect.map(SystemContext.combine))
172174

173175
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
174176
sessionID: SessionSchema.ID,

0 commit comments

Comments
 (0)