clementine-agent 1.0.26 → 1.0.28
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 +10 -3
- package/dist/agent/assistant.js +209 -46
- package/dist/agent/hooks.d.ts +38 -0
- package/dist/agent/hooks.js +76 -10
- package/dist/agent/route-classifier.d.ts +4 -0
- package/dist/agent/route-classifier.js +37 -0
- package/dist/agent/skill-extractor.d.ts +3 -1
- package/dist/agent/skill-extractor.js +12 -2
- 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/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +83 -3
- package/dist/gateway/router.d.ts +11 -4
- package/dist/gateway/router.js +108 -21
- package/dist/memory/store.d.ts +7 -0
- package/dist/memory/store.js +31 -0
- package/package.json +1 -1
|
@@ -79,6 +79,13 @@ export declare class PersonalAssistant {
|
|
|
79
79
|
/** Inject a background work result into the session so the next chat naturally references it. */
|
|
80
80
|
injectPendingContext(sessionKey: string, userPrompt: string, result: string): void;
|
|
81
81
|
private initMemoryStore;
|
|
82
|
+
/**
|
|
83
|
+
* Seed the in-memory hotCorrections ring buffer from persisted behavioral
|
|
84
|
+
* patterns (corrections that recurred across ≥2 sessions in the last 30d).
|
|
85
|
+
* Without this, daemon restarts would wipe the prompt-injected corrections
|
|
86
|
+
* until they reoccurred live.
|
|
87
|
+
*/
|
|
88
|
+
private primeHotCorrections;
|
|
82
89
|
private loadSessions;
|
|
83
90
|
/**
|
|
84
91
|
* Schedule a debounced session persist. Multiple calls within 500ms collapse
|
|
@@ -186,7 +193,7 @@ export declare class PersonalAssistant {
|
|
|
186
193
|
};
|
|
187
194
|
delegateProfile?: AgentProfile;
|
|
188
195
|
}): Promise<string>;
|
|
189
|
-
runCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, timeoutMs?: number, successCriteria?: string[]): Promise<string>;
|
|
196
|
+
runCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
|
|
190
197
|
/**
|
|
191
198
|
* Goal-backward verification pass using Haiku after cron job execution.
|
|
192
199
|
* Instead of vague quality ratings, verifies actual outcomes:
|
|
@@ -195,7 +202,7 @@ export declare class PersonalAssistant {
|
|
|
195
202
|
* 3. Does it connect to the goal / produce actionable results? (wired)
|
|
196
203
|
*/
|
|
197
204
|
private runCronReflection;
|
|
198
|
-
runUnleashedTask(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, maxHours?: number): Promise<string>;
|
|
205
|
+
runUnleashedTask(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, maxHours?: number, agentSlug?: string): Promise<string>;
|
|
199
206
|
/**
|
|
200
207
|
* Run a team message as an unleashed-style autonomous task.
|
|
201
208
|
* Gives team agents the same multi-phase execution as cron jobs,
|
|
@@ -203,7 +210,7 @@ export declare class PersonalAssistant {
|
|
|
203
210
|
*
|
|
204
211
|
* @param onText Streaming callback for real-time progress updates
|
|
205
212
|
*/
|
|
206
|
-
runTeamTask(fromName: string, fromSlug: string, content: string, profile: AgentProfile, onText?: (token: string) => void): Promise<string>;
|
|
213
|
+
runTeamTask(fromName: string, fromSlug: string, content: string, profile: AgentProfile, onText?: (token: string) => void, externalAbortController?: AbortController): Promise<string>;
|
|
207
214
|
/**
|
|
208
215
|
* Inject a user/assistant exchange into a session's context without running
|
|
209
216
|
* a query. Used to give the DM session visibility of cron/heartbeat outputs
|
package/dist/agent/assistant.js
CHANGED
|
@@ -15,7 +15,7 @@ import { query as rawQuery, listSubagents, getSubagentMessages, } from '@anthrop
|
|
|
15
15
|
import pino from 'pino';
|
|
16
16
|
import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, PROFILES_DIR, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, ENABLE_1M_CONTEXT, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
|
|
17
17
|
import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
|
|
18
|
-
import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, } from './hooks.js';
|
|
18
|
+
import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, logAuditJsonl, } from './hooks.js';
|
|
19
19
|
import { scanner } from '../security/scanner.js';
|
|
20
20
|
import { agentWorkingMemoryFile, listAllGoals } from '../tools/shared.js';
|
|
21
21
|
import { AgentManager } from './agent-manager.js';
|
|
@@ -84,6 +84,64 @@ function formatCapabilities(caps) {
|
|
|
84
84
|
features.push(`max ${caps.maxMessageLength} chars/message`);
|
|
85
85
|
return features.length > 0 ? features.join(', ') : 'text only';
|
|
86
86
|
}
|
|
87
|
+
/** Derive the human-readable channel label from a session key. */
|
|
88
|
+
function deriveChannel(opts) {
|
|
89
|
+
const { sessionKey, isAutonomous, cronTier } = opts;
|
|
90
|
+
if (isAutonomous)
|
|
91
|
+
return cronTier != null ? 'cron' : 'heartbeat';
|
|
92
|
+
if (!sessionKey)
|
|
93
|
+
return 'unknown';
|
|
94
|
+
if (sessionKey.startsWith('discord:user:'))
|
|
95
|
+
return 'Discord DM';
|
|
96
|
+
if (sessionKey.startsWith('discord:channel:'))
|
|
97
|
+
return 'Discord channel';
|
|
98
|
+
if (sessionKey.startsWith('slack:'))
|
|
99
|
+
return 'Slack';
|
|
100
|
+
if (sessionKey.startsWith('telegram:'))
|
|
101
|
+
return 'Telegram';
|
|
102
|
+
if (sessionKey.startsWith('whatsapp:'))
|
|
103
|
+
return 'WhatsApp';
|
|
104
|
+
if (sessionKey.startsWith('webhook:'))
|
|
105
|
+
return 'webhook';
|
|
106
|
+
return 'direct';
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Per-channel tool deny list. Narrows what the agent can invoke based on the
|
|
110
|
+
* surface area of the channel — e.g. a public Discord channel shouldn't execute
|
|
111
|
+
* shell commands on the owner's box, and SMS/WhatsApp shouldn't touch the
|
|
112
|
+
* filesystem. Owner-direct surfaces (Discord DM, dashboard, direct CLI) get the
|
|
113
|
+
* full toolset.
|
|
114
|
+
*
|
|
115
|
+
* Returned tools are added to the SDK's `disallowedTools`. Denial is strict —
|
|
116
|
+
* it overrides the positive allowlist in buildOptions.
|
|
117
|
+
*/
|
|
118
|
+
function getChannelToolDenyList(channel) {
|
|
119
|
+
const CODE_EXEC = ['Bash', 'Write', 'Edit'];
|
|
120
|
+
const SHARED_DENY = [...CODE_EXEC];
|
|
121
|
+
const SMS_DENY = [
|
|
122
|
+
...CODE_EXEC,
|
|
123
|
+
mcpTool('browser_screenshot'),
|
|
124
|
+
mcpTool('github_prs'),
|
|
125
|
+
mcpTool('rss_fetch'),
|
|
126
|
+
mcpTool('web_search'),
|
|
127
|
+
mcpTool('analyze_image'),
|
|
128
|
+
mcpTool('self_restart'),
|
|
129
|
+
mcpTool('update_self'),
|
|
130
|
+
];
|
|
131
|
+
switch (channel) {
|
|
132
|
+
case 'Discord channel':
|
|
133
|
+
case 'Slack':
|
|
134
|
+
return SHARED_DENY;
|
|
135
|
+
case 'WhatsApp':
|
|
136
|
+
case 'Telegram':
|
|
137
|
+
return SMS_DENY;
|
|
138
|
+
case 'webhook':
|
|
139
|
+
return SMS_DENY;
|
|
140
|
+
default:
|
|
141
|
+
// Discord DM (owner), direct, dashboard:web, autonomous, unknown → full tools.
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
87
145
|
// ── Token estimation & context window guard ─────────────────────────
|
|
88
146
|
/**
|
|
89
147
|
* Estimate token count using a weighted heuristic.
|
|
@@ -575,6 +633,19 @@ export class PersonalAssistant {
|
|
|
575
633
|
// ── Shared stream helpers ──────────────────────────────────────────
|
|
576
634
|
/** Log SDK result metrics and store usage. Shared across all query methods. */
|
|
577
635
|
logQueryResult(result, source, sessionKey, label, agentSlug) {
|
|
636
|
+
// Aggregate cache stats across all models used this turn
|
|
637
|
+
let cacheRead = 0;
|
|
638
|
+
let cacheCreation = 0;
|
|
639
|
+
let inputTokens = 0;
|
|
640
|
+
if (result.modelUsage) {
|
|
641
|
+
for (const usage of Object.values(result.modelUsage)) {
|
|
642
|
+
cacheRead += usage.cacheReadInputTokens ?? 0;
|
|
643
|
+
cacheCreation += usage.cacheCreationInputTokens ?? 0;
|
|
644
|
+
inputTokens += usage.inputTokens ?? 0;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const cacheDenominator = inputTokens + cacheRead + cacheCreation;
|
|
648
|
+
const cacheHitRate = cacheDenominator > 0 ? cacheRead / cacheDenominator : 0;
|
|
578
649
|
if ('total_cost_usd' in result) {
|
|
579
650
|
logger.info({
|
|
580
651
|
...(label ? { job: label } : {}),
|
|
@@ -582,7 +653,23 @@ export class PersonalAssistant {
|
|
|
582
653
|
cost_usd: result.total_cost_usd,
|
|
583
654
|
num_turns: result.num_turns,
|
|
584
655
|
duration_ms: result.duration_ms,
|
|
656
|
+
cache_read_tokens: cacheRead,
|
|
657
|
+
cache_creation_tokens: cacheCreation,
|
|
658
|
+
cache_hit_rate: Number(cacheHitRate.toFixed(3)),
|
|
585
659
|
}, `${source} query completed`);
|
|
660
|
+
logAuditJsonl({
|
|
661
|
+
event_type: 'query_complete',
|
|
662
|
+
source,
|
|
663
|
+
agent_slug: agentSlug,
|
|
664
|
+
job: label,
|
|
665
|
+
cost_usd: result.total_cost_usd,
|
|
666
|
+
num_turns: result.num_turns,
|
|
667
|
+
duration_ms: result.duration_ms,
|
|
668
|
+
tokens_in: inputTokens,
|
|
669
|
+
cache_read_tokens: cacheRead,
|
|
670
|
+
cache_creation_tokens: cacheCreation,
|
|
671
|
+
cache_hit_rate: Number(cacheHitRate.toFixed(3)),
|
|
672
|
+
});
|
|
586
673
|
}
|
|
587
674
|
if (this.memoryStore && result.modelUsage) {
|
|
588
675
|
try {
|
|
@@ -638,11 +725,39 @@ export class PersonalAssistant {
|
|
|
638
725
|
const { MEMORY_DB_PATH } = await import('../config.js');
|
|
639
726
|
this.memoryStore = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
|
|
640
727
|
this.memoryStore.initialize();
|
|
728
|
+
this.primeHotCorrections();
|
|
641
729
|
}
|
|
642
730
|
catch (err) {
|
|
643
731
|
logger.warn({ err }, 'Memory store init failed — falling back to static prompts');
|
|
644
732
|
}
|
|
645
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* Seed the in-memory hotCorrections ring buffer from persisted behavioral
|
|
736
|
+
* patterns (corrections that recurred across ≥2 sessions in the last 30d).
|
|
737
|
+
* Without this, daemon restarts would wipe the prompt-injected corrections
|
|
738
|
+
* until they reoccurred live.
|
|
739
|
+
*/
|
|
740
|
+
primeHotCorrections() {
|
|
741
|
+
if (!this.memoryStore)
|
|
742
|
+
return;
|
|
743
|
+
try {
|
|
744
|
+
const patterns = this.memoryStore.getBehavioralPatterns(2);
|
|
745
|
+
const now = new Date().toISOString();
|
|
746
|
+
for (const p of patterns.slice(0, 10)) {
|
|
747
|
+
this.hotCorrections.push({
|
|
748
|
+
correction: p.correction,
|
|
749
|
+
category: p.category,
|
|
750
|
+
timestamp: now,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
if (patterns.length > 0) {
|
|
754
|
+
logger.info({ primed: Math.min(patterns.length, 10) }, 'Primed hot corrections from behavioral patterns');
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
logger.warn({ err }, 'Priming hot corrections failed');
|
|
759
|
+
}
|
|
760
|
+
}
|
|
646
761
|
// ── Session Persistence ───────────────────────────────────────────
|
|
647
762
|
loadSessions() {
|
|
648
763
|
if (!fs.existsSync(SESSIONS_FILE))
|
|
@@ -864,40 +979,6 @@ export class PersonalAssistant {
|
|
|
864
979
|
}
|
|
865
980
|
}
|
|
866
981
|
}
|
|
867
|
-
const now = new Date();
|
|
868
|
-
// Derive channel label from session key
|
|
869
|
-
let channel = 'unknown';
|
|
870
|
-
if (isAutonomous) {
|
|
871
|
-
channel = cronTier !== null ? 'cron' : 'heartbeat';
|
|
872
|
-
}
|
|
873
|
-
else if (sessionKey) {
|
|
874
|
-
if (sessionKey.startsWith('discord:user:'))
|
|
875
|
-
channel = 'Discord DM';
|
|
876
|
-
else if (sessionKey.startsWith('discord:channel:'))
|
|
877
|
-
channel = 'Discord channel';
|
|
878
|
-
else if (sessionKey.startsWith('slack:'))
|
|
879
|
-
channel = 'Slack';
|
|
880
|
-
else if (sessionKey.startsWith('telegram:'))
|
|
881
|
-
channel = 'Telegram';
|
|
882
|
-
else if (sessionKey.startsWith('whatsapp:'))
|
|
883
|
-
channel = 'WhatsApp';
|
|
884
|
-
else if (sessionKey.startsWith('webhook:'))
|
|
885
|
-
channel = 'webhook';
|
|
886
|
-
else
|
|
887
|
-
channel = 'direct';
|
|
888
|
-
}
|
|
889
|
-
const resolvedModel = resolveModel(model) ?? MODEL;
|
|
890
|
-
const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
|
|
891
|
-
const caps = !isAutonomous ? getChannelCapabilities(channel) : null;
|
|
892
|
-
parts.push(`## Current Context
|
|
893
|
-
|
|
894
|
-
- **Date:** ${formatDate(now)}
|
|
895
|
-
- **Time:** ${formatTime(now)}
|
|
896
|
-
- **Timezone:** ${Intl.DateTimeFormat().resolvedOptions().timeZone}
|
|
897
|
-
- **Channel:** ${channel}${caps ? ` (${formatCapabilities(caps)})` : ''}
|
|
898
|
-
- **Model:** ${modelLabel} (${resolvedModel})
|
|
899
|
-
- **Vault:** ${vault}
|
|
900
|
-
`);
|
|
901
982
|
if (isAutonomous) {
|
|
902
983
|
// Minimal vault reference for heartbeats/cron — they know their tools
|
|
903
984
|
parts.push(`Vault: \`${vault}\`. Key files: MEMORY.md, ${todayISO()}.md (today), TASKS.md. Use MCP tools (memory_read/write, task_list/add/update, note_take).`);
|
|
@@ -979,7 +1060,8 @@ Never spawn a sub-agent with vague instructions like "handle this brief" — tel
|
|
|
979
1060
|
// Proactive skill injection: match user message against skill triggers
|
|
980
1061
|
if (this._lastUserMessage && !isAutonomous) {
|
|
981
1062
|
try {
|
|
982
|
-
const
|
|
1063
|
+
const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(profile?.slug);
|
|
1064
|
+
const matchedSkills = searchSkillsSync(this._lastUserMessage, 1, profile?.slug, { suppressedNames });
|
|
983
1065
|
if (matchedSkills.length > 0 && matchedSkills[0].score >= 4) {
|
|
984
1066
|
const skill = matchedSkills[0];
|
|
985
1067
|
this.memoryStore?.logSkillUse?.({
|
|
@@ -1153,6 +1235,21 @@ If you're stuck after reading several files, tell ${owner} what's blocking you.
|
|
|
1153
1235
|
You have a cost budget per message — not a hard turn limit. Work until the task is done. For long tasks (10+ tool calls), narrate progress as you go so ${owner} can see you're making headway. If a task needs many database queries, keep result sets small (LIMIT 20) to avoid filling context.`);
|
|
1154
1236
|
}
|
|
1155
1237
|
// Security rules are now appended to systemPrompt in buildOptions()
|
|
1238
|
+
// Volatile suffix — put last so the stable prefix above stays cache-friendly.
|
|
1239
|
+
const channel = deriveChannel({ sessionKey, isAutonomous, cronTier });
|
|
1240
|
+
const resolvedModel = resolveModel(model) ?? MODEL;
|
|
1241
|
+
const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
|
|
1242
|
+
const caps = !isAutonomous ? getChannelCapabilities(channel) : null;
|
|
1243
|
+
const now = new Date();
|
|
1244
|
+
parts.push(`## Current Context
|
|
1245
|
+
|
|
1246
|
+
- **Date:** ${formatDate(now)}
|
|
1247
|
+
- **Time:** ${formatTime(now)}
|
|
1248
|
+
- **Timezone:** ${Intl.DateTimeFormat().resolvedOptions().timeZone}
|
|
1249
|
+
- **Channel:** ${channel}${caps ? ` (${formatCapabilities(caps)})` : ''}
|
|
1250
|
+
- **Model:** ${modelLabel} (${resolvedModel})
|
|
1251
|
+
- **Vault:** ${vault}
|
|
1252
|
+
`);
|
|
1156
1253
|
return parts.join('\n\n---\n\n');
|
|
1157
1254
|
}
|
|
1158
1255
|
// ── Build SDK Options ─────────────────────────────────────────────
|
|
@@ -1271,8 +1368,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1271
1368
|
// Cron tier 1 gets heartbeat restrictions (read-only + vault writes).
|
|
1272
1369
|
const isCron = cronTier !== null;
|
|
1273
1370
|
const disallowed = isHeartbeat && (!isCron || (cronTier ?? 0) < 2)
|
|
1274
|
-
? getHeartbeatDisallowedTools()
|
|
1371
|
+
? [...getHeartbeatDisallowedTools()]
|
|
1275
1372
|
: [];
|
|
1373
|
+
// Per-channel tool scoping: narrow tools for surfaces where destructive
|
|
1374
|
+
// operations shouldn't happen (public Discord/Slack channels, SMS-like
|
|
1375
|
+
// channels, webhooks). Owner DMs + dashboard keep the full toolset.
|
|
1376
|
+
const channelForScoping = deriveChannel({ sessionKey, isAutonomous: isHeartbeat || isCron, cronTier });
|
|
1377
|
+
const channelDeny = getChannelToolDenyList(channelForScoping);
|
|
1378
|
+
if (channelDeny.length > 0) {
|
|
1379
|
+
for (const t of channelDeny)
|
|
1380
|
+
if (!disallowed.includes(t))
|
|
1381
|
+
disallowed.push(t);
|
|
1382
|
+
}
|
|
1276
1383
|
// Cron/heartbeat get turn limits. Interactive chat has no turn cap —
|
|
1277
1384
|
// cost budget (maxBudgetUsd) is the primary guardrail.
|
|
1278
1385
|
const effectiveMaxTurns = maxTurns
|
|
@@ -1302,11 +1409,16 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1302
1409
|
: isCron && !isUnleashed ? 'medium'
|
|
1303
1410
|
: isPlanStep || isUnleashed ? 'high'
|
|
1304
1411
|
: undefined);
|
|
1305
|
-
// ── Compute budget
|
|
1412
|
+
// ── Compute budget (telemetry only) ───────────────────────────
|
|
1413
|
+
// Cost is informational on a Claude subscription — killing a job
|
|
1414
|
+
// mid-phase because it hit $5 in tokens is worse than the cost.
|
|
1415
|
+
// We still compute the figure so dashboards/logs can show it, but
|
|
1416
|
+
// do not pass it into the SDK as an enforcement knob.
|
|
1306
1417
|
const computedBudget = maxBudgetUsd ?? (isHeartbeat && !isCron ? BUDGET.heartbeat
|
|
1307
1418
|
: isCron && (cronTier ?? 0) < 2 ? BUDGET.cronT1
|
|
1308
1419
|
: isCron ? BUDGET.cronT2
|
|
1309
1420
|
: BUDGET.chat);
|
|
1421
|
+
void computedBudget; // reserved for future cost telemetry — not enforced
|
|
1310
1422
|
// ── Compute adaptive thinking ─────────────────────────────────
|
|
1311
1423
|
const supportsThinking = !resolvedModel.includes('haiku');
|
|
1312
1424
|
const needsThinking = !isHeartbeat && (isPlanStep || isUnleashed || !isCron);
|
|
@@ -1355,7 +1467,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1355
1467
|
cwd: BASE_DIR,
|
|
1356
1468
|
env: SAFE_ENV,
|
|
1357
1469
|
...(computedEffort ? { effort: computedEffort } : {}),
|
|
1358
|
-
|
|
1470
|
+
// maxBudgetUsd intentionally omitted — see comment above.
|
|
1359
1471
|
...(computedThinking ? { thinking: computedThinking } : {}),
|
|
1360
1472
|
...(computedBetas ? { betas: computedBetas } : {}),
|
|
1361
1473
|
...(outputFormat ? { outputFormat } : {}),
|
|
@@ -1421,7 +1533,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1421
1533
|
(async () => {
|
|
1422
1534
|
try {
|
|
1423
1535
|
const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
|
|
1424
|
-
const
|
|
1536
|
+
const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(agentSlug || undefined);
|
|
1537
|
+
const matchedSkills = searchSkills(enrichedQuery, 2, agentSlug || undefined, { suppressedNames });
|
|
1425
1538
|
if (matchedSkills.length > 0) {
|
|
1426
1539
|
return `## Relevant Procedures (from past successful executions)\n\n` +
|
|
1427
1540
|
matchedSkills.map(s => {
|
|
@@ -1908,6 +2021,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1908
2021
|
let responseText = '';
|
|
1909
2022
|
let sessionId = '';
|
|
1910
2023
|
let hitRateLimit = false;
|
|
2024
|
+
let rateLimitRetryAfterMs = null;
|
|
1911
2025
|
let staleSession = false;
|
|
1912
2026
|
let contextRecovery = false;
|
|
1913
2027
|
let lastAssistantBlocks = [];
|
|
@@ -2078,6 +2192,16 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2078
2192
|
}
|
|
2079
2193
|
else if (errStr.includes('rate') && (errStr.includes('limit') || errStr.includes('rate_limit'))) {
|
|
2080
2194
|
hitRateLimit = true;
|
|
2195
|
+
// Try to respect any retry hint the server surfaced in the error text.
|
|
2196
|
+
// Matches: "retry-after: 30", "retry after 30 seconds", "retry in 30s".
|
|
2197
|
+
const m = errStr.match(/retry[-\s]?(?:after|in)[:\s]*(\d+)\s*(ms|s|seconds?|milliseconds?)?/);
|
|
2198
|
+
if (m) {
|
|
2199
|
+
const n = Number(m[1]);
|
|
2200
|
+
if (Number.isFinite(n) && n > 0) {
|
|
2201
|
+
const unit = (m[2] ?? 's').toLowerCase();
|
|
2202
|
+
rateLimitRetryAfterMs = unit.startsWith('ms') || unit.startsWith('milli') ? n : n * 1000;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2081
2205
|
}
|
|
2082
2206
|
else if (errStr.includes('autocompact') || errStr.includes('thrash') || errStr.includes('context refilled to the limit')) {
|
|
2083
2207
|
// SDK autocompact thrashing — tool outputs are too large for the context window.
|
|
@@ -2161,8 +2285,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2161
2285
|
continue;
|
|
2162
2286
|
}
|
|
2163
2287
|
if (hitRateLimit && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
|
|
2164
|
-
const
|
|
2288
|
+
const base = rateLimitRetryAfterMs
|
|
2289
|
+
?? PersonalAssistant.RATE_LIMIT_BACKOFF[Math.min(attempt, PersonalAssistant.RATE_LIMIT_BACKOFF.length - 1)];
|
|
2290
|
+
// ±25% jitter so concurrent retries don't align and re-collide.
|
|
2291
|
+
const jitter = 1 + (Math.random() - 0.5) * 0.5;
|
|
2292
|
+
const wait = Math.max(500, Math.round(base * jitter));
|
|
2293
|
+
logger.info({ sessionKey, attempt, waitMs: wait, hintedRetryAfterMs: rateLimitRetryAfterMs }, 'Rate-limited — waiting before retry');
|
|
2165
2294
|
await new Promise((r) => setTimeout(r, wait));
|
|
2295
|
+
rateLimitRetryAfterMs = null; // hint is per-attempt
|
|
2166
2296
|
continue;
|
|
2167
2297
|
}
|
|
2168
2298
|
if (hitRateLimit && !responseText) {
|
|
@@ -2994,8 +3124,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2994
3124
|
return extractDeliverable(trace) ||
|
|
2995
3125
|
trace.filter(t => t.type === 'text').map(t => t.content).join('').trim();
|
|
2996
3126
|
}
|
|
2997
|
-
async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria) {
|
|
3127
|
+
async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug) {
|
|
2998
3128
|
setInteractionSource('autonomous');
|
|
3129
|
+
const cronProfile = agentSlug && agentSlug !== 'clementine'
|
|
3130
|
+
? this.profileManager.get(agentSlug)
|
|
3131
|
+
: null;
|
|
2999
3132
|
const cronGuard = new StallGuard();
|
|
3000
3133
|
const sdkOptions = this.buildOptions({
|
|
3001
3134
|
isHeartbeat: true,
|
|
@@ -3004,6 +3137,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3004
3137
|
model: model ?? null,
|
|
3005
3138
|
enableTeams: true,
|
|
3006
3139
|
stallGuard: cronGuard,
|
|
3140
|
+
profile: cronProfile,
|
|
3007
3141
|
});
|
|
3008
3142
|
// Override cwd if a project workDir is specified
|
|
3009
3143
|
if (workDir) {
|
|
@@ -3140,7 +3274,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3140
3274
|
const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
|
|
3141
3275
|
const cronAgentSlug = sdkOptions.env?.CLEMENTINE_TEAM_AGENT;
|
|
3142
3276
|
const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
|
|
3143
|
-
const
|
|
3277
|
+
const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(cronAgentSlug || undefined);
|
|
3278
|
+
const matchedSkills = searchSkills(skillQuery, 2, cronAgentSlug || undefined, { suppressedNames });
|
|
3144
3279
|
if (matchedSkills.length > 0) {
|
|
3145
3280
|
const skillLines = matchedSkills.map(s => {
|
|
3146
3281
|
recordSkillUse(s.name);
|
|
@@ -3406,8 +3541,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3406
3541
|
}
|
|
3407
3542
|
}
|
|
3408
3543
|
// ── Unleashed Mode (Long-Running Autonomous Tasks) ─────────────────
|
|
3409
|
-
async runUnleashedTask(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, maxHours) {
|
|
3544
|
+
async runUnleashedTask(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, maxHours, agentSlug) {
|
|
3410
3545
|
setInteractionSource('autonomous');
|
|
3546
|
+
const unleashedProfile = agentSlug && agentSlug !== 'clementine'
|
|
3547
|
+
? this.profileManager.get(agentSlug)
|
|
3548
|
+
: null;
|
|
3411
3549
|
const effectiveMaxHours = maxHours ?? UNLEASHED_DEFAULT_MAX_HOURS;
|
|
3412
3550
|
const turnsPerPhase = maxTurns ?? UNLEASHED_PHASE_TURNS;
|
|
3413
3551
|
const deadline = Date.now() + effectiveMaxHours * 60 * 60 * 1000;
|
|
@@ -3478,6 +3616,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3478
3616
|
isUnleashed: true,
|
|
3479
3617
|
maxBudgetUsd: BUDGET.unleashedPhase,
|
|
3480
3618
|
stallGuard: phaseGuard,
|
|
3619
|
+
profile: unleashedProfile,
|
|
3481
3620
|
});
|
|
3482
3621
|
// Enable progress summaries for real-time status updates
|
|
3483
3622
|
sdkOptions.agentProgressSummaries = true;
|
|
@@ -3498,7 +3637,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3498
3637
|
const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
|
|
3499
3638
|
const unleashedAgentSlug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
|
|
3500
3639
|
const unleashedSkillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
|
|
3501
|
-
const
|
|
3640
|
+
const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(unleashedAgentSlug);
|
|
3641
|
+
const matchedSkills = searchSkills(unleashedSkillQuery, 2, unleashedAgentSlug, { suppressedNames });
|
|
3502
3642
|
if (matchedSkills.length > 0) {
|
|
3503
3643
|
unleashedSkillContext = `\n\n## Learned Procedures\nFollow these proven approaches when applicable:\n\n` +
|
|
3504
3644
|
matchedSkills.map(s => {
|
|
@@ -3796,7 +3936,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3796
3936
|
*
|
|
3797
3937
|
* @param onText Streaming callback for real-time progress updates
|
|
3798
3938
|
*/
|
|
3799
|
-
async runTeamTask(fromName, fromSlug, content, profile, onText) {
|
|
3939
|
+
async runTeamTask(fromName, fromSlug, content, profile, onText, externalAbortController) {
|
|
3800
3940
|
setInteractionSource('autonomous');
|
|
3801
3941
|
const taskName = `team-msg:${fromSlug}-to-${profile.slug}`;
|
|
3802
3942
|
const maxHours = 1; // Team messages get 1 hour max (not 6 like cron unleashed)
|
|
@@ -3808,6 +3948,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3808
3948
|
let lastOutput = '';
|
|
3809
3949
|
let consecutiveErrors = 0;
|
|
3810
3950
|
while (phase < maxPhases) {
|
|
3951
|
+
if (externalAbortController?.signal.aborted) {
|
|
3952
|
+
logger.info({ taskName, phase }, 'Team task aborted by caller');
|
|
3953
|
+
return lastOutput || `Team task aborted by caller at phase ${phase}.`;
|
|
3954
|
+
}
|
|
3811
3955
|
if (Date.now() >= deadline) {
|
|
3812
3956
|
logger.info({ taskName, phase }, 'Team task timed out');
|
|
3813
3957
|
return lastOutput || `Team task timed out after ${maxHours}h at phase ${phase}.`;
|
|
@@ -3878,6 +4022,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3878
4022
|
phaseAc.abort();
|
|
3879
4023
|
logger.warn({ taskName, phase }, `Team task phase ${phase} aborted — deadline reached`);
|
|
3880
4024
|
}, Math.max(deadline - Date.now(), 0));
|
|
4025
|
+
// Propagate external abort (e.g., user sent "Stop") into the phase controller
|
|
4026
|
+
const onExternalAbort = () => {
|
|
4027
|
+
phaseAc.abort();
|
|
4028
|
+
logger.info({ taskName, phase }, `Team task phase ${phase} aborted by caller`);
|
|
4029
|
+
};
|
|
4030
|
+
if (externalAbortController) {
|
|
4031
|
+
if (externalAbortController.signal.aborted)
|
|
4032
|
+
phaseAc.abort();
|
|
4033
|
+
else
|
|
4034
|
+
externalAbortController.signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
4035
|
+
}
|
|
3881
4036
|
sdkOptions.abortController = phaseAc;
|
|
3882
4037
|
try {
|
|
3883
4038
|
const stream = query({ prompt, options: sdkOptions });
|
|
@@ -3933,6 +4088,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3933
4088
|
}
|
|
3934
4089
|
catch (err) {
|
|
3935
4090
|
clearTimeout(phaseTimer);
|
|
4091
|
+
externalAbortController?.signal.removeEventListener('abort', onExternalAbort);
|
|
4092
|
+
// If this phase aborted because the caller cancelled, return cleanly —
|
|
4093
|
+
// no retry, no 3-strikes counter.
|
|
4094
|
+
if (externalAbortController?.signal.aborted) {
|
|
4095
|
+
logger.info({ taskName, phase }, 'Team task aborted mid-phase by caller');
|
|
4096
|
+
return lastOutput || `Team task aborted by caller at phase ${phase}.`;
|
|
4097
|
+
}
|
|
3936
4098
|
logger.error({ err, taskName, phase }, 'Team task phase error');
|
|
3937
4099
|
consecutiveErrors++;
|
|
3938
4100
|
if (consecutiveErrors >= 3) {
|
|
@@ -3942,6 +4104,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3942
4104
|
continue;
|
|
3943
4105
|
}
|
|
3944
4106
|
clearTimeout(phaseTimer);
|
|
4107
|
+
externalAbortController?.signal.removeEventListener('abort', onExternalAbort);
|
|
3945
4108
|
sessionId = phaseSessionId;
|
|
3946
4109
|
lastOutput = phaseOutput.trim();
|
|
3947
4110
|
consecutiveErrors = 0;
|
package/dist/agent/hooks.d.ts
CHANGED
|
@@ -9,6 +9,44 @@
|
|
|
9
9
|
* - Audit logging: persistent file + in-memory buffer
|
|
10
10
|
*/
|
|
11
11
|
import type { SendPolicy } from '../types.js';
|
|
12
|
+
export interface TraceContext {
|
|
13
|
+
trace_id: string;
|
|
14
|
+
session_id?: string;
|
|
15
|
+
channel?: string;
|
|
16
|
+
agent_slug?: string;
|
|
17
|
+
span_stack: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Run `fn` inside a trace context. Creates a new trace_id if none is supplied
|
|
21
|
+
* and inherited from an outer context. Nested calls push a span_id onto the
|
|
22
|
+
* stack so parent/child relationships survive async hops.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runWithTrace<T>(ctx: {
|
|
25
|
+
trace_id?: string;
|
|
26
|
+
session_id?: string;
|
|
27
|
+
channel?: string;
|
|
28
|
+
agent_slug?: string;
|
|
29
|
+
}, fn: () => Promise<T> | T): Promise<T> | T;
|
|
30
|
+
export declare function getTraceContext(): TraceContext | undefined;
|
|
31
|
+
export interface AuditEvent {
|
|
32
|
+
event_type: string;
|
|
33
|
+
tool_name?: string;
|
|
34
|
+
duration_ms?: number;
|
|
35
|
+
tokens_in?: number;
|
|
36
|
+
tokens_out?: number;
|
|
37
|
+
cache_read_tokens?: number;
|
|
38
|
+
cache_creation_tokens?: number;
|
|
39
|
+
cost_usd?: number;
|
|
40
|
+
num_turns?: number;
|
|
41
|
+
error?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Append a structured event to audit.jsonl with the current trace context.
|
|
46
|
+
* Runs alongside (not in place of) the legacy text audit.log so existing
|
|
47
|
+
* consumers keep working.
|
|
48
|
+
*/
|
|
49
|
+
export declare function logAuditJsonl(event: AuditEvent): void;
|
|
12
50
|
export declare function setHeartbeatMode(active: boolean, tier2Allowed?: boolean): void;
|
|
13
51
|
export declare function setApprovalCallback(cb: ((desc: string) => Promise<boolean>) | null): void;
|
|
14
52
|
export declare function setProfileTier(tier: number | null): void;
|
package/dist/agent/hooks.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
13
15
|
import { OWNER_NAME, BASE_DIR, TIMEZONE } from '../config.js';
|
|
14
16
|
// ── Shared state ───────────────────────────────────────────────────────
|
|
15
17
|
let heartbeatActive = false;
|
|
@@ -34,19 +36,27 @@ let interactionSource = 'autonomous';
|
|
|
34
36
|
const logsDir = path.join(BASE_DIR, 'logs');
|
|
35
37
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
36
38
|
const auditLogPath = path.join(logsDir, 'audit.log');
|
|
39
|
+
const auditJsonlPath = path.join(logsDir, 'audit.jsonl');
|
|
37
40
|
const MAX_AUDIT_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
41
|
+
function rotateIfLarge(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(filePath))
|
|
44
|
+
return;
|
|
45
|
+
const stat = fs.statSync(filePath);
|
|
46
|
+
if (stat.size <= MAX_AUDIT_SIZE)
|
|
47
|
+
return;
|
|
48
|
+
const backup = filePath + '.1';
|
|
49
|
+
if (fs.existsSync(backup))
|
|
50
|
+
fs.unlinkSync(backup);
|
|
51
|
+
fs.renameSync(filePath, backup);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Non-fatal
|
|
55
|
+
}
|
|
56
|
+
}
|
|
38
57
|
function appendAuditFile(line) {
|
|
39
58
|
try {
|
|
40
|
-
|
|
41
|
-
if (fs.existsSync(auditLogPath)) {
|
|
42
|
-
const stat = fs.statSync(auditLogPath);
|
|
43
|
-
if (stat.size > MAX_AUDIT_SIZE) {
|
|
44
|
-
const backup = auditLogPath + '.1';
|
|
45
|
-
if (fs.existsSync(backup))
|
|
46
|
-
fs.unlinkSync(backup);
|
|
47
|
-
fs.renameSync(auditLogPath, backup);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
59
|
+
rotateIfLarge(auditLogPath);
|
|
50
60
|
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
51
61
|
fs.appendFileSync(auditLogPath, `${timestamp} ${line}\n`);
|
|
52
62
|
}
|
|
@@ -54,6 +64,57 @@ function appendAuditFile(line) {
|
|
|
54
64
|
// Non-fatal — audit logging should never crash the assistant
|
|
55
65
|
}
|
|
56
66
|
}
|
|
67
|
+
const traceStorage = new AsyncLocalStorage();
|
|
68
|
+
function shortId() {
|
|
69
|
+
// 8-char id — collision-resistant enough for per-session correlation and
|
|
70
|
+
// much easier to eyeball in logs than a full UUID.
|
|
71
|
+
return randomUUID().replace(/-/g, '').slice(0, 8);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run `fn` inside a trace context. Creates a new trace_id if none is supplied
|
|
75
|
+
* and inherited from an outer context. Nested calls push a span_id onto the
|
|
76
|
+
* stack so parent/child relationships survive async hops.
|
|
77
|
+
*/
|
|
78
|
+
export function runWithTrace(ctx, fn) {
|
|
79
|
+
const existing = traceStorage.getStore();
|
|
80
|
+
const trace_id = ctx.trace_id ?? existing?.trace_id ?? shortId();
|
|
81
|
+
const store = {
|
|
82
|
+
trace_id,
|
|
83
|
+
session_id: ctx.session_id ?? existing?.session_id,
|
|
84
|
+
channel: ctx.channel ?? existing?.channel,
|
|
85
|
+
agent_slug: ctx.agent_slug ?? existing?.agent_slug,
|
|
86
|
+
span_stack: [shortId(), ...(existing?.span_stack ?? [])],
|
|
87
|
+
};
|
|
88
|
+
return traceStorage.run(store, fn);
|
|
89
|
+
}
|
|
90
|
+
export function getTraceContext() {
|
|
91
|
+
return traceStorage.getStore();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Append a structured event to audit.jsonl with the current trace context.
|
|
95
|
+
* Runs alongside (not in place of) the legacy text audit.log so existing
|
|
96
|
+
* consumers keep working.
|
|
97
|
+
*/
|
|
98
|
+
export function logAuditJsonl(event) {
|
|
99
|
+
try {
|
|
100
|
+
rotateIfLarge(auditJsonlPath);
|
|
101
|
+
const ctx = traceStorage.getStore();
|
|
102
|
+
const payload = {
|
|
103
|
+
ts: new Date().toISOString(),
|
|
104
|
+
trace_id: ctx?.trace_id,
|
|
105
|
+
span_id: ctx?.span_stack[0],
|
|
106
|
+
parent_span_id: ctx?.span_stack[1],
|
|
107
|
+
session_id: ctx?.session_id,
|
|
108
|
+
channel: ctx?.channel,
|
|
109
|
+
agent_slug: ctx?.agent_slug,
|
|
110
|
+
...event,
|
|
111
|
+
};
|
|
112
|
+
fs.appendFileSync(auditJsonlPath, JSON.stringify(payload) + '\n');
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Non-fatal — audit logging should never crash the assistant
|
|
116
|
+
}
|
|
117
|
+
}
|
|
57
118
|
// ── State accessors ──────────────────────────────────────────────────
|
|
58
119
|
export function setHeartbeatMode(active, tier2Allowed = false) {
|
|
59
120
|
heartbeatActive = active;
|
|
@@ -99,6 +160,11 @@ export function logToolUse(toolName, toolInput) {
|
|
|
99
160
|
const entry = `- \`${timestamp}\` **${toolName}** — ${summary}`;
|
|
100
161
|
auditLog.push(entry);
|
|
101
162
|
appendAuditFile(`${toolName} — ${summary}`);
|
|
163
|
+
logAuditJsonl({
|
|
164
|
+
event_type: 'tool_use',
|
|
165
|
+
tool_name: toolName,
|
|
166
|
+
summary,
|
|
167
|
+
});
|
|
102
168
|
}
|
|
103
169
|
// ── Heartbeat tool restrictions ─────────────────────────────────────
|
|
104
170
|
// These apply to actual heartbeats and tier-1 cron jobs (read-only).
|
|
@@ -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.
|