clementine-agent 1.0.83 → 1.0.84

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.
@@ -165,6 +165,20 @@ export class AgentManager {
165
165
  catch { /* migration failed — continue with plaintext */ }
166
166
  }
167
167
  }
168
+ // Parse active_hours from frontmatter ("HH:MM-HH:MM" → decimal hours).
169
+ // Same-day windows only; midnight-crossing strings are ignored.
170
+ let activeHours;
171
+ const ahRaw = meta.active_hours ?? meta.activeHours;
172
+ if (typeof ahRaw === 'string') {
173
+ const m = ahRaw.match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/);
174
+ if (m) {
175
+ const start = Number(m[1]) + Number(m[2]) / 60;
176
+ const end = Number(m[3]) + Number(m[4]) / 60;
177
+ if (start < end && start >= 0 && end <= 24) {
178
+ activeHours = { start, end };
179
+ }
180
+ }
181
+ }
168
182
  // Parse sendPolicy from frontmatter
169
183
  let sendPolicy;
170
184
  if (meta.sendPolicy && typeof meta.sendPolicy === 'object') {
@@ -205,6 +219,7 @@ export class AgentManager {
205
219
  status: (['active', 'paused', 'error', 'terminated'].includes(meta.status) ? meta.status : 'active'),
206
220
  budgetMonthlyCents: meta.budgetMonthlyCents ? Number(meta.budgetMonthlyCents) : undefined,
207
221
  strictMemoryIsolation: meta.strictMemoryIsolation === false ? false : true, // default true for all agents
222
+ activeHours,
208
223
  };
209
224
  }
210
225
  // ── ProfileManager-compatible interface ───────────────────────────
@@ -12,6 +12,16 @@
12
12
  */
13
13
  import type { AgentHeartbeatState } from '../types.js';
14
14
  import type { AgentManager } from '../agent/agent-manager.js';
15
+ /**
16
+ * Compute the next-check interval (minutes) for an upcoming tick. Pure
17
+ * function of the inputs — exported for tests.
18
+ */
19
+ export declare function computeNextInterval(opts: {
20
+ kind: 'acted' | 'quiet' | 'silent' | 'override';
21
+ silentStreak: number;
22
+ isActiveHours: boolean;
23
+ overrideMin?: number;
24
+ }): number;
15
25
  /**
16
26
  * Minimal gateway surface the scheduler needs for the LLM tick path.
17
27
  * Kept narrow so tests can mock it without pulling in the full Gateway.
@@ -20,6 +20,50 @@ const logger = pino({ name: 'clementine.agent-heartbeat' });
20
20
  const DEFAULT_INTERVAL_MIN = 30;
21
21
  const MIN_INTERVAL_MIN = 5;
22
22
  const MAX_INTERVAL_MIN = 12 * 60;
23
+ // ── Adaptive cadence ─────────────────────────────────────────────────
24
+ // Tick-outcome → next-check interval (minutes). Plain numbers tuned for
25
+ // the typical "specialist running on a goal" workload — fast when active,
26
+ // gradually slower when idle.
27
+ const ACTED_INTERVAL_MIN = 10;
28
+ const QUIET_INTERVAL_MIN = 60;
29
+ // Exponential backoff schedule for consecutive silent ticks. Index = streak
30
+ // length (capped at last entry). 0 silent → unused; 1st → 30, 2nd → 60, ...
31
+ const SILENT_BACKOFF_MIN = [30, 60, 120, 240, 480, 720];
32
+ // Off-hours multiplier. Applied after kind-based interval, capped at MAX.
33
+ const OFF_HOURS_MULTIPLIER = 4;
34
+ // Default active hours when an agent's profile doesn't specify
35
+ const DEFAULT_ACTIVE_START_HOUR = 8;
36
+ const DEFAULT_ACTIVE_END_HOUR = 22;
37
+ /**
38
+ * Compute the next-check interval (minutes) for an upcoming tick. Pure
39
+ * function of the inputs — exported for tests.
40
+ */
41
+ export function computeNextInterval(opts) {
42
+ if (opts.kind === 'override' && typeof opts.overrideMin === 'number') {
43
+ // User-set override always wins, still clamped to bounds.
44
+ return Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(opts.overrideMin)));
45
+ }
46
+ let base;
47
+ if (opts.kind === 'acted') {
48
+ base = ACTED_INTERVAL_MIN;
49
+ }
50
+ else if (opts.kind === 'quiet') {
51
+ base = QUIET_INTERVAL_MIN;
52
+ }
53
+ else {
54
+ // silent
55
+ const idx = Math.min(Math.max(0, opts.silentStreak - 1), SILENT_BACKOFF_MIN.length - 1);
56
+ base = SILENT_BACKOFF_MIN[idx];
57
+ }
58
+ if (!opts.isActiveHours)
59
+ base *= OFF_HOURS_MULTIPLIER;
60
+ return Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, base));
61
+ }
62
+ function isWithinActiveHours(now, profile) {
63
+ const hours = profile.activeHours ?? { start: DEFAULT_ACTIVE_START_HOUR, end: DEFAULT_ACTIVE_END_HOUR };
64
+ const localHour = now.getHours() + now.getMinutes() / 60;
65
+ return localHour >= hours.start && localHour < hours.end;
66
+ }
23
67
  export class AgentHeartbeatScheduler {
24
68
  slug;
25
69
  agentManager;
@@ -40,6 +84,10 @@ export class AgentHeartbeatScheduler {
40
84
  try {
41
85
  if (existsSync(this.stateFile)) {
42
86
  const raw = JSON.parse(readFileSync(this.stateFile, 'utf-8'));
87
+ const validKinds = ['acted', 'quiet', 'silent', 'override'];
88
+ const kind = validKinds.includes(raw.lastTickKind)
89
+ ? raw.lastTickKind
90
+ : undefined;
43
91
  return {
44
92
  slug: this.slug,
45
93
  lastTickAt: String(raw.lastTickAt ?? ''),
@@ -47,6 +95,7 @@ export class AgentHeartbeatScheduler {
47
95
  silentTickCount: Number(raw.silentTickCount ?? 0),
48
96
  fingerprint: String(raw.fingerprint ?? ''),
49
97
  ...(raw.lastSignalSummary ? { lastSignalSummary: raw.lastSignalSummary } : {}),
98
+ ...(kind ? { lastTickKind: kind } : {}),
50
99
  };
51
100
  }
52
101
  }
@@ -190,18 +239,33 @@ export class AgentHeartbeatScheduler {
190
239
  const prior = this.loadState();
191
240
  const { fingerprint, signals } = this.buildFingerprint();
192
241
  const changed = fingerprint !== prior.fingerprint;
193
- let nextCheckMinutes = DEFAULT_INTERVAL_MIN;
194
242
  let lastSignalSummary;
243
+ let llmResult = {
244
+ nextCheckMinutes: undefined,
245
+ summary: '',
246
+ ranLlm: false,
247
+ tookAction: false,
248
+ };
195
249
  const shouldRunLlm = changed && prior.fingerprint !== '' && this.gateway !== null;
196
250
  if (shouldRunLlm) {
197
251
  try {
198
252
  const result = await this.runLlmTick(profile, signals, prior, now);
199
- nextCheckMinutes = result.nextCheckMinutes ?? DEFAULT_INTERVAL_MIN;
200
- lastSignalSummary = result.summary?.slice(0, 240);
253
+ // Heuristic: a meaningful response means the agent took action. Short
254
+ // "all quiet" / "[ALL_QUIET]" responses don't count.
255
+ const summary = result.summary ?? '';
256
+ const tookAction = summary.length > 200 && !/\[ALL_QUIET\]/i.test(summary);
257
+ llmResult = {
258
+ nextCheckMinutes: result.nextCheckMinutes,
259
+ summary,
260
+ ranLlm: true,
261
+ tookAction,
262
+ };
263
+ lastSignalSummary = summary.slice(0, 240);
201
264
  }
202
265
  catch (err) {
203
266
  logger.warn({ err, slug: this.slug }, 'Agent LLM tick failed — using default cadence');
204
267
  lastSignalSummary = `llm tick error: ${String(err).slice(0, 200)}`;
268
+ llmResult.ranLlm = true; // we attempted, treat as 'quiet' for backoff
205
269
  }
206
270
  }
207
271
  else if (changed) {
@@ -210,22 +274,40 @@ export class AgentHeartbeatScheduler {
210
274
  else {
211
275
  lastSignalSummary = prior.lastSignalSummary;
212
276
  }
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);
277
+ // Classify outcome drives adaptive cadence.
278
+ const newSilentStreak = changed ? 0 : prior.silentTickCount + 1;
279
+ const tickKind = (() => {
280
+ if (typeof llmResult.nextCheckMinutes === 'number')
281
+ return 'override';
282
+ if (!changed)
283
+ return 'silent';
284
+ if (llmResult.ranLlm && llmResult.tookAction)
285
+ return 'acted';
286
+ return 'quiet';
287
+ })();
288
+ const isActive = isWithinActiveHours(now, profile);
289
+ const computedMin = computeNextInterval({
290
+ kind: tickKind,
291
+ silentStreak: newSilentStreak,
292
+ isActiveHours: isActive,
293
+ overrideMin: llmResult.nextCheckMinutes,
294
+ });
295
+ const next = new Date(now.getTime() + computedMin * 60_000);
215
296
  const state = {
216
297
  slug: this.slug,
217
298
  lastTickAt: now.toISOString(),
218
299
  nextCheckAt: next.toISOString(),
219
- silentTickCount: changed ? 0 : prior.silentTickCount + 1,
300
+ silentTickCount: newSilentStreak,
220
301
  fingerprint,
302
+ lastTickKind: tickKind,
221
303
  ...(lastSignalSummary ? { lastSignalSummary } : {}),
222
304
  };
223
305
  this.saveState(state);
224
306
  if (changed) {
225
- logger.info({ slug: this.slug, signals, fingerprint, ranLlm: shouldRunLlm, nextCheckMin: clampedMin }, 'Agent heartbeat tick');
307
+ logger.info({ slug: this.slug, signals, fingerprint, ranLlm: shouldRunLlm, kind: tickKind, nextCheckMin: computedMin, isActive }, 'Agent heartbeat tick');
226
308
  }
227
309
  else {
228
- logger.debug({ slug: this.slug, silentTicks: state.silentTickCount }, 'Agent heartbeat: silent tick');
310
+ logger.debug({ slug: this.slug, silentTicks: state.silentTickCount, nextCheckMin: computedMin, isActive }, 'Agent heartbeat: silent tick');
229
311
  }
230
312
  return state;
231
313
  }
package/dist/types.d.ts CHANGED
@@ -183,6 +183,17 @@ export interface AgentProfile {
183
183
  budgetMonthlyCents?: number;
184
184
  spentMonthlyCents?: number;
185
185
  strictMemoryIsolation?: boolean;
186
+ /**
187
+ * Active-hours window for adaptive heartbeat cadence. Decimal hours in
188
+ * the local timezone, e.g., { start: 8, end: 18 } = 8:00am–6:00pm.
189
+ * When the current time is outside this window, the agent's next-check
190
+ * interval is multiplied by 4. Parsed from `active_hours: "HH:MM-HH:MM"`
191
+ * in agent.md frontmatter; same-day windows only.
192
+ */
193
+ activeHours?: {
194
+ start: number;
195
+ end: number;
196
+ };
186
197
  }
187
198
  export type AgentStatus = 'active' | 'paused' | 'error' | 'terminated';
188
199
  export interface HeartbeatReportedTopic {
@@ -240,6 +251,15 @@ export interface AgentHeartbeatState {
240
251
  silentTickCount: number;
241
252
  fingerprint: string;
242
253
  lastSignalSummary?: string;
254
+ /**
255
+ * Outcome of the last tick. Drives adaptive cadence:
256
+ * - 'acted' → next check at active-mode interval (10 min default)
257
+ * - 'quiet' → next check at quiet interval (60 min)
258
+ * - 'silent' → exponential backoff (30 → 60 → 120 → 240 → 480, capped)
259
+ * - 'override' → agent explicitly set [NEXT_CHECK: Xm], honored as-is
260
+ * - undefined → first tick or pre-1.0.84 state
261
+ */
262
+ lastTickKind?: 'acted' | 'quiet' | 'silent' | 'override';
243
263
  }
244
264
  export interface CronJobDefinition {
245
265
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.83",
3
+ "version": "1.0.84",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",