@animalabs/agent-framework 0.31.0 → 0.33.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 (298) hide show
  1. package/README.md +204 -14
  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 +185 -78
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +279 -102
  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/code-execution/isolate/scripts.d.ts +12 -8
  17. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/scripts.js +24 -0
  19. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  20. package/dist/src/config-checks.d.ts +7 -0
  21. package/dist/src/config-checks.d.ts.map +1 -1
  22. package/dist/src/config-checks.js +14 -0
  23. package/dist/src/config-checks.js.map +1 -1
  24. package/dist/src/context-manager-errors.d.ts +25 -0
  25. package/dist/src/context-manager-errors.d.ts.map +1 -0
  26. package/dist/src/context-manager-errors.js +31 -0
  27. package/dist/src/context-manager-errors.js.map +1 -0
  28. package/dist/src/context-options.d.ts +67 -0
  29. package/dist/src/context-options.d.ts.map +1 -0
  30. package/dist/src/context-options.js +85 -0
  31. package/dist/src/context-options.js.map +1 -0
  32. package/dist/src/deferred-import.d.ts +93 -0
  33. package/dist/src/deferred-import.d.ts.map +1 -0
  34. package/dist/src/deferred-import.js +177 -0
  35. package/dist/src/deferred-import.js.map +1 -0
  36. package/dist/src/dendrite/audience.d.ts +223 -0
  37. package/dist/src/dendrite/audience.d.ts.map +1 -0
  38. package/dist/src/dendrite/audience.js +202 -0
  39. package/dist/src/dendrite/audience.js.map +1 -0
  40. package/dist/src/dendrite/index.d.ts +5 -3
  41. package/dist/src/dendrite/index.d.ts.map +1 -1
  42. package/dist/src/dendrite/index.js +2 -1
  43. package/dist/src/dendrite/index.js.map +1 -1
  44. package/dist/src/dendrite/presets.d.ts +10 -5
  45. package/dist/src/dendrite/presets.d.ts.map +1 -1
  46. package/dist/src/dendrite/presets.js +17 -5
  47. package/dist/src/dendrite/presets.js.map +1 -1
  48. package/dist/src/dendrite/registry.d.ts +62 -7
  49. package/dist/src/dendrite/registry.d.ts.map +1 -1
  50. package/dist/src/dendrite/registry.js +179 -10
  51. package/dist/src/dendrite/registry.js.map +1 -1
  52. package/dist/src/dendrite/types.d.ts +91 -8
  53. package/dist/src/dendrite/types.d.ts.map +1 -1
  54. package/dist/src/foreign-rounds.d.ts +69 -0
  55. package/dist/src/foreign-rounds.d.ts.map +1 -0
  56. package/dist/src/foreign-rounds.js +152 -0
  57. package/dist/src/foreign-rounds.js.map +1 -0
  58. package/dist/src/framework.d.ts +1977 -820
  59. package/dist/src/framework.d.ts.map +1 -1
  60. package/dist/src/framework.js +7571 -3457
  61. package/dist/src/framework.js.map +1 -1
  62. package/dist/src/gate/event-gate.d.ts +85 -15
  63. package/dist/src/gate/event-gate.d.ts.map +1 -1
  64. package/dist/src/gate/event-gate.js +421 -47
  65. package/dist/src/gate/event-gate.js.map +1 -1
  66. package/dist/src/gate/index.d.ts +1 -1
  67. package/dist/src/gate/index.d.ts.map +1 -1
  68. package/dist/src/gate/index.js.map +1 -1
  69. package/dist/src/gate/rule-scope.d.ts +30 -0
  70. package/dist/src/gate/rule-scope.d.ts.map +1 -0
  71. package/dist/src/gate/rule-scope.js +41 -0
  72. package/dist/src/gate/rule-scope.js.map +1 -0
  73. package/dist/src/gate/suppress-script-worker.d.ts +2 -0
  74. package/dist/src/gate/suppress-script-worker.d.ts.map +1 -0
  75. package/dist/src/gate/suppress-script-worker.js +103 -0
  76. package/dist/src/gate/suppress-script-worker.js.map +1 -0
  77. package/dist/src/gate/suppress-scripts.d.ts +105 -0
  78. package/dist/src/gate/suppress-scripts.d.ts.map +1 -0
  79. package/dist/src/gate/suppress-scripts.js +403 -0
  80. package/dist/src/gate/suppress-scripts.js.map +1 -0
  81. package/dist/src/gate/suppression-review.d.ts +143 -0
  82. package/dist/src/gate/suppression-review.d.ts.map +1 -0
  83. package/dist/src/gate/suppression-review.js +463 -0
  84. package/dist/src/gate/suppression-review.js.map +1 -0
  85. package/dist/src/gate/suppression-stats.d.ts +125 -0
  86. package/dist/src/gate/suppression-stats.d.ts.map +1 -0
  87. package/dist/src/gate/suppression-stats.js +214 -0
  88. package/dist/src/gate/suppression-stats.js.map +1 -0
  89. package/dist/src/gate/types.d.ts +135 -7
  90. package/dist/src/gate/types.d.ts.map +1 -1
  91. package/dist/src/gate/wake-onboarding.d.ts +119 -0
  92. package/dist/src/gate/wake-onboarding.d.ts.map +1 -0
  93. package/dist/src/gate/wake-onboarding.js +277 -0
  94. package/dist/src/gate/wake-onboarding.js.map +1 -0
  95. package/dist/src/held-writes.d.ts +31 -23
  96. package/dist/src/held-writes.d.ts.map +1 -1
  97. package/dist/src/held-writes.js +44 -59
  98. package/dist/src/held-writes.js.map +1 -1
  99. package/dist/src/index.d.ts +5 -2
  100. package/dist/src/index.d.ts.map +1 -1
  101. package/dist/src/index.js +3 -0
  102. package/dist/src/index.js.map +1 -1
  103. package/dist/src/jsonrpc-envelope.d.ts +69 -0
  104. package/dist/src/jsonrpc-envelope.d.ts.map +1 -0
  105. package/dist/src/jsonrpc-envelope.js +66 -0
  106. package/dist/src/jsonrpc-envelope.js.map +1 -0
  107. package/dist/src/mcpl/channel-registry.d.ts +131 -15
  108. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  109. package/dist/src/mcpl/channel-registry.js +290 -46
  110. package/dist/src/mcpl/channel-registry.js.map +1 -1
  111. package/dist/src/mcpl/checkpoint-manager.d.ts +2 -4
  112. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  113. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  114. package/dist/src/mcpl/feature-set-manager.d.ts +2 -4
  115. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  116. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  117. package/dist/src/mcpl/inbound-source.d.ts +2 -1
  118. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  119. package/dist/src/mcpl/inbound-source.js +2 -1
  120. package/dist/src/mcpl/inbound-source.js.map +1 -1
  121. package/dist/src/mcpl/inference-router.d.ts +6 -6
  122. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  123. package/dist/src/mcpl/inference-router.js.map +1 -1
  124. package/dist/src/mcpl/push-coalescer.d.ts +132 -22
  125. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  126. package/dist/src/mcpl/push-coalescer.js +105 -15
  127. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  128. package/dist/src/mcpl/push-handler.d.ts +9 -7
  129. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  130. package/dist/src/mcpl/push-handler.js.map +1 -1
  131. package/dist/src/mcpl/server-connection.d.ts +13 -10
  132. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  133. package/dist/src/mcpl/server-connection.js +134 -56
  134. package/dist/src/mcpl/server-connection.js.map +1 -1
  135. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  136. package/dist/src/mcpl/tool-classes.js +3 -0
  137. package/dist/src/mcpl/tool-classes.js.map +1 -1
  138. package/dist/src/mcpl/types.d.ts +8 -2
  139. package/dist/src/mcpl/types.d.ts.map +1 -1
  140. package/dist/src/mcpl/types.js.map +1 -1
  141. package/dist/src/module-registry.d.ts +24 -1
  142. package/dist/src/module-registry.d.ts.map +1 -1
  143. package/dist/src/module-registry.js +68 -18
  144. package/dist/src/module-registry.js.map +1 -1
  145. package/dist/src/modules/discord/index.d.ts +45 -8
  146. package/dist/src/modules/discord/index.d.ts.map +1 -1
  147. package/dist/src/modules/discord/index.js +237 -86
  148. package/dist/src/modules/discord/index.js.map +1 -1
  149. package/dist/src/modules/history/index.d.ts +59 -0
  150. package/dist/src/modules/history/index.d.ts.map +1 -1
  151. package/dist/src/modules/history/index.js +432 -67
  152. package/dist/src/modules/history/index.js.map +1 -1
  153. package/dist/src/modules/history/semantic.d.ts +6 -5
  154. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  155. package/dist/src/modules/history/semantic.js +15 -22
  156. package/dist/src/modules/history/semantic.js.map +1 -1
  157. package/dist/src/modules/workspace/disk-agreement.d.ts +1 -1
  158. package/dist/src/modules/workspace/disk-agreement.js +1 -1
  159. package/dist/src/modules/workspace/index.d.ts +28 -7
  160. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/index.js +76 -22
  162. package/dist/src/modules/workspace/index.js.map +1 -1
  163. package/dist/src/notice-tags.d.ts +10 -1
  164. package/dist/src/notice-tags.d.ts.map +1 -1
  165. package/dist/src/notice-tags.js +10 -1
  166. package/dist/src/notice-tags.js.map +1 -1
  167. package/dist/src/operator-log.d.ts +36 -3
  168. package/dist/src/operator-log.d.ts.map +1 -1
  169. package/dist/src/operator-log.js +2 -2
  170. package/dist/src/operator-log.js.map +1 -1
  171. package/dist/src/presentation.d.ts +746 -0
  172. package/dist/src/presentation.d.ts.map +1 -0
  173. package/dist/src/presentation.js +1435 -0
  174. package/dist/src/presentation.js.map +1 -0
  175. package/dist/src/prose-drafts.d.ts +15 -11
  176. package/dist/src/prose-drafts.d.ts.map +1 -1
  177. package/dist/src/prose-drafts.js +16 -12
  178. package/dist/src/prose-drafts.js.map +1 -1
  179. package/dist/src/read-marks.d.ts +192 -0
  180. package/dist/src/read-marks.d.ts.map +1 -0
  181. package/dist/src/read-marks.js +242 -0
  182. package/dist/src/read-marks.js.map +1 -0
  183. package/dist/src/recovery/offline-branch.d.ts +91 -6
  184. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  185. package/dist/src/recovery/offline-branch.js +273 -29
  186. package/dist/src/recovery/offline-branch.js.map +1 -1
  187. package/dist/src/recovery/recover-cli.js +22 -3
  188. package/dist/src/recovery/recover-cli.js.map +1 -1
  189. package/dist/src/send-tools.d.ts +5 -9
  190. package/dist/src/send-tools.d.ts.map +1 -1
  191. package/dist/src/send-tools.js +40 -7
  192. package/dist/src/send-tools.js.map +1 -1
  193. package/dist/src/stored-injections.d.ts +2 -2
  194. package/dist/src/stored-injections.js +2 -2
  195. package/dist/src/stored-removal.d.ts +17 -4
  196. package/dist/src/stored-removal.d.ts.map +1 -1
  197. package/dist/src/stored-removal.js +29 -5
  198. package/dist/src/stored-removal.js.map +1 -1
  199. package/dist/src/tool-result-guard.d.ts +12 -1
  200. package/dist/src/tool-result-guard.d.ts.map +1 -1
  201. package/dist/src/tool-result-guard.js +42 -8
  202. package/dist/src/tool-result-guard.js.map +1 -1
  203. package/dist/src/tune-out/coordinator.d.ts +1159 -79
  204. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  205. package/dist/src/tune-out/coordinator.js +3709 -363
  206. package/dist/src/tune-out/coordinator.js.map +1 -1
  207. package/dist/src/tune-out/tools.d.ts +114 -17
  208. package/dist/src/tune-out/tools.d.ts.map +1 -1
  209. package/dist/src/tune-out/tools.js +87 -27
  210. package/dist/src/tune-out/tools.js.map +1 -1
  211. package/dist/src/tune-out/window-anchor.d.ts +143 -0
  212. package/dist/src/tune-out/window-anchor.d.ts.map +1 -0
  213. package/dist/src/tune-out/window-anchor.js +202 -0
  214. package/dist/src/tune-out/window-anchor.js.map +1 -0
  215. package/dist/src/types/agent.d.ts +86 -31
  216. package/dist/src/types/agent.d.ts.map +1 -1
  217. package/dist/src/types/events.d.ts +2 -0
  218. package/dist/src/types/events.d.ts.map +1 -1
  219. package/dist/src/types/framework.d.ts +43 -21
  220. package/dist/src/types/framework.d.ts.map +1 -1
  221. package/dist/src/types/module.d.ts +180 -69
  222. package/dist/src/types/module.d.ts.map +1 -1
  223. package/dist/src/types/trace.d.ts +510 -36
  224. package/dist/src/types/trace.d.ts.map +1 -1
  225. package/package.json +3 -3
  226. package/src/admission/admit.ts +32 -3
  227. package/src/agent.ts +323 -120
  228. package/src/api/mcp-server-core.ts +54 -17
  229. package/src/api/server.ts +4 -23
  230. package/src/code-execution/isolate/scripts.ts +28 -3
  231. package/src/config-checks.ts +14 -0
  232. package/src/context-manager-errors.ts +37 -0
  233. package/src/context-options.ts +96 -0
  234. package/src/deferred-import.ts +239 -0
  235. package/src/dendrite/audience.ts +315 -0
  236. package/src/dendrite/index.ts +14 -2
  237. package/src/dendrite/presets.ts +18 -6
  238. package/src/dendrite/registry.ts +209 -12
  239. package/src/dendrite/types.ts +92 -8
  240. package/src/foreign-rounds.ts +199 -0
  241. package/src/framework.ts +7814 -3585
  242. package/src/gate/event-gate.ts +428 -65
  243. package/src/gate/index.ts +3 -0
  244. package/src/gate/rule-scope.ts +45 -0
  245. package/src/gate/suppress-script-worker.ts +89 -0
  246. package/src/gate/suppress-scripts.ts +414 -0
  247. package/src/gate/suppression-review.ts +517 -0
  248. package/src/gate/suppression-stats.ts +314 -0
  249. package/src/gate/types.ts +141 -7
  250. package/src/gate/wake-onboarding.ts +309 -0
  251. package/src/held-writes.ts +49 -60
  252. package/src/index.ts +9 -2
  253. package/src/jsonrpc-envelope.ts +81 -0
  254. package/src/mcpl/channel-registry.ts +353 -85
  255. package/src/mcpl/checkpoint-manager.ts +3 -2
  256. package/src/mcpl/feature-set-manager.ts +2 -1
  257. package/src/mcpl/inbound-source.ts +2 -1
  258. package/src/mcpl/inference-router.ts +7 -4
  259. package/src/mcpl/push-coalescer.ts +193 -34
  260. package/src/mcpl/push-handler.ts +10 -5
  261. package/src/mcpl/server-connection.ts +141 -60
  262. package/src/mcpl/tool-classes.ts +3 -0
  263. package/src/mcpl/types.ts +8 -2
  264. package/src/module-registry.ts +73 -17
  265. package/src/modules/discord/index.ts +229 -81
  266. package/src/modules/history/index.ts +439 -70
  267. package/src/modules/history/semantic.ts +15 -21
  268. package/src/modules/workspace/disk-agreement.ts +1 -1
  269. package/src/modules/workspace/index.ts +85 -23
  270. package/src/notice-tags.ts +10 -1
  271. package/src/operator-log.ts +36 -3
  272. package/src/presentation.ts +1676 -0
  273. package/src/prose-drafts.ts +16 -12
  274. package/src/read-marks.ts +328 -0
  275. package/src/recovery/offline-branch.ts +346 -32
  276. package/src/recovery/recover-cli.ts +21 -3
  277. package/src/send-tools.ts +40 -7
  278. package/src/stored-injections.ts +2 -2
  279. package/src/stored-removal.ts +31 -7
  280. package/src/tool-result-guard.ts +41 -7
  281. package/src/tune-out/coordinator.ts +4089 -457
  282. package/src/tune-out/tools.ts +160 -43
  283. package/src/tune-out/window-anchor.ts +277 -0
  284. package/src/types/agent.ts +90 -31
  285. package/src/types/events.ts +2 -0
  286. package/src/types/framework.ts +43 -21
  287. package/src/types/module.ts +181 -68
  288. package/src/types/trace.ts +542 -40
  289. package/dist/src/reasoning-loss-notice.d.ts +0 -23
  290. package/dist/src/reasoning-loss-notice.d.ts.map +0 -1
  291. package/dist/src/reasoning-loss-notice.js +0 -52
  292. package/dist/src/reasoning-loss-notice.js.map +0 -1
  293. package/dist/src/reasoning-loss.d.ts +0 -24
  294. package/dist/src/reasoning-loss.d.ts.map +0 -1
  295. package/dist/src/reasoning-loss.js +0 -102
  296. package/dist/src/reasoning-loss.js.map +0 -1
  297. package/src/reasoning-loss-notice.ts +0 -64
  298. package/src/reasoning-loss.ts +0 -120
@@ -16,16 +16,20 @@ 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';
23
25
 
24
26
  import type { ToolDefinition } from '../types/events.js';
27
+ import type { TraceEventInput } from '../types/trace.js';
25
28
  import type {
26
29
  GateConfig,
27
30
  GatePolicy,
28
31
  GatePolicyMatch,
32
+ SuppressConfig,
29
33
  GateBehavior,
30
34
  GateDecision,
31
35
  GateEntryRef,
@@ -146,15 +150,20 @@ interface PendingEvent {
146
150
  * a channel message's id, a push event's event id (isNewestEntry). */
147
151
  messageId?: string;
148
152
  eventId?: string;
153
+ /** GateEventInfo.recipients. */
154
+ recipients?: readonly string[];
149
155
  }
150
156
 
151
157
  /** 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. */
158
+ * went to; its stored id, once stored (absent while a surgery's hold, or
159
+ * its agent's recovery, keeps the write in memory); and `presented: false`
160
+ * when it wasn't presented to the agent: kept in memory, or stored while a
161
+ * turn of the agent's was alive, or with a presentation at rest that
162
+ * failed (#291's presentation index). The host marks the wake that goes with it from these. */
154
163
  export interface WritePlacement {
155
164
  agent?: string;
156
- deferredId?: string;
157
165
  messageId?: string;
166
+ presented?: false;
158
167
  }
159
168
 
160
169
  /**
@@ -259,15 +268,6 @@ interface PolicyStats {
259
268
  lastMatchTimestamp: number | null;
260
269
  }
261
270
 
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
271
  // ---------------------------------------------------------------------------
272
272
  // Glob matching (simple * wildcards)
273
273
  // ---------------------------------------------------------------------------
@@ -447,7 +447,7 @@ function behaviorFingerprint(b: import('./types.js').GateBehavior): string {
447
447
  // default), it now loads without it.
448
448
 
449
449
  const CONFIG_FIELDS = ['default', 'policies'];
450
- const POLICY_FIELDS = ['name', 'match', 'behavior', 'resets', 'passthrough'];
450
+ const POLICY_FIELDS = ['name', 'match', 'behavior', 'resets', 'passthrough', 'suppress'];
451
451
  const MATCH_FIELDS = [
452
452
  'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
453
453
  ];
@@ -456,6 +456,7 @@ const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample', 'judge'] as
456
456
  const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
457
457
  const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
458
458
  const JUDGE_FIELDS = ['debounceMs', 'maxWaitMs', 'instructions'];
459
+ const SUPPRESS_FIELDS = ['description', 'script'];
459
460
 
460
461
  function isPlainObject(value: unknown): value is Record<string, unknown> {
461
462
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -937,6 +938,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json', named: (pat
937
938
  const match = readMatch(r, obj.match);
938
939
  const resets = readResets(r, obj.resets);
939
940
  const passthrough = readPassthrough(r, obj.passthrough, kind);
941
+ const suppress = readSuppress(r, obj.suppress, obj.behavior, match, obj.match);
940
942
  const reading: RuleReading = {
941
943
  where: r.where, problems: r.problems, notes: r.notes, quiet: kind === 'skip' || kind === 'defer' || r.noTokens === true,
942
944
  };
@@ -944,6 +946,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json', named: (pat
944
946
  const policy: GatePolicy = { name, match, behavior };
945
947
  if (resets) policy.resets = resets;
946
948
  if (passthrough) policy.passthrough = true;
949
+ if (suppress) policy.suppress = suppress;
947
950
  reading.policy = policy;
948
951
  if (r.matchesAll) reading.matchesAll = true;
949
952
  }
@@ -1333,8 +1336,7 @@ function readMatch(r: Reader, value: unknown): GatePolicyMatch {
1333
1336
  // Left with nothing that narrows it once something in it was named, it
1334
1337
  // matches every event. Empty lists and nulls alone always meant no
1335
1338
  // 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;
1339
+ if (!matchNarrows(match) && r.problems.length + r.notes.length > named) r.matchesAll = true;
1338
1340
  return match;
1339
1341
  }
1340
1342
 
@@ -1381,6 +1383,133 @@ function readPassthrough(r: Reader, value: unknown, kind: BehaviorKind | undefin
1381
1383
  return false;
1382
1384
  }
1383
1385
 
1386
+ /** Whether a match narrows what it matches: anything but an empty scope. */
1387
+ export function matchNarrows(match: GatePolicyMatch): boolean {
1388
+ return Object.entries(match).some(([field, v]) => !(field === 'scope' && Array.isArray(v) && v.length === 0));
1389
+ }
1390
+
1391
+ /**
1392
+ * Whether a suppress rule's match names what it hides: an event type, or a
1393
+ * source, channel, mount, path or tag pattern that isn't only wildcards
1394
+ * (namesSomething), a metadata flag, or a content filter. A pattern of
1395
+ * wildcards alone, such as `source: "*"`, or only `tagsNone`, takes every
1396
+ * event, which a suppress rule may not.
1397
+ */
1398
+ function suppressMatchNarrows(match: GatePolicyMatch): boolean {
1399
+ if ((match.scope ?? []).length > 0 || (match.metadataTrue ?? []).length > 0 || match.filter !== undefined) return true;
1400
+ if ([match.source, match.channel, match.mount, match.pathGlob].some(namesSomething)) return true;
1401
+ return [...(match.tagsAny ?? []), ...(match.tagsAll ?? [])].some(namesSomething);
1402
+ }
1403
+
1404
+ /** Longest suppress description, in characters: the review sends it with every rule it judges. */
1405
+ export const MAX_SUPPRESS_DESCRIPTION = 2_000;
1406
+ /** Longest suppress script, in characters. */
1407
+ export const MAX_SUPPRESS_SCRIPT = 20_000;
1408
+ const SUPPRESS_EXAMPLE = '{"description": "the eye going on and off"}';
1409
+
1410
+ /**
1411
+ * A rule's `suppress`. Every problem with it is read as a rule that doesn't
1412
+ * suppress, which is how a release without suppression reads the rule:
1413
+ * its behavior then decides the event, and the event is rendered. The
1414
+ * behavior has to be written out as "defer" (or "skip"), not left to
1415
+ * default, because such a release reads a rule with no behavior as
1416
+ * "always" and would wake the agent on every event the rule was meant to
1417
+ * hide.
1418
+ */
1419
+ function readSuppress(r: Reader, value: unknown, behavior: unknown, match: GatePolicyMatch, rawMatch: unknown): SuppressConfig | undefined {
1420
+ if (value === undefined || value === false) return undefined;
1421
+ const without = "so the rule doesn't suppress, and its behavior decides its events as for any other rule";
1422
+ if (!isPlainObject(value)) {
1423
+ r.problems.push({ text: `suppress is ${shownValue(value)}, where an object such as ${SUPPRESS_EXAMPLE} is needed`, read: without });
1424
+ return undefined;
1425
+ }
1426
+ const before = r.problems.length;
1427
+ // A script given as null or "" is a script that's missing, not one left
1428
+ // out: read as left out, the rule would hide every event its match takes.
1429
+ const { script: _givenScript, ...rest } = value;
1430
+ const emptyScript = Object.hasOwn(value, 'script') && (value.script === null || value.script === '');
1431
+ if (emptyScript) {
1432
+ 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 });
1433
+ }
1434
+ const given = withoutEmpty(`${r.where}: suppress`, emptyScript ? rest : value, r.notes);
1435
+ const reported = r.problems.length;
1436
+ readFieldNames(r, 'suppress', given, SUPPRESS_FIELDS);
1437
+ // A field it doesn't read can't be left aside here, as elsewhere in a
1438
+ // rule: one meant as a condition, left out, would hide every event the
1439
+ // match takes.
1440
+ const unread = Object.keys(given).filter((key) => !SUPPRESS_FIELDS.includes(key));
1441
+ if (unread.length > 0 && r.problems.length === reported) {
1442
+ r.problems.push({ text: `suppress has ${quotedList(unread)}, and a suppress rule reads only "description" and "script"`, read: without });
1443
+ }
1444
+ const config: SuppressConfig = { description: '' };
1445
+ if (typeof given.description !== 'string' || given.description.trim() === '') {
1446
+ r.problems.push({
1447
+ text: `suppress.description is ${given.description === undefined ? 'missing' : shownValue(given.description)}, where the rule's `
1448
+ + 'purpose in a few words is needed: the review checks what the rule hides against it',
1449
+ read: without,
1450
+ });
1451
+ } else if ([...given.description].length > MAX_SUPPRESS_DESCRIPTION) {
1452
+ r.problems.push({
1453
+ text: `suppress.description is ${[...given.description].length.toLocaleString('en-US')} characters, over the `
1454
+ + `${MAX_SUPPRESS_DESCRIPTION.toLocaleString('en-US')} allowed`,
1455
+ read: without,
1456
+ });
1457
+ } else {
1458
+ config.description = given.description.trim();
1459
+ }
1460
+ if (given.script !== undefined) {
1461
+ if (typeof given.script !== 'string' || given.script.trim() === '') {
1462
+ 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 });
1463
+ } else if ([...given.script].length > MAX_SUPPRESS_SCRIPT) {
1464
+ r.problems.push({
1465
+ text: `suppress.script is ${[...given.script].length.toLocaleString('en-US')} characters, over the ${MAX_SUPPRESS_SCRIPT.toLocaleString('en-US')} allowed`,
1466
+ read: without,
1467
+ });
1468
+ } else {
1469
+ // Parsed only, never run here: the script runs on its worker.
1470
+ try {
1471
+ new Function(`"use strict";\nreturn (${given.script}\n);`);
1472
+ config.script = given.script;
1473
+ } catch (e) {
1474
+ r.problems.push({ text: `suppress.script doesn't parse (${quotedValue(errorText(e))})`, read: without });
1475
+ }
1476
+ }
1477
+ }
1478
+ if (behavior !== 'defer' && behavior !== 'skip') {
1479
+ r.problems.push({
1480
+ text: `suppress is given, and a rule that suppresses needs "behavior": "defer" written out, so a release that `
1481
+ + `doesn't know "suppress" defers its events instead of waking you on them; it has `
1482
+ + (behavior === undefined ? 'no behavior' : `behavior ${shownValue(behavior)}`),
1483
+ read: without,
1484
+ });
1485
+ }
1486
+ // A match field the gate doesn't read would, left aside, hide more than was
1487
+ // written: the condition it was meant to add is gone.
1488
+ if (isPlainObject(rawMatch)) {
1489
+ const rawFilter = isPlainObject(rawMatch.filter) ? Object.keys(rawMatch.filter).filter((key) => !FILTER_FIELDS.includes(key)).map((key) => `filter.${key}`) : [];
1490
+ const unreadMatch = [...Object.keys(rawMatch).filter((key) => !MATCH_FIELDS.includes(key)), ...rawFilter];
1491
+ if (unreadMatch.length > 0) {
1492
+ 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 });
1493
+ }
1494
+ }
1495
+ if (!suppressMatchNarrows(match)) {
1496
+ r.problems.push({
1497
+ text: 'suppress is given, and the rule\'s match names nothing it hides: a suppress rule needs a match that names '
1498
+ + 'a scope, a source, a channel, tags, a mount or path, metadata or a filter (not a pattern of wildcards alone, or tagsNone alone), '
1499
+ + 'so it can\'t hide everything',
1500
+ read: without,
1501
+ });
1502
+ }
1503
+ // Only a rule read without any problem suppresses: one read past a problem
1504
+ // (a slip of a field, a match read without a part of it) matches more, or
1505
+ // decides otherwise, than its author wrote, and hiding is what can't be
1506
+ // seen to go wrong. Its behavior decides its events instead.
1507
+ if (before > 0) {
1508
+ r.problems.push({ text: 'suppress is given on a rule with problems, and a rule suppresses only when it has none', read: without });
1509
+ }
1510
+ return r.problems.length === 0 ? config : undefined;
1511
+ }
1512
+
1384
1513
  /** What a load or a start does with a rule. */
1385
1514
  type RuleOutcome = 'as-written' | 'read' | 'left-out';
1386
1515
 
@@ -1962,7 +2091,9 @@ export function findShadowedPolicies(policies: GatePolicy[], eventTypesFrom?: Ev
1962
2091
  const out: ShadowWarning[] = [];
1963
2092
  for (let i = 0; i < policies.length; i++) {
1964
2093
  const earlier = policies[i];
1965
- if (earlier.passthrough) continue;
2094
+ // A suppress rule lets addressed events and the heartbeat's by, so it
2095
+ // can't be said to take every event of a later rule.
2096
+ if (earlier.passthrough || earlier.suppress) continue;
1966
2097
  for (let j = i + 1; j < policies.length; j++) {
1967
2098
  const found = subsumedEventTypes(earlier, policies[j], eventTypesFrom);
1968
2099
  if (found) {
@@ -2004,6 +2135,33 @@ function errorText(err: unknown): string {
2004
2135
  }
2005
2136
  }
2006
2137
 
2138
+ class PartialDelivery extends Error {
2139
+ constructor(readonly woke: readonly string[], readonly notWoken: readonly string[], cause: unknown) {
2140
+ super(errorText(cause), { cause });
2141
+ }
2142
+ }
2143
+
2144
+ /**
2145
+ * Runs a batch's delivery to `woken`, which pushes each agent it wakes to
2146
+ * `woke`: a throw once any is woken is a PartialDelivery naming them and the
2147
+ * rest; one before any wake is thrown as it is.
2148
+ */
2149
+ function deliveredTracking(woke: string[], woken: readonly string[], deliver: () => void): void {
2150
+ try {
2151
+ deliver();
2152
+ } catch (err) {
2153
+ if (woke.length === 0) throw err;
2154
+ throw new PartialDelivery(woke, woken.filter((name) => !woke.includes(name)), err);
2155
+ }
2156
+ }
2157
+
2158
+ function deliveredInPart(err: unknown): string | undefined {
2159
+ if (!(err instanceof PartialDelivery)) return undefined;
2160
+ const one = err.notWoken.length === 1;
2161
+ return `woke ${joinedList(err.woke)}, and delivering it threw (${err.message}), `
2162
+ + `so it's dropped for ${joinedList(err.notWoken)}, who ${one ? "isn't" : "aren't"} woken`;
2163
+ }
2164
+
2007
2165
  /**
2008
2166
  * Run a caller's callback once the change it's told of is made and traced. A
2009
2167
  * throw from it is logged, not thrown, so the call answers as the change it
@@ -2123,6 +2281,8 @@ export class EventGate {
2123
2281
 
2124
2282
  // Per-policy stats
2125
2283
  private stats = new Map<string, PolicyStats>();
2284
+ /** Suppress rules' scripts and their state (suppress-scripts.ts), kept beside gate.json. */
2285
+ private readonly suppressScripts: SuppressScripts;
2126
2286
 
2127
2287
  // The shadow lint's last findings (see refreshShadowWarnings), recomputed
2128
2288
  // on every config change and fingerprinted so an unchanged set doesn't
@@ -2172,7 +2332,7 @@ export class EventGate {
2172
2332
  private lastPrivilegedCheck = 0;
2173
2333
 
2174
2334
  // Dependency-injected callbacks
2175
- private emitTrace: (event: TraceEventLike) => void;
2335
+ private emitTrace: (event: TraceEventInput) => void;
2176
2336
  private addMessageFn: (
2177
2337
  participant: string,
2178
2338
  content: Array<{ type: 'text'; text: string }>,
@@ -2181,6 +2341,10 @@ export class EventGate {
2181
2341
  forAgent?: string,
2182
2342
  /** Out-param: where the message landed, for the wake that goes with it. */
2183
2343
  placement?: WritePlacement,
2344
+ /** The agents the row is for; absent: the host's default for the write.
2345
+ * The host stamps those of them that read where it stores the row, and
2346
+ * its default when none does. */
2347
+ audience?: readonly string[],
2184
2348
  ) => unknown;
2185
2349
  private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
2186
2350
  private getAgentNamesFn: () => string[];
@@ -2188,7 +2352,7 @@ export class EventGate {
2188
2352
  private isHeldFn: (serverId: string | undefined, channelId: string | undefined) => boolean;
2189
2353
  private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined;
2190
2354
  private channelShownFn?: (channel: { serverId: string; channelId: string }) => string;
2191
- private isNewestEntryFn?: (entry: GateEntryRef) => boolean;
2355
+ private isNewestEntryFn?: (entry: GateEntryRef, recipients?: readonly string[]) => boolean;
2192
2356
  /** Clock injection — keeps the new rate_limit / passive_sample paths
2193
2357
  * testable without monkey-patching Date.now globally. */
2194
2358
  private now: () => number;
@@ -2197,13 +2361,14 @@ export class EventGate {
2197
2361
  configPath: string;
2198
2362
  initialConfig?: GateConfig;
2199
2363
  privilegedUsersPath?: string;
2200
- emitTrace: (event: TraceEventLike) => void;
2364
+ emitTrace: (event: TraceEventInput) => void;
2201
2365
  addMessage: (
2202
2366
  participant: string,
2203
2367
  content: Array<{ type: 'text'; text: string }>,
2204
2368
  metadata?: Record<string, unknown>,
2205
2369
  forAgent?: string,
2206
2370
  placement?: WritePlacement,
2371
+ audience?: readonly string[],
2207
2372
  ) => unknown;
2208
2373
  /** `after`: the placement of the message the wake is for, when it has
2209
2374
  * one, so the host can drop the wake once a request that carried the
@@ -2221,6 +2386,8 @@ export class EventGate {
2221
2386
  now?: () => number;
2222
2387
  /** Per-event timeout (ms) for the optional gate.js script. Default 50. */
2223
2388
  scriptTimeoutMs?: number;
2389
+ /** How long a suppress rule's script may take for one event before the event is shown (ms). Default 50. */
2390
+ suppressScriptTimeoutMs?: number;
2224
2391
  /** The reply judge `judge` rules ask (JudgeConfig). Absent: a judge
2225
2392
  * rule's batch is delivered as a debounce's, and gate_status says so. */
2226
2393
  judge?: JudgeFn;
@@ -2251,8 +2418,10 @@ export class EventGate {
2251
2418
  * waiting to be written there. A batched wake for that one message
2252
2419
  * doesn't write its line when it fires: it hands the line to the wake
2253
2420
  * (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;
2421
+ * (WakeLine). With `recipients`, the windows are those agents', each of
2422
+ * which the event must be newest in. Absent: every batched wake writes
2423
+ * its line, as before. */
2424
+ isNewestEntry?: (entry: GateEntryRef, recipients?: readonly string[]) => boolean;
2256
2425
  /** The event types the gate can see from a source, when the host knows
2257
2426
  * them (an MCPL server's id: MCPL_GATE_EVENT_TYPES), else undefined.
2258
2427
  * The rule-order notes (findShadowedPolicies) leave out types a rule's
@@ -2260,6 +2429,11 @@ export class EventGate {
2260
2429
  eventTypesFrom?: EventTypesFrom;
2261
2430
  }) {
2262
2431
  this.configPath = opts.configPath;
2432
+ // Before the rules are read: a start's merge can clear a rule's state, its script's included.
2433
+ this.suppressScripts = new SuppressScripts({
2434
+ statePath: join(dirname(opts.configPath), 'suppress-state.json'),
2435
+ ...(opts.suppressScriptTimeoutMs !== undefined ? { timeoutMs: opts.suppressScriptTimeoutMs } : {}),
2436
+ });
2263
2437
  this.privilegedUsersPath = opts.privilegedUsersPath;
2264
2438
  this.emitTrace = opts.emitTrace;
2265
2439
  this.addMessageFn = opts.addMessage;
@@ -2541,7 +2715,6 @@ export class EventGate {
2541
2715
  ...changes,
2542
2716
  // Every name the start added to gate.json, as an append's trace gave.
2543
2717
  addedPolicies: names,
2544
- timestamp: Date.now(),
2545
2718
  });
2546
2719
  }
2547
2720
  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 +2825,6 @@ export class EventGate {
2652
2825
  type: 'gate:config-error',
2653
2826
  error: why,
2654
2827
  configPath: this.configPath,
2655
- timestamp: Date.now(),
2656
2828
  });
2657
2829
  }
2658
2830
  this.configError = problem;
@@ -2798,7 +2970,11 @@ export class EventGate {
2798
2970
  private clearStateFor(was: GateConfig, next: GateConfig): { removed: string[]; cleared: string[] } {
2799
2971
  const behaviorsByName = (config: GateConfig): Map<string, string> => {
2800
2972
  const byName = new Map<string, string[]>();
2801
- for (const p of config.policies) byName.set(p.name, [...(byName.get(p.name) ?? []), behaviorFingerprint(p.behavior)]);
2973
+ for (const p of config.policies) {
2974
+ // A suppress rule's script is part of what it is: a changed script starts from a fresh state.
2975
+ const fingerprint = behaviorFingerprint(p.behavior) + (p.suppress ? ` suppress:${p.suppress.script ?? ''}` : '');
2976
+ byName.set(p.name, [...(byName.get(p.name) ?? []), fingerprint]);
2977
+ }
2802
2978
  return new Map([...byName].map(([name, behaviors]) => [name, JSON.stringify(behaviors)]));
2803
2979
  };
2804
2980
  const before = behaviorsByName(was);
@@ -2851,7 +3027,6 @@ export class EventGate {
2851
3027
  removedPolicies: removedPolicies.length > 0 ? removedPolicies : undefined,
2852
3028
  stateClearedPolicies:
2853
3029
  stateClearedPolicies.length > 0 ? stateClearedPolicies : undefined,
2854
- timestamp: now,
2855
3030
  });
2856
3031
  } catch {
2857
3032
  // The rules in force stay, and the next check tries again.
@@ -2977,7 +3152,6 @@ export class EventGate {
2977
3152
  configPath: this.configPath,
2978
3153
  policyName: policy.name,
2979
3154
  replaced: idx >= 0,
2980
- timestamp: Date.now(),
2981
3155
  });
2982
3156
  if (ignored.length > 0) {
2983
3157
  // A caller that doesn't take the notes still gets them said, in the log.
@@ -3020,7 +3194,6 @@ export class EventGate {
3020
3194
  type: 'gate:policy-removed',
3021
3195
  configPath: this.configPath,
3022
3196
  policyName: name,
3023
- timestamp: Date.now(),
3024
3197
  });
3025
3198
  return true;
3026
3199
  }
@@ -3037,6 +3210,17 @@ export class EventGate {
3037
3210
  return current.policies.map((p) => p.name);
3038
3211
  }
3039
3212
 
3213
+ /** The rules in force, in order (copies): what the last decision was made by. */
3214
+ policiesInForce(): GatePolicy[] {
3215
+ return this.config.policies.map((p) => structuredClone(p));
3216
+ }
3217
+
3218
+ /** The rule in force by this name (a copy), or undefined. */
3219
+ policyInForce(name: string): GatePolicy | undefined {
3220
+ const p = this.config.policies.find((x) => x.name === name);
3221
+ return p ? structuredClone(p) : undefined;
3222
+ }
3223
+
3040
3224
  /** @internal For `wake_add_rule`'s and `wake_remove_rule`'s refusals; not a host API. */
3041
3225
  ruleNamesForChange(): { names: string[]; note: string } | { refused: string } {
3042
3226
  const edit = this.rulesForChange();
@@ -3223,7 +3407,6 @@ export class EventGate {
3223
3407
  matchedPolicy: 'asleep',
3224
3408
  trigger: false,
3225
3409
  behavior: 'skip',
3226
- timestamp: this.now(),
3227
3410
  });
3228
3411
  return { trigger: false, policyName: 'asleep', behavior: 'skip' };
3229
3412
  }
@@ -3246,6 +3429,11 @@ export class EventGate {
3246
3429
  decision = this.computeDecision({ name: 'gate.js', match: {}, behavior: scripted }, info);
3247
3430
  } else {
3248
3431
  for (const compiled of this.compiledPolicies) {
3432
+ // A suppress rule doesn't decide a wake for an event suppression was
3433
+ // asked about: one it takes was suppressed before the gate was asked,
3434
+ // and one that reaches here is one it let by. Where suppression wasn't
3435
+ // asked (no view to hide it from), it defers, as its behavior says.
3436
+ if (compiled.policy.suppress && this.passesOverSuppress(info)) continue;
3249
3437
  if (!this.compiledMatches(compiled, info)) continue;
3250
3438
  const candidate = this.computeDecision(compiled.policy, info);
3251
3439
  if (candidate.trigger || !compiled.policy.passthrough) {
@@ -3281,7 +3469,6 @@ export class EventGate {
3281
3469
  trigger: decision.trigger,
3282
3470
  behavior: formatBehavior(decision.behavior),
3283
3471
  ...(observed.length > 0 ? { observed } : {}),
3284
- timestamp: this.now(),
3285
3472
  });
3286
3473
 
3287
3474
  return decision;
@@ -3306,7 +3493,6 @@ export class EventGate {
3306
3493
  behavior: formatBehavior(policy.behavior),
3307
3494
  eventType: info.eventType,
3308
3495
  source: info.serverId || undefined,
3309
- timestamp: this.now(),
3310
3496
  });
3311
3497
 
3312
3498
  if (policy.behavior === 'defer' || policy.behavior === 'skip') {
@@ -3452,6 +3638,7 @@ export class EventGate {
3452
3638
  private clearPolicyState(name: string): void {
3453
3639
  const debounce = this.debounceTimers.get(name);
3454
3640
  if (debounce) clearTimeout(debounce.timer);
3641
+ this.suppressScripts.forget(name);
3455
3642
  this.rateLimitBuckets.delete(name);
3456
3643
  this.passiveSampleCounters.delete(name);
3457
3644
  this.rateLimitDenied.delete(name);
@@ -3507,6 +3694,7 @@ export class EventGate {
3507
3694
  */
3508
3695
  probe(info: GateEventInfo): GateDecision {
3509
3696
  for (const compiled of this.compiledPolicies) {
3697
+ if (compiled.policy.suppress && this.passesOverSuppress(info)) continue; // as evaluate does
3510
3698
  if (!this.compiledMatches(compiled, info)) continue;
3511
3699
  const trigger = this.wouldTrigger(compiled.policy, info);
3512
3700
  if (trigger || !compiled.policy.passthrough) {
@@ -3517,6 +3705,135 @@ export class EventGate {
3517
3705
  return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
3518
3706
  }
3519
3707
 
3708
+ /**
3709
+ * The suppress rule that takes this event, or null when none does.
3710
+ * Side-effect-free, like probe: no counter moves, no batch forms, nothing
3711
+ * is traced, and a rule's script runs on a copy of its state, which is
3712
+ * then dropped. (A closure a script builds for itself is its own, and a
3713
+ * 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
3714
+ * framework asks decideSuppression instead, once per event.
3715
+ *
3716
+ * It reads the rules as evaluate does, first match deciding: a rule ahead
3717
+ * of the suppress rule that matches the event decides it instead (it isn't
3718
+ * suppressed), unless it's a passthrough rule that wouldn't fire. A rule
3719
+ * with a script (`suppress.script`) takes an event its match takes only
3720
+ * when the script says to hide it; otherwise the event goes on to the rules
3721
+ * after it, as one the rule doesn't match does. Sleep and gate.js aren't
3722
+ * consulted: they decide wakes, and suppression decides what's rendered.
3723
+ *
3724
+ * Never suppressed, whatever the rules say: an event addressed to the
3725
+ * agent (tagged `chat:addressed`: a mention, a reply, a DM) and the
3726
+ * heartbeat's (the agent's own clock). A suppress rule lets them by to the
3727
+ * rules after it, and its script isn't asked. The framework itself never
3728
+ * asks about console or API input.
3729
+ */
3730
+ suppressionFor(info: GateEventInfo): string | null {
3731
+ return this.walkSuppression(info, false);
3732
+ }
3733
+
3734
+ /**
3735
+ * suppressionFor, for an event that is arriving now: each script asked
3736
+ * keeps the state it leaves, whatever later rules do with the event, and a
3737
+ * suppressed event counts in the passthrough rules ahead of its rule. Ask
3738
+ * once per event.
3739
+ */
3740
+ decideSuppression(info: GateEventInfo): string | null {
3741
+ return this.walkSuppression(info, true);
3742
+ }
3743
+
3744
+ private walkSuppression(info: GateEventInfo, commit: boolean): string | null {
3745
+ this.reloadIfChanged();
3746
+ this.syncSuppressScripts();
3747
+ if (info.serverId === 'heartbeat') return null;
3748
+ const addressed = Array.isArray(info.tags) && info.tags.includes('chat:addressed');
3749
+ let event: SuppressScriptEvent | undefined;
3750
+ const observers: GatePolicy[] = [];
3751
+ for (const compiled of this.compiledPolicies) {
3752
+ if (!this.compiledMatches(compiled, info)) continue;
3753
+ const suppress = compiled.policy.suppress;
3754
+ if (suppress) {
3755
+ if (addressed) continue;
3756
+ if (suppress.script !== undefined) {
3757
+ event ??= this.suppressScriptEvent(info);
3758
+ const ran = this.suppressScripts.run(compiled.policy.name, event, commit);
3759
+ // A script that failed shows the event: it goes on to the rules after.
3760
+ if (!ran || !('hide' in ran) || !ran.hide) continue;
3761
+ }
3762
+ // A suppressed event never reaches evaluate, so the passthrough rules
3763
+ // ahead of this one count it here, as evaluate would have: none of
3764
+ // them fires for it, and each fires on its schedule. A dry run counts
3765
+ // nothing.
3766
+ if (commit) for (const observer of observers) this.computeDecision(observer, info);
3767
+ return compiled.policy.name;
3768
+ }
3769
+ if (compiled.policy.passthrough && !this.wouldTrigger(compiled.policy, info)) { observers.push(compiled.policy); continue; }
3770
+ return null;
3771
+ }
3772
+ return null;
3773
+ }
3774
+
3775
+ /** 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. */
3776
+ private passesOverSuppress(info: GateEventInfo): boolean {
3777
+ return info.suppressionAsked === true || info.serverId === 'heartbeat'
3778
+ || (Array.isArray(info.tags) && info.tags.includes('chat:addressed'));
3779
+ }
3780
+
3781
+ /** The scripts in step with the rules in force (SuppressScripts.sync: only changed ones are compiled). */
3782
+ private syncSuppressScripts(): void {
3783
+ this.suppressScripts.sync(this.config.policies.map((p) => ({ name: p.name, ...(p.suppress?.script !== undefined ? { script: p.suppress.script } : {}) })));
3784
+ }
3785
+
3786
+ /** What a suppress script is given for an event: plain JSON, its metadata included where it can be. */
3787
+ private suppressScriptEvent(info: GateEventInfo): SuppressScriptEvent {
3788
+ let metadata: Record<string, unknown> = {};
3789
+ try { metadata = JSON.parse(JSON.stringify(info.metadata ?? {})) as Record<string, unknown>; } catch { metadata = {}; }
3790
+ const channel = this.routeChannelFor(info) ?? (info.channelId || undefined);
3791
+ const author = this.extractAuthorId(info.metadata);
3792
+ return {
3793
+ eventType: info.eventType,
3794
+ source: info.serverId,
3795
+ ...(channel ? { channel } : {}),
3796
+ ...(author ? { author } : {}),
3797
+ tags: [...(info.tags ?? [])],
3798
+ text: info.content,
3799
+ at: this.now(),
3800
+ metadata,
3801
+ };
3802
+ }
3803
+
3804
+ /**
3805
+ * Whether a suppress script compiles to a function, on the scripts' worker:
3806
+ * an error's text, or null. wake_add_rule asks before it saves a rule.
3807
+ */
3808
+ checkSuppressScript(script: string): string | null {
3809
+ return this.suppressScripts.check(script);
3810
+ }
3811
+
3812
+ /** A suppress rule's script state as it stands (a copy), for a host or a test. */
3813
+ suppressScriptState(name: string): Record<string, unknown> | undefined {
3814
+ this.syncSuppressScripts();
3815
+ return this.suppressScripts.stateOf(name);
3816
+ }
3817
+
3818
+ /**
3819
+ * Count a row the framework suppressed by `name` (suppressionFor's
3820
+ * answer) once it's written. The rule's stats count it as a match, so
3821
+ * gate_status shows how many events each suppress rule took since start.
3822
+ */
3823
+ noteSuppressed(name: string, info: Pick<GateEventInfo, 'eventType' | 'serverId' | 'channelId'>): void {
3824
+ const policyStats = this.stats.get(name) ?? { matchCount: 0, lastMatchTimestamp: null };
3825
+ policyStats.matchCount++;
3826
+ policyStats.lastMatchTimestamp = this.now();
3827
+ this.stats.set(name, policyStats);
3828
+ this.emitTrace({
3829
+ type: 'gate:suppressed',
3830
+ policyName: name,
3831
+ eventType: info.eventType,
3832
+ ...(info.serverId ? { serverId: info.serverId } : {}),
3833
+ ...(info.channelId ? { channelId: info.channelId } : {}),
3834
+ });
3835
+ }
3836
+
3520
3837
  /** The canonical synthetic events the probe table runs: chat shapes as MCPL delivers them, and a heartbeat. */
3521
3838
  private probeEvents(): Array<{ label: string; info: GateEventInfo }> {
3522
3839
  const chat = (
@@ -3592,7 +3909,12 @@ export class EventGate {
3592
3909
  */
3593
3910
  probeTable(): GateProbeRow[] {
3594
3911
  return this.probeEvents().map(({ label, info }) => {
3595
- const d = this.probe(info);
3912
+ const suppressedBy = this.suppressionFor(info);
3913
+ if (suppressedBy !== null) {
3914
+ return { probe: label, eventType: info.eventType, policy: suppressedBy, behavior: 'suppress', wouldWake: false };
3915
+ }
3916
+ // As admission asks: suppression first, so a script that says show lets the probe on.
3917
+ const d = this.probe({ ...info, suppressionAsked: true });
3596
3918
  return {
3597
3919
  probe: label,
3598
3920
  eventType: info.eventType,
@@ -3642,7 +3964,6 @@ export class EventGate {
3642
3964
  type: 'gate:shadow-warnings',
3643
3965
  reason,
3644
3966
  warnings: warnings.map(formatShadowWarning),
3645
- timestamp: this.now(),
3646
3967
  });
3647
3968
  }
3648
3969
  }
@@ -3705,6 +4026,7 @@ export class EventGate {
3705
4026
  routeChannelId: this.routeChannelFor(info),
3706
4027
  ...(typeof info.metadata?.messageId === 'string' ? { messageId: info.metadata.messageId } : {}),
3707
4028
  ...(typeof info.metadata?.eventId === 'string' ? { eventId: info.metadata.eventId } : {}),
4029
+ ...(info.recipients !== undefined ? { recipients: [...info.recipients] } : {}),
3708
4030
  };
3709
4031
 
3710
4032
  const existing = this.debounceTimers.get(policy.name);
@@ -3764,8 +4086,8 @@ export class EventGate {
3764
4086
  } catch (err) {
3765
4087
  const one = events.length === 1;
3766
4088
  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`,
4089
+ `[gate] Rule ${quotedValue(policyName)}: its pending batch of ${events.length} ${one ? 'event' : 'events'} `
4090
+ + (deliveredInPart(err) ?? `couldn't be delivered (${errorText(err)}), so the batch is dropped`),
3769
4091
  );
3770
4092
  return;
3771
4093
  }
@@ -3774,7 +4096,6 @@ export class EventGate {
3774
4096
  type: 'gate:debounce-delivered',
3775
4097
  policyName,
3776
4098
  eventCount: events.length,
3777
- timestamp: Date.now(),
3778
4099
  });
3779
4100
  }
3780
4101
 
@@ -3796,11 +4117,34 @@ export class EventGate {
3796
4117
  type: 'gate:held-dropped',
3797
4118
  dropped: batched.length - events.length,
3798
4119
  channels: [...new Set(batched.filter((e) => !events.includes(e)).map(heldAt))],
3799
- timestamp: this.now(),
3800
4120
  });
3801
4121
  }
3802
4122
  if (events.length === 0) return;
4123
+ // Who each delivery woke, so that a throw after a wake is told as a
4124
+ // delivery in part (PartialDelivery), the batch dropped only for those
4125
+ // it didn't wake.
4126
+ const woke: string[] = [];
4127
+ if (events.every((e) => e.recipients === undefined)) {
4128
+ const names = this.getAgentNamesFn();
4129
+ deliveredTracking(woke, names, () => this.deliverBatch(events, names, undefined, woke));
4130
+ return;
4131
+ }
4132
+ const woken = new Set(events.flatMap((e) => e.recipients ?? []));
4133
+ if (events.some((e) => e.recipients === undefined)) for (const name of this.getAgentNamesFn()) woken.add(name);
4134
+ const groups = new Map<string, { agents: string[]; events: PendingEvent[] }>();
4135
+ for (const agentName of woken) {
4136
+ const seen = events.filter((e) => e.recipients === undefined || e.recipients.includes(agentName));
4137
+ const key = seen.map((e) => events.indexOf(e)).join(',');
4138
+ const group = groups.get(key) ?? { agents: [], events: seen };
4139
+ group.agents.push(agentName);
4140
+ groups.set(key, group);
4141
+ }
4142
+ deliveredTracking(woke, [...woken], () => {
4143
+ for (const group of groups.values()) this.deliverBatch(group.events, group.agents, group.agents, woke);
4144
+ });
4145
+ }
3803
4146
 
4147
+ private deliverBatch(events: PendingEvent[], woken: readonly string[], audience?: readonly string[], woke?: string[]): void {
3804
4148
  const policyNames = [...new Set(events.map(e => e.policyName))];
3805
4149
 
3806
4150
  // Events whose content the framework already stored at arrival (MCPL
@@ -3859,7 +4203,12 @@ export class EventGate {
3859
4203
  const text = `[${NOTICE_TAG.gate}: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
3860
4204
  const only = events.length === 1 && events[0].inContext ? events[0] : undefined;
3861
4205
  let line: WakeLine | undefined;
3862
- if (only && this.newestEntry(only)) {
4206
+ // Where a line stored now landed, for the delivery agent's wake, as the
4207
+ // self-wake's notice places its own (readsRowFor): a live turn that
4208
+ // carried and answered the line drops the wake. Other agents' wakes stay
4209
+ // plain by readsRowFor's rule.
4210
+ const placement: WritePlacement = {};
4211
+ if (only && this.newestEntry(only, audience)) {
3863
4212
  const shownChannel = only.routeChannelId ?? only.channelId;
3864
4213
  line = {
3865
4214
  entry: entryRef(only),
@@ -3872,7 +4221,7 @@ export class EventGate {
3872
4221
  this.addMessageFn('user', [{ type: 'text', text }], {
3873
4222
  source: 'gate:debounce',
3874
4223
  policies: policyNames,
3875
- });
4224
+ }, undefined, placement, audience);
3876
4225
  }
3877
4226
 
3878
4227
  // Provenance of the batched wake (who/where woke the agent): the newest
@@ -3883,19 +4232,20 @@ export class EventGate {
3883
4232
  // back to whoever addressed the agent, not to whichever channel last saw
3884
4233
  // any traffic. Ambient-only batches set no locus. A batch whose line the
3885
4234
  // 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.
4235
+ // shared by every agent woken with these messages, so it is written once.
3887
4236
  const chosen = wakeProvenance(events);
3888
4237
  const provenance = line ? { ...chosen, line } : chosen;
3889
- for (const agentName of this.getAgentNamesFn()) {
3890
- this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance);
4238
+ for (const agentName of woken) {
4239
+ this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance, line ? undefined : placement);
4240
+ woke?.push(agentName);
3891
4241
  }
3892
4242
  }
3893
4243
 
3894
4244
  /** isNewestEntry for one event; a callback fault keeps the line. */
3895
- private newestEntry(event: PendingEvent): boolean {
4245
+ private newestEntry(event: PendingEvent, recipients?: readonly string[]): boolean {
3896
4246
  if (!this.isNewestEntryFn) return false;
3897
4247
  try {
3898
- return this.isNewestEntryFn(entryRef(event)) === true;
4248
+ return this.isNewestEntryFn(entryRef(event), recipients) === true;
3899
4249
  } catch {
3900
4250
  return false;
3901
4251
  }
@@ -4014,7 +4364,8 @@ export class EventGate {
4014
4364
  * the judge to weigh what waiting costs: each agent's pending self-wake
4015
4365
  * (skip_reply's wake_in_seconds), the end of a sleep, and the pending
4016
4366
  * 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).
4367
+ * when it fires; a judge rule's goes to the judge, so it only may). A batch,
4368
+ * and a sleep with no agent named, reach the gate's recipients alone.
4018
4369
  */
4019
4370
  upcomingWakes(agentNames: string[], exceptPolicy?: string): UpcomingWake[] {
4020
4371
  const wakes: UpcomingWake[] = [];
@@ -4022,19 +4373,21 @@ export class EventGate {
4022
4373
  for (const name of agentNames) {
4023
4374
  const due = this.selfWakeDue.get(name);
4024
4375
  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` });
4376
+ wakes.push({ at: due.at, certain: true, kind: 'self-wake', what: `${agentNames.length > 1 ? `${name}'s` : 'its'} own ${due.source} timer` });
4026
4377
  }
4027
4378
  }
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' });
4379
+ const recipients = this.getAgentNamesFn();
4380
+ const recipient = agentNames.some((name) => recipients.includes(name));
4381
+ if (this.sleepUntil > now && (this.sleepAgent === undefined ? recipient : agentNames.includes(this.sleepAgent))) {
4382
+ wakes.push({ at: this.sleepUntil, certain: true, kind: 'sleep-end', what: 'the end of its sleep' });
4030
4383
  }
4031
4384
  for (const [name, state] of this.debounceTimers) {
4032
- if (name === exceptPolicy || state.events.length === 0) continue;
4385
+ if (!recipient || name === exceptPolicy || state.events.length === 0) continue;
4033
4386
  const n = state.events.length;
4034
4387
  const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
4035
4388
  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}` });
4389
+ ? { at: state.dueAt, certain: false, kind: 'judge-batch', what: `rule ${quotedValue(name)}'s batch of ${batch} goes to the judge` }
4390
+ : { at: state.dueAt, certain: true, kind: 'debounce', what: `rule ${quotedValue(name)}'s debounced batch of ${batch}` });
4038
4391
  }
4039
4392
  return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
4040
4393
  }
@@ -4131,7 +4484,6 @@ export class EventGate {
4131
4484
  ...(!failed && typeof verdict.inputTokens === 'number' ? { inputTokens: verdict.inputTokens } : {}),
4132
4485
  ...(!failed && typeof verdict.cacheReadTokens === 'number' ? { cacheReadTokens: verdict.cacheReadTokens } : {}),
4133
4486
  ...(!failed && typeof verdict.outputTokens === 'number' ? { outputTokens: verdict.outputTokens } : {}),
4134
- timestamp: Date.now(),
4135
4487
  });
4136
4488
  if (this.disposed) return;
4137
4489
  const judge = this.currentJudge(policyName, state.judge);
@@ -4156,8 +4508,8 @@ export class EventGate {
4156
4508
  } catch (err) {
4157
4509
  const one = events.length === 1;
4158
4510
  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`,
4511
+ `[gate] a judged batch of ${events.length} ${one ? 'event' : 'events'} `
4512
+ + (deliveredInPart(err) ?? `couldn't be delivered (${errorText(err)}), so the batch is dropped`),
4161
4513
  );
4162
4514
  }
4163
4515
  }
@@ -4231,8 +4583,8 @@ export class EventGate {
4231
4583
  * buffer through the same path a turn's end uses. Self-wake and sleep-expiry
4232
4584
  * timers deliberately do NOT route through this — their inference requests
4233
4585
  * 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.
4586
+ * and releases at resume, whereas the buffer's deliverEvents wakes every
4587
+ * agent a batch is for.
4236
4588
  */
4237
4589
  setQuiesced(quiesced: boolean): void {
4238
4590
  if (this.quiesced === quiesced) return;
@@ -4260,10 +4612,12 @@ export class EventGate {
4260
4612
  this.deliverEvents(events);
4261
4613
  } catch (err) {
4262
4614
  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
- );
4615
+ const held = `${events.length} ${one ? 'event' : 'events'}`;
4616
+ const partly = deliveredInPart(err);
4617
+ console.error(partly
4618
+ ? `[gate] a batch of ${held}, held for an agent's turn or the host's quiesce, ${partly}`
4619
+ : `[gate] ${held} held for an agent's turn or the host's quiesce couldn't be delivered (${errorText(err)}), `
4620
+ + `so ${one ? "it's" : "they're"} dropped`);
4267
4621
  }
4268
4622
  });
4269
4623
  }
@@ -4347,7 +4701,6 @@ export class EventGate {
4347
4701
  matchedPolicy: 'asleep',
4348
4702
  trigger: false,
4349
4703
  behavior: 'skip',
4350
- timestamp: this.now(),
4351
4704
  });
4352
4705
  return { until: this.sleepUntil };
4353
4706
  }
@@ -4405,13 +4758,13 @@ export class EventGate {
4405
4758
  matchedPolicy: 'self-wake',
4406
4759
  trigger: true,
4407
4760
  behavior: 'always',
4408
- timestamp: this.now(),
4409
4761
  });
4410
4762
  // Compact in-window notice: without it the wake turn contains nothing
4411
4763
  // new, so the agent can't tell WHAT woke it (its own timer vs. a
4412
4764
  // 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).
4765
+ // before the inference request so it rides the wake turn: it is stored
4766
+ // at once, and while a turn of the agent's is alive it is kept out of
4767
+ // the window until the agent's next presentation point.
4415
4768
  const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
4416
4769
  // Deliver the notice into the WAKING agent's window — a self-wake
4417
4770
  // armed by a non-primary agent (e.g. a subconscious cadence) must not
@@ -4440,7 +4793,6 @@ export class EventGate {
4440
4793
  matchedPolicy: 'self-wake',
4441
4794
  trigger: false,
4442
4795
  behavior: 'skip',
4443
- timestamp: this.now(),
4444
4796
  });
4445
4797
  return { inMs: ms, armed: true };
4446
4798
  }
@@ -4483,7 +4835,6 @@ export class EventGate {
4483
4835
  matchedPolicy: 'asleep',
4484
4836
  trigger: true,
4485
4837
  behavior: 'always',
4486
- timestamp: this.now(),
4487
4838
  });
4488
4839
  const detail = note ? `${reason}: ${note}` : reason;
4489
4840
  const targets = agent ? [agent] : this.getAgentNamesFn();
@@ -4613,6 +4964,7 @@ export class EventGate {
4613
4964
  }
4614
4965
 
4615
4966
  getStatus(): GateStatus {
4967
+ this.syncSuppressScripts();
4616
4968
  const policies: GatePolicyStats[] = this.config.policies.map(p => {
4617
4969
  const stats = this.stats.get(p.name);
4618
4970
  const debounce = this.debounceTimers.get(p.name);
@@ -4621,6 +4973,9 @@ export class EventGate {
4621
4973
  behavior: p.behavior,
4622
4974
  matchCount: stats?.matchCount ?? 0,
4623
4975
  lastMatchTimestamp: stats?.lastMatchTimestamp ?? null,
4976
+ ...(p.suppress
4977
+ ? { suppress: { ...p.suppress, ...(p.suppress.script !== undefined && this.suppressScripts.status(p.name) ? { scriptStatus: this.suppressScripts.status(p.name)! } : {}) } }
4978
+ : {}),
4624
4979
  };
4625
4980
  if (typeof p.behavior === 'object' && 'debounce' in p.behavior) {
4626
4981
  result.debounceState = {
@@ -4684,6 +5039,13 @@ export class EventGate {
4684
5039
  // Fresh, not the cache: the sources the host knows (eventTypesFrom)
4685
5040
  // change as servers connect, with no change to the rules.
4686
5041
  shadowWarnings: this.lintShadows().map(formatShadowWarning),
5042
+ ...((): Pick<GateStatus, 'catchAll'> => {
5043
+ const rule = firstCatchAll(this.config.policies, { lettingBy: true });
5044
+ return rule ? { catchAll: {
5045
+ rule,
5046
+ 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.`,
5047
+ } } : {};
5048
+ })(),
4687
5049
  totalEvaluations: this.totalEvaluations,
4688
5050
  defaultDecisions: {
4689
5051
  triggered: this.defaultTriggered,
@@ -4720,6 +5082,7 @@ export class EventGate {
4720
5082
 
4721
5083
  dispose(): void {
4722
5084
  this.gateScript.dispose();
5085
+ this.suppressScripts.dispose();
4723
5086
  for (const state of this.debounceTimers.values()) {
4724
5087
  clearTimeout(state.timer);
4725
5088
  }