clementine-agent 1.0.26 → 1.0.27
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.d.ts +3 -3
- package/dist/agent/assistant.js +41 -5
- package/dist/agent/route-classifier.d.ts +4 -0
- package/dist/agent/route-classifier.js +37 -0
- package/dist/channels/discord-agent-bot.d.ts +0 -2
- package/dist/channels/discord-agent-bot.js +16 -28
- package/dist/channels/discord-utils.d.ts +13 -1
- package/dist/channels/discord-utils.js +64 -0
- package/dist/channels/discord.js +36 -6
- package/dist/gateway/cron-scheduler.js +13 -4
- package/dist/gateway/router.d.ts +10 -4
- package/dist/gateway/router.js +70 -21
- package/package.json +1 -1
|
@@ -186,7 +186,7 @@ export declare class PersonalAssistant {
|
|
|
186
186
|
};
|
|
187
187
|
delegateProfile?: AgentProfile;
|
|
188
188
|
}): Promise<string>;
|
|
189
|
-
runCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, timeoutMs?: number, successCriteria?: string[]): Promise<string>;
|
|
189
|
+
runCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
|
|
190
190
|
/**
|
|
191
191
|
* Goal-backward verification pass using Haiku after cron job execution.
|
|
192
192
|
* Instead of vague quality ratings, verifies actual outcomes:
|
|
@@ -195,7 +195,7 @@ export declare class PersonalAssistant {
|
|
|
195
195
|
* 3. Does it connect to the goal / produce actionable results? (wired)
|
|
196
196
|
*/
|
|
197
197
|
private runCronReflection;
|
|
198
|
-
runUnleashedTask(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, maxHours?: number): Promise<string>;
|
|
198
|
+
runUnleashedTask(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, maxHours?: number, agentSlug?: string): Promise<string>;
|
|
199
199
|
/**
|
|
200
200
|
* Run a team message as an unleashed-style autonomous task.
|
|
201
201
|
* Gives team agents the same multi-phase execution as cron jobs,
|
|
@@ -203,7 +203,7 @@ export declare class PersonalAssistant {
|
|
|
203
203
|
*
|
|
204
204
|
* @param onText Streaming callback for real-time progress updates
|
|
205
205
|
*/
|
|
206
|
-
runTeamTask(fromName: string, fromSlug: string, content: string, profile: AgentProfile, onText?: (token: string) => void): Promise<string>;
|
|
206
|
+
runTeamTask(fromName: string, fromSlug: string, content: string, profile: AgentProfile, onText?: (token: string) => void, externalAbortController?: AbortController): Promise<string>;
|
|
207
207
|
/**
|
|
208
208
|
* Inject a user/assistant exchange into a session's context without running
|
|
209
209
|
* a query. Used to give the DM session visibility of cron/heartbeat outputs
|
package/dist/agent/assistant.js
CHANGED
|
@@ -1302,11 +1302,16 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1302
1302
|
: isCron && !isUnleashed ? 'medium'
|
|
1303
1303
|
: isPlanStep || isUnleashed ? 'high'
|
|
1304
1304
|
: undefined);
|
|
1305
|
-
// ── Compute budget
|
|
1305
|
+
// ── Compute budget (telemetry only) ───────────────────────────
|
|
1306
|
+
// Cost is informational on a Claude subscription — killing a job
|
|
1307
|
+
// mid-phase because it hit $5 in tokens is worse than the cost.
|
|
1308
|
+
// We still compute the figure so dashboards/logs can show it, but
|
|
1309
|
+
// do not pass it into the SDK as an enforcement knob.
|
|
1306
1310
|
const computedBudget = maxBudgetUsd ?? (isHeartbeat && !isCron ? BUDGET.heartbeat
|
|
1307
1311
|
: isCron && (cronTier ?? 0) < 2 ? BUDGET.cronT1
|
|
1308
1312
|
: isCron ? BUDGET.cronT2
|
|
1309
1313
|
: BUDGET.chat);
|
|
1314
|
+
void computedBudget; // reserved for future cost telemetry — not enforced
|
|
1310
1315
|
// ── Compute adaptive thinking ─────────────────────────────────
|
|
1311
1316
|
const supportsThinking = !resolvedModel.includes('haiku');
|
|
1312
1317
|
const needsThinking = !isHeartbeat && (isPlanStep || isUnleashed || !isCron);
|
|
@@ -1355,7 +1360,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1355
1360
|
cwd: BASE_DIR,
|
|
1356
1361
|
env: SAFE_ENV,
|
|
1357
1362
|
...(computedEffort ? { effort: computedEffort } : {}),
|
|
1358
|
-
|
|
1363
|
+
// maxBudgetUsd intentionally omitted — see comment above.
|
|
1359
1364
|
...(computedThinking ? { thinking: computedThinking } : {}),
|
|
1360
1365
|
...(computedBetas ? { betas: computedBetas } : {}),
|
|
1361
1366
|
...(outputFormat ? { outputFormat } : {}),
|
|
@@ -2994,8 +2999,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2994
2999
|
return extractDeliverable(trace) ||
|
|
2995
3000
|
trace.filter(t => t.type === 'text').map(t => t.content).join('').trim();
|
|
2996
3001
|
}
|
|
2997
|
-
async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria) {
|
|
3002
|
+
async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug) {
|
|
2998
3003
|
setInteractionSource('autonomous');
|
|
3004
|
+
const cronProfile = agentSlug && agentSlug !== 'clementine'
|
|
3005
|
+
? this.profileManager.get(agentSlug)
|
|
3006
|
+
: null;
|
|
2999
3007
|
const cronGuard = new StallGuard();
|
|
3000
3008
|
const sdkOptions = this.buildOptions({
|
|
3001
3009
|
isHeartbeat: true,
|
|
@@ -3004,6 +3012,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3004
3012
|
model: model ?? null,
|
|
3005
3013
|
enableTeams: true,
|
|
3006
3014
|
stallGuard: cronGuard,
|
|
3015
|
+
profile: cronProfile,
|
|
3007
3016
|
});
|
|
3008
3017
|
// Override cwd if a project workDir is specified
|
|
3009
3018
|
if (workDir) {
|
|
@@ -3406,8 +3415,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3406
3415
|
}
|
|
3407
3416
|
}
|
|
3408
3417
|
// ── Unleashed Mode (Long-Running Autonomous Tasks) ─────────────────
|
|
3409
|
-
async runUnleashedTask(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, maxHours) {
|
|
3418
|
+
async runUnleashedTask(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, maxHours, agentSlug) {
|
|
3410
3419
|
setInteractionSource('autonomous');
|
|
3420
|
+
const unleashedProfile = agentSlug && agentSlug !== 'clementine'
|
|
3421
|
+
? this.profileManager.get(agentSlug)
|
|
3422
|
+
: null;
|
|
3411
3423
|
const effectiveMaxHours = maxHours ?? UNLEASHED_DEFAULT_MAX_HOURS;
|
|
3412
3424
|
const turnsPerPhase = maxTurns ?? UNLEASHED_PHASE_TURNS;
|
|
3413
3425
|
const deadline = Date.now() + effectiveMaxHours * 60 * 60 * 1000;
|
|
@@ -3478,6 +3490,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3478
3490
|
isUnleashed: true,
|
|
3479
3491
|
maxBudgetUsd: BUDGET.unleashedPhase,
|
|
3480
3492
|
stallGuard: phaseGuard,
|
|
3493
|
+
profile: unleashedProfile,
|
|
3481
3494
|
});
|
|
3482
3495
|
// Enable progress summaries for real-time status updates
|
|
3483
3496
|
sdkOptions.agentProgressSummaries = true;
|
|
@@ -3796,7 +3809,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3796
3809
|
*
|
|
3797
3810
|
* @param onText Streaming callback for real-time progress updates
|
|
3798
3811
|
*/
|
|
3799
|
-
async runTeamTask(fromName, fromSlug, content, profile, onText) {
|
|
3812
|
+
async runTeamTask(fromName, fromSlug, content, profile, onText, externalAbortController) {
|
|
3800
3813
|
setInteractionSource('autonomous');
|
|
3801
3814
|
const taskName = `team-msg:${fromSlug}-to-${profile.slug}`;
|
|
3802
3815
|
const maxHours = 1; // Team messages get 1 hour max (not 6 like cron unleashed)
|
|
@@ -3808,6 +3821,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3808
3821
|
let lastOutput = '';
|
|
3809
3822
|
let consecutiveErrors = 0;
|
|
3810
3823
|
while (phase < maxPhases) {
|
|
3824
|
+
if (externalAbortController?.signal.aborted) {
|
|
3825
|
+
logger.info({ taskName, phase }, 'Team task aborted by caller');
|
|
3826
|
+
return lastOutput || `Team task aborted by caller at phase ${phase}.`;
|
|
3827
|
+
}
|
|
3811
3828
|
if (Date.now() >= deadline) {
|
|
3812
3829
|
logger.info({ taskName, phase }, 'Team task timed out');
|
|
3813
3830
|
return lastOutput || `Team task timed out after ${maxHours}h at phase ${phase}.`;
|
|
@@ -3878,6 +3895,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3878
3895
|
phaseAc.abort();
|
|
3879
3896
|
logger.warn({ taskName, phase }, `Team task phase ${phase} aborted — deadline reached`);
|
|
3880
3897
|
}, Math.max(deadline - Date.now(), 0));
|
|
3898
|
+
// Propagate external abort (e.g., user sent "Stop") into the phase controller
|
|
3899
|
+
const onExternalAbort = () => {
|
|
3900
|
+
phaseAc.abort();
|
|
3901
|
+
logger.info({ taskName, phase }, `Team task phase ${phase} aborted by caller`);
|
|
3902
|
+
};
|
|
3903
|
+
if (externalAbortController) {
|
|
3904
|
+
if (externalAbortController.signal.aborted)
|
|
3905
|
+
phaseAc.abort();
|
|
3906
|
+
else
|
|
3907
|
+
externalAbortController.signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
3908
|
+
}
|
|
3881
3909
|
sdkOptions.abortController = phaseAc;
|
|
3882
3910
|
try {
|
|
3883
3911
|
const stream = query({ prompt, options: sdkOptions });
|
|
@@ -3933,6 +3961,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3933
3961
|
}
|
|
3934
3962
|
catch (err) {
|
|
3935
3963
|
clearTimeout(phaseTimer);
|
|
3964
|
+
externalAbortController?.signal.removeEventListener('abort', onExternalAbort);
|
|
3965
|
+
// If this phase aborted because the caller cancelled, return cleanly —
|
|
3966
|
+
// no retry, no 3-strikes counter.
|
|
3967
|
+
if (externalAbortController?.signal.aborted) {
|
|
3968
|
+
logger.info({ taskName, phase }, 'Team task aborted mid-phase by caller');
|
|
3969
|
+
return lastOutput || `Team task aborted by caller at phase ${phase}.`;
|
|
3970
|
+
}
|
|
3936
3971
|
logger.error({ err, taskName, phase }, 'Team task phase error');
|
|
3937
3972
|
consecutiveErrors++;
|
|
3938
3973
|
if (consecutiveErrors >= 3) {
|
|
@@ -3942,6 +3977,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3942
3977
|
continue;
|
|
3943
3978
|
}
|
|
3944
3979
|
clearTimeout(phaseTimer);
|
|
3980
|
+
externalAbortController?.signal.removeEventListener('abort', onExternalAbort);
|
|
3945
3981
|
sessionId = phaseSessionId;
|
|
3946
3982
|
lastOutput = phaseOutput.trim();
|
|
3947
3983
|
consecutiveErrors = 0;
|
|
@@ -23,6 +23,10 @@ export interface RouteDecision {
|
|
|
23
23
|
confidence: number;
|
|
24
24
|
reasoning: string;
|
|
25
25
|
}
|
|
26
|
+
export declare function isDirectImperative(userMessage: string): {
|
|
27
|
+
match: boolean;
|
|
28
|
+
pattern?: string;
|
|
29
|
+
};
|
|
26
30
|
/**
|
|
27
31
|
* Session keys eligible for routing. Any key NOT in this set is
|
|
28
32
|
* considered agent-scoped or system-scoped and never routes.
|
|
@@ -18,6 +18,36 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import pino from 'pino';
|
|
20
20
|
const logger = pino({ name: 'clementine.route-classifier' });
|
|
21
|
+
/**
|
|
22
|
+
* Direct-imperative guardrail.
|
|
23
|
+
*
|
|
24
|
+
* When the user is explicitly instructing Clementine to do the work herself
|
|
25
|
+
* — "I need you to…", "you need to…", "use the CLI…", "stop/wait" —
|
|
26
|
+
* routing to a specialist is almost always wrong. The user knows who they
|
|
27
|
+
* are talking to and is asking *her* to act. Delegating here produces the
|
|
28
|
+
* "every message spawns a team task" cascade that leaves the user waiting
|
|
29
|
+
* and shouting "Stop".
|
|
30
|
+
*
|
|
31
|
+
* This check runs before the LLM classifier and the explicit-mention fast
|
|
32
|
+
* path, so even "Nora, I need you to do X" stays with Clementine when Nate
|
|
33
|
+
* is DMing Clementine (the `isRoutable` gate already guarantees the session
|
|
34
|
+
* belongs to Clementine).
|
|
35
|
+
*/
|
|
36
|
+
const DIRECT_IMPERATIVE_PATTERNS = [
|
|
37
|
+
/\bi (need|want|would like) you to\b/i,
|
|
38
|
+
/\byou (need to|should|gotta|have to|must)\b/i,
|
|
39
|
+
/\b(please )?(use|run|execute|call|invoke|query|check|pull|grab|fetch|look up|lookup|search) (the )?(sf|salesforce|cli|bash|sdk|api|db|database)\b/i,
|
|
40
|
+
/^(stop|wait|hold on|nevermind|never mind|cancel)\b/i,
|
|
41
|
+
];
|
|
42
|
+
export function isDirectImperative(userMessage) {
|
|
43
|
+
const text = userMessage.trim();
|
|
44
|
+
for (const re of DIRECT_IMPERATIVE_PATTERNS) {
|
|
45
|
+
const m = text.match(re);
|
|
46
|
+
if (m)
|
|
47
|
+
return { match: true, pattern: m[0] };
|
|
48
|
+
}
|
|
49
|
+
return { match: false };
|
|
50
|
+
}
|
|
21
51
|
/**
|
|
22
52
|
* Session keys eligible for routing. Any key NOT in this set is
|
|
23
53
|
* considered agent-scoped or system-scoped and never routes.
|
|
@@ -156,6 +186,13 @@ export async function classifyRoute(userMessage, agents, gateway) {
|
|
|
156
186
|
const specialists = agents.filter(a => a.slug !== 'clementine');
|
|
157
187
|
if (specialists.length === 0)
|
|
158
188
|
return null;
|
|
189
|
+
// Direct-imperative guardrail: user is instructing Clementine to act —
|
|
190
|
+
// do not delegate, even if an agent is named.
|
|
191
|
+
const imperative = isDirectImperative(userMessage);
|
|
192
|
+
if (imperative.match) {
|
|
193
|
+
logger.info({ pattern: imperative.pattern }, 'Routing skipped — direct imperative');
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
159
196
|
// Fast path: explicit slug mention anywhere in the message.
|
|
160
197
|
for (const a of specialists) {
|
|
161
198
|
const nameLower = a.name.toLowerCase();
|
|
@@ -76,8 +76,6 @@ export declare class AgentBotClient {
|
|
|
76
76
|
sendDmTo(userId: string, text: string, embed?: EmbedBuilder): Promise<void>;
|
|
77
77
|
/** Send a message to a specific channel via this agent bot. */
|
|
78
78
|
sendToChannel(channelId: string, text: string, embed?: EmbedBuilder): Promise<void>;
|
|
79
|
-
/** Send a startup status embed to the owner's DMs. */
|
|
80
|
-
private sendStartupStatus;
|
|
81
79
|
private buildAgentStatusEmbed;
|
|
82
80
|
private sendOrUpdateStatusEmbed;
|
|
83
81
|
/**
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { ActionRowBuilder, ActivityType, ChannelType, Client, EmbedBuilder, Events, GatewayIntentBits, ModalBuilder, Partials, REST, Routes, SlashCommandBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js';
|
|
16
16
|
import pino from 'pino';
|
|
17
|
-
import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, sanitizeResponse } from './discord-utils.js';
|
|
17
|
+
import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, sanitizeResponse, rehydrateStatusEmbed, setSavedStatusEmbed } from './discord-utils.js';
|
|
18
18
|
import { MODELS } from '../config.js';
|
|
19
19
|
import * as cronParser from 'cron-parser';
|
|
20
20
|
const logger = pino({ name: 'clementine.agent-bot' });
|
|
@@ -124,10 +124,14 @@ export class AgentBotClient {
|
|
|
124
124
|
type: ActivityType.Custom,
|
|
125
125
|
}],
|
|
126
126
|
});
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
//
|
|
127
|
+
// Intentionally NOT sending a startup DM. The main Clementine bot
|
|
128
|
+
// already posts a consolidated "here's who's online" embed in the
|
|
129
|
+
// owner DM, so per-agent DMs on every restart are pure noise.
|
|
130
|
+
// Send status embed to the agent's primary channel (if available).
|
|
131
|
+
// Rehydrate any prior embed first so we edit-in-place instead of
|
|
132
|
+
// posting a fresh pinned message on every restart.
|
|
130
133
|
if (this.config.cronScheduler && this.resolvedChannelIds.length > 0) {
|
|
134
|
+
this.statusEmbedMessage = await rehydrateStatusEmbed(this.client, this.config.slug);
|
|
131
135
|
await this.sendOrUpdateStatusEmbed();
|
|
132
136
|
// Auto-update status embed on state changes (debounced)
|
|
133
137
|
this.config.cronScheduler.onStatusChange(() => {
|
|
@@ -289,30 +293,6 @@ export class AgentBotClient {
|
|
|
289
293
|
}
|
|
290
294
|
}
|
|
291
295
|
}
|
|
292
|
-
/** Send a startup status embed to the owner's DMs. */
|
|
293
|
-
async sendStartupStatus() {
|
|
294
|
-
if (!this.config.ownerId)
|
|
295
|
-
return;
|
|
296
|
-
try {
|
|
297
|
-
const owner = await this.client.users.fetch(this.config.ownerId, { force: true });
|
|
298
|
-
const dmChannel = await owner.createDM();
|
|
299
|
-
// Use the rich agent status embed if cronScheduler is available
|
|
300
|
-
const embed = this.config.cronScheduler
|
|
301
|
-
? this.buildAgentStatusEmbed()
|
|
302
|
-
: new EmbedBuilder()
|
|
303
|
-
.setColor(0x22c55e)
|
|
304
|
-
.setTitle(`${this.config.profile.name} is online`)
|
|
305
|
-
.setDescription(this.config.profile.description)
|
|
306
|
-
.addFields({ name: 'Model', value: this.config.profile.model || 'sonnet', inline: true }, { name: 'Tier', value: String(this.config.profile.tier), inline: true })
|
|
307
|
-
.setFooter({ text: `Agent bot \u00b7 ${this.client.user?.tag ?? 'unknown'}` })
|
|
308
|
-
.setTimestamp();
|
|
309
|
-
await dmChannel.send({ embeds: [embed] });
|
|
310
|
-
logger.info({ slug: this.config.slug }, 'Sent startup status embed to owner DMs');
|
|
311
|
-
}
|
|
312
|
-
catch (err) {
|
|
313
|
-
logger.error({ err, slug: this.config.slug }, 'Failed to send startup status embed');
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
296
|
// ── Agent-scoped status embed ──────────────────────────────────────
|
|
317
297
|
buildAgentStatusEmbed() {
|
|
318
298
|
const now = new Date();
|
|
@@ -454,6 +434,11 @@ export class AgentBotClient {
|
|
|
454
434
|
await this.statusEmbedMessage.pin();
|
|
455
435
|
}
|
|
456
436
|
catch { /* may lack perms */ }
|
|
437
|
+
// Persist so the next restart edits this same message instead of
|
|
438
|
+
// posting another pinned copy.
|
|
439
|
+
if (this.statusEmbedMessage) {
|
|
440
|
+
setSavedStatusEmbed(this.config.slug, channelId, this.statusEmbedMessage.id);
|
|
441
|
+
}
|
|
457
442
|
}
|
|
458
443
|
}
|
|
459
444
|
}
|
|
@@ -854,6 +839,9 @@ export class AgentBotClient {
|
|
|
854
839
|
await this.statusEmbedMessage.pin();
|
|
855
840
|
}
|
|
856
841
|
catch { /* non-fatal */ }
|
|
842
|
+
if (this.statusEmbedMessage) {
|
|
843
|
+
setSavedStatusEmbed(this.config.slug, message.channel.id, this.statusEmbedMessage.id);
|
|
844
|
+
}
|
|
857
845
|
}
|
|
858
846
|
}
|
|
859
847
|
else {
|
|
@@ -4,7 +4,19 @@
|
|
|
4
4
|
* Extracted from discord.ts so agent bot clients can reuse streaming,
|
|
5
5
|
* chunking, and sanitization without importing the monolith.
|
|
6
6
|
*/
|
|
7
|
-
import { EmbedBuilder, type Message } from 'discord.js';
|
|
7
|
+
import { EmbedBuilder, type Client, type Message } from 'discord.js';
|
|
8
|
+
export declare function getSavedStatusEmbed(slug: string): {
|
|
9
|
+
channelId: string;
|
|
10
|
+
messageId: string;
|
|
11
|
+
} | null;
|
|
12
|
+
export declare function setSavedStatusEmbed(slug: string, channelId: string, messageId: string): void;
|
|
13
|
+
export declare function clearSavedStatusEmbed(slug: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* Try to re-hydrate a previously-saved status embed message so that on
|
|
16
|
+
* restart the bot edits it in place instead of posting a fresh one.
|
|
17
|
+
* Returns the Message if still reachable, else null.
|
|
18
|
+
*/
|
|
19
|
+
export declare function rehydrateStatusEmbed(client: Client, slug: string): Promise<Message | null>;
|
|
8
20
|
export declare const STREAM_EDIT_INTERVAL = 400;
|
|
9
21
|
export declare const THINKING_INDICATOR = "\u2728 *thinking...*";
|
|
10
22
|
export declare const DISCORD_MSG_LIMIT = 2000;
|
|
@@ -5,6 +5,70 @@
|
|
|
5
5
|
* chunking, and sanitization without importing the monolith.
|
|
6
6
|
*/
|
|
7
7
|
import { EmbedBuilder } from 'discord.js';
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import pino from 'pino';
|
|
11
|
+
import { BASE_DIR } from '../config.js';
|
|
12
|
+
const utilsLogger = pino({ name: 'clementine.discord-utils' });
|
|
13
|
+
// ── Persistent status-embed state ─────────────────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// When the daemon restarts, in-memory references to status-embed messages
|
|
16
|
+
// are lost, so the bots used to post a fresh pinned embed every time.
|
|
17
|
+
// Persist (channelId, messageId) per slug so the next boot can fetch the
|
|
18
|
+
// existing message and edit it in place — no restart ping spam.
|
|
19
|
+
const STATUS_EMBED_STATE_FILE = path.join(BASE_DIR, '.agent-status-embeds.json');
|
|
20
|
+
function loadStatusEmbedState() {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(STATUS_EMBED_STATE_FILE))
|
|
23
|
+
return {};
|
|
24
|
+
return JSON.parse(readFileSync(STATUS_EMBED_STATE_FILE, 'utf-8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function saveStatusEmbedState(state) {
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(STATUS_EMBED_STATE_FILE, JSON.stringify(state, null, 2));
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
utilsLogger.debug({ err }, 'Failed to save status embed state (non-fatal)');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function getSavedStatusEmbed(slug) {
|
|
39
|
+
const s = loadStatusEmbedState()[slug];
|
|
40
|
+
return s ?? null;
|
|
41
|
+
}
|
|
42
|
+
export function setSavedStatusEmbed(slug, channelId, messageId) {
|
|
43
|
+
const state = loadStatusEmbedState();
|
|
44
|
+
state[slug] = { channelId, messageId };
|
|
45
|
+
saveStatusEmbedState(state);
|
|
46
|
+
}
|
|
47
|
+
export function clearSavedStatusEmbed(slug) {
|
|
48
|
+
const state = loadStatusEmbedState();
|
|
49
|
+
delete state[slug];
|
|
50
|
+
saveStatusEmbedState(state);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Try to re-hydrate a previously-saved status embed message so that on
|
|
54
|
+
* restart the bot edits it in place instead of posting a fresh one.
|
|
55
|
+
* Returns the Message if still reachable, else null.
|
|
56
|
+
*/
|
|
57
|
+
export async function rehydrateStatusEmbed(client, slug) {
|
|
58
|
+
const saved = getSavedStatusEmbed(slug);
|
|
59
|
+
if (!saved)
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
const channel = await client.channels.fetch(saved.channelId).catch(() => null);
|
|
63
|
+
if (!channel || !('messages' in channel))
|
|
64
|
+
return null;
|
|
65
|
+
const msg = await channel.messages.fetch(saved.messageId).catch(() => null);
|
|
66
|
+
return msg ?? null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
8
72
|
export const STREAM_EDIT_INTERVAL = 400;
|
|
9
73
|
export const THINKING_INDICATOR = '\u2728 *thinking...*';
|
|
10
74
|
export const DISCORD_MSG_LIMIT = 2000;
|
package/dist/channels/discord.js
CHANGED
|
@@ -10,7 +10,7 @@ import pino from 'pino';
|
|
|
10
10
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
-
import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, } from './discord-utils.js';
|
|
13
|
+
import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, rehydrateStatusEmbed, setSavedStatusEmbed, } from './discord-utils.js';
|
|
14
14
|
import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, ENABLE_1M_CONTEXT, } from '../config.js';
|
|
15
15
|
import { findProjectByName, getLinkedProjects } from '../agent/assistant.js';
|
|
16
16
|
import * as cronParser from 'cron-parser';
|
|
@@ -62,6 +62,20 @@ const slashCommands = [
|
|
|
62
62
|
new SlashCommandBuilder().setName('help').setDescription('Show all available commands'),
|
|
63
63
|
];
|
|
64
64
|
const botMessageMap = new Map();
|
|
65
|
+
/**
|
|
66
|
+
* Recognize short natural-language stop commands so the user doesn't have
|
|
67
|
+
* to remember the `!stop` slash syntax. Only matches short standalone
|
|
68
|
+
* messages (≤30 chars) so a longer message containing the word "stop" is
|
|
69
|
+
* not misread as an abort.
|
|
70
|
+
*/
|
|
71
|
+
function isStopCommand(text) {
|
|
72
|
+
const t = text.trim().toLowerCase().replace(/[.!?,]+$/g, '');
|
|
73
|
+
if (t === '!stop' || t === '/stop')
|
|
74
|
+
return true;
|
|
75
|
+
if (t.length > 30)
|
|
76
|
+
return false;
|
|
77
|
+
return /^(stop|cancel|nevermind|never mind|hold on|wait( (stop|up))?|pause|abort)$/.test(t);
|
|
78
|
+
}
|
|
65
79
|
function trackBotMessage(messageId, context) {
|
|
66
80
|
botMessageMap.set(messageId, context);
|
|
67
81
|
// Evict oldest entries to prevent memory leak
|
|
@@ -576,6 +590,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
576
590
|
await statusEmbedMessage.pin();
|
|
577
591
|
}
|
|
578
592
|
catch { /* may already be pinned or lack perms */ }
|
|
593
|
+
// Persist so the next restart edits this message instead of posting another
|
|
594
|
+
const channelId = statusEmbedMessage?.channelId ?? target?.id;
|
|
595
|
+
if (channelId && statusEmbedMessage) {
|
|
596
|
+
setSavedStatusEmbed('clementine', channelId, statusEmbedMessage.id);
|
|
597
|
+
}
|
|
579
598
|
}
|
|
580
599
|
}
|
|
581
600
|
catch (err) {
|
|
@@ -599,6 +618,10 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
599
618
|
await statusEmbedMessage.pin();
|
|
600
619
|
}
|
|
601
620
|
catch { /* non-fatal */ }
|
|
621
|
+
const channelId = statusEmbedMessage?.channelId ?? channel?.id;
|
|
622
|
+
if (channelId && statusEmbedMessage) {
|
|
623
|
+
setSavedStatusEmbed('clementine', channelId, statusEmbedMessage.id);
|
|
624
|
+
}
|
|
602
625
|
}
|
|
603
626
|
}
|
|
604
627
|
catch (err) {
|
|
@@ -621,16 +644,19 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
621
644
|
logger.error({ err }, 'Failed to register slash commands');
|
|
622
645
|
}
|
|
623
646
|
updatePresence();
|
|
624
|
-
//
|
|
647
|
+
// Rehydrate + auto-update the owner-DM status embed. If a prior pinned
|
|
648
|
+
// embed is still reachable we edit it in place so restarts don't spam
|
|
649
|
+
// the owner's DM with fresh pinned messages.
|
|
625
650
|
try {
|
|
626
651
|
const owner = await client.users.fetch(DISCORD_OWNER_ID, { force: true });
|
|
627
652
|
const dmChannel = await owner.createDM();
|
|
628
653
|
cachedDmChannel = dmChannel;
|
|
654
|
+
statusEmbedMessage = await rehydrateStatusEmbed(client, 'clementine');
|
|
629
655
|
await sendOrUpdateStatusEmbed(dmChannel);
|
|
630
|
-
logger.info(
|
|
656
|
+
logger.info({ rehydrated: !!statusEmbedMessage }, 'Status embed ready for owner DM');
|
|
631
657
|
}
|
|
632
658
|
catch (err) {
|
|
633
|
-
logger.error({ err }, 'Failed to
|
|
659
|
+
logger.error({ err }, 'Failed to prepare startup status embed');
|
|
634
660
|
}
|
|
635
661
|
// Event-driven embed updates — debounced to avoid API spam
|
|
636
662
|
cronScheduler.onStatusChange(() => {
|
|
@@ -1078,8 +1104,12 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
1078
1104
|
}
|
|
1079
1105
|
catch { /* referenced message may be deleted */ }
|
|
1080
1106
|
}
|
|
1081
|
-
// ──
|
|
1082
|
-
|
|
1107
|
+
// ── Stop command — abort active query + any in-flight team tasks ─
|
|
1108
|
+
// Accept the canonical !stop/ /stop AND plain natural-language variants
|
|
1109
|
+
// ("stop", "Stop", "Stop.", "cancel", "wait stop", "nevermind") that
|
|
1110
|
+
// users actually type. Only triggers on short standalone messages so
|
|
1111
|
+
// we don't accidentally abort a longer message that contains "stop".
|
|
1112
|
+
if (isDm && isStopCommand(text)) {
|
|
1083
1113
|
const stopped = gateway.stopSession(sessionKey);
|
|
1084
1114
|
await message.reply(stopped ? 'Stopping...' : 'Nothing running to stop.');
|
|
1085
1115
|
return;
|
|
@@ -854,12 +854,12 @@ export class CronScheduler {
|
|
|
854
854
|
const startedAt = new Date();
|
|
855
855
|
try {
|
|
856
856
|
// Standard cron jobs get a timeout via SDK AbortController (advisor may override)
|
|
857
|
-
let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria);
|
|
857
|
+
let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug);
|
|
858
858
|
// alwaysDeliver: retry once if the response is empty/noise
|
|
859
859
|
if (job.alwaysDeliver && (!response || CronScheduler.isCronNoise(response))) {
|
|
860
860
|
logger.info({ job: job.name }, 'alwaysDeliver: empty/noise response — retrying once');
|
|
861
861
|
try {
|
|
862
|
-
const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria);
|
|
862
|
+
const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug);
|
|
863
863
|
if (retryResponse && !CronScheduler.isCronNoise(retryResponse)) {
|
|
864
864
|
response = retryResponse;
|
|
865
865
|
}
|
|
@@ -1563,7 +1563,10 @@ export class CronScheduler {
|
|
|
1563
1563
|
enriched: !!advice.promptEnrichment,
|
|
1564
1564
|
}, 'Goal work: advisor applied');
|
|
1565
1565
|
this.gateway.handleCronJob(jobName, enrichedPrompt, 2, effectiveMaxTurns, effectiveModel, undefined, // workDir
|
|
1566
|
-
useUnleashed ? 'unleashed' : undefined, useUnleashed ? 1 : undefined
|
|
1566
|
+
useUnleashed ? 'unleashed' : undefined, useUnleashed ? 1 : undefined, // 1 hour max for unleashed goal work
|
|
1567
|
+
undefined, // timeoutMs (use default)
|
|
1568
|
+
undefined, // successCriteria
|
|
1569
|
+
ownerSlug ?? undefined).then((result) => {
|
|
1567
1570
|
if (result && !CronScheduler.isCronNoise(result)) {
|
|
1568
1571
|
this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
|
|
1569
1572
|
}
|
|
@@ -1576,7 +1579,13 @@ export class CronScheduler {
|
|
|
1576
1579
|
}).catch((err) => {
|
|
1577
1580
|
// Advisor import failed — fall back to basic execution
|
|
1578
1581
|
logger.warn({ err, goalId: trigger.goalId }, 'Advisor unavailable — running goal work with defaults');
|
|
1579
|
-
this.gateway.handleCronJob(jobName, prompt, 2, trigger.maxTurns ?? 15
|
|
1582
|
+
this.gateway.handleCronJob(jobName, prompt, 2, trigger.maxTurns ?? 15, undefined, // model
|
|
1583
|
+
undefined, // workDir
|
|
1584
|
+
undefined, // mode
|
|
1585
|
+
undefined, // maxHours
|
|
1586
|
+
undefined, // timeoutMs
|
|
1587
|
+
undefined, // successCriteria
|
|
1588
|
+
ownerSlug ?? undefined).then((result) => {
|
|
1580
1589
|
if (result && !CronScheduler.isCronNoise(result)) {
|
|
1581
1590
|
this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
|
|
1582
1591
|
}
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ export declare class Gateway {
|
|
|
46
46
|
private _saveSeenChannels;
|
|
47
47
|
/** Mark a channel as seen (owner approved or explicitly always-allowed). */
|
|
48
48
|
markChannelSeen(channelKey: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the agent slug for a session so cross-agent delivery stays in-persona.
|
|
51
|
+
* Prefers the explicit session profile (set by agent bots + cron-scheduler), then
|
|
52
|
+
* falls back to parsing the session-key format.
|
|
53
|
+
*/
|
|
54
|
+
private _agentSlugFromSessionKey;
|
|
49
55
|
/**
|
|
50
56
|
* Deliver a deep-mode result back to the user.
|
|
51
57
|
* Routes through the agent's session so it responds conversationally,
|
|
@@ -124,8 +130,8 @@ export declare class Gateway {
|
|
|
124
130
|
clearSessionProfile(sessionKey: string): void;
|
|
125
131
|
isSessionBusy(sessionKey: string): boolean;
|
|
126
132
|
/**
|
|
127
|
-
* Abort an in-progress chat query
|
|
128
|
-
* Returns true if
|
|
133
|
+
* Abort an in-progress chat query AND any in-flight delegated team tasks
|
|
134
|
+
* for this session. Returns true if anything was actually aborted.
|
|
129
135
|
*/
|
|
130
136
|
stopSession(sessionKey: string): boolean;
|
|
131
137
|
/**
|
|
@@ -138,13 +144,13 @@ export declare class Gateway {
|
|
|
138
144
|
private acquireSessionLock;
|
|
139
145
|
handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback): Promise<string>;
|
|
140
146
|
handleHeartbeat(standingInstructions: string, changesSummary?: string, timeContext?: string, dedupContext?: string, profile?: import('../types.js').AgentProfile | null): Promise<string>;
|
|
141
|
-
handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[]): Promise<string>;
|
|
147
|
+
handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
|
|
142
148
|
/**
|
|
143
149
|
* Process a team message as an autonomous task — same multi-phase execution
|
|
144
150
|
* as cron unleashed jobs, so agents can work until done instead of being
|
|
145
151
|
* killed by the 5-minute interactive chat timeout.
|
|
146
152
|
*/
|
|
147
|
-
handleTeamTask(fromName: string, fromSlug: string, content: string, profile: import('../types.js').AgentProfile, onText?: (token: string) => void): Promise<string>;
|
|
153
|
+
handleTeamTask(fromName: string, fromSlug: string, content: string, profile: import('../types.js').AgentProfile, onText?: (token: string) => void, abortController?: AbortController): Promise<string>;
|
|
148
154
|
handlePlan(sessionKey: string, taskDescription: string, onProgress?: (updates: PlanProgressUpdate[]) => Promise<void>, onApproval?: (planSummary: string, steps: PlanStep[]) => Promise<boolean | string>): Promise<string>;
|
|
149
155
|
handleWorkflow(workflow: WorkflowDefinition, inputs?: Record<string, string>): Promise<string>;
|
|
150
156
|
/**
|
package/dist/gateway/router.js
CHANGED
|
@@ -175,6 +175,24 @@ export class Gateway {
|
|
|
175
175
|
this._loadSeenChannels().add(channelKey);
|
|
176
176
|
this._saveSeenChannels();
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolve the agent slug for a session so cross-agent delivery stays in-persona.
|
|
180
|
+
* Prefers the explicit session profile (set by agent bots + cron-scheduler), then
|
|
181
|
+
* falls back to parsing the session-key format.
|
|
182
|
+
*/
|
|
183
|
+
_agentSlugFromSessionKey(sessionKey) {
|
|
184
|
+
const profile = this.getSessionProfile(sessionKey);
|
|
185
|
+
if (profile && profile !== 'clementine')
|
|
186
|
+
return profile;
|
|
187
|
+
const parts = sessionKey.split(':');
|
|
188
|
+
if (parts[0] !== 'discord')
|
|
189
|
+
return undefined;
|
|
190
|
+
if (parts[1] === 'agent' || parts[1] === 'member-dm')
|
|
191
|
+
return parts[2];
|
|
192
|
+
if ((parts[1] === 'channel' || parts[1] === 'member') && parts.length >= 5)
|
|
193
|
+
return parts[3];
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
178
196
|
/**
|
|
179
197
|
* Deliver a deep-mode result back to the user.
|
|
180
198
|
* Routes through the agent's session so it responds conversationally,
|
|
@@ -182,17 +200,19 @@ export class Gateway {
|
|
|
182
200
|
* Falls back to pushing rawResult directly if the agent call fails.
|
|
183
201
|
*/
|
|
184
202
|
async _deliverDeepResult(sessionKey, syntheticPrompt, rawFallback) {
|
|
203
|
+
const agentSlug = this._agentSlugFromSessionKey(sessionKey);
|
|
204
|
+
const ctx = { sessionKey, ...(agentSlug ? { agentSlug } : {}) };
|
|
185
205
|
try {
|
|
186
206
|
const agentReply = await this.handleMessage(sessionKey, syntheticPrompt);
|
|
187
207
|
if (agentReply?.trim()) {
|
|
188
|
-
await this._dispatcher?.send(agentReply,
|
|
189
|
-
logger.info({ sessionKey }, 'Deep mode result delivered via agent follow-up + dispatcher');
|
|
208
|
+
await this._dispatcher?.send(agentReply, ctx);
|
|
209
|
+
logger.info({ sessionKey, agentSlug }, 'Deep mode result delivered via agent follow-up + dispatcher');
|
|
190
210
|
}
|
|
191
211
|
}
|
|
192
212
|
catch (err) {
|
|
193
213
|
logger.warn({ err, sessionKey }, 'Deep mode agent follow-up failed — using raw fallback');
|
|
194
214
|
if (rawFallback.trim()) {
|
|
195
|
-
await this._dispatcher?.send(rawFallback.slice(0, 1500),
|
|
215
|
+
await this._dispatcher?.send(rawFallback.slice(0, 1500), ctx)
|
|
196
216
|
.catch(async (e) => {
|
|
197
217
|
// Both paths failed — surface it instead of swallowing at debug level.
|
|
198
218
|
logger.warn({ err: e, sessionKey }, 'Deep mode fallback delivery failed — persisting to daily note');
|
|
@@ -239,16 +259,30 @@ export class Gateway {
|
|
|
239
259
|
// Fire the team task in the background; ack immediately.
|
|
240
260
|
const ackMessage = `Routing this to **${targetProfile.name}** (${decision.reasoning.toLowerCase()}). I'll post their response back here when done.`;
|
|
241
261
|
onText?.(ackMessage).catch(() => { });
|
|
242
|
-
this
|
|
262
|
+
// Track this task so "Stop" can abort it along with the chat query.
|
|
263
|
+
const teamAbortController = new AbortController();
|
|
264
|
+
const sess = this.getSession(sessionKey);
|
|
265
|
+
if (!sess.teamTaskControllers)
|
|
266
|
+
sess.teamTaskControllers = new Set();
|
|
267
|
+
sess.teamTaskControllers.add(teamAbortController);
|
|
268
|
+
this.handleTeamTask('Clementine', 'clementine', text, targetProfile, undefined, teamAbortController)
|
|
243
269
|
.then(response => {
|
|
244
270
|
if (!response)
|
|
245
271
|
return;
|
|
246
272
|
const delivery = `**${targetProfile.name}**: ${response}`;
|
|
247
|
-
return this._dispatcher?.send(delivery, { sessionKey });
|
|
273
|
+
return this._dispatcher?.send(delivery, { sessionKey, agentSlug: targetProfile.slug });
|
|
248
274
|
})
|
|
249
275
|
.catch(err => {
|
|
276
|
+
if (teamAbortController.signal.aborted) {
|
|
277
|
+
logger.info({ target: decision.targetAgent, sessionKey }, 'Delegated task aborted by user');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
250
280
|
logger.warn({ err, target: decision.targetAgent }, 'Delegated task failed');
|
|
251
|
-
void this._dispatcher?.send(`**${targetProfile.name}** hit an error handling that: ${String(err).slice(0, 200)}`, { sessionKey });
|
|
281
|
+
void this._dispatcher?.send(`**${targetProfile.name}** hit an error handling that: ${String(err).slice(0, 200)}`, { sessionKey, agentSlug: targetProfile.slug });
|
|
282
|
+
})
|
|
283
|
+
.finally(() => {
|
|
284
|
+
const s = this.sessions.get(sessionKey);
|
|
285
|
+
s?.teamTaskControllers?.delete(teamAbortController);
|
|
252
286
|
});
|
|
253
287
|
return { delegated: true, ackMessage };
|
|
254
288
|
}
|
|
@@ -580,17 +614,28 @@ export class Gateway {
|
|
|
580
614
|
return this.sessions.get(sessionKey)?.lock !== undefined;
|
|
581
615
|
}
|
|
582
616
|
/**
|
|
583
|
-
* Abort an in-progress chat query
|
|
584
|
-
* Returns true if
|
|
617
|
+
* Abort an in-progress chat query AND any in-flight delegated team tasks
|
|
618
|
+
* for this session. Returns true if anything was actually aborted.
|
|
585
619
|
*/
|
|
586
620
|
stopSession(sessionKey) {
|
|
587
|
-
const
|
|
621
|
+
const s = this.sessions.get(sessionKey);
|
|
622
|
+
let aborted = false;
|
|
623
|
+
const ac = s?.abortController;
|
|
588
624
|
if (ac && !ac.signal.aborted) {
|
|
589
625
|
ac.abort();
|
|
590
|
-
|
|
591
|
-
return true;
|
|
626
|
+
aborted = true;
|
|
592
627
|
}
|
|
593
|
-
|
|
628
|
+
if (s?.teamTaskControllers?.size) {
|
|
629
|
+
for (const tac of s.teamTaskControllers) {
|
|
630
|
+
if (!tac.signal.aborted)
|
|
631
|
+
tac.abort();
|
|
632
|
+
}
|
|
633
|
+
aborted = true;
|
|
634
|
+
logger.info({ sessionKey, count: s.teamTaskControllers.size }, 'Aborted in-flight team tasks');
|
|
635
|
+
}
|
|
636
|
+
if (aborted)
|
|
637
|
+
logger.info({ sessionKey }, 'Session stopped by user');
|
|
638
|
+
return aborted;
|
|
594
639
|
}
|
|
595
640
|
/**
|
|
596
641
|
* Serialize access to a session. If a query is already in-flight when a new
|
|
@@ -997,12 +1042,14 @@ export class Gateway {
|
|
|
997
1042
|
const currentSess = this.getSession(sessionKey);
|
|
998
1043
|
const jobName = `deep-${Date.now()}`;
|
|
999
1044
|
currentSess.deepTask = { jobName, taskDesc, startedAt: new Date().toISOString() };
|
|
1045
|
+
const deepAgentSlug = this._agentSlugFromSessionKey(sessionKey);
|
|
1000
1046
|
// Spawn unleashed task in background — don't await
|
|
1001
1047
|
this.assistant.runUnleashedTask(jobName, `${taskDesc}\n\nOriginal request: ${text}`, 2, // tier 2 (Bash/Write/Edit enabled)
|
|
1002
1048
|
undefined, // default maxTurns (75/phase)
|
|
1003
1049
|
undefined, // default model
|
|
1004
1050
|
deepWorkDir, // honors [DEEP_MODE(work_dir=...)] if provided
|
|
1005
|
-
1
|
|
1051
|
+
1, // maxHours
|
|
1052
|
+
deepAgentSlug).then(async (result) => {
|
|
1006
1053
|
logger.info({ sessionKey, jobName, resultLen: result?.length ?? 0 }, 'Deep mode task completed');
|
|
1007
1054
|
if (result && result !== '__NOTHING__') {
|
|
1008
1055
|
this.assistant.injectPendingContext(sessionKey, text, result);
|
|
@@ -1029,7 +1076,8 @@ export class Gateway {
|
|
|
1029
1076
|
const currentSess = this.getSession(sessionKey);
|
|
1030
1077
|
const jobName = `deep-${Date.now()}`;
|
|
1031
1078
|
currentSess.deepTask = { jobName, taskDesc: text.slice(0, 200), startedAt: new Date().toISOString() };
|
|
1032
|
-
|
|
1079
|
+
const escAgentSlug = this._agentSlugFromSessionKey(sessionKey);
|
|
1080
|
+
this.assistant.runUnleashedTask(jobName, `Continue working on this task. The user asked: ${text}\n\nYou already started in a quick session and made ${toolActivityCount} tool calls. Pick up where you left off and complete the work.`, 2, undefined, undefined, undefined, 1, escAgentSlug).then(async (result) => {
|
|
1033
1081
|
logger.info({ sessionKey, jobName, resultLen: result?.length ?? 0 }, 'Auto-escalated deep mode completed');
|
|
1034
1082
|
if (result && result !== '__NOTHING__') {
|
|
1035
1083
|
this.assistant.injectPendingContext(sessionKey, text, result);
|
|
@@ -1083,9 +1131,10 @@ export class Gateway {
|
|
|
1083
1131
|
const currentSess = this.getSession(sessionKey);
|
|
1084
1132
|
const jobName = `deep-${Date.now()}`;
|
|
1085
1133
|
currentSess.deepTask = { jobName, taskDesc: text.slice(0, 200), startedAt: new Date().toISOString() };
|
|
1134
|
+
const mtAgentSlug = this._agentSlugFromSessionKey(sessionKey);
|
|
1086
1135
|
// Grab any partial response that was streamed before the error
|
|
1087
1136
|
const partialResponse = wrappedOnText ? lastStreamedText : '';
|
|
1088
|
-
this.assistant.runUnleashedTask(jobName, `Continue working on this task. The user asked: ${text}\n\nYou already started and ran out of turns. Pick up where you left off and complete the work.`, 2, undefined, undefined, undefined, 1).then(async (result) => {
|
|
1137
|
+
this.assistant.runUnleashedTask(jobName, `Continue working on this task. The user asked: ${text}\n\nYou already started and ran out of turns. Pick up where you left off and complete the work.`, 2, undefined, undefined, undefined, 1, mtAgentSlug).then(async (result) => {
|
|
1089
1138
|
logger.info({ sessionKey, jobName, resultLen: result?.length ?? 0 }, 'Max-turns deep mode completed');
|
|
1090
1139
|
if (result && result !== '__NOTHING__') {
|
|
1091
1140
|
this.assistant.injectPendingContext(sessionKey, text, result);
|
|
@@ -1158,19 +1207,19 @@ export class Gateway {
|
|
|
1158
1207
|
releaseLane();
|
|
1159
1208
|
}
|
|
1160
1209
|
}
|
|
1161
|
-
async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, mode = 'standard', maxHours, timeoutMs, successCriteria) {
|
|
1210
|
+
async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, mode = 'standard', maxHours, timeoutMs, successCriteria, agentSlug) {
|
|
1162
1211
|
const releaseLane = await lanes.acquire('cron');
|
|
1163
1212
|
try {
|
|
1164
|
-
logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${mode === 'unleashed' ? ' (unleashed)' : ''}`);
|
|
1213
|
+
logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${mode === 'unleashed' ? ' (unleashed)' : ''}${agentSlug && agentSlug !== 'clementine' ? ` as ${agentSlug}` : ''}`);
|
|
1165
1214
|
events.emit('cron:start', { jobName, tier, mode, timestamp: Date.now() });
|
|
1166
1215
|
const cronStart = Date.now();
|
|
1167
1216
|
try {
|
|
1168
1217
|
let response;
|
|
1169
1218
|
if (mode === 'unleashed') {
|
|
1170
|
-
response = await this.assistant.runUnleashedTask(jobName, jobPrompt, tier, maxTurns, model, workDir, maxHours);
|
|
1219
|
+
response = await this.assistant.runUnleashedTask(jobName, jobPrompt, tier, maxTurns, model, workDir, maxHours, agentSlug);
|
|
1171
1220
|
}
|
|
1172
1221
|
else {
|
|
1173
|
-
response = await this.assistant.runCronJob(jobName, jobPrompt, tier, maxTurns, model, workDir, timeoutMs, successCriteria);
|
|
1222
|
+
response = await this.assistant.runCronJob(jobName, jobPrompt, tier, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug);
|
|
1174
1223
|
}
|
|
1175
1224
|
// Re-baseline integrity checksums after cron job (may write to vault)
|
|
1176
1225
|
scanner.refreshIntegrity();
|
|
@@ -1193,11 +1242,11 @@ export class Gateway {
|
|
|
1193
1242
|
* as cron unleashed jobs, so agents can work until done instead of being
|
|
1194
1243
|
* killed by the 5-minute interactive chat timeout.
|
|
1195
1244
|
*/
|
|
1196
|
-
async handleTeamTask(fromName, fromSlug, content, profile, onText) {
|
|
1245
|
+
async handleTeamTask(fromName, fromSlug, content, profile, onText, abortController) {
|
|
1197
1246
|
const releaseLane = await lanes.acquire('cron');
|
|
1198
1247
|
try {
|
|
1199
1248
|
logger.info({ fromSlug, toSlug: profile.slug }, 'Running team message as autonomous task');
|
|
1200
|
-
const response = await this.assistant.runTeamTask(fromName, fromSlug, content, profile, onText);
|
|
1249
|
+
const response = await this.assistant.runTeamTask(fromName, fromSlug, content, profile, onText, abortController);
|
|
1201
1250
|
scanner.refreshIntegrity();
|
|
1202
1251
|
return response;
|
|
1203
1252
|
}
|