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
|
@@ -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
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the lifecycle of all per-agent heartbeat schedulers.
|
|
3
|
+
*
|
|
4
|
+
* Boots at daemon start, scans the AgentManager's active list, spawns one
|
|
5
|
+
* AgentHeartbeatScheduler per agent. An outer 60s interval iterates the
|
|
6
|
+
* registry and fires `tick()` on any agent whose nextCheckAt is due.
|
|
7
|
+
*
|
|
8
|
+
* Reconciliation runs each outer tick: agents added to AGENTS_DIR start
|
|
9
|
+
* heartbeats automatically; agents removed (or paused/terminated) drop
|
|
10
|
+
* out. Per-agent failures are caught so one buggy agent can't crash the
|
|
11
|
+
* daemon or stall others.
|
|
12
|
+
*/
|
|
13
|
+
import type { AgentManager } from '../agent/agent-manager.js';
|
|
14
|
+
import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
|
|
15
|
+
export declare class AgentHeartbeatManager {
|
|
16
|
+
private readonly agentManager;
|
|
17
|
+
private readonly schedulers;
|
|
18
|
+
private timer;
|
|
19
|
+
private running;
|
|
20
|
+
private ticking;
|
|
21
|
+
constructor(agentManager: AgentManager);
|
|
22
|
+
start(): void;
|
|
23
|
+
stop(): void;
|
|
24
|
+
/** Add/remove schedulers to match the current AgentManager listing. */
|
|
25
|
+
private reconcile;
|
|
26
|
+
/**
|
|
27
|
+
* One outer-loop tick. Reconcile the registry, then fire agents whose
|
|
28
|
+
* nextCheckAt has come due. Runs serially to avoid races on shared
|
|
29
|
+
* state (goals dir, cron runs dir).
|
|
30
|
+
*/
|
|
31
|
+
private outerTick;
|
|
32
|
+
/** Diagnostic helper for the dashboard / CLI. */
|
|
33
|
+
getStatus(): Array<{
|
|
34
|
+
slug: string;
|
|
35
|
+
nextCheckAt: string;
|
|
36
|
+
lastTickAt: string;
|
|
37
|
+
silentTickCount: number;
|
|
38
|
+
}>;
|
|
39
|
+
/** Look up a scheduler — useful for CLI commands like "tick this agent now." */
|
|
40
|
+
getScheduler(slug: string): AgentHeartbeatScheduler | null;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=agent-heartbeat-manager.d.ts.map
|