@cordbot/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/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/cordbot.js +10 -0
- package/dist/agent/manager.js +256 -0
- package/dist/agent/stream.js +359 -0
- package/dist/auth.js +161 -0
- package/dist/cli.js +128 -0
- package/dist/discord/client.js +24 -0
- package/dist/discord/events.js +283 -0
- package/dist/discord/sync.js +148 -0
- package/dist/index.js +72 -0
- package/dist/init.js +82 -0
- package/dist/permissions/discord.js +66 -0
- package/dist/scheduler/parser.js +62 -0
- package/dist/scheduler/runner.js +201 -0
- package/dist/service/config.js +2 -0
- package/dist/service/manifest.js +27 -0
- package/dist/service/token-manager.js +143 -0
- package/dist/service/types.js +1 -0
- package/dist/storage/database.js +122 -0
- package/dist/tools/builtin-loader.js +21 -0
- package/dist/tools/cron/add_job.js +96 -0
- package/dist/tools/cron/list_jobs.js +55 -0
- package/dist/tools/cron/remove_job.js +64 -0
- package/dist/tools/cron/update_job.js +97 -0
- package/dist/tools/gmail/list_messages.js +87 -0
- package/dist/tools/gmail/send_email.js +118 -0
- package/dist/tools/loader.js +84 -0
- package/dist/tools/share_file.js +61 -0
- package/package.json +65 -0
- package/templates/.claude-cron.template +3 -0
- package/templates/channel-CLAUDE.md.template +26 -0
- package/templates/cron-skill.md +63 -0
- package/templates/root-CLAUDE.md.template +78 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
export function parseCronFile(filePath) {
|
|
4
|
+
if (!fs.existsSync(filePath)) {
|
|
5
|
+
return { jobs: [] };
|
|
6
|
+
}
|
|
7
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
8
|
+
// Handle empty file
|
|
9
|
+
if (!content.trim()) {
|
|
10
|
+
return { jobs: [] };
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const parsed = yaml.load(content);
|
|
14
|
+
// Validate structure
|
|
15
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
16
|
+
throw new Error('Invalid YAML structure');
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(parsed.jobs)) {
|
|
19
|
+
throw new Error('jobs must be an array');
|
|
20
|
+
}
|
|
21
|
+
// Validate each job
|
|
22
|
+
const jobs = parsed.jobs.map((job, index) => {
|
|
23
|
+
if (!job.name || typeof job.name !== 'string') {
|
|
24
|
+
throw new Error(`Job at index ${index} is missing required field: name`);
|
|
25
|
+
}
|
|
26
|
+
if (!job.schedule || typeof job.schedule !== 'string') {
|
|
27
|
+
throw new Error(`Job at index ${index} is missing required field: schedule`);
|
|
28
|
+
}
|
|
29
|
+
if (!job.task || typeof job.task !== 'string') {
|
|
30
|
+
throw new Error(`Job at index ${index} is missing required field: task`);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
name: job.name,
|
|
34
|
+
schedule: job.schedule,
|
|
35
|
+
task: job.task,
|
|
36
|
+
oneTime: job.oneTime === true, // Optional field, defaults to false
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
return { jobs };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error instanceof Error) {
|
|
43
|
+
throw new Error(`Failed to parse cron file: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Failed to parse cron file');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function validateCronSchedule(schedule) {
|
|
49
|
+
// Basic cron format validation: "* * * * *" (minute hour day month weekday)
|
|
50
|
+
const parts = schedule.trim().split(/\s+/);
|
|
51
|
+
if (parts.length !== 5) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
// Each part should be either:
|
|
55
|
+
// - A number
|
|
56
|
+
// - An asterisk (*)
|
|
57
|
+
// - A range (e.g., 1-5)
|
|
58
|
+
// - A list (e.g., 1,3,5)
|
|
59
|
+
// - A step (e.g., */2)
|
|
60
|
+
const cronPartRegex = /^(\*|(\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?)$/;
|
|
61
|
+
return parts.every(part => cronPartRegex.test(part));
|
|
62
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import cron from 'node-cron';
|
|
2
|
+
import chokidar from 'chokidar';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import { parseCronFile, validateCronSchedule } from './parser.js';
|
|
6
|
+
import { streamToDiscord } from '../agent/stream.js';
|
|
7
|
+
export class CronRunner {
|
|
8
|
+
client;
|
|
9
|
+
sessionManager;
|
|
10
|
+
scheduledTasks = new Map();
|
|
11
|
+
watchers = new Map();
|
|
12
|
+
channelMappings = new Map();
|
|
13
|
+
constructor(client, sessionManager) {
|
|
14
|
+
this.client = client;
|
|
15
|
+
this.sessionManager = sessionManager;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Start watching and scheduling cron jobs for all channels
|
|
19
|
+
*/
|
|
20
|
+
start(channelMappings) {
|
|
21
|
+
console.log('⏰ Starting cron scheduler...');
|
|
22
|
+
for (const mapping of channelMappings) {
|
|
23
|
+
this.channelMappings.set(mapping.channelId, mapping);
|
|
24
|
+
this.watchCronFile(mapping);
|
|
25
|
+
}
|
|
26
|
+
console.log(`✅ Watching ${channelMappings.length} cron files`);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Add a new channel to watch
|
|
30
|
+
*/
|
|
31
|
+
addChannel(mapping) {
|
|
32
|
+
this.channelMappings.set(mapping.channelId, mapping);
|
|
33
|
+
this.watchCronFile(mapping);
|
|
34
|
+
console.log(`✅ Now watching cron file for #${mapping.channelName}`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Remove a channel from watching
|
|
38
|
+
*/
|
|
39
|
+
removeChannel(channelId) {
|
|
40
|
+
// Stop the watcher
|
|
41
|
+
const watcher = this.watchers.get(channelId);
|
|
42
|
+
if (watcher) {
|
|
43
|
+
watcher.close();
|
|
44
|
+
this.watchers.delete(channelId);
|
|
45
|
+
}
|
|
46
|
+
// Stop all scheduled tasks for this channel
|
|
47
|
+
this.stopChannelTasks(channelId);
|
|
48
|
+
// Remove from channel mappings
|
|
49
|
+
this.channelMappings.delete(channelId);
|
|
50
|
+
console.log(`⏸️ Stopped watching channel ${channelId}`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Watch a cron file for changes and schedule jobs
|
|
54
|
+
*/
|
|
55
|
+
watchCronFile(mapping) {
|
|
56
|
+
const { cronPath, channelId, folderPath } = mapping;
|
|
57
|
+
// Initial load
|
|
58
|
+
this.loadAndScheduleJobs(channelId, cronPath, folderPath);
|
|
59
|
+
// Watch for changes
|
|
60
|
+
const watcher = chokidar.watch(cronPath, {
|
|
61
|
+
persistent: true,
|
|
62
|
+
ignoreInitial: true,
|
|
63
|
+
});
|
|
64
|
+
watcher.on('change', () => {
|
|
65
|
+
console.log(`🔄 Cron file changed: ${cronPath}`);
|
|
66
|
+
this.loadAndScheduleJobs(channelId, cronPath, folderPath);
|
|
67
|
+
});
|
|
68
|
+
watcher.on('error', (error) => {
|
|
69
|
+
console.error(`Failed to watch cron file ${cronPath}:`, error);
|
|
70
|
+
});
|
|
71
|
+
this.watchers.set(channelId, watcher);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Load cron file and schedule jobs
|
|
75
|
+
*/
|
|
76
|
+
loadAndScheduleJobs(channelId, cronPath, folderPath) {
|
|
77
|
+
// Stop existing tasks for this channel
|
|
78
|
+
this.stopChannelTasks(channelId);
|
|
79
|
+
try {
|
|
80
|
+
// Parse cron file
|
|
81
|
+
const config = parseCronFile(cronPath);
|
|
82
|
+
if (config.jobs.length === 0) {
|
|
83
|
+
console.log(`No jobs configured for channel ${channelId}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Schedule each job
|
|
87
|
+
const tasks = [];
|
|
88
|
+
for (const job of config.jobs) {
|
|
89
|
+
// Validate cron schedule
|
|
90
|
+
if (!validateCronSchedule(job.schedule)) {
|
|
91
|
+
console.error(`Invalid cron schedule for job "${job.name}": ${job.schedule}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Schedule the job
|
|
95
|
+
const task = cron.schedule(job.schedule, async () => {
|
|
96
|
+
await this.executeJob(job, channelId, folderPath);
|
|
97
|
+
});
|
|
98
|
+
tasks.push({ job, task, channelId, folderPath });
|
|
99
|
+
console.log(`📅 Scheduled job "${job.name}" for channel ${channelId}: ${job.schedule}`);
|
|
100
|
+
}
|
|
101
|
+
this.scheduledTasks.set(channelId, tasks);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.error(`Failed to load cron file ${cronPath}:`, error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Execute a scheduled job
|
|
109
|
+
*/
|
|
110
|
+
async executeJob(job, channelId, folderPath) {
|
|
111
|
+
console.log(`⏰ Executing scheduled job: ${job.name}`);
|
|
112
|
+
try {
|
|
113
|
+
const channel = this.client.channels.cache.get(channelId);
|
|
114
|
+
if (!channel) {
|
|
115
|
+
console.error(`Channel ${channelId} not found`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Create a session for this job
|
|
119
|
+
const sessionId = `cron_${Date.now()}_${job.name}`;
|
|
120
|
+
// Set channel and working directory context for tools
|
|
121
|
+
this.sessionManager.setChannelContext(sessionId, channel);
|
|
122
|
+
this.sessionManager.setWorkingDirContext(sessionId, folderPath);
|
|
123
|
+
try {
|
|
124
|
+
// Create query for Claude
|
|
125
|
+
const queryResult = this.sessionManager.createQuery(job.task, null, // New session for each cron job
|
|
126
|
+
folderPath);
|
|
127
|
+
// Stream response with trigger message as prefix
|
|
128
|
+
await streamToDiscord(queryResult, channel, this.sessionManager, sessionId, folderPath, `⏰ **Scheduled task:** ${job.task}`);
|
|
129
|
+
console.log(`✅ Completed scheduled job: ${job.name}`);
|
|
130
|
+
// Store the session so the next message in this channel can continue it
|
|
131
|
+
this.sessionManager.setPendingCronSession(channelId, sessionId, folderPath);
|
|
132
|
+
// If this is a one-time job, remove it from the cron file
|
|
133
|
+
if (job.oneTime) {
|
|
134
|
+
await this.removeOneTimeJob(channelId, job.name, folderPath);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
// Clear contexts after execution
|
|
139
|
+
this.sessionManager.clearChannelContext(sessionId);
|
|
140
|
+
this.sessionManager.clearWorkingDirContext(sessionId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
console.error(`Failed to execute job "${job.name}":`, error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Stop all tasks for a channel
|
|
149
|
+
*/
|
|
150
|
+
stopChannelTasks(channelId) {
|
|
151
|
+
const tasks = this.scheduledTasks.get(channelId);
|
|
152
|
+
if (tasks) {
|
|
153
|
+
for (const { task, job } of tasks) {
|
|
154
|
+
task.stop();
|
|
155
|
+
console.log(`⏸️ Stopped job: ${job.name}`);
|
|
156
|
+
}
|
|
157
|
+
this.scheduledTasks.delete(channelId);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Remove a one-time job after it executes
|
|
162
|
+
*/
|
|
163
|
+
async removeOneTimeJob(channelId, jobName, folderPath) {
|
|
164
|
+
try {
|
|
165
|
+
const mapping = this.channelMappings.get(channelId);
|
|
166
|
+
if (!mapping) {
|
|
167
|
+
console.error(`Cannot find channel mapping for ${channelId}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const cronPath = mapping.cronPath;
|
|
171
|
+
// Read and parse the cron file
|
|
172
|
+
const config = parseCronFile(cronPath);
|
|
173
|
+
// Filter out the completed one-time job
|
|
174
|
+
const updatedJobs = config.jobs.filter(job => job.name !== jobName);
|
|
175
|
+
// Write back to file
|
|
176
|
+
const yamlContent = yaml.dump({ jobs: updatedJobs });
|
|
177
|
+
fs.writeFileSync(cronPath, yamlContent, 'utf-8');
|
|
178
|
+
console.log(`🗑️ Removed one-time job: ${jobName}`);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
console.error(`Failed to remove one-time job "${jobName}":`, error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Stop all scheduled tasks and watchers
|
|
186
|
+
*/
|
|
187
|
+
stop() {
|
|
188
|
+
console.log('⏸️ Stopping cron scheduler...');
|
|
189
|
+
// Stop all scheduled tasks
|
|
190
|
+
for (const [channelId, tasks] of this.scheduledTasks) {
|
|
191
|
+
this.stopChannelTasks(channelId);
|
|
192
|
+
}
|
|
193
|
+
// Stop all watchers
|
|
194
|
+
for (const [channelId, watcher] of this.watchers) {
|
|
195
|
+
watcher.close();
|
|
196
|
+
console.log(`⏸️ Stopped watching channel ${channelId}`);
|
|
197
|
+
}
|
|
198
|
+
this.watchers.clear();
|
|
199
|
+
console.log('✅ Cron scheduler stopped');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export async function fetchManifest(botToken, serviceUrl) {
|
|
2
|
+
try {
|
|
3
|
+
const response = await fetch(`${serviceUrl}/getBotManifest`, {
|
|
4
|
+
method: 'POST',
|
|
5
|
+
headers: {
|
|
6
|
+
'Content-Type': 'application/json',
|
|
7
|
+
},
|
|
8
|
+
body: JSON.stringify({
|
|
9
|
+
data: { botToken },
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
console.error(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const data = await response.json();
|
|
17
|
+
if (data.result?.error) {
|
|
18
|
+
console.error(`Manifest error: ${data.result.error}`);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return data.result;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.error('Error fetching manifest:', error);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { fetchManifest } from './manifest.js';
|
|
2
|
+
/**
|
|
3
|
+
* TokenManager handles OAuth token lifecycle:
|
|
4
|
+
* - Provides fresh tokens to tools
|
|
5
|
+
* - Automatically refreshes tokens before expiry
|
|
6
|
+
* - Runs background refresh loop
|
|
7
|
+
*/
|
|
8
|
+
export class TokenManager {
|
|
9
|
+
botToken;
|
|
10
|
+
serviceUrl;
|
|
11
|
+
manifest = null;
|
|
12
|
+
refreshInterval = null;
|
|
13
|
+
isRefreshing = false;
|
|
14
|
+
constructor(botToken, serviceUrl, initialManifest = null) {
|
|
15
|
+
this.botToken = botToken;
|
|
16
|
+
this.serviceUrl = serviceUrl;
|
|
17
|
+
this.manifest = initialManifest;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Start background token refresh
|
|
21
|
+
* Checks every 5 minutes and refreshes tokens expiring in <10 minutes
|
|
22
|
+
*/
|
|
23
|
+
startBackgroundRefresh() {
|
|
24
|
+
if (this.refreshInterval)
|
|
25
|
+
return;
|
|
26
|
+
// Check every 5 minutes
|
|
27
|
+
const checkInterval = 5 * 60 * 1000;
|
|
28
|
+
this.refreshInterval = setInterval(async () => {
|
|
29
|
+
await this.refreshIfNeeded();
|
|
30
|
+
}, checkInterval);
|
|
31
|
+
console.log('🔄 Token refresh background task started');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Stop background token refresh
|
|
35
|
+
*/
|
|
36
|
+
stopBackgroundRefresh() {
|
|
37
|
+
if (this.refreshInterval) {
|
|
38
|
+
clearInterval(this.refreshInterval);
|
|
39
|
+
this.refreshInterval = null;
|
|
40
|
+
console.log('⏸️ Token refresh background task stopped');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if any tokens need refresh and refresh if needed
|
|
45
|
+
*/
|
|
46
|
+
async refreshIfNeeded() {
|
|
47
|
+
if (this.isRefreshing || !this.manifest)
|
|
48
|
+
return;
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const tenMinutes = 10 * 60 * 1000;
|
|
51
|
+
let needsRefresh = false;
|
|
52
|
+
// Check if any tokens expire in the next 10 minutes
|
|
53
|
+
for (const [category, token] of Object.entries(this.manifest.tokens)) {
|
|
54
|
+
if (token && token.expiresAt) {
|
|
55
|
+
const timeUntilExpiry = token.expiresAt - now;
|
|
56
|
+
if (timeUntilExpiry < tenMinutes) {
|
|
57
|
+
console.log(`⚠️ ${category} token expires in ${Math.round(timeUntilExpiry / 1000 / 60)} minutes`);
|
|
58
|
+
needsRefresh = true;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (needsRefresh) {
|
|
64
|
+
await this.refreshTokens();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Force refresh all tokens from service
|
|
69
|
+
*/
|
|
70
|
+
async refreshTokens() {
|
|
71
|
+
if (this.isRefreshing) {
|
|
72
|
+
console.log('⏳ Token refresh already in progress...');
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
this.isRefreshing = true;
|
|
76
|
+
try {
|
|
77
|
+
console.log('🔄 Refreshing tokens from service...');
|
|
78
|
+
const newManifest = await fetchManifest(this.botToken, this.serviceUrl);
|
|
79
|
+
if (!newManifest) {
|
|
80
|
+
console.error('❌ Failed to refresh tokens - service unavailable');
|
|
81
|
+
this.isRefreshing = false;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
this.manifest = newManifest;
|
|
85
|
+
console.log('✅ Tokens refreshed successfully');
|
|
86
|
+
this.isRefreshing = false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.error('❌ Token refresh failed:', error);
|
|
91
|
+
this.isRefreshing = false;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Get a valid token for a category
|
|
97
|
+
* Automatically refreshes if token is expired or missing
|
|
98
|
+
*/
|
|
99
|
+
async getToken(category) {
|
|
100
|
+
if (!this.manifest) {
|
|
101
|
+
console.error(`❌ No manifest available for ${category}`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const token = this.manifest.tokens[category];
|
|
105
|
+
if (!token) {
|
|
106
|
+
console.error(`❌ No ${category} token in manifest`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
// Check if token is expired or about to expire (within 2 minutes)
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const twoMinutes = 2 * 60 * 1000;
|
|
112
|
+
const isExpired = token.expiresAt <= now;
|
|
113
|
+
const isExpiringSoon = token.expiresAt - now < twoMinutes;
|
|
114
|
+
if (isExpired) {
|
|
115
|
+
console.log(`⚠️ ${category} token expired, refreshing...`);
|
|
116
|
+
const refreshed = await this.refreshTokens();
|
|
117
|
+
if (!refreshed) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return this.manifest.tokens[category] || null;
|
|
121
|
+
}
|
|
122
|
+
if (isExpiringSoon) {
|
|
123
|
+
console.log(`⚠️ ${category} token expiring soon, refreshing...`);
|
|
124
|
+
// Refresh in background, but return current token
|
|
125
|
+
this.refreshTokens().catch(err => {
|
|
126
|
+
console.error('Background token refresh failed:', err);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return token;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get the current manifest
|
|
133
|
+
*/
|
|
134
|
+
getManifest() {
|
|
135
|
+
return this.manifest;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Update the manifest (useful for initial setup)
|
|
139
|
+
*/
|
|
140
|
+
setManifest(manifest) {
|
|
141
|
+
this.manifest = manifest;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
export class SessionDatabase {
|
|
3
|
+
db;
|
|
4
|
+
constructor(dbPath) {
|
|
5
|
+
this.db = new Database(dbPath);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Create a new session mapping
|
|
9
|
+
*/
|
|
10
|
+
createMapping(mapping) {
|
|
11
|
+
const now = new Date().toISOString();
|
|
12
|
+
const stmt = this.db.prepare(`
|
|
13
|
+
INSERT INTO session_mappings (
|
|
14
|
+
discord_thread_id,
|
|
15
|
+
discord_channel_id,
|
|
16
|
+
discord_message_id,
|
|
17
|
+
session_id,
|
|
18
|
+
working_directory,
|
|
19
|
+
created_at,
|
|
20
|
+
last_active_at,
|
|
21
|
+
status
|
|
22
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
23
|
+
`);
|
|
24
|
+
stmt.run(mapping.discord_thread_id, mapping.discord_channel_id, mapping.discord_message_id, mapping.session_id, mapping.working_directory, now, now, mapping.status);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get a session mapping by Discord thread ID
|
|
28
|
+
*/
|
|
29
|
+
getMapping(threadId) {
|
|
30
|
+
const stmt = this.db.prepare(`
|
|
31
|
+
SELECT * FROM session_mappings WHERE discord_thread_id = ?
|
|
32
|
+
`);
|
|
33
|
+
return stmt.get(threadId);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Get session mapping by session ID
|
|
37
|
+
*/
|
|
38
|
+
getMappingBySessionId(sessionId) {
|
|
39
|
+
const stmt = this.db.prepare(`
|
|
40
|
+
SELECT * FROM session_mappings WHERE session_id = ?
|
|
41
|
+
`);
|
|
42
|
+
return stmt.get(sessionId);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Get all sessions for a channel
|
|
46
|
+
*/
|
|
47
|
+
getChannelSessions(channelId) {
|
|
48
|
+
const stmt = this.db.prepare(`
|
|
49
|
+
SELECT * FROM session_mappings WHERE discord_channel_id = ?
|
|
50
|
+
ORDER BY last_active_at DESC
|
|
51
|
+
`);
|
|
52
|
+
return stmt.all(channelId);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Get all active sessions
|
|
56
|
+
*/
|
|
57
|
+
getAllActive() {
|
|
58
|
+
const stmt = this.db.prepare(`
|
|
59
|
+
SELECT * FROM session_mappings WHERE status = 'active'
|
|
60
|
+
ORDER BY last_active_at DESC
|
|
61
|
+
`);
|
|
62
|
+
return stmt.all();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Update last active timestamp for a session
|
|
66
|
+
*/
|
|
67
|
+
updateLastActive(threadId) {
|
|
68
|
+
const stmt = this.db.prepare(`
|
|
69
|
+
UPDATE session_mappings
|
|
70
|
+
SET last_active_at = ?
|
|
71
|
+
WHERE discord_thread_id = ?
|
|
72
|
+
`);
|
|
73
|
+
stmt.run(new Date().toISOString(), threadId);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Update session ID for a thread
|
|
77
|
+
*/
|
|
78
|
+
updateSessionId(threadId, newSessionId) {
|
|
79
|
+
const stmt = this.db.prepare(`
|
|
80
|
+
UPDATE session_mappings
|
|
81
|
+
SET session_id = ?, last_active_at = ?
|
|
82
|
+
WHERE discord_thread_id = ?
|
|
83
|
+
`);
|
|
84
|
+
stmt.run(newSessionId, new Date().toISOString(), threadId);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Archive a session
|
|
88
|
+
*/
|
|
89
|
+
archiveSession(threadId) {
|
|
90
|
+
const stmt = this.db.prepare(`
|
|
91
|
+
UPDATE session_mappings
|
|
92
|
+
SET status = 'archived'
|
|
93
|
+
WHERE discord_thread_id = ?
|
|
94
|
+
`);
|
|
95
|
+
stmt.run(threadId);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Delete a session mapping
|
|
99
|
+
*/
|
|
100
|
+
deleteMapping(threadId) {
|
|
101
|
+
const stmt = this.db.prepare(`
|
|
102
|
+
DELETE FROM session_mappings WHERE discord_thread_id = ?
|
|
103
|
+
`);
|
|
104
|
+
stmt.run(threadId);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get count of active sessions
|
|
108
|
+
*/
|
|
109
|
+
getActiveCount() {
|
|
110
|
+
const stmt = this.db.prepare(`
|
|
111
|
+
SELECT COUNT(*) as count FROM session_mappings WHERE status = 'active'
|
|
112
|
+
`);
|
|
113
|
+
const result = stmt.get();
|
|
114
|
+
return result.count;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Close the database connection
|
|
118
|
+
*/
|
|
119
|
+
close() {
|
|
120
|
+
this.db.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createTool as createListJobs } from './cron/list_jobs.js';
|
|
2
|
+
import { createTool as createAddJob } from './cron/add_job.js';
|
|
3
|
+
import { createTool as createRemoveJob } from './cron/remove_job.js';
|
|
4
|
+
import { createTool as createUpdateJob } from './cron/update_job.js';
|
|
5
|
+
import { createTool as createShareFile } from './share_file.js';
|
|
6
|
+
/**
|
|
7
|
+
* Load built-in tools that don't require authentication
|
|
8
|
+
* These tools are always available regardless of manifest configuration
|
|
9
|
+
*/
|
|
10
|
+
export function loadBuiltinTools(getCurrentWorkingDir, queueFileForSharing) {
|
|
11
|
+
const tools = [];
|
|
12
|
+
// Load cron management tools
|
|
13
|
+
tools.push(createListJobs(getCurrentWorkingDir));
|
|
14
|
+
tools.push(createAddJob(getCurrentWorkingDir));
|
|
15
|
+
tools.push(createRemoveJob(getCurrentWorkingDir));
|
|
16
|
+
tools.push(createUpdateJob(getCurrentWorkingDir));
|
|
17
|
+
// Load file sharing tool
|
|
18
|
+
tools.push(createShareFile(getCurrentWorkingDir, queueFileForSharing));
|
|
19
|
+
console.log(` ✓ Loaded ${tools.length} built-in tools`);
|
|
20
|
+
return tools;
|
|
21
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { parseCronFile, validateCronSchedule } from '../../scheduler/parser.js';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
const schema = z.object({
|
|
8
|
+
name: z.string().describe('Unique name for this cron job (e.g., "Daily summary", "Weekly report")'),
|
|
9
|
+
schedule: z.string().describe('Cron schedule in 5-field format: "minute hour day month weekday". Examples: "0 9 * * *" (daily at 9am), "0 9 * * 1" (Mondays at 9am), "*/30 * * * *" (every 30 minutes)'),
|
|
10
|
+
task: z.string().describe('Description of the task for Claude to execute when this job runs (e.g., "Summarize recent changes", "Generate weekly report")'),
|
|
11
|
+
oneTime: z.boolean().optional().describe('Set to true for one-time tasks that should be removed after execution. Default: false')
|
|
12
|
+
});
|
|
13
|
+
export function createTool(getCwd) {
|
|
14
|
+
return tool('cron_add_job', 'Add a new scheduled cron job to this Discord channel. Use this instead of bash cron/at commands - jobs will execute autonomously and post results directly to the Discord channel. Always list jobs first to avoid duplicate names.', schema.shape, async (params) => {
|
|
15
|
+
try {
|
|
16
|
+
// Validate cron schedule format
|
|
17
|
+
if (!validateCronSchedule(params.schedule)) {
|
|
18
|
+
return {
|
|
19
|
+
content: [
|
|
20
|
+
{
|
|
21
|
+
type: 'text',
|
|
22
|
+
text: JSON.stringify({
|
|
23
|
+
error: `Invalid cron schedule format: "${params.schedule}". Must be 5 fields: minute hour day month weekday. Example: "0 9 * * *" for daily at 9am.`,
|
|
24
|
+
validFormat: 'minute hour day month weekday',
|
|
25
|
+
examples: [
|
|
26
|
+
'0 9 * * * - Every day at 9:00 AM',
|
|
27
|
+
'0 9 * * 1 - Every Monday at 9:00 AM',
|
|
28
|
+
'*/30 * * * * - Every 30 minutes',
|
|
29
|
+
'0 0 1 * * - First day of every month at midnight'
|
|
30
|
+
]
|
|
31
|
+
}, null, 2)
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const cwd = getCwd();
|
|
37
|
+
const cronPath = path.join(cwd, '.claude-cron');
|
|
38
|
+
// Read existing jobs
|
|
39
|
+
const config = parseCronFile(cronPath);
|
|
40
|
+
// Check for duplicate name
|
|
41
|
+
if (config.jobs.some(job => job.name === params.name)) {
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: 'text',
|
|
46
|
+
text: JSON.stringify({
|
|
47
|
+
error: `A job named "${params.name}" already exists. Use a different name or remove the existing job first.`,
|
|
48
|
+
existingJob: config.jobs.find(job => job.name === params.name)
|
|
49
|
+
}, null, 2)
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Add new job
|
|
55
|
+
config.jobs.push({
|
|
56
|
+
name: params.name,
|
|
57
|
+
schedule: params.schedule,
|
|
58
|
+
task: params.task,
|
|
59
|
+
oneTime: params.oneTime || false
|
|
60
|
+
});
|
|
61
|
+
// Write back to file
|
|
62
|
+
const yamlContent = yaml.dump({ jobs: config.jobs });
|
|
63
|
+
fs.writeFileSync(cronPath, yamlContent, 'utf-8');
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: 'text',
|
|
68
|
+
text: JSON.stringify({
|
|
69
|
+
success: true,
|
|
70
|
+
message: `Cron job "${params.name}" added successfully!`,
|
|
71
|
+
job: {
|
|
72
|
+
name: params.name,
|
|
73
|
+
schedule: params.schedule,
|
|
74
|
+
task: params.task,
|
|
75
|
+
oneTime: params.oneTime || false
|
|
76
|
+
},
|
|
77
|
+
note: 'The job will be automatically scheduled and will start running according to the schedule. Results will be posted to this Discord channel.'
|
|
78
|
+
}, null, 2)
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: JSON.stringify({
|
|
89
|
+
error: `Failed to add cron job: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
90
|
+
}, null, 2)
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|