clementine-agent 1.0.87 → 1.0.89
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/README.md +13 -0
- package/dist/agent/decision-reflection.d.ts +65 -0
- package/dist/agent/decision-reflection.js +240 -0
- package/dist/agent/webhook-actions.d.ts +116 -0
- package/dist/agent/webhook-actions.js +240 -0
- package/dist/cli/dashboard.js +90 -0
- package/dist/tools/decision-reflection-tools.d.ts +17 -0
- package/dist/tools/decision-reflection-tools.js +83 -0
- package/dist/tools/mcp-server.js +2 -0
- package/package.json +1 -1
- package/vault/00-System/CRON.md +16 -0
package/README.md
CHANGED
|
@@ -585,6 +585,19 @@ The dashboard's "The Office" page shows each agent as an animated desk station w
|
|
|
585
585
|
- Channel assignment, model badge, project badge, tool count
|
|
586
586
|
- Edit and "Let Go" (delete) actions
|
|
587
587
|
|
|
588
|
+
### Decision-loop reflection
|
|
589
|
+
|
|
590
|
+
Each agent's autonomous decisions are recorded to a proactive ledger (action chosen, signal source, eventual outcome). The `decision_reflection` MCP tool reads that ledger, computes per-action success rates, and surfaces calibration patterns:
|
|
591
|
+
|
|
592
|
+
- "act_now success rate is 33% — many autonomous actions did not advance"
|
|
593
|
+
- "Queue-heavy bias: 12 queued vs 2 act_now — engine is being conservative"
|
|
594
|
+
- "Zero ask_user despite active autonomous work"
|
|
595
|
+
- Plus concrete tuning suggestions
|
|
596
|
+
|
|
597
|
+
By default the report is saved to `vault/00-System/agents/<slug>/reflections/<date>.md`. Pass `append_to_memory: true` to also write a compact summary into the agent's `working-memory.md` so the next heartbeat tick reads it as prompt context — that's how agents self-tune without code changes.
|
|
598
|
+
|
|
599
|
+
The shipped `vault/00-System/CRON.md` template includes a `weekly-decision-reflection` job (Sundays 9am) that runs reflection for the daemon and every active specialist.
|
|
600
|
+
|
|
588
601
|
### Per-agent heartbeats
|
|
589
602
|
|
|
590
603
|
Each specialist (Ross / Sasha / your hires) gets their own autonomous heartbeat scheduler alongside Clementine's. The cycle:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Per-agent decision-loop reflection.
|
|
3
|
+
*
|
|
4
|
+
* Reads an agent's slice of the proactive decision ledger and produces
|
|
5
|
+
* a calibration report:
|
|
6
|
+
*
|
|
7
|
+
* - How many decisions per action (act_now / queue / ask_user / snooze / ignore)
|
|
8
|
+
* - Of those, how many had recorded outcomes
|
|
9
|
+
* - Success rate (advanced / withOutcomes) per action
|
|
10
|
+
* - Top signal sources by volume
|
|
11
|
+
* - Plain-English patterns + concrete tuning suggestions
|
|
12
|
+
*
|
|
13
|
+
* The report is meant to land in the agent's working-memory so it
|
|
14
|
+
* shapes their next heartbeat tick — they read their own track record
|
|
15
|
+
* and self-correct without code changes.
|
|
16
|
+
*
|
|
17
|
+
* Pure analysis: no I/O side effects. The MCP tool wrapper handles
|
|
18
|
+
* file writes (history) and working-memory updates separately.
|
|
19
|
+
*/
|
|
20
|
+
import type { ProactiveAction, ProactiveDecision, ProactiveSource } from './proactive-engine.js';
|
|
21
|
+
export interface ActionBucket {
|
|
22
|
+
decided: number;
|
|
23
|
+
withOutcomes: number;
|
|
24
|
+
advanced: number;
|
|
25
|
+
blocked: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
/** advanced / withOutcomes * 100, or null if no outcomes recorded yet. */
|
|
28
|
+
successRatePct: number | null;
|
|
29
|
+
}
|
|
30
|
+
export interface DecisionReflection {
|
|
31
|
+
slug: string;
|
|
32
|
+
windowDays: number;
|
|
33
|
+
totalDecisions: number;
|
|
34
|
+
byAction: Record<ProactiveAction, ActionBucket>;
|
|
35
|
+
topSources: Array<{
|
|
36
|
+
source: ProactiveSource;
|
|
37
|
+
count: number;
|
|
38
|
+
}>;
|
|
39
|
+
patterns: string[];
|
|
40
|
+
suggestions: string[];
|
|
41
|
+
generatedAt: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read the ledger, filter by agent + window, compute the calibration
|
|
45
|
+
* stats. Returns a DecisionReflection ready to format.
|
|
46
|
+
*
|
|
47
|
+
* Agent matching: a record belongs to `slug` when context.owner === slug
|
|
48
|
+
* OR context.goalId resolves to a goal whose owner is slug. For now we
|
|
49
|
+
* match only on context.owner — slug-by-goal is more expensive (would
|
|
50
|
+
* need listAllGoals) and not worth it until owner is consistently set.
|
|
51
|
+
*/
|
|
52
|
+
export declare function analyzeAgentDecisions(slug: string, windowDays?: number, opts?: {
|
|
53
|
+
ledgerPath?: string;
|
|
54
|
+
now?: Date;
|
|
55
|
+
}): DecisionReflection;
|
|
56
|
+
export declare function formatReflectionReport(r: DecisionReflection): string;
|
|
57
|
+
/**
|
|
58
|
+
* Compact summary for working-memory append. Skips the full table and
|
|
59
|
+
* keeps just the patterns + suggestions so the agent's prompt doesn't
|
|
60
|
+
* get bloated with raw stats.
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatReflectionSummary(r: DecisionReflection): string;
|
|
63
|
+
/** Internal type alias used by tests / tool. */
|
|
64
|
+
export type { ProactiveDecision };
|
|
65
|
+
//# sourceMappingURL=decision-reflection.d.ts.map
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Per-agent decision-loop reflection.
|
|
3
|
+
*
|
|
4
|
+
* Reads an agent's slice of the proactive decision ledger and produces
|
|
5
|
+
* a calibration report:
|
|
6
|
+
*
|
|
7
|
+
* - How many decisions per action (act_now / queue / ask_user / snooze / ignore)
|
|
8
|
+
* - Of those, how many had recorded outcomes
|
|
9
|
+
* - Success rate (advanced / withOutcomes) per action
|
|
10
|
+
* - Top signal sources by volume
|
|
11
|
+
* - Plain-English patterns + concrete tuning suggestions
|
|
12
|
+
*
|
|
13
|
+
* The report is meant to land in the agent's working-memory so it
|
|
14
|
+
* shapes their next heartbeat tick — they read their own track record
|
|
15
|
+
* and self-correct without code changes.
|
|
16
|
+
*
|
|
17
|
+
* Pure analysis: no I/O side effects. The MCP tool wrapper handles
|
|
18
|
+
* file writes (history) and working-memory updates separately.
|
|
19
|
+
*/
|
|
20
|
+
import { recentDecisions } from './proactive-ledger.js';
|
|
21
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
22
|
+
const ALL_ACTIONS = ['act_now', 'queue', 'ask_user', 'snooze', 'ignore'];
|
|
23
|
+
const LOW_SUCCESS_THRESHOLD = 50; // < 50% success → flag as miscalibrated
|
|
24
|
+
const HIGH_VOLUME_THRESHOLD = 20; // > 20 decisions in window → "active loop"
|
|
25
|
+
const DORMANT_THRESHOLD = 0; // 0 decisions → "dormant"
|
|
26
|
+
function emptyBucket() {
|
|
27
|
+
return { decided: 0, withOutcomes: 0, advanced: 0, blocked: 0, failed: 0, successRatePct: null };
|
|
28
|
+
}
|
|
29
|
+
function emptyByAction() {
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const a of ALL_ACTIONS)
|
|
32
|
+
out[a] = emptyBucket();
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
// ── Analysis ─────────────────────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* Read the ledger, filter by agent + window, compute the calibration
|
|
38
|
+
* stats. Returns a DecisionReflection ready to format.
|
|
39
|
+
*
|
|
40
|
+
* Agent matching: a record belongs to `slug` when context.owner === slug
|
|
41
|
+
* OR context.goalId resolves to a goal whose owner is slug. For now we
|
|
42
|
+
* match only on context.owner — slug-by-goal is more expensive (would
|
|
43
|
+
* need listAllGoals) and not worth it until owner is consistently set.
|
|
44
|
+
*/
|
|
45
|
+
export function analyzeAgentDecisions(slug, windowDays = 7, opts) {
|
|
46
|
+
const now = opts?.now ?? new Date();
|
|
47
|
+
const sinceMs = windowDays * 24 * 60 * 60 * 1000;
|
|
48
|
+
const records = recentDecisions({ sinceMs }, opts?.ledgerPath ? { filePath: opts.ledgerPath, now } : { now });
|
|
49
|
+
// Filter to records relevant to this agent. Inclusion rule:
|
|
50
|
+
// - context.owner === slug (preferred — explicitly attributed)
|
|
51
|
+
// - else if slug === 'clementine': include records with no owner
|
|
52
|
+
// (the daemon's own decisions default to no-owner)
|
|
53
|
+
const isClementine = slug === 'clementine';
|
|
54
|
+
const mine = records.filter((r) => {
|
|
55
|
+
const owner = r.context.owner;
|
|
56
|
+
if (owner)
|
|
57
|
+
return owner === slug;
|
|
58
|
+
return isClementine; // unowned → Clementine
|
|
59
|
+
});
|
|
60
|
+
// Outcome records share the original decision's id and have an
|
|
61
|
+
// `outcome` field. Index by id so we can pair decisions with their
|
|
62
|
+
// outcomes in one pass.
|
|
63
|
+
const outcomeById = new Map();
|
|
64
|
+
for (const r of mine) {
|
|
65
|
+
if (r.outcome)
|
|
66
|
+
outcomeById.set(r.id, r.outcome.status);
|
|
67
|
+
}
|
|
68
|
+
const byAction = emptyByAction();
|
|
69
|
+
const sourceCounts = new Map();
|
|
70
|
+
let totalDecisions = 0;
|
|
71
|
+
for (const r of mine) {
|
|
72
|
+
if (r.outcome)
|
|
73
|
+
continue; // outcomes are indexed; only count original decisions here
|
|
74
|
+
totalDecisions++;
|
|
75
|
+
const action = r.decision.action;
|
|
76
|
+
const bucket = byAction[action];
|
|
77
|
+
bucket.decided++;
|
|
78
|
+
const outcomeStatus = outcomeById.get(r.id);
|
|
79
|
+
if (outcomeStatus !== undefined) {
|
|
80
|
+
bucket.withOutcomes++;
|
|
81
|
+
if (outcomeStatus === 'advanced')
|
|
82
|
+
bucket.advanced++;
|
|
83
|
+
else if (typeof outcomeStatus === 'string' && outcomeStatus.startsWith('blocked-'))
|
|
84
|
+
bucket.blocked++;
|
|
85
|
+
else if (outcomeStatus === 'failed')
|
|
86
|
+
bucket.failed++;
|
|
87
|
+
}
|
|
88
|
+
const src = r.decision.source;
|
|
89
|
+
sourceCounts.set(src, (sourceCounts.get(src) ?? 0) + 1);
|
|
90
|
+
}
|
|
91
|
+
// Compute success rates
|
|
92
|
+
for (const a of ALL_ACTIONS) {
|
|
93
|
+
const b = byAction[a];
|
|
94
|
+
b.successRatePct = b.withOutcomes > 0 ? Math.round((b.advanced / b.withOutcomes) * 100) : null;
|
|
95
|
+
}
|
|
96
|
+
const topSources = [...sourceCounts.entries()]
|
|
97
|
+
.map(([source, count]) => ({ source, count }))
|
|
98
|
+
.sort((a, b) => b.count - a.count)
|
|
99
|
+
.slice(0, 5);
|
|
100
|
+
const { patterns, suggestions } = derivePatterns({ slug, totalDecisions, byAction, topSources, windowDays });
|
|
101
|
+
return {
|
|
102
|
+
slug,
|
|
103
|
+
windowDays,
|
|
104
|
+
totalDecisions,
|
|
105
|
+
byAction,
|
|
106
|
+
topSources,
|
|
107
|
+
patterns,
|
|
108
|
+
suggestions,
|
|
109
|
+
generatedAt: now.toISOString(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Derive plain-English patterns + tuning suggestions from the stats.
|
|
114
|
+
* Each pattern is a one-line observation; each suggestion is concrete
|
|
115
|
+
* advice the agent (or human) can act on.
|
|
116
|
+
*/
|
|
117
|
+
function derivePatterns(input) {
|
|
118
|
+
const patterns = [];
|
|
119
|
+
const suggestions = [];
|
|
120
|
+
if (input.totalDecisions === DORMANT_THRESHOLD) {
|
|
121
|
+
patterns.push(`No decisions recorded in the last ${input.windowDays} days.`);
|
|
122
|
+
suggestions.push('Agent is dormant. Either no signals are reaching the heartbeat, or every signal is being deduped. Check the proactive ledger directly to confirm.');
|
|
123
|
+
return { patterns, suggestions };
|
|
124
|
+
}
|
|
125
|
+
if (input.totalDecisions >= HIGH_VOLUME_THRESHOLD) {
|
|
126
|
+
patterns.push(`High decision volume: ${input.totalDecisions} decisions in ${input.windowDays} days.`);
|
|
127
|
+
}
|
|
128
|
+
const actNow = input.byAction.act_now;
|
|
129
|
+
if (actNow.withOutcomes >= 3 && (actNow.successRatePct ?? 100) < LOW_SUCCESS_THRESHOLD) {
|
|
130
|
+
patterns.push(`act_now success rate is ${actNow.successRatePct}% (${actNow.advanced}/${actNow.withOutcomes}). Low — many autonomous actions did not advance.`);
|
|
131
|
+
suggestions.push('Raise the urgency threshold for act_now (in proactive-engine.ts decideGoalAdvancement / decideDailyPlanPriority) — currently firing too aggressively. Consider requiring urgency >= 5 instead of >= 4.');
|
|
132
|
+
}
|
|
133
|
+
const queue = input.byAction.queue;
|
|
134
|
+
if (queue.withOutcomes >= 3 && (queue.successRatePct ?? 100) < LOW_SUCCESS_THRESHOLD) {
|
|
135
|
+
patterns.push(`Queued items are not landing: ${queue.advanced}/${queue.withOutcomes} advanced (${queue.successRatePct}%).`);
|
|
136
|
+
suggestions.push('Queued work is going stale. Review the work-queue dwell time — items may be timing out before the heartbeat runs them.');
|
|
137
|
+
}
|
|
138
|
+
if (queue.decided > actNow.decided * 3 && actNow.decided > 0) {
|
|
139
|
+
patterns.push(`Queue-heavy bias: ${queue.decided} queued vs ${actNow.decided} act_now. The engine is being conservative.`);
|
|
140
|
+
suggestions.push('If most queued items eventually advance manually, consider lowering the queue→act_now threshold or expanding act_now eligibility.');
|
|
141
|
+
}
|
|
142
|
+
const blockedTotal = ALL_ACTIONS.reduce((sum, a) => sum + input.byAction[a].blocked, 0);
|
|
143
|
+
if (blockedTotal >= 3) {
|
|
144
|
+
patterns.push(`${blockedTotal} decisions ended blocked (waiting on user/external).`);
|
|
145
|
+
suggestions.push('Frequent blocking suggests the agent is hitting questions only the owner can answer. Review whether earlier ask_user prompts would clear the blockage faster.');
|
|
146
|
+
}
|
|
147
|
+
const askUserCount = input.byAction.ask_user.decided;
|
|
148
|
+
if (askUserCount === 0 && actNow.decided + queue.decided >= 5) {
|
|
149
|
+
patterns.push('Zero ask_user decisions despite active autonomous work.');
|
|
150
|
+
suggestions.push('Agent never asked for owner input over the window. Either everything is unambiguously autonomous (good) or the agent is over-deciding without clarity (suspect when blocked outcomes are non-trivial).');
|
|
151
|
+
}
|
|
152
|
+
const failedTotal = ALL_ACTIONS.reduce((sum, a) => sum + input.byAction[a].failed, 0);
|
|
153
|
+
if (failedTotal >= 3) {
|
|
154
|
+
patterns.push(`${failedTotal} decisions ended in failed outcomes.`);
|
|
155
|
+
suggestions.push('Multiple failures — check cron logs for the failing job names. May indicate a tool that needs maintenance, a budget cap being hit, or a misconfigured trigger.');
|
|
156
|
+
}
|
|
157
|
+
if (patterns.length === 0) {
|
|
158
|
+
patterns.push('No notable patterns detected — calibration looks healthy for this window.');
|
|
159
|
+
}
|
|
160
|
+
return { patterns, suggestions };
|
|
161
|
+
}
|
|
162
|
+
// ── Markdown formatter ───────────────────────────────────────────────
|
|
163
|
+
const ACTION_LABELS = {
|
|
164
|
+
act_now: 'Doing now',
|
|
165
|
+
queue: 'Queued',
|
|
166
|
+
ask_user: 'Needs you',
|
|
167
|
+
snooze: 'Snoozed',
|
|
168
|
+
ignore: 'Skipped',
|
|
169
|
+
};
|
|
170
|
+
export function formatReflectionReport(r) {
|
|
171
|
+
const lines = [];
|
|
172
|
+
lines.push(`# Decision reflection — ${r.slug}`);
|
|
173
|
+
lines.push('');
|
|
174
|
+
lines.push(`Generated: ${r.generatedAt} `);
|
|
175
|
+
lines.push(`Window: last ${r.windowDays} day(s) `);
|
|
176
|
+
lines.push(`Total decisions: **${r.totalDecisions}**`);
|
|
177
|
+
lines.push('');
|
|
178
|
+
if (r.totalDecisions === 0) {
|
|
179
|
+
lines.push('## No decisions in window');
|
|
180
|
+
lines.push('');
|
|
181
|
+
for (const p of r.patterns)
|
|
182
|
+
lines.push(`- ${p}`);
|
|
183
|
+
if (r.suggestions.length > 0) {
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push('### Suggestions');
|
|
186
|
+
for (const s of r.suggestions)
|
|
187
|
+
lines.push(`- ${s}`);
|
|
188
|
+
}
|
|
189
|
+
return lines.join('\n');
|
|
190
|
+
}
|
|
191
|
+
lines.push('## By action');
|
|
192
|
+
lines.push('');
|
|
193
|
+
lines.push('| Action | Decided | Outcomes | Advanced | Blocked | Failed | Success |');
|
|
194
|
+
lines.push('|---|---:|---:|---:|---:|---:|---:|');
|
|
195
|
+
for (const a of ALL_ACTIONS) {
|
|
196
|
+
const b = r.byAction[a];
|
|
197
|
+
if (b.decided === 0)
|
|
198
|
+
continue;
|
|
199
|
+
const pct = b.successRatePct === null ? '—' : `${b.successRatePct}%`;
|
|
200
|
+
lines.push(`| ${ACTION_LABELS[a]} (${a}) | ${b.decided} | ${b.withOutcomes} | ${b.advanced} | ${b.blocked} | ${b.failed} | ${pct} |`);
|
|
201
|
+
}
|
|
202
|
+
if (r.topSources.length > 0) {
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push('## Top sources');
|
|
205
|
+
for (const s of r.topSources) {
|
|
206
|
+
lines.push(`- **${s.source}**: ${s.count}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
lines.push('');
|
|
210
|
+
lines.push('## Patterns');
|
|
211
|
+
for (const p of r.patterns)
|
|
212
|
+
lines.push(`- ${p}`);
|
|
213
|
+
if (r.suggestions.length > 0) {
|
|
214
|
+
lines.push('');
|
|
215
|
+
lines.push('## Suggestions');
|
|
216
|
+
for (const s of r.suggestions)
|
|
217
|
+
lines.push(`- ${s}`);
|
|
218
|
+
}
|
|
219
|
+
return lines.join('\n');
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Compact summary for working-memory append. Skips the full table and
|
|
223
|
+
* keeps just the patterns + suggestions so the agent's prompt doesn't
|
|
224
|
+
* get bloated with raw stats.
|
|
225
|
+
*/
|
|
226
|
+
export function formatReflectionSummary(r) {
|
|
227
|
+
const lines = [];
|
|
228
|
+
lines.push(`### Self-reflection (${r.generatedAt.slice(0, 10)}, last ${r.windowDays}d, ${r.totalDecisions} decisions)`);
|
|
229
|
+
lines.push('');
|
|
230
|
+
for (const p of r.patterns)
|
|
231
|
+
lines.push(`- ${p}`);
|
|
232
|
+
if (r.suggestions.length > 0) {
|
|
233
|
+
lines.push('');
|
|
234
|
+
lines.push('**Tuning suggestions:**');
|
|
235
|
+
for (const s of r.suggestions)
|
|
236
|
+
lines.push(`- ${s}`);
|
|
237
|
+
}
|
|
238
|
+
return lines.join('\n');
|
|
239
|
+
}
|
|
240
|
+
//# sourceMappingURL=decision-reflection.js.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Webhook → action dispatch.
|
|
3
|
+
*
|
|
4
|
+
* External services (Salesforce, GitHub, calendar, email) POST events
|
|
5
|
+
* to /webhook-action/:source. This module turns those events into
|
|
6
|
+
* agentic actions:
|
|
7
|
+
*
|
|
8
|
+
* - `wake_agent` → write wake sentinel; agent ticks within ~3s
|
|
9
|
+
* - `start_background_task` → create a pending task; cron-scheduler picks
|
|
10
|
+
* it up and runs unleashed
|
|
11
|
+
*
|
|
12
|
+
* Configuration: ~/.clementine/webhook-actions.json
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* "hooks": [
|
|
16
|
+
* {
|
|
17
|
+
* "source": "github",
|
|
18
|
+
* "secretEnv": "GITHUB_WEBHOOK_SECRET",
|
|
19
|
+
* "on": [
|
|
20
|
+
* {
|
|
21
|
+
* "match": { "action": "opened", "pull_request": "*" },
|
|
22
|
+
* "do": "wake_agent",
|
|
23
|
+
* "agent": "ross-the-sdr",
|
|
24
|
+
* "reason": "PR opened — review needed"
|
|
25
|
+
* }
|
|
26
|
+
* ]
|
|
27
|
+
* }
|
|
28
|
+
* ]
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Match values: literal strings/numbers/booleans for exact match, or "*"
|
|
32
|
+
* to require the field be present (any value, non-null/undefined). Dot
|
|
33
|
+
* notation supported for nested fields ("payload.user.id"). All conditions
|
|
34
|
+
* in a `match` block must hold (AND).
|
|
35
|
+
*
|
|
36
|
+
* Templating: `prompt` and `reason` strings can interpolate payload
|
|
37
|
+
* fields with `{{ field.path }}`. Missing fields render as empty string.
|
|
38
|
+
*
|
|
39
|
+
* Every dispatched event is logged to ~/.clementine/webhook-actions/log.jsonl
|
|
40
|
+
* (rotated at 1MB / 1000 lines, 30-day retention).
|
|
41
|
+
*/
|
|
42
|
+
export type WebhookActionVerb = 'wake_agent' | 'start_background_task';
|
|
43
|
+
export interface WebhookActionRule {
|
|
44
|
+
/** Field/value conditions, all must match. Use "*" for "field present". */
|
|
45
|
+
match?: Record<string, string | number | boolean>;
|
|
46
|
+
do: WebhookActionVerb;
|
|
47
|
+
agent: string;
|
|
48
|
+
/** For wake_agent — short reason annotated on the wake sentinel. */
|
|
49
|
+
reason?: string;
|
|
50
|
+
/** For start_background_task — the prompt template. Supports {{ field.path }}. */
|
|
51
|
+
prompt?: string;
|
|
52
|
+
/** For start_background_task — wall-clock cap. Default 30. */
|
|
53
|
+
maxMinutes?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface WebhookActionSource {
|
|
56
|
+
source: string;
|
|
57
|
+
/** Env var holding the HMAC secret. Required unless `secret` is set inline. */
|
|
58
|
+
secretEnv?: string;
|
|
59
|
+
/** Inline secret (for tests / local-only setups). Prefer secretEnv in prod. */
|
|
60
|
+
secret?: string;
|
|
61
|
+
on: WebhookActionRule[];
|
|
62
|
+
}
|
|
63
|
+
export interface WebhookActionConfig {
|
|
64
|
+
hooks: WebhookActionSource[];
|
|
65
|
+
}
|
|
66
|
+
export interface DispatchResult {
|
|
67
|
+
matched: number;
|
|
68
|
+
dispatched: number;
|
|
69
|
+
errors: string[];
|
|
70
|
+
log: Array<{
|
|
71
|
+
rule: WebhookActionRule;
|
|
72
|
+
ok: boolean;
|
|
73
|
+
message: string;
|
|
74
|
+
}>;
|
|
75
|
+
}
|
|
76
|
+
export declare function loadWebhookActionConfig(opts?: {
|
|
77
|
+
configPath?: string;
|
|
78
|
+
}): WebhookActionConfig;
|
|
79
|
+
export declare function getSourceConfig(source: string, opts?: {
|
|
80
|
+
configPath?: string;
|
|
81
|
+
}): WebhookActionSource | null;
|
|
82
|
+
/** All match conditions hold. "*" means "field is present (non-null/undefined)". */
|
|
83
|
+
export declare function ruleMatches(rule: WebhookActionRule, payload: unknown): boolean;
|
|
84
|
+
/** Replace {{ dot.path }} in a template with payload values. Missing → "". */
|
|
85
|
+
export declare function renderTemplate(template: string, payload: unknown): string;
|
|
86
|
+
/**
|
|
87
|
+
* Match the payload against every rule in the source config and dispatch
|
|
88
|
+
* all matches. Each rule is independent — multiple matches all fire.
|
|
89
|
+
*/
|
|
90
|
+
export declare function dispatchWebhookActions(source: string, payload: unknown, opts?: {
|
|
91
|
+
configPath?: string;
|
|
92
|
+
baseDir?: string;
|
|
93
|
+
}): DispatchResult;
|
|
94
|
+
export interface WebhookEventLogEntry {
|
|
95
|
+
timestamp: string;
|
|
96
|
+
source: string;
|
|
97
|
+
verified: boolean;
|
|
98
|
+
matched: number;
|
|
99
|
+
dispatched: number;
|
|
100
|
+
errors: string[];
|
|
101
|
+
payloadPreview: string;
|
|
102
|
+
}
|
|
103
|
+
export declare function logWebhookEvent(entry: WebhookEventLogEntry, opts?: {
|
|
104
|
+
logPath?: string;
|
|
105
|
+
logDir?: string;
|
|
106
|
+
}): void;
|
|
107
|
+
export declare function recentWebhookEvents(limit?: number, opts?: {
|
|
108
|
+
logPath?: string;
|
|
109
|
+
}): WebhookEventLogEntry[];
|
|
110
|
+
export declare const _internals: {
|
|
111
|
+
CONFIG_PATH: string;
|
|
112
|
+
LOG_PATH: string;
|
|
113
|
+
LOG_DIR: string;
|
|
114
|
+
WAKE_DIR: string;
|
|
115
|
+
};
|
|
116
|
+
//# sourceMappingURL=webhook-actions.d.ts.map
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Webhook → action dispatch.
|
|
3
|
+
*
|
|
4
|
+
* External services (Salesforce, GitHub, calendar, email) POST events
|
|
5
|
+
* to /webhook-action/:source. This module turns those events into
|
|
6
|
+
* agentic actions:
|
|
7
|
+
*
|
|
8
|
+
* - `wake_agent` → write wake sentinel; agent ticks within ~3s
|
|
9
|
+
* - `start_background_task` → create a pending task; cron-scheduler picks
|
|
10
|
+
* it up and runs unleashed
|
|
11
|
+
*
|
|
12
|
+
* Configuration: ~/.clementine/webhook-actions.json
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* "hooks": [
|
|
16
|
+
* {
|
|
17
|
+
* "source": "github",
|
|
18
|
+
* "secretEnv": "GITHUB_WEBHOOK_SECRET",
|
|
19
|
+
* "on": [
|
|
20
|
+
* {
|
|
21
|
+
* "match": { "action": "opened", "pull_request": "*" },
|
|
22
|
+
* "do": "wake_agent",
|
|
23
|
+
* "agent": "ross-the-sdr",
|
|
24
|
+
* "reason": "PR opened — review needed"
|
|
25
|
+
* }
|
|
26
|
+
* ]
|
|
27
|
+
* }
|
|
28
|
+
* ]
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Match values: literal strings/numbers/booleans for exact match, or "*"
|
|
32
|
+
* to require the field be present (any value, non-null/undefined). Dot
|
|
33
|
+
* notation supported for nested fields ("payload.user.id"). All conditions
|
|
34
|
+
* in a `match` block must hold (AND).
|
|
35
|
+
*
|
|
36
|
+
* Templating: `prompt` and `reason` strings can interpolate payload
|
|
37
|
+
* fields with `{{ field.path }}`. Missing fields render as empty string.
|
|
38
|
+
*
|
|
39
|
+
* Every dispatched event is logged to ~/.clementine/webhook-actions/log.jsonl
|
|
40
|
+
* (rotated at 1MB / 1000 lines, 30-day retention).
|
|
41
|
+
*/
|
|
42
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { BASE_DIR } from '../config.js';
|
|
45
|
+
import { createBackgroundTask } from './background-tasks.js';
|
|
46
|
+
// ── Storage paths ────────────────────────────────────────────────────
|
|
47
|
+
const CONFIG_PATH = path.join(BASE_DIR, 'webhook-actions.json');
|
|
48
|
+
const LOG_DIR = path.join(BASE_DIR, 'webhook-actions');
|
|
49
|
+
const LOG_PATH = path.join(LOG_DIR, 'log.jsonl');
|
|
50
|
+
const WAKE_DIR = path.join(BASE_DIR, 'heartbeat', 'wake');
|
|
51
|
+
const LOG_MAX_BYTES = 1_000_000;
|
|
52
|
+
const LOG_MAX_LINES = 1000;
|
|
53
|
+
const LOG_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
54
|
+
// ── Config I/O ───────────────────────────────────────────────────────
|
|
55
|
+
export function loadWebhookActionConfig(opts) {
|
|
56
|
+
const file = opts?.configPath ?? CONFIG_PATH;
|
|
57
|
+
if (!existsSync(file))
|
|
58
|
+
return { hooks: [] };
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(readFileSync(file, 'utf-8'));
|
|
61
|
+
if (!Array.isArray(raw.hooks))
|
|
62
|
+
return { hooks: [] };
|
|
63
|
+
return { hooks: raw.hooks };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { hooks: [] };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function getSourceConfig(source, opts) {
|
|
70
|
+
return loadWebhookActionConfig(opts).hooks.find((h) => h.source === source) ?? null;
|
|
71
|
+
}
|
|
72
|
+
// ── Matcher ──────────────────────────────────────────────────────────
|
|
73
|
+
/** Read a dot-path from a JSON-ish object. Returns undefined if any segment is missing. */
|
|
74
|
+
function readPath(obj, dotPath) {
|
|
75
|
+
if (obj == null || typeof obj !== 'object')
|
|
76
|
+
return undefined;
|
|
77
|
+
let cursor = obj;
|
|
78
|
+
for (const segment of dotPath.split('.')) {
|
|
79
|
+
if (cursor == null || typeof cursor !== 'object')
|
|
80
|
+
return undefined;
|
|
81
|
+
cursor = cursor[segment];
|
|
82
|
+
}
|
|
83
|
+
return cursor;
|
|
84
|
+
}
|
|
85
|
+
/** All match conditions hold. "*" means "field is present (non-null/undefined)". */
|
|
86
|
+
export function ruleMatches(rule, payload) {
|
|
87
|
+
const conds = rule.match;
|
|
88
|
+
if (!conds || Object.keys(conds).length === 0)
|
|
89
|
+
return true; // empty match = match-all
|
|
90
|
+
for (const [pathSpec, expected] of Object.entries(conds)) {
|
|
91
|
+
const actual = readPath(payload, pathSpec);
|
|
92
|
+
if (expected === '*') {
|
|
93
|
+
if (actual === undefined || actual === null)
|
|
94
|
+
return false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Loose equality: 1 == "1", true == "true". Real users put strings in JSON; loose is friendlier.
|
|
98
|
+
// eslint-disable-next-line eqeqeq
|
|
99
|
+
if (actual == expected)
|
|
100
|
+
continue;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
/** Replace {{ dot.path }} in a template with payload values. Missing → "". */
|
|
106
|
+
export function renderTemplate(template, payload) {
|
|
107
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, dotPath) => {
|
|
108
|
+
const v = readPath(payload, dotPath);
|
|
109
|
+
if (v == null)
|
|
110
|
+
return '';
|
|
111
|
+
if (typeof v === 'object')
|
|
112
|
+
return JSON.stringify(v);
|
|
113
|
+
return String(v);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function wakeSentinelPath(slug, baseDir) {
|
|
117
|
+
return path.join(baseDir, 'heartbeat', 'wake', `${slug}.json`);
|
|
118
|
+
}
|
|
119
|
+
function dispatchOne(rule, source, payload, env) {
|
|
120
|
+
const baseDir = env.baseDir ?? BASE_DIR;
|
|
121
|
+
if (rule.do === 'wake_agent') {
|
|
122
|
+
try {
|
|
123
|
+
const wakeDir = path.join(baseDir, 'heartbeat', 'wake');
|
|
124
|
+
mkdirSync(wakeDir, { recursive: true });
|
|
125
|
+
const reason = rule.reason ? renderTemplate(rule.reason, payload) : `webhook:${source}`;
|
|
126
|
+
const sentinel = {
|
|
127
|
+
targetSlug: rule.agent,
|
|
128
|
+
fromSlug: `webhook:${source}`,
|
|
129
|
+
reason: reason.slice(0, 200),
|
|
130
|
+
requestedAt: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
writeFileSync(wakeSentinelPath(rule.agent, baseDir), JSON.stringify(sentinel, null, 2));
|
|
133
|
+
return { ok: true, message: `Woke ${rule.agent} (${reason})` };
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return { ok: false, message: `wake_agent failed: ${String(err).slice(0, 200)}` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (rule.do === 'start_background_task') {
|
|
140
|
+
if (!rule.prompt) {
|
|
141
|
+
return { ok: false, message: 'start_background_task: rule has no `prompt` template' };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const prompt = renderTemplate(rule.prompt, payload);
|
|
145
|
+
const task = createBackgroundTask({
|
|
146
|
+
fromAgent: rule.agent,
|
|
147
|
+
prompt,
|
|
148
|
+
maxMinutes: rule.maxMinutes ?? 30,
|
|
149
|
+
}, env.baseDir ? { dir: path.join(env.baseDir, 'background-tasks') } : undefined);
|
|
150
|
+
return { ok: true, message: `Queued background task ${task.id} for ${rule.agent}` };
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return { ok: false, message: `start_background_task failed: ${String(err).slice(0, 200)}` };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Exhaustiveness check — should never hit at runtime if types are honored.
|
|
157
|
+
return { ok: false, message: `Unknown action verb: ${rule.do}` };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Match the payload against every rule in the source config and dispatch
|
|
161
|
+
* all matches. Each rule is independent — multiple matches all fire.
|
|
162
|
+
*/
|
|
163
|
+
export function dispatchWebhookActions(source, payload, opts) {
|
|
164
|
+
const cfg = getSourceConfig(source, opts);
|
|
165
|
+
const result = { matched: 0, dispatched: 0, errors: [], log: [] };
|
|
166
|
+
if (!cfg) {
|
|
167
|
+
result.errors.push(`No webhook-action config for source "${source}"`);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
for (const rule of cfg.on) {
|
|
171
|
+
if (!ruleMatches(rule, payload))
|
|
172
|
+
continue;
|
|
173
|
+
result.matched++;
|
|
174
|
+
const r = dispatchOne(rule, source, payload, { baseDir: opts?.baseDir });
|
|
175
|
+
result.log.push({ rule, ok: r.ok, message: r.message });
|
|
176
|
+
if (r.ok)
|
|
177
|
+
result.dispatched++;
|
|
178
|
+
else
|
|
179
|
+
result.errors.push(r.message);
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
function rotateLogIfNeeded(opts) {
|
|
184
|
+
const file = opts?.logPath ?? LOG_PATH;
|
|
185
|
+
try {
|
|
186
|
+
if (!existsSync(file))
|
|
187
|
+
return;
|
|
188
|
+
const { size } = statSync(file);
|
|
189
|
+
if (size <= LOG_MAX_BYTES)
|
|
190
|
+
return;
|
|
191
|
+
const lines = readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
|
|
192
|
+
if (lines.length <= LOG_MAX_LINES)
|
|
193
|
+
return;
|
|
194
|
+
const cutoff = Date.now() - LOG_MAX_AGE_MS;
|
|
195
|
+
const kept = [];
|
|
196
|
+
for (const line of lines.slice(-LOG_MAX_LINES)) {
|
|
197
|
+
try {
|
|
198
|
+
const e = JSON.parse(line);
|
|
199
|
+
const ts = new Date(e.timestamp).getTime();
|
|
200
|
+
if (Number.isFinite(ts) && ts >= cutoff)
|
|
201
|
+
kept.push(line);
|
|
202
|
+
}
|
|
203
|
+
catch { /* drop malformed */ }
|
|
204
|
+
}
|
|
205
|
+
writeFileSync(file, kept.join('\n') + (kept.length ? '\n' : ''));
|
|
206
|
+
}
|
|
207
|
+
catch { /* non-fatal */ }
|
|
208
|
+
}
|
|
209
|
+
export function logWebhookEvent(entry, opts) {
|
|
210
|
+
try {
|
|
211
|
+
const dir = opts?.logDir ?? LOG_DIR;
|
|
212
|
+
const file = opts?.logPath ?? LOG_PATH;
|
|
213
|
+
mkdirSync(dir, { recursive: true });
|
|
214
|
+
appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
215
|
+
setImmediate(() => rotateLogIfNeeded(opts));
|
|
216
|
+
}
|
|
217
|
+
catch { /* non-fatal */ }
|
|
218
|
+
}
|
|
219
|
+
export function recentWebhookEvents(limit = 50, opts) {
|
|
220
|
+
try {
|
|
221
|
+
const file = opts?.logPath ?? LOG_PATH;
|
|
222
|
+
if (!existsSync(file))
|
|
223
|
+
return [];
|
|
224
|
+
const lines = readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const line of lines.slice(-limit).reverse()) {
|
|
227
|
+
try {
|
|
228
|
+
out.push(JSON.parse(line));
|
|
229
|
+
}
|
|
230
|
+
catch { /* skip */ }
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// ── Test-only ────────────────────────────────────────────────────────
|
|
239
|
+
export const _internals = { CONFIG_PATH, LOG_PATH, LOG_DIR, WAKE_DIR };
|
|
240
|
+
//# sourceMappingURL=webhook-actions.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -1364,6 +1364,66 @@ export async function cmdDashboard(opts) {
|
|
|
1364
1364
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1365
1365
|
return diff === 0;
|
|
1366
1366
|
}
|
|
1367
|
+
// ── Webhook → action triggers ─────────────────────────────────────
|
|
1368
|
+
// Sibling of /webhook/:slug. Where /webhook/:slug ingests data into
|
|
1369
|
+
// the brain, /webhook-action/:source dispatches agentic actions
|
|
1370
|
+
// (wake an agent, start a background task) based on a YAML-ish config
|
|
1371
|
+
// at ~/.clementine/webhook-actions.json. Same HMAC verification.
|
|
1372
|
+
app.post('/webhook-action/:source', rawBodyParser, async (req, res) => {
|
|
1373
|
+
const sourceParam = req.params.source;
|
|
1374
|
+
const { getSourceConfig, dispatchWebhookActions, logWebhookEvent, } = await import('../agent/webhook-actions.js');
|
|
1375
|
+
const cfg = getSourceConfig(sourceParam);
|
|
1376
|
+
if (!cfg) {
|
|
1377
|
+
res.status(404).json({ error: `No webhook-action config for source "${sourceParam}"` });
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
// Resolve the HMAC secret (env first, inline fallback for local dev).
|
|
1381
|
+
const secret = (cfg.secretEnv ? process.env[cfg.secretEnv] : undefined) ?? cfg.secret ?? '';
|
|
1382
|
+
if (!secret) {
|
|
1383
|
+
res.status(500).json({ error: `Webhook source "${sourceParam}" has no secret configured` });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const sig = String(req.headers['x-signature'] ?? req.headers['x-hub-signature-256'] ?? '').trim();
|
|
1387
|
+
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body ?? ''));
|
|
1388
|
+
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
1389
|
+
const given = sig.startsWith('sha256=') ? sig.slice('sha256='.length) : sig;
|
|
1390
|
+
if (!given || !safeHexEquals(given, expected)) {
|
|
1391
|
+
logWebhookEvent({
|
|
1392
|
+
timestamp: new Date().toISOString(),
|
|
1393
|
+
source: sourceParam,
|
|
1394
|
+
verified: false,
|
|
1395
|
+
matched: 0,
|
|
1396
|
+
dispatched: 0,
|
|
1397
|
+
errors: ['HMAC signature mismatch'],
|
|
1398
|
+
payloadPreview: rawBody.toString('utf-8').slice(0, 200),
|
|
1399
|
+
});
|
|
1400
|
+
res.status(401).json({ error: 'Invalid or missing HMAC signature' });
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
let payload;
|
|
1404
|
+
try {
|
|
1405
|
+
payload = JSON.parse(rawBody.toString('utf-8'));
|
|
1406
|
+
}
|
|
1407
|
+
catch (err) {
|
|
1408
|
+
res.status(400).json({ error: `Body is not JSON: ${err instanceof Error ? err.message : String(err)}` });
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const result = dispatchWebhookActions(sourceParam, payload);
|
|
1412
|
+
logWebhookEvent({
|
|
1413
|
+
timestamp: new Date().toISOString(),
|
|
1414
|
+
source: sourceParam,
|
|
1415
|
+
verified: true,
|
|
1416
|
+
matched: result.matched,
|
|
1417
|
+
dispatched: result.dispatched,
|
|
1418
|
+
errors: result.errors,
|
|
1419
|
+
payloadPreview: rawBody.toString('utf-8').slice(0, 200),
|
|
1420
|
+
});
|
|
1421
|
+
res.json({
|
|
1422
|
+
matched: result.matched,
|
|
1423
|
+
dispatched: result.dispatched,
|
|
1424
|
+
errors: result.errors,
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1367
1427
|
// Only parse JSON bodies on POST/PUT/PATCH — GET requests don't need body parsing.
|
|
1368
1428
|
// Registered AFTER the webhook route so /webhook/* keeps its raw body.
|
|
1369
1429
|
app.use((req, res, next) => {
|
|
@@ -1978,6 +2038,36 @@ export async function cmdDashboard(opts) {
|
|
|
1978
2038
|
app.get('/api/agent-heartbeats', (_req, res) => {
|
|
1979
2039
|
res.json(getAgentHeartbeats());
|
|
1980
2040
|
});
|
|
2041
|
+
app.get('/api/webhook-actions', async (_req, res) => {
|
|
2042
|
+
try {
|
|
2043
|
+
const { loadWebhookActionConfig, recentWebhookEvents } = await import('../agent/webhook-actions.js');
|
|
2044
|
+
const cfg = loadWebhookActionConfig();
|
|
2045
|
+
// Don't leak secrets — strip secret/secretEnv from the config response
|
|
2046
|
+
const sanitized = {
|
|
2047
|
+
hooks: cfg.hooks.map((h) => ({
|
|
2048
|
+
source: h.source,
|
|
2049
|
+
hasSecret: Boolean(h.secret) || Boolean(h.secretEnv),
|
|
2050
|
+
secretEnv: h.secretEnv ?? null,
|
|
2051
|
+
rules: h.on.length,
|
|
2052
|
+
on: h.on.map((r) => ({
|
|
2053
|
+
do: r.do,
|
|
2054
|
+
agent: r.agent,
|
|
2055
|
+
match: r.match ?? {},
|
|
2056
|
+
reason: r.reason,
|
|
2057
|
+
promptHead: r.prompt ? r.prompt.slice(0, 120) : undefined,
|
|
2058
|
+
maxMinutes: r.maxMinutes,
|
|
2059
|
+
})),
|
|
2060
|
+
})),
|
|
2061
|
+
};
|
|
2062
|
+
res.json({
|
|
2063
|
+
config: sanitized,
|
|
2064
|
+
recent: recentWebhookEvents(50),
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
catch (err) {
|
|
2068
|
+
res.status(500).json({ error: String(err).slice(0, 200) });
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
1981
2071
|
app.get('/api/background-tasks', async (_req, res) => {
|
|
1982
2072
|
try {
|
|
1983
2073
|
const { listBackgroundTasks } = await import('../agent/background-tasks.js');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Decision-loop reflection MCP tool.
|
|
3
|
+
*
|
|
4
|
+
* `decision_reflection` runs the analysis from agent/decision-reflection.ts
|
|
5
|
+
* for an agent and surfaces the result as a markdown report. Optionally
|
|
6
|
+
* persists the report to the agent's reflections history and/or appends
|
|
7
|
+
* a summary to their working-memory so the next heartbeat tick reads it
|
|
8
|
+
* as prompt context.
|
|
9
|
+
*
|
|
10
|
+
* Intended usage:
|
|
11
|
+
* - Manual call by the owner ("Clementine, reflect on your week")
|
|
12
|
+
* - Weekly cron job that calls this for each active agent
|
|
13
|
+
* - On-demand by an agent when they suspect they're miscalibrated
|
|
14
|
+
*/
|
|
15
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
16
|
+
export declare function registerDecisionReflectionTools(server: McpServer): void;
|
|
17
|
+
//# sourceMappingURL=decision-reflection-tools.d.ts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Decision-loop reflection MCP tool.
|
|
3
|
+
*
|
|
4
|
+
* `decision_reflection` runs the analysis from agent/decision-reflection.ts
|
|
5
|
+
* for an agent and surfaces the result as a markdown report. Optionally
|
|
6
|
+
* persists the report to the agent's reflections history and/or appends
|
|
7
|
+
* a summary to their working-memory so the next heartbeat tick reads it
|
|
8
|
+
* as prompt context.
|
|
9
|
+
*
|
|
10
|
+
* Intended usage:
|
|
11
|
+
* - Manual call by the owner ("Clementine, reflect on your week")
|
|
12
|
+
* - Weekly cron job that calls this for each active agent
|
|
13
|
+
* - On-demand by an agent when they suspect they're miscalibrated
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { analyzeAgentDecisions, formatReflectionReport, formatReflectionSummary, } from '../agent/decision-reflection.js';
|
|
19
|
+
import { ACTIVE_AGENT_SLUG, AGENTS_DIR, logger, textResult } from './shared.js';
|
|
20
|
+
function reflectionsDir(slug) {
|
|
21
|
+
return path.join(AGENTS_DIR, slug, 'reflections');
|
|
22
|
+
}
|
|
23
|
+
function workingMemoryPath(slug) {
|
|
24
|
+
return path.join(AGENTS_DIR, slug, 'working-memory.md');
|
|
25
|
+
}
|
|
26
|
+
function todayStamp() {
|
|
27
|
+
return new Date().toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
/** Append a summary block to working-memory.md, creating the file if needed. */
|
|
30
|
+
function appendToWorkingMemory(slug, summary) {
|
|
31
|
+
const file = workingMemoryPath(slug);
|
|
32
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
33
|
+
let existing = '';
|
|
34
|
+
if (existsSync(file))
|
|
35
|
+
existing = readFileSync(file, 'utf-8');
|
|
36
|
+
const appended = (existing.endsWith('\n') || existing === '' ? existing : existing + '\n')
|
|
37
|
+
+ '\n'
|
|
38
|
+
+ summary
|
|
39
|
+
+ '\n';
|
|
40
|
+
writeFileSync(file, appended);
|
|
41
|
+
}
|
|
42
|
+
export function registerDecisionReflectionTools(server) {
|
|
43
|
+
server.tool('decision_reflection', 'Run a self-reflection on your recent autonomous decisions: read the proactive ledger, compute success rates per action type, identify miscalibration patterns, and surface concrete tuning suggestions. Use to spot when you are over-acting, under-acting, or going dormant. Optionally writes a summary to working-memory so your next heartbeat tick reads it as context.', {
|
|
44
|
+
slug: z.string().optional().describe('Agent slug to reflect on. Defaults to the calling agent (or "clementine" for the daemon).'),
|
|
45
|
+
window_days: z.number().optional().describe('Window in days to analyze. Default 7. Range 1-90.'),
|
|
46
|
+
save_to_history: z.boolean().optional().describe('Persist the full report to vault/00-System/agents/<slug>/reflections/<date>.md (default true).'),
|
|
47
|
+
append_to_memory: z.boolean().optional().describe('Append a compact summary to working-memory.md so the next tick reads it (default false). Be deliberate — repeated appends bloat the prompt.'),
|
|
48
|
+
}, async ({ slug, window_days, save_to_history, append_to_memory }) => {
|
|
49
|
+
const targetSlug = slug || ACTIVE_AGENT_SLUG || 'clementine';
|
|
50
|
+
const window = Math.max(1, Math.min(90, typeof window_days === 'number' ? window_days : 7));
|
|
51
|
+
const persistHistory = save_to_history !== false; // default true
|
|
52
|
+
const updateMemory = append_to_memory === true; // default false (explicit opt-in)
|
|
53
|
+
const reflection = analyzeAgentDecisions(targetSlug, window);
|
|
54
|
+
const report = formatReflectionReport(reflection);
|
|
55
|
+
const writes = [];
|
|
56
|
+
if (persistHistory) {
|
|
57
|
+
try {
|
|
58
|
+
const dir = reflectionsDir(targetSlug);
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
const file = path.join(dir, `${todayStamp()}.md`);
|
|
61
|
+
writeFileSync(file, report);
|
|
62
|
+
writes.push(`Saved to ${file}`);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
logger.warn({ err, slug: targetSlug }, 'Failed to save reflection history');
|
|
66
|
+
writes.push(`Failed to save history: ${String(err).slice(0, 200)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (updateMemory) {
|
|
70
|
+
try {
|
|
71
|
+
appendToWorkingMemory(targetSlug, formatReflectionSummary(reflection));
|
|
72
|
+
writes.push(`Appended summary to ${workingMemoryPath(targetSlug)}`);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger.warn({ err, slug: targetSlug }, 'Failed to append reflection to working-memory');
|
|
76
|
+
writes.push(`Failed to update working-memory: ${String(err).slice(0, 200)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const footer = writes.length > 0 ? '\n\n---\n' + writes.map((w) => `- ${w}`).join('\n') : '';
|
|
80
|
+
return textResult(report + footer);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=decision-reflection-tools.js.map
|
package/dist/tools/mcp-server.js
CHANGED
|
@@ -28,6 +28,7 @@ import { registerArtifactTools } from './artifact-tools.js';
|
|
|
28
28
|
import { registerBrainTools } from './brain-tools.js';
|
|
29
29
|
import { registerAgentHeartbeatTools } from './agent-heartbeat-tools.js';
|
|
30
30
|
import { registerBackgroundTaskTools } from './background-task-tools.js';
|
|
31
|
+
import { registerDecisionReflectionTools } from './decision-reflection-tools.js';
|
|
31
32
|
// ── Server ──────────────────────────────────────────────────────────────
|
|
32
33
|
const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
|
|
33
34
|
const server = new McpServer({ name: serverName, version: '1.0.0' });
|
|
@@ -43,6 +44,7 @@ registerArtifactTools(server);
|
|
|
43
44
|
registerBrainTools(server);
|
|
44
45
|
registerAgentHeartbeatTools(server);
|
|
45
46
|
registerBackgroundTaskTools(server);
|
|
47
|
+
registerDecisionReflectionTools(server);
|
|
46
48
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
47
49
|
async function main() {
|
|
48
50
|
// Initialize memory store and run full sync on startup
|
package/package.json
CHANGED
package/vault/00-System/CRON.md
CHANGED
|
@@ -37,6 +37,21 @@ jobs:
|
|
|
37
37
|
4. Write a brief summary of the day in today's daily note under ## Summary
|
|
38
38
|
tier: 1
|
|
39
39
|
enabled: true
|
|
40
|
+
|
|
41
|
+
- name: weekly-decision-reflection
|
|
42
|
+
schedule: "0 9 * * 0"
|
|
43
|
+
prompt: >
|
|
44
|
+
Run a self-reflection on the past week's autonomous decisions.
|
|
45
|
+
1. Call `decision_reflection` with window_days=7, save_to_history=true, append_to_memory=true.
|
|
46
|
+
This reads the proactive ledger, computes per-action success rates, identifies
|
|
47
|
+
miscalibration patterns, and writes a tuning note to your working-memory so the
|
|
48
|
+
next heartbeat tick reads it as context.
|
|
49
|
+
2. For each specialist on the team (use `team_list` to enumerate), also run
|
|
50
|
+
`decision_reflection` with their slug, save_to_history=true, append_to_memory=true.
|
|
51
|
+
3. Briefly summarize in today's daily note under "## Decision reflection" — list each
|
|
52
|
+
agent's headline pattern (e.g., "Ross: act_now success 33%, raise threshold").
|
|
53
|
+
tier: 1
|
|
54
|
+
enabled: true
|
|
40
55
|
tags:
|
|
41
56
|
- system
|
|
42
57
|
- cron
|
|
@@ -53,6 +68,7 @@ Scheduled tasks that run automatically at specific times. Edit the frontmatter a
|
|
|
53
68
|
| morning-briefing | 8:00 AM daily | Comprehensive morning briefing |
|
|
54
69
|
| weekly-review | 6:00 PM Fridays | Weekly summary + planning |
|
|
55
70
|
| daily-memory-cleanup | 10:00 PM daily | Promote daily facts to long-term memory |
|
|
71
|
+
| weekly-decision-reflection | 9:00 AM Sundays | Per-agent self-tuning from proactive ledger |
|
|
56
72
|
|
|
57
73
|
## Schedule Syntax
|
|
58
74
|
|