clementine-agent 1.0.27 → 1.0.29

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.
@@ -12,9 +12,19 @@
12
12
  import type { AgentProfile, OnTextCallback, OnToolActivityCallback, VerboseLevel } from '../types.js';
13
13
  import { AgentManager } from './agent-manager.js';
14
14
  /**
15
- * Estimate token count using a weighted heuristic.
16
- * BPE tokenizers average ~4 chars/token for prose, but code, punctuation,
17
- * and whitespace-heavy content tokenize differently.
15
+ * Estimate token count for Claude.
16
+ *
17
+ * Anthropic's published rule of thumb is ~3.5 chars/token for English prose.
18
+ * Clementine's prompts blend English guidance with code, JSON, YAML, and
19
+ * structured memory — so we use 3.3 chars/token, slightly denser than pure
20
+ * English, which tracks within ~10% of the SDK's reported input_tokens in
21
+ * practice (see audit.jsonl tokens_in for live calibration).
22
+ *
23
+ * The previous weighted-regex heuristic (words×1.3 + punct×0.8 + lines×0.5)
24
+ * systematically undercounted code and JSON, triggering spurious compactions.
25
+ *
26
+ * Callers that need exact counts should read `usage.input_tokens` from the
27
+ * SDK result; this function is for pre-flight planning only.
18
28
  */
19
29
  export declare function estimateTokens(text: string): number;
20
30
  export interface ProjectMeta {
@@ -79,6 +89,13 @@ export declare class PersonalAssistant {
79
89
  /** Inject a background work result into the session so the next chat naturally references it. */
80
90
  injectPendingContext(sessionKey: string, userPrompt: string, result: string): void;
81
91
  private initMemoryStore;
92
+ /**
93
+ * Seed the in-memory hotCorrections ring buffer from persisted behavioral
94
+ * patterns (corrections that recurred across ≥2 sessions in the last 30d).
95
+ * Without this, daemon restarts would wipe the prompt-injected corrections
96
+ * until they reoccurred live.
97
+ */
98
+ private primeHotCorrections;
82
99
  private loadSessions;
83
100
  /**
84
101
  * Schedule a debounced session persist. Multiple calls within 500ms collapse
@@ -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,22 +84,84 @@ 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
- * Estimate token count using a weighted heuristic.
90
- * BPE tokenizers average ~4 chars/token for prose, but code, punctuation,
91
- * and whitespace-heavy content tokenize differently.
147
+ * Estimate token count for Claude.
148
+ *
149
+ * Anthropic's published rule of thumb is ~3.5 chars/token for English prose.
150
+ * Clementine's prompts blend English guidance with code, JSON, YAML, and
151
+ * structured memory — so we use 3.3 chars/token, slightly denser than pure
152
+ * English, which tracks within ~10% of the SDK's reported input_tokens in
153
+ * practice (see audit.jsonl tokens_in for live calibration).
154
+ *
155
+ * The previous weighted-regex heuristic (words×1.3 + punct×0.8 + lines×0.5)
156
+ * systematically undercounted code and JSON, triggering spurious compactions.
157
+ *
158
+ * Callers that need exact counts should read `usage.input_tokens` from the
159
+ * SDK result; this function is for pre-flight planning only.
92
160
  */
93
161
  export function estimateTokens(text) {
94
162
  if (!text)
95
163
  return 0;
96
- // Count words (sequences of alphanumeric chars) — average ~1.3 tokens per word
97
- const words = text.match(/\b\w+\b/g)?.length ?? 0;
98
- // Count non-word tokens: punctuation, brackets, operators (each is ~1 token)
99
- const punctuation = text.match(/[^\w\s]/g)?.length ?? 0;
100
- // Newlines and indentation: roughly 1 token per line
101
- const lines = text.split('\n').length;
102
- return Math.ceil(words * 1.3 + punctuation * 0.8 + lines * 0.5);
164
+ return Math.ceil(text.length / 3.3);
103
165
  }
104
166
  /**
105
167
  * Strip lone Unicode surrogates (U+D800–U+DFFF) from a string so it can be
@@ -575,6 +637,19 @@ export class PersonalAssistant {
575
637
  // ── Shared stream helpers ──────────────────────────────────────────
576
638
  /** Log SDK result metrics and store usage. Shared across all query methods. */
577
639
  logQueryResult(result, source, sessionKey, label, agentSlug) {
640
+ // Aggregate cache stats across all models used this turn
641
+ let cacheRead = 0;
642
+ let cacheCreation = 0;
643
+ let inputTokens = 0;
644
+ if (result.modelUsage) {
645
+ for (const usage of Object.values(result.modelUsage)) {
646
+ cacheRead += usage.cacheReadInputTokens ?? 0;
647
+ cacheCreation += usage.cacheCreationInputTokens ?? 0;
648
+ inputTokens += usage.inputTokens ?? 0;
649
+ }
650
+ }
651
+ const cacheDenominator = inputTokens + cacheRead + cacheCreation;
652
+ const cacheHitRate = cacheDenominator > 0 ? cacheRead / cacheDenominator : 0;
578
653
  if ('total_cost_usd' in result) {
579
654
  logger.info({
580
655
  ...(label ? { job: label } : {}),
@@ -582,7 +657,23 @@ export class PersonalAssistant {
582
657
  cost_usd: result.total_cost_usd,
583
658
  num_turns: result.num_turns,
584
659
  duration_ms: result.duration_ms,
660
+ cache_read_tokens: cacheRead,
661
+ cache_creation_tokens: cacheCreation,
662
+ cache_hit_rate: Number(cacheHitRate.toFixed(3)),
585
663
  }, `${source} query completed`);
664
+ logAuditJsonl({
665
+ event_type: 'query_complete',
666
+ source,
667
+ agent_slug: agentSlug,
668
+ job: label,
669
+ cost_usd: result.total_cost_usd,
670
+ num_turns: result.num_turns,
671
+ duration_ms: result.duration_ms,
672
+ tokens_in: inputTokens,
673
+ cache_read_tokens: cacheRead,
674
+ cache_creation_tokens: cacheCreation,
675
+ cache_hit_rate: Number(cacheHitRate.toFixed(3)),
676
+ });
586
677
  }
587
678
  if (this.memoryStore && result.modelUsage) {
588
679
  try {
@@ -638,11 +729,39 @@ export class PersonalAssistant {
638
729
  const { MEMORY_DB_PATH } = await import('../config.js');
639
730
  this.memoryStore = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
640
731
  this.memoryStore.initialize();
732
+ this.primeHotCorrections();
641
733
  }
642
734
  catch (err) {
643
735
  logger.warn({ err }, 'Memory store init failed — falling back to static prompts');
644
736
  }
645
737
  }
738
+ /**
739
+ * Seed the in-memory hotCorrections ring buffer from persisted behavioral
740
+ * patterns (corrections that recurred across ≥2 sessions in the last 30d).
741
+ * Without this, daemon restarts would wipe the prompt-injected corrections
742
+ * until they reoccurred live.
743
+ */
744
+ primeHotCorrections() {
745
+ if (!this.memoryStore)
746
+ return;
747
+ try {
748
+ const patterns = this.memoryStore.getBehavioralPatterns(2);
749
+ const now = new Date().toISOString();
750
+ for (const p of patterns.slice(0, 10)) {
751
+ this.hotCorrections.push({
752
+ correction: p.correction,
753
+ category: p.category,
754
+ timestamp: now,
755
+ });
756
+ }
757
+ if (patterns.length > 0) {
758
+ logger.info({ primed: Math.min(patterns.length, 10) }, 'Primed hot corrections from behavioral patterns');
759
+ }
760
+ }
761
+ catch (err) {
762
+ logger.warn({ err }, 'Priming hot corrections failed');
763
+ }
764
+ }
646
765
  // ── Session Persistence ───────────────────────────────────────────
647
766
  loadSessions() {
648
767
  if (!fs.existsSync(SESSIONS_FILE))
@@ -650,6 +769,21 @@ export class PersonalAssistant {
650
769
  try {
651
770
  const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8'));
652
771
  const now = Date.now();
772
+ // Drop old-format Slack session keys that pre-date workspace namespacing
773
+ // (`slack:user:*`, `slack:dm:*`). The new format is
774
+ // `slack:team:{teamId}:user:{userId}`; old keys can't be safely remapped
775
+ // because the originating workspace isn't known, so they're dropped and
776
+ // users rotate into a fresh session on their next message.
777
+ let droppedLegacy = 0;
778
+ for (const key of Object.keys(data)) {
779
+ if (/^slack:(user|dm):/.test(key)) {
780
+ delete data[key];
781
+ droppedLegacy++;
782
+ }
783
+ }
784
+ if (droppedLegacy > 0) {
785
+ logger.info({ dropped: droppedLegacy }, 'Migrated sessions: dropped pre-workspace-namespacing Slack keys');
786
+ }
653
787
  for (const [key, entry] of Object.entries(data)) {
654
788
  const ts = new Date(entry.timestamp);
655
789
  if (now - ts.getTime() > SESSION_EXPIRY_MS)
@@ -864,40 +998,6 @@ export class PersonalAssistant {
864
998
  }
865
999
  }
866
1000
  }
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
1001
  if (isAutonomous) {
902
1002
  // Minimal vault reference for heartbeats/cron — they know their tools
903
1003
  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 +1079,8 @@ Never spawn a sub-agent with vague instructions like "handle this brief" — tel
979
1079
  // Proactive skill injection: match user message against skill triggers
980
1080
  if (this._lastUserMessage && !isAutonomous) {
981
1081
  try {
982
- const matchedSkills = searchSkillsSync(this._lastUserMessage, 1, profile?.slug);
1082
+ const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(profile?.slug);
1083
+ const matchedSkills = searchSkillsSync(this._lastUserMessage, 1, profile?.slug, { suppressedNames });
983
1084
  if (matchedSkills.length > 0 && matchedSkills[0].score >= 4) {
984
1085
  const skill = matchedSkills[0];
985
1086
  this.memoryStore?.logSkillUse?.({
@@ -1153,6 +1254,21 @@ If you're stuck after reading several files, tell ${owner} what's blocking you.
1153
1254
  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
1255
  }
1155
1256
  // Security rules are now appended to systemPrompt in buildOptions()
1257
+ // Volatile suffix — put last so the stable prefix above stays cache-friendly.
1258
+ const channel = deriveChannel({ sessionKey, isAutonomous, cronTier });
1259
+ const resolvedModel = resolveModel(model) ?? MODEL;
1260
+ const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
1261
+ const caps = !isAutonomous ? getChannelCapabilities(channel) : null;
1262
+ const now = new Date();
1263
+ parts.push(`## Current Context
1264
+
1265
+ - **Date:** ${formatDate(now)}
1266
+ - **Time:** ${formatTime(now)}
1267
+ - **Timezone:** ${Intl.DateTimeFormat().resolvedOptions().timeZone}
1268
+ - **Channel:** ${channel}${caps ? ` (${formatCapabilities(caps)})` : ''}
1269
+ - **Model:** ${modelLabel} (${resolvedModel})
1270
+ - **Vault:** ${vault}
1271
+ `);
1156
1272
  return parts.join('\n\n---\n\n');
1157
1273
  }
1158
1274
  // ── Build SDK Options ─────────────────────────────────────────────
@@ -1271,8 +1387,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1271
1387
  // Cron tier 1 gets heartbeat restrictions (read-only + vault writes).
1272
1388
  const isCron = cronTier !== null;
1273
1389
  const disallowed = isHeartbeat && (!isCron || (cronTier ?? 0) < 2)
1274
- ? getHeartbeatDisallowedTools()
1390
+ ? [...getHeartbeatDisallowedTools()]
1275
1391
  : [];
1392
+ // Per-channel tool scoping: narrow tools for surfaces where destructive
1393
+ // operations shouldn't happen (public Discord/Slack channels, SMS-like
1394
+ // channels, webhooks). Owner DMs + dashboard keep the full toolset.
1395
+ const channelForScoping = deriveChannel({ sessionKey, isAutonomous: isHeartbeat || isCron, cronTier });
1396
+ const channelDeny = getChannelToolDenyList(channelForScoping);
1397
+ if (channelDeny.length > 0) {
1398
+ for (const t of channelDeny)
1399
+ if (!disallowed.includes(t))
1400
+ disallowed.push(t);
1401
+ }
1276
1402
  // Cron/heartbeat get turn limits. Interactive chat has no turn cap —
1277
1403
  // cost budget (maxBudgetUsd) is the primary guardrail.
1278
1404
  const effectiveMaxTurns = maxTurns
@@ -1426,7 +1552,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1426
1552
  (async () => {
1427
1553
  try {
1428
1554
  const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
1429
- const matchedSkills = searchSkills(enrichedQuery, 2, agentSlug || undefined);
1555
+ const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(agentSlug || undefined);
1556
+ const matchedSkills = searchSkills(enrichedQuery, 2, agentSlug || undefined, { suppressedNames });
1430
1557
  if (matchedSkills.length > 0) {
1431
1558
  return `## Relevant Procedures (from past successful executions)\n\n` +
1432
1559
  matchedSkills.map(s => {
@@ -1913,6 +2040,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1913
2040
  let responseText = '';
1914
2041
  let sessionId = '';
1915
2042
  let hitRateLimit = false;
2043
+ let rateLimitRetryAfterMs = null;
1916
2044
  let staleSession = false;
1917
2045
  let contextRecovery = false;
1918
2046
  let lastAssistantBlocks = [];
@@ -2083,6 +2211,16 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2083
2211
  }
2084
2212
  else if (errStr.includes('rate') && (errStr.includes('limit') || errStr.includes('rate_limit'))) {
2085
2213
  hitRateLimit = true;
2214
+ // Try to respect any retry hint the server surfaced in the error text.
2215
+ // Matches: "retry-after: 30", "retry after 30 seconds", "retry in 30s".
2216
+ const m = errStr.match(/retry[-\s]?(?:after|in)[:\s]*(\d+)\s*(ms|s|seconds?|milliseconds?)?/);
2217
+ if (m) {
2218
+ const n = Number(m[1]);
2219
+ if (Number.isFinite(n) && n > 0) {
2220
+ const unit = (m[2] ?? 's').toLowerCase();
2221
+ rateLimitRetryAfterMs = unit.startsWith('ms') || unit.startsWith('milli') ? n : n * 1000;
2222
+ }
2223
+ }
2086
2224
  }
2087
2225
  else if (errStr.includes('autocompact') || errStr.includes('thrash') || errStr.includes('context refilled to the limit')) {
2088
2226
  // SDK autocompact thrashing — tool outputs are too large for the context window.
@@ -2166,8 +2304,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2166
2304
  continue;
2167
2305
  }
2168
2306
  if (hitRateLimit && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
2169
- const wait = PersonalAssistant.RATE_LIMIT_BACKOFF[Math.min(attempt, PersonalAssistant.RATE_LIMIT_BACKOFF.length - 1)];
2307
+ const base = rateLimitRetryAfterMs
2308
+ ?? PersonalAssistant.RATE_LIMIT_BACKOFF[Math.min(attempt, PersonalAssistant.RATE_LIMIT_BACKOFF.length - 1)];
2309
+ // ±25% jitter so concurrent retries don't align and re-collide.
2310
+ const jitter = 1 + (Math.random() - 0.5) * 0.5;
2311
+ const wait = Math.max(500, Math.round(base * jitter));
2312
+ logger.info({ sessionKey, attempt, waitMs: wait, hintedRetryAfterMs: rateLimitRetryAfterMs }, 'Rate-limited — waiting before retry');
2170
2313
  await new Promise((r) => setTimeout(r, wait));
2314
+ rateLimitRetryAfterMs = null; // hint is per-attempt
2171
2315
  continue;
2172
2316
  }
2173
2317
  if (hitRateLimit && !responseText) {
@@ -3149,7 +3293,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3149
3293
  const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
3150
3294
  const cronAgentSlug = sdkOptions.env?.CLEMENTINE_TEAM_AGENT;
3151
3295
  const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
3152
- const matchedSkills = searchSkills(skillQuery, 2, cronAgentSlug || undefined);
3296
+ const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(cronAgentSlug || undefined);
3297
+ const matchedSkills = searchSkills(skillQuery, 2, cronAgentSlug || undefined, { suppressedNames });
3153
3298
  if (matchedSkills.length > 0) {
3154
3299
  const skillLines = matchedSkills.map(s => {
3155
3300
  recordSkillUse(s.name);
@@ -3511,7 +3656,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3511
3656
  const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
3512
3657
  const unleashedAgentSlug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
3513
3658
  const unleashedSkillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
3514
- const matchedSkills = searchSkills(unleashedSkillQuery, 2, unleashedAgentSlug);
3659
+ const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(unleashedAgentSlug);
3660
+ const matchedSkills = searchSkills(unleashedSkillQuery, 2, unleashedAgentSlug, { suppressedNames });
3515
3661
  if (matchedSkills.length > 0) {
3516
3662
  unleashedSkillContext = `\n\n## Learned Procedures\nFollow these proven approaches when applicable:\n\n` +
3517
3663
  matchedSkills.map(s => {
@@ -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).
@@ -57,7 +57,9 @@ export interface SkillMatch {
57
57
  attachments: string[];
58
58
  skillDir: string;
59
59
  }
60
- export declare function searchSkills(query: string, limit?: number, agentSlug?: string): SkillMatch[];
60
+ export declare function searchSkills(query: string, limit?: number, agentSlug?: string, opts?: {
61
+ suppressedNames?: Set<string>;
62
+ }): SkillMatch[];
61
63
  /** Record that a skill was used (bump use count). */
62
64
  export declare function recordSkillUse(skillName: string, agentSlug?: string): void;
63
65
  /** List all active skills (global + all agent-scoped). */
@@ -316,7 +316,7 @@ async function mergeSkill(assistant, existing, incoming) {
316
316
  return null;
317
317
  }
318
318
  }
319
- export function searchSkills(query, limit = 3, agentSlug) {
319
+ export function searchSkills(query, limit = 3, agentSlug, opts) {
320
320
  const dirs = [];
321
321
  // Agent-scoped skills get priority (boost=2)
322
322
  if (agentSlug) {
@@ -332,6 +332,7 @@ export function searchSkills(query, limit = 3, agentSlug) {
332
332
  const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
333
333
  const results = [];
334
334
  const seen = new Set();
335
+ const suppressed = opts?.suppressedNames;
335
336
  for (const { dir, boost } of dirs) {
336
337
  const files = readdirSync(dir).filter(f => f.endsWith('.md'));
337
338
  for (const file of files) {
@@ -339,6 +340,10 @@ export function searchSkills(query, limit = 3, agentSlug) {
339
340
  if (seen.has(name))
340
341
  continue;
341
342
  seen.add(name);
343
+ // Feedback-gated: skip skills that have been repeatedly associated with
344
+ // negative user feedback (see store.getSkillsToSuppress).
345
+ if (suppressed?.has(name))
346
+ continue;
342
347
  try {
343
348
  const raw = readFileSync(path.join(dir, file), 'utf-8');
344
349
  const parsed = matter(raw);
@@ -346,8 +351,13 @@ export function searchSkills(query, limit = 3, agentSlug) {
346
351
  const title = parsed.data.title ?? '';
347
352
  const description = parsed.data.description ?? '';
348
353
  // Score: trigger matches (high weight) + title/description word overlap + agent boost
354
+ // Filter non-string triggers defensively — YAML quirks like leading "##"
355
+ // parse as null and would crash toLowerCase(), causing the entire skill
356
+ // to be silently dropped by the outer catch. Skip them instead.
349
357
  let score = 0;
350
- const triggerLower = triggers.map(t => t.toLowerCase());
358
+ const triggerLower = triggers
359
+ .filter((t) => typeof t === 'string' && t.length > 0)
360
+ .map(t => t.toLowerCase());
351
361
  for (const word of queryWords) {
352
362
  for (const trigger of triggerLower) {
353
363
  if (trigger.includes(word) || word.includes(trigger))
@@ -59,7 +59,7 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
59
59
  app.error(async (error) => {
60
60
  logger.error({ err: error }, 'Slack app error — continuing');
61
61
  });
62
- app.message(async ({ message, client }) => {
62
+ app.message(async ({ message, client, context }) => {
63
63
  try {
64
64
  // Type guard: only handle regular user messages
65
65
  if (!('user' in message) || !('text' in message))
@@ -72,6 +72,10 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
72
72
  if (slackBotManager?.getOwnedChannelIds().includes(message.channel))
73
73
  return;
74
74
  const userId = message.user;
75
+ // Slack user IDs are scoped per-workspace, so a bare `slack:user:{uid}`
76
+ // collides across workspaces. Namespace by team/workspace ID so sessions
77
+ // stay isolated even when the same bot is installed in multiple workspaces.
78
+ const teamId = context.teamId ?? (await client.auth.test().then(r => r.team_id).catch(() => 'unknown'));
75
79
  // Owner-only check
76
80
  if (SLACK_OWNER_USER_ID && userId !== SLACK_OWNER_USER_ID) {
77
81
  logger.warn(`Ignored Slack message from non-owner: ${userId}`);
@@ -93,7 +97,7 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
93
97
  return;
94
98
  const channel = message.channel;
95
99
  const threadTs = ('thread_ts' in message ? message.thread_ts : undefined) ?? message.ts;
96
- const sessionKey = `slack:user:${userId}`;
100
+ const sessionKey = `slack:team:${teamId}:user:${userId}`;
97
101
  // ── !stop — abort active query (bypasses session lock) ────────────
98
102
  if (text === '!stop' || text === '/stop') {
99
103
  const stopped = gateway.stopSession(sessionKey);
@@ -60,6 +60,7 @@ export declare class CronScheduler {
60
60
  private disabledJobs;
61
61
  private scheduledTasks;
62
62
  private runningJobs;
63
+ private runMetadata;
63
64
  private completedJobs;
64
65
  private watching;
65
66
  readonly runLog: CronRunLog;
@@ -71,7 +72,21 @@ export declare class CronScheduler {
71
72
  private goalTriggerDir;
72
73
  private triggerTimer;
73
74
  private statusChangeListeners;
75
+ private static readonly RUNNING_JOBS_FILE;
74
76
  constructor(gateway: Gateway, dispatcher: NotificationDispatcher);
77
+ /**
78
+ * Atomically persist the current runningJobs set to disk. Uses write-then-
79
+ * rename so a crash mid-write cannot corrupt the file.
80
+ */
81
+ private persistRunningJobs;
82
+ /**
83
+ * On startup, read the persisted running-jobs file. Any entries present
84
+ * represent jobs interrupted by a previous crash. Surface each to audit.jsonl
85
+ * and clear the file. Deliberately do NOT auto-restart — the next scheduled
86
+ * tick handles it, avoiding duplicate external side effects (emails sent,
87
+ * commits pushed, etc.) from a partial prior run.
88
+ */
89
+ private reconcileInterruptedJobs;
75
90
  /** Load job definitions from CRON.md and agent dirs without scheduling tasks. */
76
91
  private loadJobDefinitions;
77
92
  /** Register a listener that fires when system state changes (job start/finish, self-improve, etc). */
@@ -7,7 +7,7 @@
7
7
  * retry helpers, CronRunLog, and daily-note logging utilities used by both schedulers.
8
8
  */
9
9
  import { execSync } from 'node:child_process';
10
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, watchFile, unwatchFile, writeFileSync, } from 'node:fs';
10
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, watchFile, unwatchFile, writeFileSync, } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import cron from 'node-cron';
13
13
  import matter from 'gray-matter';
@@ -17,6 +17,7 @@ import { listAllGoals, findGoalPath, readGoalById } from '../tools/shared.js';
17
17
  import { scanner } from '../security/scanner.js';
18
18
  import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
19
19
  import { SelfImproveLoop } from '../agent/self-improve.js';
20
+ import { logAuditJsonl } from '../agent/hooks.js';
20
21
  const logger = pino({ name: 'clementine.cron' });
21
22
  /** Default timeout for standard cron jobs (10 minutes). */
22
23
  const CRON_STANDARD_TIMEOUT_MS = 10 * 60 * 1000;
@@ -332,6 +333,7 @@ export class CronScheduler {
332
333
  disabledJobs = new Set();
333
334
  scheduledTasks = new Map();
334
335
  runningJobs = new Set();
336
+ runMetadata = new Map();
335
337
  completedJobs = new Map(); // jobName → completion timestamp
336
338
  watching = false;
337
339
  runLog;
@@ -346,6 +348,10 @@ export class CronScheduler {
346
348
  triggerTimer = null;
347
349
  // Event-driven status change listeners (used by Discord status embed)
348
350
  statusChangeListeners = [];
351
+ // Disk-backed mirror of runningJobs for crash-safe idempotency. If the
352
+ // daemon dies mid-run, startup reconciliation surfaces the interrupted job
353
+ // to audit.jsonl and clears the file so the next scheduled tick proceeds.
354
+ static RUNNING_JOBS_FILE = path.join(BASE_DIR, 'cron-running.json');
349
355
  constructor(gateway, dispatcher) {
350
356
  this.gateway = gateway;
351
357
  this.dispatcher = dispatcher;
@@ -355,6 +361,65 @@ export class CronScheduler {
355
361
  // query jobs on connect which happens before start().
356
362
  this.loadJobDefinitions();
357
363
  }
364
+ /**
365
+ * Atomically persist the current runningJobs set to disk. Uses write-then-
366
+ * rename so a crash mid-write cannot corrupt the file.
367
+ */
368
+ persistRunningJobs(metaByName) {
369
+ try {
370
+ const entries = [...this.runningJobs].map(name => ({
371
+ jobName: name,
372
+ startedAt: metaByName?.get(name)?.startedAt ?? new Date().toISOString(),
373
+ runId: metaByName?.get(name)?.runId ?? '',
374
+ pid: process.pid,
375
+ }));
376
+ const tmp = CronScheduler.RUNNING_JOBS_FILE + '.tmp';
377
+ writeFileSync(tmp, JSON.stringify(entries, null, 2));
378
+ renameSync(tmp, CronScheduler.RUNNING_JOBS_FILE);
379
+ }
380
+ catch (err) {
381
+ logger.debug({ err }, 'Failed to persist running-jobs file');
382
+ }
383
+ }
384
+ /**
385
+ * On startup, read the persisted running-jobs file. Any entries present
386
+ * represent jobs interrupted by a previous crash. Surface each to audit.jsonl
387
+ * and clear the file. Deliberately do NOT auto-restart — the next scheduled
388
+ * tick handles it, avoiding duplicate external side effects (emails sent,
389
+ * commits pushed, etc.) from a partial prior run.
390
+ */
391
+ reconcileInterruptedJobs() {
392
+ try {
393
+ if (!existsSync(CronScheduler.RUNNING_JOBS_FILE))
394
+ return;
395
+ const raw = readFileSync(CronScheduler.RUNNING_JOBS_FILE, 'utf-8');
396
+ const entries = JSON.parse(raw);
397
+ if (!Array.isArray(entries) || entries.length === 0) {
398
+ unlinkSync(CronScheduler.RUNNING_JOBS_FILE);
399
+ return;
400
+ }
401
+ const detectedAt = new Date().toISOString();
402
+ for (const entry of entries) {
403
+ logger.warn({ ...entry, detectedAt }, 'Interrupted cron job detected on startup');
404
+ logAuditJsonl({
405
+ event_type: 'cron_interrupted',
406
+ jobName: entry.jobName,
407
+ runId: entry.runId,
408
+ startedAt: entry.startedAt,
409
+ detectedAt,
410
+ previousPid: entry.pid,
411
+ });
412
+ }
413
+ unlinkSync(CronScheduler.RUNNING_JOBS_FILE);
414
+ }
415
+ catch (err) {
416
+ logger.warn({ err }, 'Failed to reconcile running-jobs file — starting fresh');
417
+ try {
418
+ unlinkSync(CronScheduler.RUNNING_JOBS_FILE);
419
+ }
420
+ catch { /* ignore */ }
421
+ }
422
+ }
358
423
  /** Load job definitions from CRON.md and agent dirs without scheduling tasks. */
359
424
  loadJobDefinitions() {
360
425
  this.jobs = parseCronJobs();
@@ -376,6 +441,9 @@ export class CronScheduler {
376
441
  }
377
442
  }
378
443
  start() {
444
+ // Surface any jobs that were mid-run when the daemon last died and clear
445
+ // the crash-consistency file before scheduling new ticks.
446
+ this.reconcileInterruptedJobs();
379
447
  this.reloadJobs();
380
448
  this.reloadWorkflows();
381
449
  this.watchCronFile();
@@ -800,6 +868,11 @@ export class CronScheduler {
800
868
  catch { /* non-fatal */ }
801
869
  }
802
870
  this.runningJobs.add(job.name);
871
+ this.runMetadata.set(job.name, {
872
+ startedAt: new Date().toISOString(),
873
+ runId: Math.random().toString(36).slice(2, 10),
874
+ });
875
+ this.persistRunningJobs(this.runMetadata);
803
876
  this.emitStatusChange();
804
877
  try {
805
878
  logger.info(`Running cron job: ${job.name}${job.agentSlug ? ` (agent: ${job.agentSlug})` : ''}`);
@@ -969,6 +1042,8 @@ export class CronScheduler {
969
1042
  }
970
1043
  finally {
971
1044
  this.runningJobs.delete(job.name);
1045
+ this.runMetadata.delete(job.name);
1046
+ this.persistRunningJobs(this.runMetadata);
972
1047
  this.emitStatusChange();
973
1048
  // Fire-and-forget: check if this agent's profile needs self-learning update
974
1049
  if (job.agentSlug) {
@@ -5,16 +5,36 @@
5
5
  * Retries up to 3 times on a 5-minute interval, then logs as permanently failed.
6
6
  */
7
7
  import type { NotificationContext } from '../types.js';
8
+ interface QueuedMessage {
9
+ text: string;
10
+ context?: NotificationContext;
11
+ attempts: number;
12
+ firstAttempt: string;
13
+ lastAttempt: string;
14
+ }
15
+ interface DlqEntry extends QueuedMessage {
16
+ failedAt: string;
17
+ reason: string;
18
+ }
8
19
  type SendFn = (text: string, context?: NotificationContext) => Promise<{
9
20
  delivered: boolean;
10
21
  }>;
22
+ type PermanentFailureFn = (entry: DlqEntry) => void | Promise<void>;
11
23
  export declare class DeliveryQueue {
12
24
  private queue;
25
+ private dlq;
13
26
  private timer;
14
27
  private sendFn;
28
+ private onPermanentFailure;
15
29
  constructor();
16
30
  /** Register the send function (from NotificationDispatcher). */
17
31
  setSender(fn: SendFn): void;
32
+ /**
33
+ * Register a callback invoked once per permanent failure (after MAX_ATTEMPTS).
34
+ * Wire this to an owner-alerting channel (Discord DM, email, etc.) so drops
35
+ * don't stay hidden in daily notes.
36
+ */
37
+ setOnPermanentFailure(fn: PermanentFailureFn): void;
18
38
  /** Start the retry drain loop. */
19
39
  start(): void;
20
40
  stop(): void;
@@ -23,8 +43,18 @@ export declare class DeliveryQueue {
23
43
  /** Drain the queue: retry each message, remove successes and expired items. */
24
44
  private drain;
25
45
  get size(): number;
46
+ /** Read-only snapshot of the DLQ (most recent first). */
47
+ getDlq(): DlqEntry[];
48
+ get dlqSize(): number;
49
+ /**
50
+ * Move DLQ entries back to the retry queue for another attempt. Returns the
51
+ * number of entries requeued. Intended for a dashboard "replay" button.
52
+ */
53
+ replayDlq(filter?: (entry: DlqEntry) => boolean): number;
26
54
  private load;
27
55
  private save;
56
+ private loadDlq;
57
+ private saveDlq;
28
58
  }
29
59
  export {};
30
60
  //# sourceMappingURL=delivery-queue.d.ts.map
@@ -11,19 +11,32 @@ import { BASE_DIR } from '../config.js';
11
11
  import { logToDailyNote } from './cron-scheduler.js';
12
12
  const logger = pino({ name: 'clementine.delivery-queue' });
13
13
  const QUEUE_FILE = path.join(BASE_DIR, 'delivery-queue.json');
14
+ const DLQ_FILE = path.join(BASE_DIR, 'delivery-dlq.json');
15
+ const DLQ_MAX_ENTRIES = 500;
14
16
  const MAX_ATTEMPTS = 3;
15
17
  const RETRY_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
16
18
  export class DeliveryQueue {
17
19
  queue = [];
20
+ dlq = [];
18
21
  timer = null;
19
22
  sendFn = null;
23
+ onPermanentFailure = null;
20
24
  constructor() {
21
25
  this.load();
26
+ this.loadDlq();
22
27
  }
23
28
  /** Register the send function (from NotificationDispatcher). */
24
29
  setSender(fn) {
25
30
  this.sendFn = fn;
26
31
  }
32
+ /**
33
+ * Register a callback invoked once per permanent failure (after MAX_ATTEMPTS).
34
+ * Wire this to an owner-alerting channel (Discord DM, email, etc.) so drops
35
+ * don't stay hidden in daily notes.
36
+ */
37
+ setOnPermanentFailure(fn) {
38
+ this.onPermanentFailure = fn;
39
+ }
27
40
  /** Start the retry drain loop. */
28
41
  start() {
29
42
  if (this.timer)
@@ -73,11 +86,28 @@ export class DeliveryQueue {
73
86
  logger.debug({ err }, 'Retry delivery attempt failed');
74
87
  }
75
88
  if (msg.attempts >= MAX_ATTEMPTS) {
76
- // Permanently failed — log to daily note so the user can find it
89
+ // Permanently failed — persist to DLQ for dashboard replay + surface to owner
77
90
  const preview = msg.text.slice(0, 100).replace(/\n/g, ' ');
91
+ const entry = {
92
+ ...msg,
93
+ failedAt: new Date().toISOString(),
94
+ reason: 'max_attempts_exceeded',
95
+ };
96
+ this.dlq.push(entry);
97
+ if (this.dlq.length > DLQ_MAX_ENTRIES)
98
+ this.dlq = this.dlq.slice(-DLQ_MAX_ENTRIES);
99
+ this.saveDlq();
78
100
  logToDailyNote(`**[Delivery permanently failed]** (${msg.attempts} attempts): ${preview}`);
79
- logger.warn({ attempts: msg.attempts, preview }, 'Message permanently failed delivery — logged to daily note');
80
- continue; // drop from queue
101
+ logger.warn({ attempts: msg.attempts, preview, dlqSize: this.dlq.length }, 'Message permanently failed delivery — moved to DLQ');
102
+ if (this.onPermanentFailure) {
103
+ try {
104
+ await this.onPermanentFailure(entry);
105
+ }
106
+ catch (err) {
107
+ logger.debug({ err }, 'Permanent-failure hook threw');
108
+ }
109
+ }
110
+ continue; // drop from retry queue
81
111
  }
82
112
  remaining.push(msg);
83
113
  }
@@ -87,6 +117,37 @@ export class DeliveryQueue {
87
117
  get size() {
88
118
  return this.queue.length;
89
119
  }
120
+ /** Read-only snapshot of the DLQ (most recent first). */
121
+ getDlq() {
122
+ return [...this.dlq].reverse();
123
+ }
124
+ get dlqSize() {
125
+ return this.dlq.length;
126
+ }
127
+ /**
128
+ * Move DLQ entries back to the retry queue for another attempt. Returns the
129
+ * number of entries requeued. Intended for a dashboard "replay" button.
130
+ */
131
+ replayDlq(filter) {
132
+ if (this.dlq.length === 0)
133
+ return 0;
134
+ const now = new Date().toISOString();
135
+ const toReplay = filter ? this.dlq.filter(filter) : [...this.dlq];
136
+ for (const entry of toReplay) {
137
+ this.queue.push({
138
+ text: entry.text,
139
+ context: entry.context,
140
+ attempts: 0,
141
+ firstAttempt: now,
142
+ lastAttempt: now,
143
+ });
144
+ }
145
+ this.dlq = filter ? this.dlq.filter(e => !filter(e)) : [];
146
+ this.save();
147
+ this.saveDlq();
148
+ logger.info({ replayed: toReplay.length, queueSize: this.queue.length }, 'DLQ entries replayed');
149
+ return toReplay.length;
150
+ }
90
151
  load() {
91
152
  if (!existsSync(QUEUE_FILE))
92
153
  return;
@@ -106,5 +167,24 @@ export class DeliveryQueue {
106
167
  logger.debug({ err }, 'Failed to persist delivery queue');
107
168
  }
108
169
  }
170
+ loadDlq() {
171
+ if (!existsSync(DLQ_FILE))
172
+ return;
173
+ try {
174
+ this.dlq = JSON.parse(readFileSync(DLQ_FILE, 'utf-8'));
175
+ }
176
+ catch {
177
+ logger.warn('Failed to parse DLQ file — starting fresh');
178
+ this.dlq = [];
179
+ }
180
+ }
181
+ saveDlq() {
182
+ try {
183
+ writeFileSync(DLQ_FILE, JSON.stringify(this.dlq, null, 2));
184
+ }
185
+ catch (err) {
186
+ logger.debug({ err }, 'Failed to persist DLQ');
187
+ }
188
+ }
109
189
  }
110
190
  //# sourceMappingURL=delivery-queue.js.map
@@ -143,6 +143,7 @@ export declare class Gateway {
143
143
  */
144
144
  private acquireSessionLock;
145
145
  handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback): Promise<string>;
146
+ private _handleMessageInner;
146
147
  handleHeartbeat(standingInstructions: string, changesSummary?: string, timeContext?: string, dedupContext?: string, profile?: import('../types.js').AgentProfile | null): Promise<string>;
147
148
  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>;
148
149
  /**
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import pino from 'pino';
10
10
  import { PersonalAssistant } from '../agent/assistant.js';
11
+ import { runWithTrace, logAuditJsonl } from '../agent/hooks.js';
11
12
  import { SelfImproveLoop } from '../agent/self-improve.js';
12
13
  import { MODELS, PROFILES_DIR, AGENTS_DIR, TEAM_COMMS_LOG, BASE_DIR, SEEN_CHANNELS_FILE } from '../config.js';
13
14
  import { scanner } from '../security/scanner.js';
@@ -681,6 +682,43 @@ export class Gateway {
681
682
  if (this.draining) {
682
683
  return "I'm restarting momentarily — your message will be processed after I'm back online.";
683
684
  }
685
+ // Derive channel label for the trace tag. Mirrors deriveChannel() in the
686
+ // agent layer but kept small here so the router stays independent.
687
+ const channelForTrace = sessionKey.startsWith('discord:user:') ? 'Discord DM'
688
+ : sessionKey.startsWith('discord:channel:') ? 'Discord channel'
689
+ : sessionKey.startsWith('slack:') ? 'Slack'
690
+ : sessionKey.startsWith('telegram:') ? 'Telegram'
691
+ : sessionKey.startsWith('whatsapp:') ? 'WhatsApp'
692
+ : sessionKey.startsWith('webhook:') ? 'webhook'
693
+ : sessionKey.startsWith('dashboard:') ? 'dashboard'
694
+ : 'direct';
695
+ const traceStart = Date.now();
696
+ return runWithTrace({ session_id: sessionKey, channel: channelForTrace }, async () => {
697
+ logAuditJsonl({
698
+ event_type: 'message_received',
699
+ text_preview: text.slice(0, 120),
700
+ text_len: text.length,
701
+ });
702
+ try {
703
+ const result = await this._handleMessageInner(sessionKey, text, onText, model, maxTurns, onToolActivity);
704
+ logAuditJsonl({
705
+ event_type: 'message_completed',
706
+ duration_ms: Date.now() - traceStart,
707
+ response_len: result.length,
708
+ });
709
+ return result;
710
+ }
711
+ catch (err) {
712
+ logAuditJsonl({
713
+ event_type: 'message_failed',
714
+ duration_ms: Date.now() - traceStart,
715
+ error: String(err).slice(0, 300),
716
+ });
717
+ throw err;
718
+ }
719
+ });
720
+ }
721
+ async _handleMessageInner(sessionKey, text, onText, model, maxTurns, onToolActivity) {
684
722
  // ── Auth circuit breaker — stop spamming error messages ────────
685
723
  if (this.authCircuitOpen) {
686
724
  if (!this.shouldProbeAuth()) {
@@ -710,6 +748,8 @@ export class Gateway {
710
748
  const isOwnerDm = sessionKey.startsWith('discord:user:') ||
711
749
  sessionKey.startsWith('discord:agent:') ||
712
750
  sessionKey.startsWith('slack:dm:') ||
751
+ // New workspace-namespaced Slack DMs: slack:team:{teamId}:user:{userId}
752
+ /^slack:team:[^:]+:(user|dm):/.test(sessionKey) ||
713
753
  sessionKey.startsWith('telegram:');
714
754
  const shouldBlock = scan.verdict === 'block' && !isOwnerDm;
715
755
  if (shouldBlock) {
@@ -1270,6 +1310,8 @@ export class Gateway {
1270
1310
  const isOwnerDm = sessionKey.startsWith('discord:user:') ||
1271
1311
  sessionKey.startsWith('discord:agent:') ||
1272
1312
  sessionKey.startsWith('slack:dm:') ||
1313
+ // New workspace-namespaced Slack DMs: slack:team:{teamId}:user:{userId}
1314
+ /^slack:team:[^:]+:(user|dm):/.test(sessionKey) ||
1273
1315
  sessionKey.startsWith('telegram:');
1274
1316
  const shouldBlock = scan.verdict === 'block' && !isOwnerDm;
1275
1317
  if (shouldBlock) {
@@ -252,6 +252,13 @@ export declare class MemoryStore {
252
252
  * Get recent feedback entries.
253
253
  */
254
254
  getRecentFeedback(limit?: number): Feedback[];
255
+ /**
256
+ * Skills to suppress from retrieval: those that coincide with negative feedback
257
+ * in ≥3 sessions and whose negative rate exceeds 50% of rated sessions.
258
+ * Attribution is by session_key join; a feedback entry is credited to every
259
+ * skill retrieved in that session. Window: last 60 days.
260
+ */
261
+ getSkillsToSuppress(agentSlug?: string): Set<string>;
255
262
  /**
256
263
  * Get aggregate feedback statistics.
257
264
  */
@@ -1465,6 +1465,37 @@ export class MemoryStore {
1465
1465
  createdAt: row.created_at,
1466
1466
  }));
1467
1467
  }
1468
+ /**
1469
+ * Skills to suppress from retrieval: those that coincide with negative feedback
1470
+ * in ≥3 sessions and whose negative rate exceeds 50% of rated sessions.
1471
+ * Attribution is by session_key join; a feedback entry is credited to every
1472
+ * skill retrieved in that session. Window: last 60 days.
1473
+ */
1474
+ getSkillsToSuppress(agentSlug) {
1475
+ const suppressed = new Set();
1476
+ try {
1477
+ const sql = `
1478
+ SELECT su.skill_name,
1479
+ SUM(CASE WHEN f.rating = 'negative' THEN 1 ELSE 0 END) AS negative,
1480
+ SUM(CASE WHEN f.rating = 'positive' THEN 1 ELSE 0 END) AS positive,
1481
+ COUNT(DISTINCT f.id) AS total
1482
+ FROM skill_usage su
1483
+ JOIN feedback f ON f.session_key = su.session_key
1484
+ WHERE su.retrieved_at >= datetime('now', '-60 days')
1485
+ AND f.created_at >= su.retrieved_at
1486
+ ${agentSlug ? 'AND su.agent_slug = ?' : ''}
1487
+ GROUP BY su.skill_name
1488
+ HAVING negative >= 3 AND negative * 2 > total
1489
+ `;
1490
+ const rows = this.conn.prepare(sql).all(...(agentSlug ? [agentSlug] : []));
1491
+ for (const r of rows)
1492
+ suppressed.add(r.skill_name);
1493
+ }
1494
+ catch {
1495
+ // skill_usage or feedback tables may be empty / legacy — return empty set
1496
+ }
1497
+ return suppressed;
1498
+ }
1468
1499
  /**
1469
1500
  * Get aggregate feedback statistics.
1470
1501
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",