OpenCode 插件系统与数据库架构深度解析

七月 17, 2026 #opencode #typescript #effect #sqlite #ai-agent #plugin

OpenCode 插件系统与数据库架构深度解析

本文以 opencode 源码为参照,详细分析 third/ 目录下两个第三方插件的生效逻辑、插件的 Hook 分发机制,以及运行时数据库的读写模式。

一、插件加载机制

openCode 的插件系统入口位于 packages/opencode/src/plugin/index.ts,加载流程如下:

1. 配置解析与安装

用户在 opencode.json 中声明插件:

{
  "plugin": [
    "@devtheops/opencode-plugin-otel",
    "superpowers@git+https://github.com/obra/superpowers.git"
  ]
}

PluginLoader.loadExternal() 解析每个 specifier,通过 resolvePluginTarget() 安装 npm 包(或从 git URL 拉取),定位入口文件。

2. 入口提取

模块加载后,applyPlugin 按两种模式识别插件:

3. Hook 注册

所有插件的 Plugin(input) 被调用后返回 Hooks 对象,按顺序存入内部数组。分发方式有三种:

类型触发点示例
config 生命周期 Hook初始化时按顺序调用config(cfg)
event 事件 Hook通过 EventV2Bridge 订阅所有事件event({ event })
Trigger Hook在 opencode 特定执行点通过 Plugin.trigger() 调用chat.messageexperimental.chat.system.transform

Trigger Hook 的核心逻辑是一个简单的遍历(index.ts:302-315):

const trigger = Effect.fn("Plugin.trigger")(function* (name, input, output) {
  for (const hook of s.hooks) {
    const fn = hook[name]
    if (!fn) continue
    yield* Effect.promise(async () => fn(input, output))
  }
  return output
})

执行顺序:内置插件(CodexAuth、CopilotAuth 等)先 push,外部插件按 opencode.json 声明顺序依次 push,之后所有 Hook 点按此顺序遍历。没有优先级或排序机制。


二、superpowers 插件的生效逻辑

文件third/superpowers/.opencode/plugins/superpowers.js

这是一个纯 JavaScript 的单文件插件(107 行),通过两个 Hook 注入技能系统:

Hook 1:config(注册 skills 路径)

config: async (config) => {
  config.skills.paths.push(superpowersSkillsDir)
}

初始化时向 opencode 的 config.skills.paths 注入自己的 skills 目录(<installed-dir>/skills/),让 opencode 启动后自动发现所有 14 个技能(brainstorming、systematic-debugging、test-driven-development 等),无需用户手动配置 symlink。

Hook 2:experimental.chat.system.transform(注入系统引导)

'experimental.chat.system.transform': async (_input, output) => {
  output.system.push(getBootstrapContent())
}

每次构造 LLM 的 system prompt 时,读取 using-superpowers 技能的 SKILL.md 内容(去 frontmatter),包裹在 <EXTREMELY_IMPORTANT> 标签中追加到 system prompt 数组。这使 AI agent 在每次对话中都被强制要求在行动前检查并调用相关 skill。

这个 Hook 在两处被触发(agent/agent.ts:381session/llm/request.ts:70),因此不止执行一次。但由于 output.system 每次都是调用方新创建的局部数组,不存在重复追加问题。


三、opencode-plugin-otel 插件的生效逻辑

文件third/opencode-plugin-otel/src/index.ts

这是一个 TypeScript 插件(349 行),将 session 遥测数据通过 OpenTelemetry 协议(OTLP over gRPC/HTTP)导出。所有遥测由 OPENCODE_ENABLE_TELEMETRY 环境变量控制。

核心架构

插件函数体内惰性初始化 OTel SDK(MeterProvider、LoggerProvider、TracerProvider),然后返回三个 Hook:

config Hook

简单调整日志级别。

chat.message Hook

每次用户发送消息时触发(session/prompt.ts),启动 session span(如果尚未存在),记录 user_prompt 日志事件,并初始化 token/cost 计数器。

event Hook

订阅所有 opencode 事件,按类型分发给不同 hander:

事件Handler行为
session.createdhandleSessionCreated创建 session span
session.idlehandleSessionIdle关闭 span,flush 遥测
session.errorhandleSessionError记录错误,flush
session.diffhandleSessionDiff统计代码行数增删
command.executedhandleCommandExecuted检测 git commit
message.updatedhandleMessageUpdated累加 token/cost 计数
message.part.updatedhandleMessagePartUpdated处理工具调用 span 和指标
permission.updated/replied对应 handler跟踪权限请求

注册 SIGTERM/SIGINT/beforeExit 信号实现优雅 shutdown。

遥测无法跨插件监控

当前架构下,一个插件无法以通用方式监控另一个插件 Hook 的执行。因为 hooks 数组是内部闭包的局部变量,Plugin.trigger() 不产生任何可监听的事件——没有优先级、没有 lifecycle hook。只能通过外部 OTLP 后端间接观察 otel 插件自身的指标,或修改核心代码增加 Hook 生命周期事件。


四、LLM 消息的构造流程

这是理解系统提示词注入和数据库读写模式的关键。每次 LLM 调用时:

构造链路(prompt.tsllm.tsrequest.ts

1. msgs = 从 session_message 表取出全部消息(从最近 compaction 点起)
2. system = [...env, ...instructions, ...mcpInstructions, ...skills]
           └── 每轮重新构造,包含:
               · 当前模型名称、工作目录、日期(new Date().toDateString())
               · 项目 references 列表
               · agent 可用的 skills 列表
               · MCP server 的指令
3. modelMsgs = MessageV2.toModelMessagesEffect(msgs, model)
4. → Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
5. → handle.process({ system, messages: modelMsgs })
6. LLMRequestPrep.prepare() 中:
   · system 数组 join 成 header 字符串
   · Plugin.trigger("experimental.chat.system.transform", ..., { system })
   · messages = [{ role: "system", content: x }, ...] + modelMsgs
   · 最终传给 LLM

experimental.chat.system.transform插件修改 system prompt 的唯一入口。system prompt 不在 session 持久化的历史中——它是每轮动态构造的局部变量。

对 LLM 缓存的影响

由于 system prompt 每轮重新构造,include 的内容可能变化(如 Today's date 跨天变化、references 增删、skills 列表变化),LLM 服务端的 prompt caching(如 Anthropic 的 cache control)无法获得稳定的前缀命中。opencode 选择了新鲜度优先的设计原则。


五、数据库架构与运行时操作

引擎与配置

8 张核心表

session                              event_sequence
├── id (PK)                          ├── aggregate_id (PK, FK → session.id)
├── project_id (FK)                  ├── seq
├── title, slug, directory           └── owner_id
├── cost, tokens_*  (实时累加)
├── permission, model, agent         event
├── time_compacting                  ├── id (PK)
└── time_archived (从未写入)         ├── aggregate_id (FK)
                                     ├── seq
session_message                      ├── type
├── id (PK)                          └── data (JSON)
├── session_id (FK, cascade delete)
├── type (user/assistant/shell/      message (V1 遗留)     part (V1 遗留)
│         system/compaction/         (session 级)          (message 级)
│         synthetic/agent-switched/
│         model-switched)
├── seq (unique per session)         session_input          todo
├── data (JSON)                      ├── id (PK)           ├── session_id+position (PK)
└── time_created                     ├── session_id (FK)   ├── content, status
                                     ├── prompt (JSON)     └── priority
session_context_epoch                ├── delivery
├── session_id (PK, FK)              ├── admitted_seq
├── baseline                         └── promoted_seq
├── snapshot (JSON)
└── baseline_seq

核心读写流程

读操作(每次 LLM 请求前)

-- 1. 找到最新 compaction 点
SELECT seq FROM session_message
WHERE session_id = ? AND type = 'compaction'
ORDER BY seq DESC LIMIT 1

-- 2. 从该点加载全量消息
SELECT * FROM session_message
WHERE session_id = ? AND seq >= ?
ORDER BY seq ASC

-- 3. 加载系统上下文基线
SELECT * FROM session_context_epoch WHERE session_id = ?

写操作(每个事件触发级联写入)

用户输入 → publish(session.next.prompted):
  ├── INSERT event (持久化事件)
  └── SessionProjector:
        ├── INSERT session_message (type="user", seq=N)
        └── UPSERT session_input (delivery="steer", admitted_seq=N)

step started → INSERT session_message (type="assistant")  ← 新建一行
text delta   → 纯内存操作,不写 DB (delta 太多)
text ended   → UPDATE session_message (合并完整文本)
tool called  → UPDATE session_message (更新 tool state)
tool success → UPDATE session_message (写入 result)
step ended   → UPDATE session_message (补 finish, cost, tokens)
  └── 同时 UPDATE session SET cost += ?, tokens_* += ?

compaction   → INSERT session_message (type="compaction", seq=P)
  └── 不删除旧行,后续读取只从 seq >= P 处开始

关键点:同一轮 tool-calling 循环中,一个 assistant 消息只占一行 session_message,后续事件都是 UPDATE 同一行而非 INSERT 新行。

数据库膨胀

增长模式清理方式
session_message每用户输入、每个事件 INSERT 或 UPDATE 一行手动删 session / revert
event每个事件 INSERT 一行,永不更新cascade 删 session
event_sequence每个事件 UPDATE seqcascade

当前没有自动清理机制

唯一删除途径:手动删除 session(DELETE FROM session WHERE id = ?,级联清除所有子表)或执行 revert(删除指定 seq 之后的 session_messagesession_input 行)。


六、Hook 与 Event 完全参考

Hook 类型定义在 packages/plugin/src/index.ts:222-335,Event 定义分散在 packages/schema/src/ 下的多个文件中。

6.1 全部 Hook(21 个)

生命周期 Hook(非 trigger 模式,6 个)

这些 Hook 不通过 Plugin.trigger() 调用,而是由 opencode 核心在特定时机直接调用。

#Hook 名入参返回值作用
1disposePromise<void>插件卸载时调用,用于清理资源
2event{ event: { id, type, properties } }Promise<void>接收每个系统事件——通过 EventV2Bridge 订阅所有事件广播。这是插件感知系统状态的核心入口
3configConfig(完整配置对象)Promise<void>初始化时及配置变更时调用。插件在此注册 skills 路径、调整日志级别、注入自定义配置
4tool—(静态属性){ [key: string]: ToolDefinition }注册自定义工具供 agent 调用
5auth—(静态属性)AuthHook注册自定义认证逻辑(如 OAuth)
6provider—(静态属性)ProviderHook注册自定义模型 Provider

Trigger Hook((input, output) → void 模式,15 个)

这些 Hook 在特定执行点通过 Plugin.trigger(name, input, output) 调用,插件通过修改 output 对象来影响后续流程。

消息与 LLM 请求
#Hook 名入参 (input)出参 (output)作用触发点
7chat.message{ sessionID, agent?, model?: {providerID,modelID}, messageID?, variant? }{ message, parts }新消息到达时(处理前),可修改用户消息内容和 partsprompt.ts:999
8chat.params{ sessionID, agent, model, provider, message }{ temperature, topP, topK, maxOutputTokens?, options }修改 LLM 参数(temperature、topP 等)request.ts:114
9chat.headers{ sessionID, agent, model, provider, message }{ headers: Record<string,string> }向 LLM 请求添加自定义 HTTP 头request.ts:134
10experimental.chat.messages.transform{}(空){ messages: { info, parts }[] }修改发送给 LLM 的完整消息列表。同时在正常 prompt 和 compaction 时触发prompt.ts:1255, compaction.ts:350
11experimental.chat.system.transform{ sessionID?, model }{ system: string[] }修改 system prompt 数组。插件向 system prompt 注入额外提示词的唯一入口request.ts:69, agent.ts:381
工具生命周期
#Hook 名入参出参作用触发点
12tool.execute.before{ tool, sessionID, callID }{ args }工具执行前,可修改工具调用参数tools.ts:106, code-mode.ts:141 等 7 处
13tool.execute.after{ tool, sessionID, callID, args }{ title, output, metadata }工具执行完毕后,可修改输出/标题/元数据tools.ts:121, code-mode.ts:180 等 7 处
14tool.definition{ toolID }{ description, parameters }发送给 LLM 的工具定义(描述和参数 Schema),按工具逐一调用registry.ts:313
命令与 Shell
#Hook 名入参出参作用触发点
15command.execute.before{ command, sessionID, arguments }{ parts: Part[] }斜杠命令执行前,可替换命令产生的 partsprompt.ts:1460
16shell.env{ cwd, sessionID?, callID? }{ env: Record<string,string> }向 shell 执行环境注入额外环境变量tool/shell.ts:417, prompt.ts:554, pty.ts:71
文本、压缩与其他
#Hook 名入参出参作用触发点
17experimental.text.complete{ sessionID, messageID, partID }{ text }LLM 文本流结束时,可转换最终输出的文本processor.ts:516
18experimental.session.compacting{ sessionID }{ context: string[], prompt?: string }压缩前注入额外上下文;若设置 prompt 则替换默认压缩 promptcompaction.ts:343
19experimental.compaction.autocontinue{ sessionID, agent, model, provider, message, overflow }{ enabled: boolean }压缩完成后,设 enabled: false 阻止自动继续compaction.ts:454
20permission.askPermission{ status: "ask"|"deny"|"allow" }拦截权限请求,自动放行或拒绝权限服务
21experimental.provider.small_model{ provider }{ model? }覆盖 lightweight 操作(如标题生成)的 small model 选择Provider 服务

6.2 持久事件(32 个,session.next.*

这些事件关联 aggregateID(= sessionID)和 seq,写入 event 表,支持 replay。每个事件 name 即为 type 字段值。

#事件类型关键字段对应 session_message type说明
1session.next.agent.switched{ sessionID, messageID, agent }agent-switchedAgent 切换
2session.next.model.switched{ sessionID, messageID, model }model-switched模型切换
3session.next.moved{ sessionID, location, subdirectory? }Session 移动到新位置(不写 session_message)
4session.next.prompted{ sessionID, messageID, prompt, delivery }user用户输入完成投递,将被 LLM 处理。同时写入 session_input
5session.next.prompt.admitted{ sessionID, messageID, prompt, delivery }用户输入持久化到 session_input 表(admitted,尚未 promoted)
6session.next.context.updated{ sessionID, messageID, text }system系统上下文更新。更新 session_context_epoch
7session.next.synthetic{ sessionID, messageID, text }synthetic合成消息注入(如 compaction 后的 auto-continue)
8session.next.shell.started{ sessionID, callID, command }shellShell 命令开始执行
9session.next.shell.ended{ sessionID, callID, output }Shell 命令完成,更新 shell 行的 output
10session.next.step.started{ sessionID, assistantMessageID, agent, model, snapshot? }assistant + 关闭前一个未完成的 assistant一次 LLM 响应 step 开始(agent 循环中的一个 turn)
11session.next.step.ended{ sessionID, assistantMessageID, finish, cost, tokens, snapshot?, files? }更新 assistantStep 完成:写入 finish、cost、tokens,同时更新 session 表的聚合值
12session.next.step.failed{ sessionID, assistantMessageID, error }更新 assistant(finish="error")Step 因错误失败
13session.next.text.started{ sessionID, assistantMessageID, textID }在 assistant.content 中追加 text 块LLM 文本流开始
14session.next.text.ended{ sessionID, assistantMessageID, textID, text }合并完整文本文本流完成(提供完整 replayable 值)
15session.next.reasoning.started{ sessionID, assistantMessageID, reasoningID, providerMetadata? }追加 reasoning 块推理流开始
16session.next.reasoning.ended{ sessionID, assistantMessageID, reasoningID, text, providerMetadata? }合并完整推理文本推理流完成
17session.next.tool.input.started{ sessionID, assistantMessageID, callID, name }追加 tool 块(pending)工具输入流开始(LLM 开始生成工具调用参数)
18session.next.tool.input.ended{ sessionID, assistantMessageID, callID, text }更新 tool 输入工具输入流完成
19session.next.tool.called{ sessionID, assistantMessageID, callID, tool, input, provider }更新 tool state→running工具调用完整,准备执行
20session.next.tool.progress{ sessionID, assistantMessageID, callID, structured, content }更新 tool 进度工具执行进度快照(限流,非每个 chunk)
21session.next.tool.success{ sessionID, assistantMessageID, callID, structured, content, outputPaths?, result?, provider }更新 tool state→completed工具执行成功
22session.next.tool.failed{ sessionID, assistantMessageID, callID, error, result?, provider }更新 tool state→error工具执行失败
23session.next.retried{ sessionID, attempt, error }—(不写 session_message)Provider 请求被重试
24session.next.compaction.started{ sessionID, messageID, reason }压缩开始
25session.next.compaction.ended{ sessionID, messageID, reason, text, recent }compaction压缩完成:插入 compaction 行,更新 session.time_compacting。旧数据不删除
26session.next.revert.staged{ sessionID, revert }回退被暂存(更新 session.revert 列)
27session.next.revert.cleared{ sessionID }暂存回退被清除
28session.next.revert.committed{ sessionID, messageID }回退被提交:删除 seq > boundary 的 session_message 和 session_input

**Live-only(不持久化)**的 session.next.* 事件:text.deltareasoning.deltatool.input.deltacompaction.delta——这些流式增量事件仅实时广播,不写 event 表。

6.3 V1 遗留 Session 事件(9 个)

#事件类型持久?关键字段说明
29session.created{ sessionID, info }Session 创建(写入 session 表)
30session.updated{ sessionID, info }Session 更新
31session.deleted{ sessionID }Session 删除(级联删除所有子表)
32message.updated{ sessionID, info }消息更新(写入 V1 message 表)
33message.removed{ sessionID, messageID }消息删除
34message.part.updated{ sessionID, part }Part 更新(写入 V1 part 表,扣减/增加 session token/cost)
35message.part.removed{ sessionID, messageID, partID }Part 删除
36message.part.delta{ sessionID, messageID, partID, field, delta }Part 流式增量
37session.diff{ sessionID, diff[] }代码行增删统计
38session.error{ sessionID?, error }Session 错误

6.4 Session 状态事件(2 个)

#事件类型说明
39session.status{ sessionID, status: "idle"|"retry"|"busy" } — 当前 agent 循环状态
40session.idle{ sessionID } — Session 进入空闲(已废弃)

6.5 其他 V2 事件(48 个)

全部为 live-only(不持久化)。

类别事件
权限(4)permission.v2.asked, permission.v2.replied(V2);permission.asked, permission.replied(V1)
提问(5)question.v2.asked, question.v2.replied, question.v2.rejected(V2);question.asked, question.replied(V1)
TUI(4)tui.prompt.append, tui.command.execute, tui.toast.show, tui.session.select
PTY(4)pty.created, pty.updated, pty.exited, pty.deleted
工作区与项目(5)workspace.ready, workspace.failed, workspace.status, worktree.ready, worktree.failed
服务端(3)server.connected, global.disposed, server.instance.disposed
安装(2)installation.updated, installation.update-available
文件系统(2)file.edited, file.watcher.updated
MCP(2)mcp.tools.changed, mcp.browser.open.failed
项目(2)project.updated, project.directories.updated
集成(2)integration.updated, integration.connection.updated
LSP(1)lsp.updated
IDE(1)ide.installed
VCS(1)vcs.branch.updated
其他(6)todo.updated, plugin.added, catalog.updated, reference.updated, models-dev.refreshed, command.executed

6.6 Hook 与 Event 的汇总

类别数量
Hook(总数)21
├─ Trigger Hook15(通过 plugin.trigger() 调用)
└─ 生命周期 Hook6(dispose/event/config/tool/auth/provider
Event 类型(总数)90
├─ 持久事件32(可 replay)
└─ 瞬态事件58(live-only,不持久化)
最活跃写入表event(每事件一 INSERT)、session_message(每持久事件一 INSERT 或 UPDATE)

架构特点


总结

层面设计选择
插件 Hook 分发遍历内建 hooks 数组,无优先级,无排序机制
插件间监控不可行 — 无 Hook lifecycle 事件,hooks 数组私有
System prompt 构造每轮动态重建,新鲜度优先,牺牲 LLM 缓存命中
数据库写入事件源模式,每事件一条 event + 一条 session_message(仅持久事件)
数据库读取每次 LLM 调用从 compaction 点全量加载 session_message
数据清理无自动清理,靠手动删 session 或 revert