@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
@@ -0,0 +1,517 @@
1
+ /**
2
+ * The periodic review of what suppress rules hid (SuppressionReviewOptions).
3
+ *
4
+ * A suppress rule is the agent's own filter on its attention, and a filter
5
+ * written a little too wide hides what the agent would have wanted to see,
6
+ * with nothing in its context to show that it did. So every `intervalMs`
7
+ * (two hours by default), for each rule that hid events since the last
8
+ * review, a small model (Claude Haiku 5.5 by default) reads the rule's
9
+ * description, its match and the events it hid (a sample spread across the
10
+ * period, past `maxEventsPerRule`), and flags any that don't fit the
11
+ * description or that the agent would plausibly need to see. The agent is
12
+ * told, in a `[suppression review]` notice, what each rule hid and what was
13
+ * flagged, with the flagged events shown; a flagged event wakes it.
14
+ *
15
+ * What was reviewed is kept in a small file beside gate.json
16
+ * (`suppression-review.json`), written as the review starts and after each
17
+ * review: the time reviewed through, so a restart picks up where the last
18
+ * review stopped; for a rule the reviewer couldn't answer about, where its
19
+ * events still to be reviewed begin, so the next review takes them again; and
20
+ * each rule's description as last seen (the framework tells the review when a
21
+ * rule hides a row), so the events a rule hid before it was removed are still
22
+ * judged against it. The review reads a sample of each rule's events, sharing
23
+ * a fixed budget of text among them (EVENT_TEXT_BUDGET), not every event whole.
24
+ * The reviewer reads; it never decides what's suppressed and never edits a
25
+ * rule. The notice is the framework's own and is never suppressed.
26
+ */
27
+ import { NativeFormatter } from '@animalabs/membrane';
28
+ import type { ContentBlock, DetailedUsage, Membrane, NormalizedRequest, NormalizedResponse } from '@animalabs/membrane';
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
30
+ import { dirname } from 'node:path';
31
+ import { NOTICE_TAG } from '../notice-tags.js';
32
+ import type { SuppressedEventRecord } from './suppression-stats.js';
33
+ import type { SuppressionReviewOptions } from './types.js';
34
+ import type { TraceEventInput } from '../types/trace.js';
35
+
36
+ export const DEFAULT_REVIEW_INTERVAL_MS = 2 * 60 * 60 * 1000;
37
+ export const MIN_REVIEW_INTERVAL_MS = 10 * 60 * 1000;
38
+ export const MAX_REVIEW_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
39
+ export const DEFAULT_REVIEW_MODEL = 'claude-haiku-5-5';
40
+ export const DEFAULT_REVIEW_MAX_EVENTS = 60;
41
+ const MAX_REVIEW_EVENTS = 200;
42
+ const DEFAULT_REVIEW_TIMEOUT_MS = 60_000;
43
+ /** How long after a start the first review may run, at the soonest: the host settles first. */
44
+ const FIRST_REVIEW_DELAY_MS = 60_000;
45
+ /**
46
+ * Characters of event text the reviewer reads for one rule, shared among its
47
+ * sample: a short event takes what it needs and a long one the rest
48
+ * (eventTextCap), so what makes a long message matter past its start is
49
+ * read. Chosen, not measured: with the default sample it keeps one rule's
50
+ * request to roughly 8,000 input tokens, which a review every two hours can
51
+ * afford.
52
+ */
53
+ export const EVENT_TEXT_BUDGET = 24_000;
54
+ /** Characters of a rule's script the reviewer reads. */
55
+ const SCRIPT_CHARS = 3_000;
56
+ /** Characters of a flagged event's text the notice shows. */
57
+ const NOTICE_EVENT_CHARS = 200;
58
+ /** Flagged events the notice shows per rule; the rest are counted. */
59
+ const NOTICE_FLAGGED_SHOWN = 5;
60
+ /** Output tokens a verdict may take, adaptive thinking included. */
61
+ const VERDICT_MAX_TOKENS = 4_096;
62
+ const REVIEW_PARTICIPANT = 'suppression-review';
63
+
64
+ /** A suppress rule as the review knows it. */
65
+ export interface ReviewRuleInfo {
66
+ name: string;
67
+ description: string;
68
+ match?: unknown;
69
+ /** The rule's script, which decides which matching events it hides. */
70
+ script?: string;
71
+ }
72
+
73
+ /** One rule's review. */
74
+ export interface RuleReview {
75
+ rule: string;
76
+ /** The description judged against: the rule's own, or its last known one for a removed rule. */
77
+ description?: string;
78
+ /** Whether gate.json has the rule now. */
79
+ inForce: boolean;
80
+ /** Events it hid in the period. */
81
+ suppressed: number;
82
+ /** Of those, how many the reviewer read. */
83
+ read: number;
84
+ /** `fits`: nothing flagged; `flagged`: some events flagged; `error`: the reviewer couldn't answer; `unjudged`: no description to judge against. */
85
+ verdict: 'fits' | 'flagged' | 'error' | 'unjudged';
86
+ flagged: SuppressedEventRecord[];
87
+ /** The reviewer's one-line note, or what went wrong. */
88
+ note?: string;
89
+ }
90
+
91
+ export interface SuppressionReviewSummary {
92
+ fromMs: number;
93
+ toMs: number;
94
+ rules: RuleReview[];
95
+ total: number;
96
+ flagged: number;
97
+ }
98
+
99
+ export interface SuppressionReviewDeps {
100
+ /** The agent whose message slot is reviewed and who is told: the primary resident. */
101
+ agentName(): string | undefined;
102
+ /** The membrane the reviewer calls for that agent (the framework's judge membrane). */
103
+ membraneFor(agentName: string): Membrane;
104
+ /** The suppressed rows stored in the agent's slot, at or after `sinceMs` and before `untilMs`, oldest first. */
105
+ records(agentName: string, sinceMs: number, untilMs: number): SuppressedEventRecord[];
106
+ /** The suppress rules in force. */
107
+ rules(): ReviewRuleInfo[];
108
+ /** Tell the agent: store the notice, and wake it when `wake`. Throws when the notice can't be written. */
109
+ deliver(agentName: string, text: string, wake: boolean, summary: SuppressionReviewSummary): void;
110
+ recordUsage?(agentName: string, usage: DetailedUsage): void;
111
+ emitTrace?(event: TraceEventInput): void;
112
+ /** Where the review keeps what it reviewed through; left out, nothing is kept and a restart starts afresh. */
113
+ statePath?: string;
114
+ /** Whether a review may run now (not while the host is quiesced or stopping); a review not run waits for the next. */
115
+ mayRun?(): boolean;
116
+ /** Whether the agent has dump_suppressed (a workspace), for the notice to point at it. */
117
+ dumpAvailable?(): boolean;
118
+ now?(): number;
119
+ }
120
+
121
+ interface ReviewState {
122
+ version: 1;
123
+ reviewedThrough: number;
124
+ /** Rules the reviewer couldn't answer about: where their events still to be reviewed begin (ms). */
125
+ retryFrom: Map<string, number>;
126
+ descriptions: Map<string, string>;
127
+ }
128
+
129
+ const entriesOf = (value: unknown): Array<[string, unknown]> =>
130
+ value && typeof value === 'object' && !Array.isArray(value) ? Object.entries(value) : [];
131
+
132
+ /** The review state a file holds, or null when there's none; one that can't be used is named in the log and read as none. */
133
+ function readState(path: string): ReviewState | null {
134
+ if (!existsSync(path)) return null;
135
+ try {
136
+ const raw = JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
137
+ if (raw?.version !== 1 || typeof raw.reviewedThrough !== 'number' || !Number.isFinite(raw.reviewedThrough)) {
138
+ throw new Error('it holds no version 1 state with a reviewedThrough time');
139
+ }
140
+ const descriptions = new Map<string, string>();
141
+ for (const [name, text] of entriesOf(raw.descriptions)) if (typeof text === 'string') descriptions.set(name, text);
142
+ const retryFrom = new Map<string, number>();
143
+ for (const [name, ms] of entriesOf(raw.retryFrom)) if (typeof ms === 'number' && Number.isFinite(ms)) retryFrom.set(name, ms);
144
+ return { version: 1, reviewedThrough: raw.reviewedThrough, retryFrom, descriptions };
145
+ } catch (error) {
146
+ // Moved aside, so the review's first save doesn't write over a file a later release wrote.
147
+ const aside = `${path}.unreadable`;
148
+ let moved = '';
149
+ try { renameSync(path, aside); moved = ` It's kept as ${aside}.`; } catch { /* the log says what was read */ }
150
+ console.error(`[gate] the suppression review's progress in ${path} can't be read, so it reviews from now on.${moved}`, error);
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /** The options as the review runs them: each at its default or within its bounds. */
156
+ export function reviewSettings(options: SuppressionReviewOptions = {}) {
157
+ const interval = typeof options.intervalMs === 'number' && Number.isFinite(options.intervalMs) ? options.intervalMs : DEFAULT_REVIEW_INTERVAL_MS;
158
+ const maxEvents = typeof options.maxEventsPerRule === 'number' && Number.isFinite(options.maxEventsPerRule)
159
+ ? Math.round(options.maxEventsPerRule) : DEFAULT_REVIEW_MAX_EVENTS;
160
+ return {
161
+ intervalMs: Math.min(MAX_REVIEW_INTERVAL_MS, Math.max(MIN_REVIEW_INTERVAL_MS, interval)),
162
+ model: options.model || DEFAULT_REVIEW_MODEL,
163
+ notify: options.notify === 'flagged' ? 'flagged' as const : 'always' as const,
164
+ wakeOnFlag: options.wakeOnFlag !== false,
165
+ maxEventsPerRule: Math.min(MAX_REVIEW_EVENTS, Math.max(1, maxEvents)),
166
+ timeoutMs: typeof options.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_REVIEW_TIMEOUT_MS,
167
+ effort: options.effort ?? 'low',
168
+ };
169
+ }
170
+
171
+ /** `max` of `items`, spread evenly from the first to the last; all of them when there are no more. */
172
+ export function spread<T>(items: readonly T[], max: number): T[] {
173
+ if (items.length <= max) return [...items];
174
+ if (max === 1) return [items[items.length - 1]!];
175
+ return Array.from({ length: max }, (_, k) => items[Math.round((k * (items.length - 1)) / (max - 1))]!);
176
+ }
177
+
178
+ const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
179
+ function cut(text: string, max: number): string {
180
+ const chars = [...text];
181
+ return chars.length <= max ? text : `${chars.slice(0, max - 1).join('')}…`;
182
+ }
183
+
184
+ const REVIEW_SYSTEM = [
185
+ 'You review a filter an AI agent set on its own attention. The agent wrote a rule that hides some incoming events',
186
+ 'from its context, and described in its own words what the rule is for. You are shown that description, the',
187
+ "rule's match criteria, and events the rule hid, numbered.",
188
+ '',
189
+ 'Flag an event when it falls outside what the description says the rule is for. Also flag one the agent would',
190
+ 'plausibly need to see whatever the description says: something said to the agent by name or asking it something,',
191
+ "a warning, an error about the agent's own actions, or a person in distress or danger. Don't flag an event only",
192
+ 'because it is interesting, or because the rule hides many events.',
193
+ '',
194
+ 'Answer with one JSON object and nothing else:',
195
+ '{"fits": <true when you flag nothing>, "flagged": [<the numbers of the flagged events>], "note": "<one short sentence>"}',
196
+ ].join('\n');
197
+
198
+ /**
199
+ * The most characters any event may take so that the sample fits `budget`:
200
+ * every event shorter than it is read whole, and the budget they leave is
201
+ * shared among the rest.
202
+ */
203
+ export function eventTextCap(lengths: readonly number[], budget: number): number {
204
+ const sorted = [...lengths].sort((a, b) => a - b);
205
+ let left = budget;
206
+ for (let i = 0; i < sorted.length; i++) {
207
+ const share = Math.floor(left / (sorted.length - i));
208
+ if (sorted[i]! > share) return share;
209
+ left -= sorted[i]!;
210
+ }
211
+ return Number.POSITIVE_INFINITY;
212
+ }
213
+
214
+ /** The reviewer's request for one rule's events. */
215
+ export function reviewRequest(
216
+ rule: { name: string; description: string; match?: unknown; script?: string },
217
+ events: readonly SuppressedEventRecord[],
218
+ hidden: number,
219
+ period: { fromMs: number; toMs: number },
220
+ settings: ReturnType<typeof reviewSettings>,
221
+ ): NormalizedRequest {
222
+ const script = rule.script !== undefined
223
+ ? `Of the events it matches, it hides those this script of the agent's returns true for (state is the rule's own memory between events):\n${cut(rule.script, SCRIPT_CHARS)}\n`
224
+ : '';
225
+ const texts = events.map((e) => oneLine(e.text));
226
+ const cap = eventTextCap(texts.map((t) => [...t].length), EVENT_TEXT_BUDGET);
227
+ const listing = events.map((e, i) =>
228
+ `#${i + 1} [${e.at}] ${e.eventType}${e.source ? ` · ${e.source}` : ''}${e.channel ? ` · ${e.channel}` : ''}${e.author ? ` · ${e.author}` : ''}: ${cut(texts[i]!, cap)}`,
229
+ ).join('\n');
230
+ const text = [
231
+ `The agent's rule ${JSON.stringify(rule.name)} hides events from its context. The agent described it as:`,
232
+ JSON.stringify(rule.description),
233
+ rule.match !== undefined ? `The rule matches: ${JSON.stringify(rule.match)}` : '',
234
+ script,
235
+ `From ${new Date(period.fromMs).toISOString()} to ${new Date(period.toMs).toISOString()} it hid ${hidden} event${hidden === 1 ? '' : 's'}. `
236
+ + (events.length < hidden ? `Here are ${events.length} of them, spread across that time:` : 'Here they are:'),
237
+ '',
238
+ listing,
239
+ '',
240
+ 'Which of these, if any, should the agent have seen? Answer with the JSON object.',
241
+ ].filter((line, i, all) => line !== '' || (i > 0 && all[i - 1] !== '')).join('\n');
242
+ return {
243
+ system: REVIEW_SYSTEM,
244
+ messages: [{ participant: 'user', content: [{ type: 'text', text }] }],
245
+ config: { model: settings.model, maxTokens: VERDICT_MAX_TOKENS },
246
+ assistantParticipant: REVIEW_PARTICIPANT,
247
+ ...(settings.effort !== 'none' ? { providerParams: { output_config: { effort: settings.effort } } } : {}),
248
+ } as NormalizedRequest;
249
+ }
250
+
251
+ /** The reviewer's answer read from its text, or undefined when it holds none this can use. */
252
+ export function parseReviewAnswer(text: string, count: number): { flagged: number[]; note: string } | undefined {
253
+ const start = text.indexOf('{');
254
+ const end = text.lastIndexOf('}');
255
+ if (start < 0 || end <= start) return undefined;
256
+ let raw: unknown;
257
+ try { raw = JSON.parse(text.slice(start, end + 1)); } catch { return undefined; }
258
+ if (!raw || typeof raw !== 'object') return undefined;
259
+ const { fits, flagged, note } = raw as { fits?: unknown; flagged?: unknown; note?: unknown };
260
+ if (flagged !== undefined && !Array.isArray(flagged)) return undefined;
261
+ const named = flagged ?? [];
262
+ const numbers = [...new Set(named.filter((n): n is number => Number.isInteger(n) && n >= 1 && n <= count))].sort((a, b) => a - b);
263
+ if (typeof fits !== 'boolean' && flagged === undefined) return undefined;
264
+ // An answer that says they don't all fit, or names events, but names none
265
+ // it was shown can't be taken as "they fit": it's no answer.
266
+ if (numbers.length === 0 && (fits === false || named.length > 0)) return undefined;
267
+ return { flagged: numbers, note: typeof note === 'string' ? cut(oneLine(note), 300) : '' };
268
+ }
269
+
270
+ const clockOf = (ms: number): string => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
271
+
272
+ /** How long a span is, in the words the notice uses. */
273
+ function spanOf(ms: number): string {
274
+ const minutes = Math.round(ms / 60_000);
275
+ if (minutes < 1) return 'under a minute';
276
+ if (minutes < 120) return `${minutes} minute${minutes === 1 ? '' : 's'}`;
277
+ const hours = Math.round(minutes / 60);
278
+ return hours < 48 ? `${hours} hours` : `${Math.round(hours / 24)} days`;
279
+ }
280
+
281
+ /** The `[suppression review]` notice the agent is told; `dump` when it has dump_suppressed to point at. */
282
+ export function reviewNoticeText(summary: SuppressionReviewSummary, opts: { dump?: boolean } = {}): string {
283
+ const lines = [
284
+ `[${NOTICE_TAG.suppressionReview}] Since ${clockOf(summary.fromMs)} (${spanOf(summary.toMs - summary.fromMs)}), `
285
+ + `your suppress rules hid ${summary.total} event${summary.total === 1 ? '' : 's'}`
286
+ + (summary.flagged > 0 ? `, and the reviewer flagged ${summary.flagged} that may not fit their rule:` : '.'),
287
+ ];
288
+ for (const r of summary.rules) {
289
+ const named = `"${r.rule}"${r.description ? ` (${r.description})` : ''}${r.inForce ? '' : ', since removed,'}`;
290
+ const read = r.read < r.suppressed ? `read ${r.read} of them` : 'read them';
291
+ let said: string;
292
+ if (r.verdict === 'fits') said = `The reviewer ${read} and found they fit.`;
293
+ else if (r.verdict === 'flagged') said = `The reviewer ${read} and flagged ${r.flagged.length}${r.note ? `: ${r.note}` : '.'}`;
294
+ else if (r.verdict === 'unjudged') said = 'There was no description to judge them against.';
295
+ else said = "The reviewer couldn't answer this time; they'll be reviewed again next time, as far back as a week.";
296
+ lines.push(`- ${named} hid ${r.suppressed}. ${said}`);
297
+ // The flagged events, each cut short: the first few, and how many more.
298
+ for (const e of r.flagged.slice(0, NOTICE_FLAGGED_SHOWN)) {
299
+ lines.push(` · [${e.at}]${e.source ? ` ${e.source}` : ''}${e.author ? ` · ${e.author}` : ''}: ${JSON.stringify(cut(oneLine(e.text), NOTICE_EVENT_CHARS))} (id ${e.id})`);
300
+ }
301
+ if (r.flagged.length > NOTICE_FLAGGED_SHOWN) lines.push(` · and ${r.flagged.length - NOTICE_FLAGGED_SHOWN} more`);
302
+ }
303
+ lines.push(`${opts.dump === false ? 'suppression_stats shows' : 'suppression_stats and dump_suppressed show'} what a rule hid; wake_add_rule edits a rule, and wake_remove_rule removes one.`);
304
+ return lines.join('\n');
305
+ }
306
+
307
+ export class SuppressionReview {
308
+ private readonly settings: ReturnType<typeof reviewSettings>;
309
+ private readonly now: () => number;
310
+ private timer: ReturnType<typeof setTimeout> | null = null;
311
+ private running: Promise<SuppressionReviewSummary | null> | null = null;
312
+ private stopped = false;
313
+ private readonly inFlight = new Set<AbortController>();
314
+ private reviewedThrough: number;
315
+ private readonly retryFrom: Map<string, number>;
316
+ private readonly descriptions: Map<string, string>;
317
+
318
+ constructor(private readonly deps: SuppressionReviewDeps, options: SuppressionReviewOptions = {}) {
319
+ this.settings = reviewSettings(options);
320
+ this.now = deps.now ?? Date.now;
321
+ const state = deps.statePath ? readState(deps.statePath) : null;
322
+ this.reviewedThrough = state?.reviewedThrough ?? this.now();
323
+ this.retryFrom = state?.retryFrom ?? new Map();
324
+ this.descriptions = state?.descriptions ?? new Map();
325
+ // Where the review starts is kept at once, so a restart before the first
326
+ // review still reviews from here.
327
+ if (!state) this.saveState();
328
+ }
329
+
330
+ /** A rule's description as it stands when it hides a row: the one its rows are judged against later, though the rule be gone by then. */
331
+ noteRule(name: string, description: string): void {
332
+ if (this.descriptions.get(name) === description) return;
333
+ this.descriptions.set(name, description);
334
+ // Kept at once: a restart before the next review still judges the rows by it.
335
+ this.saveState();
336
+ }
337
+
338
+ get intervalMs(): number { return this.settings.intervalMs; }
339
+ get nextReviewAfter(): number { return this.reviewedThrough + this.settings.intervalMs; }
340
+
341
+ /** Schedule reviews: the first when the interval since the last review is up, and no sooner than a minute from now. */
342
+ start(): void {
343
+ if (this.stopped || this.timer) return;
344
+ this.schedule(Math.max(FIRST_REVIEW_DELAY_MS, this.nextReviewAfter - this.now()));
345
+ }
346
+
347
+ stop(): void {
348
+ this.stopped = true;
349
+ if (this.timer) clearTimeout(this.timer);
350
+ this.timer = null;
351
+ for (const controller of this.inFlight) controller.abort();
352
+ }
353
+
354
+ private schedule(delayMs: number): void {
355
+ if (this.stopped) return;
356
+ if (this.timer) clearTimeout(this.timer);
357
+ this.timer = setTimeout(() => {
358
+ this.timer = null;
359
+ void this.runOnce()
360
+ .catch((error) => console.error('[gate] the suppression review failed:', error))
361
+ .finally(() => this.schedule(this.settings.intervalMs));
362
+ }, Math.min(delayMs, 2_147_483_647));
363
+ this.timer.unref?.();
364
+ }
365
+
366
+ /**
367
+ * Review what was hidden since the last review, tell the agent, and record
368
+ * how far it reviewed. A call while a review runs gets that review's
369
+ * result. Null when no review ran: no agent is registered, the host isn't
370
+ * taking one now (mayRun, asked again before the agent is told), or the
371
+ * agent changed while the reviewer was asked; the next review covers the
372
+ * period. A review whose notice couldn't be written throws, and the next
373
+ * review covers its period again.
374
+ */
375
+ runOnce(): Promise<SuppressionReviewSummary | null> {
376
+ if (this.running) return this.running;
377
+ this.running = this.review().finally(() => { this.running = null; });
378
+ return this.running;
379
+ }
380
+
381
+ private async review(): Promise<SuppressionReviewSummary | null> {
382
+ const agentName = this.deps.agentName();
383
+ if (!agentName || this.stopped || (this.deps.mayRun && !this.deps.mayRun())) return null;
384
+ const fromMs = this.reviewedThrough;
385
+ const toMs = this.now();
386
+ const inForce = new Map(this.deps.rules().map((r) => [r.name, r]));
387
+ for (const rule of inForce.values()) this.descriptions.set(rule.name, rule.description);
388
+ // A rule the reviewer couldn't answer about last time is reviewed from
389
+ // where its unreviewed events begin.
390
+ const fromOf = (name: string): number => Math.min(this.retryFrom.get(name) ?? fromMs, fromMs);
391
+ const earliest = [...this.retryFrom.values()].reduce((min, from) => Math.min(min, from), fromMs);
392
+ const byRule = new Map<string, SuppressedEventRecord[]>();
393
+ for (const record of this.deps.records(agentName, earliest, toMs)) {
394
+ if (Date.parse(record.at) < fromOf(record.rule)) continue;
395
+ const list = byRule.get(record.rule) ?? [];
396
+ list.push(record);
397
+ byRule.set(record.rule, list);
398
+ }
399
+ const rules: RuleReview[] = [];
400
+ for (const [name, records] of byRule) {
401
+ if (this.stopped) return null;
402
+ const rule = inForce.get(name);
403
+ const description = rule?.description ?? this.descriptions.get(name);
404
+ const base = { rule: name, ...(description ? { description } : {}), inForce: rule !== undefined, suppressed: records.length };
405
+ if (!description) {
406
+ rules.push({ ...base, read: 0, verdict: 'unjudged', flagged: [] });
407
+ continue;
408
+ }
409
+ const sample = spread(records, this.settings.maxEventsPerRule);
410
+ rules.push(await this.judge(agentName, { name, description, ...(rule ?? {}) }, sample, records.length, { fromMs: fromOf(name), toMs }, base));
411
+ }
412
+ const summary: SuppressionReviewSummary = {
413
+ fromMs: rules.length > 0 ? rules.reduce((min, r) => Math.min(min, fromOf(r.rule)), Infinity) : fromMs, toMs, rules,
414
+ total: rules.reduce((n, r) => n + r.suppressed, 0),
415
+ flagged: rules.reduce((n, r) => n + r.flagged.length, 0),
416
+ };
417
+ // Stopped, quiesced, or the agent changed while the reviewer was asked:
418
+ // nothing is told and nothing recorded, so a later review takes this
419
+ // period again.
420
+ if (this.stopped || (this.deps.mayRun && !this.deps.mayRun()) || this.deps.agentName() !== agentName) return null;
421
+ const tell = summary.total > 0 && (this.settings.notify === 'always' || summary.flagged > 0);
422
+ if (tell) {
423
+ this.deps.deliver(agentName, reviewNoticeText(summary, { dump: this.deps.dumpAvailable?.() ?? true }), summary.flagged > 0 && this.settings.wakeOnFlag, summary);
424
+ }
425
+ this.deps.emitTrace?.({
426
+ type: 'gate:suppression-review',
427
+ agentName, fromMs: earliest, toMs, total: summary.total, flagged: summary.flagged, delivered: tell,
428
+ rules: rules.map((r) => ({ rule: r.rule, suppressed: r.suppressed, read: r.read, verdict: r.verdict, flagged: r.flagged.length })),
429
+ });
430
+ for (const r of rules) {
431
+ if (r.verdict === 'flagged' || r.verdict === 'error') {
432
+ console.error(`[gate] suppression review: "${r.rule}" hid ${r.suppressed}, ${r.verdict}${r.verdict === 'flagged' ? ` ${r.flagged.length}` : ''}${r.note ? `: ${r.note}` : ''}`);
433
+ }
434
+ }
435
+ this.reviewedThrough = toMs;
436
+ // The rules the reviewer couldn't answer about keep where their events
437
+ // begin; every other rule is reviewed through now.
438
+ for (const r of rules) {
439
+ // Kept for at most a week of events, so a reviewer that keeps failing doesn't read back without end.
440
+ if (r.verdict === 'error') this.retryFrom.set(r.rule, Math.max(fromOf(r.rule), toMs - MAX_REVIEW_INTERVAL_MS));
441
+ else this.retryFrom.delete(r.rule);
442
+ }
443
+ // Descriptions are kept for the rules in force and the ones with events
444
+ // still to review; the rest have nothing left to be judged.
445
+ // The rules in force read again now, so a rule added (and its rows
446
+ // noted) while the reviewer was asked keeps its description. One that's
447
+ // gone is dropped once nothing of it is left to judge: its rows were
448
+ // judged here, or it was in force when this review began and hid
449
+ // nothing in its period. One noted during this review is kept: its rows
450
+ // come in the next.
451
+ const nowInForce = new Set(this.deps.rules().map((r) => r.name));
452
+ for (const name of [...this.descriptions.keys()]) {
453
+ if (nowInForce.has(name) || this.retryFrom.has(name)) continue;
454
+ if (byRule.has(name) || inForce.has(name)) this.descriptions.delete(name);
455
+ }
456
+ this.saveState();
457
+ return summary;
458
+ }
459
+
460
+ private async judge(
461
+ agentName: string,
462
+ rule: ReviewRuleInfo,
463
+ sample: SuppressedEventRecord[],
464
+ hidden: number,
465
+ period: { fromMs: number; toMs: number },
466
+ base: Pick<RuleReview, 'rule' | 'description' | 'inForce' | 'suppressed'>,
467
+ ): Promise<RuleReview> {
468
+ const controller = new AbortController();
469
+ this.inFlight.add(controller);
470
+ const timer = setTimeout(() => controller.abort(), this.settings.timeoutMs);
471
+ timer.unref?.();
472
+ try {
473
+ const request = reviewRequest(rule, sample, hidden, period, this.settings);
474
+ const response = await this.deps.membraneFor(agentName).complete(request, { signal: controller.signal, formatter: new NativeFormatter() });
475
+ const usage = (response as NormalizedResponse).details?.usage;
476
+ if (usage) this.deps.recordUsage?.(agentName, usage);
477
+ const text = response.content
478
+ .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
479
+ .map((b) => b.text)
480
+ .join('\n');
481
+ const answer = parseReviewAnswer(text, sample.length);
482
+ if (!answer) {
483
+ return { ...base, read: sample.length, verdict: 'error', flagged: [], note: `its answer held no verdict (stop: ${response.stopReason})` };
484
+ }
485
+ const flagged = answer.flagged.map((n) => sample[n - 1]!);
486
+ return {
487
+ ...base, read: sample.length, verdict: flagged.length > 0 ? 'flagged' : 'fits', flagged,
488
+ ...(answer.note ? { note: answer.note } : {}),
489
+ };
490
+ } catch (error) {
491
+ const message = controller.signal.aborted && !this.stopped
492
+ ? `no answer within ${Math.round(this.settings.timeoutMs / 1000)} s`
493
+ : cut(oneLine(error instanceof Error ? error.message : String(error)), 160);
494
+ return { ...base, read: 0, verdict: 'error', flagged: [], note: message };
495
+ } finally {
496
+ clearTimeout(timer);
497
+ this.inFlight.delete(controller);
498
+ }
499
+ }
500
+
501
+ private saveState(): void {
502
+ const path = this.deps.statePath;
503
+ if (!path) return;
504
+ try {
505
+ mkdirSync(dirname(path), { recursive: true });
506
+ const temp = `${path}.${process.pid}.tmp`;
507
+ const state = {
508
+ version: 1, reviewedThrough: this.reviewedThrough,
509
+ retryFrom: Object.fromEntries(this.retryFrom), descriptions: Object.fromEntries(this.descriptions),
510
+ };
511
+ writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`);
512
+ renameSync(temp, path);
513
+ } catch (error) {
514
+ console.error(`[gate] the suppression review couldn't record its progress in ${path}, so a restart reviews from its start time:`, error);
515
+ }
516
+ }
517
+ }