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.
- package/dist/agent/assistant.d.ts +7 -0
- package/dist/agent/assistant.js +168 -41
- package/dist/agent/hooks.d.ts +38 -0
- package/dist/agent/hooks.js +76 -10
- package/dist/agent/skill-extractor.d.ts +3 -1
- package/dist/agent/skill-extractor.js +12 -2
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +83 -3
- package/dist/gateway/router.d.ts +1 -0
- package/dist/gateway/router.js +38 -0
- package/dist/memory/store.d.ts +7 -0
- package/dist/memory/store.js +31 -0
- package/package.json +1 -1
|
@@ -79,6 +79,13 @@ export declare class PersonalAssistant {
|
|
|
79
79
|
/** Inject a background work result into the session so the next chat naturally references it. */
|
|
80
80
|
injectPendingContext(sessionKey: string, userPrompt: string, result: string): void;
|
|
81
81
|
private initMemoryStore;
|
|
82
|
+
/**
|
|
83
|
+
* Seed the in-memory hotCorrections ring buffer from persisted behavioral
|
|
84
|
+
* patterns (corrections that recurred across ≥2 sessions in the last 30d).
|
|
85
|
+
* Without this, daemon restarts would wipe the prompt-injected corrections
|
|
86
|
+
* until they reoccurred live.
|
|
87
|
+
*/
|
|
88
|
+
private primeHotCorrections;
|
|
82
89
|
private loadSessions;
|
|
83
90
|
/**
|
|
84
91
|
* Schedule a debounced session persist. Multiple calls within 500ms collapse
|
package/dist/agent/assistant.js
CHANGED
|
@@ -15,7 +15,7 @@ import { query as rawQuery, listSubagents, getSubagentMessages, } from '@anthrop
|
|
|
15
15
|
import pino from 'pino';
|
|
16
16
|
import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, PROFILES_DIR, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, ENABLE_1M_CONTEXT, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
|
|
17
17
|
import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
|
|
18
|
-
import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, } from './hooks.js';
|
|
18
|
+
import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, logAuditJsonl, } from './hooks.js';
|
|
19
19
|
import { scanner } from '../security/scanner.js';
|
|
20
20
|
import { agentWorkingMemoryFile, listAllGoals } from '../tools/shared.js';
|
|
21
21
|
import { AgentManager } from './agent-manager.js';
|
|
@@ -84,6 +84,64 @@ function formatCapabilities(caps) {
|
|
|
84
84
|
features.push(`max ${caps.maxMessageLength} chars/message`);
|
|
85
85
|
return features.length > 0 ? features.join(', ') : 'text only';
|
|
86
86
|
}
|
|
87
|
+
/** Derive the human-readable channel label from a session key. */
|
|
88
|
+
function deriveChannel(opts) {
|
|
89
|
+
const { sessionKey, isAutonomous, cronTier } = opts;
|
|
90
|
+
if (isAutonomous)
|
|
91
|
+
return cronTier != null ? 'cron' : 'heartbeat';
|
|
92
|
+
if (!sessionKey)
|
|
93
|
+
return 'unknown';
|
|
94
|
+
if (sessionKey.startsWith('discord:user:'))
|
|
95
|
+
return 'Discord DM';
|
|
96
|
+
if (sessionKey.startsWith('discord:channel:'))
|
|
97
|
+
return 'Discord channel';
|
|
98
|
+
if (sessionKey.startsWith('slack:'))
|
|
99
|
+
return 'Slack';
|
|
100
|
+
if (sessionKey.startsWith('telegram:'))
|
|
101
|
+
return 'Telegram';
|
|
102
|
+
if (sessionKey.startsWith('whatsapp:'))
|
|
103
|
+
return 'WhatsApp';
|
|
104
|
+
if (sessionKey.startsWith('webhook:'))
|
|
105
|
+
return 'webhook';
|
|
106
|
+
return 'direct';
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Per-channel tool deny list. Narrows what the agent can invoke based on the
|
|
110
|
+
* surface area of the channel — e.g. a public Discord channel shouldn't execute
|
|
111
|
+
* shell commands on the owner's box, and SMS/WhatsApp shouldn't touch the
|
|
112
|
+
* filesystem. Owner-direct surfaces (Discord DM, dashboard, direct CLI) get the
|
|
113
|
+
* full toolset.
|
|
114
|
+
*
|
|
115
|
+
* Returned tools are added to the SDK's `disallowedTools`. Denial is strict —
|
|
116
|
+
* it overrides the positive allowlist in buildOptions.
|
|
117
|
+
*/
|
|
118
|
+
function getChannelToolDenyList(channel) {
|
|
119
|
+
const CODE_EXEC = ['Bash', 'Write', 'Edit'];
|
|
120
|
+
const SHARED_DENY = [...CODE_EXEC];
|
|
121
|
+
const SMS_DENY = [
|
|
122
|
+
...CODE_EXEC,
|
|
123
|
+
mcpTool('browser_screenshot'),
|
|
124
|
+
mcpTool('github_prs'),
|
|
125
|
+
mcpTool('rss_fetch'),
|
|
126
|
+
mcpTool('web_search'),
|
|
127
|
+
mcpTool('analyze_image'),
|
|
128
|
+
mcpTool('self_restart'),
|
|
129
|
+
mcpTool('update_self'),
|
|
130
|
+
];
|
|
131
|
+
switch (channel) {
|
|
132
|
+
case 'Discord channel':
|
|
133
|
+
case 'Slack':
|
|
134
|
+
return SHARED_DENY;
|
|
135
|
+
case 'WhatsApp':
|
|
136
|
+
case 'Telegram':
|
|
137
|
+
return SMS_DENY;
|
|
138
|
+
case 'webhook':
|
|
139
|
+
return SMS_DENY;
|
|
140
|
+
default:
|
|
141
|
+
// Discord DM (owner), direct, dashboard:web, autonomous, unknown → full tools.
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
87
145
|
// ── Token estimation & context window guard ─────────────────────────
|
|
88
146
|
/**
|
|
89
147
|
* Estimate token count using a weighted heuristic.
|
|
@@ -575,6 +633,19 @@ export class PersonalAssistant {
|
|
|
575
633
|
// ── Shared stream helpers ──────────────────────────────────────────
|
|
576
634
|
/** Log SDK result metrics and store usage. Shared across all query methods. */
|
|
577
635
|
logQueryResult(result, source, sessionKey, label, agentSlug) {
|
|
636
|
+
// Aggregate cache stats across all models used this turn
|
|
637
|
+
let cacheRead = 0;
|
|
638
|
+
let cacheCreation = 0;
|
|
639
|
+
let inputTokens = 0;
|
|
640
|
+
if (result.modelUsage) {
|
|
641
|
+
for (const usage of Object.values(result.modelUsage)) {
|
|
642
|
+
cacheRead += usage.cacheReadInputTokens ?? 0;
|
|
643
|
+
cacheCreation += usage.cacheCreationInputTokens ?? 0;
|
|
644
|
+
inputTokens += usage.inputTokens ?? 0;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const cacheDenominator = inputTokens + cacheRead + cacheCreation;
|
|
648
|
+
const cacheHitRate = cacheDenominator > 0 ? cacheRead / cacheDenominator : 0;
|
|
578
649
|
if ('total_cost_usd' in result) {
|
|
579
650
|
logger.info({
|
|
580
651
|
...(label ? { job: label } : {}),
|
|
@@ -582,7 +653,23 @@ export class PersonalAssistant {
|
|
|
582
653
|
cost_usd: result.total_cost_usd,
|
|
583
654
|
num_turns: result.num_turns,
|
|
584
655
|
duration_ms: result.duration_ms,
|
|
656
|
+
cache_read_tokens: cacheRead,
|
|
657
|
+
cache_creation_tokens: cacheCreation,
|
|
658
|
+
cache_hit_rate: Number(cacheHitRate.toFixed(3)),
|
|
585
659
|
}, `${source} query completed`);
|
|
660
|
+
logAuditJsonl({
|
|
661
|
+
event_type: 'query_complete',
|
|
662
|
+
source,
|
|
663
|
+
agent_slug: agentSlug,
|
|
664
|
+
job: label,
|
|
665
|
+
cost_usd: result.total_cost_usd,
|
|
666
|
+
num_turns: result.num_turns,
|
|
667
|
+
duration_ms: result.duration_ms,
|
|
668
|
+
tokens_in: inputTokens,
|
|
669
|
+
cache_read_tokens: cacheRead,
|
|
670
|
+
cache_creation_tokens: cacheCreation,
|
|
671
|
+
cache_hit_rate: Number(cacheHitRate.toFixed(3)),
|
|
672
|
+
});
|
|
586
673
|
}
|
|
587
674
|
if (this.memoryStore && result.modelUsage) {
|
|
588
675
|
try {
|
|
@@ -638,11 +725,39 @@ export class PersonalAssistant {
|
|
|
638
725
|
const { MEMORY_DB_PATH } = await import('../config.js');
|
|
639
726
|
this.memoryStore = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
|
|
640
727
|
this.memoryStore.initialize();
|
|
728
|
+
this.primeHotCorrections();
|
|
641
729
|
}
|
|
642
730
|
catch (err) {
|
|
643
731
|
logger.warn({ err }, 'Memory store init failed — falling back to static prompts');
|
|
644
732
|
}
|
|
645
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* Seed the in-memory hotCorrections ring buffer from persisted behavioral
|
|
736
|
+
* patterns (corrections that recurred across ≥2 sessions in the last 30d).
|
|
737
|
+
* Without this, daemon restarts would wipe the prompt-injected corrections
|
|
738
|
+
* until they reoccurred live.
|
|
739
|
+
*/
|
|
740
|
+
primeHotCorrections() {
|
|
741
|
+
if (!this.memoryStore)
|
|
742
|
+
return;
|
|
743
|
+
try {
|
|
744
|
+
const patterns = this.memoryStore.getBehavioralPatterns(2);
|
|
745
|
+
const now = new Date().toISOString();
|
|
746
|
+
for (const p of patterns.slice(0, 10)) {
|
|
747
|
+
this.hotCorrections.push({
|
|
748
|
+
correction: p.correction,
|
|
749
|
+
category: p.category,
|
|
750
|
+
timestamp: now,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
if (patterns.length > 0) {
|
|
754
|
+
logger.info({ primed: Math.min(patterns.length, 10) }, 'Primed hot corrections from behavioral patterns');
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
logger.warn({ err }, 'Priming hot corrections failed');
|
|
759
|
+
}
|
|
760
|
+
}
|
|
646
761
|
// ── Session Persistence ───────────────────────────────────────────
|
|
647
762
|
loadSessions() {
|
|
648
763
|
if (!fs.existsSync(SESSIONS_FILE))
|
|
@@ -864,40 +979,6 @@ export class PersonalAssistant {
|
|
|
864
979
|
}
|
|
865
980
|
}
|
|
866
981
|
}
|
|
867
|
-
const now = new Date();
|
|
868
|
-
// Derive channel label from session key
|
|
869
|
-
let channel = 'unknown';
|
|
870
|
-
if (isAutonomous) {
|
|
871
|
-
channel = cronTier !== null ? 'cron' : 'heartbeat';
|
|
872
|
-
}
|
|
873
|
-
else if (sessionKey) {
|
|
874
|
-
if (sessionKey.startsWith('discord:user:'))
|
|
875
|
-
channel = 'Discord DM';
|
|
876
|
-
else if (sessionKey.startsWith('discord:channel:'))
|
|
877
|
-
channel = 'Discord channel';
|
|
878
|
-
else if (sessionKey.startsWith('slack:'))
|
|
879
|
-
channel = 'Slack';
|
|
880
|
-
else if (sessionKey.startsWith('telegram:'))
|
|
881
|
-
channel = 'Telegram';
|
|
882
|
-
else if (sessionKey.startsWith('whatsapp:'))
|
|
883
|
-
channel = 'WhatsApp';
|
|
884
|
-
else if (sessionKey.startsWith('webhook:'))
|
|
885
|
-
channel = 'webhook';
|
|
886
|
-
else
|
|
887
|
-
channel = 'direct';
|
|
888
|
-
}
|
|
889
|
-
const resolvedModel = resolveModel(model) ?? MODEL;
|
|
890
|
-
const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
|
|
891
|
-
const caps = !isAutonomous ? getChannelCapabilities(channel) : null;
|
|
892
|
-
parts.push(`## Current Context
|
|
893
|
-
|
|
894
|
-
- **Date:** ${formatDate(now)}
|
|
895
|
-
- **Time:** ${formatTime(now)}
|
|
896
|
-
- **Timezone:** ${Intl.DateTimeFormat().resolvedOptions().timeZone}
|
|
897
|
-
- **Channel:** ${channel}${caps ? ` (${formatCapabilities(caps)})` : ''}
|
|
898
|
-
- **Model:** ${modelLabel} (${resolvedModel})
|
|
899
|
-
- **Vault:** ${vault}
|
|
900
|
-
`);
|
|
901
982
|
if (isAutonomous) {
|
|
902
983
|
// Minimal vault reference for heartbeats/cron — they know their tools
|
|
903
984
|
parts.push(`Vault: \`${vault}\`. Key files: MEMORY.md, ${todayISO()}.md (today), TASKS.md. Use MCP tools (memory_read/write, task_list/add/update, note_take).`);
|
|
@@ -979,7 +1060,8 @@ Never spawn a sub-agent with vague instructions like "handle this brief" — tel
|
|
|
979
1060
|
// Proactive skill injection: match user message against skill triggers
|
|
980
1061
|
if (this._lastUserMessage && !isAutonomous) {
|
|
981
1062
|
try {
|
|
982
|
-
const
|
|
1063
|
+
const suppressedNames = this.memoryStore?.getSkillsToSuppress?.(profile?.slug);
|
|
1064
|
+
const matchedSkills = searchSkillsSync(this._lastUserMessage, 1, profile?.slug, { suppressedNames });
|
|
983
1065
|
if (matchedSkills.length > 0 && matchedSkills[0].score >= 4) {
|
|
984
1066
|
const skill = matchedSkills[0];
|
|
985
1067
|
this.memoryStore?.logSkillUse?.({
|
|
@@ -1153,6 +1235,21 @@ If you're stuck after reading several files, tell ${owner} what's blocking you.
|
|
|
1153
1235
|
You have a cost budget per message — not a hard turn limit. Work until the task is done. For long tasks (10+ tool calls), narrate progress as you go so ${owner} can see you're making headway. If a task needs many database queries, keep result sets small (LIMIT 20) to avoid filling context.`);
|
|
1154
1236
|
}
|
|
1155
1237
|
// Security rules are now appended to systemPrompt in buildOptions()
|
|
1238
|
+
// Volatile suffix — put last so the stable prefix above stays cache-friendly.
|
|
1239
|
+
const channel = deriveChannel({ sessionKey, isAutonomous, cronTier });
|
|
1240
|
+
const resolvedModel = resolveModel(model) ?? MODEL;
|
|
1241
|
+
const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
|
|
1242
|
+
const caps = !isAutonomous ? getChannelCapabilities(channel) : null;
|
|
1243
|
+
const now = new Date();
|
|
1244
|
+
parts.push(`## Current Context
|
|
1245
|
+
|
|
1246
|
+
- **Date:** ${formatDate(now)}
|
|
1247
|
+
- **Time:** ${formatTime(now)}
|
|
1248
|
+
- **Timezone:** ${Intl.DateTimeFormat().resolvedOptions().timeZone}
|
|
1249
|
+
- **Channel:** ${channel}${caps ? ` (${formatCapabilities(caps)})` : ''}
|
|
1250
|
+
- **Model:** ${modelLabel} (${resolvedModel})
|
|
1251
|
+
- **Vault:** ${vault}
|
|
1252
|
+
`);
|
|
1156
1253
|
return parts.join('\n\n---\n\n');
|
|
1157
1254
|
}
|
|
1158
1255
|
// ── Build SDK Options ─────────────────────────────────────────────
|
|
@@ -1271,8 +1368,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1271
1368
|
// Cron tier 1 gets heartbeat restrictions (read-only + vault writes).
|
|
1272
1369
|
const isCron = cronTier !== null;
|
|
1273
1370
|
const disallowed = isHeartbeat && (!isCron || (cronTier ?? 0) < 2)
|
|
1274
|
-
? getHeartbeatDisallowedTools()
|
|
1371
|
+
? [...getHeartbeatDisallowedTools()]
|
|
1275
1372
|
: [];
|
|
1373
|
+
// Per-channel tool scoping: narrow tools for surfaces where destructive
|
|
1374
|
+
// operations shouldn't happen (public Discord/Slack channels, SMS-like
|
|
1375
|
+
// channels, webhooks). Owner DMs + dashboard keep the full toolset.
|
|
1376
|
+
const channelForScoping = deriveChannel({ sessionKey, isAutonomous: isHeartbeat || isCron, cronTier });
|
|
1377
|
+
const channelDeny = getChannelToolDenyList(channelForScoping);
|
|
1378
|
+
if (channelDeny.length > 0) {
|
|
1379
|
+
for (const t of channelDeny)
|
|
1380
|
+
if (!disallowed.includes(t))
|
|
1381
|
+
disallowed.push(t);
|
|
1382
|
+
}
|
|
1276
1383
|
// Cron/heartbeat get turn limits. Interactive chat has no turn cap —
|
|
1277
1384
|
// cost budget (maxBudgetUsd) is the primary guardrail.
|
|
1278
1385
|
const effectiveMaxTurns = maxTurns
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 => {
|
package/dist/agent/hooks.d.ts
CHANGED
|
@@ -9,6 +9,44 @@
|
|
|
9
9
|
* - Audit logging: persistent file + in-memory buffer
|
|
10
10
|
*/
|
|
11
11
|
import type { SendPolicy } from '../types.js';
|
|
12
|
+
export interface TraceContext {
|
|
13
|
+
trace_id: string;
|
|
14
|
+
session_id?: string;
|
|
15
|
+
channel?: string;
|
|
16
|
+
agent_slug?: string;
|
|
17
|
+
span_stack: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Run `fn` inside a trace context. Creates a new trace_id if none is supplied
|
|
21
|
+
* and inherited from an outer context. Nested calls push a span_id onto the
|
|
22
|
+
* stack so parent/child relationships survive async hops.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runWithTrace<T>(ctx: {
|
|
25
|
+
trace_id?: string;
|
|
26
|
+
session_id?: string;
|
|
27
|
+
channel?: string;
|
|
28
|
+
agent_slug?: string;
|
|
29
|
+
}, fn: () => Promise<T> | T): Promise<T> | T;
|
|
30
|
+
export declare function getTraceContext(): TraceContext | undefined;
|
|
31
|
+
export interface AuditEvent {
|
|
32
|
+
event_type: string;
|
|
33
|
+
tool_name?: string;
|
|
34
|
+
duration_ms?: number;
|
|
35
|
+
tokens_in?: number;
|
|
36
|
+
tokens_out?: number;
|
|
37
|
+
cache_read_tokens?: number;
|
|
38
|
+
cache_creation_tokens?: number;
|
|
39
|
+
cost_usd?: number;
|
|
40
|
+
num_turns?: number;
|
|
41
|
+
error?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Append a structured event to audit.jsonl with the current trace context.
|
|
46
|
+
* Runs alongside (not in place of) the legacy text audit.log so existing
|
|
47
|
+
* consumers keep working.
|
|
48
|
+
*/
|
|
49
|
+
export declare function logAuditJsonl(event: AuditEvent): void;
|
|
12
50
|
export declare function setHeartbeatMode(active: boolean, tier2Allowed?: boolean): void;
|
|
13
51
|
export declare function setApprovalCallback(cb: ((desc: string) => Promise<boolean>) | null): void;
|
|
14
52
|
export declare function setProfileTier(tier: number | null): void;
|
package/dist/agent/hooks.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
13
15
|
import { OWNER_NAME, BASE_DIR, TIMEZONE } from '../config.js';
|
|
14
16
|
// ── Shared state ───────────────────────────────────────────────────────
|
|
15
17
|
let heartbeatActive = false;
|
|
@@ -34,19 +36,27 @@ let interactionSource = 'autonomous';
|
|
|
34
36
|
const logsDir = path.join(BASE_DIR, 'logs');
|
|
35
37
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
36
38
|
const auditLogPath = path.join(logsDir, 'audit.log');
|
|
39
|
+
const auditJsonlPath = path.join(logsDir, 'audit.jsonl');
|
|
37
40
|
const MAX_AUDIT_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
41
|
+
function rotateIfLarge(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(filePath))
|
|
44
|
+
return;
|
|
45
|
+
const stat = fs.statSync(filePath);
|
|
46
|
+
if (stat.size <= MAX_AUDIT_SIZE)
|
|
47
|
+
return;
|
|
48
|
+
const backup = filePath + '.1';
|
|
49
|
+
if (fs.existsSync(backup))
|
|
50
|
+
fs.unlinkSync(backup);
|
|
51
|
+
fs.renameSync(filePath, backup);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Non-fatal
|
|
55
|
+
}
|
|
56
|
+
}
|
|
38
57
|
function appendAuditFile(line) {
|
|
39
58
|
try {
|
|
40
|
-
|
|
41
|
-
if (fs.existsSync(auditLogPath)) {
|
|
42
|
-
const stat = fs.statSync(auditLogPath);
|
|
43
|
-
if (stat.size > MAX_AUDIT_SIZE) {
|
|
44
|
-
const backup = auditLogPath + '.1';
|
|
45
|
-
if (fs.existsSync(backup))
|
|
46
|
-
fs.unlinkSync(backup);
|
|
47
|
-
fs.renameSync(auditLogPath, backup);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
59
|
+
rotateIfLarge(auditLogPath);
|
|
50
60
|
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
51
61
|
fs.appendFileSync(auditLogPath, `${timestamp} ${line}\n`);
|
|
52
62
|
}
|
|
@@ -54,6 +64,57 @@ function appendAuditFile(line) {
|
|
|
54
64
|
// Non-fatal — audit logging should never crash the assistant
|
|
55
65
|
}
|
|
56
66
|
}
|
|
67
|
+
const traceStorage = new AsyncLocalStorage();
|
|
68
|
+
function shortId() {
|
|
69
|
+
// 8-char id — collision-resistant enough for per-session correlation and
|
|
70
|
+
// much easier to eyeball in logs than a full UUID.
|
|
71
|
+
return randomUUID().replace(/-/g, '').slice(0, 8);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run `fn` inside a trace context. Creates a new trace_id if none is supplied
|
|
75
|
+
* and inherited from an outer context. Nested calls push a span_id onto the
|
|
76
|
+
* stack so parent/child relationships survive async hops.
|
|
77
|
+
*/
|
|
78
|
+
export function runWithTrace(ctx, fn) {
|
|
79
|
+
const existing = traceStorage.getStore();
|
|
80
|
+
const trace_id = ctx.trace_id ?? existing?.trace_id ?? shortId();
|
|
81
|
+
const store = {
|
|
82
|
+
trace_id,
|
|
83
|
+
session_id: ctx.session_id ?? existing?.session_id,
|
|
84
|
+
channel: ctx.channel ?? existing?.channel,
|
|
85
|
+
agent_slug: ctx.agent_slug ?? existing?.agent_slug,
|
|
86
|
+
span_stack: [shortId(), ...(existing?.span_stack ?? [])],
|
|
87
|
+
};
|
|
88
|
+
return traceStorage.run(store, fn);
|
|
89
|
+
}
|
|
90
|
+
export function getTraceContext() {
|
|
91
|
+
return traceStorage.getStore();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Append a structured event to audit.jsonl with the current trace context.
|
|
95
|
+
* Runs alongside (not in place of) the legacy text audit.log so existing
|
|
96
|
+
* consumers keep working.
|
|
97
|
+
*/
|
|
98
|
+
export function logAuditJsonl(event) {
|
|
99
|
+
try {
|
|
100
|
+
rotateIfLarge(auditJsonlPath);
|
|
101
|
+
const ctx = traceStorage.getStore();
|
|
102
|
+
const payload = {
|
|
103
|
+
ts: new Date().toISOString(),
|
|
104
|
+
trace_id: ctx?.trace_id,
|
|
105
|
+
span_id: ctx?.span_stack[0],
|
|
106
|
+
parent_span_id: ctx?.span_stack[1],
|
|
107
|
+
session_id: ctx?.session_id,
|
|
108
|
+
channel: ctx?.channel,
|
|
109
|
+
agent_slug: ctx?.agent_slug,
|
|
110
|
+
...event,
|
|
111
|
+
};
|
|
112
|
+
fs.appendFileSync(auditJsonlPath, JSON.stringify(payload) + '\n');
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Non-fatal — audit logging should never crash the assistant
|
|
116
|
+
}
|
|
117
|
+
}
|
|
57
118
|
// ── State accessors ──────────────────────────────────────────────────
|
|
58
119
|
export function setHeartbeatMode(active, tier2Allowed = false) {
|
|
59
120
|
heartbeatActive = active;
|
|
@@ -99,6 +160,11 @@ export function logToolUse(toolName, toolInput) {
|
|
|
99
160
|
const entry = `- \`${timestamp}\` **${toolName}** — ${summary}`;
|
|
100
161
|
auditLog.push(entry);
|
|
101
162
|
appendAuditFile(`${toolName} — ${summary}`);
|
|
163
|
+
logAuditJsonl({
|
|
164
|
+
event_type: 'tool_use',
|
|
165
|
+
tool_name: toolName,
|
|
166
|
+
summary,
|
|
167
|
+
});
|
|
102
168
|
}
|
|
103
169
|
// ── Heartbeat tool restrictions ─────────────────────────────────────
|
|
104
170
|
// These apply to actual heartbeats and tier-1 cron jobs (read-only).
|
|
@@ -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
|
|
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
|
|
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 —
|
|
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 —
|
|
80
|
-
|
|
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
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/gateway/router.js
CHANGED
|
@@ -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()) {
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
*/
|