clementine-agent 1.0.75 → 1.0.77
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/cli/dashboard.js +38 -3
- 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 +180 -73
- package/dist/tools/session-tools.js +58 -6
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -108,7 +108,7 @@ export function gatherInsightSignals(gateway) {
|
|
|
108
108
|
catch { /* non-fatal */ }
|
|
109
109
|
// 2. Check for cron job failures
|
|
110
110
|
try {
|
|
111
|
-
const runLogDir = path.join(BASE_DIR, 'cron', '
|
|
111
|
+
const runLogDir = path.join(BASE_DIR, 'cron', 'runs');
|
|
112
112
|
if (existsSync(runLogDir)) {
|
|
113
113
|
const logFiles = readdirSync(runLogDir).filter(f => f.endsWith('.jsonl')).slice(0, 10);
|
|
114
114
|
for (const file of logFiles) {
|
|
@@ -118,7 +118,8 @@ export function gatherInsightSignals(gateway) {
|
|
|
118
118
|
continue;
|
|
119
119
|
try {
|
|
120
120
|
const entry = JSON.parse(lastLine);
|
|
121
|
-
|
|
121
|
+
const finishedAt = entry.finishedAt ?? entry.timestamp;
|
|
122
|
+
if (finishedAt && new Date(finishedAt) > new Date(twoHoursAgo) && entry.status === 'error') {
|
|
122
123
|
signals.push(`Cron job "${entry.jobName || file.replace('.jsonl', '')}" failed: ${(entry.error || '').slice(0, 100)}`);
|
|
123
124
|
}
|
|
124
125
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { DailyPlanPriority, PersistentGoal } from '../types.js';
|
|
2
|
+
export type ProactiveAction = 'act_now' | 'queue' | 'ask_user' | 'snooze' | 'ignore';
|
|
3
|
+
export type ProactiveSource = 'daily-plan' | 'goal' | 'cron' | 'inbox' | 'insight' | 'manual';
|
|
4
|
+
export interface ProactiveDecision {
|
|
5
|
+
action: ProactiveAction;
|
|
6
|
+
source: ProactiveSource;
|
|
7
|
+
reason: string;
|
|
8
|
+
urgency: number;
|
|
9
|
+
confidence: number;
|
|
10
|
+
authorityTier: 1 | 2 | 3;
|
|
11
|
+
idempotencyKey: string;
|
|
12
|
+
nextCheckAt?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DailyPlanDecisionInput {
|
|
15
|
+
priority: DailyPlanPriority;
|
|
16
|
+
date: string;
|
|
17
|
+
goalAutoSchedule?: boolean;
|
|
18
|
+
now?: Date;
|
|
19
|
+
}
|
|
20
|
+
export type ProactiveWorkType = 'stale-goal' | 'cron-failure' | 'overdue-tasks' | 'inbox' | `plan-${DailyPlanPriority['type']}` | 'unknown';
|
|
21
|
+
export interface ProactiveWorkItem {
|
|
22
|
+
type: ProactiveWorkType;
|
|
23
|
+
id?: string;
|
|
24
|
+
description: string;
|
|
25
|
+
urgency: number;
|
|
26
|
+
source?: ProactiveSource;
|
|
27
|
+
}
|
|
28
|
+
export interface GoalAdvancementDecision {
|
|
29
|
+
goal: PersistentGoal;
|
|
30
|
+
decision: ProactiveDecision;
|
|
31
|
+
score: number;
|
|
32
|
+
focus: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
export interface GoalAdvancementInput {
|
|
36
|
+
goal: PersistentGoal;
|
|
37
|
+
now?: Date;
|
|
38
|
+
inCooldown?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare function requiresHumanInput(text: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Deterministic first-pass decision for daily-plan priorities.
|
|
43
|
+
* The LLM decides what matters; this layer decides what the daemon may do.
|
|
44
|
+
*/
|
|
45
|
+
export declare function decideDailyPlanPriority(input: DailyPlanDecisionInput): ProactiveDecision;
|
|
46
|
+
export declare function decisionShouldCreateGoalTrigger(decision: ProactiveDecision): boolean;
|
|
47
|
+
export declare function decisionShouldQueueHeartbeatWork(decision: ProactiveDecision): boolean;
|
|
48
|
+
export declare function decideDiscoveredWorkItem(item: ProactiveWorkItem, now?: Date): ProactiveDecision;
|
|
49
|
+
export declare function decideGoalAdvancement(input: GoalAdvancementInput): GoalAdvancementDecision | null;
|
|
50
|
+
//# sourceMappingURL=proactive-engine.d.ts.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const HUMAN_INPUT_PATTERN = /\?|decide|choose|approve|confirm|review with|ask\s/i;
|
|
3
|
+
function shortHash(value) {
|
|
4
|
+
return createHash('sha1').update(value).digest('hex').slice(0, 10);
|
|
5
|
+
}
|
|
6
|
+
export function requiresHumanInput(text) {
|
|
7
|
+
return HUMAN_INPUT_PATTERN.test(text);
|
|
8
|
+
}
|
|
9
|
+
function clampUrgency(value) {
|
|
10
|
+
if (!Number.isFinite(value))
|
|
11
|
+
return 1;
|
|
12
|
+
return Math.max(1, Math.min(5, Math.round(value)));
|
|
13
|
+
}
|
|
14
|
+
function snoozeUntil(now, hours) {
|
|
15
|
+
return new Date(now.getTime() + hours * 60 * 60 * 1000).toISOString();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Deterministic first-pass decision for daily-plan priorities.
|
|
19
|
+
* The LLM decides what matters; this layer decides what the daemon may do.
|
|
20
|
+
*/
|
|
21
|
+
export function decideDailyPlanPriority(input) {
|
|
22
|
+
const { priority, date, goalAutoSchedule = false, now = new Date() } = input;
|
|
23
|
+
const urgency = clampUrgency(priority.urgency);
|
|
24
|
+
const keyBase = `${date}:${priority.type}:${priority.id}:${priority.action}`;
|
|
25
|
+
const idempotencyKey = `daily-plan:${shortHash(keyBase)}`;
|
|
26
|
+
if (!priority.action.trim()) {
|
|
27
|
+
return {
|
|
28
|
+
action: 'ignore',
|
|
29
|
+
source: 'daily-plan',
|
|
30
|
+
reason: 'Daily plan priority had no actionable description.',
|
|
31
|
+
urgency,
|
|
32
|
+
confidence: 0.2,
|
|
33
|
+
authorityTier: 1,
|
|
34
|
+
idempotencyKey,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (requiresHumanInput(priority.action)) {
|
|
38
|
+
return {
|
|
39
|
+
action: urgency >= 4 ? 'ask_user' : 'snooze',
|
|
40
|
+
source: 'daily-plan',
|
|
41
|
+
reason: 'Priority appears to require owner input before action.',
|
|
42
|
+
urgency,
|
|
43
|
+
confidence: 0.75,
|
|
44
|
+
authorityTier: 1,
|
|
45
|
+
idempotencyKey,
|
|
46
|
+
nextCheckAt: snoozeUntil(now, urgency >= 4 ? 4 : 24),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (priority.type === 'goal') {
|
|
50
|
+
if (urgency >= 4) {
|
|
51
|
+
return {
|
|
52
|
+
action: 'act_now',
|
|
53
|
+
source: 'daily-plan',
|
|
54
|
+
reason: 'High-urgency goal priority is autonomously actionable.',
|
|
55
|
+
urgency,
|
|
56
|
+
confidence: goalAutoSchedule ? 0.9 : 0.8,
|
|
57
|
+
authorityTier: 2,
|
|
58
|
+
idempotencyKey,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (urgency >= 3) {
|
|
62
|
+
return {
|
|
63
|
+
action: 'queue',
|
|
64
|
+
source: 'daily-plan',
|
|
65
|
+
reason: 'Medium-urgency goal priority should be queued for heartbeat work.',
|
|
66
|
+
urgency,
|
|
67
|
+
confidence: 0.7,
|
|
68
|
+
authorityTier: 1,
|
|
69
|
+
idempotencyKey,
|
|
70
|
+
nextCheckAt: snoozeUntil(now, 2),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (priority.type === 'cron-fix' && urgency >= 4) {
|
|
75
|
+
return {
|
|
76
|
+
action: 'ask_user',
|
|
77
|
+
source: 'daily-plan',
|
|
78
|
+
reason: 'Cron fixes can change autonomous behavior and should be confirmed.',
|
|
79
|
+
urgency,
|
|
80
|
+
confidence: 0.75,
|
|
81
|
+
authorityTier: 2,
|
|
82
|
+
idempotencyKey,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
action: urgency >= 3 ? 'snooze' : 'ignore',
|
|
87
|
+
source: 'daily-plan',
|
|
88
|
+
reason: 'Priority is not urgent enough for autonomous action.',
|
|
89
|
+
urgency,
|
|
90
|
+
confidence: 0.65,
|
|
91
|
+
authorityTier: 1,
|
|
92
|
+
idempotencyKey,
|
|
93
|
+
nextCheckAt: snoozeUntil(now, 24),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function decisionShouldCreateGoalTrigger(decision) {
|
|
97
|
+
return decision.action === 'act_now' && decision.authorityTier <= 2 && decision.confidence >= 0.7;
|
|
98
|
+
}
|
|
99
|
+
export function decisionShouldQueueHeartbeatWork(decision) {
|
|
100
|
+
return decision.action === 'queue' && decision.authorityTier === 1 && decision.confidence >= 0.65;
|
|
101
|
+
}
|
|
102
|
+
export function decideDiscoveredWorkItem(item, now = new Date()) {
|
|
103
|
+
const urgency = clampUrgency(item.urgency);
|
|
104
|
+
const source = item.source ?? (item.type === 'cron-failure' ? 'cron'
|
|
105
|
+
: item.type === 'inbox' ? 'inbox'
|
|
106
|
+
: item.type === 'stale-goal' ? 'goal'
|
|
107
|
+
: item.type.startsWith('plan-') ? 'daily-plan'
|
|
108
|
+
: 'manual');
|
|
109
|
+
const idempotencyKey = `${source}:${shortHash(`${item.type}:${item.id ?? ''}:${item.description}`)}`;
|
|
110
|
+
if (!item.description.trim()) {
|
|
111
|
+
return {
|
|
112
|
+
action: 'ignore',
|
|
113
|
+
source,
|
|
114
|
+
reason: 'Work item had no actionable description.',
|
|
115
|
+
urgency,
|
|
116
|
+
confidence: 0.2,
|
|
117
|
+
authorityTier: 1,
|
|
118
|
+
idempotencyKey,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (requiresHumanInput(item.description)) {
|
|
122
|
+
return {
|
|
123
|
+
action: urgency >= 4 ? 'ask_user' : 'snooze',
|
|
124
|
+
source,
|
|
125
|
+
reason: 'Work item appears to require owner input.',
|
|
126
|
+
urgency,
|
|
127
|
+
confidence: 0.7,
|
|
128
|
+
authorityTier: 1,
|
|
129
|
+
idempotencyKey,
|
|
130
|
+
nextCheckAt: snoozeUntil(now, urgency >= 4 ? 4 : 24),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
switch (item.type) {
|
|
134
|
+
case 'cron-failure':
|
|
135
|
+
return {
|
|
136
|
+
action: urgency >= 4 ? 'ask_user' : 'queue',
|
|
137
|
+
source,
|
|
138
|
+
reason: urgency >= 4
|
|
139
|
+
? 'Repeated cron failure may require owner approval before changing automation.'
|
|
140
|
+
: 'Cron failure should be queued for diagnosis.',
|
|
141
|
+
urgency,
|
|
142
|
+
confidence: 0.75,
|
|
143
|
+
authorityTier: 2,
|
|
144
|
+
idempotencyKey,
|
|
145
|
+
};
|
|
146
|
+
case 'overdue-tasks':
|
|
147
|
+
return {
|
|
148
|
+
action: urgency >= 4 ? 'ask_user' : 'snooze',
|
|
149
|
+
source,
|
|
150
|
+
reason: 'Overdue tasks need owner attention unless a concrete next action is known.',
|
|
151
|
+
urgency,
|
|
152
|
+
confidence: 0.7,
|
|
153
|
+
authorityTier: 1,
|
|
154
|
+
idempotencyKey,
|
|
155
|
+
nextCheckAt: snoozeUntil(now, urgency >= 4 ? 4 : 24),
|
|
156
|
+
};
|
|
157
|
+
case 'inbox':
|
|
158
|
+
return {
|
|
159
|
+
action: urgency >= 2 ? 'act_now' : 'queue',
|
|
160
|
+
source,
|
|
161
|
+
reason: 'Inbox triage is a reversible tier-1 organization task.',
|
|
162
|
+
urgency,
|
|
163
|
+
confidence: 0.8,
|
|
164
|
+
authorityTier: 1,
|
|
165
|
+
idempotencyKey,
|
|
166
|
+
};
|
|
167
|
+
case 'stale-goal':
|
|
168
|
+
case 'plan-goal':
|
|
169
|
+
return {
|
|
170
|
+
action: urgency >= 4 ? 'act_now' : urgency >= 3 ? 'queue' : 'snooze',
|
|
171
|
+
source,
|
|
172
|
+
reason: 'Goal-related work can move autonomously when it has a concrete action.',
|
|
173
|
+
urgency,
|
|
174
|
+
confidence: 0.75,
|
|
175
|
+
authorityTier: urgency >= 4 ? 2 : 1,
|
|
176
|
+
idempotencyKey,
|
|
177
|
+
nextCheckAt: urgency < 4 ? snoozeUntil(now, 2) : undefined,
|
|
178
|
+
};
|
|
179
|
+
default:
|
|
180
|
+
return {
|
|
181
|
+
action: urgency >= 4 ? 'ask_user' : urgency >= 3 ? 'queue' : 'ignore',
|
|
182
|
+
source,
|
|
183
|
+
reason: 'Generic work item evaluated conservatively.',
|
|
184
|
+
urgency,
|
|
185
|
+
confidence: 0.6,
|
|
186
|
+
authorityTier: 1,
|
|
187
|
+
idempotencyKey,
|
|
188
|
+
nextCheckAt: urgency >= 3 && urgency < 4 ? snoozeUntil(now, 24) : undefined,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export function decideGoalAdvancement(input) {
|
|
193
|
+
const { goal, now = new Date(), inCooldown = false } = input;
|
|
194
|
+
if (goal.status !== 'active')
|
|
195
|
+
return null;
|
|
196
|
+
// Cooldown is a hard skip — the caller already decided we shouldn't act on
|
|
197
|
+
// this goal. Returning a snooze record here just got discarded downstream.
|
|
198
|
+
if (inCooldown)
|
|
199
|
+
return null;
|
|
200
|
+
const nowMs = now.getTime();
|
|
201
|
+
const dayMs = 86_400_000;
|
|
202
|
+
const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
|
|
203
|
+
const daysSinceUpdate = Math.floor((nowMs - lastUpdate) / dayMs);
|
|
204
|
+
const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
|
|
205
|
+
const isStale = daysSinceUpdate > staleThreshold;
|
|
206
|
+
const hasWork = (goal.nextActions?.length ?? 0) > 0;
|
|
207
|
+
if (!isStale && !hasWork)
|
|
208
|
+
return null;
|
|
209
|
+
const priorityScore = goal.priority === 'high' ? 15 : goal.priority === 'medium' ? 8 : 2;
|
|
210
|
+
const stalenessScore = isStale ? Math.min(daysSinceUpdate - staleThreshold, 20) : 0;
|
|
211
|
+
const workScore = hasWork ? 5 : 0;
|
|
212
|
+
let deadlineScore = 0;
|
|
213
|
+
if (goal.targetDate) {
|
|
214
|
+
const daysUntilTarget = Math.floor((new Date(goal.targetDate).getTime() - nowMs) / dayMs);
|
|
215
|
+
if (daysUntilTarget <= 0)
|
|
216
|
+
deadlineScore = 20;
|
|
217
|
+
else if (daysUntilTarget <= 3)
|
|
218
|
+
deadlineScore = 10;
|
|
219
|
+
else if (daysUntilTarget <= 7)
|
|
220
|
+
deadlineScore = 5;
|
|
221
|
+
}
|
|
222
|
+
const score = priorityScore + stalenessScore + workScore + deadlineScore;
|
|
223
|
+
const reason = [
|
|
224
|
+
isStale ? `stale(${daysSinceUpdate}d)` : 'current',
|
|
225
|
+
`pri=${goal.priority}`,
|
|
226
|
+
hasWork ? 'has-work' : 'no-work',
|
|
227
|
+
deadlineScore > 0 ? `deadline-boost(${deadlineScore})` : '',
|
|
228
|
+
].filter(Boolean).join(', ');
|
|
229
|
+
const focus = goal.nextActions?.length > 0
|
|
230
|
+
? goal.nextActions[0]
|
|
231
|
+
: `Review and update progress on "${goal.title}"`;
|
|
232
|
+
const idempotencyKey = `goal:${shortHash(`${goal.id}:${focus}`)}`;
|
|
233
|
+
const urgency = Math.min(5, Math.max(1, Math.ceil(score / 8)));
|
|
234
|
+
const action = score >= 13 ? 'act_now' : score >= 8 ? 'queue' : 'snooze';
|
|
235
|
+
return {
|
|
236
|
+
goal,
|
|
237
|
+
score,
|
|
238
|
+
focus,
|
|
239
|
+
reason,
|
|
240
|
+
decision: {
|
|
241
|
+
action,
|
|
242
|
+
source: 'goal',
|
|
243
|
+
reason: action === 'act_now'
|
|
244
|
+
? 'Goal score is high enough for autonomous advancement.'
|
|
245
|
+
: action === 'queue'
|
|
246
|
+
? 'Goal should be queued, but not executed immediately.'
|
|
247
|
+
: 'Goal is low urgency; check later.',
|
|
248
|
+
urgency,
|
|
249
|
+
confidence: action === 'act_now' ? 0.8 : 0.7,
|
|
250
|
+
authorityTier: action === 'act_now' ? 2 : 1,
|
|
251
|
+
idempotencyKey,
|
|
252
|
+
nextCheckAt: action === 'act_now' ? undefined : snoozeUntil(now, 2),
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=proactive-engine.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ProactiveDecision } from './proactive-engine.js';
|
|
2
|
+
export interface ProactiveDecisionContext {
|
|
3
|
+
signalType: string;
|
|
4
|
+
description: string;
|
|
5
|
+
goalId?: string;
|
|
6
|
+
owner?: string;
|
|
7
|
+
metadata?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface ProactiveDecisionOutcome {
|
|
10
|
+
status: 'advanced' | 'queued' | 'asked-user' | 'snoozed' | 'ignored' | 'blocked-on-user' | 'blocked-on-external' | 'needs-different-approach' | 'monitoring' | 'no-change' | 'failed';
|
|
11
|
+
summary: string;
|
|
12
|
+
recordedAt: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ProactiveDecisionRecord {
|
|
15
|
+
id: string;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
decision: ProactiveDecision;
|
|
18
|
+
context: ProactiveDecisionContext;
|
|
19
|
+
outcome?: ProactiveDecisionOutcome;
|
|
20
|
+
}
|
|
21
|
+
export interface ProactiveLedgerOptions {
|
|
22
|
+
filePath?: string;
|
|
23
|
+
now?: Date;
|
|
24
|
+
}
|
|
25
|
+
export declare function recordDecision(decision: ProactiveDecision, context: ProactiveDecisionContext, opts?: ProactiveLedgerOptions): ProactiveDecisionRecord;
|
|
26
|
+
/**
|
|
27
|
+
* Append an outcome record. Event-sourced: outcomes are written as a NEW
|
|
28
|
+
* record sharing the original decision's `id`, not as in-place updates.
|
|
29
|
+
*
|
|
30
|
+
* `wasRecentlyDecided` therefore counts outcome records as "decided" — that
|
|
31
|
+
* is intentional. We don't want to re-act on a signal we've already handled,
|
|
32
|
+
* regardless of whether the latest entry is the original decision or its
|
|
33
|
+
* follow-up outcome.
|
|
34
|
+
*/
|
|
35
|
+
export declare function recordDecisionOutcome(decisionId: string, decision: ProactiveDecision, context: ProactiveDecisionContext, outcome: Omit<ProactiveDecisionOutcome, 'recordedAt'>, opts?: ProactiveLedgerOptions): ProactiveDecisionRecord;
|
|
36
|
+
export declare function recentDecisions(filter?: Partial<{
|
|
37
|
+
idempotencyKey: string;
|
|
38
|
+
action: ProactiveDecision['action'];
|
|
39
|
+
source: ProactiveDecision['source'];
|
|
40
|
+
sinceMs: number;
|
|
41
|
+
}>, opts?: ProactiveLedgerOptions): ProactiveDecisionRecord[];
|
|
42
|
+
export declare function wasRecentlyDecided(idempotencyKey: string, windowMs: number, opts?: ProactiveLedgerOptions): boolean;
|
|
43
|
+
export declare function outcomeStatusFromGoalDisposition(disposition: string): ProactiveDecisionOutcome['status'];
|
|
44
|
+
//# sourceMappingURL=proactive-ledger.d.ts.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { BASE_DIR } from '../config.js';
|
|
5
|
+
// Mirrors CronRunLog (cron-scheduler.ts) — keep the file bounded so reads
|
|
6
|
+
// don't grow linearly forever.
|
|
7
|
+
const MAX_BYTES = 2_000_000;
|
|
8
|
+
const MAX_LINES = 2000;
|
|
9
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
10
|
+
// Per-file dedup cache: idempotencyKey → latest timestamp ms. Built lazily
|
|
11
|
+
// on first lookup, kept in sync on every append.
|
|
12
|
+
const dedupCache = new Map();
|
|
13
|
+
const DEFAULT_LEDGER_FILE = path.join(BASE_DIR, 'proactive', 'decisions.jsonl');
|
|
14
|
+
function ledgerPath(opts) {
|
|
15
|
+
return opts?.filePath ?? DEFAULT_LEDGER_FILE;
|
|
16
|
+
}
|
|
17
|
+
function hash(value) {
|
|
18
|
+
return createHash('sha1').update(value).digest('hex').slice(0, 12);
|
|
19
|
+
}
|
|
20
|
+
function readRecords(filePath) {
|
|
21
|
+
if (!existsSync(filePath))
|
|
22
|
+
return [];
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(filePath, 'utf-8')
|
|
25
|
+
.split('\n')
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((line) => JSON.parse(line));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function getDedupCache(filePath) {
|
|
34
|
+
let cache = dedupCache.get(filePath);
|
|
35
|
+
if (cache)
|
|
36
|
+
return cache;
|
|
37
|
+
cache = new Map();
|
|
38
|
+
for (const record of readRecords(filePath)) {
|
|
39
|
+
const ts = new Date(record.timestamp).getTime();
|
|
40
|
+
if (!Number.isFinite(ts))
|
|
41
|
+
continue;
|
|
42
|
+
const prev = cache.get(record.decision.idempotencyKey) ?? 0;
|
|
43
|
+
if (ts > prev)
|
|
44
|
+
cache.set(record.decision.idempotencyKey, ts);
|
|
45
|
+
}
|
|
46
|
+
dedupCache.set(filePath, cache);
|
|
47
|
+
return cache;
|
|
48
|
+
}
|
|
49
|
+
function touchDedupCache(filePath, idempotencyKey, timestampMs) {
|
|
50
|
+
const cache = dedupCache.get(filePath);
|
|
51
|
+
if (!cache)
|
|
52
|
+
return; // not yet initialized — will be built fresh on next read
|
|
53
|
+
const prev = cache.get(idempotencyKey) ?? 0;
|
|
54
|
+
if (timestampMs > prev)
|
|
55
|
+
cache.set(idempotencyKey, timestampMs);
|
|
56
|
+
}
|
|
57
|
+
function maybePrune(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
const { size } = statSync(filePath);
|
|
60
|
+
if (size <= MAX_BYTES)
|
|
61
|
+
return;
|
|
62
|
+
const lines = readFileSync(filePath, 'utf-8').trim().split('\n').filter(Boolean);
|
|
63
|
+
if (lines.length <= MAX_LINES)
|
|
64
|
+
return;
|
|
65
|
+
const cutoff = Date.now() - MAX_AGE_MS;
|
|
66
|
+
const kept = [];
|
|
67
|
+
for (const line of lines.slice(-MAX_LINES)) {
|
|
68
|
+
try {
|
|
69
|
+
const record = JSON.parse(line);
|
|
70
|
+
const ts = new Date(record.timestamp).getTime();
|
|
71
|
+
if (Number.isFinite(ts) && ts >= cutoff)
|
|
72
|
+
kept.push(line);
|
|
73
|
+
}
|
|
74
|
+
catch { /* drop malformed */ }
|
|
75
|
+
}
|
|
76
|
+
writeFileSync(filePath, kept.join('\n') + (kept.length ? '\n' : ''));
|
|
77
|
+
// Cache is now stale w.r.t. dropped entries (older than dedup windows
|
|
78
|
+
// anyway), but rebuild lazily to avoid reading the file twice here.
|
|
79
|
+
dedupCache.delete(filePath);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// non-fatal
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function recordDecision(decision, context, opts) {
|
|
86
|
+
const now = opts?.now ?? new Date();
|
|
87
|
+
const record = {
|
|
88
|
+
id: hash(`${decision.idempotencyKey}:${now.toISOString()}:${context.signalType}`),
|
|
89
|
+
timestamp: now.toISOString(),
|
|
90
|
+
decision,
|
|
91
|
+
context,
|
|
92
|
+
};
|
|
93
|
+
const filePath = ledgerPath(opts);
|
|
94
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
95
|
+
appendFileSync(filePath, JSON.stringify(record) + '\n');
|
|
96
|
+
touchDedupCache(filePath, decision.idempotencyKey, now.getTime());
|
|
97
|
+
setImmediate(() => maybePrune(filePath));
|
|
98
|
+
return record;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Append an outcome record. Event-sourced: outcomes are written as a NEW
|
|
102
|
+
* record sharing the original decision's `id`, not as in-place updates.
|
|
103
|
+
*
|
|
104
|
+
* `wasRecentlyDecided` therefore counts outcome records as "decided" — that
|
|
105
|
+
* is intentional. We don't want to re-act on a signal we've already handled,
|
|
106
|
+
* regardless of whether the latest entry is the original decision or its
|
|
107
|
+
* follow-up outcome.
|
|
108
|
+
*/
|
|
109
|
+
export function recordDecisionOutcome(decisionId, decision, context, outcome, opts) {
|
|
110
|
+
const now = opts?.now ?? new Date();
|
|
111
|
+
const record = {
|
|
112
|
+
id: decisionId,
|
|
113
|
+
timestamp: now.toISOString(),
|
|
114
|
+
decision,
|
|
115
|
+
context,
|
|
116
|
+
outcome: {
|
|
117
|
+
...outcome,
|
|
118
|
+
recordedAt: now.toISOString(),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const filePath = ledgerPath(opts);
|
|
122
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
123
|
+
appendFileSync(filePath, JSON.stringify(record) + '\n');
|
|
124
|
+
touchDedupCache(filePath, decision.idempotencyKey, now.getTime());
|
|
125
|
+
setImmediate(() => maybePrune(filePath));
|
|
126
|
+
return record;
|
|
127
|
+
}
|
|
128
|
+
export function recentDecisions(filter = {}, opts) {
|
|
129
|
+
const filePath = ledgerPath(opts);
|
|
130
|
+
const nowMs = (opts?.now ?? new Date()).getTime();
|
|
131
|
+
return readRecords(filePath)
|
|
132
|
+
.filter((record) => {
|
|
133
|
+
if (filter.idempotencyKey && record.decision.idempotencyKey !== filter.idempotencyKey)
|
|
134
|
+
return false;
|
|
135
|
+
if (filter.action && record.decision.action !== filter.action)
|
|
136
|
+
return false;
|
|
137
|
+
if (filter.source && record.decision.source !== filter.source)
|
|
138
|
+
return false;
|
|
139
|
+
if (filter.sinceMs != null) {
|
|
140
|
+
const ts = new Date(record.timestamp).getTime();
|
|
141
|
+
if (!Number.isFinite(ts) || nowMs - ts > filter.sinceMs)
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
})
|
|
146
|
+
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
147
|
+
}
|
|
148
|
+
export function wasRecentlyDecided(idempotencyKey, windowMs, opts) {
|
|
149
|
+
const filePath = ledgerPath(opts);
|
|
150
|
+
const nowMs = (opts?.now ?? new Date()).getTime();
|
|
151
|
+
const cache = getDedupCache(filePath);
|
|
152
|
+
const latest = cache.get(idempotencyKey);
|
|
153
|
+
if (latest == null)
|
|
154
|
+
return false;
|
|
155
|
+
return nowMs - latest <= windowMs;
|
|
156
|
+
}
|
|
157
|
+
export function outcomeStatusFromGoalDisposition(disposition) {
|
|
158
|
+
switch (disposition) {
|
|
159
|
+
case 'advanced':
|
|
160
|
+
return 'advanced';
|
|
161
|
+
case 'blocked-on-user':
|
|
162
|
+
return 'blocked-on-user';
|
|
163
|
+
case 'blocked-on-external':
|
|
164
|
+
return 'blocked-on-external';
|
|
165
|
+
case 'needs-different-approach':
|
|
166
|
+
return 'needs-different-approach';
|
|
167
|
+
case 'monitoring':
|
|
168
|
+
return 'monitoring';
|
|
169
|
+
case 'no-change':
|
|
170
|
+
return 'no-change';
|
|
171
|
+
case 'error':
|
|
172
|
+
default:
|
|
173
|
+
return 'failed';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=proactive-ledger.js.map
|
|
@@ -1792,11 +1792,9 @@ export function validateProposal(area, target, proposedChange) {
|
|
|
1792
1792
|
if (SOURCE_BLOCKLIST.has(target)) {
|
|
1793
1793
|
return { valid: false, error: `Source file '${target}' is in the blocklist and cannot be modified by self-improvement` };
|
|
1794
1794
|
}
|
|
1795
|
-
// Size sanity: reject wholesale rewrites (proposed content > 2x original would be caught by caller)
|
|
1796
|
-
//
|
|
1797
|
-
|
|
1798
|
-
return { valid: false, error: 'Source proposal missing import/export statements — likely not valid TypeScript' };
|
|
1799
|
-
}
|
|
1795
|
+
// Size sanity: reject wholesale rewrites (proposed content > 2x original would be caught by caller).
|
|
1796
|
+
// Source proposals may be small patches or modules without import/export statements;
|
|
1797
|
+
// callers that apply source changes do the syntax-aware validation.
|
|
1800
1798
|
}
|
|
1801
1799
|
return { valid: true };
|
|
1802
1800
|
}
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -12380,14 +12380,24 @@ async function checkAnthropicAuth() {
|
|
|
12380
12380
|
var btn = document.getElementById('gs-auth-btn');
|
|
12381
12381
|
if (d.authenticated && card) {
|
|
12382
12382
|
card.className = 'gs-card gs-done';
|
|
12383
|
-
|
|
12383
|
+
// Email is null when auth comes from the Claude Code keychain (OAuth
|
|
12384
|
+
// login via clementine-login or claude-login that we do not have read
|
|
12385
|
+
// access to). Avoid rendering "Connected as null" — looks broken and
|
|
12386
|
+
// makes users re-click login, which hits an SDK bug in claudeAuthenticate.
|
|
12387
|
+
var label = d.email
|
|
12388
|
+
? 'Connected as ' + d.email
|
|
12389
|
+
: 'Connected via ' + (d.apiKeySource === 'keychain' ? 'Claude Code keychain' : d.apiKeySource || 'API');
|
|
12390
|
+
if (desc) desc.textContent = label;
|
|
12384
12391
|
if (btn) { btn.textContent = 'Connected'; btn.disabled = true; }
|
|
12385
12392
|
}
|
|
12386
12393
|
// Also update settings auth indicator
|
|
12387
12394
|
var settingsAuth = document.getElementById('settings-auth-status');
|
|
12388
12395
|
if (settingsAuth) {
|
|
12396
|
+
var sLabel = d.authenticated
|
|
12397
|
+
? (d.email ? 'Connected as ' + d.email : 'Connected via ' + (d.apiKeySource || 'API'))
|
|
12398
|
+
: '';
|
|
12389
12399
|
settingsAuth.innerHTML = d.authenticated
|
|
12390
|
-
? '<span style="color:var(--green)">
|
|
12400
|
+
? '<span style="color:var(--green)">' + sLabel + '</span>'
|
|
12391
12401
|
: '<span style="color:var(--text-muted)">Not connected</span>';
|
|
12392
12402
|
}
|
|
12393
12403
|
return d;
|
|
@@ -12397,6 +12407,23 @@ async function checkAnthropicAuth() {
|
|
|
12397
12407
|
async function startAnthropicOAuth() {
|
|
12398
12408
|
var btn = document.getElementById('gs-auth-btn');
|
|
12399
12409
|
var desc = document.getElementById('gs-auth-desc');
|
|
12410
|
+
// Precheck: if the user is already authenticated (env var or keychain),
|
|
12411
|
+
// don't trigger the SDK OAuth flow — which has an upstream bug where
|
|
12412
|
+
// claudeAuthenticate sends a malformed Messages API request with
|
|
12413
|
+
// cache_control on an empty text block (HTTP 400).
|
|
12414
|
+
try {
|
|
12415
|
+
var statusResp = await apiFetch('/api/auth/anthropic/status');
|
|
12416
|
+
var statusData = await statusResp.json();
|
|
12417
|
+
if (statusData && statusData.authenticated) {
|
|
12418
|
+
var card = document.getElementById('gs-step-auth');
|
|
12419
|
+
if (card) card.className = 'gs-card gs-done';
|
|
12420
|
+
if (desc) desc.textContent = statusData.email
|
|
12421
|
+
? 'Connected as ' + statusData.email
|
|
12422
|
+
: 'Already connected via ' + (statusData.apiKeySource || 'API') + ' — no login needed';
|
|
12423
|
+
if (btn) { btn.textContent = 'Connected'; btn.disabled = true; }
|
|
12424
|
+
return;
|
|
12425
|
+
}
|
|
12426
|
+
} catch (_) { /* fall through to real login */ }
|
|
12400
12427
|
if (btn) { btn.textContent = 'Opening login...'; btn.disabled = true; }
|
|
12401
12428
|
try {
|
|
12402
12429
|
var r = await apiFetch('/api/auth/anthropic/login', { method: 'POST' });
|
|
@@ -12417,7 +12444,15 @@ async function startAnthropicOAuth() {
|
|
|
12417
12444
|
throw new Error(wd.error || 'Login did not complete');
|
|
12418
12445
|
}
|
|
12419
12446
|
} catch (err) {
|
|
12420
|
-
|
|
12447
|
+
// Special case: the SDK's claudeAuthenticate has a known bug where it
|
|
12448
|
+
// sends malformed API payloads ("cache_control cannot be set for empty
|
|
12449
|
+
// text blocks"). If that fires, point the user at the CLI login path.
|
|
12450
|
+
var msg = err && err.message ? err.message : String(err);
|
|
12451
|
+
if (/cache_control.+empty text blocks/i.test(msg)) {
|
|
12452
|
+
if (desc) desc.textContent = 'In-browser login hit a known SDK bug. Run "clementine login" in your terminal instead, or check if you are already logged in — the daemon only needs to start once.';
|
|
12453
|
+
} else {
|
|
12454
|
+
if (desc) desc.textContent = 'Login failed: ' + msg;
|
|
12455
|
+
}
|
|
12421
12456
|
if (btn) { btn.textContent = 'Retry Login'; btn.disabled = false; }
|
|
12422
12457
|
}
|
|
12423
12458
|
}
|
|
@@ -159,6 +159,7 @@ export declare class CronScheduler {
|
|
|
159
159
|
* Creates the causal link: "action X was attempted for goal Y, result was Z."
|
|
160
160
|
*/
|
|
161
161
|
private logGoalOutcome;
|
|
162
|
+
private logProactiveDecisionOutcome;
|
|
162
163
|
/**
|
|
163
164
|
* Apply non-destructive cron changes suggested by the daily planner.
|
|
164
165
|
* Only auto-applies suggestions that reference a high-priority goal with autoSchedule=true.
|
|
@@ -18,6 +18,7 @@ import { scanner } from '../security/scanner.js';
|
|
|
18
18
|
import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
|
|
19
19
|
import { SelfImproveLoop } from '../agent/self-improve.js';
|
|
20
20
|
import { logAuditJsonl } from '../agent/hooks.js';
|
|
21
|
+
import { outcomeStatusFromGoalDisposition, recentDecisions, recordDecisionOutcome, } from '../agent/proactive-ledger.js';
|
|
21
22
|
const logger = pino({ name: 'clementine.cron' });
|
|
22
23
|
/** Default timeout for standard cron jobs (10 minutes). */
|
|
23
24
|
const CRON_STANDARD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -1643,7 +1644,7 @@ export class CronScheduler {
|
|
|
1643
1644
|
const advice = getExecutionAdvice(jobName, syntheticJob);
|
|
1644
1645
|
if (advice.shouldSkip) {
|
|
1645
1646
|
logger.info({ goalId: trigger.goalId, reason: advice.skipReason }, 'Goal work skipped by advisor (circuit breaker)');
|
|
1646
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, `Skipped: ${advice.skipReason}
|
|
1647
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, `Skipped: ${advice.skipReason}`, trigger.decision);
|
|
1647
1648
|
return;
|
|
1648
1649
|
}
|
|
1649
1650
|
const effectiveMaxTurns = advice.adjustedMaxTurns ?? syntheticJob.maxTurns ?? 15;
|
|
@@ -1667,10 +1668,10 @@ export class CronScheduler {
|
|
|
1667
1668
|
this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
|
|
1668
1669
|
}
|
|
1669
1670
|
logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1670
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
|
|
1671
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', result, undefined, trigger.decision);
|
|
1671
1672
|
}).catch((err) => {
|
|
1672
1673
|
logger.error({ err, goalId: trigger.goalId }, 'Goal work session failed');
|
|
1673
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, String(err));
|
|
1674
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, String(err), trigger.decision);
|
|
1674
1675
|
});
|
|
1675
1676
|
}).catch((err) => {
|
|
1676
1677
|
// Advisor import failed — fall back to basic execution
|
|
@@ -1686,10 +1687,10 @@ export class CronScheduler {
|
|
|
1686
1687
|
this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
|
|
1687
1688
|
}
|
|
1688
1689
|
logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1689
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
|
|
1690
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', result, undefined, trigger.decision);
|
|
1690
1691
|
}).catch((goalErr) => {
|
|
1691
1692
|
logger.error({ err: goalErr, goalId: trigger.goalId }, 'Goal work session failed');
|
|
1692
|
-
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, String(goalErr));
|
|
1693
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, String(goalErr), trigger.decision);
|
|
1693
1694
|
});
|
|
1694
1695
|
});
|
|
1695
1696
|
}
|
|
@@ -1706,7 +1707,7 @@ export class CronScheduler {
|
|
|
1706
1707
|
* Log goal work session outcome to a per-goal progress JSONL file.
|
|
1707
1708
|
* Creates the causal link: "action X was attempted for goal Y, result was Z."
|
|
1708
1709
|
*/
|
|
1709
|
-
logGoalOutcome(goalId, goalPath, prevUpdatedAt, prevNotesCount, focus, source, result, error) {
|
|
1710
|
+
logGoalOutcome(goalId, goalPath, prevUpdatedAt, prevNotesCount, focus, source, result, error, proactiveDecision) {
|
|
1710
1711
|
try {
|
|
1711
1712
|
let madeProgress = false;
|
|
1712
1713
|
let newNotesCount = prevNotesCount;
|
|
@@ -1738,12 +1739,46 @@ export class CronScheduler {
|
|
|
1738
1739
|
mkdirSync(progressDir, { recursive: true });
|
|
1739
1740
|
const progressFile = path.join(progressDir, `${goalId}.progress.jsonl`);
|
|
1740
1741
|
appendFileSync(progressFile, JSON.stringify(entry) + '\n');
|
|
1742
|
+
if (proactiveDecision) {
|
|
1743
|
+
this.logProactiveDecisionOutcome(proactiveDecision, {
|
|
1744
|
+
goalId,
|
|
1745
|
+
focus,
|
|
1746
|
+
source,
|
|
1747
|
+
disposition,
|
|
1748
|
+
madeProgress,
|
|
1749
|
+
resultSnippet: entry.resultSnippet,
|
|
1750
|
+
newProgressNotes: entry.newProgressNotes,
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1741
1753
|
logger.info({ goalId, madeProgress, disposition, status: entry.status }, 'Goal outcome logged');
|
|
1742
1754
|
}
|
|
1743
1755
|
catch (err) {
|
|
1744
1756
|
logger.debug({ err, goalId }, 'Failed to log goal outcome (non-fatal)');
|
|
1745
1757
|
}
|
|
1746
1758
|
}
|
|
1759
|
+
logProactiveDecisionOutcome(decision, details) {
|
|
1760
|
+
try {
|
|
1761
|
+
const existing = recentDecisions({ idempotencyKey: decision.idempotencyKey }, undefined)[0];
|
|
1762
|
+
const decisionId = existing?.id ?? decision.idempotencyKey;
|
|
1763
|
+
recordDecisionOutcome(decisionId, decision, {
|
|
1764
|
+
signalType: 'goal-work-outcome',
|
|
1765
|
+
description: details.focus,
|
|
1766
|
+
goalId: details.goalId,
|
|
1767
|
+
metadata: {
|
|
1768
|
+
source: details.source,
|
|
1769
|
+
disposition: details.disposition,
|
|
1770
|
+
madeProgress: details.madeProgress,
|
|
1771
|
+
newProgressNotes: details.newProgressNotes,
|
|
1772
|
+
},
|
|
1773
|
+
}, {
|
|
1774
|
+
status: outcomeStatusFromGoalDisposition(details.disposition),
|
|
1775
|
+
summary: details.resultSnippet || details.disposition,
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
catch (err) {
|
|
1779
|
+
logger.debug({ err, goalId: details.goalId }, 'Failed to log proactive decision outcome (non-fatal)');
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1747
1782
|
/**
|
|
1748
1783
|
* Apply non-destructive cron changes suggested by the daily planner.
|
|
1749
1784
|
* Only auto-applies suggestions that reference a high-priority goal with autoSchedule=true.
|
|
@@ -93,10 +93,12 @@ export declare class HeartbeatScheduler {
|
|
|
93
93
|
private claimNextItem;
|
|
94
94
|
private completeItem;
|
|
95
95
|
private failItem;
|
|
96
|
+
private recordWorkItemOutcome;
|
|
96
97
|
static enqueueWork(opts: {
|
|
97
98
|
description: string;
|
|
98
99
|
prompt: string;
|
|
99
100
|
source: string;
|
|
101
|
+
idempotencyKey?: string;
|
|
100
102
|
priority?: 'high' | 'normal';
|
|
101
103
|
maxTurns?: number;
|
|
102
104
|
tier?: number;
|
|
@@ -12,8 +12,11 @@ import pino from 'pino';
|
|
|
12
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
13
|
import { 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,18 +436,13 @@ 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' ||
|
|
399
|
-
continue;
|
|
400
|
-
// Skip if already queued
|
|
401
|
-
if (pendingDescriptions.has(priority.action))
|
|
442
|
+
const decision = decideDailyPlanPriority({ priority, date: todayPlan.date });
|
|
443
|
+
if (priority.type !== 'goal' || !decisionShouldQueueHeartbeatWork(decision))
|
|
402
444
|
continue;
|
|
403
|
-
|
|
404
|
-
if (/\?|decide|choose|approve|confirm|review with|ask\s/i.test(priority.action))
|
|
445
|
+
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
405
446
|
continue;
|
|
406
447
|
HeartbeatScheduler.enqueueWork({
|
|
407
448
|
description: priority.action,
|
|
@@ -409,10 +450,17 @@ export class HeartbeatScheduler {
|
|
|
409
450
|
`Use goal_work to make progress. If you need information from the owner to proceed, ` +
|
|
410
451
|
`note the blocker and move on.`,
|
|
411
452
|
source: `daily-plan:${priority.id}`,
|
|
453
|
+
idempotencyKey: decision.idempotencyKey,
|
|
412
454
|
priority: 'high',
|
|
413
455
|
maxTurns: 10,
|
|
414
456
|
tier: 1,
|
|
415
457
|
});
|
|
458
|
+
recordDecision(decision, {
|
|
459
|
+
signalType: 'daily-plan-priority',
|
|
460
|
+
description: priority.action,
|
|
461
|
+
goalId: priority.id,
|
|
462
|
+
metadata: { planDate: todayPlan.date, type: priority.type },
|
|
463
|
+
});
|
|
416
464
|
logger.info({ goalId: priority.id, action: priority.action }, 'Auto-queued goal work from daily plan');
|
|
417
465
|
}
|
|
418
466
|
}
|
|
@@ -432,10 +480,12 @@ export class HeartbeatScheduler {
|
|
|
432
480
|
this.completeItem(item.id, result || 'completed');
|
|
433
481
|
completedWork.push({ description: item.description, result: result || 'completed' });
|
|
434
482
|
logToDailyNote(`**Heartbeat work: ${item.description}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
483
|
+
this.recordWorkItemOutcome(item, 'advanced', result || 'completed');
|
|
435
484
|
}
|
|
436
485
|
catch (err) {
|
|
437
486
|
this.failItem(item.id, String(err));
|
|
438
487
|
logger.warn({ err, id: item.id }, 'Heartbeat work item failed');
|
|
488
|
+
this.recordWorkItemOutcome(item, 'failed', String(err));
|
|
439
489
|
}
|
|
440
490
|
}
|
|
441
491
|
// ── Decide whether to invoke the LLM ──
|
|
@@ -907,7 +957,8 @@ export class HeartbeatScheduler {
|
|
|
907
957
|
const activeGoals = allGoals.filter((g) => g && g.status === 'active');
|
|
908
958
|
if (activeGoals.length === 0)
|
|
909
959
|
return null;
|
|
910
|
-
const
|
|
960
|
+
const nowDate = new Date();
|
|
961
|
+
const now = nowDate.getTime();
|
|
911
962
|
const DAY_MS = 86_400_000;
|
|
912
963
|
const lines = activeGoals.map((g) => {
|
|
913
964
|
const nextAct = g.nextActions?.length > 0 ? ` | Next: ${g.nextActions[0]}` : '';
|
|
@@ -998,8 +1049,8 @@ export class HeartbeatScheduler {
|
|
|
998
1049
|
const allGoals = preloadedGoals ?? HeartbeatScheduler.loadAllGoals();
|
|
999
1050
|
if (allGoals.length === 0)
|
|
1000
1051
|
return;
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1052
|
+
const nowDate = new Date();
|
|
1053
|
+
const now = nowDate.getTime();
|
|
1003
1054
|
// Load recent goal outcomes for disposition-based throttling.
|
|
1004
1055
|
// The agent classifies each outcome (ADVANCED, BLOCKED_ON_USER, etc.)
|
|
1005
1056
|
// and we use that to decide when/whether to retry.
|
|
@@ -1059,47 +1110,20 @@ export class HeartbeatScheduler {
|
|
|
1059
1110
|
}
|
|
1060
1111
|
}
|
|
1061
1112
|
catch { /* non-fatal */ }
|
|
1062
|
-
// Score ALL active goals
|
|
1063
|
-
//
|
|
1113
|
+
// Score ALL active goals through the proactive decision engine. Stale
|
|
1114
|
+
// goals get urgency, but current high-priority goals with pending work
|
|
1115
|
+
// also qualify.
|
|
1064
1116
|
const scoredGoals = [];
|
|
1065
1117
|
for (const goal of allGoals) {
|
|
1066
1118
|
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
|
|
1119
|
+
const advancement = decideGoalAdvancement({
|
|
1120
|
+
goal,
|
|
1121
|
+
now: nowDate,
|
|
1122
|
+
inCooldown: goalCooldowns.has(goal.id),
|
|
1123
|
+
});
|
|
1124
|
+
if (advancement && decisionShouldCreateGoalTrigger(advancement.decision)) {
|
|
1125
|
+
scoredGoals.push(advancement);
|
|
1094
1126
|
}
|
|
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
1127
|
}
|
|
1104
1128
|
catch {
|
|
1105
1129
|
continue;
|
|
@@ -1110,10 +1134,9 @@ export class HeartbeatScheduler {
|
|
|
1110
1134
|
// Sort by score descending, take top 2
|
|
1111
1135
|
scoredGoals.sort((a, b) => b.score - a.score);
|
|
1112
1136
|
const toAdvance = scoredGoals.slice(0, 2);
|
|
1113
|
-
for (const { goal, reason } of toAdvance) {
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
: `Review and update progress on "${goal.title}"`;
|
|
1137
|
+
for (const { goal, reason, focus, decision, score } of toAdvance) {
|
|
1138
|
+
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
1139
|
+
continue;
|
|
1117
1140
|
const trigger = {
|
|
1118
1141
|
goalId: goal.id,
|
|
1119
1142
|
focus,
|
|
@@ -1121,10 +1144,18 @@ export class HeartbeatScheduler {
|
|
|
1121
1144
|
triggeredAt: new Date().toISOString(),
|
|
1122
1145
|
source: 'heartbeat-advance',
|
|
1123
1146
|
reason,
|
|
1147
|
+
decision,
|
|
1124
1148
|
};
|
|
1125
|
-
const triggerPath = path.join(goalTriggerDir, `${
|
|
1149
|
+
const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
|
|
1126
1150
|
writeFileSync(triggerPath, JSON.stringify(trigger, null, 2));
|
|
1127
|
-
|
|
1151
|
+
recordDecision(decision, {
|
|
1152
|
+
signalType: 'goal-advancement',
|
|
1153
|
+
description: focus,
|
|
1154
|
+
goalId: goal.id,
|
|
1155
|
+
owner: goal.owner,
|
|
1156
|
+
metadata: { score, reason, title: goal.title },
|
|
1157
|
+
});
|
|
1158
|
+
logger.info({ goalId: goal.id, title: goal.title, score, reason, focus, decision: decision.action }, 'Advancing goal via trigger');
|
|
1128
1159
|
}
|
|
1129
1160
|
// Note: task generation removed — the main goal trigger already includes
|
|
1130
1161
|
// nextActions[0] as focus, so a separate task-gen trigger was redundant
|
|
@@ -1158,6 +1189,38 @@ export class HeartbeatScheduler {
|
|
|
1158
1189
|
try {
|
|
1159
1190
|
const content = readFileSync(filePath, 'utf-8');
|
|
1160
1191
|
const title = file.replace(/\.md$/, '');
|
|
1192
|
+
const decision = decideDiscoveredWorkItem({
|
|
1193
|
+
type: 'inbox',
|
|
1194
|
+
id: title,
|
|
1195
|
+
description: `Triage inbox item: ${title}`,
|
|
1196
|
+
urgency: 2,
|
|
1197
|
+
source: 'inbox',
|
|
1198
|
+
});
|
|
1199
|
+
if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
|
|
1200
|
+
continue;
|
|
1201
|
+
if (decision.action !== 'act_now') {
|
|
1202
|
+
HeartbeatScheduler.enqueueWork({
|
|
1203
|
+
description: `Triage inbox item: ${title}`,
|
|
1204
|
+
prompt: `Triage this inbox item later: ${title}`,
|
|
1205
|
+
source: `inbox:${title}`,
|
|
1206
|
+
idempotencyKey: decision.idempotencyKey,
|
|
1207
|
+
priority: 'normal',
|
|
1208
|
+
maxTurns: 5,
|
|
1209
|
+
tier: 1,
|
|
1210
|
+
});
|
|
1211
|
+
recordDecision(decision, {
|
|
1212
|
+
signalType: 'inbox-triage',
|
|
1213
|
+
description: `Triage inbox item: ${title}`,
|
|
1214
|
+
metadata: { file },
|
|
1215
|
+
});
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
const decisionContext = {
|
|
1219
|
+
signalType: 'inbox-triage',
|
|
1220
|
+
description: `Triage inbox item: ${title}`,
|
|
1221
|
+
metadata: { file },
|
|
1222
|
+
};
|
|
1223
|
+
const decisionRecord = recordDecision(decision, decisionContext);
|
|
1161
1224
|
// Move file before processing to prevent duplicate triage on next tick
|
|
1162
1225
|
const destPath = path.join(processedDir, file);
|
|
1163
1226
|
try {
|
|
@@ -1188,6 +1251,15 @@ export class HeartbeatScheduler {
|
|
|
1188
1251
|
logToDailyNote(`**Inbox processed: ${title}** — ${result.slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1189
1252
|
}
|
|
1190
1253
|
logger.info({ file: title }, 'Inbox item processed');
|
|
1254
|
+
try {
|
|
1255
|
+
recordDecisionOutcome(decisionRecord.id, decision, decisionContext, {
|
|
1256
|
+
status: 'advanced',
|
|
1257
|
+
summary: (result ?? 'processed').slice(0, 200),
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
catch (err) {
|
|
1261
|
+
logger.debug({ err, file: title }, 'Failed to record inbox outcome (non-fatal)');
|
|
1262
|
+
}
|
|
1191
1263
|
})
|
|
1192
1264
|
.catch((err) => {
|
|
1193
1265
|
// Restore file to inbox on failure so it retries
|
|
@@ -1197,6 +1269,15 @@ export class HeartbeatScheduler {
|
|
|
1197
1269
|
}
|
|
1198
1270
|
catch { /* best-effort restore */ }
|
|
1199
1271
|
logger.warn({ err, file: title }, 'Failed to process inbox item');
|
|
1272
|
+
try {
|
|
1273
|
+
recordDecisionOutcome(decisionRecord.id, decision, decisionContext, {
|
|
1274
|
+
status: 'failed',
|
|
1275
|
+
summary: String(err).slice(0, 200),
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
catch (recordErr) {
|
|
1279
|
+
logger.debug({ err: recordErr, file: title }, 'Failed to record inbox outcome (non-fatal)');
|
|
1280
|
+
}
|
|
1200
1281
|
});
|
|
1201
1282
|
}
|
|
1202
1283
|
catch (err) {
|
|
@@ -1349,14 +1430,40 @@ export class HeartbeatScheduler {
|
|
|
1349
1430
|
HeartbeatScheduler.saveWorkQueue(queue);
|
|
1350
1431
|
}
|
|
1351
1432
|
}
|
|
1433
|
+
recordWorkItemOutcome(item, status, summary) {
|
|
1434
|
+
const key = item.idempotencyKey;
|
|
1435
|
+
if (!key)
|
|
1436
|
+
return;
|
|
1437
|
+
try {
|
|
1438
|
+
const original = recentDecisions({ idempotencyKey: key }, undefined)[0];
|
|
1439
|
+
if (!original)
|
|
1440
|
+
return;
|
|
1441
|
+
recordDecisionOutcome(original.id, original.decision, original.context, {
|
|
1442
|
+
status,
|
|
1443
|
+
summary: summary.slice(0, 200),
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
catch (err) {
|
|
1447
|
+
logger.debug({ err, id: item.id }, 'Failed to record work-item outcome (non-fatal)');
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1352
1450
|
static enqueueWork(opts) {
|
|
1353
1451
|
const queue = HeartbeatScheduler.loadWorkQueue();
|
|
1452
|
+
// Only dedup against in-flight items here. Cross-tick "we already acted on this
|
|
1453
|
+
// signal" lives in the proactive ledger (wasRecentlyDecided); blocking on
|
|
1454
|
+
// 'completed' here would prevent legitimate re-runs of multi-session work.
|
|
1455
|
+
const dedupKey = opts.idempotencyKey ?? opts.source;
|
|
1456
|
+
const existing = queue.find((item) => (item.idempotencyKey ?? item.source) === dedupKey &&
|
|
1457
|
+
(item.status === 'pending' || item.status === 'running'));
|
|
1458
|
+
if (existing)
|
|
1459
|
+
return existing.id;
|
|
1354
1460
|
const id = randomBytes(4).toString('hex');
|
|
1355
1461
|
const item = {
|
|
1356
1462
|
id,
|
|
1357
1463
|
description: opts.description,
|
|
1358
1464
|
prompt: opts.prompt,
|
|
1359
1465
|
source: opts.source,
|
|
1466
|
+
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
1360
1467
|
priority: opts.priority ?? 'normal',
|
|
1361
1468
|
queuedAt: new Date().toISOString(),
|
|
1362
1469
|
maxTurns: opts.maxTurns ?? 3,
|
|
@@ -5,6 +5,15 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { BASE_DIR, HANDOFFS_DIR, INBOX_DIR, TASKS_FILE, logger, textResult, todayStr, listAllGoals, } from './shared.js';
|
|
8
|
+
import { decideDiscoveredWorkItem } from '../agent/proactive-engine.js';
|
|
9
|
+
import { recentDecisions } from '../agent/proactive-ledger.js';
|
|
10
|
+
const ACTION_LABEL = {
|
|
11
|
+
act_now: 'Doing now',
|
|
12
|
+
queue: 'Queued',
|
|
13
|
+
ask_user: 'Needs you',
|
|
14
|
+
snooze: 'Snoozed',
|
|
15
|
+
ignore: 'Skipped',
|
|
16
|
+
};
|
|
8
17
|
function ensureHandoffsDir() {
|
|
9
18
|
if (!existsSync(HANDOFFS_DIR))
|
|
10
19
|
mkdirSync(HANDOFFS_DIR, { recursive: true });
|
|
@@ -78,7 +87,7 @@ export function registerSessionTools(server) {
|
|
|
78
87
|
const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
|
|
79
88
|
if (daysSinceUpdate > staleThreshold) {
|
|
80
89
|
const urgency = Math.min(5, Math.floor(daysSinceUpdate / staleThreshold) + (goal.priority === 'high' ? 2 : goal.priority === 'medium' ? 1 : 0));
|
|
81
|
-
items.push({ type: 'stale-goal', urgency, description: `Goal "${goal.title}" stale for ${daysSinceUpdate}d (${goal.priority} priority)` });
|
|
90
|
+
items.push({ type: 'stale-goal', id: goal.id, urgency, description: `Goal "${goal.title}" stale for ${daysSinceUpdate}d (${goal.priority} priority)` });
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
93
|
// 2. Failing cron jobs
|
|
@@ -92,7 +101,7 @@ export function registerSessionTools(server) {
|
|
|
92
101
|
const errCount = consecutiveErrors === -1 ? recent.length : consecutiveErrors;
|
|
93
102
|
if (errCount >= 2) {
|
|
94
103
|
const jobName = recent[0]?.jobName ?? f.replace('.jsonl', '');
|
|
95
|
-
items.push({ type: 'cron-failure', urgency: Math.min(5, errCount), description: `Job "${jobName}" has ${errCount} consecutive failures` });
|
|
104
|
+
items.push({ type: 'cron-failure', id: jobName, urgency: Math.min(5, errCount), description: `Job "${jobName}" has ${errCount} consecutive failures` });
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
catch {
|
|
@@ -107,13 +116,13 @@ export function registerSessionTools(server) {
|
|
|
107
116
|
const overdue = (content.match(/- \[ \].*?📅\s*(\d{4}-\d{2}-\d{2})/g) ?? [])
|
|
108
117
|
.filter(line => { const m = line.match(/📅\s*(\d{4}-\d{2}-\d{2})/); return m && m[1] < today; });
|
|
109
118
|
if (overdue.length > 0)
|
|
110
|
-
items.push({ type: 'overdue-tasks', urgency: 4, description: `${overdue.length} overdue task(s) in TASKS.md` });
|
|
119
|
+
items.push({ type: 'overdue-tasks', id: 'tasks-overdue', urgency: 4, description: `${overdue.length} overdue task(s) in TASKS.md` });
|
|
111
120
|
}
|
|
112
121
|
// 4. Inbox items
|
|
113
122
|
if (existsSync(INBOX_DIR)) {
|
|
114
123
|
const inboxFiles = readdirSync(INBOX_DIR).filter(f => f.endsWith('.md') && !f.startsWith('_'));
|
|
115
124
|
if (inboxFiles.length > 0)
|
|
116
|
-
items.push({ type: 'inbox', urgency: 2, description: `${inboxFiles.length} unprocessed inbox item(s)` });
|
|
125
|
+
items.push({ type: 'inbox', id: 'inbox', urgency: 2, description: `${inboxFiles.length} unprocessed inbox item(s)` });
|
|
117
126
|
}
|
|
118
127
|
// 5. Daily plan priorities
|
|
119
128
|
const planFile = path.join(BASE_DIR, 'plans', 'daily', `${todayStr()}.json`);
|
|
@@ -123,7 +132,7 @@ export function registerSessionTools(server) {
|
|
|
123
132
|
for (const p of (plan.priorities ?? []).slice(0, 5)) {
|
|
124
133
|
const alreadyListed = items.some(i => i.description.includes(p.id) || i.description.includes(p.action?.slice(0, 20)));
|
|
125
134
|
if (!alreadyListed)
|
|
126
|
-
items.push({ type: `plan-${p.type}`, urgency: p.urgency ?? 3, description: `[From daily plan] ${p.action}` });
|
|
135
|
+
items.push({ type: `plan-${p.type}`, id: p.id, urgency: p.urgency ?? 3, description: `[From daily plan] ${p.action}` });
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
138
|
catch { /* non-fatal */ }
|
|
@@ -132,8 +141,51 @@ export function registerSessionTools(server) {
|
|
|
132
141
|
const topItems = items.slice(0, maxItems);
|
|
133
142
|
if (topItems.length === 0)
|
|
134
143
|
return textResult('No work items discovered. All goals on track, no failures, inbox clear.');
|
|
135
|
-
const lines = topItems.map(i =>
|
|
144
|
+
const lines = topItems.map((i) => {
|
|
145
|
+
const decision = decideDiscoveredWorkItem(i);
|
|
146
|
+
const label = ACTION_LABEL[decision.action];
|
|
147
|
+
return `- [${i.type}] Urgency ${i.urgency}/5 → ${label}: ${i.description}`;
|
|
148
|
+
});
|
|
136
149
|
return textResult(`## Discovered Work Items (${topItems.length})\n${lines.join('\n')}`);
|
|
137
150
|
});
|
|
151
|
+
server.tool('proactive_stats', 'Report how the proactive engine has been deciding and what those decisions led to. Use to tune urgency thresholds.', {
|
|
152
|
+
since_hours: z.number().optional().describe('Window in hours (default: 168 = 7 days)'),
|
|
153
|
+
}, async ({ since_hours }) => {
|
|
154
|
+
const windowMs = (since_hours ?? 168) * 60 * 60 * 1000;
|
|
155
|
+
const records = recentDecisions({ sinceMs: windowMs }, undefined);
|
|
156
|
+
if (records.length === 0)
|
|
157
|
+
return textResult('No proactive decisions recorded in window.');
|
|
158
|
+
const bySource = new Map();
|
|
159
|
+
const outcomeByKey = new Map();
|
|
160
|
+
for (const r of records) {
|
|
161
|
+
if (r.outcome)
|
|
162
|
+
outcomeByKey.set(r.decision.idempotencyKey, r.outcome.status);
|
|
163
|
+
}
|
|
164
|
+
for (const r of records) {
|
|
165
|
+
if (r.outcome)
|
|
166
|
+
continue;
|
|
167
|
+
const sourceMap = bySource.get(r.decision.source) ?? new Map();
|
|
168
|
+
const bucket = sourceMap.get(r.decision.action) ?? { decided: 0, outcomes: {} };
|
|
169
|
+
bucket.decided++;
|
|
170
|
+
const outcome = outcomeByKey.get(r.decision.idempotencyKey);
|
|
171
|
+
if (outcome)
|
|
172
|
+
bucket.outcomes[outcome] = (bucket.outcomes[outcome] ?? 0) + 1;
|
|
173
|
+
sourceMap.set(r.decision.action, bucket);
|
|
174
|
+
bySource.set(r.decision.source, sourceMap);
|
|
175
|
+
}
|
|
176
|
+
const lines = [`## Proactive Stats (last ${since_hours ?? 168}h, ${records.length} records)\n`];
|
|
177
|
+
for (const [source, actionMap] of bySource) {
|
|
178
|
+
lines.push(`### ${source}`);
|
|
179
|
+
for (const [action, bucket] of actionMap) {
|
|
180
|
+
const outcomes = Object.entries(bucket.outcomes);
|
|
181
|
+
const outcomeStr = outcomes.length === 0
|
|
182
|
+
? 'no outcomes recorded'
|
|
183
|
+
: outcomes.map(([k, v]) => `${k}=${v}`).join(', ');
|
|
184
|
+
lines.push(`- ${ACTION_LABEL[action]} (${action}): decided ${bucket.decided}× → ${outcomeStr}`);
|
|
185
|
+
}
|
|
186
|
+
lines.push('');
|
|
187
|
+
}
|
|
188
|
+
return textResult(lines.join('\n'));
|
|
189
|
+
});
|
|
138
190
|
}
|
|
139
191
|
//# sourceMappingURL=session-tools.js.map
|
package/dist/types.d.ts
CHANGED