clementine-agent 1.0.76 → 1.0.78
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/dist/agent/insight-engine.js +3 -2
- package/dist/agent/proactive-engine.d.ts +50 -0
- package/dist/agent/proactive-engine.js +256 -0
- package/dist/agent/proactive-ledger.d.ts +44 -0
- package/dist/agent/proactive-ledger.js +176 -0
- package/dist/agent/self-improve.js +3 -5
- package/dist/gateway/agent-heartbeat-manager.d.ts +42 -0
- package/dist/gateway/agent-heartbeat-manager.js +123 -0
- package/dist/gateway/agent-heartbeat-scheduler.d.ts +48 -0
- package/dist/gateway/agent-heartbeat-scheduler.js +223 -0
- package/dist/gateway/cron-scheduler.d.ts +1 -0
- package/dist/gateway/cron-scheduler.js +41 -6
- package/dist/gateway/heartbeat-scheduler.d.ts +2 -0
- package/dist/gateway/heartbeat-scheduler.js +239 -76
- package/dist/index.js +34 -20
- package/dist/tools/session-tools.js +63 -6
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
|
@@ -9,11 +9,14 @@ 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
|
+
import { decideDailyPlanPriority, decideDiscoveredWorkItem, decideGoalAdvancement, decisionShouldCreateGoalTrigger, decisionShouldQueueHeartbeatWork, } from '../agent/proactive-engine.js';
|
|
16
|
+
import { recentDecisions, recordDecision, recordDecisionOutcome, wasRecentlyDecided, } from '../agent/proactive-ledger.js';
|
|
15
17
|
import { CronRunLog, logToDailyNote, todayISO } from './cron-scheduler.js';
|
|
16
18
|
const logger = pino({ name: 'clementine.heartbeat' });
|
|
19
|
+
const PROACTIVE_DECISION_DEDUPE_MS = 24 * 60 * 60 * 1000;
|
|
17
20
|
// ── HeartbeatScheduler ────────────────────────────────────────────────
|
|
18
21
|
export class HeartbeatScheduler {
|
|
19
22
|
stateFile;
|
|
@@ -307,6 +310,14 @@ export class HeartbeatScheduler {
|
|
|
307
310
|
// ── Active hours check ────────────────────────────────────────────
|
|
308
311
|
// Check active hours
|
|
309
312
|
if (hour < HEARTBEAT_ACTIVE_START || hour >= HEARTBEAT_ACTIVE_END) {
|
|
313
|
+
// Critical proactive alerts are allowed outside active hours; normal
|
|
314
|
+
// heartbeat narration still stays quiet.
|
|
315
|
+
try {
|
|
316
|
+
await this.runInsightCheck();
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
logger.debug({ err }, 'Outside-hours insight check failed (non-fatal)');
|
|
320
|
+
}
|
|
310
321
|
logger.debug(`Heartbeat skipped: outside active hours (${hour}:00)`);
|
|
311
322
|
return;
|
|
312
323
|
}
|
|
@@ -319,26 +330,61 @@ export class HeartbeatScheduler {
|
|
|
319
330
|
logger.info('First active-hours tick — generating daily plan');
|
|
320
331
|
const plan = await dailyPlanner.plan();
|
|
321
332
|
if (plan.priorities.length > 0) {
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
333
|
+
const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
|
|
334
|
+
mkdirSync(goalTriggerDir, { recursive: true });
|
|
335
|
+
let acted = 0;
|
|
336
|
+
let queued = 0;
|
|
337
|
+
let askUser = 0;
|
|
338
|
+
for (const item of plan.priorities) {
|
|
339
|
+
const decision = decideDailyPlanPriority({ priority: item, date: plan.date });
|
|
340
|
+
if (decision.action === 'ask_user')
|
|
341
|
+
askUser++;
|
|
342
|
+
if (item.type === 'goal' && decisionShouldCreateGoalTrigger(decision)) {
|
|
343
|
+
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
344
|
+
continue;
|
|
345
|
+
const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
|
|
346
|
+
if (!existsSync(triggerPath)) {
|
|
347
|
+
writeFileSync(triggerPath, JSON.stringify({
|
|
348
|
+
goalId: item.id,
|
|
349
|
+
focus: item.action,
|
|
350
|
+
maxTurns: 30,
|
|
351
|
+
triggeredAt: new Date().toISOString(),
|
|
352
|
+
source: 'daily-plan',
|
|
353
|
+
decision,
|
|
354
|
+
}, null, 2));
|
|
355
|
+
recordDecision(decision, {
|
|
356
|
+
signalType: 'daily-plan-priority',
|
|
357
|
+
description: item.action,
|
|
358
|
+
goalId: item.id,
|
|
359
|
+
metadata: { planDate: plan.date, type: item.type },
|
|
360
|
+
});
|
|
361
|
+
acted++;
|
|
338
362
|
}
|
|
339
363
|
}
|
|
364
|
+
else if (item.type === 'goal' && decisionShouldQueueHeartbeatWork(decision)) {
|
|
365
|
+
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
366
|
+
continue;
|
|
367
|
+
HeartbeatScheduler.enqueueWork({
|
|
368
|
+
description: item.action,
|
|
369
|
+
prompt: `Goal progress: ${item.action}\n\nThis is a medium-priority item from today's daily plan (goal: ${item.id}). ` +
|
|
370
|
+
`Use goal_work to make progress. If you need information from the owner to proceed, ` +
|
|
371
|
+
`note the blocker and move on.`,
|
|
372
|
+
source: `daily-plan:${item.id}`,
|
|
373
|
+
idempotencyKey: decision.idempotencyKey,
|
|
374
|
+
priority: 'normal',
|
|
375
|
+
maxTurns: 10,
|
|
376
|
+
tier: 1,
|
|
377
|
+
});
|
|
378
|
+
recordDecision(decision, {
|
|
379
|
+
signalType: 'daily-plan-priority',
|
|
380
|
+
description: item.action,
|
|
381
|
+
goalId: item.id,
|
|
382
|
+
metadata: { planDate: plan.date, type: item.type },
|
|
383
|
+
});
|
|
384
|
+
queued++;
|
|
385
|
+
}
|
|
340
386
|
}
|
|
341
|
-
logger.info({ priorities: plan.priorities.length,
|
|
387
|
+
logger.info({ priorities: plan.priorities.length, acted, queued, askUser }, 'Daily plan generated and evaluated');
|
|
342
388
|
}
|
|
343
389
|
// Apply non-destructive cron changes suggested by the daily planner
|
|
344
390
|
if (plan.suggestedCronChanges?.length > 0 && this.cronScheduler) {
|
|
@@ -390,29 +436,60 @@ export class HeartbeatScheduler {
|
|
|
390
436
|
changesSummary += `\n\n## Today's Plan\n${todayPlan.summary}\nTop priorities: ${todayPlan.priorities.slice(0, 3).map(p => p.action).join('; ')}`;
|
|
391
437
|
// ── Goal-driven work auto-queuing ─────────────────────────
|
|
392
438
|
// Close the loop: daily planner priorities → work queue items
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
const pendingDescriptions = new Set(currentQueue.filter(i => i.status === 'pending' || i.status === 'running').map(i => i.description));
|
|
439
|
+
// The proactive ledger (wasRecentlyDecided) plus enqueueWork's
|
|
440
|
+
// in-flight dedup cover what the old description-based set was for.
|
|
396
441
|
for (const priority of todayPlan.priorities) {
|
|
397
|
-
|
|
398
|
-
if (priority.type !== 'goal' ||
|
|
442
|
+
const decision = decideDailyPlanPriority({ priority, date: todayPlan.date });
|
|
443
|
+
if (priority.type !== 'goal' || !decisionShouldQueueHeartbeatWork(decision))
|
|
399
444
|
continue;
|
|
400
|
-
|
|
401
|
-
if (pendingDescriptions.has(priority.action))
|
|
445
|
+
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
402
446
|
continue;
|
|
403
|
-
//
|
|
404
|
-
|
|
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');
|
|
405
474
|
continue;
|
|
475
|
+
}
|
|
406
476
|
HeartbeatScheduler.enqueueWork({
|
|
407
477
|
description: priority.action,
|
|
408
478
|
prompt: `Goal progress: ${priority.action}\n\nThis is a high-priority item from today's daily plan (goal: ${priority.id}). ` +
|
|
409
479
|
`Use goal_work to make progress. If you need information from the owner to proceed, ` +
|
|
410
480
|
`note the blocker and move on.`,
|
|
411
481
|
source: `daily-plan:${priority.id}`,
|
|
482
|
+
idempotencyKey: decision.idempotencyKey,
|
|
412
483
|
priority: 'high',
|
|
413
484
|
maxTurns: 10,
|
|
414
485
|
tier: 1,
|
|
415
486
|
});
|
|
487
|
+
recordDecision(decision, {
|
|
488
|
+
signalType: 'daily-plan-priority',
|
|
489
|
+
description: priority.action,
|
|
490
|
+
goalId: priority.id,
|
|
491
|
+
metadata: { planDate: todayPlan.date, type: priority.type },
|
|
492
|
+
});
|
|
416
493
|
logger.info({ goalId: priority.id, action: priority.action }, 'Auto-queued goal work from daily plan');
|
|
417
494
|
}
|
|
418
495
|
}
|
|
@@ -432,10 +509,12 @@ export class HeartbeatScheduler {
|
|
|
432
509
|
this.completeItem(item.id, result || 'completed');
|
|
433
510
|
completedWork.push({ description: item.description, result: result || 'completed' });
|
|
434
511
|
logToDailyNote(`**Heartbeat work: ${item.description}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
512
|
+
this.recordWorkItemOutcome(item, 'advanced', result || 'completed');
|
|
435
513
|
}
|
|
436
514
|
catch (err) {
|
|
437
515
|
this.failItem(item.id, String(err));
|
|
438
516
|
logger.warn({ err, id: item.id }, 'Heartbeat work item failed');
|
|
517
|
+
this.recordWorkItemOutcome(item, 'failed', String(err));
|
|
439
518
|
}
|
|
440
519
|
}
|
|
441
520
|
// ── Decide whether to invoke the LLM ──
|
|
@@ -907,7 +986,8 @@ export class HeartbeatScheduler {
|
|
|
907
986
|
const activeGoals = allGoals.filter((g) => g && g.status === 'active');
|
|
908
987
|
if (activeGoals.length === 0)
|
|
909
988
|
return null;
|
|
910
|
-
const
|
|
989
|
+
const nowDate = new Date();
|
|
990
|
+
const now = nowDate.getTime();
|
|
911
991
|
const DAY_MS = 86_400_000;
|
|
912
992
|
const lines = activeGoals.map((g) => {
|
|
913
993
|
const nextAct = g.nextActions?.length > 0 ? ` | Next: ${g.nextActions[0]}` : '';
|
|
@@ -998,8 +1078,8 @@ export class HeartbeatScheduler {
|
|
|
998
1078
|
const allGoals = preloadedGoals ?? HeartbeatScheduler.loadAllGoals();
|
|
999
1079
|
if (allGoals.length === 0)
|
|
1000
1080
|
return;
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1081
|
+
const nowDate = new Date();
|
|
1082
|
+
const now = nowDate.getTime();
|
|
1003
1083
|
// Load recent goal outcomes for disposition-based throttling.
|
|
1004
1084
|
// The agent classifies each outcome (ADVANCED, BLOCKED_ON_USER, etc.)
|
|
1005
1085
|
// and we use that to decide when/whether to retry.
|
|
@@ -1059,47 +1139,20 @@ export class HeartbeatScheduler {
|
|
|
1059
1139
|
}
|
|
1060
1140
|
}
|
|
1061
1141
|
catch { /* non-fatal */ }
|
|
1062
|
-
// Score ALL active goals
|
|
1063
|
-
//
|
|
1142
|
+
// Score ALL active goals through the proactive decision engine. Stale
|
|
1143
|
+
// goals get urgency, but current high-priority goals with pending work
|
|
1144
|
+
// also qualify.
|
|
1064
1145
|
const scoredGoals = [];
|
|
1065
1146
|
for (const goal of allGoals) {
|
|
1066
1147
|
try {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
|
|
1075
|
-
const isStale = daysSinceUpdate > staleThreshold;
|
|
1076
|
-
const hasWork = (goal.nextActions?.length ?? 0) > 0;
|
|
1077
|
-
// Skip non-stale goals that have no pending work
|
|
1078
|
-
if (!isStale && !hasWork)
|
|
1079
|
-
continue;
|
|
1080
|
-
// Scoring: priority (high=15, medium=8, low=2) + staleness bonus + work availability
|
|
1081
|
-
const priorityScore = goal.priority === 'high' ? 15 : goal.priority === 'medium' ? 8 : 2;
|
|
1082
|
-
const stalenessScore = isStale ? Math.min(daysSinceUpdate - staleThreshold, 20) : 0;
|
|
1083
|
-
const workScore = hasWork ? 5 : 0;
|
|
1084
|
-
// Goals approaching target date get a deadline urgency boost
|
|
1085
|
-
let deadlineScore = 0;
|
|
1086
|
-
if (goal.targetDate) {
|
|
1087
|
-
const daysUntilTarget = Math.floor((new Date(goal.targetDate).getTime() - now) / DAY_MS);
|
|
1088
|
-
if (daysUntilTarget <= 0)
|
|
1089
|
-
deadlineScore = 20; // overdue
|
|
1090
|
-
else if (daysUntilTarget <= 3)
|
|
1091
|
-
deadlineScore = 10; // imminent
|
|
1092
|
-
else if (daysUntilTarget <= 7)
|
|
1093
|
-
deadlineScore = 5; // approaching
|
|
1148
|
+
const advancement = decideGoalAdvancement({
|
|
1149
|
+
goal,
|
|
1150
|
+
now: nowDate,
|
|
1151
|
+
inCooldown: goalCooldowns.has(goal.id),
|
|
1152
|
+
});
|
|
1153
|
+
if (advancement && decisionShouldCreateGoalTrigger(advancement.decision)) {
|
|
1154
|
+
scoredGoals.push(advancement);
|
|
1094
1155
|
}
|
|
1095
|
-
const totalScore = priorityScore + stalenessScore + workScore + deadlineScore;
|
|
1096
|
-
const reason = [
|
|
1097
|
-
isStale ? `stale(${daysSinceUpdate}d)` : 'current',
|
|
1098
|
-
`pri=${goal.priority}`,
|
|
1099
|
-
hasWork ? 'has-work' : 'no-work',
|
|
1100
|
-
deadlineScore > 0 ? `deadline-boost(${deadlineScore})` : '',
|
|
1101
|
-
].filter(Boolean).join(', ');
|
|
1102
|
-
scoredGoals.push({ goal, score: totalScore, reason });
|
|
1103
1156
|
}
|
|
1104
1157
|
catch {
|
|
1105
1158
|
continue;
|
|
@@ -1110,10 +1163,9 @@ export class HeartbeatScheduler {
|
|
|
1110
1163
|
// Sort by score descending, take top 2
|
|
1111
1164
|
scoredGoals.sort((a, b) => b.score - a.score);
|
|
1112
1165
|
const toAdvance = scoredGoals.slice(0, 2);
|
|
1113
|
-
for (const { goal, reason } of toAdvance) {
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
: `Review and update progress on "${goal.title}"`;
|
|
1166
|
+
for (const { goal, reason, focus, decision, score } of toAdvance) {
|
|
1167
|
+
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
1168
|
+
continue;
|
|
1117
1169
|
const trigger = {
|
|
1118
1170
|
goalId: goal.id,
|
|
1119
1171
|
focus,
|
|
@@ -1121,10 +1173,18 @@ export class HeartbeatScheduler {
|
|
|
1121
1173
|
triggeredAt: new Date().toISOString(),
|
|
1122
1174
|
source: 'heartbeat-advance',
|
|
1123
1175
|
reason,
|
|
1176
|
+
decision,
|
|
1124
1177
|
};
|
|
1125
|
-
const triggerPath = path.join(goalTriggerDir, `${
|
|
1178
|
+
const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
|
|
1126
1179
|
writeFileSync(triggerPath, JSON.stringify(trigger, null, 2));
|
|
1127
|
-
|
|
1180
|
+
recordDecision(decision, {
|
|
1181
|
+
signalType: 'goal-advancement',
|
|
1182
|
+
description: focus,
|
|
1183
|
+
goalId: goal.id,
|
|
1184
|
+
owner: goal.owner,
|
|
1185
|
+
metadata: { score, reason, title: goal.title },
|
|
1186
|
+
});
|
|
1187
|
+
logger.info({ goalId: goal.id, title: goal.title, score, reason, focus, decision: decision.action }, 'Advancing goal via trigger');
|
|
1128
1188
|
}
|
|
1129
1189
|
// Note: task generation removed — the main goal trigger already includes
|
|
1130
1190
|
// nextActions[0] as focus, so a separate task-gen trigger was redundant
|
|
@@ -1158,6 +1218,38 @@ export class HeartbeatScheduler {
|
|
|
1158
1218
|
try {
|
|
1159
1219
|
const content = readFileSync(filePath, 'utf-8');
|
|
1160
1220
|
const title = file.replace(/\.md$/, '');
|
|
1221
|
+
const decision = decideDiscoveredWorkItem({
|
|
1222
|
+
type: 'inbox',
|
|
1223
|
+
id: title,
|
|
1224
|
+
description: `Triage inbox item: ${title}`,
|
|
1225
|
+
urgency: 2,
|
|
1226
|
+
source: 'inbox',
|
|
1227
|
+
});
|
|
1228
|
+
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
1229
|
+
continue;
|
|
1230
|
+
if (decision.action !== 'act_now') {
|
|
1231
|
+
HeartbeatScheduler.enqueueWork({
|
|
1232
|
+
description: `Triage inbox item: ${title}`,
|
|
1233
|
+
prompt: `Triage this inbox item later: ${title}`,
|
|
1234
|
+
source: `inbox:${title}`,
|
|
1235
|
+
idempotencyKey: decision.idempotencyKey,
|
|
1236
|
+
priority: 'normal',
|
|
1237
|
+
maxTurns: 5,
|
|
1238
|
+
tier: 1,
|
|
1239
|
+
});
|
|
1240
|
+
recordDecision(decision, {
|
|
1241
|
+
signalType: 'inbox-triage',
|
|
1242
|
+
description: `Triage inbox item: ${title}`,
|
|
1243
|
+
metadata: { file },
|
|
1244
|
+
});
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
const decisionContext = {
|
|
1248
|
+
signalType: 'inbox-triage',
|
|
1249
|
+
description: `Triage inbox item: ${title}`,
|
|
1250
|
+
metadata: { file },
|
|
1251
|
+
};
|
|
1252
|
+
const decisionRecord = recordDecision(decision, decisionContext);
|
|
1161
1253
|
// Move file before processing to prevent duplicate triage on next tick
|
|
1162
1254
|
const destPath = path.join(processedDir, file);
|
|
1163
1255
|
try {
|
|
@@ -1168,18 +1260,45 @@ export class HeartbeatScheduler {
|
|
|
1168
1260
|
// If move fails, skip — will retry next tick
|
|
1169
1261
|
continue;
|
|
1170
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
|
+
: '';
|
|
1171
1288
|
// Build a prompt for the agent to triage this inbox item
|
|
1172
1289
|
const prompt = `Triage this inbox item and take appropriate action.\n\n` +
|
|
1173
1290
|
`**Title:** ${title}\n` +
|
|
1174
1291
|
`**Content:**\n${content.slice(0, 2000)}\n\n` +
|
|
1292
|
+
teamBlock +
|
|
1175
1293
|
`## Instructions:\n` +
|
|
1176
|
-
`1. Determine the intent: Is this a task, a reference/note, a reminder, or
|
|
1294
|
+
`1. Determine the intent: Is this a task, a reference/note, a reminder, project update, or work for a teammate?\n` +
|
|
1177
1295
|
`2. Take the appropriate action:\n` +
|
|
1178
1296
|
` - **Task**: Use \`task_add\` to create a task with the right priority and due date.\n` +
|
|
1179
1297
|
` - **Reference**: Use \`note_create\` or \`memory_write\` to file it in the vault.\n` +
|
|
1180
1298
|
` - **Reminder**: Add to today's daily note with \`memory_write(action="append_daily")\`.\n` +
|
|
1181
1299
|
` - **Project update**: Update the relevant project note.\n` +
|
|
1182
|
-
`
|
|
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).`;
|
|
1183
1302
|
// Fire-and-forget — run as a lightweight cron job
|
|
1184
1303
|
this.gateway
|
|
1185
1304
|
.handleCronJob(`inbox:${title}`, prompt, 1, 5)
|
|
@@ -1188,6 +1307,15 @@ export class HeartbeatScheduler {
|
|
|
1188
1307
|
logToDailyNote(`**Inbox processed: ${title}** — ${result.slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1189
1308
|
}
|
|
1190
1309
|
logger.info({ file: title }, 'Inbox item processed');
|
|
1310
|
+
try {
|
|
1311
|
+
recordDecisionOutcome(decisionRecord.id, decision, decisionContext, {
|
|
1312
|
+
status: 'advanced',
|
|
1313
|
+
summary: (result ?? 'processed').slice(0, 200),
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
catch (err) {
|
|
1317
|
+
logger.debug({ err, file: title }, 'Failed to record inbox outcome (non-fatal)');
|
|
1318
|
+
}
|
|
1191
1319
|
})
|
|
1192
1320
|
.catch((err) => {
|
|
1193
1321
|
// Restore file to inbox on failure so it retries
|
|
@@ -1197,6 +1325,15 @@ export class HeartbeatScheduler {
|
|
|
1197
1325
|
}
|
|
1198
1326
|
catch { /* best-effort restore */ }
|
|
1199
1327
|
logger.warn({ err, file: title }, 'Failed to process inbox item');
|
|
1328
|
+
try {
|
|
1329
|
+
recordDecisionOutcome(decisionRecord.id, decision, decisionContext, {
|
|
1330
|
+
status: 'failed',
|
|
1331
|
+
summary: String(err).slice(0, 200),
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
catch (recordErr) {
|
|
1335
|
+
logger.debug({ err: recordErr, file: title }, 'Failed to record inbox outcome (non-fatal)');
|
|
1336
|
+
}
|
|
1200
1337
|
});
|
|
1201
1338
|
}
|
|
1202
1339
|
catch (err) {
|
|
@@ -1349,14 +1486,40 @@ export class HeartbeatScheduler {
|
|
|
1349
1486
|
HeartbeatScheduler.saveWorkQueue(queue);
|
|
1350
1487
|
}
|
|
1351
1488
|
}
|
|
1489
|
+
recordWorkItemOutcome(item, status, summary) {
|
|
1490
|
+
const key = item.idempotencyKey;
|
|
1491
|
+
if (!key)
|
|
1492
|
+
return;
|
|
1493
|
+
try {
|
|
1494
|
+
const original = recentDecisions({ idempotencyKey: key }, undefined)[0];
|
|
1495
|
+
if (!original)
|
|
1496
|
+
return;
|
|
1497
|
+
recordDecisionOutcome(original.id, original.decision, original.context, {
|
|
1498
|
+
status,
|
|
1499
|
+
summary: summary.slice(0, 200),
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
catch (err) {
|
|
1503
|
+
logger.debug({ err, id: item.id }, 'Failed to record work-item outcome (non-fatal)');
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1352
1506
|
static enqueueWork(opts) {
|
|
1353
1507
|
const queue = HeartbeatScheduler.loadWorkQueue();
|
|
1508
|
+
// Only dedup against in-flight items here. Cross-tick "we already acted on this
|
|
1509
|
+
// signal" lives in the proactive ledger (wasRecentlyDecided); blocking on
|
|
1510
|
+
// 'completed' here would prevent legitimate re-runs of multi-session work.
|
|
1511
|
+
const dedupKey = opts.idempotencyKey ?? opts.source;
|
|
1512
|
+
const existing = queue.find((item) => (item.idempotencyKey ?? item.source) === dedupKey &&
|
|
1513
|
+
(item.status === 'pending' || item.status === 'running'));
|
|
1514
|
+
if (existing)
|
|
1515
|
+
return existing.id;
|
|
1354
1516
|
const id = randomBytes(4).toString('hex');
|
|
1355
1517
|
const item = {
|
|
1356
1518
|
id,
|
|
1357
1519
|
description: opts.description,
|
|
1358
1520
|
prompt: opts.prompt,
|
|
1359
1521
|
source: opts.source,
|
|
1522
|
+
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
1360
1523
|
priority: opts.priority ?? 'normal',
|
|
1361
1524
|
queuedAt: new Date().toISOString(),
|
|
1362
1525
|
maxTurns: opts.maxTurns ?? 3,
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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,10 @@ 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 only in P2 — LLM ticks land in P3.
|
|
665
|
+
const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
|
|
666
|
+
const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager());
|
|
655
667
|
// ── Build channel tasks ──────────────────────────────────────────
|
|
656
668
|
const channelTasks = [];
|
|
657
669
|
const activeChannels = [];
|
|
@@ -748,6 +760,7 @@ async function asyncMain() {
|
|
|
748
760
|
// Start heartbeat + cron + timers
|
|
749
761
|
heartbeat.start();
|
|
750
762
|
cronScheduler.start();
|
|
763
|
+
agentHeartbeats.start();
|
|
751
764
|
const timerInterval = startTimerChecker(dispatcher, gateway);
|
|
752
765
|
// Start brain ingest scheduler (polls registered REST sources on their cron)
|
|
753
766
|
try {
|
|
@@ -938,6 +951,7 @@ async function asyncMain() {
|
|
|
938
951
|
// Now safe to tear down remaining infrastructure
|
|
939
952
|
heartbeat.stop();
|
|
940
953
|
cronScheduler.stop();
|
|
954
|
+
agentHeartbeats.stop();
|
|
941
955
|
// ── Self-restart (enhanced with health check + rollback) ────────
|
|
942
956
|
if (restartRequested) {
|
|
943
957
|
// Clear our PID file BEFORE spawning the child, so ensureSingleton()
|