OpenCode 插件系统与数据库架构深度解析
七月 17, 2026 #opencode #typescript #effect #sqlite #ai-agent #pluginOpenCode 插件系统与数据库架构深度解析
本文以 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 按两种模式识别插件:
- V1 模式:
readV1Plugin()检查是否存在{ id, server: Plugin }结构的默认导出 - Legacy 模式:
getLegacyPlugins()遍历模块的所有导出,将函数值直接当作Plugin实例
3. Hook 注册
所有插件的 Plugin(input) 被调用后返回 Hooks 对象,按顺序存入内部数组。分发方式有三种:
| 类型 | 触发点 | 示例 |
|---|---|---|
config 生命周期 Hook | 初始化时按顺序调用 | config(cfg) |
event 事件 Hook | 通过 EventV2Bridge 订阅所有事件 | event({ event }) |
| Trigger Hook | 在 opencode 特定执行点通过 Plugin.trigger() 调用 | chat.message、experimental.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:381 和 session/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.created | handleSessionCreated | 创建 session span |
session.idle | handleSessionIdle | 关闭 span,flush 遥测 |
session.error | handleSessionError | 记录错误,flush |
session.diff | handleSessionDiff | 统计代码行数增删 |
command.executed | handleCommandExecuted | 检测 git commit |
message.updated | handleMessageUpdated | 累加 token/cost 计数 |
message.part.updated | handleMessagePartUpdated | 处理工具调用 span 和指标 |
permission.updated/replied | 对应 handler | 跟踪权限请求 |
注册 SIGTERM/SIGINT/beforeExit 信号实现优雅 shutdown。
遥测无法跨插件监控
当前架构下,一个插件无法以通用方式监控另一个插件 Hook 的执行。因为 hooks 数组是内部闭包的局部变量,Plugin.trigger() 不产生任何可监听的事件——没有优先级、没有 lifecycle hook。只能通过外部 OTLP 后端间接观察 otel 插件自身的指标,或修改核心代码增加 Hook 生命周期事件。
四、LLM 消息的构造流程
这是理解系统提示词注入和数据库读写模式的关键。每次 LLM 调用时:
构造链路(prompt.ts → llm.ts → request.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 选择了新鲜度优先的设计原则。
五、数据库架构与运行时操作
引擎与配置
- 引擎:SQLite(WAL 模式)
- 文件位置:
~/.local/share/opencode/opencode.db - PRAGMA:
journal_mode=WAL、synchronous=NORMAL、busy_timeout=5000、cache_size=-64000(64MB)、foreign_keys=ON
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 seq | cascade |
当前没有自动清理机制:
- Compaction 只是追加 summary 行 + "跳过"旧消息,不删除任何数据
time_archived字段存在于 schema 中,但未被任何代码写入- 没有 TTL、没有到期清理、没有最大行数限制
唯一删除途径:手动删除 session(DELETE FROM session WHERE id = ?,级联清除所有子表)或执行 revert(删除指定 seq 之后的 session_message 和 session_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 名 | 入参 | 返回值 | 作用 |
|---|---|---|---|---|
| 1 | dispose | 无 | Promise<void> | 插件卸载时调用,用于清理资源 |
| 2 | event | { event: { id, type, properties } } | Promise<void> | 接收每个系统事件——通过 EventV2Bridge 订阅所有事件广播。这是插件感知系统状态的核心入口 |
| 3 | config | Config(完整配置对象) | Promise<void> | 初始化时及配置变更时调用。插件在此注册 skills 路径、调整日志级别、注入自定义配置 |
| 4 | tool | —(静态属性) | { [key: string]: ToolDefinition } | 注册自定义工具供 agent 调用 |
| 5 | auth | —(静态属性) | AuthHook | 注册自定义认证逻辑(如 OAuth) |
| 6 | provider | —(静态属性) | ProviderHook | 注册自定义模型 Provider |
Trigger Hook((input, output) → void 模式,15 个)
这些 Hook 在特定执行点通过 Plugin.trigger(name, input, output) 调用,插件通过修改 output 对象来影响后续流程。
消息与 LLM 请求
| # | Hook 名 | 入参 (input) | 出参 (output) | 作用 | 触发点 |
|---|---|---|---|---|---|
| 7 | chat.message | { sessionID, agent?, model?: {providerID,modelID}, messageID?, variant? } | { message, parts } | 新消息到达时(处理前),可修改用户消息内容和 parts | prompt.ts:999 |
| 8 | chat.params | { sessionID, agent, model, provider, message } | { temperature, topP, topK, maxOutputTokens?, options } | 修改 LLM 参数(temperature、topP 等) | request.ts:114 |
| 9 | chat.headers | { sessionID, agent, model, provider, message } | { headers: Record<string,string> } | 向 LLM 请求添加自定义 HTTP 头 | request.ts:134 |
| 10 | experimental.chat.messages.transform | {}(空) | { messages: { info, parts }[] } | 修改发送给 LLM 的完整消息列表。同时在正常 prompt 和 compaction 时触发 | prompt.ts:1255, compaction.ts:350 |
| 11 | experimental.chat.system.transform | { sessionID?, model } | { system: string[] } | 修改 system prompt 数组。插件向 system prompt 注入额外提示词的唯一入口 | request.ts:69, agent.ts:381 |
工具生命周期
| # | Hook 名 | 入参 | 出参 | 作用 | 触发点 |
|---|---|---|---|---|---|
| 12 | tool.execute.before | { tool, sessionID, callID } | { args } | 工具执行前,可修改工具调用参数 | tools.ts:106, code-mode.ts:141 等 7 处 |
| 13 | tool.execute.after | { tool, sessionID, callID, args } | { title, output, metadata } | 工具执行完毕后,可修改输出/标题/元数据 | tools.ts:121, code-mode.ts:180 等 7 处 |
| 14 | tool.definition | { toolID } | { description, parameters } | 发送给 LLM 的工具定义(描述和参数 Schema),按工具逐一调用 | registry.ts:313 |
命令与 Shell
| # | Hook 名 | 入参 | 出参 | 作用 | 触发点 |
|---|---|---|---|---|---|
| 15 | command.execute.before | { command, sessionID, arguments } | { parts: Part[] } | 斜杠命令执行前,可替换命令产生的 parts | prompt.ts:1460 |
| 16 | shell.env | { cwd, sessionID?, callID? } | { env: Record<string,string> } | 向 shell 执行环境注入额外环境变量 | tool/shell.ts:417, prompt.ts:554, pty.ts:71 |
文本、压缩与其他
| # | Hook 名 | 入参 | 出参 | 作用 | 触发点 |
|---|---|---|---|---|---|
| 17 | experimental.text.complete | { sessionID, messageID, partID } | { text } | LLM 文本流结束时,可转换最终输出的文本 | processor.ts:516 |
| 18 | experimental.session.compacting | { sessionID } | { context: string[], prompt?: string } | 压缩前注入额外上下文;若设置 prompt 则替换默认压缩 prompt | compaction.ts:343 |
| 19 | experimental.compaction.autocontinue | { sessionID, agent, model, provider, message, overflow } | { enabled: boolean } | 压缩完成后,设 enabled: false 阻止自动继续 | compaction.ts:454 |
| 20 | permission.ask | Permission | { status: "ask"|"deny"|"allow" } | 拦截权限请求,自动放行或拒绝 | 权限服务 |
| 21 | experimental.provider.small_model | { provider } | { model? } | 覆盖 lightweight 操作(如标题生成)的 small model 选择 | Provider 服务 |
6.2 持久事件(32 个,session.next.*)
这些事件关联 aggregateID(= sessionID)和 seq,写入 event 表,支持 replay。每个事件 name 即为 type 字段值。
| # | 事件类型 | 关键字段 | 对应 session_message type | 说明 |
|---|---|---|---|---|
| 1 | session.next.agent.switched | { sessionID, messageID, agent } | agent-switched | Agent 切换 |
| 2 | session.next.model.switched | { sessionID, messageID, model } | model-switched | 模型切换 |
| 3 | session.next.moved | { sessionID, location, subdirectory? } | — | Session 移动到新位置(不写 session_message) |
| 4 | session.next.prompted | { sessionID, messageID, prompt, delivery } | user | 用户输入完成投递,将被 LLM 处理。同时写入 session_input 表 |
| 5 | session.next.prompt.admitted | { sessionID, messageID, prompt, delivery } | — | 用户输入持久化到 session_input 表(admitted,尚未 promoted) |
| 6 | session.next.context.updated | { sessionID, messageID, text } | system | 系统上下文更新。更新 session_context_epoch 表 |
| 7 | session.next.synthetic | { sessionID, messageID, text } | synthetic | 合成消息注入(如 compaction 后的 auto-continue) |
| 8 | session.next.shell.started | { sessionID, callID, command } | shell | Shell 命令开始执行 |
| 9 | session.next.shell.ended | { sessionID, callID, output } | — | Shell 命令完成,更新 shell 行的 output |
| 10 | session.next.step.started | { sessionID, assistantMessageID, agent, model, snapshot? } | assistant + 关闭前一个未完成的 assistant | 一次 LLM 响应 step 开始(agent 循环中的一个 turn) |
| 11 | session.next.step.ended | { sessionID, assistantMessageID, finish, cost, tokens, snapshot?, files? } | 更新 assistant | Step 完成:写入 finish、cost、tokens,同时更新 session 表的聚合值 |
| 12 | session.next.step.failed | { sessionID, assistantMessageID, error } | 更新 assistant(finish="error") | Step 因错误失败 |
| 13 | session.next.text.started | { sessionID, assistantMessageID, textID } | 在 assistant.content 中追加 text 块 | LLM 文本流开始 |
| 14 | session.next.text.ended | { sessionID, assistantMessageID, textID, text } | 合并完整文本 | 文本流完成(提供完整 replayable 值) |
| 15 | session.next.reasoning.started | { sessionID, assistantMessageID, reasoningID, providerMetadata? } | 追加 reasoning 块 | 推理流开始 |
| 16 | session.next.reasoning.ended | { sessionID, assistantMessageID, reasoningID, text, providerMetadata? } | 合并完整推理文本 | 推理流完成 |
| 17 | session.next.tool.input.started | { sessionID, assistantMessageID, callID, name } | 追加 tool 块(pending) | 工具输入流开始(LLM 开始生成工具调用参数) |
| 18 | session.next.tool.input.ended | { sessionID, assistantMessageID, callID, text } | 更新 tool 输入 | 工具输入流完成 |
| 19 | session.next.tool.called | { sessionID, assistantMessageID, callID, tool, input, provider } | 更新 tool state→running | 工具调用完整,准备执行 |
| 20 | session.next.tool.progress | { sessionID, assistantMessageID, callID, structured, content } | 更新 tool 进度 | 工具执行进度快照(限流,非每个 chunk) |
| 21 | session.next.tool.success | { sessionID, assistantMessageID, callID, structured, content, outputPaths?, result?, provider } | 更新 tool state→completed | 工具执行成功 |
| 22 | session.next.tool.failed | { sessionID, assistantMessageID, callID, error, result?, provider } | 更新 tool state→error | 工具执行失败 |
| 23 | session.next.retried | { sessionID, attempt, error } | —(不写 session_message) | Provider 请求被重试 |
| 24 | session.next.compaction.started | { sessionID, messageID, reason } | — | 压缩开始 |
| 25 | session.next.compaction.ended | { sessionID, messageID, reason, text, recent } | compaction | 压缩完成:插入 compaction 行,更新 session.time_compacting。旧数据不删除 |
| 26 | session.next.revert.staged | { sessionID, revert } | — | 回退被暂存(更新 session.revert 列) |
| 27 | session.next.revert.cleared | { sessionID } | — | 暂存回退被清除 |
| 28 | session.next.revert.committed | { sessionID, messageID } | — | 回退被提交:删除 seq > boundary 的 session_message 和 session_input |
**Live-only(不持久化)**的
session.next.*事件:text.delta、reasoning.delta、tool.input.delta、compaction.delta——这些流式增量事件仅实时广播,不写event表。
6.3 V1 遗留 Session 事件(9 个)
| # | 事件类型 | 持久? | 关键字段 | 说明 |
|---|---|---|---|---|
| 29 | session.created | 是 | { sessionID, info } | Session 创建(写入 session 表) |
| 30 | session.updated | 是 | { sessionID, info } | Session 更新 |
| 31 | session.deleted | 是 | { sessionID } | Session 删除(级联删除所有子表) |
| 32 | message.updated | 是 | { sessionID, info } | 消息更新(写入 V1 message 表) |
| 33 | message.removed | 是 | { sessionID, messageID } | 消息删除 |
| 34 | message.part.updated | 是 | { sessionID, part } | Part 更新(写入 V1 part 表,扣减/增加 session token/cost) |
| 35 | message.part.removed | 是 | { sessionID, messageID, partID } | Part 删除 |
| 36 | message.part.delta | 否 | { sessionID, messageID, partID, field, delta } | Part 流式增量 |
| 37 | session.diff | 否 | { sessionID, diff[] } | 代码行增删统计 |
| 38 | session.error | 否 | { sessionID?, error } | Session 错误 |
6.4 Session 状态事件(2 个)
| # | 事件类型 | 说明 |
|---|---|---|
| 39 | session.status | { sessionID, status: "idle"|"retry"|"busy" } — 当前 agent 循环状态 |
| 40 | session.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 Hook | 15(通过 plugin.trigger() 调用) |
| └─ 生命周期 Hook | 6(dispose/event/config/tool/auth/provider) |
| Event 类型(总数) | 90 |
| ├─ 持久事件 | 32(可 replay) |
| └─ 瞬态事件 | 58(live-only,不持久化) |
| 最活跃写入表 | event(每事件一 INSERT)、session_message(每持久事件一 INSERT 或 UPDATE) |
架构特点:
eventHook 接收全部 90 种事件的推送,是插件感知系统状态的唯一通道- 只有 32 个
session.next.*持久事件会同时写入event和session_message两张表 - 同一轮 agent 循环中,一个 assistant 应答对应一行
session_message;step.startedINSERT 该行,后续所有 text/tool step 事件 UPDATE 同一行 experimental.chat.system.transform是修改 system prompt 的唯一 trigger Hooktool.execute.before/tool.execute.after覆盖所有类型的工具(内置工具 + MCP 工具 + task 子 agent)
总结
| 层面 | 设计选择 |
|---|---|
| 插件 Hook 分发 | 遍历内建 hooks 数组,无优先级,无排序机制 |
| 插件间监控 | 不可行 — 无 Hook lifecycle 事件,hooks 数组私有 |
| System prompt 构造 | 每轮动态重建,新鲜度优先,牺牲 LLM 缓存命中 |
| 数据库写入 | 事件源模式,每事件一条 event + 一条 session_message(仅持久事件) |
| 数据库读取 | 每次 LLM 调用从 compaction 点全量加载 session_message |
| 数据清理 | 无自动清理,靠手动删 session 或 revert |