clementine-agent 1.0.27 → 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
@@ -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
@@ -1426,7 +1533,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1426
1533
  (async () => {
1427
1534
  try {
1428
1535
  const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
1429
- const matchedSkills = searchSkills(enrichedQuery, 2, agentSlug || undefined);
1536
+ const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(agentSlug || undefined);
1537
+ const matchedSkills = searchSkills(enrichedQuery, 2, agentSlug || undefined, { suppressedNames });
1430
1538
  if (matchedSkills.length > 0) {
1431
1539
  return `## Relevant Procedures (from past successful executions)\n\n` +
1432
1540
  matchedSkills.map(s => {
@@ -1913,6 +2021,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1913
2021
  let responseText = '';
1914
2022
  let sessionId = '';
1915
2023
  let hitRateLimit = false;
2024
+ let rateLimitRetryAfterMs = null;
1916
2025
  let staleSession = false;
1917
2026
  let contextRecovery = false;
1918
2027
  let lastAssistantBlocks = [];
@@ -2083,6 +2192,16 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2083
2192
  }
2084
2193
  else if (errStr.includes('rate') && (errStr.includes('limit') || errStr.includes('rate_limit'))) {
2085
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
+ }
2086
2205
  }
2087
2206
  else if (errStr.includes('autocompact') || errStr.includes('thrash') || errStr.includes('context refilled to the limit')) {
2088
2207
  // SDK autocompact thrashing — tool outputs are too large for the context window.
@@ -2166,8 +2285,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2166
2285
  continue;
2167
2286
  }
2168
2287
  if (hitRateLimit && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
2169
- 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');
2170
2294
  await new Promise((r) => setTimeout(r, wait));
2295
+ rateLimitRetryAfterMs = null; // hint is per-attempt
2171
2296
  continue;
2172
2297
  }
2173
2298
  if (hitRateLimit && !responseText) {
@@ -3149,7 +3274,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3149
3274
  const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
3150
3275
  const cronAgentSlug = sdkOptions.env?.CLEMENTINE_TEAM_AGENT;
3151
3276
  const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
3152
- const matchedSkills = searchSkills(skillQuery, 2, cronAgentSlug || undefined);
3277
+ const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(cronAgentSlug || undefined);
3278
+ const matchedSkills = searchSkills(skillQuery, 2, cronAgentSlug || undefined, { suppressedNames });
3153
3279
  if (matchedSkills.length > 0) {
3154
3280
  const skillLines = matchedSkills.map(s => {
3155
3281
  recordSkillUse(s.name);
@@ -3511,7 +3637,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3511
3637
  const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
3512
3638
  const unleashedAgentSlug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
3513
3639
  const unleashedSkillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
3514
- const matchedSkills = searchSkills(unleashedSkillQuery, 2, unleashedAgentSlug);
3640
+ const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(unleashedAgentSlug);
3641
+ const matchedSkills = searchSkills(unleashedSkillQuery, 2, unleashedAgentSlug, { suppressedNames });
3515
3642
  if (matchedSkills.length > 0) {
3516
3643
  unleashedSkillContext = `\n\n## Learned Procedures\nFollow these proven approaches when applicable:\n\n` +
3517
3644
  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))
@@ -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()) {
@@ -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.28",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",