@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.
- package/docs/agent.md +43 -4
- package/docs/crons.md +100 -0
- package/docs/identity.md +38 -0
- package/docs/skills.md +77 -0
- package/docs/system-prompt.md +25 -13
- package/docs/telegram.md +61 -2
- package/package.json +2 -1
- package/src/channels/telegram/index.js +65 -0
- package/src/server/agent.js +59 -19
- package/src/server/app.js +125 -2
- package/src/server/config.js +43 -0
- package/src/server/cron-scheduler.js +35 -0
- package/src/server/crons.js +106 -0
- package/src/server/tools.js +234 -72
- package/docs/findings/001-context-explosion.md +0 -116
- package/docs/findings/002-handoff-edge-cases.md +0 -84
- package/docs/findings/003-event-loop-blocking-and-reliability.md +0 -120
- package/docs/findings/004-agent-reliability-improvements.md +0 -162
- package/docs/findings/005-installation-timeout.md +0 -128
- package/docs/findings/006-malformed-tool-schema.md +0 -118
- package/docs/findings/007-telegram-errors-and-handoff-stalling.md +0 -271
- package/docs/findings/008-exec-timeout-architecture.md +0 -118
- package/docs/findings/009-non-string-response-field.md +0 -153
- package/docs/findings/010-checkpoint-field-type-safety.md +0 -121
- package/docs/findings/011-empty-model-response.md +0 -157
- package/docs/findings/012-empty-nudge-loses-recovery-text.md +0 -121
- package/docs/findings/013-stderr-visibility-and-truncation.md +0 -59
- package/docs/findings/014-exec-stderr-artifact-and-malformed-tool-args.md +0 -202
- package/docs/findings/015-failed-run-context-strip.md +0 -142
- package/docs/findings/016-file-writing-corruption-and-stderr-loop.md +0 -119
- package/docs/findings/017-looping-intervention-and-lossy-checkpoint.md +0 -110
- package/docs/findings/018-anthropic-oauth-token-support.md +0 -72
package/src/server/agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/server/config.js
CHANGED
|
@@ -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
|
+
}
|