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.
- package/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/agent/agent-manager.d.ts +69 -0
- package/dist/agent/agent-manager.js +441 -0
- package/dist/agent/assistant.d.ts +225 -0
- package/dist/agent/assistant.js +3888 -0
- package/dist/agent/auto-update.d.ts +32 -0
- package/dist/agent/auto-update.js +186 -0
- package/dist/agent/daily-planner.d.ts +24 -0
- package/dist/agent/daily-planner.js +379 -0
- package/dist/agent/execution-advisor.d.ts +10 -0
- package/dist/agent/execution-advisor.js +272 -0
- package/dist/agent/hooks.d.ts +45 -0
- package/dist/agent/hooks.js +564 -0
- package/dist/agent/insight-engine.d.ts +66 -0
- package/dist/agent/insight-engine.js +225 -0
- package/dist/agent/intent-classifier.d.ts +48 -0
- package/dist/agent/intent-classifier.js +214 -0
- package/dist/agent/link-extractor.d.ts +19 -0
- package/dist/agent/link-extractor.js +90 -0
- package/dist/agent/mcp-bridge.d.ts +62 -0
- package/dist/agent/mcp-bridge.js +435 -0
- package/dist/agent/metacognition.d.ts +66 -0
- package/dist/agent/metacognition.js +221 -0
- package/dist/agent/orchestrator.d.ts +81 -0
- package/dist/agent/orchestrator.js +790 -0
- package/dist/agent/profiles.d.ts +22 -0
- package/dist/agent/profiles.js +91 -0
- package/dist/agent/prompt-cache.d.ts +24 -0
- package/dist/agent/prompt-cache.js +68 -0
- package/dist/agent/prompt-evolver.d.ts +28 -0
- package/dist/agent/prompt-evolver.js +279 -0
- package/dist/agent/role-scaffolds.d.ts +28 -0
- package/dist/agent/role-scaffolds.js +433 -0
- package/dist/agent/safe-restart.d.ts +41 -0
- package/dist/agent/safe-restart.js +150 -0
- package/dist/agent/self-improve.d.ts +66 -0
- package/dist/agent/self-improve.js +1706 -0
- package/dist/agent/session-event-log.d.ts +114 -0
- package/dist/agent/session-event-log.js +233 -0
- package/dist/agent/skill-extractor.d.ts +72 -0
- package/dist/agent/skill-extractor.js +435 -0
- package/dist/agent/source-mods.d.ts +61 -0
- package/dist/agent/source-mods.js +230 -0
- package/dist/agent/source-preflight.d.ts +25 -0
- package/dist/agent/source-preflight.js +100 -0
- package/dist/agent/stall-guard.d.ts +62 -0
- package/dist/agent/stall-guard.js +109 -0
- package/dist/agent/strategic-planner.d.ts +60 -0
- package/dist/agent/strategic-planner.js +352 -0
- package/dist/agent/team-bus.d.ts +89 -0
- package/dist/agent/team-bus.js +556 -0
- package/dist/agent/team-router.d.ts +26 -0
- package/dist/agent/team-router.js +37 -0
- package/dist/agent/tool-loop-detector.d.ts +59 -0
- package/dist/agent/tool-loop-detector.js +242 -0
- package/dist/agent/workflow-runner.d.ts +36 -0
- package/dist/agent/workflow-runner.js +317 -0
- package/dist/agent/workflow-variables.d.ts +16 -0
- package/dist/agent/workflow-variables.js +62 -0
- package/dist/channels/discord-agent-bot.d.ts +101 -0
- package/dist/channels/discord-agent-bot.js +881 -0
- package/dist/channels/discord-bot-manager.d.ts +80 -0
- package/dist/channels/discord-bot-manager.js +262 -0
- package/dist/channels/discord-utils.d.ts +51 -0
- package/dist/channels/discord-utils.js +293 -0
- package/dist/channels/discord.d.ts +12 -0
- package/dist/channels/discord.js +1832 -0
- package/dist/channels/slack-agent-bot.d.ts +73 -0
- package/dist/channels/slack-agent-bot.js +320 -0
- package/dist/channels/slack-bot-manager.d.ts +66 -0
- package/dist/channels/slack-bot-manager.js +236 -0
- package/dist/channels/slack-utils.d.ts +39 -0
- package/dist/channels/slack-utils.js +189 -0
- package/dist/channels/slack.d.ts +11 -0
- package/dist/channels/slack.js +196 -0
- package/dist/channels/telegram.d.ts +10 -0
- package/dist/channels/telegram.js +235 -0
- package/dist/channels/webhook.d.ts +9 -0
- package/dist/channels/webhook.js +78 -0
- package/dist/channels/whatsapp.d.ts +11 -0
- package/dist/channels/whatsapp.js +181 -0
- package/dist/cli/chat.d.ts +14 -0
- package/dist/cli/chat.js +220 -0
- package/dist/cli/cron.d.ts +17 -0
- package/dist/cli/cron.js +552 -0
- package/dist/cli/dashboard.d.ts +15 -0
- package/dist/cli/dashboard.js +17677 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +2474 -0
- package/dist/cli/routes/delegations.d.ts +19 -0
- package/dist/cli/routes/delegations.js +154 -0
- package/dist/cli/routes/digest.d.ts +17 -0
- package/dist/cli/routes/digest.js +375 -0
- package/dist/cli/routes/goals.d.ts +14 -0
- package/dist/cli/routes/goals.js +258 -0
- package/dist/cli/routes/workflows.d.ts +18 -0
- package/dist/cli/routes/workflows.js +97 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +619 -0
- package/dist/cli/tunnel.d.ts +35 -0
- package/dist/cli/tunnel.js +141 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +278 -0
- package/dist/events/bus.d.ts +43 -0
- package/dist/events/bus.js +136 -0
- package/dist/gateway/cron-scheduler.d.ts +166 -0
- package/dist/gateway/cron-scheduler.js +1767 -0
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +110 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
- package/dist/gateway/heartbeat-scheduler.js +1298 -0
- package/dist/gateway/heartbeat.d.ts +3 -0
- package/dist/gateway/heartbeat.js +3 -0
- package/dist/gateway/lanes.d.ts +24 -0
- package/dist/gateway/lanes.js +76 -0
- package/dist/gateway/notifications.d.ts +29 -0
- package/dist/gateway/notifications.js +75 -0
- package/dist/gateway/router.d.ts +210 -0
- package/dist/gateway/router.js +1330 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1015 -0
- package/dist/memory/chunker.d.ts +28 -0
- package/dist/memory/chunker.js +226 -0
- package/dist/memory/consolidation.d.ts +44 -0
- package/dist/memory/consolidation.js +171 -0
- package/dist/memory/context-assembler.d.ts +50 -0
- package/dist/memory/context-assembler.js +149 -0
- package/dist/memory/embeddings.d.ts +38 -0
- package/dist/memory/embeddings.js +180 -0
- package/dist/memory/graph-store.d.ts +66 -0
- package/dist/memory/graph-store.js +613 -0
- package/dist/memory/mmr.d.ts +21 -0
- package/dist/memory/mmr.js +75 -0
- package/dist/memory/search.d.ts +26 -0
- package/dist/memory/search.js +67 -0
- package/dist/memory/store.d.ts +530 -0
- package/dist/memory/store.js +2022 -0
- package/dist/security/integrity.d.ts +24 -0
- package/dist/security/integrity.js +58 -0
- package/dist/security/patterns.d.ts +34 -0
- package/dist/security/patterns.js +110 -0
- package/dist/security/scanner.d.ts +32 -0
- package/dist/security/scanner.js +263 -0
- package/dist/tools/admin-tools.d.ts +12 -0
- package/dist/tools/admin-tools.js +1278 -0
- package/dist/tools/external-tools.d.ts +11 -0
- package/dist/tools/external-tools.js +1327 -0
- package/dist/tools/goal-tools.d.ts +9 -0
- package/dist/tools/goal-tools.js +159 -0
- package/dist/tools/mcp-server.d.ts +13 -0
- package/dist/tools/mcp-server.js +141 -0
- package/dist/tools/memory-tools.d.ts +10 -0
- package/dist/tools/memory-tools.js +568 -0
- package/dist/tools/session-tools.d.ts +6 -0
- package/dist/tools/session-tools.js +146 -0
- package/dist/tools/shared.d.ts +216 -0
- package/dist/tools/shared.js +340 -0
- package/dist/tools/team-tools.d.ts +6 -0
- package/dist/tools/team-tools.js +447 -0
- package/dist/tools/tool-meta.d.ts +34 -0
- package/dist/tools/tool-meta.js +133 -0
- package/dist/tools/vault-tools.d.ts +8 -0
- package/dist/tools/vault-tools.js +457 -0
- package/dist/types.d.ts +716 -0
- package/dist/types.js +16 -0
- package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
- package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
- package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
- package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
- package/dist/vault-migrations/helpers.d.ts +14 -0
- package/dist/vault-migrations/helpers.js +44 -0
- package/dist/vault-migrations/runner.d.ts +14 -0
- package/dist/vault-migrations/runner.js +139 -0
- package/dist/vault-migrations/types.d.ts +42 -0
- package/dist/vault-migrations/types.js +9 -0
- package/install.sh +320 -0
- package/package.json +84 -0
- package/scripts/postinstall.js +125 -0
- package/vault/00-System/AGENTS.md +66 -0
- package/vault/00-System/CRON.md +71 -0
- package/vault/00-System/HEARTBEAT.md +58 -0
- package/vault/00-System/MEMORY.md +16 -0
- package/vault/00-System/SOUL.md +96 -0
- package/vault/05-Tasks/TASKS.md +19 -0
- package/vault/06-Templates/_Daily-Template.md +28 -0
- 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
|