@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
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* auto-sync between real filesystem and Chronicle, and manual materialization.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFile, stat, access } from 'node:fs/promises';
|
|
9
|
-
import { join, resolve, relative } from 'node:path';
|
|
8
|
+
import { readFile, stat, access, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
9
|
+
import { join, resolve, relative, dirname } from 'node:path';
|
|
10
10
|
import type { JsStore } from '@animalabs/chronicle';
|
|
11
11
|
import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
|
|
12
12
|
import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
|
|
@@ -25,9 +25,13 @@ import type {
|
|
|
25
25
|
StatusInput,
|
|
26
26
|
MaterializeInput,
|
|
27
27
|
SyncInput,
|
|
28
|
-
|
|
28
|
+
WorkspaceCreatedEvent,
|
|
29
|
+
WorkspaceModifiedEvent,
|
|
30
|
+
WorkspaceDeletedEvent,
|
|
31
|
+
WorkspaceFsOp,
|
|
29
32
|
} from './types.js';
|
|
30
|
-
import {
|
|
33
|
+
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
34
|
+
import { MountWatcher, type FsChange } from './watcher.js';
|
|
31
35
|
import { syncFromFs, materializeToFs, hashContent, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js';
|
|
32
36
|
|
|
33
37
|
export type {
|
|
@@ -85,6 +89,12 @@ export class WorkspaceModule implements Module {
|
|
|
85
89
|
|
|
86
90
|
/**
|
|
87
91
|
* Inject the Chronicle store. Must be called after framework creation.
|
|
92
|
+
*
|
|
93
|
+
* Host ordering is: `AgentFramework.create()` calls `module.start(ctx)`
|
|
94
|
+
* before the framework is fully returned, and the host (e.g. conhost) wires
|
|
95
|
+
* the store via `initStore(store)` afterwards. Neither half has everything
|
|
96
|
+
* it needs on its own, so watcher setup runs in whichever callback fires
|
|
97
|
+
* second. `ensureRunning()` gates on `ctx && mounts-populated`.
|
|
88
98
|
*/
|
|
89
99
|
initStore(store: JsStore): void {
|
|
90
100
|
this.store = store;
|
|
@@ -114,6 +124,8 @@ export class WorkspaceModule implements Module {
|
|
|
114
124
|
};
|
|
115
125
|
this.mounts.set(mount.name, mountState);
|
|
116
126
|
}
|
|
127
|
+
|
|
128
|
+
this.ensureRunning();
|
|
117
129
|
}
|
|
118
130
|
|
|
119
131
|
async start(ctx: ModuleContext): Promise<void> {
|
|
@@ -133,19 +145,38 @@ export class WorkspaceModule implements Module {
|
|
|
133
145
|
}
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
|
|
148
|
+
this.ensureRunning();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Start chokidar watchers and emit workspace:mounted events.
|
|
153
|
+
* Idempotent: safe to call from both start() and initStore(), fires once
|
|
154
|
+
* per mount regardless of which runs second.
|
|
155
|
+
*/
|
|
156
|
+
private ensureRunning(): void {
|
|
157
|
+
if (!this.ctx || this.mounts.size === 0) return;
|
|
158
|
+
|
|
137
159
|
for (const [name, mount] of this.mounts) {
|
|
160
|
+
if (this.watchers.has(name)) continue;
|
|
161
|
+
|
|
138
162
|
const watchMode = mount.config.watch ?? 'always';
|
|
139
163
|
if (watchMode === 'always') {
|
|
140
|
-
const watcher = new MountWatcher(mount.config, (
|
|
141
|
-
this.handleFsChanges(name,
|
|
164
|
+
const watcher = new MountWatcher(mount.config, (changes) => {
|
|
165
|
+
this.handleFsChanges(name, changes);
|
|
142
166
|
});
|
|
143
167
|
watcher.start();
|
|
144
168
|
this.watchers.set(name, watcher);
|
|
169
|
+
|
|
170
|
+
// Chokidar is started with ignoreInitial:true, so files already on
|
|
171
|
+
// disk at session start would be invisible. Trigger one syncFromFs
|
|
172
|
+
// pass — syncFromFs diffs disk against the tree state, so only files
|
|
173
|
+
// new-to-this-session's-tree fire workspace:created events. Fresh
|
|
174
|
+
// sessions see the existing catalog; restarts only see what appeared
|
|
175
|
+
// while the session was down.
|
|
176
|
+
void this.initialScan(name);
|
|
145
177
|
}
|
|
146
178
|
|
|
147
|
-
|
|
148
|
-
this.ctx?.pushEvent({
|
|
179
|
+
this.ctx.pushEvent({
|
|
149
180
|
type: 'workspace:mounted',
|
|
150
181
|
mount: name,
|
|
151
182
|
path: mount.config.path,
|
|
@@ -153,6 +184,25 @@ export class WorkspaceModule implements Module {
|
|
|
153
184
|
}
|
|
154
185
|
}
|
|
155
186
|
|
|
187
|
+
private async initialScan(mountName: string): Promise<void> {
|
|
188
|
+
const store = this.store;
|
|
189
|
+
const mount = this.mounts.get(mountName);
|
|
190
|
+
if (!store || !mount) return;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const result = await syncFromFs(store, mount);
|
|
194
|
+
mount.initialSyncDone = true;
|
|
195
|
+
this.emitFsEvents(mountName, result.synced, result.conflicts);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
198
|
+
this.ctx?.pushEvent({
|
|
199
|
+
type: 'workspace:initial-scan-failed',
|
|
200
|
+
mount: mountName,
|
|
201
|
+
error: msg,
|
|
202
|
+
} as ProcessEvent);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
156
206
|
async stop(): Promise<void> {
|
|
157
207
|
// Persist state
|
|
158
208
|
if (this.ctx) {
|
|
@@ -330,8 +380,26 @@ export class WorkspaceModule implements Module {
|
|
|
330
380
|
}
|
|
331
381
|
}
|
|
332
382
|
|
|
333
|
-
async onProcess(
|
|
334
|
-
|
|
383
|
+
async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
384
|
+
// Wake agents on filesystem events when the originating mount opted in.
|
|
385
|
+
// The EventGate still has final say — gate policies can further filter by
|
|
386
|
+
// mount name, path glob, or op type without requiring a recipe change.
|
|
387
|
+
if (!(WORKSPACE_FS_EVENT_TYPES as readonly string[]).includes(event.type)) {
|
|
388
|
+
return {};
|
|
389
|
+
}
|
|
390
|
+
const mountName = (event as { mount?: string }).mount;
|
|
391
|
+
if (!mountName) return {};
|
|
392
|
+
const mount = this.mounts.get(mountName);
|
|
393
|
+
if (!mount) return {};
|
|
394
|
+
|
|
395
|
+
const flag = mount.config.wakeOnChange;
|
|
396
|
+
if (!flag) return {};
|
|
397
|
+
|
|
398
|
+
const op = event.type.slice('workspace:'.length) as WorkspaceFsOp;
|
|
399
|
+
const opAllowed = flag === true || (Array.isArray(flag) && flag.includes(op));
|
|
400
|
+
if (!opAllowed) return {};
|
|
401
|
+
|
|
402
|
+
return { requestInference: true };
|
|
335
403
|
}
|
|
336
404
|
|
|
337
405
|
// ==========================================================================
|
|
@@ -366,6 +434,58 @@ export class WorkspaceModule implements Module {
|
|
|
366
434
|
return this.store;
|
|
367
435
|
}
|
|
368
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Persist a single write/edit/delete to disk when the mount opts in via
|
|
439
|
+
* `autoMaterialize`. Required for cross-agent pipelines — another agent's
|
|
440
|
+
* chokidar watcher on the same directory only sees real filesystem events.
|
|
441
|
+
* The local watcher's `suppress()` absorbs the echo so we don't self-wake.
|
|
442
|
+
*
|
|
443
|
+
* Returns an error string on failure so callers can surface it in the tool
|
|
444
|
+
* result. The autoMaterialize contract is specifically "disk is the source
|
|
445
|
+
* of truth for downstream agents", so a silent disk-write failure with a
|
|
446
|
+
* Chronicle commit would defeat the whole purpose. No-op cases (flag off,
|
|
447
|
+
* read-only mount) return null (success).
|
|
448
|
+
*/
|
|
449
|
+
private async autoMaterialize(
|
|
450
|
+
mount: MountState,
|
|
451
|
+
relativePath: string,
|
|
452
|
+
op: 'write' | 'delete',
|
|
453
|
+
content?: Buffer,
|
|
454
|
+
): Promise<string | null> {
|
|
455
|
+
if (!mount.config.autoMaterialize) return null;
|
|
456
|
+
if (mount.config.mode === 'read-only') return null;
|
|
457
|
+
|
|
458
|
+
const absolutePath = join(mount.config.path, relativePath);
|
|
459
|
+
const watcher = this.watchers.get(mount.config.name);
|
|
460
|
+
watcher?.suppress(relativePath);
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
if (op === 'write' && content) {
|
|
464
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
465
|
+
await writeFile(absolutePath, content);
|
|
466
|
+
mount.materializedHashes.set(relativePath, hashContent(content));
|
|
467
|
+
} else if (op === 'delete') {
|
|
468
|
+
try {
|
|
469
|
+
await unlink(absolutePath);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
472
|
+
}
|
|
473
|
+
mount.materializedHashes.delete(relativePath);
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
} catch (err) {
|
|
477
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
478
|
+
this.ctx?.pushEvent({
|
|
479
|
+
type: 'workspace:materialize-failed',
|
|
480
|
+
mount: mount.config.name,
|
|
481
|
+
path: relativePath,
|
|
482
|
+
op,
|
|
483
|
+
error: msg,
|
|
484
|
+
} as ProcessEvent);
|
|
485
|
+
return msg;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
369
489
|
// ==========================================================================
|
|
370
490
|
// Lazy Sync
|
|
371
491
|
// ==========================================================================
|
|
@@ -456,12 +576,21 @@ export class WorkspaceModule implements Module {
|
|
|
456
576
|
return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
|
|
457
577
|
}
|
|
458
578
|
|
|
459
|
-
const
|
|
579
|
+
const buffer = Buffer.from(input.content, 'utf-8');
|
|
580
|
+
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
460
581
|
store.treeSet(mount.treeStateId, relativePath, {
|
|
461
582
|
blobHash,
|
|
462
583
|
size: Buffer.byteLength(input.content),
|
|
463
584
|
mode: 0o644,
|
|
464
585
|
});
|
|
586
|
+
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', buffer);
|
|
587
|
+
if (materializeError) {
|
|
588
|
+
return {
|
|
589
|
+
success: false,
|
|
590
|
+
error: `Wrote to Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
|
|
591
|
+
isError: true,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
465
594
|
|
|
466
595
|
return {
|
|
467
596
|
success: true,
|
|
@@ -513,12 +642,21 @@ export class WorkspaceModule implements Module {
|
|
|
513
642
|
? content.replaceAll(input.oldString, input.newString)
|
|
514
643
|
: content.replace(input.oldString, input.newString);
|
|
515
644
|
|
|
516
|
-
const
|
|
645
|
+
const newBuffer = Buffer.from(content, 'utf-8');
|
|
646
|
+
const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
|
|
517
647
|
store.treeSet(mount.treeStateId, relativePath, {
|
|
518
648
|
blobHash: newBlobHash,
|
|
519
649
|
size: Buffer.byteLength(content),
|
|
520
650
|
mode: entry.mode,
|
|
521
651
|
});
|
|
652
|
+
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', newBuffer);
|
|
653
|
+
if (materializeError) {
|
|
654
|
+
return {
|
|
655
|
+
success: false,
|
|
656
|
+
error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
|
|
657
|
+
isError: true,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
522
660
|
|
|
523
661
|
return {
|
|
524
662
|
success: true,
|
|
@@ -542,6 +680,14 @@ export class WorkspaceModule implements Module {
|
|
|
542
680
|
}
|
|
543
681
|
|
|
544
682
|
store.treeRemove(mount.treeStateId, relativePath);
|
|
683
|
+
const materializeError = await this.autoMaterialize(mount, relativePath, 'delete');
|
|
684
|
+
if (materializeError) {
|
|
685
|
+
return {
|
|
686
|
+
success: false,
|
|
687
|
+
error: `Removed from Chronicle but failed to unlink "${input.path}" from disk: ${materializeError}. Downstream agents will still see the stale file until manual cleanup.`,
|
|
688
|
+
isError: true,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
545
691
|
|
|
546
692
|
return { success: true, data: { path: input.path, deleted: true } };
|
|
547
693
|
}
|
|
@@ -869,20 +1015,12 @@ export class WorkspaceModule implements Module {
|
|
|
869
1015
|
mount.initialSyncDone = true;
|
|
870
1016
|
|
|
871
1017
|
if (result.synced.length > 0 || result.conflicts.length > 0) {
|
|
872
|
-
allResults.push({
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
paths: result.synced.map(p => `${name}/${p}`),
|
|
879
|
-
mount: name,
|
|
880
|
-
conflicts: result.conflicts.length > 0
|
|
881
|
-
? result.conflicts.map(c => `${name}/${c.path}`)
|
|
882
|
-
: undefined,
|
|
883
|
-
};
|
|
884
|
-
this.ctx.pushEvent(event as ProcessEvent);
|
|
885
|
-
}
|
|
1018
|
+
allResults.push({
|
|
1019
|
+
mount: name,
|
|
1020
|
+
synced: result.synced.map(s => s.path),
|
|
1021
|
+
conflicts: result.conflicts,
|
|
1022
|
+
});
|
|
1023
|
+
this.emitFsEvents(name, result.synced, result.conflicts);
|
|
886
1024
|
}
|
|
887
1025
|
}
|
|
888
1026
|
|
|
@@ -902,23 +1040,76 @@ export class WorkspaceModule implements Module {
|
|
|
902
1040
|
|
|
903
1041
|
/**
|
|
904
1042
|
* Handle filesystem changes detected by watcher (watch: 'always' mode).
|
|
1043
|
+
*
|
|
1044
|
+
* The watcher carries the op per path. For created/modified we call
|
|
1045
|
+
* syncFromFs to pull content into the tree; for deleted we strip the tree
|
|
1046
|
+
* entry directly (the file is gone, nothing to read). We emit one event per
|
|
1047
|
+
* op type.
|
|
905
1048
|
*/
|
|
906
|
-
private async handleFsChanges(mountName: string,
|
|
1049
|
+
private async handleFsChanges(mountName: string, changes: FsChange[]): Promise<void> {
|
|
907
1050
|
const store = this.store;
|
|
908
1051
|
const mount = this.mounts.get(mountName);
|
|
909
1052
|
if (!store || !mount) return;
|
|
910
1053
|
|
|
911
|
-
const
|
|
1054
|
+
const touchedPaths = changes.filter(c => c.op !== 'deleted').map(c => c.path);
|
|
1055
|
+
const deletedPaths = changes.filter(c => c.op === 'deleted').map(c => c.path);
|
|
1056
|
+
|
|
1057
|
+
const syncResult = touchedPaths.length > 0
|
|
1058
|
+
? await syncFromFs(store, mount, touchedPaths)
|
|
1059
|
+
: { synced: [], conflicts: [], skipped: [] };
|
|
1060
|
+
|
|
1061
|
+
// Strip deleted entries from the tree so downstream consumers see a
|
|
1062
|
+
// coherent state. syncFromFs would also do this if we passed the paths in,
|
|
1063
|
+
// but the op from the watcher is authoritative — skip the access() probe.
|
|
1064
|
+
for (const p of deletedPaths) {
|
|
1065
|
+
const existing = store.treeGet(mount.treeStateId, p);
|
|
1066
|
+
if (existing) {
|
|
1067
|
+
store.treeRemove(mount.treeStateId, p);
|
|
1068
|
+
syncResult.synced.push({ path: p, op: 'deleted' });
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
912
1071
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1072
|
+
this.emitFsEvents(mountName, syncResult.synced, syncResult.conflicts);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Group synced paths by op and push one ProcessEvent per non-empty op.
|
|
1077
|
+
*/
|
|
1078
|
+
private emitFsEvents(
|
|
1079
|
+
mountName: string,
|
|
1080
|
+
synced: Array<{ path: string; op: WorkspaceFsOp }>,
|
|
1081
|
+
conflicts: ConflictInfo[],
|
|
1082
|
+
): void {
|
|
1083
|
+
if (!this.ctx || synced.length === 0) return;
|
|
1084
|
+
|
|
1085
|
+
const byOp = new Map<WorkspaceFsOp, string[]>();
|
|
1086
|
+
for (const { path, op } of synced) {
|
|
1087
|
+
const list = byOp.get(op) ?? [];
|
|
1088
|
+
list.push(`${mountName}/${path}`);
|
|
1089
|
+
byOp.set(op, list);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Conflicts by definition require a prior tree entry (sync detected that
|
|
1093
|
+
// the agent-side version diverged from the filesystem baseline), which
|
|
1094
|
+
// means the path is being re-synced as a modification. Attach conflicts
|
|
1095
|
+
// only to the workspace:modified event, intersected with its paths — a
|
|
1096
|
+
// created/deleted batch can't carry a meaningful conflict.
|
|
1097
|
+
const conflictsByPath = new Map<string, ConflictInfo>();
|
|
1098
|
+
for (const c of conflicts) conflictsByPath.set(c.path, c);
|
|
1099
|
+
|
|
1100
|
+
for (const [op, paths] of byOp) {
|
|
1101
|
+
const type = opToEventType(op);
|
|
1102
|
+
let eventConflicts: string[] | undefined;
|
|
1103
|
+
if (op === 'modified' && conflictsByPath.size > 0) {
|
|
1104
|
+
const matched = paths.filter(p => conflictsByPath.has(p.slice(mountName.length + 1)));
|
|
1105
|
+
if (matched.length > 0) eventConflicts = matched;
|
|
1106
|
+
}
|
|
1107
|
+
const event = {
|
|
1108
|
+
type,
|
|
1109
|
+
paths,
|
|
917
1110
|
mount: mountName,
|
|
918
|
-
conflicts:
|
|
919
|
-
|
|
920
|
-
: undefined,
|
|
921
|
-
};
|
|
1111
|
+
...(eventConflicts ? { conflicts: eventConflicts } : {}),
|
|
1112
|
+
} as WorkspaceCreatedEvent | WorkspaceModifiedEvent | WorkspaceDeletedEvent;
|
|
922
1113
|
this.ctx.pushEvent(event as ProcessEvent);
|
|
923
1114
|
}
|
|
924
1115
|
}
|
|
@@ -56,9 +56,16 @@ export interface ConflictInfo {
|
|
|
56
56
|
agentBlobHash: string;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export type SyncOp = 'created' | 'modified' | 'deleted';
|
|
60
|
+
|
|
61
|
+
export interface SyncedPath {
|
|
62
|
+
path: string;
|
|
63
|
+
op: SyncOp;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
export interface SyncResult {
|
|
60
|
-
/** Paths that were synced */
|
|
61
|
-
synced:
|
|
67
|
+
/** Paths that were synced, tagged with the op detected against tree state */
|
|
68
|
+
synced: SyncedPath[];
|
|
62
69
|
/** Paths that conflicted (both agent and user modified) — filesystem wins */
|
|
63
70
|
conflicts: ConflictInfo[];
|
|
64
71
|
/** Paths that were skipped (binary, too large, etc.) */
|
|
@@ -97,7 +104,7 @@ export async function syncFromFs(
|
|
|
97
104
|
const existing = store.treeGet(mount.treeStateId, relativePath);
|
|
98
105
|
if (existing) {
|
|
99
106
|
store.treeRemove(mount.treeStateId, relativePath);
|
|
100
|
-
result.synced.push(relativePath);
|
|
107
|
+
result.synced.push({ path: relativePath, op: 'deleted' });
|
|
101
108
|
}
|
|
102
109
|
continue;
|
|
103
110
|
}
|
|
@@ -151,7 +158,7 @@ export async function syncFromFs(
|
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
store.treeSet(mount.treeStateId, relativePath, entry);
|
|
154
|
-
result.synced.push(relativePath);
|
|
161
|
+
result.synced.push({ path: relativePath, op: existing ? 'modified' : 'created' });
|
|
155
162
|
} catch {
|
|
156
163
|
result.skipped.push(relativePath);
|
|
157
164
|
}
|
|
@@ -36,6 +36,26 @@ export interface MountConfig {
|
|
|
36
36
|
followSymlinks?: boolean;
|
|
37
37
|
/** Maximum file size in bytes (default: 5MB) */
|
|
38
38
|
maxFileSize?: number;
|
|
39
|
+
/**
|
|
40
|
+
* If set, filesystem changes on this mount request inference.
|
|
41
|
+
* - `true` — wake on any op (created | modified | deleted)
|
|
42
|
+
* - array — wake only for the listed ops
|
|
43
|
+
*
|
|
44
|
+
* Self-writes through the module's own tools are already suppressed by the
|
|
45
|
+
* watcher, so a mount shared between agents only wakes on external writes.
|
|
46
|
+
* The EventGate still has final say — use gate policies for path-glob
|
|
47
|
+
* filtering or to disable wake temporarily via hot-reload.
|
|
48
|
+
*/
|
|
49
|
+
wakeOnChange?: boolean | Array<'created' | 'modified' | 'deleted'>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* If true, writes/edits/deletes via the module's tools materialize to the
|
|
53
|
+
* filesystem immediately (not just into Chronicle tree state). Required for
|
|
54
|
+
* any mount shared across agents as a communication channel — without it,
|
|
55
|
+
* another agent's watcher on the same directory sees nothing. The watcher
|
|
56
|
+
* suppresses its own echoes, so this doesn't cause self-wake loops.
|
|
57
|
+
*/
|
|
58
|
+
autoMaterialize?: boolean;
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
/**
|
|
@@ -175,14 +195,36 @@ export interface SyncInput {
|
|
|
175
195
|
// Events
|
|
176
196
|
// ============================================================================
|
|
177
197
|
|
|
178
|
-
export
|
|
179
|
-
|
|
198
|
+
export type WorkspaceFsOp = 'created' | 'modified' | 'deleted';
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Fired when files in a mount are created. `paths` are mount-prefixed
|
|
202
|
+
* (e.g. "tickets/2026-04-20-foo.md"). Conflicts are reported when Chronicle
|
|
203
|
+
* sync detected divergence between the filesystem and the tree state.
|
|
204
|
+
*/
|
|
205
|
+
export interface WorkspaceCreatedEvent {
|
|
206
|
+
type: 'workspace:created';
|
|
180
207
|
paths: string[];
|
|
181
208
|
mount: string;
|
|
182
209
|
conflicts?: string[];
|
|
183
210
|
[key: string]: unknown;
|
|
184
211
|
}
|
|
185
212
|
|
|
213
|
+
export interface WorkspaceModifiedEvent {
|
|
214
|
+
type: 'workspace:modified';
|
|
215
|
+
paths: string[];
|
|
216
|
+
mount: string;
|
|
217
|
+
conflicts?: string[];
|
|
218
|
+
[key: string]: unknown;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface WorkspaceDeletedEvent {
|
|
222
|
+
type: 'workspace:deleted';
|
|
223
|
+
paths: string[];
|
|
224
|
+
mount: string;
|
|
225
|
+
[key: string]: unknown;
|
|
226
|
+
}
|
|
227
|
+
|
|
186
228
|
export interface WorkspaceMountedEvent {
|
|
187
229
|
type: 'workspace:mounted';
|
|
188
230
|
mount: string;
|
|
@@ -197,6 +239,20 @@ export interface WorkspaceUnmountedEvent {
|
|
|
197
239
|
}
|
|
198
240
|
|
|
199
241
|
export type WorkspaceEvent =
|
|
200
|
-
|
|
|
242
|
+
| WorkspaceCreatedEvent
|
|
243
|
+
| WorkspaceModifiedEvent
|
|
244
|
+
| WorkspaceDeletedEvent
|
|
201
245
|
| WorkspaceMountedEvent
|
|
202
246
|
| WorkspaceUnmountedEvent;
|
|
247
|
+
|
|
248
|
+
export const WORKSPACE_FS_EVENT_TYPES = [
|
|
249
|
+
'workspace:created',
|
|
250
|
+
'workspace:modified',
|
|
251
|
+
'workspace:deleted',
|
|
252
|
+
] as const;
|
|
253
|
+
|
|
254
|
+
export type WorkspaceFsEventType = typeof WORKSPACE_FS_EVENT_TYPES[number];
|
|
255
|
+
|
|
256
|
+
export function opToEventType(op: WorkspaceFsOp): WorkspaceFsEventType {
|
|
257
|
+
return `workspace:${op}` as WorkspaceFsEventType;
|
|
258
|
+
}
|
|
@@ -1,12 +1,22 @@
|
|
|
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';
|
|
6
9
|
import { type MountConfig } from './types.js';
|
|
7
10
|
|
|
11
|
+
export type FsOp = 'created' | 'modified' | 'deleted';
|
|
12
|
+
|
|
13
|
+
export interface FsChange {
|
|
14
|
+
path: string;
|
|
15
|
+
op: FsOp;
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
export interface WatcherEvents {
|
|
9
|
-
onChange(
|
|
19
|
+
onChange(changes: FsChange[]): void;
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
/**
|
|
@@ -15,17 +25,19 @@ export interface WatcherEvents {
|
|
|
15
25
|
*/
|
|
16
26
|
export class MountWatcher {
|
|
17
27
|
private watcher: FSWatcher | null = null;
|
|
18
|
-
|
|
28
|
+
// Most recent op seen per path within the current debounce window.
|
|
29
|
+
// create -> modify collapses to 'created' (single batch); delete always wins.
|
|
30
|
+
private pendingChanges = new Map<string, FsOp>();
|
|
19
31
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
20
32
|
private suppressedPaths = new Set<string>();
|
|
21
33
|
private suppressionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
22
34
|
private readonly debounceMs: number;
|
|
23
35
|
private readonly config: MountConfig;
|
|
24
|
-
private readonly onChangeCallback: (
|
|
36
|
+
private readonly onChangeCallback: (changes: FsChange[]) => void;
|
|
25
37
|
|
|
26
38
|
constructor(
|
|
27
39
|
config: MountConfig,
|
|
28
|
-
onChange: (
|
|
40
|
+
onChange: (changes: FsChange[]) => void,
|
|
29
41
|
) {
|
|
30
42
|
this.config = config;
|
|
31
43
|
this.debounceMs = config.watchDebounceMs ?? 300;
|
|
@@ -51,21 +63,22 @@ export class MountWatcher {
|
|
|
51
63
|
},
|
|
52
64
|
});
|
|
53
65
|
|
|
54
|
-
const handleEvent = (filePath: string) => {
|
|
55
|
-
// Convert absolute path to mount-relative
|
|
66
|
+
const handleEvent = (op: FsOp) => (filePath: string) => {
|
|
56
67
|
const relative = this.toRelative(filePath);
|
|
57
68
|
if (!relative) return;
|
|
58
69
|
|
|
59
|
-
// Skip if this path is suppressed (we just wrote it)
|
|
70
|
+
// Skip if this path is suppressed (we just wrote it).
|
|
71
|
+
// Suppression covers all ops — a materialize-then-delete by the module
|
|
72
|
+
// should not echo either the add or the unlink.
|
|
60
73
|
if (this.suppressedPaths.has(relative)) return;
|
|
61
74
|
|
|
62
|
-
this.
|
|
75
|
+
this.mergeOp(relative, op);
|
|
63
76
|
this.scheduleFire();
|
|
64
77
|
};
|
|
65
78
|
|
|
66
|
-
this.watcher.on('add', handleEvent);
|
|
67
|
-
this.watcher.on('change', handleEvent);
|
|
68
|
-
this.watcher.on('unlink', handleEvent);
|
|
79
|
+
this.watcher.on('add', handleEvent('created'));
|
|
80
|
+
this.watcher.on('change', handleEvent('modified'));
|
|
81
|
+
this.watcher.on('unlink', handleEvent('deleted'));
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
/**
|
|
@@ -92,12 +105,10 @@ export class MountWatcher {
|
|
|
92
105
|
/**
|
|
93
106
|
* Suppress watcher events for a path temporarily.
|
|
94
107
|
* Used after materializing to avoid echo events.
|
|
95
|
-
* After the cooldown, the path is re-checked via the recheckCallback.
|
|
96
108
|
*/
|
|
97
109
|
suppress(relativePath: string, cooldownMs = 500): void {
|
|
98
110
|
this.suppressedPaths.add(relativePath);
|
|
99
111
|
|
|
100
|
-
// Clear existing timer for this path
|
|
101
112
|
const existing = this.suppressionTimers.get(relativePath);
|
|
102
113
|
if (existing) clearTimeout(existing);
|
|
103
114
|
|
|
@@ -116,6 +127,44 @@ export class MountWatcher {
|
|
|
116
127
|
return this.suppressedPaths.has(relativePath);
|
|
117
128
|
}
|
|
118
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Merge a new op for a path into the pending batch. Chokidar can fire
|
|
132
|
+
* multiple events per path inside one debounce window; collapse them to the
|
|
133
|
+
* single op that reflects the net effect at end-of-window.
|
|
134
|
+
*
|
|
135
|
+
* Transitions (prev → op → result):
|
|
136
|
+
* ∅ → * → op
|
|
137
|
+
* * → deleted → deleted (trailing delete wins)
|
|
138
|
+
* deleted → created → modified (atomic save: unlink+rename;
|
|
139
|
+
* file exists with new contents)
|
|
140
|
+
* created → modified → created (still net-new this window)
|
|
141
|
+
* created → created → created
|
|
142
|
+
* modified → created → created (shouldn't happen, but keep
|
|
143
|
+
* created to avoid a false
|
|
144
|
+
* delete->recreate signal)
|
|
145
|
+
* modified → modified → modified
|
|
146
|
+
*/
|
|
147
|
+
private mergeOp(path: string, op: FsOp): void {
|
|
148
|
+
const prev = this.pendingChanges.get(path);
|
|
149
|
+
if (!prev) {
|
|
150
|
+
this.pendingChanges.set(path, op);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (op === 'deleted') {
|
|
154
|
+
this.pendingChanges.set(path, 'deleted');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (prev === 'deleted' && op === 'created') {
|
|
158
|
+
this.pendingChanges.set(path, 'modified');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (prev === 'created') {
|
|
162
|
+
this.pendingChanges.set(path, 'created');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.pendingChanges.set(path, op);
|
|
166
|
+
}
|
|
167
|
+
|
|
119
168
|
private scheduleFire(): void {
|
|
120
169
|
if (this.debounceTimer) {
|
|
121
170
|
clearTimeout(this.debounceTimer);
|
|
@@ -123,9 +172,9 @@ export class MountWatcher {
|
|
|
123
172
|
this.debounceTimer = setTimeout(() => {
|
|
124
173
|
this.debounceTimer = null;
|
|
125
174
|
if (this.pendingChanges.size > 0) {
|
|
126
|
-
const
|
|
175
|
+
const changes: FsChange[] = [...this.pendingChanges].map(([path, op]) => ({ path, op }));
|
|
127
176
|
this.pendingChanges.clear();
|
|
128
|
-
this.onChangeCallback(
|
|
177
|
+
this.onChangeCallback(changes);
|
|
129
178
|
}
|
|
130
179
|
}, this.debounceMs);
|
|
131
180
|
}
|
package/src/types/trace.ts
CHANGED
|
@@ -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 & {
|