@ducci/jarvis 1.0.38 → 1.0.40

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.
Files changed (32) hide show
  1. package/docs/agent.md +43 -4
  2. package/docs/crons.md +100 -0
  3. package/docs/identity.md +38 -0
  4. package/docs/skills.md +77 -0
  5. package/docs/system-prompt.md +25 -13
  6. package/docs/telegram.md +61 -2
  7. package/package.json +2 -1
  8. package/src/channels/telegram/index.js +65 -0
  9. package/src/server/agent.js +59 -19
  10. package/src/server/app.js +125 -2
  11. package/src/server/config.js +43 -0
  12. package/src/server/cron-scheduler.js +35 -0
  13. package/src/server/crons.js +106 -0
  14. package/src/server/tools.js +234 -72
  15. package/docs/findings/001-context-explosion.md +0 -116
  16. package/docs/findings/002-handoff-edge-cases.md +0 -84
  17. package/docs/findings/003-event-loop-blocking-and-reliability.md +0 -120
  18. package/docs/findings/004-agent-reliability-improvements.md +0 -162
  19. package/docs/findings/005-installation-timeout.md +0 -128
  20. package/docs/findings/006-malformed-tool-schema.md +0 -118
  21. package/docs/findings/007-telegram-errors-and-handoff-stalling.md +0 -271
  22. package/docs/findings/008-exec-timeout-architecture.md +0 -118
  23. package/docs/findings/009-non-string-response-field.md +0 -153
  24. package/docs/findings/010-checkpoint-field-type-safety.md +0 -121
  25. package/docs/findings/011-empty-model-response.md +0 -157
  26. package/docs/findings/012-empty-nudge-loses-recovery-text.md +0 -121
  27. package/docs/findings/013-stderr-visibility-and-truncation.md +0 -59
  28. package/docs/findings/014-exec-stderr-artifact-and-malformed-tool-args.md +0 -202
  29. package/docs/findings/015-failed-run-context-strip.md +0 -142
  30. package/docs/findings/016-file-writing-corruption-and-stderr-loop.md +0 -119
  31. package/docs/findings/017-looping-intervention-and-lossy-checkpoint.md +0 -110
  32. package/docs/findings/018-anthropic-oauth-token-support.md +0 -72
@@ -4,6 +4,7 @@ import { loadSystemPrompt, resolveSystemPrompt } from './config.js';
4
4
  import { loadSession, saveSession, createSession } from './sessions.js';
5
5
  import { loadTools, getToolDefinitions, executeTool } from './tools.js';
6
6
  import { appendLog } from './logging.js';
7
+ import * as cronScheduler from './cron-scheduler.js';
7
8
  import chalk from 'chalk';
8
9
 
9
10
  const FORMAT_NUDGE = 'Your previous response was not valid JSON. Respond only with the required JSON object: {"response": "...", "logSummary": "..."}';
@@ -102,7 +103,7 @@ function hasConsecutiveModelErrors(messages) {
102
103
  * Runs a single agent loop up to maxIterations.
103
104
  * Returns { iteration, response, logSummary, status, runToolCalls, checkpoint }.
104
105
  */
105
- async function runAgentLoop(client, config, session, prepareMessages, usageAccum) {
106
+ export async function runAgentLoop(client, config, session, prepareMessages, usageAccum) {
106
107
  let tools = await loadTools();
107
108
  let toolDefs = getToolDefinitions(tools);
108
109
  let iteration = 0;
@@ -169,7 +170,6 @@ async function runAgentLoop(client, config, session, prepareMessages, usageAccum
169
170
  })),
170
171
  });
171
172
 
172
- let toolsModified = false;
173
173
  let stderrErrorInIteration = false;
174
174
  for (const toolCall of assistantMessage.tool_calls) {
175
175
  const toolName = toolCall.function.name;
@@ -201,10 +201,6 @@ async function runAgentLoop(client, config, session, prepareMessages, usageAccum
201
201
  toolStatus = 'error';
202
202
  }
203
203
 
204
- if (toolName === 'save_tool' && toolStatus === 'ok') {
205
- toolsModified = true;
206
- }
207
-
208
204
  const resultObj = typeof result === 'object' && result !== null ? result : null;
209
205
  const toolFailed = toolStatus === 'error' || (resultObj && resultObj.status === 'error');
210
206
  if (toolFailed) {
@@ -232,6 +228,20 @@ async function runAgentLoop(client, config, session, prepareMessages, usageAccum
232
228
  content: sessionContent,
233
229
  });
234
230
 
231
+ // Dynamic cron scheduling — update the in-memory scheduler immediately
232
+ // so the cron is active without requiring a server restart.
233
+ if (toolStatus === 'ok') {
234
+ try {
235
+ if (toolName === 'create_cron') {
236
+ const cronEntry = JSON.parse(resultStr)?.cron;
237
+ if (cronEntry) cronScheduler.schedule(cronEntry);
238
+ } else if (toolName === 'delete_cron') {
239
+ const id = JSON.parse(resultStr)?.id;
240
+ if (id) cronScheduler.unschedule(id);
241
+ }
242
+ } catch { /* ignore parse errors */ }
243
+ }
244
+
235
245
  const callKey = `${toolName}|${JSON.stringify(toolArgs)}|${resultStr}`;
236
246
  loopTracker.set(callKey, (loopTracker.get(callKey) || 0) + 1);
237
247
  }
@@ -265,12 +275,6 @@ async function runAgentLoop(client, config, session, prepareMessages, usageAccum
265
275
  });
266
276
  }
267
277
 
268
- // Reload tools if any were created/updated this iteration
269
- if (toolsModified) {
270
- tools = await loadTools();
271
- toolDefs = getToolDefinitions(tools);
272
- }
273
-
274
278
  continue;
275
279
  }
276
280
 
@@ -454,11 +458,32 @@ async function runAgentLoop(client, config, session, prepareMessages, usageAccum
454
458
  return { iteration, response, logSummary, status, runToolCalls, checkpoint: null };
455
459
  }
456
460
 
461
+ /**
462
+ * Acquires the session lock and runs fn() inside it.
463
+ * Used by the cron runner to safely write to a session that may also
464
+ * receive concurrent user messages.
465
+ */
466
+ export async function withSessionLock(sessionId, fn) {
467
+ const previous = sessionQueues.get(sessionId) ?? Promise.resolve();
468
+ let releaseLock;
469
+ const current = new Promise(resolve => { releaseLock = resolve; });
470
+ sessionQueues.set(sessionId, current);
471
+ await previous;
472
+ try {
473
+ return await fn();
474
+ } finally {
475
+ releaseLock();
476
+ if (sessionQueues.get(sessionId) === current) {
477
+ sessionQueues.delete(sessionId);
478
+ }
479
+ }
480
+ }
481
+
457
482
  /**
458
483
  * Main entry point: handles a single POST /api/chat request.
459
484
  * Manages the handoff loop across multiple agent runs.
460
485
  */
461
- export async function handleChat(config, requestSessionId, userMessage) {
486
+ export async function handleChat(config, requestSessionId, userMessage, attachments = []) {
462
487
  const sessionId = requestSessionId || crypto.randomUUID();
463
488
 
464
489
  // Serialize concurrent requests for the same session. Each request registers
@@ -472,7 +497,7 @@ export async function handleChat(config, requestSessionId, userMessage) {
472
497
  await previous;
473
498
 
474
499
  try {
475
- return await _runHandleChat(config, sessionId, userMessage);
500
+ return await _runHandleChat(config, sessionId, userMessage, attachments);
476
501
  } finally {
477
502
  releaseLock();
478
503
  // Clean up only if no one else has queued behind us
@@ -486,7 +511,7 @@ export async function handleChat(config, requestSessionId, userMessage) {
486
511
  * The actual chat logic, extracted so handleChat can wrap it cleanly with the
487
512
  * session lock.
488
513
  */
489
- async function _runHandleChat(config, sessionId, userMessage) {
514
+ async function _runHandleChat(config, sessionId, userMessage, attachments = []) {
490
515
  const client = createClient(config);
491
516
 
492
517
  const systemPromptTemplate = loadSystemPrompt();
@@ -520,21 +545,36 @@ async function _runHandleChat(config, sessionId, userMessage) {
520
545
  userMessageWithContext += note;
521
546
  }
522
547
 
523
- // Append user message and reset handoff state
524
- session.messages.push({ role: 'user', content: userMessageWithContext });
548
+ // Append user message and reset handoff state.
549
+ // If attachments (e.g. images) are present, build a multimodal content array.
550
+ let userContent;
551
+ if (attachments && attachments.length > 0) {
552
+ userContent = [
553
+ ...attachments.map(a => ({ type: 'image_url', image_url: { url: a.url } })),
554
+ { type: 'text', text: userMessageWithContext },
555
+ ];
556
+ } else {
557
+ userContent = userMessageWithContext;
558
+ }
559
+ session.messages.push({ role: 'user', content: userContent });
525
560
  session.metadata.handoffCount = 0;
526
561
  session.metadata.failedApproaches = [];
527
562
  session.metadata.lastCheckpointRemaining = null;
528
563
  session.metadata.checkpointState = {};
529
564
 
530
- // Resolves {{user_info}} in system prompt at runtime (never persisted)
565
+ // Resolves {{user_info}} in system prompt at runtime (never persisted).
566
+ // Applies a sliding window: always includes the system prompt (messages[0])
567
+ // plus the most recent contextWindow messages, so long sessions don't overflow
568
+ // the model's context. Full history is always preserved on disk.
531
569
  function prepareMessages(messages) {
532
- return messages.map((msg, i) => {
570
+ const resolved = messages.map((msg, i) => {
533
571
  if (i === 0 && msg.role === 'system') {
534
572
  return { ...msg, content: resolveSystemPrompt(msg.content, sessionId) };
535
573
  }
536
574
  return msg;
537
575
  });
576
+ if (resolved.length <= config.contextWindow + 1) return resolved;
577
+ return [resolved[0], ...resolved.slice(-(config.contextWindow))];
538
578
  }
539
579
 
540
580
  const allToolCalls = [];
package/src/server/app.js CHANGED
@@ -2,10 +2,11 @@ import express from 'express';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { fileURLToPath } from 'url';
5
- import { realpathSync } from 'fs';
6
- import { loadConfig, ensureDirectories } from './config.js';
5
+ import fs, { realpathSync, existsSync, writeFileSync } from 'fs';
6
+ import { loadConfig, ensureDirectories, PATHS } from './config.js';
7
7
  import { seedTools } from './tools.js';
8
8
  import { handleChat } from './agent.js';
9
+ import { initCrons } from './crons.js';
9
10
  import { startTelegramChannel } from '../channels/telegram/index.js';
10
11
 
11
12
  const __filename = fileURLToPath(import.meta.url);
@@ -58,6 +59,125 @@ app.use((req, res, next) => {
58
59
  }
59
60
  });
60
61
 
62
+ const DEFAULT_IDENTITY = `# Identity
63
+
64
+ You are Jarvis, a fully autonomous agent running on a local server. You have access to tools and can execute shell commands on the machine you run on.
65
+
66
+ Be concise and direct in your responses. Avoid unnecessary filler. When a task is done, say so clearly.
67
+ `;
68
+
69
+ function seedIdentity() {
70
+ if (!existsSync(PATHS.identityFile)) {
71
+ writeFileSync(PATHS.identityFile, DEFAULT_IDENTITY, 'utf8');
72
+ console.log('Created default identity.md');
73
+ }
74
+ }
75
+
76
+ const EXAMPLE_SKILL_MD = `---
77
+ name: add-two-integers
78
+ description: Adds two integer numbers by running a Node.js script
79
+ ---
80
+
81
+ # Add Two Integers
82
+
83
+ Use this skill when asked to add two integer numbers.
84
+
85
+ ## How to use
86
+
87
+ Run the bundled script via \`exec\` with two integer arguments:
88
+
89
+ \`\`\`sh
90
+ node ~/.jarvis/data/skills/add-two-integers/add.js <a> <b>
91
+ \`\`\`
92
+
93
+ Example:
94
+
95
+ \`\`\`sh
96
+ node ~/.jarvis/data/skills/add-two-integers/add.js 3 7
97
+ # Output: 10
98
+ \`\`\`
99
+
100
+ Both arguments must be integers. The script exits with code 1 and prints a usage error if either argument is missing or not a valid integer.
101
+ `;
102
+
103
+ const EXAMPLE_SKILL_JS = `const a = parseInt(process.argv[2], 10);
104
+ const b = parseInt(process.argv[3], 10);
105
+
106
+ if (isNaN(a) || isNaN(b)) {
107
+ console.error('Usage: node add.js <integer_a> <integer_b>');
108
+ process.exit(1);
109
+ }
110
+
111
+ console.log(a + b);
112
+ `;
113
+
114
+ const MANAGE_SKILL_MD = `---
115
+ name: manage-skill
116
+ description: Create, edit, or delete a skill in ~/.jarvis/data/skills/. Use this when the user asks to add a new skill, update an existing skill, or remove a skill.
117
+ ---
118
+
119
+ # Manage Skill
120
+
121
+ Use this skill when the user asks to create, list, edit, or delete a skill.
122
+
123
+ ## What makes a good skill
124
+
125
+ - A skill describes a **workflow or approach**, not a single command
126
+ - The name is specific and lowercase with hyphens (e.g. \`scan-open-ports\`, not \`scanning\`)
127
+ - The description (frontmatter) is the **only signal the agent has to decide whether to load the skill**. Write it so the agent reliably recognises when this skill applies: be specific about the task type, not just the topic, and include when to use it. Bad: "Manages ports." Good: "Scan a target host for open ports using nmap and return a structured report. Use this when the user asks to scan ports or check what services are running on a host."
128
+ - Instructions are written for the agent, not the user — be explicit about which tools to use
129
+ - If the skill needs a script, bundle it in the same folder and reference it by absolute path using \`~/.jarvis/data/skills/<name>/script.js\`
130
+ - Prefer \`write_file\` over \`exec+echo\` for writing any file
131
+
132
+ ## Folder structure
133
+
134
+ \`\`\`
135
+ ~/.jarvis/data/skills/<name>/
136
+ skill.md ← required
137
+ *.js / *.sh ← optional bundled scripts
138
+ \`\`\`
139
+
140
+ ## Frontmatter format
141
+
142
+ \`\`\`yaml
143
+ ---
144
+ name: skill-name
145
+ description: Description that reliably tells the agent when to use this skill
146
+ ---
147
+ \`\`\`
148
+
149
+ ## Create a skill
150
+
151
+ 1. Create the folder: \`exec\` → \`mkdir -p ~/.jarvis/data/skills/<name>\`
152
+ 2. Write \`skill.md\` with frontmatter + instructions using \`write_file\`
153
+ 3. If scripts are needed, write them with \`write_file\` into the same folder
154
+
155
+ ## Edit a skill
156
+
157
+ 1. Read current content: \`exec\` → \`cat ~/.jarvis/data/skills/<name>/skill.md\`
158
+ 2. Overwrite with updated content using \`write_file\`
159
+
160
+ ## Delete a skill
161
+
162
+ 1. Confirm the skill name with the user before deleting
163
+ 2. \`exec\` → \`rm -rf ~/.jarvis/data/skills/<name>\`
164
+ `;
165
+
166
+ function seedSkills() {
167
+ const skills = [
168
+ { dir: 'add-two-integers', files: { 'skill.md': EXAMPLE_SKILL_MD, 'add.js': EXAMPLE_SKILL_JS } },
169
+ { dir: 'manage-skill', files: { 'skill.md': MANAGE_SKILL_MD } },
170
+ ];
171
+ for (const skill of skills) {
172
+ const skillDir = path.join(PATHS.skillsDir, skill.dir);
173
+ fs.mkdirSync(skillDir, { recursive: true });
174
+ for (const [filename, content] of Object.entries(skill.files)) {
175
+ const filePath = path.join(skillDir, filename);
176
+ if (!existsSync(filePath)) writeFileSync(filePath, content, 'utf8');
177
+ }
178
+ }
179
+ }
180
+
61
181
  function startServer() {
62
182
  let config;
63
183
  try {
@@ -69,6 +189,9 @@ function startServer() {
69
189
 
70
190
  ensureDirectories();
71
191
  seedTools();
192
+ seedIdentity();
193
+ seedSkills();
194
+ initCrons(config);
72
195
 
73
196
  app.locals.config = config;
74
197
 
@@ -19,6 +19,9 @@ export const PATHS = {
19
19
  toolsFile: path.join(JARVIS_DIR, 'data', 'tools', 'tools.json'),
20
20
  logsDir: path.join(JARVIS_DIR, 'logs'),
21
21
  userInfoFile: path.join(JARVIS_DIR, 'data', 'user-info.json'),
22
+ identityFile: path.join(JARVIS_DIR, 'data', 'identity.md'),
23
+ skillsDir: path.join(JARVIS_DIR, 'data', 'skills'),
24
+ cronsFile: path.join(JARVIS_DIR, 'data', 'crons.json'),
22
25
  systemPromptFile: path.join(__dirname, '..', '..', 'docs', 'system-prompt.md'),
23
26
  };
24
27
 
@@ -54,6 +57,7 @@ export function loadConfig() {
54
57
  fallbackModel: settings.fallbackModel || (provider === 'anthropic' ? 'claude-haiku-4-5-20251001' : 'openrouter/free'),
55
58
  maxIterations: settings.maxIterations || 10,
56
59
  maxHandoffs: settings.maxHandoffs || 5,
60
+ contextWindow: settings.contextWindow || 100,
57
61
  port: settings.port || 18008,
58
62
  telegram: {
59
63
  token: process.env.TELEGRAM_BOT_TOKEN || null,
@@ -69,7 +73,43 @@ export function loadSystemPrompt() {
69
73
  return match[1].trim();
70
74
  }
71
75
 
76
+ function parseSkillFrontmatter(content) {
77
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
78
+ if (!match) return null;
79
+ const meta = {};
80
+ for (const line of match[1].split('\n')) {
81
+ const [key, ...rest] = line.split(':');
82
+ if (key && rest.length) meta[key.trim()] = rest.join(':').trim();
83
+ }
84
+ return meta;
85
+ }
86
+
72
87
  export function resolveSystemPrompt(promptTemplate, sessionId) {
88
+ let identity = '';
89
+ try {
90
+ identity = fs.readFileSync(PATHS.identityFile, 'utf8').trim();
91
+ } catch {
92
+ // File doesn't exist yet
93
+ }
94
+
95
+ let skillsList = '(none)';
96
+ try {
97
+ const entries = fs.readdirSync(PATHS.skillsDir, { withFileTypes: true });
98
+ const skills = [];
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory()) continue;
101
+ const skillFile = path.join(PATHS.skillsDir, entry.name, 'skill.md');
102
+ try {
103
+ const content = fs.readFileSync(skillFile, 'utf8');
104
+ const meta = parseSkillFrontmatter(content);
105
+ if (meta?.name && meta?.description) {
106
+ skills.push(`- ${meta.name}: ${meta.description}`);
107
+ }
108
+ } catch { /* skip malformed skills */ }
109
+ }
110
+ if (skills.length > 0) skillsList = skills.join('\n');
111
+ } catch { /* skills dir doesn't exist yet */ }
112
+
73
113
  let userInfo = '(none yet)';
74
114
  try {
75
115
  const raw = fs.readFileSync(PATHS.userInfoFile, 'utf8');
@@ -80,7 +120,10 @@ export function resolveSystemPrompt(promptTemplate, sessionId) {
80
120
  } catch {
81
121
  // File doesn't exist yet
82
122
  }
123
+
83
124
  return promptTemplate
125
+ .replace('{{identity}}', identity)
126
+ .replace('{{skills}}', skillsList)
84
127
  .replace('{{session_id}}', sessionId || 'unknown')
85
128
  .replace('{{user_info}}', userInfo);
86
129
  }
@@ -0,0 +1,35 @@
1
+ import cron from 'node-cron';
2
+
3
+ // Maps cron id -> active node-cron task
4
+ const tasks = new Map();
5
+
6
+ let _runCron = null;
7
+ let _config = null;
8
+
9
+ export function init(runCronFn, config) {
10
+ _runCron = runCronFn;
11
+ _config = config;
12
+ }
13
+
14
+ export function schedule(entry) {
15
+ // Stop existing task if rescheduling
16
+ if (tasks.has(entry.id)) {
17
+ tasks.get(entry.id).stop();
18
+ }
19
+ const task = cron.schedule(entry.schedule, () => {
20
+ _runCron(entry, _config).catch(e => {
21
+ console.error(`[cron] Error running "${entry.name}": ${e.message}`);
22
+ });
23
+ });
24
+ tasks.set(entry.id, task);
25
+ console.log(`[cron] scheduled "${entry.name}" (${entry.schedule})`);
26
+ }
27
+
28
+ export function unschedule(id) {
29
+ const task = tasks.get(id);
30
+ if (task) {
31
+ task.stop();
32
+ tasks.delete(id);
33
+ console.log(`[cron] unscheduled id=${id}`);
34
+ }
35
+ }
@@ -0,0 +1,106 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { runAgentLoop, withSessionLock } from './agent.js';
4
+ import { createClient } from './provider.js';
5
+ import { loadSystemPrompt, resolveSystemPrompt, PATHS } from './config.js';
6
+ import { createSession, loadSession, saveSession } from './sessions.js';
7
+ import * as cronScheduler from './cron-scheduler.js';
8
+ import { load as loadTelegramSessions } from '../channels/telegram/sessions.js';
9
+
10
+ function loadCrons() {
11
+ try {
12
+ return JSON.parse(fs.readFileSync(PATHS.cronsFile, 'utf8'));
13
+ } catch {
14
+ return [];
15
+ }
16
+ }
17
+
18
+ async function appendCronLog(cronId, entry) {
19
+ const logFile = path.join(PATHS.logsDir, `cron-${cronId}.jsonl`);
20
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
21
+ await fs.promises.appendFile(logFile, line, 'utf8');
22
+ }
23
+
24
+ async function writeSyntheticMessageToTelegramSession(entry, response, config) {
25
+ const chatId = config.telegram?.allowedUserIds?.[0];
26
+ if (!chatId) return;
27
+
28
+ const sessions = loadTelegramSessions();
29
+ const sessionId = sessions[chatId];
30
+ if (!sessionId) return;
31
+
32
+ const ts = new Date().toISOString().slice(0, 16).replace('T', ' ');
33
+ const syntheticMessage = `[Cron "${entry.name}" | ${ts}] ${response}`;
34
+
35
+ await withSessionLock(sessionId, async () => {
36
+ const session = await loadSession(sessionId);
37
+ if (!session) return;
38
+ session.messages.push({ role: 'assistant', content: syntheticMessage });
39
+ await saveSession(sessionId, session);
40
+ });
41
+ }
42
+
43
+ export async function runCron(entry, config) {
44
+ console.log(`[cron] running "${entry.name}"`);
45
+
46
+ const systemPromptTemplate = loadSystemPrompt();
47
+ const session = createSession(systemPromptTemplate);
48
+ session.messages.push({ role: 'user', content: entry.prompt });
49
+
50
+ const client = createClient(config);
51
+ const usageAccum = { prompt: 0, completion: 0, cacheRead: 0, cacheCreation: 0 };
52
+
53
+ function prepareMessages(messages) {
54
+ return messages.map((msg, i) => {
55
+ if (i === 0 && msg.role === 'system') {
56
+ return { ...msg, content: resolveSystemPrompt(msg.content, `cron-${entry.id}`) };
57
+ }
58
+ return msg;
59
+ });
60
+ }
61
+
62
+ let run;
63
+ try {
64
+ run = await runAgentLoop(client, config, session, prepareMessages, usageAccum);
65
+ } catch (e) {
66
+ run = { status: 'error', response: e.message, logSummary: e.message, runToolCalls: [] };
67
+ }
68
+
69
+ // Log to cron JSONL
70
+ await appendCronLog(entry.id, {
71
+ cronName: entry.name,
72
+ status: run.status,
73
+ response: run.response,
74
+ logSummary: run.logSummary,
75
+ }).catch(e => console.error(`[cron] log error: ${e.message}`));
76
+
77
+ // Write synthetic message to user's Telegram session
78
+ await writeSyntheticMessageToTelegramSession(entry, run.response, config).catch(e => {
79
+ console.error(`[cron] telegram session write error: ${e.message}`);
80
+ });
81
+
82
+ // once: true — delete after firing
83
+ if (entry.once) {
84
+ try {
85
+ const crons = loadCrons().filter(c => c.id !== entry.id);
86
+ fs.writeFileSync(PATHS.cronsFile, JSON.stringify(crons, null, 2), 'utf8');
87
+ cronScheduler.unschedule(entry.id);
88
+ } catch (e) {
89
+ console.error(`[cron] cleanup error: ${e.message}`);
90
+ }
91
+ }
92
+ }
93
+
94
+ export function initCrons(config) {
95
+ cronScheduler.init(runCron, config);
96
+
97
+ const crons = loadCrons();
98
+ for (const entry of crons) {
99
+ try {
100
+ cronScheduler.schedule(entry);
101
+ } catch (e) {
102
+ console.error(`[cron] failed to schedule "${entry.name}": ${e.message}`);
103
+ }
104
+ }
105
+ console.log(`[cron] initialized ${crons.length} cron(s)`);
106
+ }