@animalabs/agent-framework 0.1.0 → 0.3.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 (102) hide show
  1. package/dist/src/framework.d.ts +13 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +133 -10
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/gate/event-gate.d.ts +5 -0
  6. package/dist/src/gate/event-gate.d.ts.map +1 -1
  7. package/dist/src/gate/event-gate.js +104 -6
  8. package/dist/src/gate/event-gate.js.map +1 -1
  9. package/dist/src/gate/types.d.ts +31 -0
  10. package/dist/src/gate/types.d.ts.map +1 -1
  11. package/dist/src/index.d.ts +3 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/index.js +4 -0
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/src/mcpl/channel-registry.d.ts +16 -2
  16. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  17. package/dist/src/mcpl/channel-registry.js +101 -6
  18. package/dist/src/mcpl/channel-registry.js.map +1 -1
  19. package/dist/src/mcpl/push-handler.js +1 -1
  20. package/dist/src/mcpl/push-handler.js.map +1 -1
  21. package/dist/src/mcpl/server-connection.d.ts +17 -2
  22. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  23. package/dist/src/mcpl/server-connection.js +66 -3
  24. package/dist/src/mcpl/server-connection.js.map +1 -1
  25. package/dist/src/mcpl/tool-policy.d.ts +24 -0
  26. package/dist/src/mcpl/tool-policy.d.ts.map +1 -0
  27. package/dist/src/mcpl/tool-policy.js +46 -0
  28. package/dist/src/mcpl/tool-policy.js.map +1 -0
  29. package/dist/src/mcpl/types.d.ts +31 -0
  30. package/dist/src/mcpl/types.d.ts.map +1 -1
  31. package/dist/src/mcpl/types.js.map +1 -1
  32. package/dist/src/modules/workspace/index.d.ts +43 -1
  33. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  34. package/dist/src/modules/workspace/index.js +246 -32
  35. package/dist/src/modules/workspace/index.js.map +1 -1
  36. package/dist/src/modules/workspace/sync.d.ts +7 -2
  37. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  38. package/dist/src/modules/workspace/sync.js +2 -2
  39. package/dist/src/modules/workspace/sync.js.map +1 -1
  40. package/dist/src/modules/workspace/types.d.ts +55 -3
  41. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  42. package/dist/src/modules/workspace/types.js +8 -1
  43. package/dist/src/modules/workspace/types.js.map +1 -1
  44. package/dist/src/modules/workspace/watcher.d.ts +40 -3
  45. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  46. package/dist/src/modules/workspace/watcher.js +80 -13
  47. package/dist/src/modules/workspace/watcher.js.map +1 -1
  48. package/dist/src/types/trace.d.ts +20 -0
  49. package/dist/src/types/trace.d.ts.map +1 -1
  50. package/dist/src/usage/index.d.ts +3 -0
  51. package/dist/src/usage/index.d.ts.map +1 -0
  52. package/dist/src/usage/index.js +2 -0
  53. package/dist/src/usage/index.js.map +1 -0
  54. package/dist/src/usage/types.d.ts +28 -0
  55. package/dist/src/usage/types.d.ts.map +1 -0
  56. package/dist/src/usage/types.js +2 -0
  57. package/dist/src/usage/types.js.map +1 -0
  58. package/dist/src/usage/usage-tracker.d.ts +33 -0
  59. package/dist/src/usage/usage-tracker.d.ts.map +1 -0
  60. package/dist/src/usage/usage-tracker.js +115 -0
  61. package/dist/src/usage/usage-tracker.js.map +1 -0
  62. package/dist/test/channel-registry-typing.test.d.ts +2 -0
  63. package/dist/test/channel-registry-typing.test.d.ts.map +1 -0
  64. package/dist/test/channel-registry-typing.test.js +82 -0
  65. package/dist/test/channel-registry-typing.test.js.map +1 -0
  66. package/dist/test/event-gate.test.js +135 -16
  67. package/dist/test/event-gate.test.js.map +1 -1
  68. package/dist/test/mcpl-gate-roundtrip.test.d.ts +2 -0
  69. package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +1 -0
  70. package/dist/test/mcpl-gate-roundtrip.test.js +140 -0
  71. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -0
  72. package/dist/test/tool-policy.test.d.ts +2 -0
  73. package/dist/test/tool-policy.test.d.ts.map +1 -0
  74. package/dist/test/tool-policy.test.js +51 -0
  75. package/dist/test/tool-policy.test.js.map +1 -0
  76. package/dist/test/usage-tracker.test.d.ts +2 -0
  77. package/dist/test/usage-tracker.test.d.ts.map +1 -0
  78. package/dist/test/usage-tracker.test.js +129 -0
  79. package/dist/test/usage-tracker.test.js.map +1 -0
  80. package/dist/test/workspace-watcher.test.d.ts +2 -0
  81. package/dist/test/workspace-watcher.test.d.ts.map +1 -0
  82. package/dist/test/workspace-watcher.test.js +117 -0
  83. package/dist/test/workspace-watcher.test.js.map +1 -0
  84. package/dist/tsconfig.tsbuildinfo +1 -1
  85. package/package.json +3 -3
  86. package/src/framework.ts +148 -10
  87. package/src/gate/event-gate.ts +106 -6
  88. package/src/gate/types.ts +28 -0
  89. package/src/index.ts +7 -0
  90. package/src/mcpl/channel-registry.ts +120 -8
  91. package/src/mcpl/push-handler.ts +1 -1
  92. package/src/mcpl/server-connection.ts +78 -3
  93. package/src/mcpl/tool-policy.ts +52 -0
  94. package/src/mcpl/types.ts +34 -0
  95. package/src/modules/workspace/index.ts +269 -38
  96. package/src/modules/workspace/sync.ts +11 -4
  97. package/src/modules/workspace/types.ts +70 -3
  98. package/src/modules/workspace/watcher.ts +104 -15
  99. package/src/types/trace.ts +26 -1
  100. package/src/usage/index.ts +2 -0
  101. package/src/usage/types.ts +27 -0
  102. package/src/usage/usage-tracker.ts +141 -0
package/src/framework.ts CHANGED
@@ -51,7 +51,10 @@ import { InferenceRouter } from './mcpl/inference-router.js';
51
51
  import { ChannelRegistry } from './mcpl/channel-registry.js';
52
52
  import { safeSlice } from './safe-slice.js';
53
53
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
54
+ import { isToolAllowed } from './mcpl/tool-policy.js';
54
55
  import { EventGate } from './gate/event-gate.js';
56
+ import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
57
+ import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
55
58
  import type { McplServerConnection } from './mcpl/server-connection.js';
56
59
  import type {
57
60
  McplServerConfig,
@@ -120,6 +123,44 @@ class DefaultErrorPolicy implements ErrorPolicy {
120
123
  /** Default sync interval in milliseconds */
121
124
  const DEFAULT_SYNC_INTERVAL_MS = 1000;
122
125
 
126
+ /**
127
+ * Extract fields the EventGate cares about from a ProcessEvent. The set of
128
+ * event variants that carry `content`/`mount`/`paths`/`metadata` is open-ended
129
+ * (modules can define CustomEvents), so we read by name rather than match the
130
+ * discriminant. Centralizing this lets new gate-visible fields be added in one
131
+ * place instead of retyping the narrowing at every call site.
132
+ */
133
+ function extractGateFields(event: ProcessEvent): {
134
+ content: string;
135
+ metadata: Record<string, unknown>;
136
+ mount?: string;
137
+ paths?: string[];
138
+ } {
139
+ const rec = event as unknown as Record<string, unknown>;
140
+ const rawContent = rec.content;
141
+ let content = '';
142
+ if (typeof rawContent === 'string') {
143
+ content = rawContent;
144
+ } else if (Array.isArray(rawContent)) {
145
+ // ContentBlock[] — concatenate text blocks so gate content filters match
146
+ content = rawContent
147
+ .filter((b): b is { type: 'text'; text: string } =>
148
+ !!b && typeof b === 'object' && (b as { type?: unknown }).type === 'text'
149
+ && typeof (b as { text?: unknown }).text === 'string'
150
+ )
151
+ .map(b => b.text)
152
+ .join('\n');
153
+ }
154
+ const metadata = (rec.metadata && typeof rec.metadata === 'object')
155
+ ? rec.metadata as Record<string, unknown>
156
+ : {};
157
+ const mount = typeof rec.mount === 'string' ? rec.mount : undefined;
158
+ const paths = Array.isArray(rec.paths)
159
+ ? rec.paths.filter((p): p is string => typeof p === 'string')
160
+ : undefined;
161
+ return { content, metadata, mount, paths };
162
+ }
163
+
123
164
  /**
124
165
  * The main agent framework.
125
166
  */
@@ -173,6 +214,9 @@ export class AgentFramework {
173
214
  // EventGate (null when FrameworkConfig.gate is omitted)
174
215
  private eventGate: EventGate | null = null;
175
216
 
217
+ // Session-level token usage tracking (always-on)
218
+ private usageTracker: UsageTracker;
219
+
176
220
  private constructor(
177
221
  store: JsStore,
178
222
  ownsStore: boolean,
@@ -192,6 +236,9 @@ export class AgentFramework {
192
236
  this.processLoggingPersist = processLoggingPersist;
193
237
  this.processLoggingBroadcast = processLoggingBroadcast;
194
238
  this.queue = new ProcessQueueImpl();
239
+ this.usageTracker = new UsageTracker({
240
+ emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
241
+ });
195
242
 
196
243
  // Initialize module registry with callbacks
197
244
  this.moduleRegistry = new ModuleRegistry(store, this.queue, {
@@ -276,6 +323,9 @@ export class AgentFramework {
276
323
  processLoggingBroadcast
277
324
  );
278
325
 
326
+ // Restore persisted usage data (if any) from prior session
327
+ framework.restoreUsageState();
328
+
279
329
  // Create agents
280
330
  for (const agentConfig of config.agents) {
281
331
  await framework.createAgent(agentConfig);
@@ -424,6 +474,16 @@ export class AgentFramework {
424
474
  this.traceListeners.push(listener);
425
475
  }
426
476
 
477
+ /**
478
+ * Public accessor for the MCPL channel registry.
479
+ * Null when no MCPL servers are configured.
480
+ * Modules that need channel-level operations (typing indicators, default publish channel,
481
+ * etc.) obtain them here.
482
+ */
483
+ get channels(): ChannelRegistry | null {
484
+ return this.channelRegistry;
485
+ }
486
+
427
487
  /**
428
488
  * Remove a trace event listener.
429
489
  */
@@ -530,6 +590,36 @@ export class AgentFramework {
530
590
  return this.membrane;
531
591
  }
532
592
 
593
+ getSessionUsage(): SessionUsageSnapshot {
594
+ return this.usageTracker.getSnapshot();
595
+ }
596
+
597
+ private restoreUsageState(): void {
598
+ try {
599
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
600
+ if (data && typeof data === 'object' && (data as any).usage) {
601
+ const restored = (data as any).usage as PersistedUsageState;
602
+ this.usageTracker = new UsageTracker({
603
+ emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
604
+ restored,
605
+ });
606
+ }
607
+ } catch {
608
+ // No prior state or corrupt — start fresh (already initialized in constructor)
609
+ }
610
+ }
611
+
612
+ private persistUsageState(): void {
613
+ try {
614
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
615
+ const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
616
+ state.usage = this.usageTracker.toJSON();
617
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
618
+ } catch {
619
+ // Non-fatal — usage tracking is best-effort
620
+ }
621
+ }
622
+
533
623
  /**
534
624
  * Create an ephemeral agent that is NOT registered in the main event loop.
535
625
  *
@@ -1422,13 +1512,11 @@ export class AgentFramework {
1422
1512
 
1423
1513
  // Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry)
1424
1514
  if (this.eventGate && event.type !== 'mcpl:push-event' && event.type !== 'mcpl:channel-incoming') {
1425
- const content = 'content' in event ? String((event as { content: unknown }).content) : '';
1426
1515
  const decision = this.eventGate.evaluate({
1427
- content,
1516
+ ...extractGateFields(event),
1428
1517
  eventType: event.type,
1429
1518
  serverId: source,
1430
1519
  channelId: '',
1431
- metadata: 'metadata' in event ? (event as { metadata: Record<string, unknown> }).metadata : {},
1432
1520
  });
1433
1521
  if (!decision.trigger) return;
1434
1522
  }
@@ -1823,12 +1911,13 @@ export class AgentFramework {
1823
1911
  const speechContent = hadToolCalls ? [] : allText;
1824
1912
  const thoughts = hadToolCalls ? allText : [];
1825
1913
 
1826
- const tokenUsage = response.usage
1914
+ const du = response.details?.usage;
1915
+ const tokenUsage = du
1827
1916
  ? {
1828
- input: response.usage.inputTokens,
1829
- output: response.usage.outputTokens,
1830
- cacheCreation: response.details?.usage?.cacheCreationTokens,
1831
- cacheRead: response.details?.usage?.cacheReadTokens,
1917
+ input: du.inputTokens,
1918
+ output: du.outputTokens,
1919
+ cacheCreation: du.cacheCreationTokens,
1920
+ cacheRead: du.cacheReadTokens,
1832
1921
  }
1833
1922
  : undefined;
1834
1923
  this.emitTrace({
@@ -1838,6 +1927,16 @@ export class AgentFramework {
1838
1927
  tokenUsage,
1839
1928
  });
1840
1929
 
1930
+ if (du) {
1931
+ this.usageTracker.onInferenceCompleted(agent.name, {
1932
+ inputTokens: du.inputTokens,
1933
+ outputTokens: du.outputTokens,
1934
+ cacheCreationTokens: du.cacheCreationTokens,
1935
+ cacheReadTokens: du.cacheReadTokens,
1936
+ }, du.estimatedCost ? { total: du.estimatedCost.total, currency: du.estimatedCost.currency } : undefined);
1937
+ this.persistUsageState();
1938
+ }
1939
+
1841
1940
  // Log inference
1842
1941
  this.logInference({
1843
1942
  timestamp: startTime,
@@ -1942,6 +2041,8 @@ export class AgentFramework {
1942
2041
  tokenUsage: {
1943
2042
  input: event.usage.inputTokens,
1944
2043
  output: event.usage.outputTokens,
2044
+ cacheCreation: event.usage.cacheCreationTokens,
2045
+ cacheRead: event.usage.cacheReadTokens,
1945
2046
  },
1946
2047
  });
1947
2048
  break;
@@ -2011,6 +2112,13 @@ export class AgentFramework {
2011
2112
  }
2012
2113
  const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
2013
2114
  const toolName = call.name.slice(prefix.length + 2); // Strip prefix + '--'
2115
+ if (!isToolAllowed(toolName, config)) {
2116
+ return {
2117
+ success: false,
2118
+ error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
2119
+ isError: true,
2120
+ };
2121
+ }
2014
2122
  try {
2015
2123
  const result = await server.sendToolsCall(toolName, call.input as Record<string, unknown>);
2016
2124
  return {
@@ -2367,10 +2475,10 @@ export class AgentFramework {
2367
2475
  (event) => this.pushEvent(event),
2368
2476
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
2369
2477
  {
2370
- sendTypingFn: (serverId, channelId) => {
2478
+ sendTypingFn: (serverId, channelId, metadata, op) => {
2371
2479
  const server = this.mcplServerRegistry!.getServer(serverId);
2372
2480
  if (server) {
2373
- server.sendChannelsTyping(channelId);
2481
+ server.sendChannelsTyping(channelId, metadata, op);
2374
2482
  }
2375
2483
  },
2376
2484
  shouldTriggerInference: triggerFilter,
@@ -2390,6 +2498,12 @@ export class AgentFramework {
2390
2498
 
2391
2499
  for (const config of serverConfigs) {
2392
2500
  try {
2501
+ // Record per-server channel subscription policy before the server
2502
+ // registers channels — handleRegister fires during the handshake.
2503
+ if (config.channelSubscription !== undefined && this.channelRegistry) {
2504
+ this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
2505
+ }
2506
+
2393
2507
  const connection = await this.mcplServerRegistry.addServer(config, hostCapabilities);
2394
2508
 
2395
2509
  // Wire event listeners then flush any events that arrived during the
@@ -2451,6 +2565,12 @@ export class AgentFramework {
2451
2565
  * Push events and inference requests are deferred to Steps 6/7.
2452
2566
  */
2453
2567
  private wireMcplEvents(connection: McplServerConnection): void {
2568
+ // Forward subprocess stderr lines as trace events so consumers (conhost,
2569
+ // log sinks, TUI badges) can persist and surface them.
2570
+ connection.on('stderr', (params: { line: string }) => {
2571
+ this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
2572
+ });
2573
+
2454
2574
  // Handle dynamic feature set changes from server
2455
2575
  connection.on('feature-sets-changed', (params: FeatureSetsChangedParams) => {
2456
2576
  this.featureSetManager?.handleFeatureSetsChanged(connection.id, params);
@@ -2548,6 +2668,7 @@ export class AgentFramework {
2548
2668
  try {
2549
2669
  const result = await server.sendToolsList();
2550
2670
  for (const tool of result.tools) {
2671
+ if (!isToolAllowed(tool.name, config)) continue;
2551
2672
  // MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
2552
2673
  const schema = tool.inputSchema as import('./types/index.js').ToolDefinition['inputSchema'];
2553
2674
  tools.push({
@@ -2637,6 +2758,23 @@ export class AgentFramework {
2637
2758
  return;
2638
2759
  }
2639
2760
 
2761
+ const config = this.mcplServerConfigs.get(serverId);
2762
+ if (!isToolAllowed(toolName, config)) {
2763
+ this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
2764
+ this.pushEvent({
2765
+ type: 'tool-result',
2766
+ callId: call.id,
2767
+ agentName,
2768
+ moduleName: `mcpl:${serverId}`,
2769
+ result: {
2770
+ success: false,
2771
+ error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
2772
+ isError: true,
2773
+ },
2774
+ });
2775
+ return;
2776
+ }
2777
+
2640
2778
  this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
2641
2779
  const startTime = Date.now();
2642
2780
  const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
@@ -76,6 +76,8 @@ interface CompiledPolicy {
76
76
  filterRegex?: RegExp;
77
77
  sourceRegex?: RegExp;
78
78
  channelRegex?: RegExp;
79
+ mountRegex?: RegExp;
80
+ pathRegex?: RegExp;
79
81
  }
80
82
 
81
83
  interface PolicyStats {
@@ -170,6 +172,8 @@ function validatePolicy(raw: unknown): GatePolicy {
170
172
  if (Array.isArray(m.scope)) match.scope = m.scope.filter(s => typeof s === 'string');
171
173
  if (typeof m.source === 'string') match.source = m.source;
172
174
  if (typeof m.channel === 'string') match.channel = m.channel;
175
+ if (typeof m.mount === 'string') match.mount = m.mount;
176
+ if (typeof m.pathGlob === 'string') match.pathGlob = m.pathGlob;
173
177
  if (m.filter && typeof m.filter === 'object') {
174
178
  const f = m.filter as Record<string, unknown>;
175
179
  if ((f.type === 'text' || f.type === 'regex') && typeof f.pattern === 'string') {
@@ -210,6 +214,12 @@ export class EventGate {
210
214
  // Per-policy stats
211
215
  private stats = new Map<string, PolicyStats>();
212
216
 
217
+ // Observability counters for events that didn't match any policy
218
+ private totalEvaluations = 0;
219
+ private defaultTriggered = 0;
220
+ private defaultSkipped = 0;
221
+ private defaultByEventType = new Map<string, { triggered: number; skipped: number }>();
222
+
213
223
  // Dependency-injected callbacks
214
224
  private emitTrace: (event: TraceEventLike) => void;
215
225
  private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
@@ -230,13 +240,44 @@ export class EventGate {
230
240
  this.requestInferenceFn = opts.requestInference;
231
241
  this.getAgentNamesFn = opts.getAgentNames;
232
242
 
233
- // Seed config file if it doesn't exist
234
- if (opts.initialConfig && !existsSync(this.configPath)) {
243
+ // Seed or reconcile the on-disk config.
244
+ //
245
+ // First start: write `initialConfig` verbatim.
246
+ // Subsequent starts: if the recipe/config source declares policies that
247
+ // aren't yet in gate.json (e.g. the user edited the recipe between
248
+ // sessions), append them by name. Existing policies are left alone so
249
+ // user edits via `workspace--edit _config/gate.json` survive.
250
+ // The `default` field is not reconciled — it's an explicit operator
251
+ // choice and a recipe change shouldn't silently flip live wake
252
+ // behavior for a session that's already been running.
253
+ if (opts.initialConfig) {
235
254
  try {
236
255
  mkdirSync(dirname(this.configPath), { recursive: true });
237
- writeFileSync(this.configPath, JSON.stringify(opts.initialConfig, null, 2) + '\n');
256
+ if (!existsSync(this.configPath)) {
257
+ writeFileSync(this.configPath, JSON.stringify(opts.initialConfig, null, 2) + '\n');
258
+ } else {
259
+ const existing = this.loadConfig();
260
+ if (existing) {
261
+ const existingNames = new Set(existing.policies.map(p => p.name));
262
+ const missing = opts.initialConfig.policies.filter(p => !existingNames.has(p.name));
263
+ if (missing.length > 0) {
264
+ const reconciled: GateConfig = {
265
+ ...existing,
266
+ policies: [...existing.policies, ...missing],
267
+ };
268
+ writeFileSync(this.configPath, JSON.stringify(reconciled, null, 2) + '\n');
269
+ opts.emitTrace({
270
+ type: 'gate:config-reconciled',
271
+ configPath: this.configPath,
272
+ addedPolicies: missing.map(p => p.name),
273
+ timestamp: Date.now(),
274
+ });
275
+ }
276
+ }
277
+ }
238
278
  } catch (err) {
239
- this.configErrors.push(`Failed to seed gate.json: ${err}`);
279
+ const msg = err instanceof Error ? err.message : String(err);
280
+ this.configErrors.push(`Failed to seed/reconcile gate.json: ${msg}`);
240
281
  }
241
282
  }
242
283
 
@@ -286,6 +327,12 @@ export class EventGate {
286
327
  if (policy.match.channel) {
287
328
  compiled.channelRegex = compileGlob(policy.match.channel);
288
329
  }
330
+ if (policy.match.mount) {
331
+ compiled.mountRegex = compileGlob(policy.match.mount);
332
+ }
333
+ if (policy.match.pathGlob) {
334
+ compiled.pathRegex = compileGlob(policy.match.pathGlob);
335
+ }
289
336
  return compiled;
290
337
  });
291
338
  }
@@ -368,6 +415,21 @@ export class EventGate {
368
415
  return false;
369
416
  }
370
417
 
418
+ // Mount check (workspace fs events)
419
+ if (compiled.mountRegex) {
420
+ if (!info.mount || !compiled.mountRegex.test(info.mount)) {
421
+ return false;
422
+ }
423
+ }
424
+
425
+ // Path glob check — matches if ANY path matches
426
+ if (compiled.pathRegex) {
427
+ const paths = info.paths;
428
+ if (!paths || paths.length === 0) return false;
429
+ const anyMatch = paths.some(p => compiled.pathRegex!.test(p));
430
+ if (!anyMatch) return false;
431
+ }
432
+
371
433
  // Content filter check
372
434
  if (match.filter) {
373
435
  if (match.filter.type === 'text') {
@@ -394,9 +456,38 @@ export class EventGate {
394
456
  this.reloadIfChanged();
395
457
 
396
458
  const policy = this.matchPolicy(info);
459
+ const decision = this.computeDecision(policy, info);
460
+
461
+ this.totalEvaluations++;
462
+ if (!policy) {
463
+ const bucket = this.defaultByEventType.get(info.eventType)
464
+ ?? { triggered: 0, skipped: 0 };
465
+ if (decision.trigger) {
466
+ this.defaultTriggered++;
467
+ bucket.triggered++;
468
+ } else {
469
+ this.defaultSkipped++;
470
+ bucket.skipped++;
471
+ }
472
+ this.defaultByEventType.set(info.eventType, bucket);
473
+ }
474
+
475
+ this.emitTrace({
476
+ type: 'gate:decision',
477
+ eventType: info.eventType,
478
+ serverId: info.serverId || undefined,
479
+ channelId: info.channelId || undefined,
480
+ matchedPolicy: decision.policyName,
481
+ trigger: decision.trigger,
482
+ behavior: typeof decision.behavior === 'object' ? `debounce:${decision.behavior.debounce}` : decision.behavior,
483
+ timestamp: Date.now(),
484
+ });
485
+
486
+ return decision;
487
+ }
397
488
 
489
+ private computeDecision(policy: GatePolicy | null, info: GateEventInfo): GateDecision {
398
490
  if (!policy) {
399
- // No policy matched — use default
400
491
  const trigger = (this.config.default ?? 'always') === 'always';
401
492
  return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
402
493
  }
@@ -407,7 +498,7 @@ export class EventGate {
407
498
  policyStats.lastMatchTimestamp = Date.now();
408
499
  this.stats.set(policy.name, policyStats);
409
500
 
410
- // Emit trace
501
+ // Legacy trace kept for backward compatibility with existing consumers.
411
502
  this.emitTrace({
412
503
  type: 'gate:policy-matched',
413
504
  policyName: policy.name,
@@ -599,6 +690,9 @@ export class EventGate {
599
690
  return result;
600
691
  });
601
692
 
693
+ const byEventType: Record<string, { triggered: number; skipped: number }> = {};
694
+ for (const [k, v] of this.defaultByEventType) byEventType[k] = { ...v };
695
+
602
696
  return {
603
697
  configPath: this.configPath,
604
698
  configSource: this.configSource,
@@ -606,6 +700,12 @@ export class EventGate {
606
700
  default: this.config.default ?? 'always',
607
701
  policies,
608
702
  errors: [...this.configErrors],
703
+ totalEvaluations: this.totalEvaluations,
704
+ defaultDecisions: {
705
+ triggered: this.defaultTriggered,
706
+ skipped: this.defaultSkipped,
707
+ byEventType,
708
+ },
609
709
  };
610
710
  }
611
711
 
package/src/gate/types.ts CHANGED
@@ -30,6 +30,16 @@ export interface GatePolicyMatch {
30
30
  channel?: string;
31
31
  /** Content text filter. */
32
32
  filter?: { type: 'text' | 'regex'; pattern: string };
33
+ /**
34
+ * Mount name to match (exact or glob with *). Populated for workspace
35
+ * filesystem events (workspace:created/modified/deleted).
36
+ */
37
+ mount?: string;
38
+ /**
39
+ * Glob applied against each path in the event. Matches if ANY path matches.
40
+ * Supports `*` wildcard; not full gitignore syntax.
41
+ */
42
+ pathGlob?: string;
33
43
  }
34
44
 
35
45
  // ---------------------------------------------------------------------------
@@ -87,6 +97,10 @@ export interface GateEventInfo {
87
97
  serverId: string;
88
98
  channelId: string;
89
99
  metadata?: Record<string, unknown>;
100
+ /** Workspace mount name (for workspace:* events). */
101
+ mount?: string;
102
+ /** Mount-prefixed paths touched (for workspace:* events). */
103
+ paths?: string[];
90
104
  }
91
105
 
92
106
  /** Per-policy runtime statistics (for gate:status). */
@@ -109,4 +123,18 @@ export interface GateStatus {
109
123
  default: 'always' | 'skip';
110
124
  policies: GatePolicyStats[];
111
125
  errors: string[];
126
+ /** Total events the gate has evaluated since startup. */
127
+ totalEvaluations: number;
128
+ /**
129
+ * Count of events that fell through to `default` (no policy matched),
130
+ * broken down by whether they triggered or were skipped. Useful for
131
+ * spotting events that are arriving but silently dropped — if a policy
132
+ * appears to never fire (matchCount 0) and this count is growing for the
133
+ * same eventType, the event is reaching the gate but being dropped.
134
+ */
135
+ defaultDecisions: {
136
+ triggered: number;
137
+ skipped: number;
138
+ byEventType: Record<string, { triggered: number; skipped: number }>;
139
+ };
112
140
  }
package/src/index.ts CHANGED
@@ -52,10 +52,17 @@ export type {
52
52
  // Note: AgentInfo, MessageEditedEvent, MessageRemovedEvent intentionally not re-exported
53
53
  // from api/types.js to avoid conflicts with ./types/index.js
54
54
 
55
+ // Usage tracking
56
+ export { UsageTracker } from './usage/index.js';
57
+ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './usage/index.js';
58
+
55
59
  // EventGate
56
60
  export { EventGate } from './gate/index.js';
57
61
  export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior } from './gate/index.js';
58
62
 
63
+ // MCPL channel registry (exposed for modules that need channel-level operations)
64
+ export { ChannelRegistry } from './mcpl/index.js';
65
+
59
66
  // Re-export commonly used types from dependencies
60
67
  export type { ContextManager, ContextStrategy, TokenBudget } from '@animalabs/context-manager';
61
68
  export { PassthroughStrategy, AutobiographicalStrategy, KnowledgeStrategy } from '@animalabs/context-manager';