@animalabs/agent-framework 0.27.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 (344) hide show
  1. package/README.md +145 -41
  2. package/dist/src/agent.d.ts +29 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +73 -12
  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 +3 -12
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/api/server.d.ts.map +1 -1
  10. package/dist/src/api/server.js +11 -5
  11. package/dist/src/api/server.js.map +1 -1
  12. package/dist/src/auto-continue.d.ts +5 -2
  13. package/dist/src/auto-continue.d.ts.map +1 -1
  14. package/dist/src/auto-continue.js +9 -5
  15. package/dist/src/auto-continue.js.map +1 -1
  16. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  17. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/runner.js +7 -5
  19. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +3 -2
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/cut-off-round.d.ts +24 -0
  24. package/dist/src/cut-off-round.d.ts.map +1 -0
  25. package/dist/src/cut-off-round.js +87 -0
  26. package/dist/src/cut-off-round.js.map +1 -0
  27. package/dist/src/framework.d.ts +468 -125
  28. package/dist/src/framework.d.ts.map +1 -1
  29. package/dist/src/framework.js +3651 -1111
  30. package/dist/src/framework.js.map +1 -1
  31. package/dist/src/gate/event-gate.d.ts +133 -13
  32. package/dist/src/gate/event-gate.d.ts.map +1 -1
  33. package/dist/src/gate/event-gate.js +654 -59
  34. package/dist/src/gate/event-gate.js.map +1 -1
  35. package/dist/src/gate/index.d.ts +3 -2
  36. package/dist/src/gate/index.d.ts.map +1 -1
  37. package/dist/src/gate/index.js +2 -1
  38. package/dist/src/gate/index.js.map +1 -1
  39. package/dist/src/gate/reply-judge.d.ts +116 -0
  40. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  41. package/dist/src/gate/reply-judge.js +467 -0
  42. package/dist/src/gate/reply-judge.js.map +1 -0
  43. package/dist/src/gate/types.d.ts +173 -2
  44. package/dist/src/gate/types.d.ts.map +1 -1
  45. package/dist/src/heartbeat/heartbeat.d.ts +202 -0
  46. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  47. package/dist/src/heartbeat/heartbeat.js +778 -0
  48. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  49. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  50. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  51. package/dist/src/heartbeat/reminder-time.js +107 -0
  52. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  53. package/dist/src/held-writes.d.ts +3 -3
  54. package/dist/src/held-writes.js +3 -3
  55. package/dist/src/host-presentation.d.ts +170 -0
  56. package/dist/src/host-presentation.d.ts.map +1 -0
  57. package/dist/src/host-presentation.js +561 -0
  58. package/dist/src/host-presentation.js.map +1 -0
  59. package/dist/src/index.d.ts +5 -1
  60. package/dist/src/index.d.ts.map +1 -1
  61. package/dist/src/index.js +3 -1
  62. package/dist/src/index.js.map +1 -1
  63. package/dist/src/line-reader.d.ts +4 -0
  64. package/dist/src/line-reader.d.ts.map +1 -0
  65. package/dist/src/line-reader.js +59 -0
  66. package/dist/src/line-reader.js.map +1 -0
  67. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  68. package/dist/src/mcpl/capability-mask.js +3 -2
  69. package/dist/src/mcpl/capability-mask.js.map +1 -1
  70. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  71. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  72. package/dist/src/mcpl/channel-registry.js +133 -54
  73. package/dist/src/mcpl/channel-registry.js.map +1 -1
  74. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  75. package/dist/src/mcpl/conversation-router.js +2 -1
  76. package/dist/src/mcpl/conversation-router.js.map +1 -1
  77. package/dist/src/mcpl/convert-block.d.ts +1 -0
  78. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  79. package/dist/src/mcpl/convert-block.js +16 -4
  80. package/dist/src/mcpl/convert-block.js.map +1 -1
  81. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  82. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  83. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  84. package/dist/src/mcpl/inbound-source.d.ts +29 -11
  85. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  86. package/dist/src/mcpl/inbound-source.js +43 -70
  87. package/dist/src/mcpl/inbound-source.js.map +1 -1
  88. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  89. package/dist/src/mcpl/modern-connection.js +11 -7
  90. package/dist/src/mcpl/modern-connection.js.map +1 -1
  91. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  92. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  93. package/dist/src/mcpl/protocol-family.js +25 -3
  94. package/dist/src/mcpl/protocol-family.js.map +1 -1
  95. package/dist/src/mcpl/push-handler.js +2 -2
  96. package/dist/src/mcpl/push-handler.js.map +1 -1
  97. package/dist/src/mcpl/references.d.ts +12 -40
  98. package/dist/src/mcpl/references.d.ts.map +1 -1
  99. package/dist/src/mcpl/references.js +29 -23
  100. package/dist/src/mcpl/references.js.map +1 -1
  101. package/dist/src/mcpl/server-connection.d.ts +18 -8
  102. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  103. package/dist/src/mcpl/server-connection.js +70 -30
  104. package/dist/src/mcpl/server-connection.js.map +1 -1
  105. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  106. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  107. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  108. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  109. package/dist/src/mcpl/tags.d.ts +9 -2
  110. package/dist/src/mcpl/tags.d.ts.map +1 -1
  111. package/dist/src/mcpl/tags.js +12 -2
  112. package/dist/src/mcpl/tags.js.map +1 -1
  113. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  114. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  115. package/dist/src/mcpl/tool-presence.js +150 -80
  116. package/dist/src/mcpl/tool-presence.js.map +1 -1
  117. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  118. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  119. package/dist/src/mcpl/tool-signature.js +60 -30
  120. package/dist/src/mcpl/tool-signature.js.map +1 -1
  121. package/dist/src/mcpl/toolset-host.d.ts +88 -55
  122. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  123. package/dist/src/mcpl/toolset-host.js +220 -111
  124. package/dist/src/mcpl/toolset-host.js.map +1 -1
  125. package/dist/src/mcpl/toolsets-module.d.ts +27 -35
  126. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  127. package/dist/src/mcpl/toolsets-module.js +313 -91
  128. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  129. package/dist/src/mcpl/transport.d.ts +1 -1
  130. package/dist/src/mcpl/transport.d.ts.map +1 -1
  131. package/dist/src/mcpl/transport.js +16 -16
  132. package/dist/src/mcpl/transport.js.map +1 -1
  133. package/dist/src/mcpl/types.d.ts +31 -13
  134. package/dist/src/mcpl/types.d.ts.map +1 -1
  135. package/dist/src/mcpl/types.js.map +1 -1
  136. package/dist/src/module-registry.d.ts +10 -8
  137. package/dist/src/module-registry.d.ts.map +1 -1
  138. package/dist/src/module-registry.js +15 -9
  139. package/dist/src/module-registry.js.map +1 -1
  140. package/dist/src/modules/api/index.d.ts.map +1 -1
  141. package/dist/src/modules/api/index.js +4 -2
  142. package/dist/src/modules/api/index.js.map +1 -1
  143. package/dist/src/modules/discord/index.d.ts.map +1 -1
  144. package/dist/src/modules/discord/index.js +33 -25
  145. package/dist/src/modules/discord/index.js.map +1 -1
  146. package/dist/src/modules/history/index.d.ts.map +1 -1
  147. package/dist/src/modules/history/index.js +30 -17
  148. package/dist/src/modules/history/index.js.map +1 -1
  149. package/dist/src/modules/sender-name.d.ts +7 -0
  150. package/dist/src/modules/sender-name.d.ts.map +1 -0
  151. package/dist/src/modules/sender-name.js +10 -0
  152. package/dist/src/modules/sender-name.js.map +1 -0
  153. package/dist/src/modules/workspace/answers.d.ts +26 -0
  154. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  155. package/dist/src/modules/workspace/answers.js +138 -0
  156. package/dist/src/modules/workspace/answers.js.map +1 -0
  157. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  158. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  159. package/dist/src/modules/workspace/branch-intent.js +6 -2
  160. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  161. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  162. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  163. package/dist/src/modules/workspace/direct-write.js +59 -24
  164. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  165. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  166. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  168. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  169. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  170. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  171. package/dist/src/modules/workspace/edit-text.js +4 -2
  172. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  173. package/dist/src/modules/workspace/effects.d.ts +4 -2
  174. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/effects.js +67 -50
  176. package/dist/src/modules/workspace/effects.js.map +1 -1
  177. package/dist/src/modules/workspace/index.d.ts +63 -51
  178. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/index.js +980 -547
  180. package/dist/src/modules/workspace/index.js.map +1 -1
  181. package/dist/src/modules/workspace/observe.d.ts +125 -82
  182. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  183. package/dist/src/modules/workspace/observe.js +465 -241
  184. package/dist/src/modules/workspace/observe.js.map +1 -1
  185. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/read-page.js +7 -1
  187. package/dist/src/modules/workspace/read-page.js.map +1 -1
  188. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  189. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/reconcile.js +156 -50
  191. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  192. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  193. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/result-text.js.map +1 -1
  195. package/dist/src/modules/workspace/sync.d.ts +9 -0
  196. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/sync.js +14 -0
  198. package/dist/src/modules/workspace/sync.js.map +1 -1
  199. package/dist/src/modules/workspace/types.d.ts +96 -43
  200. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  201. package/dist/src/modules/workspace/types.js.map +1 -1
  202. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  203. package/dist/src/modules/workspace/watcher.js +8 -3
  204. package/dist/src/modules/workspace/watcher.js.map +1 -1
  205. package/dist/src/notice-tags.d.ts +79 -0
  206. package/dist/src/notice-tags.d.ts.map +1 -0
  207. package/dist/src/notice-tags.js +83 -0
  208. package/dist/src/notice-tags.js.map +1 -0
  209. package/dist/src/prose-drafts.d.ts +4 -3
  210. package/dist/src/prose-drafts.d.ts.map +1 -1
  211. package/dist/src/prose-drafts.js +3 -2
  212. package/dist/src/prose-drafts.js.map +1 -1
  213. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  214. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  215. package/dist/src/reasoning-loss-notice.js +52 -0
  216. package/dist/src/reasoning-loss-notice.js.map +1 -0
  217. package/dist/src/reasoning-loss.d.ts +24 -0
  218. package/dist/src/reasoning-loss.d.ts.map +1 -0
  219. package/dist/src/reasoning-loss.js +102 -0
  220. package/dist/src/reasoning-loss.js.map +1 -0
  221. package/dist/src/send-tools.d.ts +3 -2
  222. package/dist/src/send-tools.d.ts.map +1 -1
  223. package/dist/src/send-tools.js +3 -2
  224. package/dist/src/send-tools.js.map +1 -1
  225. package/dist/src/sent-rows.js +1 -1
  226. package/dist/src/silent-heartbeat.d.ts +1 -53
  227. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  228. package/dist/src/silent-heartbeat.js +56 -3
  229. package/dist/src/silent-heartbeat.js.map +1 -1
  230. package/dist/src/supplied-text.d.ts +35 -0
  231. package/dist/src/supplied-text.d.ts.map +1 -0
  232. package/dist/src/supplied-text.js +141 -0
  233. package/dist/src/supplied-text.js.map +1 -0
  234. package/dist/src/thinking-drops.d.ts +4 -11
  235. package/dist/src/thinking-drops.d.ts.map +1 -1
  236. package/dist/src/thinking-drops.js +7 -14
  237. package/dist/src/thinking-drops.js.map +1 -1
  238. package/dist/src/tool-args.d.ts +12 -0
  239. package/dist/src/tool-args.d.ts.map +1 -1
  240. package/dist/src/tool-args.js +39 -4
  241. package/dist/src/tool-args.js.map +1 -1
  242. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  243. package/dist/src/tool-image-ledger.js +2 -1
  244. package/dist/src/tool-image-ledger.js.map +1 -1
  245. package/dist/src/tool-presentation.d.ts.map +1 -1
  246. package/dist/src/tool-presentation.js +5 -4
  247. package/dist/src/tool-presentation.js.map +1 -1
  248. package/dist/src/tool-result-guard.d.ts +8 -4
  249. package/dist/src/tool-result-guard.d.ts.map +1 -1
  250. package/dist/src/tool-result-guard.js +23 -11
  251. package/dist/src/tool-result-guard.js.map +1 -1
  252. package/dist/src/tune-out/coordinator.d.ts +3 -2
  253. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  254. package/dist/src/tune-out/coordinator.js +24 -26
  255. package/dist/src/tune-out/coordinator.js.map +1 -1
  256. package/dist/src/tune-out/tools.d.ts +4 -1
  257. package/dist/src/tune-out/tools.d.ts.map +1 -1
  258. package/dist/src/types/agent.d.ts +63 -18
  259. package/dist/src/types/agent.d.ts.map +1 -1
  260. package/dist/src/types/events.d.ts +18 -8
  261. package/dist/src/types/events.d.ts.map +1 -1
  262. package/dist/src/types/framework.d.ts +58 -0
  263. package/dist/src/types/framework.d.ts.map +1 -1
  264. package/dist/src/types/module.d.ts +27 -13
  265. package/dist/src/types/module.d.ts.map +1 -1
  266. package/dist/src/types/trace.d.ts +34 -1
  267. package/dist/src/types/trace.d.ts.map +1 -1
  268. package/package.json +3 -3
  269. package/src/agent.ts +76 -12
  270. package/src/api/mcp-server-core.ts +3 -14
  271. package/src/api/server.ts +11 -5
  272. package/src/auto-continue.ts +12 -5
  273. package/src/code-execution/isolate/runner.ts +7 -5
  274. package/src/code-execution/isolate/scripts.ts +3 -2
  275. package/src/cut-off-round.ts +98 -0
  276. package/src/framework.ts +3772 -1138
  277. package/src/gate/event-gate.ts +705 -60
  278. package/src/gate/index.ts +9 -1
  279. package/src/gate/reply-judge.ts +512 -0
  280. package/src/gate/types.ts +176 -3
  281. package/src/heartbeat/heartbeat.ts +843 -0
  282. package/src/heartbeat/reminder-time.ts +107 -0
  283. package/src/held-writes.ts +3 -3
  284. package/src/host-presentation.ts +605 -0
  285. package/src/index.ts +5 -1
  286. package/src/line-reader.ts +58 -0
  287. package/src/mcpl/capability-mask.ts +3 -2
  288. package/src/mcpl/channel-registry.ts +139 -53
  289. package/src/mcpl/conversation-router.ts +2 -1
  290. package/src/mcpl/convert-block.ts +17 -4
  291. package/src/mcpl/hook-orchestrator.ts +4 -3
  292. package/src/mcpl/inbound-source.ts +45 -70
  293. package/src/mcpl/modern-connection.ts +11 -7
  294. package/src/mcpl/protocol-family.ts +24 -3
  295. package/src/mcpl/push-handler.ts +2 -2
  296. package/src/mcpl/references.ts +31 -23
  297. package/src/mcpl/server-connection.ts +76 -34
  298. package/src/mcpl/skip-reply-wake.ts +35 -0
  299. package/src/mcpl/tags.ts +13 -2
  300. package/src/mcpl/tool-presence.ts +149 -82
  301. package/src/mcpl/tool-signature.ts +64 -32
  302. package/src/mcpl/toolset-host.ts +212 -110
  303. package/src/mcpl/toolsets-module.ts +277 -91
  304. package/src/mcpl/transport.ts +18 -14
  305. package/src/mcpl/types.ts +32 -13
  306. package/src/module-registry.ts +14 -9
  307. package/src/modules/api/index.ts +4 -2
  308. package/src/modules/discord/index.ts +37 -25
  309. package/src/modules/history/index.ts +30 -18
  310. package/src/modules/sender-name.ts +9 -0
  311. package/src/modules/workspace/answers.ts +151 -0
  312. package/src/modules/workspace/branch-intent.ts +10 -5
  313. package/src/modules/workspace/direct-write.ts +59 -23
  314. package/src/modules/workspace/disk-agreement.ts +52 -21
  315. package/src/modules/workspace/edit-text.ts +7 -5
  316. package/src/modules/workspace/effects.ts +69 -50
  317. package/src/modules/workspace/index.ts +966 -556
  318. package/src/modules/workspace/observe.ts +497 -242
  319. package/src/modules/workspace/read-page.ts +7 -1
  320. package/src/modules/workspace/reconcile.ts +184 -49
  321. package/src/modules/workspace/result-text.ts +2 -0
  322. package/src/modules/workspace/sync.ts +16 -0
  323. package/src/modules/workspace/types.ts +96 -43
  324. package/src/modules/workspace/watcher.ts +8 -3
  325. package/src/notice-tags.ts +84 -0
  326. package/src/prose-drafts.ts +4 -3
  327. package/src/reasoning-loss-notice.ts +64 -0
  328. package/src/reasoning-loss.ts +120 -0
  329. package/src/send-tools.ts +3 -2
  330. package/src/sent-rows.ts +1 -1
  331. package/src/silent-heartbeat.ts +4 -3
  332. package/src/supplied-text.ts +154 -0
  333. package/src/thinking-drops.ts +10 -16
  334. package/src/tool-args.ts +38 -4
  335. package/src/tool-image-ledger.ts +2 -1
  336. package/src/tool-presentation.ts +5 -4
  337. package/src/tool-result-guard.ts +26 -11
  338. package/src/tune-out/coordinator.ts +25 -27
  339. package/src/tune-out/tools.ts +4 -1
  340. package/src/types/agent.ts +63 -18
  341. package/src/types/events.ts +18 -8
  342. package/src/types/framework.ts +59 -0
  343. package/src/types/module.ts +28 -13
  344. package/src/types/trace.ts +36 -1
@@ -19,9 +19,10 @@
19
19
  */
20
20
 
21
21
  import { spawn, type ChildProcess } from 'node:child_process';
22
- import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
23
22
  import { EventEmitter } from 'node:events';
23
+ import type { Readable } from 'node:stream';
24
24
  import { StringDecoder } from 'node:string_decoder';
25
+ import { escapeLineSeparators, readLines } from '../line-reader.js';
25
26
  import WebSocket from 'ws';
26
27
 
27
28
  import type { McplServerConfig } from './types.js';
@@ -325,16 +326,18 @@ export class StdioTransport extends McplTransport {
325
326
  });
326
327
  }
327
328
 
329
+ private readonly stopLines: () => void;
330
+
328
331
  private constructor(
329
332
  private readonly child: ChildProcess,
330
- private readonly rl: ReadlineInterface,
333
+ stdout: Readable,
331
334
  /** The server this child serves, named in its stderr lines and in what
332
335
  * its reaping reports. */
333
336
  serverId: string,
334
337
  ) {
335
338
  super(serverId);
336
339
 
337
- this.rl.on('line', (line: string) => this.pushLine(line));
340
+ this.stopLines = readLines(stdout, (line) => this.pushLine(line));
338
341
 
339
342
  // Stitch stderr chunks into whole lines (a chunk can split a line, and a
340
343
  // character's bytes), and keep the stream's last characters for
@@ -383,12 +386,7 @@ export class StdioTransport extends McplTransport {
383
386
  });
384
387
  };
385
388
  this.child.stdin?.on('error', endOnPipeError);
386
- // On the stream itself, since close() closes readline, which takes its own
387
- // listener off the stream. While readline is open, its listener, which
388
- // came first, re-emits the error on the interface, where an empty
389
- // listener absorbs that copy.
390
389
  this.child.stdout?.on('error', endOnPipeError);
391
- this.rl.on('error', () => { /* readline's copy of a stdout error, handled above */ });
392
390
  // stderr carries diagnostics only: a read error on it is reported, to the
393
391
  // listeners or else in the log, and never thrown. It ends nothing once
394
392
  // the connection is wired; during a handshake the listener is the
@@ -453,8 +451,7 @@ export class StdioTransport extends McplTransport {
453
451
  resolve();
454
452
  });
455
453
  });
456
- const rl = createInterface({ input: child.stdout! });
457
- return new StdioTransport(child, rl, config.id);
454
+ return new StdioTransport(child, child.stdout!, config.id);
458
455
  }
459
456
 
460
457
  writeLine(json: string): void {
@@ -462,7 +459,7 @@ export class StdioTransport extends McplTransport {
462
459
  // transport is ending it.
463
460
  if (this.pipeError) return;
464
461
  // stdin may be gone if the child died between the closed-check and here.
465
- this.child.stdin?.write(json + '\n');
462
+ this.child.stdin?.write(escapeLineSeparators(json) + '\n');
466
463
  }
467
464
 
468
465
  private closing: Promise<void> | null = null;
@@ -492,7 +489,7 @@ export class StdioTransport extends McplTransport {
492
489
 
493
490
  private async reap(): Promise<void> {
494
491
  this.markClosed({ reason: 'closed by host' });
495
- this.rl.close();
492
+ this.stopLines();
496
493
  await this.terminate();
497
494
  }
498
495
 
@@ -696,7 +693,7 @@ export class WebSocketTransport extends McplTransport {
696
693
 
697
694
  writeLine(json: string): void {
698
695
  if (this.ws.readyState === WebSocket.OPEN) {
699
- this.ws.send(json);
696
+ this.ws.send(escapeLineSeparators(json));
700
697
  }
701
698
  }
702
699
 
@@ -830,7 +827,7 @@ const SERVER_CONFIG_BOOLEANS: Record<BooleanSetting, string> = {
830
827
  inheritEnv: 'true passes the server the host\'s whole environment, and false or leaving it out passes only the allowlisted and LC_* host variables',
831
828
  reconnect: 'true retries in the background when the server can\'t be reached or its connection drops, and false or leaving it out doesn\'t',
832
829
  allowHostCommands: 'true lets the server run the host\'s admin commands, such as undo, hide, unstick and quiesce, and false or leaving it out refuses them',
833
- toolHints: 'true advertises MCPL tool hints (RFC-014), so the server\'s dynamic tools are announced in the conversation and called through toolsets--act, and false or leaving it out lists every tool as before',
830
+ toolHints: 'true advertises MCPL tool hints (RFC-014) to an MCPL server, so its dynamic tools are announced in the conversation and called through toolsets--act; leaving it out does the same wherever the name toolsets is free; false lists every tool as before and advertises none',
834
831
  };
835
832
 
836
833
  /**
@@ -849,6 +846,13 @@ export function assertServerConfigBooleans(config: McplServerConfig): void {
849
846
  if (setting === 'inheritEnv' && dialsNetwork(config)) continue;
850
847
  assertBooleanSetting(`MCPL server "${config.id}" ${setting}`, config[setting], whatTrueDoes);
851
848
  }
849
+ // Not a boolean, but read the same way: a value that isn't one of the
850
+ // three would otherwise be taken for the default.
851
+ const presentation = (config as { presentation?: unknown }).presentation;
852
+ if (presentation !== undefined && presentation !== null && presentation !== 'full' && presentation !== 'tab' && presentation !== 'closed-tab') {
853
+ const kind = typeof presentation === 'string' ? 'another string' : Array.isArray(presentation) ? 'a list' : typeof presentation;
854
+ throw new Error(`MCPL server "${config.id}" presentation must be "full", "tab" or "closed-tab" (or left out for full), not ${kind}`);
855
+ }
852
856
  }
853
857
 
854
858
  /**
package/src/mcpl/types.ts CHANGED
@@ -193,8 +193,8 @@ export interface McplHostCapabilities {
193
193
  modelInfo?: boolean;
194
194
  featureSets?: boolean;
195
195
  channels?: boolean | McplChannelCapabilities;
196
- /** RFC-014 §6.3: this host presents tool hints. Sent only to a server
197
- * whose config sets `toolHints: true`. */
196
+ /** RFC-014 §6.3: this host presents tool hints. Sent to every server
197
+ * with hints (McplServerConfig.toolHints: on unless false). */
198
198
  toolHints?: boolean;
199
199
  }
200
200
 
@@ -384,7 +384,9 @@ export interface McplServerConfig {
384
384
  *
385
385
  * `disabledCapabilities` takes precedence on conflict. `version` and
386
386
  * `featureSets` are not maskable here — feature sets have their own
387
- * enable/disable knobs above.
387
+ * enable/disable knobs above — and neither are RFC-014's `toolHints`,
388
+ * `toolsets` and `surface`, which a server's own `toolHints` config turns
389
+ * off.
388
390
  */
389
391
  enabledCapabilities?: string[];
390
392
 
@@ -506,18 +508,35 @@ export interface McplServerConfig {
506
508
  toolLifecycle?: import('./tool-lifecycle.js').ToolLifecycleConfig;
507
509
 
508
510
  /**
509
- * MCPL RFC-014 (tool presence, toolsets and surfaces). Default false: the
510
- * host advertises nothing and treats every tool as static, byte for byte as
511
- * before. When true, the host advertises `experimental.mcpl.toolHints`, and
512
- * honours hints only from a server that advertised `toolHints` too. Then
513
- * the server's static tools stay in the tool list as before, and its
514
- * dynamic tools (toolset tools, `mcpl/presence: "dynamic"`) leave it: they
515
- * are announced in the conversation as they become visible and invisible,
516
- * and called through the `toolsets` module's `toolsets--act`, opened with
517
- * `toolsets--open`, inspected with `toolsets--look` and closed with
518
- * `toolsets--close`. Only `true` turns it on, and null counts as not set.
511
+ * MCPL RFC-014 (tool presence, toolsets and surfaces). On unless set to
512
+ * `false`: the host advertises `experimental.mcpl.toolHints` and honours
513
+ * hints (through the `toolsets` meta-tools) only from a server that
514
+ * advertised `toolHints` too. Such a server's static tools stay in the tool
515
+ * list as before, and its dynamic tools (toolset tools, `mcpl/presence:
516
+ * "dynamic"`) leave it: they are announced in the conversation as they
517
+ * become visible and invisible, and called through `toolsets--act`, opened
518
+ * with `toolsets--open`, inspected with `toolsets--look` and closed with
519
+ * `toolsets--close`. A server that doesn't advertise `toolHints` lists every
520
+ * tool as before. `false` advertises nothing and treats every tool as
521
+ * static. Omitted and null are on unless a module given to `create()`
522
+ * holds the name `toolsets` (or a restart is moving a server to that
523
+ * prefix, which is refused); `true` with such a module is refused.
519
524
  */
520
525
  toolHints?: boolean;
526
+
527
+ /**
528
+ * How this server is presented to agents at first (the host's own
529
+ * toolsets, src/host-presentation.ts): `'full'` (the default) puts its
530
+ * tools in the tool list, as before; `'tab'` keeps it connected, its events
531
+ * still arriving, with none of its tools in the tool list until an agent
532
+ * opens its tab (`toolsets--open {server}`) and calls them through
533
+ * `toolsets--act`; `'closed-tab'` leaves it disconnected until an agent
534
+ * opens it. Each agent can switch it later (`toolsets--close {server}`,
535
+ * with `disconnect: true` for a closed tab; `toolsets--open {server,
536
+ * full: true}` back into the tool list), and that choice is kept across
537
+ * restarts beside the store, over this default.
538
+ */
539
+ presentation?: 'full' | 'tab' | 'closed-tab';
521
540
  }
522
541
 
523
542
  // ============================================================================
@@ -28,7 +28,7 @@ import { heldWriteId } from './held-writes.js';
28
28
  import type { Agent } from './agent.js';
29
29
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from './shutdown.js';
30
30
  import { logValue } from './mcpl/log-value.js';
31
- import { toolArgsProblems } from './tool-args.js';
31
+ import { asCalled, markedInputRefusal, toolArgsProblems } from './tool-args.js';
32
32
 
33
33
  /** How the registry queues a module's event: its push names the module. A
34
34
  * `ProcessQueue` serves too, ignoring the name. */
@@ -282,8 +282,8 @@ export class ModuleRegistry {
282
282
  /**
283
283
  * All utilities from all modules, namespaced exactly like tools
284
284
  * (`module--name`). See Module.getUtilities — these are deliberately kept
285
- * out of the model-facing tool list and surfaced through the framework's
286
- * `utils` meta-tool; dispatch goes through the same handleToolCall.
285
+ * out of the model-facing tool list and presented in the host's built-in
286
+ * toolset; dispatch goes through the same handleToolCall.
287
287
  */
288
288
  getAllUtilities(): ToolDefinition[] {
289
289
  const utilities: ToolDefinition[] = [];
@@ -300,8 +300,8 @@ export class ModuleRegistry {
300
300
 
301
301
  /**
302
302
  * A module tool's call for a path that stores or sends its result: the
303
- * model's dispatch, a script's inner call, the `utils` meta-tool for a
304
- * model. The result is checked and read once, in its JSON form
303
+ * model's dispatch (a `toolsets--act` call of it included), a script's
304
+ * inner call. The result is checked and read once, in its JSON form
305
305
  * ({@link storableToolResult}).
306
306
  */
307
307
  async storableToolCall(call: ToolCall, options: { definition?: ToolDefinition } = {}): Promise<ToolResult> {
@@ -317,11 +317,16 @@ export class ModuleRegistry {
317
317
  * A call whose input isn't an object, or lacks a key the tool's schema
318
318
  * requires, is answered before the module runs, with an error result that
319
319
  * opens "Nothing was done:" and names what to send (toolArgsProblems).
320
- * A caller that has already resolved the tool's definition, as the `utils`
321
- * tool has a utility's, passes it as `definition`, and it's checked against
322
- * that one.
320
+ * So is a call membrane marked `unparseableInput`, unless its text is JSON
321
+ * `null`, which reaches the module as a call with no arguments
322
+ * (markedInputRefusal).
323
+ * A caller that has already resolved the tool's definition passes it as
324
+ * `definition`, and it's checked against that one.
323
325
  */
324
- async handleToolCall(call: ToolCall, options: { definition?: ToolDefinition } = {}): Promise<ToolResult> {
326
+ async handleToolCall(given: ToolCall, options: { definition?: ToolDefinition } = {}): Promise<ToolResult> {
327
+ const marked = markedInputRefusal(given);
328
+ if (marked !== undefined) return { success: false, error: marked, isError: true };
329
+ const call = asCalled(given);
325
330
  // Parse module--tool name
326
331
  const sepIndex = call.name.indexOf('--');
327
332
  if (sepIndex === -1) {
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { unknownToolMessage } from '../unknown-tool.js';
9
+ import { outsideSenderName } from '../sender-name.js';
9
10
  import type {
10
11
  Module,
11
12
  ModuleContext,
@@ -62,12 +63,13 @@ export class ApiModule implements Module {
62
63
  }
63
64
 
64
65
  private handleMessage(event: ApiMessageEvent): EventResponse {
66
+ const carried = event.metadata?.author;
65
67
  const response: EventResponse = {
66
68
  addMessages: [
67
69
  {
68
- participant: event.participant,
70
+ participant: outsideSenderName(event.participant, 'API'),
69
71
  content: [{ type: 'text', text: event.content }],
70
- metadata: event.metadata,
72
+ metadata: { ...event.metadata, author: typeof carried === 'object' && carried !== null ? carried : { name: event.participant } },
71
73
  },
72
74
  ],
73
75
  };
@@ -7,6 +7,8 @@
7
7
  * - Tools for explicit Discord operations (send, DM, react, etc.)
8
8
  */
9
9
 
10
+ import { NOTICE_TAG } from '../../notice-tags.js';
11
+ import { markedText, suppliedValue } from '../../supplied-text.js';
10
12
  import type { ContentBlock } from '@animalabs/membrane';
11
13
  import { continuesBody, type StoredMessage } from '@animalabs/context-manager';
12
14
  import type {
@@ -47,6 +49,7 @@ import type {
47
49
  import { formatZonedDateTime, resolveTimeZone } from '../../timezone.js';
48
50
  import { logValue } from '../../mcpl/log-value.js';
49
51
  import { unknownToolMessage } from '../unknown-tool.js';
52
+ import { outsideSenderName } from '../sender-name.js';
50
53
  import { DiscordRefusal } from './refusal.js';
51
54
 
52
55
  // Re-export event types for consumers who want to handle them
@@ -119,10 +122,11 @@ const NOT_RUNNING = "Speech handling wasn't changed: the Discord module hasn't s
119
122
  /** What the agent reads for a text attachment whose contents couldn't be
120
123
  * fetched: by an error, or by an answer that wasn't OK. */
121
124
  function attachmentNotFetched(filename: string): { type: 'text'; text: string } {
122
- return {
123
- type: 'text',
124
- text: `[Attachment: ${filename} couldn't be fetched, so its text isn't in your context. If you need it, ask the sender in the channel to paste it.]`,
125
- };
125
+ return { type: 'text', text: notFetchedText(suppliedValue(filename)) };
126
+ }
127
+
128
+ function notFetchedText(filename: string): string {
129
+ return `[${NOTICE_TAG.attachment}: ${filename} couldn't be fetched, so its text isn't in your context. If you need it, ask the sender in the channel to paste it.]`;
126
130
  }
127
131
 
128
132
  /**
@@ -165,7 +169,7 @@ function seconds(n: number): string {
165
169
 
166
170
  /** What a stored message reads when it has no text of its own and nothing the
167
171
  * host added, so the agent still sees that a message was there. */
168
- const EMPTY_MESSAGE = '[Empty message]';
172
+ const EMPTY_MESSAGE = `[${NOTICE_TAG.emptyMessage}]`;
169
173
 
170
174
  /**
171
175
  * Metadata on a stored Discord message whose first block is its own text,
@@ -184,6 +188,14 @@ function ownText(content: string): string {
184
188
  return content.trim() ? content : '';
185
189
  }
186
190
 
191
+ function storedOwnText(content: string): string {
192
+ return markedText(ownText(content));
193
+ }
194
+
195
+ function holdsOwnText(text: string, own: string): boolean {
196
+ return text === own || text === markedText(own);
197
+ }
198
+
187
199
  const isEmptyMarker = (block: ContentBlock): boolean => block.type === 'text' && block.text === EMPTY_MESSAGE;
188
200
  const isBlankText = (block: ContentBlock): boolean => block.type === 'text' && !block.text.trim();
189
201
 
@@ -198,7 +210,7 @@ function textFirst(text: string, after: ContentBlock[] = []): ContentBlock[] {
198
210
  /** A text-first row's content with `text` as the message's own text, in
199
211
  * place of its first block, keeping what the host added after it. */
200
212
  function withOwnText(content: ContentBlock[], text: string): ContentBlock[] {
201
- return textFirst(ownText(text), content.slice(1).filter((block) => !isEmptyMarker(block)));
213
+ return textFirst(storedOwnText(text), content.slice(1).filter((block) => !isEmptyMarker(block)));
202
214
  }
203
215
 
204
216
  /**
@@ -221,11 +233,11 @@ function contentForText(stored: StoredMessage, discordText: string): ContentBloc
221
233
  const text = ownText(discordText);
222
234
  const [first, ...rest] = stored.content;
223
235
  if (stored.metadata?.[TEXT_FIRST] === true) {
224
- if (first?.type === 'text' && first.text === text) return null;
236
+ if (first?.type === 'text' && holdsOwnText(first.text, text)) return null;
225
237
  return withOwnText(stored.content, text);
226
238
  }
227
- if (!text || (first?.type === 'text' && first.text === discordText)) return null;
228
- return [{ type: 'text', text: discordText }, ...(first?.type === 'text' ? rest : stored.content).filter((block) => !isEmptyMarker(block))];
239
+ if (!text || (first?.type === 'text' && holdsOwnText(first.text, discordText))) return null;
240
+ return [{ type: 'text', text: markedText(discordText) }, ...(first?.type === 'text' ? rest : stored.content).filter((block) => !isEmptyMarker(block))];
229
241
  }
230
242
 
231
243
  /**
@@ -255,7 +267,7 @@ function needsRebuild(stored: StoredMessage, message: HistoryMessage): boolean {
255
267
  const [first, ...rest] = stored.content;
256
268
  if (!own && first?.type === 'text' && !isBlankText(first) && !isEmptyMarker(first)) return true;
257
269
  const expected = (own ? 1 : 0) + attachments;
258
- const counted = own && first?.type === 'text' && first.text === own ? 1 + held(rest) : held(stored.content);
270
+ const counted = own && first?.type === 'text' && holdsOwnText(first.text, own) ? 1 + held(rest) : held(stored.content);
259
271
  return counted !== expected || (expected === 0 && !stored.content.some(isEmptyMarker));
260
272
  }
261
273
 
@@ -268,14 +280,14 @@ function needsRebuild(stored: StoredMessage, message: HistoryMessage): boolean {
268
280
  function holdsOtherText(stored: StoredMessage, message: HistoryMessage): boolean {
269
281
  const own = ownText(message.content);
270
282
  const [first, ...rest] = stored.content;
271
- const others = own && first?.type === 'text' && first.text === own ? rest : stored.content;
283
+ const others = own && first?.type === 'text' && holdsOwnText(first.text, own) ? rest : stored.content;
272
284
  return others.some((block) => block.type === 'text' && !isBlankText(block) && !isEmptyMarker(block));
273
285
  }
274
286
 
275
287
  /** Whether a message's conversion holds, after the message's own text, the
276
288
  * note for an attachment of its whose text wasn't fetched. */
277
289
  function holdsNotFetched(content: ContentBlock[], message: HistoryMessage): boolean {
278
- const notes = new Set((message.attachments ?? []).map((attachment) => attachmentNotFetched(attachment.filename).text));
290
+ const notes = new Set((message.attachments ?? []).flatMap((attachment) => [attachmentNotFetched(attachment.filename).text, notFetchedText(attachment.filename)]));
279
291
  return content.slice(1).some((block) => block.type === 'text' && notes.has(block.text));
280
292
  }
281
293
 
@@ -302,9 +314,10 @@ function forGoneAttachment(block: ContentBlock, attachments: readonly DiscordAtt
302
314
  if (block.type !== 'text') return false;
303
315
  const { text } = block;
304
316
  const opening = text.startsWith('\n--- File: ') ? (filename: string) => `\n--- File: ${filename} ---\n`
305
- : text.startsWith('[Attachment: ') ? (filename: string) => `[Attachment: ${filename} `
317
+ : text.startsWith(`[${NOTICE_TAG.attachment}: `) ? (filename: string) => `[${NOTICE_TAG.attachment}: ${filename} `
306
318
  : null;
307
- return opening !== null && !attachments.some((attachment) => text.startsWith(opening(attachment.filename)));
319
+ return opening !== null && !attachments.some((attachment) =>
320
+ text.startsWith(opening(suppliedValue(attachment.filename))) || text.startsWith(opening(attachment.filename)));
308
321
  }
309
322
 
310
323
  /**
@@ -818,7 +831,7 @@ export class DiscordModule implements Module {
818
831
 
819
832
  // Built from the message and its attachments, or from its text alone
820
833
  // when an event carries no blocks; text first, as TEXT_FIRST marks.
821
- const contentBlocks = msg.contentBlocks ?? textFirst(ownText(msg.content));
834
+ const contentBlocks = msg.contentBlocks ?? textFirst(storedOwnText(msg.content));
822
835
  this.noteLive(msg.discordMessageId);
823
836
 
824
837
  // A message a history sync wrote first, stored or held, keeps that row:
@@ -833,9 +846,10 @@ export class DiscordModule implements Module {
833
846
  ...(stored ? {} : {
834
847
  addMessages: [
835
848
  {
836
- participant: msg.authorName,
849
+ participant: outsideSenderName(msg.authorName, 'Discord'),
837
850
  content: contentBlocks,
838
851
  metadata: {
852
+ author: { id: msg.authorId, name: msg.authorName },
839
853
  external: { source: 'discord', id: msg.discordMessageId },
840
854
  channelId: msg.channelId,
841
855
  timestamp: msg.timestamp,
@@ -1144,14 +1158,12 @@ export class DiscordModule implements Module {
1144
1158
  // For bot's own messages (from previous sessions), use the agent name
1145
1159
  // This ensures continuity of conversation history
1146
1160
  const isBotMessage = discordMsg.authorId === this.state.botUserId;
1147
- const agentName = ctx.getAgents()[0]?.name;
1148
- const participantName = isBotMessage
1149
- ? (agentName ?? discordMsg.authorName)
1150
- : discordMsg.authorName;
1161
+ const agentName = isBotMessage ? ctx.getAgents()[0]?.name : undefined;
1151
1162
  ctx.addMessage(
1152
- participantName,
1163
+ agentName ?? outsideSenderName(discordMsg.authorName, 'Discord'),
1153
1164
  content,
1154
1165
  {
1166
+ ...(agentName === undefined ? { author: { id: discordMsg.authorId, name: discordMsg.authorName } } : {}),
1155
1167
  external: { source: 'discord', id: discordMsg.id },
1156
1168
  channelId,
1157
1169
  timestamp: discordMsg.timestamp.getTime(),
@@ -1371,7 +1383,7 @@ export class DiscordModule implements Module {
1371
1383
  if (isTextFile && attachment.size >= TEXT_FILE_LIMIT_BYTES) {
1372
1384
  blocks.push({
1373
1385
  type: 'text',
1374
- text: `[Attachment: ${attachment.filename} (${attachment.size.toLocaleString('en-US')} bytes): this host reads text files under ${TEXT_FILE_LIMIT_BYTES.toLocaleString('en-US')} bytes into your context, so its text isn't here. If you need it, ask the sender to paste the part that matters.]`,
1386
+ text: `[${NOTICE_TAG.attachment}: ${suppliedValue(attachment.filename)} (${attachment.size.toLocaleString('en-US')} bytes): this host reads text files under ${TEXT_FILE_LIMIT_BYTES.toLocaleString('en-US')} bytes into your context, so its text isn't here. If you need it, ask the sender to paste the part that matters.]`,
1375
1387
  });
1376
1388
  continue;
1377
1389
  }
@@ -1383,7 +1395,7 @@ export class DiscordModule implements Module {
1383
1395
  const textContent = await response.text();
1384
1396
  blocks.push({
1385
1397
  type: 'text',
1386
- text: `\n--- File: ${attachment.filename} ---\n${textContent}\n--- End of ${attachment.filename} ---\n`,
1398
+ text: `\n--- File: ${suppliedValue(attachment.filename)} ---\n${markedText(textContent)}\n--- End of ${suppliedValue(attachment.filename)} ---\n`,
1387
1399
  });
1388
1400
  } else {
1389
1401
  // An answer whose body is never read holds its connection.
@@ -1401,11 +1413,11 @@ export class DiscordModule implements Module {
1401
1413
  // For unsupported attachments, add a note
1402
1414
  blocks.push({
1403
1415
  type: 'text',
1404
- text: `[Attachment: ${attachment.filename} (${contentType || 'unknown type'}): this host didn't read its contents in, so they aren't in your context. If you need them, ask the sender for them in a form you can read, such as pasted text.]`,
1416
+ text: `[${NOTICE_TAG.attachment}: ${suppliedValue(attachment.filename)} (${contentType ? suppliedValue(contentType) : 'unknown type'}): this host didn't read its contents in, so they aren't in your context. If you need them, ask the sender for them in a form you can read, such as pasted text.]`,
1405
1417
  });
1406
1418
  }
1407
1419
 
1408
- return textFirst(ownText(message.content), blocks);
1420
+ return textFirst(storedOwnText(message.content), blocks);
1409
1421
  }
1410
1422
 
1411
1423
  /**
@@ -535,7 +535,7 @@ export class HistoryModule implements Module {
535
535
  channelId?: string;
536
536
  n: number;
537
537
  side: 'newest' | 'oldest';
538
- }): { messages: StoredMessage[]; more: boolean } {
538
+ }): { messages: StoredMessage[]; more: boolean; total?: number } {
539
539
  const cm = this.cm as ContextManager;
540
540
  const { fromMs, toMs, channelId, n, side } = opts;
541
541
  const newest = side === 'newest';
@@ -553,8 +553,8 @@ export class HistoryModule implements Module {
553
553
  const fetch = (f: number | undefined, t: number | undefined, limit: number, offset = 0) =>
554
554
  limit <= 0 ? [] : cm.queryMessagesByTimeAndChannel({ fromMs: f, toMs: t, channelId, limit, offset }).messages;
555
555
  const total = count(fromMs, toMs);
556
- if (n === 0) return { messages: [], more: total > 0 };
557
- if (total <= n) return { messages: dirSort(fetch(fromMs, toMs, total)), more: false };
556
+ if (n === 0) return { messages: [], more: total > 0, total };
557
+ if (total <= n) return { messages: dirSort(fetch(fromMs, toMs, total)), more: false, total };
558
558
 
559
559
  // The window is parameterised by its FAR bound `e`: [e, toMs] for the
560
560
  // newest side, [fromMs, e] for the oldest; its count is monotone in e.
@@ -580,7 +580,7 @@ export class HistoryModule implements Module {
580
580
  }
581
581
  const fetchCap = Math.max(4 * n, n + 1000);
582
582
  if (inCount <= fetchCap) {
583
- return { messages: dirSort(fetch(...win(inside), inCount)).slice(0, n), more: true };
583
+ return { messages: dirSort(fetch(...win(inside), inCount)).slice(0, n), more: true, total };
584
584
  }
585
585
  // Overshoot: bisect the far bound. The empty side starts one past the
586
586
  // range's near end (newest: past toMs / any date; oldest: before lo).
@@ -595,7 +595,7 @@ export class HistoryModule implements Module {
595
595
  inside = mid;
596
596
  inCount = c;
597
597
  if (c <= fetchCap) {
598
- return { messages: dirSort(fetch(...win(mid), c)).slice(0, n), more: true };
598
+ return { messages: dirSort(fetch(...win(mid), c)).slice(0, n), more: true, total };
599
599
  }
600
600
  }
601
601
  }
@@ -607,7 +607,7 @@ export class HistoryModule implements Module {
607
607
  const need = n - near.length;
608
608
  const ties = count(inside, inside);
609
609
  const edgeMs = fetch(inside, inside, need, newest ? Math.max(0, ties - need) : 0);
610
- return { messages: dirSort([...near, ...edgeMs]).slice(0, n), more: true };
610
+ return { messages: dirSort([...near, ...edgeMs]).slice(0, n), more: true, total };
611
611
  }
612
612
 
613
613
  async start(ctx: ModuleContext): Promise<void> {
@@ -1150,7 +1150,8 @@ export class HistoryModule implements Module {
1150
1150
  hint:
1151
1151
  (pageFull
1152
1152
  ? 'More matching messages exist. '
1153
- : `Stopped after scanning ${scanned} messages of the window without reaching its end. `) +
1153
+ : `Stopped after scanning ${scanned} messages of the window without reaching its end` +
1154
+ (page.length === 0 ? ', so returning none is not a negative result for the window. ' : '. ')) +
1154
1155
  `Continue by repeating this call with the same from/to/channelId/author plus ` +
1155
1156
  `the fields of \`resume\` (windowOffset:${resume.windowOffset}, offset:${resume.offset}, afterId).`,
1156
1157
  };
@@ -1373,6 +1374,7 @@ export class HistoryModule implements Module {
1373
1374
  !!skipRange && m.timestamp.getTime() === edgeMs && m.sequence >= skipRange[0] && m.sequence <= skipRange[1];
1374
1375
  let windowMessages: StoredMessage[];
1375
1376
  let truncated: boolean;
1377
+ let unscanned: number | undefined;
1376
1378
  {
1377
1379
  // Over-fetch by the number of messages at the edge millisecond (the
1378
1380
  // only ones that can be skipped), then drop the skipped ones.
@@ -1381,6 +1383,7 @@ export class HistoryModule implements Module {
1381
1383
  const pool = skipRange ? w.messages.filter((m) => !isSkipped(m)) : w.messages;
1382
1384
  truncated = w.more || pool.length > maxScan;
1383
1385
  windowMessages = pool.slice(0, maxScan);
1386
+ if (w.total !== undefined) unscanned = w.total - (w.messages.length - pool.length) - windowMessages.length;
1384
1387
  }
1385
1388
  const candidatePoolSize = windowMessages.length;
1386
1389
  const candidates = authorFilter ? windowMessages.filter(authorFilter) : windowMessages;
@@ -1394,7 +1397,7 @@ export class HistoryModule implements Module {
1394
1397
  // the pool was never looked at) or the pool itself was truncated. The
1395
1398
  // timestamp is the continuation point — from: (oldest) / to: (newest).
1396
1399
  // Boundary is inclusive, so a message at exactly that instant may repeat.
1397
- const resumeInfo = (scanned: number): Record<string, unknown> => {
1400
+ const resumeInfo = (scanned: number, matched: number): Record<string, unknown> => {
1398
1401
  const stoppedEarly = scanned < candidates.length;
1399
1402
  if (!stoppedEarly && !truncated) return {};
1400
1403
  // Stopped at limit: the last candidate actually looked at. Scanned the
@@ -1432,9 +1435,16 @@ export class HistoryModule implements Module {
1432
1435
  hint:
1433
1436
  (stoppedEarly
1434
1437
  ? 'Stopped at limit; more candidates remain. '
1435
- : `Only the ${order} ${candidatePoolSize} messages of this range were scanned (maxScan). `) +
1438
+ : matched === 0
1439
+ ? `No match among the ${order} ${candidatePoolSize} ${candidatePoolSize === 1 ? 'message' : 'messages'} ` +
1440
+ 'scanned (maxScan); ' +
1441
+ (unscanned === undefined
1442
+ ? 'the rest of this range was not searched'
1443
+ : `${unscanned} more in this range ${unscanned === 1 ? 'was' : 'were'} not searched`) +
1444
+ ', so this is not a negative result for the range. '
1445
+ : `Only the ${order} ${candidatePoolSize} messages of this range were scanned (maxScan). `) +
1436
1446
  `Continue by repeating the call with the fields of \`resume\` (${bound}:"${at}" and skipSequences), ` +
1437
- 'or raise limit/maxScan, or narrow with channelId/author/dates.',
1447
+ `or raise limit/maxScan, or narrow with ${[channelId === undefined ? 'channelId' : null, stoppedEarly && input.author === undefined ? 'author' : null, 'dates'].filter((w) => w !== null).join('/')}.`,
1438
1448
  };
1439
1449
  };
1440
1450
 
@@ -1449,7 +1459,7 @@ export class HistoryModule implements Module {
1449
1459
  // on a deadline. See search-regex-worker.ts's header for the full
1450
1460
  // rationale.
1451
1461
  const { matches, scanned } = await this.searchWithRegexWorker(candidates, query, flags, limit);
1452
- return { success: true, data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned), matches } };
1462
+ return { success: true, data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned, matches.length), matches } };
1453
1463
  }
1454
1464
 
1455
1465
  // Plain substring search (String.prototype.indexOf) is inherently
@@ -1481,7 +1491,7 @@ export class HistoryModule implements Module {
1481
1491
 
1482
1492
  return {
1483
1493
  success: true,
1484
- data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned), matches },
1494
+ data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned, matches.length), matches },
1485
1495
  };
1486
1496
  }
1487
1497
 
@@ -2578,9 +2588,11 @@ function inTextUnits(text: string, hit: MatchHit): MatchHit {
2578
2588
  // author filtering
2579
2589
  // ============================================================================
2580
2590
 
2581
- /** metadata.author as written by MCPL channel ingestion ({id, name}). Every
2582
- * incoming channel message is stored with participant "user" — the real
2583
- * author lives only here. */
2591
+ /** metadata.author as MCPL channel ingestion and DiscordModule write it
2592
+ * ({id, name}), and ApiModule ({name}, or the author object an api:message
2593
+ * event carries). Their participant is "user", or the sender's name marked
2594
+ * with its source unless it's the generic "user", so the plain author
2595
+ * name is read here. */
2584
2596
  function authorOf(msg: StoredMessage): { id?: string; name?: string } | undefined {
2585
2597
  const a = (msg.metadata as { author?: unknown } | undefined)?.author;
2586
2598
  if (!a || typeof a !== 'object') return undefined;
@@ -2647,9 +2659,9 @@ function toAuthorSet(spec: AuthorSpec | undefined, field: string): Set<string> |
2647
2659
 
2648
2660
  /**
2649
2661
  * Predicate for `author`/`excludeAuthor`, or null when neither is given.
2650
- * A message's identities are its metadata.author name and id plus its
2651
- * stored participant — the last is what identifies the agent's own turns
2652
- * (and anything else ingested without author metadata). Exact match after
2662
+ * A message's identities are its metadata.author name and id when it has
2663
+ * one, and otherwise its stored participant, which is what identifies the
2664
+ * agent's own turns (and anything else ingested without author metadata). Exact match after
2653
2665
  * normalization, never substring: "ann" must not pull in "joanne".
2654
2666
  */
2655
2667
  function buildAuthorFilter(include: AuthorSpec | undefined, exclude: AuthorSpec | undefined): ((m: StoredMessage) => boolean) | null {
@@ -0,0 +1,9 @@
1
+ /** The participant a module stores for a sender's name from outside the
2
+ * framework: on one line, and marked with its source, so it isn't an
3
+ * agent's name unless a host names an agent in that form. The generic
4
+ * `user` stays as given, since context-manager places injections by the
5
+ * last message whose participant is `user`, in any capitalization. */
6
+ export function outsideSenderName(name: string, source: string): string {
7
+ const line = name.replace(/[\p{Cc}\u2028\u2029]+/gu, ' ');
8
+ return line.toLowerCase() === 'user' ? line : `${line} (${source})`;
9
+ }