evolclaw 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -2
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -27
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1069 -141
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +28 -0
  9. package/dist/aun/aid/store.js +1 -1
  10. package/dist/aun/storage/download.js +1 -1
  11. package/dist/aun/storage/upload.js +13 -1
  12. package/dist/channels/aun.js +406 -293
  13. package/dist/channels/dingtalk.js +77 -140
  14. package/dist/channels/feishu.js +97 -150
  15. package/dist/channels/qqbot.js +75 -138
  16. package/dist/channels/wechat.js +75 -136
  17. package/dist/channels/wecom.js +75 -138
  18. package/dist/cli/agent.js +8 -5
  19. package/dist/cli/index.js +177 -44
  20. package/dist/cli/init.js +33 -6
  21. package/dist/cli/model.js +1 -1
  22. package/dist/cli/stats.js +558 -0
  23. package/dist/cli/version.js +87 -0
  24. package/dist/cli/watch-msg.js +5 -2
  25. package/dist/config-store.js +12 -6
  26. package/dist/core/channel-loader.js +84 -82
  27. package/dist/core/command-handler.js +473 -114
  28. package/dist/core/evolagent-registry.js +1 -0
  29. package/dist/core/evolagent.js +1 -1
  30. package/dist/core/interaction-router.js +8 -0
  31. package/dist/core/message/command-handler-agent-control.js +63 -1
  32. package/dist/core/message/im-renderer.js +35 -13
  33. package/dist/core/message/items-formatter.js +9 -1
  34. package/dist/core/message/message-bridge.js +49 -21
  35. package/dist/core/message/message-log.js +1 -0
  36. package/dist/core/message/message-processor.js +295 -35
  37. package/dist/core/message/message-queue.js +2 -2
  38. package/dist/core/message/pending-hints.js +232 -0
  39. package/dist/core/message/response-depth.js +56 -0
  40. package/dist/core/model/model-catalog.js +1 -1
  41. package/dist/core/model/model-scope.js +2 -2
  42. package/dist/core/permission.js +9 -12
  43. package/dist/core/relation/peer-identity.js +16 -1
  44. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  45. package/dist/core/session/session-manager.js +27 -13
  46. package/dist/core/session/session-title.js +26 -0
  47. package/dist/core/stats/billing.js +151 -0
  48. package/dist/core/stats/budget.js +93 -0
  49. package/dist/core/stats/db.js +314 -0
  50. package/dist/core/stats/eck-vars.js +84 -0
  51. package/dist/core/stats/index.js +10 -0
  52. package/dist/core/stats/normalizer.js +78 -0
  53. package/dist/core/stats/query.js +760 -0
  54. package/dist/core/stats/writer.js +115 -0
  55. package/dist/core/trigger/manager.js +34 -0
  56. package/dist/core/trigger/parser.js +9 -3
  57. package/dist/core/trigger/scheduler.js +20 -17
  58. package/dist/{agents → eck}/manifest-engine.js +20 -1
  59. package/dist/{agents → eck}/message-renderer.js +24 -1
  60. package/dist/index.js +130 -8
  61. package/dist/ipc.js +17 -1
  62. package/dist/utils/cross-platform.js +23 -5
  63. package/dist/utils/ecweb-pair.js +20 -0
  64. package/dist/utils/stats.js +14 -0
  65. package/kits/docs/evolclaw/INDEX.md +3 -1
  66. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  67. package/kits/docs/evolclaw/fs.md +131 -0
  68. package/kits/docs/evolclaw/group-fs.md +209 -0
  69. package/kits/docs/evolclaw/stats.md +70 -0
  70. package/kits/docs/venues/aun-group.md +29 -6
  71. package/kits/docs/venues/group.md +5 -4
  72. package/kits/eck_manifest.json +12 -0
  73. package/kits/eck_message_manifest.json +30 -3
  74. package/kits/rules/05-venue.md +1 -1
  75. package/kits/templates/message-fragments/inject-default.md +2 -0
  76. package/kits/templates/system-fragments/response-depth.md +16 -0
  77. package/package.json +4 -4
  78. package/dist/agents/baseagent-normalize.js +0 -19
  79. package/dist/core/relation/peer-key.js +0 -16
  80. package/dist/evolclaw-config.js +0 -11
  81. package/dist/utils/channel-helpers.js +0 -46
  82. /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
  83. /package/dist/{agents → eck}/kit-renderer.js +0 -0
@@ -356,6 +356,7 @@ export class EvolAgentRegistry {
356
356
  toInfo(agent) {
357
357
  return {
358
358
  name: agent.name,
359
+ aid: agent.aid,
359
360
  status: agent.status,
360
361
  channels: agent.channelInstanceNames(),
361
362
  projectPath: agent.projectPath,
@@ -3,7 +3,7 @@ import { logger } from '../utils/logger.js';
3
3
  import { saveAgent } from '../config-store.js';
4
4
  import { formatChannelKey, tryParseChannelKey } from './channel-loader.js';
5
5
  import { agentPersonalDir } from '../paths.js';
6
- import { fileCache } from './cache/file-cache.js';
6
+ import { fileCache } from './daemon-file-cache.js';
7
7
  /**
8
8
  * EvolAgent —— 一个 self-agent 的运行时表示。
9
9
  *
@@ -68,6 +68,14 @@ export class InteractionRouter {
68
68
  const handler = this.handlers.get(response.id);
69
69
  if (!handler)
70
70
  return false;
71
+ // Initiator 校验(集中式 backstop):非发起者的操作直接丢弃,不消费 handler、不解除等待,
72
+ // 让真正的发起者仍可继续操作。身份只信渠道传入的已认证 operatorId(来自消息信封,非 payload 自报)。
73
+ // 渠道层若已自行校验(如飞书的 reject toast),此处不会重复命中(operatorId 已匹配)。
74
+ if (handler.initiatorId && response.operatorId
75
+ && response.operatorId !== handler.initiatorId) {
76
+ logger.info(`[InteractionRouter] rejected non-initiator: operator=${response.operatorId} initiator=${handler.initiatorId} id=${response.id}`);
77
+ return false;
78
+ }
71
79
  if (handler.timer)
72
80
  clearTimeout(handler.timer);
73
81
  this.handlers.delete(response.id);
@@ -1,7 +1,8 @@
1
- import { agentCreateNonInteractive, agentDelete, agentEnable, agentDisable, agentList, agentShow, agentSet, } from '../../cli/agent.js';
1
+ import { agentCreateNonInteractive, agentDelete, agentEnable, agentDisable, agentList, agentShow, agentSet, agentReload, } from '../../cli/agent.js';
2
2
  import { logger } from '../../utils/logger.js';
3
3
  import { resolvePaths } from '../../paths.js';
4
4
  import path from 'path';
5
+ import { loadAgent, saveAgent } from '../../config-store.js';
5
6
  import { CreateStatusWriter, readCreateStatus } from './create-status.js';
6
7
  /** 把 cli/agent.ts 的 error 字符串映射为结构化错误码 */
7
8
  function classifyError(error) {
@@ -116,8 +117,69 @@ export async function execAgentAction(action, args, peerId) {
116
117
  return { error: res.error, code: classifyError(res.error) };
117
118
  return { data: { aid: res.aid, enabled: res.enabled, reloaded: res.reloaded } };
118
119
  }
120
+ if (action === 'reload') {
121
+ if (!a.aid)
122
+ return { error: '缺少 aid', code: 'INVALID_ARGS' };
123
+ const res = await agentReload(a.aid);
124
+ if (!('ok' in res) || res.ok !== true)
125
+ return { error: res.error, code: classifyError(res.error) };
126
+ return { data: { aid: a.aid, reloaded: true } };
127
+ }
128
+ if (action === 'update') {
129
+ return await execAgentUpdate(a);
130
+ }
119
131
  return { error: `不支持的 action: ${action}`, code: 'INVALID_ARGS' };
120
132
  }
133
+ /** name=agent 的 menu.action=update:仅落盘 config patch,不触发 reload。
134
+ * 直接 loadAgent + saveAgent(不走 agentSet,避免其内部自动 evolagent.reload)——
135
+ * 重载由用户在 Agents 页操作列手动触发(带任务执行检查)。
136
+ * AUN 渠道绑定 agent 顶层 aid,不可通过 patch 编辑:拒绝改 aid、拒绝 channels 数组里出现 aun 条目。
137
+ * 可写字段:baseagents / projects / owners / chatmode / channels(非 aun)。 */
138
+ export async function execAgentUpdate(args) {
139
+ const a = args ?? {};
140
+ if (!a.aid)
141
+ return { error: '缺少 aid', code: 'INVALID_ARGS' };
142
+ const p = a.patch ?? {};
143
+ if (p.aid !== undefined) {
144
+ return { error: 'aid 不可修改(AUN 身份绑定,如需换 AID 请删除后重建)', code: 'INVALID_ARGS' };
145
+ }
146
+ if (Array.isArray(p.channels) && p.channels.some((c) => c?.type === 'aun')) {
147
+ return { error: 'AUN 渠道不可通过 patch 编辑(由 agent aid 隐式管理)', code: 'INVALID_ARGS' };
148
+ }
149
+ const config = loadAgent(a.aid);
150
+ if (!config)
151
+ return { error: `Agent "${a.aid}" not found`, code: 'NOT_FOUND' };
152
+ let touched = false;
153
+ if (p.baseagents !== undefined) {
154
+ config.baseagents = p.baseagents;
155
+ touched = true;
156
+ }
157
+ if (p.projects !== undefined) {
158
+ config.projects = p.projects;
159
+ touched = true;
160
+ }
161
+ if (p.owners !== undefined) {
162
+ config.owners = p.owners;
163
+ touched = true;
164
+ }
165
+ if (p.chatmode !== undefined) {
166
+ config.chatmode = p.chatmode;
167
+ touched = true;
168
+ }
169
+ if (p.channels !== undefined) {
170
+ config.channels = p.channels;
171
+ touched = true;
172
+ }
173
+ if (!touched)
174
+ return { error: 'patch 为空,无可写字段', code: 'INVALID_ARGS' };
175
+ try {
176
+ saveAgent(config);
177
+ }
178
+ catch (e) {
179
+ return { error: e?.message || String(e), code: classifyError(e?.message || String(e)) };
180
+ }
181
+ return { data: { aid: a.aid, saved: true } };
182
+ }
121
183
  /** project 兜底:显式值 > rootPath 合成 > defaultPath > undefined */
122
184
  export function resolveProjectPath(explicit, aid, defaults) {
123
185
  if (explicit && explicit.trim())
@@ -308,21 +308,24 @@ export class IMRenderer {
308
308
  clearTimeout(this.timer);
309
309
  this.timer = undefined;
310
310
  }
311
- // 上下文错误短语过滤:剔除错误关键词本身,保留前后内容
312
- const ctxErrPattern = /prompt is too long|input is too long|context too long|context limit|context_length_exceeded|上下文过长/gi;
313
- const stripCtxErr = (s) => s.replace(ctxErrPattern, '').trim();
314
- this.textBuffer = stripCtxErr(this.textBuffer);
315
- this.allText = stripCtxErr(this.allText);
316
- for (const item of this.itemsQueue) {
317
- if (item.kind === 'text')
318
- item.text = stripCtxErr(item.text);
319
- }
320
- // 文件标记过滤
321
- if (this.opts.fileMarkerPattern) {
322
- this.textBuffer = this.textBuffer.replace(this.opts.fileMarkerPattern, '').trim();
311
+ if (isFinal) {
312
+ // 上下文错误短语过滤:剔除错误关键词本身,保留前后内容。
313
+ // 只在最终 flush 清理,避免中间定时 flush trim 掉 Markdown 块级换行。
314
+ const ctxErrPattern = /prompt is too long|input is too long|context too long|context limit|context_length_exceeded|上下文过长/gi;
315
+ const stripCtxErr = (s) => s.replace(ctxErrPattern, '').trim();
316
+ this.textBuffer = stripCtxErr(this.textBuffer);
317
+ this.allText = stripCtxErr(this.allText);
323
318
  for (const item of this.itemsQueue) {
324
319
  if (item.kind === 'text')
325
- item.text = item.text.replace(this.opts.fileMarkerPattern, '');
320
+ item.text = stripCtxErr(item.text);
321
+ }
322
+ // 文件标记过滤
323
+ if (this.opts.fileMarkerPattern) {
324
+ this.textBuffer = this.textBuffer.replace(this.opts.fileMarkerPattern, '').trim();
325
+ for (const item of this.itemsQueue) {
326
+ if (item.kind === 'text')
327
+ item.text = item.text.replace(this.opts.fileMarkerPattern, '');
328
+ }
326
329
  }
327
330
  }
328
331
  // 清掉空 text items
@@ -356,6 +359,25 @@ export class IMRenderer {
356
359
  this.lastFlush = Date.now();
357
360
  this.flushCount++;
358
361
  }
362
+ // 1.5 非最终定时 flush:把已累积的文本块作为独立 result.text 发出。
363
+ // 每个 text 事件本身是完整语义块(runner 已合并流式 delta),工具调用前的
364
+ // 文本一向作为独立气泡发送(见 message-processor 的 flushText 调用)。
365
+ // 这里补上「文本块后面没有紧跟 tool_use」的情况——例如 readonly 拒绝写文件时
366
+ // SDK 直接拒绝、不产生 tool_use 事件,文本会一直滞留 buffer,直到下一个
367
+ // tool_use 才被 flushText 带出,并与其后的文本合并成一条(用户侧表现为:
368
+ // 第一条文本等待一分多钟后才和第二条凑成一条发出)。定时器到期即发,根除滞留。
369
+ if (!isFinal && this.textBuffer.length > 0) {
370
+ const text = this.textBuffer;
371
+ this.textBuffer = '';
372
+ const payload = { kind: 'result.text', text, isFinal: false };
373
+ this.sentContent = true;
374
+ this.sendChain = this.sendChain
375
+ .then(() => this.opts.send(payload))
376
+ .catch(e => logger.warn('[IMRenderer] timed result.text send failed:', e));
377
+ await this.sendChain;
378
+ this.lastFlush = Date.now();
379
+ this.flushCount++;
380
+ }
359
381
  // 2. isFinal=true 时单独发最终回复文本
360
382
  if (isFinal && finalText.length > 0) {
361
383
  const payload = { kind: 'result.text', text: finalText, isFinal: true };
@@ -34,7 +34,7 @@ function formatItem(item) {
34
34
  case 'tool_result': {
35
35
  if (!item.ok) {
36
36
  const errMsg = item.error || (typeof item.result === 'string' ? item.result : '执行失败');
37
- return `⚠️ ${item.name}: ${errMsg}`;
37
+ return `⚠️ ${item.name}: ${capLines(errMsg, 5)}`;
38
38
  }
39
39
  return item.text ? `✓ ${item.name}: ${item.text}` : `✓ ${item.name}`;
40
40
  }
@@ -48,6 +48,14 @@ function formatItem(item) {
48
48
  return '';
49
49
  }
50
50
  }
51
+ /** 把多行文本截断到最多 maxLines 行,超出部分用省略提示替代。用于工具报错输出,避免刷屏。 */
52
+ function capLines(text, maxLines) {
53
+ const lines = text.split('\n');
54
+ if (lines.length <= maxLines)
55
+ return text;
56
+ const omitted = lines.length - maxLines;
57
+ return lines.slice(0, maxLines).join('\n') + `\n…(省略 ${omitted} 行)`;
58
+ }
51
59
  function summarizeArgs(args) {
52
60
  if (!args || typeof args !== 'object')
53
61
  return '';
@@ -109,6 +109,10 @@ export class MessageBridge {
109
109
  return;
110
110
  // 3. session 解析(使用 Channel 层填充的 chatType)
111
111
  const chatType = msg.chatType || 'private';
112
+ if (!(await this.canCreateThreadSession(channelName, msg, chatType))) {
113
+ await sendReply(msg.channelId, '群聊中无权限创建话题', msg.replyContext);
114
+ return;
115
+ }
112
116
  const metadata = {};
113
117
  // 话题会话创建时写入 replyContext(用于 threadId 路由);主会话不写(避免群聊覆盖)
114
118
  if (msg.threadId && msg.replyContext)
@@ -134,7 +138,7 @@ export class MessageBridge {
134
138
  const owningAgent = this.agentRegistry?.resolveByChannel(channelName);
135
139
  const effectiveProjectPath = owningAgent?.projectPath
136
140
  ?? this.defaultProjectPath;
137
- const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType, undefined, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType);
141
+ const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, this.extractTopicName(msg), msg.peerId, chatType, undefined, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType);
138
142
  // 4. 群聊发送者标注由消息渲染层(message-renderer)逐条承担,不再在此硬编码前缀,
139
143
  // 消息日志因此保存干净原文。policy.messagePrefix 暂保留(未来清理)。
140
144
  // 5. 构造完整消息(channel 字段存实例名,用于 session 精确匹配)
@@ -143,6 +147,7 @@ export class MessageBridge {
143
147
  channelType: msg.channelType || effectiveChannelType,
144
148
  channelId: msg.channelId, content,
145
149
  selfAID: msg.selfAID,
150
+ agentId: session.agentId,
146
151
  chatType,
147
152
  images: msg.images, timestamp: Date.now(),
148
153
  peerId: msg.peerId, peerName: msg.peerName,
@@ -152,25 +157,30 @@ export class MessageBridge {
152
157
  sameEgressIp: msg.sameEgressIp,
153
158
  messageId: msg.messageId,
154
159
  mentions: msg.mentions, mentionAids: msg.mentionAids, threadId: msg.threadId,
160
+ topicName: this.extractTopicName(msg),
155
161
  replyContext: msg.replyContext,
162
+ source: msg.source,
163
+ dispatchMode: msg.dispatchMode,
156
164
  };
157
- // 5.5 写入消息记录(入方向)
158
- const chatDir = this.sessionManager.getChatDir(session);
159
- const inboundEncrypt = msg.replyContext?.metadata?.encrypted != null ? !!(msg.replyContext.metadata.encrypted) : undefined;
160
- const inboundChatmode = msg.replyContext?.metadata?.chatmode;
161
- appendMessageLog(chatDir, buildInboundEntry({
162
- from: msg.peerId || 'unknown',
163
- to: msg.selfAID || 'self',
164
- chatType,
165
- groupId: msg.groupId ?? null,
166
- msgId: msg.messageId ?? null,
167
- content,
168
- replyTo: msg.replyContext?.replyToMessageId ?? null,
169
- permMode: session.identity?.role ?? null,
170
- timestamp: fullMessage.timestamp,
171
- encrypt: inboundEncrypt,
172
- chatmode: inboundChatmode,
173
- }));
165
+ // 5.5 写入消息记录(入方向)。
166
+ {
167
+ const chatDir = this.sessionManager.getChatDir(session);
168
+ const inboundEncrypt = msg.replyContext?.metadata?.encrypted != null ? !!(msg.replyContext.metadata.encrypted) : undefined;
169
+ const inboundChatmode = msg.replyContext?.metadata?.chatmode;
170
+ appendMessageLog(chatDir, buildInboundEntry({
171
+ from: msg.peerId || 'unknown',
172
+ to: msg.selfAID || 'self',
173
+ chatType,
174
+ groupId: msg.groupId ?? null,
175
+ msgId: msg.messageId ?? null,
176
+ content,
177
+ replyTo: msg.replyContext?.replyToMessageId ?? null,
178
+ permMode: session.identity?.role ?? null,
179
+ timestamp: fullMessage.timestamp,
180
+ encrypt: inboundEncrypt,
181
+ chatmode: inboundChatmode,
182
+ }));
183
+ }
174
184
  // 6. ACK + debounce/enqueue
175
185
  // ACK 在到达时立即做(每条独立 ACK),不等合并
176
186
  // Interrupt 模式(单聊)→ 入队前 debounce 合并
@@ -209,6 +219,7 @@ export class MessageBridge {
209
219
  static MENU_NAME_MAP = {
210
220
  pwd: '/pwd',
211
221
  session: '/session',
222
+ topic: '/topic',
212
223
  baseagent: '/baseagent',
213
224
  model: '/model',
214
225
  effort: '/effort',
@@ -221,6 +232,23 @@ export class MessageBridge {
221
232
  agent: '/agent',
222
233
  trigger: '/trigger',
223
234
  };
235
+ extractTopicName(msg) {
236
+ const raw = msg.topicName
237
+ ?? msg.replyContext?.title
238
+ ?? msg.replyContext?.metadata?.topicName
239
+ ?? msg.replyContext?.metadata?.title;
240
+ const name = typeof raw === 'string' ? raw.trim() : '';
241
+ return name || undefined;
242
+ }
243
+ async canCreateThreadSession(channel, msg, chatType) {
244
+ if (chatType !== 'group' || !msg.threadId)
245
+ return true;
246
+ const existing = await this.sessionManager.getThreadSession(channel, msg.channelId, msg.threadId);
247
+ if (existing)
248
+ return true;
249
+ const role = this.sessionManager.resolveIdentity(channel, msg.peerId).role;
250
+ return role === 'owner' || role === 'admin';
251
+ }
224
252
  resolveCmd(name, cmd) {
225
253
  if (cmd)
226
254
  return cmd;
@@ -278,7 +306,7 @@ export class MessageBridge {
278
306
  const { id, name, cmd } = req;
279
307
  try {
280
308
  const resolvedCmd = this.resolveCmd(name, cmd);
281
- const result = await this.cmdHandler.execMenuQuery(resolvedCmd, channel, msg.channelId, msg.peerId, req.args);
309
+ const result = await this.cmdHandler.execMenuQuery(resolvedCmd, channel, msg.channelId, msg.peerId, req.args, msg.chatType);
282
310
  if ('error' in result)
283
311
  throw { code: result.code || 'EXEC_FAILED', message: result.error };
284
312
  await this.sendMenuResponse(adapter, channel, msg.channelId, { type: 'menu.response', id, name, data: result.data }, sendReply);
@@ -294,7 +322,7 @@ export class MessageBridge {
294
322
  const { id, name, cmd } = req;
295
323
  try {
296
324
  const resolvedCmd = this.resolveCmd(name, cmd);
297
- const data = await this.cmdHandler.getSubMenuItems(resolvedCmd, channel, msg.channelId, msg.peerId, req.args) ?? [];
325
+ const data = await this.cmdHandler.getSubMenuItems(resolvedCmd, channel, msg.channelId, msg.peerId, req.args, undefined, msg.chatType) ?? [];
298
326
  await this.sendMenuResponse(adapter, channel, msg.channelId, { type: 'menu.response', id, name, data }, sendReply);
299
327
  }
300
328
  catch (err) {
@@ -328,7 +356,7 @@ export class MessageBridge {
328
356
  if (!action)
329
357
  throw { code: 'MISSING_VALUE', message: '缺少 action 参数' };
330
358
  const resolvedCmd = this.resolveCmd(name, cmd);
331
- const result = await this.cmdHandler.execMenuAction(resolvedCmd, action, args, channel, msg.channelId, msg.peerId);
359
+ const result = await this.cmdHandler.execMenuAction(resolvedCmd, action, args, channel, msg.channelId, msg.peerId, undefined, msg.chatType);
332
360
  if ('error' in result)
333
361
  throw { code: result.code || 'EXEC_FAILED', message: result.error };
334
362
  await this.sendMenuResponse(adapter, channel, msg.channelId, { type: 'menu.response', id, name, data: result.data }, sendReply);
@@ -68,6 +68,7 @@ export function buildInboundEntry(opts) {
68
68
  durationMs: null,
69
69
  encrypt: opts.encrypt,
70
70
  chatmode: opts.chatmode,
71
+ source: opts.source,
71
72
  };
72
73
  }
73
74
  export function buildOutboundEntry(opts) {