clementine-agent 1.0.84 → 1.0.86

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.
@@ -23,6 +23,8 @@ export interface RouteDecision {
23
23
  confidence: number;
24
24
  reasoning: string;
25
25
  }
26
+ /** Test-only: reset the cache between runs. */
27
+ export declare function _resetRouteCache(): void;
26
28
  export declare function isDirectImperative(userMessage: string): {
27
29
  match: boolean;
28
30
  pattern?: string;
@@ -18,6 +18,49 @@
18
18
  */
19
19
  import pino from 'pino';
20
20
  const logger = pino({ name: 'clementine.route-classifier' });
21
+ // ── LRU cache for repeated messages ──────────────────────────────────
22
+ // Same text + same available-agents set → same decision. Skips the
23
+ // Haiku LLM call entirely on cache hit (saves 1-2 seconds per repeat).
24
+ // Bounded by both size and TTL so stale rosters can't cause wrong routes.
25
+ const ROUTE_CACHE_MAX = 100;
26
+ const ROUTE_CACHE_TTL_MS = 5 * 60 * 1000;
27
+ // Insertion-ordered Map = LRU when we delete-and-reinsert on hit.
28
+ const routeCache = new Map();
29
+ function cacheKey(text, agents) {
30
+ // Trim + normalize whitespace so trailing-newline variations of the
31
+ // same message hit the same cache entry.
32
+ const normText = text.replace(/\s+/g, ' ').trim();
33
+ // Sort slugs so order doesn't matter; include count to invalidate on
34
+ // hire/fire (the agents array changes shape).
35
+ const slugFingerprint = agents.map(a => a.slug).sort().join(',');
36
+ return `${slugFingerprint}::${normText}`;
37
+ }
38
+ function cacheGet(key, now) {
39
+ const entry = routeCache.get(key);
40
+ if (!entry)
41
+ return null;
42
+ if (entry.expiresAt <= now) {
43
+ routeCache.delete(key);
44
+ return null;
45
+ }
46
+ // LRU touch: remove + re-insert to move to end of insertion order
47
+ routeCache.delete(key);
48
+ routeCache.set(key, entry);
49
+ return entry;
50
+ }
51
+ function cachePut(key, decision, now) {
52
+ routeCache.set(key, { decision, expiresAt: now + ROUTE_CACHE_TTL_MS });
53
+ while (routeCache.size > ROUTE_CACHE_MAX) {
54
+ const oldest = routeCache.keys().next().value;
55
+ if (oldest === undefined)
56
+ break;
57
+ routeCache.delete(oldest);
58
+ }
59
+ }
60
+ /** Test-only: reset the cache between runs. */
61
+ export function _resetRouteCache() {
62
+ routeCache.clear();
63
+ }
21
64
  /**
22
65
  * Direct-imperative guardrail.
23
66
  *
@@ -229,6 +272,15 @@ export async function classifyRoute(userMessage, agents, gateway) {
229
272
  logger.debug({ trigger: 'question-opener' }, 'Routing skipped — question-opener');
230
273
  return null;
231
274
  }
275
+ // Cache hit short-circuit — same message + same roster as a recent
276
+ // call gets the same decision without firing the Haiku classifier.
277
+ const now = Date.now();
278
+ const key = cacheKey(userMessage, agents);
279
+ const hit = cacheGet(key, now);
280
+ if (hit) {
281
+ logger.debug({ trigger: 'cache-hit', cachedAgent: hit.decision?.targetAgent ?? 'clementine' }, 'Route classifier cache hit');
282
+ return hit.decision;
283
+ }
232
284
  // LLM classifier for everything else.
233
285
  const prompt = buildPrompt(userMessage, agents);
234
286
  let raw;
@@ -239,6 +291,7 @@ export async function classifyRoute(userMessage, agents, gateway) {
239
291
  }
240
292
  catch (err) {
241
293
  logger.warn({ err }, 'Route classifier call failed');
294
+ // Don't cache failures — next call should retry the LLM.
242
295
  return null;
243
296
  }
244
297
  const decision = parseResponse(raw);
@@ -254,6 +307,7 @@ export async function classifyRoute(userMessage, agents, gateway) {
254
307
  decision.targetAgent = 'clementine';
255
308
  decision.confidence = Math.min(decision.confidence, 0.3);
256
309
  }
310
+ cachePut(key, decision, now);
257
311
  return decision;
258
312
  }
259
313
  //# sourceMappingURL=route-classifier.js.map
@@ -1084,6 +1084,7 @@ function getAgentHeartbeats() {
1084
1084
  silentTickCount: Number(state.silentTickCount ?? 0),
1085
1085
  fingerprint: state.fingerprint ?? '',
1086
1086
  lastSignalSummary: state.lastSignalSummary ?? null,
1087
+ lastTickKind: state.lastTickKind ?? null,
1087
1088
  lastTickAgoMs: lastTickMs ? now - lastTickMs : null,
1088
1089
  nextCheckInMs: nextCheckMs ? nextCheckMs - now : null,
1089
1090
  isDue: nextCheckMs > 0 && nextCheckMs <= now,
@@ -8977,6 +8978,26 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
8977
8978
  .desk-kpi-strip .dss-icon { font-size: 12px; opacity: 0.7; }
8978
8979
  .desk-kpi-strip .dss-val { font-weight: 700; color: var(--accent); }
8979
8980
 
8981
+ /* ── Heartbeat Strip ────────────── */
8982
+ .desk-hb-strip {
8983
+ display: flex;
8984
+ justify-content: center;
8985
+ gap: 6px;
8986
+ padding: 2px 12px;
8987
+ font-size: 10px;
8988
+ color: var(--text-muted);
8989
+ min-height: 0;
8990
+ }
8991
+ .desk-hb-strip:empty { display: none; }
8992
+ .desk-hb-strip .dss-item {
8993
+ display: flex;
8994
+ align-items: center;
8995
+ gap: 4px;
8996
+ white-space: nowrap;
8997
+ }
8998
+ .desk-hb-strip .dss-icon { font-size: 11px; opacity: 0.85; }
8999
+ .desk-hb-strip .dss-val { font-weight: 600; color: var(--text-secondary); }
9000
+
8980
9001
  /* ── Health Badge ──────────────────── */
8981
9002
  .desk-health-badge {
8982
9003
  display: none;
@@ -17272,6 +17293,9 @@ async function refreshTeam() {
17272
17293
  '</details>';
17273
17294
  }
17274
17295
 
17296
+ // Heartbeat strip — last/next tick, populated async from /api/agent-heartbeats
17297
+ var hbStrip = '<div class="desk-hb-strip" id="hb-' + a.slug + '"></div>';
17298
+
17275
17299
  // SDR KPI strip (fetched async, populated by data attribute)
17276
17300
  var kpiStrip = '<div class="desk-kpi-strip" id="kpi-' + a.slug + '"></div>';
17277
17301
 
@@ -17299,6 +17323,7 @@ async function refreshTeam() {
17299
17323
  (actions ? '<div class="desk-actions">' + actions + '</div>' : '') +
17300
17324
  '</div>' +
17301
17325
  statsStrip +
17326
+ hbStrip +
17302
17327
  kpiStrip +
17303
17328
  cronDetails +
17304
17329
  '</div>';
@@ -17312,6 +17337,54 @@ async function refreshTeam() {
17312
17337
 
17313
17338
  grid.innerHTML = cards.join('');
17314
17339
 
17340
+ // Heartbeat strip — single batch fetch, populate per-agent (avoids N requests).
17341
+ apiFetch('/api/agent-heartbeats').then(function(r) { return r.json(); }).then(function(hbList) {
17342
+ if (!Array.isArray(hbList)) return;
17343
+ var byAgent = {};
17344
+ for (var i = 0; i < hbList.length; i++) byAgent[hbList[i].slug] = hbList[i];
17345
+ agents.forEach(function(a) {
17346
+ var hb = byAgent[a.slug];
17347
+ var el = document.getElementById('hb-' + a.slug);
17348
+ if (!el || !hb) return;
17349
+ // "in 12 min" / "due now" / "10s ago tick"
17350
+ var nextLabel;
17351
+ if (hb.isDue) {
17352
+ nextLabel = 'due now';
17353
+ } else if (typeof hb.nextCheckInMs === 'number') {
17354
+ var mins = Math.max(1, Math.round(hb.nextCheckInMs / 60000));
17355
+ nextLabel = mins < 60 ? 'in ' + mins + 'm' : 'in ' + Math.floor(mins / 60) + 'h';
17356
+ } else {
17357
+ nextLabel = '—';
17358
+ }
17359
+ var lastLabel = hb.lastTickAt ? fmtTimeAgo(hb.lastTickAt) : 'never';
17360
+ // Tick-kind icon — small visual signal
17361
+ var kindIcon = '';
17362
+ var kindTitle = '';
17363
+ switch (hb.lastSignalSummary && hb.lastSignalSummary.indexOf('llm tick error') === 0
17364
+ ? 'error' : '') {
17365
+ case 'error':
17366
+ kindIcon = '⚠'; kindTitle = 'Last tick errored — fell back to quiet cadence';
17367
+ break;
17368
+ }
17369
+ // Use a literal field if available — backend exposes lastTickKind via state file
17370
+ // (we read it through getAgentHeartbeats which preserves the field)
17371
+ if (!kindIcon) {
17372
+ switch (hb.lastTickKind) {
17373
+ case 'acted': kindIcon = '⚡'; kindTitle = 'Active — agent took action last tick'; break;
17374
+ case 'quiet': kindIcon = '◌'; kindTitle = 'Quiet — agent saw signals but had nothing to do'; break;
17375
+ case 'silent': kindIcon = '·'; kindTitle = 'Silent — fingerprint unchanged, backing off'; break;
17376
+ case 'override': kindIcon = '◇'; kindTitle = 'Override — cadence set explicitly by the agent'; break;
17377
+ default: kindIcon = '·'; kindTitle = 'No tick recorded yet';
17378
+ }
17379
+ }
17380
+ el.innerHTML =
17381
+ '<span class="dss-item" title="' + kindTitle + '">' +
17382
+ '<span class="dss-icon">' + kindIcon + '</span> ' +
17383
+ '<span class="dss-val">' + lastLabel + '</span> · next ' + nextLabel +
17384
+ '</span>';
17385
+ });
17386
+ }).catch(function() { /* heartbeat endpoint missing or daemon old — silently skip */ });
17387
+
17315
17388
  // Async-fetch KPIs and health for each agent
17316
17389
  agents.forEach(function(a) {
17317
17390
  // KPI strip
@@ -10,7 +10,7 @@
10
10
  * out. Per-agent failures are caught so one buggy agent can't crash the
11
11
  * daemon or stall others.
12
12
  */
13
- import { existsSync, mkdirSync, watch } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import pino from 'pino';
16
16
  import { AGENTS_DIR, BASE_DIR } from '../config.js';
@@ -107,22 +107,15 @@ export class AgentHeartbeatManager {
107
107
  if (!filename.endsWith('.json'))
108
108
  return;
109
109
  const slug = filename.replace(/\.json$/, '');
110
- // Consume the sentinel + wake the agent
111
- try {
112
- // Use unlinkSync via require to keep top-level import surface tight
113
- // (already imported existsSync — we use it before unlink to be safe)
114
- const sentinelPath = path.join(wakeDir, filename);
115
- if (existsSync(sentinelPath)) {
116
- // best-effort: import unlinkSync inline
117
- // eslint-disable-next-line @typescript-eslint/no-var-requires
118
- const fs = require('node:fs');
119
- try {
120
- fs.unlinkSync(sentinelPath);
121
- }
122
- catch { /* ignore */ }
110
+ // Consume the sentinel + wake the agent. Best-effort delete so the
111
+ // same sentinel can't fire repeatedly.
112
+ const sentinelPath = path.join(wakeDir, filename);
113
+ if (existsSync(sentinelPath)) {
114
+ try {
115
+ unlinkSync(sentinelPath);
123
116
  }
117
+ catch { /* ignore */ }
124
118
  }
125
- catch { /* non-fatal */ }
126
119
  this.scheduleWake(slug, 'wake-sentinel');
127
120
  });
128
121
  }
@@ -197,9 +190,7 @@ export class AgentHeartbeatManager {
197
190
  const triggerPath = path.join(BASE_DIR, 'cron', 'goal-triggers', filename);
198
191
  if (!existsSync(triggerPath))
199
192
  return; // file was already consumed by cron-scheduler
200
- // eslint-disable-next-line @typescript-eslint/no-var-requires
201
- const fs = require('node:fs');
202
- const trigger = JSON.parse(fs.readFileSync(triggerPath, 'utf-8'));
193
+ const trigger = JSON.parse(readFileSync(triggerPath, 'utf-8'));
203
194
  if (!trigger.goalId)
204
195
  return;
205
196
  const lookup = listAllGoals().find((g) => g.goal && g.goal.id === trigger.goalId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.84",
3
+ "version": "1.0.86",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",