clementine-agent 1.0.13 → 1.0.14

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.
@@ -982,6 +982,13 @@ Never spawn a sub-agent with vague instructions like "handle this brief" — tel
982
982
  const matchedSkills = searchSkillsSync(this._lastUserMessage, 1, profile?.slug);
983
983
  if (matchedSkills.length > 0 && matchedSkills[0].score >= 4) {
984
984
  const skill = matchedSkills[0];
985
+ this.memoryStore?.logSkillUse?.({
986
+ skillName: skill.name,
987
+ sessionKey: sessionKey ?? null,
988
+ queryText: this._lastUserMessage,
989
+ score: skill.score,
990
+ agentSlug: profile?.slug ?? null,
991
+ });
985
992
  let skillBlock = `## Relevant Skill: ${skill.title}\n\n${skill.content.slice(0, 800)}`;
986
993
  // Surface linked tools + warn about whitelist conflicts
987
994
  if (skill.toolsUsed.length > 0) {
@@ -1419,6 +1426,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1419
1426
  return `## Relevant Procedures (from past successful executions)\n\n` +
1420
1427
  matchedSkills.map(s => {
1421
1428
  recordSkillUse(s.name);
1429
+ this.memoryStore?.logSkillUse?.({
1430
+ skillName: s.name,
1431
+ sessionKey: sessionKey ?? null,
1432
+ queryText: enrichedQuery,
1433
+ score: s.score,
1434
+ agentSlug: agentSlug || null,
1435
+ });
1422
1436
  return `## Skill: ${s.title}\n${s.content}`;
1423
1437
  }).join('\n\n');
1424
1438
  }
@@ -3125,10 +3139,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3125
3139
  try {
3126
3140
  const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
3127
3141
  const cronAgentSlug = sdkOptions.env?.CLEMENTINE_TEAM_AGENT;
3128
- const matchedSkills = searchSkills(jobName + ' ' + jobPrompt.slice(0, 200), 2, cronAgentSlug || undefined);
3142
+ const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
3143
+ const matchedSkills = searchSkills(skillQuery, 2, cronAgentSlug || undefined);
3129
3144
  if (matchedSkills.length > 0) {
3130
3145
  const skillLines = matchedSkills.map(s => {
3131
3146
  recordSkillUse(s.name);
3147
+ this.memoryStore?.logSkillUse?.({
3148
+ skillName: s.name,
3149
+ sessionKey: `cron:${cronAgentSlug ?? 'clementine'}:${jobName}`,
3150
+ queryText: skillQuery,
3151
+ score: s.score,
3152
+ agentSlug: cronAgentSlug || null,
3153
+ });
3132
3154
  let block = `### ${s.title}\n${s.content}`;
3133
3155
  if (s.toolsUsed.length > 0)
3134
3156
  block += `\n**Tools:** ${s.toolsUsed.join(', ')}`;
@@ -3475,11 +3497,19 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3475
3497
  try {
3476
3498
  const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
3477
3499
  const unleashedAgentSlug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
3478
- const matchedSkills = searchSkills(jobName + ' ' + jobPrompt.slice(0, 200), 2, unleashedAgentSlug);
3500
+ const unleashedSkillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
3501
+ const matchedSkills = searchSkills(unleashedSkillQuery, 2, unleashedAgentSlug);
3479
3502
  if (matchedSkills.length > 0) {
3480
3503
  unleashedSkillContext = `\n\n## Learned Procedures\nFollow these proven approaches when applicable:\n\n` +
3481
3504
  matchedSkills.map(s => {
3482
3505
  recordSkillUse(s.name);
3506
+ this.memoryStore?.logSkillUse?.({
3507
+ skillName: s.name,
3508
+ sessionKey: `unleashed:${jobName}`,
3509
+ queryText: unleashedSkillQuery,
3510
+ score: s.score,
3511
+ agentSlug: unleashedAgentSlug || null,
3512
+ });
3483
3513
  let block = `### ${s.title}\n${s.content}`;
3484
3514
  if (s.toolsUsed.length > 0)
3485
3515
  block += `\n**Tools:** ${s.toolsUsed.join(', ')}`;
@@ -69,4 +69,14 @@ export declare function listSkills(agentSlug?: string): Array<{
69
69
  updatedAt: string;
70
70
  agentSlug?: string;
71
71
  }>;
72
+ /**
73
+ * Move skills that were never used (useCount=0, no usage telemetry rows) and
74
+ * are older than `olderThanDays` to the `.archive/` subdirectory inside their
75
+ * skill dir. Returns the list of archived skill names.
76
+ *
77
+ * `retrievalCount(name)` is consulted so that even skills whose frontmatter
78
+ * useCount wasn't bumped (the FS write is best-effort) still get preserved
79
+ * when the SQLite telemetry shows retrievals.
80
+ */
81
+ export declare function archiveStaleSkills(olderThanDays?: number, retrievalCount?: (skillName: string) => number): string[];
72
82
  //# sourceMappingURL=skill-extractor.d.ts.map
@@ -424,6 +424,67 @@ export function listSkills(agentSlug) {
424
424
  }
425
425
  return results;
426
426
  }
427
+ // ── Stale skill archival ────────────────────────────────────────────
428
+ /**
429
+ * Move skills that were never used (useCount=0, no usage telemetry rows) and
430
+ * are older than `olderThanDays` to the `.archive/` subdirectory inside their
431
+ * skill dir. Returns the list of archived skill names.
432
+ *
433
+ * `retrievalCount(name)` is consulted so that even skills whose frontmatter
434
+ * useCount wasn't bumped (the FS write is best-effort) still get preserved
435
+ * when the SQLite telemetry shows retrievals.
436
+ */
437
+ export function archiveStaleSkills(olderThanDays = 90, retrievalCount) {
438
+ ensureDirs();
439
+ const archived = [];
440
+ const cutoffMs = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
441
+ const dirs = [];
442
+ if (existsSync(GLOBAL_SKILLS_DIR))
443
+ dirs.push(GLOBAL_SKILLS_DIR);
444
+ if (existsSync(AGENTS_DIR)) {
445
+ for (const entry of readdirSync(AGENTS_DIR)) {
446
+ const candidate = path.join(AGENTS_DIR, entry, 'skills');
447
+ if (existsSync(candidate))
448
+ dirs.push(candidate);
449
+ }
450
+ }
451
+ for (const dir of dirs) {
452
+ const archiveDir = path.join(dir, '.archive');
453
+ for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
454
+ const filePath = path.join(dir, file);
455
+ try {
456
+ const parsed = matter(readFileSync(filePath, 'utf-8'));
457
+ const name = file.replace(/\.md$/, '');
458
+ const useCount = Number(parsed.data.useCount ?? 0);
459
+ const createdAt = parsed.data.createdAt ? Date.parse(parsed.data.createdAt) : NaN;
460
+ if (!Number.isFinite(createdAt) || createdAt > cutoffMs)
461
+ continue;
462
+ if (useCount > 0)
463
+ continue;
464
+ if (retrievalCount && retrievalCount(name) > 0)
465
+ continue;
466
+ if (!existsSync(archiveDir))
467
+ mkdirSync(archiveDir, { recursive: true });
468
+ const archivePath = path.join(archiveDir, file);
469
+ copyFileSync(filePath, archivePath);
470
+ unlinkSync(filePath);
471
+ // Also move the backup if it exists
472
+ const bakPath = filePath.replace(/\.md$/, '.md.bak');
473
+ if (existsSync(bakPath)) {
474
+ try {
475
+ copyFileSync(bakPath, archivePath.replace(/\.md$/, '.md.bak'));
476
+ unlinkSync(bakPath);
477
+ }
478
+ catch { /* best-effort */ }
479
+ }
480
+ archived.push(name);
481
+ logger.info({ name, dir }, 'Archived stale skill (unused for ' + olderThanDays + '+ days)');
482
+ }
483
+ catch { /* skip malformed */ }
484
+ }
485
+ }
486
+ return archived;
487
+ }
427
488
  // ── Helpers ─────────────────────────────────────────────────────────
428
489
  function slugify(text) {
429
490
  return text
@@ -72,6 +72,10 @@ export declare class AgentBotClient {
72
72
  * Used by BotManager.sendAsAgent() for cron result routing.
73
73
  */
74
74
  sendNotification(text: string, embed?: EmbedBuilder): Promise<void>;
75
+ /** Send a DM to a specific user via this agent bot. */
76
+ sendDmTo(userId: string, text: string, embed?: EmbedBuilder): Promise<void>;
77
+ /** Send a message to a specific channel via this agent bot. */
78
+ sendToChannel(channelId: string, text: string, embed?: EmbedBuilder): Promise<void>;
75
79
  /** Send a startup status embed to the owner's DMs. */
76
80
  private sendStartupStatus;
77
81
  private buildAgentStatusEmbed;
@@ -254,6 +254,41 @@ export class AgentBotClient {
254
254
  }
255
255
  }
256
256
  }
257
+ /** Send a DM to a specific user via this agent bot. */
258
+ async sendDmTo(userId, text, embed) {
259
+ if (this.status !== 'online')
260
+ throw new Error(`Bot ${this.config.slug} is not online`);
261
+ const user = await this.client.users.fetch(userId, { force: true });
262
+ const dmChannel = await user.createDM();
263
+ if (embed) {
264
+ await dmChannel.send({ embeds: [embed] });
265
+ }
266
+ else {
267
+ const { chunkText } = await import('./discord-utils.js');
268
+ for (const chunk of chunkText(text, 1900)) {
269
+ await dmChannel.send(chunk);
270
+ }
271
+ }
272
+ }
273
+ /** Send a message to a specific channel via this agent bot. */
274
+ async sendToChannel(channelId, text, embed) {
275
+ if (this.status !== 'online')
276
+ throw new Error(`Bot ${this.config.slug} is not online`);
277
+ const channel = this.client.channels.cache.get(channelId)
278
+ ?? await this.client.channels.fetch(channelId).catch(() => null);
279
+ if (!channel || !('send' in channel)) {
280
+ throw new Error(`Channel ${channelId} not available to bot ${this.config.slug}`);
281
+ }
282
+ if (embed) {
283
+ await channel.send({ embeds: [embed] });
284
+ }
285
+ else {
286
+ const { chunkText } = await import('./discord-utils.js');
287
+ for (const chunk of chunkText(text, 1900)) {
288
+ await channel.send(chunk);
289
+ }
290
+ }
291
+ }
257
292
  /** Send a startup status embed to the owner's DMs. */
258
293
  async sendStartupStatus() {
259
294
  if (!this.config.ownerId)
@@ -63,6 +63,10 @@ export declare class BotManager {
63
63
  * Throws if the bot is unavailable — callers should fall back to main bot.
64
64
  */
65
65
  sendAsAgent(slug: string, text: string): Promise<void>;
66
+ /** Send a DM to a specific user through the agent's bot. */
67
+ sendAsAgentToUser(slug: string, userId: string, text: string): Promise<void>;
68
+ /** Send a message to a specific channel through the agent's bot. */
69
+ sendAsAgentToChannel(slug: string, channelId: string, text: string): Promise<void>;
66
70
  /**
67
71
  * Deliver a team message to an agent's bot — posts the message visibly
68
72
  * in the bot's channel and triggers the agent to process and respond.
@@ -176,6 +176,22 @@ export class BotManager {
176
176
  const embed = formatCronEmbed(text);
177
177
  await bot.sendNotification(text, embed ?? undefined);
178
178
  }
179
+ /** Send a DM to a specific user through the agent's bot. */
180
+ async sendAsAgentToUser(slug, userId, text) {
181
+ const bot = this.bots.get(slug);
182
+ if (!bot)
183
+ throw new Error(`No bot for agent '${slug}'`);
184
+ const embed = formatCronEmbed(text);
185
+ await bot.sendDmTo(userId, text, embed ?? undefined);
186
+ }
187
+ /** Send a message to a specific channel through the agent's bot. */
188
+ async sendAsAgentToChannel(slug, channelId, text) {
189
+ const bot = this.bots.get(slug);
190
+ if (!bot)
191
+ throw new Error(`No bot for agent '${slug}'`);
192
+ const embed = formatCronEmbed(text);
193
+ await bot.sendToChannel(channelId, text, embed ?? undefined);
194
+ }
179
195
  /**
180
196
  * Deliver a team message to an agent's bot — posts the message visibly
181
197
  * in the bot's channel and triggers the agent to process and respond.
@@ -1671,7 +1671,148 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
1671
1671
  // Cache the owner's DM channel from successful interactions so
1672
1672
  // cron/heartbeat notifications don't depend on a fresh API fetch.
1673
1673
  let cachedDmChannel = null;
1674
+ /**
1675
+ * Send `text` to a specific channel via the main bot. Returns true on success.
1676
+ * Caller should fall back to default delivery on failure.
1677
+ */
1678
+ async function sendToMainBotChannel(channelId, text) {
1679
+ try {
1680
+ const channel = client.channels.cache.get(channelId)
1681
+ ?? await client.channels.fetch(channelId).catch(() => null);
1682
+ if (!channel || !('send' in channel))
1683
+ return false;
1684
+ const embed = formatCronEmbed(text);
1685
+ if (embed) {
1686
+ await channel.send({ embeds: [embed] });
1687
+ }
1688
+ else {
1689
+ for (const chunk of chunkText(text, 1900)) {
1690
+ await channel.send(chunk);
1691
+ }
1692
+ }
1693
+ return true;
1694
+ }
1695
+ catch (err) {
1696
+ logger.warn({ err, channelId }, 'Main bot channel send failed');
1697
+ return false;
1698
+ }
1699
+ }
1700
+ /** Send a DM to a specific user via the main bot. Returns true on success. */
1701
+ async function sendMainBotDm(userId, text) {
1702
+ try {
1703
+ const user = await client.users.fetch(userId, { force: true });
1704
+ const dmChannel = await user.createDM();
1705
+ const embed = formatCronEmbed(text);
1706
+ if (embed) {
1707
+ await dmChannel.send({ embeds: [embed] });
1708
+ }
1709
+ else {
1710
+ for (const chunk of chunkText(text, 1900)) {
1711
+ await dmChannel.send(chunk);
1712
+ }
1713
+ }
1714
+ return true;
1715
+ }
1716
+ catch (err) {
1717
+ logger.warn({ err, userId }, 'Main bot DM send failed');
1718
+ return false;
1719
+ }
1720
+ }
1721
+ /**
1722
+ * Route a notification back to the channel/user identified by sessionKey.
1723
+ * Returns true when delivered; false if the caller should fall back to default routing.
1724
+ *
1725
+ * Session key formats:
1726
+ * discord:user:{userId} → main bot DM to user
1727
+ * discord:channel:{channelId}:{userId} → main bot, send in channel
1728
+ * discord:channel:{channelId}:{slug}:{userId} → agent bot for {slug}, send in channel
1729
+ * discord:agent:{slug}:{userId} → agent bot DM to user
1730
+ * discord:member:{channelId}:{userId} → agent bot that owns {channelId}, send in channel
1731
+ * discord:member:{channelId}:{slug}:{userId} → agent bot for {slug}, send in channel
1732
+ * discord:member-dm:{slug}:{userId} → agent bot DM to non-owner user
1733
+ */
1734
+ async function trySessionRouting(sessionKey, text) {
1735
+ const parts = sessionKey.split(':');
1736
+ if (parts[0] !== 'discord' || parts.length < 3)
1737
+ return false;
1738
+ const kind = parts[1];
1739
+ try {
1740
+ if (kind === 'user' && parts[2]) {
1741
+ return await sendMainBotDm(parts[2], text);
1742
+ }
1743
+ if (kind === 'channel' && parts[2]) {
1744
+ const channelId = parts[2];
1745
+ // discord:channel:{channelId}:{slug}:{userId} → agent bot
1746
+ if (parts.length >= 5 && botManager?.hasBot(parts[3])) {
1747
+ try {
1748
+ await botManager.sendAsAgentToChannel(parts[3], channelId, text);
1749
+ return true;
1750
+ }
1751
+ catch (err) {
1752
+ logger.warn({ err, slug: parts[3], channelId }, 'Agent bot channel send failed — trying main bot');
1753
+ }
1754
+ }
1755
+ return await sendToMainBotChannel(channelId, text);
1756
+ }
1757
+ if (kind === 'agent' && parts[2] && parts[3]) {
1758
+ const slug = parts[2];
1759
+ const userId = parts[3];
1760
+ if (botManager?.hasBot(slug)) {
1761
+ try {
1762
+ await botManager.sendAsAgentToUser(slug, userId, text);
1763
+ return true;
1764
+ }
1765
+ catch (err) {
1766
+ logger.warn({ err, slug, userId }, 'Agent bot DM send failed');
1767
+ return false;
1768
+ }
1769
+ }
1770
+ return false;
1771
+ }
1772
+ if (kind === 'member' && parts[2]) {
1773
+ const channelId = parts[2];
1774
+ // Figure out which agent owns this channel
1775
+ const slug = parts.length >= 5 ? parts[3] : botManager?.getAgentForChannel(channelId) ?? null;
1776
+ if (slug && botManager?.hasBot(slug)) {
1777
+ try {
1778
+ await botManager.sendAsAgentToChannel(slug, channelId, text);
1779
+ return true;
1780
+ }
1781
+ catch (err) {
1782
+ logger.warn({ err, slug, channelId }, 'Agent bot channel send failed for member session');
1783
+ }
1784
+ }
1785
+ return await sendToMainBotChannel(channelId, text);
1786
+ }
1787
+ if (kind === 'member-dm' && parts[2] && parts[3]) {
1788
+ const slug = parts[2];
1789
+ const userId = parts[3];
1790
+ if (botManager?.hasBot(slug)) {
1791
+ try {
1792
+ await botManager.sendAsAgentToUser(slug, userId, text);
1793
+ return true;
1794
+ }
1795
+ catch (err) {
1796
+ logger.warn({ err, slug, userId }, 'Agent bot DM send failed for member-dm session');
1797
+ return false;
1798
+ }
1799
+ }
1800
+ return false;
1801
+ }
1802
+ }
1803
+ catch (err) {
1804
+ logger.warn({ err, sessionKey }, 'Session routing failed');
1805
+ }
1806
+ return false;
1807
+ }
1674
1808
  async function discordNotify(text, context) {
1809
+ // Session-aware routing: send back to the originating channel/user when we know it.
1810
+ if (context?.sessionKey) {
1811
+ const routed = await trySessionRouting(context.sessionKey, text);
1812
+ if (routed)
1813
+ return;
1814
+ // Fall through to legacy routing if session routing couldn't deliver
1815
+ }
1675
1816
  // Route to agent bot if available
1676
1817
  if (context?.agentSlug && botManager?.hasBot(context.agentSlug)) {
1677
1818
  try {
@@ -175,7 +175,14 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
175
175
  }
176
176
  });
177
177
  // Register notification sender
178
- async function slackNotify(text) {
178
+ async function slackNotify(text, context) {
179
+ // Session-aware routing: post back to the originating channel if known.
180
+ if (context?.sessionKey) {
181
+ const routed = await trySlackSessionRouting(context.sessionKey, text);
182
+ if (routed)
183
+ return;
184
+ // Fall back to owner DM below
185
+ }
179
186
  if (!SLACK_OWNER_USER_ID)
180
187
  return;
181
188
  try {
@@ -189,6 +196,49 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
189
196
  logger.error({ err }, 'Failed to send Slack notification');
190
197
  }
191
198
  }
199
+ /**
200
+ * Route a notification back to the Slack channel/thread identified by sessionKey.
201
+ * Returns true on success.
202
+ *
203
+ * Session key formats:
204
+ * slack:user:{userId} → DM to user
205
+ * slack:channel:{channelId}:{userId} → post in channel
206
+ * slack:channel:{channelId}:{slug}:{userId} → post in channel (agent-scoped chat)
207
+ * slack:dm:{userId} → DM to user
208
+ * slack:agent:{slug}:{userId} → DM to user (agent-scoped)
209
+ */
210
+ async function trySlackSessionRouting(sessionKey, text) {
211
+ const parts = sessionKey.split(':');
212
+ if (parts[0] !== 'slack' || parts.length < 3)
213
+ return false;
214
+ const kind = parts[1];
215
+ try {
216
+ if ((kind === 'user' || kind === 'dm') && parts[2]) {
217
+ const dm = await app.client.conversations.open({ users: parts[2] });
218
+ const channelId = dm.channel?.id;
219
+ if (!channelId)
220
+ return false;
221
+ await sendChunkedSlack(app.client, channelId, mdToSlack(text));
222
+ return true;
223
+ }
224
+ if (kind === 'channel' && parts[2]) {
225
+ await sendChunkedSlack(app.client, parts[2], mdToSlack(text));
226
+ return true;
227
+ }
228
+ if (kind === 'agent' && parts[3]) {
229
+ const dm = await app.client.conversations.open({ users: parts[3] });
230
+ const channelId = dm.channel?.id;
231
+ if (!channelId)
232
+ return false;
233
+ await sendChunkedSlack(app.client, channelId, mdToSlack(text));
234
+ return true;
235
+ }
236
+ }
237
+ catch (err) {
238
+ logger.warn({ err, sessionKey }, 'Slack session routing failed');
239
+ }
240
+ return false;
241
+ }
192
242
  dispatcher.register('slack', slackNotify);
193
243
  logger.info('Starting Slack bot (Socket Mode)...');
194
244
  await app.start();
@@ -214,7 +214,20 @@ export async function startTelegram(gateway, dispatcher) {
214
214
  await ctx.reply('Voice messages are not yet supported in this version.');
215
215
  });
216
216
  // Register notification sender
217
- async function telegramNotify(text) {
217
+ async function telegramNotify(text, context) {
218
+ // Session-aware routing: send back to the originating chat when known.
219
+ if (context?.sessionKey) {
220
+ const chatId = parseTelegramSessionKey(context.sessionKey);
221
+ if (chatId) {
222
+ try {
223
+ await sendChunked(bot, chatId, mdToTelegram(text));
224
+ return;
225
+ }
226
+ catch (err) {
227
+ logger.warn({ err, sessionKey: context.sessionKey }, 'Telegram session routing failed — falling back to owner');
228
+ }
229
+ }
230
+ }
218
231
  if (!TELEGRAM_OWNER_ID || ownerIdNum === 0)
219
232
  return;
220
233
  try {
@@ -225,6 +238,20 @@ export async function startTelegram(gateway, dispatcher) {
225
238
  logger.error({ err }, 'Failed to send Telegram notification');
226
239
  }
227
240
  }
241
+ /**
242
+ * Parse a Telegram sessionKey and return the chat ID to post in.
243
+ * Formats supported: telegram:user:{chatId}, telegram:{chatId}.
244
+ */
245
+ function parseTelegramSessionKey(sessionKey) {
246
+ const parts = sessionKey.split(':');
247
+ if (parts[0] !== 'telegram')
248
+ return null;
249
+ const raw = parts[1] === 'user' ? parts[2] : parts[1];
250
+ if (!raw)
251
+ return null;
252
+ const n = Number(raw);
253
+ return Number.isFinite(n) && n !== 0 ? n : null;
254
+ }
228
255
  dispatcher.register('telegram', telegramNotify);
229
256
  logger.info('Starting Telegram bot (long polling)...');
230
257
  await bot.start({
@@ -135,8 +135,18 @@ async function cachedAsync(key, ttlMs, compute) {
135
135
  // ── Lazy gateway for chat ────────────────────────────────────────────
136
136
  let gatewayInstance = null;
137
137
  let gatewayInitializing = false;
138
+ let gatewayDispatcher = null;
139
+ /** SSE broadcaster; set once cmdDashboard has built the SSE infrastructure. */
140
+ let dashboardSseBroadcast = null;
138
141
  /** Reset the cached gateway (called when daemon PID changes). */
139
142
  function resetGateway() {
143
+ if (gatewayDispatcher) {
144
+ try {
145
+ gatewayDispatcher.shutdown();
146
+ }
147
+ catch { /* best-effort */ }
148
+ gatewayDispatcher = null;
149
+ }
140
150
  gatewayInstance = null;
141
151
  responseCache.clear();
142
152
  }
@@ -156,6 +166,26 @@ async function getGateway() {
156
166
  gatewayInstance = new GatewayClass(assistant);
157
167
  const { setApprovalCallback } = await import('../agent/hooks.js');
158
168
  setApprovalCallback(async () => false);
169
+ // Wire a local NotificationDispatcher so deep-task results launched from
170
+ // dashboard chat sessions can be pushed back into the browser via SSE.
171
+ try {
172
+ const { NotificationDispatcher } = await import('../gateway/notifications.js');
173
+ const dispatcher = new NotificationDispatcher();
174
+ dispatcher.register('dashboard', async (text, context) => {
175
+ if (!dashboardSseBroadcast)
176
+ return;
177
+ dashboardSseBroadcast({
178
+ type: 'deep_result',
179
+ data: { sessionKey: context?.sessionKey ?? null, text },
180
+ });
181
+ });
182
+ gatewayInstance.setDispatcher(dispatcher);
183
+ gatewayDispatcher = dispatcher;
184
+ }
185
+ catch (err) {
186
+ // Non-fatal — deep-task results from dashboard sessions just won't surface live
187
+ console.warn('Failed to wire dashboard SSE dispatcher:', err);
188
+ }
159
189
  return gatewayInstance;
160
190
  }
161
191
  catch (err) {
@@ -2009,6 +2039,8 @@ export async function cmdDashboard(opts) {
2009
2039
  }
2010
2040
  }
2011
2041
  }
2042
+ // Let the lazy-gateway dispatcher publish deep_result events through SSE.
2043
+ dashboardSseBroadcast = broadcastEvent;
2012
2044
  // SSE events handler moved before auth middleware (see above)
2013
2045
  // ── POST routes (actions) ──────────────────────────────────────
2014
2046
  app.post('/api/cron/run/:job', (req, res) => {
@@ -3743,19 +3775,87 @@ export async function cmdDashboard(opts) {
3743
3775
  }
3744
3776
  });
3745
3777
  // ── Skills (Procedural Memory) API ──────────────────────────────────
3746
- app.get('/api/skills', (_req, res) => {
3778
+ // NOTE: /api/skills/pending routes must come before /api/skills/:name so
3779
+ // Express doesn't capture "pending" as a :name param.
3780
+ app.get('/api/skills/pending', async (_req, res) => {
3781
+ try {
3782
+ const { listPendingSkills } = await import('../agent/skill-extractor.js');
3783
+ res.json({ skills: listPendingSkills() });
3784
+ }
3785
+ catch (err) {
3786
+ res.status(500).json({ error: String(err) });
3787
+ }
3788
+ });
3789
+ app.post('/api/skills/pending/:name/approve', async (req, res) => {
3790
+ try {
3791
+ const { approvePendingSkill } = await import('../agent/skill-extractor.js');
3792
+ const result = approvePendingSkill(req.params.name);
3793
+ if (!result.ok) {
3794
+ res.status(404).json(result);
3795
+ return;
3796
+ }
3797
+ res.json(result);
3798
+ }
3799
+ catch (err) {
3800
+ res.status(500).json({ error: String(err) });
3801
+ }
3802
+ });
3803
+ app.post('/api/skills/pending/:name/reject', async (req, res) => {
3804
+ try {
3805
+ const { rejectPendingSkill } = await import('../agent/skill-extractor.js');
3806
+ const result = rejectPendingSkill(req.params.name);
3807
+ if (!result.ok) {
3808
+ res.status(404).json(result);
3809
+ return;
3810
+ }
3811
+ res.json(result);
3812
+ }
3813
+ catch (err) {
3814
+ res.status(500).json({ error: String(err) });
3815
+ }
3816
+ });
3817
+ app.get('/api/skills', async (_req, res) => {
3747
3818
  try {
3748
3819
  const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
3749
3820
  if (!existsSync(skillsDir)) {
3750
3821
  res.json({ skills: [] });
3751
3822
  return;
3752
3823
  }
3824
+ // Aggregate last-7-day retrieval stats from skill_usage table (best-effort).
3825
+ const usageStats = new Map();
3826
+ if (existsSync(MEMORY_DB_PATH)) {
3827
+ try {
3828
+ const Database = (await import('better-sqlite3')).default;
3829
+ const db = new Database(MEMORY_DB_PATH, { readonly: true });
3830
+ try {
3831
+ const rows = db.prepare(`SELECT skill_name,
3832
+ COUNT(*) AS retrievals,
3833
+ MAX(retrieved_at) AS last_retrieved_at,
3834
+ AVG(score) AS avg_score
3835
+ FROM skill_usage
3836
+ WHERE retrieved_at >= datetime('now', '-7 days')
3837
+ GROUP BY skill_name`).all();
3838
+ for (const r of rows) {
3839
+ usageStats.set(r.skill_name, {
3840
+ retrievals7d: r.retrievals,
3841
+ lastRetrievedAt: r.last_retrieved_at,
3842
+ avgScore: r.avg_score,
3843
+ });
3844
+ }
3845
+ }
3846
+ catch { /* skill_usage may not exist on older DBs */ }
3847
+ db.close();
3848
+ }
3849
+ catch { /* non-fatal */ }
3850
+ }
3753
3851
  const files = readdirSync(skillsDir).filter(f => f.endsWith('.md'));
3754
3852
  const skills = files.map(f => {
3755
3853
  try {
3756
3854
  const parsed = matter(readFileSync(path.join(skillsDir, f), 'utf-8'));
3855
+ const name = f.replace('.md', '');
3856
+ const stats = usageStats.get(name);
3757
3857
  return {
3758
- name: f.replace('.md', ''),
3858
+ name,
3759
3859
  title: parsed.data.title ?? f,
3760
3860
  description: parsed.data.description ?? '',
3761
3861
  source: parsed.data.source ?? 'unknown',
@@ -3766,6 +3866,9 @@ export async function cmdDashboard(opts) {
3766
3866
  lastUsed: parsed.data.lastUsed ?? null,
3767
3867
  createdAt: parsed.data.createdAt ?? '',
3768
3868
  updatedAt: parsed.data.updatedAt ?? '',
3869
+ retrievals7d: stats?.retrievals7d ?? 0,
3870
+ lastRetrievedAt: stats?.lastRetrievedAt ?? null,
3871
+ avgScore: stats?.avgScore ?? null,
3769
3872
  };
3770
3873
  }
3771
3874
  catch {
@@ -8974,7 +9077,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
8974
9077
  <button class="active" onclick="switchTab('automations','scheduled')">Scheduled Tasks</button>
8975
9078
  <button onclick="switchTab('automations','timers')">Timers <span class="tab-badge" id="tab-timer-count" style="display:none">0</span></button>
8976
9079
  <button onclick="switchTab('automations','self-improve')">Self-Improve <span class="tab-badge" id="tab-si-pending" style="display:none">0</span></button>
8977
- <button onclick="switchTab('automations','skills')">Skills <span class="tab-badge" id="tab-skill-count" style="display:none">0</span></button>
9080
+ <button onclick="switchTab('automations','skills')">Skills <span class="tab-badge" id="tab-skill-count" style="display:none">0</span><span class="tab-badge" id="tab-pending-skill-count" title="pending approval" style="display:none;background:#f59e0b;color:#000">0</span></button>
8978
9081
  <button onclick="switchTab('automations','analytics')">Execution Analytics</button>
8979
9082
  </div>
8980
9083
  <div id="automations-tab-content">
@@ -9032,6 +9135,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
9032
9135
  </div>
9033
9136
  </div>
9034
9137
  </div>
9138
+ <div class="card" id="pending-skills-card" style="display:none">
9139
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
9140
+ <span>Pending Approval</span>
9141
+ <span class="badge badge-orange" id="pending-skills-count-badge" style="font-size:10px">0 pending</span>
9142
+ </div>
9143
+ <div class="card-body" id="panel-pending-skills"></div>
9144
+ </div>
9035
9145
  <div class="card">
9036
9146
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
9037
9147
  <span>Learned Skills</span>
@@ -16031,7 +16141,83 @@ async function expandSkill(name) {
16031
16141
  } catch(e) { toast('Failed to load skill', 'error'); }
16032
16142
  }
16033
16143
 
16144
+ async function refreshPendingSkills() {
16145
+ try {
16146
+ var r = await apiFetch('/api/skills/pending');
16147
+ var d = await r.json();
16148
+ var pending = d.skills || [];
16149
+ var tabBadge = document.getElementById('tab-pending-skill-count');
16150
+ if (tabBadge) {
16151
+ tabBadge.textContent = String(pending.length);
16152
+ tabBadge.style.display = pending.length > 0 ? '' : 'none';
16153
+ }
16154
+ var card = document.getElementById('pending-skills-card');
16155
+ var countBadge = document.getElementById('pending-skills-count-badge');
16156
+ var container = document.getElementById('panel-pending-skills');
16157
+ if (!container) return;
16158
+ if (pending.length === 0) {
16159
+ if (card) card.style.display = 'none';
16160
+ container.innerHTML = '';
16161
+ return;
16162
+ }
16163
+ if (card) card.style.display = '';
16164
+ if (countBadge) countBadge.textContent = pending.length + ' pending';
16165
+
16166
+ var html = '<div style="display:flex;flex-direction:column;gap:10px">';
16167
+ for (var s of pending) {
16168
+ var sourceTag = s.source === 'cron' ? '<span class="badge badge-green" style="font-size:10px">cron</span>'
16169
+ : s.source === 'unleashed' ? '<span class="badge badge-purple" style="font-size:10px">unleashed</span>'
16170
+ : s.source === 'chat' ? '<span class="badge badge-blue" style="font-size:10px">chat</span>'
16171
+ : '<span class="badge badge-gray" style="font-size:10px">' + esc(s.source || 'unknown') + '</span>';
16172
+ var age = s.createdAt ? timeAgo(s.createdAt) : '';
16173
+ var scopeTag = s.agentSlug
16174
+ ? '<span style="font-size:10px;color:var(--text-muted)">for ' + esc(s.agentSlug) + '</span>'
16175
+ : '<span style="font-size:10px;color:var(--text-muted)">global</span>';
16176
+ html += '<div style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">'
16177
+ + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
16178
+ + '<strong>' + esc(s.title) + '</strong> ' + sourceTag + ' ' + scopeTag
16179
+ + (age ? ' <span style="font-size:10px;color:var(--text-muted)">\\u00b7 learned ' + age + '</span>' : '')
16180
+ + '<span style="margin-left:auto;display:flex;gap:6px">'
16181
+ + '<button onclick="approvePendingSkill(\\x27' + esc(s.name) + '\\x27)" style="background:var(--accent);border:1px solid var(--accent);border-radius:4px;padding:3px 10px;font-size:11px;color:white;cursor:pointer">Approve</button>'
16182
+ + '<button onclick="rejectPendingSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:3px 10px;font-size:11px;color:var(--red);cursor:pointer">Reject</button>'
16183
+ + '</span>'
16184
+ + '</div>'
16185
+ + '<div style="font-size:12px;color:var(--text-secondary)">' + esc(s.description || '') + '</div>'
16186
+ + '</div>';
16187
+ }
16188
+ html += '</div>';
16189
+ container.innerHTML = html;
16190
+ } catch(e) { /* non-fatal */ }
16191
+ }
16192
+
16193
+ async function approvePendingSkill(name) {
16194
+ try {
16195
+ var r = await apiJson('POST', '/api/skills/pending/' + encodeURIComponent(name) + '/approve', {});
16196
+ if (r && r.ok) {
16197
+ toast(r.message || 'Skill approved', 'success');
16198
+ refreshPendingSkills();
16199
+ refreshSkills();
16200
+ } else {
16201
+ toast((r && r.message) || 'Failed to approve', 'error');
16202
+ }
16203
+ } catch(e) { toast('Failed to approve skill', 'error'); }
16204
+ }
16205
+
16206
+ async function rejectPendingSkill(name) {
16207
+ if (!confirm('Reject this pending skill? It will be deleted.')) return;
16208
+ try {
16209
+ var r = await apiJson('POST', '/api/skills/pending/' + encodeURIComponent(name) + '/reject', {});
16210
+ if (r && r.ok) {
16211
+ toast(r.message || 'Skill rejected', 'success');
16212
+ refreshPendingSkills();
16213
+ } else {
16214
+ toast((r && r.message) || 'Failed to reject', 'error');
16215
+ }
16216
+ } catch(e) { toast('Failed to reject skill', 'error'); }
16217
+ }
16218
+
16034
16219
  async function refreshSkills() {
16220
+ refreshPendingSkills();
16035
16221
  try {
16036
16222
  var r = await apiFetch('/api/skills');
16037
16223
  var d = await r.json();
@@ -16075,12 +16261,15 @@ async function refreshSkills() {
16075
16261
  + (s.toolsUsed.length > 4 ? ' <span style="font-size:10px;color:var(--text-muted)">+' + (s.toolsUsed.length - 4) + '</span>' : '')
16076
16262
  + '</div>';
16077
16263
  }
16264
+ var retrieval7d = (typeof s.retrievals7d === 'number' && s.retrievals7d > 0)
16265
+ ? ' \\u00b7 ' + s.retrievals7d + ' retrievals (7d)'
16266
+ : '';
16078
16267
  html += '<div id="skill-card-' + esc(s.name) + '" style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">'
16079
16268
  + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">'
16080
16269
  + '<strong style="cursor:pointer" onclick="expandSkill(\\x27' + esc(s.name) + '\\x27)">' + esc(s.title) + '</strong> ' + sourceTag
16081
16270
  + (sourceCtx ? ' ' + sourceCtx : '')
16082
16271
  + '<span style="margin-left:auto;display:flex;align-items:center;gap:8px">'
16083
- + '<span style="font-size:11px;color:var(--text-muted)">used ' + s.useCount + 'x' + (age ? ' \\u00b7 ' + age : '') + '</span>'
16272
+ + '<span style="font-size:11px;color:var(--text-muted)">used ' + s.useCount + 'x' + (age ? ' \\u00b7 ' + age : '') + retrieval7d + '</span>'
16084
16273
  + '<button onclick="expandSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--text-secondary);cursor:pointer">View</button>'
16085
16274
  + '<button onclick="editSkillInBuilder(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--accent);cursor:pointer">Edit</button>'
16086
16275
  + '<button onclick="deleteSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--red);cursor:pointer">Delete</button>'
@@ -17538,6 +17727,34 @@ try {
17538
17727
  toast('Daemon restarted \u2014 refreshing data...', 'info');
17539
17728
  setTimeout(function() { refreshAll(); }, 1500);
17540
17729
  }
17730
+ if (evt.type === 'deep_result') {
17731
+ try {
17732
+ var container = document.getElementById('chat-messages');
17733
+ var text = (evt.data && evt.data.text) ? evt.data.text : '';
17734
+ if (container && text) {
17735
+ var emptyState = container.querySelector('.empty-state');
17736
+ if (emptyState) emptyState.remove();
17737
+ var row = document.createElement('div');
17738
+ row.className = 'chat-assistant-row';
17739
+ var av = document.createElement('div');
17740
+ av.className = 'chat-avatar-sm';
17741
+ av.innerHTML = (lastStatusData && lastStatusData.name ? lastStatusData.name : 'C').charAt(0).toUpperCase();
17742
+ row.appendChild(av);
17743
+ var bubble = document.createElement('div');
17744
+ bubble.className = 'chat-bubble assistant';
17745
+ bubble.innerHTML = renderMd(text);
17746
+ var meta = document.createElement('div');
17747
+ meta.className = 'chat-meta';
17748
+ meta.textContent = new Date().toLocaleTimeString() + ' \u00b7 deep task';
17749
+ bubble.appendChild(meta);
17750
+ row.appendChild(bubble);
17751
+ container.appendChild(row);
17752
+ container.scrollTop = container.scrollHeight;
17753
+ } else {
17754
+ toast('Deep task result ready \u2014 open chat to view.', 'info');
17755
+ }
17756
+ } catch(e) { /* non-fatal */ }
17757
+ }
17541
17758
  } catch(err) { /* ignore */ }
17542
17759
  };
17543
17760
  } catch(err) { /* SSE not supported */ }
@@ -174,6 +174,25 @@ export class HeartbeatScheduler {
174
174
  logger.warn({ err }, 'Per-agent self-improvement scheduling error');
175
175
  }
176
176
  }
177
+ // Daily stale-skill archival: run once per day at 3 AM. Skills never
178
+ // retrieved in 90+ days (both frontmatter useCount and skill_usage empty)
179
+ // get moved to .archive/ so they stop competing in trigger matching.
180
+ if (hour === 3 && this.lastState.lastSkillDecayDate !== todayISO()) {
181
+ this.lastState.lastSkillDecayDate = todayISO();
182
+ this.saveState();
183
+ import('../agent/skill-extractor.js').then(({ archiveStaleSkills }) => {
184
+ try {
185
+ const store = this.gateway.getMemoryStore();
186
+ const archived = archiveStaleSkills(90, store ? (name) => store.skillRetrievalCount(name) : undefined);
187
+ if (archived.length > 0) {
188
+ logger.info({ count: archived.length, names: archived.slice(0, 5) }, 'Archived stale skills');
189
+ }
190
+ }
191
+ catch (err) {
192
+ logger.warn({ err }, 'Stale skill archival failed');
193
+ }
194
+ }).catch(err => logger.warn({ err }, 'Stale skill archival import failed'));
195
+ }
177
196
  // Evening memory consolidation: once per day between 7-9 PM
178
197
  if (hour >= 19 && hour < 21 && this.lastConsolidationDate !== todayISO()) {
179
198
  this.lastConsolidationDate = todayISO();
@@ -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.14",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",