clementine-agent 1.0.22 → 1.0.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.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Clementine TypeScript — Lightweight complexity classifier.
3
+ *
4
+ * Deterministic regex + length heuristics that decide whether a user
5
+ * message is "complex" enough to warrant planning-before-acting. No
6
+ * LLM call — gate is cheap enough to run on every message.
7
+ *
8
+ * When complex, the gateway injects a "plan-first" system-prompt
9
+ * directive so the agent proposes a numbered plan and waits for
10
+ * confirmation before diving in. Not perfect — the LLM still decides
11
+ * what "plan" means — but much more consistent than a generic
12
+ * SOUL.md directive that the model ignores half the time.
13
+ */
14
+ export interface ComplexityVerdict {
15
+ complex: boolean;
16
+ reason: string;
17
+ signals: string[];
18
+ }
19
+ /**
20
+ * Classify complexity. Pure function — no LLM, no I/O.
21
+ */
22
+ export declare function classifyComplexity(text: string): ComplexityVerdict;
23
+ /**
24
+ * Build a system-prompt directive to inject when a complex message is
25
+ * detected. Prepended to Clementine's normal system prompt for this
26
+ * single query only. Short + declarative — meta-instructions are
27
+ * easier for the model to follow when they're terse.
28
+ */
29
+ export declare function planFirstDirective(): string;
30
+ //# sourceMappingURL=complexity-classifier.d.ts.map
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Clementine TypeScript — Lightweight complexity classifier.
3
+ *
4
+ * Deterministic regex + length heuristics that decide whether a user
5
+ * message is "complex" enough to warrant planning-before-acting. No
6
+ * LLM call — gate is cheap enough to run on every message.
7
+ *
8
+ * When complex, the gateway injects a "plan-first" system-prompt
9
+ * directive so the agent proposes a numbered plan and waits for
10
+ * confirmation before diving in. Not perfect — the LLM still decides
11
+ * what "plan" means — but much more consistent than a generic
12
+ * SOUL.md directive that the model ignores half the time.
13
+ */
14
+ /**
15
+ * Action verbs that signal the user is asking Clementine to DO things
16
+ * (as opposed to asking questions or making small talk). Multiple
17
+ * action verbs in one message is a strong complexity signal.
18
+ */
19
+ const ACTION_VERBS = [
20
+ 'send', 'create', 'run', 'schedule', 'update', 'delete', 'add', 'remove',
21
+ 'draft', 'write', 'post', 'publish', 'deploy', 'build', 'edit', 'move',
22
+ 'rename', 'archive', 'restore', 'assign', 'delegate', 'email', 'message',
23
+ 'invite', 'book', 'cancel', 'notify', 'alert', 'set up', 'tear down',
24
+ 'process', 'review', 'approve', 'reject',
25
+ 'extract', 'fetch', 'pull', 'gather', 'compile', 'summarize', 'analyze',
26
+ 'generate', 'produce', 'export', 'import', 'upload', 'download', 'sync',
27
+ ];
28
+ /**
29
+ * Chain markers — "do X and then Y" explicitly encode a multi-step
30
+ * task. A single occurrence in a DO-type message is a clear signal.
31
+ */
32
+ const CHAIN_MARKERS = [
33
+ /\band\s+then\b/i,
34
+ /,\s+then\b/i, // "X, then Y"
35
+ /\bfirst\b[\s\S]{0,80}\bthen\b/i,
36
+ /\bafter\s+(that|which)\b/i,
37
+ /\bonce\s+(that|you)\b.*,/i,
38
+ /\bnext\b.*,/i,
39
+ ];
40
+ /**
41
+ * Phrasings that explicitly ask for plan-first behavior. Triggers
42
+ * regardless of other heuristics.
43
+ */
44
+ const EXPLICIT_PLAN_ASKS = [
45
+ /\bpropose\s+a\s+plan\b/i,
46
+ /\bwhat\s+(would|'d)\s+be\s+your\s+approach\b/i,
47
+ /\bplan\s+(this|it)\s+out\b/i,
48
+ /\blay\s+out\s+(a|the)\s+plan\b/i,
49
+ /\bwalk\s+me\s+through\s+(what|how)\b/i,
50
+ ];
51
+ function countActionVerbs(text) {
52
+ const lower = text.toLowerCase();
53
+ let count = 0;
54
+ for (const v of ACTION_VERBS) {
55
+ const re = new RegExp(`\\b${v.replace(/\s+/g, '\\s+')}\\b`, 'g');
56
+ const matches = lower.match(re);
57
+ if (matches)
58
+ count += matches.length;
59
+ }
60
+ return count;
61
+ }
62
+ /**
63
+ * Rough entity count — quoted strings, @mentions, and capitalized
64
+ * multi-word phrases that look like proper nouns. Not perfect;
65
+ * designed to catch cases like "email John, Sarah, and Mike".
66
+ */
67
+ function countEntities(text) {
68
+ let count = 0;
69
+ // Quoted strings
70
+ count += (text.match(/"[^"]{2,60}"/g) ?? []).length;
71
+ count += (text.match(/'[^']{2,60}'/g) ?? []).length;
72
+ // @mentions
73
+ count += (text.match(/@\w+/g) ?? []).length;
74
+ // Comma-separated name lists (e.g., "John, Sarah, and Mike")
75
+ const listMatch = text.match(/(?:[A-Z][a-z]{1,20},\s+){2,}(?:and\s+)?[A-Z][a-z]{1,20}/);
76
+ if (listMatch)
77
+ count += 3;
78
+ return count;
79
+ }
80
+ /**
81
+ * Classify complexity. Pure function — no LLM, no I/O.
82
+ */
83
+ export function classifyComplexity(text) {
84
+ if (!text || typeof text !== 'string')
85
+ return { complex: false, reason: 'empty', signals: [] };
86
+ const trimmed = text.trim();
87
+ // Skip commands and very short messages
88
+ if (trimmed.length < 30)
89
+ return { complex: false, reason: 'too short', signals: [] };
90
+ if (trimmed.startsWith('!') || trimmed.startsWith('/'))
91
+ return { complex: false, reason: 'command', signals: [] };
92
+ const signals = [];
93
+ // Signal 1: explicit ask for plan-first
94
+ for (const re of EXPLICIT_PLAN_ASKS) {
95
+ if (re.test(trimmed)) {
96
+ return { complex: true, reason: 'user explicitly asked for a plan', signals: ['explicit-plan-ask'] };
97
+ }
98
+ }
99
+ // Signal 2: multiple action verbs
100
+ const verbs = countActionVerbs(trimmed);
101
+ if (verbs >= 3)
102
+ signals.push(`${verbs} action verbs`);
103
+ // Signal 3: chain markers
104
+ for (const re of CHAIN_MARKERS) {
105
+ if (re.test(trimmed)) {
106
+ signals.push('chain marker');
107
+ break;
108
+ }
109
+ }
110
+ // Signal 4: multiple entities
111
+ const entities = countEntities(trimmed);
112
+ if (entities >= 3)
113
+ signals.push(`${entities} entities`);
114
+ // Signal 5: long message with at least one action verb (big scope, not just a question)
115
+ if (trimmed.length > 400 && verbs >= 1)
116
+ signals.push('long + action');
117
+ // Gate: at least 2 signals fire, OR a single high-confidence signal
118
+ // (chain markers, explicit-plan-ask, or 3+ action verbs).
119
+ const highConfidenceSingles = [
120
+ verbs >= 3,
121
+ signals.includes('chain marker'),
122
+ ];
123
+ if (highConfidenceSingles.some(Boolean)) {
124
+ return { complex: true, reason: 'strong single signal', signals };
125
+ }
126
+ if (signals.length >= 2) {
127
+ return { complex: true, reason: 'multiple signals', signals };
128
+ }
129
+ return { complex: false, reason: 'below threshold', signals };
130
+ }
131
+ /**
132
+ * Build a system-prompt directive to inject when a complex message is
133
+ * detected. Prepended to Clementine's normal system prompt for this
134
+ * single query only. Short + declarative — meta-instructions are
135
+ * easier for the model to follow when they're terse.
136
+ */
137
+ export function planFirstDirective() {
138
+ return [
139
+ '## PLAN BEFORE ACTING',
140
+ '',
141
+ 'This request has multiple steps. Before doing any of them:',
142
+ '1. Write a numbered plan (3-7 steps, one line each).',
143
+ '2. Call out anything that needs my decision — which contact, which template, which timing.',
144
+ '3. End with: "Reply **go** to start, or tell me what to change."',
145
+ '4. STOP. Do NOT start executing the plan in this turn.',
146
+ '',
147
+ 'When I reply "go" (or equivalent) in the next message, proceed with the plan.',
148
+ 'If I edit the plan, revise and ask again.',
149
+ '',
150
+ 'SKIP this protocol only if the request is actually a single step disguised as multiple (e.g., "send an email to Aaron about X and cc Sarah" is one email, not two).',
151
+ ].join('\n');
152
+ }
153
+ //# sourceMappingURL=complexity-classifier.js.map
@@ -168,6 +168,48 @@ export function gatherInsightSignals(gateway) {
168
168
  }
169
169
  }
170
170
  catch { /* non-fatal */ }
171
+ // 5. Broken jobs from the failure monitor. Any currently-flagged job
172
+ // with a diagnosis is a real, actionable signal the owner should
173
+ // see proactively rather than stumble across in the dashboard.
174
+ try {
175
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
176
+ const fm = require('../gateway/failure-monitor.js');
177
+ const broken = fm.computeBrokenJobs();
178
+ for (const b of broken.slice(0, 3)) {
179
+ const hint = b.diagnosis?.rootCause
180
+ ? ` — ${b.diagnosis.rootCause.slice(0, 120)}`
181
+ : '';
182
+ signals.push(`Broken cron job "${b.jobName}": ${b.errorCount48h}/${b.totalRuns48h} failures${hint}`);
183
+ if (b.diagnosis?.proposedFix?.autoApply) {
184
+ signals.push(`One-click fix available for "${b.jobName}" — ${b.diagnosis.proposedFix.details.slice(0, 100)}`);
185
+ }
186
+ }
187
+ }
188
+ catch { /* failure-monitor may not be loadable; fine */ }
189
+ // 6. Claim tracker — failed claims in the last N hours erode trust.
190
+ // Surface them so the owner sees "Clementine said she'd do X; she
191
+ // didn't" instead of silently swallowing the miss.
192
+ try {
193
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
194
+ const { MemoryStore } = require('../memory/store.js');
195
+ const { MEMORY_DB_PATH, VAULT_DIR } = require('../config.js');
196
+ if (existsSync(MEMORY_DB_PATH)) {
197
+ const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
198
+ store.initialize();
199
+ const db = store.conn;
200
+ try {
201
+ const rows = db.prepare(`SELECT subject, claim_type FROM claims
202
+ WHERE status = 'failed' AND verified_at >= datetime('now', '-6 hours')
203
+ ORDER BY verified_at DESC LIMIT 3`).all();
204
+ for (const r of rows) {
205
+ signals.push(`Failed claim: "${r.subject}" (${r.claim_type}) — I promised and didn't deliver`);
206
+ }
207
+ }
208
+ catch { /* table may not exist */ }
209
+ store.close();
210
+ }
211
+ }
212
+ catch { /* non-fatal */ }
171
213
  return signals;
172
214
  }
173
215
  /**
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Clementine TypeScript — Team-routing classifier.
3
+ *
4
+ * Decides whether a user message addressed to Clementine should be
5
+ * delegated to a specialist agent (Ross, Sasha, Nora, etc.) or handled
6
+ * by Clementine herself.
7
+ *
8
+ * CRITICAL safety rail: this classifier is ONLY invoked when the user
9
+ * is talking TO Clementine. Direct-to-agent messages (agent bot DMs,
10
+ * agent-scoped channels) bypass routing entirely — the session-key
11
+ * ownership check in gateway/router.ts enforces this before calling
12
+ * classifyRoute. Routing never crosses the boundary between different
13
+ * agent bots.
14
+ *
15
+ * Returns structured decision: {targetAgent, confidence, reasoning}.
16
+ * Caller decides what to do with confidence (auto-delegate, soft-suggest,
17
+ * or stay with Clementine).
18
+ */
19
+ import type { AgentProfile } from '../types.js';
20
+ import type { Gateway } from '../gateway/router.js';
21
+ export interface RouteDecision {
22
+ targetAgent: string;
23
+ confidence: number;
24
+ reasoning: string;
25
+ }
26
+ /**
27
+ * Session keys eligible for routing. Any key NOT in this set is
28
+ * considered agent-scoped or system-scoped and never routes.
29
+ *
30
+ * - `discord:user:{ownerId}` — main bot DM with owner
31
+ * - `discord:channel:{channelId}:{ownerId}` — owner's main channel
32
+ * (where Clementine's main bot is posted, without an agent slug
33
+ * embedded in the key)
34
+ * - `slack:user:{userId}` / `slack:dm:{userId}` — Slack DM/owner channel
35
+ * - `dashboard:*` — web dashboard chat
36
+ * - `cli:*` — local CLI chat
37
+ *
38
+ * Rejected prefixes (routing NEVER fires):
39
+ * - `discord:agent:{slug}:*` — direct-to-agent DM
40
+ * - `discord:member:*`, `discord:member-dm:*` — member channels/DMs
41
+ * - Any `discord:channel:{channelId}:{slug}:{userId}` with an agent slug
42
+ * embedded (5-part form, where position 3 is an agent slug)
43
+ * - `slack:agent:*`, `slack:channel:*:{slug}:*`
44
+ * - `team:*` — inter-agent messages travel via team-bus, never route
45
+ */
46
+ export declare function isRoutable(sessionKey: string, _ownerAgentSlugs: Set<string>): boolean;
47
+ /**
48
+ * Classify a user message. Returns null if the call fails — caller
49
+ * should fall back to Clementine handling.
50
+ */
51
+ export declare function classifyRoute(userMessage: string, agents: AgentProfile[], gateway: Gateway): Promise<RouteDecision | null>;
52
+ //# sourceMappingURL=route-classifier.d.ts.map
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Clementine TypeScript — Team-routing classifier.
3
+ *
4
+ * Decides whether a user message addressed to Clementine should be
5
+ * delegated to a specialist agent (Ross, Sasha, Nora, etc.) or handled
6
+ * by Clementine herself.
7
+ *
8
+ * CRITICAL safety rail: this classifier is ONLY invoked when the user
9
+ * is talking TO Clementine. Direct-to-agent messages (agent bot DMs,
10
+ * agent-scoped channels) bypass routing entirely — the session-key
11
+ * ownership check in gateway/router.ts enforces this before calling
12
+ * classifyRoute. Routing never crosses the boundary between different
13
+ * agent bots.
14
+ *
15
+ * Returns structured decision: {targetAgent, confidence, reasoning}.
16
+ * Caller decides what to do with confidence (auto-delegate, soft-suggest,
17
+ * or stay with Clementine).
18
+ */
19
+ import pino from 'pino';
20
+ const logger = pino({ name: 'clementine.route-classifier' });
21
+ /**
22
+ * Session keys eligible for routing. Any key NOT in this set is
23
+ * considered agent-scoped or system-scoped and never routes.
24
+ *
25
+ * - `discord:user:{ownerId}` — main bot DM with owner
26
+ * - `discord:channel:{channelId}:{ownerId}` — owner's main channel
27
+ * (where Clementine's main bot is posted, without an agent slug
28
+ * embedded in the key)
29
+ * - `slack:user:{userId}` / `slack:dm:{userId}` — Slack DM/owner channel
30
+ * - `dashboard:*` — web dashboard chat
31
+ * - `cli:*` — local CLI chat
32
+ *
33
+ * Rejected prefixes (routing NEVER fires):
34
+ * - `discord:agent:{slug}:*` — direct-to-agent DM
35
+ * - `discord:member:*`, `discord:member-dm:*` — member channels/DMs
36
+ * - Any `discord:channel:{channelId}:{slug}:{userId}` with an agent slug
37
+ * embedded (5-part form, where position 3 is an agent slug)
38
+ * - `slack:agent:*`, `slack:channel:*:{slug}:*`
39
+ * - `team:*` — inter-agent messages travel via team-bus, never route
40
+ */
41
+ export function isRoutable(sessionKey, _ownerAgentSlugs) {
42
+ if (!sessionKey)
43
+ return false;
44
+ const parts = sessionKey.split(':');
45
+ // Structural rule: any 5+ part channel key has an agent slug embedded
46
+ // (e.g. `discord:channel:{channelId}:{slug}:{userId}`). We reject this
47
+ // regardless of whether the slug appears in a passed-in roster — the
48
+ // ownerAgentSlugs list can be stale during agent-hire/rename events,
49
+ // and the key SHAPE is the safer source of truth.
50
+ //
51
+ // `_ownerAgentSlugs` is kept in the signature for future use but the
52
+ // current implementation is structure-only.
53
+ // Agent-bot DMs and member sessions are always agent-scoped
54
+ if (parts[0] === 'discord') {
55
+ const kind = parts[1];
56
+ if (kind === 'agent' || kind === 'member' || kind === 'member-dm')
57
+ return false;
58
+ // Any 5+ part channel key → agent-scoped, never route
59
+ if (kind === 'channel' && parts.length >= 5)
60
+ return false;
61
+ // discord:user:* and the 4-part discord:channel:{channelId}:{userId} pass
62
+ return kind === 'user' || (kind === 'channel' && parts.length === 4);
63
+ }
64
+ if (parts[0] === 'slack') {
65
+ const kind = parts[1];
66
+ if (kind === 'agent')
67
+ return false;
68
+ // Any 5+ part channel key → agent-scoped
69
+ if (kind === 'channel' && parts.length >= 5)
70
+ return false;
71
+ return kind === 'user' || kind === 'dm' || (kind === 'channel' && parts.length === 4);
72
+ }
73
+ if (parts[0] === 'telegram')
74
+ return parts[1] === 'user' || /^\d+$/.test(parts[1] ?? '');
75
+ if (parts[0] === 'dashboard')
76
+ return true;
77
+ if (parts[0] === 'cli')
78
+ return true;
79
+ // Anything else (team:*, cron:*, heartbeat-triggered, etc.) — no routing
80
+ return false;
81
+ }
82
+ /** Build the agent roster string for the classifier prompt. */
83
+ function formatAgentRoster(agents) {
84
+ const lines = [];
85
+ // Clementine is always an option — the "stay with me" target
86
+ lines.push('- **clementine**: generalist assistant, calendar/inbox/planning, meta questions, small talk, anything not clearly a specialist task');
87
+ for (const a of agents) {
88
+ if (a.slug === 'clementine')
89
+ continue;
90
+ // Use name + description; truncate to keep the prompt tight
91
+ const desc = (a.description ?? '').slice(0, 200).replace(/\s+/g, ' ').trim();
92
+ lines.push(`- **${a.slug}** (${a.name}): ${desc}`);
93
+ }
94
+ return lines.join('\n');
95
+ }
96
+ function buildPrompt(userMessage, agents) {
97
+ return [
98
+ 'You are Clementine\'s team dispatcher. Decide which team member should handle an incoming user message.',
99
+ '',
100
+ '## The team:',
101
+ formatAgentRoster(agents),
102
+ '',
103
+ '## The message:',
104
+ userMessage.slice(0, 1500),
105
+ '',
106
+ '## Decision rules',
107
+ '',
108
+ '- Default to **clementine** (the generalist) unless the request clearly matches a specialist agent\'s domain.',
109
+ '- Match on DOMAIN, not keywords. "Help me think about our outbound strategy" is strategic → Clementine. "Send a follow-up to Aaron about the Scorpion audit" is operational outbound → the SDR agent.',
110
+ '- If the user explicitly names an agent ("have Ross do X"), pick that agent at confidence 1.0.',
111
+ '- If the request is meta ("what agents do I have", "how did Ross do this week") → clementine.',
112
+ '- Small talk, greetings, casual chat → clementine.',
113
+ '- Ambiguous or multi-domain requests → clementine with lower confidence (she can delegate herself).',
114
+ '',
115
+ '## Confidence scale',
116
+ '- 0.9-1.0: Explicit address of a specific agent, or a textbook specialist task (e.g., "send a follow-up" → SDR)',
117
+ '- 0.7-0.9: Clear specialist domain but implicit (e.g., "draft a LinkedIn message" → SDR, "write a content brief" → CMO agent)',
118
+ '- 0.4-0.7: Plausibly specialist but could go to Clementine',
119
+ '- <0.4: Generalist task or ambiguous — clementine',
120
+ '',
121
+ '## Output schema (JSON only, no fences):',
122
+ '{',
123
+ ' "targetAgent": "slug (use \\"clementine\\" if no specialist match)",',
124
+ ' "confidence": 0.0-1.0,',
125
+ ' "reasoning": "one short sentence — what signal drove the choice"',
126
+ '}',
127
+ ].join('\n');
128
+ }
129
+ function parseResponse(raw) {
130
+ try {
131
+ const match = raw.match(/\{[\s\S]*\}/);
132
+ if (!match)
133
+ return null;
134
+ const parsed = JSON.parse(match[0]);
135
+ if (typeof parsed.targetAgent !== 'string')
136
+ return null;
137
+ const confidence = typeof parsed.confidence === 'number'
138
+ ? Math.max(0, Math.min(1, parsed.confidence))
139
+ : 0;
140
+ return {
141
+ targetAgent: parsed.targetAgent.trim().toLowerCase(),
142
+ confidence,
143
+ reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning.slice(0, 200) : '',
144
+ };
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ /**
151
+ * Classify a user message. Returns null if the call fails — caller
152
+ * should fall back to Clementine handling.
153
+ */
154
+ export async function classifyRoute(userMessage, agents, gateway) {
155
+ // Only classify when there's at least one non-clementine agent available.
156
+ const specialists = agents.filter(a => a.slug !== 'clementine');
157
+ if (specialists.length === 0)
158
+ return null;
159
+ // Fast path: explicit slug mention anywhere in the message.
160
+ for (const a of specialists) {
161
+ const nameLower = a.name.toLowerCase();
162
+ const firstName = nameLower.split(/\s+/)[0];
163
+ // Only match on reasonable word boundaries; skip one-letter firsts
164
+ if (firstName.length < 3)
165
+ continue;
166
+ const wordRe = new RegExp(`\\b(${firstName}|${a.slug})\\b`, 'i');
167
+ if (wordRe.test(userMessage)) {
168
+ logger.debug({ slug: a.slug, trigger: 'explicit-mention' }, 'Fast-path routing decision');
169
+ return {
170
+ targetAgent: a.slug,
171
+ confidence: 1.0,
172
+ reasoning: `User explicitly addressed ${a.name} by name.`,
173
+ };
174
+ }
175
+ }
176
+ // LLM classifier for everything else.
177
+ const prompt = buildPrompt(userMessage, agents);
178
+ let raw;
179
+ try {
180
+ raw = await gateway.handleCronJob('route-classify', prompt, 1, // tier 1
181
+ 3, // maxTurns — classifier doesn't need tools
182
+ 'haiku');
183
+ }
184
+ catch (err) {
185
+ logger.warn({ err }, 'Route classifier call failed');
186
+ return null;
187
+ }
188
+ const decision = parseResponse(raw);
189
+ if (!decision) {
190
+ logger.warn({ rawHead: raw.slice(0, 200) }, 'Route classifier returned unparseable response');
191
+ return null;
192
+ }
193
+ // Validate target exists in the roster; if not, treat as Clementine.
194
+ const allSlugs = new Set(agents.map(a => a.slug));
195
+ allSlugs.add('clementine');
196
+ if (!allSlugs.has(decision.targetAgent)) {
197
+ logger.warn({ decision }, 'Classifier returned unknown agent — treating as clementine');
198
+ decision.targetAgent = 'clementine';
199
+ decision.confidence = Math.min(decision.confidence, 0.3);
200
+ }
201
+ return decision;
202
+ }
203
+ //# sourceMappingURL=route-classifier.js.map
@@ -2075,6 +2075,16 @@ export async function cmdDashboard(opts) {
2075
2075
  res.status(500).json({ error: String(err) });
2076
2076
  }
2077
2077
  });
2078
+ // ── Team routing audit ──────────────────────────────────────────
2079
+ app.get('/api/routing-audit', async (_req, res) => {
2080
+ try {
2081
+ const { getRecentRouteDecisions } = await import('../gateway/router.js');
2082
+ res.json({ decisions: getRecentRouteDecisions(50) });
2083
+ }
2084
+ catch (err) {
2085
+ res.status(500).json({ error: String(err) });
2086
+ }
2087
+ });
2078
2088
  // ── Claims + trust score ────────────────────────────────────────
2079
2089
  app.get('/api/claims', async (req, res) => {
2080
2090
  try {
@@ -9417,6 +9427,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
9417
9427
  <div class="card-header">Recent claims</div>
9418
9428
  <div class="card-body" id="panel-claims"><div class="empty-state">Loading...</div></div>
9419
9429
  </div>
9430
+ <div class="card" style="margin-top:16px">
9431
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
9432
+ <span>Team routing decisions</span>
9433
+ <span style="font-size:11px;color:var(--text-muted)">Only owner-facing Clementine sessions are classified &mdash; agent-bot DMs bypass routing entirely.</span>
9434
+ </div>
9435
+ <div class="card-body" id="panel-routing-audit"><div class="empty-state">Loading...</div></div>
9436
+ </div>
9420
9437
  </div>
9421
9438
 
9422
9439
  <!-- ═══ Logs Page ═══ -->
@@ -10458,7 +10475,7 @@ function navigateTo(page, opts) {
10458
10475
  document.getElementById('builder-input').focus();
10459
10476
  }
10460
10477
  if (page === 'automations') { refreshCron(); refreshTimers(); refreshSelfImprove(); refreshSkills(); refreshBrokenJobs(); }
10461
- if (page === 'claims') { refreshClaims(); }
10478
+ if (page === 'claims') { refreshClaims(); refreshRoutingAudit(); }
10462
10479
  if (page === 'intelligence') { refreshMemory(); }
10463
10480
  if (page === 'settings') { refreshSettings(); refreshRemoteAccess(); refreshSalesforce(); refreshClaudeIntegrations(); refreshMcpServers(); }
10464
10481
  if (page === 'logs') refreshLogs();
@@ -16401,6 +16418,44 @@ async function refreshClaims(filter) {
16401
16418
  }
16402
16419
  }
16403
16420
 
16421
+ async function refreshRoutingAudit() {
16422
+ var container = document.getElementById('panel-routing-audit');
16423
+ if (!container) return;
16424
+ try {
16425
+ var r = await apiFetch('/api/routing-audit');
16426
+ var d = await r.json();
16427
+ var decisions = d.decisions || [];
16428
+ if (decisions.length === 0) {
16429
+ container.innerHTML = '<div class="empty-state">No routing decisions yet. Send Clementine a message that could be delegated and it will show up here.</div>';
16430
+ return;
16431
+ }
16432
+ var actionColor = {
16433
+ 'auto-delegated': '#22c55e',
16434
+ 'soft-suggested': '#f59e0b',
16435
+ 'stayed-with-clementine': '#6b7280',
16436
+ };
16437
+ var html = '<div style="display:flex;flex-direction:column;gap:6px;font-size:12px">';
16438
+ for (var de of decisions) {
16439
+ var color = actionColor[de.action] || '#6b7280';
16440
+ var confPct = Math.round((de.confidence || 0) * 100);
16441
+ html += '<div style="padding:8px 10px;border:1px solid var(--border);border-left:3px solid ' + color + ';border-radius:4px;background:var(--bg-secondary)">'
16442
+ + '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
16443
+ + '<span style="font-size:10px;padding:1px 6px;background:' + color + '22;color:' + color + ';border-radius:3px">' + esc(de.action) + '</span>'
16444
+ + '<span style="font-size:11px"><strong>' + esc(de.targetAgent) + '</strong> @ ' + confPct + '%</span>'
16445
+ + '<span style="font-size:10px;color:var(--text-muted)">' + timeAgo(de.timestamp) + '</span>'
16446
+ + '<span style="font-size:10px;color:var(--text-muted);margin-left:auto">' + esc(de.sessionKey) + '</span>'
16447
+ + '</div>'
16448
+ + '<div style="font-size:11px;color:var(--text-secondary);margin-top:4px">\u201c' + esc(de.messageSnippet.slice(0, 200)) + '\u201d</div>'
16449
+ + '<div style="font-size:10px;color:var(--text-muted);margin-top:2px;font-style:italic">' + esc(de.reasoning) + '</div>'
16450
+ + '</div>';
16451
+ }
16452
+ html += '</div>';
16453
+ container.innerHTML = html;
16454
+ } catch (e) {
16455
+ container.innerHTML = '<div class="empty-state" style="color:var(--red)">Failed to load routing audit</div>';
16456
+ }
16457
+ }
16458
+
16404
16459
  async function markClaim(id, status) {
16405
16460
  var endpoint = status === 'verified' ? 'mark-verified' : status === 'failed' ? 'mark-failed' : 'dismiss';
16406
16461
  try {
@@ -123,13 +123,24 @@ const PATTERNS = [
123
123
  * Bounded to prevent memory growth — oldest entries are evicted.
124
124
  */
125
125
  const MAX_PENDING_LLM = 20;
126
+ const PENDING_LLM_TTL_MS = 6 * 60 * 60 * 1000; // 6h — after that a claim is stale anyway
126
127
  const pendingLLMExtraction = [];
128
+ function pruneExpiredPending(now = Date.now()) {
129
+ while (pendingLLMExtraction.length > 0) {
130
+ const oldest = pendingLLMExtraction[0];
131
+ if (now - oldest.queuedAt <= PENDING_LLM_TTL_MS)
132
+ break;
133
+ pendingLLMExtraction.shift();
134
+ }
135
+ }
127
136
  function enqueueForLLM(text, sessionKey, agentSlug) {
137
+ const now = Date.now();
138
+ pruneExpiredPending(now);
128
139
  // De-dup by text hash within the queue — don't re-enqueue the same DM.
129
140
  const hash = sha1(text);
130
- if (pendingLLMExtraction.some(e => sha1(e.text) === hash))
141
+ if (pendingLLMExtraction.some(e => e.hash === hash))
131
142
  return;
132
- pendingLLMExtraction.push({ text, sessionKey, agentSlug, queuedAt: Date.now() });
143
+ pendingLLMExtraction.push({ text, hash, sessionKey, agentSlug, queuedAt: now });
133
144
  while (pendingLLMExtraction.length > MAX_PENDING_LLM)
134
145
  pendingLLMExtraction.shift();
135
146
  }
@@ -208,11 +219,16 @@ export function extractClaims(text, sessionKey, agentSlug) {
208
219
  * the next sweep.
209
220
  */
210
221
  export async function drainLLMFallback(gateway, maxPerSweep = 3) {
222
+ pruneExpiredPending();
211
223
  let drained = 0;
212
- const batch = pendingLLMExtraction.splice(0, Math.min(maxPerSweep, pendingLLMExtraction.length));
224
+ // Peek don't remove yet. We only splice on successful processing so a
225
+ // transient LLM failure doesn't silently drop the candidate.
226
+ const batch = pendingLLMExtraction.slice(0, Math.min(maxPerSweep, pendingLLMExtraction.length));
227
+ const toRemove = new Set();
213
228
  for (const item of batch) {
214
229
  try {
215
230
  const claims = await llmExtractClaims(item.text, gateway);
231
+ toRemove.add(item.hash); // success (or "no claims" — not worth re-trying)
216
232
  if (claims.length === 0)
217
233
  continue;
218
234
  const toRecord = claims.map(c => ({
@@ -229,9 +245,18 @@ export async function drainLLMFallback(gateway, maxPerSweep = 3) {
229
245
  drained += claims.length;
230
246
  }
231
247
  catch (err) {
248
+ // Don't add to toRemove — leave in queue for next sweep. TTL eventually
249
+ // evicts permanently-failing entries.
232
250
  logger.debug({ err }, 'LLM fallback extraction failed for one DM');
233
251
  }
234
252
  }
253
+ // Remove successfully-processed entries in one pass
254
+ if (toRemove.size > 0) {
255
+ for (let i = pendingLLMExtraction.length - 1; i >= 0; i--) {
256
+ if (toRemove.has(pendingLLMExtraction[i].hash))
257
+ pendingLLMExtraction.splice(i, 1);
258
+ }
259
+ }
235
260
  return drained;
236
261
  }
237
262
  async function llmExtractClaims(text, gateway) {
@@ -52,7 +52,10 @@ function loadCache() {
52
52
  function saveCache(cache) {
53
53
  try {
54
54
  mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
55
- writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
55
+ const tmp = CACHE_FILE + '.tmp';
56
+ writeFileSync(tmp, JSON.stringify(cache, null, 2));
57
+ const { renameSync } = require('node:fs');
58
+ renameSync(tmp, CACHE_FILE);
56
59
  }
57
60
  catch (err) {
58
61
  logger.warn({ err }, 'Failed to persist diagnostic cache');
@@ -50,7 +50,12 @@ function loadState() {
50
50
  function saveState(state) {
51
51
  try {
52
52
  mkdirSync(path.dirname(STATE_FILE), { recursive: true });
53
- writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
53
+ // Atomic write — write to temp file then rename. Prevents partial
54
+ // writes from corrupting the state if the process is killed mid-write.
55
+ const tmp = STATE_FILE + '.tmp';
56
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
57
+ const { renameSync } = require('node:fs');
58
+ renameSync(tmp, STATE_FILE);
54
59
  }
55
60
  catch (err) {
56
61
  logger.warn({ err }, 'Failed to persist failure-monitor state');
@@ -29,7 +29,10 @@ function loadState() {
29
29
  function saveState(state) {
30
30
  try {
31
31
  mkdirSync(path.dirname(STATE_FILE), { recursive: true });
32
- writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
32
+ const tmp = STATE_FILE + '.tmp';
33
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
34
+ const { renameSync } = require('node:fs');
35
+ renameSync(tmp, STATE_FILE);
33
36
  }
34
37
  catch (err) {
35
38
  logger.warn({ err }, 'Failed to persist fix-verification state');
@@ -53,6 +53,14 @@ export declare class Gateway {
53
53
  * Falls back to pushing rawResult directly if the agent call fails.
54
54
  */
55
55
  private _deliverDeepResult;
56
+ /**
57
+ * For Clementine-owned sessions, classify whether the message should be
58
+ * delegated to a specialist agent. Returns null when routing isn't
59
+ * eligible; { delegated: true, ackMessage } when auto-delegated;
60
+ * { delegated: false, softSuggest } when only suggesting.
61
+ */
62
+ static routeAuditLogPath(): string;
63
+ private _maybeRouteToSpecialist;
56
64
  private _agentManager?;
57
65
  private _teamRouter?;
58
66
  private _teamBus?;
@@ -215,4 +223,15 @@ export declare class Gateway {
215
223
  /** Extract a procedural skill from a successful cron execution (fire-and-forget). */
216
224
  extractCronSkill(jobName: string, prompt: string, output: string, durationMs: number, agentSlug?: string): Promise<void>;
217
225
  }
226
+ interface RouteAuditEntry {
227
+ timestamp: string;
228
+ sessionKey: string;
229
+ messageSnippet: string;
230
+ targetAgent: string;
231
+ confidence: number;
232
+ reasoning: string;
233
+ action: 'auto-delegated' | 'soft-suggested' | 'stayed-with-clementine';
234
+ }
235
+ export declare function getRecentRouteDecisions(limit?: number): RouteAuditEntry[];
236
+ export {};
218
237
  //# sourceMappingURL=router.d.ts.map
@@ -205,6 +205,67 @@ export class Gateway {
205
205
  }
206
206
  }
207
207
  }
208
+ /**
209
+ * For Clementine-owned sessions, classify whether the message should be
210
+ * delegated to a specialist agent. Returns null when routing isn't
211
+ * eligible; { delegated: true, ackMessage } when auto-delegated;
212
+ * { delegated: false, softSuggest } when only suggesting.
213
+ */
214
+ static routeAuditLogPath() {
215
+ return path.join(BASE_DIR, 'routing-audit.jsonl');
216
+ }
217
+ async _maybeRouteToSpecialist(sessionKey, text, onText) {
218
+ try {
219
+ const { isRoutable, classifyRoute } = await import('../agent/route-classifier.js');
220
+ // Fetch team roster and build the set of agent slugs for the routing gate
221
+ const agentMgr = this.getAgentManager();
222
+ const agents = agentMgr.listAll();
223
+ const ownerAgentSlugs = new Set(agents.filter(a => a.slug !== 'clementine').map(a => a.slug));
224
+ if (!isRoutable(sessionKey, ownerAgentSlugs))
225
+ return null;
226
+ if (ownerAgentSlugs.size === 0)
227
+ return null; // no team to route to
228
+ const decision = await classifyRoute(text, agents, this);
229
+ if (!decision)
230
+ return null;
231
+ logRouteDecision({ sessionKey, message: text, decision });
232
+ if (decision.targetAgent === 'clementine')
233
+ return null;
234
+ const targetProfile = agents.find(a => a.slug === decision.targetAgent);
235
+ if (!targetProfile)
236
+ return null;
237
+ // Auto-delegate at high confidence
238
+ if (decision.confidence >= 0.8) {
239
+ // Fire the team task in the background; ack immediately.
240
+ const ackMessage = `Routing this to **${targetProfile.name}** (${decision.reasoning.toLowerCase()}). I'll post their response back here when done.`;
241
+ onText?.(ackMessage).catch(() => { });
242
+ this.handleTeamTask('Clementine', 'clementine', text, targetProfile)
243
+ .then(response => {
244
+ if (!response)
245
+ return;
246
+ const delivery = `**${targetProfile.name}**: ${response}`;
247
+ return this._dispatcher?.send(delivery, { sessionKey });
248
+ })
249
+ .catch(err => {
250
+ logger.warn({ err, target: decision.targetAgent }, 'Delegated task failed');
251
+ void this._dispatcher?.send(`**${targetProfile.name}** hit an error handling that: ${String(err).slice(0, 200)}`, { sessionKey });
252
+ });
253
+ return { delegated: true, ackMessage };
254
+ }
255
+ // Soft-suggest at medium confidence
256
+ if (decision.confidence >= 0.5) {
257
+ return {
258
+ delegated: false,
259
+ softSuggest: `[Routing suggestion: This looks like it could be ${targetProfile.name}'s domain (${decision.reasoning}). If you want to delegate, reply "send to ${targetProfile.name}" or address them directly. Otherwise I'll handle it.]`,
260
+ };
261
+ }
262
+ return null; // low confidence — stay with Clementine silently
263
+ }
264
+ catch (err) {
265
+ logger.debug({ err, sessionKey }, 'Team routing attempt failed (non-fatal)');
266
+ return null;
267
+ }
268
+ }
208
269
  // Team system (lazy-initialized)
209
270
  _agentManager;
210
271
  _teamRouter;
@@ -676,6 +737,48 @@ export class Gateway {
676
737
  // Use per-message override, then session default, then global default
677
738
  const sess = this.sessions.get(sessionKey);
678
739
  const effectiveModel = model ?? sess?.model;
740
+ // ── Team routing (Clementine-owned sessions only) ──────────────
741
+ // If the user is talking TO Clementine (her main bot DM, owner
742
+ // channel, dashboard, or CLI) and hasn't locked the session to a
743
+ // specific agent profile, classify whether the message should go
744
+ // to a specialist. Direct-to-agent-bot sessions bypass this entirely.
745
+ // Small-talk and meta queries stay with Clementine by default.
746
+ const routingResult = !isInternalMsg && !sess?.profile && !text.startsWith('!')
747
+ ? await this._maybeRouteToSpecialist(sessionKey, text, onText)
748
+ : null;
749
+ if (routingResult?.delegated) {
750
+ return routingResult.ackMessage;
751
+ }
752
+ // Soft-suggest mode: pass annotation through to Clementine's reply
753
+ if (routingResult?.softSuggest) {
754
+ securityAnnotation = (securityAnnotation
755
+ ? securityAnnotation + '\n\n'
756
+ : '') + routingResult.softSuggest;
757
+ }
758
+ // ── Pre-flight planning for complex asks ───────────────────────
759
+ // For interactive sessions only (owner DMs, dashboard, CLI), a
760
+ // cheap deterministic heuristic flags complex multi-step requests.
761
+ // When it fires, we prepend a directive to the text that tells
762
+ // the agent to propose a plan + stop, rather than executing
763
+ // directly. Not a hard stop — on the user's "go" reply the
764
+ // agent proceeds from the plan it proposed.
765
+ let enrichedText = text;
766
+ const isInteractive = isOwnerDm
767
+ || sessionKey.startsWith('dashboard:')
768
+ || sessionKey.startsWith('cli:');
769
+ if (isInteractive && !isInternalMsg && !text.startsWith('!')) {
770
+ try {
771
+ const { classifyComplexity, planFirstDirective } = await import('../agent/complexity-classifier.js');
772
+ const verdict = classifyComplexity(text);
773
+ if (verdict.complex) {
774
+ logger.info({ sessionKey, signals: verdict.signals, reason: verdict.reason }, 'Pre-flight planning directive injected');
775
+ enrichedText = `${planFirstDirective()}\n\n---\n\n${text}`;
776
+ }
777
+ }
778
+ catch (err) {
779
+ logger.debug({ err }, 'Complexity classifier failed (non-fatal)');
780
+ }
781
+ }
679
782
  // ── Deep mode control ──────────────────────────────────────────
680
783
  if (sess?.deepTask) {
681
784
  const lower = text.toLowerCase().trim();
@@ -796,7 +899,7 @@ export class Gateway {
796
899
  // If the previous query on this session was interrupted by this
797
900
  // incoming message, fold the partial output in so the agent can pivot
798
901
  // smoothly instead of re-planning from scratch.
799
- let chatPrompt = text;
902
+ let chatPrompt = enrichedText;
800
903
  const interrupt = sessState.pendingInterrupt;
801
904
  if (interrupt && interrupt.partial.trim()) {
802
905
  delete sessState.pendingInterrupt;
@@ -804,7 +907,7 @@ export class Gateway {
804
907
  chatPrompt =
805
908
  `[You were mid-response when the user sent a new message — they chose not to wait. ` +
806
909
  `Here's what you had said so far (may be mid-sentence):\n---\n${partialPreview}\n---\n` +
807
- `New message from user:]\n\n${text}`;
910
+ `New message from user:]\n\n${enrichedText}`;
808
911
  logger.info({ sessionKey, partialLen: interrupt.partial.length }, 'Folding interrupted partial into new prompt');
809
912
  }
810
913
  else if (interrupt) {
@@ -1380,4 +1483,43 @@ export class Gateway {
1380
1483
  }
1381
1484
  }
1382
1485
  }
1486
+ /**
1487
+ * In-memory ring buffer of recent routing decisions. The dashboard
1488
+ * endpoint reads from this without hitting disk. Persisted to
1489
+ * routing-audit.jsonl on every append so a restart replays them from
1490
+ * the file next boot (TODO if we need the history to survive restarts).
1491
+ */
1492
+ const _routeAuditBuffer = [];
1493
+ function logRouteDecision(opts) {
1494
+ const action = opts.decision.targetAgent === 'clementine'
1495
+ ? 'stayed-with-clementine'
1496
+ : opts.decision.confidence >= 0.8
1497
+ ? 'auto-delegated'
1498
+ : opts.decision.confidence >= 0.5
1499
+ ? 'soft-suggested'
1500
+ : 'stayed-with-clementine';
1501
+ const entry = {
1502
+ timestamp: new Date().toISOString(),
1503
+ sessionKey: opts.sessionKey,
1504
+ messageSnippet: opts.message.slice(0, 300),
1505
+ targetAgent: opts.decision.targetAgent,
1506
+ confidence: opts.decision.confidence,
1507
+ reasoning: opts.decision.reasoning,
1508
+ action,
1509
+ };
1510
+ _routeAuditBuffer.push(entry);
1511
+ while (_routeAuditBuffer.length > 200)
1512
+ _routeAuditBuffer.shift();
1513
+ try {
1514
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1515
+ const { appendFileSync } = require('node:fs');
1516
+ appendFileSync(Gateway.routeAuditLogPath(), JSON.stringify(entry) + '\n');
1517
+ }
1518
+ catch (err) {
1519
+ logger.debug({ err }, 'Route audit log write failed (non-fatal)');
1520
+ }
1521
+ }
1522
+ export function getRecentRouteDecisions(limit = 50) {
1523
+ return _routeAuditBuffer.slice(-limit).reverse();
1524
+ }
1383
1525
  //# sourceMappingURL=router.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",