clementine-agent 1.1.22 → 1.1.24
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
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';
|
|
@@ -2946,6 +2947,15 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2946
2947
|
if (!contradictionRetried && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES && responseText.trim()) {
|
|
2947
2948
|
try {
|
|
2948
2949
|
const toolCallRecords = collectToolCalls(collectedSdkMessages);
|
|
2950
|
+
// Feed every tool outcome to the MCP circuit breaker so flaky
|
|
2951
|
+
// connectors get tripped + surfaced via the advisor-events
|
|
2952
|
+
// path that insight-engine already monitors.
|
|
2953
|
+
for (const r of toolCallRecords) {
|
|
2954
|
+
try {
|
|
2955
|
+
recordMcpToolOutcome(r.name, r.resultClass);
|
|
2956
|
+
}
|
|
2957
|
+
catch { /* non-fatal */ }
|
|
2958
|
+
}
|
|
2949
2959
|
// Diagnostic — emits once per turn so we can see what the
|
|
2950
2960
|
// validator is working with even when it doesn't fire. Without
|
|
2951
2961
|
// this we're blind to the "regex missed the phrasing" case.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-agent brain digest.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates raw signals from across the team (memory recurrence, cron
|
|
5
|
+
* activity, memory growth) and runs a single LLM synthesis pass to
|
|
6
|
+
* produce a leadable markdown narrative — what the team accomplished,
|
|
7
|
+
* what they learned in common, where to lead next.
|
|
8
|
+
*
|
|
9
|
+
* Intended caller: `clementine brain digest` CLI for v1; cron entry +
|
|
10
|
+
* heartbeat-side proactive surfacing for v2.
|
|
11
|
+
*/
|
|
12
|
+
import type { AgentManager } from './agent-manager.js';
|
|
13
|
+
import type { MemoryStore } from '../memory/store.js';
|
|
14
|
+
import type { PersonalAssistant } from './assistant.js';
|
|
15
|
+
export interface BrainDigestInputs {
|
|
16
|
+
windowDays: number;
|
|
17
|
+
agents: Array<{
|
|
18
|
+
slug: string;
|
|
19
|
+
name: string;
|
|
20
|
+
}>;
|
|
21
|
+
/** Clusters of similar memory chunks recurring across multiple agents. */
|
|
22
|
+
crossAgentClusters: Array<{
|
|
23
|
+
agents: string[];
|
|
24
|
+
representativeContent: string;
|
|
25
|
+
representativeSource: string;
|
|
26
|
+
memberCount: number;
|
|
27
|
+
}>;
|
|
28
|
+
/** Per-job summary of runs in the window. */
|
|
29
|
+
cronRunsByJob: Array<{
|
|
30
|
+
jobName: string;
|
|
31
|
+
agentSlug: string | null;
|
|
32
|
+
runs: number;
|
|
33
|
+
failures: number;
|
|
34
|
+
}>;
|
|
35
|
+
/** Chunk count growth per agent in the window — proxy for "what they worked on". */
|
|
36
|
+
memoryDeltas: Array<{
|
|
37
|
+
agentSlug: string;
|
|
38
|
+
chunksAdded: number;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
/** Aggregate raw signals — pure data, no LLM call. */
|
|
42
|
+
export declare function gatherBrainDigestInputs(opts: {
|
|
43
|
+
agentManager: AgentManager;
|
|
44
|
+
memoryStore: MemoryStore;
|
|
45
|
+
baseDir: string;
|
|
46
|
+
windowDays: number;
|
|
47
|
+
}): BrainDigestInputs;
|
|
48
|
+
/**
|
|
49
|
+
* Format the raw inputs as a single text block the LLM can synthesize.
|
|
50
|
+
* Kept terse — the LLM does the heavy lifting of pattern surfacing.
|
|
51
|
+
*/
|
|
52
|
+
export declare function formatRawMaterial(inputs: BrainDigestInputs): string;
|
|
53
|
+
export declare function runBrainDigest(opts: {
|
|
54
|
+
assistant: PersonalAssistant;
|
|
55
|
+
agentManager: AgentManager;
|
|
56
|
+
memoryStore: MemoryStore;
|
|
57
|
+
baseDir: string;
|
|
58
|
+
windowDays?: number;
|
|
59
|
+
model?: string;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
markdown: string;
|
|
62
|
+
inputs: BrainDigestInputs;
|
|
63
|
+
}>;
|
|
64
|
+
//# sourceMappingURL=brain-digest.d.ts.map
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-agent brain digest.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates raw signals from across the team (memory recurrence, cron
|
|
5
|
+
* activity, memory growth) and runs a single LLM synthesis pass to
|
|
6
|
+
* produce a leadable markdown narrative — what the team accomplished,
|
|
7
|
+
* what they learned in common, where to lead next.
|
|
8
|
+
*
|
|
9
|
+
* Intended caller: `clementine brain digest` CLI for v1; cron entry +
|
|
10
|
+
* heartbeat-side proactive surfacing for v2.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import pino from 'pino';
|
|
15
|
+
const logger = pino({ name: 'clementine.brain-digest' });
|
|
16
|
+
/** Aggregate raw signals — pure data, no LLM call. */
|
|
17
|
+
export function gatherBrainDigestInputs(opts) {
|
|
18
|
+
const sinceMs = Date.now() - opts.windowDays * 86_400_000;
|
|
19
|
+
const sinceIso = new Date(sinceMs).toISOString();
|
|
20
|
+
const agents = opts.agentManager.listAll().map(a => ({ slug: a.slug, name: a.name }));
|
|
21
|
+
// 1. Cross-agent memory recurrence — facts/topics surfaced by 2+ agents.
|
|
22
|
+
let crossAgentClusters = [];
|
|
23
|
+
try {
|
|
24
|
+
const clusters = opts.memoryStore.findCrossAgentRecurrence({
|
|
25
|
+
threshold: 0.85,
|
|
26
|
+
minAgents: 2,
|
|
27
|
+
limit: 20,
|
|
28
|
+
});
|
|
29
|
+
crossAgentClusters = clusters.map(c => ({
|
|
30
|
+
agents: c.agents,
|
|
31
|
+
representativeContent: c.representative.content.slice(0, 400),
|
|
32
|
+
representativeSource: `${c.representative.sourceFile}>${c.representative.section}`,
|
|
33
|
+
memberCount: c.members.length,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
logger.debug({ err }, 'Cross-agent recurrence scan failed — continuing with empty list');
|
|
38
|
+
}
|
|
39
|
+
// 2. Cron run summary — walk cron/runs/*.jsonl, filter to the window.
|
|
40
|
+
const cronRunsByJob = gatherCronRunsByJob(opts.baseDir, sinceIso);
|
|
41
|
+
// 3. Memory deltas — chunk growth per agent in the window.
|
|
42
|
+
const memoryDeltas = gatherMemoryDeltas(opts.memoryStore, sinceIso);
|
|
43
|
+
return {
|
|
44
|
+
windowDays: opts.windowDays,
|
|
45
|
+
agents,
|
|
46
|
+
crossAgentClusters,
|
|
47
|
+
cronRunsByJob,
|
|
48
|
+
memoryDeltas,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function gatherCronRunsByJob(baseDir, sinceIso) {
|
|
52
|
+
const runsDir = path.join(baseDir, 'cron', 'runs');
|
|
53
|
+
if (!existsSync(runsDir))
|
|
54
|
+
return [];
|
|
55
|
+
const sinceMs = Date.parse(sinceIso);
|
|
56
|
+
const aggregates = new Map();
|
|
57
|
+
for (const file of readdirSync(runsDir).filter(f => f.endsWith('.jsonl'))) {
|
|
58
|
+
const jobName = file.replace(/\.jsonl$/, '');
|
|
59
|
+
const filePath = path.join(runsDir, file);
|
|
60
|
+
let lines;
|
|
61
|
+
try {
|
|
62
|
+
lines = readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
let runs = 0;
|
|
68
|
+
let failures = 0;
|
|
69
|
+
let agentSlug = null;
|
|
70
|
+
// jobNames may be agent-scoped: "<agent-slug>:<job>"
|
|
71
|
+
const parts = jobName.split(':');
|
|
72
|
+
if (parts.length > 1) {
|
|
73
|
+
agentSlug = parts[0];
|
|
74
|
+
}
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
try {
|
|
77
|
+
const entry = JSON.parse(line);
|
|
78
|
+
const ts = entry.startedAt ?? entry.finishedAt;
|
|
79
|
+
if (!ts)
|
|
80
|
+
continue;
|
|
81
|
+
if (Date.parse(ts) < sinceMs)
|
|
82
|
+
continue;
|
|
83
|
+
runs++;
|
|
84
|
+
if (entry.status === 'error')
|
|
85
|
+
failures++;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (runs > 0) {
|
|
92
|
+
aggregates.set(jobName, { jobName, agentSlug, runs, failures });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Array.from(aggregates.values()).sort((a, b) => b.runs - a.runs);
|
|
96
|
+
}
|
|
97
|
+
function gatherMemoryDeltas(memoryStore, sinceIso) {
|
|
98
|
+
// Reach into the underlying connection — same pattern memory-tools.ts uses.
|
|
99
|
+
const conn = memoryStore.conn;
|
|
100
|
+
try {
|
|
101
|
+
const rows = conn
|
|
102
|
+
.prepare(`SELECT COALESCE(agent_slug, 'global') as agentSlug, COUNT(*) as chunksAdded
|
|
103
|
+
FROM chunks
|
|
104
|
+
WHERE updated_at >= ?
|
|
105
|
+
GROUP BY agent_slug
|
|
106
|
+
ORDER BY chunksAdded DESC`)
|
|
107
|
+
.all(sinceIso);
|
|
108
|
+
return rows;
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
logger.debug({ err }, 'Memory delta query failed — continuing with empty list');
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Format the raw inputs as a single text block the LLM can synthesize.
|
|
117
|
+
* Kept terse — the LLM does the heavy lifting of pattern surfacing.
|
|
118
|
+
*/
|
|
119
|
+
export function formatRawMaterial(inputs) {
|
|
120
|
+
const sections = [];
|
|
121
|
+
sections.push(`## Window\nLast ${inputs.windowDays} days.`);
|
|
122
|
+
sections.push(`## Team roster\n${inputs.agents.length === 0 ? '(no specialist agents)' : inputs.agents.map(a => `- ${a.name} (${a.slug})`).join('\n')}`);
|
|
123
|
+
if (inputs.cronRunsByJob.length === 0) {
|
|
124
|
+
sections.push(`## Cron activity\n(no autonomous runs in window)`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const lines = inputs.cronRunsByJob.slice(0, 20).map(r => {
|
|
128
|
+
const tag = r.agentSlug ? ` [${r.agentSlug}]` : '';
|
|
129
|
+
const failTag = r.failures > 0 ? ` — ${r.failures} failure${r.failures === 1 ? '' : 's'}` : '';
|
|
130
|
+
return `- ${r.jobName}${tag}: ${r.runs} run${r.runs === 1 ? '' : 's'}${failTag}`;
|
|
131
|
+
});
|
|
132
|
+
sections.push(`## Cron activity\n${lines.join('\n')}`);
|
|
133
|
+
}
|
|
134
|
+
if (inputs.memoryDeltas.length === 0) {
|
|
135
|
+
sections.push(`## Memory growth\n(no new chunks in window)`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const lines = inputs.memoryDeltas.map(d => `- ${d.agentSlug}: +${d.chunksAdded} chunks`);
|
|
139
|
+
sections.push(`## Memory growth\n${lines.join('\n')}`);
|
|
140
|
+
}
|
|
141
|
+
if (inputs.crossAgentClusters.length === 0) {
|
|
142
|
+
sections.push(`## Cross-agent recurrence\n(no facts surfaced from 2+ agents)`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const lines = inputs.crossAgentClusters.slice(0, 12).map((c, i) => {
|
|
146
|
+
const preview = c.representativeContent.replace(/\n/g, ' ').slice(0, 200);
|
|
147
|
+
return `${i + 1}. agents: ${c.agents.join(', ')} (${c.memberCount} chunks)\n "${preview}${preview.length >= 200 ? '…' : ''}"`;
|
|
148
|
+
});
|
|
149
|
+
sections.push(`## Cross-agent recurrence\n${lines.join('\n')}`);
|
|
150
|
+
}
|
|
151
|
+
return sections.join('\n\n');
|
|
152
|
+
}
|
|
153
|
+
const SYNTHESIS_SYSTEM_PROMPT = `You are Clementine, the master assistant. Your team of specialist agents has been working autonomously, and you need to write a **brain digest** — a leadable summary of what happened over the window, what the team learned in common, and where you should lead them next.
|
|
154
|
+
|
|
155
|
+
Format the digest as markdown:
|
|
156
|
+
|
|
157
|
+
# Brain Digest — last {N} days
|
|
158
|
+
|
|
159
|
+
## What happened
|
|
160
|
+
2-3 sentence overview of activity. Be specific about who did what.
|
|
161
|
+
|
|
162
|
+
## What we learned together
|
|
163
|
+
The cross-agent recurrence section shows facts/topics that surfaced from MULTIPLE agents — these are the team's emerging shared knowledge. List the 3-5 most meaningful patterns. If empty, say "Nothing recurred across agents this window — the team's still working in parallel silos."
|
|
164
|
+
|
|
165
|
+
## Where to lead
|
|
166
|
+
2-3 concrete priorities or follow-ups based on what you see. What's the team's biggest opportunity? What's at risk? What's a clear next move?
|
|
167
|
+
|
|
168
|
+
## Per-agent highlights
|
|
169
|
+
One bullet per active agent — what they worked on, status (healthy / quiet / failing). Skip agents with no activity.
|
|
170
|
+
|
|
171
|
+
**Style rules:**
|
|
172
|
+
- Lead with what matters. Don't list raw data — synthesize.
|
|
173
|
+
- Be honest about sparse data. If the window is quiet, say so. Don't pad.
|
|
174
|
+
- Under 400 words total. Cut anything that doesn't help you lead the team.
|
|
175
|
+
- No greeting, no sign-off — this is a working document.
|
|
176
|
+
`;
|
|
177
|
+
export async function runBrainDigest(opts) {
|
|
178
|
+
const windowDays = opts.windowDays ?? 7;
|
|
179
|
+
const inputs = gatherBrainDigestInputs({
|
|
180
|
+
agentManager: opts.agentManager,
|
|
181
|
+
memoryStore: opts.memoryStore,
|
|
182
|
+
baseDir: opts.baseDir,
|
|
183
|
+
windowDays,
|
|
184
|
+
});
|
|
185
|
+
const rawMaterial = formatRawMaterial(inputs);
|
|
186
|
+
const prompt = `${SYNTHESIS_SYSTEM_PROMPT.replace('{N}', String(windowDays))}\n\n---\n\n# Raw signals\n\n${rawMaterial}`;
|
|
187
|
+
logger.info({ windowDays, agents: inputs.agents.length, clusters: inputs.crossAgentClusters.length, jobs: inputs.cronRunsByJob.length }, 'Running brain digest synthesis');
|
|
188
|
+
const markdown = await opts.assistant.runPlanStep('brain-digest', prompt, {
|
|
189
|
+
tier: 1,
|
|
190
|
+
maxTurns: 3,
|
|
191
|
+
model: opts.model ?? 'sonnet',
|
|
192
|
+
disableTools: true, // synthesis only — no tool calls
|
|
193
|
+
});
|
|
194
|
+
return { markdown: markdown.trim(), inputs };
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=brain-digest.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/cli/index.js
CHANGED
|
@@ -2130,6 +2130,80 @@ configCmd
|
|
|
2130
2130
|
console.error(` Failed to open editor: ${editor}`);
|
|
2131
2131
|
}
|
|
2132
2132
|
});
|
|
2133
|
+
// ── Brain commands ──────────────────────────────────────────────────
|
|
2134
|
+
const brainCmd = program
|
|
2135
|
+
.command('brain')
|
|
2136
|
+
.description('Cross-agent synthesis — leadable summaries of what your team learned');
|
|
2137
|
+
brainCmd
|
|
2138
|
+
.command('digest')
|
|
2139
|
+
.description('Run a brain digest — synthesize the past N days of cross-agent activity into a leadable narrative')
|
|
2140
|
+
.option('-d, --days <n>', 'Window in days', '7')
|
|
2141
|
+
.option('-m, --model <model>', 'Model to use for synthesis (sonnet, haiku, opus)', 'sonnet')
|
|
2142
|
+
.option('--save', 'Also save the digest to vault/00-System/brain-digests/<date>.md')
|
|
2143
|
+
.option('--raw', 'Print the raw signals only — skip the LLM synthesis')
|
|
2144
|
+
.action(async (opts) => {
|
|
2145
|
+
const BOLD = '\x1b[1m';
|
|
2146
|
+
const DIM = '\x1b[0;90m';
|
|
2147
|
+
const GREEN = '\x1b[0;32m';
|
|
2148
|
+
const RED = '\x1b[0;31m';
|
|
2149
|
+
const RESET = '\x1b[0m';
|
|
2150
|
+
const days = Math.max(1, Math.min(60, parseInt(opts.days, 10) || 7));
|
|
2151
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
2152
|
+
delete process.env['CLAUDECODE'];
|
|
2153
|
+
try {
|
|
2154
|
+
const { AgentManager } = await import('../agent/agent-manager.js');
|
|
2155
|
+
const { MemoryStore } = await import('../memory/store.js');
|
|
2156
|
+
const { gatherBrainDigestInputs, formatRawMaterial, runBrainDigest } = await import('../agent/brain-digest.js');
|
|
2157
|
+
const VAULT_DIR = path.join(BASE_DIR, 'vault');
|
|
2158
|
+
const DB_PATH = path.join(VAULT_DIR, '.memory.db');
|
|
2159
|
+
const AGENTS_DIR = path.join(BASE_DIR, 'agents');
|
|
2160
|
+
const agentManager = new AgentManager(AGENTS_DIR);
|
|
2161
|
+
const memoryStore = new MemoryStore(DB_PATH, VAULT_DIR);
|
|
2162
|
+
// Raw mode short-circuits the LLM call — useful for inspecting signals.
|
|
2163
|
+
if (opts.raw) {
|
|
2164
|
+
const inputs = gatherBrainDigestInputs({ agentManager, memoryStore, baseDir: BASE_DIR, windowDays: days });
|
|
2165
|
+
console.log();
|
|
2166
|
+
console.log(` ${BOLD}Brain digest — raw signals (${days} days)${RESET}`);
|
|
2167
|
+
console.log();
|
|
2168
|
+
console.log(formatRawMaterial(inputs));
|
|
2169
|
+
console.log();
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
console.log();
|
|
2173
|
+
console.log(` ${DIM}Synthesizing brain digest over ${days} days using ${opts.model}…${RESET}`);
|
|
2174
|
+
const { PersonalAssistant } = await import('../agent/assistant.js');
|
|
2175
|
+
const assistant = new PersonalAssistant();
|
|
2176
|
+
// Headless: auto-deny any approval prompts during synthesis.
|
|
2177
|
+
const { setApprovalCallback } = await import('../agent/hooks.js');
|
|
2178
|
+
setApprovalCallback(async () => false);
|
|
2179
|
+
const result = await runBrainDigest({
|
|
2180
|
+
assistant,
|
|
2181
|
+
agentManager,
|
|
2182
|
+
memoryStore,
|
|
2183
|
+
baseDir: BASE_DIR,
|
|
2184
|
+
windowDays: days,
|
|
2185
|
+
model: opts.model,
|
|
2186
|
+
});
|
|
2187
|
+
console.log();
|
|
2188
|
+
console.log(result.markdown);
|
|
2189
|
+
console.log();
|
|
2190
|
+
console.log(` ${DIM}Sources: ${result.inputs.agents.length} agent(s), ${result.inputs.cronRunsByJob.length} cron job(s) active, ${result.inputs.crossAgentClusters.length} cross-agent cluster(s).${RESET}`);
|
|
2191
|
+
if (opts.save) {
|
|
2192
|
+
const digestsDir = path.join(VAULT_DIR, '00-System', 'brain-digests');
|
|
2193
|
+
mkdirSync(digestsDir, { recursive: true });
|
|
2194
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
2195
|
+
const filename = path.join(digestsDir, `${stamp}-${days}d.md`);
|
|
2196
|
+
const fileBody = `---\ntype: brain-digest\ngeneratedAt: ${new Date().toISOString()}\nwindowDays: ${days}\nmodel: ${opts.model}\n---\n\n${result.markdown}\n`;
|
|
2197
|
+
writeFileSync(filename, fileBody);
|
|
2198
|
+
console.log(` ${GREEN}✓${RESET} Saved to ${DIM}${filename}${RESET}`);
|
|
2199
|
+
}
|
|
2200
|
+
console.log();
|
|
2201
|
+
}
|
|
2202
|
+
catch (err) {
|
|
2203
|
+
console.error(` ${RED}Error generating brain digest:${RESET} ${err instanceof Error ? err.message : String(err)}`);
|
|
2204
|
+
process.exit(1);
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
2133
2207
|
// ── Agent commands ──────────────────────────────────────────────────
|
|
2134
2208
|
const agentCmd = program
|
|
2135
2209
|
.command('agent')
|