@animalabs/agent-framework 0.11.0 → 0.13.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 (160) hide show
  1. package/dist/src/agent.d.ts +11 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +60 -4
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +72 -1
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +873 -123
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +7 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +15 -1
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/kv-unified-wire.d.ts +16 -0
  14. package/dist/src/kv-unified-wire.d.ts.map +1 -0
  15. package/dist/src/kv-unified-wire.js +2 -0
  16. package/dist/src/kv-unified-wire.js.map +1 -0
  17. package/dist/src/mcpl/channel-registry.d.ts +84 -1
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +188 -5
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  22. package/dist/src/mcpl/hook-orchestrator.js +28 -2
  23. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  24. package/dist/src/mcpl/push-handler.d.ts +6 -1
  25. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  26. package/dist/src/mcpl/push-handler.js +29 -2
  27. package/dist/src/mcpl/push-handler.js.map +1 -1
  28. package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
  29. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
  30. package/dist/src/mcpl/reference-fetcher.js +240 -0
  31. package/dist/src/mcpl/reference-fetcher.js.map +1 -0
  32. package/dist/src/mcpl/references.d.ts +125 -0
  33. package/dist/src/mcpl/references.d.ts.map +1 -0
  34. package/dist/src/mcpl/references.js +262 -0
  35. package/dist/src/mcpl/references.js.map +1 -0
  36. package/dist/src/mcpl/types.d.ts +32 -0
  37. package/dist/src/mcpl/types.d.ts.map +1 -1
  38. package/dist/src/mcpl/types.js.map +1 -1
  39. package/dist/src/modules/index.d.ts +2 -1
  40. package/dist/src/modules/index.d.ts.map +1 -1
  41. package/dist/src/modules/index.js +1 -1
  42. package/dist/src/modules/index.js.map +1 -1
  43. package/dist/src/modules/workspace/index.d.ts +125 -2
  44. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  45. package/dist/src/modules/workspace/index.js +354 -58
  46. package/dist/src/modules/workspace/index.js.map +1 -1
  47. package/dist/src/modules/workspace/types.d.ts +7 -0
  48. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  49. package/dist/src/modules/workspace/types.js.map +1 -1
  50. package/dist/src/tool-result-history.d.ts.map +1 -1
  51. package/dist/src/tool-result-history.js +14 -0
  52. package/dist/src/tool-result-history.js.map +1 -1
  53. package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
  54. package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
  55. package/dist/src/tool-wrapper-prose-guard.js +37 -0
  56. package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
  57. package/dist/src/tune-out/coordinator.d.ts +144 -0
  58. package/dist/src/tune-out/coordinator.d.ts.map +1 -0
  59. package/dist/src/tune-out/coordinator.js +444 -0
  60. package/dist/src/tune-out/coordinator.js.map +1 -0
  61. package/dist/src/tune-out/tools.d.ts +43 -0
  62. package/dist/src/tune-out/tools.d.ts.map +1 -0
  63. package/dist/src/tune-out/tools.js +93 -0
  64. package/dist/src/tune-out/tools.js.map +1 -0
  65. package/dist/src/types/agent.d.ts +15 -0
  66. package/dist/src/types/agent.d.ts.map +1 -1
  67. package/dist/src/types/framework.d.ts +6 -0
  68. package/dist/src/types/framework.d.ts.map +1 -1
  69. package/dist/src/usage-accounting.d.ts +5 -0
  70. package/dist/src/usage-accounting.d.ts.map +1 -0
  71. package/dist/src/usage-accounting.js +11 -0
  72. package/dist/src/usage-accounting.js.map +1 -0
  73. package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
  74. package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
  75. package/dist/test/channel-incoming-targeting.test.js +81 -0
  76. package/dist/test/channel-incoming-targeting.test.js.map +1 -0
  77. package/dist/test/conversation-routing.test.js +39 -0
  78. package/dist/test/conversation-routing.test.js.map +1 -1
  79. package/dist/test/kv-unified-wiring.test.d.ts +2 -0
  80. package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
  81. package/dist/test/kv-unified-wiring.test.js +56 -0
  82. package/dist/test/kv-unified-wiring.test.js.map +1 -0
  83. package/dist/test/mcpl-push-convert.test.d.ts +2 -0
  84. package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
  85. package/dist/test/mcpl-push-convert.test.js +42 -0
  86. package/dist/test/mcpl-push-convert.test.js.map +1 -0
  87. package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
  88. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
  89. package/dist/test/mcpl-reference-fetcher.test.js +199 -0
  90. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
  91. package/dist/test/per-agent-delivery.test.d.ts +2 -0
  92. package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
  93. package/dist/test/per-agent-delivery.test.js +108 -0
  94. package/dist/test/per-agent-delivery.test.js.map +1 -0
  95. package/dist/test/present-while-acting.test.js +64 -0
  96. package/dist/test/present-while-acting.test.js.map +1 -1
  97. package/dist/test/tool-result-history.test.js +78 -0
  98. package/dist/test/tool-result-history.test.js.map +1 -1
  99. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
  100. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
  101. package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
  102. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
  103. package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
  104. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
  105. package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
  106. package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
  107. package/dist/test/tune-out-e2e.test.d.ts +2 -0
  108. package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
  109. package/dist/test/tune-out-e2e.test.js +142 -0
  110. package/dist/test/tune-out-e2e.test.js.map +1 -0
  111. package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
  112. package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
  113. package/dist/test/tune-out-gate-composition.test.js +221 -0
  114. package/dist/test/tune-out-gate-composition.test.js.map +1 -0
  115. package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
  116. package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
  117. package/dist/test/tune-out-lifecycle.test.js +121 -0
  118. package/dist/test/tune-out-lifecycle.test.js.map +1 -0
  119. package/dist/test/turn-trigger-provenance.test.d.ts +2 -0
  120. package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
  121. package/dist/test/turn-trigger-provenance.test.js +85 -0
  122. package/dist/test/turn-trigger-provenance.test.js.map +1 -0
  123. package/dist/test/usage-accounting.test.d.ts +2 -0
  124. package/dist/test/usage-accounting.test.d.ts.map +1 -0
  125. package/dist/test/usage-accounting.test.js +13 -0
  126. package/dist/test/usage-accounting.test.js.map +1 -0
  127. package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
  128. package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
  129. package/dist/test/workspace-branch-continuation.test.js +183 -0
  130. package/dist/test/workspace-branch-continuation.test.js.map +1 -0
  131. package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
  132. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
  133. package/dist/test/workspace-read-file-from-disk.test.js +174 -0
  134. package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
  135. package/dist/test/workspace-read-image.test.js +8 -0
  136. package/dist/test/workspace-read-image.test.js.map +1 -1
  137. package/dist/test/workspace-restore-order.test.js +1 -1
  138. package/dist/test/workspace-restore-order.test.js.map +1 -1
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +2 -2
  141. package/src/agent.ts +77 -4
  142. package/src/framework.ts +953 -128
  143. package/src/gate/event-gate.ts +28 -3
  144. package/src/kv-unified-wire.ts +12 -0
  145. package/src/mcpl/channel-registry.ts +264 -8
  146. package/src/mcpl/hook-orchestrator.ts +29 -2
  147. package/src/mcpl/push-handler.ts +30 -2
  148. package/src/mcpl/reference-fetcher.ts +240 -0
  149. package/src/mcpl/references.ts +292 -0
  150. package/src/mcpl/types.ts +29 -0
  151. package/src/modules/index.ts +2 -1
  152. package/src/modules/workspace/index.ts +425 -66
  153. package/src/modules/workspace/types.ts +7 -0
  154. package/src/tool-result-history.ts +11 -0
  155. package/src/tool-wrapper-prose-guard.ts +38 -0
  156. package/src/tune-out/coordinator.ts +548 -0
  157. package/src/tune-out/tools.ts +129 -0
  158. package/src/types/agent.ts +16 -0
  159. package/src/types/framework.ts +7 -0
  160. package/src/usage-accounting.ts +8 -0
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { safeSlice } from './safe-slice.js';
18
+ import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './mcpl/references.js';
18
19
 
19
20
  /**
20
21
  * House-safe default inline cap (chars) for tool results, error results, and
@@ -61,10 +62,20 @@ function tryHistoryStringFromContentArray(data: unknown): string | null {
61
62
  const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown };
62
63
  if (b.type === 'text' && typeof b.text === 'string') {
63
64
  parts.push(b.text);
65
+ } else if (isInlineContradiction(b)) {
66
+ // RFC-005 vector 2: checked before the data branch so inline data
67
+ // claiming bulk disposition is withheld, not rendered.
68
+ parts.push(INLINE_WITHHELD_TEXT);
64
69
  } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
65
70
  // Decoded byte estimate from base64 length (3/4 ratio, rounded).
66
71
  const approxBytes = Math.floor(b.data.length * 3 / 4);
67
72
  parts.push(`[image: ${b.mimeType}, ${formatSize(approxBytes)}]`);
73
+ } else if (b.type === 'resource' || ((b.type === 'image' || b.type === 'audio') && typeof (b as { uri?: unknown }).uri === 'string')) {
74
+ // RFC-005 reference block: a bounded stub, never the raw block JSON
75
+ // (which used to inline the URI — and the whole array — into history).
76
+ const stub = referenceStubOrNull(b, 'from tool result');
77
+ if (stub === null) return null;
78
+ parts.push(stub);
68
79
  } else {
69
80
  return null;
70
81
  }
@@ -0,0 +1,38 @@
1
+ import type { ContentBlock } from '@animalabs/membrane';
2
+
3
+ /**
4
+ * Recognize ONLY a whole-response textual invocation wrapper for a tool that
5
+ * was registered on this exact inference. This is containment, not parsing:
6
+ * callers must never execute the returned tool name or wrapper body.
7
+ */
8
+ export function detectKnownToolWrapperProse(
9
+ content: ContentBlock[],
10
+ knownToolNames: ReadonlySet<string>,
11
+ ): string | null {
12
+ if (knownToolNames.size === 0) return null;
13
+
14
+ // Signed thinking may precede visible prose. Any other non-text block makes
15
+ // this a mixed response, not an exact textual wrapper.
16
+ const text: string[] = [];
17
+ for (const block of content) {
18
+ if (block.type === 'text') text.push(block.text);
19
+ else if (block.type !== 'thinking' && block.type !== 'redacted_thinking') return null;
20
+ }
21
+ const body = text.join('\n').trim();
22
+ if (!body) return null;
23
+
24
+ const tagName = '[A-Za-z_][A-Za-z0-9_.:-]*';
25
+ let match = body.match(new RegExp(`^<(${tagName})\\s*/>$`));
26
+ if (match && knownToolNames.has(match[1]!)) return match[1]!;
27
+
28
+ match = body.match(new RegExp(`^<(${tagName})\\s*>([\\s\\S]*?)<\\/\\1\\s*>$`));
29
+ if (match && knownToolNames.has(match[1]!)) return match[1]!;
30
+
31
+ // Legacy XML formatter shape used by older Claude harnesses.
32
+ match = body.match(/^<invoke\s*>\s*<tool\s+name=(['"])([^'"]+)\1(?:\s+[^>]*)?\s*\/>\s*<\/invoke\s*>$/);
33
+ if (match && knownToolNames.has(match[2]!)) return match[2]!;
34
+ match = body.match(/^<invoke\s*>\s*<tool\s+name=(['"])([^'"]+)\1(?:\s+[^>]*)?\s*>([\s\S]*?)<\/tool\s*>\s*<\/invoke\s*>$/);
35
+ if (match && knownToolNames.has(match[2]!)) return match[2]!;
36
+
37
+ return null;
38
+ }
@@ -0,0 +1,548 @@
1
+ /**
2
+ * TuneOutCoordinator (issue #77) — the host-side machinery between a
3
+ * tuned-out channel and the subconscious resident.
4
+ *
5
+ * Responsibilities: classify diverted traffic (stamp everything, wake on
6
+ * addressed), acknowledge suppressed mentions deterministically, run the
7
+ * per-channel summary cadence, count wakes durably with max-wakes
8
+ * auto-cancel, and deliver the cancel dump.
9
+ *
10
+ * Voice doctrine ("no words in models' mouths"): everything the resident
11
+ * reads from the subconscious is the subconscious's verbatim text under its
12
+ * own participant name. Host-authored framing is system-styled and never
13
+ * voiced: `<tuned-out-backlog …>` wraps raw diverted messages (the
14
+ * <backscroll> precedent), and `[Tune-out: …]` bracket notices carry
15
+ * lifecycle facts (the [Gate: …] precedent).
16
+ */
17
+
18
+ import { randomUUID } from 'node:crypto';
19
+ import type { ContentBlock } from '@animalabs/membrane';
20
+ import type { StoredMessage } from '@animalabs/context-manager';
21
+ import type { ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js';
22
+ import type { McplServerRegistry } from '../mcpl/server-registry.js';
23
+ import { CapabilityGrant } from '../mcpl/capability-grant.js';
24
+
25
+ /** Defaults for tune_out tool params. */
26
+ export const TUNE_OUT_DEFAULTS = {
27
+ cadenceSeconds: 1800,
28
+ backlogCap: 200,
29
+ maxWakes: 5,
30
+ } as const;
31
+
32
+ /** Coalescing window for wake invocations: a mention storm becomes one
33
+ * subconscious turn, and max-wakes counts invocations, not messages. */
34
+ const WAKE_COALESCE_MS = 2_000;
35
+
36
+ export interface TuneOutFrameworkHooks {
37
+ /** Deliver a message into an agent's window (per-agent seam). */
38
+ addMessage: (
39
+ participant: string,
40
+ content: ContentBlock[],
41
+ metadata?: Record<string, unknown>,
42
+ forAgent?: string,
43
+ ) => string;
44
+ /** Queue an inference request for an agent. */
45
+ requestInference: (agentName: string, reason: string, source: string) => void;
46
+ /** The subconscious's registry/participant name. */
47
+ subconsciousName: () => string | null;
48
+ /** The primary resident's registry name. */
49
+ primaryName: () => string | null;
50
+ /** Read the primary's stored messages (shared slot, unfiltered). */
51
+ getStoredMessages: () => StoredMessage[];
52
+ /** Current chronicle head sequence (window anchor for new epochs). */
53
+ currentSequence: () => number;
54
+ /** Move the subconscious window anchor (never forward past active epochs). */
55
+ setSubconsciousAnchor: (sequence: number) => void;
56
+ /** True when a channel is bound to a conversation-fork agent. */
57
+ isForkBound: (channelId: string) => boolean;
58
+ /** Gate privileged-users list (#77: privileged authors wake, like mentions). */
59
+ isPrivilegedAuthor: (authorId: string | null | undefined) => boolean;
60
+ /** speak_in_channel master switch (recipe-side, canary-gated). */
61
+ allowChannelSpeech: () => boolean;
62
+ emitTrace: (event: { type: string; [key: string]: unknown }) => void;
63
+ }
64
+
65
+ export class TuneOutCoordinator {
66
+ private cadenceTimers = new Map<string, ReturnType<typeof setTimeout>>();
67
+ /** Duration-expiry timers (params.expiresAtMs); cleared on any cancel. */
68
+ private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
69
+ /** Channels with a wake invocation pending inside the coalescing window. */
70
+ private pendingWakes = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>();
71
+ /** Diverted-message tallies since the last subconscious invocation, per channel key. */
72
+ private divertedSinceInvocation = new Map<string, number>();
73
+
74
+ constructor(
75
+ private readonly channelRegistry: ChannelRegistry,
76
+ private readonly serverRegistry: McplServerRegistry,
77
+ private readonly hooks: TuneOutFrameworkHooks,
78
+ ) {}
79
+
80
+ private key(serverId: string, channelId: string): string {
81
+ return `${serverId}\0${channelId}`;
82
+ }
83
+
84
+ /** Restart cadence + expiry timers and re-anchor after a process restart. */
85
+ resumeActiveEpochs(): void {
86
+ let oldestAnchor: number | null = null;
87
+ for (const { serverId, channelId, params } of this.listActive()) {
88
+ // An epoch whose deadline passed while the process was down cancels
89
+ // immediately — the resident gets their backlog on the schedule they
90
+ // committed to, not whenever the host happened to restart.
91
+ if (params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now()) {
92
+ this.cancel(serverId, channelId, 'duration', 'duration elapsed');
93
+ continue;
94
+ }
95
+ this.startCadence(serverId, channelId, params.cadenceSeconds);
96
+ this.armExpiry(serverId, channelId, params);
97
+ if (oldestAnchor === null || params.startedAtSequence < oldestAnchor) {
98
+ oldestAnchor = params.startedAtSequence;
99
+ }
100
+ }
101
+ if (oldestAnchor !== null) {
102
+ this.hooks.setSubconsciousAnchor(oldestAnchor);
103
+ }
104
+ }
105
+
106
+ private armExpiry(serverId: string, channelId: string, params: TuneOutParams): void {
107
+ if (params.expiresAtMs === undefined) return;
108
+ const key = this.key(serverId, channelId);
109
+ const existing = this.expiryTimers.get(key);
110
+ if (existing) clearTimeout(existing);
111
+ const remaining = Math.max(0, params.expiresAtMs - Date.now());
112
+ this.expiryTimers.set(key, setTimeout(() => {
113
+ this.expiryTimers.delete(key);
114
+ this.cancel(serverId, channelId, 'duration', 'duration elapsed');
115
+ }, remaining));
116
+ }
117
+
118
+ private listActive(): Array<{ serverId: string; channelId: string; params: TuneOutParams }> {
119
+ const active: Array<{ serverId: string; channelId: string; params: TuneOutParams }> = [];
120
+ for (const entry of this.channelRegistry.listChannelsRaw()) {
121
+ const state = this.channelRegistry.getTuneOutState(entry.serverId, entry.descriptor.id);
122
+ if (state) {
123
+ active.push({ serverId: entry.serverId, channelId: entry.descriptor.id, params: state.params });
124
+ }
125
+ }
126
+ return active;
127
+ }
128
+
129
+ // ==========================================================================
130
+ // Enter / cancel
131
+ // ==========================================================================
132
+
133
+ enter(
134
+ serverId: string,
135
+ channelId: string,
136
+ opts: { cadenceSeconds?: number; backlogCap?: number; maxWakes?: number; durationSeconds?: number },
137
+ source: string,
138
+ ): { ok: true; params: TuneOutParams } | { ok: false; error: string } {
139
+ if (!this.hooks.subconsciousName()) {
140
+ return { ok: false, error: 'tune-out requires the subconscious resident (framework config `subconscious`)' };
141
+ }
142
+ if (this.hooks.isForkBound(channelId)) {
143
+ return { ok: false, error: `channel ${channelId} is bound to a conversation fork; tune-out and fork routing are mutually exclusive` };
144
+ }
145
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
146
+ return { ok: false, error: `channel ${channelId} is already tuned out (cancel first to change params)` };
147
+ }
148
+ const params: TuneOutParams = {
149
+ epochId: randomUUID(),
150
+ cadenceSeconds: Math.max(30, opts.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
151
+ backlogCap: Math.max(0, opts.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap),
152
+ maxWakes: Math.max(1, opts.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
153
+ startedAtSequence: this.hooks.currentSequence(),
154
+ ...(opts.durationSeconds !== undefined
155
+ ? { expiresAtMs: Date.now() + Math.max(60, opts.durationSeconds) * 1000 }
156
+ : {}),
157
+ };
158
+ this.channelRegistry.enterTuneOut(serverId, channelId, params, source);
159
+ this.armExpiry(serverId, channelId, params);
160
+
161
+ // Window anchor: start of the OLDEST active tune-out. Never move the
162
+ // anchor forward here — an older active epoch owns it.
163
+ const oldest = this.listActive().reduce(
164
+ (min, e) => Math.min(min, e.params.startedAtSequence),
165
+ params.startedAtSequence,
166
+ );
167
+ this.hooks.setSubconsciousAnchor(oldest);
168
+
169
+ this.divertedSinceInvocation.set(this.key(serverId, channelId), 0);
170
+ this.startCadence(serverId, channelId, params.cadenceSeconds);
171
+ this.hooks.emitTrace({ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId });
172
+ return { ok: true, params };
173
+ }
174
+
175
+ /**
176
+ * End a tune-out: durable flip, system-framed backlog dump into the
177
+ * resident's window (capped), lifecycle notice, one wake for the
178
+ * resident. `subconsciousNote` is the subconscious's own text (from its
179
+ * cancel_tuneout call) — delivered verbatim under its name, never
180
+ * synthesized here.
181
+ */
182
+ cancel(
183
+ serverId: string,
184
+ channelId: string,
185
+ source: string,
186
+ reason: string,
187
+ subconsciousNote?: string,
188
+ ): { ok: true } | { ok: false; error: string } {
189
+ const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source);
190
+ if (!ended) {
191
+ return { ok: false, error: `channel ${channelId} is not tuned out` };
192
+ }
193
+ this.stopCadence(serverId, channelId);
194
+ const expiry = this.expiryTimers.get(this.key(serverId, channelId));
195
+ if (expiry) {
196
+ clearTimeout(expiry);
197
+ this.expiryTimers.delete(this.key(serverId, channelId));
198
+ }
199
+ const pending = this.pendingWakes.get(this.key(serverId, channelId));
200
+ if (pending) {
201
+ clearTimeout(pending.timer);
202
+ this.pendingWakes.delete(this.key(serverId, channelId));
203
+ }
204
+ this.divertedSinceInvocation.delete(this.key(serverId, channelId));
205
+
206
+ // Backlog: every message stamped with this epoch, oldest first.
207
+ const epochId = ended.params.epochId;
208
+ const backlog = this.hooks.getStoredMessages().filter(
209
+ (m) => (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId,
210
+ );
211
+ const cap = ended.params.backlogCap;
212
+ const shown = cap > 0 ? backlog.slice(-cap) : [];
213
+ const truncated = backlog.length - shown.length;
214
+
215
+ const lines = shown.map((m) => {
216
+ const author = (m.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? m.participant;
217
+ const text = m.content
218
+ .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
219
+ .map((b) => b.text)
220
+ .join('\n');
221
+ return `${author}: ${text}`;
222
+ });
223
+ const attrs = [
224
+ `channel="${channelId}"`,
225
+ `messages=${backlog.length}`,
226
+ ...(truncated > 0 ? [`truncated=${truncated} (oldest; fetch_history covers the rest)`] : []),
227
+ ];
228
+ const dump =
229
+ `<tuned-out-backlog ${attrs.join(' ')}>\n${lines.join('\n')}\n</tuned-out-backlog>`;
230
+
231
+ // Host-framed system artifacts — never voiced as anyone.
232
+ this.hooks.addMessage(
233
+ 'user',
234
+ [{ type: 'text', text: `[Tune-out: ${channelId} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]\n\n${dump}` }],
235
+ { system: true, kind: 'tune-out-cancel', channelId, epochId },
236
+ );
237
+ // The subconscious's own words, under its own name, verbatim.
238
+ const subName = this.hooks.subconsciousName();
239
+ if (subconsciousNote && subName) {
240
+ this.hooks.addMessage(
241
+ subName,
242
+ [{ type: 'text', text: subconsciousNote }],
243
+ { kind: 'subconscious-note', channelId, epochId },
244
+ );
245
+ }
246
+ const primary = this.hooks.primaryName();
247
+ if (primary) {
248
+ this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
249
+ }
250
+ // The subconscious gets one cancel turn of its own (unless it initiated
251
+ // the cancel — its note already carries its words): the issue's cancel
252
+ // shape is dump + a report from the subconscious, and when the cap
253
+ // truncated the raw dump, its summary is what covers the tail.
254
+ if (source !== 'subconscious' && subName) {
255
+ const truncNote = truncated > 0
256
+ ? ` ${truncated} of ${backlog.length} diverted messages were above the cap and not delivered raw.`
257
+ : '';
258
+ this.hooks.addMessage(
259
+ 'user',
260
+ [{ type: 'text', text: `[Tune-out cancelled: ${channelId} — ${reason}.${truncNote}]` }],
261
+ { system: true, kind: 'tune-out-cancel-report', channelId },
262
+ subName,
263
+ );
264
+ this.hooks.requestInference(subName, `tune-out cancelled: ${channelId}`, 'tune-out');
265
+ }
266
+ this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount });
267
+ return { ok: true };
268
+ }
269
+
270
+ // ==========================================================================
271
+ // Ingestion: divert, stamp, classify
272
+ // ==========================================================================
273
+
274
+ /**
275
+ * Consulted by the framework for every incoming channel message BEFORE
276
+ * storage. Null = channel not tuned out (proceed normally). Otherwise the
277
+ * caller stamps `metadata.tuneOut = { epochId }`, stores the message
278
+ * without waking the residents, and this coordinator has already handled
279
+ * wake bookkeeping (coalesced subconscious invocation, durable count,
280
+ * suppression ack, possible auto-cancel).
281
+ */
282
+ onIncoming(
283
+ serverId: string,
284
+ channelId: string,
285
+ messageId: string,
286
+ tags: string[] | undefined,
287
+ authorId?: string,
288
+ gatePass = true,
289
+ ): { epochId: string } | null {
290
+ const state = this.channelRegistry.getTuneOutState(serverId, channelId);
291
+ if (!state) return null;
292
+
293
+ const key = this.key(serverId, channelId);
294
+ this.divertedSinceInvocation.set(key, (this.divertedSinceInvocation.get(key) ?? 0) + 1);
295
+
296
+ // Wake classification: the resident's wake gate composes as a
297
+ // PRECONDITION (antra, #tuneout-talk: the subconscious is affected BY
298
+ // main's wake gate, it does not replace it) — an event the gate would
299
+ // suppress for the resident (muted author, rate-limit) must not wake
300
+ // the subconscious either; it diverts silently into the backlog and
301
+ // surfaces in cadence summaries. On top of the gate: addressed
302
+ // mentions and gate-privileged authors wake, per the issue.
303
+ const addressed = Array.isArray(tags) && tags.includes('chat:addressed');
304
+ const privileged = this.hooks.isPrivilegedAuthor(authorId);
305
+ if (gatePass && addressed) {
306
+ // The mention-er always gets a deterministic signal, independent of
307
+ // what the subconscious later decides (grant-gated; decline
308
+ // precedent). Gate-suppressed mentions get NO reaction: main would
309
+ // not have signaled either, and reacting would leak the resident's
310
+ // standing mute to its subject.
311
+ void this.acknowledgeSuppressed(serverId, channelId, messageId);
312
+ }
313
+ if (gatePass && (addressed || privileged)) {
314
+ this.scheduleWakeInvocation(serverId, channelId);
315
+ }
316
+ return { epochId: state.params.epochId };
317
+ }
318
+
319
+ private async acknowledgeSuppressed(serverId: string, channelId: string, messageId: string): Promise<void> {
320
+ try {
321
+ const server = this.serverRegistry.getServer(serverId);
322
+ if (!server || !CapabilityGrant.of(server).has('channels.acknowledge')) return;
323
+ await server.sendChannelsAcknowledge({
324
+ channelId,
325
+ messageId,
326
+ intent: 'suppressed-tuned-out',
327
+ });
328
+ } catch (err) {
329
+ this.hooks.emitTrace({
330
+ type: 'tune-out:ack-failed',
331
+ serverId,
332
+ channelId,
333
+ error: (err as Error).message,
334
+ });
335
+ }
336
+ }
337
+
338
+ // ==========================================================================
339
+ // Subconscious invocation (cadence + coalesced wakes)
340
+ // ==========================================================================
341
+
342
+ private startCadence(serverId: string, channelId: string, cadenceSeconds: number): void {
343
+ this.stopCadence(serverId, channelId);
344
+ const key = this.key(serverId, channelId);
345
+ const tick = () => {
346
+ const state = this.channelRegistry.getTuneOutState(serverId, channelId);
347
+ if (!state) return; // cancelled meanwhile
348
+ const diverted = this.divertedSinceInvocation.get(key) ?? 0;
349
+ // An empty period needs no turn at all — skip silently.
350
+ if (diverted > 0) {
351
+ this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
352
+ }
353
+ this.cadenceTimers.set(key, setTimeout(tick, cadenceSeconds * 1000));
354
+ };
355
+ this.cadenceTimers.set(key, setTimeout(tick, cadenceSeconds * 1000));
356
+ }
357
+
358
+ private stopCadence(serverId: string, channelId: string): void {
359
+ const key = this.key(serverId, channelId);
360
+ const timer = this.cadenceTimers.get(key);
361
+ if (timer) clearTimeout(timer);
362
+ this.cadenceTimers.delete(key);
363
+ }
364
+
365
+ private scheduleWakeInvocation(serverId: string, channelId: string): void {
366
+ const key = this.key(serverId, channelId);
367
+ const pending = this.pendingWakes.get(key);
368
+ if (pending) {
369
+ pending.count++;
370
+ return;
371
+ }
372
+ const timer = setTimeout(() => {
373
+ const entry = this.pendingWakes.get(key);
374
+ this.pendingWakes.delete(key);
375
+ // Count ONE durable wake per coalesced invocation.
376
+ const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
377
+ if (!counted) return; // cancelled inside the window
378
+ if (counted.wakeCount > counted.params.maxWakes) {
379
+ this.cancel(
380
+ serverId,
381
+ channelId,
382
+ 'tune-out-max-wakes',
383
+ `wake budget exhausted (${counted.params.maxWakes})`,
384
+ );
385
+ return;
386
+ }
387
+ this.invokeSubconscious(serverId, channelId, 'wake', entry?.count ?? 1);
388
+ }, WAKE_COALESCE_MS);
389
+ this.pendingWakes.set(key, { count: 1, timer });
390
+ }
391
+
392
+ /**
393
+ * One subconscious turn: a system-framed trigger notice (bracket style —
394
+ * what happened, never what to say) plus standing dispositions, then an
395
+ * inference request. The merged view supplies the actual traffic.
396
+ */
397
+ private invokeSubconscious(
398
+ serverId: string,
399
+ channelId: string,
400
+ trigger: 'cadence' | 'wake',
401
+ count: number,
402
+ ): void {
403
+ const subName = this.hooks.subconsciousName();
404
+ if (!subName) return;
405
+ const key = this.key(serverId, channelId);
406
+ this.divertedSinceInvocation.set(key, 0);
407
+
408
+ const notice = trigger === 'wake'
409
+ ? `[Tune-out wake: ${channelId} — ${count} addressed message${count === 1 ? '' : 's'}]`
410
+ : `[Tune-out cadence: ${channelId} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`;
411
+ this.hooks.addMessage(
412
+ 'user',
413
+ [{ type: 'text', text: notice }],
414
+ { system: true, kind: `tune-out-${trigger}`, channelId },
415
+ subName,
416
+ );
417
+ this.hooks.requestInference(subName, `tune-out ${trigger}: ${channelId}`, 'tune-out');
418
+ }
419
+
420
+ // ==========================================================================
421
+ // Dispositions (standing notes, snapshot slot)
422
+ // ==========================================================================
423
+
424
+ private dispositionsCache: Record<string, string> | null = null;
425
+
426
+ private get dispositionsStateId(): string {
427
+ return `subconscious/${this.hooks.primaryName() ?? 'default'}/dispositions`;
428
+ }
429
+
430
+ /**
431
+ * The dispositions as a fixed system-position ContextInjection for the
432
+ * subconscious's compiles (antra: deliver changes, never let them wash
433
+ * away — it has no long-term memory, so they must not live in window
434
+ * content). Bytes change only when a disposition changes: KV-friendly.
435
+ */
436
+ getDispositionsInjection(): {
437
+ namespace: string;
438
+ position: 'system';
439
+ content: Array<{ type: 'text'; text: string }>;
440
+ } | null {
441
+ const dispositions = this.readDispositions();
442
+ const entries = Object.entries(dispositions);
443
+ if (entries.length === 0) return null;
444
+ return {
445
+ namespace: 'dispositions',
446
+ position: 'system',
447
+ content: [{
448
+ type: 'text',
449
+ text: `[Standing dispositions]\n${entries.map(([k, v]) => `- ${k}: ${v}`).join('\n')}`,
450
+ }],
451
+ };
452
+ }
453
+
454
+ private readDispositions(): Record<string, string> {
455
+ if (this.dispositionsCache) return this.dispositionsCache;
456
+ try {
457
+ const raw = this.channelRegistry.readCoordinatorState(this.dispositionsStateId);
458
+ this.dispositionsCache = (raw && typeof raw === 'object') ? raw as Record<string, string> : {};
459
+ } catch {
460
+ this.dispositionsCache = {};
461
+ }
462
+ return this.dispositionsCache;
463
+ }
464
+
465
+ noteDisposition(key: string, text: string): void {
466
+ const dispositions = { ...this.readDispositions() };
467
+ if (text === '') {
468
+ delete dispositions[key];
469
+ } else {
470
+ dispositions[key] = text;
471
+ }
472
+ this.dispositionsCache = dispositions;
473
+ this.channelRegistry.writeCoordinatorState(this.dispositionsStateId, dispositions);
474
+ }
475
+
476
+ // ==========================================================================
477
+ // Subconscious tool dispatch
478
+ // ==========================================================================
479
+
480
+ async handleSubconsciousTool(
481
+ name: string,
482
+ input: Record<string, unknown>,
483
+ ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
484
+ switch (name) {
485
+ case 'deliver_summary': {
486
+ const text = String(input.text ?? '').trim();
487
+ const channelId = String(input.channelId ?? '');
488
+ if (!text) return { success: false, error: 'empty summary', isError: false };
489
+ const subName = this.hooks.subconsciousName();
490
+ if (!subName) return { success: false, error: 'subconscious not configured', isError: true };
491
+ // Verbatim, under its own participant name, into the resident's window.
492
+ this.hooks.addMessage(subName, [{ type: 'text', text }], {
493
+ kind: 'subconscious-summary',
494
+ channelId,
495
+ });
496
+ return { success: true, data: { delivered: true } };
497
+ }
498
+ case 'cancel_tuneout': {
499
+ const channelId = String(input.channelId ?? '');
500
+ const located = this.findByChannel(channelId);
501
+ if (!located) return { success: false, error: `channel ${channelId} is not tuned out`, isError: false };
502
+ const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
503
+ const result = this.cancel(located.serverId, channelId, 'subconscious', 'subconscious judgment', note);
504
+ return result.ok
505
+ ? { success: true, data: { cancelled: true } }
506
+ : { success: false, error: result.error, isError: false };
507
+ }
508
+ case 'note_disposition': {
509
+ const key = String(input.key ?? '').trim();
510
+ if (!key) return { success: false, error: 'key required', isError: false };
511
+ this.noteDisposition(key, String(input.text ?? ''));
512
+ return { success: true, data: { noted: key } };
513
+ }
514
+ case 'speak_in_channel': {
515
+ if (!this.hooks.allowChannelSpeech()) {
516
+ return {
517
+ success: false,
518
+ isError: false,
519
+ error: 'channel speech is disabled for the subconscious (recipe: subconscious.allowChannelSpeech)',
520
+ };
521
+ }
522
+ const channelId = String(input.channelId ?? '');
523
+ const text = String(input.text ?? '').trim();
524
+ if (!text) return { success: false, error: 'empty message', isError: false };
525
+ return this.channelRegistry.publishForAgent(channelId, text, this.hooks.subconsciousName() ?? 'Subconscious');
526
+ }
527
+ default:
528
+ return { success: false, error: `unknown subconscious tool: ${name}`, isError: true };
529
+ }
530
+ }
531
+
532
+ private findByChannel(channelId: string): { serverId: string } | null {
533
+ for (const entry of this.listActive()) {
534
+ if (entry.channelId === channelId) return { serverId: entry.serverId };
535
+ }
536
+ return null;
537
+ }
538
+
539
+ /** Stop all timers (framework shutdown). */
540
+ stop(): void {
541
+ for (const timer of this.cadenceTimers.values()) clearTimeout(timer);
542
+ this.cadenceTimers.clear();
543
+ for (const timer of this.expiryTimers.values()) clearTimeout(timer);
544
+ this.expiryTimers.clear();
545
+ for (const { timer } of this.pendingWakes.values()) clearTimeout(timer);
546
+ this.pendingWakes.clear();
547
+ }
548
+ }