clementine-agent 1.0.77 → 1.0.79

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.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Owns the lifecycle of all per-agent heartbeat schedulers.
3
+ *
4
+ * Boots at daemon start, scans the AgentManager's active list, spawns one
5
+ * AgentHeartbeatScheduler per agent. An outer 60s interval iterates the
6
+ * registry and fires `tick()` on any agent whose nextCheckAt is due.
7
+ *
8
+ * Reconciliation runs each outer tick: agents added to AGENTS_DIR start
9
+ * heartbeats automatically; agents removed (or paused/terminated) drop
10
+ * out. Per-agent failures are caught so one buggy agent can't crash the
11
+ * daemon or stall others.
12
+ */
13
+ import type { AgentManager } from '../agent/agent-manager.js';
14
+ import { AgentHeartbeatScheduler, type AgentHeartbeatGateway } from './agent-heartbeat-scheduler.js';
15
+ export declare class AgentHeartbeatManager {
16
+ private readonly agentManager;
17
+ private readonly gateway;
18
+ private readonly schedulers;
19
+ private timer;
20
+ private running;
21
+ private ticking;
22
+ constructor(agentManager: AgentManager, gateway?: AgentHeartbeatGateway);
23
+ start(): void;
24
+ stop(): void;
25
+ /** Add/remove schedulers to match the current AgentManager listing. */
26
+ private reconcile;
27
+ /**
28
+ * One outer-loop tick. Reconcile the registry, then fire agents whose
29
+ * nextCheckAt has come due. Runs serially to avoid races on shared
30
+ * state (goals dir, cron runs dir).
31
+ */
32
+ private outerTick;
33
+ /** Diagnostic helper for the dashboard / CLI. */
34
+ getStatus(): Array<{
35
+ slug: string;
36
+ nextCheckAt: string;
37
+ lastTickAt: string;
38
+ silentTickCount: number;
39
+ }>;
40
+ /** Look up a scheduler — useful for CLI commands like "tick this agent now." */
41
+ getScheduler(slug: string): AgentHeartbeatScheduler | null;
42
+ }
43
+ //# sourceMappingURL=agent-heartbeat-manager.d.ts.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Owns the lifecycle of all per-agent heartbeat schedulers.
3
+ *
4
+ * Boots at daemon start, scans the AgentManager's active list, spawns one
5
+ * AgentHeartbeatScheduler per agent. An outer 60s interval iterates the
6
+ * registry and fires `tick()` on any agent whose nextCheckAt is due.
7
+ *
8
+ * Reconciliation runs each outer tick: agents added to AGENTS_DIR start
9
+ * heartbeats automatically; agents removed (or paused/terminated) drop
10
+ * out. Per-agent failures are caught so one buggy agent can't crash the
11
+ * daemon or stall others.
12
+ */
13
+ import pino from 'pino';
14
+ import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
15
+ const logger = pino({ name: 'clementine.agent-heartbeat-manager' });
16
+ const OUTER_TICK_MS = 60_000;
17
+ export class AgentHeartbeatManager {
18
+ agentManager;
19
+ gateway;
20
+ schedulers = new Map();
21
+ timer = null;
22
+ running = false;
23
+ ticking = false;
24
+ constructor(agentManager, gateway) {
25
+ this.agentManager = agentManager;
26
+ this.gateway = gateway ?? null;
27
+ }
28
+ start() {
29
+ if (this.running)
30
+ return;
31
+ this.running = true;
32
+ this.reconcile();
33
+ // Run an immediate tick so schedulers boot up without a 60s delay.
34
+ this.outerTick().catch((err) => logger.error({ err }, 'Initial agent heartbeat tick failed'));
35
+ this.timer = setInterval(() => {
36
+ this.outerTick().catch((err) => logger.error({ err }, 'Agent heartbeat outer tick failed'));
37
+ }, OUTER_TICK_MS);
38
+ logger.info({ agents: this.schedulers.size }, 'Agent heartbeat manager started');
39
+ }
40
+ stop() {
41
+ if (!this.running)
42
+ return;
43
+ this.running = false;
44
+ if (this.timer) {
45
+ clearInterval(this.timer);
46
+ this.timer = null;
47
+ }
48
+ this.schedulers.clear();
49
+ logger.info('Agent heartbeat manager stopped');
50
+ }
51
+ /** Add/remove schedulers to match the current AgentManager listing. */
52
+ reconcile() {
53
+ let active = [];
54
+ try {
55
+ active = this.agentManager
56
+ .listAll()
57
+ .filter((p) => p.slug !== 'clementine' && this.agentManager.isRunnable(p.slug))
58
+ .map((p) => p.slug);
59
+ }
60
+ catch (err) {
61
+ logger.warn({ err }, 'Failed to list agents during reconcile — keeping current set');
62
+ return;
63
+ }
64
+ const activeSet = new Set(active);
65
+ // Add new
66
+ for (const slug of active) {
67
+ if (!this.schedulers.has(slug)) {
68
+ this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager, this.gateway ? { gateway: this.gateway } : {}));
69
+ logger.info({ slug }, 'Agent heartbeat: registered scheduler');
70
+ }
71
+ }
72
+ // Remove gone-or-paused
73
+ for (const slug of [...this.schedulers.keys()]) {
74
+ if (!activeSet.has(slug)) {
75
+ this.schedulers.delete(slug);
76
+ logger.info({ slug }, 'Agent heartbeat: deregistered scheduler');
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * One outer-loop tick. Reconcile the registry, then fire agents whose
82
+ * nextCheckAt has come due. Runs serially to avoid races on shared
83
+ * state (goals dir, cron runs dir).
84
+ */
85
+ async outerTick(now = new Date()) {
86
+ if (this.ticking)
87
+ return; // prior outer tick still in flight — skip
88
+ this.ticking = true;
89
+ try {
90
+ this.reconcile();
91
+ for (const [slug, scheduler] of this.schedulers) {
92
+ try {
93
+ if (!scheduler.isDue(now))
94
+ continue;
95
+ await scheduler.tick(now);
96
+ }
97
+ catch (err) {
98
+ logger.warn({ err, slug }, 'Agent heartbeat tick failed — continuing');
99
+ }
100
+ }
101
+ }
102
+ finally {
103
+ this.ticking = false;
104
+ }
105
+ }
106
+ /** Diagnostic helper for the dashboard / CLI. */
107
+ getStatus() {
108
+ const out = [];
109
+ for (const [slug, scheduler] of this.schedulers) {
110
+ const state = scheduler.loadState();
111
+ out.push({
112
+ slug,
113
+ nextCheckAt: state.nextCheckAt,
114
+ lastTickAt: state.lastTickAt,
115
+ silentTickCount: state.silentTickCount,
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+ /** Look up a scheduler — useful for CLI commands like "tick this agent now." */
121
+ getScheduler(slug) {
122
+ return this.schedulers.get(slug) ?? null;
123
+ }
124
+ }
125
+ //# sourceMappingURL=agent-heartbeat-manager.js.map
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Per-agent heartbeat scheduler — one instance per specialist agent
3
+ * (Ross, Sasha, Nora, etc.). Runs autonomously alongside Clementine's
4
+ * own HeartbeatScheduler.
5
+ *
6
+ * Phase 2 — cheap path only. No LLM call. The tick loads state, scans
7
+ * three signals (pending delegated tasks, recent goal updates, recent
8
+ * cron completions), updates fingerprint, and persists state.
9
+ *
10
+ * Phase 3 will add the LLM-path tick (assistant.heartbeat() with the
11
+ * agent's profile) when the fingerprint indicates a real signal change.
12
+ */
13
+ import type { AgentHeartbeatState } from '../types.js';
14
+ import type { AgentManager } from '../agent/agent-manager.js';
15
+ /**
16
+ * Minimal gateway surface the scheduler needs for the LLM tick path.
17
+ * Kept narrow so tests can mock it without pulling in the full Gateway.
18
+ */
19
+ export interface AgentHeartbeatGateway {
20
+ handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
21
+ }
22
+ export interface AgentHeartbeatOptions {
23
+ /** Override the base directory for test isolation. Defaults to config.BASE_DIR. */
24
+ baseDir?: string;
25
+ /** Override the agents directory for test isolation. Defaults to config.AGENTS_DIR. */
26
+ agentsDir?: string;
27
+ /**
28
+ * Gateway used for the LLM tick path. When omitted, the scheduler runs in
29
+ * cheap-path-only mode (observation + logging, no LLM call). Tests pass
30
+ * mocks here; production passes the real Gateway.
31
+ */
32
+ gateway?: AgentHeartbeatGateway;
33
+ }
34
+ export declare class AgentHeartbeatScheduler {
35
+ private readonly slug;
36
+ private readonly agentManager;
37
+ private readonly baseDir;
38
+ private readonly agentsDir;
39
+ private readonly stateFile;
40
+ private readonly gateway;
41
+ constructor(slug: string, agentManager: AgentManager, opts?: AgentHeartbeatOptions);
42
+ /** Read persisted state, or return a fresh state ready to tick now. */
43
+ loadState(): AgentHeartbeatState;
44
+ saveState(state: AgentHeartbeatState): void;
45
+ /** True if the agent is due for a tick. */
46
+ isDue(now?: Date): boolean;
47
+ /**
48
+ * Compute a cheap fingerprint of "anything material to this agent."
49
+ * Three signals: pending delegated tasks, latest goal update, latest
50
+ * cron run timestamp. Sync filesystem reads — bounded and small.
51
+ */
52
+ private buildFingerprint;
53
+ /**
54
+ * Tick. Loads state, builds fingerprint, decides whether to invoke the
55
+ * LLM path, persists the new state. The LLM call is only made when:
56
+ *
57
+ * 1. The fingerprint changed (something material moved since last tick), AND
58
+ * 2. The prior fingerprint was non-empty (we don't fire LLM on the very
59
+ * first tick after daemon start — those are noisy and not signal), AND
60
+ * 3. A gateway is wired (opts.gateway). Tests run cheap-path-only.
61
+ */
62
+ tick(now?: Date): Promise<AgentHeartbeatState>;
63
+ /**
64
+ * Build and dispatch the LLM tick prompt via gateway.handleCronJob.
65
+ * Output already routes to the agent's Discord channel (dispatcher.send
66
+ * is called inside the cron path with the agentSlug).
67
+ */
68
+ private runLlmTick;
69
+ /** Parse `[NEXT_CHECK: Xm]` directive from the agent's output. Public for tests. */
70
+ static parseLlmTickOutput(output: string): {
71
+ nextCheckMinutes: number | undefined;
72
+ summary: string;
73
+ };
74
+ /** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
75
+ setNextCheckIn(minutes: number, now?: Date): void;
76
+ getSlug(): string;
77
+ }
78
+ //# sourceMappingURL=agent-heartbeat-scheduler.d.ts.map
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Per-agent heartbeat scheduler — one instance per specialist agent
3
+ * (Ross, Sasha, Nora, etc.). Runs autonomously alongside Clementine's
4
+ * own HeartbeatScheduler.
5
+ *
6
+ * Phase 2 — cheap path only. No LLM call. The tick loads state, scans
7
+ * three signals (pending delegated tasks, recent goal updates, recent
8
+ * cron completions), updates fingerprint, and persists state.
9
+ *
10
+ * Phase 3 will add the LLM-path tick (assistant.heartbeat() with the
11
+ * agent's profile) when the fingerprint indicates a real signal change.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from 'node:fs';
15
+ import path from 'node:path';
16
+ import pino from 'pino';
17
+ import { AGENTS_DIR, BASE_DIR } from '../config.js';
18
+ import { listAllGoals } from '../tools/shared.js';
19
+ const logger = pino({ name: 'clementine.agent-heartbeat' });
20
+ const DEFAULT_INTERVAL_MIN = 30;
21
+ const MIN_INTERVAL_MIN = 5;
22
+ const MAX_INTERVAL_MIN = 12 * 60;
23
+ export class AgentHeartbeatScheduler {
24
+ slug;
25
+ agentManager;
26
+ baseDir;
27
+ agentsDir;
28
+ stateFile;
29
+ gateway;
30
+ constructor(slug, agentManager, opts = {}) {
31
+ this.slug = slug;
32
+ this.agentManager = agentManager;
33
+ this.baseDir = opts.baseDir ?? BASE_DIR;
34
+ this.agentsDir = opts.agentsDir ?? AGENTS_DIR;
35
+ this.stateFile = path.join(this.baseDir, 'heartbeat', 'agents', slug, 'state.json');
36
+ this.gateway = opts.gateway ?? null;
37
+ }
38
+ /** Read persisted state, or return a fresh state ready to tick now. */
39
+ loadState() {
40
+ try {
41
+ if (existsSync(this.stateFile)) {
42
+ const raw = JSON.parse(readFileSync(this.stateFile, 'utf-8'));
43
+ return {
44
+ slug: this.slug,
45
+ lastTickAt: String(raw.lastTickAt ?? ''),
46
+ nextCheckAt: String(raw.nextCheckAt ?? new Date().toISOString()),
47
+ silentTickCount: Number(raw.silentTickCount ?? 0),
48
+ fingerprint: String(raw.fingerprint ?? ''),
49
+ ...(raw.lastSignalSummary ? { lastSignalSummary: raw.lastSignalSummary } : {}),
50
+ };
51
+ }
52
+ }
53
+ catch (err) {
54
+ logger.warn({ err, slug: this.slug }, 'Failed to load agent heartbeat state — starting fresh');
55
+ }
56
+ return {
57
+ slug: this.slug,
58
+ lastTickAt: '',
59
+ nextCheckAt: new Date().toISOString(),
60
+ silentTickCount: 0,
61
+ fingerprint: '',
62
+ };
63
+ }
64
+ saveState(state) {
65
+ try {
66
+ mkdirSync(path.dirname(this.stateFile), { recursive: true });
67
+ writeFileSync(this.stateFile, JSON.stringify(state, null, 2));
68
+ }
69
+ catch (err) {
70
+ logger.warn({ err, slug: this.slug }, 'Failed to save agent heartbeat state — non-fatal');
71
+ }
72
+ }
73
+ /** True if the agent is due for a tick. */
74
+ isDue(now = new Date()) {
75
+ const state = this.loadState();
76
+ if (!state.nextCheckAt)
77
+ return true;
78
+ return new Date(state.nextCheckAt).getTime() <= now.getTime();
79
+ }
80
+ /**
81
+ * Compute a cheap fingerprint of "anything material to this agent."
82
+ * Three signals: pending delegated tasks, latest goal update, latest
83
+ * cron run timestamp. Sync filesystem reads — bounded and small.
84
+ */
85
+ buildFingerprint() {
86
+ const signals = { slug: this.slug };
87
+ // 1. Pending delegated task count
88
+ try {
89
+ const tasksDir = path.join(this.agentsDir, this.slug, 'tasks');
90
+ if (existsSync(tasksDir)) {
91
+ const files = readdirSync(tasksDir).filter((f) => f.endsWith('.json'));
92
+ let pendingCount = 0;
93
+ for (const file of files) {
94
+ try {
95
+ const task = JSON.parse(readFileSync(path.join(tasksDir, file), 'utf-8'));
96
+ if (task && task.status === 'pending')
97
+ pendingCount++;
98
+ }
99
+ catch { /* skip malformed */ }
100
+ }
101
+ signals.pendingTasks = pendingCount;
102
+ }
103
+ else {
104
+ signals.pendingTasks = 0;
105
+ }
106
+ }
107
+ catch {
108
+ signals.pendingTasks = 0;
109
+ }
110
+ // 2. Latest goal updatedAt for this agent's goals
111
+ try {
112
+ let latest = '';
113
+ for (const { goal, owner } of listAllGoals()) {
114
+ if (owner !== this.slug)
115
+ continue;
116
+ const updatedAt = goal.updatedAt ?? '';
117
+ if (updatedAt > latest)
118
+ latest = updatedAt;
119
+ }
120
+ signals.latestGoalUpdate = latest;
121
+ }
122
+ catch {
123
+ signals.latestGoalUpdate = '';
124
+ }
125
+ // 3. Latest cron run for any of this agent's crons (file mtime is enough)
126
+ try {
127
+ const runsDir = path.join(this.baseDir, 'cron', 'runs');
128
+ let latestMs = 0;
129
+ if (existsSync(runsDir)) {
130
+ const prefix = `${this.slug}:`;
131
+ for (const file of readdirSync(runsDir)) {
132
+ if (!file.endsWith('.jsonl'))
133
+ continue;
134
+ if (!file.startsWith(prefix) && !file.startsWith(this.slug + '_'))
135
+ continue;
136
+ try {
137
+ const mtime = statSync(path.join(runsDir, file)).mtimeMs;
138
+ if (mtime > latestMs)
139
+ latestMs = mtime;
140
+ }
141
+ catch { /* skip */ }
142
+ }
143
+ }
144
+ signals.latestCronRunMs = latestMs;
145
+ }
146
+ catch {
147
+ signals.latestCronRunMs = 0;
148
+ }
149
+ const fingerprint = createHash('sha1')
150
+ .update(JSON.stringify(signals))
151
+ .digest('hex')
152
+ .slice(0, 16);
153
+ return { fingerprint, signals };
154
+ }
155
+ /**
156
+ * Tick. Loads state, builds fingerprint, decides whether to invoke the
157
+ * LLM path, persists the new state. The LLM call is only made when:
158
+ *
159
+ * 1. The fingerprint changed (something material moved since last tick), AND
160
+ * 2. The prior fingerprint was non-empty (we don't fire LLM on the very
161
+ * first tick after daemon start — those are noisy and not signal), AND
162
+ * 3. A gateway is wired (opts.gateway). Tests run cheap-path-only.
163
+ */
164
+ async tick(now = new Date()) {
165
+ const profile = this.agentManager.get(this.slug);
166
+ if (!profile) {
167
+ // Agent was removed mid-flight — return a state that won't tick again soon.
168
+ return {
169
+ slug: this.slug,
170
+ lastTickAt: now.toISOString(),
171
+ nextCheckAt: new Date(now.getTime() + MAX_INTERVAL_MIN * 60_000).toISOString(),
172
+ silentTickCount: 0,
173
+ fingerprint: '',
174
+ lastSignalSummary: 'agent profile not found',
175
+ };
176
+ }
177
+ if (!this.agentManager.isRunnable(this.slug)) {
178
+ logger.debug({ slug: this.slug, status: profile.status }, 'Agent not runnable — skipping tick');
179
+ const next = new Date(now.getTime() + DEFAULT_INTERVAL_MIN * 60_000);
180
+ const prior = this.loadState();
181
+ const state = {
182
+ ...prior,
183
+ slug: this.slug,
184
+ lastTickAt: now.toISOString(),
185
+ nextCheckAt: next.toISOString(),
186
+ };
187
+ this.saveState(state);
188
+ return state;
189
+ }
190
+ const prior = this.loadState();
191
+ const { fingerprint, signals } = this.buildFingerprint();
192
+ const changed = fingerprint !== prior.fingerprint;
193
+ let nextCheckMinutes = DEFAULT_INTERVAL_MIN;
194
+ let lastSignalSummary;
195
+ const shouldRunLlm = changed && prior.fingerprint !== '' && this.gateway !== null;
196
+ if (shouldRunLlm) {
197
+ try {
198
+ const result = await this.runLlmTick(profile, signals, prior, now);
199
+ nextCheckMinutes = result.nextCheckMinutes ?? DEFAULT_INTERVAL_MIN;
200
+ lastSignalSummary = result.summary?.slice(0, 240);
201
+ }
202
+ catch (err) {
203
+ logger.warn({ err, slug: this.slug }, 'Agent LLM tick failed — using default cadence');
204
+ lastSignalSummary = `llm tick error: ${String(err).slice(0, 200)}`;
205
+ }
206
+ }
207
+ else if (changed) {
208
+ lastSignalSummary = `signal change: ${JSON.stringify(signals)}`.slice(0, 240);
209
+ }
210
+ else {
211
+ lastSignalSummary = prior.lastSignalSummary;
212
+ }
213
+ const clampedMin = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(nextCheckMinutes)));
214
+ const next = new Date(now.getTime() + clampedMin * 60_000);
215
+ const state = {
216
+ slug: this.slug,
217
+ lastTickAt: now.toISOString(),
218
+ nextCheckAt: next.toISOString(),
219
+ silentTickCount: changed ? 0 : prior.silentTickCount + 1,
220
+ fingerprint,
221
+ ...(lastSignalSummary ? { lastSignalSummary } : {}),
222
+ };
223
+ this.saveState(state);
224
+ if (changed) {
225
+ logger.info({ slug: this.slug, signals, fingerprint, ranLlm: shouldRunLlm, nextCheckMin: clampedMin }, 'Agent heartbeat tick');
226
+ }
227
+ else {
228
+ logger.debug({ slug: this.slug, silentTicks: state.silentTickCount }, 'Agent heartbeat: silent tick');
229
+ }
230
+ return state;
231
+ }
232
+ /**
233
+ * Build and dispatch the LLM tick prompt via gateway.handleCronJob.
234
+ * Output already routes to the agent's Discord channel (dispatcher.send
235
+ * is called inside the cron path with the agentSlug).
236
+ */
237
+ async runLlmTick(profile, signals, prior, now) {
238
+ if (!this.gateway) {
239
+ return { nextCheckMinutes: undefined, summary: '' };
240
+ }
241
+ const sinceLastMin = prior.lastTickAt
242
+ ? Math.max(0, Math.round((now.getTime() - new Date(prior.lastTickAt).getTime()) / 60_000))
243
+ : 0;
244
+ const prompt = [
245
+ `[Heartbeat check-in: ${profile.slug}]`,
246
+ '',
247
+ `You are ${profile.name}. ${profile.description}`,
248
+ '',
249
+ `## Routine check-in`,
250
+ `This is your scheduled heartbeat tick (${sinceLastMin}min since last).`,
251
+ `Something in your scope has changed since you last checked in.`,
252
+ '',
253
+ `### Signals`,
254
+ `- Pending delegated tasks: ${signals.pendingTasks ?? 0}`,
255
+ `- Latest goal update: ${signals.latestGoalUpdate || 'none'}`,
256
+ `- Latest cron run: ${signals.latestCronRunMs ? new Date(Number(signals.latestCronRunMs)).toISOString() : 'none'}`,
257
+ '',
258
+ `### Instructions`,
259
+ `1. Quickly scan TASKS.md, your goals, and recent cron output for anything that needs action right now.`,
260
+ `2. If there's a clear next action you can take in 1–2 turns, do it.`,
261
+ `3. If you're blocked, waiting on someone, or it's all-quiet, say so concisely.`,
262
+ `4. End your response with \`[NEXT_CHECK: Xm]\` to set when to check in next (5–720 min). Default 30m. Use shorter intervals during active work, longer during quiet hours.`,
263
+ `5. Keep your response under 3 sentences unless you actually took action.`,
264
+ ].join('\n');
265
+ const jobName = `heartbeat:${this.slug}`;
266
+ const result = await this.gateway.handleCronJob(jobName, prompt, 1, 5, undefined, undefined, 'standard', undefined, undefined, undefined, this.slug);
267
+ const parsed = AgentHeartbeatScheduler.parseLlmTickOutput(result);
268
+ return { nextCheckMinutes: parsed.nextCheckMinutes, summary: parsed.summary };
269
+ }
270
+ /** Parse `[NEXT_CHECK: Xm]` directive from the agent's output. Public for tests. */
271
+ static parseLlmTickOutput(output) {
272
+ const match = output.match(/\[NEXT_CHECK:\s*(\d+)\s*m?\]/i);
273
+ const nextCheckMinutes = match ? parseInt(match[1], 10) : undefined;
274
+ // Strip the directive from the summary so logs don't echo it back
275
+ const summary = output.replace(/\[NEXT_CHECK:[^\]]*\]/gi, '').trim();
276
+ return { nextCheckMinutes, summary };
277
+ }
278
+ /** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
279
+ setNextCheckIn(minutes, now = new Date()) {
280
+ const clamped = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(minutes)));
281
+ const prior = this.loadState();
282
+ const state = {
283
+ ...prior,
284
+ slug: this.slug,
285
+ nextCheckAt: new Date(now.getTime() + clamped * 60_000).toISOString(),
286
+ };
287
+ this.saveState(state);
288
+ }
289
+ getSlug() {
290
+ return this.slug;
291
+ }
292
+ }
293
+ //# sourceMappingURL=agent-heartbeat-scheduler.js.map
@@ -9,8 +9,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync,
9
9
  import path from 'node:path';
10
10
  import matter from 'gray-matter';
11
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 { listAllGoals } from '../tools/shared.js';
12
+ import { HEARTBEAT_FILE, TASKS_FILE, INBOX_DIR, DAILY_NOTES_DIR, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_ACTIVE_START, HEARTBEAT_ACTIVE_END, BASE_DIR, GOALS_DIR, AGENTS_DIR, HEARTBEAT_WORK_QUEUE_FILE, DISCORD_OWNER_ID, } from '../config.js';
13
+ import { findGoalPath, listAllGoals } from '../tools/shared.js';
14
14
  import { gatherInsightSignals, buildInsightPrompt, parseInsightResponse, canSendInsight, recordInsightSent, recordInsightAcked, maybeIncreaseCooldown, } from '../agent/insight-engine.js';
15
15
  import { decideDailyPlanPriority, decideDiscoveredWorkItem, decideGoalAdvancement, decisionShouldCreateGoalTrigger, decisionShouldQueueHeartbeatWork, } from '../agent/proactive-engine.js';
16
16
  import { recentDecisions, recordDecision, recordDecisionOutcome, wasRecentlyDecided, } from '../agent/proactive-ledger.js';
@@ -444,6 +444,35 @@ export class HeartbeatScheduler {
444
444
  continue;
445
445
  if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
446
446
  continue;
447
+ // If the goal belongs to a specialist agent, route to them via a
448
+ // goal-trigger file instead of running the work as Clementine.
449
+ // processGoalTriggers in cron-scheduler reads goal.owner and
450
+ // dispatches with the right profile + Discord channel.
451
+ const goalLookup = findGoalPath(priority.id);
452
+ const ownerSlug = goalLookup && goalLookup.owner !== 'clementine' ? goalLookup.owner : null;
453
+ if (ownerSlug) {
454
+ const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
455
+ mkdirSync(goalTriggerDir, { recursive: true });
456
+ const trigger = {
457
+ goalId: priority.id,
458
+ focus: priority.action,
459
+ maxTurns: 15,
460
+ triggeredAt: new Date().toISOString(),
461
+ source: 'daily-plan',
462
+ decision,
463
+ };
464
+ const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
465
+ writeFileSync(triggerPath, JSON.stringify(trigger, null, 2));
466
+ recordDecision(decision, {
467
+ signalType: 'daily-plan-priority',
468
+ description: priority.action,
469
+ goalId: priority.id,
470
+ owner: ownerSlug,
471
+ metadata: { planDate: todayPlan.date, type: priority.type, routedTo: ownerSlug },
472
+ });
473
+ logger.info({ goalId: priority.id, owner: ownerSlug, action: priority.action }, 'Routed daily-plan goal to owning agent');
474
+ continue;
475
+ }
447
476
  HeartbeatScheduler.enqueueWork({
448
477
  description: priority.action,
449
478
  prompt: `Goal progress: ${priority.action}\n\nThis is a high-priority item from today's daily plan (goal: ${priority.id}). ` +
@@ -1231,18 +1260,45 @@ export class HeartbeatScheduler {
1231
1260
  // If move fails, skip — will retry next tick
1232
1261
  continue;
1233
1262
  }
1263
+ // Load active team so Clementine can delegate when an item belongs
1264
+ // to a specialist. Read agent.md frontmatter for slug/name/scope.
1265
+ const teamLines = [];
1266
+ try {
1267
+ if (existsSync(AGENTS_DIR)) {
1268
+ const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true })
1269
+ .filter((d) => d.isDirectory() && !d.name.startsWith('_'))
1270
+ .map((d) => d.name);
1271
+ for (const slug of agentDirs) {
1272
+ const agentMd = path.join(AGENTS_DIR, slug, 'agent.md');
1273
+ if (!existsSync(agentMd))
1274
+ continue;
1275
+ try {
1276
+ const fm = matter(readFileSync(agentMd, 'utf-8')).data;
1277
+ const desc = (fm.description ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
1278
+ teamLines.push(`- \`${slug}\` (${fm.name ?? slug})${desc ? ` — ${desc}` : ''}`);
1279
+ }
1280
+ catch { /* skip malformed agent.md */ }
1281
+ }
1282
+ }
1283
+ }
1284
+ catch { /* non-fatal */ }
1285
+ const teamBlock = teamLines.length > 0
1286
+ ? `## Your Team (delegate when work clearly belongs to one of them)\n${teamLines.join('\n')}\n\n`
1287
+ : '';
1234
1288
  // Build a prompt for the agent to triage this inbox item
1235
1289
  const prompt = `Triage this inbox item and take appropriate action.\n\n` +
1236
1290
  `**Title:** ${title}\n` +
1237
1291
  `**Content:**\n${content.slice(0, 2000)}\n\n` +
1292
+ teamBlock +
1238
1293
  `## Instructions:\n` +
1239
- `1. Determine the intent: Is this a task, a reference/note, a reminder, or something else?\n` +
1294
+ `1. Determine the intent: Is this a task, a reference/note, a reminder, project update, or work for a teammate?\n` +
1240
1295
  `2. Take the appropriate action:\n` +
1241
1296
  ` - **Task**: Use \`task_add\` to create a task with the right priority and due date.\n` +
1242
1297
  ` - **Reference**: Use \`note_create\` or \`memory_write\` to file it in the vault.\n` +
1243
1298
  ` - **Reminder**: Add to today's daily note with \`memory_write(action="append_daily")\`.\n` +
1244
1299
  ` - **Project update**: Update the relevant project note.\n` +
1245
- `3. Respond with a one-line summary of what you did.`;
1300
+ ` - **Delegate to a teammate**: If the item is clearly work for a specialist on your team, use \`team_message\` to hand it off with enough context for them to act. Don't try to do their job yourself.\n` +
1301
+ `3. Respond with a one-line summary of what you did (including who you delegated to, if anyone).`;
1246
1302
  // Fire-and-forget — run as a lightweight cron job
1247
1303
  this.gateway
1248
1304
  .handleCronJob(`inbox:${title}`, prompt, 1, 5)
package/dist/index.js CHANGED
@@ -453,28 +453,36 @@ function startTimerChecker(dispatcher, gateway) {
453
453
  // ── Log rotation ─────────────────────────────────────────────────────
454
454
  const LOG_MAX_BYTES = 2 * 1024 * 1024; // 2 MB
455
455
  const LOG_MAX_BACKUPS = 7;
456
+ function rotateOne(logFile) {
457
+ if (!existsSync(logFile))
458
+ return;
459
+ const size = statSync(logFile).size;
460
+ if (size < LOG_MAX_BYTES)
461
+ return;
462
+ // Rotate: delete .log.7, shift .log.6→.log.7, ... .log→.log.1
463
+ const oldest = `${logFile}.${LOG_MAX_BACKUPS}`;
464
+ if (existsSync(oldest))
465
+ unlinkSync(oldest);
466
+ for (let i = LOG_MAX_BACKUPS - 1; i >= 1; i--) {
467
+ const src = `${logFile}.${i}`;
468
+ if (existsSync(src))
469
+ renameSync(src, `${logFile}.${i + 1}`);
470
+ }
471
+ renameSync(logFile, `${logFile}.1`);
472
+ writeFileSync(logFile, '');
473
+ }
456
474
  function rotateLogIfNeeded() {
457
- const logFile = path.join(config.BASE_DIR, 'logs', 'clementine.log');
458
- try {
459
- if (!existsSync(logFile))
460
- return;
461
- const size = statSync(logFile).size;
462
- if (size < LOG_MAX_BYTES)
463
- return;
464
- // Rotate: delete .log.7, shift .log.6→.log.7, ... .log→.log.1
465
- const oldest = `${logFile}.${LOG_MAX_BACKUPS}`;
466
- if (existsSync(oldest))
467
- unlinkSync(oldest);
468
- for (let i = LOG_MAX_BACKUPS - 1; i >= 1; i--) {
469
- const src = `${logFile}.${i}`;
470
- if (existsSync(src))
471
- renameSync(src, `${logFile}.${i + 1}`);
475
+ // cron.log is appended to by launchd-spawned `clementine cron run` invocations
476
+ // — each is a one-shot process that closes the FD after writing, so a
477
+ // rename-rotate at daemon startup is safe.
478
+ const logsDir = path.join(config.BASE_DIR, 'logs');
479
+ for (const name of ['clementine.log', 'cron.log']) {
480
+ try {
481
+ rotateOne(path.join(logsDir, name));
482
+ }
483
+ catch (err) {
484
+ logger.warn({ err, name }, 'Log rotation failed — continuing startup');
472
485
  }
473
- renameSync(logFile, `${logFile}.1`);
474
- writeFileSync(logFile, '');
475
- }
476
- catch (err) {
477
- logger.warn({ err }, 'Log rotation failed — continuing startup');
478
486
  }
479
487
  }
480
488
  // ── Async main ───────────────────────────────────────────────────────
@@ -652,6 +660,11 @@ async function asyncMain() {
652
660
  const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
653
661
  const cronScheduler = new CronScheduler(gateway, dispatcher);
654
662
  heartbeat.setCronScheduler(cronScheduler);
663
+ // Per-agent heartbeats (Ross / Sasha / Nora / future hires). Cheap-path
664
+ // observation on every tick; LLM tick fires on signal change with the
665
+ // agent's profile and routes output to their Discord channel.
666
+ const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
667
+ const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager(), gateway);
655
668
  // ── Build channel tasks ──────────────────────────────────────────
656
669
  const channelTasks = [];
657
670
  const activeChannels = [];
@@ -748,6 +761,7 @@ async function asyncMain() {
748
761
  // Start heartbeat + cron + timers
749
762
  heartbeat.start();
750
763
  cronScheduler.start();
764
+ agentHeartbeats.start();
751
765
  const timerInterval = startTimerChecker(dispatcher, gateway);
752
766
  // Start brain ingest scheduler (polls registered REST sources on their cron)
753
767
  try {
@@ -938,6 +952,7 @@ async function asyncMain() {
938
952
  // Now safe to tear down remaining infrastructure
939
953
  heartbeat.stop();
940
954
  cronScheduler.stop();
955
+ agentHeartbeats.stop();
941
956
  // ── Self-restart (enhanced with health check + rollback) ────────
942
957
  if (restartRequested) {
943
958
  // Clear our PID file BEFORE spawning the child, so ensureSingleton()
@@ -141,6 +141,11 @@ export function registerSessionTools(server) {
141
141
  const topItems = items.slice(0, maxItems);
142
142
  if (topItems.length === 0)
143
143
  return textResult('No work items discovered. All goals on track, no failures, inbox clear.');
144
+ // Decisions here are advisory — the agent surveys, then chooses whether
145
+ // to act. We deliberately do NOT recordDecision: the autonomous paths
146
+ // (processInbox, daily-plan loop, goal advancement) record their own
147
+ // decisions when they actually act, so the ledger reflects committed
148
+ // decisions and proactive_stats stays signal-rich.
144
149
  const lines = topItems.map((i) => {
145
150
  const decision = decideDiscoveredWorkItem(i);
146
151
  const label = ACTION_LABEL[decision.action];
package/dist/types.d.ts CHANGED
@@ -221,6 +221,19 @@ export interface HeartbeatWorkItem {
221
221
  error?: string;
222
222
  agentSlug?: string;
223
223
  }
224
+ /**
225
+ * State for one specialist agent's heartbeat scheduler. Persisted at
226
+ * ~/.clementine/heartbeat/agents/<slug>/state.json. Manager reads
227
+ * `nextCheckAt` to decide whether the agent is due for a tick.
228
+ */
229
+ export interface AgentHeartbeatState {
230
+ slug: string;
231
+ lastTickAt: string;
232
+ nextCheckAt: string;
233
+ silentTickCount: number;
234
+ fingerprint: string;
235
+ lastSignalSummary?: string;
236
+ }
224
237
  export interface CronJobDefinition {
225
238
  name: string;
226
239
  schedule: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.77",
3
+ "version": "1.0.79",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",