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.
- package/dist/agent/assistant.d.ts +20 -3
- package/dist/agent/assistant.js +197 -51
- 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/channels/slack.js +6 -2
- package/dist/gateway/cron-scheduler.d.ts +15 -0
- package/dist/gateway/cron-scheduler.js +76 -1
- 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 +42 -0
- package/dist/memory/store.d.ts +7 -0
- package/dist/memory/store.js +31 -0
- package/package.json +1 -1
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
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,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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 => {
|
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))
|
package/dist/channels/slack.js
CHANGED
|
@@ -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 —
|
|
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()) {
|
|
@@ -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) {
|
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
|
*/
|