@animalabs/agent-framework 0.12.0 → 0.14.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 (106) hide show
  1. package/dist/src/agent.d.ts +2 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +3 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +76 -4
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +933 -151
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +62 -2
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +56 -2
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/index.d.ts +1 -1
  14. package/dist/src/gate/index.d.ts.map +1 -1
  15. package/dist/src/gate/index.js +1 -1
  16. package/dist/src/gate/index.js.map +1 -1
  17. package/dist/src/index.d.ts +1 -1
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/mcpl/channel-registry.d.ts +84 -1
  21. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  22. package/dist/src/mcpl/channel-registry.js +160 -4
  23. package/dist/src/mcpl/channel-registry.js.map +1 -1
  24. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  25. package/dist/src/modules/workspace/index.js +5 -1
  26. package/dist/src/modules/workspace/index.js.map +1 -1
  27. package/dist/src/tool-image-ledger.d.ts +175 -0
  28. package/dist/src/tool-image-ledger.d.ts.map +1 -0
  29. package/dist/src/tool-image-ledger.js +257 -0
  30. package/dist/src/tool-image-ledger.js.map +1 -0
  31. package/dist/src/tool-result-history.d.ts +15 -2
  32. package/dist/src/tool-result-history.d.ts.map +1 -1
  33. package/dist/src/tool-result-history.js +16 -9
  34. package/dist/src/tool-result-history.js.map +1 -1
  35. package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
  36. package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
  37. package/dist/src/tool-wrapper-prose-guard.js +37 -0
  38. package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
  39. package/dist/src/tune-out/coordinator.d.ts +144 -0
  40. package/dist/src/tune-out/coordinator.d.ts.map +1 -0
  41. package/dist/src/tune-out/coordinator.js +444 -0
  42. package/dist/src/tune-out/coordinator.js.map +1 -0
  43. package/dist/src/tune-out/tools.d.ts +43 -0
  44. package/dist/src/tune-out/tools.d.ts.map +1 -0
  45. package/dist/src/tune-out/tools.js +93 -0
  46. package/dist/src/tune-out/tools.js.map +1 -0
  47. package/dist/src/types/agent.d.ts +17 -0
  48. package/dist/src/types/agent.d.ts.map +1 -1
  49. package/dist/src/types/framework.d.ts +6 -0
  50. package/dist/src/types/framework.d.ts.map +1 -1
  51. package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
  52. package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
  53. package/dist/test/channel-incoming-targeting.test.js +81 -0
  54. package/dist/test/channel-incoming-targeting.test.js.map +1 -0
  55. package/dist/test/event-gate.test.js +84 -1
  56. package/dist/test/event-gate.test.js.map +1 -1
  57. package/dist/test/per-agent-delivery.test.d.ts +2 -0
  58. package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
  59. package/dist/test/per-agent-delivery.test.js +108 -0
  60. package/dist/test/per-agent-delivery.test.js.map +1 -0
  61. package/dist/test/puppet-tool-call.test.js +1 -0
  62. package/dist/test/puppet-tool-call.test.js.map +1 -1
  63. package/dist/test/save-recent-image.test.d.ts +26 -0
  64. package/dist/test/save-recent-image.test.d.ts.map +1 -0
  65. package/dist/test/save-recent-image.test.js +707 -0
  66. package/dist/test/save-recent-image.test.js.map +1 -0
  67. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
  68. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
  69. package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
  70. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
  71. package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
  72. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
  73. package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
  74. package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
  75. package/dist/test/tune-out-e2e.test.d.ts +2 -0
  76. package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
  77. package/dist/test/tune-out-e2e.test.js +142 -0
  78. package/dist/test/tune-out-e2e.test.js.map +1 -0
  79. package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
  80. package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
  81. package/dist/test/tune-out-gate-composition.test.js +221 -0
  82. package/dist/test/tune-out-gate-composition.test.js.map +1 -0
  83. package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
  84. package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
  85. package/dist/test/tune-out-lifecycle.test.js +121 -0
  86. package/dist/test/tune-out-lifecycle.test.js.map +1 -0
  87. package/dist/test/turn-trigger-provenance.test.js +17 -0
  88. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  89. package/dist/test/workspace-read-image.test.js +8 -0
  90. package/dist/test/workspace-read-image.test.js.map +1 -1
  91. package/dist/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +2 -2
  93. package/src/agent.ts +3 -0
  94. package/src/framework.ts +1051 -154
  95. package/src/gate/event-gate.ts +99 -6
  96. package/src/gate/index.ts +1 -1
  97. package/src/index.ts +1 -1
  98. package/src/mcpl/channel-registry.ts +235 -7
  99. package/src/modules/workspace/index.ts +5 -1
  100. package/src/tool-image-ledger.ts +334 -0
  101. package/src/tool-result-history.ts +30 -9
  102. package/src/tool-wrapper-prose-guard.ts +38 -0
  103. package/src/tune-out/coordinator.ts +548 -0
  104. package/src/tune-out/tools.ts +129 -0
  105. package/src/types/agent.ts +18 -0
  106. package/src/types/framework.ts +7 -0
@@ -78,6 +78,37 @@ interface PendingEvent {
78
78
  * metadata carries one — batched-wake lines render this instead of the raw
79
79
  * composite channel id. */
80
80
  channelLabel?: string;
81
+ /** Author id of the event (discord snowflake etc.), when the metadata carries
82
+ * one — a debounced wake names the newest such author as its counterparty. */
83
+ authorId?: string;
84
+ /** MCPL server id the event came from — the namespace for the counterparty
85
+ * (`<serverId>:user:<id>`), same as the direct channel-incoming path. */
86
+ serverId?: string;
87
+ /** `chat:addressed` (mention / reply-to-bot / DM): an addressed event outranks
88
+ * newer ambient chatter when a batched wake picks its provenance — the same
89
+ * rule the framework applies to the turn's speech locus. */
90
+ addressed: boolean;
91
+ }
92
+
93
+ /**
94
+ * Where a gate-requested wake came from, handed to the framework alongside the
95
+ * reason so the turn's InferenceRequest can carry it (host telemetry stamps
96
+ * "who woke the agent" from it). Ids only, never content or display names.
97
+ */
98
+ export interface WakeProvenance {
99
+ /** Composite channel id of the chosen event — telemetry only, never a
100
+ * speech locus. Omitted for `mcpl:push-event` events, whose channel ids
101
+ * are raw server ids (unroutable and a second spelling of the same
102
+ * channel); the framework derives the composite id on the direct path. */
103
+ channelId?: string;
104
+ /** Server-namespaced author id: `<serverId>:user:<id>` (falls back to the
105
+ * channel id's first segment only when the event carried no server id). */
106
+ counterparty?: string;
107
+ /** True when the chosen event was `chat:addressed`. */
108
+ addressed?: boolean;
109
+ /** Timestamp (ms) of the chosen event, so a consumer coalescing several
110
+ * requests can order by event recency, not by flush order. */
111
+ at?: number;
81
112
  }
82
113
 
83
114
  interface DebounceState {
@@ -149,6 +180,34 @@ function isMetadataTruthy(value: unknown): boolean {
149
180
  return Boolean(value);
150
181
  }
151
182
 
183
+ /**
184
+ * Provenance of a batched wake: the newest ADDRESSED channel-bearing event
185
+ * wins (mention / reply / DM outranks newer ambient chatter — the framework's
186
+ * own locus rule); else the newest channel-bearing event. Channel, author and
187
+ * addressed all come from that one event, so they can never disagree.
188
+ */
189
+ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefined {
190
+ let newest: PendingEvent | undefined;
191
+ let newestAddressed: PendingEvent | undefined;
192
+ for (const e of events) {
193
+ if (!e.channelId && !e.authorId) continue;
194
+ if (!newest || e.timestamp >= newest.timestamp) newest = e;
195
+ if (e.addressed && (!newestAddressed || e.timestamp >= newestAddressed.timestamp)) newestAddressed = e;
196
+ }
197
+ const pick = newestAddressed ?? newest;
198
+ if (!pick) return undefined;
199
+ // push-event channel ids are the adapter's raw ids (a Discord snowflake):
200
+ // not a composite channel id, so not reported as one
201
+ const channelId = pick.channelId && pick.eventType !== 'mcpl:push-event' ? pick.channelId : undefined;
202
+ const ns = pick.serverId || (pick.channelId ? pick.channelId.split(':')[0] : '') || 'channel';
203
+ return {
204
+ ...(channelId ? { channelId } : {}),
205
+ ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
206
+ ...(pick.addressed ? { addressed: true } : {}),
207
+ at: pick.timestamp,
208
+ };
209
+ }
210
+
152
211
  /** Compact, log-friendly serialization of a GateBehavior. */
153
212
  function formatBehavior(b: import('./types.js').GateBehavior): string {
154
213
  if (typeof b === 'string') return b;
@@ -515,8 +574,14 @@ export class EventGate {
515
574
 
516
575
  // Dependency-injected callbacks
517
576
  private emitTrace: (event: TraceEventLike) => void;
518
- private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
519
- private requestInferenceFn: (agentName: string, reason: string, source: string) => void;
577
+ private addMessageFn: (
578
+ participant: string,
579
+ content: Array<{ type: 'text'; text: string }>,
580
+ metadata?: Record<string, unknown>,
581
+ /** Registry agent to deliver into; absent = primary (historical). */
582
+ forAgent?: string,
583
+ ) => unknown;
584
+ private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance) => void;
520
585
  private getAgentNamesFn: () => string[];
521
586
  /** Clock injection — keeps the new rate_limit / passive_sample paths
522
587
  * testable without monkey-patching Date.now globally. */
@@ -527,8 +592,13 @@ export class EventGate {
527
592
  initialConfig?: GateConfig;
528
593
  privilegedUsersPath?: string;
529
594
  emitTrace: (event: TraceEventLike) => void;
530
- addMessage: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
531
- requestInference: (agentName: string, reason: string, source: string) => void;
595
+ addMessage: (
596
+ participant: string,
597
+ content: Array<{ type: 'text'; text: string }>,
598
+ metadata?: Record<string, unknown>,
599
+ forAgent?: string,
600
+ ) => unknown;
601
+ requestInference: (agentName: string, reason: string, source: string, provenance?: WakeProvenance) => void;
532
602
  getAgentNames: () => string[];
533
603
  /** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
534
604
  now?: () => number;
@@ -1374,6 +1444,9 @@ export class EventGate {
1374
1444
  typeof info.metadata?.channelName === 'string' && info.metadata.channelName
1375
1445
  ? (info.metadata.channelName as string)
1376
1446
  : undefined,
1447
+ authorId: this.extractAuthorId(info.metadata) ?? undefined,
1448
+ serverId: info.serverId || undefined,
1449
+ addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'),
1377
1450
  };
1378
1451
 
1379
1452
  const existing = this.debounceTimers.get(policy.name);
@@ -1461,8 +1534,14 @@ export class EventGate {
1461
1534
  policies: policyNames,
1462
1535
  });
1463
1536
 
1537
+ // Provenance of the batched wake (telemetry: who/where woke the agent):
1538
+ // the newest ADDRESSED event first, else the newest event naming a
1539
+ // channel or an author; channel + author + addressed from that ONE
1540
+ // event. It does not set the turn's speech locus — the framework keeps
1541
+ // its own routing rule for that.
1542
+ const provenance = wakeProvenance(events);
1464
1543
  for (const agentName of this.getAgentNamesFn()) {
1465
- this.requestInferenceFn(agentName, 'gate:debounce', 'gate');
1544
+ this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance);
1466
1545
  }
1467
1546
  }
1468
1547
 
@@ -1582,10 +1661,13 @@ export class EventGate {
1582
1661
  // before the inference request so it rides the wake turn (addMessage's
1583
1662
  // turn-alive guard defers it to that turn's start if needed).
1584
1663
  const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
1664
+ // Deliver the notice into the WAKING agent's window — a self-wake
1665
+ // armed by a non-primary agent (e.g. a subconscious cadence) must not
1666
+ // narrate itself into the primary's context.
1585
1667
  this.addMessageFn('user', [{
1586
1668
  type: 'text',
1587
1669
  text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
1588
- }], { source: 'gate:self-wake' });
1670
+ }], { source: 'gate:self-wake' }, agentName);
1589
1671
  this.requestInferenceFn(
1590
1672
  agentName,
1591
1673
  `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`,
@@ -1659,6 +1741,17 @@ export class EventGate {
1659
1741
  }
1660
1742
 
1661
1743
  /** Load the privileged-users file (bare array or { userIds: [...] }). */
1744
+ /**
1745
+ * Whether an author id is on the privileged-users list (hot-reloaded).
1746
+ * Shared by sleep bypass and tune-out wake classification (#77: wakes are
1747
+ * "mentions, gate-privileged authors, etc.").
1748
+ */
1749
+ isPrivilegedUser(authorId: string | null | undefined): boolean {
1750
+ if (!authorId) return false;
1751
+ this.loadPrivileged();
1752
+ return this.privilegedUserIds.has(authorId);
1753
+ }
1754
+
1662
1755
  private loadPrivileged(): void {
1663
1756
  if (!this.privilegedUsersPath) return;
1664
1757
  try {
package/src/gate/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { EventGate } from './event-gate.js';
1
+ export { EventGate, wakeProvenance, type WakeProvenance } from './event-gate.js';
2
2
  export type {
3
3
  GateConfig,
4
4
  GateOptions,
package/src/index.ts CHANGED
@@ -76,7 +76,7 @@ export type {
76
76
  export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './usage/index.js';
77
77
 
78
78
  // EventGate
79
- export { EventGate } from './gate/index.js';
79
+ export { EventGate, type WakeProvenance } from './gate/index.js';
80
80
  export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior } from './gate/index.js';
81
81
 
82
82
  // MCPL channel registry (exposed for modules that need channel-level operations)
@@ -45,14 +45,53 @@ import { CapabilityGrant } from './capability-grant.js';
45
45
  const TYPING_INTERVAL_MS = 7_000;
46
46
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
47
47
 
48
- type DesiredChannelState = 'open' | 'closed';
48
+ type DesiredChannelState = 'open' | 'closed' | 'tuned-out';
49
+
50
+ /**
51
+ * Parameters of an active tune-out (issue #77), carried on the
52
+ * 'desired-state' record entering the tuned-out state and projected into
53
+ * `desiredStates`. A tuned-out channel stays OPEN at the transport (traffic
54
+ * must keep arriving for the subconscious); the divert-don't-wake behavior
55
+ * is applied downstream at ingestion.
56
+ */
57
+ export interface TuneOutParams {
58
+ /** Identity of this tune-out epoch. Stamped into diverted messages
59
+ * (`metadata.tuneOut = { epochId }`) for permanent main-view exclusion
60
+ * and per-epoch audit; also keys durable wake counting. */
61
+ epochId: string;
62
+ /** Subconscious summary cadence, in seconds. */
63
+ cadenceSeconds: number;
64
+ /** Maximum messages dumped raw at cancel; above the cap the subconscious
65
+ * curates a digest and `fetch_history` covers the rest. */
66
+ backlogCap: number;
67
+ /** Wake invocations before the tune-out auto-cancels. */
68
+ maxWakes: number;
69
+ /** Chronicle sequence when the tune-out began — window anchor + audit bound. */
70
+ startedAtSequence: number;
71
+ /** Absolute wall-clock deadline (epoch ms). When set, the tune-out
72
+ * auto-cancels at this time via the standard cancel flow ("duration
73
+ * elapsed") — the agent's self-binding attention budget (#77 "for a
74
+ * period chosen by the agent"). Unset = until cancelled. */
75
+ expiresAtMs?: number;
76
+ }
49
77
 
50
78
  interface ChannelLifecycleEvent {
51
- kind: 'desired-state' | 'legacy-policy-migrated' | 'invitation-declined';
79
+ kind:
80
+ | 'desired-state'
81
+ | 'legacy-policy-migrated'
82
+ | 'invitation-declined'
83
+ | 'tune-out-wake';
52
84
  serverId: string;
53
85
  timestamp: string;
54
86
  channelId?: string;
55
87
  desired?: DesiredChannelState;
88
+ /** Present when desired === 'tuned-out'. */
89
+ tuneOut?: TuneOutParams;
90
+ /** kind 'tune-out-wake': durable running wake count for an epoch.
91
+ * Lives in the lifecycle log (not gate stats) because gate runtime
92
+ * state dies with the process and max-wakes must not reset on restart. */
93
+ epochId?: string;
94
+ wakeCount?: number;
56
95
  source?: string;
57
96
  messageId?: string;
58
97
  acknowledgment?: string;
@@ -438,7 +477,15 @@ export class ChannelRegistry {
438
477
  /** Chronicle-projected desired lifecycle state, keyed by server + channel.
439
478
  * Provenance is kept so reconcile can tell a pure default (nobody ever
440
479
  * decided) from a real decision (agent-tool, invitation-declined, …). */
441
- private desiredStates = new Map<string, { state: DesiredChannelState; source: string }>();
480
+ private desiredStates = new Map<string, {
481
+ state: DesiredChannelState;
482
+ source: string;
483
+ /** Present iff state === 'tuned-out'. */
484
+ tuneOut?: TuneOutParams;
485
+ /** Durable wake count for the active tune-out epoch (replayed from
486
+ * 'tune-out-wake' lifecycle records; see recordTuneOutWake). */
487
+ wakeCount?: number;
488
+ }>();
442
489
 
443
490
  /** One-time migration inputs from the retired recipe auto-open policy. */
444
491
  private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
@@ -1146,12 +1193,31 @@ export class ChannelRegistry {
1146
1193
  if (
1147
1194
  event.kind === 'desired-state' &&
1148
1195
  typeof event.channelId === 'string' &&
1149
- (event.desired === 'open' || event.desired === 'closed')
1196
+ (event.desired === 'open' || event.desired === 'closed' ||
1197
+ (event.desired === 'tuned-out' && event.tuneOut))
1150
1198
  ) {
1151
1199
  this.desiredStates.set(
1152
1200
  this.lifecycleKey(event.serverId, event.channelId),
1153
- { state: event.desired, source: typeof event.source === "string" ? event.source : "unknown" },
1201
+ {
1202
+ state: event.desired,
1203
+ source: typeof event.source === "string" ? event.source : "unknown",
1204
+ tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined,
1205
+ wakeCount: 0,
1206
+ },
1154
1207
  );
1208
+ } else if (
1209
+ event.kind === 'tune-out-wake' &&
1210
+ typeof event.channelId === 'string' &&
1211
+ typeof event.wakeCount === 'number'
1212
+ ) {
1213
+ // Fold durable wake counts into the projection — but only while the
1214
+ // epoch that recorded them is still the active desired state
1215
+ // (last-record-wins semantics, same as desired-state itself).
1216
+ const key = this.lifecycleKey(event.serverId, event.channelId);
1217
+ const current = this.desiredStates.get(key);
1218
+ if (current?.state === 'tuned-out' && current.tuneOut?.epochId === event.epochId) {
1219
+ current.wakeCount = event.wakeCount;
1220
+ }
1155
1221
  } else if (event.kind === 'legacy-policy-migrated') {
1156
1222
  this.migratedLegacyPolicies.add(event.serverId);
1157
1223
  }
@@ -1181,6 +1247,147 @@ export class ChannelRegistry {
1181
1247
  });
1182
1248
  }
1183
1249
 
1250
+ // ==========================================================================
1251
+ // Tune-out state (issue #77) — durable in the lifecycle log
1252
+ // ==========================================================================
1253
+
1254
+ /**
1255
+ * Enter (or re-enter with fresh params) the tuned-out state for a channel.
1256
+ * Re-entering under a new epochId replaces the active epoch; the previous
1257
+ * epoch's stamped messages stay excluded (stamps are permanent) and its
1258
+ * wake count is superseded. Transport stays open (see reconcile).
1259
+ */
1260
+ enterTuneOut(
1261
+ serverId: string,
1262
+ channelId: string,
1263
+ params: TuneOutParams,
1264
+ source: string,
1265
+ ): void {
1266
+ const key = this.lifecycleKey(serverId, channelId);
1267
+ this.desiredStates.set(key, {
1268
+ state: 'tuned-out',
1269
+ source,
1270
+ tuneOut: params,
1271
+ wakeCount: 0,
1272
+ });
1273
+ this.appendLifecycleEvent({
1274
+ kind: 'desired-state',
1275
+ serverId,
1276
+ channelId,
1277
+ desired: 'tuned-out',
1278
+ tuneOut: params,
1279
+ source,
1280
+ timestamp: new Date().toISOString(),
1281
+ });
1282
+ }
1283
+
1284
+ /**
1285
+ * End the active tune-out, returning the channel to `nextState`.
1286
+ * The caller (tune-out coordinator) owns the dump/notice flow; this is
1287
+ * only the durable state flip. No-op returning null if the channel is
1288
+ * not tuned out.
1289
+ */
1290
+ cancelTuneOut(
1291
+ serverId: string,
1292
+ channelId: string,
1293
+ nextState: 'open' | 'closed',
1294
+ source: string,
1295
+ ): { params: TuneOutParams; wakeCount: number } | null {
1296
+ const key = this.lifecycleKey(serverId, channelId);
1297
+ const current = this.desiredStates.get(key);
1298
+ if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
1299
+ const ended = { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
1300
+ this.desiredStates.set(key, { state: nextState, source });
1301
+ this.appendLifecycleEvent({
1302
+ kind: 'desired-state',
1303
+ serverId,
1304
+ channelId,
1305
+ desired: nextState,
1306
+ source,
1307
+ timestamp: new Date().toISOString(),
1308
+ });
1309
+ return ended;
1310
+ }
1311
+
1312
+ /**
1313
+ * Durably record one wake invocation against the active epoch and return
1314
+ * the updated count with the params (the coordinator compares against
1315
+ * maxWakes and decides auto-cancel). Durable here, not in gate stats:
1316
+ * gate runtime state dies with the process, and a restart must not grant
1317
+ * a hammered channel a fresh wake budget.
1318
+ */
1319
+ recordTuneOutWake(
1320
+ serverId: string,
1321
+ channelId: string,
1322
+ ): { params: TuneOutParams; wakeCount: number } | null {
1323
+ const key = this.lifecycleKey(serverId, channelId);
1324
+ const current = this.desiredStates.get(key);
1325
+ if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
1326
+ current.wakeCount = (current.wakeCount ?? 0) + 1;
1327
+ this.appendLifecycleEvent({
1328
+ kind: 'tune-out-wake',
1329
+ serverId,
1330
+ channelId,
1331
+ epochId: current.tuneOut.epochId,
1332
+ wakeCount: current.wakeCount,
1333
+ timestamp: new Date().toISOString(),
1334
+ });
1335
+ return { params: current.tuneOut, wakeCount: current.wakeCount };
1336
+ }
1337
+
1338
+ /** Active tune-out params + wake count for a channel, or null. */
1339
+ getTuneOutState(
1340
+ serverId: string,
1341
+ channelId: string,
1342
+ ): { params: TuneOutParams; wakeCount: number } | null {
1343
+ const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId));
1344
+ if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
1345
+ return { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
1346
+ }
1347
+
1348
+ /** Registered channel entries (read-only iteration for the coordinator). */
1349
+ listChannelsRaw(): Array<{ serverId: string; descriptor: ChannelDescriptor }> {
1350
+ return [...this.channels.values()].map((e) => ({
1351
+ serverId: e.serverId,
1352
+ descriptor: e.descriptor,
1353
+ }));
1354
+ }
1355
+
1356
+ /**
1357
+ * Small chronicle snapshot-state helpers for the tune-out coordinator
1358
+ * (dispositions slot). Registration is idempotent; absent store = null/no-op
1359
+ * (mirrors the lifecycle log's optional-store posture).
1360
+ */
1361
+ readCoordinatorState(stateId: string): unknown {
1362
+ if (!this.store) return null;
1363
+ try {
1364
+ this.store.registerState({ id: stateId, strategy: 'snapshot' });
1365
+ } catch { /* already registered */ }
1366
+ return this.store.getStateJson(stateId);
1367
+ }
1368
+
1369
+ writeCoordinatorState(stateId: string, value: unknown): void {
1370
+ if (!this.store) return;
1371
+ try {
1372
+ this.store.registerState({ id: stateId, strategy: 'snapshot' });
1373
+ } catch { /* already registered */ }
1374
+ this.store.setStateJson(stateId, value);
1375
+ }
1376
+
1377
+ /**
1378
+ * Publish into a channel on behalf of a named non-resident agent (the
1379
+ * subconscious's speak_in_channel). Same delivery path as the
1380
+ * channel_publish tool; the agent name rides the speech-routed trace.
1381
+ */
1382
+ async publishForAgent(
1383
+ channelId: string,
1384
+ text: string,
1385
+ agentName: string,
1386
+ ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
1387
+ this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text });
1388
+ return this.handleToolPublish({ channelId, content: text });
1389
+ }
1390
+
1184
1391
  /**
1185
1392
  * Consume the old recipe policy exactly once. It seeds Chronicle for
1186
1393
  * existing deployments, but is not an ongoing admission policy: channels
@@ -1297,7 +1504,10 @@ export class ChannelRegistry {
1297
1504
  const key = `${serverId}:${channel.id}`;
1298
1505
  const desired = this.getDesiredState(serverId, channel.id);
1299
1506
  const entry = this.channels.get(key);
1300
- if (desired !== 'open') {
1507
+ // Tuned-out sits on the OPEN side of reconcile: traffic must keep
1508
+ // arriving (the subconscious reads it); only main's wake/visibility
1509
+ // is diverted, downstream at ingestion.
1510
+ if (desired !== 'open' && desired !== 'tuned-out') {
1301
1511
  try {
1302
1512
  await server.sendChannelsClose({ channelId: channel.id });
1303
1513
  if (entry) entry.open = false;
@@ -1710,6 +1920,22 @@ export class ChannelRegistry {
1710
1920
  };
1711
1921
  }
1712
1922
 
1923
+ // A tuned-out channel is transport-open but attention-diverted; a plain
1924
+ // open would silently discard the epoch (and its pending backlog dump).
1925
+ // Cancelling is the tune-out coordinator's flow, reached via its own
1926
+ // tool — refuse with the pointer rather than eat the state.
1927
+ if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
1928
+ return {
1929
+ success: false,
1930
+ isError: false,
1931
+ error:
1932
+ `Channel ${input.channelId} is tuned out. Cancel the tune-out ` +
1933
+ `(tune_out with mode: 'cancel') to resume normal attention — ` +
1934
+ `cancelling delivers the diverted backlog.`,
1935
+ data: { refusal: 'tuned-out', channelId: input.channelId },
1936
+ };
1937
+ }
1938
+
1713
1939
  const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
1714
1940
  if (entry.open && alreadyDesiredOpen && !input.backscroll) {
1715
1941
  return {
@@ -1808,7 +2034,9 @@ export class ChannelRegistry {
1808
2034
  // than retry — unless it certifies an explicit idle lease.
1809
2035
  if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
1810
2036
  const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
1811
- if (current?.state === 'open' && current.source === 'agent-tool') {
2037
+ // Tuned-out is stated intent too: a GC/housekeeping close would
2038
+ // silently end the epoch and orphan its backlog.
2039
+ if ((current?.state === 'open' || current?.state === 'tuned-out') && current.source === 'agent-tool') {
1812
2040
  return {
1813
2041
  success: false,
1814
2042
  isError: false,
@@ -369,9 +369,13 @@ function validateJpeg(bytes: Buffer, mountPrefixedPath: string): void {
369
369
  offset += 1;
370
370
 
371
371
  if (marker === JPEG_EOI) {
372
- if (!sawSof || !sawSos || offset !== bytes.length) {
372
+ if (!sawSof || !sawSos) {
373
373
  invalidImage('JPEG', mountPrefixedPath);
374
374
  }
375
+ // Bytes after EOI are tolerated: hardware encoders (e.g. Raspberry Pi
376
+ // camera stills) pad each frame to a 4-byte boundary with NULs after
377
+ // the EOI marker, and every mainstream decoder stops at EOI. The image
378
+ // proper (SOI..EOI) has been fully validated by this point.
375
379
  return;
376
380
  }
377
381
  if (marker === JPEG_TEM || (marker >= 0xd0 && marker <= 0xd7)) {