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.
@@ -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
@@ -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 matchedSkills = searchSkillsSync(this._lastUserMessage, 1, profile?.slug);
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 cap ────────────────────────────────────────
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
- ...(computedBudget !== undefined ? { maxBudgetUsd: computedBudget } : {}),
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 matchedSkills = searchSkills(enrichedQuery, 2, agentSlug || undefined);
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 wait = PersonalAssistant.RATE_LIMIT_BACKOFF[Math.min(attempt, PersonalAssistant.RATE_LIMIT_BACKOFF.length - 1)];
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 matchedSkills = searchSkills(skillQuery, 2, cronAgentSlug || undefined);
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 matchedSkills = searchSkills(unleashedSkillQuery, 2, unleashedAgentSlug);
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;
@@ -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;
@@ -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
- // Simple rotation: if file exceeds max size, rename to .log.1 and start fresh
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.