clementine-agent 1.0.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 (190) hide show
  1. package/.env.example +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +795 -0
  4. package/dist/agent/agent-manager.d.ts +69 -0
  5. package/dist/agent/agent-manager.js +441 -0
  6. package/dist/agent/assistant.d.ts +225 -0
  7. package/dist/agent/assistant.js +3888 -0
  8. package/dist/agent/auto-update.d.ts +32 -0
  9. package/dist/agent/auto-update.js +186 -0
  10. package/dist/agent/daily-planner.d.ts +24 -0
  11. package/dist/agent/daily-planner.js +379 -0
  12. package/dist/agent/execution-advisor.d.ts +10 -0
  13. package/dist/agent/execution-advisor.js +272 -0
  14. package/dist/agent/hooks.d.ts +45 -0
  15. package/dist/agent/hooks.js +564 -0
  16. package/dist/agent/insight-engine.d.ts +66 -0
  17. package/dist/agent/insight-engine.js +225 -0
  18. package/dist/agent/intent-classifier.d.ts +48 -0
  19. package/dist/agent/intent-classifier.js +214 -0
  20. package/dist/agent/link-extractor.d.ts +19 -0
  21. package/dist/agent/link-extractor.js +90 -0
  22. package/dist/agent/mcp-bridge.d.ts +62 -0
  23. package/dist/agent/mcp-bridge.js +435 -0
  24. package/dist/agent/metacognition.d.ts +66 -0
  25. package/dist/agent/metacognition.js +221 -0
  26. package/dist/agent/orchestrator.d.ts +81 -0
  27. package/dist/agent/orchestrator.js +790 -0
  28. package/dist/agent/profiles.d.ts +22 -0
  29. package/dist/agent/profiles.js +91 -0
  30. package/dist/agent/prompt-cache.d.ts +24 -0
  31. package/dist/agent/prompt-cache.js +68 -0
  32. package/dist/agent/prompt-evolver.d.ts +28 -0
  33. package/dist/agent/prompt-evolver.js +279 -0
  34. package/dist/agent/role-scaffolds.d.ts +28 -0
  35. package/dist/agent/role-scaffolds.js +433 -0
  36. package/dist/agent/safe-restart.d.ts +41 -0
  37. package/dist/agent/safe-restart.js +150 -0
  38. package/dist/agent/self-improve.d.ts +66 -0
  39. package/dist/agent/self-improve.js +1706 -0
  40. package/dist/agent/session-event-log.d.ts +114 -0
  41. package/dist/agent/session-event-log.js +233 -0
  42. package/dist/agent/skill-extractor.d.ts +72 -0
  43. package/dist/agent/skill-extractor.js +435 -0
  44. package/dist/agent/source-mods.d.ts +61 -0
  45. package/dist/agent/source-mods.js +230 -0
  46. package/dist/agent/source-preflight.d.ts +25 -0
  47. package/dist/agent/source-preflight.js +100 -0
  48. package/dist/agent/stall-guard.d.ts +62 -0
  49. package/dist/agent/stall-guard.js +109 -0
  50. package/dist/agent/strategic-planner.d.ts +60 -0
  51. package/dist/agent/strategic-planner.js +352 -0
  52. package/dist/agent/team-bus.d.ts +89 -0
  53. package/dist/agent/team-bus.js +556 -0
  54. package/dist/agent/team-router.d.ts +26 -0
  55. package/dist/agent/team-router.js +37 -0
  56. package/dist/agent/tool-loop-detector.d.ts +59 -0
  57. package/dist/agent/tool-loop-detector.js +242 -0
  58. package/dist/agent/workflow-runner.d.ts +36 -0
  59. package/dist/agent/workflow-runner.js +317 -0
  60. package/dist/agent/workflow-variables.d.ts +16 -0
  61. package/dist/agent/workflow-variables.js +62 -0
  62. package/dist/channels/discord-agent-bot.d.ts +101 -0
  63. package/dist/channels/discord-agent-bot.js +881 -0
  64. package/dist/channels/discord-bot-manager.d.ts +80 -0
  65. package/dist/channels/discord-bot-manager.js +262 -0
  66. package/dist/channels/discord-utils.d.ts +51 -0
  67. package/dist/channels/discord-utils.js +293 -0
  68. package/dist/channels/discord.d.ts +12 -0
  69. package/dist/channels/discord.js +1832 -0
  70. package/dist/channels/slack-agent-bot.d.ts +73 -0
  71. package/dist/channels/slack-agent-bot.js +320 -0
  72. package/dist/channels/slack-bot-manager.d.ts +66 -0
  73. package/dist/channels/slack-bot-manager.js +236 -0
  74. package/dist/channels/slack-utils.d.ts +39 -0
  75. package/dist/channels/slack-utils.js +189 -0
  76. package/dist/channels/slack.d.ts +11 -0
  77. package/dist/channels/slack.js +196 -0
  78. package/dist/channels/telegram.d.ts +10 -0
  79. package/dist/channels/telegram.js +235 -0
  80. package/dist/channels/webhook.d.ts +9 -0
  81. package/dist/channels/webhook.js +78 -0
  82. package/dist/channels/whatsapp.d.ts +11 -0
  83. package/dist/channels/whatsapp.js +181 -0
  84. package/dist/cli/chat.d.ts +14 -0
  85. package/dist/cli/chat.js +220 -0
  86. package/dist/cli/cron.d.ts +17 -0
  87. package/dist/cli/cron.js +552 -0
  88. package/dist/cli/dashboard.d.ts +15 -0
  89. package/dist/cli/dashboard.js +17677 -0
  90. package/dist/cli/index.d.ts +3 -0
  91. package/dist/cli/index.js +2474 -0
  92. package/dist/cli/routes/delegations.d.ts +19 -0
  93. package/dist/cli/routes/delegations.js +154 -0
  94. package/dist/cli/routes/digest.d.ts +17 -0
  95. package/dist/cli/routes/digest.js +375 -0
  96. package/dist/cli/routes/goals.d.ts +14 -0
  97. package/dist/cli/routes/goals.js +258 -0
  98. package/dist/cli/routes/workflows.d.ts +18 -0
  99. package/dist/cli/routes/workflows.js +97 -0
  100. package/dist/cli/setup.d.ts +8 -0
  101. package/dist/cli/setup.js +619 -0
  102. package/dist/cli/tunnel.d.ts +35 -0
  103. package/dist/cli/tunnel.js +141 -0
  104. package/dist/config.d.ts +145 -0
  105. package/dist/config.js +278 -0
  106. package/dist/events/bus.d.ts +43 -0
  107. package/dist/events/bus.js +136 -0
  108. package/dist/gateway/cron-scheduler.d.ts +166 -0
  109. package/dist/gateway/cron-scheduler.js +1767 -0
  110. package/dist/gateway/delivery-queue.d.ts +30 -0
  111. package/dist/gateway/delivery-queue.js +110 -0
  112. package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
  113. package/dist/gateway/heartbeat-scheduler.js +1298 -0
  114. package/dist/gateway/heartbeat.d.ts +3 -0
  115. package/dist/gateway/heartbeat.js +3 -0
  116. package/dist/gateway/lanes.d.ts +24 -0
  117. package/dist/gateway/lanes.js +76 -0
  118. package/dist/gateway/notifications.d.ts +29 -0
  119. package/dist/gateway/notifications.js +75 -0
  120. package/dist/gateway/router.d.ts +210 -0
  121. package/dist/gateway/router.js +1330 -0
  122. package/dist/index.d.ts +12 -0
  123. package/dist/index.js +1015 -0
  124. package/dist/memory/chunker.d.ts +28 -0
  125. package/dist/memory/chunker.js +226 -0
  126. package/dist/memory/consolidation.d.ts +44 -0
  127. package/dist/memory/consolidation.js +171 -0
  128. package/dist/memory/context-assembler.d.ts +50 -0
  129. package/dist/memory/context-assembler.js +149 -0
  130. package/dist/memory/embeddings.d.ts +38 -0
  131. package/dist/memory/embeddings.js +180 -0
  132. package/dist/memory/graph-store.d.ts +66 -0
  133. package/dist/memory/graph-store.js +613 -0
  134. package/dist/memory/mmr.d.ts +21 -0
  135. package/dist/memory/mmr.js +75 -0
  136. package/dist/memory/search.d.ts +26 -0
  137. package/dist/memory/search.js +67 -0
  138. package/dist/memory/store.d.ts +530 -0
  139. package/dist/memory/store.js +2022 -0
  140. package/dist/security/integrity.d.ts +24 -0
  141. package/dist/security/integrity.js +58 -0
  142. package/dist/security/patterns.d.ts +34 -0
  143. package/dist/security/patterns.js +110 -0
  144. package/dist/security/scanner.d.ts +32 -0
  145. package/dist/security/scanner.js +263 -0
  146. package/dist/tools/admin-tools.d.ts +12 -0
  147. package/dist/tools/admin-tools.js +1278 -0
  148. package/dist/tools/external-tools.d.ts +11 -0
  149. package/dist/tools/external-tools.js +1327 -0
  150. package/dist/tools/goal-tools.d.ts +9 -0
  151. package/dist/tools/goal-tools.js +159 -0
  152. package/dist/tools/mcp-server.d.ts +13 -0
  153. package/dist/tools/mcp-server.js +141 -0
  154. package/dist/tools/memory-tools.d.ts +10 -0
  155. package/dist/tools/memory-tools.js +568 -0
  156. package/dist/tools/session-tools.d.ts +6 -0
  157. package/dist/tools/session-tools.js +146 -0
  158. package/dist/tools/shared.d.ts +216 -0
  159. package/dist/tools/shared.js +340 -0
  160. package/dist/tools/team-tools.d.ts +6 -0
  161. package/dist/tools/team-tools.js +447 -0
  162. package/dist/tools/tool-meta.d.ts +34 -0
  163. package/dist/tools/tool-meta.js +133 -0
  164. package/dist/tools/vault-tools.d.ts +8 -0
  165. package/dist/tools/vault-tools.js +457 -0
  166. package/dist/types.d.ts +716 -0
  167. package/dist/types.js +16 -0
  168. package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
  169. package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
  170. package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
  171. package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
  172. package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
  173. package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
  174. package/dist/vault-migrations/helpers.d.ts +14 -0
  175. package/dist/vault-migrations/helpers.js +44 -0
  176. package/dist/vault-migrations/runner.d.ts +14 -0
  177. package/dist/vault-migrations/runner.js +139 -0
  178. package/dist/vault-migrations/types.d.ts +42 -0
  179. package/dist/vault-migrations/types.js +9 -0
  180. package/install.sh +320 -0
  181. package/package.json +84 -0
  182. package/scripts/postinstall.js +125 -0
  183. package/vault/00-System/AGENTS.md +66 -0
  184. package/vault/00-System/CRON.md +71 -0
  185. package/vault/00-System/HEARTBEAT.md +58 -0
  186. package/vault/00-System/MEMORY.md +16 -0
  187. package/vault/00-System/SOUL.md +96 -0
  188. package/vault/05-Tasks/TASKS.md +19 -0
  189. package/vault/06-Templates/_Daily-Template.md +28 -0
  190. package/vault/06-Templates/_People-Template.md +22 -0
@@ -0,0 +1,1298 @@
1
+ /**
2
+ * Clementine TypeScript — Heartbeat scheduler.
3
+ *
4
+ * HeartbeatScheduler: periodic general check-ins using setInterval.
5
+ * Channel-agnostic — sends notifications via the NotificationDispatcher.
6
+ */
7
+ import { createHash, randomBytes } from 'node:crypto';
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
9
+ import path from 'node:path';
10
+ import matter from 'gray-matter';
11
+ import pino from 'pino';
12
+ import { HEARTBEAT_FILE, TASKS_FILE, INBOX_DIR, DAILY_NOTES_DIR, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_ACTIVE_START, HEARTBEAT_ACTIVE_END, BASE_DIR, GOALS_DIR, HEARTBEAT_WORK_QUEUE_FILE, DISCORD_OWNER_ID, } from '../config.js';
13
+ import { gatherInsightSignals, buildInsightPrompt, parseInsightResponse, canSendInsight, recordInsightSent, recordInsightAcked, maybeIncreaseCooldown, } from '../agent/insight-engine.js';
14
+ import { CronRunLog, logToDailyNote, todayISO } from './cron-scheduler.js';
15
+ const logger = pino({ name: 'clementine.heartbeat' });
16
+ // ── HeartbeatScheduler ────────────────────────────────────────────────
17
+ export class HeartbeatScheduler {
18
+ stateFile;
19
+ gateway;
20
+ dispatcher;
21
+ lastState;
22
+ timer = null;
23
+ running = false;
24
+ lastSelfImproveDate = '';
25
+ lastConsolidationDate = '';
26
+ lastAgentSiRuns = new Map();
27
+ cronScheduler = null;
28
+ runLog = new CronRunLog();
29
+ /** Wire up the cron scheduler so daily plan suggestions can be applied. */
30
+ setCronScheduler(cs) { this.cronScheduler = cs; }
31
+ getLastAgentSiRun(slug) {
32
+ return this.lastAgentSiRuns.get(slug);
33
+ }
34
+ setLastAgentSiRun(slug) {
35
+ this.lastAgentSiRuns.set(slug, new Date().toISOString());
36
+ }
37
+ constructor(gateway, dispatcher) {
38
+ this.gateway = gateway;
39
+ this.dispatcher = dispatcher;
40
+ this.stateFile = path.join(BASE_DIR, '.heartbeat_state.json');
41
+ this.lastState = this.loadState();
42
+ // Restore persisted scheduling dates from state so they survive restarts
43
+ if (this.lastState.lastSelfImproveDate)
44
+ this.lastSelfImproveDate = this.lastState.lastSelfImproveDate;
45
+ if (this.lastState.lastConsolidationDate)
46
+ this.lastConsolidationDate = this.lastState.lastConsolidationDate;
47
+ if (this.lastState.lastAgentSiRuns) {
48
+ this.lastAgentSiRuns = new Map(Object.entries(this.lastState.lastAgentSiRuns));
49
+ }
50
+ }
51
+ start() {
52
+ if (this.running)
53
+ return;
54
+ this.running = true;
55
+ const intervalMs = HEARTBEAT_INTERVAL_MINUTES * 60 * 1000;
56
+ this.timer = setInterval(() => {
57
+ this.heartbeatTick().catch((err) => {
58
+ logger.error({ err }, 'Heartbeat tick failed');
59
+ });
60
+ }, intervalMs);
61
+ logger.info(`Heartbeat started: every ${HEARTBEAT_INTERVAL_MINUTES}min, active ${HEARTBEAT_ACTIVE_START}:00-${HEARTBEAT_ACTIVE_END}:00`);
62
+ }
63
+ stop() {
64
+ this.running = false;
65
+ if (this.timer) {
66
+ clearInterval(this.timer);
67
+ this.timer = null;
68
+ logger.info('Heartbeat stopped');
69
+ }
70
+ }
71
+ async runManual() {
72
+ const standingInstructions = this.readHeartbeatConfig();
73
+ const now = new Date();
74
+ const [, currentDetails] = this.computeStateFingerprint();
75
+ const { summary } = this.computeChangesSummary(this.lastState.details ?? {}, currentDetails);
76
+ let changesSummary = summary;
77
+ const activitySummary = this.getRecentActivitySummary();
78
+ if (activitySummary) {
79
+ changesSummary += `\n\nRecent activity:\n${activitySummary}`;
80
+ }
81
+ const goalSummary = HeartbeatScheduler.loadGoalSummary();
82
+ if (goalSummary) {
83
+ changesSummary += `\n\n${goalSummary}`;
84
+ }
85
+ const dedupContext = this.buildDedupContext();
86
+ const timeContext = HeartbeatScheduler.getTimeContext(now.getHours());
87
+ try {
88
+ const response = await this.gateway.handleHeartbeat(standingInstructions, changesSummary, timeContext, dedupContext);
89
+ return response || '*(heartbeat completed — nothing to report)*';
90
+ }
91
+ catch (err) {
92
+ logger.error({ err }, 'Manual heartbeat failed');
93
+ return `Heartbeat error: ${err}`;
94
+ }
95
+ }
96
+ // ── Private methods ─────────────────────────────────────────────────
97
+ async heartbeatTick() {
98
+ // Periodic housekeeping: evict stale gateway sessions
99
+ try {
100
+ this.gateway.evictStaleSessions();
101
+ }
102
+ catch (err) {
103
+ logger.warn({ err }, 'Session eviction failed');
104
+ }
105
+ const now = new Date();
106
+ const hour = now.getHours();
107
+ // ── Nightly tasks: run regardless of active hours ─────────────────
108
+ // These have their own hour/date guards and must fire outside active hours.
109
+ // Nightly self-improvement: run once per day at 1 AM
110
+ if (hour === 1 && this.lastSelfImproveDate !== todayISO()) {
111
+ this.lastSelfImproveDate = todayISO();
112
+ this.lastState.lastSelfImproveDate = this.lastSelfImproveDate;
113
+ this.saveState();
114
+ logger.info('Triggering nightly self-improvement cycle');
115
+ const notifyProposal = async (experiment) => {
116
+ const msg = `**Self-Improve Proposal** (#${experiment.iteration}) — needs your approval\n` +
117
+ `**Area:** ${experiment.area} → ${experiment.target}\n` +
118
+ `**Score:** ${(experiment.score * 10).toFixed(1)}/10\n` +
119
+ `**Hypothesis:** ${experiment.hypothesis.slice(0, 200)}\n\n` +
120
+ `Run \`!self-improve apply ${experiment.id}\` to approve, ` +
121
+ `or \`!self-improve deny ${experiment.id}\` to reject. ` +
122
+ `Also visible in the dashboard under Automations → Self-Improve.`;
123
+ await this.dispatcher.send(msg, {})
124
+ .catch(err => logger.debug({ err }, 'Failed to send self-improve proposal notification'));
125
+ };
126
+ this.gateway.handleSelfImprove('run-nightly', {}, notifyProposal).then(summary => {
127
+ // Notify owner of self-improvement results
128
+ if (summary && !summary.includes('Iterations: 0')) {
129
+ this.dispatcher.send(`**Self-Improvement Report (nightly)**\n${summary}`, {}).catch(err => logger.debug({ err }, 'Failed to send self-improvement report'));
130
+ }
131
+ }).catch(err => {
132
+ logger.error({ err }, 'Nightly self-improvement failed');
133
+ // Surface infrastructure errors to the user — silent failures
134
+ // that repeat every night are worse than a one-time notification
135
+ this.dispatcher.send(`**Self-Improvement Failed (nightly)**\n` +
136
+ `The self-improvement loop crashed: ${String(err).slice(0, 200)}\n\n` +
137
+ `This will keep failing every night until the root cause is fixed. ` +
138
+ `Ask me to check the self-improvement status for details.`, {}).catch(() => { });
139
+ });
140
+ }
141
+ // Weekly per-agent improvement: one agent per day at 2 AM, cycling through
142
+ if (hour === 2) {
143
+ try {
144
+ const agentMgr = this.gateway.getAgentManager();
145
+ const agents = agentMgr.listAll().filter(a => a.slug !== 'clementine');
146
+ if (agents.length > 0) {
147
+ const dayOfYear = Math.floor((Date.now() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000);
148
+ const agentIndex = dayOfYear % agents.length;
149
+ const targetAgent = agents[agentIndex];
150
+ const lastRun = this.getLastAgentSiRun(targetAgent.slug);
151
+ const daysSinceLastRun = lastRun ? (Date.now() - new Date(lastRun).getTime()) / 86400000 : Infinity;
152
+ if (daysSinceLastRun >= 7) {
153
+ logger.info({ agentSlug: targetAgent.slug }, 'Triggering weekly per-agent self-improvement');
154
+ this.gateway.handleSelfImprove('run-agent', { experimentId: targetAgent.slug }).catch(err => {
155
+ logger.error({ err, agentSlug: targetAgent.slug }, 'Per-agent self-improvement failed');
156
+ });
157
+ this.setLastAgentSiRun(targetAgent.slug);
158
+ this.lastState.lastAgentSiRuns = Object.fromEntries(this.lastAgentSiRuns);
159
+ this.saveState();
160
+ }
161
+ }
162
+ }
163
+ catch (err) {
164
+ logger.warn({ err }, 'Per-agent self-improvement scheduling error');
165
+ }
166
+ }
167
+ // Evening memory consolidation: once per day between 7-9 PM
168
+ if (hour >= 19 && hour < 21 && this.lastConsolidationDate !== todayISO()) {
169
+ this.lastConsolidationDate = todayISO();
170
+ this.lastState.lastConsolidationDate = this.lastConsolidationDate;
171
+ this.saveState();
172
+ logger.info('Triggering evening memory consolidation');
173
+ // Phase 1: Programmatic consolidation (dedup, summarize, extract principles)
174
+ import('../memory/consolidation.js').then(async ({ runConsolidation }) => {
175
+ const store = this.gateway.getMemoryStore();
176
+ if (!store) {
177
+ logger.debug('Memory store not available — skipping programmatic consolidation');
178
+ return;
179
+ }
180
+ // LLM callback for summarization/principle extraction
181
+ const llmCall = async (prompt) => {
182
+ const result = await this.gateway.handleCronJob('consolidation-llm', prompt, 1, 1, 'haiku');
183
+ return result || '';
184
+ };
185
+ const result = await runConsolidation(store, llmCall);
186
+ if (result.deduped > 0 || result.summarized > 0 || result.principlesExtracted > 0) {
187
+ logger.info(result, 'Programmatic consolidation results');
188
+ }
189
+ // Rebuild embedding vocabulary and backfill after consolidation
190
+ try {
191
+ const embResult = store.buildEmbeddings();
192
+ if (embResult.backfilled > 0) {
193
+ logger.info(embResult, 'Embedding backfill after consolidation');
194
+ }
195
+ }
196
+ catch (err) {
197
+ logger.debug({ err }, 'Embedding backfill failed (non-fatal)');
198
+ }
199
+ }).catch(err => {
200
+ logger.warn({ err }, 'Programmatic consolidation failed');
201
+ });
202
+ // Phase 2: LLM-driven fact promotion (existing behavior, kept as complement)
203
+ this.gateway.handleCronJob('memory-consolidation', 'Review today\'s daily note and recent conversations. Promote any durable facts ' +
204
+ '(preferences, decisions, people info, project updates) to long-term memory using ' +
205
+ 'memory_write. Skip anything already in MEMORY.md. Be selective — only save facts ' +
206
+ 'that will be useful in future conversations. Do not create duplicate entries.', 1, // tier 1 (vault-only)
207
+ 3, // max 3 turns
208
+ 'haiku').catch(err => {
209
+ logger.error({ err }, 'Evening memory consolidation failed');
210
+ });
211
+ }
212
+ // Sunday evening: weekly review (between 8-9 PM)
213
+ if (now.getDay() === 0 && hour >= 20 && hour < 21) {
214
+ import('../agent/strategic-planner.js').then(async ({ StrategicPlanner }) => {
215
+ const planner = new StrategicPlanner();
216
+ if (!planner.hasWeeklyReview()) {
217
+ logger.info('Triggering weekly review');
218
+ const review = await planner.generateWeeklyReview();
219
+ if (review.summary && review.summary !== 'No data available for weekly review.') {
220
+ this.dispatcher.send(`**Weekly Review**\n\n${review.summary}\n\n` +
221
+ (review.accomplishments.length > 0 ? `**Done:** ${review.accomplishments.join('; ')}\n` : '') +
222
+ (review.recommendations.length > 0 ? `**Next week:** ${review.recommendations.join('; ')}` : '')).catch(err => logger.debug({ err }, 'Failed to send weekly review'));
223
+ }
224
+ }
225
+ }).catch(err => {
226
+ logger.warn({ err }, 'Weekly review failed');
227
+ });
228
+ }
229
+ // First Monday of month: monthly assessment (between 8-9 PM)
230
+ if (now.getDay() === 1 && now.getDate() <= 7 && hour >= 20 && hour < 21) {
231
+ import('../agent/strategic-planner.js').then(async ({ StrategicPlanner }) => {
232
+ const planner = new StrategicPlanner();
233
+ if (!planner.hasMonthlyAssessment()) {
234
+ logger.info('Triggering monthly strategic assessment');
235
+ const assessment = await planner.generateMonthlyAssessment();
236
+ if (assessment.summary && assessment.summary !== 'No data available for monthly assessment.') {
237
+ this.dispatcher.send(`**Monthly Assessment**\n\n${assessment.summary}\n\n` +
238
+ (assessment.proposedGoals.length > 0
239
+ ? `**Proposed goals:** ${assessment.proposedGoals.map(g => g.title).join(', ')}`
240
+ : '')).catch(err => logger.debug({ err }, 'Failed to send monthly assessment'));
241
+ }
242
+ }
243
+ }).catch(err => {
244
+ logger.warn({ err }, 'Monthly assessment failed');
245
+ });
246
+ }
247
+ // ── Active hours check ────────────────────────────────────────────
248
+ // Check active hours
249
+ if (hour < HEARTBEAT_ACTIVE_START || hour >= HEARTBEAT_ACTIVE_END) {
250
+ logger.debug(`Heartbeat skipped: outside active hours (${hour}:00)`);
251
+ return;
252
+ }
253
+ // ── Daily planning session: first tick of the day ──
254
+ let dailyPlanner = null;
255
+ try {
256
+ const { DailyPlanner } = await import('../agent/daily-planner.js');
257
+ dailyPlanner = new DailyPlanner();
258
+ if (!dailyPlanner.hasPlanForToday()) {
259
+ logger.info('First active-hours tick — generating daily plan');
260
+ const plan = await dailyPlanner.plan();
261
+ if (plan.priorities.length > 0) {
262
+ const highUrgency = plan.priorities.filter(p => p.urgency >= 4);
263
+ if (highUrgency.length > 0) {
264
+ const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
265
+ mkdirSync(goalTriggerDir, { recursive: true });
266
+ for (const item of highUrgency) {
267
+ if (item.type === 'goal') {
268
+ const triggerPath = path.join(goalTriggerDir, `${item.id}.trigger.json`);
269
+ if (!existsSync(triggerPath)) {
270
+ writeFileSync(triggerPath, JSON.stringify({
271
+ goalId: item.id,
272
+ focus: item.action,
273
+ maxTurns: 30,
274
+ triggeredAt: new Date().toISOString(),
275
+ source: 'daily-plan',
276
+ }, null, 2));
277
+ }
278
+ }
279
+ }
280
+ }
281
+ logger.info({ priorities: plan.priorities.length, urgent: plan.priorities.filter(p => p.urgency >= 4).length }, 'Daily plan generated');
282
+ }
283
+ // Apply non-destructive cron changes suggested by the daily planner
284
+ if (plan.suggestedCronChanges?.length > 0 && this.cronScheduler) {
285
+ this.cronScheduler.applySuggestedCronChanges(plan.suggestedCronChanges);
286
+ }
287
+ // Goal-plan alignment check
288
+ try {
289
+ const { StrategicPlanner } = await import('../agent/strategic-planner.js');
290
+ const sp = new StrategicPlanner();
291
+ const warning = sp.checkGoalPlanAlignment(plan);
292
+ if (warning) {
293
+ logger.info({ warning }, 'Goal-plan misalignment detected');
294
+ // Inject warning into today's context so the heartbeat can surface it
295
+ plan.summary = `${plan.summary}\n\n⚠ ${warning}`;
296
+ writeFileSync(path.join(BASE_DIR, 'plans', 'daily', `${plan.date}.json`), JSON.stringify(plan, null, 2));
297
+ }
298
+ }
299
+ catch { /* non-fatal */ }
300
+ }
301
+ }
302
+ catch (err) {
303
+ logger.warn({ err }, 'Daily planning failed (non-fatal)');
304
+ }
305
+ // Compute current state for context (always run — heartbeats keep the agent alive)
306
+ const [currentFingerprint, currentDetails] = this.computeStateFingerprint();
307
+ // Build change summary — tells the agent what's different since last tick
308
+ const { summary: rawSummary, hasRealChanges } = this.computeChangesSummary(this.lastState.details ?? {}, currentDetails);
309
+ let changesSummary = rawSummary;
310
+ // Include recent chat/cron activity so the heartbeat knows what happened
311
+ const activitySummary = this.getRecentActivitySummary();
312
+ if (activitySummary) {
313
+ changesSummary += `\n\nRecent activity:\n${activitySummary}`;
314
+ }
315
+ // Inject active goal summaries so the heartbeat can flag goals needing attention
316
+ const goalSummary = HeartbeatScheduler.loadGoalSummary();
317
+ if (goalSummary) {
318
+ changesSummary += `\n\n${goalSummary}`;
319
+ }
320
+ // Enrich active goals with relevant memory snippets
321
+ const goalMemoryContext = this.enrichGoalsWithMemory();
322
+ if (goalMemoryContext) {
323
+ changesSummary += `\n\n${goalMemoryContext}`;
324
+ }
325
+ // Inject daily plan summary if available
326
+ try {
327
+ const todayPlan = dailyPlanner?.getPlan();
328
+ if (todayPlan) {
329
+ changesSummary += `\n\n## Today's Plan\n${todayPlan.summary}\nTop priorities: ${todayPlan.priorities.slice(0, 3).map(p => p.action).join('; ')}`;
330
+ // ── Goal-driven work auto-queuing ─────────────────────────
331
+ // Close the loop: daily planner priorities → work queue items
332
+ // Only queue high-urgency goal items that are autonomously actionable
333
+ const currentQueue = HeartbeatScheduler.loadWorkQueue();
334
+ const pendingDescriptions = new Set(currentQueue.filter(i => i.status === 'pending' || i.status === 'running').map(i => i.description));
335
+ for (const priority of todayPlan.priorities) {
336
+ // Only auto-queue high-urgency goal items (urgency >= 7)
337
+ if (priority.type !== 'goal' || priority.urgency < 7)
338
+ continue;
339
+ // Skip if already queued
340
+ if (pendingDescriptions.has(priority.action))
341
+ continue;
342
+ // Skip if the action requires human input (heuristic: contains question marks or decision words)
343
+ if (/\?|decide|choose|approve|confirm|review with|ask\s/i.test(priority.action))
344
+ continue;
345
+ HeartbeatScheduler.enqueueWork({
346
+ description: priority.action,
347
+ prompt: `Goal progress: ${priority.action}\n\nThis is a high-priority item from today's daily plan (goal: ${priority.id}). ` +
348
+ `Use goal_work to make progress. If you need information from the owner to proceed, ` +
349
+ `note the blocker and move on.`,
350
+ source: `daily-plan:${priority.id}`,
351
+ priority: 'high',
352
+ maxTurns: 10,
353
+ tier: 1,
354
+ });
355
+ logger.info({ goalId: priority.id, action: priority.action }, 'Auto-queued goal work from daily plan');
356
+ }
357
+ }
358
+ }
359
+ catch (err) {
360
+ logger.warn({ err }, 'Daily plan enrichment failed');
361
+ }
362
+ // ── Drain work queue (max 2 items per tick) ──
363
+ const completedWork = [];
364
+ for (let i = 0; i < 2; i++) {
365
+ const item = this.claimNextItem();
366
+ if (!item)
367
+ break;
368
+ logger.info({ id: item.id, description: item.description }, 'Executing heartbeat work item');
369
+ try {
370
+ const result = await this.gateway.handleCronJob(`heartbeat-work:${item.id}`, item.prompt, item.tier, item.maxTurns);
371
+ this.completeItem(item.id, result || 'completed');
372
+ completedWork.push({ description: item.description, result: result || 'completed' });
373
+ logToDailyNote(`**Heartbeat work: ${item.description}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
374
+ }
375
+ catch (err) {
376
+ this.failItem(item.id, String(err));
377
+ logger.warn({ err, id: item.id }, 'Heartbeat work item failed');
378
+ }
379
+ }
380
+ // ── Decide whether to invoke the LLM ──
381
+ this.pruneReportedTopics();
382
+ if (!this.shouldInvokeAgent(hasRealChanges, completedWork, currentDetails)) {
383
+ // Silent tick — no LLM call, just housekeeping
384
+ this.lastState = {
385
+ ...this.lastState,
386
+ fingerprint: currentFingerprint,
387
+ details: currentDetails,
388
+ timestamp: now.toISOString(),
389
+ consecutiveSilentBeats: (this.lastState.consecutiveSilentBeats ?? 0) + 1,
390
+ };
391
+ this.saveState();
392
+ logger.info({ silentBeats: this.lastState.consecutiveSilentBeats }, 'Heartbeat silent — nothing new');
393
+ // Still run housekeeping
394
+ this.advanceGoals();
395
+ this.processInbox();
396
+ // Fall through to nightly tasks below — don't return early
397
+ }
398
+ else {
399
+ // Build dedup context from previously reported topics
400
+ const dedupContext = this.buildDedupContext();
401
+ // Build work summary for completed items
402
+ let workSummary = '';
403
+ if (completedWork.length > 0) {
404
+ workSummary = '## Work Completed This Tick\n' + completedWork.map((w) => `- **${w.description}**: ${w.result.slice(0, 200)}`).join('\n');
405
+ }
406
+ if (workSummary) {
407
+ changesSummary = workSummary + '\n\n' + changesSummary;
408
+ }
409
+ // Check for incomplete work from previous chat queries
410
+ try {
411
+ const incompleteFile = path.join(BASE_DIR, 'incomplete-work.json');
412
+ if (existsSync(incompleteFile)) {
413
+ const entries = JSON.parse(readFileSync(incompleteFile, 'utf-8'));
414
+ const unhandled = entries.filter(e => !e.handled);
415
+ if (unhandled.length > 0) {
416
+ const incompleteSection = '## Incomplete Work (needs follow-up)\n' +
417
+ 'Earlier tasks may not have been fully completed. Before reporting, VERIFY the current status: ' +
418
+ 'check task files, vault notes, deployed sites, or any artifacts to determine what actually got done. ' +
419
+ 'Then report your findings conversationally — e.g. "Those audits from earlier actually all completed successfully" ' +
420
+ 'or "Two of three finished, the third still needs work." Never just say you\'re checking — do the check, then report.\n' +
421
+ unhandled.map(e => `- **${e.userPrompt.slice(0, 200)}** (${e.toolCallCount} tool calls, started ${e.timestamp})`).join('\n');
422
+ changesSummary = incompleteSection + '\n\n' + changesSummary;
423
+ // Mark as handled
424
+ for (const e of entries) {
425
+ if (!e.handled)
426
+ e.handled = true;
427
+ }
428
+ writeFileSync(incompleteFile, JSON.stringify(entries, null, 2));
429
+ logger.info({ count: unhandled.length }, 'Injected incomplete work into heartbeat prompt');
430
+ }
431
+ }
432
+ }
433
+ catch (err) {
434
+ logger.warn({ err }, 'Failed to read incomplete work for heartbeat');
435
+ }
436
+ // Persist new state (reset silent counter since we're invoking)
437
+ this.lastState = {
438
+ ...this.lastState,
439
+ fingerprint: currentFingerprint,
440
+ details: currentDetails,
441
+ timestamp: now.toISOString(),
442
+ consecutiveSilentBeats: 0,
443
+ };
444
+ this.saveState();
445
+ // Build time-of-day context
446
+ const timeContext = HeartbeatScheduler.getTimeContext(hour);
447
+ // Read standing instructions from HEARTBEAT.md
448
+ const standingInstructions = this.readHeartbeatConfig();
449
+ try {
450
+ const response = await this.gateway.handleHeartbeat(standingInstructions, changesSummary, timeContext, dedupContext);
451
+ const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
452
+ const ampm = hour >= 12 ? 'PM' : 'AM';
453
+ const timeStr = `${h12}:${String(now.getMinutes()).padStart(2, '0')} ${ampm}`;
454
+ if (response && !HeartbeatScheduler.shouldSuppressMessage(response)) {
455
+ // Extract topic tags for dedup, then strip before sending
456
+ const newTopics = HeartbeatScheduler.extractReportedTopics(response);
457
+ const cleanResponse = HeartbeatScheduler.stripTopicTags(response);
458
+ await this.dispatcher.send(`**[${timeStr} check-in]**\n\n${cleanResponse}`);
459
+ logToDailyNote(`**${timeStr}**: ${cleanResponse.slice(0, 100).replace(/\n/g, ' ')}`);
460
+ // Log to run history so heartbeats are visible via !cron runs __heartbeat__
461
+ this.runLog.append({
462
+ jobName: '__heartbeat__',
463
+ startedAt: now.toISOString(),
464
+ finishedAt: new Date().toISOString(),
465
+ status: 'ok',
466
+ durationMs: Date.now() - now.getTime(),
467
+ attempt: 1,
468
+ outputPreview: cleanResponse.slice(0, 200),
469
+ });
470
+ // Inject heartbeat output into owner's DM session so replies have context
471
+ if (DISCORD_OWNER_ID && DISCORD_OWNER_ID !== '0') {
472
+ this.gateway.injectContext(`discord:user:${DISCORD_OWNER_ID}`, `[Heartbeat check-in at ${timeStr}]`, cleanResponse);
473
+ }
474
+ // Update dedup ledger
475
+ if (newTopics.length > 0) {
476
+ this.lastState.reportedTopics = [
477
+ ...(this.lastState.reportedTopics ?? []),
478
+ ...newTopics,
479
+ ];
480
+ }
481
+ this.lastState.lastDiscordMessageAt = now.toISOString();
482
+ this.lastState.consecutiveSilentBeats = 0;
483
+ this.saveState();
484
+ }
485
+ else {
486
+ logger.info(`Heartbeat suppressed at ${timeStr}`);
487
+ this.lastState.consecutiveSilentBeats = (this.lastState.consecutiveSilentBeats ?? 0) + 1;
488
+ this.saveState();
489
+ }
490
+ }
491
+ catch (err) {
492
+ logger.error({ err }, 'Heartbeat tick failed');
493
+ }
494
+ // Fire-and-forget: advance active goals by writing trigger files
495
+ this.advanceGoals();
496
+ // Fire-and-forget: process inbox items
497
+ this.processInbox();
498
+ // ── Confidence-based escalations from cron jobs ──────────────────
499
+ // Check if any cron jobs flagged low-confidence results for user review
500
+ try {
501
+ const escalationsFile = path.join(BASE_DIR, 'escalations.json');
502
+ if (existsSync(escalationsFile)) {
503
+ const escalations = JSON.parse(readFileSync(escalationsFile, 'utf-8'));
504
+ if (escalations.length > 0) {
505
+ // Drain all pending escalations
506
+ const messages = [];
507
+ for (const esc of escalations) {
508
+ messages.push(`**${esc.jobName}** (${esc.confidence} confidence, ${esc.toolCallCount} tool calls): ` +
509
+ `${String(esc.deliverablePreview ?? '').slice(0, 200)}`);
510
+ }
511
+ // Clear escalations
512
+ writeFileSync(escalationsFile, '[]');
513
+ // Send notification
514
+ await this.dispatcher.send(`**[Review needed]** These cron jobs completed but I'm not confident in the results:\n\n${messages.join('\n\n')}\n\nShould I try them again with a different approach?`);
515
+ logger.info({ count: escalations.length }, 'Delivered confidence escalations to user');
516
+ }
517
+ }
518
+ }
519
+ catch (err) {
520
+ logger.debug({ err }, 'Escalation check failed (non-fatal)');
521
+ }
522
+ // ── Proactive insight engine ─────────────────────────────────────
523
+ try {
524
+ await this.runInsightCheck();
525
+ }
526
+ catch (err) {
527
+ logger.debug({ err }, 'Insight check failed (non-fatal)');
528
+ }
529
+ // ── Per-agent heartbeats ──────────────────────────────────────────
530
+ // Each team agent with a HEARTBEAT.md gets its own check-in
531
+ try {
532
+ const agentMgr = this.gateway.getAgentManager();
533
+ const agents = agentMgr.listAll().filter(a => a.slug !== 'clementine');
534
+ for (const agent of agents) {
535
+ const agentHbFile = path.join(BASE_DIR, 'vault', '00-System', 'agents', agent.slug, 'HEARTBEAT.md');
536
+ if (!existsSync(agentHbFile))
537
+ continue;
538
+ try {
539
+ const agentInstructions = readFileSync(agentHbFile, 'utf-8').trim();
540
+ if (!agentInstructions)
541
+ continue;
542
+ const agentProfile = agent;
543
+ logger.info({ agent: agent.slug }, 'Running agent heartbeat');
544
+ const agentResponse = await this.gateway.handleHeartbeat(agentInstructions, '', // no shared changes summary for agent heartbeats
545
+ timeContext, '', // no dedup for agent heartbeats yet
546
+ agentProfile);
547
+ if (agentResponse && !HeartbeatScheduler.shouldSuppressMessage(agentResponse)) {
548
+ const cleanAgentResponse = HeartbeatScheduler.stripTopicTags(agentResponse);
549
+ await this.dispatcher.send(`**[${agent.name} check-in]**\n\n${cleanAgentResponse}`, { agentSlug: agent.slug });
550
+ // Inject agent heartbeat into owner's DM session so replies have context
551
+ if (DISCORD_OWNER_ID && DISCORD_OWNER_ID !== '0') {
552
+ this.gateway.injectContext(`discord:user:${DISCORD_OWNER_ID}`, `[${agent.name} check-in]`, cleanAgentResponse);
553
+ }
554
+ }
555
+ }
556
+ catch (err) {
557
+ logger.warn({ err, agent: agent.slug }, 'Agent heartbeat failed');
558
+ }
559
+ }
560
+ }
561
+ catch (err) {
562
+ logger.warn({ err }, 'Per-agent heartbeat loop failed');
563
+ }
564
+ } // end of shouldInvokeAgent else-block
565
+ }
566
+ /**
567
+ * Proactive insight check — gather signals, evaluate urgency, send if warranted.
568
+ * Runs as a lightweight Haiku call, separate from the main heartbeat LLM invocation.
569
+ */
570
+ async runInsightCheck() {
571
+ // Initialize insight state if needed
572
+ if (!this.lastState.insightState) {
573
+ this.lastState.insightState = {
574
+ sentToday: [],
575
+ unackedCount: 0,
576
+ cooldownMultiplier: 1,
577
+ };
578
+ }
579
+ const insightState = this.lastState.insightState;
580
+ // Check throttling
581
+ if (!canSendInsight(insightState))
582
+ return;
583
+ // Check for increased cooldown due to ignored messages
584
+ maybeIncreaseCooldown(insightState);
585
+ // Gather raw signals (no LLM call)
586
+ const signals = gatherInsightSignals(this.gateway);
587
+ if (signals.length === 0)
588
+ return;
589
+ // Build prompt for urgency rating
590
+ const prompt = buildInsightPrompt(signals);
591
+ if (!prompt)
592
+ return;
593
+ // Run lightweight LLM call via gateway
594
+ const response = await this.gateway.handleCronJob('insight-check', prompt, 1, // tier 1
595
+ 1, // max 1 turn (just rating + message)
596
+ 'haiku');
597
+ if (!response)
598
+ return;
599
+ const insight = parseInsightResponse(response);
600
+ if (!insight)
601
+ return;
602
+ // Urgency-based delivery
603
+ const hour = new Date().getHours();
604
+ const inActiveHours = hour >= HEARTBEAT_ACTIVE_START && hour < HEARTBEAT_ACTIVE_END;
605
+ if (insight.urgency >= 5) {
606
+ // Critical: send immediately regardless of hours
607
+ await this.dispatcher.send(`**[Proactive alert]** ${insight.message}`);
608
+ recordInsightSent(insightState);
609
+ this.saveState();
610
+ }
611
+ else if (insight.urgency >= 4 && inActiveHours) {
612
+ // Important: send during active hours
613
+ await this.dispatcher.send(`**[Heads up]** ${insight.message}`);
614
+ recordInsightSent(insightState);
615
+ this.saveState();
616
+ }
617
+ // Urgency 3 = informational — already included in regular heartbeat context
618
+ // via the signals gathered above, no separate notification needed
619
+ }
620
+ /** Called when user replies to a proactive message — resets cooldown. */
621
+ recordInsightAcknowledged() {
622
+ if (!this.lastState.insightState)
623
+ return;
624
+ recordInsightAcked(this.lastState.insightState);
625
+ this.saveState();
626
+ }
627
+ readHeartbeatConfig() {
628
+ if (!existsSync(HEARTBEAT_FILE)) {
629
+ return 'Work-first heartbeat. Execute any queued work items first, then check for genuinely NEW issues only. ' +
630
+ 'If overdue tasks exist, alert. If nothing changed since last report, respond with exactly: __NOTHING__ ' +
631
+ 'Tag each topic: [topic: short-key]. No bullet checklists. Write naturally.';
632
+ }
633
+ const raw = readFileSync(HEARTBEAT_FILE, 'utf-8');
634
+ const parsed = matter(raw);
635
+ return parsed.content;
636
+ }
637
+ loadState() {
638
+ if (existsSync(this.stateFile)) {
639
+ try {
640
+ return JSON.parse(readFileSync(this.stateFile, 'utf-8'));
641
+ }
642
+ catch {
643
+ logger.warn('Failed to load heartbeat state — starting fresh');
644
+ }
645
+ }
646
+ return { fingerprint: '', details: {}, timestamp: '' };
647
+ }
648
+ saveState() {
649
+ try {
650
+ writeFileSync(this.stateFile, JSON.stringify(this.lastState, null, 2));
651
+ }
652
+ catch (err) {
653
+ logger.warn({ err }, 'Failed to save heartbeat state');
654
+ }
655
+ }
656
+ computeStateFingerprint() {
657
+ const details = {};
658
+ const todayStr = todayISO();
659
+ // Count tasks by status from TASKS.md
660
+ if (existsSync(TASKS_FILE)) {
661
+ const content = readFileSync(TASKS_FILE, 'utf-8');
662
+ let overdue = 0;
663
+ let dueToday = 0;
664
+ let pending = 0;
665
+ for (const line of content.split('\n')) {
666
+ const s = line.trim();
667
+ if (/^- \[ \]/.test(s)) {
668
+ pending++;
669
+ const dueMatch = s.match(/📅\s*(\d{4}-\d{2}-\d{2})/);
670
+ if (dueMatch) {
671
+ const dueDate = dueMatch[1];
672
+ if (dueDate < todayStr)
673
+ overdue++;
674
+ else if (dueDate === todayStr)
675
+ dueToday++;
676
+ }
677
+ }
678
+ }
679
+ details.tasks_pending = pending;
680
+ details.tasks_overdue = overdue;
681
+ details.tasks_due_today = dueToday;
682
+ }
683
+ // Count inbox items
684
+ if (existsSync(INBOX_DIR)) {
685
+ try {
686
+ const files = readdirSync(INBOX_DIR).filter((f) => f.endsWith('.md'));
687
+ details.inbox_count = files.length;
688
+ }
689
+ catch {
690
+ details.inbox_count = 0;
691
+ }
692
+ }
693
+ // Hash of today's daily note size
694
+ const todayNote = path.join(DAILY_NOTES_DIR, `${todayStr}.md`);
695
+ if (existsSync(todayNote)) {
696
+ details.daily_note_size = statSync(todayNote).size;
697
+ }
698
+ // Include the date so day rollover always triggers a heartbeat
699
+ details.today = todayStr;
700
+ // Build fingerprint from details
701
+ const fingerprintStr = JSON.stringify(details, Object.keys(details).sort());
702
+ const fingerprint = createHash('md5').update(fingerprintStr).digest('hex').slice(0, 12);
703
+ return [fingerprint, details];
704
+ }
705
+ computeChangesSummary(oldDetails, newDetails) {
706
+ const changes = [];
707
+ const oldOverdue = Number(oldDetails.tasks_overdue ?? 0);
708
+ const newOverdue = Number(newDetails.tasks_overdue ?? 0);
709
+ if (newOverdue > oldOverdue) {
710
+ changes.push(`${newOverdue - oldOverdue} NEW overdue task(s) since last check`);
711
+ }
712
+ else if (newOverdue > 0) {
713
+ changes.push(`${newOverdue} overdue task(s)`);
714
+ }
715
+ const oldDue = Number(oldDetails.tasks_due_today ?? 0);
716
+ const newDue = Number(newDetails.tasks_due_today ?? 0);
717
+ if (newDue !== oldDue) {
718
+ changes.push(`Tasks due today: ${oldDue} → ${newDue}`);
719
+ }
720
+ const oldPending = Number(oldDetails.tasks_pending ?? 0);
721
+ const newPending = Number(newDetails.tasks_pending ?? 0);
722
+ if (newPending !== oldPending) {
723
+ const diff = newPending - oldPending;
724
+ const word = diff > 0 ? 'added' : 'completed/removed';
725
+ changes.push(`${Math.abs(diff)} task(s) ${word} (pending: ${oldPending} → ${newPending})`);
726
+ }
727
+ const oldInbox = Number(oldDetails.inbox_count ?? 0);
728
+ const newInbox = Number(newDetails.inbox_count ?? 0);
729
+ if (newInbox > oldInbox) {
730
+ changes.push(`${newInbox - oldInbox} new inbox item(s)`);
731
+ }
732
+ // Track daily note changes for context but don't count as "real" —
733
+ // these are usually self-caused by heartbeat logging
734
+ const oldSize = Number(oldDetails.daily_note_size ?? 0);
735
+ const newSize = Number(newDetails.daily_note_size ?? 0);
736
+ const noteInfo = [];
737
+ if (newSize > oldSize && oldSize > 0) {
738
+ noteInfo.push('Daily note has new entries');
739
+ }
740
+ else if (newSize > 0 && oldSize === 0) {
741
+ noteInfo.push('Daily note was created');
742
+ }
743
+ const hasRealChanges = changes.length > 0;
744
+ const allChanges = [...changes, ...noteInfo];
745
+ return {
746
+ summary: allChanges.length > 0 ? allChanges.join('; ') : '',
747
+ hasRealChanges,
748
+ };
749
+ }
750
+ /**
751
+ * Summarise recent chat/cron activity from transcripts so the heartbeat
752
+ * agent knows what happened since the last beat.
753
+ */
754
+ getRecentActivitySummary() {
755
+ const sinceIso = this.lastState.timestamp || new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString();
756
+ const entries = this.gateway.getRecentActivity(sinceIso);
757
+ if (entries.length === 0)
758
+ return '';
759
+ // Group by session and summarise
760
+ const sessions = new Map();
761
+ for (const e of entries) {
762
+ if (e.role === 'system')
763
+ continue; // skip tool-call audit entries
764
+ const info = sessions.get(e.sessionKey) ?? { count: 0, snippets: [] };
765
+ info.count++;
766
+ if (info.snippets.length < 2) {
767
+ const label = e.role === 'user' ? 'User' : 'Bot';
768
+ info.snippets.push(`${label}: ${e.content.slice(0, 150)}`);
769
+ }
770
+ sessions.set(e.sessionKey, info);
771
+ }
772
+ const lines = [];
773
+ for (const [key, info] of sessions) {
774
+ const channel = key.split(':').slice(0, 2).join(':');
775
+ lines.push(`- ${channel}: ${info.count} messages`);
776
+ for (const s of info.snippets) {
777
+ lines.push(` ${s}`);
778
+ }
779
+ }
780
+ return lines.join('\n');
781
+ }
782
+ /**
783
+ * Load active goal summaries for injection into heartbeat prompts.
784
+ * Returns null if no active goals exist.
785
+ */
786
+ static loadGoalSummary() {
787
+ try {
788
+ if (!existsSync(GOALS_DIR))
789
+ return null;
790
+ const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
791
+ if (files.length === 0)
792
+ return null;
793
+ const activeGoals = files
794
+ .map(f => { try {
795
+ return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
796
+ }
797
+ catch {
798
+ return null;
799
+ } })
800
+ .filter((g) => g && g.status === 'active');
801
+ if (activeGoals.length === 0)
802
+ return null;
803
+ const now = Date.now();
804
+ const DAY_MS = 86_400_000;
805
+ const lines = activeGoals.map((g) => {
806
+ const nextAct = g.nextActions?.length > 0 ? ` | Next: ${g.nextActions[0]}` : '';
807
+ const blockers = g.blockers?.length > 0 ? ` | BLOCKED: ${g.blockers[0]}` : '';
808
+ // Flag stale goals that haven't been updated recently
809
+ const lastUpdate = g.updatedAt ? new Date(g.updatedAt).getTime() : 0;
810
+ const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
811
+ const staleThreshold = g.reviewFrequency === 'daily' ? 1 : g.reviewFrequency === 'weekly' ? 7 : 30;
812
+ const staleTag = daysSinceUpdate > staleThreshold ? ` | ⚠ STALE (${daysSinceUpdate}d since update)` : '';
813
+ return `- [${g.priority.toUpperCase()}] ${g.title} (${g.id}, owner: ${g.owner})${nextAct}${blockers}${staleTag}`;
814
+ });
815
+ // Count goals needing work
816
+ const staleCount = activeGoals.filter((g) => {
817
+ const lastUpdate = g.updatedAt ? new Date(g.updatedAt).getTime() : 0;
818
+ const daysSince = Math.floor((now - lastUpdate) / DAY_MS);
819
+ const threshold = g.reviewFrequency === 'daily' ? 1 : g.reviewFrequency === 'weekly' ? 7 : 30;
820
+ return daysSince > threshold;
821
+ }).length;
822
+ let header = `Active goals (${activeGoals.length}):`;
823
+ if (staleCount > 0) {
824
+ header += ` ${staleCount} goal(s) are STALE and need attention. Use \`goal_work\` to spawn focused work sessions on stale or high-priority goals.`;
825
+ }
826
+ else {
827
+ header += ' Review if any need attention.';
828
+ }
829
+ return `${header}\n${lines.join('\n')}`;
830
+ }
831
+ catch (err) {
832
+ logger.warn({ err }, 'Goal summary enrichment failed');
833
+ return null;
834
+ }
835
+ }
836
+ /**
837
+ * Enrich top active goals with relevant memory snippets.
838
+ * Searches FTS5 memory for each goal's title+description to surface
839
+ * recent conversations and facts the heartbeat agent can act on.
840
+ */
841
+ enrichGoalsWithMemory() {
842
+ try {
843
+ if (!existsSync(GOALS_DIR))
844
+ return null;
845
+ const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
846
+ if (files.length === 0)
847
+ return null;
848
+ const goals = files
849
+ .map(f => { try {
850
+ return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
851
+ }
852
+ catch {
853
+ return null;
854
+ } })
855
+ .filter((g) => g && g.status === 'active' && g.priority !== 'low');
856
+ if (goals.length === 0)
857
+ return null;
858
+ // Sort by priority (high first) and take top 3
859
+ const priorityOrder = { high: 0, medium: 1 };
860
+ goals.sort((a, b) => (priorityOrder[a.priority] ?? 2) - (priorityOrder[b.priority] ?? 2));
861
+ const topGoals = goals.slice(0, 3);
862
+ const sections = [];
863
+ for (const goal of topGoals) {
864
+ const query = `${goal.title} ${goal.description || ''}`.trim();
865
+ const results = this.gateway.searchMemory(query, 2);
866
+ if (results.length === 0)
867
+ continue;
868
+ const snippets = results.map((r) => {
869
+ const source = r.sourceFile ? path.basename(r.sourceFile, path.extname(r.sourceFile)) : r.section;
870
+ const content = (r.content || '').slice(0, 150).replace(/\n/g, ' ').trim();
871
+ return ` [${source}] ${content}`;
872
+ });
873
+ sections.push(`- ${goal.title}:\n${snippets.join('\n')}`);
874
+ }
875
+ if (sections.length === 0)
876
+ return null;
877
+ return `Memory insights for active goals (act on these if relevant):\n${sections.join('\n')}`;
878
+ }
879
+ catch (err) {
880
+ logger.warn({ err }, 'Goal memory enrichment failed');
881
+ return null;
882
+ }
883
+ }
884
+ /**
885
+ * Proactively advance goals by writing trigger files for the cron scheduler
886
+ * to pick up. Scores ALL active goals — not just stale ones — so high-priority
887
+ * goals with pending nextActions get worked on even if recently updated.
888
+ *
889
+ * Conservative: max 2 triggers per tick, skips if triggers are already pending.
890
+ */
891
+ advanceGoals() {
892
+ try {
893
+ const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
894
+ mkdirSync(goalTriggerDir, { recursive: true });
895
+ // Guard: don't double-trigger if files are already pending
896
+ const pending = readdirSync(goalTriggerDir).filter(f => f.endsWith('.trigger.json'));
897
+ if (pending.length > 0)
898
+ return;
899
+ if (!existsSync(GOALS_DIR))
900
+ return;
901
+ const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
902
+ if (files.length === 0)
903
+ return;
904
+ const now = Date.now();
905
+ const DAY_MS = 86_400_000;
906
+ // Load recent goal outcomes for disposition-based throttling.
907
+ // The agent classifies each outcome (ADVANCED, BLOCKED_ON_USER, etc.)
908
+ // and we use that to decide when/whether to retry.
909
+ const goalCooldowns = new Set();
910
+ const HOUR_MS = 60 * 60 * 1000;
911
+ try {
912
+ const progressDir = path.join(GOALS_DIR, 'progress');
913
+ if (existsSync(progressDir)) {
914
+ for (const pf of readdirSync(progressDir).filter(f => f.endsWith('.progress.jsonl'))) {
915
+ const lines = readFileSync(path.join(progressDir, pf), 'utf-8').trim().split('\n').filter(Boolean);
916
+ const recent = lines.slice(-3).map(l => { try {
917
+ return JSON.parse(l);
918
+ }
919
+ catch {
920
+ return null;
921
+ } }).filter(Boolean);
922
+ if (recent.length === 0)
923
+ continue;
924
+ const goalId = recent[0].goalId;
925
+ const lastEntry = recent[recent.length - 1];
926
+ const lastAge = now - new Date(lastEntry.timestamp).getTime();
927
+ const disposition = lastEntry.disposition ?? lastEntry.status;
928
+ // Disposition-based cooldowns:
929
+ switch (disposition) {
930
+ case 'blocked-on-user':
931
+ // Don't retry until user interacts — check once per 8 hours as a gentle reminder
932
+ if (lastAge < 8 * HOUR_MS)
933
+ goalCooldowns.add(goalId);
934
+ break;
935
+ case 'blocked-on-external':
936
+ // Check every 2 hours — external state may have changed
937
+ if (lastAge < 2 * HOUR_MS)
938
+ goalCooldowns.add(goalId);
939
+ break;
940
+ case 'needs-different-approach':
941
+ // Wait 4 hours — give SI loop or human time to adjust strategy
942
+ if (lastAge < 4 * HOUR_MS)
943
+ goalCooldowns.add(goalId);
944
+ break;
945
+ case 'monitoring':
946
+ // Monitoring = checked, nothing changed. Check every 2 hours
947
+ if (lastAge < 2 * HOUR_MS)
948
+ goalCooldowns.add(goalId);
949
+ break;
950
+ case 'error':
951
+ case 'no-change':
952
+ // Failures: 2hr cooldown
953
+ if (lastAge < 2 * HOUR_MS)
954
+ goalCooldowns.add(goalId);
955
+ break;
956
+ case 'advanced':
957
+ default:
958
+ // Made real progress — eligible for next tick (no cooldown)
959
+ break;
960
+ }
961
+ }
962
+ }
963
+ }
964
+ catch { /* non-fatal */ }
965
+ // Score ALL active goals — stale goals get urgency bonus, but
966
+ // non-stale high-priority goals with pending work also qualify.
967
+ const scoredGoals = [];
968
+ for (const f of files) {
969
+ try {
970
+ const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
971
+ if (goal.status !== 'active')
972
+ continue;
973
+ // Skip goals in cooldown (failed recently or producing stale output)
974
+ if (goalCooldowns.has(goal.id))
975
+ continue;
976
+ const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
977
+ const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
978
+ const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
979
+ const isStale = daysSinceUpdate > staleThreshold;
980
+ const hasWork = (goal.nextActions?.length ?? 0) > 0;
981
+ // Skip non-stale goals that have no pending work
982
+ if (!isStale && !hasWork)
983
+ continue;
984
+ // Scoring: priority (high=15, medium=8, low=2) + staleness bonus + work availability
985
+ const priorityScore = goal.priority === 'high' ? 15 : goal.priority === 'medium' ? 8 : 2;
986
+ const stalenessScore = isStale ? Math.min(daysSinceUpdate - staleThreshold, 20) : 0;
987
+ const workScore = hasWork ? 5 : 0;
988
+ // Goals approaching target date get a deadline urgency boost
989
+ let deadlineScore = 0;
990
+ if (goal.targetDate) {
991
+ const daysUntilTarget = Math.floor((new Date(goal.targetDate).getTime() - now) / DAY_MS);
992
+ if (daysUntilTarget <= 0)
993
+ deadlineScore = 20; // overdue
994
+ else if (daysUntilTarget <= 3)
995
+ deadlineScore = 10; // imminent
996
+ else if (daysUntilTarget <= 7)
997
+ deadlineScore = 5; // approaching
998
+ }
999
+ const totalScore = priorityScore + stalenessScore + workScore + deadlineScore;
1000
+ const reason = [
1001
+ isStale ? `stale(${daysSinceUpdate}d)` : 'current',
1002
+ `pri=${goal.priority}`,
1003
+ hasWork ? 'has-work' : 'no-work',
1004
+ deadlineScore > 0 ? `deadline-boost(${deadlineScore})` : '',
1005
+ ].filter(Boolean).join(', ');
1006
+ scoredGoals.push({ goal, score: totalScore, reason });
1007
+ }
1008
+ catch {
1009
+ continue;
1010
+ }
1011
+ }
1012
+ if (scoredGoals.length === 0)
1013
+ return;
1014
+ // Sort by score descending, take top 2
1015
+ scoredGoals.sort((a, b) => b.score - a.score);
1016
+ const toAdvance = scoredGoals.slice(0, 2);
1017
+ for (const { goal, reason } of toAdvance) {
1018
+ const focus = goal.nextActions?.length > 0
1019
+ ? goal.nextActions[0]
1020
+ : `Review and update progress on "${goal.title}"`;
1021
+ const trigger = {
1022
+ goalId: goal.id,
1023
+ focus,
1024
+ maxTurns: 30,
1025
+ triggeredAt: new Date().toISOString(),
1026
+ source: 'heartbeat-advance',
1027
+ reason,
1028
+ };
1029
+ const triggerPath = path.join(goalTriggerDir, `${goal.id}.trigger.json`);
1030
+ writeFileSync(triggerPath, JSON.stringify(trigger, null, 2));
1031
+ logger.info({ goalId: goal.id, title: goal.title, score: toAdvance[0].score, reason, focus }, 'Advancing goal via trigger');
1032
+ }
1033
+ // Note: task generation removed — the main goal trigger already includes
1034
+ // nextActions[0] as focus, so a separate task-gen trigger was redundant
1035
+ // and doubled every goal work attempt.
1036
+ }
1037
+ catch (err) {
1038
+ logger.warn({ err }, 'Failed to advance goals');
1039
+ }
1040
+ }
1041
+ /**
1042
+ * Process pending inbox items. For each .md file in INBOX_DIR:
1043
+ * - Routes to the gateway as a cron-style task for the agent to triage
1044
+ * - The agent determines the intent (task, reference, reminder) and acts
1045
+ * - Processed files are moved to a _processed subfolder
1046
+ *
1047
+ * Conservative: processes at most 3 items per heartbeat tick.
1048
+ */
1049
+ processInbox() {
1050
+ try {
1051
+ if (!existsSync(INBOX_DIR))
1052
+ return;
1053
+ const files = readdirSync(INBOX_DIR).filter((f) => f.endsWith('.md') && !f.startsWith('_'));
1054
+ if (files.length === 0)
1055
+ return;
1056
+ // Process at most 3 items per tick to avoid overloading
1057
+ const batch = files.slice(0, 3);
1058
+ const processedDir = path.join(INBOX_DIR, '_processed');
1059
+ mkdirSync(processedDir, { recursive: true });
1060
+ for (const file of batch) {
1061
+ const filePath = path.join(INBOX_DIR, file);
1062
+ try {
1063
+ const content = readFileSync(filePath, 'utf-8');
1064
+ const title = file.replace(/\.md$/, '');
1065
+ // Move file before processing to prevent duplicate triage on next tick
1066
+ const destPath = path.join(processedDir, file);
1067
+ try {
1068
+ writeFileSync(destPath, content);
1069
+ unlinkSync(filePath);
1070
+ }
1071
+ catch {
1072
+ // If move fails, skip — will retry next tick
1073
+ continue;
1074
+ }
1075
+ // Build a prompt for the agent to triage this inbox item
1076
+ const prompt = `Triage this inbox item and take appropriate action.\n\n` +
1077
+ `**Title:** ${title}\n` +
1078
+ `**Content:**\n${content.slice(0, 2000)}\n\n` +
1079
+ `## Instructions:\n` +
1080
+ `1. Determine the intent: Is this a task, a reference/note, a reminder, or something else?\n` +
1081
+ `2. Take the appropriate action:\n` +
1082
+ ` - **Task**: Use \`task_add\` to create a task with the right priority and due date.\n` +
1083
+ ` - **Reference**: Use \`note_create\` or \`memory_write\` to file it in the vault.\n` +
1084
+ ` - **Reminder**: Add to today's daily note with \`memory_write(action="append_daily")\`.\n` +
1085
+ ` - **Project update**: Update the relevant project note.\n` +
1086
+ `3. Respond with a one-line summary of what you did.`;
1087
+ // Fire-and-forget — run as a lightweight cron job
1088
+ this.gateway
1089
+ .handleCronJob(`inbox:${title}`, prompt, 1, 5)
1090
+ .then((result) => {
1091
+ if (result) {
1092
+ logToDailyNote(`**Inbox processed: ${title}** — ${result.slice(0, 100).replace(/\n/g, ' ')}`);
1093
+ }
1094
+ logger.info({ file: title }, 'Inbox item processed');
1095
+ })
1096
+ .catch((err) => {
1097
+ // Restore file to inbox on failure so it retries
1098
+ try {
1099
+ writeFileSync(filePath, content);
1100
+ unlinkSync(destPath);
1101
+ }
1102
+ catch { /* best-effort restore */ }
1103
+ logger.warn({ err, file: title }, 'Failed to process inbox item');
1104
+ });
1105
+ }
1106
+ catch (err) {
1107
+ logger.warn({ err, file }, 'Failed to read inbox item');
1108
+ }
1109
+ }
1110
+ }
1111
+ catch (err) {
1112
+ logger.warn({ err }, 'Failed to process inbox');
1113
+ }
1114
+ }
1115
+ static getTimeContext(hour) {
1116
+ const day = new Date().toLocaleDateString('en-US', { weekday: 'long' });
1117
+ if (hour >= 8 && hour < 10) {
1118
+ return `${day} morning — Be forward-looking. Mention today's plan priorities, flag anything due today, set the tone for the day.`;
1119
+ }
1120
+ else if (hour >= 10 && hour < 14) {
1121
+ return `${day} midday — Quick progress check. Reference anything discussed earlier today. Flag stuck or overdue items briefly.`;
1122
+ }
1123
+ else if (hour >= 14 && hour < 18) {
1124
+ return `${day} afternoon — Focus on what's been accomplished and what's still open. Be brief unless something needs attention.`;
1125
+ }
1126
+ else if (hour >= 18 && hour < 22) {
1127
+ return `${day} evening — Reflective wrap-up. Summarize what got done, note anything carrying over to tomorrow. Good time to consolidate memory and promote durable facts.`;
1128
+ }
1129
+ return '';
1130
+ }
1131
+ static shouldSuppressMessage(response) {
1132
+ const trimmed = response.trim();
1133
+ // Suppress the explicit opt-out signal
1134
+ if (trimmed === '__NOTHING__')
1135
+ return true;
1136
+ // Suppress variations the model sometimes produces
1137
+ if (/^_*NOTHING_*$/i.test(trimmed))
1138
+ return true;
1139
+ // Suppress "NOTHING" followed by parenthetical context (e.g. "NOTHING\n\n(Same blockers...)")
1140
+ if (/^_*NOTHING_*\s*(\(|$)/im.test(trimmed))
1141
+ return true;
1142
+ // Suppress empty-substance responses: the model announces what it would do but produces no actual content
1143
+ // e.g. "I'll run the heartbeat check." followed by nothing, or tool-not-available complaints
1144
+ const lower = trimmed.toLowerCase();
1145
+ if (lower.length < 200 && (/^i'?ll run the heartbeat/i.test(lower) ||
1146
+ /tools?.{0,20}(?:aren'?t|not|unavailable|isn'?t).{0,20}(?:available|accessible|loaded)/i.test(lower) ||
1147
+ /can'?t (?:load state|check|properly run|access)/i.test(lower)))
1148
+ return true;
1149
+ return false;
1150
+ }
1151
+ // ── Dedup Ledger ──────────────────────────────────────────────────
1152
+ pruneReportedTopics() {
1153
+ const topics = this.lastState.reportedTopics ?? [];
1154
+ const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000;
1155
+ this.lastState.reportedTopics = topics
1156
+ .filter((t) => new Date(t.reportedAt).getTime() > fourHoursAgo)
1157
+ .slice(-20);
1158
+ }
1159
+ buildDedupContext() {
1160
+ const topics = this.lastState.reportedTopics ?? [];
1161
+ if (topics.length === 0)
1162
+ return '';
1163
+ const lines = topics.map((t) => {
1164
+ const time = new Date(t.reportedAt).toLocaleTimeString('en-US', {
1165
+ hour: 'numeric', minute: '2-digit', hour12: true,
1166
+ });
1167
+ return `- ${time}: ${t.summary} [topic: ${t.topic}]`;
1168
+ });
1169
+ return `## Already Reported (do NOT repeat unless status changed)\n${lines.join('\n')}`;
1170
+ }
1171
+ static extractReportedTopics(response) {
1172
+ const topics = [];
1173
+ const tagRegex = /\[topic:\s*([^\]]+)\]/g;
1174
+ let match;
1175
+ while ((match = tagRegex.exec(response)) !== null) {
1176
+ const topic = match[1].trim();
1177
+ const lineStart = response.lastIndexOf('\n', match.index) + 1;
1178
+ const lineEnd = response.indexOf('\n', match.index + match[0].length);
1179
+ const line = response.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
1180
+ const summary = line.replace(/\[topic:[^\]]+\]/g, '').trim();
1181
+ // Derive agentSlug from topic key — e.g. "ross:appointments" → "ross"
1182
+ const colonIdx = topic.indexOf(':');
1183
+ const agentSlug = colonIdx > 0 ? topic.substring(0, colonIdx) : undefined;
1184
+ topics.push({
1185
+ topic,
1186
+ summary,
1187
+ reportedAt: new Date().toISOString(),
1188
+ ...(agentSlug ? { agentSlug } : {}),
1189
+ });
1190
+ }
1191
+ return topics;
1192
+ }
1193
+ static stripTopicTags(response) {
1194
+ return response.replace(/\s*\[topic:[^\]]+\]/g, '').trim();
1195
+ }
1196
+ // ── Work Queue ────────────────────────────────────────────────────
1197
+ static loadWorkQueue() {
1198
+ try {
1199
+ if (!existsSync(HEARTBEAT_WORK_QUEUE_FILE))
1200
+ return [];
1201
+ return JSON.parse(readFileSync(HEARTBEAT_WORK_QUEUE_FILE, 'utf-8'));
1202
+ }
1203
+ catch {
1204
+ return [];
1205
+ }
1206
+ }
1207
+ static saveWorkQueue(items) {
1208
+ const dir = path.dirname(HEARTBEAT_WORK_QUEUE_FILE);
1209
+ mkdirSync(dir, { recursive: true });
1210
+ writeFileSync(HEARTBEAT_WORK_QUEUE_FILE, JSON.stringify(items, null, 2));
1211
+ }
1212
+ claimNextItem() {
1213
+ const queue = HeartbeatScheduler.loadWorkQueue();
1214
+ // Auto-cleanup: remove items older than 24 hours
1215
+ const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
1216
+ const cleaned = queue.filter((item) => new Date(item.queuedAt).getTime() > dayAgo || item.status === 'running');
1217
+ // Find highest-priority pending item
1218
+ const pending = cleaned.filter((item) => item.status === 'pending');
1219
+ pending.sort((a, b) => {
1220
+ if (a.priority === 'high' && b.priority !== 'high')
1221
+ return -1;
1222
+ if (b.priority === 'high' && a.priority !== 'high')
1223
+ return 1;
1224
+ return new Date(a.queuedAt).getTime() - new Date(b.queuedAt).getTime();
1225
+ });
1226
+ const next = pending[0];
1227
+ if (!next) {
1228
+ if (cleaned.length !== queue.length)
1229
+ HeartbeatScheduler.saveWorkQueue(cleaned);
1230
+ return null;
1231
+ }
1232
+ next.status = 'running';
1233
+ HeartbeatScheduler.saveWorkQueue(cleaned);
1234
+ return next;
1235
+ }
1236
+ completeItem(id, result) {
1237
+ const queue = HeartbeatScheduler.loadWorkQueue();
1238
+ const item = queue.find((i) => i.id === id);
1239
+ if (item) {
1240
+ item.status = 'completed';
1241
+ item.completedAt = new Date().toISOString();
1242
+ item.result = result.slice(0, 500);
1243
+ HeartbeatScheduler.saveWorkQueue(queue);
1244
+ }
1245
+ }
1246
+ failItem(id, error) {
1247
+ const queue = HeartbeatScheduler.loadWorkQueue();
1248
+ const item = queue.find((i) => i.id === id);
1249
+ if (item) {
1250
+ item.status = 'failed';
1251
+ item.completedAt = new Date().toISOString();
1252
+ item.error = error.slice(0, 500);
1253
+ HeartbeatScheduler.saveWorkQueue(queue);
1254
+ }
1255
+ }
1256
+ static enqueueWork(opts) {
1257
+ const queue = HeartbeatScheduler.loadWorkQueue();
1258
+ const id = randomBytes(4).toString('hex');
1259
+ const item = {
1260
+ id,
1261
+ description: opts.description,
1262
+ prompt: opts.prompt,
1263
+ source: opts.source,
1264
+ priority: opts.priority ?? 'normal',
1265
+ queuedAt: new Date().toISOString(),
1266
+ maxTurns: opts.maxTurns ?? 3,
1267
+ tier: opts.tier ?? 1,
1268
+ status: 'pending',
1269
+ ...(opts.agentSlug ? { agentSlug: opts.agentSlug } : {}),
1270
+ };
1271
+ queue.push(item);
1272
+ HeartbeatScheduler.saveWorkQueue(queue);
1273
+ logger.info({ id, description: opts.description }, 'Work item enqueued for heartbeat');
1274
+ return id;
1275
+ }
1276
+ // ── Decision Logic ────────────────────────────────────────────────
1277
+ shouldInvokeAgent(hasRealChanges, workCompleted, currentDetails) {
1278
+ if (workCompleted.length > 0)
1279
+ return true;
1280
+ const newOverdue = Number(currentDetails.tasks_overdue ?? 0);
1281
+ if (newOverdue > 0) {
1282
+ const lastReported = (this.lastState.reportedTopics ?? [])
1283
+ .find((t) => t.topic === 'overdue-tasks');
1284
+ if (!lastReported)
1285
+ return true;
1286
+ const hoursSince = (Date.now() - new Date(lastReported.reportedAt).getTime()) / (60 * 60 * 1000);
1287
+ if (hoursSince >= 1)
1288
+ return true;
1289
+ }
1290
+ if (hasRealChanges)
1291
+ return true;
1292
+ const silentBeats = this.lastState.consecutiveSilentBeats ?? 0;
1293
+ if (silentBeats >= 3)
1294
+ return true;
1295
+ return false;
1296
+ }
1297
+ }
1298
+ //# sourceMappingURL=heartbeat-scheduler.js.map