@animalabs/connectome-host 0.7.4 → 0.8.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 (60) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +245 -0
  7. package/CONTRIBUTING.md +47 -19
  8. package/README.md +27 -0
  9. package/bun.lock +27 -31
  10. package/changelog.d/README.md +28 -0
  11. package/package.json +5 -5
  12. package/recipes/SETUP.md +11 -5
  13. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  14. package/recipes/knowledge-miner.json +0 -30
  15. package/recipes/mock-test.json +19 -0
  16. package/recipes/triumvirate.json +6 -1
  17. package/scripts/release-changelog.ts +210 -21
  18. package/src/cache-keepalive-log.ts +41 -0
  19. package/src/commands.ts +96 -0
  20. package/src/framework-strategy.ts +37 -0
  21. package/src/gate-telemetry.ts +106 -0
  22. package/src/headless.ts +10 -0
  23. package/src/index.ts +167 -55
  24. package/src/mcpl-config.ts +99 -1
  25. package/src/modules/identity-module.ts +310 -2
  26. package/src/modules/instructions-module.ts +265 -0
  27. package/src/modules/mcpl-admin-module.ts +58 -11
  28. package/src/modules/subagent-module.ts +18 -0
  29. package/src/recipe.ts +732 -25
  30. package/src/web/panel-data.ts +19 -0
  31. package/src/workspace-mounts.ts +73 -0
  32. package/test/audit-module-optins.test.ts +10 -3
  33. package/test/cache-keepalive-log.test.ts +83 -0
  34. package/test/conversations-recipe.test.ts +142 -0
  35. package/test/framework-fkm-composition.test.ts +35 -3
  36. package/test/framework-strategy-defaults.test.ts +19 -0
  37. package/test/gate-telemetry-adapter.test.ts +84 -0
  38. package/test/gate-telemetry.test.ts +91 -0
  39. package/test/identity-and-surfaces.test.ts +212 -1
  40. package/test/instructions-module.test.ts +258 -0
  41. package/test/mcpl-admin-module.test.ts +41 -0
  42. package/test/mcpl-agent-overlay.test.ts +51 -3
  43. package/test/mcpl-child-env.test.ts +64 -0
  44. package/test/nudge-command.test.ts +47 -0
  45. package/test/recipe-cache-keepalive.test.ts +59 -0
  46. package/test/recipe-compression-fallback.test.ts +19 -0
  47. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  48. package/test/recipe-instructions.test.ts +176 -0
  49. package/test/recipe-kv-unified.test.ts +87 -0
  50. package/test/recipe-mcp-source.test.ts +54 -0
  51. package/test/recipe-openai-compatible.test.ts +54 -0
  52. package/test/recipe-path-resolution.test.ts +19 -8
  53. package/test/recipe-provider.test.ts +14 -0
  54. package/test/recipe-save-unresolved.test.ts +244 -0
  55. package/test/recipe-source-only.test.ts +38 -0
  56. package/test/release-changelog.test.ts +202 -0
  57. package/test/subagent-prose-routing.test.ts +109 -0
  58. package/test/workspace-mounts.test.ts +68 -0
  59. package/web/src/App.tsx +1 -0
  60. package/web/src/Health.tsx +61 -1
@@ -33,6 +33,7 @@ import {
33
33
  createPublicKey,
34
34
  generateKeyPairSync,
35
35
  sign as cryptoSign,
36
+ createHash,
36
37
  type KeyObject,
37
38
  } from 'node:crypto';
38
39
  import type {
@@ -41,6 +42,7 @@ import type {
41
42
  ToolCall,
42
43
  ToolResult,
43
44
  ToolDefinition,
45
+ WorkspaceModule,
44
46
  } from '@animalabs/agent-framework';
45
47
 
46
48
  export interface IdentityModuleConfig {
@@ -51,10 +53,57 @@ export interface IdentityModuleConfig {
51
53
  home: string;
52
54
  /** Audience assumed when none is named. */
53
55
  defaultAudience?: string;
56
+ /**
57
+ * Services reachable through the `request` utility: audience name → API
58
+ * base URL. The allowlist IS the security boundary — the host only ever
59
+ * attaches standing access to these bases, so the utility can't be
60
+ * steered at arbitrary URLs. Merged over built-in defaults for the
61
+ * animalabs services.
62
+ */
63
+ services?: Record<string, string>;
54
64
  /** Injectable for tests. */
55
65
  fetchImpl?: typeof fetch;
56
66
  }
57
67
 
68
+ /** Known services when anchored at the animalabs home — a recipe can extend
69
+ * or override via `services`. */
70
+ const DEFAULT_SERVICES: Record<string, string> = {
71
+ orrery: 'https://orrery.animalabs.ai',
72
+ eidoverse: 'https://eidoverse.animalabs.ai',
73
+ };
74
+
75
+ /** How long a fetched service directory is trusted before a background-ish
76
+ * refresh. Short by design: "a service changed" should reach residents in
77
+ * about a minute, and an unknown name forces a refresh immediately anyway. */
78
+ const SERVICES_TTL_MS = 60_000;
79
+ const SERVICES_TIMEOUT_MS = 5_000;
80
+
81
+ const REQUEST_BODY_MAX = 256 * 1024;
82
+ /** Uploads are bytes the host streams from a workspace file, not text the
83
+ * model wrote, so the small JSON-body cap would be the wrong limit: a track
84
+ * or a render is legitimately megabytes. Still bounded — one call should not
85
+ * be able to push an unbounded file at a service. */
86
+ const UPLOAD_BODY_MAX = 64 * 1024 * 1024;
87
+
88
+ /** Enough to label the common uploads honestly; anything else is
89
+ * application/octet-stream unless the caller says otherwise. */
90
+ const CONTENT_TYPES: Record<string, string> = {
91
+ mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac', ogg: 'audio/ogg',
92
+ opus: 'audio/opus', m4a: 'audio/mp4', aac: 'audio/aac',
93
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
94
+ webp: 'image/webp', svg: 'image/svg+xml',
95
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
96
+ json: 'application/json', txt: 'text/plain', md: 'text/markdown',
97
+ pdf: 'application/pdf', zip: 'application/zip',
98
+ };
99
+
100
+ function guessContentType(path: string): string {
101
+ const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
102
+ return CONTENT_TYPES[ext] ?? 'application/octet-stream';
103
+ }
104
+ const RESPONSE_INLINE_MAX = 24 * 1024;
105
+ const RESPONSE_BODY_MAX = 64 * 1024 * 1024;
106
+
58
107
  /** Persisted beside the key after a successful registration. */
59
108
  interface IdentityRecord {
60
109
  sub: string;
@@ -74,12 +123,19 @@ export class IdentityModule implements Module {
74
123
  readonly name = 'identity';
75
124
  private readonly recordPath: string;
76
125
 
126
+ /** Last good service directory from the home node, and when we got it. */
127
+ private servicesFromHome: Record<string, string> = {};
128
+ private servicesFetchedAt = 0;
129
+ private servicesInflight: Promise<void> | null = null;
130
+
77
131
  constructor(private readonly config: IdentityModuleConfig) {
78
132
  this.recordPath = config.keyPath.replace(/\.pem$/, '') + '.json';
79
133
  }
80
134
 
81
- async start(_ctx: ModuleContext): Promise<void> {}
82
- async stop(): Promise<void> {}
135
+ private ctx: ModuleContext | null = null;
136
+
137
+ async start(ctx: ModuleContext): Promise<void> { this.ctx = ctx; }
138
+ async stop(): Promise<void> { this.ctx = null; }
83
139
 
84
140
  getTools(): ToolDefinition[] {
85
141
  return []; // utilities-only, by design — see module header
@@ -95,6 +151,30 @@ export class IdentityModule implements Module {
95
151
  'managed by the host from this — you never handle credentials yourself.',
96
152
  inputSchema: { type: 'object', properties: {} },
97
153
  },
154
+ {
155
+ name: 'request',
156
+ description:
157
+ 'Call a connected service’s API (e.g. "orrery") with your standing access ' +
158
+ 'attached by the host — nothing for you to obtain, renew, or handle; renewal ' +
159
+ 'is automatic. Give the service name and a path; returns {status, body}. ' +
160
+ 'For binary responses, pass saveAs with a workspace path (e.g. files/artifacts/image.png). ' +
161
+ 'To send a file — audio, images, anything large — pass fromFile with a workspace ' +
162
+ 'path instead of body; the host streams the bytes, so the file never has to pass ' +
163
+ 'through what you are writing.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ service: { type: 'string', description: 'Service name, e.g. "orrery". Unknown names list what is available.' },
168
+ path: { type: 'string', description: 'API path starting with "/", e.g. "/api/ops".' },
169
+ method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'Default GET.' },
170
+ body: { type: 'object', description: 'JSON body for POST/PUT.' },
171
+ fromFile: { type: 'string', description: 'Workspace path whose raw bytes become the request body, e.g. files/music/track.mp3. Use instead of body for uploads; not combinable with it.' },
172
+ contentType: { type: 'string', description: 'Content-Type for fromFile uploads. Inferred from the file extension when omitted.' },
173
+ saveAs: { type: 'string', description: 'Optional workspace path for the raw response bytes, e.g. files/artifacts/image.png. Required to retrieve binary bodies without loss.' },
174
+ },
175
+ required: ['service', 'path'],
176
+ },
177
+ },
98
178
  {
99
179
  name: 'accept_invite',
100
180
  description:
@@ -120,6 +200,8 @@ export class IdentityModule implements Module {
120
200
  return this.status();
121
201
  case 'accept_invite':
122
202
  return await this.acceptInvite(call.input as { invite?: unknown; name?: unknown });
203
+ case 'request':
204
+ return await this.request(call.input as { service?: unknown; path?: unknown; method?: unknown; body?: unknown; saveAs?: unknown });
123
205
  default:
124
206
  return fail(`Unknown identity utility: ${call.name}`);
125
207
  }
@@ -239,6 +321,232 @@ export class IdentityModule implements Module {
239
321
  );
240
322
  }
241
323
 
324
+ /** Audience → API base, resolved from the home node's `/services` directory
325
+ * at runtime. The archipelago's service list belongs to the trust anchor,
326
+ * not to a constant compiled into every host: a service added at the home
327
+ * node reaches every resident without a restart or a recipe edit.
328
+ *
329
+ * Layering, most specific last: fetched directory → built-in defaults for
330
+ * anything the directory omits → recipe `services` (explicit local config
331
+ * still wins, for testing and for hosts anchored elsewhere).
332
+ *
333
+ * Never throws: a home node that is down, slow, or rate-limiting leaves the
334
+ * last good directory in place (or the built-in defaults on a cold start),
335
+ * so losing discovery degrades to today's behaviour rather than to an
336
+ * outage. */
337
+ private async resolveServices(opts: { force?: boolean } = {}): Promise<Record<string, string>> {
338
+ const now = Date.now();
339
+ const fresh = this.servicesFetchedAt > 0 && now - this.servicesFetchedAt < SERVICES_TTL_MS;
340
+ if (!opts.force && fresh) return this.layerServices();
341
+ // Collapse concurrent refreshes onto one request.
342
+ if (!this.servicesInflight) {
343
+ const f = this.config.fetchImpl ?? fetch;
344
+ this.servicesInflight = (async () => {
345
+ try {
346
+ const res = await f(`https://${this.config.home}/services`, {
347
+ method: 'GET',
348
+ headers: { accept: 'application/json' },
349
+ signal: AbortSignal.timeout(SERVICES_TIMEOUT_MS),
350
+ });
351
+ if (!res.ok) throw new Error(`status ${res.status}`);
352
+ const json = (await res.json()) as { services?: Record<string, unknown> };
353
+ const map: Record<string, string> = {};
354
+ for (const [name, base] of Object.entries(json.services ?? {})) {
355
+ if (typeof base === 'string' && /^https:\/\//.test(base)) map[name] = base;
356
+ }
357
+ this.servicesFromHome = map;
358
+ this.servicesFetchedAt = Date.now();
359
+ } catch (err) {
360
+ // Keep whatever we had; note it once per failure for the operator.
361
+ console.error(
362
+ `[identity] service directory refresh failed (${this.config.home}): ${err instanceof Error ? err.message : String(err)}`,
363
+ );
364
+ } finally {
365
+ this.servicesInflight = null;
366
+ }
367
+ })();
368
+ }
369
+ await this.servicesInflight;
370
+ return this.layerServices();
371
+ }
372
+
373
+ private layerServices(): Record<string, string> {
374
+ return { ...DEFAULT_SERVICES, ...this.servicesFromHome, ...this.config.services };
375
+ }
376
+
377
+ /** The agent-facing HTTP seam for non-MCPL services (Orrery et al.): the
378
+ * host resolves the base URL from the directory, fetches fresh access,
379
+ * attaches it, and returns only {status, body}. The credential exists
380
+ * for the duration of one fetch, outside model context. */
381
+ private async request(input: {
382
+ service?: unknown;
383
+ path?: unknown;
384
+ method?: unknown;
385
+ body?: unknown;
386
+ fromFile?: unknown;
387
+ contentType?: unknown;
388
+ saveAs?: unknown;
389
+ }): Promise<ToolResult> {
390
+ const service = typeof input.service === 'string' ? input.service : '';
391
+ let services = await this.resolveServices();
392
+ let base = services[service];
393
+ if (!base && service) {
394
+ // A name we don't know yet is the likeliest moment for the directory to
395
+ // have moved under us — a service that went live since our last refresh.
396
+ // Re-ask once before refusing, so a new archipelago service is usable
397
+ // immediately rather than after a cache expiry.
398
+ services = await this.resolveServices({ force: true });
399
+ base = services[service];
400
+ }
401
+ if (!base) {
402
+ return fail(`Unknown service "${service}". Available: ${Object.keys(services).join(', ')}`);
403
+ }
404
+ if (typeof input.path !== 'string' || !input.path.startsWith('/')) {
405
+ return fail('`path` must be a string starting with "/"');
406
+ }
407
+ const method = typeof input.method === 'string' ? input.method.toUpperCase() : 'GET';
408
+ if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) return fail(`unsupported method ${method}`);
409
+ let bodyStr: string | undefined;
410
+ if (input.body !== undefined && method !== 'GET') {
411
+ bodyStr = typeof input.body === 'string' ? input.body : JSON.stringify(input.body);
412
+ if (bodyStr.length > REQUEST_BODY_MAX) return fail(`body too large (${bodyStr.length} > ${REQUEST_BODY_MAX})`);
413
+ }
414
+
415
+ // Uploads: the agent names a workspace file, the host sends its bytes. The
416
+ // mirror of saveAs — a file too big to write into a turn is exactly the
417
+ // case that needs this, so the bytes never enter model context, and the
418
+ // small JSON body cap does not apply to them.
419
+ let upload: { bytes: Buffer; contentType: string; path: string } | undefined;
420
+ if (typeof input.fromFile === 'string' && input.fromFile.length > 0) {
421
+ if (bodyStr !== undefined) return fail('pass either `body` or `fromFile`, not both');
422
+ if (method === 'GET') return fail('`fromFile` needs a method with a body (POST or PUT)');
423
+ const workspace = this.ctx?.getModule<WorkspaceModule>('workspace');
424
+ if (!workspace) return fail('identity request: workspace module is not available for fromFile');
425
+ // Disk first, store second. An upload is egress: reading it through the
426
+ // append-only store would retain every byte forever and refuse anything
427
+ // binary or over the mount's maxFileSize — which is most media. Disk
428
+ // has neither problem. The store remains the fallback for a file the
429
+ // resident wrote through the workspace and never materialized.
430
+ const diskRead = (workspace as WorkspaceModule & {
431
+ readBinaryFromDisk?: (
432
+ path: string,
433
+ opts?: { maxBytes?: number },
434
+ ) => Promise<{ data: Buffer; absolutePath: string } | { error: string }>;
435
+ }).readBinaryFromDisk;
436
+ const fromDisk = diskRead
437
+ ? await diskRead.call(workspace, input.fromFile, { maxBytes: UPLOAD_BODY_MAX })
438
+ : { error: 'this host has no disk read path' };
439
+ let bytes: Buffer;
440
+ if (!('error' in fromDisk)) {
441
+ bytes = fromDisk.data;
442
+ } else {
443
+ const fromStore = await workspace.readBinary(input.fromFile);
444
+ if ('error' in fromStore) {
445
+ return fail(
446
+ `identity request: could not read ${input.fromFile}: ${fromDisk.error}; ${fromStore.error}`,
447
+ );
448
+ }
449
+ bytes = fromStore.data;
450
+ }
451
+ if (bytes.byteLength > UPLOAD_BODY_MAX) {
452
+ return fail(`file too large (${bytes.byteLength} > ${UPLOAD_BODY_MAX})`);
453
+ }
454
+ upload = {
455
+ bytes,
456
+ contentType:
457
+ typeof input.contentType === 'string' && input.contentType
458
+ ? input.contentType
459
+ : guessContentType(input.fromFile),
460
+ path: input.fromFile,
461
+ };
462
+ }
463
+
464
+ let access: string;
465
+ try {
466
+ access = await this.accessFor(service); // service name == audience name
467
+ } catch (err) {
468
+ return fail(err instanceof Error ? err.message : String(err));
469
+ }
470
+ const f = this.config.fetchImpl ?? fetch;
471
+ try {
472
+ const res = await f(`${base}${input.path}`, {
473
+ method,
474
+ headers: {
475
+ authorization: `Bearer ${access}`,
476
+ ...(bodyStr !== undefined ? { 'content-type': 'application/json' } : {}),
477
+ ...(upload ? { 'content-type': upload.contentType } : {}),
478
+ },
479
+ ...(bodyStr !== undefined ? { body: bodyStr } : {}),
480
+ // Uint8Array view, not the Buffer itself: Buffer is not a BodyInit.
481
+ ...(upload ? { body: new Uint8Array(upload.bytes) } : {}),
482
+ });
483
+ const bytes = Buffer.from(await res.arrayBuffer());
484
+ if (bytes.byteLength > RESPONSE_BODY_MAX) {
485
+ return fail(`response too large (${bytes.byteLength} > ${RESPONSE_BODY_MAX})`);
486
+ }
487
+ const declaredType = res.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() || '';
488
+ const contentType = declaredType || 'application/octet-stream';
489
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
490
+
491
+ if (typeof input.saveAs === 'string' && input.saveAs.length > 0) {
492
+ const workspace = this.ctx?.getModule<WorkspaceModule>('workspace');
493
+ if (!workspace) return fail('identity request: workspace module is not available for saveAs');
494
+ const written = await workspace.writeBinary(input.saveAs, bytes, contentType);
495
+ if (!written.success) return fail(`identity request: could not save response: ${written.error ?? 'unknown'}`);
496
+ return ok({
497
+ status: res.status,
498
+ saved: { path: input.saveAs, size: bytes.byteLength, contentType, sha256 },
499
+ });
500
+ }
501
+
502
+ const text = bytes.toString('utf8');
503
+ let parsed: unknown;
504
+ let parsedJson = false;
505
+ try {
506
+ parsed = JSON.parse(text);
507
+ parsedJson = true;
508
+ } catch {
509
+ /* not JSON */
510
+ }
511
+ const textual = declaredType.startsWith('text/')
512
+ || declaredType === 'application/json'
513
+ || declaredType.endsWith('+json')
514
+ || declaredType === 'application/xml'
515
+ || declaredType.endsWith('+xml')
516
+ // Some tiny internal/fake services omit content-type on JSON. A full
517
+ // successful parse is a safer fallback than treating valid JSON as
518
+ // opaque bytes; arbitrary binary almost never parses as one JSON value.
519
+ || (!declaredType && parsedJson);
520
+ if (!textual) {
521
+ return ok({
522
+ status: res.status,
523
+ body: null,
524
+ binary: {
525
+ size: bytes.byteLength, contentType, sha256,
526
+ note: 'Binary response omitted from text context; repeat the request with saveAs to write it byte-exactly to a workspace mount.',
527
+ },
528
+ });
529
+ }
530
+
531
+ let body: unknown = parsedJson ? parsed : text;
532
+ const raw = typeof body === 'string' ? body : JSON.stringify(body);
533
+ if (raw.length > RESPONSE_INLINE_MAX) {
534
+ body = `${raw.slice(0, RESPONSE_INLINE_MAX)}… [truncated ${raw.length - RESPONSE_INLINE_MAX} chars]`;
535
+ }
536
+ return ok({
537
+ status: res.status,
538
+ body,
539
+ // A receipt for what left the house, so an upload is verifiable from
540
+ // the turn that made it without re-reading the file.
541
+ ...(upload
542
+ ? { sent: { path: upload.path, size: upload.bytes.byteLength, contentType: upload.contentType } }
543
+ : {}),
544
+ });
545
+ } catch (err) {
546
+ return fail(`${service} request failed: ${err instanceof Error ? err.message : String(err)}`);
547
+ }
548
+ }
549
+
242
550
  private async acceptInvite(input: { invite?: unknown; name?: unknown }): Promise<ToolResult> {
243
551
  if (typeof input.invite !== 'string' || typeof input.name !== 'string') {
244
552
  return fail('accept_invite needs { invite, name }');
@@ -0,0 +1,265 @@
1
+ /**
2
+ * InstructionsModule — injects a shared, living instructions document into
3
+ * every agent's context on every turn.
4
+ *
5
+ * The document is a CLAUDE.md analogue maintained in a workspace mount:
6
+ * operators (or the agents themselves, on a read-write mount) edit one file,
7
+ * and the current content reaches the resident agent AND every ephemeral
8
+ * subagent via the framework's gatherContext hook before each inference.
9
+ * Nothing is persisted to Chronicle — position 'system' injections are
10
+ * per-turn overlays, so edits take effect on the next turn and stale copies
11
+ * never accumulate in history.
12
+ *
13
+ * Fail-open by design: a missing mount, missing file, or any read error
14
+ * yields no injection (never a blocked inference), with a warning logged
15
+ * once per distinct error rather than every turn.
16
+ *
17
+ * Requires the workspace module: the configured path is a mount-prefixed
18
+ * workspace path ("<mountName>/<relativePath>") resolved through
19
+ * WorkspaceModule.resolveAbsolutePath, so mount scoping and the
20
+ * path-traversal guard apply. validateRecipe enforces the pairing.
21
+ *
22
+ * Symlink policy: resolveAbsolutePath's traversal guard is lexical, and a
23
+ * symlink inside the mount pointing outside it would otherwise smuggle
24
+ * arbitrary host-readable files into the trusted instructions block. Before
25
+ * reading, both the mount root and the resolved file are realpath'd and the
26
+ * file must remain inside the root — the same guard WorkspaceModule applies
27
+ * to its own image reads. (The right long-term home for this is a safe-read
28
+ * API on WorkspaceModule itself; agent-framework is a separately released
29
+ * package, so this module enforces the policy locally until one exists.)
30
+ *
31
+ * Reads are bounded: at most maxBytes is ever loaded (the file is read
32
+ * through a handle, not readFile), so an oversized or growing mounted file
33
+ * cannot balloon memory past the configured cap.
34
+ */
35
+
36
+ import { promises as fs } from 'node:fs';
37
+ import { sep } from 'node:path';
38
+ import type {
39
+ Module,
40
+ ModuleContext,
41
+ ProcessEvent,
42
+ ProcessState,
43
+ EventResponse,
44
+ ToolDefinition,
45
+ ToolCall,
46
+ ToolResult,
47
+ } from '@animalabs/agent-framework';
48
+ import type { ContextInjection } from '@animalabs/context-manager';
49
+
50
+ /**
51
+ * The slice of WorkspaceModule this module depends on. Structural so tests
52
+ * can substitute a stub resolver; production wiring passes the real
53
+ * WorkspaceModule (which satisfies this shape).
54
+ */
55
+ export interface WorkspacePathResolver {
56
+ resolveAbsolutePath(mountPrefixedPath: string): string | null;
57
+ }
58
+
59
+ export interface InstructionsModuleConfig {
60
+ /** Workspace path "<mountName>/<relativePath>". Default "instructions/AGENTS.md". */
61
+ path?: string;
62
+ /** Heading line prepended to the injected block. */
63
+ header?: string;
64
+ /** Truncate content beyond this many bytes (with an explicit marker). Default 32768. */
65
+ maxBytes?: number;
66
+ /** Where the block lands in the compiled context. Default 'system'. */
67
+ position?: 'system' | 'beforeUser' | 'afterUser';
68
+ }
69
+
70
+ export const DEFAULT_INSTRUCTIONS_PATH = 'instructions/AGENTS.md';
71
+ export const DEFAULT_INSTRUCTIONS_HEADER =
72
+ '# Shared operating instructions (live document)';
73
+ export const DEFAULT_INSTRUCTIONS_MAX_BYTES = 32768;
74
+
75
+ export class InstructionsModule implements Module {
76
+ readonly name = 'instructions';
77
+
78
+ // gatherContext is a stat + (on change) one file read — well under 2s.
79
+ // A modest explicit budget keeps a wedged filesystem from eating the
80
+ // registry-wide 15s default before inference proceeds without us.
81
+ readonly contextTimeoutMs = 2000;
82
+
83
+ private readonly path: string;
84
+ private readonly header: string;
85
+ private readonly maxBytes: number;
86
+ private readonly position: 'system' | 'beforeUser' | 'afterUser';
87
+
88
+ private workspace: WorkspacePathResolver | null = null;
89
+
90
+ /** Cache keyed by (realpath, mtimeMs, size) — reread only when the file
91
+ * changes. The realpath in the key covers a symlink retargeted between
92
+ * turns to a different in-mount file with identical stat numbers. */
93
+ private cached: {
94
+ realFile: string;
95
+ mtimeMs: number;
96
+ size: number;
97
+ injections: ContextInjection[];
98
+ } | null = null;
99
+
100
+ /** Error messages already warned about — fail-open must not spam per turn. */
101
+ private warned = new Set<string>();
102
+
103
+ constructor(config: InstructionsModuleConfig = {}) {
104
+ this.path = config.path ?? DEFAULT_INSTRUCTIONS_PATH;
105
+ this.header = config.header ?? DEFAULT_INSTRUCTIONS_HEADER;
106
+ this.maxBytes = config.maxBytes ?? DEFAULT_INSTRUCTIONS_MAX_BYTES;
107
+ this.position = config.position ?? 'system';
108
+ }
109
+
110
+ /** Peer injection (same pattern as setFramework/setIdentity elsewhere). */
111
+ setWorkspace(workspace: WorkspacePathResolver): void {
112
+ this.workspace = workspace;
113
+ }
114
+
115
+ async start(_ctx: ModuleContext): Promise<void> {}
116
+
117
+ async stop(): Promise<void> {
118
+ this.cached = null;
119
+ this.warned.clear();
120
+ }
121
+
122
+ getTools(): ToolDefinition[] {
123
+ // Passive module — no tools, only gatherContext. The file itself is
124
+ // read/edited through the workspace module's own tools.
125
+ return [];
126
+ }
127
+
128
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
129
+ return {
130
+ success: false,
131
+ error: `InstructionsModule has no tool "${call.name}"`,
132
+ isError: true,
133
+ };
134
+ }
135
+
136
+ async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
137
+ return {};
138
+ }
139
+
140
+ /**
141
+ * Same injection for every agent — resident and ephemeral subagents alike
142
+ * share the one living document (that is the point of the module).
143
+ */
144
+ async gatherContext(_agentName: string): Promise<ContextInjection[]> {
145
+ if (!this.workspace) {
146
+ this.warnOnce('workspace module not wired — no instructions injected');
147
+ return [];
148
+ }
149
+
150
+ const absPath = this.workspace.resolveAbsolutePath(this.path);
151
+ if (!absPath) {
152
+ this.warnOnce(
153
+ `cannot resolve "${this.path}" — unknown mount or path escapes it; no instructions injected`,
154
+ );
155
+ return [];
156
+ }
157
+ // Mount root, via the same resolver (a bare mount name resolves to the
158
+ // root). Needed for the realpath containment check below.
159
+ const mountName = this.path.slice(0, this.path.indexOf('/'));
160
+ const mountRoot = this.workspace.resolveAbsolutePath(mountName);
161
+ if (!mountRoot) {
162
+ this.warnOnce(
163
+ `cannot resolve mount "${mountName}" root; no instructions injected`,
164
+ );
165
+ return [];
166
+ }
167
+
168
+ try {
169
+ // Symlink guard: resolveAbsolutePath's containment is lexical only, so
170
+ // realpath both ends and require the real file to still live under the
171
+ // real mount root. Runs before the cache consult — a symlink swapped
172
+ // since last turn must never serve (or seed) cached content.
173
+ const realRoot = await fs.realpath(mountRoot);
174
+ const realFile = await fs.realpath(absPath);
175
+ if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
176
+ this.cached = null;
177
+ this.warnOnce(
178
+ `"${this.path}" resolves outside its mount after following symlinks; no instructions injected`,
179
+ );
180
+ return [];
181
+ }
182
+
183
+ // Handle-based read: stat and read against one open descriptor (no
184
+ // stat-then-read race), loading at most maxBytes regardless of file
185
+ // size — readFile would buffer the whole file first.
186
+ const handle = await fs.open(realFile, 'r');
187
+ try {
188
+ const stat = await handle.stat();
189
+ if (
190
+ this.cached &&
191
+ this.cached.realFile === realFile &&
192
+ this.cached.mtimeMs === stat.mtimeMs &&
193
+ this.cached.size === stat.size
194
+ ) {
195
+ return this.cached.injections;
196
+ }
197
+
198
+ const readLen = Math.min(stat.size, this.maxBytes);
199
+ const buf = Buffer.alloc(readLen);
200
+ let filled = 0;
201
+ while (filled < readLen) {
202
+ const { bytesRead } = await handle.read(buf, filled, readLen - filled, filled);
203
+ if (bytesRead === 0) break; // file shrank mid-read; keep what we have
204
+ filled += bytesRead;
205
+ }
206
+
207
+ let content: string;
208
+ if (stat.size > this.maxBytes) {
209
+ // Back the cut up to a UTF-8 sequence boundary so a multibyte
210
+ // character split at the cap never decodes to U+FFFD right before
211
+ // the marker. The bytes past the cap were never read, so detect a
212
+ // straddle from the kept tail alone: find the last lead byte and
213
+ // drop the sequence iff it declares more bytes than were kept.
214
+ let cut = filled;
215
+ let lead = filled - 1;
216
+ let trailing = 0;
217
+ while (lead >= 0 && (buf[lead]! & 0xc0) === 0x80) {
218
+ lead--;
219
+ trailing++;
220
+ }
221
+ if (lead >= 0) {
222
+ const b = buf[lead]!;
223
+ const expected =
224
+ (b & 0x80) === 0 ? 1
225
+ : (b & 0xe0) === 0xc0 ? 2
226
+ : (b & 0xf0) === 0xe0 ? 3
227
+ : (b & 0xf8) === 0xf0 ? 4
228
+ : 1; // invalid lead — leave it; decoding was lossy anyway
229
+ if (expected > trailing + 1) cut = lead;
230
+ }
231
+ content =
232
+ buf.subarray(0, cut).toString('utf-8') +
233
+ `\n\n[truncated: first ${cut} of ${stat.size} bytes]`;
234
+ } else {
235
+ content = buf.subarray(0, filled).toString('utf-8');
236
+ }
237
+
238
+ const injections: ContextInjection[] = [
239
+ {
240
+ namespace: 'instructions',
241
+ position: this.position,
242
+ content: [{ type: 'text', text: `${this.header}\n\n${content}` }],
243
+ },
244
+ ];
245
+ this.cached = { realFile, mtimeMs: stat.mtimeMs, size: stat.size, injections };
246
+ // Recovered — let a future recurrence of a previous error warn again.
247
+ this.warned.clear();
248
+ return injections;
249
+ } finally {
250
+ await handle.close();
251
+ }
252
+ } catch (error) {
253
+ // Fail open: missing file or any read error never blocks inference.
254
+ this.cached = null;
255
+ this.warnOnce(error instanceof Error ? error.message : String(error));
256
+ return [];
257
+ }
258
+ }
259
+
260
+ private warnOnce(message: string): void {
261
+ if (this.warned.has(message)) return;
262
+ this.warned.add(message);
263
+ console.error(`InstructionsModule: ${message}`);
264
+ }
265
+ }