@johpaz/hive 1.1.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/CONTRIBUTING.md +44 -0
- package/README.md +310 -0
- package/package.json +96 -0
- package/packages/cli/package.json +28 -0
- package/packages/cli/src/commands/agent-run.ts +168 -0
- package/packages/cli/src/commands/agents.ts +398 -0
- package/packages/cli/src/commands/chat.ts +142 -0
- package/packages/cli/src/commands/config.ts +50 -0
- package/packages/cli/src/commands/cron.ts +161 -0
- package/packages/cli/src/commands/dev.ts +95 -0
- package/packages/cli/src/commands/doctor.ts +133 -0
- package/packages/cli/src/commands/gateway.ts +443 -0
- package/packages/cli/src/commands/logs.ts +57 -0
- package/packages/cli/src/commands/mcp.ts +175 -0
- package/packages/cli/src/commands/message.ts +77 -0
- package/packages/cli/src/commands/onboard.ts +1868 -0
- package/packages/cli/src/commands/security.ts +144 -0
- package/packages/cli/src/commands/service.ts +50 -0
- package/packages/cli/src/commands/sessions.ts +116 -0
- package/packages/cli/src/commands/skills.ts +187 -0
- package/packages/cli/src/commands/update.ts +25 -0
- package/packages/cli/src/index.ts +185 -0
- package/packages/cli/src/utils/token.ts +6 -0
- package/packages/code-bridge/README.md +78 -0
- package/packages/code-bridge/package.json +18 -0
- package/packages/code-bridge/src/index.ts +95 -0
- package/packages/code-bridge/src/process-manager.ts +212 -0
- package/packages/code-bridge/src/schemas.ts +133 -0
- package/packages/core/package.json +46 -0
- package/packages/core/src/agent/agent-loop.ts +369 -0
- package/packages/core/src/agent/compaction.ts +140 -0
- package/packages/core/src/agent/context-compiler.ts +378 -0
- package/packages/core/src/agent/context-guard.ts +91 -0
- package/packages/core/src/agent/context.ts +138 -0
- package/packages/core/src/agent/conversation-store.ts +198 -0
- package/packages/core/src/agent/curator.ts +158 -0
- package/packages/core/src/agent/hooks.ts +166 -0
- package/packages/core/src/agent/index.ts +116 -0
- package/packages/core/src/agent/llm-client.ts +503 -0
- package/packages/core/src/agent/native-tools.ts +505 -0
- package/packages/core/src/agent/prompt-builder.ts +532 -0
- package/packages/core/src/agent/providers/index.ts +167 -0
- package/packages/core/src/agent/providers.ts +1 -0
- package/packages/core/src/agent/reflector.ts +170 -0
- package/packages/core/src/agent/service.ts +64 -0
- package/packages/core/src/agent/stuck-loop.ts +133 -0
- package/packages/core/src/agent/supervisor.ts +39 -0
- package/packages/core/src/agent/tracer.ts +102 -0
- package/packages/core/src/agent/workspace.ts +110 -0
- package/packages/core/src/canvas/canvas-manager.test.ts +161 -0
- package/packages/core/src/canvas/canvas-manager.ts +319 -0
- package/packages/core/src/canvas/canvas-tools.ts +420 -0
- package/packages/core/src/canvas/emitter.ts +115 -0
- package/packages/core/src/canvas/index.ts +2 -0
- package/packages/core/src/channels/base.ts +138 -0
- package/packages/core/src/channels/discord.ts +260 -0
- package/packages/core/src/channels/index.ts +7 -0
- package/packages/core/src/channels/manager.ts +383 -0
- package/packages/core/src/channels/slack.ts +287 -0
- package/packages/core/src/channels/telegram.ts +502 -0
- package/packages/core/src/channels/webchat.ts +128 -0
- package/packages/core/src/channels/whatsapp.ts +375 -0
- package/packages/core/src/config/index.ts +12 -0
- package/packages/core/src/config/loader.ts +529 -0
- package/packages/core/src/events/event-bus.ts +169 -0
- package/packages/core/src/gateway/index.ts +5 -0
- package/packages/core/src/gateway/initializer.ts +290 -0
- package/packages/core/src/gateway/lane-queue.ts +169 -0
- package/packages/core/src/gateway/resolver.ts +108 -0
- package/packages/core/src/gateway/router.ts +124 -0
- package/packages/core/src/gateway/server.ts +3317 -0
- package/packages/core/src/gateway/session.ts +95 -0
- package/packages/core/src/gateway/slash-commands.ts +192 -0
- package/packages/core/src/heartbeat/index.ts +157 -0
- package/packages/core/src/index.ts +19 -0
- package/packages/core/src/integrations/catalog.ts +286 -0
- package/packages/core/src/integrations/env.ts +64 -0
- package/packages/core/src/integrations/index.ts +2 -0
- package/packages/core/src/memory/index.ts +1 -0
- package/packages/core/src/memory/notes.ts +68 -0
- package/packages/core/src/plugins/api.ts +128 -0
- package/packages/core/src/plugins/index.ts +2 -0
- package/packages/core/src/plugins/loader.ts +365 -0
- package/packages/core/src/resilience/circuit-breaker.ts +225 -0
- package/packages/core/src/security/google-chat.ts +269 -0
- package/packages/core/src/security/index.ts +192 -0
- package/packages/core/src/security/pairing.ts +250 -0
- package/packages/core/src/security/rate-limit.ts +270 -0
- package/packages/core/src/security/signal.ts +321 -0
- package/packages/core/src/state/store.ts +312 -0
- package/packages/core/src/storage/bun-sqlite-store.ts +188 -0
- package/packages/core/src/storage/crypto.ts +101 -0
- package/packages/core/src/storage/db-context.ts +333 -0
- package/packages/core/src/storage/onboarding.ts +1087 -0
- package/packages/core/src/storage/schema.ts +541 -0
- package/packages/core/src/storage/seed.ts +571 -0
- package/packages/core/src/storage/sqlite.ts +387 -0
- package/packages/core/src/storage/usage.ts +212 -0
- package/packages/core/src/tools/bridge-events.ts +74 -0
- package/packages/core/src/tools/browser.ts +275 -0
- package/packages/core/src/tools/codebridge.ts +421 -0
- package/packages/core/src/tools/coordinator-tools.ts +179 -0
- package/packages/core/src/tools/cron.ts +611 -0
- package/packages/core/src/tools/exec.ts +140 -0
- package/packages/core/src/tools/fs.ts +364 -0
- package/packages/core/src/tools/index.ts +12 -0
- package/packages/core/src/tools/memory.ts +176 -0
- package/packages/core/src/tools/notify.ts +113 -0
- package/packages/core/src/tools/project-management.ts +376 -0
- package/packages/core/src/tools/project.ts +375 -0
- package/packages/core/src/tools/read.ts +158 -0
- package/packages/core/src/tools/web.ts +436 -0
- package/packages/core/src/tools/workspace.ts +171 -0
- package/packages/core/src/utils/benchmark.ts +80 -0
- package/packages/core/src/utils/crypto.ts +73 -0
- package/packages/core/src/utils/date.ts +42 -0
- package/packages/core/src/utils/index.ts +4 -0
- package/packages/core/src/utils/logger.ts +388 -0
- package/packages/core/src/utils/retry.ts +70 -0
- package/packages/core/src/voice/index.ts +583 -0
- package/packages/core/tsconfig.json +9 -0
- package/packages/mcp/package.json +26 -0
- package/packages/mcp/src/config.ts +13 -0
- package/packages/mcp/src/index.ts +1 -0
- package/packages/mcp/src/logger.ts +42 -0
- package/packages/mcp/src/manager.ts +434 -0
- package/packages/mcp/src/transports/index.ts +67 -0
- package/packages/mcp/src/transports/sse.ts +241 -0
- package/packages/mcp/src/transports/websocket.ts +159 -0
- package/packages/skills/package.json +21 -0
- package/packages/skills/src/bundled/agent_management/SKILL.md +24 -0
- package/packages/skills/src/bundled/browser_automation/SKILL.md +30 -0
- package/packages/skills/src/bundled/context_compact/SKILL.md +35 -0
- package/packages/skills/src/bundled/cron_manager/SKILL.md +52 -0
- package/packages/skills/src/bundled/file_manager/SKILL.md +76 -0
- package/packages/skills/src/bundled/http_client/SKILL.md +24 -0
- package/packages/skills/src/bundled/memory/SKILL.md +42 -0
- package/packages/skills/src/bundled/project_management/SKILL.md +26 -0
- package/packages/skills/src/bundled/shell/SKILL.md +43 -0
- package/packages/skills/src/bundled/system_notify/SKILL.md +52 -0
- package/packages/skills/src/bundled/voice/SKILL.md +25 -0
- package/packages/skills/src/bundled/web_search/SKILL.md +29 -0
- package/packages/skills/src/index.ts +1 -0
- package/packages/skills/src/loader.ts +282 -0
- package/packages/tools/package.json +43 -0
- package/packages/tools/src/browser/browser.test.ts +111 -0
- package/packages/tools/src/browser/index.ts +272 -0
- package/packages/tools/src/canvas/index.ts +220 -0
- package/packages/tools/src/cron/cron.test.ts +164 -0
- package/packages/tools/src/cron/index.ts +304 -0
- package/packages/tools/src/filesystem/filesystem.test.ts +240 -0
- package/packages/tools/src/filesystem/index.ts +379 -0
- package/packages/tools/src/git/index.ts +239 -0
- package/packages/tools/src/index.ts +4 -0
- package/packages/tools/src/shell/detect-env.ts +70 -0
- package/packages/tools/tsconfig.json +9 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import type { Tool } from "../agent/native-tools.ts";
|
|
2
|
+
import { Cron } from "croner";
|
|
3
|
+
import type { Config } from "../config/loader.ts";
|
|
4
|
+
import { logger } from "../utils/logger.ts";
|
|
5
|
+
import { getDb } from "../storage/sqlite";
|
|
6
|
+
import { getUserDate, getUserTime } from "../utils/date";
|
|
7
|
+
|
|
8
|
+
export interface CronJob {
|
|
9
|
+
id: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
cronExpression: string;
|
|
13
|
+
taskType: string;
|
|
14
|
+
taskConfig: any;
|
|
15
|
+
notifyChannelId: string | null;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
maxRuns: number | null;
|
|
18
|
+
runCount: number;
|
|
19
|
+
expiresAt: number | null;
|
|
20
|
+
lastRun: number | null;
|
|
21
|
+
nextRun: number | null;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type TriggerFn = (
|
|
26
|
+
sessionId: string,
|
|
27
|
+
task: string,
|
|
28
|
+
jobId: string,
|
|
29
|
+
context: { fecha_usuario: string; hora_usuario: string }
|
|
30
|
+
) => void;
|
|
31
|
+
|
|
32
|
+
// ── Scheduler in-memory state ────────────────────────────────────────────────
|
|
33
|
+
// One Croner instance per active job, keyed by jobId
|
|
34
|
+
const activeJobs = new Map<string, Cron>();
|
|
35
|
+
let globalOnTrigger: TriggerFn | undefined;
|
|
36
|
+
let schedulerInitialized = false;
|
|
37
|
+
|
|
38
|
+
const log = logger.child("cron");
|
|
39
|
+
|
|
40
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Computes the next UTC timestamp for a cron expression interpreted in the
|
|
44
|
+
* user's timezone. All timestamps stored in DB are UTC (Unix seconds).
|
|
45
|
+
*/
|
|
46
|
+
function calculateNextRun(expression: string, timezone = "UTC"): number {
|
|
47
|
+
try {
|
|
48
|
+
const cron = new Cron(expression, { timezone });
|
|
49
|
+
const next = cron.nextRun();
|
|
50
|
+
if (next) return Math.floor(next.getTime() / 1000);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
log.error(`calculateNextRun error: ${(e as Error).message}`);
|
|
53
|
+
}
|
|
54
|
+
// Fallback: 1 hour from now
|
|
55
|
+
return Math.floor(Date.now() / 1000) + 3600;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Formats a UTC timestamp as a human-readable local date-time string.
|
|
60
|
+
*/
|
|
61
|
+
function formatLocal(utcSeconds: number | null, timezone: string): string | null {
|
|
62
|
+
if (!utcSeconds) return null;
|
|
63
|
+
try {
|
|
64
|
+
return new Date(utcSeconds * 1000).toLocaleString("es", {
|
|
65
|
+
timeZone: timezone,
|
|
66
|
+
dateStyle: "full",
|
|
67
|
+
timeStyle: "short",
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
return new Date(utcSeconds * 1000).toISOString();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves the best notification channel for a user, applying the priority chain:
|
|
76
|
+
* 1. Explicit job-level notify_channel_id
|
|
77
|
+
* 2. User's preferred_cron_channel (if not 'auto')
|
|
78
|
+
* 3. AUTO: telegram (active) > discord (active) > webchat
|
|
79
|
+
*/
|
|
80
|
+
export function resolveBestChannel(userId: string, explicitChannelId?: string | null): string {
|
|
81
|
+
if (explicitChannelId) return explicitChannelId;
|
|
82
|
+
|
|
83
|
+
const db = getDb();
|
|
84
|
+
|
|
85
|
+
// Check user preference
|
|
86
|
+
const userRow = db.query<{ preferred_cron_channel: string }, [string]>(
|
|
87
|
+
"SELECT preferred_cron_channel FROM users WHERE id = ?"
|
|
88
|
+
).get(userId);
|
|
89
|
+
const preference = userRow?.preferred_cron_channel ?? "auto";
|
|
90
|
+
|
|
91
|
+
if (preference !== "auto") {
|
|
92
|
+
// Verify the preferred channel is actually active before using it
|
|
93
|
+
const ch = db.query<{ id: string }, [string]>(
|
|
94
|
+
"SELECT id FROM channels WHERE id = ? AND active = 1"
|
|
95
|
+
).get(preference);
|
|
96
|
+
if (ch) return preference;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// AUTO: pick best active channel by priority
|
|
100
|
+
const priority = ["telegram", "discord", "webchat"];
|
|
101
|
+
for (const channelId of priority) {
|
|
102
|
+
const ch = db.query<{ id: string }, [string]>(
|
|
103
|
+
"SELECT id FROM channels WHERE id = ? AND active = 1"
|
|
104
|
+
).get(channelId);
|
|
105
|
+
if (ch) return channelId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return "webchat";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Stops and removes a job from the active map. */
|
|
112
|
+
function stopJob(jobId: string): void {
|
|
113
|
+
const existing = activeJobs.get(jobId);
|
|
114
|
+
if (existing) {
|
|
115
|
+
existing.stop();
|
|
116
|
+
activeJobs.delete(jobId);
|
|
117
|
+
log.debug(`Stopped cron job: ${jobId}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Schedules (or re-schedules) a job using a native Croner instance.
|
|
123
|
+
* The expression is interpreted in `userTimezone`; Croner fires at the
|
|
124
|
+
* exact wall-clock time in that timezone and converts to the correct UTC
|
|
125
|
+
* moment internally.
|
|
126
|
+
*/
|
|
127
|
+
function scheduleJob(
|
|
128
|
+
jobId: string,
|
|
129
|
+
name: string,
|
|
130
|
+
expression: string,
|
|
131
|
+
taskConfig: any,
|
|
132
|
+
userTimezone: string,
|
|
133
|
+
notifyChannelId: string | null
|
|
134
|
+
): void {
|
|
135
|
+
// Replace any existing instance for this job
|
|
136
|
+
stopJob(jobId);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const cron = new Cron(expression, { timezone: userTimezone }, () => {
|
|
140
|
+
const db = getDb();
|
|
141
|
+
const now = Math.floor(Date.now() / 1000);
|
|
142
|
+
|
|
143
|
+
// Reload job state to get fresh run_count / max_runs / expires_at
|
|
144
|
+
const job = db.query<any, [string]>(
|
|
145
|
+
"SELECT run_count, max_runs, expires_at FROM cron_jobs WHERE id = ?"
|
|
146
|
+
).get(jobId);
|
|
147
|
+
|
|
148
|
+
if (!job) {
|
|
149
|
+
log.warn(`Cron job ${jobId} no longer exists, stopping`);
|
|
150
|
+
stopJob(jobId);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check expiry
|
|
155
|
+
if (job.expires_at && now >= job.expires_at) {
|
|
156
|
+
log.info(`Cron job ${jobId} (${name}) expired — auto-disabling`);
|
|
157
|
+
db.query("UPDATE cron_jobs SET enabled = 0 WHERE id = ?").run(jobId);
|
|
158
|
+
stopJob(jobId);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const newRunCount = (job.run_count ?? 0) + 1;
|
|
163
|
+
const nextRun = calculateNextRun(expression, userTimezone);
|
|
164
|
+
|
|
165
|
+
// Check max_runs BEFORE firing — auto-disable when limit reached
|
|
166
|
+
if (job.max_runs !== null && newRunCount > job.max_runs) {
|
|
167
|
+
log.info(`Cron job ${jobId} (${name}) reached max_runs=${job.max_runs} — auto-disabling`);
|
|
168
|
+
db.query("UPDATE cron_jobs SET enabled = 0 WHERE id = ?").run(jobId);
|
|
169
|
+
stopJob(jobId);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Update DB: last_run, next_run, run_count; auto-disable if this was the last run
|
|
174
|
+
const isLastRun = job.max_runs !== null && newRunCount >= job.max_runs;
|
|
175
|
+
db.query(
|
|
176
|
+
"UPDATE cron_jobs SET last_run = ?, next_run = ?, run_count = ?, enabled = ? WHERE id = ?"
|
|
177
|
+
).run(now, nextRun, newRunCount, isLastRun ? 0 : 1, jobId);
|
|
178
|
+
|
|
179
|
+
if (isLastRun) {
|
|
180
|
+
log.info(`Cron job ${jobId} (${name}) completed all ${job.max_runs} run(s) — auto-disabling`);
|
|
181
|
+
stopJob(jobId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
log.info(`Cron triggered: ${jobId} — ${name} (run ${newRunCount}${job.max_runs ? `/${job.max_runs}` : ""})`, {
|
|
185
|
+
userTimezone,
|
|
186
|
+
localTime: getUserTime(userTimezone),
|
|
187
|
+
nextRun: new Date(nextRun * 1000).toISOString(),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const sessionId = taskConfig?.sessionId || jobId;
|
|
191
|
+
|
|
192
|
+
if (globalOnTrigger) {
|
|
193
|
+
globalOnTrigger(sessionId, name, jobId, {
|
|
194
|
+
fecha_usuario: getUserDate(userTimezone),
|
|
195
|
+
hora_usuario: getUserTime(userTimezone),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
activeJobs.set(jobId, cron);
|
|
201
|
+
|
|
202
|
+
// Keep next_run in DB in sync with what Croner computed
|
|
203
|
+
const next = cron.nextRun();
|
|
204
|
+
if (next) {
|
|
205
|
+
getDb()
|
|
206
|
+
.query("UPDATE cron_jobs SET next_run = ? WHERE id = ?")
|
|
207
|
+
.run(Math.floor(next.getTime() / 1000), jobId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
log.debug(`Scheduled job "${name}" (${expression}) in ${userTimezone}`);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
log.error(`Failed to schedule job ${jobId}: ${(e as Error).message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Public initializer ────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Called once at gateway startup. Loads all enabled jobs from DB and creates
|
|
220
|
+
* a Croner instance for each one so they fire at the right moment.
|
|
221
|
+
*/
|
|
222
|
+
export function initCronScheduler(onTrigger: TriggerFn): void {
|
|
223
|
+
globalOnTrigger = onTrigger;
|
|
224
|
+
schedulerInitialized = true;
|
|
225
|
+
|
|
226
|
+
// Stop any leftover instances (hot-reload safety)
|
|
227
|
+
for (const cron of activeJobs.values()) cron.stop();
|
|
228
|
+
activeJobs.clear();
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const db = getDb();
|
|
232
|
+
const now = Math.floor(Date.now() / 1000);
|
|
233
|
+
const jobs = db.query<any, [number]>(`
|
|
234
|
+
SELECT c.*, u.timezone
|
|
235
|
+
FROM cron_jobs c
|
|
236
|
+
LEFT JOIN users u ON c.user_id = u.id
|
|
237
|
+
WHERE c.enabled = 1
|
|
238
|
+
AND (c.expires_at IS NULL OR c.expires_at > ?)
|
|
239
|
+
AND (c.max_runs IS NULL OR c.run_count < c.max_runs)
|
|
240
|
+
`).all(now);
|
|
241
|
+
|
|
242
|
+
for (const job of jobs) {
|
|
243
|
+
const tz = job.timezone || "UTC";
|
|
244
|
+
const taskConfig = job.task_config ? JSON.parse(job.task_config) : {};
|
|
245
|
+
scheduleJob(job.id, job.name, job.cron_expression, taskConfig, tz, job.notify_channel_id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
log.info(`Cron scheduler initialized — ${jobs.length} job(s) active`);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
log.error(`initCronScheduler error: ${(e as Error).message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Tool factory ──────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export function createCronTools(
|
|
257
|
+
_config: Config,
|
|
258
|
+
onTrigger?: TriggerFn
|
|
259
|
+
): Tool[] {
|
|
260
|
+
if (onTrigger) globalOnTrigger = onTrigger;
|
|
261
|
+
|
|
262
|
+
// Boot scheduler only once — subsequent calls from tool resolution skip this
|
|
263
|
+
if (!schedulerInitialized) {
|
|
264
|
+
initCronScheduler(globalOnTrigger ?? (() => { }));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── cron_add ────────────────────────────────────────────────────────────────
|
|
268
|
+
const cronAdd: Tool = {
|
|
269
|
+
name: "cron_add",
|
|
270
|
+
description:
|
|
271
|
+
"Add a scheduled task. The cron expression is interpreted in the user's timezone; " +
|
|
272
|
+
"storage in DB is always UTC. Never ask the user for cron syntax — convert natural " +
|
|
273
|
+
"language to a 5-field expression yourself. " +
|
|
274
|
+
"The notification channel is auto-selected (prefers Telegram if active); " +
|
|
275
|
+
"pass notifyChannelId only if the user explicitly requests a specific channel.",
|
|
276
|
+
parameters: {
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
name: { type: "string", description: "Short name / description of the task" },
|
|
280
|
+
expression: {
|
|
281
|
+
type: "string",
|
|
282
|
+
description:
|
|
283
|
+
"5-field cron expression in user's LOCAL timezone. " +
|
|
284
|
+
"E.g. '0 9 * * *' = every day at 9 AM local time.",
|
|
285
|
+
},
|
|
286
|
+
taskType: {
|
|
287
|
+
type: "string",
|
|
288
|
+
enum: ["message", "agent", "custom"],
|
|
289
|
+
description: "Type of task",
|
|
290
|
+
},
|
|
291
|
+
taskMessage: {
|
|
292
|
+
type: "string",
|
|
293
|
+
description: "Message or instruction the agent should execute when the task fires",
|
|
294
|
+
},
|
|
295
|
+
notifyChannelId: {
|
|
296
|
+
type: "string",
|
|
297
|
+
description:
|
|
298
|
+
"Channel to notify (optional). Leave unset to auto-select best available channel " +
|
|
299
|
+
"(telegram > discord > webchat based on user preference).",
|
|
300
|
+
},
|
|
301
|
+
maxRuns: {
|
|
302
|
+
type: "number",
|
|
303
|
+
description:
|
|
304
|
+
"Maximum number of times this job should run. " +
|
|
305
|
+
"After reaching this count the job auto-disables. Omit for unlimited.",
|
|
306
|
+
},
|
|
307
|
+
expiresAt: {
|
|
308
|
+
type: "string",
|
|
309
|
+
description:
|
|
310
|
+
"ISO-8601 date/time after which the job auto-disables (e.g. '2024-12-31T23:59:00'). " +
|
|
311
|
+
"Interpreted in the user's timezone. Omit for no expiry.",
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
required: ["name", "expression"],
|
|
315
|
+
},
|
|
316
|
+
execute: async (params, config) => {
|
|
317
|
+
const name = params.name as string;
|
|
318
|
+
const expression = params.expression as string;
|
|
319
|
+
const taskType = (params.taskType as string) || "message";
|
|
320
|
+
const taskMessage = (params.taskMessage as string) || name;
|
|
321
|
+
const explicitChannelId = params.notifyChannelId as string | undefined;
|
|
322
|
+
const maxRuns = params.maxRuns != null ? Number(params.maxRuns) : null;
|
|
323
|
+
|
|
324
|
+
const db = getDb();
|
|
325
|
+
const userId = (config?.configurable?.user_id as string) || process.env.HIVE_USER_ID;
|
|
326
|
+
if (!userId) throw new Error("userId not found — complete onboarding first.");
|
|
327
|
+
|
|
328
|
+
const sessionId = (config?.configurable?.thread_id as string) || userId;
|
|
329
|
+
|
|
330
|
+
const userRow = db.query<any, [string]>("SELECT timezone FROM users WHERE id = ?").get(userId);
|
|
331
|
+
if (!userRow) throw new Error(`User ${userId} not found.`);
|
|
332
|
+
const userTimezone = userRow.timezone || "UTC";
|
|
333
|
+
|
|
334
|
+
// Parse expiresAt in user's timezone → UTC unix timestamp
|
|
335
|
+
let expiresAt: number | null = null;
|
|
336
|
+
if (params.expiresAt) {
|
|
337
|
+
try {
|
|
338
|
+
const d = new Date(params.expiresAt as string);
|
|
339
|
+
if (!isNaN(d.getTime())) expiresAt = Math.floor(d.getTime() / 1000);
|
|
340
|
+
} catch {
|
|
341
|
+
log.warn(`Invalid expiresAt value: ${params.expiresAt}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Auto-resolve best notification channel
|
|
346
|
+
const notifyChannelId = resolveBestChannel(userId, explicitChannelId);
|
|
347
|
+
|
|
348
|
+
const now = Math.floor(Date.now() / 1000);
|
|
349
|
+
const taskConfig = { message: taskMessage, sessionId };
|
|
350
|
+
const nextRun = calculateNextRun(expression, userTimezone);
|
|
351
|
+
|
|
352
|
+
const row = db.query(`
|
|
353
|
+
INSERT INTO cron_jobs
|
|
354
|
+
(user_id, name, cron_expression, task_type, task_config,
|
|
355
|
+
notify_channel_id, enabled, max_runs, run_count, expires_at, last_run, next_run, created_at)
|
|
356
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?, 0, ?, NULL, ?, ?)
|
|
357
|
+
RETURNING id
|
|
358
|
+
`).get(
|
|
359
|
+
userId, name, expression, taskType, JSON.stringify(taskConfig),
|
|
360
|
+
notifyChannelId, maxRuns, expiresAt, nextRun, now
|
|
361
|
+
) as { id: string };
|
|
362
|
+
|
|
363
|
+
const id = row.id;
|
|
364
|
+
|
|
365
|
+
scheduleJob(id, name, expression, taskConfig, userTimezone, notifyChannelId);
|
|
366
|
+
|
|
367
|
+
log.info(`Added cron job: ${id}`, { name, expression, userTimezone, sessionId, notifyChannelId });
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
success: true,
|
|
371
|
+
jobId: id,
|
|
372
|
+
name,
|
|
373
|
+
expression,
|
|
374
|
+
notifyChannelId,
|
|
375
|
+
maxRuns: maxRuns ?? "unlimited",
|
|
376
|
+
expiresAt: expiresAt ? new Date(expiresAt * 1000).toISOString() : null,
|
|
377
|
+
nextRunUtc: new Date(nextRun * 1000).toISOString(),
|
|
378
|
+
nextRunLocal: formatLocal(nextRun, userTimezone),
|
|
379
|
+
timezone: userTimezone,
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// ── cron_list ───────────────────────────────────────────────────────────────
|
|
385
|
+
const cronList: Tool = {
|
|
386
|
+
name: "cron_list",
|
|
387
|
+
description:
|
|
388
|
+
"List all scheduled tasks. Returns both UTC timestamps and local-time strings " +
|
|
389
|
+
"so you can display dates in the user's timezone without manual conversion.",
|
|
390
|
+
parameters: { type: "object", properties: {} },
|
|
391
|
+
execute: async (_params, config) => {
|
|
392
|
+
const db = getDb();
|
|
393
|
+
const userId = (config?.configurable?.user_id as string) || process.env.HIVE_USER_ID;
|
|
394
|
+
const userRow = userId
|
|
395
|
+
? db.query<any, [string]>("SELECT timezone FROM users WHERE id = ?").get(userId)
|
|
396
|
+
: null;
|
|
397
|
+
const userTimezone = userRow?.timezone || "UTC";
|
|
398
|
+
|
|
399
|
+
const rows = db.query<any, []>(
|
|
400
|
+
"SELECT * FROM cron_jobs ORDER BY created_at DESC"
|
|
401
|
+
).all();
|
|
402
|
+
|
|
403
|
+
const jobs = rows.map((row: any) => ({
|
|
404
|
+
id: row.id,
|
|
405
|
+
name: row.name,
|
|
406
|
+
expression: row.cron_expression,
|
|
407
|
+
taskType: row.task_type,
|
|
408
|
+
enabled: row.enabled === 1,
|
|
409
|
+
// UTC ISO — for programmatic use
|
|
410
|
+
nextRunUtc: row.next_run ? new Date(row.next_run * 1000).toISOString() : null,
|
|
411
|
+
lastRunUtc: row.last_run ? new Date(row.last_run * 1000).toISOString() : null,
|
|
412
|
+
// Local strings — display these to the user
|
|
413
|
+
nextRunLocal: formatLocal(row.next_run, userTimezone),
|
|
414
|
+
lastRunLocal: formatLocal(row.last_run, userTimezone),
|
|
415
|
+
timezone: userTimezone,
|
|
416
|
+
notifyChannelId: row.notify_channel_id,
|
|
417
|
+
}));
|
|
418
|
+
|
|
419
|
+
return { jobs, count: jobs.length, timezone: userTimezone };
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// ── cron_remove ─────────────────────────────────────────────────────────────
|
|
424
|
+
const cronRemove: Tool = {
|
|
425
|
+
name: "cron_remove",
|
|
426
|
+
description: "Remove a scheduled task",
|
|
427
|
+
parameters: {
|
|
428
|
+
type: "object",
|
|
429
|
+
properties: {
|
|
430
|
+
jobId: { type: "string", description: "ID of the job to remove" },
|
|
431
|
+
},
|
|
432
|
+
required: ["jobId"],
|
|
433
|
+
},
|
|
434
|
+
execute: async (params) => {
|
|
435
|
+
const jobId = params.jobId as string;
|
|
436
|
+
const db = getDb();
|
|
437
|
+
|
|
438
|
+
const existing = db.query<any, [string]>(
|
|
439
|
+
"SELECT id FROM cron_jobs WHERE id = ?"
|
|
440
|
+
).get(jobId);
|
|
441
|
+
if (!existing) throw new Error(`Job not found: ${jobId}`);
|
|
442
|
+
|
|
443
|
+
// Stop Croner instance first, then remove from DB
|
|
444
|
+
stopJob(jobId);
|
|
445
|
+
db.query("DELETE FROM cron_jobs WHERE id = ?").run(jobId);
|
|
446
|
+
|
|
447
|
+
log.info(`Removed cron job: ${jobId}`);
|
|
448
|
+
return { success: true, removedJob: jobId };
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// ── cron_edit ───────────────────────────────────────────────────────────────
|
|
453
|
+
const cronEdit: Tool = {
|
|
454
|
+
name: "cron_edit",
|
|
455
|
+
description: "Edit a scheduled task (name, expression, enable/disable, channel, limits)",
|
|
456
|
+
parameters: {
|
|
457
|
+
type: "object",
|
|
458
|
+
properties: {
|
|
459
|
+
jobId: { type: "string", description: "ID of the job to edit" },
|
|
460
|
+
name: { type: "string", description: "New name" },
|
|
461
|
+
expression: {
|
|
462
|
+
type: "string",
|
|
463
|
+
description: "New cron expression (5-field, in user's local timezone)",
|
|
464
|
+
},
|
|
465
|
+
enabled: { type: "boolean", description: "Enable or disable the job" },
|
|
466
|
+
notifyChannelId: {
|
|
467
|
+
type: "string",
|
|
468
|
+
description: "New notification channel. Use 'auto' to re-apply auto-selection.",
|
|
469
|
+
},
|
|
470
|
+
maxRuns: {
|
|
471
|
+
type: "number",
|
|
472
|
+
description: "New max runs limit. Set to 0 to remove the limit.",
|
|
473
|
+
},
|
|
474
|
+
expiresAt: {
|
|
475
|
+
type: "string",
|
|
476
|
+
description: "New expiry date (ISO-8601). Set to '' to remove expiry.",
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
required: ["jobId"],
|
|
480
|
+
},
|
|
481
|
+
execute: async (params) => {
|
|
482
|
+
const jobId = params.jobId as string;
|
|
483
|
+
const db = getDb();
|
|
484
|
+
|
|
485
|
+
const existing = db.query<any, [string]>(
|
|
486
|
+
"SELECT * FROM cron_jobs WHERE id = ?"
|
|
487
|
+
).get(jobId);
|
|
488
|
+
if (!existing) throw new Error(`Job not found: ${jobId}`);
|
|
489
|
+
|
|
490
|
+
const userRow = db.query<any, [string]>(
|
|
491
|
+
"SELECT timezone FROM users WHERE id = ?"
|
|
492
|
+
).get(existing.user_id);
|
|
493
|
+
const userTimezone = userRow?.timezone || "UTC";
|
|
494
|
+
|
|
495
|
+
const sets: string[] = [];
|
|
496
|
+
const vals: any[] = [];
|
|
497
|
+
|
|
498
|
+
if (params.name) {
|
|
499
|
+
sets.push("name = ?");
|
|
500
|
+
vals.push(params.name);
|
|
501
|
+
}
|
|
502
|
+
if (params.expression) {
|
|
503
|
+
sets.push("cron_expression = ?");
|
|
504
|
+
vals.push(params.expression);
|
|
505
|
+
sets.push("next_run = ?");
|
|
506
|
+
vals.push(calculateNextRun(params.expression as string, userTimezone));
|
|
507
|
+
}
|
|
508
|
+
if (params.enabled !== undefined) {
|
|
509
|
+
sets.push("enabled = ?");
|
|
510
|
+
vals.push(params.enabled ? 1 : 0);
|
|
511
|
+
}
|
|
512
|
+
if (params.notifyChannelId !== undefined) {
|
|
513
|
+
const ch = params.notifyChannelId === "auto"
|
|
514
|
+
? resolveBestChannel(existing.user_id)
|
|
515
|
+
: params.notifyChannelId as string;
|
|
516
|
+
sets.push("notify_channel_id = ?");
|
|
517
|
+
vals.push(ch);
|
|
518
|
+
}
|
|
519
|
+
if (params.maxRuns !== undefined) {
|
|
520
|
+
sets.push("max_runs = ?");
|
|
521
|
+
vals.push((params.maxRuns as number) === 0 ? null : Number(params.maxRuns));
|
|
522
|
+
}
|
|
523
|
+
if (params.expiresAt !== undefined) {
|
|
524
|
+
if (params.expiresAt === "") {
|
|
525
|
+
sets.push("expires_at = ?");
|
|
526
|
+
vals.push(null);
|
|
527
|
+
} else {
|
|
528
|
+
const d = new Date(params.expiresAt as string);
|
|
529
|
+
if (!isNaN(d.getTime())) {
|
|
530
|
+
sets.push("expires_at = ?");
|
|
531
|
+
vals.push(Math.floor(d.getTime() / 1000));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (sets.length > 0) {
|
|
537
|
+
vals.push(jobId);
|
|
538
|
+
db.query(`UPDATE cron_jobs SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const updated = db.query<any, [string]>(
|
|
542
|
+
"SELECT * FROM cron_jobs WHERE id = ?"
|
|
543
|
+
).get(jobId);
|
|
544
|
+
|
|
545
|
+
if (updated.enabled) {
|
|
546
|
+
const taskConfig = updated.task_config ? JSON.parse(updated.task_config) : {};
|
|
547
|
+
scheduleJob(
|
|
548
|
+
jobId, updated.name, updated.cron_expression,
|
|
549
|
+
taskConfig, userTimezone, updated.notify_channel_id
|
|
550
|
+
);
|
|
551
|
+
} else {
|
|
552
|
+
stopJob(jobId);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
log.info(`Edited cron job: ${jobId}`);
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
success: true,
|
|
559
|
+
job: {
|
|
560
|
+
id: updated.id,
|
|
561
|
+
name: updated.name,
|
|
562
|
+
expression: updated.cron_expression,
|
|
563
|
+
enabled: updated.enabled === 1,
|
|
564
|
+
notifyChannelId: updated.notify_channel_id,
|
|
565
|
+
maxRuns: updated.max_runs ?? "unlimited",
|
|
566
|
+
runCount: updated.run_count ?? 0,
|
|
567
|
+
expiresAt: updated.expires_at ? new Date(updated.expires_at * 1000).toISOString() : null,
|
|
568
|
+
nextRunUtc: updated.next_run ? new Date(updated.next_run * 1000).toISOString() : null,
|
|
569
|
+
nextRunLocal: formatLocal(updated.next_run, userTimezone),
|
|
570
|
+
timezone: userTimezone,
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return [cronAdd, cronList, cronRemove, cronEdit];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── Query helpers (used externally) ──────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
function rowToCronJob(row: any): CronJob {
|
|
582
|
+
return {
|
|
583
|
+
id: row.id,
|
|
584
|
+
userId: row.user_id,
|
|
585
|
+
name: row.name,
|
|
586
|
+
cronExpression: row.cron_expression,
|
|
587
|
+
taskType: row.task_type,
|
|
588
|
+
taskConfig: row.task_config ? JSON.parse(row.task_config) : null,
|
|
589
|
+
notifyChannelId: row.notify_channel_id,
|
|
590
|
+
enabled: row.enabled === 1,
|
|
591
|
+
maxRuns: row.max_runs ?? null,
|
|
592
|
+
runCount: row.run_count ?? 0,
|
|
593
|
+
expiresAt: row.expires_at ?? null,
|
|
594
|
+
lastRun: row.last_run,
|
|
595
|
+
nextRun: row.next_run,
|
|
596
|
+
createdAt: row.created_at,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function getCronJobById(jobId: string): CronJob | null {
|
|
601
|
+
const db = getDb();
|
|
602
|
+
const row = db.query<any, [string]>("SELECT * FROM cron_jobs WHERE id = ?").get(jobId);
|
|
603
|
+
if (!row) return null;
|
|
604
|
+
return rowToCronJob(row);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function getCronJobsByUser(userId: string): CronJob[] {
|
|
608
|
+
const db = getDb();
|
|
609
|
+
const rows = db.query<any, [string]>("SELECT * FROM cron_jobs WHERE user_id = ?").all(userId);
|
|
610
|
+
return rows.map(rowToCronJob);
|
|
611
|
+
}
|