clementine-agent 1.0.13 → 1.0.15

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.
@@ -10,6 +10,20 @@ import { DeliveryQueue } from './delivery-queue.js';
10
10
  const logger = pino({ name: 'clementine.notifications' });
11
11
  /** Safety cap — prevent runaway messages, but each channel handles its own chunking/limits. */
12
12
  const MAX_MESSAGE_LENGTH = 8000;
13
+ /** Map a sessionKey prefix to the registered channel name that owns it. */
14
+ function channelForSessionKey(sessionKey) {
15
+ if (sessionKey.startsWith('discord:'))
16
+ return 'discord';
17
+ if (sessionKey.startsWith('slack:'))
18
+ return 'slack';
19
+ if (sessionKey.startsWith('telegram:'))
20
+ return 'telegram';
21
+ if (sessionKey.startsWith('whatsapp:'))
22
+ return 'whatsapp';
23
+ if (sessionKey.startsWith('dashboard:'))
24
+ return 'dashboard';
25
+ return null;
26
+ }
13
27
  export class NotificationDispatcher {
14
28
  senders = new Map();
15
29
  _retryQueue;
@@ -52,9 +66,20 @@ export class NotificationDispatcher {
52
66
  const capped = text.length > MAX_MESSAGE_LENGTH
53
67
  ? text.slice(0, MAX_MESSAGE_LENGTH - 20) + '\n\n_(truncated)_'
54
68
  : text;
69
+ // If sessionKey is set, route only to the channel that owns it.
70
+ // Fan out to all channels only when no originating channel is known.
71
+ const targetChannel = context?.sessionKey ? channelForSessionKey(context.sessionKey) : null;
72
+ const scopedSenders = [];
73
+ if (targetChannel && this.senders.has(targetChannel)) {
74
+ scopedSenders.push([targetChannel, this.senders.get(targetChannel)]);
75
+ }
76
+ else {
77
+ for (const entry of this.senders)
78
+ scopedSenders.push(entry);
79
+ }
55
80
  const channelErrors = {};
56
81
  let anySuccess = false;
57
- for (const [name, sender] of this.senders) {
82
+ for (const [name, sender] of scopedSenders) {
58
83
  try {
59
84
  await sender(capped, context);
60
85
  anySuccess = true;
@@ -185,14 +185,14 @@ export class Gateway {
185
185
  try {
186
186
  const agentReply = await this.handleMessage(sessionKey, syntheticPrompt);
187
187
  if (agentReply?.trim()) {
188
- await this._dispatcher?.send(agentReply);
188
+ await this._dispatcher?.send(agentReply, { sessionKey });
189
189
  logger.info({ sessionKey }, 'Deep mode result delivered via agent follow-up + dispatcher');
190
190
  }
191
191
  }
192
192
  catch (err) {
193
193
  logger.warn({ err, sessionKey }, 'Deep mode agent follow-up failed — using raw fallback');
194
194
  if (rawFallback.trim()) {
195
- await this._dispatcher?.send(rawFallback.slice(0, 1500))
195
+ await this._dispatcher?.send(rawFallback.slice(0, 1500), { sessionKey })
196
196
  .catch(async (e) => {
197
197
  // Both paths failed — surface it instead of swallowing at debug level.
198
198
  logger.warn({ err: e, sessionKey }, 'Deep mode fallback delivery failed — persisting to daily note');
@@ -22,6 +22,26 @@ export declare class MemoryStore {
22
22
  * Create the database and schema if needed.
23
23
  */
24
24
  initialize(): void;
25
+ private _stmtLogSkillUse;
26
+ /**
27
+ * Record that a skill was retrieved and injected into a query context.
28
+ * Outcome is left null; a follow-up could backfill from reflection scores.
29
+ */
30
+ logSkillUse(row: {
31
+ skillName: string;
32
+ sessionKey?: string | null;
33
+ queryText?: string | null;
34
+ score?: number | null;
35
+ agentSlug?: string | null;
36
+ }): void;
37
+ /** Aggregate skill usage stats keyed by skill_name. */
38
+ skillUsageStats(windowDays?: number): Map<string, {
39
+ retrievals: number;
40
+ lastRetrievedAt: string | null;
41
+ avgScore: number | null;
42
+ }>;
43
+ /** Number of times a skill has been retrieved (all time). */
44
+ skillRetrievalCount(skillName: string): number;
25
45
  /**
26
46
  * Close the database connection.
27
47
  */
@@ -405,8 +405,72 @@ export class MemoryStore {
405
405
  CREATE INDEX IF NOT EXISTS idx_sf_sync_local ON sf_sync_log(local_table, local_id);
406
406
  CREATE INDEX IF NOT EXISTS idx_sf_sync_sfid ON sf_sync_log(sf_id);
407
407
  CREATE INDEX IF NOT EXISTS idx_sf_sync_status ON sf_sync_log(sync_status);
408
+
409
+ CREATE TABLE IF NOT EXISTS skill_usage (
410
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
411
+ skill_name TEXT NOT NULL,
412
+ session_key TEXT,
413
+ query_text TEXT,
414
+ retrieved_at TEXT NOT NULL DEFAULT (datetime('now')),
415
+ score REAL,
416
+ outcome TEXT,
417
+ agent_slug TEXT
418
+ );
419
+ CREATE INDEX IF NOT EXISTS idx_skill_usage_name ON skill_usage(skill_name, retrieved_at DESC);
420
+ CREATE INDEX IF NOT EXISTS idx_skill_usage_time ON skill_usage(retrieved_at DESC);
408
421
  `);
409
422
  }
423
+ // ── Skill usage telemetry ─────────────────────────────────────────
424
+ _stmtLogSkillUse = null;
425
+ /**
426
+ * Record that a skill was retrieved and injected into a query context.
427
+ * Outcome is left null; a follow-up could backfill from reflection scores.
428
+ */
429
+ logSkillUse(row) {
430
+ try {
431
+ if (!this._stmtLogSkillUse) {
432
+ this._stmtLogSkillUse = this.conn.prepare('INSERT INTO skill_usage (skill_name, session_key, query_text, score, agent_slug) VALUES (?, ?, ?, ?, ?)');
433
+ }
434
+ this._stmtLogSkillUse.run(row.skillName, row.sessionKey ?? null, row.queryText ? row.queryText.slice(0, 200) : null, row.score ?? null, row.agentSlug ?? null);
435
+ }
436
+ catch {
437
+ // Best-effort — telemetry must never break retrieval.
438
+ }
439
+ }
440
+ /** Aggregate skill usage stats keyed by skill_name. */
441
+ skillUsageStats(windowDays = 7) {
442
+ const out = new Map();
443
+ try {
444
+ const rows = this.conn.prepare(`SELECT skill_name,
445
+ COUNT(*) AS retrievals,
446
+ MAX(retrieved_at) AS last_retrieved_at,
447
+ AVG(score) AS avg_score
448
+ FROM skill_usage
449
+ WHERE retrieved_at >= datetime('now', ?)
450
+ GROUP BY skill_name`).all(`-${Math.max(1, Math.floor(windowDays))} days`);
451
+ for (const r of rows) {
452
+ out.set(r.skill_name, {
453
+ retrievals: r.retrievals,
454
+ lastRetrievedAt: r.last_retrieved_at,
455
+ avgScore: r.avg_score,
456
+ });
457
+ }
458
+ }
459
+ catch {
460
+ // Table may not exist yet on legacy DBs — caller should tolerate empty.
461
+ }
462
+ return out;
463
+ }
464
+ /** Number of times a skill has been retrieved (all time). */
465
+ skillRetrievalCount(skillName) {
466
+ try {
467
+ const row = this.conn.prepare('SELECT COUNT(*) AS cnt FROM skill_usage WHERE skill_name = ?').get(skillName);
468
+ return row?.cnt ?? 0;
469
+ }
470
+ catch {
471
+ return 0;
472
+ }
473
+ }
410
474
  /**
411
475
  * Close the database connection.
412
476
  */
package/dist/types.d.ts CHANGED
@@ -104,6 +104,8 @@ export type OnTextCallback = (text: string) => Promise<void>;
104
104
  export type OnToolActivityCallback = (toolName: string, toolInput: Record<string, unknown>) => Promise<void>;
105
105
  export interface NotificationContext {
106
106
  agentSlug?: string;
107
+ /** When set, the dispatcher routes the message back to the channel that owns this session. */
108
+ sessionKey?: string;
107
109
  }
108
110
  export type NotificationSender = (text: string, context?: NotificationContext) => Promise<void>;
109
111
  /** Policy governing autonomous outbound email sending for an agent. */
@@ -192,6 +194,7 @@ export interface HeartbeatState {
192
194
  lastSelfImproveDate?: string;
193
195
  lastConsolidationDate?: string;
194
196
  lastAgentSiRuns?: Record<string, string>;
197
+ lastSkillDecayDate?: string;
195
198
  /** Proactive insight engine state */
196
199
  insightState?: {
197
200
  sentToday: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",