@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.
- package/dist/src/framework.d.ts +13 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +133 -10
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +5 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +104 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +31 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +16 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +101 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -1
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +17 -2
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +66 -3
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-policy.d.ts +24 -0
- package/dist/src/mcpl/tool-policy.d.ts.map +1 -0
- package/dist/src/mcpl/tool-policy.js +46 -0
- package/dist/src/mcpl/tool-policy.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +31 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +43 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +246 -32
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +7 -2
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +2 -2
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +55 -3
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js +8 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +40 -3
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +80 -13
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/types/trace.d.ts +20 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/index.d.ts +3 -0
- package/dist/src/usage/index.d.ts.map +1 -0
- package/dist/src/usage/index.js +2 -0
- package/dist/src/usage/index.js.map +1 -0
- package/dist/src/usage/types.d.ts +28 -0
- package/dist/src/usage/types.d.ts.map +1 -0
- package/dist/src/usage/types.js +2 -0
- package/dist/src/usage/types.js.map +1 -0
- package/dist/src/usage/usage-tracker.d.ts +33 -0
- package/dist/src/usage/usage-tracker.d.ts.map +1 -0
- package/dist/src/usage/usage-tracker.js +115 -0
- package/dist/src/usage/usage-tracker.js.map +1 -0
- package/dist/test/channel-registry-typing.test.d.ts +2 -0
- package/dist/test/channel-registry-typing.test.d.ts.map +1 -0
- package/dist/test/channel-registry-typing.test.js +82 -0
- package/dist/test/channel-registry-typing.test.js.map +1 -0
- package/dist/test/event-gate.test.js +135 -16
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/mcpl-gate-roundtrip.test.d.ts +2 -0
- package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +1 -0
- package/dist/test/mcpl-gate-roundtrip.test.js +140 -0
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -0
- package/dist/test/tool-policy.test.d.ts +2 -0
- package/dist/test/tool-policy.test.d.ts.map +1 -0
- package/dist/test/tool-policy.test.js +51 -0
- package/dist/test/tool-policy.test.js.map +1 -0
- package/dist/test/usage-tracker.test.d.ts +2 -0
- package/dist/test/usage-tracker.test.d.ts.map +1 -0
- package/dist/test/usage-tracker.test.js +129 -0
- package/dist/test/usage-tracker.test.js.map +1 -0
- package/dist/test/workspace-watcher.test.d.ts +2 -0
- package/dist/test/workspace-watcher.test.d.ts.map +1 -0
- package/dist/test/workspace-watcher.test.js +117 -0
- package/dist/test/workspace-watcher.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/framework.ts +148 -10
- package/src/gate/event-gate.ts +106 -6
- package/src/gate/types.ts +28 -0
- package/src/index.ts +7 -0
- package/src/mcpl/channel-registry.ts +120 -8
- package/src/mcpl/push-handler.ts +1 -1
- package/src/mcpl/server-connection.ts +78 -3
- package/src/mcpl/tool-policy.ts +52 -0
- package/src/mcpl/types.ts +34 -0
- package/src/modules/workspace/index.ts +269 -38
- package/src/modules/workspace/sync.ts +11 -4
- package/src/modules/workspace/types.ts +70 -3
- package/src/modules/workspace/watcher.ts +104 -15
- package/src/types/trace.ts +26 -1
- package/src/usage/index.ts +2 -0
- package/src/usage/types.ts +27 -0
- 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
|
-
|
|
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
|
|
1914
|
+
const du = response.details?.usage;
|
|
1915
|
+
const tokenUsage = du
|
|
1827
1916
|
? {
|
|
1828
|
-
input:
|
|
1829
|
-
output:
|
|
1830
|
-
cacheCreation:
|
|
1831
|
-
cacheRead:
|
|
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> : {};
|
package/src/gate/event-gate.ts
CHANGED
|
@@ -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
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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';
|