@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,804 @@
1
+ /**
2
+ * The heartbeat: the agent's own schedule of wakes, in the framework.
3
+ *
4
+ * Two kinds of wake, both the agent's to set:
5
+ * - the ambient heartbeat, one periodic tick (`intervalSeconds`, `paused`,
6
+ * `message`, `deliveryMode`): a message-mode tick is a timestamped prompt
7
+ * stored in the agent's window, a silent tick a private wake that stores
8
+ * no prompt and suppresses automatic prose (silent-heartbeat.ts);
9
+ * - reminders, one-off (a delay or an `atIso` time) or repeating (an
10
+ * interval), each a stored message with a header naming it and the time.
11
+ *
12
+ * This was heartbeat-mcpl, an MCPL server; it moved here so the framework
13
+ * knows when the agent's next scheduled wake is (the reply judge is told,
14
+ * EventGate.upcomingWakes) and needs no server, policy exchange or push
15
+ * delivery to keep it. Its tools are listed as `heartbeat--heartbeat_status`
16
+ * and the rest, and calls by heartbeat-mcpl's names (`mcpl--heartbeat--…`)
17
+ * reach them too. Its wakes go to the gate as that server's pushes did, a
18
+ * push from the server id `heartbeat`, so the agent's rules decide them as
19
+ * they decided those, and a sleep lets them through, as it let that server
20
+ * id through.
21
+ *
22
+ * State (the schedule, when the next tick is due, and the reminders) is one
23
+ * JSON file, by default `{storePath}/config/heartbeat.json`. A first start
24
+ * with no file imports heartbeat-mcpl's two files when the host names them
25
+ * (`importFrom`). The next tick's time is kept, so a restart doesn't push it
26
+ * back; a tick or a reminder that fell due while the host was down fires
27
+ * once, shortly after start, a repeating reminder once for the periods it
28
+ * missed.
29
+ */
30
+
31
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
32
+ import { dirname } from 'node:path';
33
+ import { checkToolInput, shownValue } from '../gate/event-gate.js';
34
+ import type { UpcomingWake } from '../gate/types.js';
35
+ import type { ToolDefinition } from '../types/events.js';
36
+ import type { ToolParameter } from '@animalabs/membrane';
37
+ import { FIRST_REMINDER_TIME, LAST_REMINDER_TIME, canHoldTime, parseReminderTime } from './reminder-time.js';
38
+
39
+ /** The prefix the heartbeat's tools carry. */
40
+ export const HEARTBEAT_TOOL_PREFIX = 'heartbeat--';
41
+ /** The prefix heartbeat-mcpl's tools carried under the server id `heartbeat`
42
+ * and the default tool prefix: a call by that name, as the agent's context
43
+ * may still show them, reaches the same tool. */
44
+ export const LEGACY_HEARTBEAT_TOOL_PREFIX = 'mcpl--heartbeat--';
45
+ /**
46
+ * Whether an MCPL server is heartbeat-mcpl, or names its tools as the
47
+ * heartbeat's: by its id, a tool prefix of `heartbeat` or `mcpl--heartbeat`,
48
+ * or a command or argument that runs heartbeat-mcpl. The framework's
49
+ * heartbeat refuses one beside it, at create and at a runtime connect.
50
+ */
51
+ export function isHeartbeatMcplServer(server: { id: string; toolPrefix?: string }): boolean {
52
+ if (server.id === 'heartbeat' || server.toolPrefix === 'heartbeat' || server.toolPrefix === 'mcpl--heartbeat') return true;
53
+ const { command, args } = server as { command?: unknown; args?: unknown };
54
+ const parts = [command, ...(Array.isArray(args) ? args : [])];
55
+ return parts.some((part) => typeof part === 'string' && /heartbeat-mcpl/.test(part));
56
+ }
57
+
58
+ /** A heartbeat tool's name under the other prefix, for a name under one: the
59
+ * framework's `heartbeat--x` and heartbeat-mcpl's `mcpl--heartbeat--x` are
60
+ * one tool. Undefined for any other name. */
61
+ export function heartbeatToolAlias(name: string): string | undefined {
62
+ if (name.startsWith(LEGACY_HEARTBEAT_TOOL_PREFIX)) return `${HEARTBEAT_TOOL_PREFIX}${name.slice(LEGACY_HEARTBEAT_TOOL_PREFIX.length)}`;
63
+ if (name.startsWith(HEARTBEAT_TOOL_PREFIX)) return `${LEGACY_HEARTBEAT_TOOL_PREFIX}${name.slice(HEARTBEAT_TOOL_PREFIX.length)}`;
64
+ return undefined;
65
+ }
66
+
67
+ /** The tools that change the schedule, as against the two that only show it. */
68
+ export const HEARTBEAT_CHANGING_TOOLS: readonly string[] = ['heartbeat_configure', 'heartbeat_trigger', 'reminder_add', 'reminder_cancel'];
69
+
70
+ export type HeartbeatDeliveryMode = 'message' | 'silent';
71
+
72
+ /** The ambient heartbeat's schedule. */
73
+ export interface HeartbeatSettings {
74
+ intervalSeconds: number;
75
+ paused: boolean;
76
+ message: string;
77
+ deliveryMode: HeartbeatDeliveryMode;
78
+ }
79
+
80
+ export const DEFAULT_HEARTBEAT: Readonly<HeartbeatSettings> = {
81
+ intervalSeconds: 4 * 60 * 60,
82
+ paused: false,
83
+ deliveryMode: 'message',
84
+ message: '[heartbeat] Periodic self-check-in. Review anything pending — reminders, schedules, follow-ups — and act if needed; otherwise a brief note to yourself is fine.',
85
+ };
86
+
87
+ /** A scheduled reminder. `everySeconds` null: one-off, dropped once it fires. */
88
+ export interface Reminder {
89
+ id: string;
90
+ message: string;
91
+ /** When it was set (ms). */
92
+ createdAt: number;
93
+ /** When it's next due (ms). */
94
+ fireAt: number;
95
+ everySeconds: number | null;
96
+ /** When a delivery that failed is tried again (ms); its `fireAt` stays the time it was due. */
97
+ retryAt?: number;
98
+ }
99
+
100
+ /** Host options (`FrameworkConfig.heartbeat`). */
101
+ export interface HeartbeatOptions {
102
+ /** The state file. Default `{storePath}/config/heartbeat.json`. */
103
+ statePath?: string;
104
+ /** The schedule a first start writes when there's nothing to import. */
105
+ defaults?: Partial<HeartbeatSettings>;
106
+ /**
107
+ * heartbeat-mcpl's files (its HEARTBEAT_CONFIG_FILE and
108
+ * HEARTBEAT_REMINDERS_FILE), read once, by a start that finds no state
109
+ * file, so an agent moving off the server keeps its schedule and its
110
+ * reminders. They're left as they are.
111
+ */
112
+ importFrom?: { configPath?: string; remindersPath?: string };
113
+ }
114
+
115
+ /** A wake the heartbeat asks the framework to deliver. */
116
+ export type HeartbeatWake =
117
+ | { kind: 'heartbeat'; reason: 'schedule' | 'manual'; silent: boolean; text: string; eventId: string }
118
+ | { kind: 'reminder'; reminderId: string; recurring: boolean; text: string; eventId: string };
119
+
120
+ interface HeartbeatState {
121
+ v: 1;
122
+ settings: HeartbeatSettings;
123
+ /** When the next tick is due (ms); null while paused. */
124
+ nextFireAt: number | null;
125
+ reminders: Reminder[];
126
+ }
127
+
128
+ const MIN_HEARTBEAT_SECONDS = 60;
129
+ const MIN_REMINDER_SECONDS = 5;
130
+ /** What fell due while the host was down fires this long after start, once the host's servers have connected. */
131
+ const START_GRACE_MS = 10_000;
132
+ /** A delivery that throws is tried again this much later. */
133
+ const RETRY_MS = 60_000;
134
+ /** A reminder delivered this late or later says when it was due. */
135
+ const LATE_MS = 60_000;
136
+ /** The longest wait a timer holds; longer ones are re-armed. */
137
+ const MAX_TIMEOUT_MS = 2_147_483_647;
138
+
139
+ const INTERVAL_UNITS = [['intervalSeconds', 1], ['intervalMinutes', 60], ['intervalHours', 3600]] as const;
140
+ const DELAY_UNITS = [['inSeconds', 1], ['inMinutes', 60], ['inHours', 3600]] as const;
141
+ const EVERY_UNITS = [['everySeconds', 1], ['everyMinutes', 60], ['everyHours', 3600]] as const;
142
+
143
+ type Units = typeof INTERVAL_UNITS | typeof DELAY_UNITS | typeof EVERY_UNITS;
144
+
145
+ const tool = (name: string, description: string, properties: Record<string, ToolParameter>, required?: string[]): ToolDefinition => ({
146
+ name: `${HEARTBEAT_TOOL_PREFIX}${name}`,
147
+ description,
148
+ inputSchema: { type: 'object', properties, ...(required ? { required } : {}) },
149
+ });
150
+
151
+ function toolDefinitions(timeZone: string): ToolDefinition[] {
152
+ return [
153
+ tool('heartbeat_status',
154
+ 'Show your heartbeat schedule (interval, paused or not, when the next one is due, delivery mode, message) and how many reminders are pending.',
155
+ {}),
156
+ tool('heartbeat_configure',
157
+ 'Change your own ambient heartbeat. Give any of: the interval, in intervalSeconds, intervalMinutes and intervalHours, which add up, 60 seconds or more; paused; deliveryMode; message. It\'s saved, so it persists across restarts. Changing the interval or resuming starts the next interval now. With no arguments it changes nothing and shows the schedule. (Reminders are separate: see reminder_add.)',
158
+ {
159
+ intervalSeconds: { type: 'number', description: 'Wake interval in seconds. Adds to intervalMinutes and intervalHours; together, 60 seconds or more.' },
160
+ intervalMinutes: { type: 'number', description: 'Wake interval in minutes. Adds to intervalSeconds and intervalHours.' },
161
+ intervalHours: { type: 'number', description: 'Wake interval in hours. Adds to intervalSeconds and intervalMinutes.' },
162
+ paused: { type: 'boolean', description: 'Pause (true) or resume (false) heartbeats.' },
163
+ deliveryMode: { type: 'string', enum: ['message', 'silent'], description: 'message: a timestamped prompt in your window; silent: a private tick that stores no prompt and no automatic prose.' },
164
+ message: { type: 'string', description: 'The text a message-mode heartbeat brings you.' },
165
+ }),
166
+ tool('heartbeat_trigger', 'Fire one ambient heartbeat now. The schedule doesn\'t change.', {}),
167
+ tool('reminder_add',
168
+ 'Schedule a reminder that wakes you with a message. One-off: give a delay, in inSeconds, inMinutes and inHours, which add up, or a time, atIso. Repeating: give an interval, in everySeconds, everyMinutes and everyHours, which add up, 5 seconds or more; it first fires one interval out, or at the delay or atIso you also give. It\'s saved, so it survives restarts. Returns the reminder\'s id.',
169
+ {
170
+ message: { type: 'string', description: 'The text you get when it fires, after a header naming the reminder, when it was set (or its interval) and the current time: `[reminder <id>, set <time> · current time: <time>] <message>`.' },
171
+ inSeconds: { type: 'number', description: 'Fire this many seconds from now. Adds to inMinutes and inHours.' },
172
+ inMinutes: { type: 'number', description: 'Fire this many minutes from now. Adds to inSeconds and inHours.' },
173
+ inHours: { type: 'number', description: 'Fire this many hours from now. Adds to inSeconds and inMinutes.' },
174
+ atIso: { type: 'string', description: `When it fires, or first fires if it repeats: an ISO-8601 date/time. Without an offset it's read in ${timeZone}, and a date alone is midnight there; a wall time a clock change skips or repeats is refused, naming the offsets to choose from. Z or a numeric offset gives the instant directly (e.g. 2026-07-01T09:00:00-04:00). A past time fires at once.` },
175
+ everySeconds: { type: 'number', description: 'Repeat every N seconds. Adds to everyMinutes and everyHours; together, 5 seconds or more.' },
176
+ everyMinutes: { type: 'number', description: 'Repeat every N minutes. Adds to everySeconds and everyHours.' },
177
+ everyHours: { type: 'number', description: 'Repeat every N hours. Adds to everySeconds and everyMinutes.' },
178
+ },
179
+ ['message']),
180
+ tool('reminder_list', 'List your pending reminders: id, when each is due, whether it repeats, message.', {}),
181
+ tool('reminder_cancel', 'Cancel a reminder by its id (from reminder_add or reminder_list).',
182
+ { id: { type: 'string', description: 'The reminder\'s id, as reminder_add returned it and reminder_list shows it; it starts with rem_.' } },
183
+ ['id']),
184
+ ];
185
+ }
186
+
187
+ /** What a refused call left alone, by tool. */
188
+ const REFUSED: Record<string, string> = {
189
+ heartbeat_status: 'nothing was shown',
190
+ heartbeat_configure: 'nothing was changed',
191
+ heartbeat_trigger: 'no heartbeat was sent',
192
+ reminder_add: 'no reminder was set',
193
+ reminder_list: 'nothing was listed',
194
+ reminder_cancel: 'nothing was cancelled',
195
+ };
196
+
197
+ /**
198
+ * A time as the heartbeat's texts show it, to the second, with its offset and
199
+ * zone: `2026-10-26T09:00:00-07:00 [America/Los_Angeles]`, as heartbeat-mcpl
200
+ * wrote it, so the agent reads its ticks and reminders as before.
201
+ */
202
+ export function formatHeartbeatTime(value: number, timeZone: string): string {
203
+ const parts = new Intl.DateTimeFormat('en-CA', {
204
+ timeZone,
205
+ year: 'numeric', month: '2-digit', day: '2-digit',
206
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
207
+ hourCycle: 'h23', timeZoneName: 'longOffset',
208
+ }).formatToParts(new Date(value));
209
+ const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '';
210
+ const rawOffset = get('timeZoneName');
211
+ const offset = rawOffset === 'GMT' ? '+00:00' : rawOffset.replace('GMT', '');
212
+ return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}${offset} [${timeZone}]`;
213
+ }
214
+
215
+ /** A timer at an absolute time, re-armed past the longest wait a timer holds. */
216
+ function callAt(dueAt: number, now: () => number, onDue: () => void): () => void {
217
+ let timer: ReturnType<typeof setTimeout>;
218
+ const arm = (): void => {
219
+ const wait = Math.max(0, Math.min(dueAt - now(), MAX_TIMEOUT_MS));
220
+ timer = setTimeout(() => (now() < dueAt ? arm() : onDue()), wait);
221
+ };
222
+ arm();
223
+ return () => clearTimeout(timer);
224
+ }
225
+
226
+ /** The text a reminder brings: a header naming it, then its message. */
227
+ export function reminderText(rem: Reminder, now: number, timeZone: string): string {
228
+ let schedule = rem.everySeconds !== null ? `, repeats every ${rem.everySeconds}s` : `, set ${formatHeartbeatTime(rem.createdAt, timeZone)}`;
229
+ if (now - rem.fireAt >= LATE_MS) schedule += `, due ${formatHeartbeatTime(rem.fireAt, timeZone)}`;
230
+ return `[reminder ${rem.id}${schedule} · current time: ${formatHeartbeatTime(now, timeZone)}] ${rem.message}`;
231
+ }
232
+
233
+ /** A length of time as the agent is shown it. */
234
+ function shownLength(seconds: number): string {
235
+ if (seconds < 120) return `${seconds}s`;
236
+ if (seconds < 7200) return `${+(seconds / 60).toFixed(1)} min`;
237
+ return `${+(seconds / 3600).toFixed(2)} h`;
238
+ }
239
+
240
+ function isObject(x: unknown): x is Record<string, unknown> {
241
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
242
+ }
243
+
244
+ export class Heartbeat {
245
+ private readonly statePath: string;
246
+ private readonly defaults: HeartbeatSettings;
247
+ private readonly importFrom: HeartbeatOptions['importFrom'];
248
+ private readonly timeZone: string;
249
+ private readonly deliver: (wake: HeartbeatWake) => void;
250
+ private readonly now: () => number;
251
+ private readonly tools: ToolDefinition[];
252
+ private state: HeartbeatState;
253
+ /** Why the state file can't be written now, when it can't (its text couldn't be read and set aside). */
254
+ private unsaveable: string | null = null;
255
+ /** What the last save's failure was, for the tool that made the change. */
256
+ private lastSaveError: string | null = null;
257
+ private cancelTick: (() => void) | null = null;
258
+ private cancelReminders: (() => void) | null = null;
259
+ /** Nothing fires before this (ms): a start's grace for what fell due while the host was down. */
260
+ private notBefore = 0;
261
+ private started = false;
262
+
263
+ constructor(opts: {
264
+ statePath: string;
265
+ timeZone: string;
266
+ deliver: (wake: HeartbeatWake) => void;
267
+ defaults?: Partial<HeartbeatSettings>;
268
+ importFrom?: HeartbeatOptions['importFrom'];
269
+ now?: () => number;
270
+ }) {
271
+ this.statePath = opts.statePath;
272
+ this.timeZone = opts.timeZone;
273
+ this.deliver = opts.deliver;
274
+ this.importFrom = opts.importFrom;
275
+ this.now = opts.now ?? Date.now;
276
+ this.defaults = { ...DEFAULT_HEARTBEAT, ...readSettings(opts.defaults ?? {}, DEFAULT_HEARTBEAT, (note) => log(`defaults: ${note}`)) };
277
+ this.tools = toolDefinitions(this.timeZone);
278
+ this.state = { v: 1, settings: { ...this.defaults }, nextFireAt: null, reminders: [] };
279
+ }
280
+
281
+ /** Load (or import, or create) the state and arm the timers. */
282
+ start(): void {
283
+ if (this.started) return;
284
+ this.started = true;
285
+ const now = this.now();
286
+ this.notBefore = now + START_GRACE_MS;
287
+ this.state = this.load(now);
288
+ const s = this.state;
289
+ if (s.settings.paused) {
290
+ s.nextFireAt = null;
291
+ } else if (s.nextFireAt === null || !Number.isFinite(s.nextFireAt)) {
292
+ s.nextFireAt = now + s.settings.intervalSeconds * 1000;
293
+ }
294
+ this.save();
295
+ this.armTick();
296
+ this.armReminders();
297
+ log(`started: ${this.statusLine()}`);
298
+ }
299
+
300
+ /** Clear the timers; the state stays saved. */
301
+ stop(): void {
302
+ this.started = false;
303
+ this.cancelTick?.();
304
+ this.cancelTick = null;
305
+ this.cancelReminders?.();
306
+ this.cancelReminders = null;
307
+ }
308
+
309
+ /** The tools, named as agents call them (`heartbeat--…`). */
310
+ getTools(): ToolDefinition[] {
311
+ return this.tools;
312
+ }
313
+
314
+ /** Whether `name` is one of the heartbeat's tools, by its name or heartbeat-mcpl's. */
315
+ ownsTool(name: string): boolean {
316
+ return this.tools.some((t) => t.name === canonicalName(name));
317
+ }
318
+
319
+ /** What the heartbeat will wake the agent with, soonest first, for the reply judge (UpcomingWake). */
320
+ upcomingWakes(): UpcomingWake[] {
321
+ const wakes: UpcomingWake[] = [];
322
+ const s = this.state;
323
+ if (!s.settings.paused && s.nextFireAt !== null) {
324
+ wakes.push({
325
+ at: Math.max(s.nextFireAt, this.notBefore),
326
+ certain: true,
327
+ what: `its heartbeat (${s.settings.deliveryMode === 'silent' ? 'a silent tick' : 'a check-in prompt'}, every ${shownLength(s.settings.intervalSeconds)})`,
328
+ });
329
+ }
330
+ for (const r of s.reminders) {
331
+ const message = r.message.length > 80 ? `${r.message.slice(0, 77)}...` : r.message;
332
+ wakes.push({
333
+ at: Math.max(dueAt(r), this.notBefore),
334
+ certain: true,
335
+ what: `its reminder ${JSON.stringify(message)}${r.everySeconds !== null ? ` (repeats every ${shownLength(r.everySeconds)})` : ''}`,
336
+ });
337
+ }
338
+ return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
339
+ }
340
+
341
+ /**
342
+ * Handle a call to one of the tools, by its full name. Arguments are read
343
+ * as a wake rule's are: a slip of a parameter's name, or a field given
344
+ * outside the parameter it belongs to, refuses the call, naming it; an
345
+ * unknown name is noted in the result.
346
+ */
347
+ handleToolCall(calledName: string, rawInput: unknown): { success: boolean; data?: string; error?: string; isError?: boolean } {
348
+ const fullName = canonicalName(calledName);
349
+ const def = this.tools.find((t) => t.name === fullName);
350
+ const name = fullName.slice(HEARTBEAT_TOOL_PREFIX.length);
351
+ if (!def) return { success: false, error: `There's no heartbeat tool named ${JSON.stringify(calledName)}.`, isError: true };
352
+ const renamed = calledName !== fullName
353
+ ? ` (The heartbeat's tools are now ${HEARTBEAT_TOOL_PREFIX}…: this is ${fullName}. ${LEGACY_HEARTBEAT_TOOL_PREFIX}… still reaches them.)`
354
+ : '';
355
+ const ignored: string[] = [];
356
+ let args: Record<string, unknown>;
357
+ try {
358
+ args = checkToolInput(fullName, isObject(rawInput) ? rawInput : {}, def.inputSchema.properties as Record<string, unknown>, ignored);
359
+ } catch (err) {
360
+ return { success: false, error: (err as Error).message, isError: true };
361
+ }
362
+ const problems: string[] = [];
363
+ let result: { text: string; error?: boolean };
364
+ switch (name) {
365
+ case 'heartbeat_configure': result = this.configure(args, problems); break;
366
+ case 'heartbeat_trigger': result = this.trigger(); break;
367
+ case 'reminder_add': result = this.addReminder(args, problems); break;
368
+ case 'reminder_list': result = { text: this.listText() }; break;
369
+ case 'reminder_cancel': result = this.cancelReminder(args, problems); break;
370
+ default: result = { text: this.statusText() };
371
+ }
372
+ const text = `${ignored.length > 0 ? `${result.text} ${ignored.join(' ')}` : result.text}${renamed}`;
373
+ return result.error ? { success: false, error: text, isError: true } : { success: true, data: text };
374
+ }
375
+
376
+ // ===========================================================================
377
+ // Tools
378
+ // ===========================================================================
379
+
380
+ private refuse(tool: string, problems: readonly string[]): { text: string; error: true } {
381
+ return { text: `${HEARTBEAT_TOOL_PREFIX}${tool}: ${REFUSED[tool]}. ${problems.join(' ')}`, error: true };
382
+ }
383
+
384
+ private statusLine(): string {
385
+ const s = this.state.settings;
386
+ const next = !s.paused && this.state.nextFireAt !== null
387
+ ? ` | next in ~${Math.max(0, Math.round((Math.max(this.state.nextFireAt, this.notBefore) - this.now()) / 1000))}s`
388
+ : '';
389
+ return `Heartbeat ${s.paused ? 'PAUSED' : 'ACTIVE'} | mode=${s.deliveryMode} | interval=${s.intervalSeconds}s (${(s.intervalSeconds / 3600).toFixed(2)}h)`
390
+ + `${next} | reminders=${this.state.reminders.length}`;
391
+ }
392
+
393
+ private statusText(): string {
394
+ return `${this.statusLine()} | message="${this.state.settings.message}"${this.saveNote()}`;
395
+ }
396
+
397
+ private saveNote(): string {
398
+ if (this.unsaveable) return ` (Not saved: ${this.unsaveable}.)`;
399
+ if (this.lastSaveError) return ` (Not saved: writing ${this.statePath} failed: ${this.lastSaveError}.)`;
400
+ return '';
401
+ }
402
+
403
+ private configure(args: Record<string, unknown>, problems: string[]): { text: string; error?: true } {
404
+ const interval = addUnits(args, INTERVAL_UNITS, problems);
405
+ if (interval !== null && interval < MIN_HEARTBEAT_SECONDS) {
406
+ problems.push(`The interval comes to ${interval} s, under the heartbeat's ${MIN_HEARTBEAT_SECONDS}-second floor; give ${MIN_HEARTBEAT_SECONDS} seconds or more, or pause the heartbeat with "paused": true.`);
407
+ } else if (interval !== null && !canHoldTime(this.now() + interval * 1000)) {
408
+ problems.push(`The interval puts the next heartbeat past ${LAST_REMINDER_TIME}; give a shorter one, or pause the heartbeat with "paused": true.`);
409
+ }
410
+ if (args.paused !== undefined && typeof args.paused !== 'boolean') {
411
+ problems.push(`"paused" is ${shownValue(args.paused)}, where true or false is needed.`);
412
+ }
413
+ if (args.deliveryMode !== undefined && args.deliveryMode !== 'message' && args.deliveryMode !== 'silent') {
414
+ problems.push(`"deliveryMode" is ${shownValue(args.deliveryMode)}, where "message" or "silent" is needed.`);
415
+ }
416
+ if (args.message !== undefined && (typeof args.message !== 'string' || !args.message.trim())) {
417
+ problems.push(`"message" is ${shownValue(args.message)}; give the text each heartbeat should bring, or leave it out to keep the one it has.`);
418
+ }
419
+ if (problems.length > 0) return this.refuse('heartbeat_configure', problems);
420
+ const s = this.state.settings;
421
+ let changed = false;
422
+ let reschedule = false;
423
+ if (interval !== null && interval !== s.intervalSeconds) { s.intervalSeconds = interval; changed = true; reschedule = true; }
424
+ if (typeof args.paused === 'boolean' && args.paused !== s.paused) { s.paused = args.paused; changed = true; reschedule = true; }
425
+ if ((args.deliveryMode === 'message' || args.deliveryMode === 'silent') && args.deliveryMode !== s.deliveryMode) {
426
+ s.deliveryMode = args.deliveryMode;
427
+ changed = true;
428
+ }
429
+ if (typeof args.message === 'string' && args.message !== s.message) { s.message = args.message; changed = true; }
430
+ if (reschedule) {
431
+ this.state.nextFireAt = s.paused ? null : this.now() + s.intervalSeconds * 1000;
432
+ this.armTick();
433
+ }
434
+ if (changed) this.save();
435
+ return { text: `${changed ? 'Updated.' : 'No changes.'} ${this.statusText()}` };
436
+ }
437
+
438
+ private trigger(): { text: string; error?: true } {
439
+ if (!this.fireTick('manual')) {
440
+ return { text: `${HEARTBEAT_TOOL_PREFIX}heartbeat_trigger: no heartbeat was sent: its delivery failed, as the log says. ${this.statusText()}`, error: true };
441
+ }
442
+ return { text: `Heartbeat fired. ${this.statusText()}` };
443
+ }
444
+
445
+ private addReminder(args: Record<string, unknown>, problems: string[]): { text: string; error?: true } {
446
+ const message = typeof args.message === 'string' ? args.message.trim() : '';
447
+ if (typeof args.message !== 'string') problems.push(`"message" is ${shownValue(args.message)}; give the text to deliver when it fires.`);
448
+ else if (!message) problems.push('"message" is blank; give the text to deliver when it fires.');
449
+ const delay = addUnits(args, DELAY_UNITS, problems);
450
+ const every = addUnits(args, EVERY_UNITS, problems);
451
+ const atGiven = args.atIso !== undefined;
452
+ if (every !== null && every < MIN_REMINDER_SECONDS) {
453
+ problems.push(`The repeat interval comes to ${every} s, under the ${MIN_REMINDER_SECONDS}-second floor; give ${MIN_REMINDER_SECONDS} seconds or more, or leave it out and give a delay or atIso for a one-off.`);
454
+ }
455
+ if (atGiven && delay !== null) problems.push('"atIso" and a delay both say when it first fires; give one of them.');
456
+ if (delay === null && !atGiven && every === null) {
457
+ problems.push('No time was given: give a delay in inSeconds, inMinutes or inHours, a time in atIso, or an interval to repeat in everySeconds, everyMinutes or everyHours.');
458
+ }
459
+ let at: number | null = null;
460
+ if (atGiven) {
461
+ if (typeof args.atIso !== 'string') {
462
+ problems.push(`"atIso" is ${shownValue(args.atIso)}, where an ISO-8601 date/time is needed.`);
463
+ } else {
464
+ try {
465
+ at = parseReminderTime(args.atIso, this.timeZone);
466
+ } catch (err) {
467
+ problems.push((err as Error).message);
468
+ }
469
+ }
470
+ }
471
+ const now = this.now();
472
+ const first = delay !== null ? now + delay * 1000 : at !== null ? at : every !== null ? now + every * 1000 : null;
473
+ if (first !== null && !canHoldTime(first)) {
474
+ problems.push(`That puts it past ${LAST_REMINDER_TIME}, the last time a reminder can hold; give a shorter delay or interval, or an earlier atIso.`);
475
+ } else if (first !== null && every !== null && !canHoldTime(first + every * 1000)) {
476
+ problems.push(`Its second fire would be past ${LAST_REMINDER_TIME}, the last time a reminder can hold; give a shorter interval.`);
477
+ }
478
+ if (problems.length > 0 || first === null) return this.refuse('reminder_add', problems);
479
+ const rem: Reminder = {
480
+ id: `rem_${now.toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
481
+ message,
482
+ createdAt: now,
483
+ fireAt: first,
484
+ everySeconds: every,
485
+ };
486
+ this.state.reminders.push(rem);
487
+ this.save();
488
+ this.armReminders();
489
+ log(`added reminder ${rem.id} fireAt=${new Date(first).toISOString()} every=${rem.everySeconds ?? 'once'}`);
490
+ return { text: `Reminder ${rem.id} set: ${this.describe(rem, now)}${this.saveNote()}` };
491
+ }
492
+
493
+ private describe(rem: Reminder, now: number): string {
494
+ const when = rem.retryAt !== undefined
495
+ ? `its delivery failed; trying again in ~${Math.max(0, Math.round((rem.retryAt - now) / 1000))}s`
496
+ : rem.fireAt <= now ? 'overdue' : `~${Math.round((rem.fireAt - now) / 1000)}s`;
497
+ const rec = rem.everySeconds !== null ? `repeats every ${rem.everySeconds}s` : 'one-shot';
498
+ return `due ${formatHeartbeatTime(rem.fireAt, this.timeZone)} (${when}) | ${rec} | "${rem.message}"`;
499
+ }
500
+
501
+ private listText(): string {
502
+ const reminders = [...this.state.reminders].sort((a, b) => a.fireAt - b.fireAt);
503
+ if (reminders.length === 0) return `No reminders set.${this.saveNote()}`;
504
+ const now = this.now();
505
+ return `${reminders.length} reminder(s):\n${reminders.map((r) => `${r.id} | ${this.describe(r, now)}`).join('\n')}${this.saveNote()}`;
506
+ }
507
+
508
+ private cancelReminder(args: Record<string, unknown>, problems: string[]): { text: string; error?: true } {
509
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
510
+ if (typeof args.id !== 'string' || !id) problems.push(`"id" is ${shownValue(args.id)}; give the reminder's id, as reminder_list shows it.`);
511
+ if (problems.length > 0) return this.refuse('reminder_cancel', problems);
512
+ const before = this.state.reminders.length;
513
+ this.state.reminders = this.state.reminders.filter((r) => r.id !== id);
514
+ if (this.state.reminders.length === before) {
515
+ return this.refuse('reminder_cancel', [`No reminder has the id "${id}". If it was a one-off that has fired, or it was cancelled already, there's nothing more to do; otherwise reminder_list shows the pending ids.`]);
516
+ }
517
+ this.save();
518
+ this.armReminders();
519
+ log(`cancelled reminder ${id}`);
520
+ return { text: `Cancelled ${id}. ${this.state.reminders.length} reminder(s) remain.${this.saveNote()}` };
521
+ }
522
+
523
+ // ===========================================================================
524
+ // Timers and delivery
525
+ // ===========================================================================
526
+
527
+ private armTick(): void {
528
+ this.cancelTick?.();
529
+ this.cancelTick = null;
530
+ const at = this.state.nextFireAt;
531
+ if (!this.started || this.state.settings.paused || at === null) return;
532
+ this.cancelTick = callAt(Math.max(at, this.notBefore), this.now, () => {
533
+ this.cancelTick = null;
534
+ // A tick whose delivery threw is tried again shortly, not a whole interval later.
535
+ const delivered = this.fireTick('schedule');
536
+ this.state.nextFireAt = this.now() + (delivered ? this.state.settings.intervalSeconds * 1000 : RETRY_MS);
537
+ this.save();
538
+ this.armTick();
539
+ });
540
+ }
541
+
542
+ /** Deliver one tick; false when delivery threw. */
543
+ private fireTick(reason: 'schedule' | 'manual'): boolean {
544
+ const now = this.now();
545
+ const s = this.state.settings;
546
+ const silent = s.deliveryMode === 'silent';
547
+ try {
548
+ this.deliver({
549
+ kind: 'heartbeat',
550
+ reason,
551
+ silent,
552
+ text: `[current time: ${formatHeartbeatTime(now, this.timeZone)}] ${s.message}`,
553
+ eventId: `heartbeat_${now}_${Math.random().toString(36).slice(2, 8)}`,
554
+ });
555
+ log(`fired heartbeat (${reason}${silent ? ', silent' : ''})`);
556
+ return true;
557
+ } catch (err) {
558
+ log(`heartbeat (${reason}) couldn't be delivered: ${(err as Error).message}`);
559
+ return false;
560
+ }
561
+ }
562
+
563
+ private armReminders(): void {
564
+ this.cancelReminders?.();
565
+ this.cancelReminders = null;
566
+ if (!this.started || this.state.reminders.length === 0) return;
567
+ const due = Math.min(...this.state.reminders.map(dueAt));
568
+ this.cancelReminders = callAt(Math.max(due, this.notBefore), this.now, () => {
569
+ this.cancelReminders = null;
570
+ this.fireDueReminders();
571
+ });
572
+ }
573
+
574
+ private fireDueReminders(): void {
575
+ const now = this.now();
576
+ const due = this.state.reminders.filter((r) => dueAt(r) <= now).sort((a, b) => dueAt(a) - dueAt(b));
577
+ for (const rem of due) {
578
+ const i = this.state.reminders.findIndex((r) => r.id === rem.id);
579
+ if (i === -1) continue;
580
+ try {
581
+ this.deliver({
582
+ kind: 'reminder',
583
+ reminderId: rem.id,
584
+ recurring: rem.everySeconds !== null,
585
+ text: reminderText(rem, now, this.timeZone),
586
+ eventId: `reminder_${now}_${Math.random().toString(36).slice(2, 8)}`,
587
+ });
588
+ } catch (err) {
589
+ // Its due time stays, for its header's "due" and its repeat's rhythm.
590
+ log(`reminder ${rem.id} couldn't be delivered (${(err as Error).message}); trying again in ${RETRY_MS / 1000}s`);
591
+ this.state.reminders[i] = { ...rem, retryAt: now + RETRY_MS };
592
+ continue;
593
+ }
594
+ if (rem.everySeconds === null) {
595
+ this.state.reminders.splice(i, 1);
596
+ log(`fired reminder ${rem.id} (one-shot)`);
597
+ continue;
598
+ }
599
+ // A repeating reminder fires once for periods it missed, then keeps its rhythm from now.
600
+ let next = rem.fireAt + rem.everySeconds * 1000;
601
+ if (next <= now) next = now + rem.everySeconds * 1000;
602
+ if (canHoldTime(next)) {
603
+ const { retryAt: _retried, ...kept } = rem;
604
+ this.state.reminders[i] = { ...kept, fireAt: next };
605
+ } else {
606
+ this.state.reminders.splice(i, 1);
607
+ log(`ended repeating reminder ${rem.id}: its next time is past ${LAST_REMINDER_TIME}`);
608
+ }
609
+ log(`fired reminder ${rem.id} (repeating)`);
610
+ }
611
+ this.save();
612
+ this.armReminders();
613
+ }
614
+
615
+ // ===========================================================================
616
+ // State
617
+ // ===========================================================================
618
+
619
+ private load(now: number): HeartbeatState {
620
+ // What an earlier start in this process couldn't read doesn't hold this one back.
621
+ this.unsaveable = null;
622
+ this.lastSaveError = null;
623
+ if (existsSync(this.statePath)) {
624
+ let text: string;
625
+ try {
626
+ text = readFileSync(this.statePath, 'utf-8');
627
+ } catch (err) {
628
+ this.unsaveable = `${this.statePath} couldn't be read (${(err as Error).message}), so nothing is written over it until a start reads it`;
629
+ log(`${this.unsaveable}; running on the defaults meanwhile`);
630
+ return { v: 1, settings: { ...this.defaults }, nextFireAt: null, reminders: [] };
631
+ }
632
+ const read = this.readState(text);
633
+ if (read) return read;
634
+ const aside = `${this.statePath}.unreadable-${now}`;
635
+ try {
636
+ renameSync(this.statePath, aside);
637
+ log(`${this.statePath} isn't state this framework writes; moved it to ${aside} and starting from the defaults`);
638
+ } catch (err) {
639
+ this.unsaveable = `${this.statePath} isn't state this framework writes and couldn't be moved aside (${(err as Error).message})`;
640
+ log(`${this.unsaveable}; running on the defaults meanwhile`);
641
+ }
642
+ return { v: 1, settings: { ...this.defaults }, nextFireAt: null, reminders: [] };
643
+ }
644
+ return this.imported() ?? { v: 1, settings: { ...this.defaults }, nextFireAt: null, reminders: [] };
645
+ }
646
+
647
+ /** The state a file holds, or undefined when it isn't state this framework writes. */
648
+ private readState(text: string): HeartbeatState | undefined {
649
+ let raw: unknown;
650
+ try {
651
+ raw = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
652
+ } catch {
653
+ return undefined;
654
+ }
655
+ if (!isObject(raw) || raw.v !== 1 || !isObject(raw.settings) || !Array.isArray(raw.reminders)) return undefined;
656
+ const settings = { ...this.defaults, ...readSettings(raw.settings, this.defaults, (note) => log(`${this.statePath}: ${note}`)) };
657
+ const nextFireAt = typeof raw.nextFireAt === 'number' && canHoldTime(raw.nextFireAt) ? raw.nextFireAt : null;
658
+ return { v: 1, settings, nextFireAt, reminders: readReminders(raw.reminders, (note) => log(`${this.statePath}: ${note}`)) };
659
+ }
660
+
661
+ /** heartbeat-mcpl's files, read once when there's no state file and the host names them. */
662
+ private imported(): HeartbeatState | undefined {
663
+ const from = this.importFrom;
664
+ if (!from?.configPath && !from?.remindersPath) return undefined;
665
+ // A file that's there but can't be read leaves the import unfinished:
666
+ // nothing is saved, so the next start tries it again, and meanwhile the
667
+ // agent runs on what could be read, which its tools say isn't saved.
668
+ const failed: string[] = [];
669
+ const readJson = (path: string | undefined, what: string): unknown => {
670
+ if (!path || !existsSync(path)) return undefined;
671
+ try {
672
+ const text = readFileSync(path, 'utf-8');
673
+ return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
674
+ } catch (err) {
675
+ failed.push(`heartbeat-mcpl's ${what} at ${path} couldn't be read (${(err as Error).message})`);
676
+ return undefined;
677
+ }
678
+ };
679
+ const config = readJson(from.configPath, 'schedule');
680
+ const reminders = readJson(from.remindersPath, 'reminders');
681
+ if (failed.length > 0) {
682
+ this.unsaveable = `${failed.join(', and ')}, so the import isn't finished and nothing is saved until a start can read it`;
683
+ log(this.unsaveable);
684
+ }
685
+ if (config === undefined && reminders === undefined) {
686
+ return failed.length > 0 ? { v: 1, settings: { ...this.defaults }, nextFireAt: null, reminders: [] } : undefined;
687
+ }
688
+ const settings = { ...this.defaults };
689
+ if (isObject(config)) {
690
+ const stored = config.intervalSeconds;
691
+ // heartbeat-mcpl saved an infinite interval as null, and loaded it paused.
692
+ Object.assign(settings, readSettings(
693
+ stored === null ? { ...config, intervalSeconds: undefined, paused: true } : config,
694
+ this.defaults,
695
+ (note) => log(`importing ${from.configPath}: ${note}`),
696
+ ));
697
+ }
698
+ const rows = Array.isArray(reminders) ? readReminders(reminders, (note) => log(`importing ${from.remindersPath}: ${note}`)) : [];
699
+ log(`imported heartbeat-mcpl's ${[config !== undefined ? `schedule (${from.configPath})` : '', reminders !== undefined ? `${rows.length} reminder(s) (${from.remindersPath})` : ''].filter(Boolean).join(' and ')}; the files are left as they are`);
700
+ return { v: 1, settings, nextFireAt: null, reminders: rows };
701
+ }
702
+
703
+ private save(): void {
704
+ if (this.unsaveable) return;
705
+ try {
706
+ mkdirSync(dirname(this.statePath), { recursive: true });
707
+ const tmp = `${this.statePath}.tmp-${process.pid}`;
708
+ writeFileSync(tmp, JSON.stringify(this.state, null, 2));
709
+ renameSync(tmp, this.statePath);
710
+ this.lastSaveError = null;
711
+ } catch (err) {
712
+ this.lastSaveError = (err as Error).message;
713
+ log(`writing ${this.statePath} failed: ${this.lastSaveError}`);
714
+ }
715
+ }
716
+ }
717
+
718
+ /** A schedule's fields as a file or the host gives them: each one usable, read; any other, noted and left to `fallback`. */
719
+ function readSettings(raw: Record<string, unknown>, fallback: HeartbeatSettings, note: (text: string) => void): Partial<HeartbeatSettings> {
720
+ const out: Partial<HeartbeatSettings> = {};
721
+ const interval = raw.intervalSeconds;
722
+ if (interval !== undefined) {
723
+ if (typeof interval !== 'number' || !Number.isFinite(interval)) {
724
+ note(`intervalSeconds ${shownValue(interval)} isn't a number of seconds; using ${fallback.intervalSeconds}`);
725
+ } else if (interval < MIN_HEARTBEAT_SECONDS) {
726
+ note(`intervalSeconds ${interval} is under the ${MIN_HEARTBEAT_SECONDS}-second floor; using ${MIN_HEARTBEAT_SECONDS}`);
727
+ out.intervalSeconds = MIN_HEARTBEAT_SECONDS;
728
+ } else {
729
+ out.intervalSeconds = interval;
730
+ }
731
+ }
732
+ if (raw.paused !== undefined) {
733
+ if (typeof raw.paused === 'boolean') out.paused = raw.paused;
734
+ else note(`paused ${shownValue(raw.paused)} isn't true or false; using ${fallback.paused}`);
735
+ }
736
+ if (raw.deliveryMode !== undefined) {
737
+ if (raw.deliveryMode === 'message' || raw.deliveryMode === 'silent') out.deliveryMode = raw.deliveryMode;
738
+ else note(`deliveryMode ${shownValue(raw.deliveryMode)} isn't "message" or "silent"; using ${fallback.deliveryMode}`);
739
+ }
740
+ if (raw.message !== undefined) {
741
+ if (typeof raw.message === 'string' && raw.message.trim()) out.message = raw.message;
742
+ else note(`message ${shownValue(raw.message)} isn't text; using the default`);
743
+ }
744
+ return out;
745
+ }
746
+
747
+ /** Reminder rows as a file holds them: each one this framework (or heartbeat-mcpl) writes, read; any other, noted and dropped. */
748
+ function readReminders(rows: unknown[], note: (text: string) => void): Reminder[] {
749
+ const out: Reminder[] = [];
750
+ for (const row of rows) {
751
+ if (!isObject(row) || typeof row.id !== 'string' || typeof row.message !== 'string' || typeof row.createdAt !== 'number'
752
+ || typeof row.fireAt !== 'number' || !canHoldTime(row.fireAt)
753
+ || !(row.everySeconds === null || (typeof row.everySeconds === 'number' && Number.isFinite(row.everySeconds)))) {
754
+ note(`dropped reminder ${shownValue(row)}: it isn't a reminder this reads, or its time is outside ${FIRST_REMINDER_TIME} to ${LAST_REMINDER_TIME}`);
755
+ continue;
756
+ }
757
+ let every = row.everySeconds as number | null;
758
+ if (every !== null && every <= 0) {
759
+ note(`reminder ${row.id}: interval ${every}s is no interval; it fires once`);
760
+ every = null;
761
+ } else if (every !== null && every < MIN_REMINDER_SECONDS) {
762
+ note(`reminder ${row.id}: interval ${every}s is under the ${MIN_REMINDER_SECONDS}-second floor; using ${MIN_REMINDER_SECONDS}`);
763
+ every = MIN_REMINDER_SECONDS;
764
+ }
765
+ out.push({
766
+ id: row.id, message: row.message, createdAt: row.createdAt, fireAt: row.fireAt, everySeconds: every,
767
+ ...(typeof row.retryAt === 'number' && canHoldTime(row.retryAt) ? { retryAt: row.retryAt } : {}),
768
+ });
769
+ }
770
+ return out;
771
+ }
772
+
773
+ /** A tool's name as the heartbeat lists it, for a call by either prefix. */
774
+ function canonicalName(name: string): string {
775
+ return name.startsWith(LEGACY_HEARTBEAT_TOOL_PREFIX)
776
+ ? `${HEARTBEAT_TOOL_PREFIX}${name.slice(LEGACY_HEARTBEAT_TOOL_PREFIX.length)}`
777
+ : name;
778
+ }
779
+
780
+ /** When a reminder is next tried: its retry, after a delivery that failed, or its due time. */
781
+ function dueAt(rem: Reminder): number {
782
+ return rem.retryAt ?? rem.fireAt;
783
+ }
784
+
785
+ /** A length given in units that add up (seconds, minutes, hours), in seconds; null when none was given. */
786
+ function addUnits(args: Record<string, unknown>, units: Units, problems: string[]): number | null {
787
+ let total = 0;
788
+ let given = false;
789
+ for (const [name, factor] of units) {
790
+ const value = args[name];
791
+ if (value === undefined) continue;
792
+ given = true;
793
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
794
+ problems.push(`"${name}" is ${shownValue(value)}, where a number 0 or more is needed.`);
795
+ continue;
796
+ }
797
+ total += value * factor;
798
+ }
799
+ return given ? Math.round(total * 1000) / 1000 : null;
800
+ }
801
+
802
+ function log(text: string): void {
803
+ console.error(`[heartbeat] ${text}`);
804
+ }