@animalabs/agent-framework 0.28.0 → 0.29.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 (240) hide show
  1. package/README.md +88 -25
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +4 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  6. package/dist/src/api/mcp-server-core.js +3 -12
  7. package/dist/src/api/mcp-server-core.js.map +1 -1
  8. package/dist/src/api/server.d.ts.map +1 -1
  9. package/dist/src/api/server.js +7 -3
  10. package/dist/src/api/server.js.map +1 -1
  11. package/dist/src/auto-continue.d.ts +2 -0
  12. package/dist/src/auto-continue.d.ts.map +1 -1
  13. package/dist/src/auto-continue.js +5 -2
  14. package/dist/src/auto-continue.js.map +1 -1
  15. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  16. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  17. package/dist/src/code-execution/isolate/runner.js +7 -5
  18. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  19. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.js +3 -2
  21. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  22. package/dist/src/framework.d.ts +164 -36
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +1120 -479
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts +34 -10
  27. package/dist/src/gate/event-gate.d.ts.map +1 -1
  28. package/dist/src/gate/event-gate.js +118 -48
  29. package/dist/src/gate/event-gate.js.map +1 -1
  30. package/dist/src/gate/index.d.ts +1 -1
  31. package/dist/src/gate/index.d.ts.map +1 -1
  32. package/dist/src/gate/index.js +1 -1
  33. package/dist/src/gate/index.js.map +1 -1
  34. package/dist/src/gate/types.d.ts +12 -2
  35. package/dist/src/gate/types.d.ts.map +1 -1
  36. package/dist/src/heartbeat/heartbeat.d.ts +18 -0
  37. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -1
  38. package/dist/src/heartbeat/heartbeat.js +34 -8
  39. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  40. package/dist/src/held-writes.d.ts +3 -3
  41. package/dist/src/held-writes.js +3 -3
  42. package/dist/src/host-presentation.d.ts +170 -0
  43. package/dist/src/host-presentation.d.ts.map +1 -0
  44. package/dist/src/host-presentation.js +561 -0
  45. package/dist/src/host-presentation.js.map +1 -0
  46. package/dist/src/index.d.ts +2 -2
  47. package/dist/src/index.d.ts.map +1 -1
  48. package/dist/src/index.js +1 -1
  49. package/dist/src/index.js.map +1 -1
  50. package/dist/src/line-reader.d.ts +4 -0
  51. package/dist/src/line-reader.d.ts.map +1 -0
  52. package/dist/src/line-reader.js +59 -0
  53. package/dist/src/line-reader.js.map +1 -0
  54. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  55. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  56. package/dist/src/mcpl/channel-registry.js +131 -53
  57. package/dist/src/mcpl/channel-registry.js.map +1 -1
  58. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  59. package/dist/src/mcpl/conversation-router.js +2 -1
  60. package/dist/src/mcpl/conversation-router.js.map +1 -1
  61. package/dist/src/mcpl/convert-block.d.ts +1 -0
  62. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  63. package/dist/src/mcpl/convert-block.js +16 -4
  64. package/dist/src/mcpl/convert-block.js.map +1 -1
  65. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  66. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  67. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  68. package/dist/src/mcpl/inbound-source.d.ts +3 -1
  69. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  70. package/dist/src/mcpl/inbound-source.js +3 -1
  71. package/dist/src/mcpl/inbound-source.js.map +1 -1
  72. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  73. package/dist/src/mcpl/modern-connection.js +11 -7
  74. package/dist/src/mcpl/modern-connection.js.map +1 -1
  75. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  76. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  77. package/dist/src/mcpl/protocol-family.js +25 -3
  78. package/dist/src/mcpl/protocol-family.js.map +1 -1
  79. package/dist/src/mcpl/push-handler.js +2 -2
  80. package/dist/src/mcpl/push-handler.js.map +1 -1
  81. package/dist/src/mcpl/references.d.ts +12 -40
  82. package/dist/src/mcpl/references.d.ts.map +1 -1
  83. package/dist/src/mcpl/references.js +29 -23
  84. package/dist/src/mcpl/references.js.map +1 -1
  85. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  86. package/dist/src/mcpl/server-connection.js +20 -20
  87. package/dist/src/mcpl/server-connection.js.map +1 -1
  88. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  89. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  90. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  91. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  92. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  93. package/dist/src/mcpl/tool-presence.js +18 -17
  94. package/dist/src/mcpl/tool-presence.js.map +1 -1
  95. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  96. package/dist/src/mcpl/tool-signature.js +17 -16
  97. package/dist/src/mcpl/tool-signature.js.map +1 -1
  98. package/dist/src/mcpl/toolset-host.d.ts +8 -26
  99. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  100. package/dist/src/mcpl/toolset-host.js +16 -6
  101. package/dist/src/mcpl/toolset-host.js.map +1 -1
  102. package/dist/src/mcpl/toolsets-module.d.ts +14 -25
  103. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.js +180 -50
  105. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  106. package/dist/src/mcpl/transport.d.ts +1 -1
  107. package/dist/src/mcpl/transport.d.ts.map +1 -1
  108. package/dist/src/mcpl/transport.js +16 -16
  109. package/dist/src/mcpl/transport.js.map +1 -1
  110. package/dist/src/mcpl/types.d.ts +28 -12
  111. package/dist/src/mcpl/types.d.ts.map +1 -1
  112. package/dist/src/mcpl/types.js.map +1 -1
  113. package/dist/src/module-registry.d.ts +6 -7
  114. package/dist/src/module-registry.d.ts.map +1 -1
  115. package/dist/src/module-registry.js +6 -7
  116. package/dist/src/module-registry.js.map +1 -1
  117. package/dist/src/modules/api/index.d.ts.map +1 -1
  118. package/dist/src/modules/api/index.js +4 -2
  119. package/dist/src/modules/api/index.js.map +1 -1
  120. package/dist/src/modules/discord/index.d.ts.map +1 -1
  121. package/dist/src/modules/discord/index.js +33 -25
  122. package/dist/src/modules/discord/index.js.map +1 -1
  123. package/dist/src/modules/history/index.js +8 -6
  124. package/dist/src/modules/history/index.js.map +1 -1
  125. package/dist/src/modules/sender-name.d.ts +7 -0
  126. package/dist/src/modules/sender-name.d.ts.map +1 -0
  127. package/dist/src/modules/sender-name.js +10 -0
  128. package/dist/src/modules/sender-name.js.map +1 -0
  129. package/dist/src/modules/workspace/answers.d.ts +5 -1
  130. package/dist/src/modules/workspace/answers.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/answers.js +16 -1
  132. package/dist/src/modules/workspace/answers.js.map +1 -1
  133. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.js +6 -4
  135. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  136. package/dist/src/modules/workspace/effects.d.ts +4 -2
  137. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  138. package/dist/src/modules/workspace/effects.js +50 -40
  139. package/dist/src/modules/workspace/effects.js.map +1 -1
  140. package/dist/src/modules/workspace/index.d.ts +1 -0
  141. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/index.js +66 -36
  143. package/dist/src/modules/workspace/index.js.map +1 -1
  144. package/dist/src/modules/workspace/observe.d.ts +51 -20
  145. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/observe.js +120 -82
  147. package/dist/src/modules/workspace/observe.js.map +1 -1
  148. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  149. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/reconcile.js +133 -37
  151. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  152. package/dist/src/notice-tags.d.ts +79 -0
  153. package/dist/src/notice-tags.d.ts.map +1 -0
  154. package/dist/src/notice-tags.js +83 -0
  155. package/dist/src/notice-tags.js.map +1 -0
  156. package/dist/src/reasoning-loss-notice.d.ts.map +1 -1
  157. package/dist/src/reasoning-loss-notice.js +5 -3
  158. package/dist/src/reasoning-loss-notice.js.map +1 -1
  159. package/dist/src/silent-heartbeat.d.ts +1 -53
  160. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  161. package/dist/src/silent-heartbeat.js +56 -3
  162. package/dist/src/silent-heartbeat.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +35 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -0
  165. package/dist/src/supplied-text.js +141 -0
  166. package/dist/src/supplied-text.js.map +1 -0
  167. package/dist/src/tool-args.d.ts.map +1 -1
  168. package/dist/src/tool-args.js +9 -8
  169. package/dist/src/tool-args.js.map +1 -1
  170. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  171. package/dist/src/tool-image-ledger.js +2 -1
  172. package/dist/src/tool-image-ledger.js.map +1 -1
  173. package/dist/src/tool-presentation.d.ts.map +1 -1
  174. package/dist/src/tool-presentation.js +5 -4
  175. package/dist/src/tool-presentation.js.map +1 -1
  176. package/dist/src/tune-out/coordinator.d.ts +3 -2
  177. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  178. package/dist/src/tune-out/coordinator.js +24 -26
  179. package/dist/src/tune-out/coordinator.js.map +1 -1
  180. package/dist/src/types/agent.d.ts +23 -9
  181. package/dist/src/types/agent.d.ts.map +1 -1
  182. package/dist/src/types/framework.d.ts +43 -0
  183. package/dist/src/types/framework.d.ts.map +1 -1
  184. package/dist/src/types/module.d.ts +22 -11
  185. package/dist/src/types/module.d.ts.map +1 -1
  186. package/package.json +2 -2
  187. package/src/agent.ts +4 -1
  188. package/src/api/mcp-server-core.ts +3 -14
  189. package/src/api/server.ts +7 -3
  190. package/src/auto-continue.ts +8 -2
  191. package/src/code-execution/isolate/runner.ts +7 -5
  192. package/src/code-execution/isolate/scripts.ts +3 -2
  193. package/src/framework.ts +1132 -492
  194. package/src/gate/event-gate.ts +128 -47
  195. package/src/gate/index.ts +1 -1
  196. package/src/gate/types.ts +12 -2
  197. package/src/heartbeat/heartbeat.ts +48 -9
  198. package/src/held-writes.ts +3 -3
  199. package/src/host-presentation.ts +605 -0
  200. package/src/index.ts +2 -2
  201. package/src/line-reader.ts +58 -0
  202. package/src/mcpl/channel-registry.ts +137 -52
  203. package/src/mcpl/conversation-router.ts +2 -1
  204. package/src/mcpl/convert-block.ts +17 -4
  205. package/src/mcpl/hook-orchestrator.ts +4 -3
  206. package/src/mcpl/inbound-source.ts +3 -1
  207. package/src/mcpl/modern-connection.ts +11 -7
  208. package/src/mcpl/protocol-family.ts +24 -3
  209. package/src/mcpl/push-handler.ts +2 -2
  210. package/src/mcpl/references.ts +31 -23
  211. package/src/mcpl/server-connection.ts +20 -18
  212. package/src/mcpl/skip-reply-wake.ts +35 -0
  213. package/src/mcpl/tool-presence.ts +18 -17
  214. package/src/mcpl/tool-signature.ts +17 -16
  215. package/src/mcpl/toolset-host.ts +18 -6
  216. package/src/mcpl/toolsets-module.ts +142 -50
  217. package/src/mcpl/transport.ts +18 -14
  218. package/src/mcpl/types.ts +29 -12
  219. package/src/module-registry.ts +6 -7
  220. package/src/modules/api/index.ts +4 -2
  221. package/src/modules/discord/index.ts +37 -25
  222. package/src/modules/history/index.ts +8 -6
  223. package/src/modules/sender-name.ts +9 -0
  224. package/src/modules/workspace/answers.ts +20 -2
  225. package/src/modules/workspace/direct-write.ts +4 -3
  226. package/src/modules/workspace/effects.ts +50 -40
  227. package/src/modules/workspace/index.ts +58 -34
  228. package/src/modules/workspace/observe.ts +139 -88
  229. package/src/modules/workspace/reconcile.ts +159 -36
  230. package/src/notice-tags.ts +84 -0
  231. package/src/reasoning-loss-notice.ts +5 -3
  232. package/src/silent-heartbeat.ts +4 -3
  233. package/src/supplied-text.ts +154 -0
  234. package/src/tool-args.ts +9 -8
  235. package/src/tool-image-ledger.ts +2 -1
  236. package/src/tool-presentation.ts +5 -4
  237. package/src/tune-out/coordinator.ts +25 -27
  238. package/src/types/agent.ts +23 -9
  239. package/src/types/framework.ts +44 -0
  240. package/src/types/module.ts +23 -11
@@ -0,0 +1,58 @@
1
+ import type { Readable } from 'node:stream';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+
4
+ const LF = 0x0a;
5
+ const CR = 0x0d;
6
+
7
+ export function readLines(input: Readable, onLine: (line: string) => void, onEnd?: () => void): () => void {
8
+ const decoder = new StringDecoder('utf8');
9
+ let partial = '';
10
+ let lineEndedAtCr = false;
11
+ let stopped = false;
12
+
13
+ const take = (text: string) => {
14
+ let from = 0;
15
+ if (lineEndedAtCr && text.length > 0) {
16
+ lineEndedAtCr = false;
17
+ if (text.charCodeAt(0) === LF) from = 1;
18
+ }
19
+ for (let i = from; i < text.length && !stopped; i++) {
20
+ const code = text.charCodeAt(i);
21
+ if (code !== LF && code !== CR) continue;
22
+ const line = partial + text.slice(from, i);
23
+ partial = '';
24
+ if (code === CR) {
25
+ if (i + 1 === text.length) lineEndedAtCr = true;
26
+ else if (text.charCodeAt(i + 1) === LF) i++;
27
+ }
28
+ from = i + 1;
29
+ onLine(line);
30
+ }
31
+ if (!stopped) partial += text.slice(from);
32
+ };
33
+
34
+ const onData = (chunk: Buffer | string) => take(decoder.write(chunk));
35
+ const onStreamEnd = () => {
36
+ take(decoder.end());
37
+ if (!stopped && partial.length > 0) {
38
+ const last = partial;
39
+ partial = '';
40
+ onLine(last);
41
+ }
42
+ if (stopped) return;
43
+ stop();
44
+ onEnd?.();
45
+ };
46
+ const stop = () => {
47
+ stopped = true;
48
+ input.off('data', onData);
49
+ input.off('end', onStreamEnd);
50
+ };
51
+ input.on('data', onData);
52
+ input.on('end', onStreamEnd);
53
+ return stop;
54
+ }
55
+
56
+ export function escapeLineSeparators(json: string): string {
57
+ return json.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
58
+ }
@@ -13,8 +13,10 @@
13
13
  * - Build channel context for beforeInference params
14
14
  */
15
15
 
16
+ import { NOTICE_TAG } from '../notice-tags.js';
16
17
  import type { ContentBlock } from '@animalabs/membrane';
17
- import { convertBlock } from './convert-block.js';
18
+ import { convertBlocks } from './convert-block.js';
19
+ import { cutToFit, markedText, markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from '../supplied-text.js';
18
20
  import type { JsStore } from '@animalabs/chronicle';
19
21
  import { isDeepStrictEqual } from 'node:util';
20
22
  import { ensureStateRegistered } from '../module-registry.js';
@@ -45,7 +47,7 @@ import { RefusalLines } from './refusal-lines.js';
45
47
  import { CapabilityGrant } from './capability-grant.js';
46
48
  import type { InboundSource } from './inbound-source.js';
47
49
  import { McplRequestError, type McplServerConnection } from './server-connection.js';
48
- import { readSkipReplyWake } from './skip-reply-wake.js';
50
+ import { readSkipReplyWake, skipReplyNote, type SkipReplyEarlierWake } from './skip-reply-wake.js';
49
51
  import { isProseTargetToken } from './prose-grammar.js';
50
52
 
51
53
  // ============================================================================
@@ -643,19 +645,21 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
643
645
  // Constructor Options
644
646
  // ============================================================================
645
647
 
646
- /** Bound connector values for notices, logs and an outcome's reason. A
647
- * PublishOutcome keeps the connector's structured data (`detail`) as the
648
- * connection gives it, its strings redacted (redactedData). */
648
+ /** Bound connector values for notices, logs and an outcome's reason, on one
649
+ * line with their notice openings marked (suppliedValue). A PublishOutcome
650
+ * keeps the connector's structured data (`detail`) as the connection gives
651
+ * it, its strings redacted (redactedData). */
649
652
  export function connectorText(value: unknown): string {
650
653
  let text: string;
651
654
  try {
652
655
  text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
653
656
  } catch {
654
- return '[unserializable connector value]';
657
+ return `[${NOTICE_TAG.unserializableConnectorValue}]`;
655
658
  }
656
659
  const limit = 2000;
657
- const suffix = '… [truncated]';
658
- return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
660
+ const suffix = `… [${NOTICE_TAG.truncated}]`;
661
+ const shown = suppliedValue(text);
662
+ return shown.length > limit ? cutToFit(shown, limit - suffix.length) + suffix : shown;
659
663
  }
660
664
 
661
665
  /**
@@ -688,6 +692,9 @@ export function sameChannel(a: ChannelRef | null | undefined, b: ChannelRef | nu
688
692
  return a.serverId === b.serverId && a.channelId === b.channelId;
689
693
  }
690
694
 
695
+ export const NO_CHANNEL_EXAMPLE = '#<channel>';
696
+ export const NO_PERSON_EXAMPLE = '@<person>';
697
+
691
698
  /**
692
699
  * A channel's server-qualified name, `serverId:channelId`. A name given as a
693
700
  * string (a tool's channelId, a `>>` target, a host's call) may take this
@@ -699,12 +706,32 @@ export function qualifiedChannelName(ref: ChannelRef): string {
699
706
  return `${ref.serverId}:${ref.channelId}`;
700
707
  }
701
708
 
709
+ function markedHistory(history: readonly unknown[]): unknown[] {
710
+ return history.map((item) => {
711
+ if (item === null || typeof item !== 'object' || Array.isArray(item) || !('content' in item)) return item;
712
+ const { content } = item as { content: unknown };
713
+ if (Array.isArray(content)) return { ...item, content: markNoticeOpenings(content.map(withEmbeddedTextMarked) as object[]) };
714
+ return typeof content === 'string' ? { ...item, content: markedText(content) } : item;
715
+ });
716
+ }
717
+
718
+ function withEmbeddedTextMarked(block: unknown): unknown {
719
+ if (block === null || typeof block !== 'object' || (block as { type?: unknown }).type !== 'resource') return block;
720
+ const { resource } = block as { resource?: unknown };
721
+ if (resource === null || typeof resource !== 'object' || typeof (resource as { text?: unknown }).text !== 'string') return block;
722
+ return { ...block, resource: { ...resource, text: markedText((resource as { text: string }).text) } };
723
+ }
724
+
702
725
  /** A channel told apart by its id and its server, for one no string picks
703
- * alone: `"b:room" on server "a"`. No input takes it for a name. */
726
+ * alone or whose name wouldn't show as itself: `"b:room" on server "a"`,
727
+ * each a JSON literal a channel tool's channelId and serverId take back
728
+ * (quotedValue). No input takes the whole form for a name. */
704
729
  function channelOnServer(ref: ChannelRef): string {
705
- return `${JSON.stringify(ref.channelId)} on server ${JSON.stringify(ref.serverId)}`;
730
+ return `${quotedValue(ref.channelId)} on server ${quotedValue(ref.serverId)}`;
706
731
  }
707
732
 
733
+ const CHANNEL_ON_SERVER_AT_END = /("(?:[^"\\]|\\.)*") on server ("(?:[^"\\]|\\.)*")$/;
734
+
708
735
  /**
709
736
  * What one channels/publish attempt established (ChannelRegistry.publish).
710
737
  * - `delivered`: the connector returned `delivered: true`. A historical
@@ -1089,6 +1116,10 @@ export type ChannelToolOrigin =
1089
1116
  * name one of them answers to by its id means that channel
1090
1117
  * (ChannelRegistry.ownChannelNamed). */
1091
1118
  own?: readonly ChannelRef[];
1119
+ /** For a skip_reply whose self-wake the framework armed: the heartbeat's
1120
+ * wake due before it, when there is one, which its confirmation names
1121
+ * (skipReplyNote). */
1122
+ earlierWake?: SkipReplyEarlierWake;
1092
1123
  };
1093
1124
 
1094
1125
  /** The machine decision sources a module-origin channel_close may record.
@@ -1894,7 +1925,7 @@ export class ChannelRegistry {
1894
1925
  }
1895
1926
  }
1896
1927
  if (message.tags) message.tags = expandCoreTags(message.tags);
1897
- const convertedContent: ContentBlock[] = message.content.map((block) => convertBlock(block, 'attachment on channel message', serverId));
1928
+ const convertedContent: ContentBlock[] = convertBlocks(message.content, 'attachment on channel message', serverId);
1898
1929
  if (!emptyAllowed && isVisiblyEmptyConversion(message.content, convertedContent)) {
1899
1930
  rejectEmpty();
1900
1931
  continue;
@@ -2386,7 +2417,10 @@ export class ChannelRegistry {
2386
2417
  return this.handleToolJournal(input as { content?: string });
2387
2418
 
2388
2419
  case 'skip_reply':
2389
- return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: unknown });
2420
+ return this.handleToolSkipReply(
2421
+ input as { reason?: string; wake_in_seconds?: unknown },
2422
+ origin?.kind === 'agent' ? origin.earlierWake : undefined,
2423
+ );
2390
2424
 
2391
2425
  default:
2392
2426
  // Not every agent is given every channel tool, so the refusal points
@@ -2442,7 +2476,8 @@ export class ChannelRegistry {
2442
2476
  // declared it inbound-only, since speech isn't posted there): a conversation
2443
2477
  // fork's home channel, else this turn's channel (single trunk agent — item-3
2444
2478
  // redux: its locus, else its triggering channel), else the global default
2445
- // (heartbeats). Without this, a fork
2479
+ // (a no-trigger turn's, but not a heartbeat's, whose turn takes none).
2480
+ // Without this, a fork
2446
2481
  // was advertised the global locus but published somewhere else; and a trunk
2447
2482
  // agent was told the wrong channel under concurrency.
2448
2483
  // Each lookup is keyed on whether a name was given, not its truthiness:
@@ -3921,12 +3956,20 @@ export class ChannelRegistry {
3921
3956
  * name, only where no registered channel takes that name, as a channel
3922
3957
  * tool reads it or a `>>` target does (by label or raw id too), so the
3923
3958
  * name can't send anything to another channel; else by its id and server.
3959
+ * A name that wouldn't show as itself (showsAsItself: one holding a line
3960
+ * break, a notice opening, a quote, a backslash or a lone surrogate, or
3961
+ * whitespace at its edges) is shown by its id and its server too, which
3962
+ * the channel tools take exactly and the prose resolver doesn't, so no name
3963
+ * shown can be another channel's.
3924
3964
  */
3925
3965
  nameShown(ref: ChannelRef): string {
3926
3966
  const entry = this.entryAt(ref);
3927
- if (entry) return this.nameFor(entry) ?? channelOnServer(ref);
3967
+ if (entry) {
3968
+ const name = this.nameFor(entry);
3969
+ return name !== undefined && showsAsItself(name) ? name : channelOnServer(ref);
3970
+ }
3928
3971
  for (const name of [ref.channelId, qualifiedChannelName(ref)]) {
3929
- if (this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name))) return name;
3972
+ if (showsAsItself(name) && this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name))) return name;
3930
3973
  }
3931
3974
  return channelOnServer(ref);
3932
3975
  }
@@ -3966,7 +4009,7 @@ export class ChannelRegistry {
3966
4009
  private tuneOutEndText(entry: ChannelEntry, tool?: string): string {
3967
4010
  const close = tool === 'channel_close';
3968
4011
  return this.nameFor(entry) !== undefined
3969
- ? `Call tune_out with channelId ${this.quotedShown(refOf(entry))} and mode "cancel" for it to resume normal ` +
4012
+ ? `Call tune_out with channelId ${quotedValue(this.nameFor(entry)!)} and mode "cancel" for it to resume normal ` +
3970
4013
  `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
3971
4014
  : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
3972
4015
  'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
@@ -3979,7 +4022,7 @@ export class ChannelRegistry {
3979
4022
  */
3980
4023
  quotedShown(ref: ChannelRef): string {
3981
4024
  const shown = this.nameShown(ref);
3982
- return shown === channelOnServer(ref) ? shown : JSON.stringify(shown);
4025
+ return shown === channelOnServer(ref) ? shown : quotedValue(shown);
3983
4026
  }
3984
4027
 
3985
4028
  /**
@@ -4017,7 +4060,7 @@ export class ChannelRegistry {
4017
4060
  const nothing = opts.nothing ?? 'nothing ran';
4018
4061
  const names = entries.map((e) => this.nameFor(e));
4019
4062
  if (names.every((n) => n !== undefined)) {
4020
- const listed = names.map((n) => JSON.stringify(n)).join(', ');
4063
+ const listed = names.map((n) => quotedValue(n!)).join(', ');
4021
4064
  const servers = [...new Set(entries.map((e) => JSON.stringify(e.serverId)))];
4022
4065
  // One server's two channels can answer to one name too, as `s`'s
4023
4066
  // `s:room` and `room` both do to `s:room`; its server picks neither.
@@ -4038,8 +4081,8 @@ export class ChannelRegistry {
4038
4081
  const own = names[i];
4039
4082
  if (own === undefined && !opts.takesServerId) return `- ${where}: no channelId reaches it alone.`;
4040
4083
  return own !== undefined
4041
- ? `- ${where}: call again with channelId ${JSON.stringify(own)}.`
4042
- : `- ${where}: call again with channelId ${JSON.stringify(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
4084
+ ? `- ${where}: call again with channelId ${quotedValue(own)}.`
4085
+ : `- ${where}: call again with channelId ${quotedValue(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
4043
4086
  });
4044
4087
  const count = entries.length === 2 ? 'either of two channels' : `any of ${entries.length} channels`;
4045
4088
  return `Channel ${JSON.stringify(name)} could mean ${count}, so ${nothing}:\n${lines.join('\n')}`;
@@ -4078,7 +4121,7 @@ export class ChannelRegistry {
4078
4121
  // so the text says whose.
4079
4122
  if (!alone) return 'channel_list shows the channels and their servers.';
4080
4123
  const one = named[0]!;
4081
- const names = `${JSON.stringify(channelId)} alone names channel ${JSON.stringify(one.descriptor.id)} on server `
4124
+ const names = `${JSON.stringify(channelId)} alone names channel ${quotedValue(one.descriptor.id)} on server `
4082
4125
  + JSON.stringify(one.serverId);
4083
4126
  return takesServerId ? `${names}: for that one, call again with serverId left out.` : `${names}.`;
4084
4127
  }
@@ -4090,7 +4133,7 @@ export class ChannelRegistry {
4090
4133
  const places = elsewhere.map((e) => {
4091
4134
  const at = `on server ${JSON.stringify(e.serverId)}`;
4092
4135
  const name = this.nameFor(e);
4093
- return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${JSON.stringify(name)}`;
4136
+ return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${quotedValue(name)}`;
4094
4137
  });
4095
4138
  return `It's ${places.length === 1 ? places[0] : `${places.slice(0, -1).join(', ')} and ${places.at(-1)}`}.`;
4096
4139
  }
@@ -4394,17 +4437,19 @@ export class ChannelRegistry {
4394
4437
  spec: string,
4395
4438
  opts?: { forSpeech?: boolean; own?: readonly ChannelRef[] },
4396
4439
  ): PublishDestination | { error: string; candidates?: string[]; refusedChannelId?: string } {
4397
- const own = this.ownChannelNamed(spec.trim(), opts?.own);
4440
+ const name = spec.trim();
4441
+ if (name === NO_CHANNEL_EXAMPLE || name === NO_PERSON_EXAMPLE) return { error: `${name} is the example's placeholder: write the name it stands for in its place` };
4442
+ const own = this.ownChannelNamed(name, opts?.own);
4398
4443
  let resolved: PublishDestination | { error: string; candidates?: string[] };
4399
4444
  if (own === undefined) {
4400
- resolved = this.resolveTargetByName(spec);
4445
+ resolved = this.resolveTargetByName(name);
4401
4446
  } else if ('neither' in own) {
4402
- resolved = { error: `"${spec.trim()}" is the id of this turn's channels on more than one server`, candidates: this.ownCandidates(own.neither).map((c) => c.text) };
4447
+ resolved = { error: `"${name}" is the id of this turn's channels on more than one server`, candidates: this.ownCandidates(own.neither).map((c) => c.prose) };
4403
4448
  } else {
4404
4449
  const entry = this.entryAt(own.channel);
4405
4450
  resolved = entry
4406
4451
  ? { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }
4407
- : { error: `${this.ownChannelGoneText(spec.trim(), own.channel)}, and nothing can be sent there until that server registers it again` };
4452
+ : { error: `${this.ownChannelGoneText(name, own.channel)}, and nothing can be sent there until that server registers it again` };
4408
4453
  }
4409
4454
  if (!opts?.forSpeech || 'error' in resolved) return resolved;
4410
4455
  const entry = this.entryAt(resolved);
@@ -4463,7 +4508,7 @@ export class ChannelRegistry {
4463
4508
  if (!mine) return undefined;
4464
4509
  if ('neither' in mine) {
4465
4510
  const done = tool === 'publish' ? 'was posted' : tool === 'deliver' ? 'was delivered' : 'ran';
4466
- return { error: this.ownNeitherRefusal(name, mine.neither, done) };
4511
+ return { error: this.ownNeitherRefusal(name, mine.neither, done, false) };
4467
4512
  }
4468
4513
  return this.entryAt(mine.channel) ? { channel: mine.channel } : { error: this.ownChannelGoneRefusal(name, mine.channel, tool) };
4469
4514
  }
@@ -4482,32 +4527,73 @@ export class ChannelRegistry {
4482
4527
  /**
4483
4528
  * The turn's channels a name means neither of (ownChannelNamed), each by a
4484
4529
  * string that reaches it outside the turn's rule: its server-qualified
4485
- * name, where that picks it alone, since its id is the name refused. One
4486
- * no string picks alone, and one its server no longer registers, is named
4487
- * by its id and server, saying so; `reaches` says which a name reaches.
4530
+ * name, where that picks it alone, since its id is the name refused. `text`
4531
+ * is that name, which a refusal writes as JSON; `prose`, which a `>>`
4532
+ * target reads, is that name where it shows as itself, else its id and
4533
+ * server. One no string picks alone, and one its server no longer
4534
+ * registers, are named by id and server in both, saying so. `reaches` says
4535
+ * which a name reaches.
4488
4536
  */
4489
- private ownCandidates(channels: readonly ChannelRef[]): Array<{ text: string; reaches: boolean }> {
4537
+ private ownCandidates(channels: readonly ChannelRef[]): Array<{ text: string; prose: string; reaches: boolean; byServer: boolean }> {
4490
4538
  return channels.map((c) => {
4491
4539
  const entry = this.entryAt(c);
4492
- if (!entry) return { text: `${channelOnServer(c)} (its server no longer registers it)`, reaches: false };
4540
+ if (!entry) {
4541
+ const text = `${channelOnServer(c)} (its server no longer registers it)`;
4542
+ return { text, prose: text, reaches: false, byServer: false };
4543
+ }
4493
4544
  const qualified = qualifiedChannelName(c);
4494
4545
  const picked = this.entriesNamed(qualified);
4495
- return picked.length === 1 && picked[0] === entry
4496
- ? { text: qualified, reaches: true }
4497
- : { text: `${channelOnServer(c)} (no channelId reaches it alone)`, reaches: false };
4546
+ if (picked.length !== 1 || picked[0] !== entry) {
4547
+ const text = `${channelOnServer(c)} (no channelId reaches it alone)`;
4548
+ return { text, prose: text, reaches: false, byServer: true };
4549
+ }
4550
+ return { text: qualified, prose: showsAsItself(qualified) ? qualified : channelOnServer(c), reaches: true, byServer: false };
4498
4551
  });
4499
4552
  }
4500
4553
 
4554
+ /**
4555
+ * What a refusal listing these candidates says of each one that is a
4556
+ * channel shown by its id and server: the channelId channel_publish reaches
4557
+ * it by, or that no channelId reaches it alone. That is its server-qualified
4558
+ * name where that picks it alone, since a bare id is what a turn's own
4559
+ * channels answer to first, whether or not their server still registers
4560
+ * them (ownChannelNamed), and else its id where that picks it alone and
4561
+ * isn't empty (channel_publish refuses an empty one). It leaves out a channel no
4562
+ * longer registered and a candidate that already says no channelId reaches
4563
+ * it alone, and is empty when nothing is left.
4564
+ */
4565
+ publishRouteFor(candidates: readonly string[]): string {
4566
+ const parts = new Set<string>();
4567
+ for (const candidate of candidates) {
4568
+ const shown = CHANNEL_ON_SERVER_AT_END.exec(candidate);
4569
+ if (!shown) continue;
4570
+ const ref: ChannelRef = { channelId: JSON.parse(shown[1]!) as string, serverId: JSON.parse(shown[2]!) as string };
4571
+ const entry = this.entryAt(ref);
4572
+ if (!entry) continue;
4573
+ const name = [qualifiedChannelName(ref), ref.channelId].find((n) => {
4574
+ const named = n === '' ? [] : this.entriesNamed(n);
4575
+ return named.length === 1 && named[0] === entry;
4576
+ });
4577
+ parts.add(name !== undefined
4578
+ ? `channel_publish reaches ${channelOnServer(ref)} with channelId ${quotedValue(name)}`
4579
+ : `no channelId reaches ${channelOnServer(ref)} alone`);
4580
+ }
4581
+ return parts.size ? ` For a channel shown by its id and server: ${[...parts].join('; ')}.` : '';
4582
+ }
4583
+
4501
4584
  /** A tool's refusal of a name the turn's channels on more than one server
4502
4585
  * answer to by their id (ownChannelNamed's neither). */
4503
- private ownNeitherRefusal(name: string, channels: readonly ChannelRef[], done: string): string {
4586
+ private ownNeitherRefusal(name: string, channels: readonly ChannelRef[], done: string, takesServer = true): string {
4504
4587
  const candidates = this.ownCandidates(channels);
4505
- const named = candidates.filter((c) => c.reaches).map((c) => JSON.stringify(c.text));
4588
+ const named = candidates.filter((c) => c.reaches).map((c) => quotedValue(c.text));
4506
4589
  const pick = named.length === candidates.length ? ' Call again with channelId set to one of them.'
4507
4590
  : named.length === 1 ? ` Call again with channelId ${named[0]}.`
4508
4591
  : named.length > 1 ? ` Call again with channelId set to one of ${named.join(', ')}.` : '';
4509
- const listed = candidates.map((c) => (c.reaches ? JSON.stringify(c.text) : c.text)).join(', ');
4510
- return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}`;
4592
+ const listed = candidates.map((c) => (c.reaches ? quotedValue(c.text) : c.text)).join(', ');
4593
+ const byServer = takesServer && candidates.some((c) => c.byServer)
4594
+ ? ' For a channel shown by its id and server, call again with that channelId and that serverId.'
4595
+ : '';
4596
+ return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}${byServer}`;
4511
4597
  }
4512
4598
 
4513
4599
  private resolveTargetByName(
@@ -4521,7 +4607,7 @@ export class ChannelRegistry {
4521
4607
  // A candidate is named by the token that picks it alone (nameFor), so
4522
4608
  // two servers' channels with one id or one label can be told apart, or,
4523
4609
  // where no string picks it alone, by its id and its server.
4524
- const token = (e: ChannelEntry): string => this.nameFor(e) ?? channelOnServer(refOf(e));
4610
+ const token = (e: ChannelEntry): string => this.nameShown(refOf(e));
4525
4611
  // A DM is shown by its label, the person, where that label, given as a
4526
4612
  // target, picks that DM. It doesn't where another channel answers to that
4527
4613
  // label too, as two servers' DMs with one person do (a label is matched
@@ -4537,7 +4623,7 @@ export class ChannelRegistry {
4537
4623
  };
4538
4624
  const dmShown = (list: ChannelEntry[]): string[] => list.map((e) => {
4539
4625
  const label = e.descriptor.label ?? e.descriptor.id;
4540
- return labelPicks(e, label) ? label : token(e);
4626
+ return showsAsItself(label) && labelPicks(e, label) ? label : token(e);
4541
4627
  });
4542
4628
 
4543
4629
  // A channel id, or its server-qualified name (entriesNamed).
@@ -4655,7 +4741,7 @@ export class ChannelRegistry {
4655
4741
  if (byName.length > 1) {
4656
4742
  return {
4657
4743
  error: `"${trimmed}" matches several channels`,
4658
- candidates: byName.map((e) => `${e.descriptor.label} = ${token(e)}`),
4744
+ candidates: byName.map((e) => `${nameToTypeBack(e.descriptor.label ?? '')} = ${token(e)}`),
4659
4745
  };
4660
4746
  }
4661
4747
 
@@ -4664,7 +4750,7 @@ export class ChannelRegistry {
4664
4750
  const near = entries
4665
4751
  .filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
4666
4752
  .slice(0, 5)
4667
- .map((e) => `${e.descriptor.label} = ${token(e)}`);
4753
+ .map((e) => `${nameToTypeBack(e.descriptor.label ?? '')} = ${token(e)}`);
4668
4754
  return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
4669
4755
  }
4670
4756
 
@@ -4701,7 +4787,7 @@ export class ChannelRegistry {
4701
4787
  qualifiedChannelName(ref),
4702
4788
  ];
4703
4789
  for (const c of candidates) {
4704
- if (!isProseTargetToken(c) || c === '#') continue;
4790
+ if (!isProseTargetToken(c) || c === '#' || !showsAsItself(c)) continue;
4705
4791
  const r = this.resolveProseTarget(c, { forSpeech: true });
4706
4792
  if ('channelId' in r && sameChannel(r, ref)) return c;
4707
4793
  }
@@ -4990,7 +5076,7 @@ export class ChannelRegistry {
4990
5076
  channelId: input.channelId,
4991
5077
  serverId: entry.serverId,
4992
5078
  status: alreadyDesiredOpen ? 'reconciled' : 'opened',
4993
- ...(result.history ? { history: result.history } : {}),
5079
+ ...(result.history ? { history: markedHistory(result.history) } : {}),
4994
5080
  ...(result.historyTruncated ? { historyTruncated: true } : {}),
4995
5081
  },
4996
5082
  };
@@ -5479,13 +5565,14 @@ export class ChannelRegistry {
5479
5565
  * tool, so any trailing prose this turn is NOT posted. Replaces the old
5480
5566
  * overloaded use of `think` for staying silent.
5481
5567
  */
5482
- private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: unknown }): ToolResult {
5568
+ private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: unknown }, earlierWake?: SkipReplyEarlierWake): ToolResult {
5483
5569
  // The self-wake itself is armed by the framework (which owns the
5484
5570
  // EventGate) BEFORE its dispatch reaches this handler, from the same
5485
5571
  // reading, and the framework refuses a wake the host can't set before
5486
5572
  // dispatching. A caller of this registry's handleChannelToolCall
5487
5573
  // (AgentFramework.channels) goes past both, so its note names a wake
5488
- // nothing armed.
5574
+ // nothing armed. The framework, which also owns the heartbeat, says
5575
+ // which of its wakes is due before the self-wake (`earlierWake`).
5489
5576
  const wake = readSkipReplyWake(input);
5490
5577
  const selfWake = wake.kind === 'wake' ? wake.seconds : undefined;
5491
5578
  return {
@@ -5501,9 +5588,7 @@ export class ChannelRegistry {
5501
5588
  // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
5502
5589
  data: {
5503
5590
  skipped: true,
5504
- note: selfWake !== undefined
5505
- ? `Turn ended; nothing sent. Self-wake in ~${selfWake}s unless something wakes you first.`
5506
- : 'Turn ended; nothing sent.',
5591
+ note: skipReplyNote(selfWake, selfWake !== undefined ? earlierWake : undefined),
5507
5592
  },
5508
5593
  };
5509
5594
  }
@@ -6096,7 +6181,7 @@ export class ChannelRegistry {
6096
6181
  entry = this.entryAt(serverGiven ? { serverId: input.serverId!, channelId: fallback!.channelId } : fallback!);
6097
6182
  } else if (own && 'neither' in own) {
6098
6183
  if (seen) seen.refusal = 'shared-id';
6099
- return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted'), isError: true };
6184
+ return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted', false), isError: true };
6100
6185
  } else if (own) {
6101
6186
  entry = this.entryAt(own.channel);
6102
6187
  if (!entry) {
@@ -6127,7 +6212,7 @@ export class ChannelRegistry {
6127
6212
  // channel with its id isn't where its message came from.
6128
6213
  error: input.channelId === undefined
6129
6214
  ? serverGiven
6130
- ? `Server "${input.serverId}" has no channel "${fallback!.channelId}", the id of the channel this call would default to, so nothing was posted.`
6215
+ ? `Server "${input.serverId}" has no channel ${quotedValue(fallback!.channelId)}, the id of the channel this call would default to, so nothing was posted.`
6131
6216
  : `The channel this would default to, ${this.quotedShown(fallback!)}, isn't registered now, so nothing was posted.`
6132
6217
  // This tool's schema has no serverId, so one given here is a host's
6133
6218
  // ref (publishForAgent), or one an agent added unasked, and the
@@ -34,6 +34,7 @@
34
34
  * follow-up.
35
35
  */
36
36
 
37
+ import { NOTICE_TAG } from '../notice-tags.js';
37
38
  import { createHash } from 'node:crypto';
38
39
  import type { ContextStrategy } from '@animalabs/context-manager';
39
40
  import type { ChannelDescriptor } from './types.js';
@@ -144,7 +145,7 @@ export type RoutingDecision =
144
145
  const DEFAULT_IDLE_TTL_MS = 12 * 60 * 60 * 1000;
145
146
 
146
147
  export const DEFAULT_CLOSURE_PROMPT =
147
- '[system] This engagement is closing due to inactivity. Finalize your ' +
148
+ `[${NOTICE_TAG.system}] This engagement is closing due to inactivity. Finalize your ` +
148
149
  'work: post any promised results to the channel and record outstanding ' +
149
150
  'threads as unresolved. Do not ask follow-up questions.';
150
151
 
@@ -4,6 +4,7 @@
4
4
  * about a malformed block holds on all three.
5
5
  */
6
6
 
7
+ import { NOTICE_TAG } from '../notice-tags.js';
7
8
  import type { ContentBlock } from '@animalabs/membrane';
8
9
  import {
9
10
  AUDIO_NO_DATA_TEXT,
@@ -18,6 +19,7 @@ import {
18
19
  } from './references.js';
19
20
  import { NON_STRING_TEXT_PLACEHOLDER } from './visible-content.js';
20
21
  import type { McplContentBlock } from './types.js';
22
+ import { markNoticeOpenings } from '../supplied-text.js';
21
23
 
22
24
  /**
23
25
  * Convert a single MCPL wire-format content block to a membrane ContentBlock.
@@ -50,7 +52,7 @@ export function convertBlock(block: McplContentBlock, provenance: string, server
50
52
  // is a claim, one the reference rejects included, as classifyBlock
51
53
  // and isInlineContradiction read it: a falsy one such as 0, false
52
54
  // or '' stubs too, rather than sending the URI as the image's source.
53
- return { type: 'text', text: referenceStubOrNull(block, provenance, serverId) ?? '[reference]' };
55
+ return { type: 'text', text: referenceStubOrNull(block, provenance, serverId) ?? `[${NOTICE_TAG.reference}]` };
54
56
  }
55
57
  if (isFilledString(block.data) && isFilledString(block.mimeType)) {
56
58
  return {
@@ -76,7 +78,7 @@ export function convertBlock(block: McplContentBlock, provenance: string, server
76
78
  // RFC-005 uri-form media with a disposition claim, a falsy one
77
79
  // included, as for an image (above): stub, do not hand the URI to
78
80
  // the provider or inline it.
79
- return { type: 'text', text: referenceStubOrNull(block, provenance, serverId) ?? '[reference]' };
81
+ return { type: 'text', text: referenceStubOrNull(block, provenance, serverId) ?? `[${NOTICE_TAG.reference}]` };
80
82
  }
81
83
  if (isFilledString(block.data) && isFilledString(block.mimeType)) {
82
84
  return {
@@ -95,14 +97,14 @@ export function convertBlock(block: McplContentBlock, provenance: string, server
95
97
  }
96
98
  // RFC-005: reference blocks become bounded stubs — never raw URIs
97
99
  // (a signed URL is a bearer credential that looks like a location).
98
- return { type: 'text', text: referenceStubOrNull(block, provenance, serverId) ?? '[reference]' };
100
+ return { type: 'text', text: referenceStubOrNull(block, provenance, serverId) ?? `[${NOTICE_TAG.reference}]` };
99
101
  }
100
102
 
101
103
  default: {
102
104
  // MCP's link to a resource isn't one of MCPL's own blocks, but a server
103
105
  // can send one, and RFC-005 §8.1 makes it a reference, shown as one.
104
106
  if ((block as { type?: unknown }).type === 'resource_link') {
105
- return { type: 'text', text: referenceStubOrNull(block, provenance, serverId) ?? '[reference]' };
107
+ return { type: 'text', text: referenceStubOrNull(block, provenance, serverId) ?? `[${NOTICE_TAG.reference}]` };
106
108
  }
107
109
  // Unknown wire block types previously fell off the exhaustive switch
108
110
  // and propagated `undefined` into ContentBlock[]. Fail visibly. A type
@@ -113,6 +115,17 @@ export function convertBlock(block: McplContentBlock, provenance: string, server
113
115
  }
114
116
  }
115
117
 
118
+ export function convertBlocks(blocks: readonly McplContentBlock[], provenance: string, serverId: string): ContentBlock[] {
119
+ const converted = blocks.map((block) => convertBlock(block, provenance, serverId));
120
+ return markNoticeOpenings(converted, (index) => sendersText(blocks[index]));
121
+ }
122
+
123
+ function sendersText(block: McplContentBlock | undefined): boolean {
124
+ if (block?.type === 'text') return typeof block.text === 'string';
125
+ const c = classifyBlock(block);
126
+ return c.kind === 'embedded' && c.contents.kind === 'block' && c.contents.block.type === 'text';
127
+ }
128
+
116
129
  /** A string with something in it: the only data, mimeType or uri a block's
117
130
  * source is built from, and the only uri that makes it a reference. Anything
118
131
  * else a sender puts there leaves the block without it, so conversion never
@@ -14,7 +14,8 @@
14
14
  */
15
15
 
16
16
  import type { ContentBlock } from '@animalabs/membrane';
17
- import { convertBlock } from './convert-block.js';
17
+ import { convertBlocks } from './convert-block.js';
18
+ import { markedText } from '../supplied-text.js';
18
19
  import type { ContextInjection } from '@animalabs/context-manager';
19
20
 
20
21
  import type {
@@ -64,9 +65,9 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
64
65
  */
65
66
  function convertContent(content: string | McplContentBlock[], serverId: string): ContentBlock[] {
66
67
  if (typeof content === 'string') {
67
- return [{ type: 'text', text: content }];
68
+ return [{ type: 'text', text: markedText(content) }];
68
69
  }
69
- return content.map((block) => convertBlock(block, 'attachment on context injection', serverId));
70
+ return convertBlocks(content, 'attachment on context injection', serverId);
70
71
  }
71
72
 
72
73
  /**
@@ -217,7 +217,9 @@ function storedBlock(block: unknown): unknown {
217
217
  * The version identity of a delivered body (agreed with the receipts lane,
218
218
  * room-220 #46752–#47210): SHA-256 hex of `canonicalJson([blocks])` over the
219
219
  * ContentBlock[] the framework stores for the body — after MCPL conversion,
220
- * before any host decoration is added and before storage shards it — with
220
+ * which marks the server's text at the framework's notice openings
221
+ * (supplied-text.ts), before any host decoration is added and before storage
222
+ * shards it — with
221
223
  * each block as the store keeps it, which context-manager's `blockAsStored`
222
224
  * gives. So a body hashes alike as delivered, as handed to storage, and as
223
225
  * read back unsharded from the store with its blobs resolved, except one