opentag平台提及到本地代理
§ 00 · Source-level Observatory

opentag 平台提及到本地代理

把 Slack、GitHub、Lark 的 @agent 事件统一成 OpenTagEvent,再交给本地 Codex 或 Claude Code 执行。

★ 359
GitHub Stars
31.8K
LOC
TypeScript monorepo
Files
2026-06-29
Last commit
graph TD A[Platform or dataset event] --> B[opentag contract layer] B --> C[Normalization and parsing] C --> D[Execution or evaluation loop] D --> E[Result, score, or local memory] C --> F[Validation and edge handling]
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

把 Slack、GitHub、Lark 的 @agent 事件统一成 OpenTagEvent,再交给本地 Codex 或 Claude Code 执行。 这次重做只围绕源码可见的边界:入口如何被解析、核心对象如何流转、失败路径如何被测试或显式降级。

阅读重点不是营销功能,而是从真实文件里抽出的契约链。下面的索引同时保留真实行号,便于回到 sources/opentag/ 复核。

▸核心数据

架构模式
协议与调度拓扑
核心抽象
核心契约与运行时边界
验证数据
以源码测试、解析器和配置表作为可复核证据
下游依赖
pnpm monorepo

▸源码证据索引

源文件地图: 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

§ 01 · ARCHITECTURE

协议与调度拓扑

架构可以拆成三段:输入契约、核心处理、输出/评分/写入。每段都有独立文件承接,而不是把逻辑堆在 README 示例里。

▸系统拓扑

graph TD A[Platform or dataset event] --> B[opentag contract layer] B --> C[Normalization and parsing] C --> D[Execution or evaluation loop] D --> E[Result, score, or local memory] C --> F[Validation and edge handling]

▸模块清单

协议入口
事件、上下文、权限和 mutation intent 先归一成可校验契约。
调度核心
Dispatcher/runner 只交换 run、progress、result,不绑定具体平台 UI。
验证层
Parser、schema 和单测围绕失败输入设计,避免无声漂移。

▸关键数据结构

packages/core/src/protocol.ts · L18-70TypeScript
  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;
深度阅读 · Context packet 与协议收敛 →
§ 02 · CORE MODULES

核心契约与运行时边界

▸契约入口如何收口

第一组代码展示输入被分类、清洗或注册的地方;这些函数决定后续流程拿到的是结构化对象,而不是自由文本。

packages/core/src/protocol.ts · L97-146TypeScript
  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");

▸执行/评测边界

第二组代码展示核心运行边界:请求体、运行输入、模型输出或本地写入都在这里被转换成下游可消费的形态。

packages/dispatcher/src/server.ts · L49-69TypeScript
  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}

▸状态与配置的交界

packages/dispatcher/src/server.ts · L179-217TypeScript
 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}`;
深度阅读 · Local runtime 与执行器选择 →
§ 03 · KEY FLOW

从平台消息到本地执行

关键流程从输入开始,经过规范化、执行或评分,再输出结果。源码里真正重要的是失败点是否被显式表达。

▸入口到规范化

[1] raw event / sample → parser → typed payload
[2] payload → policy / prompt / context → bounded execution
packages/local-runtime/src/runtime.ts · L7-36TypeScript
   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  };

▸结果回写或评分

result → validation → persistence / reply / aggregate score
packages/local-runtime/src/runtime.ts · L59-72TypeScript
  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  };

▸失败路径不是旁路

packages/runner/src/executor.ts · L13-70TypeScript
  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>;
§ 04 · TESTS & VALIDATION

测试与验证面

验证面主要看三类风险:输入格式漂移、外部工具失败、以及安全/路径/权限边界。

▸测试或防御性代码

packages/store/src/repository.ts · L1-48TypeScript
   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 实际路径。
§ 05 · HIGHLIGHTS

值得保留的设计

契约清晰
核心边界被拆到独立模块,读者可以直接从行号回溯。
失败可见
解析、评分、路径和权限错误大多被显式编码,而不是吞掉。
局部可替换
平台、模型或摘要后端通过配置/接口切换。
测试有抓手
单测围绕 parser、security、config 或 runner 这样的真实风险点。
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

这些风险来自源码结构和发布形态,不是对项目价值的否定。

文档与实现可能错位
README 的高层叙述需要用当前 clone 的源码重新核验。
边界依赖外部系统
平台 API、模型服务或 Claude Code 本身会引入非仓库内可控变量。
评测/摘要可被输入污染
长上下文、prompt tag、转录文本都需要清洗策略。
本地状态要谨慎
写入历史、SQLite 或工作树时必须保留路径与权限边界。
诚实记录 — 本页只解释当前源码能证明的内容;模型权重表现、平台 SLA 或未发布路线不作外推。
Generated · 2026-06-29Source · amplifthq/opentag← 返回 Trending 索引