@animalabs/agent-framework 0.30.0 → 0.31.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 (179) hide show
  1. package/README.md +71 -8
  2. package/dist/src/agent.d.ts +29 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +82 -17
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +10 -3
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  10. package/dist/src/code-execution/isolate/render.js +7 -6
  11. package/dist/src/code-execution/isolate/render.js.map +1 -1
  12. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  13. package/dist/src/code-execution/isolate/scripts.js +5 -4
  14. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  15. package/dist/src/framework.d.ts +129 -92
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +1115 -704
  18. package/dist/src/framework.js.map +1 -1
  19. package/dist/src/gate/event-gate.d.ts +24 -17
  20. package/dist/src/gate/event-gate.d.ts.map +1 -1
  21. package/dist/src/gate/event-gate.js +198 -144
  22. package/dist/src/gate/event-gate.js.map +1 -1
  23. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  24. package/dist/src/gate/recipe-reconcile.js +6 -5
  25. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  26. package/dist/src/host-presentation.d.ts.map +1 -1
  27. package/dist/src/host-presentation.js +5 -7
  28. package/dist/src/host-presentation.js.map +1 -1
  29. package/dist/src/index.d.ts +1 -0
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +1 -0
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/inference-bounds.d.ts +37 -0
  34. package/dist/src/inference-bounds.d.ts.map +1 -0
  35. package/dist/src/inference-bounds.js +84 -0
  36. package/dist/src/inference-bounds.js.map +1 -0
  37. package/dist/src/line-reader.d.ts +7 -1
  38. package/dist/src/line-reader.d.ts.map +1 -1
  39. package/dist/src/line-reader.js +17 -6
  40. package/dist/src/line-reader.js.map +1 -1
  41. package/dist/src/mcpl/errors.d.ts +1 -0
  42. package/dist/src/mcpl/errors.d.ts.map +1 -1
  43. package/dist/src/mcpl/errors.js +3 -0
  44. package/dist/src/mcpl/errors.js.map +1 -1
  45. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  46. package/dist/src/mcpl/inbound-source.js +2 -2
  47. package/dist/src/mcpl/inference-router.d.ts +11 -1
  48. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  49. package/dist/src/mcpl/inference-router.js +65 -9
  50. package/dist/src/mcpl/inference-router.js.map +1 -1
  51. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  52. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/modern-connection.js +85 -74
  54. package/dist/src/mcpl/modern-connection.js.map +1 -1
  55. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  57. package/dist/src/mcpl/protocol-family.js +45 -3
  58. package/dist/src/mcpl/protocol-family.js.map +1 -1
  59. package/dist/src/mcpl/push-coalescer.d.ts +2 -7
  60. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  62. package/dist/src/mcpl/references.d.ts +1 -2
  63. package/dist/src/mcpl/references.d.ts.map +1 -1
  64. package/dist/src/mcpl/references.js +3 -3
  65. package/dist/src/mcpl/references.js.map +1 -1
  66. package/dist/src/mcpl/server-connection.d.ts +14 -0
  67. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  68. package/dist/src/mcpl/server-connection.js +26 -22
  69. package/dist/src/mcpl/server-connection.js.map +1 -1
  70. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  71. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  72. package/dist/src/mcpl/tool-signature.js +16 -11
  73. package/dist/src/mcpl/tool-signature.js.map +1 -1
  74. package/dist/src/mcpl/transport.d.ts +44 -21
  75. package/dist/src/mcpl/transport.d.ts.map +1 -1
  76. package/dist/src/mcpl/transport.js +283 -64
  77. package/dist/src/mcpl/transport.js.map +1 -1
  78. package/dist/src/mcpl/types.d.ts +17 -1
  79. package/dist/src/mcpl/types.d.ts.map +1 -1
  80. package/dist/src/mcpl/types.js.map +1 -1
  81. package/dist/src/module-registry.d.ts +32 -4
  82. package/dist/src/module-registry.d.ts.map +1 -1
  83. package/dist/src/module-registry.js +189 -25
  84. package/dist/src/module-registry.js.map +1 -1
  85. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  86. package/dist/src/modules/discord/discord-js-client.js +24 -16
  87. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  88. package/dist/src/modules/sender-name.d.ts +5 -4
  89. package/dist/src/modules/sender-name.d.ts.map +1 -1
  90. package/dist/src/modules/sender-name.js +7 -5
  91. package/dist/src/modules/sender-name.js.map +1 -1
  92. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  93. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  95. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  96. package/dist/src/modules/workspace/index.d.ts +6 -3
  97. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/index.js +61 -15
  99. package/dist/src/modules/workspace/index.js.map +1 -1
  100. package/dist/src/modules/workspace/observe.d.ts +9 -1
  101. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  102. package/dist/src/modules/workspace/observe.js +50 -10
  103. package/dist/src/modules/workspace/observe.js.map +1 -1
  104. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  105. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/reconcile.js +19 -8
  107. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +8 -0
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  112. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  113. package/dist/src/modules/workspace/watcher.js +7 -5
  114. package/dist/src/modules/workspace/watcher.js.map +1 -1
  115. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  116. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  117. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  118. package/dist/src/supplied-text.d.ts +2 -0
  119. package/dist/src/supplied-text.d.ts.map +1 -1
  120. package/dist/src/supplied-text.js +16 -7
  121. package/dist/src/supplied-text.js.map +1 -1
  122. package/dist/src/tool-result-guard.js +1 -1
  123. package/dist/src/tool-result-guard.js.map +1 -1
  124. package/dist/src/tune-out/coordinator.d.ts +2 -20
  125. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  126. package/dist/src/tune-out/coordinator.js +175 -70
  127. package/dist/src/tune-out/coordinator.js.map +1 -1
  128. package/dist/src/tune-out/tools.d.ts +14 -2
  129. package/dist/src/tune-out/tools.d.ts.map +1 -1
  130. package/dist/src/tune-out/tools.js.map +1 -1
  131. package/dist/src/types/agent.d.ts +2 -1
  132. package/dist/src/types/agent.d.ts.map +1 -1
  133. package/dist/src/types/framework.d.ts +54 -15
  134. package/dist/src/types/framework.d.ts.map +1 -1
  135. package/dist/src/types/module.d.ts +42 -3
  136. package/dist/src/types/module.d.ts.map +1 -1
  137. package/dist/src/types/trace.d.ts +4 -3
  138. package/dist/src/types/trace.d.ts.map +1 -1
  139. package/package.json +3 -3
  140. package/src/agent.ts +90 -25
  141. package/src/api/mcp-server-core.ts +10 -3
  142. package/src/code-execution/isolate/render.ts +7 -6
  143. package/src/code-execution/isolate/scripts.ts +5 -4
  144. package/src/framework.ts +1145 -723
  145. package/src/gate/event-gate.ts +220 -145
  146. package/src/gate/recipe-reconcile.ts +7 -5
  147. package/src/host-presentation.ts +4 -7
  148. package/src/index.ts +1 -0
  149. package/src/inference-bounds.ts +111 -0
  150. package/src/line-reader.ts +22 -5
  151. package/src/mcpl/errors.ts +4 -0
  152. package/src/mcpl/inbound-source.ts +2 -2
  153. package/src/mcpl/inference-router.ts +67 -10
  154. package/src/mcpl/modern-connection.ts +83 -68
  155. package/src/mcpl/protocol-family.ts +37 -4
  156. package/src/mcpl/push-coalescer.ts +2 -7
  157. package/src/mcpl/references.ts +3 -4
  158. package/src/mcpl/server-connection.ts +30 -21
  159. package/src/mcpl/tool-signature.ts +18 -11
  160. package/src/mcpl/transport.ts +261 -60
  161. package/src/mcpl/types.ts +18 -1
  162. package/src/module-registry.ts +178 -25
  163. package/src/modules/discord/discord-js-client.ts +33 -29
  164. package/src/modules/sender-name.ts +8 -5
  165. package/src/modules/workspace/disk-agreement.ts +39 -15
  166. package/src/modules/workspace/index.ts +65 -14
  167. package/src/modules/workspace/observe.ts +51 -10
  168. package/src/modules/workspace/reconcile.ts +22 -8
  169. package/src/modules/workspace/types.ts +5 -0
  170. package/src/modules/workspace/watcher.ts +8 -6
  171. package/src/recovery/discord-awareness-outbox.ts +1 -11
  172. package/src/supplied-text.ts +17 -6
  173. package/src/tool-result-guard.ts +1 -1
  174. package/src/tune-out/coordinator.ts +187 -85
  175. package/src/tune-out/tools.ts +14 -2
  176. package/src/types/agent.ts +2 -1
  177. package/src/types/framework.ts +55 -15
  178. package/src/types/module.ts +42 -3
  179. package/src/types/trace.ts +4 -3
@@ -49,6 +49,54 @@ function writeIdOf(m) {
49
49
  const writeId = m.metadata?.deferredWriteId;
50
50
  return typeof writeId === 'string' ? writeId : undefined;
51
51
  }
52
+ function keysOf(rows) {
53
+ const keys = { ids: [], writeIds: [] };
54
+ for (const m of rows) {
55
+ const writeId = m.writeId ?? writeIdOf(m);
56
+ if (writeId !== undefined)
57
+ keys.writeIds.push(writeId);
58
+ else if (m.id !== undefined)
59
+ keys.ids.push(m.id);
60
+ }
61
+ return keys;
62
+ }
63
+ function keyedBy(keys) {
64
+ const ids = new Set(keys.flatMap((k) => k.ids));
65
+ const writeIds = new Set(keys.flatMap((k) => k.writeIds));
66
+ return (m) => {
67
+ const writeId = m.writeId ?? writeIdOf(m);
68
+ return writeId !== undefined ? writeIds.has(writeId) : m.id !== undefined && ids.has(m.id);
69
+ };
70
+ }
71
+ const isStrings = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string');
72
+ const isRowKeys = (value) => typeof value === 'object' && value !== null && isStrings(value.ids) && isStrings(value.writeIds);
73
+ function isReleaseRecord(value) {
74
+ if (!isRowKeys(value))
75
+ return false;
76
+ const record = value;
77
+ return typeof record.total === 'number' && typeof record.truncated === 'number' && isRowKeys(record.report);
78
+ }
79
+ function releaseRecordsOf(rows, epochId) {
80
+ const records = [];
81
+ for (const m of rows) {
82
+ const meta = m.metadata;
83
+ if (meta?.kind !== 'tune-out-cancel' || meta.epochId !== epochId)
84
+ continue;
85
+ if (isReleaseRecord(meta.released) && !records.some((r) => JSON.stringify(r) === JSON.stringify(meta.released))) {
86
+ records.push(meta.released);
87
+ }
88
+ }
89
+ return records;
90
+ }
91
+ function truncationNote(records) {
92
+ const truncated = records.reduce((n, r) => n + r.truncated, 0);
93
+ if (truncated === 0)
94
+ return '';
95
+ const total = records.reduce((n, r) => n + r.total, 0);
96
+ return records.length === 1
97
+ ? ` ${truncated} of ${total} diverted messages, the oldest, weren't delivered raw to the resident.`
98
+ : ` ${truncated} of ${total} diverted messages across ${records.length} dumps, the oldest in each dump that left any out, weren't delivered raw to the resident.`;
99
+ }
52
100
  /**
53
101
  * Whether a held message is one of this channel's: by its channel id, and by
54
102
  * its server where it records one, since two servers can register the same
@@ -61,7 +109,7 @@ function inChannelOf(serverId, channelId) {
61
109
  return (meta?.channelId ?? channelId) === channelId && (meta?.serverId ?? serverId) === serverId;
62
110
  };
63
111
  }
64
- /** How long after an end time whose cancel threw the cancel is tried again. */
112
+ /** How long after an end time's or a wake budget's cancel threw the cancel is tried again. */
65
113
  const EXPIRY_RETRY_MS = 60_000;
66
114
  /** Ends a backlog line cut to fit in one of the residents' messages. */
67
115
  const CUT_MARK = ` [${NOTICE_TAG.cutToFit}; the whole message is in the channel's history]`;
@@ -157,8 +205,6 @@ export class TuneOutCoordinator {
157
205
  lateForks = new WeakSet();
158
206
  /** The all-except scope's durable record, cached (undefined = not read yet). */
159
207
  scopeCache;
160
- /** The epoch whose release this process delivered and whose hold is not yet recorded as cleared. */
161
- scopeReleased;
162
208
  /** Timer key for the scope's cadence and expiry. */
163
209
  static SCOPE_KEY = '\0all-except';
164
210
  constructor(channelRegistry, serverRegistry, hooks) {
@@ -207,26 +253,59 @@ export class TuneOutCoordinator {
207
253
  // An epoch whose deadline passed while the process was down cancels
208
254
  // immediately — the resident gets their backlog on the schedule they
209
255
  // committed to, not whenever the host happened to restart.
210
- if (params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now()) {
211
- this.cancel(serverId, channelId, 'duration', 'duration elapsed');
212
- continue;
256
+ const inForce = () => this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === params.epochId;
257
+ const expired = params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now();
258
+ if (expired) {
259
+ try {
260
+ this.cancel(serverId, channelId, 'duration', 'duration elapsed');
261
+ continue;
262
+ }
263
+ catch (err) {
264
+ if (!inForce()) {
265
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} reached its end time while the process was down and its cancel ended it, but a step after the end failed:`, err);
266
+ continue;
267
+ }
268
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} reached its end time while the process was down, and its cancel failed, so it stays tuned out and its cancel is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
269
+ }
213
270
  }
214
271
  this.startCadence(serverId, channelId, params.cadenceSeconds);
215
- this.armExpiry(serverId, channelId, params);
272
+ const wakeCount = this.channelRegistry.getTuneOutState(serverId, channelId)?.wakeCount ?? 0;
273
+ if (wakeCount > params.maxWakes) {
274
+ this.cancelPastBudget(serverId, channelId, params.maxWakes, 'start');
275
+ if (!inForce())
276
+ continue;
277
+ }
278
+ else {
279
+ this.armExpiry(serverId, channelId, expired ? { ...params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS } : params);
280
+ }
216
281
  if (oldestAnchor === null || params.startedAtSequence < oldestAnchor) {
217
282
  oldestAnchor = params.startedAtSequence;
218
283
  }
219
284
  }
220
285
  const scope = this.getAllExcept();
221
286
  if (scope) {
287
+ let inForce = true;
222
288
  if (scope.params.expiresAtMs <= Date.now()) {
223
- this.cancelAllExcept('duration', 'duration elapsed');
289
+ try {
290
+ this.cancelAllExcept('duration', 'duration elapsed');
291
+ inForce = false;
292
+ }
293
+ catch (err) {
294
+ if (this.getAllExcept()?.params.epochId !== scope.params.epochId) {
295
+ console.error('[tune-out] the all-except hold reached its end time while the process was down and its release ended it, but a step after the end failed:', err);
296
+ inForce = false;
297
+ }
298
+ else {
299
+ console.error(`[tune-out] the all-except hold reached its end time while the process was down, and its release failed, so it stays in force and its release is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
300
+ this.armScopeTimers({ ...scope.params, expiresAtMs: Date.now() + EXPIRY_RETRY_MS });
301
+ }
302
+ }
224
303
  }
225
304
  else {
226
305
  this.armScopeTimers(scope.params);
227
- if (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor) {
228
- oldestAnchor = scope.params.startedAtSequence;
229
- }
306
+ }
307
+ if (inForce && (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor)) {
308
+ oldestAnchor = scope.params.startedAtSequence;
230
309
  }
231
310
  }
232
311
  if (oldestAnchor !== null) {
@@ -418,41 +497,28 @@ export class TuneOutCoordinator {
418
497
  hasReader() {
419
498
  return this.readerAvailable();
420
499
  }
421
- /**
422
- * End a tune-out: durable flip, system-framed backlog dump into the
423
- * resident's window (capped), lifecycle notice, one wake for the
424
- * resident. `subconsciousNote` is the subconscious's own text (from its
425
- * cancel_tuneout call) — delivered verbatim under its name, never
426
- * synthesized here.
427
- */
428
500
  cancel(serverId, channelId, source, reason, subconsciousNote,
429
501
  /** `reader: 'forks'`: the reader fork whose note this is. */
430
502
  noteAuthor,
431
503
  /** The lifecycle state the channel returns to (a tool close ends in 'closed'). */
432
504
  nextState = 'open') {
433
- const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
434
- if (!ended) {
505
+ const ending = this.channelRegistry.getTuneOutState(serverId, channelId);
506
+ if (!ending) {
435
507
  // Its reader is the resident's tune_out: cancel_tuneout finds the channel first.
436
508
  return { ok: false, error: this.notTunedOut(this.quotedShown(serverId, channelId), 'you') };
437
509
  }
438
- this.clearTimers(serverId, channelId);
439
- this.divertedSinceInvocation.delete(this.key(serverId, channelId));
440
- // Backlog: every message held under this epoch. The stored ones come in
441
- // store order, then those still in the deferred queue (held mid-turn,
442
- // not yet written), oldest first. That's arrival order, except when a
443
- // held message stays queued while a later one is stored: a store write
444
- // refused at a turn-start flush leaves it queued, and that turn's
445
- // assembly can then store later coalesced messages directly.
446
- const epochId = ended.params.epochId;
510
+ const epochId = ending.params.epochId;
447
511
  const ofEpoch = (m) => m.metadata?.tuneOut?.epochId === epochId;
448
- const held = (this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch);
449
- const backlog = [
450
- ...this.hooks.getStoredMessages().filter(ofEpoch),
451
- ...held,
452
- ];
512
+ const storedRows = this.hooks.getStoredMessages();
513
+ const queuedRows = this.hooks.getDeferredMessages?.() ?? [];
514
+ const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
515
+ const dumpedEarlier = keyedBy(earlier);
516
+ const unreleased = (m) => ofEpoch(m) && !dumpedEarlier(m);
517
+ const held = queuedRows.filter(unreleased);
518
+ const backlog = [...storedRows.filter(unreleased), ...held];
453
519
  // Whole messages: a cap below one shows none, where slice(-0.5) would
454
520
  // start at 0 and take them all.
455
- const cap = Math.floor(ended.params.backlogCap);
521
+ const cap = Math.floor(ending.params.backlogCap);
456
522
  const authorOf = (m) => suppliedValue(m.metadata?.author?.name ?? m.participant);
457
523
  // A line names its author as the resident knows them and marks what a
458
524
  // text line can't carry (`[image]`), so the dump says what it leaves out.
@@ -462,7 +528,7 @@ export class TuneOutCoordinator {
462
528
  const historyTool = this.hooks.historyTool ? this.hooks.historyTool() : 'fetch_history';
463
529
  // The channel as the resident and the subconscious are shown it.
464
530
  const name = this.shown(serverId, channelId);
465
- const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`;
531
+ const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ending.wakeCount} wake${ending.wakeCount === 1 ? '' : 's'}]`;
466
532
  const render = (lines, truncated) => {
467
533
  const attrs = [
468
534
  `channel=${this.quotedShown(serverId, channelId)}`,
@@ -533,10 +599,26 @@ export class TuneOutCoordinator {
533
599
  ...heldLeftOut,
534
600
  ...held.slice(heldLeftOut.length).filter((m) => m.content.some((b) => b.type !== 'text') || (newestCut && m === shown[0])),
535
601
  ];
536
- // Host-framed system artifacts — never voiced as anyone.
537
- this.hooks.addMessage('user', [{ type: 'text', text: render(lines, truncated) }], { system: true, kind: 'tune-out-cancel', channelId, epochId });
538
- // The epoch is already ended durably; the release must be as durable.
539
- this.hooks.commitDeliveries?.();
602
+ const records = [...earlier];
603
+ if (earlier.length === 0 || backlog.length > 0) {
604
+ const record = { ...keysOf(backlog), total: backlog.length, truncated, report: keysOf(heldForReport) };
605
+ this.hooks.addMessage('user', [{ type: 'text', text: render(lines, truncated) }], { system: true, kind: 'tune-out-cancel', channelId, epochId, released: record });
606
+ records.push(record);
607
+ }
608
+ if (this.hooks.commitDeliveries?.() === false) {
609
+ throw new Error(`the backlog of ${this.quotedShown(serverId, channelId)} could not be made durable, so its tune-out stays in force`);
610
+ }
611
+ this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source);
612
+ this.clearTimers(serverId, channelId);
613
+ this.divertedSinceInvocation.delete(this.key(serverId, channelId));
614
+ const reportedEarlier = keyedBy(earlier.map((r) => r.report));
615
+ const release = {
616
+ truncated: records.reduce((n, r) => n + r.truncated, 0),
617
+ heldForReport: [
618
+ ...[...storedRows, ...queuedRows].filter((m) => ofEpoch(m) && reportedEarlier(m)),
619
+ ...(records.length > earlier.length ? heldForReport : []),
620
+ ],
621
+ };
540
622
  // The subconscious's own words, under its own name, verbatim.
541
623
  const subName = this.hooks.subconsciousName();
542
624
  if (subconsciousNote && subName) {
@@ -564,17 +646,15 @@ export class TuneOutCoordinator {
564
646
  // and when it wrote no note and the dump left any message out, since
565
647
  // nothing else then covers it. Otherwise its note speaks for what it saw.
566
648
  const selfCancel = source === 'subconscious';
567
- if (subName && (!selfCancel || heldForReport.length > 0 || (!subconsciousNote && truncated > 0))) {
568
- const truncNote = truncated > 0
569
- ? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.`
570
- : '';
571
- const heldNote = heldForReport.length > 0
572
- ? ` Still held at the cancel, so your view may not have them yet: the ${heldForReport.length} message${heldForReport.length === 1 ? '' : 's'} after this one.`
649
+ if (subName && (!selfCancel || release.heldForReport.length > 0 || (!subconsciousNote && release.truncated > 0))) {
650
+ const truncNote = truncationNote(records);
651
+ const heldNote = release.heldForReport.length > 0
652
+ ? ` Still held at the cancel, so your view may not have them yet: the ${release.heldForReport.length} message${release.heldForReport.length === 1 ? '' : 's'} after this one.`
573
653
  : '';
574
654
  this.hooks.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${reason}.${truncNote}${heldNote}]` }], { system: true, kind: 'tune-out-cancel-report', channelId }, subName);
575
655
  // Each held message is a row of its own, so a per-message cap cuts each
576
656
  // one on its own, its author's label included, not the batch's newest.
577
- for (const m of heldForReport) {
657
+ for (const m of release.heldForReport) {
578
658
  this.hooks.addMessage('user', [{ type: 'text', text: `${authorOf(m)}:` }, ...m.content], { system: true, kind: 'tune-out-cancel-held', channelId }, subName);
579
659
  }
580
660
  this.hooks.requestInference(subName, `tune-out cancelled: ${name}`, 'tune-out');
@@ -592,9 +672,7 @@ export class TuneOutCoordinator {
592
672
  const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued);
593
673
  const unread = rest.backlog.length + rest.queued.length;
594
674
  if (!selfCancel || unread > 0) {
595
- const truncNote = truncated > 0
596
- ? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.`
597
- : '';
675
+ const truncNote = truncationNote(records);
598
676
  const lateNote = selfCancel
599
677
  ? ` ${unread === 1 ? 'This message' : `These ${unread} messages`} arrived after the reader that cancelled was handed its own.`
600
678
  : '';
@@ -607,7 +685,7 @@ export class TuneOutCoordinator {
607
685
  // there that its reader hasn't looked at are counted again, as the
608
686
  // channel's own tune-out's count stood in for theirs.
609
687
  this.rebuildTally(serverId, channelId);
610
- this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount });
688
+ this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ending.wakeCount });
611
689
  return { ok: true };
612
690
  }
613
691
  // ==========================================================================
@@ -814,20 +892,20 @@ export class TuneOutCoordinator {
814
892
  }
815
893
  return counts;
816
894
  }
817
- /**
818
- * End the all-except hold: durable flip, one release notice with a
819
- * per-channel backlog (each capped), the reader's note if any, one wake
820
- * for the resident, and the reader's own look at what it had not seen.
821
- */
822
895
  cancelAllExcept(source, reason, note, noteAuthor) {
823
896
  const scope = this.getAllExcept();
824
897
  if (!scope)
825
898
  return { ok: false, error: 'no all-except hold is active' };
826
899
  const { epochId, except, backlogCap } = scope.params;
827
900
  const ofEpoch = (m) => m.metadata?.tuneOut?.epochId === epochId;
828
- const all = [...this.hooks.getStoredMessages().filter(ofEpoch), ...(this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch)];
901
+ const storedRows = this.hooks.getStoredMessages();
902
+ const queuedRows = this.hooks.getDeferredMessages?.() ?? [];
903
+ const all = [...storedRows.filter(ofEpoch), ...queuedRows.filter(ofEpoch)];
904
+ const earlier = releaseRecordsOf([...storedRows, ...queuedRows], epochId);
905
+ const releasedEarlier = keyedBy(earlier);
906
+ const fresh = all.filter((m) => !releasedEarlier(m));
829
907
  const byChannel = new Map();
830
- for (const m of all) {
908
+ for (const m of fresh) {
831
909
  const meta = m.metadata;
832
910
  const serverId = meta?.serverId ?? '';
833
911
  const channelId = meta?.channelId ?? '(unknown channel)';
@@ -837,12 +915,14 @@ export class TuneOutCoordinator {
837
915
  byChannel.set(k, entry);
838
916
  }
839
917
  const historyTool = this.hooks.historyTool?.() ?? null;
918
+ let truncatedInAll = 0;
840
919
  const dumps = [...byChannel.values()].map(({ serverId, channelId, backlog }) => {
841
920
  // Floored here too: a hold stored before entry floored its cap can hold
842
921
  // a fraction.
843
922
  const cap = Math.floor(backlogCap);
844
923
  const shown = cap > 0 ? backlog.slice(-cap) : [];
845
924
  const truncated = backlog.length - shown.length;
925
+ truncatedInAll += truncated;
846
926
  const attrs = [
847
927
  `channel=${quotedValue(this.shown(serverId, channelId))}`,
848
928
  `messages=${backlog.length}`,
@@ -852,18 +932,15 @@ export class TuneOutCoordinator {
852
932
  });
853
933
  const summary = byChannel.size === 0
854
934
  ? 'Nothing was held.'
855
- : `${all.length} message${all.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
856
- if (this.scopeReleased !== epochId) {
857
- this.hooks.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.tuneOut}: all channels except ${this.shown(except.serverId, except.channelId)} released — ${reason}, ${scope.wakeCount} wake${scope.wakeCount === 1 ? '' : 's'}]\n\n${summary}${dumps.length > 0 ? '\n\n' + dumps.join('\n\n') : ''}` }], { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId });
858
- this.hooks.commitDeliveries?.();
859
- this.scopeReleased = epochId;
860
- }
861
- // The release is recorded before the hold is: a failure past this point
862
- // leaves the hold in force with its release already delivered. A retry in
863
- // this process records only the hold's end; a restart between the two
864
- // delivers the release again, where the other order lost it outright.
935
+ : `${fresh.length} message${fresh.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`;
936
+ if (earlier.length === 0 || fresh.length > 0) {
937
+ const record = { ...keysOf(fresh), total: fresh.length, truncated: truncatedInAll, report: { ids: [], writeIds: [] } };
938
+ this.hooks.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.tuneOut}: all channels except ${this.shown(except.serverId, except.channelId)} released — ${reason}, ${scope.wakeCount} wake${scope.wakeCount === 1 ? '' : 's'}]\n\n${summary}${dumps.length > 0 ? '\n\n' + dumps.join('\n\n') : ''}` }], { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId, released: record });
939
+ }
940
+ if (this.hooks.commitDeliveries?.() === false) {
941
+ throw new Error('the all-except release could not be made durable, so the hold stays in force');
942
+ }
865
943
  this.writeScope(null);
866
- this.scopeReleased = undefined;
867
944
  this.clearScopeTimers();
868
945
  for (const [key, pending] of this.pendingWakes) {
869
946
  const [serverId, channelId] = key.split('\0');
@@ -1090,6 +1167,34 @@ export class TuneOutCoordinator {
1090
1167
  }, WAKE_COALESCE_MS);
1091
1168
  this.pendingWakes.set(key, { count: 1, timer });
1092
1169
  }
1170
+ cancelPastBudget(serverId, channelId, maxWakes, from = 'wake') {
1171
+ const key = this.key(serverId, channelId);
1172
+ const existing = this.expiryTimers.get(key);
1173
+ if (existing)
1174
+ clearTimeout(existing);
1175
+ const end = (first) => {
1176
+ try {
1177
+ this.expiryTimers.set(key, setTimeout(() => end(false), EXPIRY_RETRY_MS));
1178
+ if (!this.cancel(serverId, channelId, 'tune-out-max-wakes', `wake budget exhausted (${maxWakes})`).ok) {
1179
+ this.clearTimers(serverId, channelId);
1180
+ }
1181
+ }
1182
+ catch (err) {
1183
+ if (first && from === 'start') {
1184
+ if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
1185
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} was past its wake budget at start, and its cancel failed, so it stays tuned out and its cancel is tried again in ${EXPIRY_RETRY_MS / 1000} s:`, err);
1186
+ }
1187
+ else {
1188
+ console.error(`[tune-out] ${this.shown(serverId, channelId)} was past its wake budget at start and its cancel ended it, but a step after the end failed:`, err);
1189
+ }
1190
+ }
1191
+ else {
1192
+ this.timerFailed('wake', serverId, channelId, err);
1193
+ }
1194
+ }
1195
+ };
1196
+ end(true);
1197
+ }
1093
1198
  /** One coalesced wake: counted durably against the epoch that holds the channel. */
1094
1199
  fireWake(serverId, channelId, count) {
1095
1200
  if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
@@ -1098,7 +1203,7 @@ export class TuneOutCoordinator {
1098
1203
  if (!counted)
1099
1204
  return; // cancelled inside the window
1100
1205
  if (counted.wakeCount > counted.params.maxWakes) {
1101
- this.cancel(serverId, channelId, 'tune-out-max-wakes', `wake budget exhausted (${counted.params.maxWakes})`);
1206
+ this.cancelPastBudget(serverId, channelId, counted.params.maxWakes);
1102
1207
  return;
1103
1208
  }
1104
1209
  this.invokeSubconscious(serverId, channelId, 'wake', count);