@animalabs/agent-framework 0.33.0 → 0.34.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 (176) hide show
  1. package/README.md +136 -8
  2. package/dist/src/agent.d.ts +25 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +68 -13
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/append-all.d.ts +5 -0
  7. package/dist/src/append-all.d.ts.map +1 -0
  8. package/dist/src/append-all.js +8 -0
  9. package/dist/src/append-all.js.map +1 -0
  10. package/dist/src/context-options.d.ts +9 -6
  11. package/dist/src/context-options.d.ts.map +1 -1
  12. package/dist/src/context-options.js +13 -8
  13. package/dist/src/context-options.js.map +1 -1
  14. package/dist/src/cut-off-round.js +4 -4
  15. package/dist/src/dendrite/registry.d.ts.map +1 -1
  16. package/dist/src/dendrite/registry.js +4 -2
  17. package/dist/src/dendrite/registry.js.map +1 -1
  18. package/dist/src/failure-text.d.ts +36 -0
  19. package/dist/src/failure-text.d.ts.map +1 -0
  20. package/dist/src/failure-text.js +89 -0
  21. package/dist/src/failure-text.js.map +1 -0
  22. package/dist/src/framework.d.ts +94 -30
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +987 -284
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts +40 -6
  27. package/dist/src/gate/event-gate.d.ts.map +1 -1
  28. package/dist/src/gate/event-gate.js +66 -25
  29. package/dist/src/gate/event-gate.js.map +1 -1
  30. package/dist/src/gate/reply-judge.d.ts +4 -0
  31. package/dist/src/gate/reply-judge.d.ts.map +1 -1
  32. package/dist/src/gate/reply-judge.js +12 -3
  33. package/dist/src/gate/reply-judge.js.map +1 -1
  34. package/dist/src/gate/suppression-review.js +2 -2
  35. package/dist/src/gate/suppression-review.js.map +1 -1
  36. package/dist/src/gate/types.d.ts +7 -0
  37. package/dist/src/gate/types.d.ts.map +1 -1
  38. package/dist/src/heartbeat/heartbeat.js +1 -1
  39. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  40. package/dist/src/index.d.ts +1 -0
  41. package/dist/src/index.d.ts.map +1 -1
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/index.js.map +1 -1
  44. package/dist/src/inference-bounds.d.ts +2 -1
  45. package/dist/src/inference-bounds.d.ts.map +1 -1
  46. package/dist/src/inference-bounds.js +9 -6
  47. package/dist/src/inference-bounds.js.map +1 -1
  48. package/dist/src/mcpl/channel-registry.d.ts +57 -13
  49. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  50. package/dist/src/mcpl/channel-registry.js +136 -24
  51. package/dist/src/mcpl/channel-registry.js.map +1 -1
  52. package/dist/src/mcpl/modern-connection.d.ts +109 -15
  53. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  54. package/dist/src/mcpl/modern-connection.js +593 -86
  55. package/dist/src/mcpl/modern-connection.js.map +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts +3 -1
  57. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  58. package/dist/src/mcpl/protocol-family.js +15 -9
  59. package/dist/src/mcpl/protocol-family.js.map +1 -1
  60. package/dist/src/mcpl/server-connection.d.ts +37 -18
  61. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  62. package/dist/src/mcpl/server-connection.js +31 -16
  63. package/dist/src/mcpl/server-connection.js.map +1 -1
  64. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  65. package/dist/src/mcpl/tool-presence.js +10 -6
  66. package/dist/src/mcpl/tool-presence.js.map +1 -1
  67. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  68. package/dist/src/mcpl/toolset-host.js +7 -6
  69. package/dist/src/mcpl/toolset-host.js.map +1 -1
  70. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  71. package/dist/src/mcpl/toolsets-module.js +2 -1
  72. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  73. package/dist/src/mcpl/types.d.ts +25 -4
  74. package/dist/src/mcpl/types.d.ts.map +1 -1
  75. package/dist/src/mcpl/types.js.map +1 -1
  76. package/dist/src/modules/discord/index.js +1 -1
  77. package/dist/src/modules/discord/index.js.map +1 -1
  78. package/dist/src/modules/health/index.js +2 -2
  79. package/dist/src/modules/health/index.js.map +1 -1
  80. package/dist/src/modules/history/semantic.js +3 -3
  81. package/dist/src/modules/history/semantic.js.map +1 -1
  82. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  83. package/dist/src/modules/workspace/index.js +4 -3
  84. package/dist/src/modules/workspace/index.js.map +1 -1
  85. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  86. package/dist/src/modules/workspace/observe.js +2 -1
  87. package/dist/src/modules/workspace/observe.js.map +1 -1
  88. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  89. package/dist/src/modules/workspace/reconcile.js +3 -2
  90. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  91. package/dist/src/notice-tags.d.ts +10 -8
  92. package/dist/src/notice-tags.d.ts.map +1 -1
  93. package/dist/src/notice-tags.js +10 -8
  94. package/dist/src/notice-tags.js.map +1 -1
  95. package/dist/src/presentation.d.ts +2 -1
  96. package/dist/src/presentation.d.ts.map +1 -1
  97. package/dist/src/presentation.js +4 -2
  98. package/dist/src/presentation.js.map +1 -1
  99. package/dist/src/reasoning-loss.d.ts +5 -0
  100. package/dist/src/reasoning-loss.d.ts.map +1 -0
  101. package/dist/src/reasoning-loss.js +8 -0
  102. package/dist/src/reasoning-loss.js.map +1 -0
  103. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  104. package/dist/src/recovery/offline-branch.js +5 -4
  105. package/dist/src/recovery/offline-branch.js.map +1 -1
  106. package/dist/src/recovery/recover-cli.js +2 -1
  107. package/dist/src/recovery/recover-cli.js.map +1 -1
  108. package/dist/src/sent-rows.js +1 -1
  109. package/dist/src/supplied-text.d.ts +1 -1
  110. package/dist/src/supplied-text.js +3 -3
  111. package/dist/src/supplied-text.js.map +1 -1
  112. package/dist/src/thinking-drops.d.ts +2 -0
  113. package/dist/src/thinking-drops.d.ts.map +1 -1
  114. package/dist/src/thinking-drops.js.map +1 -1
  115. package/dist/src/tool-presentation.d.ts.map +1 -1
  116. package/dist/src/tool-presentation.js +38 -9
  117. package/dist/src/tool-presentation.js.map +1 -1
  118. package/dist/src/tune-out/coordinator.d.ts +13 -4
  119. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  120. package/dist/src/tune-out/coordinator.js +33 -16
  121. package/dist/src/tune-out/coordinator.js.map +1 -1
  122. package/dist/src/tune-out/tools.d.ts.map +1 -1
  123. package/dist/src/tune-out/tools.js +2 -1
  124. package/dist/src/tune-out/tools.js.map +1 -1
  125. package/dist/src/types/agent.d.ts +39 -5
  126. package/dist/src/types/agent.d.ts.map +1 -1
  127. package/dist/src/types/framework.d.ts +43 -8
  128. package/dist/src/types/framework.d.ts.map +1 -1
  129. package/dist/src/types/inference-log.d.ts +1 -0
  130. package/dist/src/types/inference-log.d.ts.map +1 -1
  131. package/dist/src/types/trace.d.ts +64 -4
  132. package/dist/src/types/trace.d.ts.map +1 -1
  133. package/package.json +3 -3
  134. package/src/agent.ts +71 -14
  135. package/src/append-all.ts +6 -0
  136. package/src/context-options.ts +18 -8
  137. package/src/cut-off-round.ts +4 -4
  138. package/src/dendrite/registry.ts +4 -2
  139. package/src/failure-text.ts +88 -0
  140. package/src/framework.ts +1018 -293
  141. package/src/gate/event-gate.ts +90 -26
  142. package/src/gate/reply-judge.ts +12 -3
  143. package/src/gate/suppression-review.ts +2 -2
  144. package/src/gate/types.ts +7 -0
  145. package/src/heartbeat/heartbeat.ts +1 -1
  146. package/src/index.ts +1 -0
  147. package/src/inference-bounds.ts +11 -6
  148. package/src/mcpl/channel-registry.ts +160 -31
  149. package/src/mcpl/modern-connection.ts +629 -91
  150. package/src/mcpl/protocol-family.ts +15 -9
  151. package/src/mcpl/server-connection.ts +28 -15
  152. package/src/mcpl/tool-presence.ts +10 -5
  153. package/src/mcpl/toolset-host.ts +7 -6
  154. package/src/mcpl/toolsets-module.ts +2 -1
  155. package/src/mcpl/types.ts +25 -4
  156. package/src/modules/discord/index.ts +1 -1
  157. package/src/modules/health/index.ts +2 -2
  158. package/src/modules/history/semantic.ts +3 -3
  159. package/src/modules/workspace/index.ts +4 -3
  160. package/src/modules/workspace/observe.ts +2 -1
  161. package/src/modules/workspace/reconcile.ts +3 -2
  162. package/src/notice-tags.ts +11 -8
  163. package/src/presentation.ts +4 -2
  164. package/src/reasoning-loss.ts +7 -0
  165. package/src/recovery/offline-branch.ts +5 -4
  166. package/src/recovery/recover-cli.ts +2 -1
  167. package/src/sent-rows.ts +1 -1
  168. package/src/supplied-text.ts +3 -3
  169. package/src/thinking-drops.ts +2 -0
  170. package/src/tool-presentation.ts +34 -9
  171. package/src/tune-out/coordinator.ts +32 -15
  172. package/src/tune-out/tools.ts +2 -1
  173. package/src/types/agent.ts +36 -5
  174. package/src/types/framework.ts +44 -8
  175. package/src/types/inference-log.ts +1 -0
  176. package/src/types/trace.ts +66 -4
@@ -3,6 +3,7 @@ import { constants, fchmodSync, closeSync, fstatSync, lstatSync, openSync, readS
3
3
  import { closestNames } from './closest-names.js';
4
4
  import { boundedErrorMessage, errorMessage } from './error-message.js';
5
5
  import type { ToolDefinition, ToolResult } from './types/events.js';
6
+ import { appendAll } from './append-all.js';
6
7
 
7
8
  export interface ToolPresentationConfig {
8
9
  /** Absolute path to shared editable JSON. Missing file means no overrides. */
@@ -36,6 +37,8 @@ const MAX_DESCRIPTION_CHARS = 32768;
36
37
  const protectedNames = new Set(['set_tool_visibility', 'set_tool_description', 'workspace--read', 'toolsets--open', 'toolsets--act', 'toolsets--look', 'toolsets--close']);
37
38
  export const isPresentationTool = (name: string): boolean => name === 'set_tool_visibility' || name === 'set_tool_description';
38
39
  const hash = (value: string): string => createHash('sha256').update(value).digest('hex');
40
+ /** The catalogue's line layout, folded into its revision so a line reference saved under an earlier layout reads as stale. Raise it with any change to where the catalogue's lines fall. */
41
+ const CATALOGUE_LAYOUT = 2;
39
42
 
40
43
  /**
41
44
  * A presentation or defaults file this host can't use. Each problem is a
@@ -61,9 +64,9 @@ const characters = (text: string): number => [...text].length;
61
64
  /** A text's first `n` characters, never splitting a code point. */
62
65
  const firstCharacters = (text: string, n: number): string => [...text].slice(0, n).join('');
63
66
 
64
- /** A name as a message quotes it: whole up to 60 characters, and past that cut, with its length, since a file can hold a key of any size. */
65
- function quoted(name: string): string {
66
- return characters(name) <= 60 ? JSON.stringify(name) : `${JSON.stringify(firstCharacters(name, 57))}... (${count(characters(name))} characters)`;
67
+ /** A text as a message quotes it: whole up to `limit` characters, and past that cut, with its length, since a file can hold a key, and a tool a description, of any size. */
68
+ function quoted(name: string, limit = 60): string {
69
+ return characters(name) <= limit ? JSON.stringify(name) : `${JSON.stringify(firstCharacters(name, limit - 3))}... (${count(characters(name))} characters)`;
67
70
  }
68
71
 
69
72
  /** A file's problem as a message quotes it: its own clauses whole, and anything else bounded, since nobody wrote that text for its reader. */
@@ -246,7 +249,7 @@ export class ToolPresentation {
246
249
  return {name:tool.name,source:sources.get(tool.name) ?? 'Unattributed',visible:override?.visible ?? true,originalDescription:tool.description,description,descriptionSource,inputSchema:structuredClone(tool.inputSchema)};
247
250
  });
248
251
  const available = tools.map((tool,i) => ({...structuredClone(tool),description:entries[i].description}));
249
- return {revision:hash(JSON.stringify({raw,entries,diagnostics})),source:this.config.path,cataloguePath:this.config.cataloguePath,diagnostics,available,
252
+ return {revision:hash(JSON.stringify({layout:CATALOGUE_LAYOUT,raw,entries,diagnostics})),source:this.config.path,cataloguePath:this.config.cataloguePath,diagnostics,available,
250
253
  advertised:available.filter((_,i)=>entries[i].visible),entries};
251
254
  }
252
255
  edit(tool: string, input: unknown, available: ToolDefinition[]): ToolResult {
@@ -336,7 +339,7 @@ export class ToolPresentation {
336
339
  const name = args.name as string;
337
340
  if (!available.some((t) => t.name === name)) {
338
341
  const near = closestNames(name, available.map((t) => t.name));
339
- throw new PresentationRefusal(`Nothing was changed: no tool named ${quoted(name)} is available to you.${near.length ? ` Did you mean ${quotedList(near, 'or', JSON.stringify)}?` : ''}`
342
+ throw new PresentationRefusal(`Nothing was changed: you have no tool named ${quoted(name)} right now, shown or hidden.${near.length ? ` Did you mean ${quotedList(near, 'or', JSON.stringify)}?` : ''}`
340
343
  + ` Copy the exact name from your tool catalogue's index, workspace--read {"path":${JSON.stringify(this.config.cataloguePath)},"limit":140}, reading on with "offset" if the index runs past that.`);
341
344
  }
342
345
  if (field === 'description' && typeof args.description === 'string' && characters(args.description) > MAX_DESCRIPTION_CHARS)
@@ -346,6 +349,11 @@ export class ToolPresentation {
346
349
  return name;
347
350
  }
348
351
  }
352
+ const SUMMARY_CHARACTERS = 200;
353
+ function summaryOf(description: string): string {
354
+ const first = description.split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/).find(line => line.trim() !== '');
355
+ return first === undefined ? '' : `: ${quoted(first, SUMMARY_CHARACTERS)}`;
356
+ }
349
357
  export function renderCatalogue(snapshot: PresentationSnapshot): string {
350
358
  const groups = new Map<string, PresentationSnapshot['entries']>();
351
359
  for (const entry of snapshot.entries) {
@@ -360,12 +368,22 @@ export function renderCatalogue(snapshot: PresentationSnapshot): string {
360
368
  'Visible = not hidden by you: in your tool list, unless the host keeps it in its "settings" toolset or a server\'s tab (toolsets--look shows those). Hidden = moved into the "settings" toolset, or its MCPL server\'s tab: toolsets--look shows it, toolsets--act calls it once that toolset is open.',
361
369
  'These are your visibility choices, not fixed primary/secondary rankings.',
362
370
  'Copy exact names from the index; similar-sounding names may not exist.',
371
+ 'Index by source: line N.',
363
372
  'Show a hidden tool: set_tool_visibility {"name":"EXACT_NAME","visible":true}.',
364
373
  `Read details: workspace--read {"path":${JSON.stringify(snapshot.cataloguePath)},"offset":LINE,"limit":LINES}.`,
365
374
  'Line references below belong to this revision; reread the index after changes.', '',
366
375
  ];
376
+ const indexLineAt = lines.indexOf('Index by source: line N.');
367
377
  // One line each: a quoted parse error can hold the file's own line breaks.
368
378
  for (const diagnostic of snapshot.diagnostics) lines.push('Diagnostic: '+diagnostic.replace(/\r\n|\r|\n/g, '\\n'));
379
+ const hidden = snapshot.entries.filter(e => !e.visible);
380
+ const hiddenAt = new Map<string, number>();
381
+ if (hidden.length) {
382
+ lines.push(`## Hidden (${hidden.length})`, '');
383
+ for (const entry of hidden) {hiddenAt.set(entry.name, lines.length); lines.push('');}
384
+ lines.push('');
385
+ }
386
+ lines[indexLineAt] = `Index by source: line ${lines.length + 1}.`;
369
387
  lines.push('## Index by source', '');
370
388
  const index = new Map<string, number>();
371
389
  for (const [source, entries] of groups) {
@@ -378,11 +396,18 @@ export function renderCatalogue(snapshot: PresentationSnapshot): string {
378
396
  lines.push(`### ${source}`, '');
379
397
  for (const entry of entries) {
380
398
  const start = lines.length + 1;
381
- lines.push(`#### ${entry.name} (${entry.visible?'visible':'hidden'})`, '',
382
- ...entry.description.split('\n'), '', `Description from: ${entry.descriptionSource ?? 'installed component'}`, '', `Schema: ${JSON.stringify(entry.inputSchema)}`);
383
- if(entry.description!==entry.originalDescription) lines.push('', 'Original description:', ...entry.originalDescription.split('\n'));
399
+ lines.push(`#### ${entry.name} (${entry.visible?'visible':'hidden'})`, '');
400
+ appendAll(lines, entry.description.split('\n'));
401
+ lines.push('', `Description from: ${entry.descriptionSource ?? 'installed component'}`, '', `Schema: ${JSON.stringify(entry.inputSchema)}`);
402
+ if(entry.description!==entry.originalDescription) {
403
+ lines.push('', 'Original description:');
404
+ appendAll(lines, entry.originalDescription.split('\n'));
405
+ }
384
406
  lines.push('');
385
- lines[index.get(entry.name)!] = `- [${entry.visible?'visible':'hidden'}] ${entry.name} — details: offset ${start}, limit ${lines.length-start+1}`;
407
+ const details = `details: offset ${start}, limit ${lines.length-start+1}`;
408
+ lines[index.get(entry.name)!] = `- [${entry.visible?'visible':'hidden'}] ${entry.name} — ${details}`;
409
+ const at = hiddenAt.get(entry.name);
410
+ if (at !== undefined) lines[at] = `- ${entry.name} (${entry.source})${summaryOf(entry.description)} — ${details}`;
386
411
  }
387
412
  }
388
413
  return lines.join('\n');
@@ -1214,8 +1214,9 @@ export class TuneOutCoordinator {
1214
1214
  * runDeferredCancels runs them sooner.
1215
1215
  */
1216
1216
  private deferredRetry: ReturnType<typeof setTimeout> | undefined;
1217
- /** Timer key for the scope's cadence and expiry. */
1218
- private static readonly SCOPE_KEY = '\0all-except';
1217
+ /** Timer key for the scope's cadence and expiry; never a channel's key,
1218
+ * which starts with `[`. */
1219
+ private static readonly SCOPE_KEY = 'all-except';
1219
1220
 
1220
1221
  constructor(
1221
1222
  private readonly channelRegistry: ChannelRegistry,
@@ -1223,8 +1224,15 @@ export class TuneOutCoordinator {
1223
1224
  private readonly hooks: TuneOutFrameworkHooks,
1224
1225
  ) {}
1225
1226
 
1227
+ /** A channel's key in the coordinator's maps and its stored last looks.
1228
+ * The store refuses an object key holding a NUL, so no separator: the
1229
+ * pair as JSON, which no server or channel id can make ambiguous. */
1226
1230
  private key(serverId: string, channelId: string): string {
1227
- return `${serverId}\0${channelId}`;
1231
+ return JSON.stringify([serverId, channelId]);
1232
+ }
1233
+
1234
+ private channelOf(key: string): [string, string] {
1235
+ return JSON.parse(key) as [string, string];
1228
1236
  }
1229
1237
 
1230
1238
  /** The readers' hand-off bookkeeping's key: one epoch's reading of one channel. */
@@ -1367,7 +1375,7 @@ export class TuneOutCoordinator {
1367
1375
  if (!held.has(key) && this.holdFor(meta.serverId ?? '', meta.channelId)?.epochId === epochId) held.add(key);
1368
1376
  }
1369
1377
  for (const key of held) {
1370
- const [serverId, channelId] = key.split('\0') as [string, string];
1378
+ const [serverId, channelId] = this.channelOf(key);
1371
1379
  this.rebuildTally(serverId, channelId);
1372
1380
  }
1373
1381
  this.pruneLooks();
@@ -1481,7 +1489,7 @@ export class TuneOutCoordinator {
1481
1489
  private anchorSubconscious(occasion: 'enter' | 'resume' = 'enter'): void {
1482
1490
  const active = this.activeStarts();
1483
1491
  if (active.length === 0) return;
1484
- const sequence = Math.min(...active.map((e) => e.sequence));
1492
+ const sequence = active.reduce((min, e) => Math.min(min, e.sequence), Infinity);
1485
1493
  const epochIds = active.filter((e) => e.sequence === sequence).map((e) => e.epochId);
1486
1494
  const kept = this.ordinalsKept();
1487
1495
  const read = kept ? this.readWindowState() : { starts: {} };
@@ -3186,10 +3194,16 @@ export class TuneOutCoordinator {
3186
3194
  * of the record reads them in first (lossRecordUnread), and writes this
3187
3195
  * run's kept ones there too; where this run keeps any, that write is
3188
3196
  * made here, before the marker, and the last stop's bound is judged by
3189
- * the destination's record (carriedNeeded). Never throws.
3197
+ * the destination's record (carriedNeeded). The subconscious's last
3198
+ * looks are read again too, so the next look reads the destination's
3199
+ * record (lastLooks), unless `sameBranch` says the store switched to the
3200
+ * branch it was on, as a refusing manager's recovery does: no branch
3201
+ * moved, and the cache keeps any look a refused write held only there.
3202
+ * Never throws.
3190
3203
  */
3191
- branchMoved(): void {
3204
+ branchMoved(sameBranch = false): void {
3192
3205
  if (this.stopped) return;
3206
+ if (!sameBranch) this.lastLooksCache = undefined;
3193
3207
  try {
3194
3208
  const branch = this.branchNow();
3195
3209
  if (branch !== undefined && this.runStarts.has(branch)) {
@@ -4512,7 +4526,7 @@ export class TuneOutCoordinator {
4512
4526
  // One look per held channel with new traffic since the last look.
4513
4527
  for (const [channelKey, diverted] of this.divertedSinceInvocation) {
4514
4528
  if (diverted <= 0) continue;
4515
- const [serverId, channelId] = channelKey.split('\0') as [string, string];
4529
+ const [serverId, channelId] = this.channelOf(channelKey);
4516
4530
  if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue; // a per-channel epoch's own cadence
4517
4531
  this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
4518
4532
  }
@@ -4686,7 +4700,7 @@ export class TuneOutCoordinator {
4686
4700
  this.writeScope(null);
4687
4701
  this.clearScopeTimers();
4688
4702
  for (const [key, pending] of this.pendingWakes) {
4689
- const [serverId, channelId] = key.split('\0') as [string, string];
4703
+ const [serverId, channelId] = this.channelOf(key);
4690
4704
  if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue;
4691
4705
  clearTimeout(pending.timer);
4692
4706
  this.pendingWakes.delete(key);
@@ -4730,7 +4744,7 @@ export class TuneOutCoordinator {
4730
4744
  }
4731
4745
  }
4732
4746
  for (const key of this.divertedSinceInvocation.keys()) {
4733
- const [serverId, channelId] = key.split('\0') as [string, string];
4747
+ const [serverId, channelId] = this.channelOf(key);
4734
4748
  if (!this.channelRegistry.getTuneOutState(serverId, channelId)) this.divertedSinceInvocation.delete(key);
4735
4749
  }
4736
4750
  this.dropHandOffs(epochId);
@@ -5149,14 +5163,17 @@ export class TuneOutCoordinator {
5149
5163
  this.writeLooks(rest);
5150
5164
  }
5151
5165
 
5152
- /** At boot: the last looks of epochs no longer active go. */
5153
5166
  private pruneLooks(): void {
5154
5167
  if (this.hooks.subconsciousName() == null) return;
5155
5168
  const looks = this.lastLooks();
5156
5169
  if (!looks) return;
5157
- const active = new Set(this.listActive().map((e) => e.params.epochId));
5158
- const scope = this.getAllExcept();
5159
- if (scope) active.add(scope.params.epochId);
5170
+ let active: Set<string>;
5171
+ try {
5172
+ active = new Set(this.holdsOnRecord().map((hold) => hold.epochId));
5173
+ } catch (err) {
5174
+ console.error(`[tune-out] the holds on record could not be read, so the subconscious's last looks are all kept: ${thrownReason(err)}`);
5175
+ return;
5176
+ }
5160
5177
  const kept = Object.fromEntries(Object.entries(looks).filter(([epochId]) => active.has(epochId)));
5161
5178
  if (Object.keys(kept).length !== Object.keys(looks).length) this.writeLooks(kept);
5162
5179
  }
@@ -5181,7 +5198,7 @@ export class TuneOutCoordinator {
5181
5198
  if (trigger === 'cadence' && backlog.length === 0) return false;
5182
5199
  // The cursor only moves forward: a returned row handed again can be
5183
5200
  // behind it.
5184
- const cursor = Math.max(this.readerCursor.get(key) ?? -1, ...backlog.map((m) => m.sequence));
5201
+ const cursor = backlog.reduce((max, m) => Math.max(max, m.sequence), this.readerCursor.get(key) ?? -1);
5185
5202
  if (backlog.length > 0) this.readerCursor.set(key, cursor);
5186
5203
  const returned = this.readerReturned.get(key);
5187
5204
  if (returned) {
@@ -1,6 +1,7 @@
1
1
  import type { ContentBlock } from '@animalabs/membrane';
2
2
  import { authorLine } from '../author-presentation.js';
3
3
  import type { StoredMessage } from '@animalabs/context-manager';
4
+ import { appendAll } from '../append-all.js';
4
5
  /**
5
6
  * The subconscious's tool surface (issue #77).
6
7
  *
@@ -394,7 +395,7 @@ export function defaultReaderFraming(ctx: ReaderFramingContext): ContentBlock[]
394
395
  const media = message.content.filter((b) => b.type !== 'text');
395
396
  if (media.length > 0) {
396
397
  flush();
397
- content.push(...media);
398
+ appendAll(content, media);
398
399
  }
399
400
  }
400
401
  flush();
@@ -221,11 +221,15 @@ export interface AgentConfig {
221
221
  * counts it. After a fold, each block after the stretch it rewrote is
222
222
  * bound to a conversation the request no longer holds. Where the context
223
223
  * strategy still sends such a block, the provider drops it on every call
224
- * until a later fold takes it out of the context, and while such blocks
225
- * are sent, no stream gives a sample and the calibration keeps the value
226
- * it had. A strategy that leaves that thinking out at the fold sends none
227
- * to drop. The direct API carries the control too, but reads no report: it
228
- * traces no usage and feeds no calibration.
224
+ * until a later fold takes it out of the context, or, with `binds` set
225
+ * too, until the next compile after a note lands: the stream tells the
226
+ * context manager which stored reply's thinking was dropped
227
+ * (`noteThinkingDropped`), and once a note lands, the next compile sends
228
+ * that reply without it. While such blocks are sent,
229
+ * no stream gives a sample and the calibration keeps the value it had. A
230
+ * strategy that leaves that thinking out at the fold sends none to drop.
231
+ * The direct API carries the control too, but reads no report: it traces
232
+ * no usage, feeds no calibration and notes nothing.
229
233
  *
230
234
  * Default false: nothing is sent, and the account's default applies.
231
235
  */
@@ -249,6 +253,11 @@ export interface AgentConfig {
249
253
  * thinking is kept at the next compile. The direct API
250
254
  * (`runInference`) notes nothing, so each of its replies' thinking is
251
255
  * left out at the compile after it's stored;
256
+ * - with `dropBlock` set too, each framework stream tells the context
257
+ * manager which stored replies' thinking the provider reports dropping
258
+ * (`noteThinkingDropped`), naming the compile it was built from, so
259
+ * once a note lands, the next compile sends those replies without their
260
+ * thinking;
252
261
  * - with `physicalWindowTokens` set too, `maxStreamTokens` no longer
253
262
  * starts a new stream mid-turn, since the new stream's compile can take
254
263
  * the turn's earlier reasoning out of the request;
@@ -753,6 +762,28 @@ export interface InferenceRequest {
753
762
  * channel (ambient-only batches leave it unset).
754
763
  */
755
764
  wakeChannelId?: string;
765
+ /**
766
+ * Framework-set on a gate wake whose batch held no message that addressed
767
+ * the agent, and which so took no route: the channels its messages came
768
+ * from (a `chat:system` notice names none, unless it's a coalesced
769
+ * render's latest occurrence), each once. A start merged from several
770
+ * requests carries every one's channels, or, when any of them comes from
771
+ * outside the framework, isn't a silent heartbeat tick and brought
772
+ * channels, only those requests' channels. A request a quiesce keeps in
773
+ * another's place, and one a provider hold takes another into, carry both
774
+ * requests' channels.
775
+ * The framework's own wakes that continue a
776
+ * turn (a restart, a re-inference, a continuation, a retry, a bounce, a
777
+ * re-arm), unless its own wake named a channel that resolves to none,
778
+ * carry the channels the turn's starts gathered, whether or not the turn
779
+ * had a locus, so a turn's trigger can name channels from several batches,
780
+ * and a turn they open with no locus can name them too.
781
+ * When such a turn's plain speech, or a sleep's announcement, is routed
782
+ * with no locus, the agent is told these channels, unless the turn's own
783
+ * wake named a channel that resolves to none, or the turn is an ephemeral
784
+ * run's, which is told nothing.
785
+ */
786
+ wakeChannels?: Array<{ serverId: string; channelId: string }>;
756
787
  /** Timestamp of the event the wake provenance was taken from (ms). */
757
788
  wakeAt?: number;
758
789
  /** Batched-wake lines the gate handed to this wake rather than wrote
@@ -191,18 +191,23 @@ export interface FrameworkConfig {
191
191
  * (architecture channel, 2026-10-06: the §3 carve-out accepted by the
192
192
  * residents).
193
193
  *
194
- * - `'delivery'` (default): the locus comes only from the delivery that
195
- * woke the turn, a conversation fork's home channel, or a channel the
196
- * agent opened during the turn. Incoming traffic never moves it — not
197
- * between turns, not mid-turn. A turn with no such locus keeps its
198
- * prose private, and says so in a `[routing]` notice.
194
+ * - `'delivery'` (default): the locus comes from the delivery that woke
195
+ * the turn, a conversation fork's home channel, or a channel the agent
196
+ * opened during the turn, and past those only from an open all-except
197
+ * hold's channel or `unpromptedChannel` (below), each for the turns that
198
+ * take it. Incoming traffic never moves it — not between turns, not
199
+ * mid-turn. A turn with no such locus keeps its prose private, and says
200
+ * so in a `[routing]` notice.
199
201
  * - `'latest-inbound'`: the previous rule. The most recent delivered
200
202
  * inbound message is a global fallback locus for turns without one, and
201
203
  * an addressed message injected mid-turn re-pins the turn's locus.
202
204
  *
203
- * Held traffic never moves the locus under either rule. Neither rule
204
- * applies to an agent under locked prose routing (AgentConfig.proseRouting
205
- * 'locked'): its turns speak at its lock, whatever arrives.
205
+ * Held traffic never moves the locus under either rule. Under either, a
206
+ * turn the agent's own timer starts takes `unpromptedChannel` where it's
207
+ * set, after an all-except hold's channel and before both rules' own
208
+ * fallbacks. Neither rule applies to an agent under locked prose routing
209
+ * (AgentConfig.proseRouting 'locked'): its turns speak at its lock,
210
+ * whatever arrives.
206
211
  */
207
212
  speech?: {
208
213
  locus?: 'delivery' | 'latest-inbound';
@@ -502,6 +507,35 @@ export interface FrameworkConfig {
502
507
  */
503
508
  inferenceRouting?: InferenceRoutingPolicy;
504
509
 
510
+ /**
511
+ * The channel for a turn the agent's own timer starts, which no message
512
+ * prompted: the wake `skip_reply` schedules, or the end of a `sleep`. Plain
513
+ * speech in locus or hybrid prose routing goes there, and so does
514
+ * `channel_publish` naming no channel in any prose routing mode but locked,
515
+ * which servers are told as the turn's default outgoing channel. An
516
+ * all-except hold's channel still comes first while one is open, and without
517
+ * the setting such a turn follows `speech.locus`. A turn the framework opens
518
+ * to continue such a turn that had no locus of its own, as in explicit or
519
+ * disabled routing, takes it too: a `!` continuation, autoContinue's next
520
+ * turn, or a bounced reply's wake. No other turn takes it: not one a message
521
+ * prompted, from a channel, a module, a person at a console or another agent;
522
+ * not a heartbeat's or a reminder's (its plain speech is held as drafts); not
523
+ * a conversation fork's or an ephemeral run's; not a locked agent's, which
524
+ * speaks at its lock or holds its speech as drafts; and not a start merged
525
+ * from a timer's wake and any other cause. One value, for every agent on the
526
+ * framework. A registered channel's id, e.g. `discord:{guildId}:{channelId}`,
527
+ * or, to pick one server's where two register that id,
528
+ * `{serverId}:{channelId}`. `create()` refuses a value that is neither a
529
+ * string nor null, or is blank; null reads as not set. A value that names no
530
+ * registered channel, several, or one its server declared inbound-only, when
531
+ * a turn would take it, is logged, and logged again only after a turn has
532
+ * used it since; the turn takes what it would without the setting.
533
+ * `channel_publish`'s description adds a default for such turns only on a
534
+ * framework with an event gate, the only one with such turns; on any other
535
+ * the description is as it is without the setting.
536
+ */
537
+ unpromptedChannel?: string | null;
538
+
505
539
  /** EventGate config. If omitted, all events trigger inference (unchanged default). */
506
540
  gate?: GateOptions;
507
541
  /**
@@ -677,6 +711,8 @@ export interface InferenceLogEntry {
677
711
  success: boolean;
678
712
  /** Why it didn't succeed: the failure's message, or what stopped it */
679
713
  error?: string;
714
+ /** `error` in the framework's words, with no provider's text: what an agent may be shown of it */
715
+ errorWords?: string;
680
716
  /** Request data or blob ID if large. A stream the framework ended at a
681
717
  * tool boundary (stopReason 'turn_ended', 'context_budget' or
682
718
  * 'physical_window') records a note in place of the body. */
@@ -54,6 +54,7 @@ export interface InferenceLogSummary {
54
54
  requestId: string;
55
55
  success: boolean;
56
56
  error?: string;
57
+ errorWords?: string;
57
58
  durationMs: number;
58
59
  tokenUsage?: { input: number; output: number; cacheCreation?: number; cacheRead?: number };
59
60
  stopReason?: string;
@@ -142,6 +142,14 @@ export type TraceEvent =
142
142
  agentName: string;
143
143
  /** The failure's message, bounded as every trace event's `error` is (2,000 characters). */
144
144
  error: string;
145
+ /**
146
+ * The reason the agent's `[inference-failed]` note may give, before the
147
+ * note's own 600-character cut, and bounded as `error` is. Present only
148
+ * when the failure's message holds no provider's text: a progress
149
+ * bound's cut, a stream's abort, or context-manager's refusal of a
150
+ * compile. Absent otherwise, and a note then gives no reason.
151
+ */
152
+ shownReason?: string;
145
153
  /** Whether membrane classified the failure as retryable (observability). */
146
154
  retryable?: boolean;
147
155
  /**
@@ -156,6 +164,55 @@ export type TraceEvent =
156
164
  /** The provider's own error code, when it sent one (at most 128 characters). */
157
165
  providerErrorCode?: string;
158
166
  })
167
+ | (TraceEventBase & {
168
+ /**
169
+ * A start whose compile was refused for budget while compression was
170
+ * pending waits in the agent's budget hold, idle, instead of failing
171
+ * (#304). It follows the refused attempt's `inference:failed`. Once
172
+ * something the refused compile depends on has changed (compression's
173
+ * progress, nothing left pending, a surgery or an undo, the agent's
174
+ * budget or live context settings, or a quiesce's end), the hold ends,
175
+ * the held requests are queued, and its note is stored as the agent's
176
+ * own row, entering its window at once or, while a turn of the agent's
177
+ * is alive, at its next presentation point; a failed write stores
178
+ * none. Their start's own compile is the check: refused again while
179
+ * compression is pending, it waits in a new hold, with a new
180
+ * `inference:held`. `inference:hold_ended` follows each, once. An
181
+ * ephemeral run's start, and one before the framework's `start()`,
182
+ * never waits.
183
+ */
184
+ type: 'inference:held';
185
+ agentName: string;
186
+ /** 'over_budget', or 'context_refusal' when no summary covers a span the compile would drop. */
187
+ errorType: 'over_budget' | 'context_refusal';
188
+ /** The refusal's message, bounded as a trace event's `error` is (2,000 characters). */
189
+ error: string;
190
+ /** The refused compile's budget and size in tokens, when context-manager gave them. */
191
+ budget?: number;
192
+ actual?: number;
193
+ /** When this hold started (epoch ms). A wait held again after its
194
+ * release starts a new hold; its `context-refusal` alert and the note
195
+ * count from the wait's first hold. */
196
+ since: number;
197
+ /** Requests the hold holds as it starts: the refused start's own. */
198
+ heldRequests: number;
199
+ })
200
+ | (TraceEventBase & {
201
+ type: 'inference:hold_ended';
202
+ agentName: string;
203
+ /**
204
+ * 'released': something the refused compile depends on changed, so the
205
+ * held requests were queued and their start's compile is the check.
206
+ * 'stopped': the held turn was ended by `abortInference()`, `stop()` or
207
+ * its agent's end, reported after its `inference:aborted`. 'removed': a
208
+ * surgery or an undo removed what the held turn stood on, reported after
209
+ * its `inference:aborted`, and the wakes that joined it go back to the
210
+ * queue.
211
+ */
212
+ outcome: 'released' | 'stopped' | 'removed';
213
+ /** Since the hold started (`inference:held`'s `since`). */
214
+ waitedMs: number;
215
+ })
159
216
 
160
217
  // Streaming inference lifecycle
161
218
  | (TraceEventBase & {
@@ -1043,14 +1100,19 @@ export type TraceEvent =
1043
1100
  // this stream, not a separate system. See connectome docs/observability.md.
1044
1101
  | (TraceEventBase & {
1045
1102
  type: 'ops:alert';
1046
- /** Alert kind — open set: 'refusal' | 'hard-down' | 'mcpl-down' | … */
1103
+ /** Alert kind — open set: 'refusal' | 'hard-down' | 'mcpl-down' | …
1104
+ * A kind ending in `-clear` ends the alert of the kind before it:
1105
+ * 'context-refusal-clear' is raised when a budget hold whose wait
1106
+ * raised 'context-refusal' ends with its turn stopped or removed, so
1107
+ * the waiting start doesn't run. */
1047
1108
  kind: string;
1048
1109
  /** Agent name, or a server/component id for non-agent alerts (e.g. mcpl-down). */
1049
1110
  agentName: string;
1050
1111
  /** At most 2,000 characters. */
1051
1112
  message: string;
1052
- /** Kind-specific structured payload (mirrors the failures.log record),
1053
- * bounded field by field as that record is. */
1113
+ /** Kind-specific structured payload (mirrors the failures.log record,
1114
+ * where the alert writes one: a budget hold's 'context-refusal' and its
1115
+ * clear write none), bounded field by field as that record is. */
1054
1116
  data?: Record<string, unknown>;
1055
1117
  })
1056
1118
 
@@ -1560,7 +1622,7 @@ export type TraceEvent =
1560
1622
  });
1561
1623
 
1562
1624
  /** Why rows left an agent's window (`presentation:retracted`). */
1563
- export type PresentationRetractReason = 'hide' | 'suppress' | 'coalescer' | 'module' | 'resume' | 'rewind' | 'shed' | 'boot' | 'elsewhere';
1625
+ export type PresentationRetractReason = 'hide' | 'suppress' | 'coalescer' | 'module' | 'resume' | 'rewind' | 'shed' | 'boot' | 'elsewhere' | 'unsent-injection';
1564
1626
 
1565
1627
  /**
1566
1628
  * Listener for trace events.