@animalabs/agent-framework 0.1.0 → 0.2.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 +5 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +91 -8
- 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 +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +11 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +40 -1
- 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/types.d.ts +15 -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 +36 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +210 -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 +44 -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 +28 -3
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +53 -12
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/types/trace.d.ts +16 -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/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/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 +90 -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 +104 -8
- package/src/gate/event-gate.ts +106 -6
- package/src/gate/types.ts +28 -0
- package/src/index.ts +4 -0
- package/src/mcpl/channel-registry.ts +45 -1
- package/src/mcpl/push-handler.ts +1 -1
- package/src/mcpl/types.ts +16 -0
- package/src/modules/workspace/index.ts +228 -37
- package/src/modules/workspace/sync.ts +11 -4
- package/src/modules/workspace/types.ts +59 -3
- package/src/modules/workspace/watcher.ts +64 -15
- package/src/types/trace.ts +19 -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
|
@@ -52,6 +52,8 @@ 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
54
|
import { EventGate } from './gate/event-gate.js';
|
|
55
|
+
import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
|
|
56
|
+
import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
|
|
55
57
|
import type { McplServerConnection } from './mcpl/server-connection.js';
|
|
56
58
|
import type {
|
|
57
59
|
McplServerConfig,
|
|
@@ -120,6 +122,44 @@ class DefaultErrorPolicy implements ErrorPolicy {
|
|
|
120
122
|
/** Default sync interval in milliseconds */
|
|
121
123
|
const DEFAULT_SYNC_INTERVAL_MS = 1000;
|
|
122
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Extract fields the EventGate cares about from a ProcessEvent. The set of
|
|
127
|
+
* event variants that carry `content`/`mount`/`paths`/`metadata` is open-ended
|
|
128
|
+
* (modules can define CustomEvents), so we read by name rather than match the
|
|
129
|
+
* discriminant. Centralizing this lets new gate-visible fields be added in one
|
|
130
|
+
* place instead of retyping the narrowing at every call site.
|
|
131
|
+
*/
|
|
132
|
+
function extractGateFields(event: ProcessEvent): {
|
|
133
|
+
content: string;
|
|
134
|
+
metadata: Record<string, unknown>;
|
|
135
|
+
mount?: string;
|
|
136
|
+
paths?: string[];
|
|
137
|
+
} {
|
|
138
|
+
const rec = event as unknown as Record<string, unknown>;
|
|
139
|
+
const rawContent = rec.content;
|
|
140
|
+
let content = '';
|
|
141
|
+
if (typeof rawContent === 'string') {
|
|
142
|
+
content = rawContent;
|
|
143
|
+
} else if (Array.isArray(rawContent)) {
|
|
144
|
+
// ContentBlock[] — concatenate text blocks so gate content filters match
|
|
145
|
+
content = rawContent
|
|
146
|
+
.filter((b): b is { type: 'text'; text: string } =>
|
|
147
|
+
!!b && typeof b === 'object' && (b as { type?: unknown }).type === 'text'
|
|
148
|
+
&& typeof (b as { text?: unknown }).text === 'string'
|
|
149
|
+
)
|
|
150
|
+
.map(b => b.text)
|
|
151
|
+
.join('\n');
|
|
152
|
+
}
|
|
153
|
+
const metadata = (rec.metadata && typeof rec.metadata === 'object')
|
|
154
|
+
? rec.metadata as Record<string, unknown>
|
|
155
|
+
: {};
|
|
156
|
+
const mount = typeof rec.mount === 'string' ? rec.mount : undefined;
|
|
157
|
+
const paths = Array.isArray(rec.paths)
|
|
158
|
+
? rec.paths.filter((p): p is string => typeof p === 'string')
|
|
159
|
+
: undefined;
|
|
160
|
+
return { content, metadata, mount, paths };
|
|
161
|
+
}
|
|
162
|
+
|
|
123
163
|
/**
|
|
124
164
|
* The main agent framework.
|
|
125
165
|
*/
|
|
@@ -173,6 +213,9 @@ export class AgentFramework {
|
|
|
173
213
|
// EventGate (null when FrameworkConfig.gate is omitted)
|
|
174
214
|
private eventGate: EventGate | null = null;
|
|
175
215
|
|
|
216
|
+
// Session-level token usage tracking (always-on)
|
|
217
|
+
private usageTracker: UsageTracker;
|
|
218
|
+
|
|
176
219
|
private constructor(
|
|
177
220
|
store: JsStore,
|
|
178
221
|
ownsStore: boolean,
|
|
@@ -192,6 +235,9 @@ export class AgentFramework {
|
|
|
192
235
|
this.processLoggingPersist = processLoggingPersist;
|
|
193
236
|
this.processLoggingBroadcast = processLoggingBroadcast;
|
|
194
237
|
this.queue = new ProcessQueueImpl();
|
|
238
|
+
this.usageTracker = new UsageTracker({
|
|
239
|
+
emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
|
|
240
|
+
});
|
|
195
241
|
|
|
196
242
|
// Initialize module registry with callbacks
|
|
197
243
|
this.moduleRegistry = new ModuleRegistry(store, this.queue, {
|
|
@@ -276,6 +322,9 @@ export class AgentFramework {
|
|
|
276
322
|
processLoggingBroadcast
|
|
277
323
|
);
|
|
278
324
|
|
|
325
|
+
// Restore persisted usage data (if any) from prior session
|
|
326
|
+
framework.restoreUsageState();
|
|
327
|
+
|
|
279
328
|
// Create agents
|
|
280
329
|
for (const agentConfig of config.agents) {
|
|
281
330
|
await framework.createAgent(agentConfig);
|
|
@@ -530,6 +579,36 @@ export class AgentFramework {
|
|
|
530
579
|
return this.membrane;
|
|
531
580
|
}
|
|
532
581
|
|
|
582
|
+
getSessionUsage(): SessionUsageSnapshot {
|
|
583
|
+
return this.usageTracker.getSnapshot();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private restoreUsageState(): void {
|
|
587
|
+
try {
|
|
588
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
589
|
+
if (data && typeof data === 'object' && (data as any).usage) {
|
|
590
|
+
const restored = (data as any).usage as PersistedUsageState;
|
|
591
|
+
this.usageTracker = new UsageTracker({
|
|
592
|
+
emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
|
|
593
|
+
restored,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
} catch {
|
|
597
|
+
// No prior state or corrupt — start fresh (already initialized in constructor)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private persistUsageState(): void {
|
|
602
|
+
try {
|
|
603
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
604
|
+
const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
|
|
605
|
+
state.usage = this.usageTracker.toJSON();
|
|
606
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
607
|
+
} catch {
|
|
608
|
+
// Non-fatal — usage tracking is best-effort
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
533
612
|
/**
|
|
534
613
|
* Create an ephemeral agent that is NOT registered in the main event loop.
|
|
535
614
|
*
|
|
@@ -1422,13 +1501,11 @@ export class AgentFramework {
|
|
|
1422
1501
|
|
|
1423
1502
|
// Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry)
|
|
1424
1503
|
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
1504
|
const decision = this.eventGate.evaluate({
|
|
1427
|
-
|
|
1505
|
+
...extractGateFields(event),
|
|
1428
1506
|
eventType: event.type,
|
|
1429
1507
|
serverId: source,
|
|
1430
1508
|
channelId: '',
|
|
1431
|
-
metadata: 'metadata' in event ? (event as { metadata: Record<string, unknown> }).metadata : {},
|
|
1432
1509
|
});
|
|
1433
1510
|
if (!decision.trigger) return;
|
|
1434
1511
|
}
|
|
@@ -1823,12 +1900,13 @@ export class AgentFramework {
|
|
|
1823
1900
|
const speechContent = hadToolCalls ? [] : allText;
|
|
1824
1901
|
const thoughts = hadToolCalls ? allText : [];
|
|
1825
1902
|
|
|
1826
|
-
const
|
|
1903
|
+
const du = response.details?.usage;
|
|
1904
|
+
const tokenUsage = du
|
|
1827
1905
|
? {
|
|
1828
|
-
input:
|
|
1829
|
-
output:
|
|
1830
|
-
cacheCreation:
|
|
1831
|
-
cacheRead:
|
|
1906
|
+
input: du.inputTokens,
|
|
1907
|
+
output: du.outputTokens,
|
|
1908
|
+
cacheCreation: du.cacheCreationTokens,
|
|
1909
|
+
cacheRead: du.cacheReadTokens,
|
|
1832
1910
|
}
|
|
1833
1911
|
: undefined;
|
|
1834
1912
|
this.emitTrace({
|
|
@@ -1838,6 +1916,16 @@ export class AgentFramework {
|
|
|
1838
1916
|
tokenUsage,
|
|
1839
1917
|
});
|
|
1840
1918
|
|
|
1919
|
+
if (du) {
|
|
1920
|
+
this.usageTracker.onInferenceCompleted(agent.name, {
|
|
1921
|
+
inputTokens: du.inputTokens,
|
|
1922
|
+
outputTokens: du.outputTokens,
|
|
1923
|
+
cacheCreationTokens: du.cacheCreationTokens,
|
|
1924
|
+
cacheReadTokens: du.cacheReadTokens,
|
|
1925
|
+
}, du.estimatedCost ? { total: du.estimatedCost.total, currency: du.estimatedCost.currency } : undefined);
|
|
1926
|
+
this.persistUsageState();
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1841
1929
|
// Log inference
|
|
1842
1930
|
this.logInference({
|
|
1843
1931
|
timestamp: startTime,
|
|
@@ -1942,6 +2030,8 @@ export class AgentFramework {
|
|
|
1942
2030
|
tokenUsage: {
|
|
1943
2031
|
input: event.usage.inputTokens,
|
|
1944
2032
|
output: event.usage.outputTokens,
|
|
2033
|
+
cacheCreation: event.usage.cacheCreationTokens,
|
|
2034
|
+
cacheRead: event.usage.cacheReadTokens,
|
|
1945
2035
|
},
|
|
1946
2036
|
});
|
|
1947
2037
|
break;
|
|
@@ -2390,6 +2480,12 @@ export class AgentFramework {
|
|
|
2390
2480
|
|
|
2391
2481
|
for (const config of serverConfigs) {
|
|
2392
2482
|
try {
|
|
2483
|
+
// Record per-server channel subscription policy before the server
|
|
2484
|
+
// registers channels — handleRegister fires during the handshake.
|
|
2485
|
+
if (config.channelSubscription !== undefined && this.channelRegistry) {
|
|
2486
|
+
this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2393
2489
|
const connection = await this.mcplServerRegistry.addServer(config, hostCapabilities);
|
|
2394
2490
|
|
|
2395
2491
|
// Wire event listeners then flush any events that arrived during the
|
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,6 +52,10 @@ 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';
|
|
@@ -195,6 +195,13 @@ export class ChannelRegistry {
|
|
|
195
195
|
/** Per-channel typing indicator timers. */
|
|
196
196
|
private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Per-server channel auto-open policy. Populated by the framework from
|
|
200
|
+
* each McplServerConfig at connect time; default ('auto') applies when
|
|
201
|
+
* unset.
|
|
202
|
+
*/
|
|
203
|
+
private subscriptionPolicies = new Map<string, 'auto' | 'manual' | string[]>();
|
|
204
|
+
|
|
198
205
|
constructor(
|
|
199
206
|
serverRegistry: McplServerRegistry,
|
|
200
207
|
featureSetManager: FeatureSetManager,
|
|
@@ -212,6 +219,14 @@ export class ChannelRegistry {
|
|
|
212
219
|
this.shouldTriggerInference = options?.shouldTriggerInference;
|
|
213
220
|
}
|
|
214
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Set the channel auto-open policy for a server. Called by the framework
|
|
224
|
+
* when each MCPL server is registered.
|
|
225
|
+
*/
|
|
226
|
+
setSubscriptionPolicy(serverId: string, policy: 'auto' | 'manual' | string[]): void {
|
|
227
|
+
this.subscriptionPolicies.set(serverId, policy);
|
|
228
|
+
}
|
|
229
|
+
|
|
215
230
|
// ==========================================================================
|
|
216
231
|
// Handler Methods (called from framework.ts wireMcplEvents)
|
|
217
232
|
// ==========================================================================
|
|
@@ -339,7 +354,7 @@ export class ChannelRegistry {
|
|
|
339
354
|
textContent,
|
|
340
355
|
{
|
|
341
356
|
...message.metadata,
|
|
342
|
-
eventType: 'channel
|
|
357
|
+
eventType: 'mcpl:channel-incoming',
|
|
343
358
|
serverId,
|
|
344
359
|
channelId: message.channelId,
|
|
345
360
|
messageId: message.messageId,
|
|
@@ -574,7 +589,23 @@ export class ChannelRegistry {
|
|
|
574
589
|
const server = this.serverRegistry.getServer(serverId);
|
|
575
590
|
if (!server) return;
|
|
576
591
|
|
|
592
|
+
const policy = this.subscriptionPolicies.get(serverId) ?? 'auto';
|
|
593
|
+
if (policy === 'manual') {
|
|
594
|
+
this.emitTraceFn({
|
|
595
|
+
type: 'mcpl:channels-auto-open-skipped',
|
|
596
|
+
serverId,
|
|
597
|
+
reason: 'manual',
|
|
598
|
+
count: channels.length,
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const allowList = Array.isArray(policy) ? new Set(policy) : null;
|
|
604
|
+
let opened = 0;
|
|
605
|
+
|
|
577
606
|
for (const channel of channels) {
|
|
607
|
+
if (allowList && !allowList.has(channel.id)) continue;
|
|
608
|
+
|
|
578
609
|
const key = `${serverId}:${channel.id}`;
|
|
579
610
|
try {
|
|
580
611
|
await server.sendChannelsOpen({
|
|
@@ -585,6 +616,7 @@ export class ChannelRegistry {
|
|
|
585
616
|
if (entry) {
|
|
586
617
|
entry.open = true;
|
|
587
618
|
}
|
|
619
|
+
opened++;
|
|
588
620
|
} catch (err) {
|
|
589
621
|
this.emitTraceFn({
|
|
590
622
|
type: 'mcpl:channel-open-failed',
|
|
@@ -594,6 +626,18 @@ export class ChannelRegistry {
|
|
|
594
626
|
});
|
|
595
627
|
}
|
|
596
628
|
}
|
|
629
|
+
|
|
630
|
+
// Surface the common typo failure mode: allow-list set but nothing
|
|
631
|
+
// matched. Without this trace a one-char typo in a channel id looks
|
|
632
|
+
// identical to "MCPL didn't register the channel".
|
|
633
|
+
if (allowList && opened === 0 && channels.length > 0) {
|
|
634
|
+
this.emitTraceFn({
|
|
635
|
+
type: 'mcpl:channels-allow-list-empty',
|
|
636
|
+
serverId,
|
|
637
|
+
allowList: [...allowList],
|
|
638
|
+
registered: channels.map(c => c.id),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
597
641
|
}
|
|
598
642
|
|
|
599
643
|
// ==========================================================================
|
package/src/mcpl/push-handler.ts
CHANGED
|
@@ -195,7 +195,7 @@ export class PushHandler {
|
|
|
195
195
|
serverId,
|
|
196
196
|
featureSet: params.featureSet,
|
|
197
197
|
eventId: params.eventId,
|
|
198
|
-
eventType: 'push
|
|
198
|
+
eventType: 'mcpl:push-event',
|
|
199
199
|
...(params.origin ?? {}),
|
|
200
200
|
};
|
|
201
201
|
triggerInference = this.shouldTriggerInference(textContent, metadata);
|
package/src/mcpl/types.ts
CHANGED
|
@@ -222,6 +222,22 @@ export interface McplServerConfig {
|
|
|
222
222
|
* Must not collide with any module name or another server's prefix.
|
|
223
223
|
*/
|
|
224
224
|
toolPrefix?: string;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* How channels registered by this server are opened.
|
|
228
|
+
* - `'auto'` (default) — every registered channel is auto-opened; the host
|
|
229
|
+
* receives all traffic. Matches prior behavior. Right for agents that
|
|
230
|
+
* should listen passively (e.g. a frontdesk clerk).
|
|
231
|
+
* - `'manual'` — registered channels are listed but not opened. The agent
|
|
232
|
+
* must call the `channel_open` tool to start receiving messages. Right
|
|
233
|
+
* for focused agents that should not be flooded with chatter from
|
|
234
|
+
* servers that advertise many public channels (e.g. Zulip's full stream
|
|
235
|
+
* list).
|
|
236
|
+
* - `string[]` — allow-list of channel ids; only matching channels are
|
|
237
|
+
* auto-opened. Ids are server-scoped (the raw id the server registers,
|
|
238
|
+
* e.g. `"zulip:tracker-miner-f"`).
|
|
239
|
+
*/
|
|
240
|
+
channelSubscription?: 'auto' | 'manual' | string[];
|
|
225
241
|
}
|
|
226
242
|
|
|
227
243
|
// ============================================================================
|