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
package/dist/cli/cron.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine CLI — Standalone cron runner.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight runner that initializes just the agent + gateway (no channels,
|
|
5
|
+
* no daemon), parses CRON.md, and executes a single job or heartbeat.
|
|
6
|
+
* Designed to be called by OS scheduler and exit.
|
|
7
|
+
*/
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import cron from 'node-cron';
|
|
13
|
+
import matter from 'gray-matter';
|
|
14
|
+
import { parseCronJobs, HeartbeatScheduler, CronRunLog, classifyError, } from '../gateway/heartbeat.js';
|
|
15
|
+
const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
|
|
16
|
+
const LAST_RUN_FILE = path.join(BASE_DIR, '.cron_last_run.json');
|
|
17
|
+
/** Exponential backoff schedule in ms: 30s, 1m, 5m, 15m, 60m */
|
|
18
|
+
const BACKOFF_MS = [30_000, 60_000, 300_000, 900_000, 3_600_000];
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
// ── Lightweight agent + gateway init ─────────────────────────────────
|
|
23
|
+
async function initGateway() {
|
|
24
|
+
// Set CLEMENTINE_HOME so config.ts resolves correctly
|
|
25
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
26
|
+
// Clear nested session guard so the SDK can spawn Claude CLI subprocesses
|
|
27
|
+
delete process.env['CLAUDECODE'];
|
|
28
|
+
const { PersonalAssistant } = await import('../agent/assistant.js');
|
|
29
|
+
const assistant = new PersonalAssistant();
|
|
30
|
+
const { Gateway } = await import('../gateway/router.js');
|
|
31
|
+
const gateway = new Gateway(assistant);
|
|
32
|
+
// Wire approval callback (auto-deny in headless mode)
|
|
33
|
+
const { setApprovalCallback } = await import('../agent/hooks.js');
|
|
34
|
+
setApprovalCallback(async () => false);
|
|
35
|
+
return { gateway };
|
|
36
|
+
}
|
|
37
|
+
// ── Commands ─────────────────────────────────────────────────────────
|
|
38
|
+
export async function cmdCronList() {
|
|
39
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
40
|
+
const jobs = parseCronJobs();
|
|
41
|
+
if (jobs.length === 0) {
|
|
42
|
+
console.log('No cron jobs defined. Edit vault/00-System/CRON.md to add jobs.');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const runLog = new CronRunLog(BASE_DIR);
|
|
46
|
+
console.log('Cron jobs:\n');
|
|
47
|
+
for (const job of jobs) {
|
|
48
|
+
const status = job.enabled ? 'enabled' : 'disabled';
|
|
49
|
+
const recent = runLog.readRecent(job.name, 1);
|
|
50
|
+
const lastRun = recent.length > 0
|
|
51
|
+
? `last run: ${recent[0].finishedAt.slice(0, 16).replace('T', ' ')} (${recent[0].status})`
|
|
52
|
+
: 'never run';
|
|
53
|
+
const errors = runLog.consecutiveErrors(job.name);
|
|
54
|
+
const errorTag = errors > 0 ? ` [${errors} consecutive error(s)]` : '';
|
|
55
|
+
console.log(` ${job.name} (${job.schedule}) [${status}]`);
|
|
56
|
+
console.log(` ${job.prompt.slice(0, 100)}`);
|
|
57
|
+
console.log(` ${lastRun}${errorTag}`);
|
|
58
|
+
console.log();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function cmdCronRun(jobName) {
|
|
62
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
63
|
+
const jobs = parseCronJobs();
|
|
64
|
+
let job = jobs.find((j) => j.name === jobName);
|
|
65
|
+
if (!job) {
|
|
66
|
+
console.error(`Job not found: ${jobName}`);
|
|
67
|
+
console.error(`Available jobs: ${jobs.map((j) => j.name).join(', ') || '(none)'}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const { gateway } = await initGateway();
|
|
71
|
+
const runLog = new CronRunLog(BASE_DIR);
|
|
72
|
+
// ── Pre-check gate ──
|
|
73
|
+
if (job.preCheck) {
|
|
74
|
+
console.log(`Running pre-check for ${job.name}...`);
|
|
75
|
+
try {
|
|
76
|
+
const stdout = execSync(job.preCheck, {
|
|
77
|
+
timeout: 30_000,
|
|
78
|
+
encoding: 'utf-8',
|
|
79
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
80
|
+
cwd: job.workDir || undefined,
|
|
81
|
+
}).trim();
|
|
82
|
+
console.log(`Pre-check passed${stdout ? ` (${stdout.split('\n').length} lines of context)` : ''}`);
|
|
83
|
+
if (stdout.length > 0) {
|
|
84
|
+
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}` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (preCheckErr) {
|
|
88
|
+
const exitCode = preCheckErr.status ?? 1;
|
|
89
|
+
console.log(`Pre-check exit ${exitCode} — no work to do, skipping job`);
|
|
90
|
+
runLog.append({
|
|
91
|
+
jobName: job.name,
|
|
92
|
+
startedAt: new Date().toISOString(),
|
|
93
|
+
finishedAt: new Date().toISOString(),
|
|
94
|
+
status: 'skipped',
|
|
95
|
+
durationMs: 0,
|
|
96
|
+
attempt: 0,
|
|
97
|
+
outputPreview: `Pre-check exit ${exitCode} — no work`,
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ── Inject context field if present ──
|
|
103
|
+
if (job.context) {
|
|
104
|
+
job = { ...job, prompt: `## Context\n${job.context}\n\n${job.prompt}` };
|
|
105
|
+
}
|
|
106
|
+
// ── Inject attachment content ──
|
|
107
|
+
const attachDir = path.join(BASE_DIR, 'attachments', job.name.replace(/[^a-zA-Z0-9_:-]/g, '_'));
|
|
108
|
+
if (existsSync(attachDir)) {
|
|
109
|
+
try {
|
|
110
|
+
const attachFiles = readdirSync(attachDir);
|
|
111
|
+
const textExts = ['.csv', '.md', '.txt', '.json', '.tsv'];
|
|
112
|
+
let attachContent = '';
|
|
113
|
+
let totalChars = 0;
|
|
114
|
+
const MAX_ATTACH_CHARS = 50_000;
|
|
115
|
+
for (const af of attachFiles) {
|
|
116
|
+
const ext = path.extname(af).toLowerCase();
|
|
117
|
+
const afPath = path.join(attachDir, af);
|
|
118
|
+
if (textExts.includes(ext)) {
|
|
119
|
+
const content = readFileSync(afPath, 'utf-8');
|
|
120
|
+
if (totalChars + content.length > MAX_ATTACH_CHARS) {
|
|
121
|
+
attachContent += `\n### ${af}\n*(truncated — file too large, available at: ${afPath})*\n`;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
attachContent += `\n### ${af}\n\`\`\`\n${content}\n\`\`\`\n`;
|
|
125
|
+
totalChars += content.length;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
attachContent += `\n### ${af}\n*(binary file — available at: ${afPath})*\n`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (attachContent) {
|
|
132
|
+
job = { ...job, prompt: `## Reference Files\nThese files were attached for context:\n${attachContent}\n\n${job.prompt}` };
|
|
133
|
+
console.log(`Injected ${attachFiles.length} attachment(s) into prompt`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch { /* non-fatal */ }
|
|
137
|
+
}
|
|
138
|
+
console.log(`Running cron job: ${job.name}`);
|
|
139
|
+
const startedAt = new Date();
|
|
140
|
+
try {
|
|
141
|
+
const response = await gateway.handleCronJob(job.name, job.prompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours);
|
|
142
|
+
const finishedAt = new Date();
|
|
143
|
+
runLog.append({
|
|
144
|
+
jobName: job.name,
|
|
145
|
+
startedAt: startedAt.toISOString(),
|
|
146
|
+
finishedAt: finishedAt.toISOString(),
|
|
147
|
+
status: 'ok',
|
|
148
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
149
|
+
attempt: 1,
|
|
150
|
+
outputPreview: response ? response.slice(0, 200) : undefined,
|
|
151
|
+
});
|
|
152
|
+
console.log(response || '(no output)');
|
|
153
|
+
if (response && response !== '__NOTHING__') {
|
|
154
|
+
console.log('\n(Note: Standalone runner — output not delivered to channels. Use the daemon for channel delivery.)');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const finishedAt = new Date();
|
|
159
|
+
runLog.append({
|
|
160
|
+
jobName: job.name,
|
|
161
|
+
startedAt: startedAt.toISOString(),
|
|
162
|
+
finishedAt: finishedAt.toISOString(),
|
|
163
|
+
status: 'error',
|
|
164
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
165
|
+
error: String(err).slice(0, 500),
|
|
166
|
+
errorType: classifyError(err),
|
|
167
|
+
attempt: 1,
|
|
168
|
+
});
|
|
169
|
+
console.error(`Error: ${err}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Check if the main daemon process is alive via its PID file. */
|
|
174
|
+
function isDaemonRunning() {
|
|
175
|
+
// PID file is named after the assistant (e.g. .clementine.pid)
|
|
176
|
+
const envPath = path.join(BASE_DIR, '.env');
|
|
177
|
+
let name = 'clementine';
|
|
178
|
+
if (existsSync(envPath)) {
|
|
179
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
180
|
+
const match = content.match(/^ASSISTANT_NAME=(.+)$/m);
|
|
181
|
+
if (match)
|
|
182
|
+
name = match[1].trim().toLowerCase();
|
|
183
|
+
}
|
|
184
|
+
const pidFile = path.join(BASE_DIR, `.${name}.pid`);
|
|
185
|
+
if (!existsSync(pidFile))
|
|
186
|
+
return false;
|
|
187
|
+
try {
|
|
188
|
+
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
189
|
+
if (isNaN(pid))
|
|
190
|
+
return false;
|
|
191
|
+
process.kill(pid, 0); // signal 0 = check if alive
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export async function cmdCronRunDue() {
|
|
199
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
200
|
+
// Skip if the daemon is running — it has its own CronScheduler
|
|
201
|
+
if (isDaemonRunning()) {
|
|
202
|
+
console.log('Daemon is running — skipping standalone cron (daemon handles scheduling)');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const jobs = parseCronJobs();
|
|
206
|
+
const enabledJobs = jobs.filter((j) => j.enabled);
|
|
207
|
+
if (enabledJobs.length === 0) {
|
|
208
|
+
return; // nothing to do, silent exit for OS scheduler
|
|
209
|
+
}
|
|
210
|
+
const now = new Date();
|
|
211
|
+
const lastRuns = loadLastRuns();
|
|
212
|
+
const dueJobs = enabledJobs.filter((job) => isJobDue(job, now, lastRuns));
|
|
213
|
+
if (dueJobs.length === 0) {
|
|
214
|
+
return; // nothing due
|
|
215
|
+
}
|
|
216
|
+
const { gateway } = await initGateway();
|
|
217
|
+
const runLog = new CronRunLog(BASE_DIR);
|
|
218
|
+
// Note: the standalone runner doesn't deliver notifications to channels.
|
|
219
|
+
// The daemon's CronScheduler handles delivery via NotificationDispatcher.
|
|
220
|
+
// This runner is a fallback for when the daemon is down.
|
|
221
|
+
for (let job of dueJobs) {
|
|
222
|
+
console.log(`[${new Date().toLocaleString()}] Running due job: ${job.name}`);
|
|
223
|
+
// ── Pre-check gate ──
|
|
224
|
+
if (job.preCheck) {
|
|
225
|
+
try {
|
|
226
|
+
const stdout = execSync(job.preCheck, {
|
|
227
|
+
timeout: 30_000,
|
|
228
|
+
encoding: 'utf-8',
|
|
229
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
230
|
+
cwd: job.workDir || undefined,
|
|
231
|
+
}).trim();
|
|
232
|
+
if (stdout.length > 0) {
|
|
233
|
+
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}` };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (preCheckErr) {
|
|
237
|
+
const exitCode = preCheckErr.status ?? 1;
|
|
238
|
+
console.log(` Pre-check exit ${exitCode} — skipping ${job.name}`);
|
|
239
|
+
runLog.append({
|
|
240
|
+
jobName: job.name,
|
|
241
|
+
startedAt: new Date().toISOString(),
|
|
242
|
+
finishedAt: new Date().toISOString(),
|
|
243
|
+
status: 'skipped',
|
|
244
|
+
durationMs: 0,
|
|
245
|
+
attempt: 0,
|
|
246
|
+
outputPreview: `Pre-check exit ${exitCode} — no work`,
|
|
247
|
+
});
|
|
248
|
+
lastRuns[job.name] = now.toISOString();
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Determine retry ceiling from error history
|
|
253
|
+
const priorErrors = runLog.consecutiveErrors(job.name);
|
|
254
|
+
const maxAttempts = 1 + Math.min(priorErrors, BACKOFF_MS.length);
|
|
255
|
+
let succeeded = false;
|
|
256
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
257
|
+
const startedAt = new Date();
|
|
258
|
+
try {
|
|
259
|
+
const response = await gateway.handleCronJob(job.name, job.prompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours);
|
|
260
|
+
const finishedAt = new Date();
|
|
261
|
+
runLog.append({
|
|
262
|
+
jobName: job.name,
|
|
263
|
+
startedAt: startedAt.toISOString(),
|
|
264
|
+
finishedAt: finishedAt.toISOString(),
|
|
265
|
+
status: 'ok',
|
|
266
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
267
|
+
attempt,
|
|
268
|
+
outputPreview: response ? response.slice(0, 200) : undefined,
|
|
269
|
+
});
|
|
270
|
+
if (response) {
|
|
271
|
+
console.log(`[${job.name}] ${response}`);
|
|
272
|
+
// Output logged to run history; daemon handles channel delivery
|
|
273
|
+
}
|
|
274
|
+
succeeded = true;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
const finishedAt = new Date();
|
|
279
|
+
const errorType = classifyError(err);
|
|
280
|
+
runLog.append({
|
|
281
|
+
jobName: job.name,
|
|
282
|
+
startedAt: startedAt.toISOString(),
|
|
283
|
+
finishedAt: finishedAt.toISOString(),
|
|
284
|
+
status: attempt < maxAttempts && errorType === 'transient' ? 'retried' : 'error',
|
|
285
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
286
|
+
error: String(err).slice(0, 500),
|
|
287
|
+
errorType,
|
|
288
|
+
attempt,
|
|
289
|
+
});
|
|
290
|
+
if (errorType === 'permanent') {
|
|
291
|
+
console.error(`[${job.name}] Permanent error — not retrying: ${err}`);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
if (attempt < maxAttempts) {
|
|
295
|
+
const backoffMs = BACKOFF_MS[Math.min(attempt - 1, BACKOFF_MS.length - 1)];
|
|
296
|
+
console.log(`[${job.name}] Transient error — retrying in ${backoffMs / 1000}s (attempt ${attempt}/${maxAttempts})`);
|
|
297
|
+
await sleep(backoffMs);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
console.error(`[${job.name}] Failed after ${attempt} attempt(s): ${err}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (succeeded) {
|
|
305
|
+
lastRuns[job.name] = now.toISOString();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
saveLastRuns(lastRuns);
|
|
309
|
+
}
|
|
310
|
+
export async function cmdCronAdd(name, schedule, prompt, options) {
|
|
311
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
312
|
+
// Validate cron expression
|
|
313
|
+
if (!cron.validate(schedule)) {
|
|
314
|
+
console.error(`Invalid cron expression: ${schedule}`);
|
|
315
|
+
console.error('Examples: "0 9 * * 1" (Mon 9 AM), "*/30 * * * *" (every 30 min)');
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
// Resolve CRON.md path
|
|
319
|
+
const cronFile = path.join(BASE_DIR, 'vault', '00-System', 'CRON.md');
|
|
320
|
+
// Read existing CRON.md or create empty structure
|
|
321
|
+
let parsed;
|
|
322
|
+
if (existsSync(cronFile)) {
|
|
323
|
+
const raw = readFileSync(cronFile, 'utf-8');
|
|
324
|
+
parsed = matter(raw);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// Create directory if needed
|
|
328
|
+
const dir = path.dirname(cronFile);
|
|
329
|
+
if (!existsSync(dir))
|
|
330
|
+
mkdirSync(dir, { recursive: true });
|
|
331
|
+
parsed = matter('');
|
|
332
|
+
parsed.data = {};
|
|
333
|
+
}
|
|
334
|
+
const jobs = (parsed.data.jobs ?? []);
|
|
335
|
+
// Check for duplicate name
|
|
336
|
+
const duplicate = jobs.find((j) => String(j.name ?? '').toLowerCase() === name.toLowerCase());
|
|
337
|
+
if (duplicate) {
|
|
338
|
+
console.error(`A job named "${name}" already exists.`);
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
// Create new job entry
|
|
342
|
+
const tier = parseInt(options.tier ?? '1', 10);
|
|
343
|
+
const newJob = {
|
|
344
|
+
name,
|
|
345
|
+
schedule,
|
|
346
|
+
prompt,
|
|
347
|
+
enabled: true,
|
|
348
|
+
tier: isNaN(tier) ? 1 : tier,
|
|
349
|
+
};
|
|
350
|
+
jobs.push(newJob);
|
|
351
|
+
parsed.data.jobs = jobs;
|
|
352
|
+
// Write back preserving body content — validate first to prevent daemon crash
|
|
353
|
+
const output = matter.stringify(parsed.content, parsed.data);
|
|
354
|
+
try {
|
|
355
|
+
matter(output);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
console.error(`Generated CRON.md has invalid YAML: ${err instanceof Error ? err.message : err}`);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
writeFileSync(cronFile, output);
|
|
362
|
+
console.log(`Added cron job: ${name}`);
|
|
363
|
+
console.log(` Schedule: ${schedule}`);
|
|
364
|
+
console.log(` Prompt: ${prompt.slice(0, 100)}`);
|
|
365
|
+
console.log(` Tier: ${newJob.tier}`);
|
|
366
|
+
console.log(` Enabled: true`);
|
|
367
|
+
console.log();
|
|
368
|
+
console.log('The daemon will auto-reload CRON.md on file change.');
|
|
369
|
+
}
|
|
370
|
+
export async function cmdCronTest(jobNameOrIndex) {
|
|
371
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
372
|
+
const jobs = parseCronJobs();
|
|
373
|
+
if (jobs.length === 0) {
|
|
374
|
+
console.error('No cron jobs defined. Edit vault/00-System/CRON.md to add jobs.');
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
// Find job by name (case-insensitive) or by numeric index
|
|
378
|
+
let job;
|
|
379
|
+
const index = parseInt(jobNameOrIndex, 10);
|
|
380
|
+
if (!isNaN(index) && index >= 0 && index < jobs.length) {
|
|
381
|
+
job = jobs[index];
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
job = jobs.find((j) => j.name.toLowerCase() === jobNameOrIndex.toLowerCase());
|
|
385
|
+
}
|
|
386
|
+
if (!job) {
|
|
387
|
+
console.error(`Job not found: ${jobNameOrIndex}`);
|
|
388
|
+
console.error(`Available jobs: ${jobs.map((j) => j.name).join(', ') || '(none)'}`);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
console.log(`Dry-running cron job: ${job.name}`);
|
|
392
|
+
console.log(` Schedule: ${job.schedule}`);
|
|
393
|
+
console.log(` Prompt: ${job.prompt.slice(0, 100)}`);
|
|
394
|
+
console.log(` Tier: ${job.tier}`);
|
|
395
|
+
console.log();
|
|
396
|
+
const { gateway } = await initGateway();
|
|
397
|
+
try {
|
|
398
|
+
const response = await gateway.handleCronJob(job.name, job.prompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours);
|
|
399
|
+
console.log('--- Output ---');
|
|
400
|
+
console.log(response || '(no output)');
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
console.error(`Error: ${err}`);
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
export async function cmdCronRuns(jobName) {
|
|
408
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
409
|
+
const runLog = new CronRunLog(BASE_DIR);
|
|
410
|
+
if (jobName) {
|
|
411
|
+
const entries = runLog.readRecent(jobName, 20);
|
|
412
|
+
if (entries.length === 0) {
|
|
413
|
+
console.log(`No run history for job: ${jobName}`);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
console.log(`Run history for ${jobName} (most recent first):\n`);
|
|
417
|
+
for (const entry of entries) {
|
|
418
|
+
const ts = entry.startedAt.slice(0, 19).replace('T', ' ');
|
|
419
|
+
const dur = `${(entry.durationMs / 1000).toFixed(1)}s`;
|
|
420
|
+
const status = entry.status === 'ok' ? '\x1b[32mok\x1b[0m' :
|
|
421
|
+
entry.status === 'retried' ? '\x1b[33mretried\x1b[0m' :
|
|
422
|
+
'\x1b[31merror\x1b[0m';
|
|
423
|
+
const attempt = entry.attempt > 1 ? ` (attempt ${entry.attempt})` : '';
|
|
424
|
+
console.log(` ${ts} ${status} ${dur}${attempt}`);
|
|
425
|
+
if (entry.error) {
|
|
426
|
+
console.log(` ${entry.error.slice(0, 120)}`);
|
|
427
|
+
}
|
|
428
|
+
if (entry.outputPreview) {
|
|
429
|
+
console.log(` ${entry.outputPreview.slice(0, 120)}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
// Show summary for all jobs
|
|
435
|
+
const jobs = parseCronJobs();
|
|
436
|
+
if (jobs.length === 0) {
|
|
437
|
+
console.log('No cron jobs defined.');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
console.log('Run history summary:\n');
|
|
441
|
+
for (const job of jobs) {
|
|
442
|
+
const entries = runLog.readRecent(job.name, 5);
|
|
443
|
+
const consecutiveErrs = runLog.consecutiveErrors(job.name);
|
|
444
|
+
const lastEntry = entries[0];
|
|
445
|
+
const lastStr = lastEntry
|
|
446
|
+
? `${lastEntry.startedAt.slice(0, 16).replace('T', ' ')} (${lastEntry.status})`
|
|
447
|
+
: 'never';
|
|
448
|
+
const errTag = consecutiveErrs > 0 ? ` [${consecutiveErrs} consecutive errors]` : '';
|
|
449
|
+
console.log(` ${job.name}: last=${lastStr}${errTag}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
export async function cmdHeartbeat() {
|
|
454
|
+
const { gateway } = await initGateway();
|
|
455
|
+
// Read HEARTBEAT.md standing instructions
|
|
456
|
+
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
457
|
+
const { HEARTBEAT_FILE } = await import('../config.js');
|
|
458
|
+
let standingInstructions = 'Check for overdue tasks. Ensure today\'s daily note exists.';
|
|
459
|
+
if (existsSync(HEARTBEAT_FILE)) {
|
|
460
|
+
const raw = readFileSync(HEARTBEAT_FILE, 'utf-8');
|
|
461
|
+
const parsed = matter(raw);
|
|
462
|
+
standingInstructions = parsed.content;
|
|
463
|
+
}
|
|
464
|
+
const hour = new Date().getHours();
|
|
465
|
+
const timeContext = HeartbeatScheduler.getTimeContext(hour);
|
|
466
|
+
console.log('Running one-shot heartbeat...');
|
|
467
|
+
const response = await gateway.handleHeartbeat(standingInstructions, '', timeContext);
|
|
468
|
+
console.log(response || '(no output)');
|
|
469
|
+
}
|
|
470
|
+
// ── Cron schedule matching ──────────────────────────────────────────
|
|
471
|
+
function isJobDue(job, now, lastRuns) {
|
|
472
|
+
if (!cron.validate(job.schedule))
|
|
473
|
+
return false;
|
|
474
|
+
// Determine how far back to look: since last run, or up to 24 hours
|
|
475
|
+
const lastRun = lastRuns[job.name];
|
|
476
|
+
let lookbackMinutes;
|
|
477
|
+
if (lastRun) {
|
|
478
|
+
const lastRunDate = new Date(lastRun);
|
|
479
|
+
const elapsedMs = now.getTime() - lastRunDate.getTime();
|
|
480
|
+
if (elapsedMs < 2 * 60 * 1000)
|
|
481
|
+
return false; // ran <2min ago, skip
|
|
482
|
+
lookbackMinutes = Math.min(Math.ceil(elapsedMs / 60_000), 1440); // cap at 24h
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
// Never run — look back up to 24 hours for any scheduled time
|
|
486
|
+
lookbackMinutes = 1440;
|
|
487
|
+
}
|
|
488
|
+
// Check if the cron schedule matches any minute in the lookback window
|
|
489
|
+
for (let offsetMin = 0; offsetMin < lookbackMinutes; offsetMin++) {
|
|
490
|
+
const checkTime = new Date(now.getTime() - offsetMin * 60 * 1000);
|
|
491
|
+
if (cronMatchesTime(job.schedule, checkTime))
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Check if a cron expression matches a specific time.
|
|
498
|
+
*/
|
|
499
|
+
function cronMatchesTime(schedule, time) {
|
|
500
|
+
const parts = schedule.trim().split(/\s+/);
|
|
501
|
+
if (parts.length < 5)
|
|
502
|
+
return false;
|
|
503
|
+
const [minField, hourField, domField, monField, dowField] = parts;
|
|
504
|
+
return (fieldMatches(minField, time.getMinutes(), 0, 59) &&
|
|
505
|
+
fieldMatches(hourField, time.getHours(), 0, 23) &&
|
|
506
|
+
fieldMatches(domField, time.getDate(), 1, 31) &&
|
|
507
|
+
fieldMatches(monField, time.getMonth() + 1, 1, 12) &&
|
|
508
|
+
fieldMatches(dowField, time.getDay(), 0, 7));
|
|
509
|
+
}
|
|
510
|
+
function fieldMatches(field, value, _min, _max) {
|
|
511
|
+
if (field === '*')
|
|
512
|
+
return true;
|
|
513
|
+
// Handle */N step values
|
|
514
|
+
if (field.startsWith('*/')) {
|
|
515
|
+
const step = parseInt(field.slice(2), 10);
|
|
516
|
+
return !isNaN(step) && step > 0 && value % step === 0;
|
|
517
|
+
}
|
|
518
|
+
// Handle comma-separated values
|
|
519
|
+
const values = field.split(',');
|
|
520
|
+
for (const v of values) {
|
|
521
|
+
// Handle ranges like 1-5
|
|
522
|
+
if (v.includes('-')) {
|
|
523
|
+
const [start, end] = v.split('-').map(Number);
|
|
524
|
+
if (!isNaN(start) && !isNaN(end) && value >= start && value <= end)
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
const num = parseInt(v, 10);
|
|
529
|
+
if (!isNaN(num) && num === value)
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
// ── Last-run state persistence ──────────────────────────────────────
|
|
536
|
+
function loadLastRuns() {
|
|
537
|
+
if (!existsSync(LAST_RUN_FILE))
|
|
538
|
+
return {};
|
|
539
|
+
try {
|
|
540
|
+
return JSON.parse(readFileSync(LAST_RUN_FILE, 'utf-8'));
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return {};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function saveLastRuns(data) {
|
|
547
|
+
const dir = path.dirname(LAST_RUN_FILE);
|
|
548
|
+
if (!existsSync(dir))
|
|
549
|
+
mkdirSync(dir, { recursive: true });
|
|
550
|
+
writeFileSync(LAST_RUN_FILE, JSON.stringify(data, null, 2));
|
|
551
|
+
}
|
|
552
|
+
//# sourceMappingURL=cron.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine Command Center — Local web dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Serves an inline HTML SPA with JSON API from Express on localhost.
|
|
5
|
+
* Zero extra deps — uses express, gray-matter, better-sqlite3 (all already installed).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Kill all existing dashboard processes before starting a new one.
|
|
9
|
+
* Uses both the PID file and a process sweep to catch orphans.
|
|
10
|
+
*/
|
|
11
|
+
export declare function killExistingDashboards(): number;
|
|
12
|
+
export declare function cmdDashboard(opts: {
|
|
13
|
+
port?: string;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
//# sourceMappingURL=dashboard.d.ts.map
|