clementine-agent 1.0.21 → 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.
@@ -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
@@ -436,6 +436,17 @@ export class MemoryStore {
436
436
  CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status, extracted_at DESC);
437
437
  CREATE INDEX IF NOT EXISTS idx_claims_due ON claims(due_at) WHERE status = 'pending';
438
438
  CREATE INDEX IF NOT EXISTS idx_claims_extracted ON claims(extracted_at DESC);
439
+
440
+ CREATE TABLE IF NOT EXISTS graded_runs (
441
+ job_name TEXT NOT NULL,
442
+ started_at TEXT NOT NULL,
443
+ passed INTEGER NOT NULL,
444
+ score INTEGER NOT NULL,
445
+ reasoning TEXT,
446
+ graded_at TEXT NOT NULL DEFAULT (datetime('now')),
447
+ PRIMARY KEY (job_name, started_at)
448
+ );
449
+ CREATE INDEX IF NOT EXISTS idx_graded_runs_job ON graded_runs(job_name, started_at DESC);
439
450
  `);
440
451
  }
441
452
  // ── Skill usage telemetry ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",