evolcore 0.0.3 → 0.0.4

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 (143) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +45 -10
  3. package/bin/ec-safe-output.js +89 -24
  4. package/dist/agents/baseagent.js +46 -0
  5. package/dist/agents/claude-runner.js +1 -31
  6. package/dist/agents/codex-app-server-client.js +68 -0
  7. package/dist/agents/codex-runner.js +157 -5
  8. package/dist/agents/runner-types.js +2 -2
  9. package/dist/aun/aid/agentmd.js +79 -0
  10. package/dist/aun/aid/encryption-seed-policy.js +29 -0
  11. package/dist/aun/aid/index.js +1 -1
  12. package/dist/aun/aid/store.js +2 -5
  13. package/dist/channels/aun.js +220 -112
  14. package/dist/channels/daemon.js +18 -4
  15. package/dist/channels/dingtalk.js +52 -137
  16. package/dist/channels/feishu.js +56 -4
  17. package/dist/channels/qqbot.js +23 -1
  18. package/dist/channels/wechat.js +303 -155
  19. package/dist/channels/wecom.js +383 -7
  20. package/dist/cli/aun-commands.js +11 -11
  21. package/dist/cli/handoff-command.js +2 -1
  22. package/dist/cli/init-channel.js +185 -67
  23. package/dist/cli/init.js +7 -5
  24. package/dist/cli/trigger-command.js +92 -11
  25. package/dist/config/access-policy-domain.js +38 -0
  26. package/dist/config/access-policy.js +134 -0
  27. package/dist/config/builtin-role-templates.js +27 -14
  28. package/dist/config/builtin-roles.js +25 -6
  29. package/dist/config/config-field-policy.js +17 -5
  30. package/dist/config/config-manager.js +198 -22
  31. package/dist/config/config-operation-service.js +35 -38
  32. package/dist/config/contact-bind-code.js +274 -0
  33. package/dist/config/contact-book-store.js +173 -5
  34. package/dist/config/contact-book.js +32 -0
  35. package/dist/config/contact-operation-service.js +9 -3
  36. package/dist/config/contact-request-service.js +331 -0
  37. package/dist/config/peer-role-resolver.js +3 -1
  38. package/dist/config/schema-registry.js +49 -24
  39. package/dist/config-store.js +14 -2
  40. package/dist/core/auth/agent-delegation.js +105 -11
  41. package/dist/core/auth/authorization-audit.js +6 -0
  42. package/dist/core/auth/operation-authorizer.js +8 -0
  43. package/dist/core/auth/operation-catalog.js +51 -3
  44. package/dist/core/auth/trigger-authorization.js +15 -0
  45. package/dist/core/bootstrap-service.js +2 -9
  46. package/dist/core/channel-loader.js +6 -2
  47. package/dist/core/command/command-handler.js +10 -13
  48. package/dist/core/command/connect-menu.js +249 -24
  49. package/dist/core/command/evol-menu-version-gate.js +38 -0
  50. package/dist/core/command/group-menu.js +421 -0
  51. package/dist/core/command/menu-handler.js +269 -85
  52. package/dist/core/command/menu-protocol.js +8 -2
  53. package/dist/core/command/menu-token-store.js +102 -0
  54. package/dist/core/command/slash-gate.js +1 -1
  55. package/dist/core/command/slash-handler.js +158 -31
  56. package/dist/core/daemon-file-cache.js +28 -0
  57. package/dist/core/event-catalog.js +29 -0
  58. package/dist/core/evolagent-registry.js +4 -39
  59. package/dist/core/handoff/runtime.js +162 -37
  60. package/dist/core/handoff/store.js +46 -2
  61. package/dist/core/handoff/types.js +1 -0
  62. package/dist/core/inference/text-inference.js +16 -74
  63. package/dist/core/message/file-markers.js +7 -0
  64. package/dist/core/message/im-renderer.js +18 -14
  65. package/dist/core/message/inbound-admission.js +124 -0
  66. package/dist/core/message/message-bridge.js +616 -74
  67. package/dist/core/message/message-log.js +1 -0
  68. package/dist/core/message/message-queue.js +185 -10
  69. package/dist/core/message/peer-mode.js +7 -8
  70. package/dist/core/message/response-engine.js +381 -79
  71. package/dist/core/permission/approval-gateway.js +17 -10
  72. package/dist/core/permission/ec-command-parser.js +101 -46
  73. package/dist/core/permission/tool-policy.js +69 -24
  74. package/dist/core/protected-paths.js +36 -11
  75. package/dist/core/session/session-manager.js +25 -3
  76. package/dist/core/session/session-mapper.js +2 -0
  77. package/dist/core/session/session-renew.js +125 -69
  78. package/dist/core/session/session-turn-coordinator.js +5 -1
  79. package/dist/eck/kit-renderer.js +12 -1
  80. package/dist/eck/message-renderer.js +79 -1
  81. package/dist/index.js +224 -89
  82. package/dist/ipc.js +81 -4
  83. package/dist/paths.js +3 -0
  84. package/dist/response-system/config-resolver.js +29 -0
  85. package/dist/response-system/coordinator.js +8 -32
  86. package/dist/response-system/engines/v1/proactive-flow.js +1 -1
  87. package/dist/response-system/index.js +1 -0
  88. package/dist/response-system/modes/single-session/index.js +7 -4
  89. package/dist/trigger/parser.js +55 -15
  90. package/dist/trigger/patch.js +4 -1
  91. package/dist/trigger/scheduler.js +441 -39
  92. package/dist/utils/error-dict.json +7 -0
  93. package/dist/utils/evolcore-version.js +21 -0
  94. package/dist/utils/logger.js +2 -2
  95. package/dist/utils/stable-semver.js +21 -0
  96. package/dist/utils/stats.js +33 -9
  97. package/kits/docs/channels/aun.md +4 -13
  98. package/kits/docs/evolcore/INDEX.md +1 -1
  99. package/kits/docs/evolcore/config.md +47 -2
  100. package/kits/docs/evolcore/contact.md +7 -3
  101. package/kits/docs/evolcore/group-rules.md +46 -4
  102. package/kits/docs/evolcore/group.md +4 -4
  103. package/kits/docs/evolcore/msg.md +5 -5
  104. package/kits/docs/evolcore/trigger.md +25 -8
  105. package/kits/docs/path-registry.md +36 -17
  106. package/kits/eck_message_manifest.json +12 -1
  107. package/kits/migrations/migrate-contact-book-v2.mjs +7 -0
  108. package/kits/rules/01-overview.md +17 -7
  109. package/kits/rules/02-navigation.md +38 -18
  110. package/kits/rules/03-identity.md +28 -24
  111. package/kits/rules/04-relation.md +44 -28
  112. package/kits/rules/05-venue.md +31 -15
  113. package/kits/rules/06-channel.md +11 -7
  114. package/kits/schemas/_meta.json +18 -7
  115. package/kits/schemas/agent-config.schema.7.json +303 -0
  116. package/kits/schemas/agent-config.schema.8.json +304 -0
  117. package/kits/schemas/agent-config.schema.9.json +374 -0
  118. package/kits/schemas/contact-book.schema.3.json +67 -0
  119. package/kits/schemas/daemon.schema.1.json +3 -2
  120. package/kits/schemas/daemon.schema.2.json +101 -0
  121. package/kits/schemas/daemon.schema.3.json +123 -0
  122. package/kits/schemas/defaults.schema.2.json +85 -0
  123. package/kits/schemas/defaults.schema.3.json +73 -0
  124. package/kits/schemas/relation-config.schema.6.json +46 -0
  125. package/kits/schemas/relation-config.schema.7.json +59 -0
  126. package/kits/schemas/single-session.schema.2.json +57 -0
  127. package/kits/templates/message-fragments/handoff-context-to-target.md +9 -0
  128. package/kits/templates/message-fragments/handoff-request-to-target.md +14 -8
  129. package/kits/templates/message-fragments/handoff-response-to-origin.md +5 -6
  130. package/kits/templates/roles/admin.json +46 -0
  131. package/kits/templates/roles/member.json +4 -0
  132. package/kits/templates/roles/visitor.json +4 -0
  133. package/kits/templates/system-fragments/channel.md +12 -2
  134. package/kits/templates/system-fragments/identity.md +3 -1
  135. package/kits/templates/system-fragments/relation.md +1 -1
  136. package/kits/templates/system-fragments/session.md +6 -2
  137. package/package.json +4 -2
  138. package/MIGRATION-0.5.0.md +0 -378
  139. package/ROLE_ACCESS_CONTROL.md +0 -174
  140. package/dist/channels/contact-bind-code.js +0 -134
  141. package/dist/channels/wecom-card.js +0 -101
  142. package/dist/channels/wecom-onboarding.js +0 -82
  143. package/dist/channels/wecom-state.js +0 -191
@@ -0,0 +1,274 @@
1
+ import { randomInt } from 'crypto';
2
+ import { isValidAid } from '../aun/aid/validation.js';
3
+ import { isAcceptedContact, resolveContactView } from './contact-book.js';
4
+ import { formatContactAlias, normalizeContactChannelKey } from './contact-alias.js';
5
+ import { mutateContactBookSync } from './contact-book-store.js';
6
+ export const CONTACT_BIND_CODE_TTL_MS = 10 * 60 * 1000;
7
+ export const CONTACT_BIND_MAX_FAILED_ATTEMPTS = 5;
8
+ export const CONTACT_BIND_MAX_PENDING_PER_CHANNEL = 100;
9
+ export const CONTACT_BIND_MAX_PENDING_TOTAL = 5_000;
10
+ export const CONTACT_BIND_MAX_INVALID_ACTORS = 1_000;
11
+ /** In-memory, one-time channel identity proof used before writing a Contact alias. */
12
+ export class ContactBindCodeRegistry {
13
+ options;
14
+ /** `selfAid + channelName + primaryId` -> pending request. */
15
+ pendingByPrimary = new Map();
16
+ /** `selfAid + channelName + code` -> pending request key. */
17
+ pendingByCode = new Map();
18
+ /** Scope -> pending request keys, used for bounded cleanup and capacity checks. */
19
+ pendingKeysByScope = new Map();
20
+ /** Unknown-code throttling is deliberately separate from any pending request. */
21
+ invalidAttemptsByActor = new Map();
22
+ constructor(options) {
23
+ this.options = options;
24
+ }
25
+ register(req) {
26
+ const selfAid = String(req.selfAid || '').trim();
27
+ const primaryId = String(req.primaryId || '').trim();
28
+ const now = req.now ?? Date.now();
29
+ const requestedCode = req.code ? String(req.code).trim() : undefined;
30
+ if (!isValidAid(selfAid))
31
+ return { ok: false, error: `invalid selfAid: ${req.selfAid}` };
32
+ const channelName = normalizeContactChannelKey(selfAid, this.options.channelType, req.channelName);
33
+ if (!channelName)
34
+ return { ok: false, error: `invalid channelName: ${req.channelName}` };
35
+ if (!isValidAid(primaryId))
36
+ return { ok: false, error: `invalid primaryId: ${req.primaryId}` };
37
+ const contact = resolveContactView(selfAid, primaryId);
38
+ if (!contact.isOwner && !isAcceptedContact(selfAid, primaryId)) {
39
+ return { ok: false, error: `only an active contact or Agent owner can bind an identity: ${primaryId}` };
40
+ }
41
+ if (requestedCode && !/^\d{6}$/.test(requestedCode))
42
+ return { ok: false, error: 'binding code must be 6 digits' };
43
+ const scope = pendingScopeKey(selfAid, channelName);
44
+ this.purgeExpiredAll(now);
45
+ const key = pendingBindKey(selfAid, channelName, primaryId);
46
+ const replaced = this.pendingByPrimary.has(key);
47
+ const code = requestedCode ?? this.generateUniqueCode(scope);
48
+ if (!code)
49
+ return { ok: false, error: 'unable to allocate a unique binding code' };
50
+ const codeKey = pendingCodeKey(scope, code);
51
+ const existingCodeOwner = this.pendingByCode.get(codeKey);
52
+ if (existingCodeOwner && existingCodeOwner !== key) {
53
+ return { ok: false, error: 'binding code is already active for this channel instance' };
54
+ }
55
+ const pendingInScope = this.pendingKeysByScope.get(scope)?.size ?? 0;
56
+ if (!replaced && pendingInScope >= CONTACT_BIND_MAX_PENDING_PER_CHANNEL) {
57
+ return { ok: false, error: 'too many pending binding requests for this channel instance' };
58
+ }
59
+ if (!replaced && this.pendingByPrimary.size >= CONTACT_BIND_MAX_PENDING_TOTAL) {
60
+ return { ok: false, error: 'too many pending binding requests in this process' };
61
+ }
62
+ if (replaced)
63
+ this.removePending(key);
64
+ const item = {
65
+ selfAid,
66
+ channelName,
67
+ primaryId,
68
+ code,
69
+ createdAt: now,
70
+ expiresAt: now + CONTACT_BIND_CODE_TTL_MS,
71
+ };
72
+ this.pendingByPrimary.set(key, item);
73
+ this.pendingByCode.set(codeKey, key);
74
+ const scopeKeys = this.pendingKeysByScope.get(scope) ?? new Set();
75
+ scopeKeys.add(key);
76
+ this.pendingKeysByScope.set(scope, scopeKeys);
77
+ return { ok: true, code, expiresAt: item.expiresAt, replaced };
78
+ }
79
+ handle(ctx) {
80
+ if (ctx.channelType !== this.options.channelType)
81
+ return { handled: false };
82
+ const selfAid = String(ctx.selfAid || '').trim();
83
+ const channelName = normalizeContactChannelKey(selfAid, this.options.channelType, ctx.channelName);
84
+ if (!channelName)
85
+ return { handled: false };
86
+ const now = ctx.now ?? Date.now();
87
+ if (ctx.chatType !== 'private')
88
+ return { handled: false };
89
+ const input = String(ctx.content ?? '').trim();
90
+ const scope = pendingScopeKey(selfAid, channelName);
91
+ const codeKey = /^\d{6}$/.test(input) ? pendingCodeKey(scope, input) : undefined;
92
+ const pendingKey = codeKey ? this.pendingByCode.get(codeKey) : undefined;
93
+ const matchingItem = pendingKey ? this.pendingByPrimary.get(pendingKey) : undefined;
94
+ if (matchingItem && now >= matchingItem.expiresAt) {
95
+ this.removePending(pendingKey);
96
+ return {
97
+ handled: true,
98
+ status: 'expired',
99
+ remainingAttempts: 0,
100
+ reply: this.expiredReply(),
101
+ };
102
+ }
103
+ this.purgeExpiredAll(now);
104
+ const hasPending = (this.pendingKeysByScope.get(scope)?.size ?? 0) > 0;
105
+ if (!hasPending)
106
+ return { handled: false };
107
+ if (!/^\d{6}$/.test(input)) {
108
+ return {
109
+ handled: true,
110
+ status: 'format',
111
+ reply: '请发送 /bind <6 位数字绑定码>。',
112
+ remainingAttempts: CONTACT_BIND_MAX_FAILED_ATTEMPTS,
113
+ };
114
+ }
115
+ if (!matchingItem) {
116
+ const invalidAttempt = this.recordInvalidAttempt(scope, String(ctx.actorId || '').trim(), now);
117
+ if (invalidAttempt.remaining > 0) {
118
+ return {
119
+ handled: true,
120
+ status: 'wrong-code',
121
+ remainingAttempts: invalidAttempt.remaining,
122
+ reply: '绑定码错误,请重新发送 /bind <6 位数字绑定码>。',
123
+ };
124
+ }
125
+ return {
126
+ handled: true,
127
+ status: 'rate-limited',
128
+ remainingAttempts: 0,
129
+ reply: '绑定码错误次数过多,请稍后再试。有效绑定码仍可直接提交。',
130
+ };
131
+ }
132
+ const actorId = String(ctx.actorId || '').trim();
133
+ if (!actorId) {
134
+ return {
135
+ handled: true,
136
+ status: 'missing-actor',
137
+ remainingAttempts: CONTACT_BIND_MAX_FAILED_ATTEMPTS,
138
+ reply: `无法识别当前${this.options.displayName}发送者身份,未建立绑定。请重新发送绑定码或重新执行绑定流程。`,
139
+ };
140
+ }
141
+ // Claim first. A matching one-time code is never reusable, including when the
142
+ // final Contact Book validation or write fails.
143
+ this.removePending(pendingKey);
144
+ try {
145
+ const alias = formatContactAlias(matchingItem.channelName, actorId);
146
+ mutateContactBookSync({
147
+ selfAid: matchingItem.selfAid,
148
+ actor: actorId,
149
+ mutation: { type: 'bind-alias-eligible', primaryId: matchingItem.primaryId, alias },
150
+ });
151
+ return {
152
+ handled: true,
153
+ status: 'bound',
154
+ reply: `${this.options.displayName}身份绑定成功:${matchingItem.channelName}:${encodeURIComponent(actorId)} -> ${matchingItem.primaryId}`,
155
+ };
156
+ }
157
+ catch (error) {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ return {
160
+ handled: true,
161
+ status: 'write-failed',
162
+ remainingAttempts: CONTACT_BIND_MAX_FAILED_ATTEMPTS,
163
+ reply: `${this.options.displayName}身份绑定写入失败:${message}`,
164
+ };
165
+ }
166
+ }
167
+ get(selfAid, channelName, primaryId) {
168
+ const normalizedSelfAid = String(selfAid || '').trim();
169
+ const normalizedChannelName = normalizeContactChannelKey(normalizedSelfAid, this.options.channelType, channelName);
170
+ if (!normalizedChannelName)
171
+ return null;
172
+ const scope = pendingScopeKey(normalizedSelfAid, normalizedChannelName);
173
+ const key = primaryId
174
+ ? pendingBindKey(normalizedSelfAid, normalizedChannelName, primaryId)
175
+ : this.pendingKeysByScope.get(scope)?.values().next().value;
176
+ const item = key ? this.pendingByPrimary.get(key) : undefined;
177
+ return item ? { ...item } : null;
178
+ }
179
+ clear() {
180
+ this.pendingByPrimary.clear();
181
+ this.pendingByCode.clear();
182
+ this.pendingKeysByScope.clear();
183
+ this.invalidAttemptsByActor.clear();
184
+ }
185
+ noPendingReply() {
186
+ return `当前没有待验证的${this.options.displayName}身份绑定码。请先在目标 Agent 的 AUN 私聊发送 /bind ${this.options.channelType},或执行 ${this.options.initCommand} 生成新的绑定码。`;
187
+ }
188
+ generateUniqueCode(scope) {
189
+ for (let attempt = 0; attempt < 100; attempt += 1) {
190
+ const code = generateBindCode();
191
+ if (!this.pendingByCode.has(pendingCodeKey(scope, code)))
192
+ return code;
193
+ }
194
+ return null;
195
+ }
196
+ purgeExpiredAll(now) {
197
+ for (const [key, item] of this.pendingByPrimary) {
198
+ if (!item || now >= item.expiresAt)
199
+ this.removePending(key);
200
+ }
201
+ }
202
+ removePending(key) {
203
+ const item = this.pendingByPrimary.get(key);
204
+ if (!item)
205
+ return;
206
+ const scope = pendingScopeKey(item.selfAid, item.channelName);
207
+ this.pendingByPrimary.delete(key);
208
+ this.pendingByCode.delete(pendingCodeKey(scope, item.code));
209
+ const scopeKeys = this.pendingKeysByScope.get(scope);
210
+ scopeKeys?.delete(key);
211
+ if (scopeKeys?.size === 0)
212
+ this.pendingKeysByScope.delete(scope);
213
+ }
214
+ recordInvalidAttempt(scope, actorId, now) {
215
+ const key = `${scope}\u0000${actorId || '<unknown>'}`;
216
+ this.purgeExpiredInvalidAttempts(now);
217
+ const current = this.invalidAttemptsByActor.get(key);
218
+ const attempts = current && now < current.expiresAt ? current.attempts + 1 : 1;
219
+ this.invalidAttemptsByActor.delete(key);
220
+ if (this.invalidAttemptsByActor.size >= CONTACT_BIND_MAX_INVALID_ACTORS) {
221
+ const oldestKey = this.invalidAttemptsByActor.keys().next().value;
222
+ if (oldestKey)
223
+ this.invalidAttemptsByActor.delete(oldestKey);
224
+ }
225
+ this.invalidAttemptsByActor.set(key, { attempts, expiresAt: now + CONTACT_BIND_CODE_TTL_MS });
226
+ return { remaining: Math.max(0, CONTACT_BIND_MAX_FAILED_ATTEMPTS - attempts) };
227
+ }
228
+ purgeExpiredInvalidAttempts(now) {
229
+ for (const [key, value] of this.invalidAttemptsByActor) {
230
+ if (now >= value.expiresAt)
231
+ this.invalidAttemptsByActor.delete(key);
232
+ }
233
+ }
234
+ expiredReply() {
235
+ return `${this.options.displayName}身份绑定码已超时。请重新在 AUN 私聊目标 Agent 发送 /bind ${this.options.channelType},或执行 ${this.options.initCommand} 并再次绑定。`;
236
+ }
237
+ }
238
+ /**
239
+ * Reserve `/bind` for channel identity binding so it is never treated as an
240
+ * ordinary chat message. A pending request determines whether the command can
241
+ * complete a binding; otherwise the user receives setup guidance.
242
+ */
243
+ export function handleExplicitContactBindCommand(registry, ctx) {
244
+ const match = /^\s*\/bind(?:\s+(.+?))?\s*$/i.exec(String(ctx.content ?? ''));
245
+ if (!match)
246
+ return { handled: false };
247
+ if (ctx.chatType !== 'private') {
248
+ return {
249
+ handled: true,
250
+ status: 'private-only',
251
+ reply: '身份绑定仅支持私聊机器人。请在私聊中发送 /bind <6 位数字绑定码>。',
252
+ };
253
+ }
254
+ const result = registry.handle({ ...ctx, content: match[1] ?? '' });
255
+ if (result.handled)
256
+ return result;
257
+ return {
258
+ handled: true,
259
+ status: 'no-pending',
260
+ reply: registry.noPendingReply(),
261
+ };
262
+ }
263
+ function generateBindCode() {
264
+ return String(randomInt(0, 1_000_000)).padStart(6, '0');
265
+ }
266
+ function pendingScopeKey(selfAid, channelName) {
267
+ return `${String(selfAid || '').trim()}\u0000${String(channelName || '').trim()}`;
268
+ }
269
+ function pendingBindKey(selfAid, channelName, primaryId) {
270
+ return `${pendingScopeKey(selfAid, channelName)}\u0000${String(primaryId || '').trim()}`;
271
+ }
272
+ function pendingCodeKey(scope, code) {
273
+ return `${scope}\u0000${code}`;
274
+ }
@@ -36,9 +36,32 @@ export function readContactRevision(selfAid) {
36
36
  export function mutateContactBook(request) {
37
37
  return enqueueContactMutation(request, []);
38
38
  }
39
+ /**
40
+ * Prepare a mutation while holding the same cross-process lock used by every Contact Book write.
41
+ * The callback must stay synchronous so the lock is never retained across an await boundary.
42
+ */
43
+ export function mutateContactBookPrepared(selfAid, prepare) {
44
+ const normalizedAid = normalizeAid(selfAid, 'self AID');
45
+ const previous = mutationTails.get(normalizedAid) ?? Promise.resolve();
46
+ let release;
47
+ const tail = new Promise(resolve => { release = resolve; });
48
+ mutationTails.set(normalizedAid, tail);
49
+ return previous
50
+ .catch(() => { })
51
+ .then(() => mutateContactBookPreparedSyncInternal(normalizedAid, prepare))
52
+ .finally(() => {
53
+ release();
54
+ if (mutationTails.get(normalizedAid) === tail)
55
+ mutationTails.delete(normalizedAid);
56
+ });
57
+ }
39
58
  export function mutateContactBookTransaction(request, changes) {
40
59
  return enqueueContactMutation(request, changes);
41
60
  }
61
+ /** Synchronous transaction variant for configuration writes that must commit atomically with contacts. */
62
+ export function mutateContactBookTransactionSync(request, changes) {
63
+ return mutateContactBookSyncInternal(request, changes);
64
+ }
42
65
  function enqueueContactMutation(request, changes) {
43
66
  const selfAid = normalizeAid(request.selfAid, 'self AID');
44
67
  const previous = mutationTails.get(selfAid) ?? Promise.resolve();
@@ -139,8 +162,79 @@ function mutateContactBookSyncInternal(request, transactionChanges) {
139
162
  release();
140
163
  }
141
164
  }
165
+ function mutateContactBookPreparedSyncInternal(selfAid, prepare) {
166
+ const release = acquireAgentLock(selfAid);
167
+ try {
168
+ recoverMutation(selfAid);
169
+ const file = agentContactConfig(selfAid);
170
+ const current = readContactBookFromDisk(selfAid);
171
+ const previousRevision = contactRevision(current);
172
+ const next = cloneBook(current);
173
+ const prepared = prepare({ contact: next, contactRevision: previousRevision });
174
+ if (prepared.mutation)
175
+ applyMutation(next, prepared.mutation, selfAid);
176
+ validateContactBook(next, selfAid);
177
+ const nextRevision = contactRevision(next);
178
+ if (nextRevision === previousRevision) {
179
+ return {
180
+ changed: false,
181
+ contactRevision: previousRevision,
182
+ previousContactRevision: previousRevision,
183
+ contact: next,
184
+ value: prepared.value,
185
+ };
186
+ }
187
+ prepared.beforeCommit?.({
188
+ contact: next,
189
+ previousContactRevision: previousRevision,
190
+ contactRevision: nextRevision,
191
+ });
192
+ const before = readTextOrNull(file);
193
+ const after = `${JSON.stringify(next, null, 2)}\n`;
194
+ const changes = [{ file, before, after }];
195
+ writeJournal(selfAid, { schemaVersion: 1, state: 'prepared', selfAid, changes });
196
+ try {
197
+ applyTransactionChange(changes[0]);
198
+ writeJournal(selfAid, { schemaVersion: 1, state: 'committed', selfAid, changes });
199
+ removeJournal(selfAid);
200
+ }
201
+ catch (error) {
202
+ recoverMutation(selfAid);
203
+ throw error;
204
+ }
205
+ notifyConfigWrite(ConfigTarget.Contact, { self: selfAid });
206
+ appendAudit(selfAid, {
207
+ selfAid,
208
+ actor: prepared.actor,
209
+ mutation: prepared.mutation,
210
+ }, previousRevision, nextRevision);
211
+ return {
212
+ changed: true,
213
+ contactRevision: nextRevision,
214
+ previousContactRevision: previousRevision,
215
+ contact: next,
216
+ value: prepared.value,
217
+ };
218
+ }
219
+ finally {
220
+ release();
221
+ }
222
+ }
142
223
  function applyMutation(book, mutation, selfAid) {
143
224
  const contacts = book.contacts;
225
+ if (mutation.type === 'batch') {
226
+ for (const item of mutation.mutations)
227
+ applyMutation(book, item, selfAid);
228
+ return;
229
+ }
230
+ if (mutation.type === 'ensure-contacts') {
231
+ for (const rawPrimaryId of mutation.primaryIds) {
232
+ const primaryId = normalizeContactAid(rawPrimaryId);
233
+ if (!contacts[primaryId])
234
+ contacts[primaryId] = {};
235
+ }
236
+ return;
237
+ }
144
238
  const primaryId = normalizeContactAid(mutation.primaryId);
145
239
  const existing = contacts[primaryId];
146
240
  if (mutation.type === 'set-status') {
@@ -150,6 +244,53 @@ function applyMutation(book, mutation, selfAid) {
150
244
  contacts[primaryId] = compactEntry({
151
245
  ...existing,
152
246
  status: mutation.status === 'active' ? undefined : mutation.status,
247
+ pendingRequest: undefined,
248
+ declinedAt: mutation.status === 'declined' ? existing?.declinedAt ?? new Date().toISOString() : undefined,
249
+ });
250
+ return;
251
+ }
252
+ if (mutation.type === 'set-request') {
253
+ if (isOwner(selfAid, primaryId)) {
254
+ throw new ContactMutationError('OWNER_PROTECTED', 'An Agent owner cannot submit a contact request');
255
+ }
256
+ contacts[primaryId] = compactEntry({
257
+ ...existing,
258
+ status: 'pending',
259
+ pendingRequest: { ...mutation.request },
260
+ declinedAt: undefined,
261
+ });
262
+ return;
263
+ }
264
+ if (mutation.type === 'review-request') {
265
+ if (!existing || existing.status !== 'pending' || existing.pendingRequest?.id !== mutation.requestId) {
266
+ throw new ContactMutationError('REQUEST_STALE', 'The contact request has changed or was already handled');
267
+ }
268
+ if (Date.parse(existing.pendingRequest.expiresAt) <= Date.parse(mutation.now)) {
269
+ throw new ContactMutationError('REQUEST_EXPIRED', 'The contact request has expired');
270
+ }
271
+ if (mutation.decision === 'reject') {
272
+ delete contacts[primaryId];
273
+ }
274
+ else {
275
+ contacts[primaryId] = compactEntry({
276
+ ...existing,
277
+ status: mutation.decision === 'block' ? 'blocked' : undefined,
278
+ pendingRequest: undefined,
279
+ declinedAt: undefined,
280
+ });
281
+ }
282
+ return;
283
+ }
284
+ if (mutation.type === 'expire-request') {
285
+ if (!existing || existing.status !== 'pending' || existing.pendingRequest?.id !== mutation.requestId)
286
+ return;
287
+ if (Date.parse(existing.pendingRequest.expiresAt) > Date.parse(mutation.now))
288
+ return;
289
+ contacts[primaryId] = compactEntry({
290
+ ...existing,
291
+ status: 'declined',
292
+ pendingRequest: undefined,
293
+ declinedAt: existing.pendingRequest.expiresAt,
153
294
  });
154
295
  return;
155
296
  }
@@ -157,7 +298,14 @@ function applyMutation(book, mutation, selfAid) {
157
298
  contacts[primaryId] = compactEntry({ ...existing, displayName: mutation.displayName ?? undefined });
158
299
  return;
159
300
  }
160
- if (mutation.type === 'bind-alias') {
301
+ if (mutation.type === 'bind-alias' || mutation.type === 'bind-alias-eligible') {
302
+ if (mutation.type === 'bind-alias-eligible') {
303
+ const eligible = isOwner(selfAid, primaryId)
304
+ || (!!existing && (existing.status === undefined || existing.status === 'active'));
305
+ if (!eligible) {
306
+ throw new ContactMutationError('CONTACT_NOT_ACTIVE', 'Only an active contact or Agent owner can bind an alias');
307
+ }
308
+ }
161
309
  for (const [owner, entry] of Object.entries(contacts)) {
162
310
  if (owner !== primaryId && entry.aliases?.includes(mutation.alias)) {
163
311
  throw new ContactMutationError('ALIAS_CONFLICT', `Alias ${mutation.alias} is already bound to ${owner}`);
@@ -186,8 +334,24 @@ function validateContactBook(book, selfAid) {
186
334
  if (!isValidAid(primaryId) || isExplicitGroupId(primaryId)) {
187
335
  throw new ContactMutationError('INVALID_CONTACT_BOOK', `Invalid contact AID: ${primaryId}`);
188
336
  }
189
- if (entry.status === 'blocked' && isOwner(selfAid, primaryId)) {
190
- throw new ContactMutationError('OWNER_PROTECTED', 'An Agent owner cannot be blocked');
337
+ if (entry.status && isOwner(selfAid, primaryId)) {
338
+ throw new ContactMutationError('OWNER_PROTECTED', 'An Agent owner must remain active');
339
+ }
340
+ if (entry.status === 'pending') {
341
+ if (!entry.pendingRequest || !Number.isFinite(Date.parse(entry.pendingRequest.submittedAt))
342
+ || !Number.isFinite(Date.parse(entry.pendingRequest.expiresAt))
343
+ || Date.parse(entry.pendingRequest.expiresAt) <= Date.parse(entry.pendingRequest.submittedAt)) {
344
+ throw new ContactMutationError('INVALID_CONTACT_BOOK', `Invalid pending request for ${primaryId}`);
345
+ }
346
+ }
347
+ else if (entry.pendingRequest) {
348
+ throw new ContactMutationError('INVALID_CONTACT_BOOK', `pendingRequest requires pending status for ${primaryId}`);
349
+ }
350
+ if (entry.status === 'declined' && !Number.isFinite(Date.parse(entry.declinedAt ?? ''))) {
351
+ throw new ContactMutationError('INVALID_CONTACT_BOOK', `declinedAt is required for ${primaryId}`);
352
+ }
353
+ if (entry.status !== 'declined' && entry.declinedAt) {
354
+ throw new ContactMutationError('INVALID_CONTACT_BOOK', `declinedAt requires declined status for ${primaryId}`);
191
355
  }
192
356
  for (const alias of entry.aliases ?? []) {
193
357
  const parsed = parseContactAlias(alias, selfAid);
@@ -225,9 +389,13 @@ function readConfiguredContactChannelKeys(selfAid) {
225
389
  function cloneBook(book) {
226
390
  const contacts = {};
227
391
  for (const [primaryId, entry] of Object.entries(book?.contacts ?? {})) {
228
- contacts[primaryId] = { ...entry, ...(entry.aliases ? { aliases: [...entry.aliases] } : {}) };
392
+ contacts[primaryId] = {
393
+ ...entry,
394
+ ...(entry.aliases ? { aliases: [...entry.aliases] } : {}),
395
+ ...(entry.pendingRequest ? { pendingRequest: { ...entry.pendingRequest } } : {}),
396
+ };
229
397
  }
230
- return { $schema_version: 2, contacts };
398
+ return { $schema_version: 3, contacts };
231
399
  }
232
400
  function compactEntry(entry) {
233
401
  return Object.fromEntries(Object.entries(entry).filter(([, value]) => value !== undefined));
@@ -70,6 +70,9 @@ export function bindContactAlias(selfAid, primaryId, channelType, actorId, chann
70
70
  if (!canonicalChannelKey || !actor) {
71
71
  throw new ContactBookError('INVALID_ALIAS', `Invalid contact alias: ${String(channelKey)}:${String(actorId)}`);
72
72
  }
73
+ if (!getContactSnapshot(selfAid).contacts.has(primary)) {
74
+ throw new ContactBookError('NOT_FOUND', `Contact was not found: ${primary}`);
75
+ }
73
76
  const alias = formatContactAlias(canonicalChannelKey, actor);
74
77
  try {
75
78
  return mutateContactBookSync({
@@ -143,6 +146,35 @@ export function resolveContactView(selfAid, primaryId) {
143
146
  export function isBlockedContact(selfAid, primaryId) {
144
147
  return resolveContactView(selfAid, primaryId).blocked;
145
148
  }
149
+ export function isAcceptedContact(selfAid, primaryId) {
150
+ const id = String(primaryId || '').trim();
151
+ const snapshot = getContactSnapshot(selfAid);
152
+ const entry = snapshot.contacts.get(id);
153
+ return !!entry && (entry.status === undefined || entry.status === 'active');
154
+ }
155
+ /**
156
+ * 对端展示名:联系人簿 displayName → principalId。仅用于展示(预览行/日志),
157
+ * 关系键始终用原生 peerId,不受此影响。
158
+ *
159
+ * 非 AUN 渠道的 actorId 是渠道原生 ID(飞书 open_id 等),直接展示不可读;
160
+ * 联系人簿已把它 alias 到主体 AID 上,取其 displayName 即可。
161
+ * 未绑定/联系人簿损坏时返回 undefined,由调用方按自己的兜底链继续。
162
+ */
163
+ export function resolvePeerDisplayLabel(selfAid, channelType, actorId, channelKey) {
164
+ const actor = String(actorId || '').trim();
165
+ if (!String(selfAid || '').trim() || !actor)
166
+ return undefined;
167
+ try {
168
+ const { principalId } = resolvePrincipal(selfAid, channelType, actor, channelKey);
169
+ if (!principalId)
170
+ return undefined;
171
+ return resolveContactView(selfAid, principalId).displayName || principalId;
172
+ }
173
+ catch {
174
+ // 联系人簿损坏(校验失败/alias 冲突)不应影响消息处理
175
+ return undefined;
176
+ }
177
+ }
146
178
  function buildSnapshot(book, selfAid, aliases) {
147
179
  const contacts = new Map();
148
180
  const blockedIndex = new Set();
@@ -3,7 +3,8 @@ import { isValidAid } from '../aun/aid/validation.js';
3
3
  import { isExplicitGroupId } from '../aun/group-identity.js';
4
4
  import { agentConfig } from '../paths.js';
5
5
  import { getContactSnapshot, resolveContactView } from './contact-book.js';
6
- import { ContactMutationError, mutateContactBook } from './contact-book-store.js';
6
+ import { ContactMutationError } from './contact-book-store.js';
7
+ import { mutateContactWithOperation } from './contact-request-service.js';
7
8
  export function resolveContactCommand(argv) {
8
9
  const args = argv[0] === 'contact' ? argv.slice(1) : [...argv];
9
10
  const selfAid = String(argValue(args, '--self') || '').trim();
@@ -56,9 +57,14 @@ export async function executeResolvedContactCommand(command, actor = 'local-cli'
56
57
  };
57
58
  }
58
59
  if (command.kind === 'set-blocked') {
59
- const result = await mutateContactBook({
60
+ if (!getContactSnapshot(command.selfAid).contacts.has(command.primaryId)) {
61
+ return { ok: false, code: 'NOT_FOUND', error: `Contact was not found: ${command.primaryId}` };
62
+ }
63
+ const result = await mutateContactWithOperation({
60
64
  selfAid: command.selfAid,
61
- actor,
65
+ actorId: actor,
66
+ operation: command.blocked ? 'block' : 'unblock',
67
+ primaryId: command.primaryId,
62
68
  mutation: {
63
69
  type: 'set-status',
64
70
  primaryId: command.primaryId,