@animalabs/agent-framework 0.26.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/README.md +99 -8
  2. package/dist/src/agent.d.ts +291 -23
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +596 -61
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +5 -4
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +34 -0
  10. package/dist/src/auto-continue.d.ts.map +1 -0
  11. package/dist/src/auto-continue.js +36 -0
  12. package/dist/src/auto-continue.js.map +1 -0
  13. package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
  14. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  15. package/dist/src/code-execution/isolate/scripts.js +5 -1
  16. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  17. package/dist/src/cut-off-round.d.ts +24 -0
  18. package/dist/src/cut-off-round.d.ts.map +1 -0
  19. package/dist/src/cut-off-round.js +87 -0
  20. package/dist/src/cut-off-round.js.map +1 -0
  21. package/dist/src/framework.d.ts +1038 -193
  22. package/dist/src/framework.d.ts.map +1 -1
  23. package/dist/src/framework.js +5967 -1514
  24. package/dist/src/framework.js.map +1 -1
  25. package/dist/src/gate/event-gate.d.ts +405 -46
  26. package/dist/src/gate/event-gate.d.ts.map +1 -1
  27. package/dist/src/gate/event-gate.js +2646 -399
  28. package/dist/src/gate/event-gate.js.map +1 -1
  29. package/dist/src/gate/index.d.ts +3 -2
  30. package/dist/src/gate/index.d.ts.map +1 -1
  31. package/dist/src/gate/index.js +2 -1
  32. package/dist/src/gate/index.js.map +1 -1
  33. package/dist/src/gate/recipe-reconcile.d.ts +103 -0
  34. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
  35. package/dist/src/gate/recipe-reconcile.js +325 -0
  36. package/dist/src/gate/recipe-reconcile.js.map +1 -0
  37. package/dist/src/gate/reply-judge.d.ts +116 -0
  38. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  39. package/dist/src/gate/reply-judge.js +467 -0
  40. package/dist/src/gate/reply-judge.js.map +1 -0
  41. package/dist/src/gate/types.d.ts +229 -8
  42. package/dist/src/gate/types.d.ts.map +1 -1
  43. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  44. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  45. package/dist/src/heartbeat/heartbeat.js +752 -0
  46. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  47. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  48. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  49. package/dist/src/heartbeat/reminder-time.js +107 -0
  50. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  51. package/dist/src/index.d.ts +10 -4
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +7 -2
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  56. package/dist/src/mcpl/capability-mask.js +3 -2
  57. package/dist/src/mcpl/capability-mask.js.map +1 -1
  58. package/dist/src/mcpl/channel-registry.d.ts +433 -113
  59. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  60. package/dist/src/mcpl/channel-registry.js +1154 -435
  61. package/dist/src/mcpl/channel-registry.js.map +1 -1
  62. package/dist/src/mcpl/conversation-router.d.ts +16 -8
  63. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  64. package/dist/src/mcpl/conversation-router.js +29 -15
  65. package/dist/src/mcpl/conversation-router.js.map +1 -1
  66. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  67. package/dist/src/mcpl/convert-block.js +14 -2
  68. package/dist/src/mcpl/convert-block.js.map +1 -1
  69. package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
  70. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  71. package/dist/src/mcpl/hook-orchestrator.js +32 -7
  72. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  73. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  74. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  75. package/dist/src/mcpl/inbound-source.js +40 -69
  76. package/dist/src/mcpl/inbound-source.js.map +1 -1
  77. package/dist/src/mcpl/index.d.ts +5 -1
  78. package/dist/src/mcpl/index.d.ts.map +1 -1
  79. package/dist/src/mcpl/index.js +4 -0
  80. package/dist/src/mcpl/index.js.map +1 -1
  81. package/dist/src/mcpl/inference-router.d.ts +14 -2
  82. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  83. package/dist/src/mcpl/inference-router.js +73 -28
  84. package/dist/src/mcpl/inference-router.js.map +1 -1
  85. package/dist/src/mcpl/prose-grammar.d.ts +4 -0
  86. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  87. package/dist/src/mcpl/prose-grammar.js +6 -0
  88. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  89. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  90. package/dist/src/mcpl/protocol-family.js +3 -0
  91. package/dist/src/mcpl/protocol-family.js.map +1 -1
  92. package/dist/src/mcpl/push-coalescer.d.ts +20 -2
  93. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  94. package/dist/src/mcpl/push-coalescer.js +33 -11
  95. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  96. package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
  97. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  98. package/dist/src/mcpl/reference-fetcher.js +177 -145
  99. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  100. package/dist/src/mcpl/references.d.ts +380 -46
  101. package/dist/src/mcpl/references.d.ts.map +1 -1
  102. package/dist/src/mcpl/references.js +736 -77
  103. package/dist/src/mcpl/references.js.map +1 -1
  104. package/dist/src/mcpl/server-connection.d.ts +93 -4
  105. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  106. package/dist/src/mcpl/server-connection.js +176 -11
  107. package/dist/src/mcpl/server-connection.js.map +1 -1
  108. package/dist/src/mcpl/tags.d.ts +9 -2
  109. package/dist/src/mcpl/tags.d.ts.map +1 -1
  110. package/dist/src/mcpl/tags.js +12 -2
  111. package/dist/src/mcpl/tags.js.map +1 -1
  112. package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
  113. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  114. package/dist/src/mcpl/tool-lifecycle.js +21 -0
  115. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  116. package/dist/src/mcpl/tool-presence.d.ts +311 -0
  117. package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
  118. package/dist/src/mcpl/tool-presence.js +697 -0
  119. package/dist/src/mcpl/tool-presence.js.map +1 -0
  120. package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
  121. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  122. package/dist/src/mcpl/tool-result-normalize.js +14 -6
  123. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  124. package/dist/src/mcpl/tool-signature.d.ts +46 -0
  125. package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
  126. package/dist/src/mcpl/tool-signature.js +321 -0
  127. package/dist/src/mcpl/tool-signature.js.map +1 -0
  128. package/dist/src/mcpl/toolset-host.d.ts +251 -0
  129. package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
  130. package/dist/src/mcpl/toolset-host.js +720 -0
  131. package/dist/src/mcpl/toolset-host.js.map +1 -0
  132. package/dist/src/mcpl/toolsets-module.d.ts +83 -0
  133. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
  134. package/dist/src/mcpl/toolsets-module.js +523 -0
  135. package/dist/src/mcpl/toolsets-module.js.map +1 -0
  136. package/dist/src/mcpl/transport.d.ts +2 -2
  137. package/dist/src/mcpl/transport.d.ts.map +1 -1
  138. package/dist/src/mcpl/transport.js +3 -2
  139. package/dist/src/mcpl/transport.js.map +1 -1
  140. package/dist/src/mcpl/types.d.ts +24 -2
  141. package/dist/src/mcpl/types.d.ts.map +1 -1
  142. package/dist/src/mcpl/types.js.map +1 -1
  143. package/dist/src/mcpl/visible-content.d.ts +7 -9
  144. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  145. package/dist/src/mcpl/visible-content.js +21 -9
  146. package/dist/src/mcpl/visible-content.js.map +1 -1
  147. package/dist/src/module-registry.d.ts +27 -9
  148. package/dist/src/module-registry.d.ts.map +1 -1
  149. package/dist/src/module-registry.js +77 -17
  150. package/dist/src/module-registry.js.map +1 -1
  151. package/dist/src/modules/discord/index.js +1 -1
  152. package/dist/src/modules/discord/index.js.map +1 -1
  153. package/dist/src/modules/history/index.d.ts +2 -1
  154. package/dist/src/modules/history/index.d.ts.map +1 -1
  155. package/dist/src/modules/history/index.js +73 -28
  156. package/dist/src/modules/history/index.js.map +1 -1
  157. package/dist/src/modules/history/semantic.d.ts +108 -6
  158. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  159. package/dist/src/modules/history/semantic.js +273 -57
  160. package/dist/src/modules/history/semantic.js.map +1 -1
  161. package/dist/src/modules/workspace/answers.d.ts +22 -0
  162. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  163. package/dist/src/modules/workspace/answers.js +123 -0
  164. package/dist/src/modules/workspace/answers.js.map +1 -0
  165. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  166. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/branch-intent.js +6 -2
  168. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  169. package/dist/src/modules/workspace/direct-write.d.ts +184 -0
  170. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
  171. package/dist/src/modules/workspace/direct-write.js +417 -0
  172. package/dist/src/modules/workspace/direct-write.js.map +1 -0
  173. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  174. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  176. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  177. package/dist/src/modules/workspace/edit-text.d.ts +27 -12
  178. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/edit-text.js +61 -29
  180. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  181. package/dist/src/modules/workspace/effects.d.ts +10 -0
  182. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  183. package/dist/src/modules/workspace/effects.js +21 -14
  184. package/dist/src/modules/workspace/effects.js.map +1 -1
  185. package/dist/src/modules/workspace/index.d.ts +308 -78
  186. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  187. package/dist/src/modules/workspace/index.js +2103 -720
  188. package/dist/src/modules/workspace/index.js.map +1 -1
  189. package/dist/src/modules/workspace/observe.d.ts +75 -63
  190. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  191. package/dist/src/modules/workspace/observe.js +366 -180
  192. package/dist/src/modules/workspace/observe.js.map +1 -1
  193. package/dist/src/modules/workspace/read-page.d.ts +41 -0
  194. package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
  195. package/dist/src/modules/workspace/read-page.js +219 -0
  196. package/dist/src/modules/workspace/read-page.js.map +1 -0
  197. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  198. package/dist/src/modules/workspace/reconcile.js +26 -16
  199. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  200. package/dist/src/modules/workspace/result-text.d.ts +68 -0
  201. package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
  202. package/dist/src/modules/workspace/result-text.js +94 -0
  203. package/dist/src/modules/workspace/result-text.js.map +1 -0
  204. package/dist/src/modules/workspace/sync.d.ts +9 -0
  205. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  206. package/dist/src/modules/workspace/sync.js +14 -0
  207. package/dist/src/modules/workspace/sync.js.map +1 -1
  208. package/dist/src/modules/workspace/text.d.ts +29 -12
  209. package/dist/src/modules/workspace/text.d.ts.map +1 -1
  210. package/dist/src/modules/workspace/text.js +35 -33
  211. package/dist/src/modules/workspace/text.js.map +1 -1
  212. package/dist/src/modules/workspace/types.d.ts +137 -60
  213. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  214. package/dist/src/modules/workspace/types.js.map +1 -1
  215. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  216. package/dist/src/modules/workspace/watcher.js +8 -3
  217. package/dist/src/modules/workspace/watcher.js.map +1 -1
  218. package/dist/src/prose-drafts.d.ts +4 -3
  219. package/dist/src/prose-drafts.d.ts.map +1 -1
  220. package/dist/src/prose-drafts.js +3 -2
  221. package/dist/src/prose-drafts.js.map +1 -1
  222. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  223. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  224. package/dist/src/reasoning-loss-notice.js +50 -0
  225. package/dist/src/reasoning-loss-notice.js.map +1 -0
  226. package/dist/src/reasoning-loss.d.ts +24 -0
  227. package/dist/src/reasoning-loss.d.ts.map +1 -0
  228. package/dist/src/reasoning-loss.js +102 -0
  229. package/dist/src/reasoning-loss.js.map +1 -0
  230. package/dist/src/send-tools.d.ts +3 -2
  231. package/dist/src/send-tools.d.ts.map +1 -1
  232. package/dist/src/send-tools.js +3 -2
  233. package/dist/src/send-tools.js.map +1 -1
  234. package/dist/src/sent-rows.d.ts +59 -0
  235. package/dist/src/sent-rows.d.ts.map +1 -0
  236. package/dist/src/sent-rows.js +145 -0
  237. package/dist/src/sent-rows.js.map +1 -0
  238. package/dist/src/silent-heartbeat.d.ts +17 -10
  239. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  240. package/dist/src/silent-heartbeat.js.map +1 -1
  241. package/dist/src/stable-hash.d.ts +3 -0
  242. package/dist/src/stable-hash.d.ts.map +1 -0
  243. package/dist/src/stable-hash.js +15 -0
  244. package/dist/src/stable-hash.js.map +1 -0
  245. package/dist/src/stored-injections.d.ts +99 -0
  246. package/dist/src/stored-injections.d.ts.map +1 -0
  247. package/dist/src/stored-injections.js +106 -0
  248. package/dist/src/stored-injections.js.map +1 -0
  249. package/dist/src/text-as-stored.d.ts +4 -3
  250. package/dist/src/text-as-stored.d.ts.map +1 -1
  251. package/dist/src/text-as-stored.js +4 -3
  252. package/dist/src/text-as-stored.js.map +1 -1
  253. package/dist/src/thinking-drops.d.ts +74 -0
  254. package/dist/src/thinking-drops.d.ts.map +1 -0
  255. package/dist/src/thinking-drops.js +96 -0
  256. package/dist/src/thinking-drops.js.map +1 -0
  257. package/dist/src/tool-args.d.ts +12 -0
  258. package/dist/src/tool-args.d.ts.map +1 -1
  259. package/dist/src/tool-args.js +35 -1
  260. package/dist/src/tool-args.js.map +1 -1
  261. package/dist/src/tool-image-ledger.d.ts +9 -8
  262. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  263. package/dist/src/tool-image-ledger.js +9 -8
  264. package/dist/src/tool-image-ledger.js.map +1 -1
  265. package/dist/src/tool-presentation.d.ts.map +1 -1
  266. package/dist/src/tool-presentation.js +3 -1
  267. package/dist/src/tool-presentation.js.map +1 -1
  268. package/dist/src/tool-result-guard.d.ts +12 -6
  269. package/dist/src/tool-result-guard.d.ts.map +1 -1
  270. package/dist/src/tool-result-guard.js +44 -17
  271. package/dist/src/tool-result-guard.js.map +1 -1
  272. package/dist/src/tool-result-history.d.ts +176 -24
  273. package/dist/src/tool-result-history.d.ts.map +1 -1
  274. package/dist/src/tool-result-history.js +411 -91
  275. package/dist/src/tool-result-history.js.map +1 -1
  276. package/dist/src/tune-out/coordinator.d.ts +17 -9
  277. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  278. package/dist/src/tune-out/coordinator.js +84 -37
  279. package/dist/src/tune-out/coordinator.js.map +1 -1
  280. package/dist/src/tune-out/tools.d.ts +4 -1
  281. package/dist/src/tune-out/tools.d.ts.map +1 -1
  282. package/dist/src/types/agent.d.ts +217 -7
  283. package/dist/src/types/agent.d.ts.map +1 -1
  284. package/dist/src/types/events.d.ts +33 -9
  285. package/dist/src/types/events.d.ts.map +1 -1
  286. package/dist/src/types/framework.d.ts +39 -3
  287. package/dist/src/types/framework.d.ts.map +1 -1
  288. package/dist/src/types/module.d.ts +95 -9
  289. package/dist/src/types/module.d.ts.map +1 -1
  290. package/dist/src/types/trace.d.ts +91 -3
  291. package/dist/src/types/trace.d.ts.map +1 -1
  292. package/package.json +5 -4
  293. package/src/agent.ts +661 -67
  294. package/src/api/server.ts +5 -4
  295. package/src/auto-continue.ts +43 -0
  296. package/src/code-execution/isolate/scripts.ts +11 -3
  297. package/src/cut-off-round.ts +98 -0
  298. package/src/framework.ts +6168 -1540
  299. package/src/gate/event-gate.ts +2895 -405
  300. package/src/gate/index.ts +10 -1
  301. package/src/gate/recipe-reconcile.ts +355 -0
  302. package/src/gate/reply-judge.ts +512 -0
  303. package/src/gate/types.ts +234 -9
  304. package/src/heartbeat/heartbeat.ts +804 -0
  305. package/src/heartbeat/reminder-time.ts +107 -0
  306. package/src/index.ts +11 -4
  307. package/src/mcpl/capability-mask.ts +3 -2
  308. package/src/mcpl/channel-registry.ts +1222 -468
  309. package/src/mcpl/conversation-router.ts +34 -16
  310. package/src/mcpl/convert-block.ts +14 -1
  311. package/src/mcpl/hook-orchestrator.ts +33 -6
  312. package/src/mcpl/inbound-source.ts +42 -69
  313. package/src/mcpl/index.ts +6 -1
  314. package/src/mcpl/inference-router.ts +79 -34
  315. package/src/mcpl/prose-grammar.ts +7 -0
  316. package/src/mcpl/protocol-family.ts +2 -0
  317. package/src/mcpl/push-coalescer.ts +51 -13
  318. package/src/mcpl/reference-fetcher.ts +201 -162
  319. package/src/mcpl/references.ts +851 -77
  320. package/src/mcpl/server-connection.ts +189 -11
  321. package/src/mcpl/tags.ts +13 -2
  322. package/src/mcpl/tool-lifecycle.ts +21 -0
  323. package/src/mcpl/tool-presence.ts +716 -0
  324. package/src/mcpl/tool-result-normalize.ts +18 -6
  325. package/src/mcpl/tool-signature.ts +316 -0
  326. package/src/mcpl/toolset-host.ts +727 -0
  327. package/src/mcpl/toolsets-module.ts +545 -0
  328. package/src/mcpl/transport.ts +3 -2
  329. package/src/mcpl/types.ts +25 -2
  330. package/src/mcpl/visible-content.ts +22 -9
  331. package/src/module-registry.ts +85 -26
  332. package/src/modules/discord/index.ts +1 -1
  333. package/src/modules/history/index.ts +70 -30
  334. package/src/modules/history/semantic.ts +308 -54
  335. package/src/modules/workspace/answers.ts +133 -0
  336. package/src/modules/workspace/branch-intent.ts +10 -5
  337. package/src/modules/workspace/direct-write.ts +460 -0
  338. package/src/modules/workspace/disk-agreement.ts +52 -21
  339. package/src/modules/workspace/edit-text.ts +84 -35
  340. package/src/modules/workspace/effects.ts +22 -13
  341. package/src/modules/workspace/index.ts +2142 -723
  342. package/src/modules/workspace/observe.ts +381 -177
  343. package/src/modules/workspace/read-page.ts +228 -0
  344. package/src/modules/workspace/reconcile.ts +27 -15
  345. package/src/modules/workspace/result-text.ts +121 -0
  346. package/src/modules/workspace/sync.ts +16 -0
  347. package/src/modules/workspace/text.ts +58 -32
  348. package/src/modules/workspace/types.ts +137 -60
  349. package/src/modules/workspace/watcher.ts +8 -3
  350. package/src/prose-drafts.ts +4 -3
  351. package/src/reasoning-loss-notice.ts +62 -0
  352. package/src/reasoning-loss.ts +120 -0
  353. package/src/send-tools.ts +3 -2
  354. package/src/sent-rows.ts +162 -0
  355. package/src/silent-heartbeat.ts +17 -10
  356. package/src/stable-hash.ts +14 -0
  357. package/src/stored-injections.ts +131 -0
  358. package/src/text-as-stored.ts +4 -3
  359. package/src/thinking-drops.ts +135 -0
  360. package/src/tool-args.ts +34 -1
  361. package/src/tool-image-ledger.ts +9 -8
  362. package/src/tool-presentation.ts +3 -1
  363. package/src/tool-result-guard.ts +45 -17
  364. package/src/tool-result-history.ts +471 -90
  365. package/src/tune-out/coordinator.ts +80 -37
  366. package/src/tune-out/tools.ts +4 -1
  367. package/src/types/agent.ts +220 -7
  368. package/src/types/events.ts +33 -9
  369. package/src/types/framework.ts +36 -3
  370. package/src/types/module.ts +91 -9
  371. package/src/types/trace.ts +94 -3
@@ -10,9 +10,11 @@
10
10
  * and are always dispatched to modules — the gate decides whether to spend
11
11
  * inference tokens, not whether the agent should know about the event.
12
12
  */
13
- import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
14
- import { dirname, join } from 'node:path';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
+ import { basename, dirname, join } from 'node:path';
15
15
  import { GateScript } from './gate-script.js';
16
+ import { canonical, duplicateName, lastRecipePath, noChanges, reconcileLine, reconcileWithRecipe } from './recipe-reconcile.js';
17
+ import { closestNames, isNearMiss } from '../closest-names.js';
16
18
  import { MAX_TIMER_MS } from '../deadline-timer.js';
17
19
  import { safeSlice } from '../safe-slice.js';
18
20
  // ---------------------------------------------------------------------------
@@ -38,6 +40,28 @@ function parseGateJson(text) {
38
40
  const MIN_DEBOUNCE_MS = 100;
39
41
  /** Maximum debounce value (ms). */
40
42
  const MAX_DEBOUNCE_MS = 300_000;
43
+ /** Maximum `judge.maxWaitMs` (ms): an hour. */
44
+ const MAX_JUDGE_WAIT_MS = 3_600_000;
45
+ /** Longest `judge.instructions` (characters) a rule may hold: they go to the
46
+ * judge with every batch, so they're kept short. */
47
+ const MAX_JUDGE_INSTRUCTIONS = 4_000;
48
+ /** How long a judge request may take by default (ms) before onError decides. */
49
+ const DEFAULT_JUDGE_TIMEOUT_MS = 20_000;
50
+ /** Judge failures in a row after which gate_status names the judge as failing. */
51
+ const JUDGE_FAILING_STREAK = 3;
52
+ /** Whether a check made at `last` is still fresh at `now`, less than the
53
+ * reload throttle ago. A clock stepped backwards since makes it stale, so
54
+ * the next check runs rather than wait for the clock to catch up. */
55
+ function checkedRecently(now, last) {
56
+ const elapsed = now - last;
57
+ return elapsed >= 0 && elapsed < RELOAD_THROTTLE_MS;
58
+ }
59
+ /** A read that failed because nothing is at the path. Any other failure
60
+ * (EACCES on a directory, ELOOP, ENOTDIR) is a file that's there but can't
61
+ * be read. */
62
+ function isMissing(err) {
63
+ return err?.code === 'ENOENT';
64
+ }
41
65
  // ---------------------------------------------------------------------------
42
66
  // Default config
43
67
  // ---------------------------------------------------------------------------
@@ -110,10 +134,35 @@ export function wakeProvenance(events) {
110
134
  ...(channelId ? { channelId } : {}),
111
135
  ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
112
136
  ...(pick.addressed ? { addressed: true } : {}),
113
- ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}),
137
+ ...(pick.addressed && pick.routeChannelId
138
+ ? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
139
+ : {}),
114
140
  at: pick.timestamp,
115
141
  };
116
142
  }
143
+ /** A `[Gate: …]` line's reference to messages already in the window: how
144
+ * many, in which channel, and how long ago the oldest arrived, as of `now`. */
145
+ function referenceLine(count, channel, oldest, now) {
146
+ const age = Math.round((now - oldest) / 1000);
147
+ return `- ${count} message${count > 1 ? 's' : ''} in ${channel} ` +
148
+ `(oldest ~${age}s ago) — already shown above at arrival; this is a ` +
149
+ `batched wake, not new content`;
150
+ }
151
+ /** A handed batched-wake line's text (WakeLine), as it reads when written at
152
+ * `now`: the gate's words, its message's age counted to then. */
153
+ export function wakeLineText(line, now) {
154
+ return `[Gate: 1 event matched]\n\n${referenceLine(1, line.shown, line.at, now)}`;
155
+ }
156
+ /** The event's own stored entry, as the framework is asked about it. */
157
+ function entryRef(event) {
158
+ return {
159
+ eventType: event.eventType,
160
+ ...(event.serverId !== undefined ? { serverId: event.serverId } : {}),
161
+ ...(event.channelId !== undefined ? { channelId: event.channelId } : {}),
162
+ ...(event.messageId !== undefined ? { messageId: event.messageId } : {}),
163
+ ...(event.eventId !== undefined ? { eventId: event.eventId } : {}),
164
+ };
165
+ }
117
166
  /**
118
167
  * Default route-channel resolution when the host supplies none: a
119
168
  * channel-incoming event's channel id is already the registered composite id;
@@ -136,8 +185,22 @@ function formatBehavior(b) {
136
185
  const k = b.passive_sample.keyBy ? `,keyBy:${b.passive_sample.keyBy}` : '';
137
186
  return `passive_sample:${b.passive_sample.every}${k}`;
138
187
  }
188
+ if ('judge' in b) {
189
+ const w = b.judge.maxWaitMs !== undefined ? `,maxWait:${b.judge.maxWaitMs}` : '';
190
+ return `judge:${b.judge.debounceMs}${w}`;
191
+ }
139
192
  return 'unknown';
140
193
  }
194
+ /** The quiet period (ms) of a batching behavior: a debounce's, or a judge's. */
195
+ function batchQuietMs(b) {
196
+ if (typeof b !== 'object')
197
+ return undefined;
198
+ if ('debounce' in b)
199
+ return b.debounce;
200
+ if ('judge' in b)
201
+ return b.judge.debounceMs;
202
+ return undefined;
203
+ }
141
204
  /**
142
205
  * Stable fingerprint for a GateBehavior, used during reload to detect
143
206
  * config changes that invalidate per-policy state. JSON.stringify is
@@ -149,178 +212,1254 @@ function behaviorFingerprint(b) {
149
212
  return typeof b === 'string' ? b : JSON.stringify(b);
150
213
  }
151
214
  // ---------------------------------------------------------------------------
152
- // Config validation
215
+ // Reading rules
153
216
  // ---------------------------------------------------------------------------
154
- function validateConfig(raw) {
155
- if (!raw || typeof raw !== 'object') {
156
- throw new Error('gate.json must be a JSON object');
217
+ // A rule is read as written when it can be, and every problem it has is named.
218
+ // gate.json and the host's starting rules are what a deployment already runs,
219
+ // so a rule with a problem is read the way earlier releases read it, and no
220
+ // event that woke the agent there goes quiet: a behavior the gate doesn't know
221
+ // is read as "always"; a field of the wrong type, one slip from one the gate
222
+ // reads there, or one the gate reads at another level of the rule (a match's
223
+ // "tagsAny" beside "name"), as left out, its problem naming where it's read;
224
+ // and a number out of range the way the starting rules ran it, but for a
225
+ // debounce, read at its nearest bound, which can send a batch later or sooner
226
+ // but drops no event in it, and a refill interval of 0, read as refilling at
227
+ // once. A rule is left out only where earlier releases refused it. One that
228
+ // skips or defers is read past a problem too, holding back what they held back:
229
+ // left out, the events it held back would go on to the rules after it, where a
230
+ // rate limit, a sample or a debounce could spend them and then hold back an
231
+ // event that woke the agent before. Under a default that holds events back, a
232
+ // file that leaves out a rule that could wake the agent isn't used, since that
233
+ // would hold back what the rule was written to wake on; earlier releases didn't
234
+ // use it either. A change through addPolicy, whose caller is there to fix it,
235
+ // refuses any problem, and nothing is saved. A field name that is no slip of
236
+ // one the gate reads there, and that it reads at no other level, changes
237
+ // nothing, a slip of another level's field included: it loads, and a note names
238
+ // it with the fields its place holds. In a rule, a name holding a field's whole
239
+ // name is such a name beside that field, an extra that explains it, and a slip
240
+ // of the field where it's given in the field's place (fieldNames); at
241
+ // gate.json's top, where it can be a label of the file, it's such a name either
242
+ // way. A file earlier releases loaded loads as they read it: a slip of
243
+ // "policies", a list of rules parked under a name the gate doesn't read, a
244
+ // rule's fields at gate.json's top and a "policies" that isn't a list each do
245
+ // nothing, and a note names each, so where "policies" writes no rules the file
246
+ // has none, and under "skip" or "defer" it holds back every event, as it did. A
247
+ // "policies" holding nothing writes no rules, as one left out does. A null is a
248
+ // field left out, and so is "", with a note: earlier releases read most fields
249
+ // that way, and none narrows anything by them. Where they refused a rule or a
250
+ // file for a null or a "" (a passthrough, a behavior's kind, gate.json's
251
+ // default), it now loads without it.
252
+ const CONFIG_FIELDS = ['default', 'policies'];
253
+ const POLICY_FIELDS = ['name', 'match', 'behavior', 'resets', 'passthrough'];
254
+ const MATCH_FIELDS = [
255
+ 'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
256
+ ];
257
+ const FILTER_FIELDS = ['type', 'pattern'];
258
+ const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample', 'judge'];
259
+ const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
260
+ const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
261
+ const JUDGE_FIELDS = ['debounceMs', 'maxWaitMs', 'instructions'];
262
+ function isPlainObject(value) {
263
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
264
+ }
265
+ /** A value that sets nothing: null, "", an empty list, or an object holding only such values. */
266
+ function holdsNothing(value) {
267
+ return value === null || value === '' || (Array.isArray(value) && value.length === 0)
268
+ || (isPlainObject(value) && Object.values(value).every(holdsNothing));
269
+ }
270
+ /**
271
+ * Whether a failed write left its file as it was: writeFileSync reports a
272
+ * failure once it has the file open, so it may have changed it, as its write
273
+ * or its close.
274
+ */
275
+ function beforeWriting(err) {
276
+ const syscall = err.syscall;
277
+ return syscall !== 'write' && syscall !== 'close';
278
+ }
279
+ /**
280
+ * A change's refusal where gate.json has no rule list it could edit without
281
+ * writing over what it holds: a list, JSON that isn't an object, or a
282
+ * "policies" that isn't a list and holds something. `runs` is what the gate
283
+ * runs meanwhile (rulesRun).
284
+ */
285
+ function noListRefusal(noList, runs) {
286
+ return `${noList}, so it has no list of rules a change could edit without writing over what it holds, and nothing `
287
+ + `was saved. Meanwhile the gate runs ${runs}. Until gate.json is an object whose "policies" is a list holding its `
288
+ + 'rules, a change is refused the same way.';
289
+ }
290
+ /**
291
+ * A change's refusal where gate.json is there but couldn't be used as a file
292
+ * a change edits: it couldn't be read, or it isn't JSON (`notJson`). Nothing is
293
+ * written over it, so what it holds, often an operator's unfinished edit, stays
294
+ * as it is. `runs` is what the gate runs meanwhile (rulesRun).
295
+ */
296
+ function unusableRefusal(unusable, runs) {
297
+ if (!unusable.notJson) {
298
+ return `gate.json couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written over it. `
299
+ + `Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json readable; until then, a change is `
300
+ + 'refused the same way.';
301
+ }
302
+ return `gate.json isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without writing `
303
+ + `over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change is `
304
+ + 'refused the same way.';
305
+ }
306
+ /**
307
+ * A change's refusal at a start that didn't use gate.json, which has no rules
308
+ * of its own (start-without): a change puts the rules gate.json then holds in
309
+ * force, so it would take out of force what the start runs in its place.
310
+ * `runs` is what the gate runs meanwhile (rulesRun); gate_status's entries
311
+ * (notUsedClause, describeStart) say which rules keep gate.json out of use
312
+ * and each fix.
313
+ */
314
+ function notUsedRefusal(runs) {
315
+ return "gate.json isn't used at this start, which runs as one without it does, and a change would put gate.json's rules in "
316
+ + `force in place of what that runs, so nothing was saved. Meanwhile the gate runs ${runs}. gate_status says what keeps `
317
+ + 'gate.json out of use and what would put it in use, such as giving it rules under "policies", or fixing the starting '
318
+ + 'rules, which whoever runs this host can do, for the next start; until then, a change is refused the same way.';
319
+ }
320
+ /**
321
+ * A value as a message quotes it: its JSON, cut short when long, never inside a
322
+ * surrogate pair, since the message reaches the agent's context.
323
+ */
324
+ export function shownValue(value) {
325
+ if (value === undefined)
326
+ return 'missing';
327
+ // JSON writes NaN and the infinities as null.
328
+ if (typeof value === 'number' && !Number.isFinite(value))
329
+ return String(value);
330
+ let json;
331
+ try {
332
+ json = JSON.stringify(value) ?? String(value);
157
333
  }
158
- const obj = raw;
159
- const defaultBehavior = obj.default ?? 'always';
160
- if (defaultBehavior !== 'always' && defaultBehavior !== 'defer' && defaultBehavior !== 'skip') {
161
- throw new Error(`gate.json "default" must be "always", "defer", or "skip", got: ${defaultBehavior}`);
334
+ catch {
335
+ json = String(value);
162
336
  }
163
- const policies = [];
164
- if (Array.isArray(obj.policies)) {
165
- for (const p of obj.policies) {
166
- policies.push(validatePolicy(p));
337
+ return json.length > 80 ? `${safeSlice(json, 0, 77)}...` : json;
338
+ }
339
+ /** Entries of a list as a message quotes them. */
340
+ function shownValues(values) {
341
+ return values.map(shownValue).join(', ');
342
+ }
343
+ /** `a`, `a and b`, `a, b and c` (or "or"). */
344
+ function joinedList(items, joiner = 'and') {
345
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} ${joiner} ${items[items.length - 1]}`;
346
+ }
347
+ /** `"a"`, `"a" and "b"`, `"a", "b" and "c"` (or "or"). */
348
+ function quotedList(names, joiner = 'and') {
349
+ return joinedList(names.map((n) => `"${n}"`), joiner);
350
+ }
351
+ /** 1st, 2nd, 3rd, 4th, 11th, 21st. */
352
+ function ordinal(n) {
353
+ const tens = n % 100;
354
+ const suffix = tens >= 11 && tens <= 13 ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] ?? 'th');
355
+ return `${n}${suffix}`;
356
+ }
357
+ /** A message part as one sentence, for joining several. */
358
+ function sentence(text) {
359
+ return /[.?!]$/.test(text) ? text : `${text}.`;
360
+ }
361
+ /** How a note speaks of what it checks: a rule's fields, or a tool's parameters. */
362
+ const RULE_FIELDS_TERMS = { unread: "which the gate doesn't read", listed: 'Its fields are' };
363
+ const TOOL_PARAMETERS_TERMS = { unread: "which it doesn't take", listed: 'Its parameters are' };
364
+ /**
365
+ * An object's field names that the gate doesn't read in its place: slips of
366
+ * one it does, and the rest. A name is a slip of each field isNearMiss finds
367
+ * it close to and, where `held`, of each whose whole name it holds, ignoring
368
+ * case, that the object doesn't give: sent in that field's place, it's the
369
+ * field under another name (`matchTags` with no `match`), where beside it,
370
+ * it's an extra. The object is one withoutEmpty read, so a field given as
371
+ * null or "" isn't given.
372
+ */
373
+ function fieldNames(obj, fields, held = true) {
374
+ const slips = [];
375
+ const unread = [];
376
+ for (const key of Object.keys(obj)) {
377
+ if (obj[key] === undefined || fields.includes(key))
378
+ continue;
379
+ // Every field the gate reads has four characters or more, so holding one
380
+ // needs no length of its own here. A field of two characters or fewer
381
+ // would be held by names that have nothing to do with it ("valid" holds
382
+ // "id"), and holding it would need the three characters isNearMiss asks
383
+ // of a part.
384
+ const holds = (field) => key.toLowerCase().includes(field.toLowerCase());
385
+ const near = fields.filter((field) => isNearMiss(key, field) || (held && !Object.hasOwn(obj, field) && holds(field)));
386
+ if (near.length === 0) {
387
+ unread.push(key);
388
+ continue;
167
389
  }
390
+ // closestNames keeps every name decided on above, in its order.
391
+ slips.push({ key, meant: closestNames(key, near, near.length) });
168
392
  }
169
- return { policies, default: defaultBehavior };
393
+ return { slips, unread };
394
+ }
395
+ /** `"tags" (did you mean "tagsAny", "tagsAll" or "tagsNone"?)` */
396
+ function slipShown(slip) {
397
+ return `"${slip.key}" (did you mean ${quotedList(slip.meant, 'or')}?)`;
170
398
  }
171
- function validatePolicy(raw) {
172
- if (!raw || typeof raw !== 'object') {
173
- throw new Error('Each gate policy must be an object');
399
+ /**
400
+ * An object's names that do nothing in its place, split into those that are
401
+ * fields read elsewhere, grouped by where (`homesOf`), and the rest.
402
+ */
403
+ function placedElsewhere(unread, homesOf) {
404
+ const misplaced = [];
405
+ const rest = [];
406
+ for (const key of unread) {
407
+ const homes = homesOf(key);
408
+ if (homes.length === 0) {
409
+ rest.push(key);
410
+ continue;
411
+ }
412
+ const same = misplaced.find((m) => m.homes.join() === homes.join());
413
+ if (same)
414
+ same.keys.push(key);
415
+ else
416
+ misplaced.push({ keys: [key], homes });
174
417
  }
175
- const obj = raw;
176
- if (typeof obj.name !== 'string' || !obj.name) {
177
- throw new Error('Gate policy must have a "name" string');
418
+ return { misplaced, rest };
419
+ }
420
+ /** `"tagsAny", a field of "match"`; `"tagsAny" and "source", fields of "match"` */
421
+ function misplacedShown(m) {
422
+ return `${quotedList(m.keys)}, ${m.keys.length === 1 ? 'a field' : 'fields'} of ${joinedList(m.homes)}`;
423
+ }
424
+ /**
425
+ * The levels of a gate.json the gate reads fields at, each as a message names
426
+ * it, `file` naming the file itself.
427
+ */
428
+ function fieldLevels(file) {
429
+ return [
430
+ { name: file, fields: CONFIG_FIELDS },
431
+ { name: 'a rule', fields: POLICY_FIELDS },
432
+ { name: '"match"', fields: MATCH_FIELDS },
433
+ { name: '"match.filter"', fields: FILTER_FIELDS },
434
+ { name: '"behavior"', fields: BEHAVIOR_KINDS },
435
+ { name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
436
+ { name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
437
+ { name: '"behavior.judge"', fields: JUDGE_FIELDS },
438
+ ];
439
+ }
440
+ /** Where a name is a field the gate reads, at a level other than the one whose fields are `here`. */
441
+ function homesOf(file, here) {
442
+ return (key) => fieldLevels(file)
443
+ .filter((level) => level.fields !== here && level.fields.includes(key))
444
+ .map((level) => level.name);
445
+ }
446
+ /** Where each field of an object schema's nested objects is taken: `tagsAny` in "match". */
447
+ function nestedFields(properties, path = '') {
448
+ const homes = new Map();
449
+ const add = (field, where) => homes.set(field, [...(homes.get(field) ?? []), where]);
450
+ for (const [name, schema] of Object.entries(properties)) {
451
+ if (!isPlainObject(schema) || !isPlainObject(schema.properties))
452
+ continue;
453
+ const where = path ? `${path}.${name}` : name;
454
+ for (const field of Object.keys(schema.properties))
455
+ add(field, `"${where}"`);
456
+ for (const [field, inner] of nestedFields(schema.properties, where))
457
+ for (const at of inner)
458
+ add(field, at);
459
+ }
460
+ return homes;
461
+ }
462
+ /** A note naming the fields an object has that do nothing in its place, with the fields that do. */
463
+ function noteUnread(where, unread, fields, notes, terms = RULE_FIELDS_TERMS) {
464
+ if (unread.length === 0)
465
+ return;
466
+ notes.push(`${where} has ${quotedList(unread)}, ${terms.unread}, so ${unread.length === 1 ? 'it does' : 'they do'} `
467
+ + `nothing. ${terms.listed} ${quotedList(fields)}.`);
468
+ }
469
+ /**
470
+ * A tool's input, its parameters given by its schema's `properties`, read as a
471
+ * rule's fields are: a null or "" parameter is left out (a note names each "",
472
+ * with what leaving it out does where `means` says), a slip of a parameter
473
+ * (fieldNames) is refused, naming the one meant, and so is a field of one of
474
+ * its object parameters given on its own, naming that parameter; any other
475
+ * name is noted, or named in that refusal. Returns the input without what was
476
+ * left out.
477
+ */
478
+ export function checkToolInput(tool, input, properties, notes, means = {}) {
479
+ const params = Object.keys(properties);
480
+ const given = withoutEmpty(tool, input, notes, means);
481
+ const { slips, unread } = fieldNames(given, params);
482
+ const homes = nestedFields(properties);
483
+ const { misplaced, rest } = placedElsewhere(unread, (key) => homes.get(key) ?? []);
484
+ if (slips.length > 0 || misplaced.length > 0) {
485
+ // Every name it doesn't take is named, so the call that follows the refusal doesn't count on one.
486
+ const has = [
487
+ ...(slips.length > 0 ? [`${slips.map(slipShown).join(' and ')}, ${TOOL_PARAMETERS_TERMS.unread}`] : []),
488
+ ...misplaced.map((m) => `${misplacedShown(m)}, ${TOOL_PARAMETERS_TERMS.unread} here`),
489
+ ...(rest.length > 0 ? [`${quotedList(rest)}, ${TOOL_PARAMETERS_TERMS.unread}`] : []),
490
+ ];
491
+ // Where a field has one home, show it there, as the call would give it. A
492
+ // value shownValue cuts short isn't one the call could give, so then the
493
+ // parameter is named alone.
494
+ const moves = misplaced.filter((m) => m.homes.length === 1).map((m) => {
495
+ const it = m.keys.length === 1 ? 'it' : 'them';
496
+ const shown = m.keys.map((key) => [key, shownValue(given[key])]);
497
+ return shown.every(([key, value]) => value === JSON.stringify(given[key]))
498
+ ? `Put ${it} in ${m.homes[0]}: {${shown.map(([key, value]) => `${JSON.stringify(key)}: ${value}`).join(', ')}}.`
499
+ : `Put ${it} in ${m.homes[0]}.`;
500
+ });
501
+ throw new Error(`${tool} has ${has.join(', and ')}, so nothing was saved. ${moves.map((move) => `${move} `).join('')}`
502
+ + `${TOOL_PARAMETERS_TERMS.listed} ${quotedList(params)}.`);
503
+ }
504
+ noteUnread(tool, rest, params, notes, TOOL_PARAMETERS_TERMS);
505
+ return given;
506
+ }
507
+ /**
508
+ * An object's fields as the gate reads them: a null or "" is a field left out,
509
+ * and a note names each "", with what leaving it out does where that's more
510
+ * than setting no constraint (`means`, by field). Every other field stays an
511
+ * own field of the copy, "__proto__" included, which JSON gives as one: set by
512
+ * assignment, it would make its value the copy's prototype, whose fields the
513
+ * gate would then read as the object's own.
514
+ */
515
+ function withoutEmpty(where, obj, notes, means = {}) {
516
+ const kept = [];
517
+ const empty = [];
518
+ for (const [key, value] of Object.entries(obj)) {
519
+ if (value === null || value === undefined)
520
+ continue;
521
+ if (value === '')
522
+ empty.push(key);
523
+ else
524
+ kept.push([key, value]);
525
+ }
526
+ const out = Object.fromEntries(kept);
527
+ if (empty.length > 0) {
528
+ const meant = empty.flatMap((key) => {
529
+ // An own field only: a name such as "constructor" would find Object.prototype's.
530
+ const what = Object.hasOwn(means, key) ? means[key] : undefined;
531
+ return what ? [what] : [];
532
+ });
533
+ notes.push(`${where} has ${quotedList(empty)} as "", so ${empty.length === 1 ? "it's" : "they're"} left out`
534
+ + `${meant.length > 0 ? `: ${meant.join(', and ')}` : ''}.`);
178
535
  }
179
- // Validate behavior
180
- let behavior;
181
- if (obj.behavior === 'always' || obj.behavior === 'defer' || obj.behavior === 'skip') {
182
- behavior = obj.behavior;
536
+ return out;
537
+ }
538
+ /** What leaving out a rule's field does, where that's more than setting no constraint. */
539
+ const RULE_LEFT_OUT = { behavior: 'a rule without one has the behavior "always"', match: 'a rule without one matches every event' };
540
+ /** The same for gate.json's own fields, under the default it then has. */
541
+ function configLeftOut(defaultBehavior) {
542
+ return {
543
+ default: 'its default is "always"',
544
+ policies: defaultBehavior === 'skip' || defaultBehavior === 'defer'
545
+ ? `it has no rules, so under its default, "${defaultBehavior}", every event is held back`
546
+ : 'it has no rules, so every event gets the default, "always"',
547
+ };
548
+ }
549
+ /**
550
+ * A rule's notes, ending with what a match that narrows nothing does. A
551
+ * refusal says it of the rule read without what's wrong in its match, as
552
+ * earlier releases read it, since that's why what's wrong there matters.
553
+ */
554
+ function notesOf(reading, refused = false) {
555
+ if (!reading.matchesAll)
556
+ return reading.notes;
557
+ return [...reading.notes, refused
558
+ ? "Read without what's wrong in its match, as earlier releases read it, the rule would match every event."
559
+ : `${reading.where}: nothing left in match narrows the rule, so it matches every event.`];
560
+ }
561
+ /** How a slip is read: as a field left out, which does nothing. */
562
+ const SLIP_READ = 'so it does nothing';
563
+ /** How a field of the wrong type is read: as one left out. */
564
+ const DROPPED = 'so the rule is read without it';
565
+ /**
566
+ * An object's field names, at `level` of the rule (such as "match"): a slip of
567
+ * a field is a problem, read as the field left out, and so is a field the gate
568
+ * reads at another level, named with where that is (`misplacedReadOf` says
569
+ * how the rule then reads, where that's more than the field doing nothing);
570
+ * any other name the gate doesn't read is noted.
571
+ */
572
+ function readFieldNames(r, level, obj, fields, readOf = () => SLIP_READ, misplacedReadOf = () => undefined) {
573
+ const { slips, unread } = fieldNames(obj, fields);
574
+ const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields));
575
+ noteUnread(level ? `${r.where}: ${level}` : r.where, rest, fields, r.notes);
576
+ for (const slip of slips) {
577
+ r.problems.push({ text: `${level ?? 'it'} has ${slipShown(slip)}, which the gate doesn't read`, read: readOf(slip) });
578
+ }
579
+ for (const m of misplaced) {
580
+ r.problems.push({
581
+ text: `${level ?? 'it'} has ${misplacedShown(m)}, which the gate doesn't read here`,
582
+ read: misplacedReadOf(m) ?? (m.keys.length === 1 ? SLIP_READ : 'so they do nothing'),
583
+ });
584
+ }
585
+ }
586
+ /** "so that entry is dropped", "so those entries are dropped". */
587
+ function droppedEntries(count) {
588
+ return count === 1 ? 'so that entry is dropped' : 'so those entries are dropped';
589
+ }
590
+ /**
591
+ * A rule, read as written, or past each problem it has. `unnamed` names a rule
592
+ * without a name in messages, and `file` the file it's in.
593
+ */
594
+ function readRule(raw, unnamed, file = 'gate.json') {
595
+ if (!isPlainObject(raw)) {
596
+ return {
597
+ where: unnamed,
598
+ problems: [{ text: `it's ${shownValue(raw)}, where a rule is an object, such as {"name": "my-rule", "behavior": "always"}` }],
599
+ notes: [],
600
+ quiet: false,
601
+ };
602
+ }
603
+ const name = nameOf(raw);
604
+ const r = { where: name ? `Policy "${name}"` : unnamed, file, problems: [], notes: [] };
605
+ const obj = withoutEmpty(r.where, raw, r.notes, RULE_LEFT_OUT);
606
+ // Field names first, so a slip of one is named before what its absence does.
607
+ readFieldNames(r, null, obj, POLICY_FIELDS, (slip) => {
608
+ if (slip.meant.includes('behavior') && obj.behavior === undefined)
609
+ return 'so the rule\'s behavior is "always"';
610
+ if (slip.meant.includes('match') && obj.match === undefined)
611
+ return 'so the rule matches every event';
612
+ return SLIP_READ;
613
+ }, (m) => {
614
+ const of = (fields) => m.keys.some((key) => fields.includes(key));
615
+ if (obj.match === undefined && (of(MATCH_FIELDS) || of(FILTER_FIELDS)))
616
+ return 'so the rule matches every event';
617
+ if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS) || of(JUDGE_FIELDS))) {
618
+ return 'so the rule\'s behavior is "always"';
619
+ }
620
+ return undefined;
621
+ });
622
+ if (!name) {
623
+ r.problems.push({
624
+ text: obj.name === undefined ? 'it has no "name"' : `name is ${shownValue(obj.name)}, where a non-empty string is needed`,
625
+ });
626
+ }
627
+ const { behavior, kind } = readBehavior(r, obj.behavior);
628
+ const match = readMatch(r, obj.match);
629
+ const resets = readResets(r, obj.resets);
630
+ const passthrough = readPassthrough(r, obj.passthrough, behavior, kind);
631
+ const reading = {
632
+ where: r.where, problems: r.problems, notes: r.notes, quiet: kind === 'skip' || kind === 'defer' || r.noTokens === true,
633
+ };
634
+ if (name && behavior && r.problems.every((p) => p.read !== undefined)) {
635
+ const policy = { name, match, behavior };
636
+ if (resets)
637
+ policy.resets = resets;
638
+ if (passthrough)
639
+ policy.passthrough = true;
640
+ reading.policy = policy;
641
+ if (r.matchesAll)
642
+ reading.matchesAll = true;
643
+ }
644
+ return reading;
645
+ }
646
+ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit", "passive_sample" and "judge"';
647
+ /**
648
+ * A rule's behavior: "always" when left out; one of the three names; or an
649
+ * object holding one of the timing kinds. Anything else but a list or an
650
+ * object was read as "always", a rule that wakes on every event it matches,
651
+ * and is read so; a list, or an object holding none of the kinds, was
652
+ * refused, and the rule is left out. Of
653
+ * two kinds or more, "debounce", then "rate_limit", then "passive_sample" was
654
+ * the one read, whatever their order, and is read so; "judge", newer than
655
+ * those, is read only where none of them is given. Each other kind is read
656
+ * on its own as well, so that a refusal names its problems too, and a load
657
+ * reads past them, since it isn't read either way. `kind` is the behavior's
658
+ * kind even where it can't be read, for passthrough's check.
659
+ */
660
+ function readBehavior(r, value) {
661
+ if (value === undefined)
662
+ return { behavior: 'always', kind: 'always' };
663
+ if (value === 'always' || value === 'defer' || value === 'skip')
664
+ return { behavior: value, kind: value };
665
+ if (!isPlainObject(value)) {
666
+ if (Array.isArray(value)) {
667
+ r.problems.push({ text: `behavior is ${shownValue(value)}, where ${BEHAVIORS_NEEDED} is needed` });
668
+ return {};
669
+ }
670
+ r.problems.push({ text: `behavior is ${shownValue(value)}, where ${BEHAVIORS_NEEDED} is needed`, read: 'so it\'s read as "always"' });
671
+ return { behavior: 'always', kind: 'always' };
672
+ }
673
+ const b = withoutEmpty(`${r.where}: behavior`, value, r.notes);
674
+ readFieldNames(r, 'behavior', b, BEHAVIOR_KINDS);
675
+ const kinds = BEHAVIOR_KINDS.filter((k) => b[k] !== undefined);
676
+ const kind = kinds[0];
677
+ if (kind === undefined) {
678
+ r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit", "passive_sample" and "judge"' });
679
+ return {};
680
+ }
681
+ if (kinds.length > 1) {
682
+ r.problems.push({ text: `behavior holds ${quotedList(kinds)}, and a rule has one behavior`, read: `so only its "${kind}" is read` });
683
+ }
684
+ const behavior = READ_KIND[kind](r, b[kind]);
685
+ for (const other of kinds.slice(1)) {
686
+ const unread = { where: r.where, file: r.file, problems: [], notes: [] };
687
+ READ_KIND[other](unread, b[other]);
688
+ for (const p of unread.problems)
689
+ r.problems.push({ text: p.text, read: `which goes unread with the rest of "${other}"` });
690
+ }
691
+ return { behavior, kind };
692
+ }
693
+ /**
694
+ * A debounce: a number of milliseconds. One out of the gate's range is read
695
+ * at its nearest bound. Earlier releases refused it in gate.json and ran it
696
+ * as written in the starting rules, where one over 2,147,483,647 ms ran after
697
+ * 1 ms, the most a timer takes; read at the bound, a batch goes out later or
698
+ * sooner than it did there, and every event in it is still delivered.
699
+ */
700
+ function readDebounce(r, ms) {
701
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
702
+ r.problems.push({ text: `behavior.debounce is ${shownValue(ms)}, where a number of milliseconds is needed, such as 60000` });
703
+ return undefined;
704
+ }
705
+ if (ms < MIN_DEBOUNCE_MS) {
706
+ r.problems.push({
707
+ text: `behavior.debounce is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
708
+ });
709
+ return { debounce: MIN_DEBOUNCE_MS };
710
+ }
711
+ if (ms > MAX_DEBOUNCE_MS) {
712
+ r.problems.push({
713
+ text: `behavior.debounce is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
714
+ });
715
+ return { debounce: MAX_DEBOUNCE_MS };
716
+ }
717
+ return { debounce: ms };
718
+ }
719
+ /**
720
+ * A rate limit. A refill interval under 0 ran as a bucket that never refills,
721
+ * waking on its first tokens and then on none of what the rule matched, and
722
+ * is read so. One of 0 refilled the bucket once and then never, its clock left
723
+ * at NaN; it's read as refilling at once, every time, so the rule limits
724
+ * nothing, which holds back nothing it woke the agent on (applyRateLimit). A
725
+ * rule whose tokens aren't a positive number is left out, as earlier releases'
726
+ * check refused it. Tokens that are a number not over 0, which ran unchecked
727
+ * from the starting rules, woke the agent on nothing the rule matched, so that
728
+ * rule is quiet (noTokens): leaving it out holds back nothing it was written
729
+ * to wake on, though what it matches goes on to the rules after it, as for any
730
+ * rule left out.
731
+ */
732
+ function readRateLimit(r, value) {
733
+ if (!isPlainObject(value)) {
734
+ r.problems.push({
735
+ text: `behavior.rate_limit is ${shownValue(value)}, where an object is needed, such as {"tokens": 3, "refillIntervalMs": 60000}`,
736
+ });
737
+ return undefined;
738
+ }
739
+ const conf = withoutEmpty(`${r.where}: behavior.rate_limit`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
740
+ readFieldNames(r, 'behavior.rate_limit', conf, RATE_LIMIT_FIELDS);
741
+ const tokens = conf.tokens;
742
+ const refill = conf.refillIntervalMs;
743
+ const tokensUsable = typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0;
744
+ if (typeof tokens === 'number' && !(tokens > 0))
745
+ r.noTokens = true;
746
+ if (!tokensUsable) {
747
+ r.problems.push({ text: `behavior.rate_limit.tokens is ${shownValue(tokens)}, where a positive number is needed` });
748
+ }
749
+ const refillUsable = typeof refill === 'number' && Number.isFinite(refill);
750
+ if (!refillUsable) {
751
+ r.problems.push({
752
+ text: `behavior.rate_limit.refillIntervalMs is ${shownValue(refill)}, where a positive number of milliseconds is needed`,
753
+ });
754
+ }
755
+ else if (refill <= 0) {
756
+ r.problems.push({
757
+ text: `behavior.rate_limit.refillIntervalMs is ${refill}, where a positive number of milliseconds is needed`,
758
+ read: refill === 0
759
+ ? 'so its tokens refill at once, and it limits nothing it matches'
760
+ : "so its tokens never refill, and once they're spent it wakes the agent on nothing it matches",
761
+ });
762
+ }
763
+ const keyBy = readKeyBy(r, 'rate_limit', conf.keyBy);
764
+ if (!tokensUsable || !refillUsable)
765
+ return undefined;
766
+ const out = { tokens, refillIntervalMs: refill };
767
+ if (keyBy !== undefined)
768
+ out.keyBy = keyBy;
769
+ return { rate_limit: out };
770
+ }
771
+ /**
772
+ * A sample. A step at or under 0 sampled every event, as 1 does, and a
773
+ * fraction its next whole number, since the count was compared with it, so
774
+ * each is read that way.
775
+ */
776
+ function readPassiveSample(r, value) {
777
+ if (!isPlainObject(value)) {
778
+ r.problems.push({ text: `behavior.passive_sample is ${shownValue(value)}, where an object is needed, such as {"every": 10}` });
779
+ return undefined;
780
+ }
781
+ const conf = withoutEmpty(`${r.where}: behavior.passive_sample`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
782
+ readFieldNames(r, 'behavior.passive_sample', conf, PASSIVE_SAMPLE_FIELDS);
783
+ const given = conf.every;
784
+ let every;
785
+ if (typeof given !== 'number' || !Number.isFinite(given)) {
786
+ r.problems.push({ text: `behavior.passive_sample.every is ${shownValue(given)}, where a whole number of 1 or more is needed` });
787
+ }
788
+ else if (Number.isInteger(given) && given > 0) {
789
+ every = given;
790
+ }
791
+ else {
792
+ every = given <= 0 ? 1 : Math.ceil(given);
793
+ r.problems.push({
794
+ text: `behavior.passive_sample.every is ${given}, ${given <= 0 ? 'under 1' : 'not a whole number'}`,
795
+ read: `so it's read as ${every}`,
796
+ });
797
+ }
798
+ const keyBy = readKeyBy(r, 'passive_sample', conf.keyBy);
799
+ if (every === undefined)
800
+ return undefined;
801
+ const out = { every };
802
+ if (keyBy !== undefined)
803
+ out.keyBy = keyBy;
804
+ return { passive_sample: out };
805
+ }
806
+ /**
807
+ * A judge: a debounce's quiet period, read as `debounce` is, with at its
808
+ * nearest bound one out of range; an optional longest wait, from that
809
+ * quiet period to an hour, likewise; and the agent's optional instructions to
810
+ * the judge, text of at most MAX_JUDGE_INSTRUCTIONS characters. Earlier
811
+ * releases knew no judge, so nothing deployed has one: a rule whose judge
812
+ * can't be read is left out, and its instructions, which go to a model with
813
+ * every batch, are never cut to fit.
814
+ */
815
+ function readJudge(r, value) {
816
+ if (!isPlainObject(value)) {
817
+ r.problems.push({
818
+ text: `behavior.judge is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
819
+ });
820
+ return undefined;
821
+ }
822
+ const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
823
+ maxWaitMs: 'a batch is judged only after its quiet period',
824
+ instructions: 'the judge decides without the agent\'s word',
825
+ });
826
+ readFieldNames(r, 'behavior.judge', conf, JUDGE_FIELDS);
827
+ let ok = true;
828
+ const ms = conf.debounceMs;
829
+ let debounceMs;
830
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
831
+ r.problems.push({ text: `behavior.judge.debounceMs is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
832
+ ok = false;
833
+ }
834
+ else if (ms < MIN_DEBOUNCE_MS) {
835
+ r.problems.push({
836
+ text: `behavior.judge.debounceMs is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
837
+ });
838
+ debounceMs = MIN_DEBOUNCE_MS;
839
+ }
840
+ else if (ms > MAX_DEBOUNCE_MS) {
841
+ r.problems.push({
842
+ text: `behavior.judge.debounceMs is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
843
+ });
844
+ debounceMs = MAX_DEBOUNCE_MS;
845
+ }
846
+ else {
847
+ debounceMs = ms;
183
848
  }
184
- else if (obj.behavior && typeof obj.behavior === 'object') {
185
- const b = obj.behavior;
186
- if ('debounce' in b) {
187
- if (typeof b.debounce !== 'number' || b.debounce <= 0) {
188
- throw new Error(`Policy "${obj.name}": debounce must be a positive number`);
849
+ let maxWaitMs;
850
+ const wait = conf.maxWaitMs;
851
+ if (wait !== undefined) {
852
+ if (typeof wait !== 'number' || !Number.isFinite(wait)) {
853
+ r.problems.push({ text: `behavior.judge.maxWaitMs is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
854
+ ok = false;
855
+ }
856
+ else {
857
+ const floor = debounceMs ?? MIN_DEBOUNCE_MS;
858
+ if (wait < floor) {
859
+ r.problems.push({
860
+ text: `behavior.judge.maxWaitMs is ${wait} ms, under the rule's ${floor} ms quiet period`,
861
+ read: `so it's read as ${floor} ms`,
862
+ });
863
+ maxWaitMs = floor;
189
864
  }
190
- if (b.debounce < MIN_DEBOUNCE_MS) {
191
- throw new Error(`Policy "${obj.name}": debounce must be >= ${MIN_DEBOUNCE_MS}ms, got ${b.debounce}`);
865
+ else if (wait > MAX_JUDGE_WAIT_MS) {
866
+ r.problems.push({
867
+ text: `behavior.judge.maxWaitMs is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
868
+ read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
869
+ });
870
+ maxWaitMs = MAX_JUDGE_WAIT_MS;
192
871
  }
193
- if (b.debounce > MAX_DEBOUNCE_MS) {
194
- throw new Error(`Policy "${obj.name}": debounce must be <= ${MAX_DEBOUNCE_MS}ms, got ${b.debounce}`);
872
+ else {
873
+ maxWaitMs = wait;
195
874
  }
196
- behavior = { debounce: b.debounce };
197
875
  }
198
- else if ('rate_limit' in b) {
199
- const rl = b.rate_limit;
200
- if (!rl || typeof rl !== 'object') {
201
- throw new Error(`Policy "${obj.name}": rate_limit must be an object`);
876
+ }
877
+ const said = conf.instructions;
878
+ let instructions;
879
+ if (said !== undefined) {
880
+ if (typeof said !== 'string') {
881
+ r.problems.push({ text: `behavior.judge.instructions is ${shownValue(said)}, where text is needed` });
882
+ ok = false;
883
+ }
884
+ else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
885
+ r.problems.push({
886
+ text: `behavior.judge.instructions is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
887
+ });
888
+ ok = false;
889
+ }
890
+ else {
891
+ instructions = said;
892
+ }
893
+ }
894
+ if (!ok || debounceMs === undefined)
895
+ return undefined;
896
+ const out = { debounceMs };
897
+ if (maxWaitMs !== undefined)
898
+ out.maxWaitMs = maxWaitMs;
899
+ if (instructions !== undefined)
900
+ out.instructions = instructions;
901
+ return { judge: out };
902
+ }
903
+ /** How each kind of behavior object is read. */
904
+ const READ_KIND = {
905
+ debounce: readDebounce,
906
+ rate_limit: readRateLimit,
907
+ passive_sample: readPassiveSample,
908
+ judge: readJudge,
909
+ };
910
+ /** A timing kind's keyBy: a field name. One of another type was read as none. */
911
+ function readKeyBy(r, kind, value) {
912
+ if (value === undefined)
913
+ return undefined;
914
+ if (typeof value === 'string')
915
+ return value;
916
+ r.problems.push({
917
+ text: `behavior.${kind}.keyBy is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
918
+ read: `so it does nothing, and every event the rule matches shares one ${kind === 'rate_limit' ? 'bucket' : 'count'}`,
919
+ });
920
+ return undefined;
921
+ }
922
+ /**
923
+ * A rule's match: every field optional, and one left out matches anything. A
924
+ * field of the wrong type, or an entry of one in a list, was dropped, which
925
+ * widened the rule, and is read so; an empty entry in a list, dropped, but for
926
+ * one in `scope`, which was kept and narrows the rule, since no event's type is
927
+ * "". A match that isn't an object matched every event, and so does one left
928
+ * with nothing that narrows it, which readRule notes, for a rule it can read,
929
+ * where something in the match was named: dropped, left out or unread.
930
+ */
931
+ function readMatch(r, value) {
932
+ const match = {};
933
+ if (value === undefined)
934
+ return match;
935
+ if (!isPlainObject(value)) {
936
+ r.problems.push({
937
+ text: `match is ${shownValue(value)}, where an object is needed, such as {"source": "discord"}`,
938
+ read: 'so the rule matches every event',
939
+ });
940
+ return match;
941
+ }
942
+ const named = r.problems.length + r.notes.length;
943
+ const m = withoutEmpty(`${r.where}: match`, value, r.notes);
944
+ readFieldNames(r, 'match', m, MATCH_FIELDS);
945
+ if (m.scope !== undefined) {
946
+ if (!Array.isArray(m.scope)) {
947
+ r.problems.push({
948
+ text: `match.scope is ${shownValue(m.scope)}, where a list of event types is needed, such as ["mcpl:channel-incoming"]`,
949
+ read: 'so the rule matches every type of event',
950
+ });
951
+ }
952
+ else {
953
+ const types = m.scope.filter((s) => typeof s === 'string');
954
+ const dropped = m.scope.filter((s) => typeof s !== 'string');
955
+ if (dropped.length > 0) {
956
+ r.problems.push({
957
+ text: `match.scope holds ${shownValues(dropped)}, which ${dropped.length === 1 ? "isn't an event type" : "aren't event types"}`,
958
+ read: types.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and the rule matches every type of event`,
959
+ });
202
960
  }
203
- const conf = rl;
204
- if (typeof conf.tokens !== 'number' || !Number.isFinite(conf.tokens) || conf.tokens <= 0) {
205
- throw new Error(`Policy "${obj.name}": rate_limit.tokens must be a positive number`);
961
+ if (types.includes('')) {
962
+ r.problems.push({
963
+ text: 'match.scope holds "", and no event\'s type is ""',
964
+ read: types.every((t) => t === '') ? 'so the rule matches no event' : 'so that entry matches no event',
965
+ narrows: true,
966
+ });
206
967
  }
207
- if (typeof conf.refillIntervalMs !== 'number' || !Number.isFinite(conf.refillIntervalMs) || conf.refillIntervalMs <= 0) {
208
- throw new Error(`Policy "${obj.name}": rate_limit.refillIntervalMs must be a positive number`);
968
+ match.scope = types;
969
+ }
970
+ }
971
+ for (const [field, example] of [
972
+ ['source', '"discord"'], ['channel', '"discord:*:12345"'], ['mount', '"project"'], ['pathGlob', '"notes/*.md"'],
973
+ ]) {
974
+ const given = m[field];
975
+ if (given === undefined)
976
+ continue;
977
+ if (typeof given === 'string')
978
+ match[field] = given;
979
+ else
980
+ r.problems.push({ text: `match.${field} is ${shownValue(given)}, where a string is needed, such as ${example}`, read: DROPPED });
981
+ }
982
+ for (const [field, example] of [
983
+ ['metadataTrue', '["mentioned"]'], ['tagsAny', '["chat:ambient"]'], ['tagsAll', '["chat:ambient"]'], ['tagsNone', '["chat:ambient"]'],
984
+ ]) {
985
+ const given = m[field];
986
+ if (given === undefined)
987
+ continue;
988
+ if (!Array.isArray(given)) {
989
+ r.problems.push({
990
+ text: `match.${field} is ${shownValue(given)}, where a list of non-empty strings is needed, such as ${example}`, read: DROPPED,
991
+ });
992
+ continue;
993
+ }
994
+ const kept = given.filter((s) => typeof s === 'string' && s.length > 0);
995
+ const dropped = given.filter((s) => typeof s !== 'string' || s.length === 0);
996
+ if (dropped.length > 0) {
997
+ r.problems.push({
998
+ text: `match.${field} holds ${shownValues(dropped)}, where each entry is a non-empty string`,
999
+ read: kept.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and with none left the list narrows nothing`,
1000
+ });
1001
+ }
1002
+ if (kept.length > 0)
1003
+ match[field] = kept;
1004
+ }
1005
+ if (m.filter !== undefined) {
1006
+ if (!isPlainObject(m.filter)) {
1007
+ r.problems.push({
1008
+ text: `match.filter is ${shownValue(m.filter)}, where an object is needed, such as {"type": "text", "pattern": "release"}`,
1009
+ read: DROPPED,
1010
+ });
1011
+ }
1012
+ else {
1013
+ // An empty pattern matches every event's content, so a filter with one
1014
+ // narrows nothing, as before, and it's left out, its other problems named.
1015
+ const emptyPattern = m.filter.pattern === '';
1016
+ const f = withoutEmpty(`${r.where}: match.filter`, emptyPattern ? { ...m.filter, pattern: undefined } : m.filter, r.notes);
1017
+ readFieldNames(r, 'match.filter', f, FILTER_FIELDS);
1018
+ const without = 'so the rule is read without its filter';
1019
+ const type = f.type === 'text' || f.type === 'regex' ? f.type : undefined;
1020
+ if (type === undefined) {
1021
+ r.problems.push({ text: `match.filter.type is ${shownValue(f.type)}, where "text" or "regex" is needed`, read: without });
209
1022
  }
210
- const out = {
211
- tokens: conf.tokens,
212
- refillIntervalMs: conf.refillIntervalMs,
213
- };
214
- if (typeof conf.keyBy === 'string' && conf.keyBy.length > 0) {
215
- out.keyBy = conf.keyBy;
1023
+ if (emptyPattern) {
1024
+ r.notes.push(`${r.where}: match.filter's pattern is "", so the filter narrows nothing, and it's left out.`);
216
1025
  }
217
- behavior = { rate_limit: out };
218
- }
219
- else if ('passive_sample' in b) {
220
- const ps = b.passive_sample;
221
- if (!ps || typeof ps !== 'object') {
222
- throw new Error(`Policy "${obj.name}": passive_sample must be an object`);
1026
+ else if (typeof f.pattern !== 'string') {
1027
+ r.problems.push({
1028
+ text: `match.filter.pattern is ${shownValue(f.pattern)}, where the text or regular expression to look for is needed`,
1029
+ read: without,
1030
+ });
223
1031
  }
224
- const conf = ps;
225
- if (typeof conf.every !== 'number' || !Number.isInteger(conf.every) || conf.every <= 0) {
226
- throw new Error(`Policy "${obj.name}": passive_sample.every must be a positive integer`);
1032
+ else if (type === 'text') {
1033
+ match.filter = { type, pattern: f.pattern };
227
1034
  }
228
- const out = { every: conf.every };
229
- if (typeof conf.keyBy === 'string' && conf.keyBy.length > 0) {
230
- out.keyBy = conf.keyBy;
1035
+ else if (type === 'regex') {
1036
+ try {
1037
+ new RegExp(f.pattern);
1038
+ match.filter = { type, pattern: f.pattern };
1039
+ }
1040
+ catch (e) {
1041
+ r.problems.push({ text: `match.filter.pattern isn't a regular expression the gate can read (${errorText(e)})` });
1042
+ }
231
1043
  }
232
- behavior = { passive_sample: out };
233
- }
234
- else {
235
- throw new Error(`Policy "${obj.name}": unknown behavior — expected always | defer | skip | { debounce } | { rate_limit } | { passive_sample }`);
236
1044
  }
237
1045
  }
238
- else {
239
- behavior = 'always';
1046
+ // Left with nothing that narrows it once something in it was named, it
1047
+ // matches every event. Empty lists and nulls alone always meant no
1048
+ // constraint, and name nothing.
1049
+ const narrows = Object.entries(match).some(([field, v]) => !(field === 'scope' && Array.isArray(v) && v.length === 0));
1050
+ if (!narrows && r.problems.length + r.notes.length > named)
1051
+ r.matchesAll = true;
1052
+ return match;
1053
+ }
1054
+ /** A rule's resets: names of rules whose state it clears when it fires. An entry that isn't a name was dropped. */
1055
+ function readResets(r, value) {
1056
+ if (value === undefined)
1057
+ return undefined;
1058
+ if (!Array.isArray(value)) {
1059
+ r.problems.push({
1060
+ text: `resets is ${shownValue(value)}, where a list of rule names is needed, such as ["other-rule"]`,
1061
+ read: 'so the rule resets no other',
1062
+ });
1063
+ return undefined;
240
1064
  }
241
- // Validate match (lenient — missing fields mean "match all")
242
- const match = {};
243
- if (obj.match && typeof obj.match === 'object') {
244
- const m = obj.match;
245
- if (Array.isArray(m.scope))
246
- match.scope = m.scope.filter(s => typeof s === 'string');
247
- if (typeof m.source === 'string')
248
- match.source = m.source;
249
- if (typeof m.channel === 'string')
250
- match.channel = m.channel;
251
- if (typeof m.mount === 'string')
252
- match.mount = m.mount;
253
- if (typeof m.pathGlob === 'string')
254
- match.pathGlob = m.pathGlob;
255
- if (Array.isArray(m.metadataTrue)) {
256
- const fields = m.metadataTrue.filter((s) => typeof s === 'string' && s.length > 0);
257
- if (fields.length > 0)
258
- match.metadataTrue = fields;
259
- }
260
- const tagList = (v) => {
261
- if (!Array.isArray(v))
262
- return undefined;
263
- const out = v.filter((s) => typeof s === 'string' && s.length > 0);
264
- return out.length > 0 ? out : undefined;
1065
+ // Unknown names are ignored at runtime, so reload races don't blow up.
1066
+ const names = value.filter((s) => typeof s === 'string' && s.length > 0);
1067
+ const dropped = value.filter((s) => typeof s !== 'string' || s.length === 0);
1068
+ if (dropped.length > 0) {
1069
+ r.problems.push({
1070
+ text: `resets holds ${shownValues(dropped)}, where each entry is a rule's name`,
1071
+ read: names.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and the rule resets no other`,
1072
+ });
1073
+ }
1074
+ return names.length > 0 ? names : undefined;
1075
+ }
1076
+ /**
1077
+ * A rule's passthrough. It's meaningful only on the counting behaviors, where
1078
+ * "matched but didn't fire" is a distinct outcome that can fall through. On
1079
+ * always, defer, skip or a debounce it would be a no-op at best and a
1080
+ * misleading promise at worst, so it's refused rather than ignored.
1081
+ */
1082
+ function readPassthrough(r, value, behavior, kind) {
1083
+ if (value === undefined || value === false)
1084
+ return false;
1085
+ if (value !== true) {
1086
+ r.problems.push({ text: `passthrough is ${shownValue(value)}, where true or false is needed` });
1087
+ return false;
1088
+ }
1089
+ if (kind === undefined || kind === 'rate_limit' || kind === 'passive_sample')
1090
+ return true;
1091
+ r.problems.push({
1092
+ text: `passthrough is only valid with rate_limit or passive_sample behaviors — for "${behavior ? formatBehavior(behavior) : kind}" `
1093
+ + 'there is no non-firing match to fall through on',
1094
+ });
1095
+ return false;
1096
+ }
1097
+ /**
1098
+ * A rule with no problem is in force as written. One with a problem is read
1099
+ * past it, the way earlier releases read it, so it wakes the agent wherever
1100
+ * they did and holds back what they held back, a rule that skips or defers
1101
+ * included. It's left out only where they refused it.
1102
+ */
1103
+ function ruleOutcome(reading) {
1104
+ if (reading.problems.length === 0)
1105
+ return 'as-written';
1106
+ return reading.policy === undefined ? 'left-out' : 'read';
1107
+ }
1108
+ /** What a load or a start says of a rule: how it's read past its problems, or that it's left out, and its notes. */
1109
+ function readingSentences(reading, outcome) {
1110
+ const said = [];
1111
+ if (outcome === 'read') {
1112
+ // Read past a problem that can widen it, a rule that holds events back holds back what it now matches.
1113
+ const behavior = reading.policy?.behavior;
1114
+ const holds = (behavior === 'skip' || behavior === 'defer') && reading.problems.some((p) => !p.narrows)
1115
+ ? ` Read so, it ${behavior === 'skip' ? 'skips' : 'defers'} every event it now matches, as earlier releases did.`
1116
+ : '';
1117
+ said.push(`${reading.where}: ${reading.problems.map((p) => `${p.text}, ${p.read}`).join('; ')}.${holds}`);
1118
+ }
1119
+ else if (outcome === 'left-out') {
1120
+ said.push(`${reading.where} is left out: ${reading.problems.map((p) => p.text).join('; ')}.`);
1121
+ }
1122
+ return [...said, ...notesOf(reading)];
1123
+ }
1124
+ /** addPolicy's refusal of a rule with problems: every one of them, and its notes. */
1125
+ function refusalOf(reading) {
1126
+ return [`${reading.where} wasn't saved: ${reading.problems.map((p) => p.text).join('; ')}.`, ...notesOf(reading, true)].join(' ');
1127
+ }
1128
+ /** A rule's name as written, when it has a usable one. */
1129
+ function nameOf(rule) {
1130
+ return isPlainObject(rule) && typeof rule.name === 'string' && rule.name ? rule.name : undefined;
1131
+ }
1132
+ /** What a load of gate.json says, from what its reading says of it: nothing, when that's nothing. */
1133
+ function loadNote(sentences) {
1134
+ return sentences.length > 0 ? `gate.json loaded. ${sentences.join(' ')}` : null;
1135
+ }
1136
+ /** The names of the rules a gate.json holds as written: none, for one with no rule list. */
1137
+ function heldNames(listed) {
1138
+ return new Set('noList' in listed ? [] : listed.rules.map(nameOf).filter((name) => name !== undefined));
1139
+ }
1140
+ /**
1141
+ * gate.json, or the host's starting rules, read (`file` names which in
1142
+ * messages). A file earlier releases loaded loads as they read it, with a
1143
+ * note naming what they didn't read: a list is read as no rules under the
1144
+ * default "always", and a slip of "policies", a name the gate doesn't read
1145
+ * (a list of rules parked under one included), a field of a rule at the
1146
+ * file's top and a "policies" that isn't a list each as nothing, so where
1147
+ * "policies" writes no rules the file has none, and its default decides every
1148
+ * event. A "policies" holding nothing, an empty list or one that isn't a
1149
+ * list, writes no rules, as one left out does. Earlier releases refused it
1150
+ * whole where it isn't an object, or its default isn't one of the three, and
1151
+ * it isn't used. Each rule is read on its own. Under a default that holds
1152
+ * events back, a rule left out that could wake the agent would hold back what
1153
+ * it was written to wake on, so the file isn't used, as earlier releases,
1154
+ * which refused such a rule and so the file, didn't use it. `start` is what a
1155
+ * later start ran over a gate.json with no rules of its own, for its note: the
1156
+ * host's starting rules beside one with no rule list, or, where gate.json
1157
+ * isn't used at that start, the clause saying why and what runs instead
1158
+ * (notUsedClause), which the sentence saying it has no rules carries.
1159
+ */
1160
+ function readConfig(raw, file, start) {
1161
+ const File = file === 'gate.json' ? file : 'The starting config';
1162
+ if (Array.isArray(raw)) {
1163
+ return {
1164
+ config: { policies: [], default: 'always' },
1165
+ rules: [],
1166
+ sentences: [
1167
+ `${File} is a list, where an object is needed, such as {"policies": [{"name": "my-rule", "behavior": "always"}]}, `
1168
+ + (start === 'beside'
1169
+ ? 'so none of it is read, and the gate runs the host\'s starting rules beside it, under the default, "always".'
1170
+ : 'so none of it is read, and every event gets the default, "always".'),
1171
+ ],
1172
+ listed: { noList: `${File} is a list` },
265
1173
  };
266
- const tagsAny = tagList(m.tagsAny);
267
- if (tagsAny)
268
- match.tagsAny = tagsAny;
269
- const tagsAll = tagList(m.tagsAll);
270
- if (tagsAll)
271
- match.tagsAll = tagsAll;
272
- const tagsNone = tagList(m.tagsNone);
273
- if (tagsNone)
274
- match.tagsNone = tagsNone;
275
- if (m.filter && typeof m.filter === 'object') {
276
- const f = m.filter;
277
- if ((f.type === 'text' || f.type === 'regex') && typeof f.pattern === 'string') {
278
- match.filter = { type: f.type, pattern: f.pattern };
279
- if (f.type === 'regex') {
280
- try {
281
- new RegExp(f.pattern);
282
- }
283
- catch (e) {
284
- throw new Error(`Policy "${obj.name}": invalid regex pattern: ${e}`);
285
- }
286
- }
287
- }
288
- }
289
1174
  }
290
- // Validate resets (optional list of policy names; unknown names are ignored
291
- // at runtime so reload races don't blow up).
292
- let resets;
293
- if (Array.isArray(obj.resets)) {
294
- const names = obj.resets.filter((s) => typeof s === 'string' && s.length > 0);
295
- if (names.length > 0)
296
- resets = names;
297
- }
298
- // Validate passthrough — only meaningful on the counting behaviors, where
299
- // "matched but didn't fire" is a distinct outcome that can fall through.
300
- // On always/defer/skip/debounce the flag would be a no-op at best and a
301
- // misleading promise at worst, so reject it loudly instead of ignoring it.
302
- let passthrough;
303
- if (obj.passthrough !== undefined) {
304
- if (typeof obj.passthrough !== 'boolean') {
305
- throw new Error(`Policy "${obj.name}": passthrough must be a boolean`);
306
- }
307
- if (obj.passthrough) {
308
- const counting = typeof behavior === 'object'
309
- && ('rate_limit' in behavior || 'passive_sample' in behavior);
310
- if (!counting) {
311
- throw new Error(`Policy "${obj.name}": passthrough is only valid with rate_limit or `
312
- + `passive_sample behaviors — for "${formatBehavior(behavior)}" there is `
313
- + 'no non-firing match to fall through on');
314
- }
315
- passthrough = true;
1175
+ if (!isPlainObject(raw))
1176
+ return { refused: `${file} must be a JSON object`, listed: { noList: `${File} is ${shownValue(raw)}` } };
1177
+ const sentences = [];
1178
+ // At a start that doesn't use it, what having no rules does is that.
1179
+ const notUsedWhy = typeof start === 'object' ? start.notUsed : undefined;
1180
+ const notUsed = notUsedWhy === undefined ? undefined : `it has no rules, so ${notUsedWhy}`;
1181
+ const obj = withoutEmpty(file, raw, sentences, { ...configLeftOut(raw.default), ...(notUsed ? { policies: notUsed } : {}) });
1182
+ // Here a name holding "default" or "policies" isn't a slip of it: at the
1183
+ // top, such a name can be a label of the file ("policiesUpdated").
1184
+ const names = fieldNames(obj, CONFIG_FIELDS, false);
1185
+ const { misplaced, rest } = placedElsewhere(names.unread, homesOf(file, CONFIG_FIELDS));
1186
+ noteUnread(file, rest, CONFIG_FIELDS, sentences);
1187
+ for (const m of misplaced) {
1188
+ sentences.push(`${file} has ${misplacedShown(m)}, which the gate doesn't read here, so ${m.keys.length === 1 ? 'it does' : 'they do'} nothing.`);
1189
+ }
1190
+ // A "policies" holding nothing writes no rules, as one left out does.
1191
+ const noPolicies = obj.policies === undefined || holdsNothing(obj.policies);
1192
+ // What a change edits, used or not: its own fields and rule list as written
1193
+ // (a list left out, null or "", or one holding nothing, is an empty one),
1194
+ // beside whatever else it holds; or, where "policies" isn't a list and holds
1195
+ // something, what it is instead, since a list there would write over it.
1196
+ const listed = noPolicies || Array.isArray(obj.policies)
1197
+ ? { top: raw, rules: Array.isArray(obj.policies) ? obj.policies : [] }
1198
+ : { noList: `${File}'s "policies" is ${shownValue(obj.policies)}` };
1199
+ const given = obj.default;
1200
+ if (given !== undefined && given !== 'always' && given !== 'defer' && given !== 'skip') {
1201
+ return { refused: `${file} "default" must be "always", "defer", or "skip", got: ${shownValue(given)}`, listed };
1202
+ }
1203
+ const defaultBehavior = given ?? 'always';
1204
+ // Beside a start that runs the host's starting rules, since it has no list to add them to, they're what its default
1205
+ // meets; at one that doesn't use it, what runs in its place is.
1206
+ const noRules = start === 'beside'
1207
+ ? `it has no rules, so the gate runs the host's starting rules beside it, under its default, "${defaultBehavior}"`
1208
+ : notUsed ?? configLeftOut(defaultBehavior).policies;
1209
+ for (const slip of names.slips) {
1210
+ let read = SLIP_READ;
1211
+ // Where "policies" writes no rules, what a slip of it holds leaves the file with none.
1212
+ if (slip.meant.includes('policies') && noPolicies)
1213
+ read = `and ${noRules}`;
1214
+ else if (slip.meant.includes('default') && obj.default === undefined)
1215
+ read = 'so its default is "always"';
1216
+ sentences.push(`${File} has ${slipShown(slip)}, which the gate doesn't read, ${read}.`);
1217
+ }
1218
+ const policies = Array.isArray(obj.policies) ? obj.policies : [];
1219
+ if (!Array.isArray(obj.policies) && obj.policies !== undefined) {
1220
+ sentences.push(`${File}'s "policies" is ${shownValue(obj.policies)}, where a list of rules is needed, such as `
1221
+ + `[{"name": "my-rule", "behavior": "always"}], and ${noRules}.`);
1222
+ }
1223
+ const rules = policies.map((rule, i) => {
1224
+ const reading = readRule(rule, `The ${ordinal(i + 1)} policy`, file);
1225
+ return { raw: rule, reading, outcome: ruleOutcome(reading) };
1226
+ });
1227
+ if (defaultBehavior !== 'always') {
1228
+ const silenced = rules.filter((rule) => rule.outcome === 'left-out' && !rule.reading.quiet);
1229
+ if (silenced.length > 0) {
1230
+ const one = silenced.length === 1;
1231
+ return {
1232
+ refused: [
1233
+ `${file} leaves out ${one ? 'a rule' : 'rules'} that could wake the agent, and under its default, "${defaultBehavior}", `
1234
+ + `what ${one ? 'it was' : 'they were'} written to wake it on would be held back.`,
1235
+ ...silenced.flatMap((rule) => readingSentences(rule.reading, rule.outcome)),
1236
+ ].join(' '),
1237
+ listed,
1238
+ silenced: silenced.map((rule) => nameOf(rule.raw)),
1239
+ };
316
1240
  }
317
1241
  }
318
- const policy = { name: obj.name, match, behavior };
319
- if (resets)
320
- policy.resets = resets;
321
- if (passthrough)
322
- policy.passthrough = passthrough;
323
- return policy;
1242
+ for (const rule of rules)
1243
+ sentences.push(...readingSentences(rule.reading, rule.outcome));
1244
+ // A list with no rules, or none the gate reads, where no sentence above said it has none.
1245
+ if (notUsed !== undefined && !sentences.some((said) => said.includes(notUsed))) {
1246
+ sentences.push(`${File} has no rules${rules.length > 0 ? ' but ones the gate leaves out' : ''}, so ${notUsedWhy}.`);
1247
+ }
1248
+ return {
1249
+ config: {
1250
+ policies: rules.filter((rule) => rule.outcome !== 'left-out').map((rule) => rule.reading.policy),
1251
+ default: defaultBehavior,
1252
+ },
1253
+ rules,
1254
+ sentences,
1255
+ listed,
1256
+ };
1257
+ }
1258
+ function readStartingRules(raw) {
1259
+ const read = readConfig(raw, 'the starting config');
1260
+ if ('refused' in read)
1261
+ return { refusedWhole: read.refused, rules: [], sentences: [] };
1262
+ return {
1263
+ config: read.config,
1264
+ given: raw,
1265
+ rules: read.rules.map((rule) => ({ name: nameOf(rule.raw), raw: rule.raw, policy: rule.reading.policy, quiet: rule.reading.quiet })),
1266
+ sentences: read.sentences,
1267
+ };
1268
+ }
1269
+ /**
1270
+ * The recipe the gate last applied (lastRecipePath), as written, or null when
1271
+ * it has none on record. It's read as the host's starting rules are, so a
1272
+ * recipe recorded as given reads back as the start read it. A record that
1273
+ * exists and isn't one the gate wrote (it can't be read, isn't JSON, is
1274
+ * refused as the starting rules are, or names a rule twice) gives null with
1275
+ * the reason, which quotes nothing from the file but a rule's name: a start
1276
+ * then removes and redefines nothing, as the first does, and says why.
1277
+ */
1278
+ function readLastRecipe(path) {
1279
+ if (!existsSync(path))
1280
+ return { recipe: null };
1281
+ let raw;
1282
+ try {
1283
+ raw = parseGateJson(readFileSync(path, 'utf-8'));
1284
+ }
1285
+ catch (err) {
1286
+ const code = err.code;
1287
+ return { recipe: null, problem: code ? `reading it failed with ${code}` : "it isn't JSON" };
1288
+ }
1289
+ const read = readStartingRules(raw);
1290
+ if (read.refusedWhole !== undefined)
1291
+ return { recipe: null, problem: "it isn't read as the starting rules are" };
1292
+ const rules = read.rules.map((rule) => rule.raw);
1293
+ const twice = duplicateName(rules);
1294
+ if (twice !== undefined)
1295
+ return { recipe: null, problem: `it names the rule "${twice}" twice` };
1296
+ return { recipe: { rules, default: isPlainObject(raw) ? raw.default : undefined } };
1297
+ }
1298
+ /**
1299
+ * A rule's meaning, for the start's merge: the rule as the gate reads it,
1300
+ * where it reads, and as written where it doesn't. So a rule `wake_add_rule`
1301
+ * wrote as read agrees with the same rule written more briefly.
1302
+ */
1303
+ function ruleMeaning(rule) {
1304
+ const reading = readRule(rule, 'A policy');
1305
+ return reading.policy ? `read ${canonical(reading.policy)}` : `written ${canonical(rule)}`;
1306
+ }
1307
+ /**
1308
+ * What gate_status says about the start, which stays until the next one: the
1309
+ * host's starting rules that couldn't be used and what the start did instead,
1310
+ * how it read them past problems, and rules a reconcile couldn't add. Worded
1311
+ * about the start, so later writes and reloads
1312
+ * leave it true; configSource says what's in force now. `held` is the names
1313
+ * of the rules gate.json holds as written, when it loaded.
1314
+ */
1315
+ function describeStart(starting, write, held) {
1316
+ const parts = [];
1317
+ if (starting.refusedWhole !== undefined) {
1318
+ // gate.json's rules: as loaded, or as read before a write of the start's
1319
+ // own left it unable to load; or none, where gate.json wasn't used either.
1320
+ const instead = write.kind === 'start-without'
1321
+ ? `gate.json, which has no rules the gate reads, wasn't used either: under its default, "${write.default}", it would hold back every `
1322
+ + 'event, so the gate started with no rules, as a start without gate.json does. Giving gate.json rules under '
1323
+ + '"policies", or fixing the starting rules, puts rules in force (a fix to the starting rules, at the next start).'
1324
+ : held !== null || write.kind === 'reconcile-failed'
1325
+ ? "The gate started with gate.json's rules."
1326
+ : 'The gate started with no rules, as on a host without starting rules.';
1327
+ parts.push(`At start, the host's starting rules weren't used: ${sentence(starting.refusedWhole)} ${instead}`);
1328
+ }
1329
+ if (starting.sentences.length > 0)
1330
+ parts.push(`In the host's starting rules, ${starting.sentences.join(' ')}`);
1331
+ // A reconcile whose write failed and left gate.json loading: the rules it
1332
+ // was adding didn't reach it, unless the write landed before failing.
1333
+ if (write.kind === 'reconcile-failed' && held) {
1334
+ if (!write.names.every((name) => held.has(name))) {
1335
+ parts.push(`At start, the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}), `
1336
+ + "so they weren't put in force. "
1337
+ + (write.text !== undefined
1338
+ ? 'Whoever runs this host can fix what kept them from being written.'
1339
+ : 'A start adds them once what kept them out is fixed.'));
1340
+ }
1341
+ // The merge's other changes: removals, redefinitions, moves and the default.
1342
+ if (write.changing?.length) {
1343
+ parts.push(`At start, the recipe's changes to gate.json (${quotedList(write.changing)}) `
1344
+ + (write.atOpen
1345
+ ? `weren't saved (${write.error}), so the gate started with gate.json's rules as they were. `
1346
+ : `may not all have been saved (${write.error}): the gate started with gate.json as the write left it. `)
1347
+ + (write.text !== undefined
1348
+ ? 'Whoever runs this host can fix what kept them from being written.'
1349
+ : 'A start applies them once what kept them out is fixed.'));
1350
+ }
1351
+ }
1352
+ return parts.length > 0 ? parts.join(' ') : null;
1353
+ }
1354
+ /**
1355
+ * Why gate.json, which has no rules of its own, isn't used at a start that runs
1356
+ * as one without it (start-without), and what runs in its place: the clause
1357
+ * after "it has no rules, so" in its note.
1358
+ */
1359
+ function notUsedClause(write, starting) {
1360
+ const back = 'Giving gate.json rules under "policies"';
1361
+ const out = outOfForce(write);
1362
+ if (out) {
1363
+ return `it isn't used at this start: under its default, "${write.default}", ${out.said}, so the gate runs the starting `
1364
+ + `rules under their own default, "${starting.config?.default ?? 'always'}", as a start without gate.json does. ${back}, or ${out.fix}, puts it in use`
1365
+ + (out.nextStart ? ' (a fix to the starting rules, at the next start)' : '');
1366
+ }
1367
+ return `under its default, "${write.default}", it would hold back every event: it isn't used at this start, and the gate runs `
1368
+ + `no rules, as a start without gate.json does, so every event gets the default, "always". ${back}, or fixing the starting rules, puts rules in force`
1369
+ + ' (a fix to the starting rules, at the next start)';
1370
+ }
1371
+ /**
1372
+ * The starting rules a start that doesn't use gate.json couldn't put in force
1373
+ * beside it or in it, which could wake the agent, and what would put them in
1374
+ * force: null where the starting rules were refused whole.
1375
+ */
1376
+ function outOfForce(write) {
1377
+ const nameless = write.nameless ?? 0;
1378
+ const left = [...(write.leftOut ?? []).map((name) => `"${name}"`), ...(nameless === 0 ? [] : [nameless === 1 ? 'one with no name' : `${nameless} with no name`])];
1379
+ const held = write.heldOut ?? [];
1380
+ const said = [];
1381
+ const fix = [];
1382
+ if (left.length > 0) {
1383
+ said.push(`the host's starting rules would leave out ${joinedList(left)}, which could wake the agent`);
1384
+ fix.push(`fixing ${joinedList(left)} in the starting rules`);
1385
+ }
1386
+ if (held.length > 0) {
1387
+ said.push(`the host's ${quotedList(held)}, which could wake the agent, would be kept out by gate.json's `
1388
+ + `${held.length === 1 ? 'rule of that name' : 'rules of those names'}, which the gate leaves out`);
1389
+ fix.push(`fixing gate.json's ${quotedList(held)}`);
1390
+ }
1391
+ // While gate.json has no rules of its own, every one of them has to be put
1392
+ // in force for it to be used, so where both kinds keep it out, both fixes
1393
+ // are needed; giving gate.json rules of its own is the other way, which the
1394
+ // entry gives first.
1395
+ // The starting rules are read at a start, so a fix to them puts gate.json in
1396
+ // use from the next one.
1397
+ return said.length > 0
1398
+ ? { said: said.join(', and '), fix: fix.length > 1 ? `both ${fix.join(', and ')}` : fix[0], nextStart: left.length > 0 }
1399
+ : null;
1400
+ }
1401
+ /**
1402
+ * What gate_status says when the start left rules in force unsaved: a first
1403
+ * start's seed that failed, a reconcile whose write left gate.json unable to
1404
+ * load (`loaded` false), or, over a gate.json with no rule list (`loaded`
1405
+ * true), the starting rules it lacks, run beside it, or, over one with no rules
1406
+ * of its own, the starting rules run under their own default where gate.json
1407
+ * isn't used. It goes once gate.json is written, or a reload applies its text
1408
+ * or, but where the start ran rules beside or in place of such a gate.json,
1409
+ * finds it holding the text in force.
1410
+ */
1411
+ function describeUnsaved(starting, write, loaded) {
1412
+ if (write.kind === 'reconcile-unsaved') {
1413
+ if (!loaded)
1414
+ return null;
1415
+ return `At start, the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}). `
1416
+ + `The gate runs them under gate.json's default, "${write.config.default ?? 'always'}", as if gate.json held them, but `
1417
+ + "they aren't saved in it: once gate.json changes and loads, what it holds is in force instead. Once gate.json has a "
1418
+ + 'list of rules under "policies", a start adds them to it, as to any gate.json with a list.';
1419
+ }
1420
+ if (write.kind === 'start-without') {
1421
+ // Starting rules refused whole put none in force, and describeStart says so.
1422
+ const out = outOfForce(write);
1423
+ if (!out)
1424
+ return null;
1425
+ return `At start, gate.json, which has no rules the gate reads, wasn't used: under its default, "${write.default}", ${out.said}. The gate `
1426
+ + `runs the starting rules under their own default, "${starting.config?.default ?? 'always'}", as a start without gate.json `
1427
+ + 'does, but they aren\'t saved in it: once gate.json changes and loads, what it holds is in force instead. Giving '
1428
+ + `gate.json rules under "policies", or ${out.fix}, puts it in use`
1429
+ + `${out.nextStart ? ' (a fix to the starting rules, at the next start)' : ''}.`;
1430
+ }
1431
+ if (loaded)
1432
+ return null;
1433
+ if (write.kind === 'seed-failed') {
1434
+ return write.atOpen
1435
+ ? `At start, gate.json couldn't be created (${write.error}), so the host's starting rules are in force but aren't saved in it. `
1436
+ + 'Whoever runs this host can fix what keeps it from being written.'
1437
+ : `At start, writing the host's starting rules to gate.json failed partway (${write.error}), so gate.json may be left `
1438
+ + "part-written. The starting rules are in force but aren't saved in it, and a restart before gate.json is fixed starts "
1439
+ + 'from them too. Whoever runs this host can fix what kept it from being written, and gate.json.';
1440
+ }
1441
+ if (write.kind === 'reconcile-failed') {
1442
+ // A write of the recipe's merge that did more than add rules, or one that only added them.
1443
+ const merging = (write.changing?.length ?? 0) > 0;
1444
+ const changes = quotedList([...write.names, ...(write.changing ?? [])]);
1445
+ const what = merging
1446
+ ? write.atOpen
1447
+ ? `the recipe's changes to gate.json (${changes}) couldn't be written (${write.error}), and gate.json couldn't be loaded after that`
1448
+ : `writing the recipe's changes to gate.json (${changes}) failed partway (${write.error}), so gate.json may be left part-written`
1449
+ : write.atOpen
1450
+ ? `the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}), and gate.json `
1451
+ + "couldn't be loaded after that"
1452
+ : `adding the host's starting rules ${quotedList(write.names)} to gate.json failed partway (${write.error}), so gate.json `
1453
+ + 'may be left part-written';
1454
+ // What those rules hold that the gate reads past or leaves out, since
1455
+ // gate.json's own entry is now its load error.
1456
+ const read = write.sentences?.length ? `, read as a load reads them: ${write.sentences.join(' ')}` : '.';
1457
+ const runs = merging ? 'the rules that write held' : "the rules it read from gate.json and the ones it was adding";
1458
+ return `At start, ${what}. The gate runs ${runs}, which aren't saved${read} `
1459
+ + `A restart before gate.json is fixed starts from ${starting.config ? "the host's starting rules" : 'no rules'}. `
1460
+ + 'Whoever runs this host can fix what kept it from being written, and gate.json.';
1461
+ }
1462
+ return null;
324
1463
  }
325
1464
  // ---------------------------------------------------------------------------
326
1465
  // Shadow lint — prove (conservatively) when rule ordering makes a rule dead
@@ -426,6 +1565,44 @@ export function formatShadowWarning(w) {
426
1565
  return `rule "${w.earlier}" makes "${w.later}" unreachable for ${types} `
427
1566
  + `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`;
428
1567
  }
1568
+ /** The text of a thrown value, for a log line. It never throws itself, whatever was thrown. */
1569
+ function errorText(err) {
1570
+ try {
1571
+ return err instanceof Error ? err.message : String(err);
1572
+ }
1573
+ catch {
1574
+ return 'a value that could not be printed';
1575
+ }
1576
+ }
1577
+ /**
1578
+ * Run a caller's callback once the change it's told of is made and traced. A
1579
+ * throw from it is logged, not thrown, so the call answers as the change it
1580
+ * made: told the change had failed, an agent could make it a second time.
1581
+ */
1582
+ function afterChange(name, callback) {
1583
+ try {
1584
+ callback();
1585
+ }
1586
+ catch (err) {
1587
+ console.error(`[gate] ${name} threw once the change was made, and the change stands: ${errorText(err)}`);
1588
+ }
1589
+ }
1590
+ /** The privileged-users file's error when some entries of its list aren't user ids. */
1591
+ function skippedPrivilegedIds(skipped, loaded) {
1592
+ const one = skipped.length === 1;
1593
+ const shown = skipped.slice(0, 5).map((x) => {
1594
+ if (typeof x === 'number' && Number.isInteger(x)) {
1595
+ return `${x} (a number this large can't be read exactly: write the id as a string, in quotes)`;
1596
+ }
1597
+ const json = JSON.stringify(x) ?? String(x);
1598
+ return json.length > 40 ? `${safeSlice(json, 0, 39)}…` : json;
1599
+ });
1600
+ const more = skipped.length > shown.length ? `, and ${skipped.length - shown.length} more` : '';
1601
+ const rest = loaded === 0 ? 'no id loaded, so no one is privileged' : `${loaded} ${loaded === 1 ? 'id' : 'ids'} did load`;
1602
+ return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file ${one ? "isn't a user id" : "aren't user ids"}, `
1603
+ + `so ${one ? 'it was' : 'they were'} skipped: ${shown.join(', ')}${more}. A user id is a non-empty string, or a safe integer `
1604
+ + `(a whole number within 2^53 - 1 of zero); ${rest}.`;
1605
+ }
429
1606
  // ---------------------------------------------------------------------------
430
1607
  // EventGate
431
1608
  // ---------------------------------------------------------------------------
@@ -435,13 +1612,60 @@ export class EventGate {
435
1612
  /** Optional agent-authored programmable gate (gate.js). */
436
1613
  gateScript;
437
1614
  compiledPolicies = [];
438
- configMtime = 0;
1615
+ /** The text of gate.json whose rules are in force, from a load or a write
1616
+ * (or the start's reconcile, whose write failed); or, while what's in force
1617
+ * is what a start ran beside gate.json or in its place (configSource not
1618
+ * "file"), gate.json's text as that start found it.
1619
+ * A reload parses the file again only when its text differs; a file that
1620
+ * holds it again after failing to load (a chmod that makes it readable,
1621
+ * an edit undone) needs nothing applied, only its error cleared. */
1622
+ configText;
1623
+ /** The notes on fields the text in force has that the gate doesn't read, if any. */
1624
+ configNote = null;
439
1625
  lastReloadCheck = 0;
440
1626
  configSource = 'default';
441
1627
  lastReloadTimestamp = null;
442
- configErrors = [];
1628
+ /** The current problem with each file the gate reads, one each, replaced
1629
+ * whenever that file is read again (gate_status `errors`). */
1630
+ configError = null;
1631
+ /** Whether gate.json's error is one that keeps what it holds out of force
1632
+ * (configUnreadable), so gate_status says what's in force instead. */
1633
+ configUnused = false;
1634
+ privilegedError = null;
1635
+ /** What the start did with the host's starting rules that it couldn't use
1636
+ * or add (describeStart), kept apart from gate.json's own error, until the
1637
+ * next start. */
1638
+ startNotice = null;
1639
+ /** The rules in force that the start couldn't save (describeUnsaved),
1640
+ * until gate.json is written or a reload applies its text. */
1641
+ startUnsaved = null;
1642
+ /** What the start's merge of the recipe couldn't do (seedOrReconcile), said with startNotice until the next start. */
1643
+ startMergeNote = null;
1644
+ /** Whether the start's unsaved rules (startUnsaved) are a merge's that did more than add rules. */
1645
+ startUnsavedMerged = false;
443
1646
  // Debounce state
444
1647
  debounceTimers = new Map();
1648
+ // Reply judge state (`judge` rules). The judge itself, when the host wires
1649
+ // one; without it a judge rule's batch is delivered as a debounce's.
1650
+ judgeFn;
1651
+ judgeTimeoutMs;
1652
+ judgeOnError;
1653
+ /** Per rule, the batch the judge is deciding now. */
1654
+ judging = new Map();
1655
+ /** Per rule, a batch that fired while a turn changing the judged context ran or the host was
1656
+ * quiesced, held to be judged once both are clear: the judge reads the
1657
+ * context the turn leaves, which may already answer it. */
1658
+ judgeHeld = new Map();
1659
+ judgeRecords = new Map();
1660
+ changesJudgedContextFn;
1661
+ /** Lifetime count of events dropped from batches waiting on the judge, at
1662
+ * the inference buffer's cap (MAX_INFERENCE_BUFFER per rule). */
1663
+ droppedJudgeEvents = 0;
1664
+ /** Judge requests failed in a row, and the newest failure's reason: a host
1665
+ * whose provider can't serve the judge's model fails every time, which
1666
+ * gate_status says once it's a streak (judgeFailingNotice). */
1667
+ judgeFailStreak = 0;
1668
+ judgeLastFailure = '';
445
1669
  // Rate-limit state — policy name → key → bucket.
446
1670
  rateLimitBuckets = new Map();
447
1671
  // Passive-sample state — policy name → key → counter (count since last fire).
@@ -486,11 +1710,17 @@ export class EventGate {
486
1710
  // self-wake (an external wake supersedes it; the timer would otherwise
487
1711
  // land as a redundant empty wake right after the turn).
488
1712
  selfWakeTimers = new Map();
1713
+ /** When each agent's pending self-wake fires, and what armed it
1714
+ * (upcomingWakes). Read only while its timer is in selfWakeTimers, so an
1715
+ * entry a fire or a cancel left behind says nothing. */
1716
+ selfWakeDue = new Map();
489
1717
  // Privileged users who may wake the agent through sleep. Hot-reloaded from
490
1718
  // privilegedUsersPath on change.
491
1719
  privilegedUsersPath;
492
1720
  privilegedUserIds = new Set();
493
- privilegedMtime = 0;
1721
+ /** The privileged-users file's text as last read, whether or not it loaded;
1722
+ * undefined when it was missing or its last read failed. */
1723
+ privilegedText;
494
1724
  lastPrivilegedCheck = 0;
495
1725
  // Dependency-injected callbacks
496
1726
  emitTrace;
@@ -498,6 +1728,8 @@ export class EventGate {
498
1728
  requestInferenceFn;
499
1729
  getAgentNamesFn;
500
1730
  resolveRouteChannelFn;
1731
+ channelShownFn;
1732
+ isNewestEntryFn;
501
1733
  /** Clock injection — keeps the new rate_limit / passive_sample paths
502
1734
  * testable without monkey-patching Date.now globally. */
503
1735
  now;
@@ -509,52 +1741,85 @@ export class EventGate {
509
1741
  this.requestInferenceFn = opts.requestInference;
510
1742
  this.getAgentNamesFn = opts.getAgentNames;
511
1743
  this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
1744
+ this.channelShownFn = opts.channelShown;
1745
+ this.isNewestEntryFn = opts.isNewestEntry;
512
1746
  this.now = opts.now ?? (() => Date.now());
1747
+ this.judgeFn = opts.judge;
1748
+ this.changesJudgedContextFn = opts.changesJudgedContext;
1749
+ this.judgeTimeoutMs = opts.judgeTimeoutMs !== undefined && Number.isFinite(opts.judgeTimeoutMs) && opts.judgeTimeoutMs > 0
1750
+ ? Math.min(opts.judgeTimeoutMs, MAX_TIMER_MS)
1751
+ : DEFAULT_JUDGE_TIMEOUT_MS;
1752
+ this.judgeOnError = opts.judgeOnError === 'skip' ? 'skip' : 'wake';
513
1753
  this.loadPrivileged();
514
- // Seed or reconcile the on-disk config.
1754
+ // Seed or reconcile the on-disk config from the host's starting rules
1755
+ // (`initialConfig`), which are read as gate.json is, rule by rule: a rule
1756
+ // with a problem is read past it, or left out (readConfig).
515
1757
  //
516
- // First start: write `initialConfig` verbatim.
517
- // Subsequent starts: if the recipe/config source declares policies that
518
- // aren't yet in gate.json (e.g. the user edited the recipe between
519
- // sessions), append them by name. Existing policies are left alone so
520
- // user edits via `workspace--edit _config/gate.json` survive.
521
- // The `default` field is not reconciled — it's an explicit operator
522
- // choice and a recipe change shouldn't silently flip live wake
523
- // behavior for a session that's already been running.
524
- if (opts.initialConfig) {
525
- try {
526
- mkdirSync(dirname(this.configPath), { recursive: true });
527
- if (!existsSync(this.configPath)) {
528
- writeFileSync(this.configPath, JSON.stringify(opts.initialConfig, null, 2) + '\n');
529
- }
530
- else {
531
- const existing = this.loadConfig();
532
- if (existing) {
533
- const existingNames = new Set(existing.policies.map(p => p.name));
534
- const missing = opts.initialConfig.policies.filter(p => !existingNames.has(p.name));
535
- if (missing.length > 0) {
536
- const reconciled = {
537
- ...existing,
538
- policies: [...existing.policies, ...missing],
539
- };
540
- writeFileSync(this.configPath, JSON.stringify(reconciled, null, 2) + '\n');
541
- opts.emitTrace({
542
- type: 'gate:config-reconciled',
543
- configPath: this.configPath,
544
- addedPolicies: missing.map(p => p.name),
545
- timestamp: Date.now(),
546
- });
547
- }
548
- }
549
- }
550
- }
551
- catch (err) {
552
- const msg = err instanceof Error ? err.message : String(err);
553
- this.configErrors.push(`Failed to seed/reconcile gate.json: ${msg}`);
554
- }
1758
+ // First start: write `initialConfig` as given, since gate.json is read the
1759
+ // same way, and record it as the recipe last applied, beside gate.json.
1760
+ // Later starts: merge the recipe into gate.json against that record,
1761
+ // three ways and as written (seedOrReconcile), so a rule the recipe
1762
+ // dropped, renamed, moved or redefined follows it, while a rule edited at
1763
+ // run time or by `workspace--edit _config/gate.json`, and one no recipe
1764
+ // declared, stays. `default` follows the same rule: an unedited one takes
1765
+ // the recipe's.
1766
+ const initial = opts.initialConfig;
1767
+ const starting = initial !== undefined ? readStartingRules(initial) : undefined;
1768
+ const write = starting ? this.seedOrReconcile(starting) : { kind: 'none' };
1769
+ // Load config: gate.json; without it, the starting rules as read, unless
1770
+ // none of them is used; and otherwise no rules, as a host without any has. When a
1771
+ // reconcile's own write left gate.json unable to load, the gate runs what
1772
+ // that write held, the rules it read as the recipe's merge changed them,
1773
+ // since a failed write of ours shouldn't take rules out of force. Over a gate.json
1774
+ // with no rule list to add them to, it runs gate.json's default with the
1775
+ // starting rules it lacks, as earlier releases did once they had written
1776
+ // them over it, where seedOrReconcile gives them (reconcile-unsaved). Where
1777
+ // gate.json has no rules of its own and they can't run beside it or be
1778
+ // added to it (start-without), earlier releases couldn't load what they
1779
+ // wrote and ran the starting rules from memory, so gate.json isn't used,
1780
+ // and the start runs as one without it does.
1781
+ const read = this.readConfigFile();
1782
+ if (read.state === 'loaded') {
1783
+ // Beside a gate.json with no rule list, the host's starting rules it
1784
+ // lacks, and its note says they run beside it, or, in place of one with
1785
+ // no rules of its own, those a start without it runs, and its note says
1786
+ // why.
1787
+ const beside = write.kind === 'reconcile-unsaved';
1788
+ const without = write.kind === 'start-without' ? write : null;
1789
+ this.config = beside ? write.config : without ? (starting?.config ?? { ...DEFAULT_CONFIG }) : read.config;
1790
+ this.configText = read.text;
1791
+ const start = beside ? 'beside' : without && starting ? { notUsed: notUsedClause(without, starting) } : undefined;
1792
+ const asStarted = start ? readConfig(parseGateJson(read.text), 'gate.json', start) : null;
1793
+ this.configNote = asStarted && 'sentences' in asStarted ? loadNote(asStarted.sentences) : read.note;
1794
+ this.configError = this.configNote;
1795
+ this.configSource = beside || starting?.config && without ? 'initial' : without ? 'default' : 'file';
1796
+ if (this.configNote)
1797
+ console.error(`[gate] ${this.configNote}`);
1798
+ }
1799
+ else if (write.kind === 'reconcile-failed') {
1800
+ this.config = write.config;
1801
+ // A change over the part-written gate.json is refused; once gate.json is
1802
+ // gone, this text is what a change edits, so every rule it holds keeps its name.
1803
+ this.configText = write.text;
1804
+ this.configNote = write.note ?? null;
1805
+ this.configSource = 'file';
1806
+ }
1807
+ else {
1808
+ this.config = starting?.config ?? { ...DEFAULT_CONFIG };
1809
+ this.configSource = starting?.config ? 'initial' : 'default';
1810
+ }
1811
+ // What the start did is kept apart from gate.json's load error, so the
1812
+ // load above can't clear it when it succeeds.
1813
+ if (starting) {
1814
+ const described = describeStart(starting, write, read.state === 'loaded' ? heldNames(read.listed) : null);
1815
+ this.startNotice = [described, this.startMergeNote].filter((part) => part !== null).join(' ') || null;
1816
+ this.startUnsaved = describeUnsaved(starting, write, read.state === 'loaded');
1817
+ this.startUnsavedMerged = write.kind === 'reconcile-failed' && (write.changing?.length ?? 0) > 0;
1818
+ }
1819
+ for (const said of [this.startNotice, this.startUnsaved]) {
1820
+ if (said)
1821
+ console.error(`[gate] ${said}`);
555
1822
  }
556
- // Load config
557
- this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG };
558
1823
  this.compiledPolicies = this.compilePolicies(this.config);
559
1824
  this.refreshShadowWarnings('startup');
560
1825
  // Programmable gate: an optional agent-authored `gate.js` next to gate.json.
@@ -564,30 +1829,382 @@ export class EventGate {
564
1829
  // =========================================================================
565
1830
  // Config loading
566
1831
  // =========================================================================
567
- loadConfig() {
568
- if (!existsSync(this.configPath)) {
569
- return null;
1832
+ /**
1833
+ * Write the starting rules to gate.json, as given, when it's absent, and
1834
+ * record them as the recipe last applied (lastRecipePath); or, when it
1835
+ * loads, merge them into its own rule list against that record, three ways
1836
+ * and as written (reconcileWithRecipe), and record them. A rule as the last
1837
+ * recipe left it follows the new one, a rule edited since keeps its edit, a
1838
+ * rule no recipe declared stays, and `default` follows the same rule. A
1839
+ * rule gate.json holds holds its name, whether the gate reads it or leaves
1840
+ * it out. gate.json is written before the record, so a stop between the two
1841
+ * leaves a state the next start reads rightly: the other order would make
1842
+ * the old recipe's rules read as edits. Nothing is recorded where gate.json
1843
+ * isn't written as the merge asks, or a name is given twice. Where
1844
+ * gate.json has no rules of its own and, under its default, one of them that
1845
+ * could wake the agent can't be put in force (left out, with no name, or kept
1846
+ * out by a rule of gate.json's of its name that the gate leaves out), or
1847
+ * they're refused whole and that default holds events back, gate.json isn't
1848
+ * used at this start, which runs as one without it does. Otherwise, where
1849
+ * gate.json has no rule list to add them to, it writes nothing, and the gate
1850
+ * runs beside it those of them it reads. Says what it did when that wasn't
1851
+ * simply done or not needed.
1852
+ */
1853
+ seedOrReconcile(starting) {
1854
+ const read = this.readConfigFile();
1855
+ if (read.state === 'absent') {
1856
+ if (!starting.config)
1857
+ return { kind: 'none' };
1858
+ try {
1859
+ mkdirSync(dirname(this.configPath), { recursive: true });
1860
+ writeFileSync(this.configPath, JSON.stringify(starting.given, null, 2) + '\n');
1861
+ }
1862
+ catch (err) {
1863
+ return { kind: 'seed-failed', error: errorText(err), atOpen: beforeWriting(err) };
1864
+ }
1865
+ const twice = duplicateName(starting.rules.map((rule) => rule.raw));
1866
+ if (twice !== undefined) {
1867
+ this.startMergeNote = `At start, the recipe names the rule "${twice}" twice, so gate.json was seeded with it as given and it `
1868
+ + "wasn't recorded as the recipe last applied: a later start merges the recipe by name, once gate.json and the recipe "
1869
+ + 'name each rule once.';
1870
+ }
1871
+ else {
1872
+ this.recordRecipe(lastRecipePath(this.configPath), starting.given, 'gate.json was seeded with the recipe');
1873
+ }
1874
+ return { kind: 'none' };
1875
+ }
1876
+ if (read.state !== 'loaded')
1877
+ return { kind: 'none' };
1878
+ const held = heldNames(read.listed);
1879
+ const fileDefault = read.config.default ?? 'always';
1880
+ // No rule list, an empty one, or one holding only rules the gate leaves out.
1881
+ const noRules = read.config.policies.length === 0;
1882
+ // Starting rules refused whole put none beside or in a gate.json with no
1883
+ // rules of its own, which holds events back. Earlier releases wrote them
1884
+ // over it or into it, couldn't load what they wrote, and ran them from
1885
+ // memory, so gate.json isn't used at this start either.
1886
+ if (noRules && starting.refusedWhole !== undefined && fileDefault !== 'always') {
1887
+ return { kind: 'start-without', default: fileDefault };
1888
+ }
1889
+ // Nor can a start put in force, beside or in a gate.json with no rules of
1890
+ // its own, a starting rule a load leaves out, one with no name, which it
1891
+ // can't add by name, or one whose name gate.json holds in a rule the gate
1892
+ // leaves out, which keeps the host's rule of that name out. Where, under
1893
+ // gate.json's default, one of them could wake the agent, earlier releases
1894
+ // ran the starting rules from memory: they couldn't load what they wrote
1895
+ // over gate.json or into it, or, refusing its rule, didn't load it at all.
1896
+ // So gate.json isn't used at this start, which runs as one without it does.
1897
+ if (noRules && fileDefault !== 'always') {
1898
+ const out = starting.rules.filter((rule) => !rule.quiet && (rule.policy === undefined || (rule.name !== undefined && held.has(rule.name))));
1899
+ if (out.length > 0) {
1900
+ const named = out.flatMap((rule) => (rule.name === undefined ? [] : [rule.name]));
1901
+ return {
1902
+ kind: 'start-without',
1903
+ default: fileDefault,
1904
+ leftOut: named.filter((name) => !held.has(name)),
1905
+ nameless: out.length - named.length,
1906
+ heldOut: named.filter((name) => held.has(name)),
1907
+ };
1908
+ }
570
1909
  }
1910
+ if ('noList' in read.listed) {
1911
+ const missing = starting.rules.filter((rule) => rule.name !== undefined && !held.has(rule.name));
1912
+ if (missing.length === 0)
1913
+ return { kind: 'none' };
1914
+ const names = missing.map((rule) => rule.name);
1915
+ // Adding them would write over what gate.json holds. Earlier releases
1916
+ // wrote them over it and ran them under its default, so the gate runs
1917
+ // them so, unsaved, beside it.
1918
+ const error = `${read.listed.noList}, so there's no list of rules to add them to without writing over what it holds`;
1919
+ // The ones it runs: a starting rule left out is named as the starting rules are read.
1920
+ const running = missing.flatMap((rule) => (rule.policy === undefined ? [] : [rule.policy]));
1921
+ if (running.length === 0)
1922
+ return { kind: 'reconcile-failed', names, atOpen: true, config: read.config, error };
1923
+ return { kind: 'reconcile-unsaved', names: running.map((policy) => policy.name), error, config: { policies: running, default: fileDefault } };
1924
+ }
1925
+ // Starting rules refused whole have nothing to merge or record.
1926
+ if (starting.refusedWhole !== undefined)
1927
+ return { kind: 'none' };
1928
+ // The merge, as written: gate.json's rule list and default against the
1929
+ // recipe's, three ways, with the recipe last applied.
1930
+ const recordPath = lastRecipePath(this.configPath);
1931
+ const last = readLastRecipe(recordPath);
1932
+ const given = isPlainObject(starting.given) ? starting.given : {};
1933
+ let merged;
1934
+ let appended = false;
1935
+ let appendedNote = '';
571
1936
  try {
572
- const raw = readFileSync(this.configPath, 'utf-8');
573
- const stat = statSync(this.configPath);
574
- this.configMtime = stat.mtimeMs;
575
- const config = validateConfig(parseGateJson(raw));
576
- this.configSource = 'file';
577
- this.configErrors = [];
578
- return config;
1937
+ merged = reconcileWithRecipe({ rules: read.listed.rules, default: read.listed.top.default }, { rules: starting.rules.map((rule) => rule.raw), default: given.default }, last.recipe, ruleMeaning);
1938
+ }
1939
+ catch (err) {
1940
+ // A name given twice: a merge by name can't say which rule was meant.
1941
+ // So the start adds the rules gate.json lacks, after its own, as
1942
+ // earlier releases did, and removes, redefines and records nothing.
1943
+ const missing = starting.rules.filter((rule) => rule.name !== undefined && !held.has(rule.name));
1944
+ const lacked = [...new Set(missing.map((rule) => rule.name))];
1945
+ appended = true;
1946
+ // What it did is said once its write is made (appendedNote), and a
1947
+ // write that fails is said as any start's is.
1948
+ const why = `At start, ${errorText(err)}, and the merge with the recipe needs one rule per name, so it didn't run`;
1949
+ const remedy = 'Once gate.json and the recipe name each rule once, a start merges the recipe.';
1950
+ this.startMergeNote = `${why}. ${remedy}`;
1951
+ appendedNote = `${why}: `
1952
+ + (lacked.length > 0
1953
+ ? `the start added the rules gate.json lacked (${quotedList(lacked)}), after its own, and removed, redefined and recorded nothing. `
1954
+ : 'gate.json is as it was, and nothing was recorded. ')
1955
+ + remedy;
1956
+ merged = {
1957
+ rules: [...read.listed.rules, ...missing.map((rule) => rule.raw)],
1958
+ default: read.listed.top.default,
1959
+ defaultChanged: false,
1960
+ changes: { ...noChanges(), added: missing.map((rule) => rule.name) },
1961
+ };
1962
+ }
1963
+ const { changes } = merged;
1964
+ if (last.problem !== undefined)
1965
+ changes.recordUnreadable = last.problem;
1966
+ const top = { ...read.listed.top };
1967
+ if (merged.defaultChanged) {
1968
+ if (merged.default === undefined)
1969
+ delete top.default;
1970
+ else
1971
+ top.default = merged.default;
1972
+ }
1973
+ const reconciled = { ...top, policies: merged.rules };
1974
+ const names = [...changes.added, ...changes.restored];
1975
+ const changing = [...changes.removed, ...changes.updated, ...changes.moved, ...(changes.defaultChange ? ['default'] : [])];
1976
+ const written = canonical(reconciled) !== canonical({ ...read.listed.top, policies: read.listed.rules });
1977
+ if (written) {
1978
+ // What the write holds, as a load reads it, for a write that fails partway.
1979
+ const holds = readConfig(reconciled, 'gate.json');
1980
+ if ('refused' in holds) {
1981
+ // A write of ours doesn't take gate.json's own rules out of force:
1982
+ // nothing is written or recorded, so it loads as it did. (With no
1983
+ // rules of its own, gate.json isn't used at this start, above.)
1984
+ const error = 'with them, gate.json would leave out a rule that could wake the agent, and under its default, '
1985
+ + `"${changes.defaultChange?.to ?? read.config.default ?? 'always'}", it wouldn't be used`;
1986
+ return { kind: 'reconcile-failed', names, changing, atOpen: true, config: read.config, error };
1987
+ }
1988
+ const text = JSON.stringify(reconciled, null, 2) + '\n';
1989
+ try {
1990
+ writeFileSync(this.configPath, text);
1991
+ }
1992
+ catch (err) {
1993
+ return {
1994
+ kind: 'reconcile-failed',
1995
+ names,
1996
+ changing,
1997
+ error: errorText(err),
1998
+ atOpen: beforeWriting(err),
1999
+ config: holds.config,
2000
+ text,
2001
+ note: loadNote(holds.sentences),
2002
+ sentences: holds.sentences,
2003
+ };
2004
+ }
2005
+ }
2006
+ if (appended)
2007
+ this.startMergeNote = appendedNote;
2008
+ // Said before the record is written, so a failed record write still
2009
+ // leaves what the start did to gate.json named. A start that only added
2010
+ // rules, since the merge couldn't run, is said in startNotice instead.
2011
+ const line = appended ? null : reconcileLine(changes);
2012
+ if (line)
2013
+ console.error(line);
2014
+ if (line || names.length > 0) {
2015
+ this.emitTrace({
2016
+ type: 'gate:config-reconciled',
2017
+ configPath: this.configPath,
2018
+ ...changes,
2019
+ // Every name the start added to gate.json, as an append's trace gave.
2020
+ addedPolicies: names,
2021
+ timestamp: Date.now(),
2022
+ });
2023
+ }
2024
+ if (!appended && (!last.recipe || canonical({ rules: last.recipe.rules, default: last.recipe.default }) !== canonical({ rules: starting.rules.map((rule) => rule.raw), default: given.default }))) {
2025
+ this.recordRecipe(recordPath, starting.given, written ? 'gate.json holds the recipe merged into it' : 'the merge left gate.json as it was');
2026
+ }
2027
+ return { kind: 'none' };
2028
+ }
2029
+ /**
2030
+ * Record the starting config, as given, as the recipe last applied, after
2031
+ * gate.json holds what the start merged (`holds` says what that is). A
2032
+ * write that fails is said with the start: the next start merges against
2033
+ * the record as the write left it.
2034
+ */
2035
+ recordRecipe(recordPath, given, holds) {
2036
+ try {
2037
+ writeFileSync(recordPath, JSON.stringify(given, null, 2) + '\n');
2038
+ }
2039
+ catch (err) {
2040
+ this.startMergeNote = `At start, ${holds}, and the gate runs it, but recording the recipe in ${basename(recordPath)} failed `
2041
+ + `(${errorText(err)}), so the next start merges against the record as this write left it. Whoever runs this host can `
2042
+ + 'fix what kept it from being written.';
2043
+ }
2044
+ }
2045
+ /**
2046
+ * gate.json as it is on disk: absent; loaded; refused, when it's JSON that
2047
+ * readConfig doesn't use; or unreadable, when it can't be read or isn't
2048
+ * JSON (`notJson`, where it could be read). A loaded file comes with its note,
2049
+ * which names what it holds that does nothing or is read past a problem, and
2050
+ * a loaded or refused one with its own fields and rule list as written, for a
2051
+ * write to edit (Listed).
2052
+ * Reading applies nothing: whoever applies a loaded config records its text
2053
+ * as `configText`. With `unlessUnchanged`, a file whose text is
2054
+ * `configText` comes back `unchanged`, unparsed, since the rules in force
2055
+ * came from it, or are what a start ran beside it or in its place.
2056
+ */
2057
+ readConfigFile(opts) {
2058
+ let raw;
2059
+ try {
2060
+ raw = readFileSync(this.configPath, 'utf-8');
2061
+ }
2062
+ catch (err) {
2063
+ if (!isMissing(err))
2064
+ return this.configUnreadable(err);
2065
+ // No file is no problem with it: the rules in force stay.
2066
+ this.configError = null;
2067
+ this.configUnused = false;
2068
+ return { state: 'absent' };
2069
+ }
2070
+ // A file holding the text in force has that text's notes as its error,
2071
+ // whatever a failed read or a look at other text since left there: beside
2072
+ // the host's starting rules, they say those run beside it.
2073
+ const inForce = raw === this.configText;
2074
+ if (opts?.unlessUnchanged && inForce) {
2075
+ this.configError = this.configNote;
2076
+ this.configUnused = false;
2077
+ return { state: 'unchanged' };
2078
+ }
2079
+ let parsed;
2080
+ try {
2081
+ parsed = parseGateJson(raw);
579
2082
  }
580
2083
  catch (err) {
581
- const msg = err instanceof Error ? err.message : String(err);
582
- this.configErrors = [msg];
2084
+ return { ...this.configUnreadable(err), notJson: true };
2085
+ }
2086
+ const read = readConfig(parsed, 'gate.json');
2087
+ // JSON the gate doesn't use is still the operator's file, edited as written.
2088
+ if ('refused' in read) {
2089
+ return { ...this.configUnreadable(new Error(read.refused)), state: 'refused', listed: read.listed, silenced: read.silenced };
2090
+ }
2091
+ // A field the gate doesn't read does nothing, and a rule is read past a
2092
+ // problem or left out, so the file loads, and each is named.
2093
+ const note = loadNote(read.sentences);
2094
+ this.configError = inForce ? this.configNote : note;
2095
+ this.configUnused = false;
2096
+ return { state: 'loaded', config: read.config, text: raw, note, listed: read.listed };
2097
+ }
2098
+ /**
2099
+ * gate.json's current error, replacing the last one, and traced and logged
2100
+ * when it's new, so an edit that doesn't load is in the log once.
2101
+ */
2102
+ configUnreadable(err) {
2103
+ const msg = err instanceof Error ? err.message : String(err);
2104
+ if (msg !== this.configError) {
2105
+ console.error(`[gate] ${this.configPath} couldn't be used (${msg}), so what it holds isn't in force`);
583
2106
  this.emitTrace({
584
2107
  type: 'gate:config-error',
585
2108
  error: msg,
586
2109
  configPath: this.configPath,
587
2110
  timestamp: Date.now(),
588
2111
  });
589
- return null; // Caller keeps previous config
590
2112
  }
2113
+ this.configError = msg;
2114
+ this.configUnused = true;
2115
+ return { state: 'unreadable', error: msg };
2116
+ }
2117
+ /**
2118
+ * gate.json's error as gate_status shows it: one that keeps what the file
2119
+ * holds out of force says what's in force instead, so it reads on its own.
2120
+ */
2121
+ configErrorShown() {
2122
+ if (this.configError === null || !this.configUnused)
2123
+ return this.configError;
2124
+ return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}.`;
2125
+ }
2126
+ /** What the gate runs, by where its rules came from, for a text that says so: "the host's starting rules". */
2127
+ rulesRun() {
2128
+ if (this.config.policies.length === 0)
2129
+ return `no rules: every event gets the default, "${this.config.default ?? 'always'}"`;
2130
+ // Rules from a gate.json text, with the start's unsaved: only a start
2131
+ // whose reconcile write left gate.json unable to load runs those, since a
2132
+ // write of gate.json that lands, a reload that finds it holding a text
2133
+ // that loads, or, while gate.json's rules are in force, one that finds it
2134
+ // holding the text in force, clears startUnsaved.
2135
+ return this.configSource === 'file'
2136
+ ? this.startUnsaved !== null
2137
+ ? this.startUnsavedMerged
2138
+ ? 'the rules its start was writing to gate.json'
2139
+ : 'the rules it read from gate.json at start and the ones it was adding'
2140
+ : 'the rules of the last gate.json it used'
2141
+ : "the host's starting rules";
2142
+ }
2143
+ /**
2144
+ * What a policy change edits: gate.json's own fields and rule list as
2145
+ * written, each rule as written, so a write keeps every rule it doesn't
2146
+ * change, problems and unknown fields included, whether gate.json loads or
2147
+ * is JSON the gate doesn't use. Over one it doesn't use, writeConfig writes
2148
+ * only what loads, so a change is refused, naming what keeps the file out
2149
+ * and what would bring it into force (`remedy`), unless it mends that, and
2150
+ * the file stays as its operator wrote it. When
2151
+ * gate.json is absent, the same of the text whose rules are in force, so a
2152
+ * rule it leaves out keeps its name; or the rules in force, where no text
2153
+ * is, it has no rule list, or what's in force is what a start ran beside or
2154
+ * in place of it. A change is refused (`refused`) for a gate.json that can't
2155
+ * be read or isn't JSON (unusableRefusal), whose rules can't be edited
2156
+ * without writing over what it holds, and for one with no rule list a write
2157
+ * could edit without that (a list, a "policies" that isn't one and holds
2158
+ * something, or JSON that isn't an object): so a removal or an anchor is
2159
+ * refused too, rather than looked for in rules that may not be what the
2160
+ * file holds. It's refused as well for a gate.json that holds the text a
2161
+ * start didn't use (start-without), since what the change wrote would put
2162
+ * its rules in force in place of what the start runs.
2163
+ */
2164
+ rulesForChange() {
2165
+ const read = this.readConfigFile();
2166
+ if (read.state === 'unreadable')
2167
+ return { refused: unusableRefusal(read, this.rulesRun()) };
2168
+ if (read.state === 'loaded' || read.state === 'refused') {
2169
+ if ('noList' in read.listed)
2170
+ return { refused: noListRefusal(read.listed.noList, this.rulesRun()) };
2171
+ // Rules not gate.json's in force while it holds the text in force: what a
2172
+ // start ran in place of one with no rules of its own, until it changes.
2173
+ if (read.state === 'loaded' && read.text === this.configText && this.configSource !== 'file') {
2174
+ return { refused: notUsedRefusal(this.rulesRun()) };
2175
+ }
2176
+ const edit = { top: read.listed.top, rules: [...read.listed.rules] };
2177
+ if (read.state === 'loaded')
2178
+ return edit;
2179
+ // What would bring it into force, for a change that doesn't. A change
2180
+ // finds its rule by name: a replacement takes the first rule with it,
2181
+ // and a removal every one.
2182
+ const names = [...new Set(read.silenced ?? [])];
2183
+ const only = names.length === 1 ? names[0] : undefined;
2184
+ const sharing = only === undefined ? 0 : edit.rules.filter((rule) => nameOf(rule) === only).length;
2185
+ // A removal that would leave no rules in force is refused (writeConfig),
2186
+ // so it isn't offered.
2187
+ const rest = readConfig({ ...edit.top, policies: edit.rules.filter((rule) => nameOf(rule) !== only) }, 'gate.json');
2188
+ const removes = !('refused' in rest) && rest.config.policies.length > 0;
2189
+ return {
2190
+ ...edit,
2191
+ remedy: sharing === 1
2192
+ ? `${removes ? 'Replacing or removing' : 'Replacing'} "${only}" by name would bring gate.json into force.`
2193
+ : sharing > 1
2194
+ ? `gate.json has ${sharing} rules named "${only}", and a change by that name replaces the first of them`
2195
+ + `${removes ? ' or removes them all' : ''}.`
2196
+ : 'No change through the gate can bring it into force, so gate.json has to be fixed by an edit of its own; '
2197
+ + 'until then, a change is refused the same way.',
2198
+ };
2199
+ }
2200
+ // While the rules in force aren't gate.json's, configText is the text a
2201
+ // start ran them beside or in place of, so its rules aren't the ones in force.
2202
+ const text = this.configText !== undefined && this.configSource === 'file'
2203
+ ? readConfig(parseGateJson(this.configText), 'gate.json')
2204
+ : undefined;
2205
+ return text && !('refused' in text) && !('noList' in text.listed)
2206
+ ? { top: text.listed.top, rules: [...text.listed.rules] }
2207
+ : { top: { policies: [], default: this.config.default ?? 'always' }, rules: [...this.config.policies] };
591
2208
  }
592
2209
  compilePolicies(config) {
593
2210
  return config.policies.map(policy => {
@@ -619,53 +2236,71 @@ export class EventGate {
619
2236
  return compiled;
620
2237
  });
621
2238
  }
2239
+ /**
2240
+ * Clear the state of each rule `was` held in force that `next` leaves out or
2241
+ * gives another behavior: a reload calls it before it puts `next` in force,
2242
+ * a write after. Without this, a rule that came back, or whose tokens 100
2243
+ * became 5 (or keyBy switched, or every 10 became 3), would run on the
2244
+ * buckets and counters of the rule it was. A rule left out loses its stats
2245
+ * (matchCount, lastMatchTimestamp) too; a rule kept keeps them, which are
2246
+ * history, not live state, and one kept with the same behavior keeps all its
2247
+ * state, its denial and fire counts included. Rules that share a name share
2248
+ * its state, so a name keeps it only where its rules, in order, keep their
2249
+ * behaviors. Each pending debounce batch is sent last, as its timer would
2250
+ * have sent it, once every rule's state is cleared; one whose delivery throws
2251
+ * is named in the log and dropped (fireDebounce), and the rest are still
2252
+ * sent, so the write or reload that cleared it has done what it was for, its
2253
+ * caller hears that it did, and no state is left behind.
2254
+ */
2255
+ clearStateFor(was, next) {
2256
+ const behaviorsByName = (config) => {
2257
+ const byName = new Map();
2258
+ for (const p of config.policies)
2259
+ byName.set(p.name, [...(byName.get(p.name) ?? []), behaviorFingerprint(p.behavior)]);
2260
+ return new Map([...byName].map(([name, behaviors]) => [name, JSON.stringify(behaviors)]));
2261
+ };
2262
+ const before = behaviorsByName(was);
2263
+ const after = behaviorsByName(next);
2264
+ const removed = [...before.keys()].filter((name) => !after.has(name));
2265
+ for (const name of removed) {
2266
+ this.clearPolicyState(name);
2267
+ this.stats.delete(name);
2268
+ this.judgeRecords.delete(name);
2269
+ }
2270
+ const cleared = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
2271
+ for (const name of cleared)
2272
+ this.clearPolicyState(name);
2273
+ for (const name of [...removed, ...cleared])
2274
+ this.fireDebounce(name);
2275
+ return { removed, cleared };
2276
+ }
622
2277
  reloadIfChanged() {
623
2278
  const now = Date.now();
624
- if (now - this.lastReloadCheck < RELOAD_THROTTLE_MS)
2279
+ if (checkedRecently(now, this.lastReloadCheck))
625
2280
  return;
626
2281
  this.lastReloadCheck = now;
627
2282
  try {
628
- if (!existsSync(this.configPath))
2283
+ // Missing, unchanged or unreadable: the rules in force stay.
2284
+ const read = this.readConfigFile({ unlessUnchanged: true });
2285
+ // gate.json holds the text in force, so none the start ran is unsaved,
2286
+ // unless what's in force is what the start ran beside it or in its place.
2287
+ if (read.state === 'unchanged' && this.configSource === 'file')
2288
+ this.startUnsaved = null;
2289
+ if (read.state !== 'loaded')
629
2290
  return;
630
- const stat = statSync(this.configPath);
631
- if (stat.mtimeMs === this.configMtime)
632
- return;
633
- const newConfig = this.loadConfig();
634
- if (!newConfig)
635
- return; // Parse error — keep previous config
636
- // Capture old policies' behavior fingerprints before any state
637
- // mutation — needed to detect kept policies whose parameters changed.
638
- const oldBehaviorByName = new Map();
639
- for (const p of this.config.policies) {
640
- oldBehaviorByName.set(p.name, behaviorFingerprint(p.behavior));
641
- }
642
- // Clean up state for REMOVED policies (gone from new config entirely).
643
- const newPolicyNames = new Set(newConfig.policies.map(p => p.name));
644
- const removedPolicies = [];
645
- for (const oldName of oldBehaviorByName.keys()) {
646
- if (!newPolicyNames.has(oldName)) {
647
- this.clearPolicyState(oldName, { deliverPendingDebounce: true });
648
- this.stats.delete(oldName);
649
- removedPolicies.push(oldName);
650
- }
651
- }
652
- // Clean up state for KEPT policies whose behavior parameters changed.
653
- // Without this, a tokens 100→5 edit (or keyBy switch, or every 10→3,
654
- // etc.) wouldn't take effect until existing buckets/counters drained.
655
- // Stats are preserved — matchCount is historical, not live state.
656
- const stateClearedPolicies = [];
657
- for (const newPolicy of newConfig.policies) {
658
- const oldHash = oldBehaviorByName.get(newPolicy.name);
659
- if (oldHash === undefined)
660
- continue; // new policy, no state to clear
661
- const newHash = behaviorFingerprint(newPolicy.behavior);
662
- if (oldHash !== newHash) {
663
- this.clearPolicyState(newPolicy.name, { deliverPendingDebounce: true });
664
- stateClearedPolicies.push(newPolicy.name);
665
- }
666
- }
2291
+ const newConfig = read.config;
2292
+ const { removed: removedPolicies, cleared: stateClearedPolicies } = this.clearStateFor(this.config, newConfig);
667
2293
  this.config = newConfig;
668
2294
  this.compiledPolicies = this.compilePolicies(newConfig);
2295
+ // Recorded only now that its rules are in force, so a reload that
2296
+ // throws before here is tried again at the next check.
2297
+ this.configText = read.text;
2298
+ this.configNote = read.note;
2299
+ this.configSource = 'file';
2300
+ if (read.note)
2301
+ console.error(`[gate] ${read.note}`);
2302
+ // The rules in force are now gate.json's, so none the start ran is unsaved.
2303
+ this.startUnsaved = null;
669
2304
  this.lastReloadTimestamp = now;
670
2305
  this.refreshShadowWarnings('reload');
671
2306
  this.emitTrace({
@@ -678,7 +2313,7 @@ export class EventGate {
678
2313
  });
679
2314
  }
680
2315
  catch {
681
- // Ignore stat errors
2316
+ // The rules in force stay, and the next check tries again.
682
2317
  }
683
2318
  }
684
2319
  // =========================================================================
@@ -687,52 +2322,83 @@ export class EventGate {
687
2322
  /**
688
2323
  * Add or replace (upsert) a single policy in gate.json and apply it live.
689
2324
  *
690
- * The raw policy is validated with the SAME `validatePolicy` used when loading
691
- * gate.json — an invalid shape throws (nothing is written) and the caller
692
- * surfaces it as a tool error. On success the policy is persisted to gate.json
693
- * (so it survives restart and is visible to `workspace--edit _config/gate.json`)
2325
+ * The raw policy is read with the SAME `readRule` used when loading gate.json,
2326
+ * and since its caller is there to fix it, any problem it has throws, naming
2327
+ * every one (nothing is written), and the caller surfaces it as a tool error,
2328
+ * where a load reads past it (readConfig). On success the policy is persisted
2329
+ * to gate.json (so it survives restart and is visible to `workspace--edit _config/gate.json`)
694
2330
  * and the in-memory config is swapped in immediately, so the rule takes effect
695
- * without waiting for the throttled mtime reload.
2331
+ * without waiting for the throttled reload.
696
2332
  *
697
2333
  * The freshest on-disk config is re-read first, so a concurrent operator edit
698
- * isn't clobbered. A policy with the same `name` is replaced IN PLACE when no
699
- * `position` is given (order preserved); WITH a `position` it is moved there
700
- * — so re-issuing a rule is also how you reposition it. New policies append
701
- * by default; `'prepend'` puts one at the very front (ahead of EVERYTHING,
702
- * including addressed-message wake rules — see the shadow lint), and the
703
- * anchored forms `{before: name}` / `{after: name}` insert relative to an
704
- * existing rule, which is almost always the safer way to order a sampler
705
- * or debounce against a specific catch-all.
2334
+ * isn't clobbered, and the change edits gate.json's own rule list by name:
2335
+ * every rule it doesn't change is written back as it was written, problems and
2336
+ * fields the gate doesn't read included, and so is everything else gate.json
2337
+ * holds, such as a list of rules parked under another name, beside which a
2338
+ * change adds a "policies" list where there's none (rulesForChange). Over a
2339
+ * gate.json the gate doesn't use, only a change after which it loads is
2340
+ * written, such as one replacing or removing the rule that keeps it out, and
2341
+ * that leaves it rules in force, since it holds events back by default; any
2342
+ * other is refused, naming what does, and the file stays as written. When
2343
+ * gate.json is absent, the change edits the text whose rules are in force the
2344
+ * same way, or the rules in force where there's none. A gate.json that can't
2345
+ * be read or isn't JSON is refused (unusableRefusal), and stays as it is. A policy with the
2346
+ * same `name` is replaced IN PLACE when no `position` is given (order
2347
+ * preserved); WITH a `position` it is moved there — so re-issuing a rule is
2348
+ * also how you reposition it. New policies append by default; `'prepend'` puts
2349
+ * one at the very front (ahead of EVERYTHING, including addressed-message wake
2350
+ * rules — see the shadow lint), and the anchored forms `{before: name}` /
2351
+ * `{after: name}` insert relative to an existing rule, which is almost always
2352
+ * the safer way to order a sampler or debounce against a specific catch-all.
2353
+ * Fields of the policy the gate reads nowhere, and that aren't a slip of one
2354
+ * it does, are left out of what's written, and named to `onIgnoredFields` once
2355
+ * the change is written and traced. A throw from the callback is logged,
2356
+ * and the change stands (afterChange). A field it reads at another level is a
2357
+ * problem, like a slip.
2358
+ *
2359
+ * Throws, writing nothing, for an invalid policy, a placement it can't make,
2360
+ * a gate.json that can't be read or isn't JSON, one with no rule list it
2361
+ * could edit without writing over what it holds, one a start didn't use,
2362
+ * which runs as one without it does, while it's unchanged, or one the gate
2363
+ * doesn't use that the change doesn't mend or would leave with no rules in
2364
+ * force; and throws when the write fails, which may leave gate.json
2365
+ * part-written.
706
2366
  */
707
2367
  addPolicy(rawPolicy, options) {
708
- const policy = validatePolicy(rawPolicy);
709
- const current = this.loadConfig() ?? this.config;
710
- const policies = [...current.policies];
711
- const idx = policies.findIndex((p) => p.name === policy.name);
2368
+ const reading = readRule(rawPolicy, 'A policy');
2369
+ if (reading.problems.length > 0 || !reading.policy)
2370
+ throw new Error(refusalOf(reading));
2371
+ const policy = reading.policy;
2372
+ const ignored = notesOf(reading);
2373
+ const edit = this.rulesForChange();
2374
+ if ('refused' in edit)
2375
+ throw new Error(edit.refused);
2376
+ const { top, rules, remedy } = edit;
2377
+ const idx = rules.findIndex((rule) => nameOf(rule) === policy.name);
712
2378
  const position = options?.position;
713
2379
  if (idx >= 0 && position === undefined) {
714
- policies[idx] = policy; // replace in place, order preserved
2380
+ rules[idx] = policy; // replace in place, order preserved
715
2381
  }
716
2382
  else {
717
2383
  if (idx >= 0)
718
- policies.splice(idx, 1); // repositioning a known rule
2384
+ rules.splice(idx, 1); // repositioning a known rule
719
2385
  if (position === 'prepend') {
720
- policies.unshift(policy);
2386
+ rules.unshift(policy);
721
2387
  }
722
2388
  else if (position !== undefined && typeof position === 'object') {
723
2389
  const anchorName = 'before' in position ? position.before : position.after;
724
- const anchorIdx = policies.findIndex((p) => p.name === anchorName);
2390
+ const anchorIdx = rules.findIndex((rule) => nameOf(rule) === anchorName);
725
2391
  if (anchorIdx < 0) {
726
2392
  throw new Error(`addPolicy: no policy named "${anchorName}" to anchor on — `
727
- + `have: [${policies.map((p) => p.name).join(', ')}]`);
2393
+ + `have: [${rules.map(nameOf).filter((name) => name !== undefined).join(', ')}]`);
728
2394
  }
729
- policies.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
2395
+ rules.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
730
2396
  }
731
2397
  else {
732
- policies.push(policy);
2398
+ rules.push(policy);
733
2399
  }
734
2400
  }
735
- this.writeConfig({ ...current, policies });
2401
+ this.writeConfig(top, rules, remedy);
736
2402
  this.emitTrace({
737
2403
  type: 'gate:policy-added',
738
2404
  configPath: this.configPath,
@@ -740,25 +2406,47 @@ export class EventGate {
740
2406
  replaced: idx >= 0,
741
2407
  timestamp: Date.now(),
742
2408
  });
2409
+ if (ignored.length > 0) {
2410
+ // A caller that doesn't take the notes still gets them said, in the log.
2411
+ const onIgnoredFields = options?.onIgnoredFields;
2412
+ if (onIgnoredFields)
2413
+ afterChange('onIgnoredFields', () => onIgnoredFields(ignored));
2414
+ else
2415
+ console.error(`[gate] ${ignored.join(' ')}`);
2416
+ }
743
2417
  return policy;
744
2418
  }
745
2419
  /**
746
2420
  * Remove a policy by name from gate.json and clear its live state. Any pending
747
- * debounce batch is delivered first (matching reloadIfChanged's removal path,
748
- * so buffered events aren't silently dropped). Returns false if no policy of
749
- * that name exists (nothing written). Freshest on-disk config is re-read first
750
- * (see addPolicy).
2421
+ * debounce batch is sent once the removal is written and in force, as its
2422
+ * timer would have sent it, so buffered events aren't silently dropped
2423
+ * (clearStateFor). Freshest on-disk config is re-read first, and every other
2424
+ * rule is written back as it was written (see addPolicy). A name that isn't
2425
+ * a non-empty string names no rule, so it returns false, writing nothing,
2426
+ * whatever gate.json holds. Otherwise it throws, writing nothing, for a
2427
+ * gate.json that can't be read or isn't JSON, one with no rule list it could
2428
+ * edit, or one a start didn't use, as addPolicy does, and returns false,
2429
+ * writing nothing, if no policy of that name exists, gate.json's own rules
2430
+ * included, so one the gate leaves out can be removed by name too. For a name
2431
+ * it holds, it throws, writing nothing, for a gate.json the gate doesn't use
2432
+ * that the removal doesn't mend or would leave with no rules in force, and
2433
+ * throws when the write fails, which may
2434
+ * leave gate.json part-written; the rules in force then stay as they were,
2435
+ * with their state.
751
2436
  */
752
2437
  removePolicy(name) {
753
- const current = this.loadConfig() ?? this.config;
754
- if (!current.policies.some((p) => p.name === name)) {
2438
+ // nameOf gives undefined for a rule with no name, so a call from JavaScript
2439
+ // with no name would otherwise remove every such rule.
2440
+ if (typeof name !== 'string' || name === '')
755
2441
  return false;
756
- }
757
- // Deliver any pending debounce batch, then drop timers/buckets/counters.
758
- this.clearPolicyState(name, { deliverPendingDebounce: true });
759
- this.stats.delete(name);
760
- const policies = current.policies.filter((p) => p.name !== name);
761
- this.writeConfig({ ...current, policies });
2442
+ const edit = this.rulesForChange();
2443
+ if ('refused' in edit)
2444
+ throw new Error(edit.refused);
2445
+ if (!edit.rules.some((rule) => nameOf(rule) === name))
2446
+ return false;
2447
+ // The write clears the rule's state once it has succeeded (writeConfig),
2448
+ // so a refusal or a failed write leaves the rule in force with its state.
2449
+ this.writeConfig(edit.top, edit.rules.filter((rule) => nameOf(rule) !== name), edit.remedy);
762
2450
  this.emitTrace({
763
2451
  type: 'gate:policy-removed',
764
2452
  configPath: this.configPath,
@@ -768,34 +2456,74 @@ export class EventGate {
768
2456
  return true;
769
2457
  }
770
2458
  /**
771
- * List the current policy names (freshest on-disk view). Cheap helper for
2459
+ * List the current policy names: those of a newer gate.json that loads (the
2460
+ * freshest on-disk view), and otherwise the rules in force, which beside a
2461
+ * gate.json with no rule list are the host's starting rules. Cheap helper for
772
2462
  * tools/UX that want to show what's active without the full status payload.
773
2463
  */
774
2464
  listPolicyNames() {
775
- const current = this.loadConfig() ?? this.config;
2465
+ const read = this.readConfigFile({ unlessUnchanged: true });
2466
+ const current = read.state === 'loaded' ? read.config : this.config;
776
2467
  return current.policies.map((p) => p.name);
777
2468
  }
778
2469
  /**
779
- * Persist a config to gate.json and apply it in memory together: write the
780
- * file, swap in the compiled policies, and sync `configMtime` to the new file
781
- * so `reloadIfChanged` doesn't redundantly re-parse identical content. Throws
782
- * if the write fails (disk/permission) — callers surface it as a tool error.
2470
+ * Persist gate.json and apply it in memory together: `top`, its own fields,
2471
+ * with `rules` as its rule list. Only what loads is written: a change to a
2472
+ * gate.json the gate doesn't use, for which `remedy` is given, what would
2473
+ * bring it into force, throws, naming that and `remedy`, and writes nothing,
2474
+ * where the change doesn't mend what keeps it out, or where it would leave
2475
+ * gate.json with no rules in force: such a gate.json holds events back by
2476
+ * default, so putting it in force would hold back every event, those the
2477
+ * rules in force wake on included. The rules in force are then what the text
2478
+ * written reads as, as at a load, so what it still holds that the gate reads
2479
+ * past or leaves out stays named; the text is kept as `configText`, so
2480
+ * `reloadIfChanged` doesn't parse it again. Once the text is written and its
2481
+ * rules are in force, a rule that was in force that it leaves out or gives
2482
+ * another behavior loses its state (clearStateFor), last, since a pending
2483
+ * batch it sends can't be taken back, and a write that fails leaves the
2484
+ * rules in force with their state; a batch that can't be delivered is
2485
+ * logged, not thrown. A write that fails throws, saying whether gate.json was
2486
+ * changed: one that fails before writing leaves it as it was, and one that
2487
+ * fails after its open may leave it part-written (beforeWriting). Callers
2488
+ * surface it as a tool error.
783
2489
  */
784
- writeConfig(config) {
785
- mkdirSync(dirname(this.configPath), { recursive: true });
786
- writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
787
- this.config = config;
788
- this.compiledPolicies = this.compilePolicies(config);
2490
+ writeConfig(top, rules, remedy) {
2491
+ const text = JSON.stringify({ ...top, policies: rules }, null, 2) + '\n';
2492
+ const read = readConfig(JSON.parse(text), 'gate.json');
2493
+ if ('refused' in read) {
2494
+ throw new Error(`Nothing was saved: with this change, gate.json wouldn't be used. ${sentence(read.refused)}${remedy ? ` ${remedy}` : ''}`);
2495
+ }
2496
+ // One the gate doesn't use, and that a change can mend, holds back what a
2497
+ // rule it leaves out would wake on, so its default holds events back.
2498
+ if (remedy !== undefined && read.config.policies.length === 0) {
2499
+ throw new Error('Nothing was saved: with this change, gate.json would have no rules the gate reads, and its default would hold back every '
2500
+ + `event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`);
2501
+ }
2502
+ try {
2503
+ mkdirSync(dirname(this.configPath), { recursive: true });
2504
+ writeFileSync(this.configPath, text);
2505
+ }
2506
+ catch (err) {
2507
+ throw new Error(beforeWriting(err)
2508
+ ? `gate.json couldn't be written (${errorText(err)}), so nothing was saved: the rules in force and gate.json stay `
2509
+ + 'as they were. Whoever runs this host can fix what keeps it from being written.'
2510
+ : `Writing gate.json failed partway (${errorText(err)}), so the change isn't in force: the rules in force stay as `
2511
+ + 'they were. gate.json may hold the whole change, which the gate reads at its next reload; or it may hold part of it '
2512
+ + 'or nothing, which the gate can\'t read, and then they stay until gate.json is fixed. '
2513
+ + 'Whoever runs this host can fix what kept it from being written, and check what gate.json holds.', { cause: err });
2514
+ }
2515
+ const was = this.config;
2516
+ this.configText = text;
2517
+ this.configNote = loadNote(read.sentences);
2518
+ this.config = read.config;
2519
+ this.compiledPolicies = this.compilePolicies(read.config);
789
2520
  this.refreshShadowWarnings('policy-mutation');
790
2521
  this.configSource = 'file';
791
- this.configErrors = [];
2522
+ this.configError = this.configNote;
2523
+ this.configUnused = false;
2524
+ this.startUnsaved = null;
792
2525
  this.lastReloadTimestamp = Date.now();
793
- try {
794
- this.configMtime = statSync(this.configPath).mtimeMs;
795
- }
796
- catch {
797
- // A failed stat only risks one redundant reload; keep the old mtime.
798
- }
2526
+ this.clearStateFor(was, read.config);
799
2527
  }
800
2528
  // =========================================================================
801
2529
  // Policy matching — first match wins (passthrough observers excepted; the
@@ -811,9 +2539,17 @@ export class EventGate {
811
2539
  if (compiled.sourceRegex && !compiled.sourceRegex.test(info.serverId)) {
812
2540
  return false;
813
2541
  }
814
- // Channel check
2542
+ // Channel check: the event's own channel id, or the registered id the
2543
+ // gate resolves for its route. A push event carries the adapter's raw id
2544
+ // (`origin.channelId`, or none), and a channels/incoming message the
2545
+ // registered one, so a pattern in the registered form matches every
2546
+ // event in its channel whose push names the channel (by `mcplChannelId`,
2547
+ // or a Discord origin's guild and channel): its messages on both paths,
2548
+ // and its reactions, edits, voice reports and other pushes.
815
2549
  if (compiled.channelRegex && !compiled.channelRegex.test(info.channelId)) {
816
- return false;
2550
+ const registered = this.routeChannelFor(info);
2551
+ if (registered === undefined || !compiled.channelRegex.test(registered))
2552
+ return false;
817
2553
  }
818
2554
  // Mount check (workspace fs events)
819
2555
  if (compiled.mountRegex) {
@@ -883,10 +2619,9 @@ export class EventGate {
883
2619
  // marker requires too: a `source` in an event's metadata is the server's
884
2620
  // own word, which any server can give.
885
2621
  if (this.sleepUntil > this.now()) {
886
- this.reloadPrivilegedIfChanged();
887
2622
  const isHeartbeat = info.serverId === 'heartbeat';
888
2623
  const authorId = this.extractAuthorId(info.metadata);
889
- const privileged = authorId !== null && this.privilegedUserIds.has(authorId);
2624
+ const privileged = this.isPrivilegedUser(authorId);
890
2625
  if (!isHeartbeat && !privileged) {
891
2626
  this.sleepSuppressed++;
892
2627
  this.totalEvaluations++;
@@ -989,7 +2724,7 @@ export class EventGate {
989
2724
  return { trigger: true, policyName: policy.name, behavior: 'always' };
990
2725
  }
991
2726
  if (typeof policy.behavior === 'object') {
992
- if ('debounce' in policy.behavior) {
2727
+ if ('debounce' in policy.behavior || 'judge' in policy.behavior) {
993
2728
  this.handleDebounce(policy, info);
994
2729
  return { trigger: false, policyName: policy.name, behavior: policy.behavior };
995
2730
  }
@@ -1007,17 +2742,25 @@ export class EventGate {
1007
2742
  // =========================================================================
1008
2743
  // rate_limit + passive_sample + resets
1009
2744
  // =========================================================================
1010
- /** Resolve the partition key for keyBy-style behaviors. */
1011
- resolveKey(metadata, keyBy) {
2745
+ /** Resolve the partition key for keyBy-style behaviors. `keyBy:
2746
+ * "channelId"` takes the registered id the gate resolves for the event when
2747
+ * it has one, as channel matching does, so a channel's push events count
2748
+ * with its channels/incoming messages rather than under their raw id. */
2749
+ resolveKey(info, keyBy) {
1012
2750
  if (!keyBy)
1013
2751
  return SHARED_BUCKET_KEY;
1014
- const value = metadata?.[keyBy];
2752
+ if (keyBy === 'channelId') {
2753
+ const registered = this.routeChannelFor(info);
2754
+ if (registered !== undefined)
2755
+ return registered;
2756
+ }
2757
+ const value = info.metadata?.[keyBy];
1015
2758
  if (value === undefined || value === null || value === '')
1016
2759
  return SHARED_BUCKET_KEY;
1017
2760
  return String(value);
1018
2761
  }
1019
2762
  applyRateLimit(policy, info, config) {
1020
- const key = this.resolveKey(info.metadata, config.keyBy);
2763
+ const key = this.resolveKey(info, config.keyBy);
1021
2764
  let buckets = this.rateLimitBuckets.get(policy.name);
1022
2765
  if (!buckets) {
1023
2766
  buckets = new Map();
@@ -1031,7 +2774,15 @@ export class EventGate {
1031
2774
  }
1032
2775
  else if (bucket.tokens < config.tokens) {
1033
2776
  const elapsed = now - bucket.lastRefill;
1034
- if (elapsed > 0) {
2777
+ // A refill interval of 0, read from gate.json or the starting rules
2778
+ // (readRateLimit), refills at once; divided by, it refilled the bucket
2779
+ // once and left its clock at NaN. Under 0, no number of whole intervals
2780
+ // has passed, so it never refills.
2781
+ if (config.refillIntervalMs === 0) {
2782
+ bucket.tokens = config.tokens;
2783
+ bucket.lastRefill = now;
2784
+ }
2785
+ else if (elapsed > 0) {
1035
2786
  const tokensToAdd = Math.floor(elapsed / config.refillIntervalMs);
1036
2787
  if (tokensToAdd > 0) {
1037
2788
  bucket.tokens = Math.min(config.tokens, bucket.tokens + tokensToAdd);
@@ -1053,7 +2804,7 @@ export class EventGate {
1053
2804
  return { trigger: false, policyName: policy.name, behavior: policy.behavior };
1054
2805
  }
1055
2806
  applyPassiveSample(policy, info, config) {
1056
- const key = this.resolveKey(info.metadata, config.keyBy);
2807
+ const key = this.resolveKey(info, config.keyBy);
1057
2808
  let counters = this.passiveSampleCounters.get(policy.name);
1058
2809
  if (!counters) {
1059
2810
  counters = new Map();
@@ -1083,21 +2834,17 @@ export class EventGate {
1083
2834
  }
1084
2835
  }
1085
2836
  /**
1086
- * Clear ALL per-policy state (debounce timer, rate-limit buckets,
1087
- * passive-sample counters, denial / fire counters) for one policy.
1088
- * Used by reloadIfChanged when a policy is removed or its behavior
1089
- * params change. Stats (matchCount, lastMatchTimestamp) are preserved
1090
- * — those are historical and shouldn't reset on a config edit.
2837
+ * Clear one policy's live state (rate-limit buckets, passive-sample
2838
+ * counters, denial / fire counters) and stop its debounce timer, leaving
2839
+ * its pending batch for fireDebounce to send, as clearStateFor does once
2840
+ * every policy's state is cleared. It leaves stats (matchCount,
2841
+ * lastMatchTimestamp), which are history; clearStateFor deletes a removed
2842
+ * policy's.
1091
2843
  */
1092
- clearPolicyState(name, options) {
2844
+ clearPolicyState(name) {
1093
2845
  const debounce = this.debounceTimers.get(name);
1094
- if (debounce) {
2846
+ if (debounce)
1095
2847
  clearTimeout(debounce.timer);
1096
- if (options.deliverPendingDebounce && debounce.events.length > 0) {
1097
- this.deliverEvents(debounce.events);
1098
- }
1099
- this.debounceTimers.delete(name);
1100
- }
1101
2848
  this.rateLimitBuckets.delete(name);
1102
2849
  this.passiveSampleCounters.delete(name);
1103
2850
  this.rateLimitDenied.delete(name);
@@ -1118,16 +2865,20 @@ export class EventGate {
1118
2865
  if (b === 'defer' || b === 'skip')
1119
2866
  return false;
1120
2867
  if (typeof b === 'object') {
1121
- if ('debounce' in b)
2868
+ if ('debounce' in b || 'judge' in b)
1122
2869
  return false; // batches; never triggers inline
1123
2870
  if ('rate_limit' in b) {
1124
2871
  const cfg = b.rate_limit;
1125
- const key = this.resolveKey(info.metadata, cfg.keyBy);
2872
+ const key = this.resolveKey(info, cfg.keyBy);
1126
2873
  const bucket = this.rateLimitBuckets.get(policy.name)?.get(key);
1127
2874
  if (!bucket)
1128
2875
  return true; // fresh bucket starts full
2876
+ // As applyRateLimit refills: at once, for an interval of 0, and never
2877
+ // for one under 0.
2878
+ if (cfg.refillIntervalMs === 0)
2879
+ return true;
1129
2880
  let tokens = bucket.tokens;
1130
- if (tokens < cfg.tokens) {
2881
+ if (tokens < cfg.tokens && cfg.refillIntervalMs > 0) {
1131
2882
  const elapsed = Math.max(0, this.now() - bucket.lastRefill);
1132
2883
  tokens = Math.min(cfg.tokens, tokens + Math.floor(elapsed / cfg.refillIntervalMs));
1133
2884
  }
@@ -1135,7 +2886,7 @@ export class EventGate {
1135
2886
  }
1136
2887
  if ('passive_sample' in b) {
1137
2888
  const cfg = b.passive_sample;
1138
- const key = this.resolveKey(info.metadata, cfg.keyBy);
2889
+ const key = this.resolveKey(info, cfg.keyBy);
1139
2890
  const count = this.passiveSampleCounters.get(policy.name)?.get(key) ?? 0;
1140
2891
  return count + 1 >= cfg.every;
1141
2892
  }
@@ -1161,16 +2912,8 @@ export class EventGate {
1161
2912
  const trigger = (this.config.default ?? 'always') === 'always';
1162
2913
  return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
1163
2914
  }
1164
- /**
1165
- * Probe a canonical set of synthetic chat/heartbeat events and report which
1166
- * rule wins each and whether it would wake. Backs the before/after table in
1167
- * the wake_add_rule result — the point where a rule change's real semantics
1168
- * must become legible (af#105; the Mythos stand-back-density incident would
1169
- * have been visible as `dm: discord-direct-address → stand-back-density`).
1170
- * Probes are representative shapes, not an exhaustive event space — rules
1171
- * keyed on specific channels/mounts may not match any probe.
1172
- */
1173
- probeTable() {
2915
+ /** The canonical synthetic events the probe table runs: chat shapes as MCPL delivers them, and a heartbeat. */
2916
+ probeEvents() {
1174
2917
  const chat = (label, eventType, metadata, tags) => ({
1175
2918
  label,
1176
2919
  info: {
@@ -1201,7 +2944,32 @@ export class EventGate {
1201
2944
  },
1202
2945
  },
1203
2946
  ];
1204
- return probes.map(({ label, info }) => {
2947
+ return probes;
2948
+ }
2949
+ /**
2950
+ * The rules no probe event matches, such as one scoped to an event type
2951
+ * the probes don't model (`external-message`, how a module's messages are
2952
+ * gated): the probe table can't show what they do, so a result that shows
2953
+ * the table names them rather than let its rows read as their effect.
2954
+ */
2955
+ probeUnreached() {
2956
+ const events = this.probeEvents().map(({ info }) => info);
2957
+ return this.compiledPolicies
2958
+ .filter((compiled) => !events.some((info) => this.compiledMatches(compiled, info)))
2959
+ .map((compiled) => compiled.policy.name);
2960
+ }
2961
+ /**
2962
+ * Probe a canonical set of synthetic chat/heartbeat events and report which
2963
+ * rule wins each and whether it would wake. Backs the before/after table in
2964
+ * the wake_add_rule result — the point where a rule change's real semantics
2965
+ * must become legible (af#105; the Mythos stand-back-density incident would
2966
+ * have been visible as `dm: discord-direct-address → stand-back-density`).
2967
+ * Probes are representative shapes, not an exhaustive event space — rules
2968
+ * keyed on specific channels/mounts, or on other event types, may not match
2969
+ * any probe; `probeUnreached` names those.
2970
+ */
2971
+ probeTable() {
2972
+ return this.probeEvents().map(({ label, info }) => {
1205
2973
  const d = this.probe(info);
1206
2974
  return {
1207
2975
  probe: label,
@@ -1277,7 +3045,8 @@ export class EventGate {
1277
3045
  }
1278
3046
  }
1279
3047
  handleDebounce(policy, info) {
1280
- const debounceMs = policy.behavior.debounce;
3048
+ const debounceMs = batchQuietMs(policy.behavior) ?? MIN_DEBOUNCE_MS;
3049
+ const judge = typeof policy.behavior === 'object' && 'judge' in policy.behavior ? policy.behavior.judge : undefined;
1281
3050
  const event = {
1282
3051
  policyName: policy.name,
1283
3052
  content: info.content.length > MAX_CONTENT_SNIPPET
@@ -1294,16 +3063,28 @@ export class EventGate {
1294
3063
  serverId: info.serverId || undefined,
1295
3064
  addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'),
1296
3065
  routeChannelId: this.routeChannelFor(info),
3066
+ ...(typeof info.metadata?.messageId === 'string' ? { messageId: info.metadata.messageId } : {}),
3067
+ ...(typeof info.metadata?.eventId === 'string' ? { eventId: info.metadata.eventId } : {}),
1297
3068
  };
1298
3069
  const existing = this.debounceTimers.get(policy.name);
3070
+ // A judge's maxWaitMs caps how far new events push the batch back: it's
3071
+ // judged by then, from its first event, quiet or not.
3072
+ const delay = (firstAt, batchJudge) => {
3073
+ const cap = batchJudge?.maxWaitMs;
3074
+ return cap === undefined ? debounceMs : Math.max(0, Math.min(debounceMs, firstAt + cap - this.now()));
3075
+ };
1299
3076
  if (existing) {
1300
3077
  clearTimeout(existing.timer);
1301
3078
  existing.events.push(event);
1302
- existing.timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
3079
+ const ms = delay(existing.firstAt, existing.judge);
3080
+ existing.timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3081
+ existing.dueAt = this.now() + ms;
1303
3082
  }
1304
3083
  else {
1305
- const timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
1306
- this.debounceTimers.set(policy.name, { timer, events: [event] });
3084
+ const firstAt = this.now();
3085
+ const ms = delay(firstAt, judge);
3086
+ const timer = setTimeout(() => this.fireDebounce(policy.name), ms);
3087
+ this.debounceTimers.set(policy.name, { timer, events: [event], firstAt, dueAt: firstAt + ms, ...(judge ? { judge } : {}) });
1307
3088
  }
1308
3089
  }
1309
3090
  fireDebounce(policyName) {
@@ -1314,6 +3095,13 @@ export class EventGate {
1314
3095
  }
1315
3096
  const events = state.events;
1316
3097
  this.debounceTimers.delete(policyName);
3098
+ // A judge rule's batch goes to the reply judge, which delivers it, or
3099
+ // doesn't, once it has decided (judgeBatch). Without a judge wired it's
3100
+ // delivered below, as a debounce's batch is.
3101
+ if (state.judge && this.judgeFn) {
3102
+ this.judgeBatch(policyName, events, state.judge);
3103
+ return;
3104
+ }
1317
3105
  // If any agent is currently inferring — or the host is quiesced — buffer
1318
3106
  // for later (with cap). While quiesced, delivering would write a
1319
3107
  // "[Gate: N events]" message into the very context being operated on
@@ -1322,7 +3110,19 @@ export class EventGate {
1322
3110
  this.bufferForInference(events);
1323
3111
  return;
1324
3112
  }
1325
- this.deliverEvents(events);
3113
+ // A delivery that throws drops the batch, named in the log: thrown from
3114
+ // its timer, it would be uncaught, and stop the process, and thrown to a
3115
+ // write or reload that sends it (clearStateFor), it would report a change
3116
+ // that was made as failed.
3117
+ try {
3118
+ this.deliverEvents(events);
3119
+ }
3120
+ catch (err) {
3121
+ const one = events.length === 1;
3122
+ console.error(`[gate] Policy "${policyName}": its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3123
+ + `(${errorText(err)}), so the batch is dropped`);
3124
+ return;
3125
+ }
1326
3126
  this.emitTrace({
1327
3127
  type: 'gate:debounce-delivered',
1328
3128
  policyName,
@@ -1345,53 +3145,388 @@ export class EventGate {
1345
3145
  const quoted = events.filter(e => !e.inContext);
1346
3146
  const referenced = events.filter(e => e.inContext);
1347
3147
  const lines = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`);
3148
+ // How each referenced channel is named in the line, in order.
3149
+ const shown = [];
1348
3150
  if (referenced.length > 0) {
3151
+ // By channel, its server and its id: two servers' channels can share
3152
+ // an id.
1349
3153
  const byChannel = new Map();
1350
3154
  for (const e of referenced) {
1351
3155
  // The registered id when known, so this line names the channel the
1352
3156
  // same way routing notices and send tools do (a push event's own
1353
- // channelId is the adapter's raw id).
1354
- const key = e.routeChannelId ?? e.channelId ?? '(unknown channel)';
1355
- const entry = byChannel.get(key) ?? { count: 0, oldest: e.timestamp, label: e.channelLabel };
3157
+ // channelId is the adapter's raw id), shown as the agent names it
3158
+ // back where the event names its server.
3159
+ // Only a registered id is a channel name; an adapter's raw id is
3160
+ // shown as it is.
3161
+ const id = e.routeChannelId ?? e.channelId;
3162
+ const name = id === undefined
3163
+ ? '(unknown channel)'
3164
+ : e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn
3165
+ ? this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId })
3166
+ : id;
3167
+ const key = JSON.stringify([e.serverId ?? null, id ?? null]);
3168
+ const entry = byChannel.get(key) ?? { name, count: 0, oldest: e.timestamp, label: e.channelLabel };
1356
3169
  entry.count += 1;
1357
3170
  entry.oldest = Math.min(entry.oldest, e.timestamp);
1358
3171
  entry.label = entry.label ?? e.channelLabel;
1359
3172
  byChannel.set(key, entry);
1360
3173
  }
1361
- for (const [channelId, { count, oldest, label }] of byChannel) {
3174
+ for (const { name, count, oldest, label } of byChannel.values()) {
1362
3175
  // Prefer the human-readable name; keep the id parenthesized so the
1363
3176
  // agent can still address tools that want the composite id. A name
1364
3177
  // may carry its own `#`, so one is added only where the name lacks
1365
3178
  // it, as at the other places that add a `#`.
1366
- const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` : channelId;
1367
- const age = Math.round((Date.now() - oldest) / 1000);
1368
- lines.push(`- ${count} message${count > 1 ? 's' : ''} in ${channel} ` +
1369
- `(oldest ~${age}s ago) — already shown above at arrival; this is a ` +
1370
- `batched wake, not new content`);
3179
+ const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${name})` : name;
3180
+ shown.push(channel);
3181
+ lines.push(referenceLine(count, channel, oldest, Date.now()));
1371
3182
  }
1372
3183
  }
3184
+ // The line says which messages a wake is for. For one message that is
3185
+ // still the newest thing in the window it would be written to, it says
3186
+ // nothing the message right above doesn't. But the turn compiles later,
3187
+ // and what lands in between puts the message above other entries, so the
3188
+ // gate hands the line to the wake (WakeProvenance.line) and a fresh start
3189
+ // of the wake's turn decides it (WakeLine). Several messages, one with
3190
+ // anything after it, or content the line quotes, keep the line now.
1373
3191
  const text = `[Gate: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
1374
- this.addMessageFn('user', [{ type: 'text', text }], {
1375
- source: 'gate:debounce',
1376
- policies: policyNames,
1377
- });
3192
+ const only = events.length === 1 && events[0].inContext ? events[0] : undefined;
3193
+ let line;
3194
+ if (only && this.newestEntry(only)) {
3195
+ const shownChannel = only.routeChannelId ?? only.channelId;
3196
+ line = {
3197
+ entry: entryRef(only),
3198
+ policyNames,
3199
+ shown: shown[0],
3200
+ at: only.timestamp,
3201
+ ...(shownChannel !== undefined ? { channelId: shownChannel } : {}),
3202
+ };
3203
+ }
3204
+ else {
3205
+ this.addMessageFn('user', [{ type: 'text', text }], {
3206
+ source: 'gate:debounce',
3207
+ policies: policyNames,
3208
+ });
3209
+ }
1378
3210
  // Provenance of the batched wake (who/where woke the agent): the newest
1379
3211
  // ADDRESSED event first, else the newest event naming a channel or an
1380
3212
  // author; channel + author + addressed from that ONE event. When that
1381
3213
  // event addressed the agent and its registered channel is known,
1382
3214
  // `routeChannelId` names the turn's speech locus too — the reply goes
1383
3215
  // back to whoever addressed the agent, not to whichever channel last saw
1384
- // any traffic. Ambient-only batches carry telemetry only.
1385
- const provenance = wakeProvenance(events);
3216
+ // any traffic. Ambient-only batches set no locus. A batch whose line the
3217
+ // gate left to the turn's start carries it too (`line`), one object
3218
+ // shared by every agent the batch wakes, so it is written once.
3219
+ const chosen = wakeProvenance(events);
3220
+ const provenance = line ? { ...chosen, line } : chosen;
1386
3221
  for (const agentName of this.getAgentNamesFn()) {
1387
3222
  this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance);
1388
3223
  }
1389
3224
  }
3225
+ /** isNewestEntry for one event; a callback fault keeps the line. */
3226
+ newestEntry(event) {
3227
+ if (!this.isNewestEntryFn)
3228
+ return false;
3229
+ try {
3230
+ return this.isNewestEntryFn(entryRef(event)) === true;
3231
+ }
3232
+ catch {
3233
+ return false;
3234
+ }
3235
+ }
3236
+ // =========================================================================
3237
+ // Reply judge (`judge` rules)
3238
+ // =========================================================================
3239
+ /**
3240
+ * Send one of a judge rule's batches to the reply judge. While a resident's
3241
+ * turn runs or the host is quiesced the batch is held instead, merged with
3242
+ * any the rule already holds, and judged once both are clear
3243
+ * (flushJudgeHeldIfClear): the judge then reads the context the turn left,
3244
+ * which may already have answered it. While the judge decides one of the
3245
+ * rule's batches, a later one waits for that verdict (finishJudging), so the
3246
+ * judge never decides two of a rule's batches at once.
3247
+ */
3248
+ judgeBatch(policyName, events, judge) {
3249
+ if (this.disposed)
3250
+ return;
3251
+ const deciding = this.judging.get(policyName);
3252
+ if (deciding) {
3253
+ deciding.queued.push(...events);
3254
+ this.capJudgeEvents(deciding.queued);
3255
+ return;
3256
+ }
3257
+ if (this.judgedTurnRunning() || this.quiesced) {
3258
+ this.holdForJudge(policyName, events, judge);
3259
+ return;
3260
+ }
3261
+ const fn = this.judgeFn;
3262
+ if (!fn) {
3263
+ this.deliverOrBuffer(events);
3264
+ return;
3265
+ }
3266
+ const abort = new AbortController();
3267
+ const agentNames = this.getAgentNamesFn();
3268
+ const state = { events, judge, started: Date.now(), abort, queued: [] };
3269
+ this.judging.set(policyName, state);
3270
+ const timedOut = new Promise((_, reject) => {
3271
+ state.timer = setTimeout(() => {
3272
+ abort.abort();
3273
+ reject(new Error(`the judge took more than ${this.judgeTimeoutMs} ms`));
3274
+ }, this.judgeTimeoutMs);
3275
+ // Never keep the process alive on the judge's account.
3276
+ state.timer.unref?.();
3277
+ });
3278
+ let asked;
3279
+ try {
3280
+ asked = fn({
3281
+ policyName,
3282
+ ...(judge.instructions !== undefined ? { instructions: judge.instructions } : {}),
3283
+ events: events.map((e) => this.judgeEventSummary(e)),
3284
+ agentNames,
3285
+ upcomingWakes: this.upcomingWakes(agentNames, policyName),
3286
+ signal: abort.signal,
3287
+ });
3288
+ }
3289
+ catch (err) {
3290
+ asked = Promise.reject(err);
3291
+ }
3292
+ Promise.race([asked, timedOut]).then((verdict) => this.finishJudging(policyName, state, verdict), (err) => this.finishJudging(policyName, state, undefined, err)).finally(() => {
3293
+ if (state.timer)
3294
+ clearTimeout(state.timer);
3295
+ }).catch((err) => {
3296
+ console.error(`[gate] Policy "${policyName}": the judge's verdict couldn't be applied (${errorText(err)})`);
3297
+ });
3298
+ }
3299
+ /** Hold a batch for the judge until no turn changing the context it reads
3300
+ * runs and the host isn't quiesced, merged with what the rule already holds. */
3301
+ holdForJudge(policyName, events, judge) {
3302
+ const held = this.judgeHeld.get(policyName);
3303
+ if (held) {
3304
+ held.events.push(...events);
3305
+ held.judge = judge;
3306
+ this.capJudgeEvents(held.events);
3307
+ }
3308
+ else {
3309
+ const fresh = [...events];
3310
+ this.capJudgeEvents(fresh);
3311
+ this.judgeHeld.set(policyName, { events: fresh, judge });
3312
+ }
3313
+ }
3314
+ /**
3315
+ * Keep a rule's events waiting on the judge to the inference buffer's cap,
3316
+ * dropping the oldest, counted and logged as bufferForInference counts its
3317
+ * drops: a turn that never ends (the wake-wedge) or a long quiesce on a busy
3318
+ * channel would otherwise grow them without bound. A queue beside a batch
3319
+ * the judge is deciding is capped on its own, so its newest events are never
3320
+ * the ones dropped for the batch ahead of it.
3321
+ */
3322
+ capJudgeEvents(events) {
3323
+ const drop = events.length - MAX_INFERENCE_BUFFER;
3324
+ if (drop <= 0)
3325
+ return;
3326
+ events.splice(0, drop);
3327
+ const before = this.droppedJudgeEvents;
3328
+ this.droppedJudgeEvents += drop;
3329
+ if (Math.floor(before / 50) !== Math.floor(this.droppedJudgeEvents / 50) || before === 0) {
3330
+ console.error(`[gate] events waiting on the reply judge reached the cap (${MAX_INFERENCE_BUFFER} per rule): dropped `
3331
+ + `${this.droppedJudgeEvents} oldest so far${this.quiesced ? ' during quiesce' : ''}`);
3332
+ }
3333
+ }
3334
+ /** The rule's judge as it is now, when the rule is still a judge rule; else the one the batch was asked with. */
3335
+ currentJudge(policyName, asked) {
3336
+ const now = this.config.policies.find((p) => p.name === policyName)?.behavior;
3337
+ return typeof now === 'object' && 'judge' in now ? now.judge : asked;
3338
+ }
3339
+ /**
3340
+ * The gate's own timers that will wake `agentNames` with no new event, for
3341
+ * the judge to weigh what waiting costs: each agent's pending self-wake
3342
+ * (skip_reply's wake_in_seconds), the end of a sleep, and the pending
3343
+ * batches of rules other than `exceptPolicy` (a debounce's wakes the agent
3344
+ * when it fires; a judge rule's goes to the judge, so it only may).
3345
+ */
3346
+ upcomingWakes(agentNames, exceptPolicy) {
3347
+ const wakes = [];
3348
+ const now = this.now();
3349
+ for (const name of agentNames) {
3350
+ const due = this.selfWakeDue.get(name);
3351
+ if (due && this.selfWakeTimers.has(name)) {
3352
+ wakes.push({ at: due.at, certain: true, what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
3353
+ }
3354
+ }
3355
+ if (this.sleepUntil > now && (this.sleepAgent === undefined || agentNames.includes(this.sleepAgent))) {
3356
+ wakes.push({ at: this.sleepUntil, certain: true, what: 'the end of its sleep' });
3357
+ }
3358
+ for (const [name, state] of this.debounceTimers) {
3359
+ if (name === exceptPolicy || state.events.length === 0)
3360
+ continue;
3361
+ const n = state.events.length;
3362
+ const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
3363
+ wakes.push(state.judge
3364
+ ? { at: state.dueAt, certain: false, what: `rule "${name}"'s batch of ${batch} goes to the judge` }
3365
+ : { at: state.dueAt, certain: true, what: `rule "${name}"'s debounced batch of ${batch}` });
3366
+ }
3367
+ return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
3368
+ }
3369
+ /** A batch's event as the judge is shown it. */
3370
+ judgeEventSummary(e) {
3371
+ const id = e.routeChannelId ?? e.channelId;
3372
+ let channel;
3373
+ if (id !== undefined) {
3374
+ let name = id;
3375
+ if (e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn) {
3376
+ try {
3377
+ name = this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId });
3378
+ }
3379
+ catch {
3380
+ name = id;
3381
+ }
3382
+ }
3383
+ channel = e.channelLabel ? `${e.channelLabel.startsWith('#') ? e.channelLabel : `#${e.channelLabel}`} (${name})` : name;
3384
+ }
3385
+ return {
3386
+ eventType: e.eventType,
3387
+ ...(channel !== undefined ? { channel } : {}),
3388
+ ...(e.authorId !== undefined ? { authorId: e.authorId } : {}),
3389
+ addressed: e.addressed,
3390
+ content: e.content,
3391
+ at: e.timestamp,
3392
+ };
3393
+ }
3394
+ /**
3395
+ * Apply the judge's verdict on a batch. Reply: the batch, with any of the
3396
+ * rule's batches that fired meanwhile, is delivered as a debounce's is
3397
+ * (held for a turn that started meanwhile, as fireDebounce holds one). Wait:
3398
+ * nothing is delivered, and batches that fired meanwhile go to the judge
3399
+ * afresh. A judge that failed, timed out or answered nothing usable decides
3400
+ * by onError: "wake" delivers, "skip" doesn't. Each verdict is traced and
3401
+ * counted for gate_status, and a failure is logged.
3402
+ */
3403
+ finishJudging(policyName, state, verdict, err) {
3404
+ // A verdict for a batch the gate let go of (dispose() clears what the
3405
+ // judge was deciding) applies to nothing, even after a start() since.
3406
+ if (this.judging.get(policyName) !== state)
3407
+ return;
3408
+ this.judging.delete(policyName);
3409
+ if (state.timer)
3410
+ clearTimeout(state.timer);
3411
+ const durationMs = Date.now() - state.started;
3412
+ const failed = verdict == null || typeof verdict !== 'object' || typeof verdict.reply !== 'boolean';
3413
+ if (failed && err?.providerAdmission === 'deferred') {
3414
+ const wakes = this.judgeOnError === 'wake';
3415
+ console.error(`[gate] Policy "${policyName}": the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
3416
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`);
3417
+ if (this.disposed)
3418
+ return;
3419
+ if (wakes)
3420
+ this.deliverOrBuffer([...state.events, ...state.queued]);
3421
+ else if (state.queued.length > 0)
3422
+ this.judgeBatch(policyName, state.queued, this.currentJudge(policyName, state.judge));
3423
+ return;
3424
+ }
3425
+ const outcome = failed ? 'error' : verdict.reply ? 'reply' : 'wait';
3426
+ const reason = failed
3427
+ ? (err !== undefined ? errorText(err) : 'the judge gave no verdict')
3428
+ : typeof verdict.reason === 'string' ? verdict.reason : '';
3429
+ const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0 } };
3430
+ record.verdicts[outcome]++;
3431
+ record.last = {
3432
+ at: Date.now(),
3433
+ verdict: outcome,
3434
+ reason: safeSlice(reason, 0, 300),
3435
+ eventCount: state.events.length,
3436
+ durationMs,
3437
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3438
+ };
3439
+ if (this.config.policies.some((p) => p.name === policyName))
3440
+ this.judgeRecords.set(policyName, record);
3441
+ if (failed) {
3442
+ this.judgeFailStreak++;
3443
+ this.judgeLastFailure = safeSlice(reason, 0, 200);
3444
+ }
3445
+ else {
3446
+ this.judgeFailStreak = 0;
3447
+ }
3448
+ const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
3449
+ if (failed) {
3450
+ console.error(`[gate] Policy "${policyName}": the reply judge failed (${reason}), so its batch of ${state.events.length} `
3451
+ + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`);
3452
+ }
3453
+ this.emitTrace({
3454
+ type: 'gate:judge-verdict',
3455
+ policyName,
3456
+ verdict: outcome,
3457
+ woke: wake,
3458
+ eventCount: state.events.length,
3459
+ durationMs,
3460
+ ...(!failed && verdict.model ? { model: verdict.model } : {}),
3461
+ ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
3462
+ ...(!failed && typeof verdict.cacheReadTokens === 'number' ? { cacheReadTokens: verdict.cacheReadTokens } : {}),
3463
+ ...(!failed && typeof verdict.outputTokens === 'number' ? { outputTokens: verdict.outputTokens } : {}),
3464
+ timestamp: Date.now(),
3465
+ });
3466
+ if (this.disposed)
3467
+ return;
3468
+ const judge = this.currentJudge(policyName, state.judge);
3469
+ if (wake) {
3470
+ this.deliverOrBuffer([...state.events, ...state.queued]);
3471
+ return;
3472
+ }
3473
+ if (state.queued.length > 0)
3474
+ this.judgeBatch(policyName, state.queued, judge);
3475
+ }
3476
+ /** Deliver a batch now, or hold it for the agents' turns and the host's
3477
+ * quiesce to end, as fireDebounce does; a delivery that throws drops it,
3478
+ * named in the log. */
3479
+ deliverOrBuffer(events) {
3480
+ if (events.length === 0)
3481
+ return;
3482
+ if (this.inferring.size > 0 || this.quiesced) {
3483
+ this.bufferForInference(events);
3484
+ return;
3485
+ }
3486
+ try {
3487
+ this.deliverEvents(events);
3488
+ }
3489
+ catch (err) {
3490
+ const one = events.length === 1;
3491
+ console.error(`[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3492
+ + `(${errorText(err)}), so the batch is dropped`);
3493
+ }
3494
+ }
3495
+ changesJudgedContext(agentName) {
3496
+ return this.changesJudgedContextFn?.(agentName) ?? true;
3497
+ }
3498
+ judgedTurnRunning() {
3499
+ return [...this.inferring].some((name) => this.changesJudgedContext(name));
3500
+ }
3501
+ stopJudging() {
3502
+ for (const [policyName, state] of [...this.judging]) {
3503
+ this.judging.delete(policyName);
3504
+ if (state.timer)
3505
+ clearTimeout(state.timer);
3506
+ state.abort.abort();
3507
+ this.holdForJudge(policyName, [...state.events, ...state.queued], state.judge);
3508
+ }
3509
+ }
3510
+ /** Send the batches held for the judge once no turn changing the context it reads runs and the host isn't quiesced. */
3511
+ flushJudgeHeldIfClear() {
3512
+ if (this.quiesced || this.judgedTurnRunning() || this.judgeHeld.size === 0)
3513
+ return;
3514
+ const held = [...this.judgeHeld];
3515
+ this.judgeHeld.clear();
3516
+ // Deferred, as the inference buffer's flush is, to stay out of the
3517
+ // turn-end callback that called this.
3518
+ queueMicrotask(() => {
3519
+ for (const [policyName, { events, judge }] of held)
3520
+ this.judgeBatch(policyName, events, this.currentJudge(policyName, judge));
3521
+ });
3522
+ }
1390
3523
  // =========================================================================
1391
3524
  // Inference lifecycle
1392
3525
  // =========================================================================
1393
3526
  onInferenceStarted(agentName) {
1394
3527
  this.inferring.add(agentName);
3528
+ if (this.changesJudgedContext(agentName))
3529
+ this.stopJudging();
1395
3530
  // A turn is starting — whatever caused it supersedes any pending
1396
3531
  // self-wake. (Arming happens MID-turn via skip_reply, i.e. after this
1397
3532
  // ran for the arming turn, so a fresh arm always survives its own turn.)
@@ -1410,10 +3545,13 @@ export class EventGate {
1410
3545
  * still running. */
1411
3546
  onInferenceResumed(agentName) {
1412
3547
  this.inferring.add(agentName);
3548
+ if (this.changesJudgedContext(agentName))
3549
+ this.stopJudging();
1413
3550
  }
1414
3551
  onInferenceEnded(agentName) {
1415
3552
  this.inferring.delete(agentName);
1416
3553
  this.flushInferenceBufferIfClear();
3554
+ this.flushJudgeHeldIfClear();
1417
3555
  }
1418
3556
  /**
1419
3557
  * Host quiesce suppression (issue #122). While set, debounce firings buffer
@@ -1428,16 +3566,35 @@ export class EventGate {
1428
3566
  if (this.quiesced === quiesced)
1429
3567
  return;
1430
3568
  this.quiesced = quiesced;
1431
- if (!quiesced)
3569
+ if (quiesced) {
3570
+ // A judge reading the context now would read it mid-surgery, and call
3571
+ // its provider while the host holds still: it's stopped, and its batch
3572
+ // held to be judged at resume.
3573
+ this.stopJudging();
3574
+ }
3575
+ else {
1432
3576
  this.flushInferenceBufferIfClear();
3577
+ this.flushJudgeHeldIfClear();
3578
+ }
1433
3579
  }
1434
3580
  flushInferenceBufferIfClear() {
1435
3581
  if (this.quiesced)
1436
3582
  return;
1437
3583
  if (this.inferring.size === 0 && this.inferenceBuffer.length > 0) {
1438
3584
  const events = this.inferenceBuffer.splice(0);
1439
- // Defer to avoid re-entrancy inside trace callbacks
1440
- queueMicrotask(() => this.deliverEvents(events));
3585
+ // Defer to avoid re-entrancy inside trace callbacks. A delivery that
3586
+ // throws drops what was held, named in the log, as fireDebounce drops a
3587
+ // batch: thrown in the microtask, it would be uncaught.
3588
+ queueMicrotask(() => {
3589
+ try {
3590
+ this.deliverEvents(events);
3591
+ }
3592
+ catch (err) {
3593
+ const one = events.length === 1;
3594
+ console.error(`[gate] ${events.length} ${one ? 'event' : 'events'} held for an agent's turn or the host's quiesce couldn't be `
3595
+ + `delivered (${errorText(err)}), so ${one ? "it's" : "they're"} dropped`);
3596
+ }
3597
+ });
1441
3598
  }
1442
3599
  }
1443
3600
  /** Live inference-gating snapshot for diagnostics (e.g. a SIGUSR2 dump).
@@ -1445,11 +3602,16 @@ export class EventGate {
1445
3602
  * is the signature of the wake-wedge: an agent stuck mid-"inference" so all
1446
3603
  * incoming events buffer and never trigger. */
1447
3604
  inferenceDiagnostics() {
3605
+ let judgeHeldEvents = 0;
3606
+ for (const held of this.judgeHeld.values())
3607
+ judgeHeldEvents += held.events.length;
1448
3608
  return {
1449
3609
  inferring: [...this.inferring],
1450
3610
  bufferedEvents: this.inferenceBuffer.length,
1451
3611
  droppedBufferedEvents: this.droppedBufferedEvents,
1452
3612
  sleepUntil: this.sleepUntil,
3613
+ judgeHeldEvents,
3614
+ droppedJudgeEvents: this.droppedJudgeEvents,
1453
3615
  };
1454
3616
  }
1455
3617
  // =========================================================================
@@ -1535,10 +3697,9 @@ export class EventGate {
1535
3697
  }
1536
3698
  /**
1537
3699
  * `agentName` is gone, as a conversation fork's end or an ephemeral run's
1538
- * removes one: its pending self-wake is cancelled, and a sleep it set ends.
1539
- * The gate's sleep holds back every agent's wakes, so nobody is asleep once
1540
- * its agent is gone, and neither wake would reach a name that no longer
1541
- * exists. A sleep another agent set, or one set with no agent, stays.
3700
+ * removes one: its pending self-wake is cancelled, rather than wake a name
3701
+ * that no longer exists. It holds no sleep: neither can set one (the
3702
+ * framework refuses their `sleep`).
1542
3703
  */
1543
3704
  agentGone(agentName) {
1544
3705
  const selfWake = this.selfWakeTimers.get(agentName);
@@ -1546,8 +3707,6 @@ export class EventGate {
1546
3707
  clearTimeout(selfWake);
1547
3708
  this.selfWakeTimers.delete(agentName);
1548
3709
  }
1549
- if (this.sleepUntil !== 0 && this.sleepAgent === agentName)
1550
- this.clearSleep();
1551
3710
  }
1552
3711
  // =========================================================================
1553
3712
  // Self-wake (skip_reply wake_in_seconds)
@@ -1586,14 +3745,19 @@ export class EventGate {
1586
3745
  const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
1587
3746
  // Deliver the notice into the WAKING agent's window — a self-wake
1588
3747
  // armed by a non-primary agent (e.g. a subconscious cadence) must not
1589
- // narrate itself into the primary's context.
3748
+ // narrate itself into the primary's context. Its wake goes with it: an
3749
+ // agent that heard the notice during a turn was awake when its timer
3750
+ // fired, and the host drops the wake once a request that carried the
3751
+ // notice is answered.
3752
+ const placement = {};
1590
3753
  this.addMessageFn('user', [{
1591
3754
  type: 'text',
1592
3755
  text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
1593
- }], { source: 'gate:self-wake' }, agentName);
1594
- this.requestInferenceFn(agentName, `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`, 'self-wake');
3756
+ }], { source: 'gate:self-wake' }, agentName, placement);
3757
+ this.requestInferenceFn(agentName, `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`, 'self-wake', undefined, placement);
1595
3758
  }, ms);
1596
3759
  this.selfWakeTimers.set(agentName, timer);
3760
+ this.selfWakeDue.set(agentName, { at: this.now() + ms, source });
1597
3761
  this.emitTrace({
1598
3762
  type: 'gate:decision',
1599
3763
  eventType: 'selfwake:set',
@@ -1675,7 +3839,6 @@ export class EventGate {
1675
3839
  return String(metadata.authorId);
1676
3840
  return null;
1677
3841
  }
1678
- /** Load the privileged-users file (bare array or { userIds: [...] }). */
1679
3842
  /**
1680
3843
  * Whether an author id is on the privileged-users list (hot-reloaded).
1681
3844
  * Shared by sleep bypass and tune-out wake classification (#77: wakes are
@@ -1684,48 +3847,82 @@ export class EventGate {
1684
3847
  isPrivilegedUser(authorId) {
1685
3848
  if (!authorId)
1686
3849
  return false;
1687
- this.loadPrivileged();
3850
+ this.reloadPrivilegedIfChanged();
1688
3851
  return this.privilegedUserIds.has(authorId);
1689
3852
  }
1690
- loadPrivileged() {
3853
+ /**
3854
+ * Read the privileged-users file: a list of user ids, or `{"userIds": [...]}`,
3855
+ * each a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero). An absent file
3856
+ * means no one is privileged. A file that can't be read, isn't JSON, or
3857
+ * holds no list leaves the list that last loaded in this process in effect
3858
+ * (none, at startup), with its error in gate_status until the file is read
3859
+ * again. In a list, an entry that isn't a user id is skipped, and named in
3860
+ * the file's error, and the rest load. With `unlessUnchanged`, a file whose
3861
+ * text is the text last read isn't parsed again.
3862
+ */
3863
+ loadPrivileged(opts) {
1691
3864
  if (!this.privilegedUsersPath)
1692
3865
  return;
3866
+ const failed = (why) => `Failed to load privileged-users file: ${why}`;
3867
+ let raw;
1693
3868
  try {
1694
- if (!existsSync(this.privilegedUsersPath)) {
3869
+ raw = readFileSync(this.privilegedUsersPath, 'utf8');
3870
+ }
3871
+ catch (err) {
3872
+ // Read again next time: a file can become readable with its text
3873
+ // unchanged, as a chmod makes it.
3874
+ this.privilegedText = undefined;
3875
+ if (isMissing(err)) {
1695
3876
  this.privilegedUserIds = new Set();
1696
- this.privilegedMtime = 0;
1697
- return;
3877
+ this.privilegedError = null;
3878
+ }
3879
+ else {
3880
+ this.privilegedError = failed(err instanceof Error ? err.message : String(err));
1698
3881
  }
1699
- this.privilegedMtime = statSync(this.privilegedUsersPath).mtimeMs;
1700
- const raw = parseGateJson(readFileSync(this.privilegedUsersPath, 'utf8'));
1701
- const ids = Array.isArray(raw) ? raw : Array.isArray(raw?.userIds) ? raw.userIds : [];
1702
- this.privilegedUserIds = new Set(ids.map((x) => String(x)));
3882
+ return;
3883
+ }
3884
+ if (opts?.unlessUnchanged && raw === this.privilegedText)
3885
+ return;
3886
+ this.privilegedText = raw;
3887
+ let ids;
3888
+ try {
3889
+ const parsed = parseGateJson(raw);
3890
+ ids = Array.isArray(parsed) ? parsed : parsed?.userIds;
1703
3891
  }
1704
3892
  catch (err) {
1705
- this.configErrors.push(`Failed to load privileged-users file: ${err instanceof Error ? err.message : String(err)}`);
3893
+ this.privilegedError = failed(err instanceof Error ? err.message : String(err));
3894
+ return;
1706
3895
  }
1707
- }
1708
- /** Throttled mtime-based reload of the privileged-users file. */
3896
+ // A file that parses but holds no list hasn't loaded either: it used to
3897
+ // mean no one is privileged, without a word.
3898
+ if (!Array.isArray(ids)) {
3899
+ this.privilegedError = failed('expected a list of user ids, or {"userIds": [...]}');
3900
+ return;
3901
+ }
3902
+ const loaded = new Set();
3903
+ const skipped = [];
3904
+ for (const id of ids) {
3905
+ if (typeof id === 'string' && id !== '')
3906
+ loaded.add(id);
3907
+ // JSON.parse has already rounded an integer past 2^53, and the rounded
3908
+ // value can be another user's id, so it's skipped rather than loaded.
3909
+ else if (typeof id === 'number' && Number.isSafeInteger(id))
3910
+ loaded.add(String(id));
3911
+ else
3912
+ skipped.push(id);
3913
+ }
3914
+ this.privilegedUserIds = loaded;
3915
+ this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size) : null;
3916
+ }
3917
+ /** At most once a second, the privileged-users file read again, and loaded when its text has changed. */
1709
3918
  reloadPrivilegedIfChanged() {
1710
3919
  if (!this.privilegedUsersPath)
1711
3920
  return;
1712
3921
  const now = this.now();
1713
- if (now - this.lastPrivilegedCheck < RELOAD_THROTTLE_MS)
3922
+ if (checkedRecently(now, this.lastPrivilegedCheck))
1714
3923
  return;
1715
3924
  this.lastPrivilegedCheck = now;
1716
- try {
1717
- if (!existsSync(this.privilegedUsersPath)) {
1718
- if (this.privilegedUserIds.size > 0)
1719
- this.privilegedUserIds = new Set();
1720
- return;
1721
- }
1722
- const mtime = statSync(this.privilegedUsersPath).mtimeMs;
1723
- if (mtime !== this.privilegedMtime)
1724
- this.loadPrivileged();
1725
- }
1726
- catch {
1727
- /* leave the last-known set in place on a transient stat/read error */
1728
- }
3925
+ this.loadPrivileged({ unlessUnchanged: true });
1729
3926
  }
1730
3927
  getToolDefinition() {
1731
3928
  return {
@@ -1737,6 +3934,10 @@ export class EventGate {
1737
3934
  };
1738
3935
  }
1739
3936
  async handleToolCall() {
3937
+ // What's in force now: each file read again if a check is due, as the
3938
+ // next event would.
3939
+ this.reloadIfChanged();
3940
+ this.reloadPrivilegedIfChanged();
1740
3941
  const status = this.getStatus();
1741
3942
  return { success: true, data: status };
1742
3943
  }
@@ -1768,6 +3969,16 @@ export class EventGate {
1768
3969
  fireCount: this.passiveSampleFires.get(p.name) ?? 0,
1769
3970
  };
1770
3971
  }
3972
+ if (typeof p.behavior === 'object' && 'judge' in p.behavior) {
3973
+ const record = this.judgeRecords.get(p.name);
3974
+ result.judgeState = {
3975
+ pendingCount: debounce?.events.length ?? 0,
3976
+ judging: this.judging.has(p.name),
3977
+ heldCount: this.judgeHeld.get(p.name)?.events.length ?? 0,
3978
+ verdicts: { ...(record?.verdicts ?? { reply: 0, wait: 0, error: 0 }) },
3979
+ ...(record?.last ? { last: { ...record.last } } : {}),
3980
+ };
3981
+ }
1771
3982
  return result;
1772
3983
  });
1773
3984
  const byEventType = {};
@@ -1792,7 +4003,11 @@ export class EventGate {
1792
4003
  default: this.config.default ?? 'always',
1793
4004
  script: this.gateScript.status(),
1794
4005
  policies,
1795
- errors: [...this.configErrors],
4006
+ errors: [
4007
+ this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError,
4008
+ this.judgeUnwiredNotice(), this.judgeFailingNotice(),
4009
+ ]
4010
+ .filter((e) => e !== null),
1796
4011
  shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
1797
4012
  totalEvaluations: this.totalEvaluations,
1798
4013
  defaultDecisions: {
@@ -1802,6 +4017,28 @@ export class EventGate {
1802
4017
  },
1803
4018
  };
1804
4019
  }
4020
+ /** gate_status's word on judge rules in force with no judge wired. */
4021
+ judgeUnwiredNotice() {
4022
+ if (this.judgeFn)
4023
+ return null;
4024
+ const names = this.config.policies
4025
+ .filter((p) => typeof p.behavior === 'object' && 'judge' in p.behavior)
4026
+ .map((p) => p.name);
4027
+ if (names.length === 0)
4028
+ return null;
4029
+ return `${quotedList(names)} ${names.length === 1 ? 'is a judge rule' : 'are judge rules'}, and this host wires no reply `
4030
+ + `judge, so ${names.length === 1 ? 'its batches wake' : 'their batches wake'} the agent as a debounce of the same quiet period would`;
4031
+ }
4032
+ /** gate_status's word on a judge that keeps failing (JUDGE_FAILING_STREAK in a row). */
4033
+ judgeFailingNotice() {
4034
+ if (this.judgeFailStreak < JUDGE_FAILING_STREAK)
4035
+ return null;
4036
+ return `the reply judge has failed its last ${this.judgeFailStreak} requests (the newest: ${this.judgeLastFailure}), `
4037
+ + (this.judgeOnError === 'wake'
4038
+ ? 'so judge rules wake the agent as debounces of the same quiet period would'
4039
+ : "so judge rules' batches don't wake the agent")
4040
+ + "; the host's judge model or provider may not serve it (GateOptions.judge)";
4041
+ }
1805
4042
  // =========================================================================
1806
4043
  // Cleanup
1807
4044
  // =========================================================================
@@ -1811,10 +4048,20 @@ export class EventGate {
1811
4048
  clearTimeout(state.timer);
1812
4049
  }
1813
4050
  this.debounceTimers.clear();
4051
+ // A judge deciding now is told to stop, and its verdict, when it comes,
4052
+ // delivers nothing (finishJudging); held batches go with the buffer.
4053
+ for (const state of this.judging.values()) {
4054
+ if (state.timer)
4055
+ clearTimeout(state.timer);
4056
+ state.abort.abort();
4057
+ }
4058
+ this.judging.clear();
4059
+ this.judgeHeld.clear();
1814
4060
  for (const timer of this.selfWakeTimers.values()) {
1815
4061
  clearTimeout(timer);
1816
4062
  }
1817
4063
  this.selfWakeTimers.clear();
4064
+ this.selfWakeDue.clear();
1818
4065
  // The sleep stays, with its note and agent, and its timer goes: fired
1819
4066
  // after a stop(), its wake reached a framework that had stopped, and the
1820
4067
  // timer held the process until the sleep's end. The next start() arms it