@ducci/jarvis 1.0.38 → 1.0.39

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 (31) 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 +19 -0
  7. package/package.json +2 -1
  8. package/src/server/agent.js +44 -14
  9. package/src/server/app.js +125 -2
  10. package/src/server/config.js +43 -0
  11. package/src/server/cron-scheduler.js +35 -0
  12. package/src/server/crons.js +106 -0
  13. package/src/server/tools.js +192 -71
  14. package/docs/findings/001-context-explosion.md +0 -116
  15. package/docs/findings/002-handoff-edge-cases.md +0 -84
  16. package/docs/findings/003-event-loop-blocking-and-reliability.md +0 -120
  17. package/docs/findings/004-agent-reliability-improvements.md +0 -162
  18. package/docs/findings/005-installation-timeout.md +0 -128
  19. package/docs/findings/006-malformed-tool-schema.md +0 -118
  20. package/docs/findings/007-telegram-errors-and-handoff-stalling.md +0 -271
  21. package/docs/findings/008-exec-timeout-architecture.md +0 -118
  22. package/docs/findings/009-non-string-response-field.md +0 -153
  23. package/docs/findings/010-checkpoint-field-type-safety.md +0 -121
  24. package/docs/findings/011-empty-model-response.md +0 -157
  25. package/docs/findings/012-empty-nudge-loses-recovery-text.md +0 -121
  26. package/docs/findings/013-stderr-visibility-and-truncation.md +0 -59
  27. package/docs/findings/014-exec-stderr-artifact-and-malformed-tool-args.md +0 -202
  28. package/docs/findings/015-failed-run-context-strip.md +0 -142
  29. package/docs/findings/016-file-writing-corruption-and-stderr-loop.md +0 -119
  30. package/docs/findings/017-looping-intervention-and-lossy-checkpoint.md +0 -110
  31. package/docs/findings/018-anthropic-oauth-token-support.md +0 -72
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
+ }
@@ -120,77 +120,6 @@ const SEED_TOOLS = {
120
120
  },
121
121
  code: `const filePath = path.join(process.env.HOME, '.jarvis/data/user-info.json'); const raw = await fs.promises.readFile(filePath, 'utf8').catch(() => '{"items":[]}'); const { items } = JSON.parse(raw); return { status: 'ok', items };`,
122
122
  },
123
- save_tool: {
124
- definition: {
125
- type: 'function',
126
- function: {
127
- name: 'save_tool',
128
- description: 'Create or update a custom tool and make it available immediately in this session. Use this to build reusable JS tools for tasks you repeat. The tool code runs in Node.js and has access to: args, fs, path, process, require, __jarvisDir. To update an existing tool, first call get_tool to read its current code and parameters, then call save_tool with your modifications.',
129
- parameters: {
130
- type: 'object',
131
- properties: {
132
- name: {
133
- type: 'string',
134
- description: 'Tool name in snake_case (e.g. "parse_json_file"). Must be unique.',
135
- },
136
- description: {
137
- type: 'string',
138
- description: 'What the tool does. Be specific — the LLM uses this to decide when to call it.',
139
- },
140
- parameters: {
141
- type: 'object',
142
- description: 'JSON Schema object for the tool parameters (with type, properties, required fields).',
143
- },
144
- code: {
145
- type: 'string',
146
- description: 'The body of an async function. Must end with a return statement — the returned value becomes the tool result. Available bindings: args (your tool parameters), fs (node:fs), path (node:path), process, require, __jarvisDir (absolute path to the jarvis server directory — use path.resolve(__jarvisDir, "../..") to get the project root for npm installs). Do NOT wrap in a function declaration. Example: const raw = await fs.promises.readFile(args.filePath, "utf8"); const data = JSON.parse(raw); return { count: data.length, first: data[0] };',
147
- },
148
- timeout: {
149
- type: 'number',
150
- description: 'Optional execution timeout in milliseconds for this tool (max 600000 = 10 minutes). Use this when the tool wraps a slow operation (e.g. a network request or long computation) that exceeds the default 60-second limit. If omitted, the default 60-second timeout applies.',
151
- },
152
- },
153
- required: ['name', 'description', 'parameters', 'code'],
154
- },
155
- },
156
- },
157
- code: `const toolsFile = path.join(process.env.HOME, '.jarvis/data/tools/tools.json'); const raw = await fs.promises.readFile(toolsFile, 'utf8').catch(() => '{}'); const tools = JSON.parse(raw); let parameters = args.parameters; if (typeof parameters === 'string') { try { parameters = JSON.parse(parameters); } catch { return { status: 'error', error: 'parameters must be a JSON Schema object, not a string. Pass the object directly, not as a JSON-serialized string.' }; } } if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) { return { status: 'error', error: 'parameters must be a JSON Schema object (e.g. { type: "object", properties: {...} }).' }; } const entry = { definition: { type: 'function', function: { name: args.name, description: args.description, parameters } }, code: args.code }; if (args.timeout !== undefined) { const t = Number(args.timeout); if (!Number.isFinite(t) || t <= 0) return { status: 'error', error: 'timeout must be a positive number in milliseconds.' }; entry.timeout = Math.min(t, 600_000); } tools[args.name] = entry; await fs.promises.writeFile(toolsFile, JSON.stringify(tools, null, 2), 'utf8'); return { status: 'ok', saved: args.name, timeout: entry.timeout || 60000 };`,
158
- },
159
- get_tool: {
160
- definition: {
161
- type: 'function',
162
- function: {
163
- name: 'get_tool',
164
- description: 'Read the full definition and code of a single tool by name. Use this before updating an existing tool so you understand its current implementation.',
165
- parameters: {
166
- type: 'object',
167
- properties: {
168
- name: {
169
- type: 'string',
170
- description: 'The tool name to retrieve.',
171
- },
172
- },
173
- required: ['name'],
174
- },
175
- },
176
- },
177
- code: `const toolsFile = path.join(process.env.HOME, '.jarvis/data/tools/tools.json'); const raw = await fs.promises.readFile(toolsFile, 'utf8').catch(() => '{}'); const tools = JSON.parse(raw); const tool = tools[args.name]; if (!tool) return { status: 'not_found', name: args.name }; return { status: 'ok', name: args.name, definition: tool.definition, code: tool.code };`,
178
- },
179
- list_tools: {
180
- definition: {
181
- type: 'function',
182
- function: {
183
- name: 'list_tools',
184
- description: 'List all available tools with their names and descriptions. Use this to see what tools exist before creating a new one.',
185
- parameters: {
186
- type: 'object',
187
- properties: {},
188
- required: [],
189
- },
190
- },
191
- },
192
- code: `const toolsFile = path.join(process.env.HOME, '.jarvis/data/tools/tools.json'); const raw = await fs.promises.readFile(toolsFile, 'utf8').catch(() => '{}'); const tools = JSON.parse(raw); const list = Object.entries(tools).map(([name, t]) => ({ name, description: t.definition.function.description })); return { status: 'ok', tools: list };`,
193
- },
194
123
  npm_install: {
195
124
  definition: {
196
125
  type: 'function',
@@ -384,6 +313,198 @@ const SEED_TOOLS = {
384
313
  return { status: 'ok', path: targetPath, bytes, mode: args.mode || '644' };
385
314
  `,
386
315
  },
316
+ get_current_time: {
317
+ definition: {
318
+ type: 'function',
319
+ function: {
320
+ name: 'get_current_time',
321
+ description: 'Returns the current server time. Call this before scheduling a cron job when the user specifies a relative time (e.g. "in 2 hours", "at 3pm today") so you can calculate the correct schedule.',
322
+ parameters: { type: 'object', properties: {}, required: [] },
323
+ },
324
+ },
325
+ code: `
326
+ const now = new Date();
327
+ return {
328
+ status: 'ok',
329
+ iso: now.toISOString(),
330
+ local: now.toLocaleString(),
331
+ utcOffset: -now.getTimezoneOffset() / 60,
332
+ };
333
+ `,
334
+ },
335
+ create_cron: {
336
+ definition: {
337
+ type: 'function',
338
+ function: {
339
+ name: 'create_cron',
340
+ description: 'Schedule a recurring or one-time task. The prompt is executed by a fresh agent with no prior context — write it as a self-contained task. For one-time tasks (e.g. "remind me in 2 hours"), set once: true. Call get_current_time first when calculating a relative schedule.',
341
+ parameters: {
342
+ type: 'object',
343
+ properties: {
344
+ name: { type: 'string', description: 'Short identifier for this cron, e.g. "backup-nightly".' },
345
+ schedule: { type: 'string', description: 'Cron expression, e.g. "0 3 * * *" for 3am daily. For a one-time task, compute the exact time from get_current_time and express it as a cron expression.' },
346
+ prompt: { type: 'string', description: 'The task prompt the agent will receive when this cron fires. Must be self-contained. Include "use send_telegram_message to notify the user with the result" if notification is desired.' },
347
+ once: { type: 'boolean', description: 'If true, the cron deletes itself after firing once. Use for one-time reminders or tasks.' },
348
+ },
349
+ required: ['name', 'schedule', 'prompt'],
350
+ },
351
+ },
352
+ },
353
+ code: `
354
+ const { randomUUID } = require('crypto');
355
+ const cronsFile = path.join(process.env.HOME, '.jarvis/data/crons.json');
356
+ const crons = JSON.parse(await fs.promises.readFile(cronsFile, 'utf8').catch(() => '[]'));
357
+ const entry = {
358
+ id: randomUUID(),
359
+ name: args.name,
360
+ schedule: args.schedule,
361
+ prompt: args.prompt,
362
+ once: args.once || false,
363
+ createdAt: new Date().toISOString(),
364
+ };
365
+ crons.push(entry);
366
+ await fs.promises.mkdir(path.dirname(cronsFile), { recursive: true });
367
+ await fs.promises.writeFile(cronsFile, JSON.stringify(crons, null, 2), 'utf8');
368
+ return { status: 'ok', cron: entry };
369
+ `,
370
+ },
371
+ list_crons: {
372
+ definition: {
373
+ type: 'function',
374
+ function: {
375
+ name: 'list_crons',
376
+ description: 'List all scheduled cron jobs.',
377
+ parameters: { type: 'object', properties: {}, required: [] },
378
+ },
379
+ },
380
+ code: `
381
+ const cronsFile = path.join(process.env.HOME, '.jarvis/data/crons.json');
382
+ const crons = JSON.parse(await fs.promises.readFile(cronsFile, 'utf8').catch(() => '[]'));
383
+ return { status: 'ok', crons };
384
+ `,
385
+ },
386
+ delete_cron: {
387
+ definition: {
388
+ type: 'function',
389
+ function: {
390
+ name: 'delete_cron',
391
+ description: 'Delete a scheduled cron job by name or id.',
392
+ parameters: {
393
+ type: 'object',
394
+ properties: {
395
+ name: { type: 'string', description: 'The cron name to delete.' },
396
+ id: { type: 'string', description: 'The cron id to delete.' },
397
+ },
398
+ },
399
+ },
400
+ },
401
+ code: `
402
+ const cronsFile = path.join(process.env.HOME, '.jarvis/data/crons.json');
403
+ const crons = JSON.parse(await fs.promises.readFile(cronsFile, 'utf8').catch(() => '[]'));
404
+ const idx = crons.findIndex(c => c.id === args.id || c.name === args.name);
405
+ if (idx === -1) return { status: 'not_found' };
406
+ const [removed] = crons.splice(idx, 1);
407
+ await fs.promises.writeFile(cronsFile, JSON.stringify(crons, null, 2), 'utf8');
408
+ return { status: 'ok', id: removed.id, name: removed.name };
409
+ `,
410
+ },
411
+ send_telegram_message: {
412
+ definition: {
413
+ type: 'function',
414
+ function: {
415
+ name: 'send_telegram_message',
416
+ description: 'Send a message to the Telegram user. Use this inside cron prompts to notify the user with the result of a task.',
417
+ parameters: {
418
+ type: 'object',
419
+ properties: {
420
+ message: { type: 'string', description: 'The message text to send.' },
421
+ },
422
+ required: ['message'],
423
+ },
424
+ },
425
+ },
426
+ code: `
427
+ const https = require('https');
428
+ const token = process.env.TELEGRAM_BOT_TOKEN;
429
+ const settingsFile = path.join(process.env.HOME, '.jarvis/data/config/settings.json');
430
+ const settings = JSON.parse(await fs.promises.readFile(settingsFile, 'utf8'));
431
+ const chatId = settings.channels?.telegram?.allowedUserIds?.[0];
432
+ if (!chatId) return { status: 'error', error: 'No Telegram chat_id configured.' };
433
+ if (!token) return { status: 'error', error: 'No TELEGRAM_BOT_TOKEN configured.' };
434
+ const body = JSON.stringify({ chat_id: chatId, text: args.message });
435
+ await new Promise((resolve, reject) => {
436
+ const req = https.request({
437
+ hostname: 'api.telegram.org',
438
+ path: '/bot' + token + '/sendMessage',
439
+ method: 'POST',
440
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
441
+ }, res => {
442
+ let data = '';
443
+ res.on('data', chunk => data += chunk);
444
+ res.on('end', () => {
445
+ const parsed = JSON.parse(data);
446
+ if (!parsed.ok) reject(new Error(parsed.description));
447
+ else resolve(parsed);
448
+ });
449
+ });
450
+ req.on('error', reject);
451
+ req.write(body);
452
+ req.end();
453
+ });
454
+ return { status: 'ok', chatId };
455
+ `,
456
+ },
457
+ read_cron_log: {
458
+ definition: {
459
+ type: 'function',
460
+ function: {
461
+ name: 'read_cron_log',
462
+ description: 'Read the execution log for a cron job. Returns the most recent runs with status, response, and logSummary.',
463
+ parameters: {
464
+ type: 'object',
465
+ properties: {
466
+ id: { type: 'string', description: 'The cron id.' },
467
+ limit: { type: 'number', description: 'Max entries to return. Defaults to 20.' },
468
+ },
469
+ required: ['id'],
470
+ },
471
+ },
472
+ },
473
+ code: `
474
+ const logsDir = path.join(process.env.HOME, '.jarvis/logs');
475
+ const logFile = path.join(logsDir, 'cron-' + args.id + '.jsonl');
476
+ const content = await fs.promises.readFile(logFile, 'utf8').catch(() => '');
477
+ const lines = content.trim().split('\\n').filter(Boolean);
478
+ const limit = args.limit || 20;
479
+ const entries = lines.slice(-limit).map(line => JSON.parse(line));
480
+ return { status: 'ok', entries };
481
+ `,
482
+ },
483
+ read_skill: {
484
+ definition: {
485
+ type: 'function',
486
+ function: {
487
+ name: 'read_skill',
488
+ description: 'Read the full instructions of a skill by name. Call this before executing a skill so you have the complete workflow. The skill name must match one of the available skills listed in your system prompt.',
489
+ parameters: {
490
+ type: 'object',
491
+ properties: {
492
+ name: {
493
+ type: 'string',
494
+ description: 'The skill name, e.g. "add-two-integers".',
495
+ },
496
+ },
497
+ required: ['name'],
498
+ },
499
+ },
500
+ },
501
+ code: `
502
+ const skillFile = path.join(process.env.HOME, '.jarvis/data/skills', args.name, 'skill.md');
503
+ const content = await fs.promises.readFile(skillFile, 'utf8').catch(() => null);
504
+ if (!content) return { status: 'not_found', name: args.name };
505
+ return { status: 'ok', name: args.name, content };
506
+ `,
507
+ },
387
508
  get_recent_sessions: {
388
509
  definition: {
389
510
  type: 'function',