clementine-agent 1.0.0
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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/agent/agent-manager.d.ts +69 -0
- package/dist/agent/agent-manager.js +441 -0
- package/dist/agent/assistant.d.ts +225 -0
- package/dist/agent/assistant.js +3888 -0
- package/dist/agent/auto-update.d.ts +32 -0
- package/dist/agent/auto-update.js +186 -0
- package/dist/agent/daily-planner.d.ts +24 -0
- package/dist/agent/daily-planner.js +379 -0
- package/dist/agent/execution-advisor.d.ts +10 -0
- package/dist/agent/execution-advisor.js +272 -0
- package/dist/agent/hooks.d.ts +45 -0
- package/dist/agent/hooks.js +564 -0
- package/dist/agent/insight-engine.d.ts +66 -0
- package/dist/agent/insight-engine.js +225 -0
- package/dist/agent/intent-classifier.d.ts +48 -0
- package/dist/agent/intent-classifier.js +214 -0
- package/dist/agent/link-extractor.d.ts +19 -0
- package/dist/agent/link-extractor.js +90 -0
- package/dist/agent/mcp-bridge.d.ts +62 -0
- package/dist/agent/mcp-bridge.js +435 -0
- package/dist/agent/metacognition.d.ts +66 -0
- package/dist/agent/metacognition.js +221 -0
- package/dist/agent/orchestrator.d.ts +81 -0
- package/dist/agent/orchestrator.js +790 -0
- package/dist/agent/profiles.d.ts +22 -0
- package/dist/agent/profiles.js +91 -0
- package/dist/agent/prompt-cache.d.ts +24 -0
- package/dist/agent/prompt-cache.js +68 -0
- package/dist/agent/prompt-evolver.d.ts +28 -0
- package/dist/agent/prompt-evolver.js +279 -0
- package/dist/agent/role-scaffolds.d.ts +28 -0
- package/dist/agent/role-scaffolds.js +433 -0
- package/dist/agent/safe-restart.d.ts +41 -0
- package/dist/agent/safe-restart.js +150 -0
- package/dist/agent/self-improve.d.ts +66 -0
- package/dist/agent/self-improve.js +1706 -0
- package/dist/agent/session-event-log.d.ts +114 -0
- package/dist/agent/session-event-log.js +233 -0
- package/dist/agent/skill-extractor.d.ts +72 -0
- package/dist/agent/skill-extractor.js +435 -0
- package/dist/agent/source-mods.d.ts +61 -0
- package/dist/agent/source-mods.js +230 -0
- package/dist/agent/source-preflight.d.ts +25 -0
- package/dist/agent/source-preflight.js +100 -0
- package/dist/agent/stall-guard.d.ts +62 -0
- package/dist/agent/stall-guard.js +109 -0
- package/dist/agent/strategic-planner.d.ts +60 -0
- package/dist/agent/strategic-planner.js +352 -0
- package/dist/agent/team-bus.d.ts +89 -0
- package/dist/agent/team-bus.js +556 -0
- package/dist/agent/team-router.d.ts +26 -0
- package/dist/agent/team-router.js +37 -0
- package/dist/agent/tool-loop-detector.d.ts +59 -0
- package/dist/agent/tool-loop-detector.js +242 -0
- package/dist/agent/workflow-runner.d.ts +36 -0
- package/dist/agent/workflow-runner.js +317 -0
- package/dist/agent/workflow-variables.d.ts +16 -0
- package/dist/agent/workflow-variables.js +62 -0
- package/dist/channels/discord-agent-bot.d.ts +101 -0
- package/dist/channels/discord-agent-bot.js +881 -0
- package/dist/channels/discord-bot-manager.d.ts +80 -0
- package/dist/channels/discord-bot-manager.js +262 -0
- package/dist/channels/discord-utils.d.ts +51 -0
- package/dist/channels/discord-utils.js +293 -0
- package/dist/channels/discord.d.ts +12 -0
- package/dist/channels/discord.js +1832 -0
- package/dist/channels/slack-agent-bot.d.ts +73 -0
- package/dist/channels/slack-agent-bot.js +320 -0
- package/dist/channels/slack-bot-manager.d.ts +66 -0
- package/dist/channels/slack-bot-manager.js +236 -0
- package/dist/channels/slack-utils.d.ts +39 -0
- package/dist/channels/slack-utils.js +189 -0
- package/dist/channels/slack.d.ts +11 -0
- package/dist/channels/slack.js +196 -0
- package/dist/channels/telegram.d.ts +10 -0
- package/dist/channels/telegram.js +235 -0
- package/dist/channels/webhook.d.ts +9 -0
- package/dist/channels/webhook.js +78 -0
- package/dist/channels/whatsapp.d.ts +11 -0
- package/dist/channels/whatsapp.js +181 -0
- package/dist/cli/chat.d.ts +14 -0
- package/dist/cli/chat.js +220 -0
- package/dist/cli/cron.d.ts +17 -0
- package/dist/cli/cron.js +552 -0
- package/dist/cli/dashboard.d.ts +15 -0
- package/dist/cli/dashboard.js +17677 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +2474 -0
- package/dist/cli/routes/delegations.d.ts +19 -0
- package/dist/cli/routes/delegations.js +154 -0
- package/dist/cli/routes/digest.d.ts +17 -0
- package/dist/cli/routes/digest.js +375 -0
- package/dist/cli/routes/goals.d.ts +14 -0
- package/dist/cli/routes/goals.js +258 -0
- package/dist/cli/routes/workflows.d.ts +18 -0
- package/dist/cli/routes/workflows.js +97 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +619 -0
- package/dist/cli/tunnel.d.ts +35 -0
- package/dist/cli/tunnel.js +141 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +278 -0
- package/dist/events/bus.d.ts +43 -0
- package/dist/events/bus.js +136 -0
- package/dist/gateway/cron-scheduler.d.ts +166 -0
- package/dist/gateway/cron-scheduler.js +1767 -0
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +110 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
- package/dist/gateway/heartbeat-scheduler.js +1298 -0
- package/dist/gateway/heartbeat.d.ts +3 -0
- package/dist/gateway/heartbeat.js +3 -0
- package/dist/gateway/lanes.d.ts +24 -0
- package/dist/gateway/lanes.js +76 -0
- package/dist/gateway/notifications.d.ts +29 -0
- package/dist/gateway/notifications.js +75 -0
- package/dist/gateway/router.d.ts +210 -0
- package/dist/gateway/router.js +1330 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1015 -0
- package/dist/memory/chunker.d.ts +28 -0
- package/dist/memory/chunker.js +226 -0
- package/dist/memory/consolidation.d.ts +44 -0
- package/dist/memory/consolidation.js +171 -0
- package/dist/memory/context-assembler.d.ts +50 -0
- package/dist/memory/context-assembler.js +149 -0
- package/dist/memory/embeddings.d.ts +38 -0
- package/dist/memory/embeddings.js +180 -0
- package/dist/memory/graph-store.d.ts +66 -0
- package/dist/memory/graph-store.js +613 -0
- package/dist/memory/mmr.d.ts +21 -0
- package/dist/memory/mmr.js +75 -0
- package/dist/memory/search.d.ts +26 -0
- package/dist/memory/search.js +67 -0
- package/dist/memory/store.d.ts +530 -0
- package/dist/memory/store.js +2022 -0
- package/dist/security/integrity.d.ts +24 -0
- package/dist/security/integrity.js +58 -0
- package/dist/security/patterns.d.ts +34 -0
- package/dist/security/patterns.js +110 -0
- package/dist/security/scanner.d.ts +32 -0
- package/dist/security/scanner.js +263 -0
- package/dist/tools/admin-tools.d.ts +12 -0
- package/dist/tools/admin-tools.js +1278 -0
- package/dist/tools/external-tools.d.ts +11 -0
- package/dist/tools/external-tools.js +1327 -0
- package/dist/tools/goal-tools.d.ts +9 -0
- package/dist/tools/goal-tools.js +159 -0
- package/dist/tools/mcp-server.d.ts +13 -0
- package/dist/tools/mcp-server.js +141 -0
- package/dist/tools/memory-tools.d.ts +10 -0
- package/dist/tools/memory-tools.js +568 -0
- package/dist/tools/session-tools.d.ts +6 -0
- package/dist/tools/session-tools.js +146 -0
- package/dist/tools/shared.d.ts +216 -0
- package/dist/tools/shared.js +340 -0
- package/dist/tools/team-tools.d.ts +6 -0
- package/dist/tools/team-tools.js +447 -0
- package/dist/tools/tool-meta.d.ts +34 -0
- package/dist/tools/tool-meta.js +133 -0
- package/dist/tools/vault-tools.d.ts +8 -0
- package/dist/tools/vault-tools.js +457 -0
- package/dist/types.d.ts +716 -0
- package/dist/types.js +16 -0
- package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
- package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
- package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
- package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
- package/dist/vault-migrations/helpers.d.ts +14 -0
- package/dist/vault-migrations/helpers.js +44 -0
- package/dist/vault-migrations/runner.d.ts +14 -0
- package/dist/vault-migrations/runner.js +139 -0
- package/dist/vault-migrations/types.d.ts +42 -0
- package/dist/vault-migrations/types.js +9 -0
- package/install.sh +320 -0
- package/package.json +84 -0
- package/scripts/postinstall.js +125 -0
- package/vault/00-System/AGENTS.md +66 -0
- package/vault/00-System/CRON.md +71 -0
- package/vault/00-System/HEARTBEAT.md +58 -0
- package/vault/00-System/MEMORY.md +16 -0
- package/vault/00-System/SOUL.md +96 -0
- package/vault/05-Tasks/TASKS.md +19 -0
- package/vault/06-Templates/_Daily-Template.md +28 -0
- package/vault/06-Templates/_People-Template.md +22 -0
|
@@ -0,0 +1,1767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Cron scheduler (autonomous execution).
|
|
3
|
+
*
|
|
4
|
+
* CronScheduler: precise scheduled tasks using node-cron
|
|
5
|
+
*
|
|
6
|
+
* Also contains shared parsers (parseCronJobs, parseAgentCronJobs, validateCronYaml),
|
|
7
|
+
* retry helpers, CronRunLog, and daily-note logging utilities used by both schedulers.
|
|
8
|
+
*/
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, watchFile, unwatchFile, writeFileSync, } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import cron from 'node-cron';
|
|
13
|
+
import matter from 'gray-matter';
|
|
14
|
+
import pino from 'pino';
|
|
15
|
+
import { CRON_FILE, WORKFLOWS_DIR, AGENTS_DIR, DAILY_NOTES_DIR, BASE_DIR, DISCORD_OWNER_ID, GOALS_DIR, CRON_REFLECTIONS_DIR, ADVISOR_LOG_PATH, TIMEZONE, } from '../config.js';
|
|
16
|
+
import { scanner } from '../security/scanner.js';
|
|
17
|
+
import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
|
|
18
|
+
import { SelfImproveLoop } from '../agent/self-improve.js';
|
|
19
|
+
const logger = pino({ name: 'clementine.cron' });
|
|
20
|
+
/** Default timeout for standard cron jobs (10 minutes). */
|
|
21
|
+
const CRON_STANDARD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
|
+
/** Timezone for cron scheduling — uses config (user-overridable) or system-detected. */
|
|
23
|
+
const SYSTEM_TIMEZONE = TIMEZONE;
|
|
24
|
+
// ── Daily Note Activity Logger ───────────────────────────────────────
|
|
25
|
+
/** Local-time YYYY-MM-DD for daily note path. */
|
|
26
|
+
export function todayISO() {
|
|
27
|
+
const d = new Date();
|
|
28
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Append a line to today's daily note under ## Interactions.
|
|
32
|
+
* Creates the section if it doesn't exist. Non-fatal — never throws.
|
|
33
|
+
*/
|
|
34
|
+
export function logToDailyNote(line) {
|
|
35
|
+
try {
|
|
36
|
+
const notePath = path.join(DAILY_NOTES_DIR, `${todayISO()}.md`);
|
|
37
|
+
if (!existsSync(notePath))
|
|
38
|
+
return; // template hasn't created the note yet
|
|
39
|
+
let content = readFileSync(notePath, 'utf-8');
|
|
40
|
+
const marker = '## Interactions';
|
|
41
|
+
const idx = content.indexOf(marker);
|
|
42
|
+
if (idx === -1) {
|
|
43
|
+
// No Interactions section — append one
|
|
44
|
+
content += `\n\n${marker}\n\n- ${line}`;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// Find the end of the marker line and insert after it
|
|
48
|
+
const afterMarker = idx + marker.length;
|
|
49
|
+
const nextNewline = content.indexOf('\n', afterMarker);
|
|
50
|
+
const insertAt = nextNewline === -1 ? content.length : nextNewline;
|
|
51
|
+
const nextSection = content.indexOf('\n## ', insertAt + 1);
|
|
52
|
+
// Insert at the end of the section (before next ## or EOF)
|
|
53
|
+
const insertPoint = nextSection === -1 ? content.length : nextSection;
|
|
54
|
+
content = content.slice(0, insertPoint) + `\n- ${line}` + content.slice(insertPoint);
|
|
55
|
+
}
|
|
56
|
+
writeFileSync(notePath, content);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
logger.warn({ err }, 'Daily note logging failed');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ── Shared CRON.md parser ────────────────────────────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* Parse cron job definitions from vault/00-System/CRON.md frontmatter.
|
|
65
|
+
* Used by both the in-process CronScheduler and the standalone CLI runner.
|
|
66
|
+
*/
|
|
67
|
+
export function parseCronJobs() {
|
|
68
|
+
if (!existsSync(CRON_FILE))
|
|
69
|
+
return [];
|
|
70
|
+
const raw = readFileSync(CRON_FILE, 'utf-8');
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = matter(raw);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
logger.error({ err }, 'CRON.md YAML parse error — keeping previous jobs. Fix the file manually.');
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const jobDefs = (parsed.data.jobs ?? []);
|
|
80
|
+
const jobs = [];
|
|
81
|
+
for (const job of jobDefs) {
|
|
82
|
+
const name = String(job.name ?? '');
|
|
83
|
+
const schedule = String(job.schedule ?? '');
|
|
84
|
+
const prompt = String(job.prompt ?? '');
|
|
85
|
+
const enabled = job.enabled !== false;
|
|
86
|
+
const tier = Number(job.tier ?? 1);
|
|
87
|
+
const maxTurns = job.max_turns != null ? Number(job.max_turns) : undefined;
|
|
88
|
+
const model = job.model != null ? String(job.model) : undefined;
|
|
89
|
+
const workDir = job.work_dir != null ? String(job.work_dir) : undefined;
|
|
90
|
+
const mode = job.mode === 'unleashed' ? 'unleashed' : 'standard';
|
|
91
|
+
const maxHours = job.max_hours != null ? Number(job.max_hours) : undefined;
|
|
92
|
+
const maxRetries = job.max_retries != null ? Number(job.max_retries) : undefined;
|
|
93
|
+
const after = job.after != null ? String(job.after) : undefined;
|
|
94
|
+
const successCriteria = Array.isArray(job.success_criteria)
|
|
95
|
+
? job.success_criteria.map(c => String(c))
|
|
96
|
+
: undefined;
|
|
97
|
+
const alwaysDeliver = job.always_deliver === true ? true : undefined;
|
|
98
|
+
const context = job.context != null ? String(job.context) : undefined;
|
|
99
|
+
const preCheck = job.pre_check != null ? String(job.pre_check) : undefined;
|
|
100
|
+
if (!name || !schedule || !prompt) {
|
|
101
|
+
logger.warn({ job }, 'Skipping malformed cron job');
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
jobs.push({ name, schedule, prompt, enabled, tier, maxTurns, model, workDir, mode, maxHours, maxRetries, after, successCriteria, alwaysDeliver, context, preCheck });
|
|
105
|
+
}
|
|
106
|
+
return jobs;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse cron jobs from agent-scoped CRON.md files.
|
|
110
|
+
* Scans each agent subdirectory for CRON.md, prefixes job names with agent slug.
|
|
111
|
+
*/
|
|
112
|
+
export function parseAgentCronJobs(agentsDir) {
|
|
113
|
+
if (!existsSync(agentsDir))
|
|
114
|
+
return [];
|
|
115
|
+
const allJobs = [];
|
|
116
|
+
let dirs;
|
|
117
|
+
try {
|
|
118
|
+
dirs = readdirSync(agentsDir, { withFileTypes: true })
|
|
119
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
|
|
120
|
+
.map((d) => d.name);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
for (const slug of dirs) {
|
|
126
|
+
const cronFile = path.join(agentsDir, slug, 'CRON.md');
|
|
127
|
+
if (!existsSync(cronFile))
|
|
128
|
+
continue;
|
|
129
|
+
try {
|
|
130
|
+
const raw = readFileSync(cronFile, 'utf-8');
|
|
131
|
+
const parsed = matter(raw);
|
|
132
|
+
const jobDefs = (parsed.data.jobs ?? []);
|
|
133
|
+
for (const job of jobDefs) {
|
|
134
|
+
const name = String(job.name ?? '');
|
|
135
|
+
const schedule = String(job.schedule ?? '');
|
|
136
|
+
const prompt = String(job.prompt ?? '');
|
|
137
|
+
const enabled = job.enabled !== false;
|
|
138
|
+
const tier = Number(job.tier ?? 1);
|
|
139
|
+
const maxTurns = job.max_turns != null ? Number(job.max_turns) : undefined;
|
|
140
|
+
const model = job.model != null ? String(job.model) : undefined;
|
|
141
|
+
const workDir = job.work_dir != null ? String(job.work_dir) : undefined;
|
|
142
|
+
const mode = job.mode === 'unleashed' ? 'unleashed' : 'standard';
|
|
143
|
+
const maxHours = job.max_hours != null ? Number(job.max_hours) : undefined;
|
|
144
|
+
const maxRetries = job.max_retries != null ? Number(job.max_retries) : undefined;
|
|
145
|
+
const after = job.after != null ? String(job.after) : undefined;
|
|
146
|
+
const successCriteria = Array.isArray(job.success_criteria)
|
|
147
|
+
? job.success_criteria.map(c => String(c))
|
|
148
|
+
: undefined;
|
|
149
|
+
const context = job.context != null ? String(job.context) : undefined;
|
|
150
|
+
const preCheck = job.pre_check != null ? String(job.pre_check) : undefined;
|
|
151
|
+
if (!name || !schedule || !prompt) {
|
|
152
|
+
logger.warn({ job, agent: slug }, 'Skipping malformed agent cron job');
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Prefix name with agent slug and tag with agentSlug
|
|
156
|
+
allJobs.push({
|
|
157
|
+
name: `${slug}:${name}`,
|
|
158
|
+
schedule, prompt, enabled, tier, maxTurns, model, workDir,
|
|
159
|
+
mode, maxHours, maxRetries, after, successCriteria, context, preCheck,
|
|
160
|
+
agentSlug: slug,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
logger.error({ err, agent: slug }, `Agent ${slug} CRON.md parse error — skipping`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return allJobs;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Validate that a CRON.md string parses without YAML errors.
|
|
172
|
+
* Call this before writing to prevent corrupted files from crashing the daemon.
|
|
173
|
+
* Returns null on success, or the error message on failure.
|
|
174
|
+
*/
|
|
175
|
+
export function validateCronYaml(content) {
|
|
176
|
+
try {
|
|
177
|
+
matter(content);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return err instanceof Error ? err.message : String(err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// ── Retry / backoff ──────────────────────────────────────────────────
|
|
185
|
+
/** Exponential backoff schedule in ms: 30s, 1m, 5m, 15m, 60m */
|
|
186
|
+
const BACKOFF_MS = [30_000, 60_000, 300_000, 900_000, 3_600_000];
|
|
187
|
+
/** Patterns that indicate a transient (retryable) error. */
|
|
188
|
+
const TRANSIENT_PATTERNS = [
|
|
189
|
+
/rate.?limit/i,
|
|
190
|
+
/429/,
|
|
191
|
+
/timeout/i,
|
|
192
|
+
/timed out/i,
|
|
193
|
+
/ECONNRESET/i,
|
|
194
|
+
/ECONNREFUSED/i,
|
|
195
|
+
/ETIMEDOUT/i,
|
|
196
|
+
/ENOTFOUND/i,
|
|
197
|
+
/socket hang up/i,
|
|
198
|
+
/5\d\d/,
|
|
199
|
+
/overloaded/i,
|
|
200
|
+
/temporarily unavailable/i,
|
|
201
|
+
/quota.?exceeded/i,
|
|
202
|
+
/too many requests/i,
|
|
203
|
+
/service.?unavailable/i,
|
|
204
|
+
/capacity/i,
|
|
205
|
+
/try again/i,
|
|
206
|
+
];
|
|
207
|
+
export function classifyError(err) {
|
|
208
|
+
const msg = String(err);
|
|
209
|
+
return TRANSIENT_PATTERNS.some((re) => re.test(msg)) ? 'transient' : 'permanent';
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Classify a TerminalReason from the SDK into a retry strategy.
|
|
213
|
+
* More precise than classifyError() which relies on regex.
|
|
214
|
+
*/
|
|
215
|
+
export function classifyTerminalReason(reason) {
|
|
216
|
+
switch (reason) {
|
|
217
|
+
// Retryable — infrastructure / rate limits
|
|
218
|
+
case 'blocking_limit':
|
|
219
|
+
case 'rapid_refill_breaker':
|
|
220
|
+
case 'aborted_streaming':
|
|
221
|
+
return 'transient';
|
|
222
|
+
// Permanent — the advisor or human needs to intervene
|
|
223
|
+
case 'max_turns': // advisor should increase turns
|
|
224
|
+
case 'prompt_too_long': // advisor should shorten prompt
|
|
225
|
+
case 'model_error': // advisor should upgrade model
|
|
226
|
+
case 'hook_stopped': // security blocked it
|
|
227
|
+
case 'stop_hook_prevented': // hook intervention
|
|
228
|
+
case 'image_error': // bad input
|
|
229
|
+
case 'aborted_tools': // timeout or user abort
|
|
230
|
+
case 'tool_deferred': // tool was deferred
|
|
231
|
+
return 'permanent';
|
|
232
|
+
// Success
|
|
233
|
+
case 'completed':
|
|
234
|
+
return 'transient'; // won't actually be in error path, but safe default
|
|
235
|
+
default:
|
|
236
|
+
return 'permanent';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function sleep(ms) {
|
|
240
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
241
|
+
}
|
|
242
|
+
// ── Run history logging ──────────────────────────────────────────────
|
|
243
|
+
/**
|
|
244
|
+
* JSONL-based per-job run log. Auto-prunes to keep files under 2 MB
|
|
245
|
+
* and 2000 lines (whichever limit hits first).
|
|
246
|
+
*/
|
|
247
|
+
export class CronRunLog {
|
|
248
|
+
dir;
|
|
249
|
+
static MAX_BYTES = 2_000_000;
|
|
250
|
+
static MAX_LINES = 2000;
|
|
251
|
+
constructor(baseDir) {
|
|
252
|
+
this.dir = path.join(baseDir ?? BASE_DIR, 'cron', 'runs');
|
|
253
|
+
if (!existsSync(this.dir)) {
|
|
254
|
+
mkdirSync(this.dir, { recursive: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
logPath(jobName) {
|
|
258
|
+
// Sanitize job name for filesystem
|
|
259
|
+
const safe = jobName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
260
|
+
return path.join(this.dir, `${safe}.jsonl`);
|
|
261
|
+
}
|
|
262
|
+
append(entry) {
|
|
263
|
+
const filePath = this.logPath(entry.jobName);
|
|
264
|
+
const line = JSON.stringify(entry) + '\n';
|
|
265
|
+
try {
|
|
266
|
+
appendFileSync(filePath, line);
|
|
267
|
+
// Schedule pruning asynchronously so it doesn't block the caller
|
|
268
|
+
setImmediate(() => this.maybePrune(filePath));
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
logger.warn({ err, job: entry.jobName }, 'Failed to write run log');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
readRecent(jobName, count = 20) {
|
|
275
|
+
const filePath = this.logPath(jobName);
|
|
276
|
+
if (!existsSync(filePath))
|
|
277
|
+
return [];
|
|
278
|
+
try {
|
|
279
|
+
const lines = readFileSync(filePath, 'utf-8')
|
|
280
|
+
.trim()
|
|
281
|
+
.split('\n')
|
|
282
|
+
.filter(Boolean);
|
|
283
|
+
return lines
|
|
284
|
+
.slice(-count)
|
|
285
|
+
.map((l) => JSON.parse(l))
|
|
286
|
+
.reverse(); // newest first
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
consecutiveErrors(jobName) {
|
|
293
|
+
const recent = this.readRecent(jobName, 10);
|
|
294
|
+
let count = 0;
|
|
295
|
+
for (const entry of recent) {
|
|
296
|
+
if (entry.status === 'ok')
|
|
297
|
+
break;
|
|
298
|
+
count++;
|
|
299
|
+
}
|
|
300
|
+
return count;
|
|
301
|
+
}
|
|
302
|
+
maybePrune(filePath) {
|
|
303
|
+
try {
|
|
304
|
+
const { size } = statSync(filePath);
|
|
305
|
+
if (size <= CronRunLog.MAX_BYTES)
|
|
306
|
+
return;
|
|
307
|
+
const lines = readFileSync(filePath, 'utf-8').trim().split('\n');
|
|
308
|
+
if (lines.length <= CronRunLog.MAX_LINES)
|
|
309
|
+
return;
|
|
310
|
+
// Keep the most recent MAX_LINES entries
|
|
311
|
+
const trimmed = lines.slice(-CronRunLog.MAX_LINES);
|
|
312
|
+
writeFileSync(filePath, trimmed.join('\n') + '\n');
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// non-critical
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// ── CronScheduler ─────────────────────────────────────────────────────
|
|
320
|
+
export class CronScheduler {
|
|
321
|
+
gateway;
|
|
322
|
+
dispatcher;
|
|
323
|
+
jobs = [];
|
|
324
|
+
disabledJobs = new Set();
|
|
325
|
+
scheduledTasks = new Map();
|
|
326
|
+
runningJobs = new Set();
|
|
327
|
+
completedJobs = new Map(); // jobName → completion timestamp
|
|
328
|
+
watching = false;
|
|
329
|
+
runLog;
|
|
330
|
+
// Workflow support
|
|
331
|
+
workflowDefs = [];
|
|
332
|
+
workflowTasks = new Map();
|
|
333
|
+
runningWorkflows = new Set();
|
|
334
|
+
watchingWorkflows = false;
|
|
335
|
+
// Trigger directory for MCP-initiated job runs
|
|
336
|
+
triggerDir = path.join(BASE_DIR, 'cron', 'triggers');
|
|
337
|
+
goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
|
|
338
|
+
triggerTimer = null;
|
|
339
|
+
// Event-driven status change listeners (used by Discord status embed)
|
|
340
|
+
statusChangeListeners = [];
|
|
341
|
+
constructor(gateway, dispatcher) {
|
|
342
|
+
this.gateway = gateway;
|
|
343
|
+
this.dispatcher = dispatcher;
|
|
344
|
+
this.runLog = new CronRunLog();
|
|
345
|
+
// Eagerly load job definitions (without scheduling) so they're
|
|
346
|
+
// available for queries before start() is called — agent bots
|
|
347
|
+
// query jobs on connect which happens before start().
|
|
348
|
+
this.loadJobDefinitions();
|
|
349
|
+
}
|
|
350
|
+
/** Load job definitions from CRON.md and agent dirs without scheduling tasks. */
|
|
351
|
+
loadJobDefinitions() {
|
|
352
|
+
this.jobs = parseCronJobs();
|
|
353
|
+
const agentJobs = parseAgentCronJobs(AGENTS_DIR);
|
|
354
|
+
if (agentJobs.length > 0) {
|
|
355
|
+
this.jobs.push(...agentJobs);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Register a listener that fires when system state changes (job start/finish, self-improve, etc). */
|
|
359
|
+
onStatusChange(cb) {
|
|
360
|
+
this.statusChangeListeners.push(cb);
|
|
361
|
+
}
|
|
362
|
+
emitStatusChange() {
|
|
363
|
+
for (const cb of this.statusChangeListeners) {
|
|
364
|
+
try {
|
|
365
|
+
cb();
|
|
366
|
+
}
|
|
367
|
+
catch { /* ignore listener errors */ }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
start() {
|
|
371
|
+
this.reloadJobs();
|
|
372
|
+
this.reloadWorkflows();
|
|
373
|
+
this.watchCronFile();
|
|
374
|
+
this.watchAgentsDir();
|
|
375
|
+
this.watchWorkflowDir();
|
|
376
|
+
this.watchTriggers();
|
|
377
|
+
// Wire up push notifications for unleashed task completions
|
|
378
|
+
this.gateway.setUnleashedCompleteCallback((jobName, result) => {
|
|
379
|
+
this.completedJobs.set(jobName, Date.now());
|
|
380
|
+
if (result && result !== '__NOTHING__') {
|
|
381
|
+
const slug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
|
|
382
|
+
// Strip system metadata for clean conversational delivery
|
|
383
|
+
const cleanResult = result
|
|
384
|
+
.replace(/^TASK_COMPLETE:\s*/i, '')
|
|
385
|
+
.replace(/^STATUS SUMMARY:?\s*/im, '')
|
|
386
|
+
.slice(0, 1500);
|
|
387
|
+
this.dispatcher.send(cleanResult, { agentSlug: slug }).catch(err => logger.debug({ err }, 'Failed to send unleashed complete notification'));
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
// Wire up phase progress notifications for unleashed tasks
|
|
391
|
+
this.gateway.setPhaseCompleteCallback((jobName, phase, _total, output) => {
|
|
392
|
+
if (phase <= 1)
|
|
393
|
+
return; // Don't spam for the first phase — wait for real progress
|
|
394
|
+
if (/TASK_COMPLETE:/i.test(output))
|
|
395
|
+
return; // Final delivery handled by unleashed complete callback
|
|
396
|
+
const slug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
|
|
397
|
+
const cleanOutput = output
|
|
398
|
+
.replace(/^STATUS SUMMARY:?\s*/im, '')
|
|
399
|
+
.slice(0, 500);
|
|
400
|
+
this.dispatcher.send(`Still working on it — ${cleanOutput}`, { agentSlug: slug }).catch(err => logger.debug({ err }, 'Failed to send phase progress notification'));
|
|
401
|
+
});
|
|
402
|
+
// Wire up real-time progress summaries (throttled to max 1 per 5 minutes)
|
|
403
|
+
const lastProgressSent = new Map();
|
|
404
|
+
this.gateway.setPhaseProgressCallback((jobName, _phase, summary) => {
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
const lastSent = lastProgressSent.get(jobName) ?? 0;
|
|
407
|
+
if (now - lastSent < 300_000)
|
|
408
|
+
return; // throttle: 1 per 5 minutes
|
|
409
|
+
lastProgressSent.set(jobName, now);
|
|
410
|
+
const slug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
|
|
411
|
+
this.dispatcher.send(summary.slice(0, 300), { agentSlug: slug }).catch(err => logger.debug({ err }, 'Failed to send phase progress summary'));
|
|
412
|
+
});
|
|
413
|
+
logger.info(`Cron scheduler started with ${this.jobs.length} jobs`);
|
|
414
|
+
}
|
|
415
|
+
stop() {
|
|
416
|
+
for (const [name, task] of this.scheduledTasks) {
|
|
417
|
+
task.stop();
|
|
418
|
+
logger.debug(`Stopped cron task: ${name}`);
|
|
419
|
+
}
|
|
420
|
+
this.scheduledTasks.clear();
|
|
421
|
+
for (const [name, task] of this.workflowTasks) {
|
|
422
|
+
task.stop();
|
|
423
|
+
logger.debug(`Stopped workflow task: ${name}`);
|
|
424
|
+
}
|
|
425
|
+
this.workflowTasks.clear();
|
|
426
|
+
this.unwatchCronFile();
|
|
427
|
+
this.unwatchAgentsDir();
|
|
428
|
+
this.unwatchWorkflowDir();
|
|
429
|
+
if (this.triggerTimer) {
|
|
430
|
+
clearInterval(this.triggerTimer);
|
|
431
|
+
this.triggerTimer = null;
|
|
432
|
+
}
|
|
433
|
+
logger.info('Cron scheduler stopped');
|
|
434
|
+
}
|
|
435
|
+
/** Watch CRON.md for changes and auto-reload jobs. */
|
|
436
|
+
watchCronFile() {
|
|
437
|
+
if (this.watching)
|
|
438
|
+
return;
|
|
439
|
+
if (!existsSync(CRON_FILE))
|
|
440
|
+
return;
|
|
441
|
+
watchFile(CRON_FILE, { interval: 2000 }, () => {
|
|
442
|
+
logger.info('CRON.md changed — reloading jobs');
|
|
443
|
+
try {
|
|
444
|
+
this.reloadJobs();
|
|
445
|
+
scanner.refreshIntegrity(); // CRON.md change is legitimate
|
|
446
|
+
logger.info(`Cron scheduler reloaded: ${this.jobs.length} jobs`);
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
logger.error({ err }, 'Failed to reload CRON.md — keeping previous schedule');
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
this.watching = true;
|
|
453
|
+
}
|
|
454
|
+
unwatchCronFile() {
|
|
455
|
+
if (!this.watching)
|
|
456
|
+
return;
|
|
457
|
+
try {
|
|
458
|
+
unwatchFile(CRON_FILE);
|
|
459
|
+
}
|
|
460
|
+
catch { /* ignore */ }
|
|
461
|
+
this.watching = false;
|
|
462
|
+
}
|
|
463
|
+
/** Watch agents directory for cron/workflow changes and auto-reload. */
|
|
464
|
+
watchingAgents = false;
|
|
465
|
+
watchAgentsDir() {
|
|
466
|
+
if (this.watchingAgents)
|
|
467
|
+
return;
|
|
468
|
+
if (!existsSync(AGENTS_DIR))
|
|
469
|
+
return;
|
|
470
|
+
watchFile(AGENTS_DIR, { interval: 5000 }, () => {
|
|
471
|
+
logger.info('Agents directory changed — reloading jobs and workflows');
|
|
472
|
+
try {
|
|
473
|
+
this.reloadJobs();
|
|
474
|
+
this.reloadWorkflows();
|
|
475
|
+
scanner.refreshIntegrity();
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
logger.error({ err }, 'Failed to reload agent configs');
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
this.watchingAgents = true;
|
|
482
|
+
}
|
|
483
|
+
unwatchAgentsDir() {
|
|
484
|
+
if (!this.watchingAgents)
|
|
485
|
+
return;
|
|
486
|
+
try {
|
|
487
|
+
unwatchFile(AGENTS_DIR);
|
|
488
|
+
}
|
|
489
|
+
catch { /* ignore */ }
|
|
490
|
+
this.watchingAgents = false;
|
|
491
|
+
}
|
|
492
|
+
reloadJobs() {
|
|
493
|
+
// Stop existing scheduled tasks (but NOT the file watcher)
|
|
494
|
+
for (const [name, task] of this.scheduledTasks) {
|
|
495
|
+
task.stop();
|
|
496
|
+
logger.debug(`Stopped cron task: ${name}`);
|
|
497
|
+
}
|
|
498
|
+
this.scheduledTasks.clear();
|
|
499
|
+
this.jobs = parseCronJobs();
|
|
500
|
+
// Evict stale entries from disabledJobs and completedJobs for removed jobs
|
|
501
|
+
const currentJobNames = new Set(this.jobs.map(j => j.name));
|
|
502
|
+
for (const name of this.disabledJobs) {
|
|
503
|
+
if (!currentJobNames.has(name))
|
|
504
|
+
this.disabledJobs.delete(name);
|
|
505
|
+
}
|
|
506
|
+
const MAX_COMPLETED_AGE_MS = 48 * 60 * 60 * 1000; // 48 hours
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
for (const [name, ts] of this.completedJobs) {
|
|
509
|
+
if (!currentJobNames.has(name) || now - ts > MAX_COMPLETED_AGE_MS) {
|
|
510
|
+
this.completedJobs.delete(name);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Merge in agent-scoped cron jobs
|
|
514
|
+
const agentJobs = parseAgentCronJobs(AGENTS_DIR);
|
|
515
|
+
if (agentJobs.length > 0) {
|
|
516
|
+
this.jobs.push(...agentJobs);
|
|
517
|
+
logger.info(`Loaded ${agentJobs.length} agent-scoped cron job(s)`);
|
|
518
|
+
}
|
|
519
|
+
if (this.jobs.length === 0) {
|
|
520
|
+
logger.info('No CRON.md found or no jobs defined');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// ── Cycle detection for `after` chains (DFS) ──────────────────────
|
|
524
|
+
const jobNames = new Set(this.jobs.map(j => j.name));
|
|
525
|
+
const afterMap = new Map(); // child → parent
|
|
526
|
+
for (const def of this.jobs) {
|
|
527
|
+
if (def.after) {
|
|
528
|
+
if (!jobNames.has(def.after)) {
|
|
529
|
+
logger.warn(`Job '${def.name}' references missing parent '${def.after}' — ignoring chain`);
|
|
530
|
+
def.after = undefined;
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
afterMap.set(def.name, def.after);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// DFS cycle detection
|
|
538
|
+
const cycledJobs = new Set();
|
|
539
|
+
for (const startName of afterMap.keys()) {
|
|
540
|
+
const visited = new Set();
|
|
541
|
+
let current = startName;
|
|
542
|
+
while (current && afterMap.has(current)) {
|
|
543
|
+
if (visited.has(current)) {
|
|
544
|
+
// Cycle found — disable all jobs in the cycle
|
|
545
|
+
for (const name of visited)
|
|
546
|
+
cycledJobs.add(name);
|
|
547
|
+
logger.error({ cycle: [...visited] }, `Circular dependency detected — disabling cycled jobs`);
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
visited.add(current);
|
|
551
|
+
current = afterMap.get(current);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
for (const name of cycledJobs) {
|
|
555
|
+
const job = this.jobs.find(j => j.name === name);
|
|
556
|
+
if (job) {
|
|
557
|
+
job.enabled = false;
|
|
558
|
+
job.after = undefined;
|
|
559
|
+
logger.error(`Disabled '${name}' due to circular chain dependency`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
for (const def of this.jobs) {
|
|
563
|
+
if (def.enabled && !this.disabledJobs.has(def.name)) {
|
|
564
|
+
// Jobs with `after` are triggered by their parent — skip cron scheduling
|
|
565
|
+
if (def.after) {
|
|
566
|
+
logger.info(`Cron job '${def.name}' chained after '${def.after}' — skipping cron schedule`);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (!cron.validate(def.schedule)) {
|
|
570
|
+
logger.error(`Invalid cron schedule for '${def.name}': ${def.schedule}`);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const task = cron.schedule(def.schedule, () => {
|
|
574
|
+
this.runJob(def).catch((err) => {
|
|
575
|
+
logger.error({ err, job: def.name }, `Cron job '${def.name}' failed`);
|
|
576
|
+
});
|
|
577
|
+
}, { timezone: SYSTEM_TIMEZONE });
|
|
578
|
+
this.scheduledTasks.set(def.name, task);
|
|
579
|
+
logger.info(`Cron job '${def.name}' scheduled: ${def.schedule} (${SYSTEM_TIMEZONE})`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
async runJob(job) {
|
|
584
|
+
// Agent status check — skip if agent is paused/terminated
|
|
585
|
+
if (job.agentSlug) {
|
|
586
|
+
const agentMgr = this.gateway?.getAgentManager?.();
|
|
587
|
+
if (agentMgr && !agentMgr.isRunnable(job.agentSlug)) {
|
|
588
|
+
const agent = agentMgr.get(job.agentSlug);
|
|
589
|
+
logger.info({ job: job.name, status: agent?.status }, `Agent '${job.agentSlug}' is ${agent?.status ?? 'unknown'} — skipping cron job`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
// Budget check — skip if over monthly budget
|
|
593
|
+
if (agentMgr) {
|
|
594
|
+
const agent = agentMgr.get(job.agentSlug);
|
|
595
|
+
if (agent?.budgetMonthlyCents && agent.budgetMonthlyCents > 0) {
|
|
596
|
+
try {
|
|
597
|
+
const { MemoryStore } = await import('../memory/store.js');
|
|
598
|
+
const { MEMORY_DB_PATH, VAULT_DIR } = await import('../config.js');
|
|
599
|
+
const { existsSync } = await import('node:fs');
|
|
600
|
+
if (existsSync(MEMORY_DB_PATH)) {
|
|
601
|
+
const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
|
|
602
|
+
store.initialize();
|
|
603
|
+
if (store.isOverBudget(job.agentSlug, agent.budgetMonthlyCents)) {
|
|
604
|
+
logger.warn({ job: job.name, agentSlug: job.agentSlug, budget: agent.budgetMonthlyCents }, `Agent '${job.agentSlug}' is over monthly budget — skipping cron job`);
|
|
605
|
+
store.close();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
store.close();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
catch { /* budget check failed — allow job to run */ }
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// Prevent concurrent runs of the same job
|
|
616
|
+
if (this.runningJobs.has(job.name)) {
|
|
617
|
+
logger.info(`Cron job '${job.name}' is already running — skipping this trigger`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// Cooldown for unleashed jobs that completed recently
|
|
621
|
+
const completedAt = this.completedJobs.get(job.name);
|
|
622
|
+
if (completedAt) {
|
|
623
|
+
const cooldownMs = (job.maxHours ?? 6) * 60 * 60 * 1000;
|
|
624
|
+
if (Date.now() - completedAt < cooldownMs) {
|
|
625
|
+
logger.info(`Cron job '${job.name}' completed recently — cooling down, skipping`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
this.completedJobs.delete(job.name);
|
|
629
|
+
}
|
|
630
|
+
// ── Pre-check gate: run shell command, skip job if exit non-zero ──
|
|
631
|
+
if (job.preCheck) {
|
|
632
|
+
try {
|
|
633
|
+
const preCheckStart = Date.now();
|
|
634
|
+
const stdout = execSync(job.preCheck, {
|
|
635
|
+
timeout: 30_000,
|
|
636
|
+
encoding: 'utf-8',
|
|
637
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
638
|
+
cwd: job.workDir || undefined,
|
|
639
|
+
}).trim();
|
|
640
|
+
const preCheckMs = Date.now() - preCheckStart;
|
|
641
|
+
logger.info({ job: job.name, preCheckMs, hasOutput: stdout.length > 0 }, 'Pre-check passed');
|
|
642
|
+
// Inject pre-check output as context so the agent doesn't re-query
|
|
643
|
+
if (stdout.length > 0) {
|
|
644
|
+
job = { ...job, prompt: `Pre-check data (already fetched — use this, do not re-query):\n\`\`\`\n${stdout.slice(0, 4000)}\n\`\`\`\n\n${job.prompt}` };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
catch (preCheckErr) {
|
|
648
|
+
// Non-zero exit or timeout → skip the job
|
|
649
|
+
const exitCode = preCheckErr.status ?? 1;
|
|
650
|
+
logger.info({ job: job.name, exitCode }, 'Pre-check failed — skipping job (no work to do)');
|
|
651
|
+
this.runLog.append({
|
|
652
|
+
jobName: job.name,
|
|
653
|
+
startedAt: new Date().toISOString(),
|
|
654
|
+
finishedAt: new Date().toISOString(),
|
|
655
|
+
status: 'skipped',
|
|
656
|
+
durationMs: 0,
|
|
657
|
+
attempt: 0,
|
|
658
|
+
outputPreview: `Pre-check exit ${exitCode} — no work`,
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// ── Owner confirmation gate ──────────────────────────────────────
|
|
664
|
+
if (job.requiresConfirmation) {
|
|
665
|
+
const timeoutMin = job.confirmationTimeoutMin ?? 5;
|
|
666
|
+
const confirmId = `cron-confirm-${job.name}-${Date.now()}`;
|
|
667
|
+
const timeoutSec = timeoutMin * 60;
|
|
668
|
+
logger.info({ job: job.name, timeoutMin }, 'Cron job requires confirmation — notifying owner');
|
|
669
|
+
await this.dispatcher.send(`**Cron job ready to run:** "${job.name}"\n` +
|
|
670
|
+
`Schedule: \`${job.schedule}\`\n\n` +
|
|
671
|
+
`Reply \`yes\` or \`go\` to proceed, \`no\` or \`skip\` to cancel. ` +
|
|
672
|
+
`Auto-runs in ${timeoutMin} min if no reply.`).catch(err => logger.debug({ err }, 'Failed to send cron confirmation request'));
|
|
673
|
+
// Override gateway's default 5-min timeout with the job's configured timeout
|
|
674
|
+
const approved = await new Promise((resolve) => {
|
|
675
|
+
const timer = setTimeout(() => {
|
|
676
|
+
if (this.gateway.getPendingApprovals().includes(confirmId)) {
|
|
677
|
+
this.gateway.resolveApproval(confirmId, true); // auto-proceed on timeout
|
|
678
|
+
}
|
|
679
|
+
resolve(true);
|
|
680
|
+
}, timeoutSec * 1000);
|
|
681
|
+
// Register the approval — channel handlers resolve it via resolveApproval()
|
|
682
|
+
this.gateway.requestApproval(job.name, confirmId).then((result) => {
|
|
683
|
+
clearTimeout(timer);
|
|
684
|
+
resolve(result === true || result === 'go' || result === 'yes');
|
|
685
|
+
}).catch(() => {
|
|
686
|
+
clearTimeout(timer);
|
|
687
|
+
resolve(true); // on error, allow job to proceed
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
if (!approved) {
|
|
691
|
+
logger.info({ job: job.name }, 'Cron job skipped by owner');
|
|
692
|
+
this.runLog.append({
|
|
693
|
+
jobName: job.name,
|
|
694
|
+
startedAt: new Date().toISOString(),
|
|
695
|
+
finishedAt: new Date().toISOString(),
|
|
696
|
+
status: 'skipped',
|
|
697
|
+
durationMs: 0,
|
|
698
|
+
attempt: 0,
|
|
699
|
+
outputPreview: 'Skipped by owner at confirmation prompt',
|
|
700
|
+
});
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
logger.info({ job: job.name }, 'Cron job confirmed — proceeding');
|
|
704
|
+
}
|
|
705
|
+
// ── Adaptive execution: consult advisor before running ──
|
|
706
|
+
const originalJob = job; // snapshot before mutation
|
|
707
|
+
const { getExecutionAdvice } = await import('../agent/execution-advisor.js');
|
|
708
|
+
const advice = getExecutionAdvice(job.name, job);
|
|
709
|
+
if (advice.shouldSkip) {
|
|
710
|
+
logger.info({ job: job.name, reason: advice.skipReason }, 'Execution advisor: circuit breaker — skipping job');
|
|
711
|
+
this.runLog.append({
|
|
712
|
+
jobName: job.name,
|
|
713
|
+
startedAt: new Date().toISOString(),
|
|
714
|
+
finishedAt: new Date().toISOString(),
|
|
715
|
+
status: 'skipped',
|
|
716
|
+
durationMs: 0,
|
|
717
|
+
attempt: 0,
|
|
718
|
+
outputPreview: `Circuit breaker: ${advice.skipReason}`,
|
|
719
|
+
});
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// Apply advisor overrides to a mutable copy
|
|
723
|
+
let advisorApplied;
|
|
724
|
+
if (advice.adjustedMaxTurns || advice.adjustedModel || advice.adjustedTimeoutMs || advice.promptEnrichment || advice.shouldEscalate) {
|
|
725
|
+
job = { ...job };
|
|
726
|
+
if (advice.adjustedMaxTurns)
|
|
727
|
+
job.maxTurns = advice.adjustedMaxTurns;
|
|
728
|
+
if (advice.adjustedModel)
|
|
729
|
+
job.model = advice.adjustedModel;
|
|
730
|
+
if (advice.shouldEscalate && job.mode !== 'unleashed') {
|
|
731
|
+
job.mode = 'unleashed';
|
|
732
|
+
logger.info({ job: job.name, reason: advice.escalationReason }, 'Execution advisor: escalating to unleashed mode');
|
|
733
|
+
}
|
|
734
|
+
if (advice.promptEnrichment) {
|
|
735
|
+
job.prompt = `## Lessons from Previous Runs\n${advice.promptEnrichment}\n\n${job.prompt}`;
|
|
736
|
+
}
|
|
737
|
+
advisorApplied = {
|
|
738
|
+
adjustedMaxTurns: advice.adjustedMaxTurns ?? undefined,
|
|
739
|
+
adjustedModel: advice.adjustedModel ?? undefined,
|
|
740
|
+
adjustedTimeoutMs: advice.adjustedTimeoutMs ?? undefined,
|
|
741
|
+
enriched: !!advice.promptEnrichment,
|
|
742
|
+
escalated: advice.shouldEscalate,
|
|
743
|
+
};
|
|
744
|
+
logger.info({
|
|
745
|
+
job: job.name,
|
|
746
|
+
...advisorApplied,
|
|
747
|
+
}, 'Execution advisor applied overrides');
|
|
748
|
+
}
|
|
749
|
+
// Compute effective timeout: advisor override > standard default
|
|
750
|
+
const effectiveTimeoutMs = job.mode !== 'unleashed'
|
|
751
|
+
? (advice.adjustedTimeoutMs ?? CRON_STANDARD_TIMEOUT_MS)
|
|
752
|
+
: undefined;
|
|
753
|
+
// Persist advisor decision for analytics
|
|
754
|
+
if (advisorApplied) {
|
|
755
|
+
try {
|
|
756
|
+
mkdirSync(path.dirname(ADVISOR_LOG_PATH), { recursive: true });
|
|
757
|
+
appendFileSync(ADVISOR_LOG_PATH, JSON.stringify({
|
|
758
|
+
jobName: job.name,
|
|
759
|
+
timestamp: new Date().toISOString(),
|
|
760
|
+
advice,
|
|
761
|
+
originalModel: originalJob.model,
|
|
762
|
+
originalMaxTurns: originalJob.maxTurns,
|
|
763
|
+
}) + '\n');
|
|
764
|
+
}
|
|
765
|
+
catch { /* non-fatal */ }
|
|
766
|
+
}
|
|
767
|
+
this.runningJobs.add(job.name);
|
|
768
|
+
this.emitStatusChange();
|
|
769
|
+
try {
|
|
770
|
+
logger.info(`Running cron job: ${job.name}${job.agentSlug ? ` (agent: ${job.agentSlug})` : ''}`);
|
|
771
|
+
// Set agent profile for scoped cron jobs
|
|
772
|
+
const cronSessionKey = `cron:${job.name}`;
|
|
773
|
+
if (job.agentSlug) {
|
|
774
|
+
this.gateway.setSessionProfile(cronSessionKey, job.agentSlug);
|
|
775
|
+
}
|
|
776
|
+
// Unleashed tasks handle their own retries/phases internally — never retry the whole task
|
|
777
|
+
const priorErrors = this.runLog.consecutiveErrors(job.name);
|
|
778
|
+
const maxAttempts = job.mode === 'unleashed'
|
|
779
|
+
? 1
|
|
780
|
+
: 1 + (job.maxRetries ?? Math.min(priorErrors, BACKOFF_MS.length));
|
|
781
|
+
// ── Inject context field if present ──
|
|
782
|
+
let jobPrompt = job.prompt;
|
|
783
|
+
if (job.context) {
|
|
784
|
+
jobPrompt = `## Context\n${job.context}\n\n${jobPrompt}`;
|
|
785
|
+
}
|
|
786
|
+
// ── Inject attachment content if present ──
|
|
787
|
+
const attachDir = path.join(BASE_DIR, 'attachments', job.name.replace(/[^a-zA-Z0-9_:-]/g, '_'));
|
|
788
|
+
if (existsSync(attachDir)) {
|
|
789
|
+
try {
|
|
790
|
+
const attachFiles = readdirSync(attachDir);
|
|
791
|
+
const textExts = ['.csv', '.md', '.txt', '.json', '.tsv'];
|
|
792
|
+
let attachContent = '';
|
|
793
|
+
let totalChars = 0;
|
|
794
|
+
const MAX_ATTACH = 50_000;
|
|
795
|
+
for (const af of attachFiles) {
|
|
796
|
+
const ext = path.extname(af).toLowerCase();
|
|
797
|
+
const afPath = path.join(attachDir, af);
|
|
798
|
+
if (textExts.includes(ext)) {
|
|
799
|
+
const content = readFileSync(afPath, 'utf-8');
|
|
800
|
+
if (totalChars + content.length > MAX_ATTACH) {
|
|
801
|
+
attachContent += `\n### ${af}\n*(truncated — available at: ${afPath})*\n`;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
attachContent += `\n### ${af}\n\`\`\`\n${content}\n\`\`\`\n`;
|
|
805
|
+
totalChars += content.length;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
attachContent += `\n### ${af}\n*(binary file — available at: ${afPath})*\n`;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (attachContent) {
|
|
812
|
+
jobPrompt = `## Reference Files\nThese files were attached for context:\n${attachContent}\n\n${jobPrompt}`;
|
|
813
|
+
logger.info({ job: job.name, files: attachFiles.length }, 'Injected attachments into prompt');
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
catch { /* non-fatal */ }
|
|
817
|
+
}
|
|
818
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
819
|
+
const startedAt = new Date();
|
|
820
|
+
try {
|
|
821
|
+
// Standard cron jobs get a timeout via SDK AbortController (advisor may override)
|
|
822
|
+
let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria);
|
|
823
|
+
// alwaysDeliver: retry once if the response is empty/noise
|
|
824
|
+
if (job.alwaysDeliver && (!response || CronScheduler.isCronNoise(response))) {
|
|
825
|
+
logger.info({ job: job.name }, 'alwaysDeliver: empty/noise response — retrying once');
|
|
826
|
+
try {
|
|
827
|
+
const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria);
|
|
828
|
+
if (retryResponse && !CronScheduler.isCronNoise(retryResponse)) {
|
|
829
|
+
response = retryResponse;
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
// Fallback: minimal check-in message
|
|
833
|
+
response = `${job.name}: Checked in, nothing notable today.`;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
catch (retryErr) {
|
|
837
|
+
logger.warn({ err: retryErr, job: job.name }, 'alwaysDeliver retry failed — using fallback');
|
|
838
|
+
response = `${job.name}: Checked in, nothing notable today.`;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
// Success — log and dispatch
|
|
842
|
+
const finishedAt = new Date();
|
|
843
|
+
const terminalReason = this.gateway.consumeLastTerminalReason();
|
|
844
|
+
const entry = {
|
|
845
|
+
jobName: job.name,
|
|
846
|
+
startedAt: startedAt.toISOString(),
|
|
847
|
+
finishedAt: finishedAt.toISOString(),
|
|
848
|
+
status: 'ok',
|
|
849
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
850
|
+
attempt,
|
|
851
|
+
outputPreview: response ? response.slice(0, 200) : undefined,
|
|
852
|
+
advisorApplied,
|
|
853
|
+
terminalReason,
|
|
854
|
+
};
|
|
855
|
+
if (response && !CronScheduler.isCronNoise(response)) {
|
|
856
|
+
// Strip internal thinking/process narration from Discord output
|
|
857
|
+
const cleanedResponse = CronScheduler.stripThinkingPrefixes(response);
|
|
858
|
+
const result = await this.dispatcher.send(cleanedResponse, { agentSlug: job.agentSlug });
|
|
859
|
+
if (!result.delivered) {
|
|
860
|
+
entry.deliveryFailed = true;
|
|
861
|
+
entry.deliveryError = Object.values(result.channelErrors).join('; ').slice(0, 300);
|
|
862
|
+
// Preserve more output when delivery fails so it's recoverable
|
|
863
|
+
entry.outputPreview = response.slice(0, 2000);
|
|
864
|
+
logger.warn({ job: job.name, errors: result.channelErrors }, 'Cron output not delivered to any channel');
|
|
865
|
+
// Surface delivery failure in daily note so the user can discover it
|
|
866
|
+
logToDailyNote(`**[Delivery failed]** ${job.name} — output saved but couldn't reach any channel`);
|
|
867
|
+
}
|
|
868
|
+
else if (Object.keys(result.channelErrors).length > 0) {
|
|
869
|
+
// Partial success — some channels failed. Log so broken channels are visible.
|
|
870
|
+
entry.deliveryError = `partial: ${Object.entries(result.channelErrors).map(([ch, e]) => `${ch}: ${e}`).join('; ').slice(0, 300)}`;
|
|
871
|
+
logger.warn({ job: job.name, errors: result.channelErrors }, 'Cron output delivered but some channels failed');
|
|
872
|
+
}
|
|
873
|
+
// Inject into owner's DM session so follow-up conversation has context
|
|
874
|
+
if (DISCORD_OWNER_ID && DISCORD_OWNER_ID !== '0') {
|
|
875
|
+
this.gateway.injectContext(`discord:user:${DISCORD_OWNER_ID}`, `[Scheduled cron: ${job.name}]`, response);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
this.runLog.append(entry);
|
|
879
|
+
// Fire-and-forget: extract procedural skill from successful long-running cron jobs
|
|
880
|
+
if (entry.status === 'ok' && entry.durationMs > 30_000 && response && response.length > 500) {
|
|
881
|
+
this.gateway.extractCronSkill(job.name, job.prompt, response, entry.durationMs, job.agentSlug)
|
|
882
|
+
.catch(err => logger.debug({ err, job: job.name }, 'Cron skill extraction failed'));
|
|
883
|
+
}
|
|
884
|
+
// Log to daily note so end-of-day summary has data to work with
|
|
885
|
+
const durationSec = Math.round(entry.durationMs / 1000);
|
|
886
|
+
const preview = response ? response.slice(0, 100).replace(/\n/g, ' ') : 'no output';
|
|
887
|
+
logToDailyNote(`**${job.name}** (${durationSec}s): ${preview}`);
|
|
888
|
+
// Fire dependent chained jobs (async, non-blocking)
|
|
889
|
+
const dependents = this.jobs.filter(j => j.after === job.name && j.enabled && !this.disabledJobs.has(j.name));
|
|
890
|
+
for (const dep of dependents) {
|
|
891
|
+
logger.info(`Chain: '${job.name}' succeeded — triggering '${dep.name}'`);
|
|
892
|
+
this.runJob(dep).catch((err) => {
|
|
893
|
+
logger.error({ err, job: dep.name }, `Chained job '${dep.name}' failed`);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
return; // done
|
|
897
|
+
}
|
|
898
|
+
catch (err) {
|
|
899
|
+
const finishedAt = new Date();
|
|
900
|
+
const errTerminalReason = this.gateway.consumeLastTerminalReason();
|
|
901
|
+
const errorType = errTerminalReason
|
|
902
|
+
? classifyTerminalReason(errTerminalReason)
|
|
903
|
+
: classifyError(err);
|
|
904
|
+
this.runLog.append({
|
|
905
|
+
jobName: job.name,
|
|
906
|
+
startedAt: startedAt.toISOString(),
|
|
907
|
+
finishedAt: finishedAt.toISOString(),
|
|
908
|
+
status: attempt < maxAttempts && errorType === 'transient' ? 'retried' : 'error',
|
|
909
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
910
|
+
error: String(err).slice(0, 1500),
|
|
911
|
+
errorType,
|
|
912
|
+
terminalReason: errTerminalReason,
|
|
913
|
+
attempt,
|
|
914
|
+
advisorApplied,
|
|
915
|
+
});
|
|
916
|
+
// Permanent error — stop immediately
|
|
917
|
+
if (errorType === 'permanent') {
|
|
918
|
+
logger.error({ err, job: job.name }, `Cron job '${job.name}' permanent error — not retrying`);
|
|
919
|
+
await this.dispatcher.send(`${job.name} failed: ${err}`, { agentSlug: job.agentSlug });
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
// Transient — retry with backoff if attempts remain
|
|
923
|
+
if (attempt < maxAttempts) {
|
|
924
|
+
const backoffMs = BACKOFF_MS[Math.min(attempt - 1, BACKOFF_MS.length - 1)];
|
|
925
|
+
logger.warn({ job: job.name, attempt, backoffMs }, `Cron job '${job.name}' transient error — retrying in ${backoffMs / 1000}s`);
|
|
926
|
+
await sleep(backoffMs);
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
logger.error({ err, job: job.name }, `Cron job '${job.name}' failed after ${attempt} attempt(s)`);
|
|
930
|
+
await this.dispatcher.send(CronScheduler.formatCronError(job.name, err), { agentSlug: job.agentSlug });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
this.runningJobs.delete(job.name);
|
|
937
|
+
this.emitStatusChange();
|
|
938
|
+
// Fire-and-forget: check if this agent's profile needs self-learning update
|
|
939
|
+
if (job.agentSlug) {
|
|
940
|
+
this.checkAgentLearning(job.agentSlug, job.name).catch(err => logger.debug({ err, job: job.name }, 'Agent learning check failed'));
|
|
941
|
+
}
|
|
942
|
+
// Close the feedback loop: append outcome to advisor decision log
|
|
943
|
+
if (advisorApplied) {
|
|
944
|
+
try {
|
|
945
|
+
const lastRun = this.runLog.readRecent(job.name, 1)[0];
|
|
946
|
+
if (lastRun) {
|
|
947
|
+
appendFileSync(ADVISOR_LOG_PATH, JSON.stringify({
|
|
948
|
+
jobName: job.name,
|
|
949
|
+
timestamp: new Date().toISOString(),
|
|
950
|
+
type: 'outcome',
|
|
951
|
+
interventions: advisorApplied,
|
|
952
|
+
outcome: lastRun.status,
|
|
953
|
+
durationMs: lastRun.durationMs,
|
|
954
|
+
}) + '\n');
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch { /* non-fatal */ }
|
|
958
|
+
}
|
|
959
|
+
// Notify on circuit breaker and escalation events
|
|
960
|
+
const consErrors = this.runLog.consecutiveErrors(job.name);
|
|
961
|
+
if (consErrors === 5) {
|
|
962
|
+
// Circuit breaker just engaged — notify
|
|
963
|
+
this.logAdvisorEvent('circuit-breaker', job.name, `Circuit breaker engaged after ${consErrors} consecutive errors`);
|
|
964
|
+
this.dispatcher.send(`⚡ **Circuit breaker engaged** for \`${job.name}\` — ${consErrors} consecutive errors. Will retry in 1 hour.`, { agentSlug: job.agentSlug }).catch(err => logger.debug({ err }, 'Failed to send circuit breaker notification'));
|
|
965
|
+
}
|
|
966
|
+
else if (consErrors >= 5) {
|
|
967
|
+
// Check if recovery probe just succeeded
|
|
968
|
+
const lastRun = this.runLog.readRecent(job.name, 1)[0];
|
|
969
|
+
if (lastRun?.status === 'ok') {
|
|
970
|
+
this.logAdvisorEvent('circuit-recovery', job.name, `Circuit breaker recovered after ${consErrors} errors`);
|
|
971
|
+
this.dispatcher.send(`✅ **Circuit breaker recovered** — \`${job.name}\` succeeded after ${consErrors} prior errors.`, { agentSlug: job.agentSlug }).catch(err => logger.debug({ err }, 'Failed to send circuit recovery notification'));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (advice.shouldEscalate) {
|
|
975
|
+
this.logAdvisorEvent('escalation', job.name, advice.escalationReason ?? 'Escalated to unleashed');
|
|
976
|
+
}
|
|
977
|
+
// Write targeted self-improvement trigger when consecutive errors are high
|
|
978
|
+
if (consErrors >= 3) {
|
|
979
|
+
try {
|
|
980
|
+
const triggerDir = path.join(BASE_DIR, 'self-improve', 'triggers');
|
|
981
|
+
mkdirSync(triggerDir, { recursive: true });
|
|
982
|
+
const triggerPath = path.join(triggerDir, `${job.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
|
|
983
|
+
writeFileSync(triggerPath, JSON.stringify({
|
|
984
|
+
jobName: job.name,
|
|
985
|
+
consecutiveErrors: consErrors,
|
|
986
|
+
recentErrors: this.runLog.readRecent(job.name, 3).map(e => e.error?.slice(0, 200)),
|
|
987
|
+
triggeredAt: new Date().toISOString(),
|
|
988
|
+
}, null, 2));
|
|
989
|
+
logger.info({ job: job.name, consErrors }, 'Wrote self-improvement trigger for failing job');
|
|
990
|
+
}
|
|
991
|
+
catch { /* non-fatal */ }
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Log an advisor event to the events JSONL file for dashboard surfacing.
|
|
997
|
+
*/
|
|
998
|
+
logAdvisorEvent(type, jobName, detail) {
|
|
999
|
+
try {
|
|
1000
|
+
const eventsPath = path.join(BASE_DIR, 'cron', 'advisor-events.jsonl');
|
|
1001
|
+
mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
1002
|
+
appendFileSync(eventsPath, JSON.stringify({
|
|
1003
|
+
type,
|
|
1004
|
+
jobName,
|
|
1005
|
+
detail,
|
|
1006
|
+
timestamp: new Date().toISOString(),
|
|
1007
|
+
}) + '\n');
|
|
1008
|
+
}
|
|
1009
|
+
catch { /* non-fatal */ }
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Check if an agent's recent cron reflections show consistently low quality.
|
|
1013
|
+
* If the last N runs average below the threshold, auto-append a "lessons learned"
|
|
1014
|
+
* section to the agent's profile. This is additive (not destructive) — it
|
|
1015
|
+
* only appends insights, never overwrites the core agent prompt.
|
|
1016
|
+
*/
|
|
1017
|
+
async checkAgentLearning(agentSlug, jobName) {
|
|
1018
|
+
const MIN_RUNS = 5;
|
|
1019
|
+
const QUALITY_THRESHOLD = 3.0;
|
|
1020
|
+
try {
|
|
1021
|
+
// Read the agent's reflection log
|
|
1022
|
+
const safeJobName = jobName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
1023
|
+
const logFile = path.join(CRON_REFLECTIONS_DIR, `${safeJobName}.jsonl`);
|
|
1024
|
+
if (!existsSync(logFile))
|
|
1025
|
+
return;
|
|
1026
|
+
const lines = readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
|
|
1027
|
+
if (lines.length < MIN_RUNS)
|
|
1028
|
+
return;
|
|
1029
|
+
// Parse the last N reflections
|
|
1030
|
+
const recent = lines.slice(-MIN_RUNS).map((l) => {
|
|
1031
|
+
try {
|
|
1032
|
+
return JSON.parse(l);
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
}).filter(Boolean);
|
|
1038
|
+
if (recent.length < MIN_RUNS)
|
|
1039
|
+
return;
|
|
1040
|
+
const avgQuality = recent.reduce((sum, r) => sum + (r.quality ?? 0), 0) / recent.length;
|
|
1041
|
+
if (avgQuality >= QUALITY_THRESHOLD)
|
|
1042
|
+
return;
|
|
1043
|
+
// Quality is consistently low — extract lessons from gaps
|
|
1044
|
+
const gaps = recent
|
|
1045
|
+
.map((r) => r.gap)
|
|
1046
|
+
.filter((g) => g && g !== 'none' && g.length > 5);
|
|
1047
|
+
if (gaps.length === 0)
|
|
1048
|
+
return;
|
|
1049
|
+
// Check if we've already added lessons recently (prevent spam)
|
|
1050
|
+
const profilePath = path.join(AGENTS_DIR, agentSlug, 'agent.md');
|
|
1051
|
+
if (!existsSync(profilePath))
|
|
1052
|
+
return;
|
|
1053
|
+
const profile = readFileSync(profilePath, 'utf-8');
|
|
1054
|
+
const lessonsMarker = '## Lessons Learned (auto-generated)';
|
|
1055
|
+
// Only update at most once per week
|
|
1056
|
+
const lastLessonMatch = profile.match(/<!-- lessons-updated: (\d{4}-\d{2}-\d{2}) -->/);
|
|
1057
|
+
if (lastLessonMatch) {
|
|
1058
|
+
const lastUpdate = new Date(lastLessonMatch[1]);
|
|
1059
|
+
const daysSince = (Date.now() - lastUpdate.getTime()) / 86_400_000;
|
|
1060
|
+
if (daysSince < 7)
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
// Deduplicate and summarize gaps
|
|
1064
|
+
const uniqueGaps = [...new Set(gaps)].slice(0, 5);
|
|
1065
|
+
const lessonsBlock = `\n\n${lessonsMarker}\n` +
|
|
1066
|
+
`<!-- lessons-updated: ${todayISO()} -->\n` +
|
|
1067
|
+
`_Based on ${MIN_RUNS} recent runs (avg quality: ${avgQuality.toFixed(1)}/5):_\n\n` +
|
|
1068
|
+
uniqueGaps.map((g) => `- ${g}`).join('\n') + '\n';
|
|
1069
|
+
// Append or replace the lessons section
|
|
1070
|
+
let updatedProfile;
|
|
1071
|
+
const existingIdx = profile.indexOf(lessonsMarker);
|
|
1072
|
+
if (existingIdx >= 0) {
|
|
1073
|
+
// Replace existing lessons section (everything from marker to end or next ##)
|
|
1074
|
+
const afterMarker = profile.slice(existingIdx);
|
|
1075
|
+
const nextSection = afterMarker.indexOf('\n## ', lessonsMarker.length);
|
|
1076
|
+
if (nextSection >= 0) {
|
|
1077
|
+
updatedProfile = profile.slice(0, existingIdx) + lessonsBlock + afterMarker.slice(nextSection);
|
|
1078
|
+
}
|
|
1079
|
+
else {
|
|
1080
|
+
updatedProfile = profile.slice(0, existingIdx) + lessonsBlock;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
updatedProfile = profile + lessonsBlock;
|
|
1085
|
+
}
|
|
1086
|
+
writeFileSync(profilePath, updatedProfile);
|
|
1087
|
+
logger.info({ agent: agentSlug, avgQuality: avgQuality.toFixed(1), gaps: uniqueGaps.length }, `Auto-appended ${uniqueGaps.length} lessons to ${agentSlug}/agent.md (avg quality: ${avgQuality.toFixed(1)})`);
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
logger.debug({ err, agent: agentSlug }, 'Agent learning check failed (non-fatal)');
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
async runManual(jobName) {
|
|
1094
|
+
const job = this.jobs.find((j) => j.name === jobName);
|
|
1095
|
+
if (!job) {
|
|
1096
|
+
return `Cron job '${jobName}' not found. Use \`!cron list\` to see available jobs.`;
|
|
1097
|
+
}
|
|
1098
|
+
if (this.runningJobs.has(jobName)) {
|
|
1099
|
+
return `Cron job '${jobName}' is already running.`;
|
|
1100
|
+
}
|
|
1101
|
+
try {
|
|
1102
|
+
await this.runJob(job);
|
|
1103
|
+
return `*(cron job '${jobName}' completed)*`;
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
return CronScheduler.formatCronError(jobName, err);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/** Filter out cron responses that are truly empty or nothing-to-report. */
|
|
1110
|
+
/** Strip internal reasoning/thinking prefixes from cron output before sending to Discord. */
|
|
1111
|
+
static stripThinkingPrefixes(response) {
|
|
1112
|
+
// Split into lines and skip leading process narration
|
|
1113
|
+
const lines = response.split('\n');
|
|
1114
|
+
const thinkingPatterns = [
|
|
1115
|
+
/^I('ll| will| need to| found| can see| should|'m going to) /i,
|
|
1116
|
+
/^Let me /i,
|
|
1117
|
+
/^Now (let me|I'll|I need) /i,
|
|
1118
|
+
/^(First|Next),? (let me|I'll|I need) /i,
|
|
1119
|
+
/^(Checking|Looking|Searching|Reading|Fetching|Pulling|Querying) /i,
|
|
1120
|
+
];
|
|
1121
|
+
let startIdx = 0;
|
|
1122
|
+
for (let i = 0; i < Math.min(lines.length, 5); i++) {
|
|
1123
|
+
const line = lines[i].trim();
|
|
1124
|
+
if (!line) {
|
|
1125
|
+
startIdx = i + 1;
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (thinkingPatterns.some(p => p.test(line))) {
|
|
1129
|
+
startIdx = i + 1;
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (startIdx > 0 && startIdx < lines.length) {
|
|
1136
|
+
return lines.slice(startIdx).join('\n').trim();
|
|
1137
|
+
}
|
|
1138
|
+
return response;
|
|
1139
|
+
}
|
|
1140
|
+
static isCronNoise(response) {
|
|
1141
|
+
const trimmed = response.trim();
|
|
1142
|
+
if (trimmed === '__NOTHING__')
|
|
1143
|
+
return true;
|
|
1144
|
+
// Only treat as noise if the response is short — avoids filtering out
|
|
1145
|
+
// substantive responses that happen to start with "No updates, but..."
|
|
1146
|
+
if (trimmed.length > 80)
|
|
1147
|
+
return false;
|
|
1148
|
+
const lower = trimmed.toLowerCase();
|
|
1149
|
+
const noisePatterns = [
|
|
1150
|
+
'nothing to report',
|
|
1151
|
+
'nothing new to report',
|
|
1152
|
+
'all clear',
|
|
1153
|
+
'no updates',
|
|
1154
|
+
'completing silently',
|
|
1155
|
+
];
|
|
1156
|
+
if (noisePatterns.some((p) => lower.startsWith(p) || lower === p))
|
|
1157
|
+
return true;
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
/** Format cron error messages for clean notifications. */
|
|
1161
|
+
static formatCronError(jobName, err) {
|
|
1162
|
+
let msg = String(err);
|
|
1163
|
+
// Strip "Error: " prefix
|
|
1164
|
+
msg = msg.replace(/^Error:\s*/i, '');
|
|
1165
|
+
// Strip stack traces
|
|
1166
|
+
const stackIdx = msg.indexOf('\n at ');
|
|
1167
|
+
if (stackIdx > 0)
|
|
1168
|
+
msg = msg.slice(0, stackIdx);
|
|
1169
|
+
// Replace exit code messages
|
|
1170
|
+
msg = msg.replace(/Claude Code process exited with code \d+/i, 'Task could not complete');
|
|
1171
|
+
// Truncate
|
|
1172
|
+
if (msg.length > 300)
|
|
1173
|
+
msg = msg.slice(0, 297) + '...';
|
|
1174
|
+
return `${jobName} failed: ${msg.trim()}`;
|
|
1175
|
+
}
|
|
1176
|
+
listJobs() {
|
|
1177
|
+
if (this.jobs.length === 0) {
|
|
1178
|
+
this.reloadJobs();
|
|
1179
|
+
}
|
|
1180
|
+
if (this.jobs.length === 0) {
|
|
1181
|
+
return 'No cron jobs configured. Edit `vault/00-System/CRON.md` to add jobs.';
|
|
1182
|
+
}
|
|
1183
|
+
const lines = ['**Scheduled Cron Jobs:**\n'];
|
|
1184
|
+
for (const job of this.jobs) {
|
|
1185
|
+
const enabled = job.enabled && !this.disabledJobs.has(job.name);
|
|
1186
|
+
const status = enabled ? 'enabled' : 'disabled';
|
|
1187
|
+
const modeTag = job.mode === 'unleashed' ? ' [unleashed]' : '';
|
|
1188
|
+
const chainTag = job.after ? ` → after "${job.after}"` : '';
|
|
1189
|
+
const retryTag = job.maxRetries != null ? ` [max ${job.maxRetries} retries]` : '';
|
|
1190
|
+
lines.push(`- **${job.name}** (\`${job.schedule}\`) — ${status}${modeTag}${chainTag}${retryTag}`);
|
|
1191
|
+
lines.push(` _${job.prompt.slice(0, 80)}_`);
|
|
1192
|
+
}
|
|
1193
|
+
return lines.join('\n');
|
|
1194
|
+
}
|
|
1195
|
+
getJobNames() {
|
|
1196
|
+
return this.jobs.map((j) => j.name);
|
|
1197
|
+
}
|
|
1198
|
+
getJob(jobName) {
|
|
1199
|
+
return this.jobs.find((j) => j.name === jobName);
|
|
1200
|
+
}
|
|
1201
|
+
isJobRunning(jobName) {
|
|
1202
|
+
return this.runningJobs.has(jobName);
|
|
1203
|
+
}
|
|
1204
|
+
getRunningJobs() {
|
|
1205
|
+
return [...this.runningJobs];
|
|
1206
|
+
}
|
|
1207
|
+
getRunningWorkflowNames() {
|
|
1208
|
+
return [...this.runningWorkflows];
|
|
1209
|
+
}
|
|
1210
|
+
/** Return all job definitions with enabled/disabled state for the status embed. */
|
|
1211
|
+
getJobDefinitions() {
|
|
1212
|
+
return this.jobs.map(j => ({
|
|
1213
|
+
...j,
|
|
1214
|
+
active: j.enabled && !this.disabledJobs.has(j.name),
|
|
1215
|
+
}));
|
|
1216
|
+
}
|
|
1217
|
+
/** Get today's run stats: total runs, successes, failures (since local midnight). */
|
|
1218
|
+
getTodayStats() {
|
|
1219
|
+
const midnight = new Date();
|
|
1220
|
+
midnight.setHours(0, 0, 0, 0);
|
|
1221
|
+
const midnightISO = midnight.toISOString();
|
|
1222
|
+
let total = 0, ok = 0, errors = 0, skipped = 0;
|
|
1223
|
+
for (const job of this.jobs) {
|
|
1224
|
+
const entries = this.runLog.readRecent(job.name, 50);
|
|
1225
|
+
for (const e of entries) {
|
|
1226
|
+
if (e.startedAt < midnightISO)
|
|
1227
|
+
break;
|
|
1228
|
+
total++;
|
|
1229
|
+
if (e.status === 'ok')
|
|
1230
|
+
ok++;
|
|
1231
|
+
else if (e.status === 'error')
|
|
1232
|
+
errors++;
|
|
1233
|
+
else if (e.status === 'skipped')
|
|
1234
|
+
skipped++;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return { total, ok, errors, skipped };
|
|
1238
|
+
}
|
|
1239
|
+
disableJob(jobName) {
|
|
1240
|
+
const job = this.jobs.find((j) => j.name === jobName);
|
|
1241
|
+
if (!job)
|
|
1242
|
+
return `Job not found: ${jobName}`;
|
|
1243
|
+
this.disabledJobs.add(jobName);
|
|
1244
|
+
const task = this.scheduledTasks.get(jobName);
|
|
1245
|
+
if (task) {
|
|
1246
|
+
task.stop();
|
|
1247
|
+
this.scheduledTasks.delete(jobName);
|
|
1248
|
+
}
|
|
1249
|
+
return `Disabled cron job: ${jobName}`;
|
|
1250
|
+
}
|
|
1251
|
+
enableJob(jobName) {
|
|
1252
|
+
this.disabledJobs.delete(jobName);
|
|
1253
|
+
this.completedJobs.delete(jobName);
|
|
1254
|
+
this.reloadJobs();
|
|
1255
|
+
return `Enabled cron job: ${jobName}`;
|
|
1256
|
+
}
|
|
1257
|
+
// ── Workflow support ──────────────────────────────────────────────
|
|
1258
|
+
reloadWorkflows() {
|
|
1259
|
+
// Stop existing workflow scheduled tasks
|
|
1260
|
+
for (const [name, task] of this.workflowTasks) {
|
|
1261
|
+
task.stop();
|
|
1262
|
+
logger.debug(`Stopped workflow task: ${name}`);
|
|
1263
|
+
}
|
|
1264
|
+
this.workflowTasks.clear();
|
|
1265
|
+
try {
|
|
1266
|
+
this.workflowDefs = parseAllWorkflowsSync(WORKFLOWS_DIR);
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
this.workflowDefs = [];
|
|
1270
|
+
}
|
|
1271
|
+
// Merge in agent-scoped workflows
|
|
1272
|
+
if (existsSync(AGENTS_DIR)) {
|
|
1273
|
+
try {
|
|
1274
|
+
const dirs = readdirSync(AGENTS_DIR, { withFileTypes: true })
|
|
1275
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
|
|
1276
|
+
.map((d) => d.name);
|
|
1277
|
+
for (const slug of dirs) {
|
|
1278
|
+
const wfDir = path.join(AGENTS_DIR, slug, 'workflows');
|
|
1279
|
+
if (!existsSync(wfDir))
|
|
1280
|
+
continue;
|
|
1281
|
+
try {
|
|
1282
|
+
const agentWfs = parseAllWorkflowsSync(wfDir);
|
|
1283
|
+
for (const wf of agentWfs) {
|
|
1284
|
+
wf.name = `${slug}:${wf.name}`;
|
|
1285
|
+
wf.agentSlug = slug;
|
|
1286
|
+
this.workflowDefs.push(wf);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
logger.warn(`Failed to parse workflows for agent ${slug}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
catch { /* agents dir not readable */ }
|
|
1295
|
+
}
|
|
1296
|
+
if (this.workflowDefs.length === 0) {
|
|
1297
|
+
logger.debug('No workflows found');
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
// Schedule workflows with cron triggers
|
|
1301
|
+
for (const wf of this.workflowDefs) {
|
|
1302
|
+
if (!wf.enabled || !wf.trigger.schedule)
|
|
1303
|
+
continue;
|
|
1304
|
+
if (!cron.validate(wf.trigger.schedule)) {
|
|
1305
|
+
logger.error(`Invalid cron schedule for workflow '${wf.name}': ${wf.trigger.schedule}`);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
const task = cron.schedule(wf.trigger.schedule, () => {
|
|
1309
|
+
this.runWorkflow(wf.name).catch(err => {
|
|
1310
|
+
logger.error({ err, workflow: wf.name }, `Scheduled workflow '${wf.name}' failed`);
|
|
1311
|
+
});
|
|
1312
|
+
}, { timezone: SYSTEM_TIMEZONE });
|
|
1313
|
+
this.workflowTasks.set(wf.name, task);
|
|
1314
|
+
logger.info(`Workflow '${wf.name}' scheduled: ${wf.trigger.schedule} (${SYSTEM_TIMEZONE})`);
|
|
1315
|
+
}
|
|
1316
|
+
logger.info(`Loaded ${this.workflowDefs.length} workflow(s), ${this.workflowTasks.size} scheduled`);
|
|
1317
|
+
}
|
|
1318
|
+
watchWorkflowDir() {
|
|
1319
|
+
if (this.watchingWorkflows)
|
|
1320
|
+
return;
|
|
1321
|
+
if (!existsSync(WORKFLOWS_DIR))
|
|
1322
|
+
return;
|
|
1323
|
+
watchFile(WORKFLOWS_DIR, { interval: 2000 }, () => {
|
|
1324
|
+
logger.info('Workflows directory changed — reloading');
|
|
1325
|
+
try {
|
|
1326
|
+
this.reloadWorkflows();
|
|
1327
|
+
scanner.refreshIntegrity();
|
|
1328
|
+
}
|
|
1329
|
+
catch (err) {
|
|
1330
|
+
logger.error({ err }, 'Failed to reload workflows');
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
this.watchingWorkflows = true;
|
|
1334
|
+
}
|
|
1335
|
+
unwatchWorkflowDir() {
|
|
1336
|
+
if (!this.watchingWorkflows)
|
|
1337
|
+
return;
|
|
1338
|
+
try {
|
|
1339
|
+
unwatchFile(WORKFLOWS_DIR);
|
|
1340
|
+
}
|
|
1341
|
+
catch { /* ignore */ }
|
|
1342
|
+
this.watchingWorkflows = false;
|
|
1343
|
+
}
|
|
1344
|
+
/** Watch the triggers directory for MCP-initiated job runs and goal work sessions. */
|
|
1345
|
+
watchTriggers() {
|
|
1346
|
+
mkdirSync(this.triggerDir, { recursive: true });
|
|
1347
|
+
mkdirSync(this.goalTriggerDir, { recursive: true });
|
|
1348
|
+
this.triggerTimer = setInterval(() => {
|
|
1349
|
+
this.processTriggers();
|
|
1350
|
+
this.processGoalTriggers();
|
|
1351
|
+
}, 3000);
|
|
1352
|
+
}
|
|
1353
|
+
/** Process any pending trigger files and run the corresponding jobs. */
|
|
1354
|
+
processTriggers() {
|
|
1355
|
+
if (!existsSync(this.triggerDir))
|
|
1356
|
+
return;
|
|
1357
|
+
let files;
|
|
1358
|
+
try {
|
|
1359
|
+
files = readdirSync(this.triggerDir).filter(f => f.endsWith('.trigger'));
|
|
1360
|
+
}
|
|
1361
|
+
catch {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
for (const file of files) {
|
|
1365
|
+
const filePath = path.join(this.triggerDir, file);
|
|
1366
|
+
try {
|
|
1367
|
+
const jobName = readFileSync(filePath, 'utf-8').trim();
|
|
1368
|
+
unlinkSync(filePath);
|
|
1369
|
+
if (!jobName)
|
|
1370
|
+
continue;
|
|
1371
|
+
logger.info({ jobName }, 'Processing MCP trigger for cron job');
|
|
1372
|
+
this.runManual(jobName).then((result) => {
|
|
1373
|
+
if (result && !result.includes('not found')) {
|
|
1374
|
+
this.dispatcher.send(`🔧 **${jobName}** (triggered):\n\n${result.slice(0, 1500)}`).catch(err => logger.debug({ err }, 'Failed to send trigger result notification'));
|
|
1375
|
+
}
|
|
1376
|
+
}).catch((err) => {
|
|
1377
|
+
logger.error({ err, jobName }, 'Trigger-initiated job failed');
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
catch (err) {
|
|
1381
|
+
logger.warn({ err, file }, 'Failed to process trigger file');
|
|
1382
|
+
try {
|
|
1383
|
+
unlinkSync(filePath);
|
|
1384
|
+
}
|
|
1385
|
+
catch { /* ignore */ }
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
/** Process any pending goal work trigger files. Routes through the execution advisor. */
|
|
1390
|
+
processGoalTriggers() {
|
|
1391
|
+
if (!existsSync(this.goalTriggerDir))
|
|
1392
|
+
return;
|
|
1393
|
+
let files;
|
|
1394
|
+
try {
|
|
1395
|
+
files = readdirSync(this.goalTriggerDir).filter(f => f.endsWith('.trigger.json'));
|
|
1396
|
+
}
|
|
1397
|
+
catch {
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
for (const file of files) {
|
|
1401
|
+
const filePath = path.join(this.goalTriggerDir, file);
|
|
1402
|
+
try {
|
|
1403
|
+
const trigger = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
1404
|
+
unlinkSync(filePath);
|
|
1405
|
+
if (!trigger.goalId)
|
|
1406
|
+
continue;
|
|
1407
|
+
const goalPath = path.join(GOALS_DIR, `${trigger.goalId}.json`);
|
|
1408
|
+
if (!existsSync(goalPath)) {
|
|
1409
|
+
logger.warn({ goalId: trigger.goalId }, 'Goal trigger references missing goal — skipping');
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
const goal = JSON.parse(readFileSync(goalPath, 'utf-8'));
|
|
1413
|
+
if (goal.status !== 'active')
|
|
1414
|
+
continue;
|
|
1415
|
+
logger.info({ goalId: trigger.goalId, title: goal.title, focus: trigger.focus }, 'Processing goal work trigger');
|
|
1416
|
+
// Load recent progress outcomes so the agent has context about what it already did
|
|
1417
|
+
let recentOutcomesContext = '';
|
|
1418
|
+
try {
|
|
1419
|
+
const progressFile = path.join(GOALS_DIR, 'progress', `${trigger.goalId}.progress.jsonl`);
|
|
1420
|
+
if (existsSync(progressFile)) {
|
|
1421
|
+
const lines = readFileSync(progressFile, 'utf-8').trim().split('\n').filter(Boolean);
|
|
1422
|
+
const recent = lines.slice(-5).map(l => { try {
|
|
1423
|
+
return JSON.parse(l);
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
return null;
|
|
1427
|
+
} }).filter(Boolean);
|
|
1428
|
+
if (recent.length > 0) {
|
|
1429
|
+
const summaries = recent.map((e) => {
|
|
1430
|
+
const ts = e.timestamp?.slice(0, 16) ?? '?';
|
|
1431
|
+
const snippet = (e.resultSnippet ?? '').slice(0, 100);
|
|
1432
|
+
const disposition = e.disposition ?? e.status;
|
|
1433
|
+
return `- [${ts}] ${disposition}: ${snippet}`;
|
|
1434
|
+
});
|
|
1435
|
+
recentOutcomesContext = `## Recent session outcomes (DO NOT repeat these — advance beyond them)\n${summaries.join('\n')}\n\n`;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
catch { /* non-fatal */ }
|
|
1440
|
+
// Build a cron-like prompt that focuses on the goal
|
|
1441
|
+
const prompt = `You are working on a focused goal session.\n\n` +
|
|
1442
|
+
`## Goal: ${goal.title}\n${goal.description}\n\n` +
|
|
1443
|
+
`## Focus for this session\n${trigger.focus}\n\n` +
|
|
1444
|
+
(goal.progressNotes?.length > 0
|
|
1445
|
+
? `## Prior progress\n${goal.progressNotes.slice(-5).map((n) => `- ${n}`).join('\n')}\n\n`
|
|
1446
|
+
: '') +
|
|
1447
|
+
recentOutcomesContext +
|
|
1448
|
+
(goal.nextActions?.length > 0
|
|
1449
|
+
? `## Planned next actions\n${goal.nextActions.map((a) => `- ${a}`).join('\n')}\n\n`
|
|
1450
|
+
: '') +
|
|
1451
|
+
(goal.blockers?.length > 0
|
|
1452
|
+
? `## Current blockers\n${goal.blockers.map((b) => `- ${b}`).join('\n')}\n\n`
|
|
1453
|
+
: '') +
|
|
1454
|
+
`## Instructions\n` +
|
|
1455
|
+
`1. Work on the focus area above. Use tools as needed.\n` +
|
|
1456
|
+
`2. When done, use \`goal_update\` to record progress notes, update next actions, and clear resolved blockers.\n` +
|
|
1457
|
+
`3. If blocked, add blockers and change status to "blocked".\n` +
|
|
1458
|
+
`4. **Classify your outcome** — end your response with exactly one of these tags:\n` +
|
|
1459
|
+
` - [ADVANCED] — made concrete new progress (data gathered, action taken, deliverable produced)\n` +
|
|
1460
|
+
` - [BLOCKED_ON_USER] — need a decision or action from the user before continuing\n` +
|
|
1461
|
+
` - [BLOCKED_ON_EXTERNAL] — waiting on external data, API, or event\n` +
|
|
1462
|
+
` - [NEEDS_DIFFERENT_APPROACH] — tried the current approach and it's not working\n` +
|
|
1463
|
+
` - [MONITORING] — checked status, nothing changed, will check again later\n` +
|
|
1464
|
+
`5. Keep your output concise — summarize what you accomplished.`;
|
|
1465
|
+
const jobName = `goal:${goal.title.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40)}`;
|
|
1466
|
+
const goalSnapshotUpdatedAt = goal.updatedAt;
|
|
1467
|
+
const goalSnapshotNotes = goal.progressNotes?.length ?? 0;
|
|
1468
|
+
// ── Route through execution advisor (same path as regular cron jobs) ──
|
|
1469
|
+
// Creates a synthetic CronJobDefinition so the advisor can apply circuit
|
|
1470
|
+
// breakers, turn-limit adjustments, model upgrades, and unleashed escalation.
|
|
1471
|
+
const syntheticJob = {
|
|
1472
|
+
name: jobName,
|
|
1473
|
+
schedule: '',
|
|
1474
|
+
prompt,
|
|
1475
|
+
enabled: true,
|
|
1476
|
+
tier: 2,
|
|
1477
|
+
maxTurns: trigger.maxTurns ?? 15,
|
|
1478
|
+
mode: 'standard',
|
|
1479
|
+
};
|
|
1480
|
+
import('../agent/execution-advisor.js').then(({ getExecutionAdvice }) => {
|
|
1481
|
+
const advice = getExecutionAdvice(jobName, syntheticJob);
|
|
1482
|
+
if (advice.shouldSkip) {
|
|
1483
|
+
logger.info({ goalId: trigger.goalId, reason: advice.skipReason }, 'Goal work skipped by advisor (circuit breaker)');
|
|
1484
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, `Skipped: ${advice.skipReason}`);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const effectiveMaxTurns = advice.adjustedMaxTurns ?? syntheticJob.maxTurns ?? 15;
|
|
1488
|
+
const effectiveModel = advice.adjustedModel ?? undefined;
|
|
1489
|
+
const useUnleashed = advice.shouldEscalate;
|
|
1490
|
+
const enrichedPrompt = advice.promptEnrichment ? `${prompt}\n\n${advice.promptEnrichment}` : prompt;
|
|
1491
|
+
logger.info({
|
|
1492
|
+
goalId: trigger.goalId,
|
|
1493
|
+
title: goal.title,
|
|
1494
|
+
maxTurns: effectiveMaxTurns,
|
|
1495
|
+
model: effectiveModel,
|
|
1496
|
+
unleashed: useUnleashed,
|
|
1497
|
+
enriched: !!advice.promptEnrichment,
|
|
1498
|
+
}, 'Goal work: advisor applied');
|
|
1499
|
+
this.gateway.handleCronJob(jobName, enrichedPrompt, 2, effectiveMaxTurns, effectiveModel, undefined, // workDir
|
|
1500
|
+
useUnleashed ? 'unleashed' : undefined, useUnleashed ? 1 : undefined).then((result) => {
|
|
1501
|
+
if (result && !CronScheduler.isCronNoise(result)) {
|
|
1502
|
+
this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
|
|
1503
|
+
}
|
|
1504
|
+
logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1505
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
|
|
1506
|
+
}).catch((err) => {
|
|
1507
|
+
logger.error({ err, goalId: trigger.goalId }, 'Goal work session failed');
|
|
1508
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, String(err));
|
|
1509
|
+
});
|
|
1510
|
+
}).catch((err) => {
|
|
1511
|
+
// Advisor import failed — fall back to basic execution
|
|
1512
|
+
logger.warn({ err, goalId: trigger.goalId }, 'Advisor unavailable — running goal work with defaults');
|
|
1513
|
+
this.gateway.handleCronJob(jobName, prompt, 2, trigger.maxTurns ?? 15).then((result) => {
|
|
1514
|
+
if (result && !CronScheduler.isCronNoise(result)) {
|
|
1515
|
+
this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
|
|
1516
|
+
}
|
|
1517
|
+
logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1518
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
|
|
1519
|
+
}).catch((goalErr) => {
|
|
1520
|
+
logger.error({ err: goalErr, goalId: trigger.goalId }, 'Goal work session failed');
|
|
1521
|
+
this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, String(goalErr));
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
catch (err) {
|
|
1526
|
+
logger.warn({ err, file }, 'Failed to process goal trigger file');
|
|
1527
|
+
try {
|
|
1528
|
+
unlinkSync(filePath);
|
|
1529
|
+
}
|
|
1530
|
+
catch { /* ignore */ }
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Log goal work session outcome to a per-goal progress JSONL file.
|
|
1536
|
+
* Creates the causal link: "action X was attempted for goal Y, result was Z."
|
|
1537
|
+
*/
|
|
1538
|
+
logGoalOutcome(goalId, goalPath, prevUpdatedAt, prevNotesCount, focus, source, result, error) {
|
|
1539
|
+
try {
|
|
1540
|
+
let madeProgress = false;
|
|
1541
|
+
let newNotesCount = prevNotesCount;
|
|
1542
|
+
// Re-read the goal to check if goal_update was called during the session
|
|
1543
|
+
if (existsSync(goalPath)) {
|
|
1544
|
+
const updated = JSON.parse(readFileSync(goalPath, 'utf-8'));
|
|
1545
|
+
madeProgress = updated.updatedAt !== prevUpdatedAt;
|
|
1546
|
+
newNotesCount = updated.progressNotes?.length ?? 0;
|
|
1547
|
+
}
|
|
1548
|
+
// Parse disposition tag from agent output (e.g., [BLOCKED_ON_USER])
|
|
1549
|
+
const dispositionMatch = (result ?? '').match(/\[(ADVANCED|BLOCKED_ON_USER|BLOCKED_ON_EXTERNAL|NEEDS_DIFFERENT_APPROACH|MONITORING)\]/);
|
|
1550
|
+
const disposition = error
|
|
1551
|
+
? 'error'
|
|
1552
|
+
: dispositionMatch
|
|
1553
|
+
? dispositionMatch[1].toLowerCase().replace(/_/g, '-')
|
|
1554
|
+
: madeProgress ? 'advanced' : 'no-change';
|
|
1555
|
+
const entry = {
|
|
1556
|
+
timestamp: new Date().toISOString(),
|
|
1557
|
+
goalId,
|
|
1558
|
+
focus,
|
|
1559
|
+
source,
|
|
1560
|
+
madeProgress,
|
|
1561
|
+
disposition,
|
|
1562
|
+
newProgressNotes: newNotesCount - prevNotesCount,
|
|
1563
|
+
resultSnippet: error ? `ERROR: ${error.slice(0, 200)}` : (result || '').slice(0, 200).replace(/\n/g, ' '),
|
|
1564
|
+
status: error ? 'error' : madeProgress ? 'progress' : 'no-change',
|
|
1565
|
+
};
|
|
1566
|
+
const progressDir = path.join(GOALS_DIR, 'progress');
|
|
1567
|
+
mkdirSync(progressDir, { recursive: true });
|
|
1568
|
+
const progressFile = path.join(progressDir, `${goalId}.progress.jsonl`);
|
|
1569
|
+
appendFileSync(progressFile, JSON.stringify(entry) + '\n');
|
|
1570
|
+
logger.info({ goalId, madeProgress, disposition, status: entry.status }, 'Goal outcome logged');
|
|
1571
|
+
}
|
|
1572
|
+
catch (err) {
|
|
1573
|
+
logger.debug({ err, goalId }, 'Failed to log goal outcome (non-fatal)');
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Apply non-destructive cron changes suggested by the daily planner.
|
|
1578
|
+
* Only auto-applies suggestions that reference a high-priority goal with autoSchedule=true.
|
|
1579
|
+
* Supports: 'add' (new job), 'enable', 'disable'. Does NOT support 'delete' or 'modify'.
|
|
1580
|
+
*/
|
|
1581
|
+
applySuggestedCronChanges(suggestions) {
|
|
1582
|
+
if (!suggestions || suggestions.length === 0)
|
|
1583
|
+
return;
|
|
1584
|
+
try {
|
|
1585
|
+
// Only apply suggestions linked to high-priority autoSchedule goals
|
|
1586
|
+
const goalFiles = existsSync(GOALS_DIR) ? readdirSync(GOALS_DIR).filter(f => f.endsWith('.json')) : [];
|
|
1587
|
+
const autoScheduleGoalTitles = new Set();
|
|
1588
|
+
for (const f of goalFiles) {
|
|
1589
|
+
try {
|
|
1590
|
+
const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
|
|
1591
|
+
if (goal.status === 'active' && goal.priority === 'high' && goal.autoSchedule) {
|
|
1592
|
+
autoScheduleGoalTitles.add(goal.title.toLowerCase());
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
catch {
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if (autoScheduleGoalTitles.size === 0)
|
|
1600
|
+
return;
|
|
1601
|
+
for (const suggestion of suggestions) {
|
|
1602
|
+
// Check if this suggestion is related to an autoSchedule goal
|
|
1603
|
+
const reasonLower = suggestion.reason.toLowerCase();
|
|
1604
|
+
const isGoalLinked = [...autoScheduleGoalTitles].some(title => reasonLower.includes(title));
|
|
1605
|
+
if (!isGoalLinked)
|
|
1606
|
+
continue;
|
|
1607
|
+
const changeLower = suggestion.change.toLowerCase().trim();
|
|
1608
|
+
if (changeLower === 'enable' || changeLower === 'disable') {
|
|
1609
|
+
// Enable/disable an existing job
|
|
1610
|
+
const existing = this.jobs.find(j => j.name === suggestion.job);
|
|
1611
|
+
if (!existing)
|
|
1612
|
+
continue;
|
|
1613
|
+
if (changeLower === 'enable') {
|
|
1614
|
+
this.disabledJobs.delete(suggestion.job);
|
|
1615
|
+
}
|
|
1616
|
+
else {
|
|
1617
|
+
this.disabledJobs.add(suggestion.job);
|
|
1618
|
+
}
|
|
1619
|
+
logger.info({ job: suggestion.job, change: changeLower, reason: suggestion.reason }, 'Applied suggested cron change');
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
// For 'add' or new job suggestions — write to CRON.md
|
|
1623
|
+
if (changeLower.startsWith('add') || changeLower.startsWith('create') || changeLower.startsWith('new')) {
|
|
1624
|
+
// Parse a schedule from the suggestion reason if possible
|
|
1625
|
+
const scheduleMatch = suggestion.reason.match(/(?:schedule|cron|at)\s*[:=]?\s*["']?([0-9*/,\- ]{9,})["']?/i);
|
|
1626
|
+
if (!scheduleMatch) {
|
|
1627
|
+
logger.debug({ job: suggestion.job }, 'Skipped add suggestion — no parseable schedule in reason');
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
const schedule = scheduleMatch[1].trim();
|
|
1631
|
+
if (!cron.validate(schedule)) {
|
|
1632
|
+
logger.debug({ job: suggestion.job, schedule }, 'Skipped add suggestion — invalid cron expression');
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
// Check duplicate
|
|
1636
|
+
const exists = this.jobs.some(j => j.name === suggestion.job);
|
|
1637
|
+
if (exists) {
|
|
1638
|
+
logger.debug({ job: suggestion.job }, 'Skipped add suggestion — job already exists');
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
// Write to CRON.md via gray-matter
|
|
1642
|
+
const matterMod = matter;
|
|
1643
|
+
if (!existsSync(CRON_FILE))
|
|
1644
|
+
continue;
|
|
1645
|
+
const raw = readFileSync(CRON_FILE, 'utf-8');
|
|
1646
|
+
const parsed = matterMod(raw);
|
|
1647
|
+
const jobs = (parsed.data.jobs ?? []);
|
|
1648
|
+
jobs.push({
|
|
1649
|
+
name: suggestion.job,
|
|
1650
|
+
schedule,
|
|
1651
|
+
prompt: suggestion.reason,
|
|
1652
|
+
enabled: true,
|
|
1653
|
+
tier: 1,
|
|
1654
|
+
});
|
|
1655
|
+
parsed.data.jobs = jobs;
|
|
1656
|
+
const output = matterMod.stringify(parsed.content, parsed.data);
|
|
1657
|
+
writeFileSync(CRON_FILE, output);
|
|
1658
|
+
logger.info({ job: suggestion.job, schedule, reason: suggestion.reason }, 'Auto-created cron job from daily plan suggestion');
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
catch (err) {
|
|
1663
|
+
logger.warn({ err }, 'Failed to apply suggested cron changes');
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
async runWorkflow(name, inputs) {
|
|
1667
|
+
const wf = this.workflowDefs.find(w => w.name === name);
|
|
1668
|
+
if (!wf) {
|
|
1669
|
+
return `Workflow '${name}' not found. Use \`!workflow list\` to see available workflows.`;
|
|
1670
|
+
}
|
|
1671
|
+
if (this.runningWorkflows.has(name)) {
|
|
1672
|
+
return `Workflow '${name}' is already running.`;
|
|
1673
|
+
}
|
|
1674
|
+
this.runningWorkflows.add(name);
|
|
1675
|
+
this.emitStatusChange();
|
|
1676
|
+
const startedAt = new Date();
|
|
1677
|
+
try {
|
|
1678
|
+
logger.info({ workflow: name, inputs }, `Running workflow: ${name}`);
|
|
1679
|
+
const response = await this.gateway.handleWorkflow(wf, inputs ?? {});
|
|
1680
|
+
if (response && response !== '*(workflow completed — no output)*') {
|
|
1681
|
+
await this.dispatcher.send(`**[Workflow: ${name}]**\n\n${response.slice(0, 1500)}`);
|
|
1682
|
+
// Inject into owner's DM session
|
|
1683
|
+
if (DISCORD_OWNER_ID && DISCORD_OWNER_ID !== '0') {
|
|
1684
|
+
this.gateway.injectContext(`discord:user:${DISCORD_OWNER_ID}`, `[Workflow: ${name}]`, response);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
const durationSec = Math.round((Date.now() - startedAt.getTime()) / 1000);
|
|
1688
|
+
logToDailyNote(`**Workflow: ${name}** (${durationSec}s): ${(response || 'no output').slice(0, 100).replace(/\n/g, ' ')}`);
|
|
1689
|
+
return response;
|
|
1690
|
+
}
|
|
1691
|
+
catch (err) {
|
|
1692
|
+
logger.error({ err, workflow: name }, `Workflow '${name}' failed`);
|
|
1693
|
+
const errMsg = `Workflow '${name}' failed: ${String(err).slice(0, 300)}`;
|
|
1694
|
+
await this.dispatcher.send(errMsg);
|
|
1695
|
+
return errMsg;
|
|
1696
|
+
}
|
|
1697
|
+
finally {
|
|
1698
|
+
this.runningWorkflows.delete(name);
|
|
1699
|
+
this.emitStatusChange();
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
getWorkflowNames() {
|
|
1703
|
+
return this.workflowDefs.map(w => w.name);
|
|
1704
|
+
}
|
|
1705
|
+
getWorkflow(name) {
|
|
1706
|
+
return this.workflowDefs.find(w => w.name === name);
|
|
1707
|
+
}
|
|
1708
|
+
isWorkflowRunning(name) {
|
|
1709
|
+
return this.runningWorkflows.has(name);
|
|
1710
|
+
}
|
|
1711
|
+
listWorkflows() {
|
|
1712
|
+
if (this.workflowDefs.length === 0) {
|
|
1713
|
+
this.reloadWorkflows();
|
|
1714
|
+
}
|
|
1715
|
+
if (this.workflowDefs.length === 0) {
|
|
1716
|
+
return 'No workflows configured. Add workflow files to `vault/00-System/workflows/`.';
|
|
1717
|
+
}
|
|
1718
|
+
const lines = ['**Workflows:**\n'];
|
|
1719
|
+
for (const wf of this.workflowDefs) {
|
|
1720
|
+
const status = wf.enabled ? 'enabled' : 'disabled';
|
|
1721
|
+
const schedule = wf.trigger.schedule ? ` (\`${wf.trigger.schedule}\`)` : ' (manual)';
|
|
1722
|
+
const running = this.runningWorkflows.has(wf.name) ? ' [running]' : '';
|
|
1723
|
+
lines.push(`- **${wf.name}**${schedule} — ${status}${running}`);
|
|
1724
|
+
if (wf.description)
|
|
1725
|
+
lines.push(` _${wf.description.slice(0, 80)}_`);
|
|
1726
|
+
lines.push(` Steps: ${wf.steps.map(s => s.id).join(' → ')}`);
|
|
1727
|
+
}
|
|
1728
|
+
return lines.join('\n');
|
|
1729
|
+
}
|
|
1730
|
+
// ── Self-Improvement ─────────────────────────────────────────────
|
|
1731
|
+
async runSelfImproveLoop(config, onProposal) {
|
|
1732
|
+
const loop = new SelfImproveLoop(this.gateway.assistant, config);
|
|
1733
|
+
this.emitStatusChange();
|
|
1734
|
+
try {
|
|
1735
|
+
return await loop.run(onProposal);
|
|
1736
|
+
}
|
|
1737
|
+
finally {
|
|
1738
|
+
this.emitStatusChange();
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
async applySelfImproveChange(experimentId) {
|
|
1742
|
+
const loop = new SelfImproveLoop(this.gateway.assistant);
|
|
1743
|
+
const result = loop.applyApprovedChange(experimentId);
|
|
1744
|
+
this.emitStatusChange();
|
|
1745
|
+
return result;
|
|
1746
|
+
}
|
|
1747
|
+
denySelfImproveChange(experimentId) {
|
|
1748
|
+
const loop = new SelfImproveLoop(this.gateway.assistant);
|
|
1749
|
+
const result = loop.denyChange(experimentId);
|
|
1750
|
+
this.emitStatusChange();
|
|
1751
|
+
return result;
|
|
1752
|
+
}
|
|
1753
|
+
getSelfImproveStatus() {
|
|
1754
|
+
const loop = new SelfImproveLoop(this.gateway.assistant);
|
|
1755
|
+
return loop.loadState();
|
|
1756
|
+
}
|
|
1757
|
+
getSelfImproveHistory(limit = 10) {
|
|
1758
|
+
const loop = new SelfImproveLoop(this.gateway.assistant);
|
|
1759
|
+
const log = loop.loadExperimentLog();
|
|
1760
|
+
return log.slice(-limit).reverse();
|
|
1761
|
+
}
|
|
1762
|
+
getSelfImprovePending() {
|
|
1763
|
+
const loop = new SelfImproveLoop(this.gateway.assistant);
|
|
1764
|
+
return loop.getPendingChanges();
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
//# sourceMappingURL=cron-scheduler.js.map
|