@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
@@ -1,12 +1,35 @@
1
1
  /**
2
2
  * Filesystem watcher with debounce, ignore patterns, and suppression.
3
+ *
4
+ * Events are carried through with their op type (created/modified/deleted) so
5
+ * downstream wake policies can key on creation vs. modification vs. deletion.
3
6
  */
4
7
 
5
8
  import { watch, type FSWatcher } from 'chokidar';
9
+ import { mkdirSync } from 'node:fs';
6
10
  import { type MountConfig } from './types.js';
7
11
 
12
+ export type FsOp = 'created' | 'modified' | 'deleted';
13
+
14
+ export interface FsChange {
15
+ path: string;
16
+ op: FsOp;
17
+ }
18
+
8
19
  export interface WatcherEvents {
9
- onChange(paths: string[]): void;
20
+ onChange(changes: FsChange[]): void;
21
+ }
22
+
23
+ /**
24
+ * Optional lifecycle callbacks. Without these, chokidar's `ready` and `error`
25
+ * events are silently dropped — which is how a failed attach can look
26
+ * identical to an empty directory in the recorded state.
27
+ */
28
+ export interface WatcherLifecycle {
29
+ /** Fires once, when chokidar completes its initial scan. */
30
+ onReady?: () => void;
31
+ /** Fires on any chokidar-reported error (ENOENT, EACCES, platform faults). */
32
+ onError?: (err: Error) => void;
10
33
  }
11
34
 
12
35
  /**
@@ -15,21 +38,26 @@ export interface WatcherEvents {
15
38
  */
16
39
  export class MountWatcher {
17
40
  private watcher: FSWatcher | null = null;
18
- private pendingChanges = new Set<string>();
41
+ // Most recent op seen per path within the current debounce window.
42
+ // create -> modify collapses to 'created' (single batch); delete always wins.
43
+ private pendingChanges = new Map<string, FsOp>();
19
44
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
20
45
  private suppressedPaths = new Set<string>();
21
46
  private suppressionTimers = new Map<string, ReturnType<typeof setTimeout>>();
22
47
  private readonly debounceMs: number;
23
48
  private readonly config: MountConfig;
24
- private readonly onChangeCallback: (paths: string[]) => void;
49
+ private readonly onChangeCallback: (changes: FsChange[]) => void;
50
+ private readonly lifecycle: WatcherLifecycle;
25
51
 
26
52
  constructor(
27
53
  config: MountConfig,
28
- onChange: (paths: string[]) => void,
54
+ onChange: (changes: FsChange[]) => void,
55
+ lifecycle: WatcherLifecycle = {},
29
56
  ) {
30
57
  this.config = config;
31
58
  this.debounceMs = config.watchDebounceMs ?? 300;
32
59
  this.onChangeCallback = onChange;
60
+ this.lifecycle = lifecycle;
33
61
  }
34
62
 
35
63
  /**
@@ -38,6 +66,21 @@ export class MountWatcher {
38
66
  start(): void {
39
67
  if (this.watcher) return;
40
68
 
69
+ // Read-write mounts: ensure the path exists before chokidar attaches.
70
+ // chokidar 4's fs.watch-based backend silently fails to fire events when
71
+ // the watched path OR any of its parent directories don't exist at
72
+ // subscribe time (see case C in the WSL/Linux repro). `ready` still fires,
73
+ // so the failure is indistinguishable from an empty directory without
74
+ // this defensive mkdir. Read-only mounts are left alone — creating
75
+ // directories a user asked us only to read would be surprising.
76
+ if (this.config.mode === 'read-write') {
77
+ try {
78
+ mkdirSync(this.config.path, { recursive: true });
79
+ } catch (err) {
80
+ this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
81
+ }
82
+ }
83
+
41
84
  const ignored = this.config.ignore ?? [];
42
85
 
43
86
  this.watcher = watch(this.config.path, {
@@ -51,21 +94,31 @@ export class MountWatcher {
51
94
  },
52
95
  });
53
96
 
54
- const handleEvent = (filePath: string) => {
55
- // Convert absolute path to mount-relative
97
+ const handleEvent = (op: FsOp) => (filePath: string) => {
56
98
  const relative = this.toRelative(filePath);
57
99
  if (!relative) return;
58
100
 
59
- // Skip if this path is suppressed (we just wrote it)
101
+ // Skip if this path is suppressed (we just wrote it).
102
+ // Suppression covers all ops — a materialize-then-delete by the module
103
+ // should not echo either the add or the unlink.
60
104
  if (this.suppressedPaths.has(relative)) return;
61
105
 
62
- this.pendingChanges.add(relative);
106
+ this.mergeOp(relative, op);
63
107
  this.scheduleFire();
64
108
  };
65
109
 
66
- this.watcher.on('add', handleEvent);
67
- this.watcher.on('change', handleEvent);
68
- this.watcher.on('unlink', handleEvent);
110
+ this.watcher.on('add', handleEvent('created'));
111
+ this.watcher.on('change', handleEvent('modified'));
112
+ this.watcher.on('unlink', handleEvent('deleted'));
113
+
114
+ if (this.lifecycle.onReady) {
115
+ this.watcher.once('ready', this.lifecycle.onReady);
116
+ }
117
+ if (this.lifecycle.onError) {
118
+ this.watcher.on('error', (err) => {
119
+ this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
120
+ });
121
+ }
69
122
  }
70
123
 
71
124
  /**
@@ -92,12 +145,10 @@ export class MountWatcher {
92
145
  /**
93
146
  * Suppress watcher events for a path temporarily.
94
147
  * Used after materializing to avoid echo events.
95
- * After the cooldown, the path is re-checked via the recheckCallback.
96
148
  */
97
149
  suppress(relativePath: string, cooldownMs = 500): void {
98
150
  this.suppressedPaths.add(relativePath);
99
151
 
100
- // Clear existing timer for this path
101
152
  const existing = this.suppressionTimers.get(relativePath);
102
153
  if (existing) clearTimeout(existing);
103
154
 
@@ -116,6 +167,44 @@ export class MountWatcher {
116
167
  return this.suppressedPaths.has(relativePath);
117
168
  }
118
169
 
170
+ /**
171
+ * Merge a new op for a path into the pending batch. Chokidar can fire
172
+ * multiple events per path inside one debounce window; collapse them to the
173
+ * single op that reflects the net effect at end-of-window.
174
+ *
175
+ * Transitions (prev → op → result):
176
+ * ∅ → * → op
177
+ * * → deleted → deleted (trailing delete wins)
178
+ * deleted → created → modified (atomic save: unlink+rename;
179
+ * file exists with new contents)
180
+ * created → modified → created (still net-new this window)
181
+ * created → created → created
182
+ * modified → created → created (shouldn't happen, but keep
183
+ * created to avoid a false
184
+ * delete->recreate signal)
185
+ * modified → modified → modified
186
+ */
187
+ private mergeOp(path: string, op: FsOp): void {
188
+ const prev = this.pendingChanges.get(path);
189
+ if (!prev) {
190
+ this.pendingChanges.set(path, op);
191
+ return;
192
+ }
193
+ if (op === 'deleted') {
194
+ this.pendingChanges.set(path, 'deleted');
195
+ return;
196
+ }
197
+ if (prev === 'deleted' && op === 'created') {
198
+ this.pendingChanges.set(path, 'modified');
199
+ return;
200
+ }
201
+ if (prev === 'created') {
202
+ this.pendingChanges.set(path, 'created');
203
+ return;
204
+ }
205
+ this.pendingChanges.set(path, op);
206
+ }
207
+
119
208
  private scheduleFire(): void {
120
209
  if (this.debounceTimer) {
121
210
  clearTimeout(this.debounceTimer);
@@ -123,9 +212,9 @@ export class MountWatcher {
123
212
  this.debounceTimer = setTimeout(() => {
124
213
  this.debounceTimer = null;
125
214
  if (this.pendingChanges.size > 0) {
126
- const paths = [...this.pendingChanges];
215
+ const changes: FsChange[] = [...this.pendingChanges].map(([path, op]) => ({ path, op }));
127
216
  this.pendingChanges.clear();
128
- this.onChangeCallback(paths);
217
+ this.onChangeCallback(changes);
129
218
  }
130
219
  }, this.debounceMs);
131
220
  }
@@ -1,5 +1,6 @@
1
1
  import type { ProcessEvent } from './events.js';
2
2
  import type { ModuleProcessResponse } from './framework.js';
3
+ import type { SessionUsage } from '../usage/types.js';
3
4
 
4
5
  /**
5
6
  * Base for all trace events.
@@ -75,7 +76,7 @@ export type TraceEvent =
75
76
  | (TraceEventBase & {
76
77
  type: 'inference:usage';
77
78
  agentName: string;
78
- tokenUsage: { input: number; output: number };
79
+ tokenUsage: { input: number; output: number; cacheCreation?: number; cacheRead?: number };
79
80
  })
80
81
  | (TraceEventBase & {
81
82
  type: 'inference:stream_resumed';
@@ -174,6 +175,23 @@ export type TraceEvent =
174
175
  configPath: string;
175
176
  policyCount: number;
176
177
  })
178
+ | (TraceEventBase & {
179
+ type: 'gate:decision';
180
+ eventType: string;
181
+ serverId?: string;
182
+ channelId?: string;
183
+ matchedPolicy: string | null;
184
+ trigger: boolean;
185
+ behavior: string;
186
+ })
187
+
188
+ // Usage lifecycle
189
+ | (TraceEventBase & {
190
+ type: 'usage:updated';
191
+ totals: SessionUsage;
192
+ agentName: string;
193
+ inferenceCount: number;
194
+ })
177
195
 
178
196
  // Undo/redo lifecycle
179
197
  | (TraceEventBase & {
@@ -188,6 +206,13 @@ export type TraceEvent =
188
206
  agentName: string;
189
207
  fromBranch: string;
190
208
  toBranch: string;
209
+ })
210
+
211
+ // MCPL subprocess stderr (one trace per line, for receipts when things break)
212
+ | (TraceEventBase & {
213
+ type: 'mcpl:server-stderr';
214
+ serverId: string;
215
+ line: string;
191
216
  });
192
217
 
193
218
  /**
@@ -0,0 +1,2 @@
1
+ export { UsageTracker } from './usage-tracker.js';
2
+ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './types.js';
@@ -0,0 +1,27 @@
1
+ export interface SessionUsage {
2
+ inputTokens: number;
3
+ outputTokens: number;
4
+ cacheCreationTokens: number;
5
+ cacheReadTokens: number;
6
+ estimatedCost?: { total: number; currency: string };
7
+ }
8
+
9
+ export interface AgentUsage {
10
+ agentName: string;
11
+ usage: SessionUsage;
12
+ inferenceCount: number;
13
+ }
14
+
15
+ export interface SessionUsageSnapshot {
16
+ totals: SessionUsage;
17
+ byAgent: AgentUsage[];
18
+ inferenceCount: number;
19
+ }
20
+
21
+ /** Trace event emitted by UsageTracker after each inference completion. */
22
+ export interface UsageUpdatedEvent {
23
+ type: 'usage:updated';
24
+ totals: SessionUsage;
25
+ agentName: string;
26
+ inferenceCount: number;
27
+ }
@@ -0,0 +1,141 @@
1
+ import type { SessionUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './types.js';
2
+
3
+ function snapshotUsage(usage: SessionUsage): SessionUsage {
4
+ return {
5
+ ...usage,
6
+ estimatedCost: usage.estimatedCost ? { ...usage.estimatedCost } : undefined,
7
+ };
8
+ }
9
+
10
+ function accumulateUsage(
11
+ target: SessionUsage,
12
+ tokens: { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number },
13
+ estimatedCost?: { total: number; currency: string }
14
+ ): void {
15
+ target.inputTokens += tokens.inputTokens;
16
+ target.outputTokens += tokens.outputTokens;
17
+ target.cacheCreationTokens += tokens.cacheCreationTokens;
18
+ target.cacheReadTokens += tokens.cacheReadTokens;
19
+ if (estimatedCost) {
20
+ if (!target.estimatedCost) {
21
+ target.estimatedCost = { total: 0, currency: estimatedCost.currency };
22
+ } else if (target.estimatedCost.currency !== estimatedCost.currency) {
23
+ // Mixed currencies — drop cost tracking rather than produce garbage
24
+ target.estimatedCost = undefined;
25
+ return;
26
+ }
27
+ target.estimatedCost.total += estimatedCost.total;
28
+ }
29
+ }
30
+
31
+ function parseUsage(raw: unknown): SessionUsage | null {
32
+ if (!raw || typeof raw !== 'object') return null;
33
+ const o = raw as Record<string, unknown>;
34
+ if (typeof o.inputTokens !== 'number' || typeof o.outputTokens !== 'number') return null;
35
+ return {
36
+ inputTokens: o.inputTokens,
37
+ outputTokens: o.outputTokens as number,
38
+ cacheCreationTokens: typeof o.cacheCreationTokens === 'number' ? o.cacheCreationTokens : 0,
39
+ cacheReadTokens: typeof o.cacheReadTokens === 'number' ? o.cacheReadTokens : 0,
40
+ estimatedCost: o.estimatedCost && typeof o.estimatedCost === 'object'
41
+ ? { total: (o.estimatedCost as any).total, currency: (o.estimatedCost as any).currency }
42
+ : undefined,
43
+ };
44
+ }
45
+
46
+ export interface PersistedUsageState {
47
+ totals: SessionUsage;
48
+ byAgent: Array<{ agentName: string; usage: SessionUsage; inferenceCount: number }>;
49
+ inferenceCount: number;
50
+ }
51
+
52
+ export class UsageTracker {
53
+ private totals: SessionUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
54
+ private byAgent: Map<string, { usage: SessionUsage; inferenceCount: number }> = new Map();
55
+ private inferenceCount = 0;
56
+ private emitTrace: (event: UsageUpdatedEvent) => void;
57
+
58
+ constructor(opts: {
59
+ emitTrace: (event: UsageUpdatedEvent) => void;
60
+ restored?: PersistedUsageState;
61
+ }) {
62
+ this.emitTrace = opts.emitTrace;
63
+ if (opts.restored) {
64
+ this.restoreFrom(opts.restored);
65
+ }
66
+ }
67
+
68
+ private restoreFrom(state: PersistedUsageState): void {
69
+ const totals = parseUsage(state.totals);
70
+ if (!totals) return;
71
+ this.totals = totals;
72
+ this.inferenceCount = typeof state.inferenceCount === 'number' ? state.inferenceCount : 0;
73
+ if (Array.isArray(state.byAgent)) {
74
+ for (const entry of state.byAgent) {
75
+ const usage = parseUsage(entry.usage);
76
+ if (usage && typeof entry.agentName === 'string') {
77
+ this.byAgent.set(entry.agentName, {
78
+ usage,
79
+ inferenceCount: typeof entry.inferenceCount === 'number' ? entry.inferenceCount : 0,
80
+ });
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ toJSON(): PersistedUsageState {
87
+ return {
88
+ totals: snapshotUsage(this.totals),
89
+ byAgent: [...this.byAgent.entries()].map(([name, data]) => ({
90
+ agentName: name,
91
+ usage: snapshotUsage(data.usage),
92
+ inferenceCount: data.inferenceCount,
93
+ })),
94
+ inferenceCount: this.inferenceCount,
95
+ };
96
+ }
97
+
98
+ onInferenceCompleted(agentName: string, usage: {
99
+ inputTokens: number;
100
+ outputTokens: number;
101
+ cacheCreationTokens?: number;
102
+ cacheReadTokens?: number;
103
+ }, estimatedCost?: { total: number; currency: string }): void {
104
+ const tokens = {
105
+ inputTokens: usage.inputTokens,
106
+ outputTokens: usage.outputTokens,
107
+ cacheCreationTokens: usage.cacheCreationTokens ?? 0,
108
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
109
+ };
110
+
111
+ accumulateUsage(this.totals, tokens, estimatedCost);
112
+ this.inferenceCount++;
113
+
114
+ let agent = this.byAgent.get(agentName);
115
+ if (!agent) {
116
+ agent = { usage: { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }, inferenceCount: 0 };
117
+ this.byAgent.set(agentName, agent);
118
+ }
119
+ accumulateUsage(agent.usage, tokens, estimatedCost);
120
+ agent.inferenceCount++;
121
+
122
+ this.emitTrace({
123
+ type: 'usage:updated',
124
+ totals: snapshotUsage(this.totals),
125
+ agentName,
126
+ inferenceCount: this.inferenceCount,
127
+ });
128
+ }
129
+
130
+ getSnapshot(): SessionUsageSnapshot {
131
+ return {
132
+ totals: snapshotUsage(this.totals),
133
+ byAgent: [...this.byAgent.entries()].map(([name, data]) => ({
134
+ agentName: name,
135
+ usage: snapshotUsage(data.usage),
136
+ inferenceCount: data.inferenceCount,
137
+ })),
138
+ inferenceCount: this.inferenceCount,
139
+ };
140
+ }
141
+ }