clementine-agent 1.0.22 → 1.0.23
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/complexity-classifier.d.ts +30 -0
- package/dist/agent/complexity-classifier.js +153 -0
- package/dist/agent/insight-engine.js +42 -0
- package/dist/agent/route-classifier.d.ts +52 -0
- package/dist/agent/route-classifier.js +197 -0
- package/dist/cli/dashboard.js +56 -1
- package/dist/gateway/router.d.ts +19 -0
- package/dist/gateway/router.js +144 -2
- package/package.json +1 -1
|
@@ -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,197 @@
|
|
|
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
|
+
// Agent-bot DMs and member sessions are always agent-scoped
|
|
46
|
+
if (parts[0] === 'discord') {
|
|
47
|
+
const kind = parts[1];
|
|
48
|
+
if (kind === 'agent' || kind === 'member' || kind === 'member-dm')
|
|
49
|
+
return false;
|
|
50
|
+
// 5-part discord:channel:{channelId}:{slug}:{userId} means agent in team chat
|
|
51
|
+
if (kind === 'channel' && parts.length >= 5 && ownerAgentSlugs.has(parts[3] ?? '')) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
// discord:user:* and the 4-part discord:channel:{channelId}:{userId} pass
|
|
55
|
+
return kind === 'user' || kind === 'channel';
|
|
56
|
+
}
|
|
57
|
+
if (parts[0] === 'slack') {
|
|
58
|
+
const kind = parts[1];
|
|
59
|
+
if (kind === 'agent')
|
|
60
|
+
return false;
|
|
61
|
+
// slack:channel:{channelId}:{slug}:{userId} — agent-scoped
|
|
62
|
+
if (kind === 'channel' && parts.length >= 5 && ownerAgentSlugs.has(parts[3] ?? '')) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return kind === 'user' || kind === 'dm' || kind === 'channel';
|
|
66
|
+
}
|
|
67
|
+
if (parts[0] === 'telegram')
|
|
68
|
+
return parts[1] === 'user' || /^\d+$/.test(parts[1] ?? '');
|
|
69
|
+
if (parts[0] === 'dashboard')
|
|
70
|
+
return true;
|
|
71
|
+
if (parts[0] === 'cli')
|
|
72
|
+
return true;
|
|
73
|
+
// Anything else (team:*, cron:*, heartbeat-triggered, etc.) — no routing
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
/** Build the agent roster string for the classifier prompt. */
|
|
77
|
+
function formatAgentRoster(agents) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
// Clementine is always an option — the "stay with me" target
|
|
80
|
+
lines.push('- **clementine**: generalist assistant, calendar/inbox/planning, meta questions, small talk, anything not clearly a specialist task');
|
|
81
|
+
for (const a of agents) {
|
|
82
|
+
if (a.slug === 'clementine')
|
|
83
|
+
continue;
|
|
84
|
+
// Use name + description; truncate to keep the prompt tight
|
|
85
|
+
const desc = (a.description ?? '').slice(0, 200).replace(/\s+/g, ' ').trim();
|
|
86
|
+
lines.push(`- **${a.slug}** (${a.name}): ${desc}`);
|
|
87
|
+
}
|
|
88
|
+
return lines.join('\n');
|
|
89
|
+
}
|
|
90
|
+
function buildPrompt(userMessage, agents) {
|
|
91
|
+
return [
|
|
92
|
+
'You are Clementine\'s team dispatcher. Decide which team member should handle an incoming user message.',
|
|
93
|
+
'',
|
|
94
|
+
'## The team:',
|
|
95
|
+
formatAgentRoster(agents),
|
|
96
|
+
'',
|
|
97
|
+
'## The message:',
|
|
98
|
+
userMessage.slice(0, 1500),
|
|
99
|
+
'',
|
|
100
|
+
'## Decision rules',
|
|
101
|
+
'',
|
|
102
|
+
'- Default to **clementine** (the generalist) unless the request clearly matches a specialist agent\'s domain.',
|
|
103
|
+
'- 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.',
|
|
104
|
+
'- If the user explicitly names an agent ("have Ross do X"), pick that agent at confidence 1.0.',
|
|
105
|
+
'- If the request is meta ("what agents do I have", "how did Ross do this week") → clementine.',
|
|
106
|
+
'- Small talk, greetings, casual chat → clementine.',
|
|
107
|
+
'- Ambiguous or multi-domain requests → clementine with lower confidence (she can delegate herself).',
|
|
108
|
+
'',
|
|
109
|
+
'## Confidence scale',
|
|
110
|
+
'- 0.9-1.0: Explicit address of a specific agent, or a textbook specialist task (e.g., "send a follow-up" → SDR)',
|
|
111
|
+
'- 0.7-0.9: Clear specialist domain but implicit (e.g., "draft a LinkedIn message" → SDR, "write a content brief" → CMO agent)',
|
|
112
|
+
'- 0.4-0.7: Plausibly specialist but could go to Clementine',
|
|
113
|
+
'- <0.4: Generalist task or ambiguous — clementine',
|
|
114
|
+
'',
|
|
115
|
+
'## Output schema (JSON only, no fences):',
|
|
116
|
+
'{',
|
|
117
|
+
' "targetAgent": "slug (use \\"clementine\\" if no specialist match)",',
|
|
118
|
+
' "confidence": 0.0-1.0,',
|
|
119
|
+
' "reasoning": "one short sentence — what signal drove the choice"',
|
|
120
|
+
'}',
|
|
121
|
+
].join('\n');
|
|
122
|
+
}
|
|
123
|
+
function parseResponse(raw) {
|
|
124
|
+
try {
|
|
125
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
126
|
+
if (!match)
|
|
127
|
+
return null;
|
|
128
|
+
const parsed = JSON.parse(match[0]);
|
|
129
|
+
if (typeof parsed.targetAgent !== 'string')
|
|
130
|
+
return null;
|
|
131
|
+
const confidence = typeof parsed.confidence === 'number'
|
|
132
|
+
? Math.max(0, Math.min(1, parsed.confidence))
|
|
133
|
+
: 0;
|
|
134
|
+
return {
|
|
135
|
+
targetAgent: parsed.targetAgent.trim().toLowerCase(),
|
|
136
|
+
confidence,
|
|
137
|
+
reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning.slice(0, 200) : '',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Classify a user message. Returns null if the call fails — caller
|
|
146
|
+
* should fall back to Clementine handling.
|
|
147
|
+
*/
|
|
148
|
+
export async function classifyRoute(userMessage, agents, gateway) {
|
|
149
|
+
// Only classify when there's at least one non-clementine agent available.
|
|
150
|
+
const specialists = agents.filter(a => a.slug !== 'clementine');
|
|
151
|
+
if (specialists.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
// Fast path: explicit slug mention anywhere in the message.
|
|
154
|
+
for (const a of specialists) {
|
|
155
|
+
const nameLower = a.name.toLowerCase();
|
|
156
|
+
const firstName = nameLower.split(/\s+/)[0];
|
|
157
|
+
// Only match on reasonable word boundaries; skip one-letter firsts
|
|
158
|
+
if (firstName.length < 3)
|
|
159
|
+
continue;
|
|
160
|
+
const wordRe = new RegExp(`\\b(${firstName}|${a.slug})\\b`, 'i');
|
|
161
|
+
if (wordRe.test(userMessage)) {
|
|
162
|
+
logger.debug({ slug: a.slug, trigger: 'explicit-mention' }, 'Fast-path routing decision');
|
|
163
|
+
return {
|
|
164
|
+
targetAgent: a.slug,
|
|
165
|
+
confidence: 1.0,
|
|
166
|
+
reasoning: `User explicitly addressed ${a.name} by name.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// LLM classifier for everything else.
|
|
171
|
+
const prompt = buildPrompt(userMessage, agents);
|
|
172
|
+
let raw;
|
|
173
|
+
try {
|
|
174
|
+
raw = await gateway.handleCronJob('route-classify', prompt, 1, // tier 1
|
|
175
|
+
3, // maxTurns — classifier doesn't need tools
|
|
176
|
+
'haiku');
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
logger.warn({ err }, 'Route classifier call failed');
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const decision = parseResponse(raw);
|
|
183
|
+
if (!decision) {
|
|
184
|
+
logger.warn({ rawHead: raw.slice(0, 200) }, 'Route classifier returned unparseable response');
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
// Validate target exists in the roster; if not, treat as Clementine.
|
|
188
|
+
const allSlugs = new Set(agents.map(a => a.slug));
|
|
189
|
+
allSlugs.add('clementine');
|
|
190
|
+
if (!allSlugs.has(decision.targetAgent)) {
|
|
191
|
+
logger.warn({ decision }, 'Classifier returned unknown agent — treating as clementine');
|
|
192
|
+
decision.targetAgent = 'clementine';
|
|
193
|
+
decision.confidence = Math.min(decision.confidence, 0.3);
|
|
194
|
+
}
|
|
195
|
+
return decision;
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=route-classifier.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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 — 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 {
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -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
|
package/dist/gateway/router.js
CHANGED
|
@@ -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 =
|
|
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${
|
|
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
|