clementine-agent 1.1.24 → 1.1.26

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.
@@ -814,6 +814,7 @@ export class PersonalAssistant {
814
814
  numTurns: result.num_turns,
815
815
  durationMs: result.duration_ms,
816
816
  agentSlug: agentSlug ?? undefined,
817
+ totalCostUsd: 'total_cost_usd' in result ? result.total_cost_usd : undefined,
817
818
  });
818
819
  }
819
820
  catch (err) {
@@ -3845,6 +3846,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3845
3846
  const cronProfile = agentSlug && agentSlug !== 'clementine'
3846
3847
  ? this.profileManager.get(agentSlug)
3847
3848
  : null;
3849
+ // Per-agent monthly budget gate. Refuse to start the cron run if this
3850
+ // agent has exceeded its cap for the calendar month. The breaker
3851
+ // surfaces via insight-engine so the owner sees it without polling.
3852
+ if (cronProfile && this.memoryStore) {
3853
+ const { checkAgentBudget } = await import('./budget-enforcement.js');
3854
+ const budget = checkAgentBudget(cronProfile, this.memoryStore);
3855
+ if (!budget.allowed) {
3856
+ logger.warn({ jobName, agentSlug, spent: budget.spentCents, limit: budget.limitCents }, 'Cron job skipped — agent over monthly budget');
3857
+ return `[BUDGET_BLOCK] ${budget.message}`;
3858
+ }
3859
+ }
3848
3860
  // Cron jobs deliver via side effects (sent emails, updated records, etc),
3849
3861
  // not chat text — pass mode='cron' so high_effort_low_output guard is
3850
3862
  // disabled. Loop detection and circular-reasoning checks stay active.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Per-agent monthly budget enforcement.
3
+ *
4
+ * AgentProfile.budgetMonthlyCents is set in agent.md frontmatter (0 or
5
+ * undefined = unlimited). This module checks the current month's spend
6
+ * against the cap before letting an autonomous activity (cron, heartbeat,
7
+ * delegated team task) start.
8
+ *
9
+ * Enforcement is intentionally narrow:
10
+ * - User-initiated chat is NEVER blocked. The owner needs to be able to
11
+ * talk to a paused agent to lift the pause (raise the cap, reset the
12
+ * period, etc.).
13
+ * - Cron + heartbeat + delegation flows ARE blocked. Those are the
14
+ * paths that can run away with cost.
15
+ *
16
+ * Surfacing: when the breaker fires, we write a circuit-breaker advisor
17
+ * event the same shape the MCP and cron breakers use. insight-engine
18
+ * picks that up and surfaces it in the next signal pull, so the owner
19
+ * sees "Budget breaker tripped for agent <slug>" in their next insight.
20
+ */
21
+ import type { AgentProfile } from '../types.js';
22
+ import type { MemoryStore } from '../memory/store.js';
23
+ export interface BudgetCheckResult {
24
+ allowed: boolean;
25
+ spentCents: number;
26
+ limitCents: number;
27
+ /** Human-readable explanation when blocked. */
28
+ message?: string;
29
+ }
30
+ /**
31
+ * Decide whether a profile's autonomous activity may proceed for the
32
+ * current calendar month. Returns allowed=true if no budget is set,
33
+ * if the agent is global Clementine (no profile), or if spend < limit.
34
+ *
35
+ * Side effect: when the breaker fires for the first time this month for
36
+ * a given agent, emits an advisor event so insight-engine surfaces it.
37
+ */
38
+ export declare function checkAgentBudget(profile: AgentProfile | null | undefined, memoryStore: MemoryStore | null | undefined): BudgetCheckResult;
39
+ /** Test seam — clear the "already notified this month" memo. */
40
+ export declare function _resetNotifiedForTesting(): void;
41
+ //# sourceMappingURL=budget-enforcement.d.ts.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Per-agent monthly budget enforcement.
3
+ *
4
+ * AgentProfile.budgetMonthlyCents is set in agent.md frontmatter (0 or
5
+ * undefined = unlimited). This module checks the current month's spend
6
+ * against the cap before letting an autonomous activity (cron, heartbeat,
7
+ * delegated team task) start.
8
+ *
9
+ * Enforcement is intentionally narrow:
10
+ * - User-initiated chat is NEVER blocked. The owner needs to be able to
11
+ * talk to a paused agent to lift the pause (raise the cap, reset the
12
+ * period, etc.).
13
+ * - Cron + heartbeat + delegation flows ARE blocked. Those are the
14
+ * paths that can run away with cost.
15
+ *
16
+ * Surfacing: when the breaker fires, we write a circuit-breaker advisor
17
+ * event the same shape the MCP and cron breakers use. insight-engine
18
+ * picks that up and surfaces it in the next signal pull, so the owner
19
+ * sees "Budget breaker tripped for agent <slug>" in their next insight.
20
+ */
21
+ import { appendFileSync, mkdirSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import pino from 'pino';
24
+ import { BASE_DIR } from '../config.js';
25
+ const logger = pino({ name: 'clementine.budget-enforcement' });
26
+ const ADVISOR_EVENTS_FILE = path.join(BASE_DIR, 'cron', 'advisor-events.jsonl');
27
+ /** Track per-agent "we already notified about this month" so we don't spam. */
28
+ const notifiedThisMonth = new Map();
29
+ function monthKey() {
30
+ const d = new Date();
31
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
32
+ }
33
+ /**
34
+ * Decide whether a profile's autonomous activity may proceed for the
35
+ * current calendar month. Returns allowed=true if no budget is set,
36
+ * if the agent is global Clementine (no profile), or if spend < limit.
37
+ *
38
+ * Side effect: when the breaker fires for the first time this month for
39
+ * a given agent, emits an advisor event so insight-engine surfaces it.
40
+ */
41
+ export function checkAgentBudget(profile, memoryStore) {
42
+ // No profile (Clementine herself) — global budget is governed elsewhere.
43
+ if (!profile)
44
+ return { allowed: true, spentCents: 0, limitCents: 0 };
45
+ const limit = profile.budgetMonthlyCents ?? 0;
46
+ // Unlimited.
47
+ if (!limit || limit <= 0)
48
+ return { allowed: true, spentCents: 0, limitCents: 0 };
49
+ if (!memoryStore)
50
+ return { allowed: true, spentCents: 0, limitCents: limit };
51
+ let spent = 0;
52
+ try {
53
+ spent = memoryStore.getMonthlyCostCents(profile.slug);
54
+ }
55
+ catch (err) {
56
+ logger.debug({ err, slug: profile.slug }, 'Budget query failed — allowing through');
57
+ return { allowed: true, spentCents: 0, limitCents: limit };
58
+ }
59
+ if (spent < limit) {
60
+ return { allowed: true, spentCents: spent, limitCents: limit };
61
+ }
62
+ const usd = (cents) => `$${(cents / 100).toFixed(2)}`;
63
+ const msg = `Agent "${profile.slug}" has hit its monthly budget (${usd(spent)} of ${usd(limit)}). ` +
64
+ `Autonomous activity (cron, heartbeat, delegation) is paused for this agent. ` +
65
+ `Lift by raising budgetMonthlyCents in agent.md or by resetting at month end.`;
66
+ // Emit the breaker event once per month per agent so insight-engine
67
+ // surfaces it but we don't spam the owner with the same message every
68
+ // single tick after the breaker trips.
69
+ const stamp = `${profile.slug}|${monthKey()}`;
70
+ if (notifiedThisMonth.get(profile.slug) !== monthKey()) {
71
+ notifiedThisMonth.set(profile.slug, monthKey());
72
+ emitAdvisorEvent({
73
+ type: 'circuit-breaker',
74
+ jobName: `budget:${profile.slug}`,
75
+ detail: msg,
76
+ });
77
+ logger.warn({ slug: profile.slug, spent, limit }, 'Agent monthly budget tripped');
78
+ }
79
+ else {
80
+ logger.debug({ stamp, spent, limit }, 'Agent budget still tripped (already notified this month)');
81
+ }
82
+ return { allowed: false, spentCents: spent, limitCents: limit, message: msg };
83
+ }
84
+ /** Test seam — clear the "already notified this month" memo. */
85
+ export function _resetNotifiedForTesting() {
86
+ notifiedThisMonth.clear();
87
+ }
88
+ function emitAdvisorEvent(evt) {
89
+ try {
90
+ mkdirSync(path.dirname(ADVISOR_EVENTS_FILE), { recursive: true });
91
+ const line = JSON.stringify({ timestamp: new Date().toISOString(), ...evt }) + '\n';
92
+ appendFileSync(ADVISOR_EVENTS_FILE, line);
93
+ }
94
+ catch (err) {
95
+ logger.debug({ err }, 'Failed to emit budget advisor event');
96
+ }
97
+ }
98
+ //# sourceMappingURL=budget-enforcement.js.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Crash forensics — capture context when something goes wrong so the next
3
+ * launch can surface "I crashed at 2:14am because X" instead of leaving
4
+ * the user wondering why their daemon went quiet.
5
+ *
6
+ * Two surfaces:
7
+ *
8
+ * 1. installCrashHandlers() wraps process.on('uncaughtException') and
9
+ * process.on('unhandledRejection') — when those fire, we write a
10
+ * timestamped JSON dump to ~/.clementine/crash-reports/. The existing
11
+ * handlers in index.ts keep the daemon alive (deliberate); the dump
12
+ * gives us a forensic trail without changing that behavior.
13
+ *
14
+ * 2. surfaceUnreadCrashReports(dispatcher) runs at startup, scans for
15
+ * report files that haven't been acknowledged, sends a one-line
16
+ * summary via the dispatcher, then renames them with a `.ack`
17
+ * suffix so they don't re-fire on the next launch.
18
+ *
19
+ * The dump shape is intentionally small (under ~10KB) so it survives even
20
+ * when the underlying problem is "we ran out of memory."
21
+ */
22
+ export type CrashType = 'uncaughtException' | 'unhandledRejection';
23
+ export interface CrashReport {
24
+ timestamp: string;
25
+ type: CrashType;
26
+ error: string;
27
+ stack?: string;
28
+ uptime: number;
29
+ pid: number;
30
+ recentLogs: string[];
31
+ }
32
+ /**
33
+ * Build a crash report payload. Pure function — exported for testing.
34
+ * Intentionally bounds the size of recentLogs so a runaway log file
35
+ * doesn't make the dump unwriteable when the system is already wobbly.
36
+ */
37
+ export declare function buildCrashReport(opts: {
38
+ type: CrashType;
39
+ error: unknown;
40
+ uptime: number;
41
+ pid: number;
42
+ baseDir: string;
43
+ }): CrashReport;
44
+ /** Write a single crash report. Best-effort — never throws. */
45
+ export declare function writeCrashReport(opts: {
46
+ type: CrashType;
47
+ error: unknown;
48
+ baseDir: string;
49
+ }): string | null;
50
+ export declare function installCrashHandlers(baseDir: string): void;
51
+ /** Test seam — clear the install flag. */
52
+ export declare function _resetInstalledForTesting(): void;
53
+ /**
54
+ * Read all unread crash reports (those without a `.ack` sibling),
55
+ * sorted oldest-first. Returned shape is the parsed payload + the
56
+ * source filename so the caller can ack it after surfacing.
57
+ */
58
+ export declare function readUnreadCrashReports(baseDir: string): Array<{
59
+ report: CrashReport;
60
+ file: string;
61
+ }>;
62
+ /** Mark a crash report as acknowledged so it doesn't re-surface. */
63
+ export declare function ackCrashReport(file: string): void;
64
+ /**
65
+ * Format a single crash report as a one-line owner-readable summary.
66
+ * Intentionally short — the full dump is on disk for deep debugging.
67
+ */
68
+ export declare function formatCrashSummary(report: CrashReport): string;
69
+ /**
70
+ * Startup helper: scan for unread reports, send each as a chat
71
+ * notification via the provided send function, then ack each one.
72
+ * Send function is the dispatcher's `send` so we don't take a hard
73
+ * dependency on the dispatcher type from this module.
74
+ */
75
+ export declare function surfaceUnreadCrashReports(baseDir: string, send: (msg: string) => Promise<void>): Promise<number>;
76
+ //# sourceMappingURL=crash-forensics.d.ts.map
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Crash forensics — capture context when something goes wrong so the next
3
+ * launch can surface "I crashed at 2:14am because X" instead of leaving
4
+ * the user wondering why their daemon went quiet.
5
+ *
6
+ * Two surfaces:
7
+ *
8
+ * 1. installCrashHandlers() wraps process.on('uncaughtException') and
9
+ * process.on('unhandledRejection') — when those fire, we write a
10
+ * timestamped JSON dump to ~/.clementine/crash-reports/. The existing
11
+ * handlers in index.ts keep the daemon alive (deliberate); the dump
12
+ * gives us a forensic trail without changing that behavior.
13
+ *
14
+ * 2. surfaceUnreadCrashReports(dispatcher) runs at startup, scans for
15
+ * report files that haven't been acknowledged, sends a one-line
16
+ * summary via the dispatcher, then renames them with a `.ack`
17
+ * suffix so they don't re-fire on the next launch.
18
+ *
19
+ * The dump shape is intentionally small (under ~10KB) so it survives even
20
+ * when the underlying problem is "we ran out of memory."
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import pino from 'pino';
25
+ const logger = pino({ name: 'clementine.crash-forensics' });
26
+ /** How many lines of recent log to capture in the dump. */
27
+ const RECENT_LOG_LINES = 30;
28
+ function reportsDir(baseDir) {
29
+ return path.join(baseDir, 'crash-reports');
30
+ }
31
+ function logFilePath(baseDir) {
32
+ return path.join(baseDir, 'logs', 'clementine.log');
33
+ }
34
+ function readRecentLogLines(baseDir, n) {
35
+ try {
36
+ const p = logFilePath(baseDir);
37
+ if (!existsSync(p))
38
+ return [];
39
+ const all = readFileSync(p, 'utf-8').split('\n').filter(Boolean);
40
+ return all.slice(-n);
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ /**
47
+ * Build a crash report payload. Pure function — exported for testing.
48
+ * Intentionally bounds the size of recentLogs so a runaway log file
49
+ * doesn't make the dump unwriteable when the system is already wobbly.
50
+ */
51
+ export function buildCrashReport(opts) {
52
+ const err = opts.error;
53
+ const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));
54
+ const stack = err instanceof Error ? err.stack : undefined;
55
+ return {
56
+ timestamp: new Date().toISOString(),
57
+ type: opts.type,
58
+ error: errorMsg.slice(0, 1000),
59
+ stack: stack?.slice(0, 4000),
60
+ uptime: opts.uptime,
61
+ pid: opts.pid,
62
+ recentLogs: readRecentLogLines(opts.baseDir, RECENT_LOG_LINES),
63
+ };
64
+ }
65
+ /** Write a single crash report. Best-effort — never throws. */
66
+ export function writeCrashReport(opts) {
67
+ try {
68
+ const dir = reportsDir(opts.baseDir);
69
+ mkdirSync(dir, { recursive: true });
70
+ const report = buildCrashReport({
71
+ type: opts.type,
72
+ error: opts.error,
73
+ uptime: process.uptime(),
74
+ pid: process.pid,
75
+ baseDir: opts.baseDir,
76
+ });
77
+ // Keep millisecond precision so back-to-back crashes don't collide
78
+ // on filename (was a real test failure — two writes within 10ms got
79
+ // the same name and the second clobbered the first).
80
+ const safeStamp = report.timestamp.replace(/[:.]/g, '-');
81
+ const filename = path.join(dir, `${safeStamp}-${opts.type}.json`);
82
+ writeFileSync(filename, JSON.stringify(report, null, 2), { mode: 0o600 });
83
+ return filename;
84
+ }
85
+ catch (err) {
86
+ // If we can't even write the dump, log to stderr — the daemon's logger
87
+ // may itself be the thing that's failed.
88
+ try {
89
+ console.error('crash-forensics: failed to write report:', err);
90
+ }
91
+ catch { /* nothing to do */ }
92
+ return null;
93
+ }
94
+ }
95
+ /**
96
+ * Wire the global handlers. Idempotent — calling twice is a no-op past
97
+ * the first install. We DON'T exit the process here: the existing
98
+ * uncaughtException handler in index.ts keeps the daemon alive on
99
+ * purpose (segfaults / OOM still kill it; this is for soft errors
100
+ * where execution can continue).
101
+ */
102
+ let _installed = false;
103
+ export function installCrashHandlers(baseDir) {
104
+ if (_installed)
105
+ return;
106
+ _installed = true;
107
+ process.on('uncaughtException', (err) => {
108
+ const file = writeCrashReport({ type: 'uncaughtException', error: err, baseDir });
109
+ if (file)
110
+ logger.warn({ file }, 'Crash report written for uncaughtException');
111
+ });
112
+ process.on('unhandledRejection', (err) => {
113
+ const file = writeCrashReport({ type: 'unhandledRejection', error: err, baseDir });
114
+ if (file)
115
+ logger.warn({ file }, 'Crash report written for unhandledRejection');
116
+ });
117
+ }
118
+ /** Test seam — clear the install flag. */
119
+ export function _resetInstalledForTesting() {
120
+ _installed = false;
121
+ }
122
+ /**
123
+ * Read all unread crash reports (those without a `.ack` sibling),
124
+ * sorted oldest-first. Returned shape is the parsed payload + the
125
+ * source filename so the caller can ack it after surfacing.
126
+ */
127
+ export function readUnreadCrashReports(baseDir) {
128
+ const dir = reportsDir(baseDir);
129
+ if (!existsSync(dir))
130
+ return [];
131
+ const all = readdirSync(dir);
132
+ const ackedSet = new Set(all.filter(f => f.endsWith('.ack')).map(f => f.replace(/\.ack$/, '')));
133
+ const unread = all
134
+ .filter(f => f.endsWith('.json') && !ackedSet.has(f))
135
+ .sort();
136
+ const out = [];
137
+ for (const name of unread) {
138
+ const filePath = path.join(dir, name);
139
+ try {
140
+ const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
141
+ out.push({ report: parsed, file: filePath });
142
+ }
143
+ catch {
144
+ // Corrupt dump — ack it anyway so we don't keep tripping on it.
145
+ ackCrashReport(filePath);
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+ /** Mark a crash report as acknowledged so it doesn't re-surface. */
151
+ export function ackCrashReport(file) {
152
+ try {
153
+ renameSync(file, `${file}.ack`);
154
+ }
155
+ catch {
156
+ // Non-fatal — worst case we surface it again next launch.
157
+ }
158
+ }
159
+ /**
160
+ * Format a single crash report as a one-line owner-readable summary.
161
+ * Intentionally short — the full dump is on disk for deep debugging.
162
+ */
163
+ export function formatCrashSummary(report) {
164
+ const stamp = report.timestamp.slice(0, 19).replace('T', ' ');
165
+ const upHours = Math.floor(report.uptime / 3600);
166
+ const upMin = Math.floor((report.uptime % 3600) / 60);
167
+ const uptimeStr = upHours > 0 ? `${upHours}h${upMin}m` : `${upMin}m`;
168
+ const errLine = report.error.split('\n')[0].slice(0, 220);
169
+ return `${stamp} (after ${uptimeStr} uptime) — ${report.type}: ${errLine}`;
170
+ }
171
+ /**
172
+ * Startup helper: scan for unread reports, send each as a chat
173
+ * notification via the provided send function, then ack each one.
174
+ * Send function is the dispatcher's `send` so we don't take a hard
175
+ * dependency on the dispatcher type from this module.
176
+ */
177
+ export async function surfaceUnreadCrashReports(baseDir, send) {
178
+ const unread = readUnreadCrashReports(baseDir);
179
+ if (unread.length === 0)
180
+ return 0;
181
+ // Group multiple reports into one digest message — one ping per launch
182
+ // is enough; the file system has the per-event detail.
183
+ const lines = unread.slice(0, 10).map(u => `• ${formatCrashSummary(u.report)}`);
184
+ const overflow = unread.length > 10 ? `\n…and ${unread.length - 10} more in ${reportsDir(baseDir)}` : '';
185
+ const dirHint = `\n\n_Full dumps: ${reportsDir(baseDir)}_`;
186
+ const msg = `**Recovered from ${unread.length} crash event${unread.length === 1 ? '' : 's'} since last successful run.**\n\n${lines.join('\n')}${overflow}${dirHint}`;
187
+ try {
188
+ await send(msg);
189
+ }
190
+ catch (err) {
191
+ logger.warn({ err }, 'Failed to dispatch crash-recovery summary');
192
+ }
193
+ for (const u of unread)
194
+ ackCrashReport(u.file);
195
+ return unread.length;
196
+ }
197
+ //# sourceMappingURL=crash-forensics.js.map
@@ -632,6 +632,30 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
632
632
  client.on(Events.Error, (err) => {
633
633
  logger.error({ err }, 'Discord client error — will attempt to reconnect');
634
634
  });
635
+ // ── Connection lifecycle observability ─────────────────────────────
636
+ // discord.js auto-reconnects via the WebSocketManager — these handlers
637
+ // give us visibility into when shards drop and recover so the daemon
638
+ // can report "Discord went offline at HH:MM, came back at HH:MM" instead
639
+ // of leaving the user wondering why nothing was responding.
640
+ let lastDisconnectAt = null;
641
+ client.on(Events.ShardDisconnect, (event, shardId) => {
642
+ lastDisconnectAt = Date.now();
643
+ logger.warn({ shardId, code: event?.code, reason: event?.reason }, 'Discord shard disconnected');
644
+ });
645
+ client.on(Events.ShardReconnecting, (shardId) => {
646
+ logger.info({ shardId }, 'Discord shard reconnecting...');
647
+ });
648
+ client.on(Events.ShardReady, (shardId, unavailableGuilds) => {
649
+ if (lastDisconnectAt !== null) {
650
+ const downtimeMs = Date.now() - lastDisconnectAt;
651
+ const downtimeSec = Math.round(downtimeMs / 1000);
652
+ logger.info({ shardId, unavailableGuilds: unavailableGuilds?.size, downtimeSec }, 'Discord shard reconnected');
653
+ lastDisconnectAt = null;
654
+ }
655
+ else {
656
+ logger.info({ shardId, unavailableGuilds: unavailableGuilds?.size }, 'Discord shard ready');
657
+ }
658
+ });
635
659
  client.once(Events.ClientReady, async (readyClient) => {
636
660
  logger.info(`${ASSISTANT_NAME} online as ${readyClient.user.tag}`);
637
661
  // Register slash commands (global — takes up to 1hr to propagate, but works in DMs)
package/dist/cli/index.js CHANGED
@@ -2130,6 +2130,189 @@ configCmd
2130
2130
  console.error(` Failed to open editor: ${editor}`);
2131
2131
  }
2132
2132
  });
2133
+ // ── Skills commands ─────────────────────────────────────────────────
2134
+ //
2135
+ // Procedural memory the agent extracts from successful runs lives at
2136
+ // vault/00-System/skills/ (global) and agents/<slug>/skills/ (per-agent).
2137
+ // New skills land in pending-approval until the owner OKs them. These
2138
+ // commands give the owner a CLI path that mirrors the dashboard UI.
2139
+ const skillsCmd = program
2140
+ .command('skills')
2141
+ .description('List, inspect, approve, and reject extracted skills');
2142
+ skillsCmd
2143
+ .command('list')
2144
+ .description('List all approved skills (global + per-agent) with use counts')
2145
+ .option('-a, --agent <slug>', 'Filter to a specific agent\'s skills')
2146
+ .option('--json', 'Emit machine-readable JSON')
2147
+ .action(async (opts) => {
2148
+ const BOLD = '\x1b[1m';
2149
+ const DIM = '\x1b[0;90m';
2150
+ const CYAN = '\x1b[0;36m';
2151
+ const RESET = '\x1b[0m';
2152
+ try {
2153
+ process.env.CLEMENTINE_HOME = BASE_DIR;
2154
+ const { listSkills } = await import('../agent/skill-extractor.js');
2155
+ const skills = listSkills(opts.agent);
2156
+ if (opts.json) {
2157
+ console.log(JSON.stringify(skills, null, 2));
2158
+ return;
2159
+ }
2160
+ if (skills.length === 0) {
2161
+ console.log();
2162
+ console.log(` ${DIM}No approved skills yet${opts.agent ? ` for "${opts.agent}"` : ''}.${RESET}`);
2163
+ console.log(` Skills get auto-extracted from successful cron / unleashed runs and queued for approval.`);
2164
+ console.log(` Pending: ${BOLD}clementine skills pending${RESET}`);
2165
+ console.log();
2166
+ return;
2167
+ }
2168
+ console.log();
2169
+ console.log(` ${BOLD}${'NAME'.padEnd(36)}${'AGENT'.padEnd(20)}${'USES'.padEnd(8)}${'UPDATED'}${RESET}`);
2170
+ console.log(` ${DIM}${'─'.repeat(80)}${RESET}`);
2171
+ for (const s of skills) {
2172
+ const agent = s.agentSlug ?? 'global';
2173
+ const updated = s.updatedAt.slice(0, 10);
2174
+ console.log(` ${s.name.slice(0, 34).padEnd(36)}${CYAN}${agent.slice(0, 18).padEnd(20)}${RESET}${String(s.useCount).padEnd(8)}${DIM}${updated}${RESET}`);
2175
+ }
2176
+ console.log();
2177
+ console.log(` ${DIM}Total: ${skills.length} skill${skills.length === 1 ? '' : 's'}.${RESET}`);
2178
+ console.log();
2179
+ }
2180
+ catch (err) {
2181
+ console.error(` Error listing skills: ${err}`);
2182
+ process.exit(1);
2183
+ }
2184
+ });
2185
+ skillsCmd
2186
+ .command('pending')
2187
+ .description('Show skills awaiting your approval')
2188
+ .option('--json', 'Emit machine-readable JSON')
2189
+ .action(async (opts) => {
2190
+ const BOLD = '\x1b[1m';
2191
+ const DIM = '\x1b[0;90m';
2192
+ const YELLOW = '\x1b[1;33m';
2193
+ const RESET = '\x1b[0m';
2194
+ try {
2195
+ process.env.CLEMENTINE_HOME = BASE_DIR;
2196
+ const { listPendingSkills } = await import('../agent/skill-extractor.js');
2197
+ const pending = listPendingSkills();
2198
+ if (opts.json) {
2199
+ console.log(JSON.stringify(pending, null, 2));
2200
+ return;
2201
+ }
2202
+ if (pending.length === 0) {
2203
+ console.log();
2204
+ console.log(` ${DIM}No skills pending approval.${RESET}`);
2205
+ console.log();
2206
+ return;
2207
+ }
2208
+ console.log();
2209
+ console.log(` ${YELLOW}${pending.length} skill${pending.length === 1 ? '' : 's'} pending approval${RESET}`);
2210
+ console.log();
2211
+ for (const s of pending) {
2212
+ const agent = s.agentSlug ? ` [agent: ${s.agentSlug}]` : '';
2213
+ console.log(` ${BOLD}${s.name}${RESET}${DIM}${agent}${RESET}`);
2214
+ console.log(` ${s.title}`);
2215
+ console.log(` ${DIM}${s.description}${RESET}`);
2216
+ console.log(` ${DIM}From ${s.source} • ${s.createdAt.slice(0, 19).replace('T', ' ')}${RESET}`);
2217
+ console.log();
2218
+ }
2219
+ console.log(` Approve: ${BOLD}clementine skills approve <name>${RESET}`);
2220
+ console.log(` Reject: ${BOLD}clementine skills reject <name>${RESET}`);
2221
+ console.log();
2222
+ }
2223
+ catch (err) {
2224
+ console.error(` Error listing pending skills: ${err}`);
2225
+ process.exit(1);
2226
+ }
2227
+ });
2228
+ skillsCmd
2229
+ .command('approve <name>')
2230
+ .description('Approve a pending skill (moves it from pending into the active library)')
2231
+ .action(async (name) => {
2232
+ const GREEN = '\x1b[0;32m';
2233
+ const RED = '\x1b[0;31m';
2234
+ const RESET = '\x1b[0m';
2235
+ try {
2236
+ process.env.CLEMENTINE_HOME = BASE_DIR;
2237
+ const { approvePendingSkill } = await import('../agent/skill-extractor.js');
2238
+ const result = approvePendingSkill(name);
2239
+ if (result.ok) {
2240
+ console.log(` ${GREEN}✓${RESET} ${result.message}`);
2241
+ }
2242
+ else {
2243
+ console.error(` ${RED}✗${RESET} ${result.message}`);
2244
+ process.exit(1);
2245
+ }
2246
+ }
2247
+ catch (err) {
2248
+ console.error(` Error approving skill: ${err}`);
2249
+ process.exit(1);
2250
+ }
2251
+ });
2252
+ skillsCmd
2253
+ .command('reject <name>')
2254
+ .description('Reject a pending skill (deletes it from the queue)')
2255
+ .action(async (name) => {
2256
+ const GREEN = '\x1b[0;32m';
2257
+ const RED = '\x1b[0;31m';
2258
+ const RESET = '\x1b[0m';
2259
+ try {
2260
+ process.env.CLEMENTINE_HOME = BASE_DIR;
2261
+ const { rejectPendingSkill } = await import('../agent/skill-extractor.js');
2262
+ const result = rejectPendingSkill(name);
2263
+ if (result.ok) {
2264
+ console.log(` ${GREEN}✓${RESET} ${result.message}`);
2265
+ }
2266
+ else {
2267
+ console.error(` ${RED}✗${RESET} ${result.message}`);
2268
+ process.exit(1);
2269
+ }
2270
+ }
2271
+ catch (err) {
2272
+ console.error(` Error rejecting skill: ${err}`);
2273
+ process.exit(1);
2274
+ }
2275
+ });
2276
+ skillsCmd
2277
+ .command('search <query>')
2278
+ .description('Preview which skills would be injected for a given query — useful for debugging skill matching')
2279
+ .option('-a, --agent <slug>', 'Search as a specific agent (skills get the agent boost)')
2280
+ .option('-n, --limit <n>', 'Max matches to show', '5')
2281
+ .action(async (query, opts) => {
2282
+ const BOLD = '\x1b[1m';
2283
+ const DIM = '\x1b[0;90m';
2284
+ const CYAN = '\x1b[0;36m';
2285
+ const GREEN = '\x1b[0;32m';
2286
+ const RESET = '\x1b[0m';
2287
+ try {
2288
+ process.env.CLEMENTINE_HOME = BASE_DIR;
2289
+ const { searchSkills } = await import('../agent/skill-extractor.js');
2290
+ const limit = parseInt(opts.limit ?? '5', 10);
2291
+ const matches = searchSkills(query, limit, opts.agent);
2292
+ if (matches.length === 0) {
2293
+ console.log();
2294
+ console.log(` ${DIM}No skills matched "${query}"${opts.agent ? ` for agent ${opts.agent}` : ''}.${RESET}`);
2295
+ console.log();
2296
+ return;
2297
+ }
2298
+ console.log();
2299
+ console.log(` ${BOLD}${matches.length} skill${matches.length === 1 ? '' : 's'} matched${RESET} ${DIM}(threshold for injection: score >= 4)${RESET}`);
2300
+ console.log();
2301
+ for (const m of matches) {
2302
+ const inject = m.score >= 4 ? `${GREEN}✓ would inject${RESET}` : `${DIM}below threshold${RESET}`;
2303
+ console.log(` ${BOLD}${m.name}${RESET} ${CYAN}score: ${m.score.toFixed(2)}${RESET} ${inject}`);
2304
+ console.log(` ${m.title}`);
2305
+ if (m.toolsUsed.length > 0) {
2306
+ console.log(` ${DIM}Tools: ${m.toolsUsed.join(', ')}${RESET}`);
2307
+ }
2308
+ console.log();
2309
+ }
2310
+ }
2311
+ catch (err) {
2312
+ console.error(` Error searching skills: ${err}`);
2313
+ process.exit(1);
2314
+ }
2315
+ });
2133
2316
  // ── Brain commands ──────────────────────────────────────────────────
2134
2317
  const brainCmd = program
2135
2318
  .command('brain')
package/dist/index.js CHANGED
@@ -665,6 +665,21 @@ async function asyncMain() {
665
665
  const dispatcher = new NotificationDispatcher();
666
666
  gateway.setDispatcher(dispatcher);
667
667
  gateway.initSkillNotifications();
668
+ // Crash recovery — surface any forensic dumps written before this start.
669
+ // Fire-and-forget; if the dispatcher isn't ready yet, the next launch
670
+ // catches it on retry (the .ack rename only happens after send succeeds).
671
+ void (async () => {
672
+ try {
673
+ const { surfaceUnreadCrashReports } = await import('./agent/crash-forensics.js');
674
+ const count = await surfaceUnreadCrashReports(config.BASE_DIR, async (msg) => { await dispatcher.send(msg); });
675
+ if (count > 0) {
676
+ logger.info({ count }, 'Surfaced crash recovery summary to owner');
677
+ }
678
+ }
679
+ catch (err) {
680
+ logger.warn({ err }, 'Failed to surface crash recovery summary');
681
+ }
682
+ })();
668
683
  // Heartbeat + Cron schedulers
669
684
  const { HeartbeatScheduler, CronScheduler } = await import('./gateway/heartbeat.js');
670
685
  const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
@@ -1106,6 +1121,12 @@ function main() {
1106
1121
  process.on('unhandledRejection', (err) => {
1107
1122
  logger.error({ err }, 'Unhandled promise rejection — daemon staying alive');
1108
1123
  });
1124
+ // Crash forensics — write a JSON dump alongside the existing log line
1125
+ // so the next launch can surface "I crashed because X" via chat.
1126
+ // Fire-and-forget: failure to load shouldn't block daemon startup.
1127
+ import('./agent/crash-forensics.js')
1128
+ .then(({ installCrashHandlers }) => installCrashHandlers(config.BASE_DIR))
1129
+ .catch((err) => logger.warn({ err }, 'Failed to install crash forensics handlers — continuing without them'));
1109
1130
  // First-run auto-setup
1110
1131
  const envFile = path.join(config.BASE_DIR, '.env');
1111
1132
  if (!existsSync(envFile)) {
@@ -593,7 +593,10 @@ export declare class MemoryStore {
593
593
  }>;
594
594
  /**
595
595
  * Log token usage from an SDK query result.
596
- * Iterates modelUsage record and inserts one row per model.
596
+ * Iterates modelUsage record and inserts one row per model. Cost is
597
+ * apportioned across models proportionally to total tokens (input +
598
+ * output) so per-agent monthly aggregations stay accurate when a turn
599
+ * uses more than one model.
597
600
  */
598
601
  logUsage(entry: {
599
602
  sessionKey: string;
@@ -607,7 +610,15 @@ export declare class MemoryStore {
607
610
  numTurns: number;
608
611
  durationMs: number;
609
612
  agentSlug?: string;
613
+ /** Total cost in USD for the whole turn (from SDK result.total_cost_usd). */
614
+ totalCostUsd?: number;
610
615
  }): void;
616
+ /**
617
+ * Get the current month's spend in cents for an agent (or for global
618
+ * Clementine if agentSlug is null/undefined). "Month" = first day of
619
+ * the current calendar month in UTC.
620
+ */
621
+ getMonthlyCostCents(agentSlug: string | null | undefined): number;
611
622
  /**
612
623
  * Get aggregated usage summary, optionally filtered by time.
613
624
  */
@@ -405,6 +405,12 @@ export class MemoryStore {
405
405
  this.conn.exec(`CREATE INDEX IF NOT EXISTS idx_usage_agent ON usage_log(agent_slug)`);
406
406
  }
407
407
  catch { /* column already exists */ }
408
+ // Migration: add cost_cents for budget enforcement (per-agent monthly caps).
409
+ // Stored as INTEGER cents to avoid float precision drift across aggregations.
410
+ try {
411
+ this.conn.exec(`ALTER TABLE usage_log ADD COLUMN cost_cents INTEGER DEFAULT 0`);
412
+ }
413
+ catch { /* column already exists */ }
408
414
  // ── SDR Operational Tables ───────────────────────────────────────
409
415
  // Leads — structured prospect records for SDR workflows
410
416
  this.conn.exec(`
@@ -2545,15 +2551,51 @@ export class MemoryStore {
2545
2551
  // ── Usage Tracking ────────────────────────────────────────────────
2546
2552
  /**
2547
2553
  * Log token usage from an SDK query result.
2548
- * Iterates modelUsage record and inserts one row per model.
2554
+ * Iterates modelUsage record and inserts one row per model. Cost is
2555
+ * apportioned across models proportionally to total tokens (input +
2556
+ * output) so per-agent monthly aggregations stay accurate when a turn
2557
+ * uses more than one model.
2549
2558
  */
2550
2559
  logUsage(entry) {
2551
2560
  if (!this._stmtInsertUsage) {
2552
- this._stmtInsertUsage = this.conn.prepare(`INSERT INTO usage_log (session_key, source, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, num_turns, duration_ms, agent_slug)
2553
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2554
- }
2561
+ this._stmtInsertUsage = this.conn.prepare(`INSERT INTO usage_log (session_key, source, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, num_turns, duration_ms, agent_slug, cost_cents)
2562
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2563
+ }
2564
+ // Apportion the total cost across models by token share.
2565
+ const totalCostCents = entry.totalCostUsd != null
2566
+ ? Math.max(0, Math.round(entry.totalCostUsd * 100))
2567
+ : 0;
2568
+ const totalTokens = Object.values(entry.modelUsage).reduce((sum, u) => sum + (u.inputTokens ?? 0) + (u.outputTokens ?? 0), 0);
2555
2569
  for (const [model, usage] of Object.entries(entry.modelUsage)) {
2556
- this._stmtInsertUsage.run(entry.sessionKey, entry.source, model, usage.inputTokens ?? 0, usage.outputTokens ?? 0, usage.cacheReadInputTokens ?? 0, usage.cacheCreationInputTokens ?? 0, entry.numTurns ?? 0, entry.durationMs ?? 0, entry.agentSlug ?? null);
2570
+ const modelTokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
2571
+ const shareCents = totalCostCents > 0 && totalTokens > 0
2572
+ ? Math.round(totalCostCents * (modelTokens / totalTokens))
2573
+ : 0;
2574
+ this._stmtInsertUsage.run(entry.sessionKey, entry.source, model, usage.inputTokens ?? 0, usage.outputTokens ?? 0, usage.cacheReadInputTokens ?? 0, usage.cacheCreationInputTokens ?? 0, entry.numTurns ?? 0, entry.durationMs ?? 0, entry.agentSlug ?? null, shareCents);
2575
+ }
2576
+ }
2577
+ /**
2578
+ * Get the current month's spend in cents for an agent (or for global
2579
+ * Clementine if agentSlug is null/undefined). "Month" = first day of
2580
+ * the current calendar month in UTC.
2581
+ */
2582
+ getMonthlyCostCents(agentSlug) {
2583
+ const startOfMonth = new Date();
2584
+ startOfMonth.setUTCDate(1);
2585
+ startOfMonth.setUTCHours(0, 0, 0, 0);
2586
+ const sinceIso = startOfMonth.toISOString();
2587
+ const where = agentSlug
2588
+ ? 'WHERE agent_slug = ? AND created_at >= ?'
2589
+ : 'WHERE agent_slug IS NULL AND created_at >= ?';
2590
+ const params = agentSlug ? [agentSlug, sinceIso] : [sinceIso];
2591
+ try {
2592
+ const row = this.conn
2593
+ .prepare(`SELECT COALESCE(SUM(cost_cents), 0) as total FROM usage_log ${where}`)
2594
+ .get(...params);
2595
+ return row?.total ?? 0;
2596
+ }
2597
+ catch {
2598
+ return 0;
2557
2599
  }
2558
2600
  }
2559
2601
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.24",
3
+ "version": "1.1.26",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",