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.
@@ -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(', ')}`;
@@ -168,6 +168,29 @@ export class SelfImproveLoop {
168
168
  logger.info('Captured SOUL.md baseline for drift detection');
169
169
  }
170
170
  const state = this.loadState();
171
+ // If a prior run aborted on an infrastructure error that can't be fixed
172
+ // by retrying (malformed MCP tool schema, bad auth, etc.), don't spin
173
+ // the loop pointlessly. Wait at least 24h before re-probing — this gives
174
+ // the owner time to fix the infra and prevents us from writing dozens
175
+ // of identical error experiments. The failure monitor surfaces the
176
+ // infraError to the owner via the broken-jobs pipeline.
177
+ if (state.infraError && state.lastRunAt) {
178
+ const hoursSinceRun = (Date.now() - Date.parse(state.lastRunAt)) / 3_600_000;
179
+ if (Number.isFinite(hoursSinceRun) && hoursSinceRun < 24) {
180
+ logger.warn({
181
+ category: state.infraError.category,
182
+ diagnostic: state.infraError.diagnostic,
183
+ hoursSinceRun: Math.round(hoursSinceRun),
184
+ }, 'Self-improve skipped — prior infra error still in cooldown. See Broken Jobs panel.');
185
+ state.status = 'completed';
186
+ this.saveState(state);
187
+ return state;
188
+ }
189
+ // Past cooldown — clear the flag and probe fresh. If it still errors,
190
+ // the loop will set it again.
191
+ logger.info('Self-improve: infra error cooldown elapsed, probing again');
192
+ delete state.infraError;
193
+ }
171
194
  state.status = 'running';
172
195
  state.lastRunAt = new Date().toISOString();
173
196
  state.currentIteration = 0;
@@ -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({