0xkobold 0.7.2 → 0.8.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 (252) hide show
  1. package/HEARTBEAT.md +239 -0
  2. package/IDENTITY.md +12 -0
  3. package/README.md +138 -4
  4. package/SOUL.md +21 -0
  5. package/dist/package.json +10 -5
  6. package/dist/src/agent/bootstrap-loader.js +295 -66
  7. package/dist/src/agent/bootstrap-loader.js.map +1 -1
  8. package/dist/src/agent/context-pruning.js +10 -5
  9. package/dist/src/agent/context-pruning.js.map +1 -1
  10. package/dist/src/agent/embedded-runner.js +29 -15
  11. package/dist/src/agent/embedded-runner.js.map +1 -1
  12. package/dist/src/agent/index.js +5 -2
  13. package/dist/src/agent/index.js.map +1 -1
  14. package/dist/src/agent/system-prompt.js +167 -20
  15. package/dist/src/agent/system-prompt.js.map +1 -1
  16. package/dist/src/agent/tools/spawn-agent.js +72 -5
  17. package/dist/src/agent/tools/spawn-agent.js.map +1 -1
  18. package/dist/src/channels/slack/webhook.js +2 -2
  19. package/dist/src/channels/slack/webhook.js.map +1 -1
  20. package/dist/src/channels/telegram/bot.js +4 -4
  21. package/dist/src/channels/telegram/bot.js.map +1 -1
  22. package/dist/src/channels/whatsapp/integration.js +4 -4
  23. package/dist/src/channels/whatsapp/integration.js.map +1 -1
  24. package/dist/src/cli/commands/gateway.js +9 -10
  25. package/dist/src/cli/commands/gateway.js.map +1 -1
  26. package/dist/src/cli/commands/init.js +90 -0
  27. package/dist/src/cli/commands/init.js.map +1 -1
  28. package/dist/src/cli/commands/setup.js +53 -0
  29. package/dist/src/cli/commands/setup.js.map +1 -1
  30. package/dist/src/cli/index.js +0 -0
  31. package/dist/src/config/unified-config.js +5 -0
  32. package/dist/src/config/unified-config.js.map +1 -1
  33. package/dist/src/cron/index.js +2 -0
  34. package/dist/src/cron/index.js.map +1 -1
  35. package/dist/src/cron/nl-parser.js +522 -0
  36. package/dist/src/cron/nl-parser.js.map +1 -0
  37. package/dist/src/cron/runner.js +6 -31
  38. package/dist/src/cron/runner.js.map +1 -1
  39. package/dist/src/event-bus/index.js.map +1 -1
  40. package/dist/src/extensions/core/agent-orchestrator-extension.js +200 -148
  41. package/dist/src/extensions/core/agent-orchestrator-extension.js.map +1 -1
  42. package/dist/src/extensions/core/diagnostics-extension.js +93 -56
  43. package/dist/src/extensions/core/diagnostics-extension.js.map +1 -1
  44. package/dist/src/extensions/core/draconic-safety-extension.js +256 -3
  45. package/dist/src/extensions/core/draconic-safety-extension.js.map +1 -1
  46. package/dist/src/extensions/core/heartbeat-extension.js +416 -150
  47. package/dist/src/extensions/core/heartbeat-extension.js.map +1 -1
  48. package/dist/src/extensions/core/{generative-agents-extension.js → learning-extension.js} +90 -128
  49. package/dist/src/extensions/core/learning-extension.js.map +1 -0
  50. package/dist/src/extensions/core/perennial-memory-extension.js +884 -87
  51. package/dist/src/extensions/core/perennial-memory-extension.js.map +1 -1
  52. package/dist/src/extensions/core/routed-ollama-extension.js +345 -0
  53. package/dist/src/extensions/core/routed-ollama-extension.js.map +1 -0
  54. package/dist/src/extensions/core/task-manager-extension.js +25 -2
  55. package/dist/src/extensions/core/task-manager-extension.js.map +1 -1
  56. package/dist/src/extensions/core/websearch-enhanced-extension.js +81 -23
  57. package/dist/src/extensions/core/websearch-enhanced-extension.js.map +1 -1
  58. package/dist/src/extensions/core/workspace-footer-extension.js +40 -63
  59. package/dist/src/extensions/core/workspace-footer-extension.js.map +1 -1
  60. package/dist/src/extensions/loader.js +5 -1
  61. package/dist/src/extensions/loader.js.map +1 -1
  62. package/dist/src/gateway/gateway-server.js +74 -3
  63. package/dist/src/gateway/gateway-server.js.map +1 -1
  64. package/dist/src/gateway/index.js +2 -2
  65. package/dist/src/gateway/index.js.map +1 -1
  66. package/dist/src/gateway/methods/agent.js +1 -1
  67. package/dist/src/gateway/methods/agent.js.map +1 -1
  68. package/dist/src/gateway/methods/index.js +4 -0
  69. package/dist/src/gateway/methods/index.js.map +1 -1
  70. package/dist/src/gateway/methods/node.js +261 -0
  71. package/dist/src/gateway/methods/node.js.map +1 -0
  72. package/dist/src/gateway/queue-modes.js +356 -0
  73. package/dist/src/gateway/queue-modes.js.map +1 -0
  74. package/dist/src/index.js +47 -6
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/llm/community-analytics.js +569 -0
  77. package/dist/src/llm/community-analytics.js.map +1 -0
  78. package/dist/src/llm/index.js +28 -4
  79. package/dist/src/llm/index.js.map +1 -1
  80. package/dist/src/llm/model-discovery.js +335 -0
  81. package/dist/src/llm/model-discovery.js.map +1 -0
  82. package/dist/src/llm/model-popularity.js +566 -0
  83. package/dist/src/llm/model-popularity.js.map +1 -0
  84. package/dist/src/llm/model-scoring-db.js +553 -0
  85. package/dist/src/llm/model-scoring-db.js.map +1 -0
  86. package/dist/src/llm/multi-provider.js +258 -0
  87. package/dist/src/llm/multi-provider.js.map +1 -0
  88. package/dist/src/llm/ollama.js +133 -187
  89. package/dist/src/llm/ollama.js.map +1 -1
  90. package/dist/src/llm/router-commands.js +773 -0
  91. package/dist/src/llm/router-commands.js.map +1 -0
  92. package/dist/src/llm/router-core.js +600 -0
  93. package/dist/src/llm/router-core.js.map +1 -0
  94. package/dist/src/memory/checkpoint-manager.js +278 -0
  95. package/dist/src/memory/checkpoint-manager.js.map +1 -0
  96. package/dist/src/memory/conflict-detector.js +279 -0
  97. package/dist/src/memory/conflict-detector.js.map +1 -0
  98. package/dist/src/memory/context-graph.js +360 -0
  99. package/dist/src/memory/context-graph.js.map +1 -0
  100. package/dist/src/memory/dialectic/benchmark-cli.js +200 -0
  101. package/dist/src/memory/dialectic/benchmark-cli.js.map +1 -0
  102. package/dist/src/memory/dialectic/debug-reasoning.js +37 -0
  103. package/dist/src/memory/dialectic/debug-reasoning.js.map +1 -0
  104. package/dist/src/memory/dialectic/index.js +135 -0
  105. package/dist/src/memory/dialectic/index.js.map +1 -0
  106. package/dist/src/memory/dialectic/nudges.js +380 -0
  107. package/dist/src/memory/dialectic/nudges.js.map +1 -0
  108. package/dist/src/memory/dialectic/reasoning-engine.js +607 -0
  109. package/dist/src/memory/dialectic/reasoning-engine.js.map +1 -0
  110. package/dist/src/memory/dialectic/reasoning.js +390 -0
  111. package/dist/src/memory/dialectic/reasoning.js.map +1 -0
  112. package/dist/src/memory/dialectic/skill-creation.js +481 -0
  113. package/dist/src/memory/dialectic/skill-creation.js.map +1 -0
  114. package/dist/src/memory/dialectic/store.js +663 -0
  115. package/dist/src/memory/dialectic/store.js.map +1 -0
  116. package/dist/src/memory/dialectic/types.js +11 -0
  117. package/dist/src/memory/dialectic/types.js.map +1 -0
  118. package/dist/src/memory/index.js +24 -2
  119. package/dist/src/memory/index.js.map +1 -1
  120. package/dist/src/memory/memory-decay.js +350 -0
  121. package/dist/src/memory/memory-decay.js.map +1 -0
  122. package/dist/src/memory/memory-integration.js +5 -5
  123. package/dist/src/memory/memory-integration.js.map +1 -1
  124. package/dist/src/memory/migrate-memory-stream.js +97 -0
  125. package/dist/src/memory/migrate-memory-stream.js.map +1 -0
  126. package/dist/src/memory/session-memory-bridge.js +49 -5
  127. package/dist/src/memory/session-memory-bridge.js.map +1 -1
  128. package/dist/src/memory/session-store.js +123 -0
  129. package/dist/src/memory/session-store.js.map +1 -1
  130. package/dist/src/memory/smart-write-rules.js +164 -0
  131. package/dist/src/memory/smart-write-rules.js.map +1 -0
  132. package/dist/src/memory/tiered-memory.js +436 -0
  133. package/dist/src/memory/tiered-memory.js.map +1 -0
  134. package/dist/src/memory/types.js +6 -0
  135. package/dist/src/memory/types.js.map +1 -0
  136. package/dist/src/memory/verify-migration.js +99 -0
  137. package/dist/src/memory/verify-migration.js.map +1 -0
  138. package/dist/src/pi-config.js +11 -9
  139. package/dist/src/pi-config.js.map +1 -1
  140. package/dist/src/skills/conditional-skills.js +464 -0
  141. package/dist/src/skills/conditional-skills.js.map +1 -0
  142. package/dist/src/skills/index.js +5 -0
  143. package/dist/src/skills/index.js.map +1 -1
  144. package/dist/src/skills/loader.js +56 -0
  145. package/dist/src/skills/loader.js.map +1 -1
  146. package/dist/src/skills/skill-manage.js +417 -0
  147. package/dist/src/skills/skill-manage.js.map +1 -0
  148. package/dist/src/tui/commands/orchestration-commands.js +62 -0
  149. package/dist/src/tui/commands/orchestration-commands.js.map +1 -1
  150. package/package.json +10 -5
  151. package/skills/model-router-test.ts +65 -0
  152. package/dist/src/extensions/core/auto-security-scan-extension.js +0 -41
  153. package/dist/src/extensions/core/auto-security-scan-extension.js.map +0 -1
  154. package/dist/src/extensions/core/cloudflare-browser-extension.js +0 -389
  155. package/dist/src/extensions/core/cloudflare-browser-extension.js.map +0 -1
  156. package/dist/src/extensions/core/compaction-safeguard.js +0 -223
  157. package/dist/src/extensions/core/compaction-safeguard.js.map +0 -1
  158. package/dist/src/extensions/core/generative-agents-extension.js.map +0 -1
  159. package/dist/src/extensions/core/obsidian-bridge-extension.js +0 -488
  160. package/dist/src/extensions/core/obsidian-bridge-extension.js.map +0 -1
  161. package/dist/src/llm/router.js +0 -145
  162. package/dist/src/llm/router.js.map +0 -1
  163. package/skills/kobold-scan-skill/node_modules/.package-lock.json +0 -172
  164. package/skills/kobold-scan-skill/node_modules/ansi-styles/index.d.ts +0 -345
  165. package/skills/kobold-scan-skill/node_modules/ansi-styles/index.js +0 -163
  166. package/skills/kobold-scan-skill/node_modules/ansi-styles/license +0 -9
  167. package/skills/kobold-scan-skill/node_modules/ansi-styles/package.json +0 -56
  168. package/skills/kobold-scan-skill/node_modules/ansi-styles/readme.md +0 -152
  169. package/skills/kobold-scan-skill/node_modules/balanced-match/.github/FUNDING.yml +0 -2
  170. package/skills/kobold-scan-skill/node_modules/balanced-match/LICENSE.md +0 -21
  171. package/skills/kobold-scan-skill/node_modules/balanced-match/README.md +0 -97
  172. package/skills/kobold-scan-skill/node_modules/balanced-match/index.js +0 -62
  173. package/skills/kobold-scan-skill/node_modules/balanced-match/package.json +0 -48
  174. package/skills/kobold-scan-skill/node_modules/brace-expansion/.github/FUNDING.yml +0 -2
  175. package/skills/kobold-scan-skill/node_modules/brace-expansion/LICENSE +0 -21
  176. package/skills/kobold-scan-skill/node_modules/brace-expansion/README.md +0 -135
  177. package/skills/kobold-scan-skill/node_modules/brace-expansion/index.js +0 -203
  178. package/skills/kobold-scan-skill/node_modules/brace-expansion/package.json +0 -49
  179. package/skills/kobold-scan-skill/node_modules/chalk/index.d.ts +0 -415
  180. package/skills/kobold-scan-skill/node_modules/chalk/license +0 -9
  181. package/skills/kobold-scan-skill/node_modules/chalk/package.json +0 -68
  182. package/skills/kobold-scan-skill/node_modules/chalk/readme.md +0 -341
  183. package/skills/kobold-scan-skill/node_modules/chalk/source/index.js +0 -229
  184. package/skills/kobold-scan-skill/node_modules/chalk/source/templates.js +0 -134
  185. package/skills/kobold-scan-skill/node_modules/chalk/source/util.js +0 -39
  186. package/skills/kobold-scan-skill/node_modules/color-convert/CHANGELOG.md +0 -54
  187. package/skills/kobold-scan-skill/node_modules/color-convert/LICENSE +0 -21
  188. package/skills/kobold-scan-skill/node_modules/color-convert/README.md +0 -68
  189. package/skills/kobold-scan-skill/node_modules/color-convert/conversions.js +0 -839
  190. package/skills/kobold-scan-skill/node_modules/color-convert/index.js +0 -81
  191. package/skills/kobold-scan-skill/node_modules/color-convert/package.json +0 -48
  192. package/skills/kobold-scan-skill/node_modules/color-convert/route.js +0 -97
  193. package/skills/kobold-scan-skill/node_modules/color-name/LICENSE +0 -8
  194. package/skills/kobold-scan-skill/node_modules/color-name/README.md +0 -11
  195. package/skills/kobold-scan-skill/node_modules/color-name/index.js +0 -152
  196. package/skills/kobold-scan-skill/node_modules/color-name/package.json +0 -28
  197. package/skills/kobold-scan-skill/node_modules/commander/LICENSE +0 -22
  198. package/skills/kobold-scan-skill/node_modules/commander/Readme.md +0 -1129
  199. package/skills/kobold-scan-skill/node_modules/commander/esm.mjs +0 -16
  200. package/skills/kobold-scan-skill/node_modules/commander/index.js +0 -27
  201. package/skills/kobold-scan-skill/node_modules/commander/lib/argument.js +0 -147
  202. package/skills/kobold-scan-skill/node_modules/commander/lib/command.js +0 -2179
  203. package/skills/kobold-scan-skill/node_modules/commander/lib/error.js +0 -45
  204. package/skills/kobold-scan-skill/node_modules/commander/lib/help.js +0 -461
  205. package/skills/kobold-scan-skill/node_modules/commander/lib/option.js +0 -326
  206. package/skills/kobold-scan-skill/node_modules/commander/lib/suggestSimilar.js +0 -100
  207. package/skills/kobold-scan-skill/node_modules/commander/package-support.json +0 -16
  208. package/skills/kobold-scan-skill/node_modules/commander/package.json +0 -80
  209. package/skills/kobold-scan-skill/node_modules/commander/typings/index.d.ts +0 -891
  210. package/skills/kobold-scan-skill/node_modules/fs.realpath/LICENSE +0 -43
  211. package/skills/kobold-scan-skill/node_modules/fs.realpath/README.md +0 -33
  212. package/skills/kobold-scan-skill/node_modules/fs.realpath/index.js +0 -66
  213. package/skills/kobold-scan-skill/node_modules/fs.realpath/old.js +0 -303
  214. package/skills/kobold-scan-skill/node_modules/fs.realpath/package.json +0 -26
  215. package/skills/kobold-scan-skill/node_modules/glob/LICENSE +0 -15
  216. package/skills/kobold-scan-skill/node_modules/glob/README.md +0 -399
  217. package/skills/kobold-scan-skill/node_modules/glob/common.js +0 -244
  218. package/skills/kobold-scan-skill/node_modules/glob/glob.js +0 -790
  219. package/skills/kobold-scan-skill/node_modules/glob/package.json +0 -55
  220. package/skills/kobold-scan-skill/node_modules/glob/sync.js +0 -486
  221. package/skills/kobold-scan-skill/node_modules/has-flag/index.d.ts +0 -39
  222. package/skills/kobold-scan-skill/node_modules/has-flag/index.js +0 -8
  223. package/skills/kobold-scan-skill/node_modules/has-flag/license +0 -9
  224. package/skills/kobold-scan-skill/node_modules/has-flag/package.json +0 -46
  225. package/skills/kobold-scan-skill/node_modules/has-flag/readme.md +0 -89
  226. package/skills/kobold-scan-skill/node_modules/inflight/LICENSE +0 -15
  227. package/skills/kobold-scan-skill/node_modules/inflight/README.md +0 -37
  228. package/skills/kobold-scan-skill/node_modules/inflight/inflight.js +0 -54
  229. package/skills/kobold-scan-skill/node_modules/inflight/package.json +0 -29
  230. package/skills/kobold-scan-skill/node_modules/inherits/LICENSE +0 -16
  231. package/skills/kobold-scan-skill/node_modules/inherits/README.md +0 -42
  232. package/skills/kobold-scan-skill/node_modules/inherits/inherits.js +0 -9
  233. package/skills/kobold-scan-skill/node_modules/inherits/inherits_browser.js +0 -27
  234. package/skills/kobold-scan-skill/node_modules/inherits/package.json +0 -29
  235. package/skills/kobold-scan-skill/node_modules/minimatch/LICENSE +0 -15
  236. package/skills/kobold-scan-skill/node_modules/minimatch/README.md +0 -259
  237. package/skills/kobold-scan-skill/node_modules/minimatch/lib/path.js +0 -4
  238. package/skills/kobold-scan-skill/node_modules/minimatch/minimatch.js +0 -944
  239. package/skills/kobold-scan-skill/node_modules/minimatch/package.json +0 -35
  240. package/skills/kobold-scan-skill/node_modules/once/LICENSE +0 -15
  241. package/skills/kobold-scan-skill/node_modules/once/README.md +0 -79
  242. package/skills/kobold-scan-skill/node_modules/once/once.js +0 -42
  243. package/skills/kobold-scan-skill/node_modules/once/package.json +0 -33
  244. package/skills/kobold-scan-skill/node_modules/supports-color/browser.js +0 -5
  245. package/skills/kobold-scan-skill/node_modules/supports-color/index.js +0 -135
  246. package/skills/kobold-scan-skill/node_modules/supports-color/license +0 -9
  247. package/skills/kobold-scan-skill/node_modules/supports-color/package.json +0 -53
  248. package/skills/kobold-scan-skill/node_modules/supports-color/readme.md +0 -76
  249. package/skills/kobold-scan-skill/node_modules/wrappy/LICENSE +0 -15
  250. package/skills/kobold-scan-skill/node_modules/wrappy/README.md +0 -36
  251. package/skills/kobold-scan-skill/node_modules/wrappy/package.json +0 -29
  252. package/skills/kobold-scan-skill/node_modules/wrappy/wrappy.js +0 -33
@@ -1,54 +1,104 @@
1
1
  /**
2
- * Heartbeat Extension - Koclaw-style Periodic Check-ins
2
+ * Heartbeat Extension - OpenClaw-Compatible Periodic Agent Check-ins
3
3
  *
4
- * Monitors agent health via periodic LLM check-ins using HEARTBEAT.md.
5
- * This is a simplified implementation inspired by koclaw/openclaw.
4
+ * Runs periodic agent turns in the main session so the model can surface
5
+ * anything that needs attention without spamming the user.
6
6
  *
7
- * How it works:
8
- * 1. Config from kobold.json (agents.defaults.heartbeat)
9
- * 2. Agent reads HEARTBEAT.md and decides if action is needed
10
- * 3. If response is HEARTBEAT_OK (with optional short text), it's suppressed
7
+ * OpenClaw-Compatible Configuration:
8
+ * - Per-agent or global configuration
9
+ * - Session isolation and light context options
10
+ * - Delivery targets (last, none, specific channel)
11
+ * - Active hours with timezone support
12
+ * - Response contract (HEARTBEAT_OK token handling)
11
13
  *
12
- * Configuration in kobold.json:
14
+ * Configuration in kobold.json or openclaw.json:
13
15
  * {
14
16
  * "agents": {
15
17
  * "defaults": {
16
18
  * "heartbeat": {
17
- * "enabled": true,
18
19
  * "every": "30m",
19
20
  * "prompt": "Read HEARTBEAT.md...",
20
21
  * "ackMaxChars": 300,
21
- * "activeHours": { "start": "09:00", "end": "22:00" }
22
+ * "target": "none",
23
+ * "activeHours": { "start": "09:00", "end": "22:00", "timezone": "America/New_York" },
24
+ * "isolatedSession": false,
25
+ * "lightContext": false,
26
+ * "model": null,
27
+ * "includeReasoning": false,
28
+ * "suppressToolErrorWarnings": false
22
29
  * }
23
- * }
30
+ * },
31
+ * "list": [
32
+ * {
33
+ * "id": "ops",
34
+ * "heartbeat": {
35
+ * "every": "1h",
36
+ * "target": "telegram",
37
+ * "to": "+15551234567"
38
+ * }
39
+ * }
40
+ * ]
24
41
  * }
25
42
  * }
26
43
  *
44
+ * Response Contract:
45
+ * - If nothing needs attention, reply with "HEARTBEAT_OK"
46
+ * - For alerts, do NOT include HEARTBEAT_OK
47
+ * - HEARTBEAT_OK at start or end is stripped; reply dropped if remaining ≤ ackMaxChars
48
+ *
27
49
  * For users: Edit HEARTBEAT.md in your workspace to customize checks.
28
50
  */
29
51
  import * as fs from "node:fs/promises";
30
52
  import * as path from "node:path";
53
+ import { fileURLToPath } from "node:url";
31
54
  import { loadConfig } from "../../config/loader.js";
32
- // Constants matching koclaw conventions
55
+ // Get the directory of this module for template resolution
56
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
57
+ const TEMPLATE_PATH = path.join(__dirname, "heartbeat-template.md");
58
+ // ════════════════════════════════════════════════════════════════════════════
59
+ // CONSTANTS
60
+ // ════════════════════════════════════════════════════════════════════════════
33
61
  const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
34
62
  const DEFAULT_EVERY = "30m";
35
63
  const DEFAULT_ACK_MAX_CHARS = 300;
36
64
  const HEARTBEAT_FILENAME = "HEARTBEAT.md";
65
+ // ════════════════════════════════════════════════════════════════════════════
66
+ // UTILITY FUNCTIONS
67
+ // ════════════════════════════════════════════════════════════════════════════
37
68
  function getDefaultConfig() {
38
69
  return {
39
70
  enabled: true,
40
71
  every: DEFAULT_EVERY,
41
- prompt: `Read HEARTBEAT.md if it exists. Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply ${HEARTBEAT_TOKEN}.`,
72
+ prompt: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply ${HEARTBEAT_TOKEN}.`,
42
73
  ackMaxChars: DEFAULT_ACK_MAX_CHARS,
74
+ target: "none",
43
75
  activeHours: null,
76
+ isolatedSession: false,
77
+ lightContext: false,
78
+ includeReasoning: false,
79
+ suppressToolErrorWarnings: false,
80
+ directPolicy: "allow",
81
+ };
82
+ }
83
+ function getDefaultState() {
84
+ return {
85
+ lastHeartbeat: null,
86
+ lastDelivery: null,
87
+ lastChannel: null,
88
+ skippedCount: 0,
89
+ ackCount: 0,
90
+ alertCount: 0,
44
91
  };
45
92
  }
46
- // Parse duration string to milliseconds
93
+ /**
94
+ * Parse duration string to milliseconds
95
+ * Supports: "30m", "1h", "2h", "1h30m", "90s"
96
+ */
47
97
  function parseDurationMs(duration) {
48
98
  const regex = /^(?:(\d+)h)?\s*(?:(\d+)m)?\s?(?:(\d+)s?)?$/i;
49
99
  const match = duration.trim().match(regex);
50
100
  if (!match) {
51
- console.warn(`[Heartbeat] Invalid duration "${duration}", using default`);
101
+ console.warn(`[Heartbeat] Invalid duration "${duration}", using default 30m`);
52
102
  return 30 * 60 * 1000;
53
103
  }
54
104
  const hours = parseInt(match[1] || "0", 10);
@@ -56,56 +106,71 @@ function parseDurationMs(duration) {
56
106
  const seconds = parseInt(match[3] || "0", 10);
57
107
  return ((hours * 60 + minutes) * 60 + seconds) * 1000;
58
108
  }
59
- // Read HEARTBEAT.md from workspace
60
- async function readHeartbeatFile(workspacePath) {
61
- try {
62
- const filePath = path.join(workspacePath, HEARTBEAT_FILENAME);
63
- const content = await fs.readFile(filePath, "utf-8");
64
- return content;
65
- }
66
- catch {
67
- return null;
68
- }
69
- }
70
- // Check if content is effectively empty
71
- function isEffectivelyEmpty(content) {
72
- const lines = content.split("\n");
73
- for (const line of lines) {
74
- const trimmed = line.trim();
75
- if (!trimmed)
76
- continue;
77
- if (/^#+\s/.test(trimmed))
78
- continue;
79
- if (/^[-*+]\s*(\[[\sXx]\]\s*)?$/.test(trimmed))
80
- continue;
81
- return false;
82
- }
83
- return true;
109
+ /**
110
+ * Parse time string "HH:MM" to minutes since midnight
111
+ */
112
+ function parseTimeToMinutes(timeStr) {
113
+ const [hours, minutes] = timeStr.split(":").map(Number);
114
+ return hours * 60 + minutes;
84
115
  }
85
- // Check if currently within active hours
116
+ /**
117
+ * Check if current time is within active hours
118
+ */
86
119
  function isWithinActiveHours(config) {
87
120
  if (!config.activeHours)
88
121
  return true;
89
122
  const now = new Date();
90
- const timeStr = now.toLocaleTimeString("en-US", {
91
- hour12: false,
92
- hour: "2-digit",
93
- minute: "2-digit",
94
- });
95
- const [currentHour, currentMin] = timeStr.split(":").map(Number);
96
- const [startHour, startMin] = config.activeHours.start.split(":").map(Number);
97
- const [endHour, endMin] = config.activeHours.end.split(":").map(Number);
98
- const currentMinutes = currentHour * 60 + currentMin;
99
- const startMinutes = startHour * 60 + startMin;
100
- const endMinutes = endHour * 60 + endMin;
101
- if (startMinutes === endMinutes)
102
- return true;
103
- if (startMinutes > endMinutes) {
104
- return currentMinutes >= startMinutes || currentMinutes < endMinutes;
123
+ // Get timezone
124
+ let tz;
125
+ if (config.activeHours.timezone === "local") {
126
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
127
+ }
128
+ else if (config.activeHours.timezone) {
129
+ tz = config.activeHours.timezone;
130
+ }
131
+ else {
132
+ // Try user timezone from config, fallback to local
133
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
134
+ }
135
+ try {
136
+ const formatter = new Intl.DateTimeFormat("en-US", {
137
+ timeZone: tz,
138
+ hour: "2-digit",
139
+ minute: "2-digit",
140
+ hour12: false,
141
+ });
142
+ const timeStr = formatter.format(now);
143
+ const [currentHour, currentMin] = timeStr.split(":").map(Number);
144
+ const currentMinutes = currentHour * 60 + currentMin;
145
+ const startMinutes = parseTimeToMinutes(config.activeHours.start);
146
+ const endMinutes = parseTimeToMinutes(config.activeHours.end);
147
+ // Handle wrap-around (e.g., 22:00 to 06:00)
148
+ if (startMinutes === endMinutes) {
149
+ // Zero-width window, always outside
150
+ return false;
151
+ }
152
+ if (startMinutes > endMinutes) {
153
+ // Wrap around midnight
154
+ return currentMinutes >= startMinutes || currentMinutes < endMinutes;
155
+ }
156
+ return currentMinutes >= startMinutes && currentMinutes < endMinutes;
157
+ }
158
+ catch {
159
+ // Invalid timezone, use local
160
+ const currentMinutes = now.getHours() * 60 + now.getMinutes();
161
+ const startMinutes = parseTimeToMinutes(config.activeHours.start);
162
+ const endMinutes = parseTimeToMinutes(config.activeHours.end);
163
+ if (startMinutes === endMinutes)
164
+ return false;
165
+ if (startMinutes > endMinutes) {
166
+ return currentMinutes >= startMinutes || currentMinutes < endMinutes;
167
+ }
168
+ return currentMinutes >= startMinutes && currentMinutes < endMinutes;
105
169
  }
106
- return currentMinutes >= startMinutes && currentMinutes < endMinutes;
107
170
  }
108
- // Find workspace directory
171
+ /**
172
+ * Find workspace directory containing HEARTBEAT.md
173
+ */
109
174
  async function findWorkspaceDir() {
110
175
  try {
111
176
  await fs.access(HEARTBEAT_FILENAME);
@@ -130,52 +195,126 @@ async function findWorkspaceDir() {
130
195
  }
131
196
  return process.cwd();
132
197
  }
133
- async function fileExists(filepath) {
198
+ /**
199
+ * Read HEARTBEAT.md from workspace
200
+ */
201
+ async function readHeartbeatFile(workspacePath) {
134
202
  try {
135
- await fs.access(filepath);
136
- return true;
203
+ const filePath = path.join(workspacePath, HEARTBEAT_FILENAME);
204
+ const content = await fs.readFile(filePath, "utf-8");
205
+ return content;
137
206
  }
138
207
  catch {
208
+ return null;
209
+ }
210
+ }
211
+ /**
212
+ * Check if content is effectively empty (no actionable items)
213
+ */
214
+ function isEffectivelyEmpty(content) {
215
+ const lines = content.split("\n");
216
+ for (const line of lines) {
217
+ const trimmed = line.trim();
218
+ if (!trimmed)
219
+ continue;
220
+ if (/^#+\s/.test(trimmed))
221
+ continue; // Headers
222
+ if (/^[-*+]\s*(\[[\sXx]\]\s*)?$/.test(trimmed))
223
+ continue; // Empty list items
224
+ if (/^<!--.*-->$/.test(trimmed))
225
+ continue; // HTML comments
226
+ if (/^\*\*.*\*\*$/.test(trimmed))
227
+ continue; // Bold standalone
139
228
  return false;
140
229
  }
230
+ return true;
141
231
  }
232
+ /**
233
+ * Process HEARTBEAT_OK response contract
234
+ * Returns { isAck, strippedContent }
235
+ */
236
+ function processHeartbeatResponse(content, ackMaxChars) {
237
+ const trimmed = content.trim();
238
+ // Check for HEARTBEAT_OK at start or end
239
+ const startsWithToken = trimmed.startsWith(HEARTBEAT_TOKEN);
240
+ const endsWithToken = trimmed.endsWith(HEARTBEAT_TOKEN);
241
+ if (!startsWithToken && !endsWithToken) {
242
+ // Not an ack - this is an alert
243
+ return { isAck: false, strippedContent: content, shouldDeliver: true };
244
+ }
245
+ // Strip the token
246
+ let stripped = trimmed;
247
+ if (startsWithToken) {
248
+ stripped = stripped.slice(HEARTBEAT_TOKEN.length).trim();
249
+ }
250
+ if (endsWithToken && !startsWithToken) {
251
+ stripped = stripped.slice(0, -HEARTBEAT_TOKEN.length).trim();
252
+ }
253
+ // Check remaining length
254
+ const shouldDeliver = stripped.length > ackMaxChars;
255
+ return {
256
+ isAck: true,
257
+ strippedContent: stripped,
258
+ shouldDeliver,
259
+ };
260
+ }
261
+ /**
262
+ * Load heartbeat configuration from kobold.json
263
+ */
264
+ async function loadHeartbeatConfig() {
265
+ const defaultConfig = getDefaultConfig();
266
+ try {
267
+ const snapshot = await loadConfig();
268
+ const hbConfig = snapshot.config.agents?.defaults?.heartbeat;
269
+ if (hbConfig) {
270
+ return {
271
+ enabled: hbConfig.enabled ?? defaultConfig.enabled,
272
+ every: hbConfig.every ?? defaultConfig.every,
273
+ prompt: hbConfig.prompt ?? defaultConfig.prompt,
274
+ ackMaxChars: hbConfig.ackMaxChars ?? defaultConfig.ackMaxChars,
275
+ target: hbConfig.target ?? defaultConfig.target,
276
+ to: hbConfig.to,
277
+ activeHours: hbConfig.activeHours ?? defaultConfig.activeHours,
278
+ isolatedSession: hbConfig.isolatedSession ?? defaultConfig.isolatedSession,
279
+ lightContext: hbConfig.lightContext ?? defaultConfig.lightContext,
280
+ model: hbConfig.model,
281
+ includeReasoning: hbConfig.includeReasoning ?? defaultConfig.includeReasoning,
282
+ suppressToolErrorWarnings: hbConfig.suppressToolErrorWarnings ?? defaultConfig.suppressToolErrorWarnings,
283
+ directPolicy: hbConfig.directPolicy ?? defaultConfig.directPolicy,
284
+ };
285
+ }
286
+ }
287
+ catch (err) {
288
+ console.warn("[Heartbeat] Failed to load config, using defaults:", err);
289
+ }
290
+ return defaultConfig;
291
+ }
292
+ // ════════════════════════════════════════════════════════════════════════════
293
+ // EXTENSION
294
+ // ════════════════════════════════════════════════════════════════════════════
142
295
  export default function heartbeatExtension(pi) {
143
296
  const startTime = Date.now();
144
297
  let checkInterval = null;
145
- let lastHeartbeat = 0;
146
- // Load config from kobold.json
147
298
  let config = getDefaultConfig();
299
+ let state = getDefaultState();
148
300
  let configLoaded = false;
149
- async function loadHeartbeatConfig() {
150
- try {
151
- const snapshot = await loadConfig();
152
- const hbConfig = snapshot.config.agents?.defaults?.heartbeat;
153
- if (hbConfig) {
154
- config = {
155
- enabled: hbConfig.enabled ?? config.enabled,
156
- every: hbConfig.every ?? config.every,
157
- prompt: hbConfig.prompt ?? config.prompt,
158
- ackMaxChars: hbConfig.ackMaxChars ?? config.ackMaxChars,
159
- activeHours: hbConfig.activeHours ?? config.activeHours,
160
- };
161
- }
162
- configLoaded = true;
163
- }
164
- catch (err) {
165
- console.warn('[Heartbeat] Failed to load config, using defaults:', err);
166
- }
301
+ // ═══════════════════════════════════════════════════════════════
302
+ // CONFIG LOADING
303
+ // ═══════════════════════════════════════════════════════════════
304
+ async function reloadConfig() {
305
+ config = await loadHeartbeatConfig();
306
+ configLoaded = true;
167
307
  }
168
308
  // Load config on startup
169
- loadHeartbeatConfig().then(() => {
309
+ reloadConfig().then(() => {
170
310
  console.log(`[Heartbeat] Config loaded (enabled: ${config.enabled}, every: ${config.every})`);
171
311
  });
172
- // Reload config on environment changes
173
312
  pi.on("session_start", async () => {
174
- await loadHeartbeatConfig();
313
+ await reloadConfig();
175
314
  console.log(`[Heartbeat] Extension loaded (enabled: ${config.enabled}, every: ${config.every})`);
176
315
  });
177
316
  // ═══════════════════════════════════════════════════════════════
178
- // REGISTER HEARTBEAT TOOL
317
+ // HEARTBEAT CHECK TOOL
179
318
  // ═══════════════════════════════════════════════════════════════
180
319
  pi.registerTool({
181
320
  name: "heartbeat_check",
@@ -189,68 +328,107 @@ export default function heartbeatExtension(pi) {
189
328
  type: "boolean",
190
329
  description: "Force a check even if recently performed",
191
330
  },
331
+ agent: {
332
+ type: "string",
333
+ description: "Agent ID to check (for per-agent configs)",
334
+ },
192
335
  },
193
336
  },
194
337
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
195
- const { force } = params;
338
+ const { force, agent } = params;
196
339
  const now = Date.now();
197
340
  const everyMs = parseDurationMs(config.every);
198
341
  // Check active hours
199
342
  if (!isWithinActiveHours(config)) {
200
343
  return {
201
- content: [{
344
+ content: [
345
+ {
202
346
  type: "text",
203
- text: "Heartbeat skipped - outside active hours. Reply HEARTBEAT_OK to acknowledge.",
204
- }],
205
- details: { skipped: true, reason: "outside_active_hours" },
347
+ text: `Heartbeat skipped - outside active hours (${config.activeHours?.start} - ${config.activeHours?.end}). Reply ${HEARTBEAT_TOKEN} to acknowledge.`,
348
+ },
349
+ ],
350
+ details: { skipped: true, reason: "outside_active_hours", activeHours: config.activeHours },
206
351
  };
207
352
  }
208
- if (!force && now - lastHeartbeat < everyMs) {
353
+ // Check if too soon (unless forced)
354
+ if (!force && state.lastHeartbeat && now - new Date(state.lastHeartbeat).getTime() < everyMs) {
355
+ const nextCheck = new Date(new Date(state.lastHeartbeat).getTime() + everyMs);
209
356
  return {
210
- content: [{
357
+ content: [
358
+ {
211
359
  type: "text",
212
- text: "Heartbeat skipped - too soon since last check. Reply HEARTBEAT_OK to acknowledge.",
213
- }],
214
- details: { skipped: true, lastHeartbeat },
360
+ text: `Heartbeat skipped - next check at ${nextCheck.toLocaleTimeString()}. Reply ${HEARTBEAT_TOKEN} to acknowledge.`,
361
+ },
362
+ ],
363
+ details: { skipped: true, reason: "too_soon", nextCheck: nextCheck.toISOString() },
215
364
  };
216
365
  }
366
+ // Find and read HEARTBEAT.md
217
367
  const workspaceDir = await findWorkspaceDir();
218
368
  const heartbeatContent = await readHeartbeatFile(workspaceDir);
219
369
  if (heartbeatContent === null) {
220
370
  return {
221
- content: [{
371
+ content: [
372
+ {
222
373
  type: "text",
223
374
  text: `No HEARTBEAT.md found in workspace. Reply ${HEARTBEAT_TOKEN} to acknowledge, or create the file to enable heartbeat checks.`,
224
- }],
225
- details: { fileExists: false },
375
+ },
376
+ ],
377
+ details: { fileExists: false, workspaceDir },
226
378
  };
227
379
  }
228
380
  if (isEffectivelyEmpty(heartbeatContent)) {
229
381
  return {
230
- content: [{
382
+ content: [
383
+ {
231
384
  type: "text",
232
- text: "HEARTBEAT.md is effectively empty (no actionable items). Reply HEARTBEAT_OK to acknowledge.",
233
- }],
385
+ text: `HEARTBEAT.md is effectively empty (no actionable items). Reply ${HEARTBEAT_TOKEN} to acknowledge.`,
386
+ },
387
+ ],
234
388
  details: { fileExists: true, isEmpty: true },
235
389
  };
236
390
  }
237
- lastHeartbeat = now;
391
+ // Update state
392
+ state.lastHeartbeat = new Date().toISOString();
393
+ // Build response with config context
394
+ const responseLines = [
395
+ `HEARTBEAT.md content:`,
396
+ "",
397
+ heartbeatContent,
398
+ "",
399
+ "---",
400
+ `Check interval: ${config.every}`,
401
+ config.activeHours ? `Active hours: ${config.activeHours.start} - ${config.activeHours.end}` : null,
402
+ `Delivery target: ${config.target}`,
403
+ "",
404
+ `Review the checklist above. If nothing needs attention, reply with "${HEARTBEAT_TOKEN}". Otherwise, describe what needs attention.`,
405
+ ].filter(Boolean);
238
406
  return {
239
- content: [{
407
+ content: [
408
+ {
240
409
  type: "text",
241
- text: `HEARTBEAT.md content:\n\n${heartbeatContent}\n\nReview the checklist above. If nothing needs attention, reply with "${HEARTBEAT_TOKEN}". Otherwise, describe what needs attention.`,
242
- }],
243
- details: { fileExists: true, isEmpty: false },
410
+ text: responseLines.join("\n"),
411
+ },
412
+ ],
413
+ details: {
414
+ fileExists: true,
415
+ isEmpty: false,
416
+ config: {
417
+ every: config.every,
418
+ target: config.target,
419
+ activeHours: config.activeHours,
420
+ },
421
+ },
244
422
  };
245
423
  },
246
424
  });
247
425
  // ═══════════════════════════════════════════════════════════════
248
- // COMMANDS
426
+ // HEARTBEAT STATUS COMMAND
249
427
  // ═══════════════════════════════════════════════════════════════
250
428
  pi.registerCommand("heartbeat", {
251
429
  description: "Show heartbeat status or trigger manual check",
252
430
  handler: async (args, ctx) => {
253
- await loadHeartbeatConfig();
431
+ await reloadConfig();
254
432
  if (args.trim() === "now" || args.trim() === "check") {
255
433
  if (!isWithinActiveHours(config)) {
256
434
  ctx.ui.notify("❤️ Heartbeat skipped - outside active hours.", "info");
@@ -270,61 +448,91 @@ export default function heartbeatExtension(pi) {
270
448
  }
271
449
  return;
272
450
  }
451
+ // Show status
273
452
  const lines = [
274
453
  "❤️ Heartbeat Configuration",
275
454
  `Enabled: ${config.enabled ? "✅" : "❌"}`,
276
455
  `Interval: ${config.every}`,
277
456
  `Ack Max Chars: ${config.ackMaxChars}`,
457
+ `Target: ${config.target}`,
458
+ `Isolated Session: ${config.isolatedSession}`,
459
+ `Light Context: ${config.lightContext}`,
278
460
  ];
279
461
  if (config.activeHours) {
280
462
  lines.push(`Active Hours: ${config.activeHours.start} - ${config.activeHours.end}`);
463
+ if (config.activeHours.timezone) {
464
+ lines.push(`Timezone: ${config.activeHours.timezone}`);
465
+ }
466
+ }
467
+ if (config.model) {
468
+ lines.push(`Model Override: ${config.model}`);
281
469
  }
282
470
  const workspaceDir = await findWorkspaceDir();
283
- const hbExists = await fileExists(path.join(workspaceDir, HEARTBEAT_FILENAME));
471
+ const hbExists = await fs
472
+ .access(path.join(workspaceDir, HEARTBEAT_FILENAME))
473
+ .then(() => true)
474
+ .catch(() => false);
284
475
  lines.push(`HEARTBEAT.md: ${hbExists ? "✅ Found" : "⚠️ Not found"}`);
476
+ // State
477
+ lines.push("");
478
+ lines.push("📊 State:");
479
+ lines.push(`Last Check: ${state.lastHeartbeat || "Never"}`);
480
+ lines.push(`Acknowledged: ${state.ackCount}`);
481
+ lines.push(`Alerts: ${state.alertCount}`);
482
+ lines.push(`Skipped: ${state.skippedCount}`);
285
483
  lines.push("", "Usage:", " /heartbeat now - Run check immediately", " /heartbeat-init - Create HEARTBEAT.md");
286
484
  ctx.ui.notify(lines.join("\n"), "info");
287
485
  },
288
486
  });
487
+ // ═══════════════════════════════════════════════════════════════
488
+ // HEARTBEAT INIT COMMAND
489
+ // ═══════════════════════════════════════════════════════════════
289
490
  pi.registerCommand("heartbeat-init", {
290
491
  description: "Create HEARTBEAT.md template in workspace",
291
492
  handler: async (_args, ctx) => {
292
493
  const workspaceDir = await findWorkspaceDir();
293
494
  const filePath = path.join(workspaceDir, HEARTBEAT_FILENAME);
294
- if (await fileExists(filePath)) {
495
+ const exists = await fs
496
+ .access(filePath)
497
+ .then(() => true)
498
+ .catch(() => false);
499
+ if (exists) {
295
500
  ctx.ui.notify(`HEARTBEAT.md already exists at ${filePath}`, "warning");
296
501
  return;
297
502
  }
298
- const template = `# Heartbeat Checklist
503
+ // Try to read the template file, fallback to embedded template
504
+ let template;
505
+ try {
506
+ template = await fs.readFile(TEMPLATE_PATH, "utf-8");
507
+ }
508
+ catch {
509
+ // Fallback to minimal template
510
+ template = `# Heartbeat Checklist
511
+
512
+ ## Response Protocol
299
513
 
300
- <!--
301
- This file controls what the agent checks during periodic heartbeats.
302
- Keep it short and actionable. If this file is empty or only contains headers,
303
- heartbeats will be skipped to save tokens.
514
+ Reply with HEARTBEAT_OK if nothing needs attention.
304
515
 
305
- The agent reads this file every heartbeat interval (default: 30m).
306
- If nothing needs attention, it replies with HEARTBEAT_OK.
307
- If something needs attention, it describes the issue without the token.
308
- -->
516
+ ---
309
517
 
310
518
  ## Regular Checks
311
519
 
312
- - [ ] Review any pending tasks in your workspace
313
- - [ ] Check for blocked items needing human input
314
- - [ ] Verify system status (if monitoring anything)
520
+ - [ ] Pending tasks needing human input
521
+ - [ ] Blocked items
522
+ - [ ] System status alerts
315
523
 
316
- ## Context-Aware
524
+ ## Context-Aware Checks
317
525
 
318
- Only check these when relevant:
319
- - [ ] Active sessions that haven't been updated recently
320
- - [ ] Scheduled tasks or reminders
321
- - [ ] Follow-ups from previous conversations
526
+ Only when relevant:
527
+ - [ ] Scheduled tasks/reminders
528
+ - [ ] Social platform notifications
529
+ - [ ] Monitoring alerts
322
530
 
323
- ## Response Protocol
531
+ ---
324
532
 
325
- If nothing needs attention → reply: \`HEARTBEAT_OK\`
326
- If something needs attention → brief alert message (no HEARTBEAT_OK token)
533
+ HEARTBEAT_OK
327
534
  `;
535
+ }
328
536
  try {
329
537
  await fs.writeFile(filePath, template, "utf-8");
330
538
  ctx.ui.notify(`✅ Created ${filePath}`, "info");
@@ -336,6 +544,55 @@ If something needs attention → brief alert message (no HEARTBEAT_OK token)
336
544
  },
337
545
  });
338
546
  // ═══════════════════════════════════════════════════════════════
547
+ // PER-AGENT HEARTBEAT CONFIG
548
+ // ═══════════════════════════════════════════════════════════════
549
+ pi.registerTool({
550
+ name: "heartbeat_config",
551
+ label: "Heartbeat Config",
552
+ description: "Get or update heartbeat configuration. Use this to check current settings or modify behavior per agent.",
553
+ // @ts-ignore TSchema mismatch
554
+ parameters: {
555
+ type: "object",
556
+ properties: {
557
+ action: {
558
+ type: "string",
559
+ enum: ["get", "set"],
560
+ description: "Get current config or update settings",
561
+ },
562
+ setting: {
563
+ type: "object",
564
+ description: "Settings to update (for set action)",
565
+ },
566
+ },
567
+ },
568
+ async execute(_toolCallId, params) {
569
+ const { action, setting } = params;
570
+ if (action === "set" && setting) {
571
+ // Merge new settings
572
+ config = { ...config, ...setting };
573
+ return {
574
+ content: [
575
+ {
576
+ type: "text",
577
+ text: `Heartbeat config updated:\n${JSON.stringify(config, null, 2)}`,
578
+ },
579
+ ],
580
+ details: { config },
581
+ };
582
+ }
583
+ // Get current config
584
+ return {
585
+ content: [
586
+ {
587
+ type: "text",
588
+ text: `Current heartbeat configuration:\n${JSON.stringify(config, null, 2)}`,
589
+ },
590
+ ],
591
+ details: { config, state },
592
+ };
593
+ },
594
+ });
595
+ // ═══════════════════════════════════════════════════════════════
339
596
  // HEALTH CHECK COMMAND (for VPS deployment)
340
597
  // ═══════════════════════════════════════════════════════════════
341
598
  pi.registerCommand("healthz", {
@@ -349,32 +606,35 @@ If something needs attention → brief alert message (no HEARTBEAT_OK token)
349
606
  timestamp: now,
350
607
  uptime: `${Math.floor(uptime / 86400)}d ${Math.floor((uptime % 86400) / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${Math.floor(uptime % 60)}s`,
351
608
  uptimeSeconds: Math.floor(uptime),
352
- version: process.env.npm_package_version || "0.0.3",
353
- extensions: {
354
- heartbeat: {
355
- enabled: config.enabled,
356
- interval: config.every,
357
- lastCheck: lastHeartbeat ? new Date(lastHeartbeat).toISOString() : null
358
- }
609
+ heartbeat: {
610
+ enabled: config.enabled,
611
+ interval: config.every,
612
+ lastCheck: state.lastHeartbeat,
613
+ activeHours: config.activeHours,
614
+ currentlyActive: isWithinActiveHours(config),
359
615
  },
360
616
  memory: {
361
- heapUsed: Math.round(memory.heapUsed / 1024 / 1024) + " MB",
362
- heapTotal: Math.round(memory.heapTotal / 1024 / 1024) + " MB",
363
- rss: Math.round(memory.rss / 1024 / 1024) + " MB"
364
- }
617
+ heapUsed: `${Math.round(memory.heapUsed / 1024 / 1024)} MB`,
618
+ heapTotal: `${Math.round(memory.heapTotal / 1024 / 1024)} MB`,
619
+ rss: `${Math.round(memory.rss / 1024 / 1024)} MB`,
620
+ },
621
+ stats: {
622
+ acknowledged: state.ackCount,
623
+ alerts: state.alertCount,
624
+ skipped: state.skippedCount,
625
+ },
365
626
  };
366
- // Return JSON for programmatic access
367
627
  ctx.ui.notify(JSON.stringify(status, null, 2), "info");
368
628
  },
369
629
  });
370
- // Also register /health as alias
371
630
  pi.registerCommand("health", {
372
631
  description: "Health check (alias for /healthz)",
373
632
  handler: async (_args, ctx) => {
374
633
  // Forward to healthz
375
- const healthzCmd = pi.getCommands?.()?.find((c) => c.name === "healthz");
376
- if (healthzCmd) {
377
- await healthzCmd.handler("", ctx);
634
+ const cmds = pi.getCommands?.() || [];
635
+ const healthz = cmds.find((c) => c.name === "healthz");
636
+ if (healthz) {
637
+ await healthz.handler("", ctx);
378
638
  }
379
639
  },
380
640
  });
@@ -382,26 +642,32 @@ If something needs attention → brief alert message (no HEARTBEAT_OK token)
382
642
  // AUTO-HEARTBEAT SCHEDULER
383
643
  // ═══════════════════════════════════════════════════════════════
384
644
  pi.on("session_start", async () => {
385
- await loadHeartbeatConfig();
645
+ await reloadConfig();
386
646
  if (!config.enabled) {
387
647
  console.log("[Heartbeat] Disabled in config, not starting scheduler");
388
648
  return;
389
649
  }
390
650
  const everyMs = parseDurationMs(config.every);
651
+ // Clear any existing interval
652
+ if (checkInterval) {
653
+ clearInterval(checkInterval);
654
+ }
391
655
  // Schedule periodic checks
392
656
  checkInterval = setInterval(async () => {
393
657
  if (!isWithinActiveHours(config)) {
394
658
  console.log("[Heartbeat] Skipped - outside active hours");
659
+ state.skippedCount++;
395
660
  return;
396
661
  }
397
662
  const workspaceDir = await findWorkspaceDir();
398
663
  const content = await readHeartbeatFile(workspaceDir);
399
664
  // Skip if no file or empty file
400
665
  if (content === null || isEffectivelyEmpty(content)) {
666
+ console.log("[Heartbeat] Skipped - no or empty HEARTBEAT.md");
401
667
  return;
402
668
  }
403
- // Use the tool to trigger heartbeat
404
- pi.sendUserMessage("Perform a heartbeat check using the heartbeat_check tool.", { deliverAs: "followUp" });
669
+ // Trigger heartbeat via user message
670
+ pi.sendUserMessage(config.prompt, { deliverAs: "followUp" });
405
671
  }, everyMs);
406
672
  console.log(`[Heartbeat] Scheduler started (every ${config.every})`);
407
673
  });
@@ -411,6 +677,6 @@ If something needs attention → brief alert message (no HEARTBEAT_OK token)
411
677
  console.log("[Heartbeat] Scheduler stopped");
412
678
  }
413
679
  });
414
- console.log("[Heartbeat] Extension loaded");
680
+ console.log("[Heartbeat] Extension loaded (OpenClaw-compatible)");
415
681
  }
416
682
  //# sourceMappingURL=heartbeat-extension.js.map