@animalabs/agent-framework 0.32.0 → 0.34.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 (368) hide show
  1. package/README.md +340 -22
  2. package/dist/src/admission/admit.d.ts +18 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -1
  4. package/dist/src/admission/admit.js +16 -3
  5. package/dist/src/admission/admit.js.map +1 -1
  6. package/dist/src/agent.d.ts +208 -80
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +345 -113
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +50 -12
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/api/server.d.ts.map +1 -1
  14. package/dist/src/api/server.js +4 -20
  15. package/dist/src/api/server.js.map +1 -1
  16. package/dist/src/append-all.d.ts +5 -0
  17. package/dist/src/append-all.d.ts.map +1 -0
  18. package/dist/src/append-all.js +8 -0
  19. package/dist/src/append-all.js.map +1 -0
  20. package/dist/src/code-execution/isolate/scripts.d.ts +12 -8
  21. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  22. package/dist/src/code-execution/isolate/scripts.js +24 -0
  23. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  24. package/dist/src/config-checks.d.ts +7 -0
  25. package/dist/src/config-checks.d.ts.map +1 -1
  26. package/dist/src/config-checks.js +14 -0
  27. package/dist/src/config-checks.js.map +1 -1
  28. package/dist/src/context-manager-errors.d.ts +25 -0
  29. package/dist/src/context-manager-errors.d.ts.map +1 -0
  30. package/dist/src/context-manager-errors.js +31 -0
  31. package/dist/src/context-manager-errors.js.map +1 -0
  32. package/dist/src/context-options.d.ts +70 -0
  33. package/dist/src/context-options.d.ts.map +1 -0
  34. package/dist/src/context-options.js +90 -0
  35. package/dist/src/context-options.js.map +1 -0
  36. package/dist/src/cut-off-round.js +4 -4
  37. package/dist/src/deferred-import.d.ts +93 -0
  38. package/dist/src/deferred-import.d.ts.map +1 -0
  39. package/dist/src/deferred-import.js +177 -0
  40. package/dist/src/deferred-import.js.map +1 -0
  41. package/dist/src/dendrite/audience.d.ts +223 -0
  42. package/dist/src/dendrite/audience.d.ts.map +1 -0
  43. package/dist/src/dendrite/audience.js +202 -0
  44. package/dist/src/dendrite/audience.js.map +1 -0
  45. package/dist/src/dendrite/index.d.ts +5 -3
  46. package/dist/src/dendrite/index.d.ts.map +1 -1
  47. package/dist/src/dendrite/index.js +2 -1
  48. package/dist/src/dendrite/index.js.map +1 -1
  49. package/dist/src/dendrite/presets.d.ts +10 -5
  50. package/dist/src/dendrite/presets.d.ts.map +1 -1
  51. package/dist/src/dendrite/presets.js +17 -5
  52. package/dist/src/dendrite/presets.js.map +1 -1
  53. package/dist/src/dendrite/registry.d.ts +62 -7
  54. package/dist/src/dendrite/registry.d.ts.map +1 -1
  55. package/dist/src/dendrite/registry.js +183 -12
  56. package/dist/src/dendrite/registry.js.map +1 -1
  57. package/dist/src/dendrite/types.d.ts +91 -8
  58. package/dist/src/dendrite/types.d.ts.map +1 -1
  59. package/dist/src/failure-text.d.ts +36 -0
  60. package/dist/src/failure-text.d.ts.map +1 -0
  61. package/dist/src/failure-text.js +89 -0
  62. package/dist/src/failure-text.js.map +1 -0
  63. package/dist/src/foreign-rounds.d.ts +69 -0
  64. package/dist/src/foreign-rounds.d.ts.map +1 -0
  65. package/dist/src/foreign-rounds.js +152 -0
  66. package/dist/src/foreign-rounds.js.map +1 -0
  67. package/dist/src/framework.d.ts +2064 -842
  68. package/dist/src/framework.d.ts.map +1 -1
  69. package/dist/src/framework.js +8586 -3739
  70. package/dist/src/framework.js.map +1 -1
  71. package/dist/src/gate/event-gate.d.ts +125 -21
  72. package/dist/src/gate/event-gate.d.ts.map +1 -1
  73. package/dist/src/gate/event-gate.js +487 -72
  74. package/dist/src/gate/event-gate.js.map +1 -1
  75. package/dist/src/gate/index.d.ts +1 -1
  76. package/dist/src/gate/index.d.ts.map +1 -1
  77. package/dist/src/gate/index.js.map +1 -1
  78. package/dist/src/gate/reply-judge.d.ts +4 -0
  79. package/dist/src/gate/reply-judge.d.ts.map +1 -1
  80. package/dist/src/gate/reply-judge.js +12 -3
  81. package/dist/src/gate/reply-judge.js.map +1 -1
  82. package/dist/src/gate/rule-scope.d.ts +30 -0
  83. package/dist/src/gate/rule-scope.d.ts.map +1 -0
  84. package/dist/src/gate/rule-scope.js +41 -0
  85. package/dist/src/gate/rule-scope.js.map +1 -0
  86. package/dist/src/gate/suppress-script-worker.d.ts +2 -0
  87. package/dist/src/gate/suppress-script-worker.d.ts.map +1 -0
  88. package/dist/src/gate/suppress-script-worker.js +103 -0
  89. package/dist/src/gate/suppress-script-worker.js.map +1 -0
  90. package/dist/src/gate/suppress-scripts.d.ts +105 -0
  91. package/dist/src/gate/suppress-scripts.d.ts.map +1 -0
  92. package/dist/src/gate/suppress-scripts.js +403 -0
  93. package/dist/src/gate/suppress-scripts.js.map +1 -0
  94. package/dist/src/gate/suppression-review.d.ts +143 -0
  95. package/dist/src/gate/suppression-review.d.ts.map +1 -0
  96. package/dist/src/gate/suppression-review.js +463 -0
  97. package/dist/src/gate/suppression-review.js.map +1 -0
  98. package/dist/src/gate/suppression-stats.d.ts +125 -0
  99. package/dist/src/gate/suppression-stats.d.ts.map +1 -0
  100. package/dist/src/gate/suppression-stats.js +214 -0
  101. package/dist/src/gate/suppression-stats.js.map +1 -0
  102. package/dist/src/gate/types.d.ts +142 -7
  103. package/dist/src/gate/types.d.ts.map +1 -1
  104. package/dist/src/gate/wake-onboarding.d.ts +119 -0
  105. package/dist/src/gate/wake-onboarding.d.ts.map +1 -0
  106. package/dist/src/gate/wake-onboarding.js +277 -0
  107. package/dist/src/gate/wake-onboarding.js.map +1 -0
  108. package/dist/src/heartbeat/heartbeat.js +1 -1
  109. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  110. package/dist/src/held-writes.d.ts +31 -23
  111. package/dist/src/held-writes.d.ts.map +1 -1
  112. package/dist/src/held-writes.js +44 -59
  113. package/dist/src/held-writes.js.map +1 -1
  114. package/dist/src/index.d.ts +6 -2
  115. package/dist/src/index.d.ts.map +1 -1
  116. package/dist/src/index.js +4 -0
  117. package/dist/src/index.js.map +1 -1
  118. package/dist/src/inference-bounds.d.ts +2 -1
  119. package/dist/src/inference-bounds.d.ts.map +1 -1
  120. package/dist/src/inference-bounds.js +9 -6
  121. package/dist/src/inference-bounds.js.map +1 -1
  122. package/dist/src/jsonrpc-envelope.d.ts +69 -0
  123. package/dist/src/jsonrpc-envelope.d.ts.map +1 -0
  124. package/dist/src/jsonrpc-envelope.js +66 -0
  125. package/dist/src/jsonrpc-envelope.js.map +1 -0
  126. package/dist/src/mcpl/channel-registry.d.ts +188 -28
  127. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  128. package/dist/src/mcpl/channel-registry.js +426 -70
  129. package/dist/src/mcpl/channel-registry.js.map +1 -1
  130. package/dist/src/mcpl/checkpoint-manager.d.ts +2 -4
  131. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  132. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  133. package/dist/src/mcpl/feature-set-manager.d.ts +2 -4
  134. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  135. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  136. package/dist/src/mcpl/inbound-source.d.ts +2 -1
  137. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  138. package/dist/src/mcpl/inbound-source.js +2 -1
  139. package/dist/src/mcpl/inbound-source.js.map +1 -1
  140. package/dist/src/mcpl/inference-router.d.ts +6 -6
  141. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  142. package/dist/src/mcpl/inference-router.js.map +1 -1
  143. package/dist/src/mcpl/modern-connection.d.ts +109 -15
  144. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  145. package/dist/src/mcpl/modern-connection.js +593 -86
  146. package/dist/src/mcpl/modern-connection.js.map +1 -1
  147. package/dist/src/mcpl/protocol-family.d.ts +3 -1
  148. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  149. package/dist/src/mcpl/protocol-family.js +15 -9
  150. package/dist/src/mcpl/protocol-family.js.map +1 -1
  151. package/dist/src/mcpl/push-coalescer.d.ts +132 -22
  152. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  153. package/dist/src/mcpl/push-coalescer.js +105 -15
  154. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  155. package/dist/src/mcpl/push-handler.d.ts +9 -7
  156. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  157. package/dist/src/mcpl/push-handler.js.map +1 -1
  158. package/dist/src/mcpl/server-connection.d.ts +50 -28
  159. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  160. package/dist/src/mcpl/server-connection.js +165 -72
  161. package/dist/src/mcpl/server-connection.js.map +1 -1
  162. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  163. package/dist/src/mcpl/tool-classes.js +3 -0
  164. package/dist/src/mcpl/tool-classes.js.map +1 -1
  165. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  166. package/dist/src/mcpl/tool-presence.js +10 -6
  167. package/dist/src/mcpl/tool-presence.js.map +1 -1
  168. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  169. package/dist/src/mcpl/toolset-host.js +7 -6
  170. package/dist/src/mcpl/toolset-host.js.map +1 -1
  171. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  172. package/dist/src/mcpl/toolsets-module.js +2 -1
  173. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  174. package/dist/src/mcpl/types.d.ts +33 -6
  175. package/dist/src/mcpl/types.d.ts.map +1 -1
  176. package/dist/src/mcpl/types.js.map +1 -1
  177. package/dist/src/module-registry.d.ts +24 -1
  178. package/dist/src/module-registry.d.ts.map +1 -1
  179. package/dist/src/module-registry.js +68 -18
  180. package/dist/src/module-registry.js.map +1 -1
  181. package/dist/src/modules/discord/index.d.ts +45 -8
  182. package/dist/src/modules/discord/index.d.ts.map +1 -1
  183. package/dist/src/modules/discord/index.js +238 -87
  184. package/dist/src/modules/discord/index.js.map +1 -1
  185. package/dist/src/modules/health/index.js +2 -2
  186. package/dist/src/modules/health/index.js.map +1 -1
  187. package/dist/src/modules/history/index.d.ts +59 -0
  188. package/dist/src/modules/history/index.d.ts.map +1 -1
  189. package/dist/src/modules/history/index.js +432 -67
  190. package/dist/src/modules/history/index.js.map +1 -1
  191. package/dist/src/modules/history/semantic.d.ts +6 -5
  192. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  193. package/dist/src/modules/history/semantic.js +18 -25
  194. package/dist/src/modules/history/semantic.js.map +1 -1
  195. package/dist/src/modules/workspace/disk-agreement.d.ts +1 -1
  196. package/dist/src/modules/workspace/disk-agreement.js +1 -1
  197. package/dist/src/modules/workspace/index.d.ts +28 -7
  198. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  199. package/dist/src/modules/workspace/index.js +80 -25
  200. package/dist/src/modules/workspace/index.js.map +1 -1
  201. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  202. package/dist/src/modules/workspace/observe.js +2 -1
  203. package/dist/src/modules/workspace/observe.js.map +1 -1
  204. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/reconcile.js +3 -2
  206. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  207. package/dist/src/notice-tags.d.ts +20 -9
  208. package/dist/src/notice-tags.d.ts.map +1 -1
  209. package/dist/src/notice-tags.js +20 -9
  210. package/dist/src/notice-tags.js.map +1 -1
  211. package/dist/src/operator-log.d.ts +36 -3
  212. package/dist/src/operator-log.d.ts.map +1 -1
  213. package/dist/src/operator-log.js +2 -2
  214. package/dist/src/operator-log.js.map +1 -1
  215. package/dist/src/presentation.d.ts +747 -0
  216. package/dist/src/presentation.d.ts.map +1 -0
  217. package/dist/src/presentation.js +1437 -0
  218. package/dist/src/presentation.js.map +1 -0
  219. package/dist/src/prose-drafts.d.ts +15 -11
  220. package/dist/src/prose-drafts.d.ts.map +1 -1
  221. package/dist/src/prose-drafts.js +16 -12
  222. package/dist/src/prose-drafts.js.map +1 -1
  223. package/dist/src/read-marks.d.ts +192 -0
  224. package/dist/src/read-marks.d.ts.map +1 -0
  225. package/dist/src/read-marks.js +242 -0
  226. package/dist/src/read-marks.js.map +1 -0
  227. package/dist/src/reasoning-loss.d.ts +5 -0
  228. package/dist/src/reasoning-loss.d.ts.map +1 -0
  229. package/dist/src/reasoning-loss.js +8 -0
  230. package/dist/src/reasoning-loss.js.map +1 -0
  231. package/dist/src/recovery/offline-branch.d.ts +91 -6
  232. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  233. package/dist/src/recovery/offline-branch.js +277 -32
  234. package/dist/src/recovery/offline-branch.js.map +1 -1
  235. package/dist/src/recovery/recover-cli.js +24 -4
  236. package/dist/src/recovery/recover-cli.js.map +1 -1
  237. package/dist/src/send-tools.d.ts +5 -9
  238. package/dist/src/send-tools.d.ts.map +1 -1
  239. package/dist/src/send-tools.js +40 -7
  240. package/dist/src/send-tools.js.map +1 -1
  241. package/dist/src/sent-rows.js +1 -1
  242. package/dist/src/stored-injections.d.ts +2 -2
  243. package/dist/src/stored-injections.js +2 -2
  244. package/dist/src/stored-removal.d.ts +17 -4
  245. package/dist/src/stored-removal.d.ts.map +1 -1
  246. package/dist/src/stored-removal.js +29 -5
  247. package/dist/src/stored-removal.js.map +1 -1
  248. package/dist/src/supplied-text.d.ts +1 -1
  249. package/dist/src/supplied-text.js +3 -3
  250. package/dist/src/supplied-text.js.map +1 -1
  251. package/dist/src/thinking-drops.d.ts +2 -0
  252. package/dist/src/thinking-drops.d.ts.map +1 -1
  253. package/dist/src/thinking-drops.js.map +1 -1
  254. package/dist/src/tool-presentation.d.ts.map +1 -1
  255. package/dist/src/tool-presentation.js +38 -9
  256. package/dist/src/tool-presentation.js.map +1 -1
  257. package/dist/src/tool-result-guard.d.ts +12 -1
  258. package/dist/src/tool-result-guard.d.ts.map +1 -1
  259. package/dist/src/tool-result-guard.js +42 -8
  260. package/dist/src/tool-result-guard.js.map +1 -1
  261. package/dist/src/tune-out/coordinator.d.ts +1170 -81
  262. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  263. package/dist/src/tune-out/coordinator.js +3732 -369
  264. package/dist/src/tune-out/coordinator.js.map +1 -1
  265. package/dist/src/tune-out/tools.d.ts +114 -17
  266. package/dist/src/tune-out/tools.d.ts.map +1 -1
  267. package/dist/src/tune-out/tools.js +89 -28
  268. package/dist/src/tune-out/tools.js.map +1 -1
  269. package/dist/src/tune-out/window-anchor.d.ts +143 -0
  270. package/dist/src/tune-out/window-anchor.d.ts.map +1 -0
  271. package/dist/src/tune-out/window-anchor.js +202 -0
  272. package/dist/src/tune-out/window-anchor.js.map +1 -0
  273. package/dist/src/types/agent.d.ts +121 -25
  274. package/dist/src/types/agent.d.ts.map +1 -1
  275. package/dist/src/types/events.d.ts +2 -0
  276. package/dist/src/types/events.d.ts.map +1 -1
  277. package/dist/src/types/framework.d.ts +72 -17
  278. package/dist/src/types/framework.d.ts.map +1 -1
  279. package/dist/src/types/inference-log.d.ts +1 -0
  280. package/dist/src/types/inference-log.d.ts.map +1 -1
  281. package/dist/src/types/module.d.ts +180 -69
  282. package/dist/src/types/module.d.ts.map +1 -1
  283. package/dist/src/types/trace.d.ts +573 -39
  284. package/dist/src/types/trace.d.ts.map +1 -1
  285. package/package.json +4 -4
  286. package/src/admission/admit.ts +32 -3
  287. package/src/agent.ts +392 -132
  288. package/src/api/mcp-server-core.ts +54 -17
  289. package/src/api/server.ts +4 -23
  290. package/src/append-all.ts +6 -0
  291. package/src/code-execution/isolate/scripts.ts +28 -3
  292. package/src/config-checks.ts +14 -0
  293. package/src/context-manager-errors.ts +37 -0
  294. package/src/context-options.ts +106 -0
  295. package/src/cut-off-round.ts +4 -4
  296. package/src/deferred-import.ts +239 -0
  297. package/src/dendrite/audience.ts +315 -0
  298. package/src/dendrite/index.ts +14 -2
  299. package/src/dendrite/presets.ts +18 -6
  300. package/src/dendrite/registry.ts +213 -14
  301. package/src/dendrite/types.ts +92 -8
  302. package/src/failure-text.ts +88 -0
  303. package/src/foreign-rounds.ts +199 -0
  304. package/src/framework.ts +8796 -3813
  305. package/src/gate/event-gate.ts +518 -91
  306. package/src/gate/index.ts +3 -0
  307. package/src/gate/reply-judge.ts +12 -3
  308. package/src/gate/rule-scope.ts +45 -0
  309. package/src/gate/suppress-script-worker.ts +89 -0
  310. package/src/gate/suppress-scripts.ts +414 -0
  311. package/src/gate/suppression-review.ts +517 -0
  312. package/src/gate/suppression-stats.ts +314 -0
  313. package/src/gate/types.ts +148 -7
  314. package/src/gate/wake-onboarding.ts +309 -0
  315. package/src/heartbeat/heartbeat.ts +1 -1
  316. package/src/held-writes.ts +49 -60
  317. package/src/index.ts +10 -2
  318. package/src/inference-bounds.ts +11 -6
  319. package/src/jsonrpc-envelope.ts +81 -0
  320. package/src/mcpl/channel-registry.ts +513 -116
  321. package/src/mcpl/checkpoint-manager.ts +3 -2
  322. package/src/mcpl/feature-set-manager.ts +2 -1
  323. package/src/mcpl/inbound-source.ts +2 -1
  324. package/src/mcpl/inference-router.ts +7 -4
  325. package/src/mcpl/modern-connection.ts +629 -91
  326. package/src/mcpl/protocol-family.ts +15 -9
  327. package/src/mcpl/push-coalescer.ts +193 -34
  328. package/src/mcpl/push-handler.ts +10 -5
  329. package/src/mcpl/server-connection.ts +169 -75
  330. package/src/mcpl/tool-classes.ts +3 -0
  331. package/src/mcpl/tool-presence.ts +10 -5
  332. package/src/mcpl/toolset-host.ts +7 -6
  333. package/src/mcpl/toolsets-module.ts +2 -1
  334. package/src/mcpl/types.ts +33 -6
  335. package/src/module-registry.ts +73 -17
  336. package/src/modules/discord/index.ts +230 -82
  337. package/src/modules/health/index.ts +2 -2
  338. package/src/modules/history/index.ts +439 -70
  339. package/src/modules/history/semantic.ts +18 -24
  340. package/src/modules/workspace/disk-agreement.ts +1 -1
  341. package/src/modules/workspace/index.ts +89 -26
  342. package/src/modules/workspace/observe.ts +2 -1
  343. package/src/modules/workspace/reconcile.ts +3 -2
  344. package/src/notice-tags.ts +21 -9
  345. package/src/operator-log.ts +36 -3
  346. package/src/presentation.ts +1678 -0
  347. package/src/prose-drafts.ts +16 -12
  348. package/src/read-marks.ts +328 -0
  349. package/src/reasoning-loss.ts +7 -0
  350. package/src/recovery/offline-branch.ts +350 -35
  351. package/src/recovery/recover-cli.ts +23 -4
  352. package/src/send-tools.ts +40 -7
  353. package/src/sent-rows.ts +1 -1
  354. package/src/stored-injections.ts +2 -2
  355. package/src/stored-removal.ts +31 -7
  356. package/src/supplied-text.ts +3 -3
  357. package/src/thinking-drops.ts +2 -0
  358. package/src/tool-presentation.ts +34 -9
  359. package/src/tool-result-guard.ts +41 -7
  360. package/src/tune-out/coordinator.ts +4121 -472
  361. package/src/tune-out/tools.ts +162 -44
  362. package/src/tune-out/window-anchor.ts +277 -0
  363. package/src/types/agent.ts +122 -25
  364. package/src/types/events.ts +2 -0
  365. package/src/types/framework.ts +73 -17
  366. package/src/types/inference-log.ts +1 -0
  367. package/src/types/module.ts +181 -68
  368. package/src/types/trace.ts +607 -43
@@ -16,16 +16,23 @@ import { cutToFit, quotedJson, quotedValue, suppliedValue } from '../supplied-te
16
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
17
17
  import { basename, dirname, join } from 'node:path';
18
18
  import { GateScript } from './gate-script.js';
19
+ import { SuppressScripts, type SuppressScriptEvent } from './suppress-scripts.js';
20
+ import { firstCatchAll, namesSomething } from './rule-scope.js';
19
21
  import { canonical, duplicateName, lastRecipePath, noChanges, reconcileLine, reconcileWithRecipe, ruleName, type WrittenConfig } from './recipe-reconcile.js';
20
22
  import { closestNames, isNearMiss } from '../closest-names.js';
21
23
  import { MAX_TIMER_MS } from '../deadline-timer.js';
22
24
  import { safeSlice } from '../safe-slice.js';
25
+ import { failureWords } from '../failure-text.js';
26
+ import { JudgeFailure } from './reply-judge.js';
27
+ import { isSystemNotice } from '../mcpl/tags.js';
23
28
 
24
29
  import type { ToolDefinition } from '../types/events.js';
30
+ import type { TraceEventInput } from '../types/trace.js';
25
31
  import type {
26
32
  GateConfig,
27
33
  GatePolicy,
28
34
  GatePolicyMatch,
35
+ SuppressConfig,
29
36
  GateBehavior,
30
37
  GateDecision,
31
38
  GateEntryRef,
@@ -41,6 +48,7 @@ import type {
41
48
  JudgeVerdict,
42
49
  UpcomingWake,
43
50
  } from './types.js';
51
+ import { appendAll } from '../append-all.js';
44
52
 
45
53
  // Re-export types consumers need
46
54
  export type {
@@ -128,7 +136,9 @@ interface PendingEvent {
128
136
  * composite channel id. */
129
137
  channelLabel?: string;
130
138
  /** Author id of the event (discord snowflake etc.), when the metadata carries
131
- * one — a debounced wake names the newest such author as its counterparty. */
139
+ * one — a debounced wake's counterparty is the author of the event
140
+ * wakeProvenance picks; when it picks none and gives no
141
+ * `batchChannels`, the author of the batch's newest notice that has one. */
132
142
  authorId?: string;
133
143
  /** MCPL server id the event came from — the namespace for the counterparty
134
144
  * (`<serverId>:user:<id>`), same as the direct channel-incoming path. */
@@ -137,6 +147,7 @@ interface PendingEvent {
137
147
  * newer ambient chatter when a batched wake picks its provenance — the same
138
148
  * rule the framework applies to the turn's speech locus. */
139
149
  addressed: boolean;
150
+ systemNotice?: true;
140
151
  /** The REGISTERED (composite) channel id the event arrived on, resolved by
141
152
  * the host at enqueue time. Differs from `channelId` for push events,
142
153
  * whose `channelId` is the adapter's raw id (a bare Discord snowflake for a
@@ -146,15 +157,20 @@ interface PendingEvent {
146
157
  * a channel message's id, a push event's event id (isNewestEntry). */
147
158
  messageId?: string;
148
159
  eventId?: string;
160
+ /** GateEventInfo.recipients. */
161
+ recipients?: readonly string[];
149
162
  }
150
163
 
151
164
  /** Where the host put a message the gate wrote: the agent whose window it
152
- * went to and, when it was held for that agent's live turn, the id it waits
153
- * under in the host's queue, or else the id it was stored under. */
165
+ * went to; its stored id, once stored (absent while a surgery's hold, or
166
+ * its agent's recovery, keeps the write in memory); and `presented: false`
167
+ * when it wasn't presented to the agent: kept in memory, or stored while a
168
+ * turn of the agent's was alive, or with a presentation at rest that
169
+ * failed (#291's presentation index). The host marks the wake that goes with it from these. */
154
170
  export interface WritePlacement {
155
171
  agent?: string;
156
- deferredId?: string;
157
172
  messageId?: string;
173
+ presented?: false;
158
174
  }
159
175
 
160
176
  /**
@@ -170,7 +186,13 @@ export interface WakeProvenance {
170
186
  * channel); the framework derives the composite id on the direct path. */
171
187
  channelId?: string;
172
188
  /** Server-namespaced author id: `<serverId>:user:<id>` (falls back to the
173
- * channel id's first segment only when the event carried no server id). */
189
+ * channel id's first segment only when the event carried no server id).
190
+ * A `chat:system` notice, unless it's a coalesced render's latest
191
+ * occurrence (GateEventInfo.folds), is never chosen for a batch's route,
192
+ * channel or `addressed` flag. In a batch in which no other event names a
193
+ * channel or an author, and which has no `batchChannels`, the chosen
194
+ * event is its newest notice that has an author, of which the wake names
195
+ * only the author and its time (`at`). */
174
196
  counterparty?: string;
175
197
  /** True when the chosen event was `chat:addressed`. */
176
198
  addressed?: boolean;
@@ -187,6 +209,19 @@ export interface WakeProvenance {
187
209
  * event named its server: channel ids are unique only within one
188
210
  * server's connection (MCPL §14.2). */
189
211
  routeServerId?: string;
212
+ /** The channels a batch came from, each its registered id and server, in
213
+ * the order they first appear, when no event of it addressed the agent:
214
+ * those the host resolved, one or more. A channel some events gave with
215
+ * its server and others without is listed once each way, since only the
216
+ * host can tell they're one. A `chat:system` notice counts
217
+ * neither as addressing the agent nor as a channel, except as a
218
+ * coalesced render's latest occurrence, which the gate takes as a
219
+ * message (GateEventInfo.folds). The wake has no route, and the turn's
220
+ * no-locus notice names them, one given without its server only if its id
221
+ * still names one registered channel when the wake is admitted, unless the
222
+ * turn's own wake named a channel that resolves to none, or the turn is an
223
+ * ephemeral run's, which writes none. */
224
+ batchChannels?: Array<{ channelId: string; serverId?: string }>;
190
225
  /** Timestamp (ms) of the chosen event, so a consumer coalescing several
191
226
  * requests can order by event recency, not by flush order. */
192
227
  at?: number;
@@ -259,15 +294,6 @@ interface PolicyStats {
259
294
  lastMatchTimestamp: number | null;
260
295
  }
261
296
 
262
- // ---------------------------------------------------------------------------
263
- // Trace event shape (subset — avoids importing the full TraceEvent union)
264
- // ---------------------------------------------------------------------------
265
-
266
- interface TraceEventLike {
267
- type: string;
268
- [key: string]: unknown;
269
- }
270
-
271
297
  // ---------------------------------------------------------------------------
272
298
  // Glob matching (simple * wildcards)
273
299
  // ---------------------------------------------------------------------------
@@ -296,15 +322,25 @@ function isMetadataTruthy(value: unknown): boolean {
296
322
  }
297
323
 
298
324
  /**
299
- * Provenance of a batched wake: the newest ADDRESSED channel-bearing event
300
- * wins (mention / reply / DM outranks newer ambient chatter — the framework's
301
- * own locus rule); else the newest channel-bearing event. Channel, author and
302
- * addressed all come from that one event, so they can never disagree.
325
+ * Provenance of a batched wake: the newest ADDRESSED event that names a
326
+ * channel or an author wins (mention / reply / DM outranks newer ambient
327
+ * chatter — the framework's own locus rule); else the newest event that
328
+ * names either. An addressed event's resolved route counts as its channel;
329
+ * an ambient one's doesn't. Channel, author and addressed all come from
330
+ * that one event, so they can never disagree. A `chat:system` notice is
331
+ * never chosen for the batch's route, channel or addressed flag, even one
332
+ * addressed by its place (a pin in a DM), since a notice never decides where
333
+ * the agent's speech goes; a coalesced render whose latest occurrence is a
334
+ * notice isn't taken as one (GateEventInfo.folds). A batch with no such event
335
+ * carries only its `batchChannels` when it has them; else its chosen event is
336
+ * its newest notice that has an author, of which it takes only the author
337
+ * and time.
303
338
  */
304
339
  export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefined {
340
+ const said = events.filter((e) => !e.systemNotice);
305
341
  let newest: PendingEvent | undefined;
306
342
  let newestAddressed: PendingEvent | undefined;
307
- for (const e of events) {
343
+ for (const e of said) {
308
344
  // A resolved route counts as naming a channel only for an ADDRESSED
309
345
  // event: a push whose origin declares only `mcplChannelId` (no raw
310
346
  // channel id, no author) is still a routable addressed message, and
@@ -317,22 +353,42 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
317
353
  if (e.addressed && (!newestAddressed || e.timestamp >= newestAddressed.timestamp)) newestAddressed = e;
318
354
  }
319
355
  const pick = newestAddressed ?? newest;
320
- if (!pick) return undefined;
356
+ const channels = said.some((e) => e.addressed) ? [] : batchRoutes(said);
357
+ if (!pick && channels.length === 0) {
358
+ const notice = events.reduce<PendingEvent | undefined>(
359
+ (n, e) => (e.systemNotice && e.authorId && (!n || e.timestamp >= n.timestamp) ? e : n), undefined);
360
+ return notice ? { counterparty: counterpartyOf(notice), at: notice.timestamp } : undefined;
361
+ }
321
362
  // push-event channel ids are the adapter's raw ids (a Discord snowflake):
322
363
  // not a composite channel id, so not reported as one
323
- const channelId = pick.channelId && pick.eventType !== 'mcpl:push-event' ? pick.channelId : undefined;
324
- const ns = pick.serverId || (pick.channelId ? pick.channelId.split(':')[0] : '') || 'channel';
364
+ const channelId = pick?.channelId && pick.eventType !== 'mcpl:push-event' ? pick.channelId : undefined;
325
365
  return {
326
366
  ...(channelId ? { channelId } : {}),
327
- ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
328
- ...(pick.addressed ? { addressed: true } : {}),
329
- ...(pick.addressed && pick.routeChannelId
367
+ ...(pick?.authorId ? { counterparty: counterpartyOf(pick) } : {}),
368
+ ...(pick?.addressed ? { addressed: true } : {}),
369
+ ...(pick?.addressed && pick.routeChannelId
330
370
  ? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
331
371
  : {}),
332
- at: pick.timestamp,
372
+ ...(channels.length > 0 ? { batchChannels: channels } : {}),
373
+ ...(pick ? { at: pick.timestamp } : {}),
333
374
  };
334
375
  }
335
376
 
377
+ function counterpartyOf(e: PendingEvent): string {
378
+ const ns = e.serverId || (e.channelId ? e.channelId.split(':')[0] : '') || 'channel';
379
+ return `${ns}:user:${e.authorId}`;
380
+ }
381
+
382
+ function batchRoutes(events: PendingEvent[]): Array<{ channelId: string; serverId?: string }> {
383
+ const routes: Array<{ channelId: string; serverId?: string }> = [];
384
+ for (const e of events) {
385
+ if (!e.routeChannelId) continue;
386
+ if (routes.some((r) => r.channelId === e.routeChannelId && r.serverId === e.serverId)) continue;
387
+ routes.push({ channelId: e.routeChannelId, ...(e.serverId ? { serverId: e.serverId } : {}) });
388
+ }
389
+ return routes;
390
+ }
391
+
336
392
  /** A `[Gate: …]` line's reference to messages already in the window: how
337
393
  * many, in which channel, and how long ago the oldest arrived, as of `now`. */
338
394
  function referenceLine(count: number, channel: string, oldest: number, now: number): string {
@@ -447,7 +503,7 @@ function behaviorFingerprint(b: import('./types.js').GateBehavior): string {
447
503
  // default), it now loads without it.
448
504
 
449
505
  const CONFIG_FIELDS = ['default', 'policies'];
450
- const POLICY_FIELDS = ['name', 'match', 'behavior', 'resets', 'passthrough'];
506
+ const POLICY_FIELDS = ['name', 'match', 'behavior', 'resets', 'passthrough', 'suppress'];
451
507
  const MATCH_FIELDS = [
452
508
  'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
453
509
  ];
@@ -456,6 +512,7 @@ const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample', 'judge'] as
456
512
  const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
457
513
  const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
458
514
  const JUDGE_FIELDS = ['debounceMs', 'maxWaitMs', 'instructions'];
515
+ const SUPPRESS_FIELDS = ['description', 'script'];
459
516
 
460
517
  function isPlainObject(value: unknown): value is Record<string, unknown> {
461
518
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -937,6 +994,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json', named: (pat
937
994
  const match = readMatch(r, obj.match);
938
995
  const resets = readResets(r, obj.resets);
939
996
  const passthrough = readPassthrough(r, obj.passthrough, kind);
997
+ const suppress = readSuppress(r, obj.suppress, obj.behavior, match, obj.match);
940
998
  const reading: RuleReading = {
941
999
  where: r.where, problems: r.problems, notes: r.notes, quiet: kind === 'skip' || kind === 'defer' || r.noTokens === true,
942
1000
  };
@@ -944,6 +1002,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json', named: (pat
944
1002
  const policy: GatePolicy = { name, match, behavior };
945
1003
  if (resets) policy.resets = resets;
946
1004
  if (passthrough) policy.passthrough = true;
1005
+ if (suppress) policy.suppress = suppress;
947
1006
  reading.policy = policy;
948
1007
  if (r.matchesAll) reading.matchesAll = true;
949
1008
  }
@@ -1333,8 +1392,7 @@ function readMatch(r: Reader, value: unknown): GatePolicyMatch {
1333
1392
  // Left with nothing that narrows it once something in it was named, it
1334
1393
  // matches every event. Empty lists and nulls alone always meant no
1335
1394
  // constraint, and name nothing.
1336
- const narrows = Object.entries(match).some(([field, v]) => !(field === 'scope' && Array.isArray(v) && v.length === 0));
1337
- if (!narrows && r.problems.length + r.notes.length > named) r.matchesAll = true;
1395
+ if (!matchNarrows(match) && r.problems.length + r.notes.length > named) r.matchesAll = true;
1338
1396
  return match;
1339
1397
  }
1340
1398
 
@@ -1381,6 +1439,133 @@ function readPassthrough(r: Reader, value: unknown, kind: BehaviorKind | undefin
1381
1439
  return false;
1382
1440
  }
1383
1441
 
1442
+ /** Whether a match narrows what it matches: anything but an empty scope. */
1443
+ export function matchNarrows(match: GatePolicyMatch): boolean {
1444
+ return Object.entries(match).some(([field, v]) => !(field === 'scope' && Array.isArray(v) && v.length === 0));
1445
+ }
1446
+
1447
+ /**
1448
+ * Whether a suppress rule's match names what it hides: an event type, or a
1449
+ * source, channel, mount, path or tag pattern that isn't only wildcards
1450
+ * (namesSomething), a metadata flag, or a content filter. A pattern of
1451
+ * wildcards alone, such as `source: "*"`, or only `tagsNone`, takes every
1452
+ * event, which a suppress rule may not.
1453
+ */
1454
+ function suppressMatchNarrows(match: GatePolicyMatch): boolean {
1455
+ if ((match.scope ?? []).length > 0 || (match.metadataTrue ?? []).length > 0 || match.filter !== undefined) return true;
1456
+ if ([match.source, match.channel, match.mount, match.pathGlob].some(namesSomething)) return true;
1457
+ return [...(match.tagsAny ?? []), ...(match.tagsAll ?? [])].some(namesSomething);
1458
+ }
1459
+
1460
+ /** Longest suppress description, in characters: the review sends it with every rule it judges. */
1461
+ export const MAX_SUPPRESS_DESCRIPTION = 2_000;
1462
+ /** Longest suppress script, in characters. */
1463
+ export const MAX_SUPPRESS_SCRIPT = 20_000;
1464
+ const SUPPRESS_EXAMPLE = '{"description": "the eye going on and off"}';
1465
+
1466
+ /**
1467
+ * A rule's `suppress`. Every problem with it is read as a rule that doesn't
1468
+ * suppress, which is how a release without suppression reads the rule:
1469
+ * its behavior then decides the event, and the event is rendered. The
1470
+ * behavior has to be written out as "defer" (or "skip"), not left to
1471
+ * default, because such a release reads a rule with no behavior as
1472
+ * "always" and would wake the agent on every event the rule was meant to
1473
+ * hide.
1474
+ */
1475
+ function readSuppress(r: Reader, value: unknown, behavior: unknown, match: GatePolicyMatch, rawMatch: unknown): SuppressConfig | undefined {
1476
+ if (value === undefined || value === false) return undefined;
1477
+ const without = "so the rule doesn't suppress, and its behavior decides its events as for any other rule";
1478
+ if (!isPlainObject(value)) {
1479
+ r.problems.push({ text: `suppress is ${shownValue(value)}, where an object such as ${SUPPRESS_EXAMPLE} is needed`, read: without });
1480
+ return undefined;
1481
+ }
1482
+ const before = r.problems.length;
1483
+ // A script given as null or "" is a script that's missing, not one left
1484
+ // out: read as left out, the rule would hide every event its match takes.
1485
+ const { script: _givenScript, ...rest } = value;
1486
+ const emptyScript = Object.hasOwn(value, 'script') && (value.script === null || value.script === '');
1487
+ if (emptyScript) {
1488
+ r.problems.push({ text: `suppress.script is ${shownValue(value.script)}, where the source of a function such as "(event, state) => false" is needed`, read: without });
1489
+ }
1490
+ const given = withoutEmpty(`${r.where}: suppress`, emptyScript ? rest : value, r.notes);
1491
+ const reported = r.problems.length;
1492
+ readFieldNames(r, 'suppress', given, SUPPRESS_FIELDS);
1493
+ // A field it doesn't read can't be left aside here, as elsewhere in a
1494
+ // rule: one meant as a condition, left out, would hide every event the
1495
+ // match takes.
1496
+ const unread = Object.keys(given).filter((key) => !SUPPRESS_FIELDS.includes(key));
1497
+ if (unread.length > 0 && r.problems.length === reported) {
1498
+ r.problems.push({ text: `suppress has ${quotedList(unread)}, and a suppress rule reads only "description" and "script"`, read: without });
1499
+ }
1500
+ const config: SuppressConfig = { description: '' };
1501
+ if (typeof given.description !== 'string' || given.description.trim() === '') {
1502
+ r.problems.push({
1503
+ text: `suppress.description is ${given.description === undefined ? 'missing' : shownValue(given.description)}, where the rule's `
1504
+ + 'purpose in a few words is needed: the review checks what the rule hides against it',
1505
+ read: without,
1506
+ });
1507
+ } else if ([...given.description].length > MAX_SUPPRESS_DESCRIPTION) {
1508
+ r.problems.push({
1509
+ text: `suppress.description is ${[...given.description].length.toLocaleString('en-US')} characters, over the `
1510
+ + `${MAX_SUPPRESS_DESCRIPTION.toLocaleString('en-US')} allowed`,
1511
+ read: without,
1512
+ });
1513
+ } else {
1514
+ config.description = given.description.trim();
1515
+ }
1516
+ if (given.script !== undefined) {
1517
+ if (typeof given.script !== 'string' || given.script.trim() === '') {
1518
+ r.problems.push({ text: `suppress.script is ${shownValue(given.script)}, where the source of a function such as "(event, state) => false" is needed`, read: without });
1519
+ } else if ([...given.script].length > MAX_SUPPRESS_SCRIPT) {
1520
+ r.problems.push({
1521
+ text: `suppress.script is ${[...given.script].length.toLocaleString('en-US')} characters, over the ${MAX_SUPPRESS_SCRIPT.toLocaleString('en-US')} allowed`,
1522
+ read: without,
1523
+ });
1524
+ } else {
1525
+ // Parsed only, never run here: the script runs on its worker.
1526
+ try {
1527
+ new Function(`"use strict";\nreturn (${given.script}\n);`);
1528
+ config.script = given.script;
1529
+ } catch (e) {
1530
+ r.problems.push({ text: `suppress.script doesn't parse (${quotedValue(errorText(e))})`, read: without });
1531
+ }
1532
+ }
1533
+ }
1534
+ if (behavior !== 'defer' && behavior !== 'skip') {
1535
+ r.problems.push({
1536
+ text: `suppress is given, and a rule that suppresses needs "behavior": "defer" written out, so a release that `
1537
+ + `doesn't know "suppress" defers its events instead of waking you on them; it has `
1538
+ + (behavior === undefined ? 'no behavior' : `behavior ${shownValue(behavior)}`),
1539
+ read: without,
1540
+ });
1541
+ }
1542
+ // A match field the gate doesn't read would, left aside, hide more than was
1543
+ // written: the condition it was meant to add is gone.
1544
+ if (isPlainObject(rawMatch)) {
1545
+ const rawFilter = isPlainObject(rawMatch.filter) ? Object.keys(rawMatch.filter).filter((key) => !FILTER_FIELDS.includes(key)).map((key) => `filter.${key}`) : [];
1546
+ const unreadMatch = [...Object.keys(rawMatch).filter((key) => !MATCH_FIELDS.includes(key)), ...rawFilter];
1547
+ if (unreadMatch.length > 0) {
1548
+ r.problems.push({ text: `suppress is given, and match has ${quotedList(unreadMatch)}, which the gate doesn't read, so the rule would hide more than was written`, read: without });
1549
+ }
1550
+ }
1551
+ if (!suppressMatchNarrows(match)) {
1552
+ r.problems.push({
1553
+ text: 'suppress is given, and the rule\'s match names nothing it hides: a suppress rule needs a match that names '
1554
+ + 'a scope, a source, a channel, tags, a mount or path, metadata or a filter (not a pattern of wildcards alone, or tagsNone alone), '
1555
+ + 'so it can\'t hide everything',
1556
+ read: without,
1557
+ });
1558
+ }
1559
+ // Only a rule read without any problem suppresses: one read past a problem
1560
+ // (a slip of a field, a match read without a part of it) matches more, or
1561
+ // decides otherwise, than its author wrote, and hiding is what can't be
1562
+ // seen to go wrong. Its behavior decides its events instead.
1563
+ if (before > 0) {
1564
+ r.problems.push({ text: 'suppress is given on a rule with problems, and a rule suppresses only when it has none', read: without });
1565
+ }
1566
+ return r.problems.length === 0 ? config : undefined;
1567
+ }
1568
+
1384
1569
  /** What a load or a start does with a rule. */
1385
1570
  type RuleOutcome = 'as-written' | 'read' | 'left-out';
1386
1571
 
@@ -1560,7 +1745,7 @@ function readConfig(
1560
1745
  };
1561
1746
  }
1562
1747
  }
1563
- for (const rule of rules) sentences.push(...readingSentences(rule.reading, rule.outcome));
1748
+ for (const rule of rules) appendAll(sentences, readingSentences(rule.reading, rule.outcome));
1564
1749
  // A list with no rules, or none the gate reads, where no sentence above said it has none.
1565
1750
  if (notUsed !== undefined && !sentences.some((said) => said.includes(notUsed))) {
1566
1751
  sentences.push(`${File} has no rules${rules.length > 0 ? ' but ones the gate leaves out' : ''}, so ${notUsedWhy}.`);
@@ -1962,7 +2147,9 @@ export function findShadowedPolicies(policies: GatePolicy[], eventTypesFrom?: Ev
1962
2147
  const out: ShadowWarning[] = [];
1963
2148
  for (let i = 0; i < policies.length; i++) {
1964
2149
  const earlier = policies[i];
1965
- if (earlier.passthrough) continue;
2150
+ // A suppress rule lets addressed events and the heartbeat's by, so it
2151
+ // can't be said to take every event of a later rule.
2152
+ if (earlier.passthrough || earlier.suppress) continue;
1966
2153
  for (let j = i + 1; j < policies.length; j++) {
1967
2154
  const found = subsumedEventTypes(earlier, policies[j], eventTypesFrom);
1968
2155
  if (found) {
@@ -1995,15 +2182,48 @@ export function formatShadowWarning(w: ShadowWarning): string {
1995
2182
  + `A scope on ${later} naming the event types it's for clears this note.`;
1996
2183
  }
1997
2184
 
2185
+ function judgeFailureWords(err: unknown): string {
2186
+ if (err instanceof JudgeFailure) return err.message;
2187
+ return err instanceof Error ? failureWords(err, 'the call failed') : 'the call failed';
2188
+ }
2189
+
1998
2190
  /** The text of a thrown value, for a log line. It never throws itself, whatever was thrown. */
1999
2191
  function errorText(err: unknown): string {
2000
2192
  try {
2193
+ if (err instanceof JudgeFailure && err.detail !== undefined) return `${err.message} (${err.detail})`;
2001
2194
  return err instanceof Error ? err.message : String(err);
2002
2195
  } catch {
2003
2196
  return 'a value that could not be printed';
2004
2197
  }
2005
2198
  }
2006
2199
 
2200
+ class PartialDelivery extends Error {
2201
+ constructor(readonly woke: readonly string[], readonly notWoken: readonly string[], cause: unknown) {
2202
+ super(errorText(cause), { cause });
2203
+ }
2204
+ }
2205
+
2206
+ /**
2207
+ * Runs a batch's delivery to `woken`, which pushes each agent it wakes to
2208
+ * `woke`: a throw once any is woken is a PartialDelivery naming them and the
2209
+ * rest; one before any wake is thrown as it is.
2210
+ */
2211
+ function deliveredTracking(woke: string[], woken: readonly string[], deliver: () => void): void {
2212
+ try {
2213
+ deliver();
2214
+ } catch (err) {
2215
+ if (woke.length === 0) throw err;
2216
+ throw new PartialDelivery(woke, woken.filter((name) => !woke.includes(name)), err);
2217
+ }
2218
+ }
2219
+
2220
+ function deliveredInPart(err: unknown): string | undefined {
2221
+ if (!(err instanceof PartialDelivery)) return undefined;
2222
+ const one = err.notWoken.length === 1;
2223
+ return `woke ${joinedList(err.woke)}, and delivering it threw (${err.message}), `
2224
+ + `so it's dropped for ${joinedList(err.notWoken)}, who ${one ? "isn't" : "aren't"} woken`;
2225
+ }
2226
+
2007
2227
  /**
2008
2228
  * Run a caller's callback once the change it's told of is made and traced. A
2009
2229
  * throw from it is logged, not thrown, so the call answers as the change it
@@ -2123,6 +2343,8 @@ export class EventGate {
2123
2343
 
2124
2344
  // Per-policy stats
2125
2345
  private stats = new Map<string, PolicyStats>();
2346
+ /** Suppress rules' scripts and their state (suppress-scripts.ts), kept beside gate.json. */
2347
+ private readonly suppressScripts: SuppressScripts;
2126
2348
 
2127
2349
  // The shadow lint's last findings (see refreshShadowWarnings), recomputed
2128
2350
  // on every config change and fingerprinted so an unchanged set doesn't
@@ -2172,7 +2394,7 @@ export class EventGate {
2172
2394
  private lastPrivilegedCheck = 0;
2173
2395
 
2174
2396
  // Dependency-injected callbacks
2175
- private emitTrace: (event: TraceEventLike) => void;
2397
+ private emitTrace: (event: TraceEventInput) => void;
2176
2398
  private addMessageFn: (
2177
2399
  participant: string,
2178
2400
  content: Array<{ type: 'text'; text: string }>,
@@ -2181,6 +2403,10 @@ export class EventGate {
2181
2403
  forAgent?: string,
2182
2404
  /** Out-param: where the message landed, for the wake that goes with it. */
2183
2405
  placement?: WritePlacement,
2406
+ /** The agents the row is for; absent: the host's default for the write.
2407
+ * The host stamps those of them that read where it stores the row, and
2408
+ * its default when none does. */
2409
+ audience?: readonly string[],
2184
2410
  ) => unknown;
2185
2411
  private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
2186
2412
  private getAgentNamesFn: () => string[];
@@ -2188,7 +2414,7 @@ export class EventGate {
2188
2414
  private isHeldFn: (serverId: string | undefined, channelId: string | undefined) => boolean;
2189
2415
  private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined;
2190
2416
  private channelShownFn?: (channel: { serverId: string; channelId: string }) => string;
2191
- private isNewestEntryFn?: (entry: GateEntryRef) => boolean;
2417
+ private isNewestEntryFn?: (entry: GateEntryRef, recipients?: readonly string[]) => boolean;
2192
2418
  /** Clock injection — keeps the new rate_limit / passive_sample paths
2193
2419
  * testable without monkey-patching Date.now globally. */
2194
2420
  private now: () => number;
@@ -2197,13 +2423,14 @@ export class EventGate {
2197
2423
  configPath: string;
2198
2424
  initialConfig?: GateConfig;
2199
2425
  privilegedUsersPath?: string;
2200
- emitTrace: (event: TraceEventLike) => void;
2426
+ emitTrace: (event: TraceEventInput) => void;
2201
2427
  addMessage: (
2202
2428
  participant: string,
2203
2429
  content: Array<{ type: 'text'; text: string }>,
2204
2430
  metadata?: Record<string, unknown>,
2205
2431
  forAgent?: string,
2206
2432
  placement?: WritePlacement,
2433
+ audience?: readonly string[],
2207
2434
  ) => unknown;
2208
2435
  /** `after`: the placement of the message the wake is for, when it has
2209
2436
  * one, so the host can drop the wake once a request that carried the
@@ -2221,6 +2448,8 @@ export class EventGate {
2221
2448
  now?: () => number;
2222
2449
  /** Per-event timeout (ms) for the optional gate.js script. Default 50. */
2223
2450
  scriptTimeoutMs?: number;
2451
+ /** How long a suppress rule's script may take for one event before the event is shown (ms). Default 50. */
2452
+ suppressScriptTimeoutMs?: number;
2224
2453
  /** The reply judge `judge` rules ask (JudgeConfig). Absent: a judge
2225
2454
  * rule's batch is delivered as a debounce's, and gate_status says so. */
2226
2455
  judge?: JudgeFn;
@@ -2251,8 +2480,10 @@ export class EventGate {
2251
2480
  * waiting to be written there. A batched wake for that one message
2252
2481
  * doesn't write its line when it fires: it hands the line to the wake
2253
2482
  * (WakeProvenance.line), for a fresh start of the wake's turn to decide
2254
- * (WakeLine). Absent: every batched wake writes its line, as before. */
2255
- isNewestEntry?: (entry: GateEntryRef) => boolean;
2483
+ * (WakeLine). With `recipients`, the windows are those agents', each of
2484
+ * which the event must be newest in. Absent: every batched wake writes
2485
+ * its line, as before. */
2486
+ isNewestEntry?: (entry: GateEntryRef, recipients?: readonly string[]) => boolean;
2256
2487
  /** The event types the gate can see from a source, when the host knows
2257
2488
  * them (an MCPL server's id: MCPL_GATE_EVENT_TYPES), else undefined.
2258
2489
  * The rule-order notes (findShadowedPolicies) leave out types a rule's
@@ -2260,6 +2491,11 @@ export class EventGate {
2260
2491
  eventTypesFrom?: EventTypesFrom;
2261
2492
  }) {
2262
2493
  this.configPath = opts.configPath;
2494
+ // Before the rules are read: a start's merge can clear a rule's state, its script's included.
2495
+ this.suppressScripts = new SuppressScripts({
2496
+ statePath: join(dirname(opts.configPath), 'suppress-state.json'),
2497
+ ...(opts.suppressScriptTimeoutMs !== undefined ? { timeoutMs: opts.suppressScriptTimeoutMs } : {}),
2498
+ });
2263
2499
  this.privilegedUsersPath = opts.privilegedUsersPath;
2264
2500
  this.emitTrace = opts.emitTrace;
2265
2501
  this.addMessageFn = opts.addMessage;
@@ -2541,7 +2777,6 @@ export class EventGate {
2541
2777
  ...changes,
2542
2778
  // Every name the start added to gate.json, as an append's trace gave.
2543
2779
  addedPolicies: names,
2544
- timestamp: Date.now(),
2545
2780
  });
2546
2781
  }
2547
2782
  if (!appended && (!last.recipe || canonical({ rules: last.recipe.rules, default: last.recipe.default }) !== canonical({ rules: starting.rules.map((rule) => rule.raw), default: given.default }))) {
@@ -2652,7 +2887,6 @@ export class EventGate {
2652
2887
  type: 'gate:config-error',
2653
2888
  error: why,
2654
2889
  configPath: this.configPath,
2655
- timestamp: Date.now(),
2656
2890
  });
2657
2891
  }
2658
2892
  this.configError = problem;
@@ -2798,7 +3032,11 @@ export class EventGate {
2798
3032
  private clearStateFor(was: GateConfig, next: GateConfig): { removed: string[]; cleared: string[] } {
2799
3033
  const behaviorsByName = (config: GateConfig): Map<string, string> => {
2800
3034
  const byName = new Map<string, string[]>();
2801
- for (const p of config.policies) byName.set(p.name, [...(byName.get(p.name) ?? []), behaviorFingerprint(p.behavior)]);
3035
+ for (const p of config.policies) {
3036
+ // A suppress rule's script is part of what it is: a changed script starts from a fresh state.
3037
+ const fingerprint = behaviorFingerprint(p.behavior) + (p.suppress ? ` suppress:${p.suppress.script ?? ''}` : '');
3038
+ byName.set(p.name, [...(byName.get(p.name) ?? []), fingerprint]);
3039
+ }
2802
3040
  return new Map([...byName].map(([name, behaviors]) => [name, JSON.stringify(behaviors)]));
2803
3041
  };
2804
3042
  const before = behaviorsByName(was);
@@ -2851,7 +3089,6 @@ export class EventGate {
2851
3089
  removedPolicies: removedPolicies.length > 0 ? removedPolicies : undefined,
2852
3090
  stateClearedPolicies:
2853
3091
  stateClearedPolicies.length > 0 ? stateClearedPolicies : undefined,
2854
- timestamp: now,
2855
3092
  });
2856
3093
  } catch {
2857
3094
  // The rules in force stay, and the next check tries again.
@@ -2977,7 +3214,6 @@ export class EventGate {
2977
3214
  configPath: this.configPath,
2978
3215
  policyName: policy.name,
2979
3216
  replaced: idx >= 0,
2980
- timestamp: Date.now(),
2981
3217
  });
2982
3218
  if (ignored.length > 0) {
2983
3219
  // A caller that doesn't take the notes still gets them said, in the log.
@@ -3020,7 +3256,6 @@ export class EventGate {
3020
3256
  type: 'gate:policy-removed',
3021
3257
  configPath: this.configPath,
3022
3258
  policyName: name,
3023
- timestamp: Date.now(),
3024
3259
  });
3025
3260
  return true;
3026
3261
  }
@@ -3037,6 +3272,17 @@ export class EventGate {
3037
3272
  return current.policies.map((p) => p.name);
3038
3273
  }
3039
3274
 
3275
+ /** The rules in force, in order (copies): what the last decision was made by. */
3276
+ policiesInForce(): GatePolicy[] {
3277
+ return this.config.policies.map((p) => structuredClone(p));
3278
+ }
3279
+
3280
+ /** The rule in force by this name (a copy), or undefined. */
3281
+ policyInForce(name: string): GatePolicy | undefined {
3282
+ const p = this.config.policies.find((x) => x.name === name);
3283
+ return p ? structuredClone(p) : undefined;
3284
+ }
3285
+
3040
3286
  /** @internal For `wake_add_rule`'s and `wake_remove_rule`'s refusals; not a host API. */
3041
3287
  ruleNamesForChange(): { names: string[]; note: string } | { refused: string } {
3042
3288
  const edit = this.rulesForChange();
@@ -3223,7 +3469,6 @@ export class EventGate {
3223
3469
  matchedPolicy: 'asleep',
3224
3470
  trigger: false,
3225
3471
  behavior: 'skip',
3226
- timestamp: this.now(),
3227
3472
  });
3228
3473
  return { trigger: false, policyName: 'asleep', behavior: 'skip' };
3229
3474
  }
@@ -3246,6 +3491,11 @@ export class EventGate {
3246
3491
  decision = this.computeDecision({ name: 'gate.js', match: {}, behavior: scripted }, info);
3247
3492
  } else {
3248
3493
  for (const compiled of this.compiledPolicies) {
3494
+ // A suppress rule doesn't decide a wake for an event suppression was
3495
+ // asked about: one it takes was suppressed before the gate was asked,
3496
+ // and one that reaches here is one it let by. Where suppression wasn't
3497
+ // asked (no view to hide it from), it defers, as its behavior says.
3498
+ if (compiled.policy.suppress && this.passesOverSuppress(info)) continue;
3249
3499
  if (!this.compiledMatches(compiled, info)) continue;
3250
3500
  const candidate = this.computeDecision(compiled.policy, info);
3251
3501
  if (candidate.trigger || !compiled.policy.passthrough) {
@@ -3281,7 +3531,6 @@ export class EventGate {
3281
3531
  trigger: decision.trigger,
3282
3532
  behavior: formatBehavior(decision.behavior),
3283
3533
  ...(observed.length > 0 ? { observed } : {}),
3284
- timestamp: this.now(),
3285
3534
  });
3286
3535
 
3287
3536
  return decision;
@@ -3306,7 +3555,6 @@ export class EventGate {
3306
3555
  behavior: formatBehavior(policy.behavior),
3307
3556
  eventType: info.eventType,
3308
3557
  source: info.serverId || undefined,
3309
- timestamp: this.now(),
3310
3558
  });
3311
3559
 
3312
3560
  if (policy.behavior === 'defer' || policy.behavior === 'skip') {
@@ -3452,6 +3700,7 @@ export class EventGate {
3452
3700
  private clearPolicyState(name: string): void {
3453
3701
  const debounce = this.debounceTimers.get(name);
3454
3702
  if (debounce) clearTimeout(debounce.timer);
3703
+ this.suppressScripts.forget(name);
3455
3704
  this.rateLimitBuckets.delete(name);
3456
3705
  this.passiveSampleCounters.delete(name);
3457
3706
  this.rateLimitDenied.delete(name);
@@ -3507,6 +3756,7 @@ export class EventGate {
3507
3756
  */
3508
3757
  probe(info: GateEventInfo): GateDecision {
3509
3758
  for (const compiled of this.compiledPolicies) {
3759
+ if (compiled.policy.suppress && this.passesOverSuppress(info)) continue; // as evaluate does
3510
3760
  if (!this.compiledMatches(compiled, info)) continue;
3511
3761
  const trigger = this.wouldTrigger(compiled.policy, info);
3512
3762
  if (trigger || !compiled.policy.passthrough) {
@@ -3517,6 +3767,135 @@ export class EventGate {
3517
3767
  return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
3518
3768
  }
3519
3769
 
3770
+ /**
3771
+ * The suppress rule that takes this event, or null when none does.
3772
+ * Side-effect-free, like probe: no counter moves, no batch forms, nothing
3773
+ * is traced, and a rule's script runs on a copy of its state, which is
3774
+ * then dropped. (A closure a script builds for itself is its own, and a
3775
+ * dry run's timeout counts toward setting a script aside, as it costs the same.) The probe table and other dry runs ask this; the
3776
+ * framework asks decideSuppression instead, once per event.
3777
+ *
3778
+ * It reads the rules as evaluate does, first match deciding: a rule ahead
3779
+ * of the suppress rule that matches the event decides it instead (it isn't
3780
+ * suppressed), unless it's a passthrough rule that wouldn't fire. A rule
3781
+ * with a script (`suppress.script`) takes an event its match takes only
3782
+ * when the script says to hide it; otherwise the event goes on to the rules
3783
+ * after it, as one the rule doesn't match does. Sleep and gate.js aren't
3784
+ * consulted: they decide wakes, and suppression decides what's rendered.
3785
+ *
3786
+ * Never suppressed, whatever the rules say: an event addressed to the
3787
+ * agent (tagged `chat:addressed`: a mention, a reply, a DM) and the
3788
+ * heartbeat's (the agent's own clock). A suppress rule lets them by to the
3789
+ * rules after it, and its script isn't asked. The framework itself never
3790
+ * asks about console or API input.
3791
+ */
3792
+ suppressionFor(info: GateEventInfo): string | null {
3793
+ return this.walkSuppression(info, false);
3794
+ }
3795
+
3796
+ /**
3797
+ * suppressionFor, for an event that is arriving now: each script asked
3798
+ * keeps the state it leaves, whatever later rules do with the event, and a
3799
+ * suppressed event counts in the passthrough rules ahead of its rule. Ask
3800
+ * once per event.
3801
+ */
3802
+ decideSuppression(info: GateEventInfo): string | null {
3803
+ return this.walkSuppression(info, true);
3804
+ }
3805
+
3806
+ private walkSuppression(info: GateEventInfo, commit: boolean): string | null {
3807
+ this.reloadIfChanged();
3808
+ this.syncSuppressScripts();
3809
+ if (info.serverId === 'heartbeat') return null;
3810
+ const addressed = Array.isArray(info.tags) && info.tags.includes('chat:addressed');
3811
+ let event: SuppressScriptEvent | undefined;
3812
+ const observers: GatePolicy[] = [];
3813
+ for (const compiled of this.compiledPolicies) {
3814
+ if (!this.compiledMatches(compiled, info)) continue;
3815
+ const suppress = compiled.policy.suppress;
3816
+ if (suppress) {
3817
+ if (addressed) continue;
3818
+ if (suppress.script !== undefined) {
3819
+ event ??= this.suppressScriptEvent(info);
3820
+ const ran = this.suppressScripts.run(compiled.policy.name, event, commit);
3821
+ // A script that failed shows the event: it goes on to the rules after.
3822
+ if (!ran || !('hide' in ran) || !ran.hide) continue;
3823
+ }
3824
+ // A suppressed event never reaches evaluate, so the passthrough rules
3825
+ // ahead of this one count it here, as evaluate would have: none of
3826
+ // them fires for it, and each fires on its schedule. A dry run counts
3827
+ // nothing.
3828
+ if (commit) for (const observer of observers) this.computeDecision(observer, info);
3829
+ return compiled.policy.name;
3830
+ }
3831
+ if (compiled.policy.passthrough && !this.wouldTrigger(compiled.policy, info)) { observers.push(compiled.policy); continue; }
3832
+ return null;
3833
+ }
3834
+ return null;
3835
+ }
3836
+
3837
+ /** Whether evaluate and probe pass over a suppress rule for this event: suppression was asked about it, or it's one suppression always lets by. */
3838
+ private passesOverSuppress(info: GateEventInfo): boolean {
3839
+ return info.suppressionAsked === true || info.serverId === 'heartbeat'
3840
+ || (Array.isArray(info.tags) && info.tags.includes('chat:addressed'));
3841
+ }
3842
+
3843
+ /** The scripts in step with the rules in force (SuppressScripts.sync: only changed ones are compiled). */
3844
+ private syncSuppressScripts(): void {
3845
+ this.suppressScripts.sync(this.config.policies.map((p) => ({ name: p.name, ...(p.suppress?.script !== undefined ? { script: p.suppress.script } : {}) })));
3846
+ }
3847
+
3848
+ /** What a suppress script is given for an event: plain JSON, its metadata included where it can be. */
3849
+ private suppressScriptEvent(info: GateEventInfo): SuppressScriptEvent {
3850
+ let metadata: Record<string, unknown> = {};
3851
+ try { metadata = JSON.parse(JSON.stringify(info.metadata ?? {})) as Record<string, unknown>; } catch { metadata = {}; }
3852
+ const channel = this.routeChannelFor(info) ?? (info.channelId || undefined);
3853
+ const author = this.extractAuthorId(info.metadata);
3854
+ return {
3855
+ eventType: info.eventType,
3856
+ source: info.serverId,
3857
+ ...(channel ? { channel } : {}),
3858
+ ...(author ? { author } : {}),
3859
+ tags: [...(info.tags ?? [])],
3860
+ text: info.content,
3861
+ at: this.now(),
3862
+ metadata,
3863
+ };
3864
+ }
3865
+
3866
+ /**
3867
+ * Whether a suppress script compiles to a function, on the scripts' worker:
3868
+ * an error's text, or null. wake_add_rule asks before it saves a rule.
3869
+ */
3870
+ checkSuppressScript(script: string): string | null {
3871
+ return this.suppressScripts.check(script);
3872
+ }
3873
+
3874
+ /** A suppress rule's script state as it stands (a copy), for a host or a test. */
3875
+ suppressScriptState(name: string): Record<string, unknown> | undefined {
3876
+ this.syncSuppressScripts();
3877
+ return this.suppressScripts.stateOf(name);
3878
+ }
3879
+
3880
+ /**
3881
+ * Count a row the framework suppressed by `name` (suppressionFor's
3882
+ * answer) once it's written. The rule's stats count it as a match, so
3883
+ * gate_status shows how many events each suppress rule took since start.
3884
+ */
3885
+ noteSuppressed(name: string, info: Pick<GateEventInfo, 'eventType' | 'serverId' | 'channelId'>): void {
3886
+ const policyStats = this.stats.get(name) ?? { matchCount: 0, lastMatchTimestamp: null };
3887
+ policyStats.matchCount++;
3888
+ policyStats.lastMatchTimestamp = this.now();
3889
+ this.stats.set(name, policyStats);
3890
+ this.emitTrace({
3891
+ type: 'gate:suppressed',
3892
+ policyName: name,
3893
+ eventType: info.eventType,
3894
+ ...(info.serverId ? { serverId: info.serverId } : {}),
3895
+ ...(info.channelId ? { channelId: info.channelId } : {}),
3896
+ });
3897
+ }
3898
+
3520
3899
  /** The canonical synthetic events the probe table runs: chat shapes as MCPL delivers them, and a heartbeat. */
3521
3900
  private probeEvents(): Array<{ label: string; info: GateEventInfo }> {
3522
3901
  const chat = (
@@ -3592,7 +3971,12 @@ export class EventGate {
3592
3971
  */
3593
3972
  probeTable(): GateProbeRow[] {
3594
3973
  return this.probeEvents().map(({ label, info }) => {
3595
- const d = this.probe(info);
3974
+ const suppressedBy = this.suppressionFor(info);
3975
+ if (suppressedBy !== null) {
3976
+ return { probe: label, eventType: info.eventType, policy: suppressedBy, behavior: 'suppress', wouldWake: false };
3977
+ }
3978
+ // As admission asks: suppression first, so a script that says show lets the probe on.
3979
+ const d = this.probe({ ...info, suppressionAsked: true });
3596
3980
  return {
3597
3981
  probe: label,
3598
3982
  eventType: info.eventType,
@@ -3642,7 +4026,6 @@ export class EventGate {
3642
4026
  type: 'gate:shadow-warnings',
3643
4027
  reason,
3644
4028
  warnings: warnings.map(formatShadowWarning),
3645
- timestamp: this.now(),
3646
4029
  });
3647
4030
  }
3648
4031
  }
@@ -3702,9 +4085,11 @@ export class EventGate {
3702
4085
  authorId: this.extractAuthorId(info.metadata) ?? undefined,
3703
4086
  serverId: info.serverId || undefined,
3704
4087
  addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'),
4088
+ ...(isSystemNotice(info.tags) && !info.folds ? { systemNotice: true as const } : {}),
3705
4089
  routeChannelId: this.routeChannelFor(info),
3706
4090
  ...(typeof info.metadata?.messageId === 'string' ? { messageId: info.metadata.messageId } : {}),
3707
4091
  ...(typeof info.metadata?.eventId === 'string' ? { eventId: info.metadata.eventId } : {}),
4092
+ ...(info.recipients !== undefined ? { recipients: [...info.recipients] } : {}),
3708
4093
  };
3709
4094
 
3710
4095
  const existing = this.debounceTimers.get(policy.name);
@@ -3764,8 +4149,8 @@ export class EventGate {
3764
4149
  } catch (err) {
3765
4150
  const one = events.length === 1;
3766
4151
  console.error(
3767
- `[gate] Rule ${quotedValue(policyName)}: its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3768
- + `(${errorText(err)}), so the batch is dropped`,
4152
+ `[gate] Rule ${quotedValue(policyName)}: its pending batch of ${events.length} ${one ? 'event' : 'events'} `
4153
+ + (deliveredInPart(err) ?? `couldn't be delivered (${errorText(err)}), so the batch is dropped`),
3769
4154
  );
3770
4155
  return;
3771
4156
  }
@@ -3774,7 +4159,6 @@ export class EventGate {
3774
4159
  type: 'gate:debounce-delivered',
3775
4160
  policyName,
3776
4161
  eventCount: events.length,
3777
- timestamp: Date.now(),
3778
4162
  });
3779
4163
  }
3780
4164
 
@@ -3796,11 +4180,34 @@ export class EventGate {
3796
4180
  type: 'gate:held-dropped',
3797
4181
  dropped: batched.length - events.length,
3798
4182
  channels: [...new Set(batched.filter((e) => !events.includes(e)).map(heldAt))],
3799
- timestamp: this.now(),
3800
4183
  });
3801
4184
  }
3802
4185
  if (events.length === 0) return;
4186
+ // Who each delivery woke, so that a throw after a wake is told as a
4187
+ // delivery in part (PartialDelivery), the batch dropped only for those
4188
+ // it didn't wake.
4189
+ const woke: string[] = [];
4190
+ if (events.every((e) => e.recipients === undefined)) {
4191
+ const names = this.getAgentNamesFn();
4192
+ deliveredTracking(woke, names, () => this.deliverBatch(events, names, undefined, woke));
4193
+ return;
4194
+ }
4195
+ const woken = new Set(events.flatMap((e) => e.recipients ?? []));
4196
+ if (events.some((e) => e.recipients === undefined)) for (const name of this.getAgentNamesFn()) woken.add(name);
4197
+ const groups = new Map<string, { agents: string[]; events: PendingEvent[] }>();
4198
+ for (const agentName of woken) {
4199
+ const seen = events.filter((e) => e.recipients === undefined || e.recipients.includes(agentName));
4200
+ const key = seen.map((e) => events.indexOf(e)).join(',');
4201
+ const group = groups.get(key) ?? { agents: [], events: seen };
4202
+ group.agents.push(agentName);
4203
+ groups.set(key, group);
4204
+ }
4205
+ deliveredTracking(woke, [...woken], () => {
4206
+ for (const group of groups.values()) this.deliverBatch(group.events, group.agents, group.agents, woke);
4207
+ });
4208
+ }
3803
4209
 
4210
+ private deliverBatch(events: PendingEvent[], woken: readonly string[], audience?: readonly string[], woke?: string[]): void {
3804
4211
  const policyNames = [...new Set(events.map(e => e.policyName))];
3805
4212
 
3806
4213
  // Events whose content the framework already stored at arrival (MCPL
@@ -3859,7 +4266,12 @@ export class EventGate {
3859
4266
  const text = `[${NOTICE_TAG.gate}: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
3860
4267
  const only = events.length === 1 && events[0].inContext ? events[0] : undefined;
3861
4268
  let line: WakeLine | undefined;
3862
- if (only && this.newestEntry(only)) {
4269
+ // Where a line stored now landed, for the delivery agent's wake, as the
4270
+ // self-wake's notice places its own (readsRowFor): a live turn that
4271
+ // carried and answered the line drops the wake. Other agents' wakes stay
4272
+ // plain by readsRowFor's rule.
4273
+ const placement: WritePlacement = {};
4274
+ if (only && this.newestEntry(only, audience)) {
3863
4275
  const shownChannel = only.routeChannelId ?? only.channelId;
3864
4276
  line = {
3865
4277
  entry: entryRef(only),
@@ -3872,30 +4284,32 @@ export class EventGate {
3872
4284
  this.addMessageFn('user', [{ type: 'text', text }], {
3873
4285
  source: 'gate:debounce',
3874
4286
  policies: policyNames,
3875
- });
4287
+ }, undefined, placement, audience);
3876
4288
  }
3877
4289
 
3878
4290
  // Provenance of the batched wake (who/where woke the agent): the newest
3879
4291
  // ADDRESSED event first, else the newest event naming a channel or an
3880
- // author; channel + author + addressed from that ONE event. When that
3881
- // event addressed the agent and its registered channel is known,
3882
- // `routeChannelId` names the turn's speech locus too — the reply goes
3883
- // back to whoever addressed the agent, not to whichever channel last saw
3884
- // any traffic. Ambient-only batches set no locus. A batch whose line the
3885
- // gate left to the turn's start carries it too (`line`), one object
3886
- // shared by every agent the batch wakes, so it is written once.
4292
+ // author, `chat:system` notices aside; channel + author + addressed from
4293
+ // that ONE event. When that event addressed the agent and its registered
4294
+ // channel is known, `routeChannelId` names the turn's speech locus too —
4295
+ // the reply goes back to whoever addressed the agent, not to whichever
4296
+ // channel last saw any traffic. Ambient-only batches set no locus, and
4297
+ // name the channels they came from (`batchChannels`). A batch whose line
4298
+ // the gate left to the turn's start carries it too (`line`), one object
4299
+ // shared by every agent woken with these messages, so it is written once.
3887
4300
  const chosen = wakeProvenance(events);
3888
4301
  const provenance = line ? { ...chosen, line } : chosen;
3889
- for (const agentName of this.getAgentNamesFn()) {
3890
- this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance);
4302
+ for (const agentName of woken) {
4303
+ this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance, line ? undefined : placement);
4304
+ woke?.push(agentName);
3891
4305
  }
3892
4306
  }
3893
4307
 
3894
4308
  /** isNewestEntry for one event; a callback fault keeps the line. */
3895
- private newestEntry(event: PendingEvent): boolean {
4309
+ private newestEntry(event: PendingEvent, recipients?: readonly string[]): boolean {
3896
4310
  if (!this.isNewestEntryFn) return false;
3897
4311
  try {
3898
- return this.isNewestEntryFn(entryRef(event)) === true;
4312
+ return this.isNewestEntryFn(entryRef(event), recipients) === true;
3899
4313
  } catch {
3900
4314
  return false;
3901
4315
  }
@@ -3918,7 +4332,7 @@ export class EventGate {
3918
4332
  if (this.disposed) return;
3919
4333
  const deciding = this.judging.get(policyName);
3920
4334
  if (deciding) {
3921
- deciding.queued.push(...events);
4335
+ appendAll(deciding.queued, events);
3922
4336
  this.capJudgeEvents(deciding.queued);
3923
4337
  return;
3924
4338
  }
@@ -3937,8 +4351,8 @@ export class EventGate {
3937
4351
  this.judging.set(policyName, state);
3938
4352
  const timedOut = new Promise<never>((_, reject) => {
3939
4353
  state.timer = setTimeout(() => {
4354
+ reject(new JudgeFailure(`the judge took more than ${this.judgeTimeoutMs} ms`));
3940
4355
  abort.abort();
3941
- reject(new Error(`the judge took more than ${this.judgeTimeoutMs} ms`));
3942
4356
  }, this.judgeTimeoutMs);
3943
4357
  // Never keep the process alive on the judge's account.
3944
4358
  state.timer.unref?.();
@@ -3971,7 +4385,7 @@ export class EventGate {
3971
4385
  private holdForJudge(policyName: string, events: PendingEvent[], judge: JudgeConfig): void {
3972
4386
  const held = this.judgeHeld.get(policyName);
3973
4387
  if (held) {
3974
- held.events.push(...events);
4388
+ appendAll(held.events, events);
3975
4389
  held.judge = judge;
3976
4390
  this.capJudgeEvents(held.events);
3977
4391
  } else {
@@ -4014,7 +4428,8 @@ export class EventGate {
4014
4428
  * the judge to weigh what waiting costs: each agent's pending self-wake
4015
4429
  * (skip_reply's wake_in_seconds), the end of a sleep, and the pending
4016
4430
  * batches of rules other than `exceptPolicy` (a debounce's wakes the agent
4017
- * when it fires; a judge rule's goes to the judge, so it only may).
4431
+ * when it fires; a judge rule's goes to the judge, so it only may). A batch,
4432
+ * and a sleep with no agent named, reach the gate's recipients alone.
4018
4433
  */
4019
4434
  upcomingWakes(agentNames: string[], exceptPolicy?: string): UpcomingWake[] {
4020
4435
  const wakes: UpcomingWake[] = [];
@@ -4022,19 +4437,21 @@ export class EventGate {
4022
4437
  for (const name of agentNames) {
4023
4438
  const due = this.selfWakeDue.get(name);
4024
4439
  if (due && this.selfWakeTimers.has(name)) {
4025
- wakes.push({ at: due.at, certain: true, what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
4440
+ wakes.push({ at: due.at, certain: true, kind: 'self-wake', what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
4026
4441
  }
4027
4442
  }
4028
- if (this.sleepUntil > now && (this.sleepAgent === undefined || agentNames.includes(this.sleepAgent))) {
4029
- wakes.push({ at: this.sleepUntil, certain: true, what: 'the end of its sleep' });
4443
+ const recipients = this.getAgentNamesFn();
4444
+ const recipient = agentNames.some((name) => recipients.includes(name));
4445
+ if (this.sleepUntil > now && (this.sleepAgent === undefined ? recipient : agentNames.includes(this.sleepAgent))) {
4446
+ wakes.push({ at: this.sleepUntil, certain: true, kind: 'sleep-end', what: 'the end of its sleep' });
4030
4447
  }
4031
4448
  for (const [name, state] of this.debounceTimers) {
4032
- if (name === exceptPolicy || state.events.length === 0) continue;
4449
+ if (!recipient || name === exceptPolicy || state.events.length === 0) continue;
4033
4450
  const n = state.events.length;
4034
4451
  const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
4035
4452
  wakes.push(state.judge
4036
- ? { at: state.dueAt, certain: false, what: `rule ${quotedValue(name)}'s batch of ${batch} goes to the judge` }
4037
- : { at: state.dueAt, certain: true, what: `rule ${quotedValue(name)}'s debounced batch of ${batch}` });
4453
+ ? { at: state.dueAt, certain: false, kind: 'judge-batch', what: `rule ${quotedValue(name)}'s batch of ${batch} goes to the judge` }
4454
+ : { at: state.dueAt, certain: true, kind: 'debounce', what: `rule ${quotedValue(name)}'s debounced batch of ${batch}` });
4038
4455
  }
4039
4456
  return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
4040
4457
  }
@@ -4094,7 +4511,7 @@ export class EventGate {
4094
4511
  }
4095
4512
  const outcome: 'reply' | 'wait' | 'error' = failed ? 'error' : verdict.reply ? 'reply' : 'wait';
4096
4513
  const reason = failed
4097
- ? (err !== undefined ? errorText(err) : 'the judge gave no verdict')
4514
+ ? (err !== undefined ? judgeFailureWords(err) : 'the judge gave no verdict')
4098
4515
  : typeof verdict.reason === 'string' ? verdict.reason : '';
4099
4516
  const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0 } };
4100
4517
  record.verdicts[outcome]++;
@@ -4116,7 +4533,7 @@ export class EventGate {
4116
4533
  const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
4117
4534
  if (failed) {
4118
4535
  console.error(
4119
- `[gate] Rule ${quotedValue(policyName)}: the reply judge failed (${reason}), so its batch of ${state.events.length} `
4536
+ `[gate] Rule ${quotedValue(policyName)}: the reply judge failed (${err !== undefined ? errorText(err) : reason}), so its batch of ${state.events.length} `
4120
4537
  + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
4121
4538
  );
4122
4539
  }
@@ -4131,7 +4548,6 @@ export class EventGate {
4131
4548
  ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
4132
4549
  ...(!failed && typeof verdict.cacheReadTokens === 'number' ? { cacheReadTokens: verdict.cacheReadTokens } : {}),
4133
4550
  ...(!failed && typeof verdict.outputTokens === 'number' ? { outputTokens: verdict.outputTokens } : {}),
4134
- timestamp: Date.now(),
4135
4551
  });
4136
4552
  if (this.disposed) return;
4137
4553
  const judge = this.currentJudge(policyName, state.judge);
@@ -4156,8 +4572,8 @@ export class EventGate {
4156
4572
  } catch (err) {
4157
4573
  const one = events.length === 1;
4158
4574
  console.error(
4159
- `[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
4160
- + `(${errorText(err)}), so the batch is dropped`,
4575
+ `[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} `
4576
+ + (deliveredInPart(err) ?? `couldn't be delivered (${errorText(err)}), so the batch is dropped`),
4161
4577
  );
4162
4578
  }
4163
4579
  }
@@ -4231,8 +4647,8 @@ export class EventGate {
4231
4647
  * buffer through the same path a turn's end uses. Self-wake and sleep-expiry
4232
4648
  * timers deliberately do NOT route through this — their inference requests
4233
4649
  * are per-agent targeted pushes that the framework's quiesce wake gate parks
4234
- * and releases at resume, whereas the buffer's deliverEvents broadcasts a
4235
- * wake to every agent.
4650
+ * and releases at resume, whereas the buffer's deliverEvents wakes every
4651
+ * agent a batch is for.
4236
4652
  */
4237
4653
  setQuiesced(quiesced: boolean): void {
4238
4654
  if (this.quiesced === quiesced) return;
@@ -4260,10 +4676,12 @@ export class EventGate {
4260
4676
  this.deliverEvents(events);
4261
4677
  } catch (err) {
4262
4678
  const one = events.length === 1;
4263
- console.error(
4264
- `[gate] ${events.length} ${one ? 'event' : 'events'} held for an agent's turn or the host's quiesce couldn't be `
4265
- + `delivered (${errorText(err)}), so ${one ? "it's" : "they're"} dropped`,
4266
- );
4679
+ const held = `${events.length} ${one ? 'event' : 'events'}`;
4680
+ const partly = deliveredInPart(err);
4681
+ console.error(partly
4682
+ ? `[gate] a batch of ${held}, held for an agent's turn or the host's quiesce, ${partly}`
4683
+ : `[gate] ${held} held for an agent's turn or the host's quiesce couldn't be delivered (${errorText(err)}), `
4684
+ + `so ${one ? "it's" : "they're"} dropped`);
4267
4685
  }
4268
4686
  });
4269
4687
  }
@@ -4347,7 +4765,6 @@ export class EventGate {
4347
4765
  matchedPolicy: 'asleep',
4348
4766
  trigger: false,
4349
4767
  behavior: 'skip',
4350
- timestamp: this.now(),
4351
4768
  });
4352
4769
  return { until: this.sleepUntil };
4353
4770
  }
@@ -4405,13 +4822,13 @@ export class EventGate {
4405
4822
  matchedPolicy: 'self-wake',
4406
4823
  trigger: true,
4407
4824
  behavior: 'always',
4408
- timestamp: this.now(),
4409
4825
  });
4410
4826
  // Compact in-window notice: without it the wake turn contains nothing
4411
4827
  // new, so the agent can't tell WHAT woke it (its own timer vs. a
4412
4828
  // heartbeat) or WHEN. One line, timestamped to the second. Added
4413
- // before the inference request so it rides the wake turn (addMessage's
4414
- // turn-alive guard defers it to that turn's start if needed).
4829
+ // before the inference request so it rides the wake turn: it is stored
4830
+ // at once, and while a turn of the agent's is alive it is kept out of
4831
+ // the window until the agent's next presentation point.
4415
4832
  const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
4416
4833
  // Deliver the notice into the WAKING agent's window — a self-wake
4417
4834
  // armed by a non-primary agent (e.g. a subconscious cadence) must not
@@ -4440,7 +4857,6 @@ export class EventGate {
4440
4857
  matchedPolicy: 'self-wake',
4441
4858
  trigger: false,
4442
4859
  behavior: 'skip',
4443
- timestamp: this.now(),
4444
4860
  });
4445
4861
  return { inMs: ms, armed: true };
4446
4862
  }
@@ -4483,7 +4899,6 @@ export class EventGate {
4483
4899
  matchedPolicy: 'asleep',
4484
4900
  trigger: true,
4485
4901
  behavior: 'always',
4486
- timestamp: this.now(),
4487
4902
  });
4488
4903
  const detail = note ? `${reason}: ${note}` : reason;
4489
4904
  const targets = agent ? [agent] : this.getAgentNamesFn();
@@ -4613,6 +5028,7 @@ export class EventGate {
4613
5028
  }
4614
5029
 
4615
5030
  getStatus(): GateStatus {
5031
+ this.syncSuppressScripts();
4616
5032
  const policies: GatePolicyStats[] = this.config.policies.map(p => {
4617
5033
  const stats = this.stats.get(p.name);
4618
5034
  const debounce = this.debounceTimers.get(p.name);
@@ -4621,6 +5037,9 @@ export class EventGate {
4621
5037
  behavior: p.behavior,
4622
5038
  matchCount: stats?.matchCount ?? 0,
4623
5039
  lastMatchTimestamp: stats?.lastMatchTimestamp ?? null,
5040
+ ...(p.suppress
5041
+ ? { suppress: { ...p.suppress, ...(p.suppress.script !== undefined && this.suppressScripts.status(p.name) ? { scriptStatus: this.suppressScripts.status(p.name)! } : {}) } }
5042
+ : {}),
4624
5043
  };
4625
5044
  if (typeof p.behavior === 'object' && 'debounce' in p.behavior) {
4626
5045
  result.debounceState = {
@@ -4684,6 +5103,13 @@ export class EventGate {
4684
5103
  // Fresh, not the cache: the sources the host knows (eventTypesFrom)
4685
5104
  // change as servers connect, with no change to the rules.
4686
5105
  shadowWarnings: this.lintShadows().map(formatShadowWarning),
5106
+ ...((): Pick<GateStatus, 'catchAll'> => {
5107
+ const rule = firstCatchAll(this.config.policies, { lettingBy: true });
5108
+ return rule ? { catchAll: {
5109
+ rule,
5110
+ note: `${JSON.stringify(rule)} lets MCPL events by without waking you and without naming a server, a channel or a tag, so a server connected later is handled by it until a rule for that server goes ahead of it.`,
5111
+ } } : {};
5112
+ })(),
4687
5113
  totalEvaluations: this.totalEvaluations,
4688
5114
  defaultDecisions: {
4689
5115
  triggered: this.defaultTriggered,
@@ -4720,6 +5146,7 @@ export class EventGate {
4720
5146
 
4721
5147
  dispose(): void {
4722
5148
  this.gateScript.dispose();
5149
+ this.suppressScripts.dispose();
4723
5150
  for (const state of this.debounceTimers.values()) {
4724
5151
  clearTimeout(state.timer);
4725
5152
  }