evolcore 0.0.25 → 0.0.27

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 (304) hide show
  1. package/CHANGELOG.md +632 -565
  2. package/LICENSE +21 -21
  3. package/README.md +455 -455
  4. package/bin/codex-managed-acl.ps1 +203 -160
  5. package/bin/codex-managed-hook.mjs +208 -208
  6. package/bin/ec-safe-output.js +327 -327
  7. package/bin/ec.js +40 -29
  8. package/bin/install-codex-managed-hooks.mjs +582 -555
  9. package/dist/agents/baseagent-availability.js +68 -0
  10. package/dist/agents/baseagent.js +96 -46
  11. package/dist/agents/claude-runner.js +172 -33
  12. package/dist/agents/codex/app-server-client.js +76 -31
  13. package/dist/agents/codex/catalog.js +1 -1
  14. package/dist/agents/codex/diagnostics.js +34 -1
  15. package/dist/agents/codex-runner.js +434 -446
  16. package/dist/agents/ecagent-runner.js +174 -34
  17. package/dist/agents/gemini-runner.js +8 -7
  18. package/dist/agents/runner-types.js +75 -19
  19. package/dist/aun/aid/agentmd.js +151 -36
  20. package/dist/aun/aid/client.js +1 -1
  21. package/dist/aun/aid/control-aid.js +180 -1
  22. package/dist/aun/aid/identity.js +4 -4
  23. package/dist/aun/aid/index.js +1 -1
  24. package/dist/aun/aid/store.js +17 -13
  25. package/dist/aun/group/history.js +303 -0
  26. package/dist/aun/group/identity.js +43 -0
  27. package/dist/aun/group/index.js +5 -0
  28. package/dist/aun/{msg/group.js → group/operations.js} +162 -39
  29. package/dist/aun/{menu-client.js → menu/client.js} +85 -6
  30. package/dist/aun/menu/index.js +2 -0
  31. package/dist/aun/menu/transport.js +49 -0
  32. package/dist/aun/msg/index.js +2 -1
  33. package/dist/aun/msg/managed-operation.js +85 -19
  34. package/dist/aun/msg/mention-schema.js +51 -0
  35. package/dist/aun/msg/p2p.js +19 -9
  36. package/dist/aun/msg/upload.js +43 -80
  37. package/dist/aun/outbox.js +29 -7
  38. package/dist/aun/rpc/client.js +1 -0
  39. package/dist/aun/storage/client.js +39 -50
  40. package/dist/channels/aun.js +842 -318
  41. package/dist/channels/daemon.js +10 -6
  42. package/dist/cli/agent-command.js +212 -89
  43. package/dist/cli/agent-create.js +626 -11
  44. package/dist/cli/agent.js +125 -13
  45. package/dist/cli/aun-commands.js +487 -337
  46. package/dist/cli/bench.js +20 -20
  47. package/dist/cli/config.js +55 -33
  48. package/dist/cli/contact.js +72 -15
  49. package/dist/cli/ctl-command.js +24 -24
  50. package/dist/cli/daemon-commands.js +140 -63
  51. package/dist/cli/diag.js +31 -0
  52. package/dist/cli/fs-command.js +70 -70
  53. package/dist/cli/handoff-command.js +46 -14
  54. package/dist/cli/index.js +91 -79
  55. package/dist/cli/init.js +34 -1
  56. package/dist/cli/link-rules.js +21 -21
  57. package/dist/cli/managed-command-guard.js +5 -1
  58. package/dist/cli/menu-client.js +51 -32
  59. package/dist/cli/model.js +3 -3
  60. package/dist/cli/net-check.js +22 -22
  61. package/dist/cli/queue-command.js +31 -31
  62. package/dist/cli/response.js +32 -32
  63. package/dist/cli/restart-monitor.js +103 -42
  64. package/dist/cli/stats.js +51 -51
  65. package/dist/cli/task-command.js +59 -0
  66. package/dist/cli/task-context.js +97 -49
  67. package/dist/cli/trigger-command.js +74 -64
  68. package/dist/config/builtin-roles.js +11 -0
  69. package/dist/config/config-field-policy.js +11 -0
  70. package/dist/config/config-manager.js +32 -4
  71. package/dist/config/config-operation-service.js +160 -0
  72. package/dist/config/contact-book-store.js +1 -1
  73. package/dist/config/contact-book.js +1 -1
  74. package/dist/config/contact-operation-service.js +97 -8
  75. package/dist/config/peer-role-resolver.js +1 -1
  76. package/dist/config/resolved-config-op.js +30 -7
  77. package/dist/config-store.js +1 -1
  78. package/dist/core/agent-application-service.js +7 -1
  79. package/dist/core/audit/event-key.js +17 -2
  80. package/dist/core/audit/log-integrity.js +34 -8
  81. package/dist/core/auth/agent-delegation.js +48 -14
  82. package/dist/core/auth/auth-gateway.js +2 -0
  83. package/dist/core/auth/authorization-audit.js +53 -49
  84. package/dist/core/auth/operation-authorizer.js +90 -7
  85. package/dist/core/auth/operation-catalog.js +145 -4
  86. package/dist/core/auth/remote-access-store.js +155 -10
  87. package/dist/core/bootstrap-messages.js +66 -7
  88. package/dist/core/bootstrap-service.js +111 -20
  89. package/dist/core/command/agent-control.js +88 -7
  90. package/dist/core/command/agent-storage-menu.js +443 -0
  91. package/dist/core/command/cli-intent-parser.js +19 -0
  92. package/dist/core/command/command-handler.js +159 -65
  93. package/dist/core/command/data-root-resolver.js +273 -0
  94. package/dist/core/command/diag-service.js +118 -0
  95. package/dist/core/command/group-menu.js +7 -2
  96. package/dist/core/command/menu-catalog.js +46 -7
  97. package/dist/core/command/menu-handler.js +392 -100
  98. package/dist/core/command/menu-protocol.js +45 -6
  99. package/dist/core/command/menu-token-store.js +41 -8
  100. package/dist/core/command/role-menu.js +2 -2
  101. package/dist/core/command/slash-gate.js +34 -4
  102. package/dist/core/command/slash-handler.js +123 -43
  103. package/dist/core/event-catalog.js +4 -0
  104. package/dist/core/evolagent-registry.js +28 -2
  105. package/dist/core/group/invite-service.js +924 -0
  106. package/dist/core/handoff/runtime.js +205 -27
  107. package/dist/core/handoff/store.js +193 -4
  108. package/dist/core/interaction-router.js +22 -8
  109. package/dist/core/message/im-renderer.js +146 -34
  110. package/dist/core/message/message-bridge.js +163 -20
  111. package/dist/core/message/message-log.js +5 -3
  112. package/dist/core/message/message-queue.js +99 -41
  113. package/dist/core/message/message-utils.js +1 -0
  114. package/dist/core/message/response-engine.js +755 -144
  115. package/dist/core/model/catalog-service.js +79 -49
  116. package/dist/core/model/context-window-suffix.js +33 -0
  117. package/dist/core/model/model-catalog.js +1 -1
  118. package/dist/core/model/model-permission.js +12 -3
  119. package/dist/core/permission/approval-gateway.js +36 -3
  120. package/dist/core/permission/ec-command-parser.js +112 -41
  121. package/dist/core/permission/protected-paths.js +134 -16
  122. package/dist/core/permission/sandbox-runtime.js +108 -17
  123. package/dist/core/permission/tool-error-code.js +15 -0
  124. package/dist/core/permission/tool-policy.js +9 -8
  125. package/dist/core/relation/peer-identity.js +57 -6
  126. package/dist/core/relation/shared-data.js +106 -0
  127. package/dist/core/role/runtime-policy.js +64 -0
  128. package/dist/core/runtime-lock.js +10 -26
  129. package/dist/core/session/runner-task-history.js +170 -0
  130. package/dist/core/session/session-manager.js +34 -6
  131. package/dist/core/session/session-mapper.js +41 -0
  132. package/dist/core/session/session-turn-coordinator.js +37 -7
  133. package/dist/core/session/session-turns.js +48 -7
  134. package/dist/core/task/task-recovery-service.js +176 -0
  135. package/dist/core/task/task-resume-operation.js +32 -0
  136. package/dist/eck/group-rules-sync.js +5 -3
  137. package/dist/eck/kit-renderer.js +1 -0
  138. package/dist/index.js +588 -217
  139. package/dist/ipc.js +193 -15
  140. package/dist/paths.js +17 -4
  141. package/dist/product.js +2 -0
  142. package/dist/response-system/engines/v1/proactive-flow.js +59 -2
  143. package/dist/stats/billing.js +41 -15
  144. package/dist/stats/db.js +159 -159
  145. package/dist/stats/eck-vars.js +9 -9
  146. package/dist/stats/price-resolver.js +28 -10
  147. package/dist/stats/query.js +139 -139
  148. package/dist/stats/role-budget.js +6 -6
  149. package/dist/stats/writer.js +51 -51
  150. package/dist/trigger/event-source.js +19 -0
  151. package/dist/trigger/feedback.js +233 -40
  152. package/dist/trigger/parser.js +55 -5
  153. package/dist/trigger/patch.js +9 -0
  154. package/dist/trigger/scheduler.js +30 -4
  155. package/dist/trigger/validation.js +20 -3
  156. package/dist/utils/cross-platform.js +30 -1
  157. package/dist/utils/ecweb-utils.js +1 -0
  158. package/dist/utils/error-dict.json +160 -160
  159. package/dist/utils/error-utils.js +61 -2
  160. package/dist/utils/evolcore-version.js +129 -0
  161. package/dist/utils/logger.js +26 -14
  162. package/dist/utils/macos-autostart.js +22 -22
  163. package/dist/utils/model-prices.jsonl +23 -23
  164. package/dist/utils/npm-ops.js +176 -35
  165. package/dist/utils/tool-summary.js +59 -3
  166. package/dist/utils/welcome.js +196 -23
  167. package/ecagent/LICENSE +21 -21
  168. package/ecagent/NOTICE +11 -11
  169. package/ecagent/README.md +61 -61
  170. package/ecagent/dist/harness/agent-harness.js +3 -0
  171. package/ecagent/dist/harness/compaction/branch-summarization.js +30 -30
  172. package/ecagent/dist/harness/compaction/compaction.js +83 -83
  173. package/ecagent/dist/harness/messages.js +7 -7
  174. package/ecagent/dist/harness/session/jsonl-storage.js +21 -2
  175. package/kits/docs/GUIDE.md +20 -20
  176. package/kits/docs/INDEX.md +60 -66
  177. package/kits/docs/aun/CHEATSHEET.md +19 -19
  178. package/kits/docs/aun/SYNC_PROTOCOL.md +15 -15
  179. package/kits/docs/channels/aun.md +69 -56
  180. package/kits/docs/channels/feishu.md +56 -56
  181. package/kits/docs/context-assembly.md +379 -367
  182. package/kits/docs/eck_templates/GUIDE.template.md +22 -22
  183. package/kits/docs/eck_templates/INDEX.template.md +28 -28
  184. package/kits/docs/eck_templates/path-registry.template.md +33 -33
  185. package/kits/docs/eck_templates/runtime.template.md +19 -19
  186. package/kits/docs/evolcore/INDEX.md +76 -71
  187. package/kits/docs/evolcore/agent-create.md +202 -165
  188. package/kits/docs/evolcore/agent.md +99 -67
  189. package/kits/docs/evolcore/aid.md +68 -60
  190. package/kits/docs/evolcore/codex-windows-sandbox.md +146 -146
  191. package/kits/docs/evolcore/config.md +241 -195
  192. package/kits/docs/evolcore/contact.md +75 -68
  193. package/kits/docs/evolcore/ctl.md +46 -46
  194. package/kits/docs/evolcore/event.md +216 -216
  195. package/kits/docs/evolcore/fs-architecture.md +1215 -1215
  196. package/kits/docs/evolcore/fs.md +110 -110
  197. package/kits/docs/evolcore/group-collaboration.md +244 -244
  198. package/kits/docs/evolcore/group-fs.md +17 -17
  199. package/kits/docs/evolcore/group-rules.md +251 -251
  200. package/kits/docs/evolcore/group.md +267 -152
  201. package/kits/docs/evolcore/menu-client.md +58 -34
  202. package/kits/docs/evolcore/model.md +50 -50
  203. package/kits/docs/evolcore/msg.md +169 -165
  204. package/kits/docs/evolcore/response.md +75 -75
  205. package/kits/docs/evolcore/rpc.md +37 -37
  206. package/kits/docs/evolcore/self-summary.md +29 -29
  207. package/kits/docs/evolcore/stats.md +83 -83
  208. package/kits/docs/evolcore/storage.md +53 -50
  209. package/kits/docs/evolcore/task.md +14 -0
  210. package/kits/docs/evolcore/trigger.md +623 -575
  211. package/kits/docs/identity/AID_PROFILE_SPEC.md +26 -26
  212. package/kits/docs/identity/PATH_OPS.md +16 -16
  213. package/kits/docs/identity/ROLE_DETAIL.md +25 -25
  214. package/kits/docs/identity/identity-tools.md +26 -26
  215. package/kits/docs/path-registry.md +62 -62
  216. package/kits/docs/prompt-loading-architecture.md +269 -269
  217. package/kits/docs/venues/aun-group.md +45 -45
  218. package/kits/docs/venues/aun-private.md +10 -10
  219. package/kits/docs/venues/client-desktop.md +10 -10
  220. package/kits/docs/venues/client-mobile.md +10 -10
  221. package/kits/docs/venues/feishu-group.md +13 -13
  222. package/kits/docs/venues/feishu-private.md +9 -9
  223. package/kits/docs/venues/group.md +42 -25
  224. package/kits/docs/venues/private.md +10 -10
  225. package/kits/eck_manifest.auxiliary.json +43 -43
  226. package/kits/eck_manifest.json +217 -190
  227. package/kits/eck_message_manifest.json +74 -74
  228. package/kits/migrations/README-role-config-v4.md +32 -32
  229. package/kits/migrations/migrate-contact-book-v2.mjs +748 -748
  230. package/kits/migrations/migrate-role-config-v4.mjs +614 -614
  231. package/kits/migrations/migrate-role-config-v5.mjs +365 -365
  232. package/kits/migrations/rename-config-file.mjs +99 -99
  233. package/kits/rules/01-overview.md +166 -162
  234. package/kits/rules/02-navigation.md +96 -96
  235. package/kits/rules/03-identity.md +36 -36
  236. package/kits/rules/04-relation.md +76 -75
  237. package/kits/rules/05-venue.md +60 -60
  238. package/kits/rules/06-channel.md +65 -63
  239. package/kits/schemas/_meta.json +53 -53
  240. package/kits/schemas/agent-config.schema.1.json +177 -177
  241. package/kits/schemas/agent-config.schema.10.json +395 -395
  242. package/kits/schemas/agent-config.schema.11.json +421 -421
  243. package/kits/schemas/agent-config.schema.12.json +451 -427
  244. package/kits/schemas/agent-config.schema.2.json +239 -239
  245. package/kits/schemas/agent-config.schema.3.json +119 -119
  246. package/kits/schemas/agent-config.schema.4.json +208 -208
  247. package/kits/schemas/agent-config.schema.5.json +326 -326
  248. package/kits/schemas/agent-config.schema.6.json +322 -322
  249. package/kits/schemas/agent-config.schema.7.json +303 -303
  250. package/kits/schemas/agent-config.schema.8.json +304 -304
  251. package/kits/schemas/agent-config.schema.9.json +364 -364
  252. package/kits/schemas/contact-book.schema.1.json +36 -36
  253. package/kits/schemas/contact-book.schema.2.json +43 -43
  254. package/kits/schemas/contact-book.schema.3.json +67 -67
  255. package/kits/schemas/daemon.schema.1.json +91 -91
  256. package/kits/schemas/daemon.schema.2.json +101 -101
  257. package/kits/schemas/daemon.schema.3.json +123 -123
  258. package/kits/schemas/daemon.schema.4.json +136 -136
  259. package/kits/schemas/daemon.schema.5.json +135 -135
  260. package/kits/schemas/daemon.schema.6.json +131 -131
  261. package/kits/schemas/defaults.schema.1.json +81 -81
  262. package/kits/schemas/defaults.schema.2.json +85 -85
  263. package/kits/schemas/defaults.schema.3.json +73 -73
  264. package/kits/schemas/defaults.schema.4.json +107 -107
  265. package/kits/schemas/defaults.schema.5.json +119 -119
  266. package/kits/schemas/menu-exec-schema-commands.md +208 -208
  267. package/kits/schemas/migrations/README.md +30 -30
  268. package/kits/schemas/relation-config.schema.1.json +158 -158
  269. package/kits/schemas/relation-config.schema.2.json +73 -73
  270. package/kits/schemas/relation-config.schema.3.json +50 -50
  271. package/kits/schemas/relation-config.schema.4.json +47 -47
  272. package/kits/schemas/relation-config.schema.5.json +47 -47
  273. package/kits/schemas/relation-config.schema.6.json +46 -46
  274. package/kits/schemas/relation-config.schema.7.json +59 -59
  275. package/kits/schemas/relation-config.schema.8.json +103 -103
  276. package/kits/schemas/role-config.schema.1.json +215 -201
  277. package/kits/schemas/role-registry.schema.1.json +35 -35
  278. package/kits/schemas/single-session.schema.1.json +31 -31
  279. package/kits/schemas/single-session.schema.2.json +57 -57
  280. package/kits/schemas/single-session.schema.3.json +32 -32
  281. package/kits/templates/bootstrap-welcome.md +17 -17
  282. package/kits/templates/message-fragments/handoff-context-to-target.md +9 -9
  283. package/kits/templates/message-fragments/handoff-request-to-target.md +19 -19
  284. package/kits/templates/message-fragments/handoff-response-to-origin.md +9 -9
  285. package/kits/templates/message-fragments/inject-default.md +2 -2
  286. package/kits/templates/message-fragments/item.md +2 -2
  287. package/kits/templates/roles/admin.json +92 -92
  288. package/kits/templates/roles/member.json +101 -96
  289. package/kits/templates/roles/owner.json +9 -9
  290. package/kits/templates/roles/visitor.json +90 -85
  291. package/kits/templates/system-fragments/baseagent.md +14 -14
  292. package/kits/templates/system-fragments/bootstrap.md +53 -34
  293. package/kits/templates/system-fragments/channel.md +28 -64
  294. package/kits/templates/system-fragments/commands.md +36 -28
  295. package/kits/templates/system-fragments/group-unread-history.md +7 -0
  296. package/kits/templates/system-fragments/identity.md +13 -13
  297. package/kits/templates/system-fragments/relation.md +19 -19
  298. package/kits/templates/system-fragments/reply.md +56 -0
  299. package/kits/templates/system-fragments/session.md +59 -59
  300. package/kits/templates/system-fragments/venue.md +31 -31
  301. package/package.json +104 -98
  302. package/dist/aun/group-identity.js +0 -9
  303. /package/dist/aun/{group-fs-download.js → group/fs-download.js} +0 -0
  304. /package/dist/aun/{msg/group-index.js → group/index-rpc.js} +0 -0
@@ -1,533 +1,533 @@
1
- import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import crypto from 'crypto';
5
- import { fileURLToPath } from 'url';
6
- import Ajv from 'ajv';
7
-
8
- const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
9
- const SCHEMA_DIR = path.resolve(SCRIPT_DIR, '..', 'schemas');
10
- const AGENT_LOCK_NAME = '.contact-book-mutation.lock';
11
- const RECEIPT_NAME = 'contact-book-v2-relation-role-authority.json';
12
- const RECEIPT = Object.freeze({
13
- schemaVersion: 1,
14
- migration: 'contact-book-v2-relation-role-authority',
15
- designVersion: 2,
16
- });
17
- const CONTACT_KEYS = new Set(['displayName', 'aliases', 'status', 'subjectType', 'role', 'blocked']);
18
- const CONTACT_STATUSES = new Set(['active', 'blocked', 'pending']);
19
-
20
- function parseArgs(argv) {
21
- const options = { apply: false, json: false, home: undefined };
22
- for (let index = 0; index < argv.length; index += 1) {
23
- const arg = argv[index];
24
- if (arg === '--apply') options.apply = true;
25
- else if (arg === '--json') options.json = true;
26
- else if (arg === '--home') options.home = argv[++index];
27
- else if (arg === '--help' || arg === '-h') options.help = true;
28
- else throw new Error(`Unknown argument: ${arg}`);
29
- }
30
- return options;
31
- }
32
-
33
- function resolveHome(explicit) {
34
- return path.resolve(explicit || process.env.EVOLCORE_HOME || path.join(os.homedir(), '.evolcore'));
35
- }
36
-
37
- function isRecord(value) {
38
- return !!value && typeof value === 'object' && !Array.isArray(value);
39
- }
40
-
41
- function isValidAid(name) {
42
- if (typeof name !== 'string') return false;
43
- const labels = name.split('.');
44
- return labels.length >= 3 && labels.every(label => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label));
45
- }
46
-
47
- function isExplicitGroupId(value) {
48
- const id = String(value || '').trim();
49
- return (id.startsWith('group.') && id.includes('/'))
50
- || /^\d+\.[a-z0-9.-]+$/i.test(id)
51
- || /^grp_[a-z0-9_-]+$/i.test(id)
52
- || /^g-[a-z0-9_-]+(?:[.@][a-z0-9.-]+)?$/i.test(id);
53
- }
54
-
55
- function readJson(file) {
56
- const text = fs.readFileSync(file, 'utf8');
57
- return { value: JSON.parse(text), text };
58
- }
59
-
60
- function loadValidators() {
61
- const ajv = new Ajv({ allErrors: true, strict: false });
62
- const load = name => JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, name), 'utf8'));
63
- return {
64
- agent: ajv.compile(load('agent-config.schema.6.json')),
65
- contact: ajv.compile(load('contact-book.schema.2.json')),
66
- relation: ajv.compile(load('relation-config.schema.5.json')),
67
- registry: ajv.compile(load('role-registry.schema.1.json')),
68
- };
69
- }
70
-
71
- function validateOrThrow(validate, value, file) {
72
- if (validate(value)) return;
73
- const detail = (validate.errors ?? [])
74
- .map(error => `${error.instancePath || '/'} ${error.message}`)
75
- .join('; ');
76
- throw new Error(`${file}: ${detail}`);
77
- }
78
-
79
- function parsePeerKey(peerKey, file) {
80
- const separator = peerKey.indexOf('#');
81
- if (separator <= 0 || separator === peerKey.length - 1) {
82
- throw new Error(`${file}: invalid relation peerKey ${JSON.stringify(peerKey)}`);
83
- }
84
- let channelId;
85
- try { channelId = decodeURIComponent(peerKey.slice(separator + 1)); }
86
- catch { throw new Error(`${file}: relation peerKey has invalid URL encoding`); }
87
- return { channelType: peerKey.slice(0, separator).toLowerCase(), channelId };
88
- }
89
-
90
- function canonicalPeerKey(channelType, channelId) {
91
- return `${channelType.toLowerCase()}#${encodeURIComponent(channelId)}`;
92
- }
93
-
94
- function listDirectRelationFiles(relationsDir) {
95
- if (!fs.existsSync(relationsDir)) return [];
96
- const files = [];
97
- for (const entry of fs.readdirSync(relationsDir, { withFileTypes: true })) {
98
- if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
99
- const file = path.join(relationsDir, entry.name, 'config.json');
100
- if (fs.existsSync(file)) files.push(file);
101
- }
102
- return files.sort();
103
- }
104
-
105
- function jsonText(value) {
106
- return `${JSON.stringify(value, null, 2)}\n`;
107
- }
108
-
109
- function appendChange(changes, file, kind, before, value) {
110
- const after = jsonText(value);
111
- if (before === after) return false;
112
- changes.push({ file, kind, before, after });
113
- return true;
114
- }
115
-
116
- function roleValue(value, roleIds, file, label) {
117
- if (value === undefined || value === null) return null;
118
- if (typeof value !== 'string' || !value) throw new Error(`${file}: ${label} must be a string or null`);
119
- if (value === 'owner') throw new Error(`${file}: ${label} cannot be owner`);
120
- if (!roleIds.has(value)) throw new Error(`${file}: ${label} references unknown role ${JSON.stringify(value)}`);
121
- return value;
122
- }
123
-
124
- function canonicalChannelKey(channelType, aid, channelName) {
125
- return `${channelType.toLowerCase()}#${aid}#${channelName}`;
126
- }
127
-
128
- function channelInventory(agent, aid, file) {
129
- const keys = new Set();
130
- const keysByName = new Map();
131
- const keysByType = new Map();
132
- for (const channel of agent.channels ?? []) {
133
- if (!isRecord(channel)) continue;
134
- const type = String(channel.type || '').trim().toLowerCase();
135
- const name = String(channel.name || '').trim();
136
- if (!type || !name) continue;
137
- if (!/^[A-Za-z0-9_-]+$/.test(type)) throw new Error(`${file}: invalid channel type ${JSON.stringify(type)}`);
138
- if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`${file}: invalid channel instance name ${JSON.stringify(name)}`);
139
- if (type === 'aun') continue;
140
- const key = canonicalChannelKey(type, aid, name);
141
- if (keys.has(key)) throw new Error(`${file}: duplicate channel instance ${key}`);
142
- keys.add(key);
143
- const byName = keysByName.get(name) ?? new Set();
144
- byName.add(key);
145
- keysByName.set(name, byName);
146
- const byType = keysByType.get(type) ?? new Set();
147
- byType.add(key);
148
- keysByType.set(type, byType);
149
- }
150
- return { keys, keysByName, keysByType };
151
- }
152
-
153
- function parseCanonicalChannelKey(channelKey, aid, inventory, file, alias) {
154
- const parts = channelKey.split('#');
155
- if (parts.length !== 3) throw new Error(`${file}: invalid channelKey in alias ${JSON.stringify(alias)}`);
156
- const [type, selfAid, name] = parts;
157
- if (!/^[A-Za-z0-9_-]+$/.test(type) || type !== type.toLowerCase()
158
- || selfAid !== aid || !/^[A-Za-z0-9_-]+$/.test(name)) {
159
- throw new Error(`${file}: alias does not identify this Agent channel instance: ${JSON.stringify(alias)}`);
160
- }
161
- const canonical = canonicalChannelKey(type, aid, name);
162
- if (type === 'aun') return null;
163
- if (!inventory.keys.has(canonical)) {
164
- throw new Error(`${file}: alias references an unconfigured channel instance: ${JSON.stringify(alias)}`);
165
- }
166
- return canonical;
167
- }
168
-
169
- function encodedPeerId(value, file, alias, alreadyEncoded = false) {
170
- let peerId = value;
171
- if (alreadyEncoded) {
172
- try { peerId = decodeURIComponent(value); }
173
- catch { throw new Error(`${file}: invalid URL encoding in alias ${JSON.stringify(alias)}`); }
174
- if (encodeURIComponent(peerId) !== value) {
175
- throw new Error(`${file}: non-canonical peer ID encoding in alias ${JSON.stringify(alias)}`);
176
- }
177
- }
178
- if (!peerId) throw new Error(`${file}: alias has an empty peer ID: ${JSON.stringify(alias)}`);
179
- return encodeURIComponent(peerId);
180
- }
181
-
182
- function parseLegacyScopedAlias(alias, aid, file) {
183
- const encoded = alias.slice('channel-key:'.length);
184
- const separator = encoded.indexOf(':');
185
- if (separator <= 0 || separator === encoded.length - 1) {
186
- throw new Error(`${file}: invalid scoped alias ${JSON.stringify(alias)}`);
187
- }
188
- let channelKey;
189
- let actorId;
190
- try {
191
- channelKey = decodeURIComponent(encoded.slice(0, separator));
192
- actorId = decodeURIComponent(encoded.slice(separator + 1));
193
- } catch {
194
- throw new Error(`${file}: invalid URL encoding in alias ${JSON.stringify(alias)}`);
195
- }
196
- return { channelKey, actorId };
197
- }
198
-
199
- function normalizeAlias(alias, aid, inventory, file) {
200
- if (typeof alias !== 'string' || !alias.trim()) throw new Error(`${file}: aliases must contain non-empty strings`);
201
- const text = alias.trim();
202
- if (text.startsWith('channel-key:')) {
203
- const legacy = parseLegacyScopedAlias(text, aid, file);
204
- const channelKey = parseCanonicalChannelKey(legacy.channelKey, aid, inventory, file, text);
205
- if (channelKey === null) return null;
206
- return `${channelKey}:${encodedPeerId(legacy.actorId, file, text)}`;
207
- }
208
- const separator = text.indexOf(':');
209
- if (separator <= 0 || separator === text.length - 1) throw new Error(`${file}: invalid alias ${JSON.stringify(alias)}`);
210
- const prefix = text.slice(0, separator).trim();
211
- const actorId = text.slice(separator + 1).trim();
212
- if (prefix.includes('#')) {
213
- const channelKey = parseCanonicalChannelKey(prefix, aid, inventory, file, text);
214
- if (channelKey === null) return null;
215
- return `${channelKey}:${encodedPeerId(actorId, file, text, true)}`;
216
- }
217
- if (!/^[A-Za-z0-9_-]+$/.test(prefix) || !actorId) throw new Error(`${file}: invalid alias ${JSON.stringify(alias)}`);
218
- if (prefix.toLowerCase() === 'aun') return null;
219
- const namedInstances = inventory.keysByName.get(prefix);
220
- if (namedInstances?.size === 1) {
221
- return `${[...namedInstances][0]}:${encodedPeerId(actorId, file, text)}`;
222
- }
223
- if (namedInstances && namedInstances.size > 1) {
224
- throw new Error(`${file}: legacy alias ${JSON.stringify(alias)} is ambiguous across channel instances ${[...namedInstances].join(', ')}`);
225
- }
226
- const typedInstances = inventory.keysByType.get(prefix.toLowerCase());
227
- if (!typedInstances || typedInstances.size === 0) {
228
- throw new Error(`${file}: alias ${JSON.stringify(alias)} does not match a configured channel instance or channel type`);
229
- }
230
- if (typedInstances.size > 1) {
231
- throw new Error(`${file}: legacy alias ${JSON.stringify(alias)} is ambiguous across channel instances ${[...typedInstances].join(', ')}`);
232
- }
233
- return `${[...typedInstances][0]}:${encodedPeerId(actorId, file, text)}`;
234
- }
235
-
236
- function relationBackupIndex(home) {
237
- const result = new Map();
238
- const root = path.join(home, 'backups', 'contact-book-v2-migration');
239
- if (!fs.existsSync(root)) return result;
240
- for (const stamp of fs.readdirSync(root, { withFileTypes: true })) {
241
- if (!stamp.isDirectory()) continue;
242
- try {
243
- const manifest = readJson(path.join(root, stamp.name, 'manifest.json')).value;
244
- if (manifest?.designVersion >= RECEIPT.designVersion) continue;
245
- } catch {}
246
- const agentsRoot = path.join(root, stamp.name, 'files', 'agents');
247
- if (!fs.existsSync(agentsRoot)) continue;
248
- for (const agent of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
249
- if (!agent.isDirectory() || !isValidAid(agent.name)) continue;
250
- const relationsDir = path.join(agentsRoot, agent.name, 'relations');
251
- for (const relationFile of listDirectRelationFiles(relationsDir)) {
252
- const peerKey = path.basename(path.dirname(relationFile));
253
- const parsed = parsePeerKey(peerKey, relationFile);
254
- let relation;
255
- try { relation = readJson(relationFile).value; }
256
- catch { continue; }
257
- if (!isRecord(relation) || typeof relation.role !== 'string' || relation.role === 'owner') continue;
258
- const agentMap = result.get(agent.name) ?? new Map();
259
- const key = canonicalPeerKey(parsed.channelType, parsed.channelId);
260
- const record = agentMap.get(key) ?? { roles: new Set(), targetKinds: new Set() };
261
- record.roles.add(relation.role);
262
- if (relation.targetKind === 'private' || relation.targetKind === 'group') record.targetKinds.add(relation.targetKind);
263
- agentMap.set(key, record);
264
- result.set(agent.name, agentMap);
265
- }
266
- }
267
- }
268
- return result;
269
- }
270
-
271
- function migrationReceiptPath(home) {
272
- return path.join(home, 'data', RECEIPT_NAME);
273
- }
274
-
275
- function hasCurrentReceipt(home) {
276
- try {
277
- const value = readJson(migrationReceiptPath(home)).value;
278
- return isRecord(value) && value.migration === RECEIPT.migration && value.designVersion === RECEIPT.designVersion;
279
- } catch {
280
- return false;
281
- }
282
- }
283
-
284
- function planAgentMigration(home, aid, validators, changes, summary, backupRoles, allowBackupRestore) {
285
- const directory = path.join(home, 'agents', aid);
286
- const agentFile = path.join(directory, 'config.json');
287
- if (!fs.existsSync(agentFile)) return false;
288
-
289
- const { value: agentInput, text: agentText } = readJson(agentFile);
290
- if (!isRecord(agentInput)) throw new Error(`${agentFile}: Agent config must be an object`);
291
- const agentVersion = agentInput.$schema_version;
292
- if (Number.isInteger(agentVersion) && agentVersion > 6
293
- && fs.existsSync(path.join(SCHEMA_DIR, `agent-config.schema.${agentVersion}.json`))) {
294
- // Contact book v2 was completed by v6. Later Agent schemas must remain
295
- // untouched when this corrective migration runs for older Agents nearby.
296
- return false;
297
- }
298
- if (![5, 6].includes(agentInput.$schema_version)) {
299
- throw new Error(`${agentFile}: contact v2 migration requires Agent schema v5 or v6`);
300
- }
301
-
302
- const registryFile = path.join(directory, 'roles', 'index.json');
303
- if (!fs.existsSync(registryFile)) throw new Error(`${registryFile}: role registry is required before contact migration`);
304
- const registry = readJson(registryFile).value;
305
- validateOrThrow(validators.registry, registry, registryFile);
306
- const roleIds = new Set(registry.roles ?? []);
307
- const owners = new Set(Array.isArray(agentInput.owners) ? agentInput.owners.map(value => String(value || '').trim()).filter(Boolean) : []);
308
- const inventory = channelInventory(agentInput, aid, agentFile);
309
-
310
- const relationsDir = path.join(directory, 'relations');
311
- const relations = new Map();
312
- for (const relationFile of listDirectRelationFiles(relationsDir)) {
313
- const peerKey = path.basename(path.dirname(relationFile));
314
- const parsed = parsePeerKey(peerKey, relationFile);
315
- const key = canonicalPeerKey(parsed.channelType, parsed.channelId);
316
- if (relations.has(key)) {
317
- throw new Error(`${relationFile}: duplicate decoded relation target ${key}`);
318
- }
319
- const { value: relationInput, text: relationText } = readJson(relationFile);
320
- if (!isRecord(relationInput)) throw new Error(`${relationFile}: relation config must be an object`);
321
- if (![4, 5].includes(relationInput.$schema_version)) {
322
- throw new Error(`${relationFile}: contact v2 migration requires Relation schema v4 or v5`);
323
- }
324
- const currentRole = roleValue(relationInput.role, roleIds, relationFile, 'relation role');
325
- const value = { ...relationInput, $schema_version: 5 };
326
- if (currentRole === null) delete value.role;
327
- else value.role = currentRole;
328
- relations.set(key, {
329
- key,
330
- file: relationFile,
331
- before: relationText,
332
- channelType: parsed.channelType,
333
- channelId: parsed.channelId,
334
- value,
335
- roleSource: currentRole ? 'relation' : null,
336
- });
337
- if (currentRole) summary.relationRolesPreserved += 1;
338
- }
339
-
340
- const ensureRelation = (channelType, channelId, targetKind) => {
341
- const key = canonicalPeerKey(channelType, channelId);
342
- let record = relations.get(key);
343
- if (!record) {
344
- const file = path.join(relationsDir, key, 'config.json');
345
- record = {
346
- key,
347
- file,
348
- before: null,
349
- channelType,
350
- channelId,
351
- value: { $schema_version: 5, targetKind },
352
- roleSource: null,
353
- };
354
- relations.set(key, record);
355
- }
356
- if (record.value.targetKind !== undefined && record.value.targetKind !== targetKind) {
357
- throw new Error(`${record.file}: targetKind ${record.value.targetKind} conflicts with required ${targetKind}`);
358
- }
359
- record.value.targetKind = targetKind;
360
- return record;
361
- };
362
-
363
- let backupEvidence = false;
364
- if (allowBackupRestore) {
365
- for (const [key, backup] of backupRoles ?? []) {
366
- const record = relations.get(key);
367
- if (!record) continue;
368
- backupEvidence = true;
369
- if (record.value.role !== undefined) continue;
370
- if (backup.roles.size !== 1) {
371
- throw new Error(`${record.file}: backup history contains conflicting relation roles ${[...backup.roles].join(', ')}`);
372
- }
373
- const restoredRole = roleValue([...backup.roles][0], roleIds, record.file, 'backup relation role');
374
- if (backup.targetKinds.size > 1) {
375
- throw new Error(`${record.file}: backup history contains conflicting targetKind values`);
376
- }
377
- if (record.value.targetKind === undefined && backup.targetKinds.size === 1) {
378
- record.value.targetKind = [...backup.targetKinds][0];
379
- }
380
- record.value.role = restoredRole;
381
- record.roleSource = 'backup';
382
- summary.relationRolesRestored += 1;
383
- if (record.channelType !== 'aun') summary.nonAunRolesRestored += 1;
384
- }
385
- }
386
-
387
- const contactFile = path.join(directory, 'contact.json');
388
- const contactRead = fs.existsSync(contactFile) ? readJson(contactFile) : { value: null, text: null };
389
- const contactInput = contactRead.value;
390
- if (contactInput !== null && !isRecord(contactInput)) throw new Error(`${contactFile}: contact book must be an object`);
391
- if (contactInput?.$schema_version !== undefined && ![1, 2].includes(contactInput.$schema_version)) {
392
- throw new Error(`${contactFile}: unsupported contact schema version ${contactInput.$schema_version}`);
393
- }
394
- const rawContacts = contactInput?.contacts ?? {};
395
- if (!isRecord(rawContacts)) throw new Error(`${contactFile}: contacts must be an object`);
396
- const contacts = {};
397
- const aliasOwners = new Map();
398
-
399
- for (const [primaryId, rawEntry] of Object.entries(rawContacts)) {
400
- if (!isValidAid(primaryId)) throw new Error(`${contactFile}: invalid contact primary AID ${JSON.stringify(primaryId)}`);
401
- if (!isRecord(rawEntry)) throw new Error(`${contactFile}: contact ${primaryId} must be an object`);
402
- const unknown = Object.keys(rawEntry).filter(key => !CONTACT_KEYS.has(key));
403
- if (unknown.length) throw new Error(`${contactFile}: contact ${primaryId} has unsupported fields ${unknown.join(', ')}`);
404
- const subjectType = rawEntry.subjectType ?? (isExplicitGroupId(primaryId) ? 'group' : 'principal');
405
- if (subjectType !== 'principal' && subjectType !== 'group') {
406
- throw new Error(`${contactFile}: contact ${primaryId} has invalid subjectType ${JSON.stringify(subjectType)}`);
407
- }
408
- const contactRole = roleValue(rawEntry.role, roleIds, contactFile, `contact ${primaryId} role`);
409
- if (contactRole) {
410
- const targetKind = subjectType === 'group' ? 'group' : 'private';
411
- const relation = ensureRelation('aun', primaryId, targetKind);
412
- if (relation.value.role === undefined) {
413
- relation.value.role = contactRole;
414
- relation.roleSource = 'contact';
415
- summary.contactRolesMigrated += 1;
416
- } else if (relation.value.role !== contactRole) {
417
- summary.contactRoleConflictsIgnored += 1;
418
- }
419
- }
420
- if (subjectType === 'group') {
421
- summary.groupContactsRemoved += 1;
422
- continue;
423
- }
424
-
425
- const next = {};
426
- if (rawEntry.displayName !== undefined) next.displayName = rawEntry.displayName;
427
- if (rawEntry.aliases !== undefined) {
428
- if (!Array.isArray(rawEntry.aliases)) throw new Error(`${contactFile}: contact ${primaryId} aliases must be an array`);
429
- const aliases = [];
430
- for (const rawAlias of rawEntry.aliases) {
431
- const normalized = normalizeAlias(rawAlias, aid, inventory, contactFile);
432
- if (normalized === null) {
433
- summary.nativeAunAliasesRemoved += 1;
434
- continue;
435
- }
436
- if (normalized !== rawAlias) summary.aliasesRewritten += 1;
437
- const existing = aliasOwners.get(normalized);
438
- if (existing && existing !== primaryId) {
439
- throw new Error(`${contactFile}: alias ${JSON.stringify(normalized)} is assigned to both ${existing} and ${primaryId}`);
440
- }
441
- aliasOwners.set(normalized, primaryId);
442
- if (!aliases.includes(normalized)) aliases.push(normalized);
443
- }
444
- if (aliases.length) next.aliases = aliases;
445
- }
446
- let status = rawEntry.status;
447
- if (status !== undefined && !CONTACT_STATUSES.has(status)) {
448
- throw new Error(`${contactFile}: contact ${primaryId} has invalid status ${JSON.stringify(status)}`);
449
- }
450
- if (rawEntry.blocked === true) {
451
- if (status !== undefined && status !== 'blocked') {
452
- throw new Error(`${contactFile}: contact ${primaryId} has conflicting blocked/status values`);
453
- }
454
- status = 'blocked';
455
- summary.blockedStatusesMigrated += 1;
456
- } else if (rawEntry.blocked !== undefined && rawEntry.blocked !== false) {
457
- throw new Error(`${contactFile}: contact ${primaryId} blocked must be boolean`);
458
- }
459
- if (owners.has(primaryId) && status === 'blocked') {
460
- throw new Error(`${contactFile}: owner contact ${primaryId} cannot be blocked`);
461
- }
462
- if (status && status !== 'active') next.status = status;
463
- contacts[primaryId] = next;
464
- }
465
-
466
- for (const rawAdmin of Array.isArray(agentInput.admins) ? agentInput.admins : []) {
467
- const admin = String(rawAdmin || '').trim();
468
- if (!isValidAid(admin)) throw new Error(`${agentFile}: invalid admin AID ${JSON.stringify(rawAdmin)}`);
469
- if (owners.has(admin)) continue;
470
- const relation = ensureRelation('aun', admin, 'private');
471
- if (relation.value.role !== 'admin') summary.adminsMigrated += 1;
472
- relation.value.role = 'admin';
473
- relation.roleSource = 'agent-admin';
474
- }
475
-
476
- for (const relation of relations.values()) {
477
- validateOrThrow(validators.relation, relation.value, relation.file);
478
- appendChange(changes, relation.file, 'relation', relation.before, relation.value);
479
- }
480
-
481
- const contactOutput = { $schema_version: 2, contacts };
482
- validateOrThrow(validators.contact, contactOutput, contactFile);
483
- if (contactRead.text !== null || Object.keys(contacts).length > 0) {
484
- appendChange(changes, contactFile, 'contact', contactRead.text, contactOutput);
485
- }
486
-
487
- const agentOutput = { ...agentInput, $schema_version: 6 };
488
- delete agentOutput.admins;
489
- validateOrThrow(validators.agent, agentOutput, agentFile);
490
- appendChange(changes, agentFile, 'agent', agentText, agentOutput);
491
- return { migrated: true, backupEvidence };
492
- }
493
-
494
- function planMigration(home) {
495
- const validators = loadValidators();
496
- const agentsRoot = path.join(home, 'agents');
497
- const changes = [];
498
- const summary = {
499
- agentsScanned: 0,
500
- agentsMigrated: 0,
501
- relationRolesPreserved: 0,
502
- relationRolesRestored: 0,
503
- nonAunRolesRestored: 0,
504
- contactRolesMigrated: 0,
505
- contactRoleConflictsIgnored: 0,
506
- adminsMigrated: 0,
507
- groupContactsRemoved: 0,
508
- aliasesRewritten: 0,
509
- nativeAunAliasesRemoved: 0,
510
- blockedStatusesMigrated: 0,
511
- };
512
- if (!fs.existsSync(agentsRoot)) return { home, changes, summary };
513
- const receiptCurrent = hasCurrentReceipt(home);
514
- const backups = receiptCurrent ? new Map() : relationBackupIndex(home);
515
- let backupEvidence = false;
516
- for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
517
- if (!entry.isDirectory() || !isValidAid(entry.name)) continue;
518
- summary.agentsScanned += 1;
519
- const result = planAgentMigration(home, entry.name, validators, changes, summary, backups.get(entry.name), !receiptCurrent);
520
- if (result?.migrated) summary.agentsMigrated += 1;
521
- if (result?.backupEvidence) backupEvidence = true;
522
- }
523
- if (!receiptCurrent && backupEvidence) {
524
- const receiptFile = migrationReceiptPath(home);
525
- const before = fs.existsSync(receiptFile) ? fs.readFileSync(receiptFile, 'utf8') : null;
526
- appendChange(changes, receiptFile, 'receipt', before, RECEIPT);
527
- }
528
- return { home, changes, summary };
529
- }
530
-
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+ import { fileURLToPath } from 'url';
6
+ import Ajv from 'ajv';
7
+
8
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
9
+ const SCHEMA_DIR = path.resolve(SCRIPT_DIR, '..', 'schemas');
10
+ const AGENT_LOCK_NAME = '.contact-book-mutation.lock';
11
+ const RECEIPT_NAME = 'contact-book-v2-relation-role-authority.json';
12
+ const RECEIPT = Object.freeze({
13
+ schemaVersion: 1,
14
+ migration: 'contact-book-v2-relation-role-authority',
15
+ designVersion: 2,
16
+ });
17
+ const CONTACT_KEYS = new Set(['displayName', 'aliases', 'status', 'subjectType', 'role', 'blocked']);
18
+ const CONTACT_STATUSES = new Set(['active', 'blocked', 'pending']);
19
+
20
+ function parseArgs(argv) {
21
+ const options = { apply: false, json: false, home: undefined };
22
+ for (let index = 0; index < argv.length; index += 1) {
23
+ const arg = argv[index];
24
+ if (arg === '--apply') options.apply = true;
25
+ else if (arg === '--json') options.json = true;
26
+ else if (arg === '--home') options.home = argv[++index];
27
+ else if (arg === '--help' || arg === '-h') options.help = true;
28
+ else throw new Error(`Unknown argument: ${arg}`);
29
+ }
30
+ return options;
31
+ }
32
+
33
+ function resolveHome(explicit) {
34
+ return path.resolve(explicit || process.env.EVOLCORE_HOME || path.join(os.homedir(), '.evolcore'));
35
+ }
36
+
37
+ function isRecord(value) {
38
+ return !!value && typeof value === 'object' && !Array.isArray(value);
39
+ }
40
+
41
+ function isValidAid(name) {
42
+ if (typeof name !== 'string') return false;
43
+ const labels = name.split('.');
44
+ return labels.length >= 3 && labels.every(label => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label));
45
+ }
46
+
47
+ function isExplicitGroupId(value) {
48
+ const id = String(value || '').trim();
49
+ return (id.startsWith('group.') && id.includes('/'))
50
+ || /^\d+\.[a-z0-9.-]+$/i.test(id)
51
+ || /^grp_[a-z0-9_-]+$/i.test(id)
52
+ || /^g-[a-z0-9_-]+(?:[.@][a-z0-9.-]+)?$/i.test(id);
53
+ }
54
+
55
+ function readJson(file) {
56
+ const text = fs.readFileSync(file, 'utf8');
57
+ return { value: JSON.parse(text), text };
58
+ }
59
+
60
+ function loadValidators() {
61
+ const ajv = new Ajv({ allErrors: true, strict: false });
62
+ const load = name => JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, name), 'utf8'));
63
+ return {
64
+ agent: ajv.compile(load('agent-config.schema.6.json')),
65
+ contact: ajv.compile(load('contact-book.schema.2.json')),
66
+ relation: ajv.compile(load('relation-config.schema.5.json')),
67
+ registry: ajv.compile(load('role-registry.schema.1.json')),
68
+ };
69
+ }
70
+
71
+ function validateOrThrow(validate, value, file) {
72
+ if (validate(value)) return;
73
+ const detail = (validate.errors ?? [])
74
+ .map(error => `${error.instancePath || '/'} ${error.message}`)
75
+ .join('; ');
76
+ throw new Error(`${file}: ${detail}`);
77
+ }
78
+
79
+ function parsePeerKey(peerKey, file) {
80
+ const separator = peerKey.indexOf('#');
81
+ if (separator <= 0 || separator === peerKey.length - 1) {
82
+ throw new Error(`${file}: invalid relation peerKey ${JSON.stringify(peerKey)}`);
83
+ }
84
+ let channelId;
85
+ try { channelId = decodeURIComponent(peerKey.slice(separator + 1)); }
86
+ catch { throw new Error(`${file}: relation peerKey has invalid URL encoding`); }
87
+ return { channelType: peerKey.slice(0, separator).toLowerCase(), channelId };
88
+ }
89
+
90
+ function canonicalPeerKey(channelType, channelId) {
91
+ return `${channelType.toLowerCase()}#${encodeURIComponent(channelId)}`;
92
+ }
93
+
94
+ function listDirectRelationFiles(relationsDir) {
95
+ if (!fs.existsSync(relationsDir)) return [];
96
+ const files = [];
97
+ for (const entry of fs.readdirSync(relationsDir, { withFileTypes: true })) {
98
+ if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
99
+ const file = path.join(relationsDir, entry.name, 'config.json');
100
+ if (fs.existsSync(file)) files.push(file);
101
+ }
102
+ return files.sort();
103
+ }
104
+
105
+ function jsonText(value) {
106
+ return `${JSON.stringify(value, null, 2)}\n`;
107
+ }
108
+
109
+ function appendChange(changes, file, kind, before, value) {
110
+ const after = jsonText(value);
111
+ if (before === after) return false;
112
+ changes.push({ file, kind, before, after });
113
+ return true;
114
+ }
115
+
116
+ function roleValue(value, roleIds, file, label) {
117
+ if (value === undefined || value === null) return null;
118
+ if (typeof value !== 'string' || !value) throw new Error(`${file}: ${label} must be a string or null`);
119
+ if (value === 'owner') throw new Error(`${file}: ${label} cannot be owner`);
120
+ if (!roleIds.has(value)) throw new Error(`${file}: ${label} references unknown role ${JSON.stringify(value)}`);
121
+ return value;
122
+ }
123
+
124
+ function canonicalChannelKey(channelType, aid, channelName) {
125
+ return `${channelType.toLowerCase()}#${aid}#${channelName}`;
126
+ }
127
+
128
+ function channelInventory(agent, aid, file) {
129
+ const keys = new Set();
130
+ const keysByName = new Map();
131
+ const keysByType = new Map();
132
+ for (const channel of agent.channels ?? []) {
133
+ if (!isRecord(channel)) continue;
134
+ const type = String(channel.type || '').trim().toLowerCase();
135
+ const name = String(channel.name || '').trim();
136
+ if (!type || !name) continue;
137
+ if (!/^[A-Za-z0-9_-]+$/.test(type)) throw new Error(`${file}: invalid channel type ${JSON.stringify(type)}`);
138
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`${file}: invalid channel instance name ${JSON.stringify(name)}`);
139
+ if (type === 'aun') continue;
140
+ const key = canonicalChannelKey(type, aid, name);
141
+ if (keys.has(key)) throw new Error(`${file}: duplicate channel instance ${key}`);
142
+ keys.add(key);
143
+ const byName = keysByName.get(name) ?? new Set();
144
+ byName.add(key);
145
+ keysByName.set(name, byName);
146
+ const byType = keysByType.get(type) ?? new Set();
147
+ byType.add(key);
148
+ keysByType.set(type, byType);
149
+ }
150
+ return { keys, keysByName, keysByType };
151
+ }
152
+
153
+ function parseCanonicalChannelKey(channelKey, aid, inventory, file, alias) {
154
+ const parts = channelKey.split('#');
155
+ if (parts.length !== 3) throw new Error(`${file}: invalid channelKey in alias ${JSON.stringify(alias)}`);
156
+ const [type, selfAid, name] = parts;
157
+ if (!/^[A-Za-z0-9_-]+$/.test(type) || type !== type.toLowerCase()
158
+ || selfAid !== aid || !/^[A-Za-z0-9_-]+$/.test(name)) {
159
+ throw new Error(`${file}: alias does not identify this Agent channel instance: ${JSON.stringify(alias)}`);
160
+ }
161
+ const canonical = canonicalChannelKey(type, aid, name);
162
+ if (type === 'aun') return null;
163
+ if (!inventory.keys.has(canonical)) {
164
+ throw new Error(`${file}: alias references an unconfigured channel instance: ${JSON.stringify(alias)}`);
165
+ }
166
+ return canonical;
167
+ }
168
+
169
+ function encodedPeerId(value, file, alias, alreadyEncoded = false) {
170
+ let peerId = value;
171
+ if (alreadyEncoded) {
172
+ try { peerId = decodeURIComponent(value); }
173
+ catch { throw new Error(`${file}: invalid URL encoding in alias ${JSON.stringify(alias)}`); }
174
+ if (encodeURIComponent(peerId) !== value) {
175
+ throw new Error(`${file}: non-canonical peer ID encoding in alias ${JSON.stringify(alias)}`);
176
+ }
177
+ }
178
+ if (!peerId) throw new Error(`${file}: alias has an empty peer ID: ${JSON.stringify(alias)}`);
179
+ return encodeURIComponent(peerId);
180
+ }
181
+
182
+ function parseLegacyScopedAlias(alias, aid, file) {
183
+ const encoded = alias.slice('channel-key:'.length);
184
+ const separator = encoded.indexOf(':');
185
+ if (separator <= 0 || separator === encoded.length - 1) {
186
+ throw new Error(`${file}: invalid scoped alias ${JSON.stringify(alias)}`);
187
+ }
188
+ let channelKey;
189
+ let actorId;
190
+ try {
191
+ channelKey = decodeURIComponent(encoded.slice(0, separator));
192
+ actorId = decodeURIComponent(encoded.slice(separator + 1));
193
+ } catch {
194
+ throw new Error(`${file}: invalid URL encoding in alias ${JSON.stringify(alias)}`);
195
+ }
196
+ return { channelKey, actorId };
197
+ }
198
+
199
+ function normalizeAlias(alias, aid, inventory, file) {
200
+ if (typeof alias !== 'string' || !alias.trim()) throw new Error(`${file}: aliases must contain non-empty strings`);
201
+ const text = alias.trim();
202
+ if (text.startsWith('channel-key:')) {
203
+ const legacy = parseLegacyScopedAlias(text, aid, file);
204
+ const channelKey = parseCanonicalChannelKey(legacy.channelKey, aid, inventory, file, text);
205
+ if (channelKey === null) return null;
206
+ return `${channelKey}:${encodedPeerId(legacy.actorId, file, text)}`;
207
+ }
208
+ const separator = text.indexOf(':');
209
+ if (separator <= 0 || separator === text.length - 1) throw new Error(`${file}: invalid alias ${JSON.stringify(alias)}`);
210
+ const prefix = text.slice(0, separator).trim();
211
+ const actorId = text.slice(separator + 1).trim();
212
+ if (prefix.includes('#')) {
213
+ const channelKey = parseCanonicalChannelKey(prefix, aid, inventory, file, text);
214
+ if (channelKey === null) return null;
215
+ return `${channelKey}:${encodedPeerId(actorId, file, text, true)}`;
216
+ }
217
+ if (!/^[A-Za-z0-9_-]+$/.test(prefix) || !actorId) throw new Error(`${file}: invalid alias ${JSON.stringify(alias)}`);
218
+ if (prefix.toLowerCase() === 'aun') return null;
219
+ const namedInstances = inventory.keysByName.get(prefix);
220
+ if (namedInstances?.size === 1) {
221
+ return `${[...namedInstances][0]}:${encodedPeerId(actorId, file, text)}`;
222
+ }
223
+ if (namedInstances && namedInstances.size > 1) {
224
+ throw new Error(`${file}: legacy alias ${JSON.stringify(alias)} is ambiguous across channel instances ${[...namedInstances].join(', ')}`);
225
+ }
226
+ const typedInstances = inventory.keysByType.get(prefix.toLowerCase());
227
+ if (!typedInstances || typedInstances.size === 0) {
228
+ throw new Error(`${file}: alias ${JSON.stringify(alias)} does not match a configured channel instance or channel type`);
229
+ }
230
+ if (typedInstances.size > 1) {
231
+ throw new Error(`${file}: legacy alias ${JSON.stringify(alias)} is ambiguous across channel instances ${[...typedInstances].join(', ')}`);
232
+ }
233
+ return `${[...typedInstances][0]}:${encodedPeerId(actorId, file, text)}`;
234
+ }
235
+
236
+ function relationBackupIndex(home) {
237
+ const result = new Map();
238
+ const root = path.join(home, 'backups', 'contact-book-v2-migration');
239
+ if (!fs.existsSync(root)) return result;
240
+ for (const stamp of fs.readdirSync(root, { withFileTypes: true })) {
241
+ if (!stamp.isDirectory()) continue;
242
+ try {
243
+ const manifest = readJson(path.join(root, stamp.name, 'manifest.json')).value;
244
+ if (manifest?.designVersion >= RECEIPT.designVersion) continue;
245
+ } catch {}
246
+ const agentsRoot = path.join(root, stamp.name, 'files', 'agents');
247
+ if (!fs.existsSync(agentsRoot)) continue;
248
+ for (const agent of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
249
+ if (!agent.isDirectory() || !isValidAid(agent.name)) continue;
250
+ const relationsDir = path.join(agentsRoot, agent.name, 'relations');
251
+ for (const relationFile of listDirectRelationFiles(relationsDir)) {
252
+ const peerKey = path.basename(path.dirname(relationFile));
253
+ const parsed = parsePeerKey(peerKey, relationFile);
254
+ let relation;
255
+ try { relation = readJson(relationFile).value; }
256
+ catch { continue; }
257
+ if (!isRecord(relation) || typeof relation.role !== 'string' || relation.role === 'owner') continue;
258
+ const agentMap = result.get(agent.name) ?? new Map();
259
+ const key = canonicalPeerKey(parsed.channelType, parsed.channelId);
260
+ const record = agentMap.get(key) ?? { roles: new Set(), targetKinds: new Set() };
261
+ record.roles.add(relation.role);
262
+ if (relation.targetKind === 'private' || relation.targetKind === 'group') record.targetKinds.add(relation.targetKind);
263
+ agentMap.set(key, record);
264
+ result.set(agent.name, agentMap);
265
+ }
266
+ }
267
+ }
268
+ return result;
269
+ }
270
+
271
+ function migrationReceiptPath(home) {
272
+ return path.join(home, 'data', RECEIPT_NAME);
273
+ }
274
+
275
+ function hasCurrentReceipt(home) {
276
+ try {
277
+ const value = readJson(migrationReceiptPath(home)).value;
278
+ return isRecord(value) && value.migration === RECEIPT.migration && value.designVersion === RECEIPT.designVersion;
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+
284
+ function planAgentMigration(home, aid, validators, changes, summary, backupRoles, allowBackupRestore) {
285
+ const directory = path.join(home, 'agents', aid);
286
+ const agentFile = path.join(directory, 'config.json');
287
+ if (!fs.existsSync(agentFile)) return false;
288
+
289
+ const { value: agentInput, text: agentText } = readJson(agentFile);
290
+ if (!isRecord(agentInput)) throw new Error(`${agentFile}: Agent config must be an object`);
291
+ const agentVersion = agentInput.$schema_version;
292
+ if (Number.isInteger(agentVersion) && agentVersion > 6
293
+ && fs.existsSync(path.join(SCHEMA_DIR, `agent-config.schema.${agentVersion}.json`))) {
294
+ // Contact book v2 was completed by v6. Later Agent schemas must remain
295
+ // untouched when this corrective migration runs for older Agents nearby.
296
+ return false;
297
+ }
298
+ if (![5, 6].includes(agentInput.$schema_version)) {
299
+ throw new Error(`${agentFile}: contact v2 migration requires Agent schema v5 or v6`);
300
+ }
301
+
302
+ const registryFile = path.join(directory, 'roles', 'index.json');
303
+ if (!fs.existsSync(registryFile)) throw new Error(`${registryFile}: role registry is required before contact migration`);
304
+ const registry = readJson(registryFile).value;
305
+ validateOrThrow(validators.registry, registry, registryFile);
306
+ const roleIds = new Set(registry.roles ?? []);
307
+ const owners = new Set(Array.isArray(agentInput.owners) ? agentInput.owners.map(value => String(value || '').trim()).filter(Boolean) : []);
308
+ const inventory = channelInventory(agentInput, aid, agentFile);
309
+
310
+ const relationsDir = path.join(directory, 'relations');
311
+ const relations = new Map();
312
+ for (const relationFile of listDirectRelationFiles(relationsDir)) {
313
+ const peerKey = path.basename(path.dirname(relationFile));
314
+ const parsed = parsePeerKey(peerKey, relationFile);
315
+ const key = canonicalPeerKey(parsed.channelType, parsed.channelId);
316
+ if (relations.has(key)) {
317
+ throw new Error(`${relationFile}: duplicate decoded relation target ${key}`);
318
+ }
319
+ const { value: relationInput, text: relationText } = readJson(relationFile);
320
+ if (!isRecord(relationInput)) throw new Error(`${relationFile}: relation config must be an object`);
321
+ if (![4, 5].includes(relationInput.$schema_version)) {
322
+ throw new Error(`${relationFile}: contact v2 migration requires Relation schema v4 or v5`);
323
+ }
324
+ const currentRole = roleValue(relationInput.role, roleIds, relationFile, 'relation role');
325
+ const value = { ...relationInput, $schema_version: 5 };
326
+ if (currentRole === null) delete value.role;
327
+ else value.role = currentRole;
328
+ relations.set(key, {
329
+ key,
330
+ file: relationFile,
331
+ before: relationText,
332
+ channelType: parsed.channelType,
333
+ channelId: parsed.channelId,
334
+ value,
335
+ roleSource: currentRole ? 'relation' : null,
336
+ });
337
+ if (currentRole) summary.relationRolesPreserved += 1;
338
+ }
339
+
340
+ const ensureRelation = (channelType, channelId, targetKind) => {
341
+ const key = canonicalPeerKey(channelType, channelId);
342
+ let record = relations.get(key);
343
+ if (!record) {
344
+ const file = path.join(relationsDir, key, 'config.json');
345
+ record = {
346
+ key,
347
+ file,
348
+ before: null,
349
+ channelType,
350
+ channelId,
351
+ value: { $schema_version: 5, targetKind },
352
+ roleSource: null,
353
+ };
354
+ relations.set(key, record);
355
+ }
356
+ if (record.value.targetKind !== undefined && record.value.targetKind !== targetKind) {
357
+ throw new Error(`${record.file}: targetKind ${record.value.targetKind} conflicts with required ${targetKind}`);
358
+ }
359
+ record.value.targetKind = targetKind;
360
+ return record;
361
+ };
362
+
363
+ let backupEvidence = false;
364
+ if (allowBackupRestore) {
365
+ for (const [key, backup] of backupRoles ?? []) {
366
+ const record = relations.get(key);
367
+ if (!record) continue;
368
+ backupEvidence = true;
369
+ if (record.value.role !== undefined) continue;
370
+ if (backup.roles.size !== 1) {
371
+ throw new Error(`${record.file}: backup history contains conflicting relation roles ${[...backup.roles].join(', ')}`);
372
+ }
373
+ const restoredRole = roleValue([...backup.roles][0], roleIds, record.file, 'backup relation role');
374
+ if (backup.targetKinds.size > 1) {
375
+ throw new Error(`${record.file}: backup history contains conflicting targetKind values`);
376
+ }
377
+ if (record.value.targetKind === undefined && backup.targetKinds.size === 1) {
378
+ record.value.targetKind = [...backup.targetKinds][0];
379
+ }
380
+ record.value.role = restoredRole;
381
+ record.roleSource = 'backup';
382
+ summary.relationRolesRestored += 1;
383
+ if (record.channelType !== 'aun') summary.nonAunRolesRestored += 1;
384
+ }
385
+ }
386
+
387
+ const contactFile = path.join(directory, 'contact.json');
388
+ const contactRead = fs.existsSync(contactFile) ? readJson(contactFile) : { value: null, text: null };
389
+ const contactInput = contactRead.value;
390
+ if (contactInput !== null && !isRecord(contactInput)) throw new Error(`${contactFile}: contact book must be an object`);
391
+ if (contactInput?.$schema_version !== undefined && ![1, 2].includes(contactInput.$schema_version)) {
392
+ throw new Error(`${contactFile}: unsupported contact schema version ${contactInput.$schema_version}`);
393
+ }
394
+ const rawContacts = contactInput?.contacts ?? {};
395
+ if (!isRecord(rawContacts)) throw new Error(`${contactFile}: contacts must be an object`);
396
+ const contacts = {};
397
+ const aliasOwners = new Map();
398
+
399
+ for (const [primaryId, rawEntry] of Object.entries(rawContacts)) {
400
+ if (!isValidAid(primaryId)) throw new Error(`${contactFile}: invalid contact primary AID ${JSON.stringify(primaryId)}`);
401
+ if (!isRecord(rawEntry)) throw new Error(`${contactFile}: contact ${primaryId} must be an object`);
402
+ const unknown = Object.keys(rawEntry).filter(key => !CONTACT_KEYS.has(key));
403
+ if (unknown.length) throw new Error(`${contactFile}: contact ${primaryId} has unsupported fields ${unknown.join(', ')}`);
404
+ const subjectType = rawEntry.subjectType ?? (isExplicitGroupId(primaryId) ? 'group' : 'principal');
405
+ if (subjectType !== 'principal' && subjectType !== 'group') {
406
+ throw new Error(`${contactFile}: contact ${primaryId} has invalid subjectType ${JSON.stringify(subjectType)}`);
407
+ }
408
+ const contactRole = roleValue(rawEntry.role, roleIds, contactFile, `contact ${primaryId} role`);
409
+ if (contactRole) {
410
+ const targetKind = subjectType === 'group' ? 'group' : 'private';
411
+ const relation = ensureRelation('aun', primaryId, targetKind);
412
+ if (relation.value.role === undefined) {
413
+ relation.value.role = contactRole;
414
+ relation.roleSource = 'contact';
415
+ summary.contactRolesMigrated += 1;
416
+ } else if (relation.value.role !== contactRole) {
417
+ summary.contactRoleConflictsIgnored += 1;
418
+ }
419
+ }
420
+ if (subjectType === 'group') {
421
+ summary.groupContactsRemoved += 1;
422
+ continue;
423
+ }
424
+
425
+ const next = {};
426
+ if (rawEntry.displayName !== undefined) next.displayName = rawEntry.displayName;
427
+ if (rawEntry.aliases !== undefined) {
428
+ if (!Array.isArray(rawEntry.aliases)) throw new Error(`${contactFile}: contact ${primaryId} aliases must be an array`);
429
+ const aliases = [];
430
+ for (const rawAlias of rawEntry.aliases) {
431
+ const normalized = normalizeAlias(rawAlias, aid, inventory, contactFile);
432
+ if (normalized === null) {
433
+ summary.nativeAunAliasesRemoved += 1;
434
+ continue;
435
+ }
436
+ if (normalized !== rawAlias) summary.aliasesRewritten += 1;
437
+ const existing = aliasOwners.get(normalized);
438
+ if (existing && existing !== primaryId) {
439
+ throw new Error(`${contactFile}: alias ${JSON.stringify(normalized)} is assigned to both ${existing} and ${primaryId}`);
440
+ }
441
+ aliasOwners.set(normalized, primaryId);
442
+ if (!aliases.includes(normalized)) aliases.push(normalized);
443
+ }
444
+ if (aliases.length) next.aliases = aliases;
445
+ }
446
+ let status = rawEntry.status;
447
+ if (status !== undefined && !CONTACT_STATUSES.has(status)) {
448
+ throw new Error(`${contactFile}: contact ${primaryId} has invalid status ${JSON.stringify(status)}`);
449
+ }
450
+ if (rawEntry.blocked === true) {
451
+ if (status !== undefined && status !== 'blocked') {
452
+ throw new Error(`${contactFile}: contact ${primaryId} has conflicting blocked/status values`);
453
+ }
454
+ status = 'blocked';
455
+ summary.blockedStatusesMigrated += 1;
456
+ } else if (rawEntry.blocked !== undefined && rawEntry.blocked !== false) {
457
+ throw new Error(`${contactFile}: contact ${primaryId} blocked must be boolean`);
458
+ }
459
+ if (owners.has(primaryId) && status === 'blocked') {
460
+ throw new Error(`${contactFile}: owner contact ${primaryId} cannot be blocked`);
461
+ }
462
+ if (status && status !== 'active') next.status = status;
463
+ contacts[primaryId] = next;
464
+ }
465
+
466
+ for (const rawAdmin of Array.isArray(agentInput.admins) ? agentInput.admins : []) {
467
+ const admin = String(rawAdmin || '').trim();
468
+ if (!isValidAid(admin)) throw new Error(`${agentFile}: invalid admin AID ${JSON.stringify(rawAdmin)}`);
469
+ if (owners.has(admin)) continue;
470
+ const relation = ensureRelation('aun', admin, 'private');
471
+ if (relation.value.role !== 'admin') summary.adminsMigrated += 1;
472
+ relation.value.role = 'admin';
473
+ relation.roleSource = 'agent-admin';
474
+ }
475
+
476
+ for (const relation of relations.values()) {
477
+ validateOrThrow(validators.relation, relation.value, relation.file);
478
+ appendChange(changes, relation.file, 'relation', relation.before, relation.value);
479
+ }
480
+
481
+ const contactOutput = { $schema_version: 2, contacts };
482
+ validateOrThrow(validators.contact, contactOutput, contactFile);
483
+ if (contactRead.text !== null || Object.keys(contacts).length > 0) {
484
+ appendChange(changes, contactFile, 'contact', contactRead.text, contactOutput);
485
+ }
486
+
487
+ const agentOutput = { ...agentInput, $schema_version: 6 };
488
+ delete agentOutput.admins;
489
+ validateOrThrow(validators.agent, agentOutput, agentFile);
490
+ appendChange(changes, agentFile, 'agent', agentText, agentOutput);
491
+ return { migrated: true, backupEvidence };
492
+ }
493
+
494
+ function planMigration(home) {
495
+ const validators = loadValidators();
496
+ const agentsRoot = path.join(home, 'agents');
497
+ const changes = [];
498
+ const summary = {
499
+ agentsScanned: 0,
500
+ agentsMigrated: 0,
501
+ relationRolesPreserved: 0,
502
+ relationRolesRestored: 0,
503
+ nonAunRolesRestored: 0,
504
+ contactRolesMigrated: 0,
505
+ contactRoleConflictsIgnored: 0,
506
+ adminsMigrated: 0,
507
+ groupContactsRemoved: 0,
508
+ aliasesRewritten: 0,
509
+ nativeAunAliasesRemoved: 0,
510
+ blockedStatusesMigrated: 0,
511
+ };
512
+ if (!fs.existsSync(agentsRoot)) return { home, changes, summary };
513
+ const receiptCurrent = hasCurrentReceipt(home);
514
+ const backups = receiptCurrent ? new Map() : relationBackupIndex(home);
515
+ let backupEvidence = false;
516
+ for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
517
+ if (!entry.isDirectory() || !isValidAid(entry.name)) continue;
518
+ summary.agentsScanned += 1;
519
+ const result = planAgentMigration(home, entry.name, validators, changes, summary, backups.get(entry.name), !receiptCurrent);
520
+ if (result?.migrated) summary.agentsMigrated += 1;
521
+ if (result?.backupEvidence) backupEvidence = true;
522
+ }
523
+ if (!receiptCurrent && backupEvidence) {
524
+ const receiptFile = migrationReceiptPath(home);
525
+ const before = fs.existsSync(receiptFile) ? fs.readFileSync(receiptFile, 'utf8') : null;
526
+ appendChange(changes, receiptFile, 'receipt', before, RECEIPT);
527
+ }
528
+ return { home, changes, summary };
529
+ }
530
+
531
531
  function assertDaemonStopped(home) {
532
532
  const candidates = [];
533
533
  const pidFile = path.join(home, 'daemon.pid');
@@ -541,221 +541,221 @@ function assertDaemonStopped(home) {
541
541
  if (error?.code !== 'ENOENT') throw error;
542
542
  }
543
543
  const instanceDir = path.join(home, 'data', 'instance');
544
- if (fs.existsSync(instanceDir)) {
545
- for (const name of fs.readdirSync(instanceDir)) {
546
- if (!/^main-\d+\.json$/.test(name)) continue;
547
- const file = path.join(instanceDir, name);
548
- try { candidates.push({ pid: Number(JSON.parse(fs.readFileSync(file, 'utf8'))?.pid), source: file }); } catch {}
549
- }
550
- }
551
- for (const { pid, source } of candidates) {
552
- if (!Number.isInteger(pid) || pid <= 0) continue;
553
- try { process.kill(pid, 0); }
554
- catch (error) {
555
- if (error?.code === 'ESRCH') continue;
556
- if (error?.code !== 'EPERM') throw error;
557
- }
558
- throw new Error(`EvolCore daemon appears to be running (pid=${pid}, record=${source}); stop it before migration`);
559
- }
560
- }
561
-
562
- function acquireDirectoryLock(lockDir, label) {
563
- const ownerFile = path.join(lockDir, 'owner.json');
564
- const lockToken = crypto.randomBytes(16).toString('hex');
565
- const candidateDir = `${lockDir}.candidate-${process.pid}-${lockToken}`;
566
- const candidateOwnerFile = path.join(candidateDir, 'owner.json');
567
- fs.mkdirSync(path.dirname(lockDir), { recursive: true });
568
- fs.mkdirSync(candidateDir);
569
- fs.writeFileSync(candidateOwnerFile, jsonText({
570
- pid: process.pid,
571
- processStartedAt: Date.now() - Math.floor(process.uptime() * 1000),
572
- createdAt: new Date().toISOString(),
573
- lockToken,
574
- }));
575
- let acquired = false;
576
- try {
577
- while (true) {
578
- try {
579
- fs.renameSync(candidateDir, lockDir);
580
- acquired = true;
581
- return () => releaseDirectoryLock(lockDir, ownerFile, lockToken);
582
- } catch (error) {
583
- if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error;
584
- }
585
- let rawOwner;
586
- let ownerPid;
587
- try {
588
- rawOwner = fs.readFileSync(ownerFile, 'utf8');
589
- ownerPid = Number(JSON.parse(rawOwner)?.pid);
590
- } catch {
591
- throw new Error(`${label} lock is unreadable: ${lockDir}`);
592
- }
593
- if (!Number.isInteger(ownerPid) || ownerPid <= 0) throw new Error(`${label} lock has an invalid owner: ${lockDir}`);
594
- try {
595
- process.kill(ownerPid, 0);
596
- throw new Error(`${label} is already running (pid=${ownerPid})`);
597
- } catch (ownerError) {
598
- if (ownerError?.code === 'EPERM') throw new Error(`${label} is already running (pid=${ownerPid})`);
599
- if (ownerError?.code !== 'ESRCH') throw ownerError;
600
- }
601
- const staleClaim = `${lockDir}.reclaimed-${crypto.createHash('sha256').update(rawOwner).digest('hex').slice(0, 16)}`;
602
- try {
603
- fs.renameSync(lockDir, staleClaim);
604
- } catch (error) {
605
- if (error?.code === 'ENOENT') continue;
606
- if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY') {
607
- throw new Error(`${label} lock changed while recovering stale ownership: ${lockDir}`);
608
- }
609
- throw error;
610
- }
611
- }
612
- } finally {
613
- if (!acquired) fs.rmSync(candidateDir, { recursive: true, force: true });
614
- }
615
- }
616
-
617
- function releaseDirectoryLock(lockDir, ownerFile, lockToken) {
618
- try {
619
- const owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8'));
620
- if (owner?.lockToken !== lockToken) return;
621
- fs.rmSync(lockDir, { recursive: true, force: true });
622
- } catch {
623
- // Ownership cannot be proven after an external replacement.
624
- }
625
- }
626
-
627
- function acquireMigrationLocks(home) {
628
- const releases = [];
629
- try {
630
- releases.push(acquireDirectoryLock(path.join(home, 'data', 'contact-book-v2-migration.lock'), 'contact migration'));
631
- const agentsRoot = path.join(home, 'agents');
632
- if (fs.existsSync(agentsRoot)) {
633
- for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
634
- if (!entry.isDirectory() || !isValidAid(entry.name)) continue;
635
- releases.push(acquireDirectoryLock(path.join(agentsRoot, entry.name, AGENT_LOCK_NAME), `contact mutation for ${entry.name}`));
636
- }
637
- }
638
- return () => { for (const release of releases.reverse()) release(); };
639
- } catch (error) {
640
- for (const release of releases.reverse()) release();
641
- throw error;
642
- }
643
- }
644
-
645
- function atomicWrite(file, content) {
646
- fs.mkdirSync(path.dirname(file), { recursive: true });
647
- const mode = fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : 0o600;
648
- const temp = `${file}.contact-v2-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`;
649
- fs.writeFileSync(temp, content, { mode });
650
- fs.renameSync(temp, file);
651
- }
652
-
653
- function journalPath(home) {
654
- return path.join(home, 'data', 'contact-book-v2-migration.json');
655
- }
656
-
657
- function writeJournal(plan, state) {
658
- atomicWrite(journalPath(plan.home), jsonText({ schemaVersion: 1, state, home: plan.home, changes: plan.changes }));
659
- }
660
-
661
- function removeJournal(home) {
662
- fs.rmSync(journalPath(home), { force: true });
663
- }
664
-
665
- function recoverJournal(home) {
666
- const file = journalPath(home);
667
- if (!fs.existsSync(file)) return;
668
- let journal;
669
- try { journal = JSON.parse(fs.readFileSync(file, 'utf8')); }
670
- catch { throw new Error(`Contact migration journal is unreadable: ${file}`); }
671
- if (journal?.schemaVersion !== 1 || !['prepared', 'committed'].includes(journal.state)
672
- || path.resolve(journal.home) !== home || !Array.isArray(journal.changes)) {
673
- throw new Error(`Contact migration journal is invalid: ${file}`);
674
- }
675
- const rootPrefix = home + path.sep;
676
- for (const item of journal.changes) {
677
- const target = path.resolve(item?.file || '');
678
- if (!target.startsWith(rootPrefix) || (item.before !== null && typeof item.before !== 'string')
679
- || typeof item.after !== 'string') {
680
- throw new Error(`Contact migration journal contains an unsafe change: ${file}`);
681
- }
682
- }
683
- if (journal.state === 'prepared') {
684
- for (const item of [...journal.changes].reverse()) {
685
- if (item.before === null) fs.rmSync(item.file, { force: true });
686
- else atomicWrite(item.file, item.before);
687
- }
688
- }
689
- removeJournal(home);
690
- }
691
-
692
- function applyMigration(plan) {
693
- if (!plan.changes.length) return null;
694
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
695
- const backup = path.join(plan.home, 'backups', 'contact-book-v2-migration', stamp);
696
- fs.mkdirSync(backup, { recursive: true });
697
- for (const item of plan.changes) {
698
- if (item.before === null) continue;
699
- const destination = path.join(backup, 'files', path.relative(plan.home, item.file));
700
- fs.mkdirSync(path.dirname(destination), { recursive: true });
701
- fs.writeFileSync(destination, item.before);
702
- }
703
- fs.writeFileSync(path.join(backup, 'manifest.json'), jsonText({
704
- schemaVersion: 2,
705
- designVersion: RECEIPT.designVersion,
706
- createdAt: new Date().toISOString(),
707
- summary: plan.summary,
708
- files: plan.changes.map(item => ({ path: path.relative(plan.home, item.file), kind: item.kind })),
709
- }));
710
- writeJournal(plan, 'prepared');
711
- try {
712
- for (const item of plan.changes) atomicWrite(item.file, item.after);
713
- writeJournal(plan, 'committed');
714
- removeJournal(plan.home);
715
- } catch (error) {
716
- recoverJournal(plan.home);
717
- throw error;
718
- }
719
- return backup;
720
- }
721
-
722
- export function runMigration(options = {}) {
723
- const home = resolveHome(options.home);
724
- const releaseLocks = acquireMigrationLocks(home);
725
- try {
726
- assertDaemonStopped(home);
727
- recoverJournal(home);
728
- const plan = planMigration(home);
729
- const backup = options.apply ? applyMigration(plan) : null;
730
- return {
731
- ok: true,
732
- mode: options.apply ? 'applied' : 'dry-run',
733
- home,
734
- changedFiles: plan.changes.length,
735
- backup,
736
- summary: plan.summary,
737
- };
738
- } finally {
739
- releaseLocks();
740
- }
741
- }
742
-
743
- async function main() {
744
- try {
745
- const options = parseArgs(process.argv.slice(2));
746
- if (options.help) {
747
- console.log('Usage: node kits/migrations/migrate-contact-book-v2.mjs [--apply] [--home <path>] [--json]');
748
- return;
749
- }
750
- const result = runMigration(options);
751
- if (options.json) console.log(JSON.stringify(result));
752
- else console.log(`Contact book v2 migration (${result.mode}): ${result.changedFiles} file(s)`);
753
- } catch (error) {
754
- const message = error instanceof Error ? error.message : String(error);
755
- if (process.argv.includes('--json')) console.log(JSON.stringify({ ok: false, error: message }));
756
- else console.error(`Migration failed: ${message}`);
757
- process.exitCode = 1;
758
- }
759
- }
760
-
761
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
544
+ if (fs.existsSync(instanceDir)) {
545
+ for (const name of fs.readdirSync(instanceDir)) {
546
+ if (!/^main-\d+\.json$/.test(name)) continue;
547
+ const file = path.join(instanceDir, name);
548
+ try { candidates.push({ pid: Number(JSON.parse(fs.readFileSync(file, 'utf8'))?.pid), source: file }); } catch {}
549
+ }
550
+ }
551
+ for (const { pid, source } of candidates) {
552
+ if (!Number.isInteger(pid) || pid <= 0) continue;
553
+ try { process.kill(pid, 0); }
554
+ catch (error) {
555
+ if (error?.code === 'ESRCH') continue;
556
+ if (error?.code !== 'EPERM') throw error;
557
+ }
558
+ throw new Error(`EvolCore daemon appears to be running (pid=${pid}, record=${source}); stop it before migration`);
559
+ }
560
+ }
561
+
562
+ function acquireDirectoryLock(lockDir, label) {
563
+ const ownerFile = path.join(lockDir, 'owner.json');
564
+ const lockToken = crypto.randomBytes(16).toString('hex');
565
+ const candidateDir = `${lockDir}.candidate-${process.pid}-${lockToken}`;
566
+ const candidateOwnerFile = path.join(candidateDir, 'owner.json');
567
+ fs.mkdirSync(path.dirname(lockDir), { recursive: true });
568
+ fs.mkdirSync(candidateDir);
569
+ fs.writeFileSync(candidateOwnerFile, jsonText({
570
+ pid: process.pid,
571
+ processStartedAt: Date.now() - Math.floor(process.uptime() * 1000),
572
+ createdAt: new Date().toISOString(),
573
+ lockToken,
574
+ }));
575
+ let acquired = false;
576
+ try {
577
+ while (true) {
578
+ try {
579
+ fs.renameSync(candidateDir, lockDir);
580
+ acquired = true;
581
+ return () => releaseDirectoryLock(lockDir, ownerFile, lockToken);
582
+ } catch (error) {
583
+ if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error;
584
+ }
585
+ let rawOwner;
586
+ let ownerPid;
587
+ try {
588
+ rawOwner = fs.readFileSync(ownerFile, 'utf8');
589
+ ownerPid = Number(JSON.parse(rawOwner)?.pid);
590
+ } catch {
591
+ throw new Error(`${label} lock is unreadable: ${lockDir}`);
592
+ }
593
+ if (!Number.isInteger(ownerPid) || ownerPid <= 0) throw new Error(`${label} lock has an invalid owner: ${lockDir}`);
594
+ try {
595
+ process.kill(ownerPid, 0);
596
+ throw new Error(`${label} is already running (pid=${ownerPid})`);
597
+ } catch (ownerError) {
598
+ if (ownerError?.code === 'EPERM') throw new Error(`${label} is already running (pid=${ownerPid})`);
599
+ if (ownerError?.code !== 'ESRCH') throw ownerError;
600
+ }
601
+ const staleClaim = `${lockDir}.reclaimed-${crypto.createHash('sha256').update(rawOwner).digest('hex').slice(0, 16)}`;
602
+ try {
603
+ fs.renameSync(lockDir, staleClaim);
604
+ } catch (error) {
605
+ if (error?.code === 'ENOENT') continue;
606
+ if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY') {
607
+ throw new Error(`${label} lock changed while recovering stale ownership: ${lockDir}`);
608
+ }
609
+ throw error;
610
+ }
611
+ }
612
+ } finally {
613
+ if (!acquired) fs.rmSync(candidateDir, { recursive: true, force: true });
614
+ }
615
+ }
616
+
617
+ function releaseDirectoryLock(lockDir, ownerFile, lockToken) {
618
+ try {
619
+ const owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8'));
620
+ if (owner?.lockToken !== lockToken) return;
621
+ fs.rmSync(lockDir, { recursive: true, force: true });
622
+ } catch {
623
+ // Ownership cannot be proven after an external replacement.
624
+ }
625
+ }
626
+
627
+ function acquireMigrationLocks(home) {
628
+ const releases = [];
629
+ try {
630
+ releases.push(acquireDirectoryLock(path.join(home, 'data', 'contact-book-v2-migration.lock'), 'contact migration'));
631
+ const agentsRoot = path.join(home, 'agents');
632
+ if (fs.existsSync(agentsRoot)) {
633
+ for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
634
+ if (!entry.isDirectory() || !isValidAid(entry.name)) continue;
635
+ releases.push(acquireDirectoryLock(path.join(agentsRoot, entry.name, AGENT_LOCK_NAME), `contact mutation for ${entry.name}`));
636
+ }
637
+ }
638
+ return () => { for (const release of releases.reverse()) release(); };
639
+ } catch (error) {
640
+ for (const release of releases.reverse()) release();
641
+ throw error;
642
+ }
643
+ }
644
+
645
+ function atomicWrite(file, content) {
646
+ fs.mkdirSync(path.dirname(file), { recursive: true });
647
+ const mode = fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : 0o600;
648
+ const temp = `${file}.contact-v2-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`;
649
+ fs.writeFileSync(temp, content, { mode });
650
+ fs.renameSync(temp, file);
651
+ }
652
+
653
+ function journalPath(home) {
654
+ return path.join(home, 'data', 'contact-book-v2-migration.json');
655
+ }
656
+
657
+ function writeJournal(plan, state) {
658
+ atomicWrite(journalPath(plan.home), jsonText({ schemaVersion: 1, state, home: plan.home, changes: plan.changes }));
659
+ }
660
+
661
+ function removeJournal(home) {
662
+ fs.rmSync(journalPath(home), { force: true });
663
+ }
664
+
665
+ function recoverJournal(home) {
666
+ const file = journalPath(home);
667
+ if (!fs.existsSync(file)) return;
668
+ let journal;
669
+ try { journal = JSON.parse(fs.readFileSync(file, 'utf8')); }
670
+ catch { throw new Error(`Contact migration journal is unreadable: ${file}`); }
671
+ if (journal?.schemaVersion !== 1 || !['prepared', 'committed'].includes(journal.state)
672
+ || path.resolve(journal.home) !== home || !Array.isArray(journal.changes)) {
673
+ throw new Error(`Contact migration journal is invalid: ${file}`);
674
+ }
675
+ const rootPrefix = home + path.sep;
676
+ for (const item of journal.changes) {
677
+ const target = path.resolve(item?.file || '');
678
+ if (!target.startsWith(rootPrefix) || (item.before !== null && typeof item.before !== 'string')
679
+ || typeof item.after !== 'string') {
680
+ throw new Error(`Contact migration journal contains an unsafe change: ${file}`);
681
+ }
682
+ }
683
+ if (journal.state === 'prepared') {
684
+ for (const item of [...journal.changes].reverse()) {
685
+ if (item.before === null) fs.rmSync(item.file, { force: true });
686
+ else atomicWrite(item.file, item.before);
687
+ }
688
+ }
689
+ removeJournal(home);
690
+ }
691
+
692
+ function applyMigration(plan) {
693
+ if (!plan.changes.length) return null;
694
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
695
+ const backup = path.join(plan.home, 'backups', 'contact-book-v2-migration', stamp);
696
+ fs.mkdirSync(backup, { recursive: true });
697
+ for (const item of plan.changes) {
698
+ if (item.before === null) continue;
699
+ const destination = path.join(backup, 'files', path.relative(plan.home, item.file));
700
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
701
+ fs.writeFileSync(destination, item.before);
702
+ }
703
+ fs.writeFileSync(path.join(backup, 'manifest.json'), jsonText({
704
+ schemaVersion: 2,
705
+ designVersion: RECEIPT.designVersion,
706
+ createdAt: new Date().toISOString(),
707
+ summary: plan.summary,
708
+ files: plan.changes.map(item => ({ path: path.relative(plan.home, item.file), kind: item.kind })),
709
+ }));
710
+ writeJournal(plan, 'prepared');
711
+ try {
712
+ for (const item of plan.changes) atomicWrite(item.file, item.after);
713
+ writeJournal(plan, 'committed');
714
+ removeJournal(plan.home);
715
+ } catch (error) {
716
+ recoverJournal(plan.home);
717
+ throw error;
718
+ }
719
+ return backup;
720
+ }
721
+
722
+ export function runMigration(options = {}) {
723
+ const home = resolveHome(options.home);
724
+ const releaseLocks = acquireMigrationLocks(home);
725
+ try {
726
+ assertDaemonStopped(home);
727
+ recoverJournal(home);
728
+ const plan = planMigration(home);
729
+ const backup = options.apply ? applyMigration(plan) : null;
730
+ return {
731
+ ok: true,
732
+ mode: options.apply ? 'applied' : 'dry-run',
733
+ home,
734
+ changedFiles: plan.changes.length,
735
+ backup,
736
+ summary: plan.summary,
737
+ };
738
+ } finally {
739
+ releaseLocks();
740
+ }
741
+ }
742
+
743
+ async function main() {
744
+ try {
745
+ const options = parseArgs(process.argv.slice(2));
746
+ if (options.help) {
747
+ console.log('Usage: node kits/migrations/migrate-contact-book-v2.mjs [--apply] [--home <path>] [--json]');
748
+ return;
749
+ }
750
+ const result = runMigration(options);
751
+ if (options.json) console.log(JSON.stringify(result));
752
+ else console.log(`Contact book v2 migration (${result.mode}): ${result.changedFiles} file(s)`);
753
+ } catch (error) {
754
+ const message = error instanceof Error ? error.message : String(error);
755
+ if (process.argv.includes('--json')) console.log(JSON.stringify({ ok: false, error: message }));
756
+ else console.error(`Migration failed: ${message}`);
757
+ process.exitCode = 1;
758
+ }
759
+ }
760
+
761
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();