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.
- package/dist/agent/assistant.js +32 -2
- package/dist/agent/self-improve.js +23 -0
- package/dist/agent/skill-extractor.d.ts +10 -0
- package/dist/agent/skill-extractor.js +61 -0
- package/dist/channels/discord-agent-bot.d.ts +4 -0
- package/dist/channels/discord-agent-bot.js +35 -0
- package/dist/channels/discord-bot-manager.d.ts +4 -0
- package/dist/channels/discord-bot-manager.js +16 -0
- package/dist/channels/discord.js +141 -0
- package/dist/channels/slack.js +51 -1
- package/dist/channels/telegram.js +28 -1
- package/dist/cli/dashboard.js +299 -5
- package/dist/gateway/cron-scheduler.d.ts +5 -0
- package/dist/gateway/cron-scheduler.js +32 -5
- package/dist/gateway/failure-monitor.d.ts +40 -0
- package/dist/gateway/failure-monitor.js +416 -0
- package/dist/gateway/fix-verification.d.ts +39 -0
- package/dist/gateway/fix-verification.js +144 -0
- package/dist/gateway/heartbeat-scheduler.js +61 -4
- package/dist/gateway/notifications.js +26 -1
- package/dist/gateway/router.js +2 -2
- package/dist/memory/store.d.ts +20 -0
- package/dist/memory/store.js +64 -0
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/dist/channels/discord.js
CHANGED
|
@@ -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 {
|
package/dist/channels/slack.js
CHANGED
|
@@ -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({
|