clementine-agent 1.0.78 → 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.
@@ -11,14 +11,15 @@
11
11
  * daemon or stall others.
12
12
  */
13
13
  import type { AgentManager } from '../agent/agent-manager.js';
14
- import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
14
+ import { AgentHeartbeatScheduler, type AgentHeartbeatGateway } from './agent-heartbeat-scheduler.js';
15
15
  export declare class AgentHeartbeatManager {
16
16
  private readonly agentManager;
17
+ private readonly gateway;
17
18
  private readonly schedulers;
18
19
  private timer;
19
20
  private running;
20
21
  private ticking;
21
- constructor(agentManager: AgentManager);
22
+ constructor(agentManager: AgentManager, gateway?: AgentHeartbeatGateway);
22
23
  start(): void;
23
24
  stop(): void;
24
25
  /** Add/remove schedulers to match the current AgentManager listing. */
@@ -16,12 +16,14 @@ const logger = pino({ name: 'clementine.agent-heartbeat-manager' });
16
16
  const OUTER_TICK_MS = 60_000;
17
17
  export class AgentHeartbeatManager {
18
18
  agentManager;
19
+ gateway;
19
20
  schedulers = new Map();
20
21
  timer = null;
21
22
  running = false;
22
23
  ticking = false;
23
- constructor(agentManager) {
24
+ constructor(agentManager, gateway) {
24
25
  this.agentManager = agentManager;
26
+ this.gateway = gateway ?? null;
25
27
  }
26
28
  start() {
27
29
  if (this.running)
@@ -63,7 +65,7 @@ export class AgentHeartbeatManager {
63
65
  // Add new
64
66
  for (const slug of active) {
65
67
  if (!this.schedulers.has(slug)) {
66
- this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager));
68
+ this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager, this.gateway ? { gateway: this.gateway } : {}));
67
69
  logger.info({ slug }, 'Agent heartbeat: registered scheduler');
68
70
  }
69
71
  }
@@ -12,11 +12,24 @@
12
12
  */
13
13
  import type { AgentHeartbeatState } from '../types.js';
14
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
+ }
15
22
  export interface AgentHeartbeatOptions {
16
23
  /** Override the base directory for test isolation. Defaults to config.BASE_DIR. */
17
24
  baseDir?: string;
18
25
  /** Override the agents directory for test isolation. Defaults to config.AGENTS_DIR. */
19
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;
20
33
  }
21
34
  export declare class AgentHeartbeatScheduler {
22
35
  private readonly slug;
@@ -24,6 +37,7 @@ export declare class AgentHeartbeatScheduler {
24
37
  private readonly baseDir;
25
38
  private readonly agentsDir;
26
39
  private readonly stateFile;
40
+ private readonly gateway;
27
41
  constructor(slug: string, agentManager: AgentManager, opts?: AgentHeartbeatOptions);
28
42
  /** Read persisted state, or return a fresh state ready to tick now. */
29
43
  loadState(): AgentHeartbeatState;
@@ -37,10 +51,26 @@ export declare class AgentHeartbeatScheduler {
37
51
  */
38
52
  private buildFingerprint;
39
53
  /**
40
- * Cheap-path tick. Returns the new state. P3 will branch into an LLM
41
- * call when the fingerprint changed; for now we just observe and log.
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.
42
61
  */
43
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
+ };
44
74
  /** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
45
75
  setNextCheckIn(minutes: number, now?: Date): void;
46
76
  getSlug(): string;
@@ -26,12 +26,14 @@ export class AgentHeartbeatScheduler {
26
26
  baseDir;
27
27
  agentsDir;
28
28
  stateFile;
29
+ gateway;
29
30
  constructor(slug, agentManager, opts = {}) {
30
31
  this.slug = slug;
31
32
  this.agentManager = agentManager;
32
33
  this.baseDir = opts.baseDir ?? BASE_DIR;
33
34
  this.agentsDir = opts.agentsDir ?? AGENTS_DIR;
34
35
  this.stateFile = path.join(this.baseDir, 'heartbeat', 'agents', slug, 'state.json');
36
+ this.gateway = opts.gateway ?? null;
35
37
  }
36
38
  /** Read persisted state, or return a fresh state ready to tick now. */
37
39
  loadState() {
@@ -151,8 +153,13 @@ export class AgentHeartbeatScheduler {
151
153
  return { fingerprint, signals };
152
154
  }
153
155
  /**
154
- * Cheap-path tick. Returns the new state. P3 will branch into an LLM
155
- * call when the fingerprint changed; for now we just observe and log.
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.
156
163
  */
157
164
  async tick(now = new Date()) {
158
165
  const profile = this.agentManager.get(this.slug);
@@ -183,28 +190,91 @@ export class AgentHeartbeatScheduler {
183
190
  const prior = this.loadState();
184
191
  const { fingerprint, signals } = this.buildFingerprint();
185
192
  const changed = fingerprint !== prior.fingerprint;
186
- const next = new Date(now.getTime() + DEFAULT_INTERVAL_MIN * 60_000);
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);
187
215
  const state = {
188
216
  slug: this.slug,
189
217
  lastTickAt: now.toISOString(),
190
218
  nextCheckAt: next.toISOString(),
191
219
  silentTickCount: changed ? 0 : prior.silentTickCount + 1,
192
220
  fingerprint,
193
- ...(changed
194
- ? { lastSignalSummary: `signal change: ${JSON.stringify(signals)}`.slice(0, 240) }
195
- : prior.lastSignalSummary
196
- ? { lastSignalSummary: prior.lastSignalSummary }
197
- : {}),
221
+ ...(lastSignalSummary ? { lastSignalSummary } : {}),
198
222
  };
199
223
  this.saveState(state);
200
224
  if (changed) {
201
- logger.info({ slug: this.slug, signals, fingerprint }, 'Agent heartbeat: signal change detected (LLM path is P3)');
225
+ logger.info({ slug: this.slug, signals, fingerprint, ranLlm: shouldRunLlm, nextCheckMin: clampedMin }, 'Agent heartbeat tick');
202
226
  }
203
227
  else {
204
228
  logger.debug({ slug: this.slug, silentTicks: state.silentTickCount }, 'Agent heartbeat: silent tick');
205
229
  }
206
230
  return state;
207
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
+ }
208
278
  /** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
209
279
  setNextCheckIn(minutes, now = new Date()) {
210
280
  const clamped = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(minutes)));
package/dist/index.js CHANGED
@@ -661,9 +661,10 @@ async function asyncMain() {
661
661
  const cronScheduler = new CronScheduler(gateway, dispatcher);
662
662
  heartbeat.setCronScheduler(cronScheduler);
663
663
  // Per-agent heartbeats (Ross / Sasha / Nora / future hires). Cheap-path
664
- // observation only in P2 LLM ticks land in P3.
664
+ // observation on every tick; LLM tick fires on signal change with the
665
+ // agent's profile and routes output to their Discord channel.
665
666
  const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
666
- const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager());
667
+ const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager(), gateway);
667
668
  // ── Build channel tasks ──────────────────────────────────────────
668
669
  const channelTasks = [];
669
670
  const activeChannels = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.78",
3
+ "version": "1.0.79",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",