@animalabs/agent-framework 0.27.0 → 0.28.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 (234) hide show
  1. package/README.md +56 -15
  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 +69 -11
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +4 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +3 -2
  10. package/dist/src/auto-continue.d.ts.map +1 -1
  11. package/dist/src/auto-continue.js +4 -3
  12. package/dist/src/auto-continue.js.map +1 -1
  13. package/dist/src/cut-off-round.d.ts +24 -0
  14. package/dist/src/cut-off-round.d.ts.map +1 -0
  15. package/dist/src/cut-off-round.js +87 -0
  16. package/dist/src/cut-off-round.js.map +1 -0
  17. package/dist/src/framework.d.ts +304 -89
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +2577 -678
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +99 -3
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +538 -13
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -1
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -0
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/reply-judge.d.ts +116 -0
  30. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  31. package/dist/src/gate/reply-judge.js +467 -0
  32. package/dist/src/gate/reply-judge.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +161 -0
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  36. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  37. package/dist/src/heartbeat/heartbeat.js +752 -0
  38. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  39. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  40. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  41. package/dist/src/heartbeat/reminder-time.js +107 -0
  42. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  43. package/dist/src/index.d.ts +4 -0
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +2 -0
  46. package/dist/src/index.js.map +1 -1
  47. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  48. package/dist/src/mcpl/capability-mask.js +3 -2
  49. package/dist/src/mcpl/capability-mask.js.map +1 -1
  50. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  51. package/dist/src/mcpl/channel-registry.js +2 -1
  52. package/dist/src/mcpl/channel-registry.js.map +1 -1
  53. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  54. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  55. package/dist/src/mcpl/inbound-source.js +40 -69
  56. package/dist/src/mcpl/inbound-source.js.map +1 -1
  57. package/dist/src/mcpl/server-connection.d.ts +18 -8
  58. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-connection.js +50 -10
  60. package/dist/src/mcpl/server-connection.js.map +1 -1
  61. package/dist/src/mcpl/tags.d.ts +9 -2
  62. package/dist/src/mcpl/tags.d.ts.map +1 -1
  63. package/dist/src/mcpl/tags.js +12 -2
  64. package/dist/src/mcpl/tags.js.map +1 -1
  65. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  66. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  67. package/dist/src/mcpl/tool-presence.js +134 -65
  68. package/dist/src/mcpl/tool-presence.js.map +1 -1
  69. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  70. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  71. package/dist/src/mcpl/tool-signature.js +47 -18
  72. package/dist/src/mcpl/tool-signature.js.map +1 -1
  73. package/dist/src/mcpl/toolset-host.d.ts +88 -37
  74. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  75. package/dist/src/mcpl/toolset-host.js +207 -108
  76. package/dist/src/mcpl/toolset-host.js.map +1 -1
  77. package/dist/src/mcpl/toolsets-module.d.ts +14 -11
  78. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  79. package/dist/src/mcpl/toolsets-module.js +141 -49
  80. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  81. package/dist/src/mcpl/types.d.ts +3 -1
  82. package/dist/src/mcpl/types.d.ts.map +1 -1
  83. package/dist/src/mcpl/types.js.map +1 -1
  84. package/dist/src/module-registry.d.ts +4 -1
  85. package/dist/src/module-registry.d.ts.map +1 -1
  86. package/dist/src/module-registry.js +9 -2
  87. package/dist/src/module-registry.js.map +1 -1
  88. package/dist/src/modules/history/index.d.ts.map +1 -1
  89. package/dist/src/modules/history/index.js +22 -11
  90. package/dist/src/modules/history/index.js.map +1 -1
  91. package/dist/src/modules/workspace/answers.d.ts +22 -0
  92. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/answers.js +123 -0
  94. package/dist/src/modules/workspace/answers.js.map +1 -0
  95. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  96. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  97. package/dist/src/modules/workspace/branch-intent.js +6 -2
  98. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  99. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  100. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  101. package/dist/src/modules/workspace/direct-write.js +57 -24
  102. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  103. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  104. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  106. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  107. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  108. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/edit-text.js +4 -2
  110. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/effects.js +20 -13
  113. package/dist/src/modules/workspace/effects.js.map +1 -1
  114. package/dist/src/modules/workspace/index.d.ts +62 -51
  115. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  116. package/dist/src/modules/workspace/index.js +934 -531
  117. package/dist/src/modules/workspace/index.js.map +1 -1
  118. package/dist/src/modules/workspace/observe.d.ts +75 -63
  119. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  120. package/dist/src/modules/workspace/observe.js +366 -180
  121. package/dist/src/modules/workspace/observe.js.map +1 -1
  122. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  123. package/dist/src/modules/workspace/read-page.js +7 -1
  124. package/dist/src/modules/workspace/read-page.js.map +1 -1
  125. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/reconcile.js +26 -16
  127. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  128. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  129. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  130. package/dist/src/modules/workspace/result-text.js.map +1 -1
  131. package/dist/src/modules/workspace/sync.d.ts +9 -0
  132. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  133. package/dist/src/modules/workspace/sync.js +14 -0
  134. package/dist/src/modules/workspace/sync.js.map +1 -1
  135. package/dist/src/modules/workspace/types.d.ts +96 -43
  136. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  137. package/dist/src/modules/workspace/types.js.map +1 -1
  138. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/watcher.js +8 -3
  140. package/dist/src/modules/workspace/watcher.js.map +1 -1
  141. package/dist/src/prose-drafts.d.ts +4 -3
  142. package/dist/src/prose-drafts.d.ts.map +1 -1
  143. package/dist/src/prose-drafts.js +3 -2
  144. package/dist/src/prose-drafts.js.map +1 -1
  145. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  146. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  147. package/dist/src/reasoning-loss-notice.js +50 -0
  148. package/dist/src/reasoning-loss-notice.js.map +1 -0
  149. package/dist/src/reasoning-loss.d.ts +24 -0
  150. package/dist/src/reasoning-loss.d.ts.map +1 -0
  151. package/dist/src/reasoning-loss.js +102 -0
  152. package/dist/src/reasoning-loss.js.map +1 -0
  153. package/dist/src/send-tools.d.ts +3 -2
  154. package/dist/src/send-tools.d.ts.map +1 -1
  155. package/dist/src/send-tools.js +3 -2
  156. package/dist/src/send-tools.js.map +1 -1
  157. package/dist/src/sent-rows.js +1 -1
  158. package/dist/src/thinking-drops.d.ts +4 -11
  159. package/dist/src/thinking-drops.d.ts.map +1 -1
  160. package/dist/src/thinking-drops.js +7 -14
  161. package/dist/src/thinking-drops.js.map +1 -1
  162. package/dist/src/tool-args.d.ts +12 -0
  163. package/dist/src/tool-args.d.ts.map +1 -1
  164. package/dist/src/tool-args.js +35 -1
  165. package/dist/src/tool-args.js.map +1 -1
  166. package/dist/src/tool-result-guard.d.ts +8 -4
  167. package/dist/src/tool-result-guard.d.ts.map +1 -1
  168. package/dist/src/tool-result-guard.js +23 -11
  169. package/dist/src/tool-result-guard.js.map +1 -1
  170. package/dist/src/tune-out/tools.d.ts +4 -1
  171. package/dist/src/tune-out/tools.d.ts.map +1 -1
  172. package/dist/src/types/agent.d.ts +40 -9
  173. package/dist/src/types/agent.d.ts.map +1 -1
  174. package/dist/src/types/events.d.ts +18 -8
  175. package/dist/src/types/events.d.ts.map +1 -1
  176. package/dist/src/types/framework.d.ts +15 -0
  177. package/dist/src/types/framework.d.ts.map +1 -1
  178. package/dist/src/types/module.d.ts +5 -2
  179. package/dist/src/types/module.d.ts.map +1 -1
  180. package/dist/src/types/trace.d.ts +34 -1
  181. package/dist/src/types/trace.d.ts.map +1 -1
  182. package/package.json +3 -3
  183. package/src/agent.ts +72 -11
  184. package/src/api/server.ts +4 -2
  185. package/src/auto-continue.ts +4 -3
  186. package/src/cut-off-round.ts +98 -0
  187. package/src/framework.ts +2694 -700
  188. package/src/gate/event-gate.ts +579 -15
  189. package/src/gate/index.ts +8 -0
  190. package/src/gate/reply-judge.ts +512 -0
  191. package/src/gate/types.ts +164 -1
  192. package/src/heartbeat/heartbeat.ts +804 -0
  193. package/src/heartbeat/reminder-time.ts +107 -0
  194. package/src/index.ts +4 -0
  195. package/src/mcpl/capability-mask.ts +3 -2
  196. package/src/mcpl/channel-registry.ts +2 -1
  197. package/src/mcpl/inbound-source.ts +42 -69
  198. package/src/mcpl/server-connection.ts +56 -16
  199. package/src/mcpl/tags.ts +13 -2
  200. package/src/mcpl/tool-presence.ts +133 -67
  201. package/src/mcpl/tool-signature.ts +51 -20
  202. package/src/mcpl/toolset-host.ts +197 -107
  203. package/src/mcpl/toolsets-module.ts +143 -49
  204. package/src/mcpl/types.ts +3 -1
  205. package/src/module-registry.ts +8 -2
  206. package/src/modules/history/index.ts +22 -12
  207. package/src/modules/workspace/answers.ts +133 -0
  208. package/src/modules/workspace/branch-intent.ts +10 -5
  209. package/src/modules/workspace/direct-write.ts +58 -23
  210. package/src/modules/workspace/disk-agreement.ts +52 -21
  211. package/src/modules/workspace/edit-text.ts +7 -5
  212. package/src/modules/workspace/effects.ts +21 -12
  213. package/src/modules/workspace/index.ts +927 -541
  214. package/src/modules/workspace/observe.ts +381 -177
  215. package/src/modules/workspace/read-page.ts +7 -1
  216. package/src/modules/workspace/reconcile.ts +27 -15
  217. package/src/modules/workspace/result-text.ts +2 -0
  218. package/src/modules/workspace/sync.ts +16 -0
  219. package/src/modules/workspace/types.ts +96 -43
  220. package/src/modules/workspace/watcher.ts +8 -3
  221. package/src/prose-drafts.ts +4 -3
  222. package/src/reasoning-loss-notice.ts +62 -0
  223. package/src/reasoning-loss.ts +120 -0
  224. package/src/send-tools.ts +3 -2
  225. package/src/sent-rows.ts +1 -1
  226. package/src/thinking-drops.ts +10 -16
  227. package/src/tool-args.ts +34 -1
  228. package/src/tool-result-guard.ts +26 -11
  229. package/src/tune-out/tools.ts +4 -1
  230. package/src/types/agent.ts +40 -9
  231. package/src/types/events.ts +18 -8
  232. package/src/types/framework.ts +15 -0
  233. package/src/types/module.ts +5 -2
  234. package/src/types/trace.ts +36 -1
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Reading a reminder's `atIso` in the agent's time zone, ported from heartbeat-mcpl
3
+ * (src/reminder-time.ts) when the heartbeat moved into the framework: a time
4
+ * without an offset is read in the zone, and a wall time a clock change skips
5
+ * or repeats is refused, naming the offsets that would say which instant.
6
+ */
7
+ const DAY_MS = 86_400_000;
8
+ const MAX_DATE_MS = 8_640_000_000_000_000;
9
+
10
+ export const FIRST_REMINDER_TIME = '-271821-04-20T00:00:00Z';
11
+ export const LAST_REMINDER_TIME = '+275760-09-13T00:00:00Z';
12
+
13
+ export function canHoldTime(ms: number): boolean {
14
+ return Math.abs(ms) <= MAX_DATE_MS;
15
+ }
16
+
17
+ function isCalendarDay(year: number, month: number, day: number): boolean {
18
+ if (month < 1 || month > 12 || day < 1) return false;
19
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
20
+ return day <= [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
21
+ }
22
+
23
+ /** ISO 8601 reads 24:00 as the end of the day. */
24
+ function isClockTime(hour: number, minute: number, second: number, fraction: number): boolean {
25
+ if (hour === 24) return minute === 0 && second === 0 && fraction === 0;
26
+ return hour <= 23 && minute <= 59 && second <= 59;
27
+ }
28
+
29
+ /** Intl names offsets with a GMT prefix, with seconds for the historical ones, and ICU 76,
30
+ * which Node 22.12.0 has, names the zero offset GMT alone. */
31
+ function offsetMilliseconds(offset: string): number | null {
32
+ if (/^(Z|GMT)$/i.test(offset)) return 0;
33
+ const match = /^(?:GMT)?([+-])(\d{2})(?:(:?)(\d{2})(?:\3(\d{2}))?)?$/.exec(offset);
34
+ if (!match || +match[2] > 23 || +(match[4] ?? 0) > 59 || +(match[5] ?? 0) > 59) return null;
35
+ return (match[1] === '-' ? -1 : 1)
36
+ * (+match[2] * 3600 + +(match[4] ?? 0) * 60 + +(match[5] ?? 0)) * 1000;
37
+ }
38
+
39
+ export function parseReminderTime(atIso: string, timeZone: string): number {
40
+ const shown = JSON.stringify(atIso);
41
+ const parts = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?([Zz]|[+-][\d:]+)?)?$/.exec(atIso);
42
+ // ECMAScript's date-time format refuses -000000 for year zero.
43
+ if (!parts || parts[1] === '-000000') {
44
+ throw new Error(`"atIso" ${shown} isn't a time this tool reads. Give a date and time like "2030-07-15T09:00", read in `
45
+ + `${timeZone}, or end it with an offset, like "2030-07-15T09:00Z" or "2030-07-15T09:00+01:00". A date alone is `
46
+ + `midnight in ${timeZone}. For a delay from now, give inSeconds, inMinutes or inHours instead.`);
47
+ }
48
+ const givenOffset = parts[8] ? offsetMilliseconds(parts[8]) : null;
49
+ if (parts[8] && givenOffset === null) {
50
+ throw new Error(`"atIso" ${shown} ends in ${JSON.stringify(parts[8])}, which isn't a UTC offset. `
51
+ + 'End it with Z for UTC, or an offset like +05:30, +0530 or +05.');
52
+ }
53
+ const calendarDate = `${parts[1]}-${parts[2]}-${parts[3]}`;
54
+ const readIn = parts[8] ? '' : `, read in ${timeZone}`;
55
+ const example = (time: string) => `"${time}${parts[8] ?? ''}"${readIn}`;
56
+ if (!isCalendarDay(+parts[1], +parts[2], +parts[3])) {
57
+ throw new Error(`"atIso" ${shown} names a day the calendar doesn't have, ${calendarDate}. Give a real date, like ${example('2030-03-01T09:00')}.`);
58
+ }
59
+ // A Date keeps milliseconds, so digits past them don't count.
60
+ if (!isClockTime(+(parts[4] ?? 0), +(parts[5] ?? 0), +(parts[6] ?? 0), +(parts[7] ?? '0').slice(0, 3))) {
61
+ throw new Error(`"atIso" ${shown} has a time no clock shows: hours run 00 to 23 and minutes and seconds 00 to 59, `
62
+ + `and 24:00, the end of the day, takes no minutes, seconds or milliseconds after it. Give one like ${example(`${calendarDate}T09:00`)}.`);
63
+ }
64
+ const outOfRange = new Error(`"atIso" ${shown}${readIn ? `${readIn},` : ''} is outside the times a reminder can hold, `
65
+ + `${FIRST_REMINDER_TIME} to ${LAST_REMINDER_TIME}. Give a time inside that range.`);
66
+ // Date.parse reads 24:00 as the start of the next day.
67
+ const wall = Date.parse(`${calendarDate}T${parts[4] ?? '00'}:${parts[5] ?? '00'}:${parts[6] ?? '00'}.${(parts[7] ?? '0').slice(0, 3).padEnd(3, '0')}Z`);
68
+ if (!Number.isFinite(Date.parse(`${calendarDate}T00:00:00Z`)) || !Number.isFinite(wall)) throw outOfRange;
69
+
70
+ if (givenOffset !== null) {
71
+ const instant = wall - givenOffset;
72
+ if (Math.abs(instant) > MAX_DATE_MS) throw outOfRange;
73
+ return instant;
74
+ }
75
+
76
+ const asTime = parts[4] === undefined ? `${calendarDate}T00:00` : atIso;
77
+ const withOffsets = (labels: string[]) => labels.map((label) => JSON.stringify(`${asTime}${label}`)).join(' or ');
78
+ const formatter = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' });
79
+ const offsetAt = (ms: number) => {
80
+ const name = formatter.formatToParts(new Date(ms)).find((part) => part.type === 'timeZoneName')?.value;
81
+ const offset = name ? offsetMilliseconds(name) : null;
82
+ if (!name || offset === null) {
83
+ throw new Error(`"atIso" ${shown} has no offset, and the zone it would be read in, ${timeZone}, can't be read here. `
84
+ + `End it with an offset, like ${withOffsets(['Z', '+01:00'])}, or give a delay from now in inSeconds, inMinutes or inHours.`);
85
+ }
86
+ return { ms: offset, label: name === 'GMT' ? '+00:00' : name.slice(3) };
87
+ };
88
+
89
+ // Around a clock change a wall time has no instant (a gap) or two (a fold).
90
+ const offsets = new Map<number, string>();
91
+ for (const delta of [-DAY_MS, DAY_MS]) {
92
+ const sample = Math.abs(wall + delta) <= MAX_DATE_MS ? wall + delta : wall;
93
+ const offset = offsetAt(sample);
94
+ offsets.set(offset.ms, offset.label);
95
+ }
96
+ const inRange = [...offsets].filter(([offset]) => Math.abs(wall - offset) <= MAX_DATE_MS);
97
+ if (inRange.length === 0) throw outOfRange;
98
+ const candidates = inRange.filter(([offset]) => offsetAt(wall - offset).ms === offset);
99
+ if (candidates.length === 1) return wall - candidates[0][0];
100
+ const read = parts[4] === undefined ? `${shown} is read as ${asTime}, which` : shown;
101
+ if (candidates.length > 1) {
102
+ throw new Error(`"atIso" ${read} happens twice in ${timeZone}, where the clocks go back over it. `
103
+ + `End it with the offset you mean: ${withOffsets(candidates.map(([, label]) => label))}.`);
104
+ }
105
+ throw new Error(`"atIso" ${read} doesn't exist in ${timeZone}, where the clocks skip over it. Give a time that exists `
106
+ + `there, or end it with an offset to say which instant you mean, like ${withOffsets([...offsets.values()])}.`);
107
+ }
package/src/index.ts CHANGED
@@ -70,6 +70,10 @@ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent
70
70
  // EventGate
71
71
  export { EventGate, wakeLineText, type WakeProvenance, type WritePlacement } from './gate/index.js';
72
72
  export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior, WakeLine } from './gate/index.js';
73
+ export { ReplyJudge, DEFAULT_JUDGE_MODEL, DEFAULT_JUDGE_MAX_INPUT_TOKENS, MIN_JUDGE_INPUT_TOKENS } from './gate/index.js';
74
+ export type { JudgeConfig, JudgeEventSummary, JudgeFn, JudgeRequest, JudgeVerdict, ReplyJudgeOptions, ReplyJudgeDeps, UpcomingWake } from './gate/index.js';
75
+ export { Heartbeat, DEFAULT_HEARTBEAT, HEARTBEAT_TOOL_PREFIX } from './heartbeat/heartbeat.js';
76
+ export type { HeartbeatOptions, HeartbeatSettings, HeartbeatWake, HeartbeatDeliveryMode, Reminder } from './heartbeat/heartbeat.js';
73
77
 
74
78
  // MCPL channel registry (exposed for modules that need channel-level operations)
75
79
  export { ChannelRegistry } from './mcpl/index.js';
@@ -81,8 +81,9 @@ function anyMatches(patterns: string[], path: string): boolean {
81
81
  return patterns.some((pattern) => patternMatchesPath(pattern, path));
82
82
  }
83
83
 
84
- /** Keys that are negotiation metadata, not grantable capabilities. */
85
- const UNMASKABLE_KEYS = new Set(['version', 'featureSets']);
84
+ /** Keys that are negotiation metadata, or RFC-014 declarations (toolHints,
85
+ * toolsets, surface), not grantable capabilities. */
86
+ const UNMASKABLE_KEYS = new Set(['version', 'featureSets', 'toolHints', 'toolsets', 'surface']);
86
87
 
87
88
  /**
88
89
  * Interior object paths whose object value is itself the capability, with
@@ -1909,7 +1909,8 @@ export class ChannelRegistry {
1909
1909
  const incomingEntry = this.channels.get(incomingKey)!;
1910
1910
  const markAccepted = (firstView = false) => {
1911
1911
  // Non-conversational markers (a reaction added or removed, an edit, a
1912
- // deletion) are ABOUT a message, not one the agent replies to: they
1912
+ // deletion, and a chat:system notice such as a join or the weather)
1913
+ // are ABOUT a message or the place, not one the agent replies to: they
1913
1914
  // must not retarget the default publish locus or become
1914
1915
  // `context.incoming`'s reply anchor. An edit the coalescer delivered
1915
1916
  // as the agent's first view counts as the message it replaced.
@@ -22,6 +22,8 @@
22
22
  */
23
23
 
24
24
  import { createHash } from 'node:crypto';
25
+ import { blockAsStored, MalformedContentError } from '@animalabs/context-manager';
26
+ import { logDetail, logValue } from './log-value.js';
25
27
 
26
28
  /** The MCPL admission lane that accepted an item. Its contract decides what
27
29
  * an `eventId` is worth: `push/event` deduplicates by eventId, and RFC-006
@@ -192,68 +194,23 @@ export function canonicalJson(value: unknown): string {
192
194
  }
193
195
 
194
196
  /**
195
- * A block as the store hands it back. Context-manager keeps inline media as
196
- * blobs (blob-manager.ts): an image whose source isn't a URL, and every
197
- * document, audio or video block, comes back as `{ type, source: { type:
198
- * 'base64', data, mediaType } }` and nothing else, its data re-encoded from
199
- * the decoded bytes, and an image's media type taken from its bytes'
200
- * signature where they have one. Any other block comes back as it was given,
201
- * except a store-internal `blob_ref` from an unresolved read, which resolves
202
- * to inline media by default. It is digested as given here, so an unresolved
203
- * read and its resolved form differ. Context-manager refuses caller-supplied
204
- * blob_ref writes; the MCPL lanes never store one.
197
+ * blockAsStored throws a `MalformedContentError` for a block the store
198
+ * refuses on write, a caller-supplied `blob_ref` among them. Such a block
199
+ * has no stored form, and whatever is thrown, it is digested as given.
205
200
  */
206
201
  function storedBlock(block: unknown): unknown {
207
- const media = block as { type?: unknown; source?: { type?: unknown; data?: unknown; mediaType?: unknown } } | null;
208
- const blob = media?.type === 'image'
209
- ? media.source?.type !== 'url'
210
- : media?.type === 'document' || media?.type === 'audio' || media?.type === 'video';
211
- const source = media?.source;
212
- if (!blob || typeof source?.data !== 'string') return block;
213
- const data = canonicalBase64(source.data);
214
- const mediaType = media!.type === 'image'
215
- ? sniffRasterImageMediaType(Buffer.from(data.slice(0, 32), 'base64')) ?? source.mediaType
216
- : source.mediaType;
217
- return { type: media!.type, source: { type: 'base64', data, mediaType } };
218
- }
219
-
220
- /**
221
- * Base64 as the store writes it back from the decoded bytes: padded, with no
222
- * whitespace, the standard alphabet and zero unused bits. Data already in
223
- * that form, as every stored copy's is, is returned without decoding it all.
224
- */
225
- function canonicalBase64(data: string): string {
226
- if (data.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(data)) {
227
- const last = data.slice(-4);
228
- if (Buffer.from(last, 'base64').toString('base64') === last) return data;
229
- }
230
- return Buffer.from(data, 'base64').toString('base64');
231
- }
232
-
233
- /**
234
- * The store's own test for raster image types with an unambiguous byte
235
- * signature, copied from context-manager's blob-manager.ts (it isn't
236
- * exported): the store relabels an image whose bytes match one of these.
237
- */
238
- function sniffRasterImageMediaType(bytes: Uint8Array): string | null {
239
- if (bytes.length >= 8 &&
240
- bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
241
- bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
242
- return 'image/png';
243
- }
244
- if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
245
- return 'image/jpeg';
246
- }
247
- if (bytes.length >= 6) {
248
- const signature = Buffer.from(bytes.subarray(0, 6)).toString('ascii');
249
- if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif';
250
- }
251
- if (bytes.length >= 12 &&
252
- Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' &&
253
- Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP') {
254
- return 'image/webp';
202
+ try {
203
+ return blockAsStored(block as Parameters<typeof blockAsStored>[0]);
204
+ } catch (err) {
205
+ if (!(err instanceof MalformedContentError)) {
206
+ const { name, message } = (err ?? {}) as { name?: unknown; message?: unknown };
207
+ console.error(
208
+ `[stored-digest-as-given] error=${logValue(typeof name === 'string' ? name : typeof err)}: ` +
209
+ logDetail(typeof message === 'string' ? message : ''),
210
+ );
211
+ }
212
+ return block;
255
213
  }
256
- return null;
257
214
  }
258
215
 
259
216
  /**
@@ -261,23 +218,39 @@ function sniffRasterImageMediaType(bytes: Uint8Array): string | null {
261
218
  * room-220 #46752–#47210): SHA-256 hex of `canonicalJson([blocks])` over the
262
219
  * ContentBlock[] the framework stores for the body — after MCPL conversion,
263
220
  * before any host decoration is added and before storage shards it — with
264
- * each block as the store keeps it (storedBlock, canonicalJson). So a body
265
- * hashes alike as delivered, as handed to storage, and as read back from
266
- * the store. The one-element array keeps the framing an undecorated,
267
- * unsharded stored copy has always hashed to, so a stamped copy and an
268
- * older copy of the same body share a version.
221
+ * each block as the store keeps it, which context-manager's `blockAsStored`
222
+ * gives. So a body hashes alike as delivered, as handed to storage, and as
223
+ * read back unsharded from the store with its blobs resolved, except one
224
+ * whose blocks the store changes together: when it mends a `tool_use` whose
225
+ * input isn't an object, it also releases a raw replay form (`rawXml`,
226
+ * `rawItem`) that the message's other blocks share, which no one block's
227
+ * stored form shows. A body an MCPL lane stamps is never one. A block the
228
+ * store refuses on write, a `blob_ref` among them, is digested as given. The
229
+ * one-element array keeps the framing an undecorated, unsharded stored copy
230
+ * has always hashed to, so a stamped copy and an older copy of the same body
231
+ * share a version where the store keeps the body as it did then.
232
+ *
233
+ * A digest is a digest under the context-manager release that computed it: a
234
+ * release that changes what the store keeps, such as its set of image
235
+ * signatures, changes the digest of a body it touches.
269
236
  *
270
237
  * Ingestion stamps it as `metadata.sourceBodyDigest`, and the same function
271
238
  * over exactly the blocks handed to storage (decorations included) as
272
239
  * `metadata.storedBodyDigest`, on both MCPL lanes. Both live outside the
273
240
  * frozen admission envelope: they describe the body actually delivered (a
274
241
  * materialization, a correction), not the admission. Neither proves later
275
- * presence or completeness; an unsharded stored copy that no longer hashes
242
+ * presence or completeness. An unsharded stored copy that no longer hashes
276
243
  * to its storedBodyDigest was changed after delivery (editMessage keeps
277
- * metadata). A body the store shards carries the whole body's two digests
278
- * on every shard, and no shard hashes to them. Check a copy read back with
279
- * its blobs resolved (getAllMessages, getMessage, or getMessageWindow
280
- * without `resolveBlobs: false`): a blob reference never matches.
244
+ * metadata), or holds blocks the store changes together, or its digest was
245
+ * taken under a release that kept some block of it differently: a
246
+ * context-manager release that changed a stored form, or an agent-framework
247
+ * release from 0.24.0 up to the one that brought in blockAsStored, which
248
+ * took stored forms from a copy of the store's transform. Digests already
249
+ * stamped are never recomputed. A body the store shards carries the whole
250
+ * body's two digests on every shard, and no shard hashes to them. Check a
251
+ * copy read back with its blobs resolved (getAllMessages, getMessage, or
252
+ * getMessageWindow without `resolveBlobs: false`): a blob reference never
253
+ * matches.
281
254
  *
282
255
  * Exported from the package root, so a consumer checks a copy against either
283
256
  * field with this same function rather than a copy of it.
@@ -240,19 +240,29 @@ export function toolsetDeclarationsOf(advertised: unknown): Record<string, unkno
240
240
  /** What the RFC-014 read tap is handed as the connection reads a message
241
241
  * that can carry hint changes (see McplServerConnection.setReadTap). */
242
242
  export type HintRead =
243
- /** A response to a request that can carry hints (RFC-014 §6.1 carrier 1).
244
- * `awaited`: whoever sent it still waits for it; false for a result that
245
- * arrives after its timeout. A JSON-RPC error carries no state and isn't
246
- * tapped. */
247
- | { kind: 'result'; method: string; params: Record<string, unknown>; result: unknown; awaited: boolean }
243
+ /** A response to a request that can carry hints (RFC-014 §6.1 carrier 1),
244
+ * or a `tools/list`'s whole inventory (§4.1), its pages joined by
245
+ * sendToolsList, `straddled` when its pages carry different revs.
246
+ * `awaited`: whoever sent it still waits for it; false for a
247
+ * result that arrives after its timeout. A JSON-RPC error carries no state
248
+ * and isn't tapped. */
249
+ | { kind: 'result'; method: string; params: Record<string, unknown>; result: unknown; awaited: boolean; straddled?: boolean }
248
250
  /** A push/event (carrier 2), as read, before the plane gate or the grant
249
251
  * admit or refuse it. */
250
252
  | { kind: 'push'; params: unknown }
251
253
  /** A standalone tools/hints (carrier 3). */
252
254
  | { kind: 'hints'; params: unknown };
253
255
 
254
- /** Requests whose results can carry hint state (RFC-014 §6.1, §4.1). */
255
- const HINTED_METHODS: ReadonlySet<string> = new Set(['tools/call', 'toolsets/open', 'toolsets/close', 'toolsets/list', 'tools/list']);
256
+ /** Requests whose results can carry hint state, remembered for a late answer (RFC-014 §6.1). */
257
+ const HINTED_METHODS: ReadonlySet<string> = new Set(['tools/call', 'toolsets/open', 'toolsets/close', 'toolsets/list']);
258
+
259
+ function hintedParams(method: string, params: Record<string, unknown>): { toolset?: string; instance?: string } {
260
+ if (method !== 'toolsets/open' && method !== 'toolsets/close') return {};
261
+ return {
262
+ ...(typeof params.toolset === 'string' ? { toolset: params.toolset } : {}),
263
+ ...(typeof params.instance === 'string' ? { instance: params.instance } : {}),
264
+ };
265
+ }
256
266
 
257
267
  /**
258
268
  * A handshake that failed, and whose cleanup couldn't confirm that the child
@@ -330,6 +340,7 @@ interface PendingRequest {
330
340
  resolve: (result: unknown) => void;
331
341
  reject: (error: Error) => void;
332
342
  method: string;
343
+ onRead?: (result: unknown) => void;
333
344
  /** Per-request timeout timer; cleared on response/close/timeout. */
334
345
  timer?: ReturnType<typeof setTimeout>;
335
346
  }
@@ -339,6 +350,9 @@ interface RequestOptions {
339
350
  timeoutMs?: number;
340
351
  /** Surface a late response even when it carries no checkpoint state. */
341
352
  surfaceOrphanedResponse?: boolean;
353
+ /** Called with a successful result as its response is read, before
354
+ * anything read after it and before the request's promise settles. */
355
+ onRead?: (result: unknown) => void;
342
356
  }
343
357
 
344
358
  /** A server→host message held while its plane is closed. `kept`: it came on
@@ -452,15 +466,18 @@ export class McplServerConnection extends EventEmitter {
452
466
  * the message: a push the plane holds or the grant refuses, a result its
453
467
  * caller stopped waiting for. The tap sees every such message of the
454
468
  * current transport, synchronously, before anything routes it; a late
455
- * answer's request is remembered past its timeout (hintedRequests) so its
456
- * hints still apply. Null (the default) taps nothing, and the connection
469
+ * answer's request is remembered past its timeout (hintedRequests, while
470
+ * no more than 1,000 hinted requests await an answer) so its hints still
471
+ * apply. Null (the default) taps nothing, and the connection
457
472
  * behaves as it always has.
458
473
  */
459
474
  private readTap: ((read: HintRead, epoch: number) => void) | null = null;
460
475
  /** The hinted requests sent on this transport, by id, kept past a timeout
461
- * so a late result is still tapped; bounded. Cleared at each boundary,
476
+ * so a late result is still tapped: each one's method, and an open's or
477
+ * close's toolset and instance, which are all the read of its result
478
+ * uses; never a call's arguments. Bounded. Cleared at each boundary,
462
479
  * since the next transport numbers its requests afresh. */
463
- private hintedRequests = new Map<string | number, { method: string; params: Record<string, unknown> }>();
480
+ private hintedRequests = new Map<string | number, { method: string; params: { toolset?: string; instance?: string } }>();
464
481
 
465
482
  /** Install (or, with null, remove) the RFC-014 read tap (see readTap). */
466
483
  setReadTap(tap: ((read: HintRead, epoch: number) => void) | null): void {
@@ -1708,22 +1725,40 @@ export class McplServerConnection extends EventEmitter {
1708
1725
  * read it as a complete page). A page that isn't well formed (no `tools` array, a cursor
1709
1726
  * that isn't a string), or cursors that don't terminate (a repeat, or more
1710
1727
  * than {@link MAX_TOOL_LIST_PAGES} pages), are errors, never a partial or
1711
- * empty inventory published as complete.
1728
+ * empty inventory published as complete. The read tap (RFC-014) is handed
1729
+ * the complete inventory once, with the last page's `_meta`, as the last
1730
+ * page's response is read, before anything read after it; a list that
1731
+ * fails or times out hands it nothing. It's `straddled` when its pages
1732
+ * carry different `mcpl/rev`s.
1712
1733
  */
1713
1734
  async sendToolsList(): Promise<{ tools: McpToolDefinition[] }> {
1714
1735
  const tools: McpToolDefinition[] = [];
1715
1736
  const seen = new Set<string>();
1716
1737
  let cursor: string | undefined;
1717
1738
  let first: Record<string, unknown> | undefined;
1739
+ const read: McpToolDefinition[] = [];
1740
+ const revs = new Set<unknown>();
1741
+ const onRead = (result: unknown) => {
1742
+ if (!result || typeof result !== 'object' || !Array.isArray((result as { tools?: unknown }).tools)) return;
1743
+ const r = result as { tools: McpToolDefinition[]; nextCursor?: unknown; _meta?: unknown };
1744
+ for (const t of r.tools) read.push(t);
1745
+ const rev = (r._meta as Record<string, unknown> | undefined)?.['mcpl/rev'];
1746
+ if (typeof rev === 'number') revs.add(rev);
1747
+ if (r.nextCursor !== undefined && r.nextCursor !== null) return;
1748
+ this.tap({
1749
+ kind: 'result', method: 'tools/list', params: {}, ...(revs.size > 1 ? { straddled: true } : {}),
1750
+ result: { tools: read, ...(r._meta !== undefined ? { _meta: r._meta } : {}) }, awaited: true,
1751
+ });
1752
+ };
1718
1753
  for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) {
1719
- const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor }) as Record<string, unknown> | null;
1754
+ const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor }, { onRead }) as Record<string, unknown> | null;
1720
1755
  const malformed = (why: string) =>
1721
1756
  new Error(`MCPL server "${this.id}" sent a malformed tools/list page ${page + 1}: ${why}`);
1722
1757
  if (!result || typeof result !== 'object' || !Array.isArray(result.tools)) {
1723
1758
  throw malformed('no tools array');
1724
1759
  }
1725
1760
  first ??= result;
1726
- tools.push(...(result.tools as McpToolDefinition[]));
1761
+ for (const t of result.tools as McpToolDefinition[]) tools.push(t);
1727
1762
  if (result.nextCursor === undefined || result.nextCursor === null) {
1728
1763
  // The first page's other fields are kept as they came, so a
1729
1764
  // single-page answer is returned exactly as before.
@@ -2176,7 +2211,7 @@ export class McplServerConnection extends EventEmitter {
2176
2211
  }
2177
2212
 
2178
2213
  return new Promise((resolve, reject) => {
2179
- const pending: PendingRequest = { resolve, reject, method };
2214
+ const pending: PendingRequest = { resolve, reject, method, ...(options.onRead ? { onRead: options.onRead } : {}) };
2180
2215
  const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
2181
2216
  if (timeoutMs > 0) {
2182
2217
  pending.timer = setTimeout(() => {
@@ -2218,7 +2253,7 @@ export class McplServerConnection extends EventEmitter {
2218
2253
  // RFC-014: a request whose result can carry hints is remembered past
2219
2254
  // its timeout, so a late result's hints still apply (readTap).
2220
2255
  if (this.readTap && HINTED_METHODS.has(method)) {
2221
- this.hintedRequests.set(id, { method, params });
2256
+ this.hintedRequests.set(id, { method, params: hintedParams(method, params) });
2222
2257
  if (this.hintedRequests.size > 1000) this.hintedRequests.delete(this.hintedRequests.keys().next().value!);
2223
2258
  }
2224
2259
  try {
@@ -2654,6 +2689,11 @@ export class McplServerConnection extends EventEmitter {
2654
2689
  logValueRedacted(response.error.message, redact),
2655
2690
  );
2656
2691
  } else {
2692
+ try {
2693
+ pending.onRead?.(response.result);
2694
+ } catch (error) {
2695
+ console.error(`MCPL server "${this.id}": reading ${pending.method}'s result failed: ${logDetail(describeCause(error))}`);
2696
+ }
2657
2697
  pending.resolve(response.result);
2658
2698
  }
2659
2699
  }
package/src/mcpl/tags.ts CHANGED
@@ -21,8 +21,10 @@ const CORE_IMPLIES: Readonly<Record<string, readonly string[]>> = {
21
21
  /**
22
22
  * RFC-001 core tags that mark a message as being ABOUT the conversation
23
23
  * rather than part of it: reactions (added or removed), edits and deletions
24
- * of an existing message. These are markers, not turns — they must not clear
25
- * explicit-send suppression, count as a new conversational round, or
24
+ * of an existing message, and (revision 3) `chat:system` notices from the
25
+ * platform or the place: a join, a pin, the weather changing in a world.
26
+ * These are markers, not turns — they must not clear explicit-send
27
+ * suppression, count as a new conversational round, or
26
28
  * retarget the default publish channel. An edit can also be the first
27
29
  * version of a message the agent sees, and then counts as that message:
28
30
  * isMarkerTagged reads the host's mark for the case the coalescer knows
@@ -36,6 +38,7 @@ export const NON_CONVERSATIONAL_TAGS: ReadonlySet<string> = new Set([
36
38
  'chat:reaction-remove',
37
39
  'chat:edited',
38
40
  'chat:deleted',
41
+ 'chat:system',
39
42
  ]);
40
43
 
41
44
  /** True when a (possibly untyped) tag list carries a non-conversational marker tag. */
@@ -54,11 +57,19 @@ export function isMarkerTagged(tags: unknown, firstView: boolean): boolean {
54
57
  return hasNonConversationalTag(firstView && Array.isArray(tags) ? tags.filter((t) => t !== 'chat:edited') : tags);
55
58
  }
56
59
 
60
+ /** True when a (possibly untyped) tag list marks a `chat:system` notice: an
61
+ * event about the place, not a message anyone sent (RFC-001 rev 3). */
62
+ export function isSystemNotice(tags: unknown): boolean {
63
+ return Array.isArray(tags) && tags.includes('chat:system');
64
+ }
65
+
57
66
  /**
58
67
  * Expand a raw tag list over the normative core closure, then resolve the
59
68
  * §16.2 mutual exclusion: `chat:addressed` and `chat:ambient` cannot both
60
69
  * stand after expansion, and addressed wins — a message that is both
61
70
  * specifically-to-you and background noise is specifically-to-you.
71
+ * `chat:system` is another facet (kind, not addressing) and stands beside
72
+ * either: a pin in a DM is addressed by its place and still a notice.
62
73
  *
63
74
  * Pure; input order is preserved for the tags that survive, with implied
64
75
  * tags appended in first-cause order. Unknown and producer-namespaced tags