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