@animalabs/agent-framework 0.1.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 (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/src/agent.d.ts +130 -0
  4. package/dist/src/agent.d.ts.map +1 -0
  5. package/dist/src/agent.js +406 -0
  6. package/dist/src/agent.js.map +1 -0
  7. package/dist/src/api/mcp-server.d.ts +37 -0
  8. package/dist/src/api/mcp-server.d.ts.map +1 -0
  9. package/dist/src/api/mcp-server.js +598 -0
  10. package/dist/src/api/mcp-server.js.map +1 -0
  11. package/dist/src/api/server.d.ts +83 -0
  12. package/dist/src/api/server.d.ts.map +1 -0
  13. package/dist/src/api/server.js +785 -0
  14. package/dist/src/api/server.js.map +1 -0
  15. package/dist/src/api/types.d.ts +395 -0
  16. package/dist/src/api/types.d.ts.map +1 -0
  17. package/dist/src/api/types.js +5 -0
  18. package/dist/src/api/types.js.map +1 -0
  19. package/dist/src/framework.d.ts +309 -0
  20. package/dist/src/framework.d.ts.map +1 -0
  21. package/dist/src/framework.js +2309 -0
  22. package/dist/src/framework.js.map +1 -0
  23. package/dist/src/gate/event-gate.d.ts +75 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -0
  25. package/dist/src/gate/event-gate.js +503 -0
  26. package/dist/src/gate/event-gate.js.map +1 -0
  27. package/dist/src/gate/index.d.ts +3 -0
  28. package/dist/src/gate/index.d.ts.map +1 -0
  29. package/dist/src/gate/index.js +2 -0
  30. package/dist/src/gate/index.js.map +1 -0
  31. package/dist/src/gate/types.d.ts +82 -0
  32. package/dist/src/gate/types.d.ts.map +1 -0
  33. package/dist/src/gate/types.js +9 -0
  34. package/dist/src/gate/types.js.map +1 -0
  35. package/dist/src/index.d.ts +17 -0
  36. package/dist/src/index.d.ts.map +1 -0
  37. package/dist/src/index.js +18 -0
  38. package/dist/src/index.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +141 -0
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
  41. package/dist/src/mcpl/channel-registry.js +647 -0
  42. package/dist/src/mcpl/channel-registry.js.map +1 -0
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
  45. package/dist/src/mcpl/checkpoint-manager.js +430 -0
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
  47. package/dist/src/mcpl/errors.d.ts +52 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -0
  49. package/dist/src/mcpl/errors.js +93 -0
  50. package/dist/src/mcpl/errors.js.map +1 -0
  51. package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
  53. package/dist/src/mcpl/feature-set-manager.js +250 -0
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -0
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
  57. package/dist/src/mcpl/hook-orchestrator.js +217 -0
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
  59. package/dist/src/mcpl/index.d.ts +14 -0
  60. package/dist/src/mcpl/index.d.ts.map +1 -0
  61. package/dist/src/mcpl/index.js +21 -0
  62. package/dist/src/mcpl/index.js.map +1 -0
  63. package/dist/src/mcpl/inference-router.d.ts +67 -0
  64. package/dist/src/mcpl/inference-router.d.ts.map +1 -0
  65. package/dist/src/mcpl/inference-router.js +249 -0
  66. package/dist/src/mcpl/inference-router.js.map +1 -0
  67. package/dist/src/mcpl/push-handler.d.ts +60 -0
  68. package/dist/src/mcpl/push-handler.d.ts.map +1 -0
  69. package/dist/src/mcpl/push-handler.js +162 -0
  70. package/dist/src/mcpl/push-handler.js.map +1 -0
  71. package/dist/src/mcpl/scope-manager.d.ts +76 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
  73. package/dist/src/mcpl/scope-manager.js +213 -0
  74. package/dist/src/mcpl/scope-manager.js.map +1 -0
  75. package/dist/src/mcpl/server-connection.d.ts +174 -0
  76. package/dist/src/mcpl/server-connection.d.ts.map +1 -0
  77. package/dist/src/mcpl/server-connection.js +559 -0
  78. package/dist/src/mcpl/server-connection.js.map +1 -0
  79. package/dist/src/mcpl/server-registry.d.ts +63 -0
  80. package/dist/src/mcpl/server-registry.d.ts.map +1 -0
  81. package/dist/src/mcpl/server-registry.js +119 -0
  82. package/dist/src/mcpl/server-registry.js.map +1 -0
  83. package/dist/src/mcpl/types.d.ts +755 -0
  84. package/dist/src/mcpl/types.d.ts.map +1 -0
  85. package/dist/src/mcpl/types.js +48 -0
  86. package/dist/src/mcpl/types.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +93 -0
  88. package/dist/src/module-registry.d.ts.map +1 -0
  89. package/dist/src/module-registry.js +386 -0
  90. package/dist/src/module-registry.js.map +1 -0
  91. package/dist/src/modules/api/index.d.ts +22 -0
  92. package/dist/src/modules/api/index.d.ts.map +1 -0
  93. package/dist/src/modules/api/index.js +63 -0
  94. package/dist/src/modules/api/index.js.map +1 -0
  95. package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
  96. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
  97. package/dist/src/modules/discord/discord-js-client.js +273 -0
  98. package/dist/src/modules/discord/discord-js-client.js.map +1 -0
  99. package/dist/src/modules/discord/index.d.ts +82 -0
  100. package/dist/src/modules/discord/index.d.ts.map +1 -0
  101. package/dist/src/modules/discord/index.js +959 -0
  102. package/dist/src/modules/discord/index.js.map +1 -0
  103. package/dist/src/modules/discord/types.d.ts +327 -0
  104. package/dist/src/modules/discord/types.d.ts.map +1 -0
  105. package/dist/src/modules/discord/types.js +5 -0
  106. package/dist/src/modules/discord/types.js.map +1 -0
  107. package/dist/src/modules/index.d.ts +11 -0
  108. package/dist/src/modules/index.d.ts.map +1 -0
  109. package/dist/src/modules/index.js +7 -0
  110. package/dist/src/modules/index.js.map +1 -0
  111. package/dist/src/modules/workspace/index.d.ts +59 -0
  112. package/dist/src/modules/workspace/index.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/index.js +831 -0
  114. package/dist/src/modules/workspace/index.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +48 -0
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -0
  117. package/dist/src/modules/workspace/sync.js +253 -0
  118. package/dist/src/modules/workspace/sync.js.map +1 -0
  119. package/dist/src/modules/workspace/types.d.ts +166 -0
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -0
  121. package/dist/src/modules/workspace/types.js +5 -0
  122. package/dist/src/modules/workspace/types.js.map +1 -0
  123. package/dist/src/modules/workspace/watcher.d.ts +43 -0
  124. package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
  125. package/dist/src/modules/workspace/watcher.js +120 -0
  126. package/dist/src/modules/workspace/watcher.js.map +1 -0
  127. package/dist/src/queue.d.ts +50 -0
  128. package/dist/src/queue.d.ts.map +1 -0
  129. package/dist/src/queue.js +95 -0
  130. package/dist/src/queue.js.map +1 -0
  131. package/dist/src/safe-slice.d.ts +8 -0
  132. package/dist/src/safe-slice.d.ts.map +1 -0
  133. package/dist/src/safe-slice.js +18 -0
  134. package/dist/src/safe-slice.js.map +1 -0
  135. package/dist/src/types/agent.d.ts +118 -0
  136. package/dist/src/types/agent.d.ts.map +1 -0
  137. package/dist/src/types/agent.js +2 -0
  138. package/dist/src/types/agent.js.map +1 -0
  139. package/dist/src/types/events.d.ts +220 -0
  140. package/dist/src/types/events.d.ts.map +1 -0
  141. package/dist/src/types/events.js +2 -0
  142. package/dist/src/types/events.js.map +1 -0
  143. package/dist/src/types/framework.d.ts +184 -0
  144. package/dist/src/types/framework.d.ts.map +1 -0
  145. package/dist/src/types/framework.js +2 -0
  146. package/dist/src/types/framework.js.map +1 -0
  147. package/dist/src/types/index.d.ts +7 -0
  148. package/dist/src/types/index.d.ts.map +1 -0
  149. package/dist/src/types/index.js +7 -0
  150. package/dist/src/types/index.js.map +1 -0
  151. package/dist/src/types/inference-log.d.ts +64 -0
  152. package/dist/src/types/inference-log.d.ts.map +1 -0
  153. package/dist/src/types/inference-log.js +5 -0
  154. package/dist/src/types/inference-log.js.map +1 -0
  155. package/dist/src/types/module.d.ts +302 -0
  156. package/dist/src/types/module.d.ts.map +1 -0
  157. package/dist/src/types/module.js +2 -0
  158. package/dist/src/types/module.js.map +1 -0
  159. package/dist/src/types/trace.d.ts +173 -0
  160. package/dist/src/types/trace.d.ts.map +1 -0
  161. package/dist/src/types/trace.js +2 -0
  162. package/dist/src/types/trace.js.map +1 -0
  163. package/dist/test/event-gate.test.d.ts +2 -0
  164. package/dist/test/event-gate.test.d.ts.map +1 -0
  165. package/dist/test/event-gate.test.js +535 -0
  166. package/dist/test/event-gate.test.js.map +1 -0
  167. package/dist/test/framework.test.d.ts +5 -0
  168. package/dist/test/framework.test.d.ts.map +1 -0
  169. package/dist/test/framework.test.js +738 -0
  170. package/dist/test/framework.test.js.map +1 -0
  171. package/dist/tsconfig.tsbuildinfo +1 -0
  172. package/package.json +58 -0
  173. package/src/agent.ts +512 -0
  174. package/src/api/mcp-server.ts +687 -0
  175. package/src/api/server.ts +1023 -0
  176. package/src/api/types.ts +542 -0
  177. package/src/framework.ts +2784 -0
  178. package/src/gate/event-gate.ts +623 -0
  179. package/src/gate/index.ts +12 -0
  180. package/src/gate/types.ts +112 -0
  181. package/src/index.ts +63 -0
  182. package/src/mcpl/channel-registry.ts +824 -0
  183. package/src/mcpl/checkpoint-manager.ts +545 -0
  184. package/src/mcpl/errors.ts +114 -0
  185. package/src/mcpl/feature-set-manager.ts +333 -0
  186. package/src/mcpl/hook-orchestrator.ts +281 -0
  187. package/src/mcpl/index.ts +134 -0
  188. package/src/mcpl/inference-router.ts +335 -0
  189. package/src/mcpl/push-handler.ts +232 -0
  190. package/src/mcpl/scope-manager.ts +262 -0
  191. package/src/mcpl/server-connection.ts +694 -0
  192. package/src/mcpl/server-registry.ts +144 -0
  193. package/src/mcpl/types.ts +991 -0
  194. package/src/module-registry.ts +547 -0
  195. package/src/modules/api/index.ts +94 -0
  196. package/src/modules/discord/discord-js-client.ts +353 -0
  197. package/src/modules/discord/index.ts +1146 -0
  198. package/src/modules/discord/types.ts +465 -0
  199. package/src/modules/index.ts +43 -0
  200. package/src/modules/workspace/index.ts +969 -0
  201. package/src/modules/workspace/sync.ts +314 -0
  202. package/src/modules/workspace/types.ts +202 -0
  203. package/src/modules/workspace/watcher.ts +142 -0
  204. package/src/queue.ts +106 -0
  205. package/src/safe-slice.ts +18 -0
  206. package/src/types/agent.ts +119 -0
  207. package/src/types/events.ts +252 -0
  208. package/src/types/framework.ts +211 -0
  209. package/src/types/index.ts +6 -0
  210. package/src/types/inference-log.ts +64 -0
  211. package/src/types/module.ts +367 -0
  212. package/src/types/trace.ts +196 -0
@@ -0,0 +1,623 @@
1
+ /**
2
+ * EventGate — file-driven inference gating with per-policy debounce.
3
+ *
4
+ * Reads policies from a `gate.json` config file. Policies are evaluated in
5
+ * order — first match wins. Each policy specifies a behavior: "always"
6
+ * (trigger immediately), "skip" (don't trigger), or { debounce: ms }
7
+ * (batch events per-policy, deliver when timer fires).
8
+ *
9
+ * The gate controls inference triggering only. Events always enter context
10
+ * and are always dispatched to modules — the gate decides whether to spend
11
+ * inference tokens, not whether the agent should know about the event.
12
+ */
13
+
14
+ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+
17
+ import type { ToolDefinition } from '../types/events.js';
18
+ import type {
19
+ GateConfig,
20
+ GatePolicy,
21
+ GatePolicyMatch,
22
+ GateBehavior,
23
+ GateDecision,
24
+ GateEventInfo,
25
+ GatePolicyStats,
26
+ GateStatus,
27
+ } from './types.js';
28
+
29
+ // Re-export types consumers need
30
+ export type { GateConfig, GateOptions, GateDecision, GateEventInfo } from './types.js';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Constants
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Max content length stored in pending debounce events. */
37
+ const MAX_CONTENT_SNIPPET = 200;
38
+ /** Max content length shown in onWake trace summaries. */
39
+ const MAX_TRACE_SNIPPET = 80;
40
+ /** Max events buffered during inference before oldest are dropped. */
41
+ const MAX_INFERENCE_BUFFER = 100;
42
+ /** Minimum interval between filesystem checks for config changes (ms). */
43
+ const RELOAD_THROTTLE_MS = 1000;
44
+ /** Minimum debounce value (ms). */
45
+ const MIN_DEBOUNCE_MS = 100;
46
+ /** Maximum debounce value (ms). */
47
+ const MAX_DEBOUNCE_MS = 300_000;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Default config
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const DEFAULT_CONFIG: GateConfig = {
54
+ policies: [],
55
+ default: 'always',
56
+ };
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Internal types
60
+ // ---------------------------------------------------------------------------
61
+
62
+ interface PendingEvent {
63
+ policyName: string;
64
+ content: string;
65
+ eventType: string;
66
+ timestamp: number;
67
+ }
68
+
69
+ interface DebounceState {
70
+ timer: ReturnType<typeof setTimeout>;
71
+ events: PendingEvent[];
72
+ }
73
+
74
+ interface CompiledPolicy {
75
+ policy: GatePolicy;
76
+ filterRegex?: RegExp;
77
+ sourceRegex?: RegExp;
78
+ channelRegex?: RegExp;
79
+ }
80
+
81
+ interface PolicyStats {
82
+ matchCount: number;
83
+ lastMatchTimestamp: number | null;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Trace event shape (subset — avoids importing the full TraceEvent union)
88
+ // ---------------------------------------------------------------------------
89
+
90
+ interface TraceEventLike {
91
+ type: string;
92
+ [key: string]: unknown;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Glob matching (simple * wildcards)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function compileGlob(pattern: string): RegExp {
100
+ // Escape everything except *, then replace * with .*
101
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
102
+ if (!pattern.includes('*')) {
103
+ return new RegExp('^' + escaped + '$');
104
+ }
105
+ return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$');
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Config validation
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function validateConfig(raw: unknown): GateConfig {
113
+ if (!raw || typeof raw !== 'object') {
114
+ throw new Error('gate.json must be a JSON object');
115
+ }
116
+
117
+ const obj = raw as Record<string, unknown>;
118
+
119
+ const defaultBehavior = obj.default ?? 'always';
120
+ if (defaultBehavior !== 'always' && defaultBehavior !== 'skip') {
121
+ throw new Error(`gate.json "default" must be "always" or "skip", got: ${defaultBehavior}`);
122
+ }
123
+
124
+ const policies: GatePolicy[] = [];
125
+ if (Array.isArray(obj.policies)) {
126
+ for (const p of obj.policies) {
127
+ policies.push(validatePolicy(p));
128
+ }
129
+ }
130
+
131
+ return { policies, default: defaultBehavior };
132
+ }
133
+
134
+ function validatePolicy(raw: unknown): GatePolicy {
135
+ if (!raw || typeof raw !== 'object') {
136
+ throw new Error('Each gate policy must be an object');
137
+ }
138
+
139
+ const obj = raw as Record<string, unknown>;
140
+
141
+ if (typeof obj.name !== 'string' || !obj.name) {
142
+ throw new Error('Gate policy must have a "name" string');
143
+ }
144
+
145
+ // Validate behavior
146
+ let behavior: GateBehavior;
147
+ if (obj.behavior === 'always' || obj.behavior === 'skip') {
148
+ behavior = obj.behavior;
149
+ } else if (obj.behavior && typeof obj.behavior === 'object') {
150
+ const b = obj.behavior as Record<string, unknown>;
151
+ if (typeof b.debounce === 'number' && b.debounce > 0) {
152
+ if (b.debounce < MIN_DEBOUNCE_MS) {
153
+ throw new Error(`Policy "${obj.name}": debounce must be >= ${MIN_DEBOUNCE_MS}ms, got ${b.debounce}`);
154
+ }
155
+ if (b.debounce > MAX_DEBOUNCE_MS) {
156
+ throw new Error(`Policy "${obj.name}": debounce must be <= ${MAX_DEBOUNCE_MS}ms, got ${b.debounce}`);
157
+ }
158
+ behavior = { debounce: b.debounce };
159
+ } else {
160
+ throw new Error(`Policy "${obj.name}": debounce must be a positive number`);
161
+ }
162
+ } else {
163
+ behavior = 'always';
164
+ }
165
+
166
+ // Validate match (lenient — missing fields mean "match all")
167
+ const match: GatePolicyMatch = {};
168
+ if (obj.match && typeof obj.match === 'object') {
169
+ const m = obj.match as Record<string, unknown>;
170
+ if (Array.isArray(m.scope)) match.scope = m.scope.filter(s => typeof s === 'string');
171
+ if (typeof m.source === 'string') match.source = m.source;
172
+ if (typeof m.channel === 'string') match.channel = m.channel;
173
+ if (m.filter && typeof m.filter === 'object') {
174
+ const f = m.filter as Record<string, unknown>;
175
+ if ((f.type === 'text' || f.type === 'regex') && typeof f.pattern === 'string') {
176
+ match.filter = { type: f.type, pattern: f.pattern };
177
+ if (f.type === 'regex') {
178
+ try { new RegExp(f.pattern as string); } catch (e) {
179
+ throw new Error(`Policy "${obj.name}": invalid regex pattern: ${e}`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ return { name: obj.name, match, behavior };
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // EventGate
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export class EventGate {
194
+ private configPath: string;
195
+ private config: GateConfig;
196
+ private compiledPolicies: CompiledPolicy[] = [];
197
+ private configMtime: number = 0;
198
+ private lastReloadCheck: number = 0;
199
+ private configSource: 'file' | 'initial' | 'default' = 'default';
200
+ private lastReloadTimestamp: number | null = null;
201
+ private configErrors: string[] = [];
202
+
203
+ // Debounce state
204
+ private debounceTimers = new Map<string, DebounceState>();
205
+
206
+ // Inference buffering
207
+ private inferring = new Set<string>();
208
+ private inferenceBuffer: PendingEvent[] = [];
209
+
210
+ // Per-policy stats
211
+ private stats = new Map<string, PolicyStats>();
212
+
213
+ // Dependency-injected callbacks
214
+ private emitTrace: (event: TraceEventLike) => void;
215
+ private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
216
+ private requestInferenceFn: (agentName: string, reason: string, source: string) => void;
217
+ private getAgentNamesFn: () => string[];
218
+
219
+ constructor(opts: {
220
+ configPath: string;
221
+ initialConfig?: GateConfig;
222
+ emitTrace: (event: TraceEventLike) => void;
223
+ addMessage: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
224
+ requestInference: (agentName: string, reason: string, source: string) => void;
225
+ getAgentNames: () => string[];
226
+ }) {
227
+ this.configPath = opts.configPath;
228
+ this.emitTrace = opts.emitTrace;
229
+ this.addMessageFn = opts.addMessage;
230
+ this.requestInferenceFn = opts.requestInference;
231
+ this.getAgentNamesFn = opts.getAgentNames;
232
+
233
+ // Seed config file if it doesn't exist
234
+ if (opts.initialConfig && !existsSync(this.configPath)) {
235
+ try {
236
+ mkdirSync(dirname(this.configPath), { recursive: true });
237
+ writeFileSync(this.configPath, JSON.stringify(opts.initialConfig, null, 2) + '\n');
238
+ } catch (err) {
239
+ this.configErrors.push(`Failed to seed gate.json: ${err}`);
240
+ }
241
+ }
242
+
243
+ // Load config
244
+ this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG };
245
+ this.compiledPolicies = this.compilePolicies(this.config);
246
+ }
247
+
248
+ // =========================================================================
249
+ // Config loading
250
+ // =========================================================================
251
+
252
+ private loadConfig(): GateConfig | null {
253
+ if (!existsSync(this.configPath)) {
254
+ return null;
255
+ }
256
+ try {
257
+ const raw = readFileSync(this.configPath, 'utf-8');
258
+ const stat = statSync(this.configPath);
259
+ this.configMtime = stat.mtimeMs;
260
+ const config = validateConfig(JSON.parse(raw));
261
+ this.configSource = 'file';
262
+ this.configErrors = [];
263
+ return config;
264
+ } catch (err) {
265
+ const msg = err instanceof Error ? err.message : String(err);
266
+ this.configErrors = [msg];
267
+ this.emitTrace({
268
+ type: 'gate:config-error',
269
+ error: msg,
270
+ configPath: this.configPath,
271
+ timestamp: Date.now(),
272
+ });
273
+ return null; // Caller keeps previous config
274
+ }
275
+ }
276
+
277
+ private compilePolicies(config: GateConfig): CompiledPolicy[] {
278
+ return config.policies.map(policy => {
279
+ const compiled: CompiledPolicy = { policy };
280
+ if (policy.match.filter?.type === 'regex') {
281
+ try { compiled.filterRegex = new RegExp(policy.match.filter.pattern, 'i'); } catch { /* skip */ }
282
+ }
283
+ if (policy.match.source) {
284
+ compiled.sourceRegex = compileGlob(policy.match.source);
285
+ }
286
+ if (policy.match.channel) {
287
+ compiled.channelRegex = compileGlob(policy.match.channel);
288
+ }
289
+ return compiled;
290
+ });
291
+ }
292
+
293
+ private reloadIfChanged(): void {
294
+ const now = Date.now();
295
+ if (now - this.lastReloadCheck < RELOAD_THROTTLE_MS) return;
296
+ this.lastReloadCheck = now;
297
+
298
+ try {
299
+ if (!existsSync(this.configPath)) return;
300
+ const stat = statSync(this.configPath);
301
+ if (stat.mtimeMs === this.configMtime) return;
302
+
303
+ const newConfig = this.loadConfig();
304
+ if (!newConfig) return; // Parse error — keep previous config
305
+
306
+ // Clean up debounce timers for removed policies
307
+ const newPolicyNames = new Set(newConfig.policies.map(p => p.name));
308
+ for (const [name, state] of this.debounceTimers) {
309
+ if (!newPolicyNames.has(name)) {
310
+ clearTimeout(state.timer);
311
+ if (state.events.length > 0) {
312
+ this.deliverEvents(state.events);
313
+ }
314
+ this.debounceTimers.delete(name);
315
+ }
316
+ }
317
+
318
+ // Reset stats for removed policies
319
+ for (const name of this.stats.keys()) {
320
+ if (!newPolicyNames.has(name)) {
321
+ this.stats.delete(name);
322
+ }
323
+ }
324
+
325
+ this.config = newConfig;
326
+ this.compiledPolicies = this.compilePolicies(newConfig);
327
+ this.lastReloadTimestamp = now;
328
+
329
+ this.emitTrace({
330
+ type: 'gate:config-reloaded',
331
+ configPath: this.configPath,
332
+ policyCount: newConfig.policies.length,
333
+ timestamp: now,
334
+ });
335
+ } catch {
336
+ // Ignore stat errors
337
+ }
338
+ }
339
+
340
+ // =========================================================================
341
+ // Policy matching — first match wins
342
+ // =========================================================================
343
+
344
+ private matchPolicy(info: GateEventInfo): GatePolicy | null {
345
+ for (const compiled of this.compiledPolicies) {
346
+ if (this.compiledMatches(compiled, info)) {
347
+ return compiled.policy;
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+
353
+ private compiledMatches(compiled: CompiledPolicy, info: GateEventInfo): boolean {
354
+ const match = compiled.policy.match;
355
+
356
+ // Scope check
357
+ if (match.scope && match.scope.length > 0 && !match.scope.includes(info.eventType)) {
358
+ return false;
359
+ }
360
+
361
+ // Source check (serverId)
362
+ if (compiled.sourceRegex && !compiled.sourceRegex.test(info.serverId)) {
363
+ return false;
364
+ }
365
+
366
+ // Channel check
367
+ if (compiled.channelRegex && !compiled.channelRegex.test(info.channelId)) {
368
+ return false;
369
+ }
370
+
371
+ // Content filter check
372
+ if (match.filter) {
373
+ if (match.filter.type === 'text') {
374
+ if (!info.content.toLowerCase().includes(match.filter.pattern.toLowerCase())) {
375
+ return false;
376
+ }
377
+ } else if (compiled.filterRegex) {
378
+ if (!compiled.filterRegex.test(info.content)) {
379
+ return false;
380
+ }
381
+ } else {
382
+ return false; // Regex failed to compile
383
+ }
384
+ }
385
+
386
+ return true;
387
+ }
388
+
389
+ // =========================================================================
390
+ // Evaluate — main entry point
391
+ // =========================================================================
392
+
393
+ evaluate(info: GateEventInfo): GateDecision {
394
+ this.reloadIfChanged();
395
+
396
+ const policy = this.matchPolicy(info);
397
+
398
+ if (!policy) {
399
+ // No policy matched — use default
400
+ const trigger = (this.config.default ?? 'always') === 'always';
401
+ return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
402
+ }
403
+
404
+ // Record stats
405
+ const policyStats = this.stats.get(policy.name) ?? { matchCount: 0, lastMatchTimestamp: null };
406
+ policyStats.matchCount++;
407
+ policyStats.lastMatchTimestamp = Date.now();
408
+ this.stats.set(policy.name, policyStats);
409
+
410
+ // Emit trace
411
+ this.emitTrace({
412
+ type: 'gate:policy-matched',
413
+ policyName: policy.name,
414
+ behavior: typeof policy.behavior === 'object' ? `debounce:${policy.behavior.debounce}` : policy.behavior,
415
+ eventType: info.eventType,
416
+ source: info.serverId || undefined,
417
+ timestamp: Date.now(),
418
+ });
419
+
420
+ if (policy.behavior === 'skip') {
421
+ return { trigger: false, policyName: policy.name, behavior: 'skip' };
422
+ }
423
+
424
+ if (policy.behavior === 'always') {
425
+ return { trigger: true, policyName: policy.name, behavior: 'always' };
426
+ }
427
+
428
+ // Debounce
429
+ this.handleDebounce(policy, info);
430
+ return { trigger: false, policyName: policy.name, behavior: policy.behavior };
431
+ }
432
+
433
+ // =========================================================================
434
+ // shouldTriggerInference callback adapter
435
+ // =========================================================================
436
+
437
+ /**
438
+ * Returns a callback compatible with McplServerConfig.shouldTriggerInference.
439
+ * Used by the framework to wire the gate into PushHandler and ChannelRegistry.
440
+ */
441
+ asShouldTriggerCallback(): (content: string, metadata: Record<string, unknown>) => boolean {
442
+ return (content: string, metadata: Record<string, unknown>): boolean => {
443
+ const decision = this.evaluate({
444
+ content,
445
+ eventType: (metadata.eventType as string) ?? 'unknown',
446
+ serverId: (metadata.serverId as string) ?? '',
447
+ channelId: (metadata.channelId as string) ?? '',
448
+ metadata,
449
+ });
450
+ return decision.trigger;
451
+ };
452
+ }
453
+
454
+ // =========================================================================
455
+ // Debounce
456
+ // =========================================================================
457
+
458
+ private handleDebounce(policy: GatePolicy, info: GateEventInfo): void {
459
+ const debounceMs = (policy.behavior as { debounce: number }).debounce;
460
+
461
+ const event: PendingEvent = {
462
+ policyName: policy.name,
463
+ content: info.content.length > MAX_CONTENT_SNIPPET
464
+ ? info.content.slice(0, MAX_CONTENT_SNIPPET) + '...'
465
+ : info.content,
466
+ eventType: info.eventType,
467
+ timestamp: Date.now(),
468
+ };
469
+
470
+ const existing = this.debounceTimers.get(policy.name);
471
+ if (existing) {
472
+ clearTimeout(existing.timer);
473
+ existing.events.push(event);
474
+ existing.timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
475
+ } else {
476
+ const timer = setTimeout(() => this.fireDebounce(policy.name), debounceMs);
477
+ this.debounceTimers.set(policy.name, { timer, events: [event] });
478
+ }
479
+ }
480
+
481
+ private fireDebounce(policyName: string): void {
482
+ const state = this.debounceTimers.get(policyName);
483
+ if (!state || state.events.length === 0) {
484
+ this.debounceTimers.delete(policyName);
485
+ return;
486
+ }
487
+
488
+ const events = state.events;
489
+ this.debounceTimers.delete(policyName);
490
+
491
+ // If any agent is currently inferring, buffer for later (with cap)
492
+ if (this.inferring.size > 0) {
493
+ this.bufferForInference(events);
494
+ return;
495
+ }
496
+
497
+ this.deliverEvents(events);
498
+
499
+ this.emitTrace({
500
+ type: 'gate:debounce-delivered',
501
+ policyName,
502
+ eventCount: events.length,
503
+ timestamp: Date.now(),
504
+ });
505
+ }
506
+
507
+ // =========================================================================
508
+ // Event delivery
509
+ // =========================================================================
510
+
511
+ private deliverEvents(events: PendingEvent[]): void {
512
+ if (events.length === 0) return;
513
+
514
+ const policyNames = [...new Set(events.map(e => e.policyName))];
515
+ const lines = events
516
+ .map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`)
517
+ .join('\n');
518
+
519
+ const text = `[Gate: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines}`;
520
+
521
+ this.addMessageFn('user', [{ type: 'text', text }], {
522
+ source: 'gate:debounce',
523
+ policies: policyNames,
524
+ });
525
+
526
+ for (const agentName of this.getAgentNamesFn()) {
527
+ this.requestInferenceFn(agentName, 'gate:debounce', 'gate');
528
+ }
529
+ }
530
+
531
+ // =========================================================================
532
+ // Inference lifecycle
533
+ // =========================================================================
534
+
535
+ onInferenceStarted(agentName: string): void {
536
+ this.inferring.add(agentName);
537
+ }
538
+
539
+ onInferenceEnded(agentName: string): void {
540
+ this.inferring.delete(agentName);
541
+
542
+ // Flush inference buffer if no agents are inferring
543
+ if (this.inferring.size === 0 && this.inferenceBuffer.length > 0) {
544
+ const events = this.inferenceBuffer.splice(0);
545
+ // Defer to avoid re-entrancy inside trace callbacks
546
+ queueMicrotask(() => this.deliverEvents(events));
547
+ }
548
+ }
549
+
550
+ // =========================================================================
551
+ // Inference buffer cap
552
+ // =========================================================================
553
+
554
+ /** Called internally when debounce fires during inference. */
555
+ private bufferForInference(events: PendingEvent[]): void {
556
+ for (const event of events) {
557
+ if (this.inferenceBuffer.length >= MAX_INFERENCE_BUFFER) {
558
+ this.inferenceBuffer.shift(); // Drop oldest
559
+ }
560
+ this.inferenceBuffer.push(event);
561
+ }
562
+ }
563
+
564
+ // =========================================================================
565
+ // Tool: gate:status
566
+ // =========================================================================
567
+
568
+ getToolDefinition(): ToolDefinition {
569
+ return {
570
+ name: 'gate:status',
571
+ description: 'Show the current EventGate configuration, per-policy match counts, debounce state, and any config errors.',
572
+ inputSchema: {
573
+ type: 'object',
574
+ },
575
+ };
576
+ }
577
+
578
+ async handleToolCall(): Promise<{ success: boolean; data?: GateStatus; error?: string }> {
579
+ const status = this.getStatus();
580
+ return { success: true, data: status };
581
+ }
582
+
583
+ getStatus(): GateStatus {
584
+ const policies: GatePolicyStats[] = this.config.policies.map(p => {
585
+ const stats = this.stats.get(p.name);
586
+ const debounce = this.debounceTimers.get(p.name);
587
+ const result: GatePolicyStats = {
588
+ name: p.name,
589
+ behavior: p.behavior,
590
+ matchCount: stats?.matchCount ?? 0,
591
+ lastMatchTimestamp: stats?.lastMatchTimestamp ?? null,
592
+ };
593
+ if (typeof p.behavior === 'object' && 'debounce' in p.behavior) {
594
+ result.debounceState = {
595
+ pendingCount: debounce?.events.length ?? 0,
596
+ nextDeliveryMs: null, // Timer internals not easily inspectable
597
+ };
598
+ }
599
+ return result;
600
+ });
601
+
602
+ return {
603
+ configPath: this.configPath,
604
+ configSource: this.configSource,
605
+ lastReloadTimestamp: this.lastReloadTimestamp,
606
+ default: this.config.default ?? 'always',
607
+ policies,
608
+ errors: [...this.configErrors],
609
+ };
610
+ }
611
+
612
+ // =========================================================================
613
+ // Cleanup
614
+ // =========================================================================
615
+
616
+ dispose(): void {
617
+ for (const state of this.debounceTimers.values()) {
618
+ clearTimeout(state.timer);
619
+ }
620
+ this.debounceTimers.clear();
621
+ this.inferenceBuffer = [];
622
+ }
623
+ }
@@ -0,0 +1,12 @@
1
+ export { EventGate } from './event-gate.js';
2
+ export type {
3
+ GateConfig,
4
+ GateOptions,
5
+ GatePolicy,
6
+ GatePolicyMatch,
7
+ GateBehavior,
8
+ GateDecision,
9
+ GateEventInfo,
10
+ GateStatus,
11
+ GatePolicyStats,
12
+ } from './types.js';