refactor(core): consolidate references (#31539)
This commit is contained in:
parent
0bb677cef9
commit
6566ede935
65 changed files with 687 additions and 2730 deletions
63
packages/core/src/config/plugin/reference.ts
Normal file
63
packages/core/src/config/plugin/reference.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
export const Plugin = {
|
||||
id: PluginV2.ID.make("core/config-reference"),
|
||||
effect: Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const references = yield* Reference.Service
|
||||
const update = yield* references.transform()
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)),
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
yield* update((editor) => {
|
||||
for (const [name, source] of entries) editor.add(name, source)
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
function validAlias(name: string) {
|
||||
return name.length > 0 && !/[\/\s`,]/.test(name)
|
||||
}
|
||||
|
||||
function local(entry: ConfigReference.Entry): entry is string | ConfigReference.Local {
|
||||
return typeof entry === "string"
|
||||
? entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")
|
||||
: "path" in entry
|
||||
}
|
||||
|
||||
function localPath(directory: string, home: string, value: string) {
|
||||
if (value.startsWith("~/")) return path.join(home, value.slice(2))
|
||||
return path.isAbsolute(value) ? value : path.resolve(directory, value)
|
||||
}
|
||||
|
|
@ -16,33 +16,3 @@ export type Entry = typeof Entry.Type
|
|||
|
||||
export const Info = Schema.Record(Schema.String, Entry)
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export type NormalizedEntry =
|
||||
| { readonly kind: "local"; readonly path: string }
|
||||
| { readonly kind: "git"; readonly repository: string; readonly branch?: string }
|
||||
| { readonly kind: "invalid"; readonly message: string }
|
||||
|
||||
export type NormalizedInfo = Record<string, NormalizedEntry>
|
||||
|
||||
export function validateAlias(name: string) {
|
||||
if (name.length === 0) return "Reference alias must not be empty"
|
||||
if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick"
|
||||
}
|
||||
|
||||
export function normalizeEntry(entry: Entry): NormalizedEntry {
|
||||
if (typeof entry === "string") {
|
||||
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry }
|
||||
return { kind: "git", repository: entry }
|
||||
}
|
||||
if ("path" in entry) return { kind: "local", path: entry.path }
|
||||
return { kind: "git", repository: entry.repository, branch: entry.branch }
|
||||
}
|
||||
|
||||
export function normalize(info: Info): NormalizedInfo {
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([name, entry]) => {
|
||||
const message = validateAlias(name)
|
||||
return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { EventV2 } from "./event"
|
|||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { Protected } from "./filesystem/protected"
|
||||
import { Ripgrep } from "./filesystem/ripgrep"
|
||||
|
|
@ -17,7 +16,6 @@ import { ToolOutputStore } from "./tool-output-store"
|
|||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ReadInput = typeof ReadInput.Type
|
||||
|
||||
|
|
@ -134,7 +132,6 @@ export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
|
|||
|
||||
export const ListInput = Schema.Struct({
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
|
|
@ -158,7 +155,6 @@ export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget"
|
|||
real: Schema.String,
|
||||
root: Schema.String,
|
||||
resource: Schema.String,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
}) {}
|
||||
|
||||
|
|
@ -239,7 +235,6 @@ export const layer = Layer.effect(
|
|||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Effect.serviceOption(Global.Service)
|
||||
const references = yield* ProjectReference.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||
const ignored = ignore()
|
||||
|
|
@ -251,21 +246,12 @@ export const layer = Layer.effect(
|
|||
.readFileString(path.join(location.project.directory, ".ignore"))
|
||||
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (ignorefile) ignored.add(ignorefile)
|
||||
const select = Effect.fnUntraced(function* (reference?: string) {
|
||||
if (!reference) return { directory: location.directory, root }
|
||||
const resolved = yield* references.get(reference)
|
||||
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
|
||||
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
|
||||
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
|
||||
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
|
||||
})
|
||||
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
|
||||
const resolve = Effect.fnUntraced(function* (input?: string) {
|
||||
const managed = path.join(
|
||||
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
|
||||
ToolOutputStore.MANAGED_DIRECTORY,
|
||||
)
|
||||
if (input && path.isAbsolute(input)) {
|
||||
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
|
||||
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
|
||||
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
|
||||
const real = yield* fs.realPath(input).pipe(Effect.orDie)
|
||||
|
|
@ -274,9 +260,9 @@ export const layer = Layer.effect(
|
|||
return yield* Effect.die(new Error("Path escapes managed tool output"))
|
||||
return { absolute: input, real, directory: managed, root: managedRoot }
|
||||
}
|
||||
const selected = yield* select(reference)
|
||||
const absolute = path.resolve(selected.directory, input ?? ".")
|
||||
if (!FSUtil.contains(selected.directory, absolute))
|
||||
const selected = { directory: location.directory, root }
|
||||
const absolute = path.resolve(location.directory, input ?? ".")
|
||||
if (!FSUtil.contains(location.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
|
|
@ -335,24 +321,24 @@ export const layer = Layer.effect(
|
|||
})
|
||||
|
||||
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return new ReadPath({
|
||||
type,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
})
|
||||
})
|
||||
const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return {
|
||||
real: target.real,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
}
|
||||
})
|
||||
const content = (target: { readonly real: string }, bytes: Uint8Array) =>
|
||||
|
|
@ -510,25 +496,24 @@ export const layer = Layer.effect(
|
|||
)
|
||||
})
|
||||
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
|
||||
const directory = yield* resolve(input.path, input.reference)
|
||||
const directory = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
|
||||
return new ListTarget({
|
||||
...directory,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
})
|
||||
})
|
||||
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return new RootTarget({
|
||||
...target,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
reference: input.reference,
|
||||
resource: relative,
|
||||
type,
|
||||
})
|
||||
})
|
||||
|
|
@ -644,5 +629,4 @@ export const layer = Layer.effect(
|
|||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provideMerge(ProjectReference.locationLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { Watcher } from "./filesystem/watcher"
|
|||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationSearch } from "./location-search"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { Reference } from "./reference"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { Pty } from "./pty"
|
||||
import { SkillV2 } from "./skill"
|
||||
|
|
@ -52,7 +52,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
location,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
ProjectReference.locationLayer,
|
||||
Reference.locationLayer,
|
||||
PluginV2.locationLayer,
|
||||
Catalog.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
|||
|
||||
/**
|
||||
* Location-scoped raw search substrate. Search authority is selected only by
|
||||
* FileSystem, preserving Location-relative paths and named read
|
||||
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
|
||||
* FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay
|
||||
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
|
||||
* share the same bounded filesystem behavior.
|
||||
*
|
||||
|
|
@ -106,7 +105,7 @@ export const layer = Layer.effect(
|
|||
return {
|
||||
path: RelativePath.make(relative),
|
||||
canonical,
|
||||
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
|
||||
resource: relative,
|
||||
mtime: info.mtime.pipe(
|
||||
Option.map((date) => date.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Config } from "../config"
|
|||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
|
|
@ -25,6 +26,7 @@ import { EnvPlugin } from "./env"
|
|||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Reference } from "../reference"
|
||||
|
||||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
|
|
@ -42,6 +44,7 @@ type Plugin = {
|
|||
| Config.Service
|
||||
| ModelsDev.Service
|
||||
| SkillV2.Service
|
||||
| Reference.Service
|
||||
>
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +70,7 @@ export const layer = Layer.effect(
|
|||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const references = yield* Reference.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
|
|
@ -85,6 +89,7 @@ export const layer = Layer.effect(
|
|||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, references),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
|
|
@ -104,6 +109,7 @@ export const layer = Layer.effect(
|
|||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
|
|
@ -124,4 +130,5 @@ export const locationLayer = layer.pipe(
|
|||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
export * as ProjectReference from "./project-reference"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { Repository } from "./repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
|
||||
export type Resolved =
|
||||
| { readonly name: string; readonly kind: "local"; readonly path: string }
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "git"
|
||||
readonly repository: string
|
||||
readonly reference: Repository.RemoteReference
|
||||
readonly path: string
|
||||
readonly branch?: string
|
||||
}
|
||||
| { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string }
|
||||
|
||||
type Valid = Exclude<Resolved, { kind: "invalid" }>
|
||||
|
||||
export type Mention =
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "reference"
|
||||
readonly reference: Valid
|
||||
readonly target?: string
|
||||
readonly path: string
|
||||
}
|
||||
| { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string }
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "missing"
|
||||
readonly target: string
|
||||
readonly path: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Resolved[]>
|
||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||
readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
|
||||
readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
|
||||
readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
|
||||
|
||||
type Materializer = {
|
||||
readonly name: string
|
||||
readonly repository: string
|
||||
readonly path: string
|
||||
readonly run: Effect.Effect<void, RepositoryCache.Error>
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
|
||||
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const references = resolveAll({
|
||||
references: ConfigReference.normalize(
|
||||
Object.assign(
|
||||
{},
|
||||
...(yield* config.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.map((document) => document.info.references ?? {}),
|
||||
),
|
||||
),
|
||||
directory: location.project.directory,
|
||||
home: global.home,
|
||||
repos: global.repos,
|
||||
})
|
||||
const materializers = yield* Effect.forEach(
|
||||
uniqueGitReferences(references),
|
||||
Effect.fnUntraced(function* (reference) {
|
||||
return {
|
||||
name: reference.name,
|
||||
repository: reference.repository,
|
||||
path: reference.path,
|
||||
run: yield* Effect.cached(
|
||||
cache
|
||||
.ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
|
||||
.pipe(Effect.asVoid),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(
|
||||
materializers,
|
||||
(materializer) =>
|
||||
materializer.run.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize project reference", {
|
||||
name: materializer.name,
|
||||
repository: materializer.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 4, discard: true },
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) {
|
||||
const normalized = normalizePath(target)
|
||||
if (!normalized)
|
||||
return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true })
|
||||
yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list: Effect.fn("ProjectReference.list")(function* () {
|
||||
return references
|
||||
}),
|
||||
get: Effect.fn("ProjectReference.get")(function* (name: string) {
|
||||
return references.find((reference) => reference.name === name)
|
||||
}),
|
||||
ensurePath,
|
||||
containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) {
|
||||
const normalized = normalizePath(target)
|
||||
return normalized
|
||||
? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized))
|
||||
: false
|
||||
}),
|
||||
resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
|
||||
const [name, ...rest] = value.split("/")
|
||||
const target = rest.length ? rest.join("/") : undefined
|
||||
const reference = references.find((reference) => reference.name === name)
|
||||
if (!reference) return
|
||||
if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
|
||||
if (reference.kind === "git") yield* ensurePath(reference.path)
|
||||
if (!target) return { name, kind: "reference", reference, path: reference.path }
|
||||
|
||||
const resolved = path.resolve(reference.path, target)
|
||||
if (!FSUtil.contains(reference.path, resolved))
|
||||
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
|
||||
if (!(yield* fs.existsSafe(resolved)))
|
||||
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
|
||||
return { name, kind: "reference", reference, target, path: resolved }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
const inert: Interface = {
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.succeed(undefined),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
}
|
||||
|
||||
export function resolveAll(input: {
|
||||
references: ConfigReference.NormalizedInfo
|
||||
directory: string
|
||||
home: string
|
||||
repos: string
|
||||
}) {
|
||||
const seen = new Map<string, { name: string; branch?: string }>()
|
||||
return Object.entries(input.references).map(([name, reference]): Resolved => {
|
||||
const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
|
||||
if (resolved.kind !== "git") return resolved
|
||||
const existing = seen.get(resolved.path)
|
||||
if (!existing) {
|
||||
seen.set(resolved.path, { name, branch: resolved.branch })
|
||||
return resolved
|
||||
}
|
||||
if (existing.branch === resolved.branch) return resolved
|
||||
return {
|
||||
name,
|
||||
kind: "invalid",
|
||||
repository: resolved.repository,
|
||||
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resolve(input: {
|
||||
name: string
|
||||
reference: ConfigReference.NormalizedEntry
|
||||
directory: string
|
||||
home: string
|
||||
repos: string
|
||||
}): Resolved {
|
||||
if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message }
|
||||
if (input.reference.kind === "local") {
|
||||
return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) }
|
||||
}
|
||||
const reference = Repository.parse(input.reference.repository)
|
||||
if (!reference || !Repository.isRemote(reference)) {
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "invalid",
|
||||
repository: input.reference.repository,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "git",
|
||||
repository: input.reference.repository,
|
||||
reference,
|
||||
path: Repository.cachePath(input.repos, reference),
|
||||
branch: input.reference.branch,
|
||||
}
|
||||
}
|
||||
|
||||
function localPath(directory: string, home: string, value: string) {
|
||||
if (value.startsWith("~/")) return path.join(home, value.slice(2))
|
||||
return path.isAbsolute(value) ? value : path.resolve(directory, value)
|
||||
}
|
||||
|
||||
function uniqueGitReferences(references: Resolved[]) {
|
||||
const seen = new Set<string>()
|
||||
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||
if (reference.kind !== "git" || seen.has(reference.path)) return false
|
||||
seen.add(reference.path)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizePath(target?: string) {
|
||||
if (!target) return
|
||||
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||
}
|
||||
|
||||
function contains(parent: string, child: string) {
|
||||
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
|
||||
}
|
||||
114
packages/core/src/reference.ts
Normal file
114
packages/core/src/reference.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
export * as Reference from "./reference"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft } from "immer"
|
||||
import { Global } from "./global"
|
||||
import { EventV2 } from "./event"
|
||||
import { Repository } from "./repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { State } from "./state"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Reference.Info")({
|
||||
name: Schema.String,
|
||||
path: AbsolutePath,
|
||||
source: Schema.suspend(() => Source),
|
||||
}) {}
|
||||
|
||||
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
|
||||
type: Schema.Literal("local"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
|
||||
type: Schema.Literal("git"),
|
||||
repository: Schema.String,
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
export const Event = {
|
||||
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
|
||||
}
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Source>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
add(name: string, source: Source): void
|
||||
remove(name: string): void
|
||||
list(): readonly [string, Source][]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Reference") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const events = yield* EventV2.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ sources: new Map() }),
|
||||
editor: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, castDraft(source)),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
}),
|
||||
finalize: (editor) =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
const seen = new Map<string, string | undefined>()
|
||||
for (const [name, source] of editor.list()) {
|
||||
if (source.type === "local") {
|
||||
materialized.set(name, new Info({ name, path: source.path, source }))
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const target = Repository.cachePath(global.repos, repository)
|
||||
if (seen.has(target) && seen.get(target) !== source.branch) continue
|
||||
seen.set(target, source.branch)
|
||||
materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source }))
|
||||
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
|
@ -349,6 +349,5 @@ const toMessage = (input: Admitted) =>
|
|||
text: input.prompt.text,
|
||||
files: input.prompt.files,
|
||||
agents: input.prompt.agents,
|
||||
references: input.prompt.references,
|
||||
time: { created: input.timeCreated },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -132,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
references: event.data.prompt.references,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export class User extends Schema.Class<User>("Session.Message.User")({
|
|||
text: Prompt.fields.text,
|
||||
files: Prompt.fields.files,
|
||||
agents: Prompt.fields.agents,
|
||||
references: Prompt.fields.references,
|
||||
type: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
|
|
|
|||
|
|
@ -29,32 +29,18 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
|
|||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
|
||||
name: Schema.String,
|
||||
kind: Schema.Literals(["local", "git", "invalid"]),
|
||||
uri: Schema.String.pipe(Schema.optional),
|
||||
repository: Schema.String.pipe(Schema.optional),
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
target: Schema.String.pipe(Schema.optional),
|
||||
targetUri: Schema.String.pipe(Schema.optional),
|
||||
problem: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("Prompt")({
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
|
||||
}) {
|
||||
static readonly equivalence = Schema.toEquivalence(Prompt)
|
||||
|
||||
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents" | "references">) {
|
||||
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents">) {
|
||||
return new Prompt({
|
||||
text: input.text,
|
||||
...(input.files === undefined ? {} : { files: input.files }),
|
||||
...(input.agents === undefined ? {} : { agents: input.agents }),
|
||||
...(input.references === undefined ? {} : { references: input.references }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||
metadata: {
|
||||
...message.metadata,
|
||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||
...(message.references?.length ? { references: message.references } : {}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
* external_directory approval before edit approval.
|
||||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ export const name = "edit"
|
|||
export const Input = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
|
||||
}),
|
||||
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
|
||||
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
|
||||
|
|
@ -100,7 +99,7 @@ export const layer = Layer.effectDiscard(
|
|||
[name]: Tool.withPermission(
|
||||
Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ export const Input = Schema.Struct({
|
|||
path: LocationSearch.FilesInput.fields.path.annotate({
|
||||
description: "Relative directory to search. Defaults to the active Location.",
|
||||
}),
|
||||
reference: LocationSearch.FilesInput.fields.reference.annotate({
|
||||
description: "Named project reference to search instead of the active Location",
|
||||
}),
|
||||
limit: LocationSearch.FilesInput.fields.limit.annotate({
|
||||
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
|
||||
}),
|
||||
|
|
@ -41,8 +38,6 @@ export const toModelOutput = (output: ModelOutput) => {
|
|||
/**
|
||||
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and traversal.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -55,20 +50,19 @@ export const layer = Layer.effectDiscard(
|
|||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
input: Input,
|
||||
output: LocationSearch.FilesResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference })
|
||||
const root = yield* filesystem.resolveRoot({ path: input.path })
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
limit: input.limit,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@ export const Input = Schema.Struct({
|
|||
path: LocationSearch.GrepInput.fields.path.annotate({
|
||||
description: "Relative file or directory to search. Defaults to the active Location.",
|
||||
}),
|
||||
reference: LocationSearch.GrepInput.fields.reference.annotate({
|
||||
description: "Named project reference to search instead of the active Location",
|
||||
}),
|
||||
include: LocationSearch.GrepInput.fields.include.annotate({
|
||||
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
|
||||
}),
|
||||
|
|
@ -56,8 +53,6 @@ export const toModelOutput = (output: Output) => {
|
|||
/**
|
||||
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and ripgrep execution.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -70,7 +65,7 @@ export const layer = Layer.effectDiscard(
|
|||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
input: Input,
|
||||
output: LocationSearch.GrepResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
|
|
@ -83,7 +78,6 @@ export const layer = Layer.effectDiscard(
|
|||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
* Model-facing V2 file-write leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
* external_directory approval before edit approval.
|
||||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ export const name = "write"
|
|||
export const Input = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
|
||||
}),
|
||||
content: Schema.String.annotate({ description: "Content to write to the file" }),
|
||||
})
|
||||
|
|
@ -55,7 +54,7 @@ export const layer = Layer.effectDiscard(
|
|||
[name]: Tool.withPermission(
|
||||
Tool.make({
|
||||
description:
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const Info = Schema.Struct({
|
|||
}),
|
||||
skills: Schema.optional(ConfigSkillsV1.Info).annotate({ description: "Additional skill folder paths" }),
|
||||
reference: Schema.optional(ConfigReferenceV1.Info).annotate({
|
||||
description: "Named git or local directory references that can be mentioned as @alias or @alias/path",
|
||||
description: "Named git or local directory references",
|
||||
}),
|
||||
watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })),
|
||||
snapshot: Schema.optional(Schema.Boolean).annotate({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue