clementine-agent 1.1.23 → 1.1.25
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 +22 -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/agent/mcp-circuit-breaker.d.ts +51 -0
- package/dist/agent/mcp-circuit-breaker.js +175 -0
- package/dist/channels/discord.js +24 -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
|
@@ -22,6 +22,7 @@ import { AgentManager } from './agent-manager.js';
|
|
|
22
22
|
import { extractLinks } from './link-extractor.js';
|
|
23
23
|
import { StallGuard } from './stall-guard.js';
|
|
24
24
|
import { collectToolCalls, detectContradiction, buildCorrectionPrompt } from './contradiction-validator.js';
|
|
25
|
+
import { recordToolOutcome as recordMcpToolOutcome } from './mcp-circuit-breaker.js';
|
|
25
26
|
import { assembleContext } from '../memory/context-assembler.js';
|
|
26
27
|
import { PromptCache } from './prompt-cache.js';
|
|
27
28
|
import { searchSkills as searchSkillsSync } from './skill-extractor.js';
|
|
@@ -813,6 +814,7 @@ export class PersonalAssistant {
|
|
|
813
814
|
numTurns: result.num_turns,
|
|
814
815
|
durationMs: result.duration_ms,
|
|
815
816
|
agentSlug: agentSlug ?? undefined,
|
|
817
|
+
totalCostUsd: 'total_cost_usd' in result ? result.total_cost_usd : undefined,
|
|
816
818
|
});
|
|
817
819
|
}
|
|
818
820
|
catch (err) {
|
|
@@ -2946,6 +2948,15 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2946
2948
|
if (!contradictionRetried && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES && responseText.trim()) {
|
|
2947
2949
|
try {
|
|
2948
2950
|
const toolCallRecords = collectToolCalls(collectedSdkMessages);
|
|
2951
|
+
// Feed every tool outcome to the MCP circuit breaker so flaky
|
|
2952
|
+
// connectors get tripped + surfaced via the advisor-events
|
|
2953
|
+
// path that insight-engine already monitors.
|
|
2954
|
+
for (const r of toolCallRecords) {
|
|
2955
|
+
try {
|
|
2956
|
+
recordMcpToolOutcome(r.name, r.resultClass);
|
|
2957
|
+
}
|
|
2958
|
+
catch { /* non-fatal */ }
|
|
2959
|
+
}
|
|
2949
2960
|
// Diagnostic — emits once per turn so we can see what the
|
|
2950
2961
|
// validator is working with even when it doesn't fire. Without
|
|
2951
2962
|
// this we're blind to the "regex missed the phrasing" case.
|
|
@@ -3835,6 +3846,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3835
3846
|
const cronProfile = agentSlug && agentSlug !== 'clementine'
|
|
3836
3847
|
? this.profileManager.get(agentSlug)
|
|
3837
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
|
+
}
|
|
3838
3860
|
// Cron jobs deliver via side effects (sent emails, updated records, etc),
|
|
3839
3861
|
// not chat text — pass mode='cron' so high_effort_low_output guard is
|
|
3840
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
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-MCP-server circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* When an MCP server starts returning errors (auth failures, connector
|
|
5
|
+
* timeouts, "no such tool available") repeatedly, agents keep calling it
|
|
6
|
+
* — burning tool turns on something that isn't going to work. This module
|
|
7
|
+
* tracks per-server failure rates with a sliding window and surfaces a
|
|
8
|
+
* tripped state to the existing insight-engine via advisor-events.jsonl
|
|
9
|
+
* (same path the cron-side circuit breaker uses).
|
|
10
|
+
*
|
|
11
|
+
* Trip rule: K failures of class auth_error/other_error within WINDOW_MS
|
|
12
|
+
* trips the breaker for COOLDOWN_MS. Argument errors are agent-fault, not
|
|
13
|
+
* connector-fault, and don't count toward the trip threshold.
|
|
14
|
+
*
|
|
15
|
+
* Auto-reset: COOLDOWN_MS after the trip moment, the breaker clears and
|
|
16
|
+
* the failure window resets. The next failure starts the count fresh.
|
|
17
|
+
*/
|
|
18
|
+
export type ToolResultClass = 'success' | 'arg_error' | 'auth_error' | 'other_error';
|
|
19
|
+
/**
|
|
20
|
+
* Extract the MCP server name from a fully-qualified tool name. Handles
|
|
21
|
+
* server names that themselves contain underscores (e.g. `claude_ai_Gmail`)
|
|
22
|
+
* by treating only the FINAL `__` separator as the server/tool boundary.
|
|
23
|
+
*
|
|
24
|
+
* mcp__clementine-tools__memory_search → "clementine-tools"
|
|
25
|
+
* mcp__claude_ai_Gmail__authenticate → "claude_ai_Gmail"
|
|
26
|
+
* mcp__ElevenLabs__text_to_speech → "ElevenLabs"
|
|
27
|
+
* mcp__plugin_x_y__do_thing → "plugin_x_y"
|
|
28
|
+
*
|
|
29
|
+
* Returns null for non-MCP tools (Bash, Read, etc.).
|
|
30
|
+
*/
|
|
31
|
+
export declare function extractServerName(toolName: string): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Record the outcome of a single tool invocation. Only auth_error /
|
|
34
|
+
* other_error count toward the failure window — arg_error is the agent's
|
|
35
|
+
* fault (bad parameters), and success obviously doesn't count.
|
|
36
|
+
*/
|
|
37
|
+
export declare function recordToolOutcome(toolName: string, resultClass: ToolResultClass): void;
|
|
38
|
+
/** True when the named server is currently in the open (failing) state. */
|
|
39
|
+
export declare function isServerTripped(server: string): boolean;
|
|
40
|
+
/** Get all currently-tripped servers — useful for status display + system-prompt injection. */
|
|
41
|
+
export declare function getTrippedServers(): Array<{
|
|
42
|
+
server: string;
|
|
43
|
+
trippedAt: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
cooldownRemainingMs: number;
|
|
46
|
+
}>;
|
|
47
|
+
/** Manual reset — used by an `mcp circuit reset` admin command (future). */
|
|
48
|
+
export declare function resetServer(server: string): boolean;
|
|
49
|
+
/** Reset every breaker — used by tests. */
|
|
50
|
+
export declare function _resetAll(): void;
|
|
51
|
+
//# sourceMappingURL=mcp-circuit-breaker.d.ts.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-MCP-server circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* When an MCP server starts returning errors (auth failures, connector
|
|
5
|
+
* timeouts, "no such tool available") repeatedly, agents keep calling it
|
|
6
|
+
* — burning tool turns on something that isn't going to work. This module
|
|
7
|
+
* tracks per-server failure rates with a sliding window and surfaces a
|
|
8
|
+
* tripped state to the existing insight-engine via advisor-events.jsonl
|
|
9
|
+
* (same path the cron-side circuit breaker uses).
|
|
10
|
+
*
|
|
11
|
+
* Trip rule: K failures of class auth_error/other_error within WINDOW_MS
|
|
12
|
+
* trips the breaker for COOLDOWN_MS. Argument errors are agent-fault, not
|
|
13
|
+
* connector-fault, and don't count toward the trip threshold.
|
|
14
|
+
*
|
|
15
|
+
* Auto-reset: COOLDOWN_MS after the trip moment, the breaker clears and
|
|
16
|
+
* the failure window resets. The next failure starts the count fresh.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import pino from 'pino';
|
|
21
|
+
import { BASE_DIR } from '../config.js';
|
|
22
|
+
const logger = pino({ name: 'clementine.mcp-circuit-breaker' });
|
|
23
|
+
/** Threshold to trip the breaker. */
|
|
24
|
+
const MAX_CONNECTOR_FAILURES = 5;
|
|
25
|
+
/** Sliding window for counting failures. */
|
|
26
|
+
const WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
|
27
|
+
/** How long the breaker stays open before auto-resetting. */
|
|
28
|
+
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
29
|
+
const ADVISOR_EVENTS_FILE = path.join(BASE_DIR, 'cron', 'advisor-events.jsonl');
|
|
30
|
+
const state = new Map();
|
|
31
|
+
/**
|
|
32
|
+
* Extract the MCP server name from a fully-qualified tool name. Handles
|
|
33
|
+
* server names that themselves contain underscores (e.g. `claude_ai_Gmail`)
|
|
34
|
+
* by treating only the FINAL `__` separator as the server/tool boundary.
|
|
35
|
+
*
|
|
36
|
+
* mcp__clementine-tools__memory_search → "clementine-tools"
|
|
37
|
+
* mcp__claude_ai_Gmail__authenticate → "claude_ai_Gmail"
|
|
38
|
+
* mcp__ElevenLabs__text_to_speech → "ElevenLabs"
|
|
39
|
+
* mcp__plugin_x_y__do_thing → "plugin_x_y"
|
|
40
|
+
*
|
|
41
|
+
* Returns null for non-MCP tools (Bash, Read, etc.).
|
|
42
|
+
*/
|
|
43
|
+
export function extractServerName(toolName) {
|
|
44
|
+
if (!toolName.startsWith('mcp__'))
|
|
45
|
+
return null;
|
|
46
|
+
const rest = toolName.slice('mcp__'.length);
|
|
47
|
+
const lastSep = rest.lastIndexOf('__');
|
|
48
|
+
if (lastSep <= 0)
|
|
49
|
+
return null;
|
|
50
|
+
return rest.slice(0, lastSep);
|
|
51
|
+
}
|
|
52
|
+
function getServerState(server) {
|
|
53
|
+
let s = state.get(server);
|
|
54
|
+
if (!s) {
|
|
55
|
+
s = { failureTimestamps: [] };
|
|
56
|
+
state.set(server, s);
|
|
57
|
+
}
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Record the outcome of a single tool invocation. Only auth_error /
|
|
62
|
+
* other_error count toward the failure window — arg_error is the agent's
|
|
63
|
+
* fault (bad parameters), and success obviously doesn't count.
|
|
64
|
+
*/
|
|
65
|
+
export function recordToolOutcome(toolName, resultClass) {
|
|
66
|
+
const server = extractServerName(toolName);
|
|
67
|
+
if (!server)
|
|
68
|
+
return; // built-in tool like Bash/Read — not our concern
|
|
69
|
+
const s = getServerState(server);
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
// Auto-reset if the cooldown has expired since the last trip.
|
|
72
|
+
if (s.trippedAt !== undefined && now - s.trippedAt >= COOLDOWN_MS) {
|
|
73
|
+
s.trippedAt = undefined;
|
|
74
|
+
s.trippedReason = undefined;
|
|
75
|
+
s.failureTimestamps = [];
|
|
76
|
+
logger.info({ server }, 'MCP circuit breaker auto-reset after cooldown');
|
|
77
|
+
emitAdvisorEvent({
|
|
78
|
+
type: 'circuit-breaker',
|
|
79
|
+
jobName: `mcp:${server}`,
|
|
80
|
+
detail: 'Connector breaker reset — probing again on next call',
|
|
81
|
+
reset: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (resultClass === 'success') {
|
|
85
|
+
// Successful call inside the window — clear the failure list so a flap
|
|
86
|
+
// doesn't accumulate forever. Don't auto-reset a tripped breaker on
|
|
87
|
+
// success though; that needs to wait for the cooldown so we don't
|
|
88
|
+
// ping-pong on intermittent failures.
|
|
89
|
+
if (s.trippedAt === undefined) {
|
|
90
|
+
s.failureTimestamps = [];
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (resultClass === 'arg_error') {
|
|
95
|
+
// Agent passed bad args — connector itself is fine.
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// auth_error or other_error — count toward the failure window.
|
|
99
|
+
s.failureTimestamps.push(now);
|
|
100
|
+
// Drop old timestamps outside the window.
|
|
101
|
+
s.failureTimestamps = s.failureTimestamps.filter(t => now - t <= WINDOW_MS);
|
|
102
|
+
if (s.trippedAt === undefined && s.failureTimestamps.length >= MAX_CONNECTOR_FAILURES) {
|
|
103
|
+
s.trippedAt = now;
|
|
104
|
+
s.trippedReason = `${s.failureTimestamps.length} ${resultClass} failure(s) in the last ${Math.round(WINDOW_MS / 60_000)}m`;
|
|
105
|
+
logger.warn({ server, failures: s.failureTimestamps.length, resultClass }, 'MCP circuit breaker tripped');
|
|
106
|
+
emitAdvisorEvent({
|
|
107
|
+
type: 'circuit-breaker',
|
|
108
|
+
jobName: `mcp:${server}`,
|
|
109
|
+
detail: `MCP connector "${server}" tripped — ${s.trippedReason}. Prefer alternatives until cooldown expires (~${Math.round(COOLDOWN_MS / 60_000)}m).`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** True when the named server is currently in the open (failing) state. */
|
|
114
|
+
export function isServerTripped(server) {
|
|
115
|
+
const s = state.get(server);
|
|
116
|
+
if (!s || s.trippedAt === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
if (Date.now() - s.trippedAt >= COOLDOWN_MS)
|
|
119
|
+
return false;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
/** Get all currently-tripped servers — useful for status display + system-prompt injection. */
|
|
123
|
+
export function getTrippedServers() {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const [server, s] of state) {
|
|
127
|
+
if (s.trippedAt === undefined)
|
|
128
|
+
continue;
|
|
129
|
+
const remaining = COOLDOWN_MS - (now - s.trippedAt);
|
|
130
|
+
if (remaining <= 0)
|
|
131
|
+
continue;
|
|
132
|
+
out.push({
|
|
133
|
+
server,
|
|
134
|
+
trippedAt: new Date(s.trippedAt).toISOString(),
|
|
135
|
+
reason: s.trippedReason ?? 'unknown',
|
|
136
|
+
cooldownRemainingMs: remaining,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** Manual reset — used by an `mcp circuit reset` admin command (future). */
|
|
142
|
+
export function resetServer(server) {
|
|
143
|
+
const s = state.get(server);
|
|
144
|
+
if (!s || s.trippedAt === undefined)
|
|
145
|
+
return false;
|
|
146
|
+
s.trippedAt = undefined;
|
|
147
|
+
s.trippedReason = undefined;
|
|
148
|
+
s.failureTimestamps = [];
|
|
149
|
+
logger.info({ server }, 'MCP circuit breaker manually reset');
|
|
150
|
+
emitAdvisorEvent({
|
|
151
|
+
type: 'circuit-breaker',
|
|
152
|
+
jobName: `mcp:${server}`,
|
|
153
|
+
detail: 'Manually reset',
|
|
154
|
+
reset: true,
|
|
155
|
+
});
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
/** Reset every breaker — used by tests. */
|
|
159
|
+
export function _resetAll() {
|
|
160
|
+
state.clear();
|
|
161
|
+
}
|
|
162
|
+
function emitAdvisorEvent(evt) {
|
|
163
|
+
try {
|
|
164
|
+
mkdirSync(path.dirname(ADVISOR_EVENTS_FILE), { recursive: true });
|
|
165
|
+
if (!existsSync(path.dirname(ADVISOR_EVENTS_FILE)))
|
|
166
|
+
return;
|
|
167
|
+
const line = JSON.stringify({ timestamp: new Date().toISOString(), ...evt }) + '\n';
|
|
168
|
+
appendFileSync(ADVISOR_EVENTS_FILE, line);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
// Non-fatal — observability event, not load-bearing for the breaker logic.
|
|
172
|
+
logger.debug({ err }, 'Failed to emit advisor event for MCP circuit breaker');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=mcp-circuit-breaker.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/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
|
/**
|