@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.
Files changed (156) hide show
  1. package/CONTRIBUTING.md +44 -0
  2. package/README.md +310 -0
  3. package/package.json +96 -0
  4. package/packages/cli/package.json +28 -0
  5. package/packages/cli/src/commands/agent-run.ts +168 -0
  6. package/packages/cli/src/commands/agents.ts +398 -0
  7. package/packages/cli/src/commands/chat.ts +142 -0
  8. package/packages/cli/src/commands/config.ts +50 -0
  9. package/packages/cli/src/commands/cron.ts +161 -0
  10. package/packages/cli/src/commands/dev.ts +95 -0
  11. package/packages/cli/src/commands/doctor.ts +133 -0
  12. package/packages/cli/src/commands/gateway.ts +443 -0
  13. package/packages/cli/src/commands/logs.ts +57 -0
  14. package/packages/cli/src/commands/mcp.ts +175 -0
  15. package/packages/cli/src/commands/message.ts +77 -0
  16. package/packages/cli/src/commands/onboard.ts +1868 -0
  17. package/packages/cli/src/commands/security.ts +144 -0
  18. package/packages/cli/src/commands/service.ts +50 -0
  19. package/packages/cli/src/commands/sessions.ts +116 -0
  20. package/packages/cli/src/commands/skills.ts +187 -0
  21. package/packages/cli/src/commands/update.ts +25 -0
  22. package/packages/cli/src/index.ts +185 -0
  23. package/packages/cli/src/utils/token.ts +6 -0
  24. package/packages/code-bridge/README.md +78 -0
  25. package/packages/code-bridge/package.json +18 -0
  26. package/packages/code-bridge/src/index.ts +95 -0
  27. package/packages/code-bridge/src/process-manager.ts +212 -0
  28. package/packages/code-bridge/src/schemas.ts +133 -0
  29. package/packages/core/package.json +46 -0
  30. package/packages/core/src/agent/agent-loop.ts +369 -0
  31. package/packages/core/src/agent/compaction.ts +140 -0
  32. package/packages/core/src/agent/context-compiler.ts +378 -0
  33. package/packages/core/src/agent/context-guard.ts +91 -0
  34. package/packages/core/src/agent/context.ts +138 -0
  35. package/packages/core/src/agent/conversation-store.ts +198 -0
  36. package/packages/core/src/agent/curator.ts +158 -0
  37. package/packages/core/src/agent/hooks.ts +166 -0
  38. package/packages/core/src/agent/index.ts +116 -0
  39. package/packages/core/src/agent/llm-client.ts +503 -0
  40. package/packages/core/src/agent/native-tools.ts +505 -0
  41. package/packages/core/src/agent/prompt-builder.ts +532 -0
  42. package/packages/core/src/agent/providers/index.ts +167 -0
  43. package/packages/core/src/agent/providers.ts +1 -0
  44. package/packages/core/src/agent/reflector.ts +170 -0
  45. package/packages/core/src/agent/service.ts +64 -0
  46. package/packages/core/src/agent/stuck-loop.ts +133 -0
  47. package/packages/core/src/agent/supervisor.ts +39 -0
  48. package/packages/core/src/agent/tracer.ts +102 -0
  49. package/packages/core/src/agent/workspace.ts +110 -0
  50. package/packages/core/src/canvas/canvas-manager.test.ts +161 -0
  51. package/packages/core/src/canvas/canvas-manager.ts +319 -0
  52. package/packages/core/src/canvas/canvas-tools.ts +420 -0
  53. package/packages/core/src/canvas/emitter.ts +115 -0
  54. package/packages/core/src/canvas/index.ts +2 -0
  55. package/packages/core/src/channels/base.ts +138 -0
  56. package/packages/core/src/channels/discord.ts +260 -0
  57. package/packages/core/src/channels/index.ts +7 -0
  58. package/packages/core/src/channels/manager.ts +383 -0
  59. package/packages/core/src/channels/slack.ts +287 -0
  60. package/packages/core/src/channels/telegram.ts +502 -0
  61. package/packages/core/src/channels/webchat.ts +128 -0
  62. package/packages/core/src/channels/whatsapp.ts +375 -0
  63. package/packages/core/src/config/index.ts +12 -0
  64. package/packages/core/src/config/loader.ts +529 -0
  65. package/packages/core/src/events/event-bus.ts +169 -0
  66. package/packages/core/src/gateway/index.ts +5 -0
  67. package/packages/core/src/gateway/initializer.ts +290 -0
  68. package/packages/core/src/gateway/lane-queue.ts +169 -0
  69. package/packages/core/src/gateway/resolver.ts +108 -0
  70. package/packages/core/src/gateway/router.ts +124 -0
  71. package/packages/core/src/gateway/server.ts +3317 -0
  72. package/packages/core/src/gateway/session.ts +95 -0
  73. package/packages/core/src/gateway/slash-commands.ts +192 -0
  74. package/packages/core/src/heartbeat/index.ts +157 -0
  75. package/packages/core/src/index.ts +19 -0
  76. package/packages/core/src/integrations/catalog.ts +286 -0
  77. package/packages/core/src/integrations/env.ts +64 -0
  78. package/packages/core/src/integrations/index.ts +2 -0
  79. package/packages/core/src/memory/index.ts +1 -0
  80. package/packages/core/src/memory/notes.ts +68 -0
  81. package/packages/core/src/plugins/api.ts +128 -0
  82. package/packages/core/src/plugins/index.ts +2 -0
  83. package/packages/core/src/plugins/loader.ts +365 -0
  84. package/packages/core/src/resilience/circuit-breaker.ts +225 -0
  85. package/packages/core/src/security/google-chat.ts +269 -0
  86. package/packages/core/src/security/index.ts +192 -0
  87. package/packages/core/src/security/pairing.ts +250 -0
  88. package/packages/core/src/security/rate-limit.ts +270 -0
  89. package/packages/core/src/security/signal.ts +321 -0
  90. package/packages/core/src/state/store.ts +312 -0
  91. package/packages/core/src/storage/bun-sqlite-store.ts +188 -0
  92. package/packages/core/src/storage/crypto.ts +101 -0
  93. package/packages/core/src/storage/db-context.ts +333 -0
  94. package/packages/core/src/storage/onboarding.ts +1087 -0
  95. package/packages/core/src/storage/schema.ts +541 -0
  96. package/packages/core/src/storage/seed.ts +571 -0
  97. package/packages/core/src/storage/sqlite.ts +387 -0
  98. package/packages/core/src/storage/usage.ts +212 -0
  99. package/packages/core/src/tools/bridge-events.ts +74 -0
  100. package/packages/core/src/tools/browser.ts +275 -0
  101. package/packages/core/src/tools/codebridge.ts +421 -0
  102. package/packages/core/src/tools/coordinator-tools.ts +179 -0
  103. package/packages/core/src/tools/cron.ts +611 -0
  104. package/packages/core/src/tools/exec.ts +140 -0
  105. package/packages/core/src/tools/fs.ts +364 -0
  106. package/packages/core/src/tools/index.ts +12 -0
  107. package/packages/core/src/tools/memory.ts +176 -0
  108. package/packages/core/src/tools/notify.ts +113 -0
  109. package/packages/core/src/tools/project-management.ts +376 -0
  110. package/packages/core/src/tools/project.ts +375 -0
  111. package/packages/core/src/tools/read.ts +158 -0
  112. package/packages/core/src/tools/web.ts +436 -0
  113. package/packages/core/src/tools/workspace.ts +171 -0
  114. package/packages/core/src/utils/benchmark.ts +80 -0
  115. package/packages/core/src/utils/crypto.ts +73 -0
  116. package/packages/core/src/utils/date.ts +42 -0
  117. package/packages/core/src/utils/index.ts +4 -0
  118. package/packages/core/src/utils/logger.ts +388 -0
  119. package/packages/core/src/utils/retry.ts +70 -0
  120. package/packages/core/src/voice/index.ts +583 -0
  121. package/packages/core/tsconfig.json +9 -0
  122. package/packages/mcp/package.json +26 -0
  123. package/packages/mcp/src/config.ts +13 -0
  124. package/packages/mcp/src/index.ts +1 -0
  125. package/packages/mcp/src/logger.ts +42 -0
  126. package/packages/mcp/src/manager.ts +434 -0
  127. package/packages/mcp/src/transports/index.ts +67 -0
  128. package/packages/mcp/src/transports/sse.ts +241 -0
  129. package/packages/mcp/src/transports/websocket.ts +159 -0
  130. package/packages/skills/package.json +21 -0
  131. package/packages/skills/src/bundled/agent_management/SKILL.md +24 -0
  132. package/packages/skills/src/bundled/browser_automation/SKILL.md +30 -0
  133. package/packages/skills/src/bundled/context_compact/SKILL.md +35 -0
  134. package/packages/skills/src/bundled/cron_manager/SKILL.md +52 -0
  135. package/packages/skills/src/bundled/file_manager/SKILL.md +76 -0
  136. package/packages/skills/src/bundled/http_client/SKILL.md +24 -0
  137. package/packages/skills/src/bundled/memory/SKILL.md +42 -0
  138. package/packages/skills/src/bundled/project_management/SKILL.md +26 -0
  139. package/packages/skills/src/bundled/shell/SKILL.md +43 -0
  140. package/packages/skills/src/bundled/system_notify/SKILL.md +52 -0
  141. package/packages/skills/src/bundled/voice/SKILL.md +25 -0
  142. package/packages/skills/src/bundled/web_search/SKILL.md +29 -0
  143. package/packages/skills/src/index.ts +1 -0
  144. package/packages/skills/src/loader.ts +282 -0
  145. package/packages/tools/package.json +43 -0
  146. package/packages/tools/src/browser/browser.test.ts +111 -0
  147. package/packages/tools/src/browser/index.ts +272 -0
  148. package/packages/tools/src/canvas/index.ts +220 -0
  149. package/packages/tools/src/cron/cron.test.ts +164 -0
  150. package/packages/tools/src/cron/index.ts +304 -0
  151. package/packages/tools/src/filesystem/filesystem.test.ts +240 -0
  152. package/packages/tools/src/filesystem/index.ts +379 -0
  153. package/packages/tools/src/git/index.ts +239 -0
  154. package/packages/tools/src/index.ts +4 -0
  155. package/packages/tools/src/shell/detect-env.ts +70 -0
  156. 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
+ }