10-Plugin 系统与 Skill 发现:扩展边界与上下文注入

10-Plugin 系统与 Skill 发现:扩展边界与上下文注入

简要概括:Plugin用PluginHost隔离API与三重保护,Skill四层发现加缓存,SkillGuidance注入prompt,Registry类型化上下文源。
对应问题 Q10:packages/core/src/plugin.tspackages/opencode/src/plugin/ 实现的插件系统,以及 core/src/skill.tsopencode/src/skill/ 的 Skill 发现机制。涵盖 PluginHost 隔离 API、Plugin.add 三重保护、Plugin.wait 同步、SkillGuidance 注入、SystemContextRegistry 类型化上下文源。 勘察依据:core/src/plugin.tscore/src/plugin/host.tscore/src/plugin/internal.tscore/src/plugin/skill.tscore/src/effect/keyed-mutex.tscore/src/system-context/index.tscore/src/system-context/registry.tscore/src/system-context/builtins.tscore/src/instruction-context.tscore/src/skill.tscore/src/skill/guidance.tscore/src/skill/discovery.tscore/src/session/context-epoch.tsopencode/src/plugin/index.tsopencode/src/plugin/loader.tsopencode/src/plugin/shared.tsopencode/src/skill/index.tsopencode/src/skill/discovery.tsopencode/src/session/system.tspackages/plugin/src/v2/effect/context.tspackages/plugin/src/v2/effect/skill.ts

一、全景:双轨插件架构

OpenCode 存在 V1 插件系统V2 插件系统两套并行架构,与 V1/V2 主循环的迁移路径一致。

bash 复制代码
                          ┌──────────────────────────────────────┐
                          │        Plugin 架构全景               │
                          └──────────────────────────────────────┘
                                         │
                    ┌────────────────────┴────────────────────┐
                    ▼                                         ▼
          ┌─────────────────┐                     ┌──────────────────────┐
          │  V1 Plugin (旧)  │                     │  V2 Plugin (新)       │
          │  opencode/src/   │                     │  core/src/plugin/     │
          │  plugin/         │                     │                      │
          └───────┬─────────┘                     └──────────┬───────────┘
                  │                                          │
    ┌─────────────┼────────────┐            ┌───────────────┼──────────────┐
    ▼             ▼            ▼            ▼               ▼              ▼
┌───────┐  ┌──────────┐  ┌─────────┐  ┌──────────┐  ┌──────────────┐  ┌─────────┐
│ index │  │  loader  │  │ shared  │  │ host.ts  │  │ internal.ts  │  │ skill   │
│ .ts   │  │  .ts     │  │  .ts    │  │ PluginHost│  │ define()+12  │  │ .ts     │
│       │  │ resolve  │  │ npm/file│  │ .make()   │  │ 内置插件      │  │ Embedded│
│ Hooks │  │ +load   │  │ source  │  │           │  │              │  │ Source  │
│ trigger│  │ external│  │         │  │           │  │              │  │         │
└───────┘  └──────────┘  └─────────┘  └──────────┘  └──────────────┘  └─────────┘
                  │                        │
                  ▼                        ▼
          ┌──────────────┐        ┌─────────────────────────────────────┐
          │ 内置 Auth    │        │       PluginHost.make(service)      │
          │ 插件 ×10     │        │  agent / aisdk / catalog / command  │
          │ (Copilot,    │        │  integration / plugin / reference   │
          │  Codex,      │        │  / skill                             │
          │  Azure...)   │        └─────────────────────────────────────┘
          └──────────────┘                        │
                                                  ▼
                                        ┌──────────────────┐
                                        │  plugin.add(id,  │
                                        │    effect)       │
                                        │  三重保护:       │
                                        │  KeyedMutex      │
                                        │  loading Set     │
                                        │  Scope.fork      │
                                        └──────────────────┘

V1 插件opencode/src/plugin/index.ts):基于 Hooks 接口,插件通过 server(input, options) 函数返回一个 Hooks 对象,包含 eventconfigdispose 等回调。通过 plugin.trigger(name, input, output) 统一触发。

V2 插件core/src/plugin.ts + core/src/plugin/):基于 Effect-TS 的 PluginContext 接口,插件通过 effect(ctx: PluginContext) => Effect<void> 函数注册 Agent / Command / Provider / Skill / Reference 等。通过 PluginHost.make(service) 提供隔离的 API。

二、PluginHost.make:隔离的扩展 API 接口

2.1 PluginContext 契约定义

插件能看到的 API 边界由 PluginContext 接口定义,位于独立的 @opencode-ai/plugin 包中:

ts 复制代码
// packages/plugin/src/v2/effect/context.ts
export interface PluginContext {
  readonly options: PluginOptions
  readonly agent: AgentHooks & Reload
  readonly aisdk: AISDKHooks
  readonly catalog: CatalogHooks & Reload
  readonly command: CommandHooks & Reload
  readonly integration: IntegrationHooks & Reload
  readonly plugin: PluginDomain
  readonly reference: ReferenceHooks & Reload
  readonly skill: SkillHooks & Reload
}

每个字段是一个 Hooks<T> 模式,提供 reload()transform(callback) 两组能力。transform 允许插件拿到一个 draft 对象,对其做读/写/删操作,然后批量提交。

2.2 PluginHost.make 的实现

ts 复制代码
// 20:219:opencode/packages/core/src/plugin/host.ts
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
  const agents = yield* AgentV2.Service
  const aisdk = yield* AISDK.Service
  const catalog = yield* Catalog.Service
  const commands = yield* CommandV2.Service
  const integration = yield* Integration.Service
  const reference = yield* Reference.Service
  const skill = yield* SkillV2.Service

  return {
    options: {},
    agent: {
      reload: agents.reload,
      transform: (callback) =>
        agents.transform((draft) =>
          callback({
            list: () => mutable(draft.list()),
            get: (id) => mutable(draft.get(AgentV2.ID.make(id))),
            default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
            update: (id, update) => draft.update(AgentV2.ID.make(id), update),
            remove: (id) => draft.remove(AgentV2.ID.make(id)),
          }),
        ),
    },
    // ... aisdk / catalog / command / integration / plugin / reference / skill
  } satisfies Interface
})

2.3 七个隔离 API 域

PluginHost.make 返回的对象提供七个隔离的 API 域,每个域封装了底层服务,用 DeepMutable 类型擦除和 ID.make() 转换做隔离边界:

API 域 提供的能力 底层服务 Draft 接口
agent reload + transform AgentV2.Service list/get/default/update/remove
aisdk sdk + language 钩子 AISDK.Service 拦截模型创建和语言选择
catalog reload + transform Catalog.Service provider 和 model 的 CRUD
command reload + transform CommandV2.Service 命令注册修改
integration reload + connection + transform Integration.Service 集成方法、OAuth 授权流
plugin add + remove PluginV2.Interface 递归插件管理
reference reload + transform Reference.Service 项目引用的增删查
skill reload + transform SkillV2.Service Skill 源的添加和列表

隔离设计要点

  1. 类型边界mutable<T>(value: T) => value as DeepMutable<T> --- 插件拿到的是可变 draft 副本,不直接持有内部不可变引用
  2. ID 转换AgentV2.ID.make(id) / ProviderV2.ID.make(id) --- 插件传 string ID,内部自动转为 branded ID 类型
  3. plugin.add/remove 递归:插件可以注册子插件,子插件共享同一 PluginHost 的隔离 API
  4. integration 域最复杂 :支持 OAuth 授权流,authorize 回调返回的 credential 会被自动包装为 Credential.OAuth.make(),同时支持 refresh 令牌刷新
ts 复制代码
// 193:196:opencode/packages/core/src/plugin/host.ts
plugin: {
  add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
  remove: (id) => plugin.remove(PluginV2.ID.make(id)),
},

插件通过 ctx.plugin.add({ id, effect }) 注册子插件,子插件的 effect 函数接收相同的 PluginContext,形成递归扩展树。

三、Plugin.add:三重保护与热重载

3.1 三重保护机制

Plugin.add 是插件加载的核心入口,通过三重保护确保并发安全、无循环依赖、生命周期可控:

ts 复制代码
// 43:83:opencode/packages/core/src/plugin.ts
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) {
  // ── 保护 1: loading Set 防循环依赖 ──
  if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)

  yield* locks.withLock(id)(                    // ── 保护 2: KeyedMutex 防并发 ──
    Effect.sync(() => {
      loading.add(id)                           // 标记正在加载
      failures.delete(id)                       // 清除旧的失败记录
    }).pipe(
      Effect.andThen(
        State.batch(
          Effect.gen(function* () {
            // ── 保护 3: Scope.fork 管理生命周期 ──
            const existing = active.get(id)
            active.delete(id)
            if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)

            const child = yield* Scope.fork(scope)
            yield* effect(host).pipe(
              Scope.provide(child),
              Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
              Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
            )
            yield* events.publish(Event.Added, { id })
            active.set(id, child)
            yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
              discard: true,
            })
            waiters.delete(id)
          }),
        ),
      ),
      Effect.onExit((exit) => {
        if (Exit.isSuccess(exit)) return Effect.void
        failures.set(id, exit)                   // 失败时记录 exit
        return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
          discard: true,
        }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
      }),
      Effect.ensuring(Effect.sync(() => loading.delete(id))),  // 清除 loading 标记
    ),
  )
})

3.2 保护 1:KeyedMutex --- 按 ID 串行化

ts 复制代码
// 20:42:opencode/packages/core/src/effect/keyed-mutex.ts
export const makeUnsafe = <Key>(): KeyedMutex<Key> => {
  const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()

  const withLock =
    (key: Key) =>
    <A, E, R>(effect: Effect.Effect<A, E, R>) =>
      Effect.suspend(() => {
        const current = locks.get(key)
        const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
        if (!current) locks.set(key, entry)
        entry.users++
        return entry.semaphore.withPermit(effect).pipe(
          Effect.ensuring(
            Effect.sync(() => {
              entry.users--
              if (entry.users === 0) locks.delete(key)  // 引用归零时删除
            }),
          ),
        )
      })

  return { size: Effect.sync(() => locks.size), withLock }
}

KeyedMutex 的关键设计

  • 同 key → 排队 :相同 plugin ID 的 add/remove/wait 操作互斥串行执行
  • 不同 key → 独立执行:不同 plugin ID 的操作可并发运行
  • 引用计数清理users 计数 holders + waiters,归零时删除 Map 条目,防止内存泄漏
  • makeUnsafe:不依赖 Effect Scope,直接创建 --- 适合在 Layer 初始化中使用

3.3 保护 2:loading Set --- 循环依赖检测

ts 复制代码
// 44:44:opencode/packages/core/src/plugin.ts
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
  • 检测时机 :在获取锁之前检查,避免死锁(如果 A 等 B 的锁,B 又开始加载 A)
  • 清除时机Effect.ensuring(Effect.sync(() => loading.delete(id))) --- 无论成功还是失败,都清除标记
  • Effect.die 而非 Effect.fail:循环依赖是程序错误(defect),不是可恢复的失败

循环依赖场景 :插件 A 的 effect(ctx) 中调用 ctx.plugin.add({ id: "A", effect }) --- 此时 A 还在 loading 中,会立即 die。

3.4 保护 3:Scope.fork --- 生命周期与热重载

ts 复制代码
// 58:65:opencode/packages/core/src/plugin.ts
const child = yield* Scope.fork(scope)
yield* effect(host).pipe(
  Scope.provide(child),
  Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
  Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
)
yield* events.publish(Event.Added, { id })
active.set(id, child)

Scope 生命周期管理

  • Scope.fork(scope) :从父 scope fork 出一个子 scope,子 scope 中的所有 Effect.acquireRelease / Effect.addFinalizer 资源都会在这个子 scope 关闭时释放
  • Scope.provide(child) :把子 scope 提供给插件的 effect(host),插件内部获取的所有资源都在这个 scope 中管理
  • Effect.onExit:插件 effect 失败时,关闭子 scope(释放所有资源);成功时不关闭(资源保持活跃)

热重载时的旧 scope 清理

ts 复制代码
// 54:56:opencode/packages/core/src/plugin.ts
const existing = active.get(id)     // 1. 取出旧 scope
active.delete(id)                    // 2. 从 active map 中移除
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)  // 3. 关闭旧 scope

热重载流程(同一个 plugin ID 再次调用 add):

bash 复制代码
plugin.add("my-plugin", newEffect)
  │
  ├── [1] loading.has("my-plugin")? → 否(上次已清除)
  │
  ├── [2] locks.withLock("my-plugin"):
  │       ├── loading.add("my-plugin")
  │       │
  │       ├── [3] existing = active.get("my-plugin")  ← 取出旧 scope
  │       ├──     active.delete("my-plugin")           ← 从 map 移除
  │       ├──     Scope.close(existing, Exit.void)     ← 关闭旧 scope
  │       │       └── 释放旧插件的所有 finalizer:
  │       │           ├── 取消事件订阅
  │       │           ├── 关闭 HTTP 连接
  │       │           ├── 清理定时器
  │       │           └── dispose 旧资源
  │       │
  │       ├── [4] child = Scope.fork(scope)  ← fork 新子 scope
  │       ├──     newEffect(host).pipe(Scope.provide(child))
  │       │       └── 新插件注册新资源到 child scope
  │       │
  │       ├── [5] events.publish(Event.Added, { id: "my-plugin" })
  │       ├──     active.set("my-plugin", child)  ← 存入新 scope
  │       │
  │       └── [6] loading.delete("my-plugin")  ← 清除 loading 标记
  │
  └── 完成:旧资源已清理,新插件已激活

关键不变量Scope.close(existing, Exit.void) 使用 Exit.void(成功退出),确保旧插件的 finalizer 以正常方式清理而非中断方式。Effect.ignore 忽略清理过程中的错误,不阻塞新插件加载。

3.5 Plugin.remove --- 卸载与清理

ts 复制代码
// 85:98:opencode/packages/core/src/plugin.ts
const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
  if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)

  yield* locks.withLock(id)(
    State.batch(
      Effect.gen(function* () {
        const current = active.get(id)
        active.delete(id)
        failures.delete(id)
        if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
      }),
    ),
  )
})

remove 的清理路径与 add 的热重载路径一致:取 scope → 删 map → 关 scope。额外清除 failures 记录,确保后续 wait 不会返回旧的失败。

四、Plugin.wait:依赖同步

4.1 问题场景

某些插件的初始化依赖另一个插件先完成。例如:插件 B 注册了 Agent "reviewer",插件 C 需要等 B 完成后才能修改 "reviewer"。Plugin.wait(id) 提供同步原语。

4.2 实现解析

ts 复制代码
// 100:126:opencode/packages/core/src/plugin.ts
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
  const waiter = yield* Deferred.make<void>()
  const pending = yield* locks.withLock(id)(
    Effect.sync(() => {
      if (active.has(id)) return false                    // 已加载,无需等待
      const failure = failures.get(id)
      if (failure) return failure                         // 之前失败了,返回失败
      const current = waiters.get(id) ?? new Set()
      current.add(waiter)                                 // 注册等待者
      waiters.set(id, current)
      return true                                         // 需要等待
    }),
  )
  if (!pending) return                                    // 已加载,直接返回
  if (typeof pending !== "boolean") return yield* pending // 之前失败,传播失败
  yield* Deferred.await(waiter).pipe(                     // 阻塞等待
    Effect.ensuring(
      locks.withLock(id)(
        Effect.sync(() => {
          const current = waiters.get(id)
          current?.delete(waiter)                         // 清理等待者
          if (current?.size === 0) waiters.delete(id)     // 空集时删除
        }),
      ),
    ),
  )
})

4.3 四种状态分支

Plugin.wait(id) 在锁内检查三种状态,返回四种行为:

状态 返回值 行为
active.has(id) false 插件已加载,无需等待,直接返回
failures.get(id) 存在 Exit.Exit<void, never> 插件之前失败,传播失败 exit
以上都不满足 true 注册 Deferred 到 waiters,阻塞等待
加载中(loading.has(id) --- wait 不会触发循环检测,因为 wait 不检查 loading

4.4 等待者通知机制

add 成功或失败时,通知所有等待者:

ts 复制代码
// 成功通知(plugin.ts:66-69)
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { discard: true })
waiters.delete(id)

// 失败通知(plugin.ts:73-78)
Effect.onExit((exit) => {
  if (Exit.isSuccess(exit)) return Effect.void
  failures.set(id, exit)
  return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
    discard: true,
  }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
})
  • 成功Deferred.succeed(waiter, undefined) --- 所有等待者收到成功信号
  • 失败Deferred.done(waiter, exit) --- 所有等待者收到与 add 相同的 Exit(失败原因传播)
  • 清理:通知完成后删除 waiters 集合,防止内存泄漏

4.5 Deferred 清理保障

ts 复制代码
// 115:125:opencode/packages/core/src/plugin.ts
yield* Deferred.await(waiter).pipe(
  Effect.ensuring(
    locks.withLock(id)(
      Effect.sync(() => {
        const current = waiters.get(id)
        current?.delete(waiter)
        if (current?.size === 0) waiters.delete(id)
      }),
    ),
  ),
)

即使 wait 被中断(如会话关闭),Effect.ensuring 也会在锁内清理等待者,防止 Deferred 泄漏。

五、内置插件启动链

5.1 PluginInternal.boot --- 12 个内置插件

ts 复制代码
// 108:123:opencode/packages/core/src/plugin/internal.ts
yield* State.batch(
  Effect.gen(function* () {
    yield* add(ConfigReferencePlugin.Plugin)    // 项目引用配置加载
    yield* add(AgentPlugin.Plugin)               // Agent 配置加载
    yield* add(CommandPlugin.Plugin)             // 命令配置加载
    yield* add(SkillPlugin.Plugin)               // 内置 skill 注入(customize-opencode)
    yield* add(ModelsDevPlugin)                  // models.dev 远程模型目录
    yield* add(ConfigAgentPlugin.Plugin)         // opencode.json 中的 agent 配置
    yield* add(ConfigCommandPlugin.Plugin)      // opencode.json 中的 command 配置
    yield* add(ConfigSkillPlugin.Plugin)        // opencode.json 中的 skill 配置
    for (const item of ProviderPlugins) yield* add(item)  // 30+ provider 插件
    yield* add(ConfigExternalPlugin.Plugin)     // 外部 npm/file 插件加载
    yield* add(ConfigProviderPlugin.Plugin)     // provider 配置覆盖
    yield* add(VariantPlugin.Plugin)            // 模型变体配置
  }),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))

5.2 define 函数与插件上下文注入

ts 复制代码
// 54:61:opencode/packages/core/src/plugin/internal.ts
export interface Plugin<R = never> {
  readonly id: string
  readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope>
}

export function define<R>(plugin: Plugin<R>) {
  return plugin
}

define 是恒等函数,仅用于类型推导。插件通过 effect(ctx) 获取 PluginContext,ctx 即 PluginHost.make 返回的隔离 API。

add 内部(第 81-106 行)通过 Effect.provideService 为插件 effect 注入全部 14 个底层服务,让插件内部可以直接 yield* Config.Service 而不需要自己管理依赖。

5.3 SkillPlugin --- 内置 Skill 注入示例

ts 复制代码
// 13:31:opencode/packages/core/src/plugin/skill.ts
export const Plugin = define({
  id: "skill",
  effect: Effect.fn(function* (ctx) {
    yield* ctx.skill.transform((draft) => {
      draft.source(
        SkillV2.EmbeddedSource.make({
          type: "embedded",
          skill: SkillV2.Info.make({
            name: "customize-opencode",
            description: "Use ONLY when the user is editing or creating opencode's own configuration...",
            location: AbsolutePath.make("/builtin/customize-opencode.md"),
            content: CustomizeOpencodeContent,
          }),
        }),
      )
    })
  }),
})

这个插件通过 ctx.skill.transform 向 Skill 系统注入一个 EmbeddedSource,内容是编译时内嵌的 customize-opencode.md。用户磁盘上的同名 skill 可以覆盖它(因为内置 skill 先注册)。

六、Skill 发现机制

6.1 双轨 Skill 系统

与 Plugin 类似,Skill 也分 V1 和 V2 两套:

维度 V1 Skill (opencode/src/skill/) V2 Skill (core/src/skill.ts)
服务 ID @opencode/Skill @opencode/v2/Skill
发现入口 discoverSkills() 扫描磁盘 load(source) 从 Source 加载
Source 类型 直接扫描目录 EmbeddedSource / DirectorySource / UrlSource
缓存策略 无缓存,每次全量扫描 Map<string, Info[]> 按 Source key 缓存
Skill 来源 .claude/skills.agents/skills、config paths、urls 由插件通过 ctx.skill.transform 注册

6.2 V1 Skill 发现 --- 四层扫描

ts 复制代码
// 173:233:opencode/packages/opencode/src/skill/index.ts
const discoverSkills = Effect.fnUntraced(function* (
  config, discovery, fsys, global,
  disableExternalSkills, disableClaudeCodeSkills,
  directory, worktree,
) {
  const state: ScanState = { matches: new Set(), dirs: new Set() }
  const externalDirs: string[] = []

  // ── 第 1 层:全局外部 Skill 目录 ──
  if (!disableExternalSkills) {
    if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR)  // ".claude"
    externalDirs.push(AGENTS_EXTERNAL_DIR)                                // ".agents"
    for (const dir of externalDirs) {
      const root = path.join(global.home, dir)
      if (!(yield* fsys.isDir(root))) continue
      yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
      // EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
    }
  }

  // ── 第 2 层:项目级外部 Skill 目录(向上查找) ──
  const upDir = yield* fsys
    .up({ targets: externalDirs, start: directory, stop: worktree })
    .pipe(Effect.catch(() => Effect.succeed([] as string[])))
  for (const root of upDir) {
    yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
  }

  // ── 第 3 层:opencode 配置目录 ──
  const configDirs = yield* config.directories()
  for (const dir of configDirs) {
    yield* scan(state, dir, OPENCODE_SKILL_PATTERN)
    // OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
  }

  // ── 第 4 层:自定义路径和远程 URL ──
  const cfg = yield* config.get()
  for (const item of cfg.skills?.paths ?? []) {
    const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
    const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
    if (!(yield* fsys.isDir(dir))) { yield* Effect.logWarning("skill path not found", { path: dir }); continue }
    yield* scan(state, dir, SKILL_PATTERN)  // SKILL_PATTERN = "**/SKILL.md"
  }
  for (const url of cfg.skills?.urls ?? []) {
    const pulledDirs = yield* discovery.pull(url)
    for (const dir of pulledDirs) {
      yield* scan(state, dir, SKILL_PATTERN)
    }
  }

  return { matches: Array.from(state.matches), dirs: Array.from(state.dirs) }
})

四层发现策略

搜索路径 Glob 模式 特性
全局外部 ~/.claude/, ~/.agents/ skills/**/SKILL.md dot 文件,可禁用 Claude Code
项目外部 findUp(.claude/, .agents/) skills/**/SKILL.md 从 cwd 向上到 worktree
opencode 配置 config.directories() {skill,skills}/**/SKILL.md opencode.json 配置目录
自定义 cfg.skills.paths + cfg.skills.urls **/SKILL.md 支持 ~/ 展开,远程 URL 拉取

6.3 SKILL.md 解析

ts 复制代码
// 105:140:opencode/packages/opencode/src/skill/index.ts
const add = Effect.fnUntraced(function* (state, match, events) {
  const md = yield* Effect.tryPromise({
    try: () => ConfigMarkdown.parse(match),    // 解析 frontmatter
    catch: (err) => err,
  }).pipe(
    Effect.catch(function* (err) {
      const message = FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}`
      const { Session } = yield* Effect.promise(() => import("@/session/session"))
      yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
      return undefined
    }),
  )
  if (!md) return
  if (!isSkillFrontmatter(md.data)) return      // 检查 { name: string, description?: string }
  if (state.skills[md.data.name]) {              // 同名 skill 覆盖(内置先注册)
    yield* Effect.logWarning("duplicate skill name", { name: md.data.name, existing: state.skills[md.data.name].location, duplicate: match })
  }
  state.dirs.add(path.dirname(match))
  state.skills[md.data.name] = { name: md.data.name, description: md.data.description, location: match, content: md.content }
})

解析流程ConfigMarkdown.parse(filepath) → 提取 frontmatter → 验证 { name: string, description?: string } → 存入 state.skills[name]。同名时后来的覆盖先注册的(内置 customize-opencode 先注册,用户磁盘同名 skill 可覆盖)。

6.4 V2 Skill 加载与缓存

ts 复制代码
// 73:119:opencode/packages/core/src/skill.ts
const load = Effect.fn("SkillV2.load")(function* (source: Source) {
  const skills: Info[] = []
  if (source.type === "embedded") return [source.skill]    // EmbeddedSource 直接返回
  const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
  for (const directory of directories) {
    const files = yield* fs
      .glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
      .pipe(Effect.catch(() => Effect.succeed([] as string[])))
    for (const filepath of files.toSorted()) {
      const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
      if (!content) continue
      const markdown = ConfigMarkdown.parseOption(content)
      if (!markdown) continue
      const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
      if (!frontmatter) continue
      const name = frontmatter.name !== undefined
        ? frontmatter.name
        : path.dirname(filepath) === directory ? path.basename(filepath, ".md") : undefined
      if (!name) continue
      skills.push({ name, description: frontmatter.description, slash: frontmatter.slash, location: AbsolutePath.make(filepath), content: markdown.content })
    }
  }
  return skills
})

const cache = new Map<string, Info[]>()
const list = Effect.fn("SkillV2.list")(function* () {
  const skills = new Map<string, Info>()
  for (const source of state.get().sources) {
    const key = Source.key(source)
    const loaded = cache.get(key) ?? (yield* load(source))  // 缓存命中则跳过磁盘扫描
    cache.set(key, loaded)
    for (const skill of loaded) skills.set(skill.name, skill)  // 同名 skill 后者覆盖前者
  }
  return Array.from(skills.values())
})

V2 的三种 Source 类型

ts 复制代码
// 15:25:opencode/packages/core/src/skill.ts
export const DirectorySource = Skill.DirectorySource   // { type: "directory", path: string }
export const UrlSource = Skill.UrlSource               // { type: "url", url: string }
export const EmbeddedSource = Skill.EmbeddedSource     // { type: "embedded", skill: Info }
export const Source = Skill.Source                     // union of three

缓存设计cache: Map<string, Info[]>Source.key(source) 缓存加载结果。Source 的 key 是序列化后的唯一标识。重复 list() 调用不会重新扫描磁盘,除非 reload() 被调用。

6.5 远程 Skill 拉取 --- 原子更新

ts 复制代码
// 99:208:opencode/packages/core/src/skill/discovery.ts
pull: Effect.fn("SkillDiscovery.pull")(function* (url) {
  const base = url.endsWith("/") ? url : `${url}/`
  const source = new URL(base)
  const index = new URL("index.json", source).href
  const data = yield* HttpClientRequest.get(index).pipe(  // 1. 拉取 index.json
    HttpClientRequest.acceptJson,
    http.execute,
    Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
    Effect.catch((error) => Effect.logError("failed to fetch skill index", { url: index, error }).pipe(Effect.as(undefined))),
  )
  if (!data) return []

  const sourceRoot = path.resolve(global.cache, "skills", Bun.hash(base).toString(16))
  return yield* Effect.forEach(data.skills, ({ skill, root, versionFile, files }) =>
    Effect.gen(function* () {
      const version = skill.version
      const current = version === undefined ? undefined
        : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))

      if (version === undefined || current === version) {
        // 无版本或版本相同:直接下载到目标路径
        yield* Effect.forEach(files, (file) => download(file.url, file.destination), { concurrency: fileConcurrency, discard: true })
      } else {
        // 版本不同:原子更新(staging → backup → swap → cleanup)
        const token = crypto.randomUUID()
        const staging = `${root}.tmp-${token}`     // 临时暂存目录
        const backup = `${root}.old-${token}`      // 旧版本备份
        yield* Effect.gen(function* () {
          const downloaded = yield* Effect.forEach(files, (file) => download(file.url, path.resolve(staging, file.file)), { concurrency: fileConcurrency })
          if (!downloaded.every(Boolean)) return    // 任何一个下载失败则放弃
          const exists = (yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) || (yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
          if (!exists) return
          yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
          yield* Effect.uninterruptible(Effect.gen(function* () {   // 不可中断的原子 swap
            const cached = yield* fs.exists(root).pipe(Effect.orDie)
            if (cached) yield* fs.rename(root, backup)              // 旧 → backup
            yield* fs.rename(staging, root).pipe(                   // staging → root
              Effect.catch((error) => Effect.gen(function* () {
                if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)  // 失败回滚
                return yield* Effect.fail(error)
              })),
            )
            if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)  // 清理 backup
          }))
        }).pipe(
          Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
          Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),  // 确保清理暂存
        )
      }
      const exists = (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || (yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
      return exists ? [AbsolutePath.make(root)] : []
    }),
    { concurrency: skillConcurrency },
  ).pipe(Effect.map((directories) => directories.flat()))
})

原子更新三步法(version 不同时):

  1. 暂存 :下载到 staging 目录,全部成功且 SKILL.md 存在
  2. swapEffect.uninterruptiblerename(root → backup) + rename(staging → root),失败时回滚
  3. 清理:删除 backup 和 staging

安全检查isSafeSegment / isSafeRelativePath):防止路径遍历攻击,确保下载文件不会逃逸出 sourceRoot

七、SkillGuidance 注入 --- 从 Skill 到 System Prompt

7.1 V1 注入路径 --- sys.skills(agent)

ts 复制代码
// 98:110:opencode/packages/opencode/src/session/system.ts
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
  if (Permission.disabled(["skill"], agent.permission).has("skill")) return

  const list = yield* skill.available(agent)   // 按 agent.permission 过滤

  return [
    "Skills provide specialized instructions and workflows for specific tasks.",
    "Use the skill tool to load a skill when a task matches its description.",
    Skill.fmt(list, { verbose: true }),         // 渲染为 <available_skills> XML
  ].join("\n")
}),

V1 组装顺序prompt.ts:1257-1258):

ts 复制代码
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
  sys.skills(agent),           // ← Skill 列表
  sys.environment(model),      // ← 环境信息
  instruction.system(),         // ← AGENTS.md/CLAUDE.md/CONTEXT.md
  sys.mcp(agent, session.permission),  // ← MCP 指令
  MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : [])]

7.2 V2 注入路径 --- SkillGuidance.load(agent) → SystemContext

ts 复制代码
// 46:68:opencode/packages/core/src/skill/guidance.ts
load: Effect.fn("SkillGuidance.load")(function* (selection) {
  const agent = selection.info
  if (!agent) return SystemContext.empty                           // 无 agent info → 空
  const permitted = SkillV2.available(yield* skills.list(), agent) // 按权限过滤
  if (permitted.length === 0 && PermissionV2.evaluate("skill", "*", agent.permissions).effect === "deny")
    return SystemContext.empty                                     // 全局禁用 skill → 空
  const available = permitted
    .flatMap((skill) => skill.description === undefined ? [] : [{ name: skill.name, description: skill.description }])
    .toSorted((a, b) => a.name.localeCompare(b.name))
  return SystemContext.make({
    key: SystemContext.Key.make("core/skill-guidance"),
    codec: Schema.toCodecJson(Schema.Array(Summary)),             // 类型化序列化
    load: Effect.succeed(available),                               // 可独立刷新
    baseline: render,                                              // 初始渲染
    update: (_previous, current) =>                                // 增量更新渲染
      ["The available skills have changed. This list supersedes the previous available skills list.", render(current)].join("\n"),
    removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
  })
})

render 函数生成 XML 格式的 skill 列表:

ts 复制代码
// 16:32:opencode/packages/core/src/skill/guidance.ts
const render = (skills: ReadonlyArray<Summary>) =>
  [
    "Skills provide specialized instructions and workflows for specific tasks.",
    "Use the skill tool to load a skill when a task matches its description.",
    ...(skills.length === 0
      ? ["No skills are currently available."]
      : [
          "<available_skills>",
          ...skills.flatMap((skill) => [
            "  <skill>",
            `    <name>${skill.name}</name>`,
            `    <description>${skill.description}</description>`,
            "  </skill>",
          ]),
          "</available_skills>",
        ]),
  ].join("\n")

7.3 V2 loadSystemContext --- 三源合并

ts 复制代码
// 168:171:opencode/packages/core/src/session/runner/llm.ts
const loadSystemContext = (agent: AgentV2.Selection) =>
  Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
    concurrency: "unbounded",
  }).pipe(Effect.map(SystemContext.combine))

三个 SystemContext 源被合并为一个:

内容 来源
systemContext.load() 环境信息 + 日期 + AGENTS.md 指令 SystemContextRegistry(builtins + InstructionContext)
skillGuidance.load(agent) 可用 skill 列表 SkillV2.Service + agent.permissions 过滤
referenceGuidance.load() 项目引用 Reference.Service

合并后的 SystemContext 通过 SessionContextEpoch.initialize/prepare 持久化到 SQLite,并生成 baseline(初始渲染)和 snapshot(可比较的结构化状态)。

7.4 V1 vs V2 Skill 注入对比

维度 V1 (sys.skills) V2 (SkillGuidance.load)
返回类型 `string undefined`
注入方式 直接拼接到 system prompt 数组 通过 SystemContext.combine 合并,经 SessionContextEpoch 持久化
刷新策略 每轮循环重新执行 Effect.all 持久化 snapshot,reconcile 增量更新
变更检测 无(每次全量渲染) schema 等价性比较,仅变更时注入 update 文本
权限过滤 Permission.disabled(["skill"], agent.permission) PermissionV2.evaluate("skill", name, agent.permissions)
渲染格式 verbose(含 location) 精简(仅 name + description)

八、SystemContextRegistry --- 类型化上下文源

8.1 核心思想

SystemContextRegistry 把系统上下文从"固定字符串"提升为"可独立刷新、可比较的类型化源"。

8.2 Source<A> --- 类型化源接口

ts 复制代码
// 32:39:opencode/packages/core/src/system-context/index.ts
export interface Source<A> {
  readonly key: Key                                                     // 命名空间标识
  readonly codec: Schema.Codec<A, Schema.Json, never, never>            // 类型安全序列化
  readonly load: Effect.Effect<A | Unavailable>                        // 观察当前值
  readonly baseline: (current: A) => string                             // 初始渲染
  readonly update: (previous: A, current: A) => string                  // 差异渲染
  readonly removed?: (previous: A) => string                            // 移除渲染
}

五个维度

  1. key: Key --- 命名空间标识,格式为 namespace/name(如 core/environment),全局唯一
  2. codec: Schema.Codec<A> --- 类型安全序列化器,用于将值编码为 JSON(持久化)和从 JSON 解码(恢复比较)
  3. load: Effect<A | Unavailable> --- 观察当前值。返回 unavailable 表示暂时不可用(不同于被移除)
  4. baseline: (current: A) => string --- 初始渲染,生成模型可见的基线文本
  5. update: (previous, current) => string --- 差异渲染,当值变化时生成更新文本
  6. removed?: (previous: A) => string --- 移除渲染,当源不再存在时生成移除通知(可选)

8.3 SystemContext.make --- 类型擦除与打包

ts 复制代码
// 135:173:opencode/packages/core/src/system-context/index.ts
export function make<A>(source: Source<A>): SystemContext {
  const decode = Schema.decodeUnknownOption(source.codec)    // 从 JSON 解码
  const encode = Schema.encodeSync(source.codec)              // 编码为 JSON
  const equivalent = Schema.toEquivalence(source.codec)       // schema 等价性比较

  return context([{
    key: source.key,
    load: source.load.pipe(
      Effect.map((value) => {
        if (isUnavailable(value)) return value                 // 传递 unavailable
        const snapshot = (): SourceSnapshot => ({
          value: encode(value),                               // 编码为 JSON 持久化
          ...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
        })
        return {
          baseline: (): Rendered => ({
            text: requireText(source.key, "baseline", source.baseline(value)),
            snapshot: snapshot(),
          }),
          compare: (previous): Compared =>                    // 与持久化的 snapshot 比较
            Option.match(decode(previous), {                  // 尝试解码 previous JSON
              onNone: (): Compared => ({ _tag: "Incompatible" }),  // 解码失败 → 不兼容
              onSome: (decoded): Compared =>
                equivalent(decoded, value)                     // schema 等价性比较
                  ? { _tag: "Unchanged" }                       // 等价 → 无变化
                  : {
                      _tag: "Updated",                         // 不等价 → 已更新
                      render: () => ({
                        text: requireText(source.key, "update", source.update(decoded, value)),
                        snapshot: snapshot(),
                      }),
                    },
            }),
        }
      }),
    ),
  }])
}

类型擦除模式

  • make<A>(source: Source<A>) 接收具体类型 A 的 source
  • 内部通过 encode/decode/equivalentA 编码为 Schema.Json(通用类型)
  • 返回的 SystemContext 携带 PackedSource(不包含 A 的类型信息)
  • 不同类型 A 的 source 打包后可以统一组合

8.4 combine --- 多源组合

ts 复制代码
// 176:180:opencode/packages/core/src/system-context/index.ts
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
  const sources = values.flatMap((value) => value[ContextTypeId])
  assertUniqueKeys(sources)                                    // 拒绝重复 key
  return context(sources)
}

combine 把多个 SystemContextPackedSource 数组拼接为一个,合并前检查 key 唯一性。

8.5 三大操作:initialize / reconcile / replace

initialize --- 首次初始化
ts 复制代码
// 198:215:opencode/packages/core/src/system-context/index.ts
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
  return observe(value).pipe(
    Effect.flatMap((entries) => {
      const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
      if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })  // 有源不可用 → 阻塞
      return Effect.succeed(initializeObservation(entries))
    }),
  )
}
  • 所有源可用 → 生成 Generation { baseline: string, snapshot: Snapshot }
  • 任何源返回 unavailable → 返回 InitializationBlocked(不生成不完整的 baseline)
reconcile --- 增量更新
ts 复制代码
// 218:280:opencode/packages/core/src/system-context/index.ts
export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect<ReconcileResult> {
  return observe(value).pipe(
    Effect.map((entries): ReconcileResult => {
      const result = reconcileObservation(entries, previous)
      if (result._tag === "Unchanged" || result._tag === "Updated") return result
      return replaceObservation(entries, previous)  // 不兼容时降级为 replace
    }),
  )
}

reconcileObservation 的核心逻辑:

  1. 逐源比较 :每个源用 compare(previous.value) 与持久化 snapshot 比较
  2. 三种结果Incompatible(schema 不兼容 → 降级 replace)、Unchanged(等价)、Updated(不等价 → 渲染更新文本)
  3. 移除检测 :如果 previous 中有源不在当前 entries 中,且有 removed 渲染 → 生成移除文本;否则降级 replace
  4. unavailable 处理:unavailable 源保留旧 snapshot(不更新也不移除)
  5. 汇总 :有更新文本 → Updated { text, snapshot };无更新 → Unchanged
replace --- 完整替换
ts 复制代码
// 283:291:opencode/packages/core/src/system-context/index.ts
export function replace(value: SystemContext, previous: Snapshot): Effect.Effect<ReplacementResult> {
  return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous)))
}

function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
  if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
    return { _tag: "ReplacementBlocked" }     // 之前已接纳的源现在不可用 → 阻塞
  return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
}
  • 之前已接纳的源现在不可用 → ReplacementBlocked(保留旧 baseline,不生成不完整的新 baseline)
  • 否则 → ReplacementReady(生成全新的 baseline + snapshot)

8.6 Registry --- 注册与加载

ts 复制代码
// 12:15:opencode/packages/core/src/system-context/registry.ts
export interface Entry {
  readonly key: SystemContext.Key
  readonly load: Effect.Effect<SystemContext.SystemContext>
}

export interface Interface {
  readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
  readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
ts 复制代码
// 25:44:opencode/packages/core/src/system-context/registry.ts
return Service.of({
  register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
    yield* Effect.acquireRelease(
      Ref.modify(entries, (current) => {                          // 原子添加
        if (current.some((item) => item.key === entry.key)) return [false, current]
        return [true, [...current, entry]]
      }).pipe(
        Effect.flatMap((added) =>
          added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
        ),
        Effect.as(entry),
      ),
      (entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),  // scope 关闭时自动移除
    )
  }),
  load: Effect.fn("SystemContextRegistry.load")(function* () {
    const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))  // 按 key 排序
    return SystemContext.combine(
      yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),  // 并发加载所有源
    )
  }),
})

Registry 关键设计

  • Effect.acquireRelease:注册时获取,scope 关闭时自动移除 --- 源的生命周期与注册时的 scope 绑定
  • 重复 key 检测Effect.die 而非 Effect.fail --- 重复 key 是程序错误
  • 按 key 排序load() 时按 key 字母序排序,保证 combine 的拼接顺序确定性
  • 并发加载concurrency: "unbounded" --- 所有注册的源并发加载,互不阻塞

8.7 内置 SystemContext 源

builtins --- 环境与日期
ts 复制代码
// 12:44:opencode/packages/core/src/system-context/builtins.ts
const builtIns = Layer.effectDiscard(
  Effect.gen(function* () {
    const location = yield* Location.Service
    const registry = yield* SystemContextRegistry.Service
    const environment = [...]  // Working directory, Workspace root, Platform 等
    const context = SystemContext.combine([
      SystemContext.make({
        key: SystemContext.Key.make("core/environment"),
        codec: Schema.toCodecJson(Schema.String),
        load: Effect.succeed(environment),
        baseline: (env) => ["Here is some useful information about the environment you are running in:", env].join("\n"),
        update: (_prev, env) => ["The environment you are running in is now:", env].join("\n"),
      }),
      SystemContext.make({
        key: SystemContext.Key.make("core/date"),
        codec: Schema.toCodecJson(Schema.String),
        load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
        baseline: (date) => `Today's date: ${date}`,
        update: (_prev, date) => `Today's date is now: ${date}`,
      }),
    ])
    yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
  }),
)
InstructionContext --- AGENTS.md 发现
ts 复制代码
// 22:91:opencode/packages/core/src/instruction-context.ts
const layer = Layer.effectDiscard(
  Effect.gen(function* () {
    const fs = yield* FSUtil.Service
    const global = yield* Global.Service
    const location = yield* Location.Service
    const registry = yield* SystemContextRegistry.Service

    const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
      SystemContext.make({
        key: SystemContext.Key.make("core/instructions"),
        codec: Schema.toCodecJson(Files),
        load: Effect.succeed(value),
        baseline: render,                                              // "Instructions from: <path>\n<content>"
        update: (_prev, current) => `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
        removed: () => "Previously loaded instructions no longer apply.",
      })

    const observe = Effect.fn("InstructionContext.observe")(function* () {
      const start = yield* fs.resolve(location.directory)             // 当前工作目录
      const stop = yield* fs.resolve(location.project.directory)      // 项目根目录
      const discovered = new Set(
        yield* Effect.forEach(
          fs.up({ targets: ["AGENTS.md"], start, stop }),             // 向上查找 AGENTS.md
          fs.resolve,
        ),
      )
      const paths = Array.dedupe([
        yield* fs.resolve(join(global.config, "AGENTS.md")),          // 全局 AGENTS.md
        ...discovered,                                                // 项目级 AGENTS.md
      ])
      const files = yield* Effect.forEach(paths, (path) => fs.readFileStringSafe(path)... )
      if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
        return SystemContext.unavailable                              // 项目级文件读取失败 → unavailable
      return files.filter((file): file is File => file !== undefined)
    })

    yield* registry.register({
      key: SystemContext.Key.make("core/instructions"),
      load: observe().pipe(Effect.map((files) => ...).pipe(
        Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),      // 观察错误 → unavailable
        Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
      )),
    })
  }),
)

AGENTS.md 发现策略

  1. 全局~/.config/opencode/AGENTS.md
  2. 项目级 :从 location.directory 向上到 location.project.directory 查找 AGENTS.md不叠加祖先目录
  3. unavailable 语义 :项目级文件读取失败 → 返回 unavailable(保留旧 snapshot,不降级为空)

8.8 SessionContextEpoch --- 持久化与增量更新

ts 复制代码
// 40:78:opencode/packages/core/src/session/context-epoch.ts
const prepareOnce = Effect.fnUntraced(function* (db, events, context, sessionID) {
  const [value, stored, compaction] = yield* Effect.all(
    [context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
    { concurrency: "unbounded" },
  )
  if (!stored) {                                                      // 首次:initialize
    const generation = yield* SystemContext.initialize(value)
    const baselineSeq = yield* insert(db, sessionID, generation)      // 持久化 baseline + snapshot
    return { baseline: generation.baseline, baselineSeq }
  }

  const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot)
    .pipe(Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })))
  const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
  const result = replacementSeq
    ? yield* SystemContext.replace(value, snapshot)                   // compaction 后:replace
    : yield* SystemContext.reconcile(value, snapshot)                // 正常:reconcile

  if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
    return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }  // 无变化或阻塞 → 旧 baseline
  }
  if (result._tag === "ReplacementReady") {
    const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
    yield* replace(db, sessionID, baselineSeq, result.generation)     // 持久化新 baseline + snapshot
    return { baseline: result.generation.baseline, baselineSeq }
  }

  // result._tag === "Updated"
  yield* events.publish(
    SessionEvent.ContextUpdated,                                     // 发布上下文更新事件
    { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
    { commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },  // 事务性提交
  )
  return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }  // baseline 不变,只更新 snapshot
})

三种路径

场景 操作 baseline 变化 snapshot 变化
首次 initialize 新建 新建
compaction 后 replace 替换 替换
正常 reconcile · Unchanged --- 不变 不变
正常 reconcile · Updated 发布 ContextUpdated 事件 不变 更新
正常 reconcile · ReplacementReady replace 替换 替换
正常 reconcile · ReplacementBlocked --- 不变 不变

关键设计Updated 路径中 baseline 不变(模型看到的系统 prompt 基线不变),但 snapshot 更新(下次 reconcile 的比较基准更新)。result.text 作为 ContextUpdated 事件发布,会被注入到对话历史中作为增量更新通知。

九、V1 Plugin 系统 --- Hooks 与 trigger

9.1 V1 插件架构

ts 复制代码
// 44:56:opencode/packages/opencode/src/plugin/index.ts
export interface Interface {
  readonly trigger: <Name extends TriggerName, Input, Output>(
    name: Name, input: Input, output: Output,
  ) => Effect.Effect<Output>
  readonly list: () => Effect.Effect<Hooks[]>
  readonly init: () => Effect.Effect<void>
}

V1 插件通过 Hooks 接口注册回调,trigger(name, input, output) 按注册顺序调用所有 hook 的对应方法:

ts 复制代码
// 280:293:opencode/packages/opencode/src/plugin/index.ts
const trigger = Effect.fn("Plugin.trigger")(function* (name, input, output) {
  if (!name) return output
  const s = yield* InstanceState.get(state)
  for (const hook of s.hooks) {
    const fn = hook[name] as any
    if (!fn) continue
    yield* Effect.promise(async () => fn(input, output))    // 串行执行
  }
  return output
})

9.2 V1 插件加载流程

ts 复制代码
// 123:238:opencode/packages/opencode/src/plugin/index.ts
const layer = Layer.effect(Service, Effect.gen(function* () {
  // ...
  const state = yield* InstanceState.make<State>(
    Effect.fn("Plugin.state")(function* (ctx) {
      const hooks: Hooks[] = []
      const bridge = yield* EffectBridge.make()
      const { Server } = yield* Effect.promise(() => import("../server/server"))
      const client = createOpencodeClient({ ... })             // SDK 客户端
      const cfg = yield* config.get()
      const input: PluginInput = { client, project, worktree, directory, experimental_workspace, serverUrl, $: Bun.$ }

      // 1. 内置 Auth 插件(10 个)
      for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
        const init = yield* Effect.tryPromise({ try: () => plugin(input), catch: errorMessage })
          .pipe(Effect.tapError((error) => Effect.logError("failed to load internal plugin", { error })), Effect.option)
        if (init._tag === "Some") hooks.push(init.value)
      }

      // 2. 外部插件(npm/file)
      const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
      if (plugins.length) yield* config.waitForDependencies()

      const loaded = yield* Effect.promise(() =>
        PluginLoader.loadExternal({ items: plugins, kind: "server", report: { ... } }),
      )
      for (const load of loaded) {
        yield* Effect.tryPromise({ try: () => applyPlugin(load, input, hooks), catch: ... })
          .pipe(Effect.tapError((error) => Effect.logError("failed to load plugin", { path: load.spec, error })), Effect.catch(() => Effect.void))
      }

      // 3. 通知所有插件当前配置
      for (const hook of hooks) {
        yield* Effect.tryPromise({ try: () => Promise.resolve(hook.config?.(cfg)), catch: errorMessage })
          .pipe(Effect.tapError((error) => Effect.logError("plugin config hook failed", { error })), Effect.ignore)
      }

      // 4. 注册事件监听 + dispose finalizer
      const unsubscribe = yield* events.listen((event) => { ... })
      yield* Effect.addFinalizer(() => unsubscribe)
      yield* Effect.addFinalizer(() => Effect.forEach(hooks, (hook) => Effect.tryPromise({ try: () => Promise.resolve(hook.dispose?.()), catch: errorMessage }).pipe(Effect.ignore)), { discard: true }))

      return { hooks }
    }),
  )
  // ...
}))

9.3 PluginLoader --- 解析与加载管线

ts 复制代码
// 86:133:opencode/packages/opencode/src/plugin/loader.ts
export async function resolve(plan: Plan, kind: PluginKind): Promise<...> {
  // Stage 1: install --- 解析插件目标路径
  let target = ""
  try { target = await resolvePluginTarget(plan.spec) }              // npm install 或 file path resolve
  catch (error) { return { ok: false, stage: "install", error } }

  // Stage 2: entry --- 检查入口点
  let base
  try { base = await createPluginEntry(plan.spec, target, kind) }     // 读取 package.json exports
  catch (error) { return { ok: false, stage: "entry", error } }
  if (!base.entry) return { ok: false, stage: "missing", value: { ...plan, message: `... does not expose a ${kind} entrypoint` } }

  // Stage 3: compatibility --- 版本兼容检查
  if (base.source === "npm") {
    try { await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) }
    catch (error) { return { ok: false, stage: "compatibility", error } }
  }

  return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}

// 136:145:opencode/packages/opencode/src/plugin/loader.ts
export async function load(row: Resolved): Promise<...> {
  let mod
  try { mod = await import(row.entry) }                               // Stage 4: dynamic import
  catch (error) { return { ok: false, error } }
  if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
  return { ok: true, value: { ...row, mod } }
}

四阶段管线

阶段 操作 失败处理
install resolvePluginTarget(spec) --- npm 安装或文件路径解析 可重试(仅 file 插件,"missing package.json or index file")
entry createPluginEntry(spec, target, kind) --- 读取 package.json exports 不可重试
compatibility checkPluginCompatibility(target, version, pkg) --- semver 检查 不可重试
load import(row.entry) --- 动态导入模块 不可重试(Bun 缓存失败的动态导入)

9.4 PluginSource --- npm vs file

ts 复制代码
// 36:37:opencode/packages/opencode/src/plugin/shared.ts
export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"

// 56:59
export function pluginSource(spec: string): PluginSource {
  if (isPathPluginSpec(spec)) return "file"   // file://, ./, /path/to
  return "npm"                                 // npm package name
}

两种来源的差异

来源 解析方式 兼容性检查 重试策略
file resolvePathPluginTarget --- 直接解析路径 跳过(视为本地开发代码) install 阶段失败可重试一次
npm Npm.add(pkg@version) --- npm 安装 semver.satisfies(version, range) 不可重试

十、完整交互流程图

bash 复制代码
                    ┌─────────────────────────────────────────┐
                    │          启动时 Plugin 初始化链          │
                    └─────────────────────────────────────────┘
                                    │
         ┌──────────────────────────┼──────────────────────────┐
         ▼                          ▼                          ▼
   V1 Plugin Layer            V2 Plugin Layer           SystemContext
   (opencode/src/plugin)      (core/src/plugin)         Registry
         │                          │                          │
         ├─ internalPlugins()       ├─ PluginInternal.boot()   ├─ builtins.register()
         │  (10 Auth 插件)          │  (12 内置插件)            │  (environment + date)
         │                          │  ┌────────────┐           │
         ├─ PluginLoader            │  │ add(12次)  │           ├─ InstructionContext
         │  .loadExternal()         │  │   │        │           │  .register()
         │  ├─ resolve(install)     │  │   ├─ loading.has? → die│  (AGENTS.md 发现)
         │  ├─ resolve(entry)       │  │   ├─ KeyedMutex.lock  │
         │  ├─ resolve(compat)      │  │   ├─ Scope.close(旧)  │ ├─ SkillGuidance
         │  └─ load(import)         │  │   ├─ Scope.fork(新)  │ │  (V2 runner 动态加载)
         │                          │  │   ├─ effect(host)    │
         ├─ applyPlugin(hooks)      │  │   ├─ active.set(新)  │
         │  └─ hooks.push()         │  │   └─ waiters.notify()│
         │                          │  └────────────┘           │
         ├─ hook.config?(cfg)       │                          │
         ├─ events.listen()         │ PluginHost.make(service) │
         └─ addFinalizer(dispose)   │  → PluginContext:        │
                                    │    agent/aisdk/catalog/  │
                                    │    command/integration/  │
                                    │    plugin/reference/skill│
                                    └──────────────────────────┘
                                                                │
                    ┌─────────────────────────────────────────┘
                    │     V2 Runner 每轮 SystemContext 加载
                    └─────────────────────────────────────────┘
                                    │
                                    ▼
                    ┌───────────────────────────────────────┐
                    │ loadSystemContext(agent)              │
                    │   Effect.all([                        │
                    │     systemContext.load(),  ← Registry │
                    │     skillGuidance.load(agent),         │
                    │     referenceGuidance.load(),         │
                    │   ], concurrency: "unbounded")        │
                    │   → SystemContext.combine()           │
                    └──────────────────┬────────────────────┘
                                       │
                    ┌──────────────────┼──────────────────┐
                    ▼                  ▼                  ▼
              首次运行            compaction后          正常轮次
              initialize          replace              reconcile
              │                   │                   │
              ├─ Generation       ├─ ReplacementReady  ├─ Unchanged → 旧 baseline
              │  baseline         │  新 baseline       ├─ Updated → 旧 baseline + 新 snapshot
              │  + snapshot       │  + snapshot        │            + ContextUpdated 事件
              └─ insert DB        └─ replace DB       ├─ ReplacementReady → 新 baseline
                                                       └─ ReplacementBlocked → 旧 baseline

十一、关键设计决策

1. "PluginHost 作为隔离边界而非共享全局"

PluginHost.make 返回的 PluginContext 是插件能看到的唯一 API。插件不直接访问 AgentV2.Service 等内部服务,而是通过 ctx.agent.transform(draft => ...) 拿到可变 draft。DeepMutable 类型擦除和 ID.make() 转换构成了隔离边界。这防止了插件直接修改内部不可变状态,所有修改都通过 State.Transformable 的 batch 提交机制原子化。

2. "Scope.fork 作为插件生命周期单元"

每个活跃插件拥有一个 Scope.Closeable 子 scope。热重载时旧 scope 被关闭(释放所有 finalizer),新 scope 被 fork。这种设计让插件资源管理完全自动化------插件内部使用 Effect.acquireRelease / Effect.addFinalizer 注册的资源,会在插件被 remove 或热重载时自动清理,无需插件作者手动管理。

3. "loading Set 在锁外检查防死锁"

loading.has(id) 检查在 locks.withLock(id) 之前 执行。如果检查在锁内,插件 A 调用 ctx.plugin.add("A", ...) 会死锁(A 持有锁,又要等锁)。在锁外检查,A 会立即 Effect.die 而非死锁。这是"快速失败优于等待"的设计原则。

4. "Plugin.wait 用 Deferred 而非轮询"

wait(id) 创建 Deferred<void> 注册到 waiters map,然后 Deferred.await 阻塞。当 add 完成时,通过 Deferred.succeed / Deferred.done 通知所有等待者。这比轮询 active.has(id) 更高效,且能传播失败 exit。

5. "SystemContext 从固定字符串到类型化源"

传统系统 prompt 是固定字符串拼接。SystemContext 把每个上下文片段建模为 Source<A>,携带 schema codec 实现类型安全的序列化和等价性比较。initialize / reconcile / replace 三操作基于 schema 等价性实现增量更新:只有变化的源生成更新文本,未变化的源保留旧 snapshot。unavailable 与"移除"的区分让临时性观察失败不会破坏已接纳的上下文。

6. "Skill 发现的层级覆盖策略"

V1 的四层发现(全局外部 → 项目外部 → opencode 配置 → 自定义路径)配合"后注册覆盖先注册"策略,让用户能精确控制 skill 来源。内置 customize-opencode skill 先注册,用户磁盘同名 skill 可覆盖它。V2 的 EmbeddedSource / DirectorySource / UrlSource 三种类型让插件能灵活注入 skill,Source.key() 缓存避免重复磁盘扫描。

7. "远程 Skill 的原子更新"

SkillDiscovery.pull 的版本不同路径使用 staging → backup → swap 三步原子更新,在 Effect.uninterruptible 内执行 swap 操作。任何一步失败都会回滚(rename(backup, root)),确保 skill 目录始终处于一致状态。下载到 staging 目录而非直接下载到目标路径,避免半完成状态。

相关推荐
阿虎儿1 小时前
Dify v1.15.0 Chatflow附件上传存储机制调研报告
人工智能
zhiSiBuYu05171 小时前
RAG 性能优化与缓存策略实战指南
人工智能·python·机器学习
大鱼>1 小时前
智能喂食器远程控制系统:硬件设计与软件实现
人工智能·iot·宠物
苏州邦恩精密1 小时前
浙江蔡司3D扫描仪应用领域及厂家选择分析
人工智能·科技·3d·自动化·制造
想会飞的蒲公英1 小时前
集成学习入门:Bagging、Boosting 到底在组合什么
人工智能·python·机器学习·集成学习·boosting
海外数字观察家1 小时前
品未云:博兹瓦纳华商批发零售一体化 ERP,破解本土软件适配难题|CSDN 技术分享
大数据·网络·人工智能·博兹瓦纳进销存系统·博兹瓦纳收银系统·博兹瓦纳erp系统·博兹瓦纳仓库管理系统
_老码1 小时前
AI-reader阅读助手开发过程
人工智能·python·ai
weixin_422329312 小时前
AgentScope Java ReActAgent 核心循环深度解析
java·开发语言·人工智能
SNKXD_12 小时前
2026年五款AI数字人系统效果差异:基于多维度横评的实证比较
大数据·人工智能