@epoch-agent/protocol
契约层。跨包边界上的类型与常量,没有任何实现。
- ✅ 做:定义跨包共用的类型 / 常量(工具、事件、权限、插件、Hook、配置、线协议),
以及纯粹作用在这些类型上的口径函数(
contentToText/promptTokens这种)—— 它们跟着类型走,不跟着某个包走,而且 tui 只依赖 protocol, 放别处它就够不着,只能自己抄一份 - ❌ 不做:领域逻辑、任何 I/O、任何状态
- 依赖:无。零 runtime 依赖,谁都可以依赖它
判断一个类型该不该放这里:是否被两个以上的包共用。只有 core 和它的直接下游用到的
(比如 SessionManager 的输入输出类型)不放这里,留在实现包。
文件
| 文件 | 内容 |
|---|---|
tool.ts | EpochTool / ToolContext / ToolResult / ToolExposure。输出预算那两格(ToolContext.outputBudget 读额度、EpochTool.governsOwnOutput 免截)的判据在各自的 JSDoc 上,嵌入宿主那侧的说明在 EMBEDDING.md |
plugin.ts | EpochPlugin —— 第三方加工具的唯一入口 |
events.ts | AgentEvent 判别联合 —— 引擎与所有宿主之间的事件契约、TokenUsage |
usage.ts | TokenUsage 的口径算术 + 压缩那条线(compressionThresholdTokens) |
message.ts | EpochMessage 及其内容块(文本 / 图片 / @ 提到的文件 / @: 引用的会话)。加第五种部件时 assertNeverPart 会让漏掉的分支在编译期红 |
permission.ts | 4 级权限(default / acceptEdits / plan / auto;bypass 是 auto 的老别名)、操作类型、审批请求 / 应答、PermissionManager 接口 |
plan.ts | plan 模式(临时只读区间,出口要人批一次)—— 与 plan 那个权限级别的口径 |
question.ts | 结构化提问的请求 / 答复(不过权限层 —— 提问不是审批) |
hook.ts | Hook 类型与 trigger |
config.ts | 配置形状 |
sidebar-menu.ts | 宿主自定义的那几格侧栏菜单:只有一项长什么样(2026-11-06 起消息契约搬去 wire-host-events.ts)。只有 Web UI 读它,但契约得住这儿(EpochConfig 和 WireConfigResponse 都引它) |
wire-host-events.ts | 宿主通信那条正规通道:`POST |
context.ts | 上下文预算的构成(/context 那张账目:每一段花了多少;每段带 kind、建议带结构化的 notes,消费方按自己的语言翻) |
task.ts | 后台任务的形状(宿主要展示它们:状态栏计数、/tasks、Web 那一栏) |
keybindings.ts | 键位的值域(动作全集 + KeyChord + 与 "ctrl+l" 写法互转)。默认表在 tui |
i18n.ts | 界面语言的值域(Lang / LANGS / DEFAULT_LANG)—— 封闭的两个值;外加 API URL 上那个 WIRE_LANG_PARAM(方案 58)。2026-08-22 起还有纯查表(makeTranslate / flattenCatalog / placeholdersOf,方案 40 PR-2)—— 消费方是 tui 和 web,它们俩连 infra 都不许依赖。分界是「有没有 I/O 和状态」:读盘和进程语言仍在 infra |
model-ref.ts | <provider>/<model> 的解析 |
agent-role.ts | 子 agent 角色(delegate_task 的 role 取值) |
commands.ts | 自定义斜杠命令的定义 / 展开结果 / 内置命令保留名单 |
mentions.ts | @ 提及的抽取规则:@路径(文件)和 @:xxx(会话,方案 53)走同一条正则。TUI 和引擎共用一份,两边写岔了会静默错位 |
system-note.ts | 「这条 user 消息其实是系统在说话」那个前缀 + 拼 / 剥两个函数。四处共用,且必须逐字节相同,理由在它的文件头 |
secret.ts | 凭据存储后端的接口 |
trust.ts | 工作区信任的判定结果 |
schedule.ts | 定时任务:三档触发器(判别联合)、一条任务 / 一次运行的形状、欠条、宿主注入点 |
schedule-pending.ts | 「还欠着的那几张欠条」那份投影 + unfixedRules()。那个集合运算落地前在仓库里有三份(server / cli / 假服务端),而它是提权路径上的判定 |
diagnostics.ts | 启动诊断(DiagnosticSink,epoch doctor 打的就是这些)。detail 收 string | LocalizedDetail —— 后者能按语言重放,toList(lang) 是那条路 |
telemetry.ts | Telemetry / SpanLike / GenAI 语义约定常量 / NOOP_TELEMETRY |
artifact.ts | 大块工具输出的落盘引用 |
wire.ts | 线上的信封(WireEnvelope / WireHubEvent / WireAuthGuard) |
wire-rest.ts | Web UI 的 REST 载荷(Wire*Response)与鉴权常量 |
wire-capability.ts | 能力页那一个端点的载荷。单开一个文件是因为 wire-rest.ts 当时已经 788 行(⚠️ 那是当时的理由;该文件今天 4232 行,判据没变 —— 新端点别再往里堆) |
wire-host-badges.ts | 宿主未读点(POST/GET /api/host/badges + host-badges 帧):运行期状态不是配置,补齐为什么走流里那一帧而不是一发 GET,判据在它的文件头 |
wire-host-topbar.ts | 顶栏左侧那排宿主按钮的运行期清单(PUT/GET/DELETE /api/host/topbar + host-topbar 帧)。两件事它单独说的:为什么这一族是「整份替换」而托盘那族是「逐项覆盖」(两条判据不打架的账),以及 items: [](宿主清空了)和 items 缺省(撤掉运行期那份、回落配置)是两句话 |
wire-security.ts | 安全中心那五块。全是读 —— 一个写字段都没有,判据在它的文件头 |
wire-permission.ts | 改这个会话此刻的权限档那一对(决定 20 ③)。和上一行不是一回事,见下 |
wire-settings.ts | 设置页:每一项赢在哪一层 + 能写进哪几层。见下面「设置那一份」 |
wire-goal.ts | 目标那一格(方案 52 PR-4):四态 + blocked 两字段 + 界面要而库里没有的那四把尺子。⚠️ WireGoalPhase / WireGoalRefusal 是 core 那两个联合的镜像(web 够不着 core),逐项相等由 core/__tests__/goal-wire.test.ts 钉着;写那条路上表达不出 blocked,判据在它的文件头 |
headless-wire.ts | 程序驱动 epoch 时 stdin / stdout 的形状(init / result + 五个入站事件) |
第三方要接这套 wire 自己做前端:这几个文件就是契约,直接
import type, 别照着文档抄(文档会过期,类型不会)。接法见 docs/EMBEDDING.md §10。
⚠️
wire-security.ts和wire-permission.ts别按「都带 permission」并起来。 前者是安全中心那一屏的载荷,全是读,答的是「规矩是什么」(规则表、遮蔽 发现、托管锁、信任、策略、审计流水);后者是输入框框外那一行上那一格, 有写,答的是「这个会话现在是哪一档、改不改得动」。 两者的耦合是真的(WirePermissionState.level就是WirePermissionStatus.level那一格,WireBlockedLevel是WireManagedLock.bypassDisabled的投影), 所以后者import type了前者那两个 —— 判据写在它的文件头, 拆文件不等于解耦。
设置那一份(wire-settings.ts)
它发的不是值,是值加上它赢在哪一层(epoch 的设置有六层来源,见 docs/SETTINGS.md)—— 只显示「当前值」的设置界面会在 「用户改了一个开关、被上面某一层压回去」时撒谎。
⚠️ 这份契约里最要紧的一个字段是 WireSettingWrite.futile,而它在读响应上:
写进的层压不过当前赢的那一层时,那个改动写了等于没写,而这一句必须在用户按下
保存之前说出来。做成写响应的一个字段就迟了 —— 那时它只是一句事后通知。
WireSettingApply 是另一条容易接错的:写完这个进程不会重读配置,所以落盘
之后 GET 回来的值一个字都不会变。别乐观更新那一行 —— 那会显示一个这台机器上
哪儿都不存在的状态。
改这里要注意
wire.ts 的信封有两个出口:epoch web 的 SSE 和
epoch --output-format stream-json 的 stdout NDJSON。共用是刻意的 ——
fixtures/wire/*.json 那几份录像因此能同时当两边的夹具,seq 的语义也只有一份。
headless-wire.ts 只加了两个生命周期事件,信封本身一个字段没动。
wire.ts / wire-rest.ts 是 server 和 web 共用的同一份形状:
假服务端(web/dev/fake-server.ts)的每个响应体都挂 satisfies Wire*Response,
所以字段名漂了两边一起编译不过。这是故意的——之前假服务端自己编了一套形状,
于是「先对假服务端验前端」这条验收顺序失去了意义。
events.ts 的 AgentEvent 加成员时,view 的 reducer 要跟着处理,
否则 TUI 和 Web 会静默丢事件。先想清楚谁发 —— diff 那一条在这个联合里
声明了、消费了、渲染了整整一轮,中间一个发射方都没有(判据写在它的 JSDoc 上)。
run-start(2026-08-15)是这个联合最新的一条,由 core 在第一次模型请求之前、
预算闸门之后发,一次 run() 最多一条。它带的 estimatedTokens 是
运行前估算,界面上必须挂「估算」字样 —— 2026-08-15 换了真分词器
(o200k_base)之后偏差小了,但它仍然是发之前算的,真值只有 provider 回的
usage 说了算。判据全文在 provider/tokenizer.ts 的文件头末尾。
agent-role.ts 的 AgentRoleScope 分「子 agent」和「会话」两档,引擎拿它只
挑 system prompt 的身份行;工具收窄、轮次预算、prompt 追加段两档完全一样。
逐条挑专家(方案 57,2026-08-17)没有第三档:session 那句身份行是
「这一次以「x」的身份工作」,「这一次」在逐条语境下正好是对的 —— 两者在模型读到的
字节上没有区别。网线上那一格是 WireSendMessageRequest.role(缺席 = 用这段会话的
角色,认不出来是 400 unknown-role),完整判据在 AgentRoleScope 的文件注释里。
WireSessionSummary.workspace 是三档判别联合(bound / none / unknown,
2026-08-19),不是一个可空引用 —— 这是一次破坏性 wire 变更(同 {binding}
那次:同一个 monorepo 里 packages/web 是唯一消费方,同批改)。换掉它的理由和
{workspace: … | null} → {binding} 那次逐字同源:那个 null 一个值扛了
「明确不使用工作区」和「这个进程说不出它在哪儿干过活」两件事,而侧栏把这一行
归到哪一组,正好取决于这两者的差别。三档的取值优先级(内存绑定 → live →
库里落着的那次决定 → unknown)在 server 的 sessions/summary.ts,
这边不抄第二份。⚠️ 同一个类型上另外三格(pendingSince / turnStartedAt /
lastFinish)照旧是可空的、也照旧只有 Hub 手里那些会话才有 ——
它们是运行时活性,进程一退就真的不存在;workspace 能落盘是因为它是
用户的一次决定。
TokenUsage 的五个 token 字段有两种不同的包含关系(缓存两项与 inputTokens
不重叠、reasoningTokens 含在 outputTokens 里),口径表就写在 events.ts 上。
要「一共多少 token」或「提示词多大」,用 usage.ts 的两个函数,
别自己把字段加一遍 —— 漏掉缓存那两项的症状是上下文占用显示成个位数百分比,
而且只在开了 prompt caching 的 provider 上才出现。
同一个文件里还有**「快满了」那条线**(2026-08-16):
compressionThresholdTokens(contextLength, threshold) 和
isContextNearlyFull(prompt, contextLength, threshold)。它们住在这儿的理由和上面
那两个逐字相同 —— 两个必然的消费方,必须给出同一个数:引擎那边是
ContextCompressor.shouldCompress()(真的触发压缩的那一处),界面那边是设置页
「记忆与上下文」里那句「压缩在 64,000 token 时开始」。各写一遍的症状极难查:
界面说「还早」,下一轮却压了。
⚠️ 它只是那条线,不是「会不会压」的完整答案 —— 压缩器上还有冷却期和熔断, 两者都只会让压缩更晚发生。所以界面拿它画的是「到线了」,不是「下一轮一定压」。