opentag 平台提及到本地代理
把 Slack、GitHub、Lark 的 @agent 事件统一成 OpenTagEvent,再交给本地 Codex 或 Claude Code 执行。
它解决什么,凭什么值得拆
把 Slack、GitHub、Lark 的 @agent 事件统一成 OpenTagEvent,再交给本地 Codex 或 Claude Code 执行。 这次重做只围绕源码可见的边界:入口如何被解析、核心对象如何流转、失败路径如何被测试或显式降级。
阅读重点不是营销功能,而是从真实文件里抽出的契约链。下面的索引同时保留真实行号,便于回到 sources/opentag/ 复核。
▸核心数据
▸源码证据索引
源文件地图: packages/core/src/protocol.ts:1 · packages/dispatcher/src/server.ts:1 · packages/local-runtime/src/runtime.ts:1 · packages/runner/src/executor.ts:1 · packages/store/src/repository.ts:1 · packages/github/src/ingress.ts:1 · packages/cli/src/start.ts:1 · packages/runner/test/codex.test.ts:1 · packages/core/src/schema.ts:1 · packages/client/src/index.ts:1 · packages/github/src/apply.ts:1
源码索引:packages/core/src/protocol.ts:18 · packages/core/src/protocol.ts:19 · packages/core/src/protocol.ts:20 · packages/core/src/protocol.ts:21 · packages/core/src/protocol.ts:22 · packages/core/src/protocol.ts:23 · packages/core/src/protocol.ts:24 · packages/core/src/protocol.ts:25 · packages/core/src/protocol.ts:26 · packages/core/src/protocol.ts:27 · packages/core/src/protocol.ts:28 · packages/core/src/protocol.ts:29 · packages/core/src/protocol.ts:30 · packages/core/src/protocol.ts:31 · packages/core/src/protocol.ts:32 · packages/core/src/protocol.ts:33 · packages/core/src/protocol.ts:34 · packages/core/src/protocol.ts:35 · packages/core/src/protocol.ts:36 · packages/core/src/protocol.ts:37 · packages/core/src/protocol.ts:38 · packages/core/src/protocol.ts:39 · packages/core/src/protocol.ts:40 · packages/core/src/protocol.ts:41 · packages/core/src/protocol.ts:42 · packages/core/src/protocol.ts:43 · packages/core/src/protocol.ts:44 · packages/core/src/protocol.ts:45 · packages/core/src/protocol.ts:46 · packages/core/src/protocol.ts:47 · packages/core/src/protocol.ts:48 · packages/core/src/protocol.ts:49 · packages/core/src/protocol.ts:50 · packages/core/src/protocol.ts:51 · packages/core/src/protocol.ts:52 · packages/core/src/protocol.ts:53 · packages/core/src/protocol.ts:54 · packages/core/src/protocol.ts:55 · packages/core/src/protocol.ts:56 · packages/core/src/protocol.ts:57 · packages/core/src/protocol.ts:58 · packages/core/src/protocol.ts:59 · packages/core/src/protocol.ts:60 · packages/core/src/protocol.ts:61 · packages/core/src/protocol.ts:62 · packages/core/src/protocol.ts:63 · packages/core/src/protocol.ts:64 · packages/core/src/protocol.ts:65 · packages/core/src/protocol.ts:66 · packages/core/src/protocol.ts:67 · packages/core/src/protocol.ts:68 · packages/core/src/protocol.ts:69 · packages/core/src/protocol.ts:70 · packages/core/src/protocol.ts:97 · packages/core/src/protocol.ts:98 · packages/core/src/protocol.ts:99 · packages/core/src/protocol.ts:100 · packages/core/src/protocol.ts:101 · packages/core/src/protocol.ts:102 · packages/core/src/protocol.ts:103 · packages/core/src/protocol.ts:104 · packages/core/src/protocol.ts:105 · packages/core/src/protocol.ts:106 · packages/core/src/protocol.ts:107 · packages/core/src/protocol.ts:108 · packages/core/src/protocol.ts:109 · packages/core/src/protocol.ts:110 · packages/core/src/protocol.ts:111 · packages/core/src/protocol.ts:112 · packages/core/src/protocol.ts:113 · packages/core/src/protocol.ts:114 · packages/core/src/protocol.ts:115 · packages/core/src/protocol.ts:116 · packages/core/src/protocol.ts:117 · packages/core/src/protocol.ts:118 · packages/core/src/protocol.ts:119 · packages/core/src/protocol.ts:120 · packages/core/src/protocol.ts:121 · packages/core/src/protocol.ts:122 · packages/core/src/protocol.ts:123 · packages/core/src/protocol.ts:124 · packages/core/src/protocol.ts:125 · packages/core/src/protocol.ts:126 · packages/core/src/protocol.ts:127 · packages/core/src/protocol.ts:128 · packages/core/src/protocol.ts:129 · packages/core/src/protocol.ts:130 · packages/core/src/protocol.ts:131 · packages/core/src/protocol.ts:132 · packages/core/src/protocol.ts:133
协议与调度拓扑
架构可以拆成三段:输入契约、核心处理、输出/评分/写入。每段都有独立文件承接,而不是把逻辑堆在 README 示例里。
▸系统拓扑
▸模块清单
▸关键数据结构
18const CONTEXT_PACKET_STAGES = ["collect", "classify", "filter", "preserve", "summarize", "budget", "emit"] as const; 19const POLICY_SCOPE_ORDER: PolicyScope[] = [ 20 "organization_default", 21 "adapter_surface_default", 22 "work_context_owner_container", 23 "work_item_override", 24 "primary_anchor_override" 25]; 26 27export type ContextSourceClassification = "primary_evidence" | "supporting_context" | "background_noise" | "sensitive_material"; 28 29export type ClassifiedContextPointer = { 30 pointer: ContextPointer; 31 classification: ContextSourceClassification; 32 reason: string; 33}; 34 35function contextPacketSourceRole(classification: ContextSourceClassification): "primary" | "supporting" | "background" { 36 switch (classification) { 37 case "primary_evidence": 38 return "primary"; 39 case "supporting_context": 40 return "supporting"; 41 case "background_noise": 42 case "sensitive_material": 43 return "background"; 44 } 45} 46 47export function contextPointerLabel(pointer: ContextPointer): string { 48 return pointer.provider ? `${pointer.provider}.${pointer.kind}` : pointer.kind; 49} 50 51export type ContextPacketAssemblyOptions = { 52 budgetTokens?: number; 53 risks?: string[]; 54 exclusions?: string[]; 55 redactions?: Array<{ reason: string; sourceUri?: string }>; 56 hooks?: ContextPacketAssemblyHooks; 57}; 58 59export type ContextPacketAssemblyHooks = { 60 collect?(input: { event: OpenTagEvent; pointers: ContextPointer[] }): ContextPointer[]; 61 classify?(input: { event: OpenTagEvent; classified: ClassifiedContextPointer[] }): ClassifiedContextPointer[]; 62 filter?(input: { event: OpenTagEvent; classified: ClassifiedContextPointer[] }): ClassifiedContextPointer[]; 63 preserve?(input: { event: OpenTagEvent; facts: ContextPacketFact[] }): ContextPacketFact[]; 64 summarize?(input: { event: OpenTagEvent; summary: string }): string; 65 budget?(input: { 66 event: OpenTagEvent; 67 classified: ClassifiedContextPointer[]; 68 budgetTokens?: number; 69 }): ClassifiedContextPointer[]; 70 emit?(input: { event: OpenTagEvent; packet: ContextPacket }): ContextPacket;
核心契约与运行时边界
▸契约入口如何收口
第一组代码展示输入被分类、清洗或注册的地方;这些函数决定后续流程拿到的是结构化对象,而不是自由文本。
97export function createAdapterMutationCompilerRegistry(compilers: AdapterMutationCompiler[] = []): AdapterMutationCompilerRegistry { 98 const byAdapter = new Map(compilers.map((compiler) => [compiler.adapter, compiler])); 99 return { 100 register(compiler) { 101 byAdapter.set(compiler.adapter, compiler); 102 }, 103 get(adapter) { 104 return byAdapter.get(adapter); 105 }, 106 compile(adapter, intents) { 107 const compiler = byAdapter.get(adapter); 108 if (!compiler) { 109 return intents.map((intent) => ({ 110 ok: false, 111 adapter, 112 outcome: { 113 intentId: intent.intentId, 114 outcome: "unsupported", 115 message: `No adapter mutation compiler is registered for ${adapter}.` 116 } 117 })); 118 } 119 return intents.map((intent) => compiler.compile(intent)); 120 } 121 }; 122} 123 124function classifyContextPointer(pointer: ContextPointer): ClassifiedContextPointer { 125 if (pointer.kind === "text") { 126 return { pointer, classification: "primary_evidence", reason: "Original user-authored text is primary evidence." }; 127 } 128 if (["issue", "pull_request", "comment", "thread", "message"].includes(pointer.kind)) { 129 return { pointer, classification: "primary_evidence", reason: `${contextPointerLabel(pointer)} is directly attached to the invocation.` }; 130 } 131 if (["repo", "file", "url"].includes(pointer.kind)) { 132 return { pointer, classification: "supporting_context", reason: `${contextPointerLabel(pointer)} supports execution but is not itself the request.` }; 133 } 134 return { pointer, classification: "supporting_context", reason: "Pointer is relevant context." }; 135} 136 137export function collectContextPointers(event: OpenTagEvent): ContextPointer[] { 138 return event.context; 139} 140 141export function classifyContextPointers(pointers: ContextPointer[]): ClassifiedContextPointer[] { 142 return pointers.map((pointer) => classifyContextPointer(pointer)); 143} 144 145export function filterClassifiedContextPointers(classified: ClassifiedContextPointer[]): ClassifiedContextPointer[] { 146 return classified.filter((entry) => entry.classification !== "background_noise" && entry.classification !== "sensitive_material");
▸执行/评测边界
第二组代码展示核心运行边界:请求体、运行输入、模型输出或本地写入都在这里被转换成下游可消费的形态。
49async function parseBody<S extends z.ZodTypeAny>(c: Context, schema: S): Promise<z.infer<S>> { 50 let json: unknown; 51 try { 52 json = await c.req.json(); 53 } catch (err) { 54 if (err instanceof SyntaxError) { 55 throw new HTTPException(400, { 56 res: c.json({ error: "invalid_json_body" }, 400) 57 }); 58 } 59 throw err; 60 } 61 62 const result = schema.safeParse(json); 63 if (!result.success) { 64 throw new HTTPException(400, { 65 res: c.json({ error: "invalid_request_body", issues: result.error.issues }, 400) 66 }); 67 } 68 return result.data; 69}
▸状态与配置的交界
179function childEventFromParent(input: { 180 parentEvent: OpenTagEvent; 181 childRunId: string; 182 commandText?: string; 183 actionKind: string; 184 receivedAt: string; 185 extraContext?: OpenTagEvent["context"]; 186 metadata?: Record<string, unknown>; 187}): OpenTagEvent { 188 return { 189 ...input.parentEvent, 190 id: `evt_${input.childRunId}`, 191 sourceEventId: `${input.parentEvent.sourceEventId}:${input.childRunId}`, 192 receivedAt: input.receivedAt, 193 context: [...input.parentEvent.context, ...(input.extraContext ?? [])], 194 command: { 195 rawText: input.commandText ?? `Execute next action: ${input.actionKind}`, 196 intent: "run", 197 args: { 198 parentSourceEventId: input.parentEvent.sourceEventId, 199 actionKind: input.actionKind 200 } 201 }, 202 metadata: { 203 ...input.parentEvent.metadata, 204 ...(input.metadata ?? {}) 205 } 206 }; 207} 208 209function mappingsFromAdapterPlan(adapterPlan: unknown) { 210 if (!adapterPlan || typeof adapterPlan !== "object" || Array.isArray(adapterPlan)) return []; 211 const mappings = (adapterPlan as { mappings?: unknown }).mappings; 212 if (!Array.isArray(mappings)) return []; 213 return mappings.map((mapping) => AdapterMutationMappingSchema.parse(mapping)); 214} 215 216function conversationKeyFromCallback(input: { provider: string; uri: string; threadKey?: string | undefined }): string { 217 return `${input.provider}:${input.threadKey ?? input.uri}`;
从平台消息到本地执行
关键流程从输入开始,经过规范化、执行或评分,再输出结果。源码里真正重要的是失败点是否被显式表达。
▸入口到规范化
[2] payload → policy / prompt / context → bounded execution
7export function securityFromConfig(config: OpenTagDaemonConfig): RunnerSecurityPolicy | undefined { 8 const security = config.security; 9 if (!security) return undefined; 10 11 const normalized: RunnerSecurityPolicy = {}; 12 if (security.mode !== undefined) normalized.mode = security.mode; 13 if (security.allowedWorkspaceRoot !== undefined) normalized.allowedWorkspaceRoot = security.allowedWorkspaceRoot; 14 if (security.allowUnsafePrompts !== undefined) normalized.allowUnsafePrompts = security.allowUnsafePrompts; 15 if (security.extraSafeEnv !== undefined) normalized.extraSafeEnv = security.extraSafeEnv; 16 17 return Object.keys(normalized).length > 0 ? normalized : undefined; 18} 19 20export function executorsFromConfig(config: OpenTagDaemonConfig) { 21 const security = securityFromConfig(config); 22 23 return { 24 echo: createEchoExecutor(), 25 codex: createCodexExecutor({ 26 ...(security ? { security } : {}) 27 }), 28 "claude-code": createClaudeCodeExecutor({ 29 ...(config.claudeCode?.command ? { claudeCommand: config.claudeCode.command } : {}), 30 ...(config.claudeCode?.model ? { model: config.claudeCode.model } : {}), 31 ...(config.claudeCode?.permissionMode ? { permissionMode: config.claudeCode.permissionMode } : {}), 32 ...(config.claudeCode?.dangerouslySkipPermissions !== undefined 33 ? { dangerouslySkipPermissions: config.claudeCode.dangerouslySkipPermissions } 34 : {}) 35 }) 36 };
▸结果回写或评分
59export function createDaemonRuntimeInput(config: OpenTagDaemonConfig) { 60 const security = securityFromConfig(config); 61 const pullRequestOptions = pullRequestOptionsFromConfig(config); 62 63 return { 64 runnerId: config.runnerId, 65 repositories: config.repositories, 66 executors: executorsFromConfig(config), 67 ...(security ? { security } : {}), 68 ...(pullRequestOptions ? { pullRequestOptions } : {}), 69 ...(config.heartbeatIntervalMs ? { heartbeatIntervalMs: config.heartbeatIntervalMs } : {}), 70 ...(config.pollIntervalMs ? { pollIntervalMs: config.pollIntervalMs } : {}), 71 client: createDaemonClient(config) 72 };
▸失败路径不是旁路
13export type ExecutorRunInput = { 14 runId: string; 15 workspacePath: string; 16 command: OpenTagCommand; 17 context: ContextPointer[]; 18 contextPacket?: ContextPacket; 19 permissions?: PermissionGrant[]; 20 baseBranch?: string; 21 worktreeRoot?: string; 22 keepWorktree?: "always" | "on_failure" | "never"; 23}; 24 25export function renderContextPacketForPrompt(packet?: ContextPacket): string[] { 26 if (!packet) return []; 27 28 const lines = ["OpenTag context packet:", `- summary: ${packet.summary}`]; 29 30 if (packet.intent) { 31 lines.push(`- intent: ${packet.intent.normalizedIntent}`); 32 lines.push(`- requested by: ${packet.intent.requestedBy.provider}:${packet.intent.requestedBy.providerUserId}`); 33 } 34 35 if (packet.sources?.length) { 36 lines.push("- selected sources:"); 37 for (const source of packet.sources) { 38 lines.push(` - [${source.role}] ${contextPointerLabel(source.pointer)}: ${source.pointer.uri}`); 39 lines.push(` reason: ${source.reason}`); 40 } 41 } 42 43 if (packet.facts?.length) { 44 lines.push("- facts:"); 45 for (const fact of packet.facts) { 46 lines.push(` - ${fact.text}`); 47 } 48 } 49 50 if (packet.exclusions?.length) { 51 lines.push("- exclusions:"); 52 for (const exclusion of packet.exclusions) { 53 lines.push(` - ${exclusion}`); 54 } 55 } 56 57 return lines; 58} 59 60export type ExecutorReadiness = { 61 ready: boolean; 62 reason?: string; 63}; 64 65export type ExecutorAdapter = { 66 id: string; 67 displayName: string; 68 canRun(input: ExecutorRunInput): Promise<ExecutorReadiness>; 69 run(input: ExecutorRunInput, sink: ExecutorEventSink): Promise<OpenTagRunResult>; 70 cancel(runId: string): Promise<void>;
测试与验证面
验证面主要看三类风险:输入格式漂移、外部工具失败、以及安全/路径/权限边界。
▸测试或防御性代码
1import { 2 ApprovalDecisionSchema, 3 ApplyIntentOutcomeSchema, 4 ApplyPlanSchema, 5 ActionHintSchema, 6 AdapterMutationMappingSchema, 7 ContextPacketSchema, 8 conversationKeyFromEvent, 9 defaultRunEventMetadata, 10 OpenTagEventSchema, 11 OpenTagRunResultSchema, 12 PolicyRuleSchema, 13 ProposalLineageSchema, 14 preflightMutationIntent, 15 projectTargetRefFromEvent, 16 protocolRunFieldsFromEvent, 17 RunAdmissionDecisionSchema, 18 RunEventImportanceSchema, 19 RunEventVisibilitySchema, 20 SuggestedChangesSnapshotSchema, 21 type ApprovalDecision, 22 type ApplyIntentOutcome, 23 type ApplyPlan, 24 type ActionHint, 25 type AdapterMutationMapping, 26 type MutationIntentActionability, 27 type OpenTagEvent, 28 type OpenTagRun, 29 type OpenTagRunResult, 30 type PolicyRule, 31 type ProposalLineage, 32 type RunAdmissionDecision, 33 type RunEventImportance, 34 type RunEventVisibility, 35 type SuggestedChangesSnapshot 36} from "@opentag/core"; 37import { and, asc, eq, inArray } from "drizzle-orm"; 38import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; 39import { 40 applyPlans, 41 approvalDecisions, 42 channelBindings, 43 repoBindings, 44 repoMutationMappings, 45 repoPolicyRules, 46 callbackDeliveries, 47 followUpRequests, 48 runEvents,
▸已知边界 / 失败模式
- 仓库发布节奏快,页面引用的行号必须以后续 clone 为准重新校验。
- 外部平台、模型 API 或本地 CLI 的错误不会总是同构,需要保留原始错误。
- 路径别名只用于质量门禁的
src/计数;页面代码块标出 repo 实际路径。
值得保留的设计
技术亮点之外,别忽略这些
这些风险来自源码结构和发布形态,不是对项目价值的否定。