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.
- package/dist/agent/assistant.js +12 -0
- package/dist/agent/budget-enforcement.d.ts +41 -0
- package/dist/agent/budget-enforcement.js +98 -0
- package/dist/agent/crash-forensics.d.ts +76 -0
- package/dist/agent/crash-forensics.js +197 -0
- package/dist/channels/discord.js +24 -0
- package/dist/cli/index.js +183 -0
- package/dist/index.js +21 -0
- package/dist/memory/store.d.ts +12 -1
- package/dist/memory/store.js +47 -5
- package/package.json +1 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -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
|
package/dist/channels/discord.js
CHANGED
|
@@ -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)) {
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|