zubo 0.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 (222) hide show
  1. package/.github/workflows/ci.yml +35 -0
  2. package/README.md +149 -0
  3. package/bun.lock +216 -0
  4. package/desktop/README.md +57 -0
  5. package/desktop/package.json +12 -0
  6. package/desktop/src-tauri/Cargo.toml +25 -0
  7. package/desktop/src-tauri/build.rs +3 -0
  8. package/desktop/src-tauri/icons/README.md +17 -0
  9. package/desktop/src-tauri/icons/icon.png +0 -0
  10. package/desktop/src-tauri/src/main.rs +189 -0
  11. package/desktop/src-tauri/tauri.conf.json +68 -0
  12. package/docs/ROADMAP.md +490 -0
  13. package/migrations/001_init.sql +9 -0
  14. package/migrations/002_memory.sql +33 -0
  15. package/migrations/003_cron.sql +24 -0
  16. package/migrations/004_usage.sql +12 -0
  17. package/migrations/005_secrets.sql +8 -0
  18. package/migrations/006_agents.sql +1 -0
  19. package/migrations/007_workflows.sql +22 -0
  20. package/migrations/008_proactive.sql +24 -0
  21. package/migrations/009_uploads.sql +9 -0
  22. package/migrations/010_observability.sql +22 -0
  23. package/migrations/011_api_keys.sql +7 -0
  24. package/migrations/012_indexes.sql +5 -0
  25. package/migrations/013_budget.sql +11 -0
  26. package/migrations/014_usage_session_idx.sql +2 -0
  27. package/package.json +39 -0
  28. package/site/404.html +156 -0
  29. package/site/CNAME +1 -0
  30. package/site/docs/agents.html +294 -0
  31. package/site/docs/api.html +446 -0
  32. package/site/docs/channels.html +345 -0
  33. package/site/docs/cli.html +238 -0
  34. package/site/docs/config.html +1034 -0
  35. package/site/docs/index.html +433 -0
  36. package/site/docs/integrations.html +381 -0
  37. package/site/docs/memory.html +254 -0
  38. package/site/docs/security.html +375 -0
  39. package/site/docs/skills.html +322 -0
  40. package/site/docs.css +412 -0
  41. package/site/index.html +638 -0
  42. package/site/install.sh +98 -0
  43. package/site/logo.svg +1 -0
  44. package/site/og-image.png +0 -0
  45. package/site/robots.txt +4 -0
  46. package/site/script.js +361 -0
  47. package/site/sitemap.xml +63 -0
  48. package/site/skills.html +532 -0
  49. package/site/style.css +1686 -0
  50. package/src/agent/agents.ts +159 -0
  51. package/src/agent/compaction.ts +53 -0
  52. package/src/agent/context.ts +18 -0
  53. package/src/agent/delegate.ts +118 -0
  54. package/src/agent/loop.ts +318 -0
  55. package/src/agent/prompts.ts +111 -0
  56. package/src/agent/session.ts +87 -0
  57. package/src/agent/teams.ts +116 -0
  58. package/src/agent/workflow-executor.ts +192 -0
  59. package/src/agent/workflow.ts +175 -0
  60. package/src/channels/adapter.ts +21 -0
  61. package/src/channels/dashboard.html.ts +2969 -0
  62. package/src/channels/discord.ts +137 -0
  63. package/src/channels/optional-deps.d.ts +17 -0
  64. package/src/channels/router.ts +199 -0
  65. package/src/channels/signal.ts +133 -0
  66. package/src/channels/slack.ts +101 -0
  67. package/src/channels/telegram.ts +102 -0
  68. package/src/channels/utils.ts +18 -0
  69. package/src/channels/webchat.ts +1797 -0
  70. package/src/channels/whatsapp.ts +119 -0
  71. package/src/config/loader.ts +22 -0
  72. package/src/config/paths.ts +43 -0
  73. package/src/config/schema.ts +121 -0
  74. package/src/db/connection.ts +20 -0
  75. package/src/db/export.ts +148 -0
  76. package/src/db/migrations.ts +42 -0
  77. package/src/index.ts +261 -0
  78. package/src/llm/claude.ts +193 -0
  79. package/src/llm/factory.ts +115 -0
  80. package/src/llm/failover.ts +101 -0
  81. package/src/llm/openai-compat.ts +409 -0
  82. package/src/llm/provider.ts +83 -0
  83. package/src/llm/smart-router.ts +241 -0
  84. package/src/logs.ts +53 -0
  85. package/src/memory/chunker.ts +58 -0
  86. package/src/memory/document-parser.ts +115 -0
  87. package/src/memory/embedder.ts +235 -0
  88. package/src/memory/engine.ts +170 -0
  89. package/src/memory/fts-index.ts +55 -0
  90. package/src/memory/hybrid-search.ts +72 -0
  91. package/src/memory/store.ts +56 -0
  92. package/src/memory/vector-index.ts +72 -0
  93. package/src/model.ts +118 -0
  94. package/src/registry/cli.ts +43 -0
  95. package/src/registry/client.ts +54 -0
  96. package/src/registry/installer.ts +67 -0
  97. package/src/scheduler/briefing.ts +71 -0
  98. package/src/scheduler/cron.ts +258 -0
  99. package/src/scheduler/heartbeat.ts +58 -0
  100. package/src/scheduler/memory-triggers.ts +100 -0
  101. package/src/scheduler/natural-cron.ts +163 -0
  102. package/src/scheduler/proactive.ts +25 -0
  103. package/src/scheduler/recipes.ts +110 -0
  104. package/src/secrets/store.ts +64 -0
  105. package/src/setup.ts +413 -0
  106. package/src/skills.ts +293 -0
  107. package/src/start.ts +373 -0
  108. package/src/status.ts +165 -0
  109. package/src/tools/builtin/connect-service.ts +205 -0
  110. package/src/tools/builtin/cron.ts +126 -0
  111. package/src/tools/builtin/datetime.ts +36 -0
  112. package/src/tools/builtin/delegate-task.ts +81 -0
  113. package/src/tools/builtin/delegate.ts +42 -0
  114. package/src/tools/builtin/diagnose.ts +41 -0
  115. package/src/tools/builtin/google-oauth.ts +379 -0
  116. package/src/tools/builtin/manage-agents.ts +149 -0
  117. package/src/tools/builtin/manage-skills.ts +294 -0
  118. package/src/tools/builtin/manage-teams.ts +89 -0
  119. package/src/tools/builtin/manage-triggers.ts +94 -0
  120. package/src/tools/builtin/manage-workflows.ts +119 -0
  121. package/src/tools/builtin/memory-search.ts +38 -0
  122. package/src/tools/builtin/memory-write.ts +30 -0
  123. package/src/tools/builtin/run-workflow.ts +36 -0
  124. package/src/tools/builtin/secrets.ts +122 -0
  125. package/src/tools/builtin/skill-registry.ts +75 -0
  126. package/src/tools/builtin-integrations/api-helpers.ts +26 -0
  127. package/src/tools/builtin-integrations/github/github_issues/SKILL.md +56 -0
  128. package/src/tools/builtin-integrations/github/github_issues/handler.ts +108 -0
  129. package/src/tools/builtin-integrations/github/github_prs/SKILL.md +57 -0
  130. package/src/tools/builtin-integrations/github/github_prs/handler.ts +113 -0
  131. package/src/tools/builtin-integrations/github/github_repos/SKILL.md +37 -0
  132. package/src/tools/builtin-integrations/github/github_repos/handler.ts +88 -0
  133. package/src/tools/builtin-integrations/google/gmail/SKILL.md +51 -0
  134. package/src/tools/builtin-integrations/google/gmail/handler.ts +125 -0
  135. package/src/tools/builtin-integrations/google/google_calendar/SKILL.md +35 -0
  136. package/src/tools/builtin-integrations/google/google_calendar/handler.ts +105 -0
  137. package/src/tools/builtin-integrations/google/google_docs/SKILL.md +35 -0
  138. package/src/tools/builtin-integrations/google/google_docs/handler.ts +108 -0
  139. package/src/tools/builtin-integrations/google/google_drive/SKILL.md +39 -0
  140. package/src/tools/builtin-integrations/google/google_drive/handler.ts +106 -0
  141. package/src/tools/builtin-integrations/google/google_sheets/SKILL.md +36 -0
  142. package/src/tools/builtin-integrations/google/google_sheets/handler.ts +116 -0
  143. package/src/tools/builtin-integrations/jira/jira_boards/SKILL.md +21 -0
  144. package/src/tools/builtin-integrations/jira/jira_boards/handler.ts +74 -0
  145. package/src/tools/builtin-integrations/jira/jira_issues/SKILL.md +28 -0
  146. package/src/tools/builtin-integrations/jira/jira_issues/handler.ts +140 -0
  147. package/src/tools/builtin-integrations/linear/linear_issues/SKILL.md +30 -0
  148. package/src/tools/builtin-integrations/linear/linear_issues/handler.ts +75 -0
  149. package/src/tools/builtin-integrations/linear/linear_projects/SKILL.md +21 -0
  150. package/src/tools/builtin-integrations/linear/linear_projects/handler.ts +43 -0
  151. package/src/tools/builtin-integrations/notion/notion_databases/SKILL.md +39 -0
  152. package/src/tools/builtin-integrations/notion/notion_databases/handler.ts +83 -0
  153. package/src/tools/builtin-integrations/notion/notion_pages/SKILL.md +43 -0
  154. package/src/tools/builtin-integrations/notion/notion_pages/handler.ts +130 -0
  155. package/src/tools/builtin-integrations/notion/notion_search/SKILL.md +27 -0
  156. package/src/tools/builtin-integrations/notion/notion_search/handler.ts +69 -0
  157. package/src/tools/builtin-integrations/slack/slack_messages/SKILL.md +42 -0
  158. package/src/tools/builtin-integrations/slack/slack_messages/handler.ts +72 -0
  159. package/src/tools/builtin-integrations/twitter/twitter_posts/SKILL.md +24 -0
  160. package/src/tools/builtin-integrations/twitter/twitter_posts/handler.ts +133 -0
  161. package/src/tools/builtin-skills/file-read/SKILL.md +26 -0
  162. package/src/tools/builtin-skills/file-read/handler.ts +66 -0
  163. package/src/tools/builtin-skills/file-write/SKILL.md +30 -0
  164. package/src/tools/builtin-skills/file-write/handler.ts +64 -0
  165. package/src/tools/builtin-skills/http-request/SKILL.md +34 -0
  166. package/src/tools/builtin-skills/http-request/handler.ts +87 -0
  167. package/src/tools/builtin-skills/shell/SKILL.md +26 -0
  168. package/src/tools/builtin-skills/shell/handler.ts +96 -0
  169. package/src/tools/builtin-skills/url-fetch/SKILL.md +26 -0
  170. package/src/tools/builtin-skills/url-fetch/handler.ts +37 -0
  171. package/src/tools/builtin-skills/web-search/SKILL.md +26 -0
  172. package/src/tools/builtin-skills/web-search/handler.ts +50 -0
  173. package/src/tools/executor.ts +205 -0
  174. package/src/tools/integration-installer.ts +106 -0
  175. package/src/tools/permissions.ts +45 -0
  176. package/src/tools/registry.ts +39 -0
  177. package/src/tools/sandbox-runner.ts +56 -0
  178. package/src/tools/sandbox.ts +82 -0
  179. package/src/tools/skill-installer.ts +52 -0
  180. package/src/tools/skill-loader.ts +259 -0
  181. package/src/types/optional-deps.d.ts +23 -0
  182. package/src/util/auth.ts +121 -0
  183. package/src/util/costs.ts +59 -0
  184. package/src/util/error-buffer.ts +32 -0
  185. package/src/util/google-tokens.ts +180 -0
  186. package/src/util/logger.ts +73 -0
  187. package/src/util/perf-collector.ts +35 -0
  188. package/src/util/rate-limiter.ts +70 -0
  189. package/src/util/tokens.ts +17 -0
  190. package/src/voice/stt.ts +57 -0
  191. package/src/voice/tts.ts +103 -0
  192. package/tests/agent/session.test.ts +109 -0
  193. package/tests/agent-loop.test.ts +54 -0
  194. package/tests/auth.test.ts +89 -0
  195. package/tests/channels.test.ts +67 -0
  196. package/tests/compaction.test.ts +44 -0
  197. package/tests/config.test.ts +51 -0
  198. package/tests/costs.test.ts +19 -0
  199. package/tests/cron.test.ts +55 -0
  200. package/tests/db/export.test.ts +219 -0
  201. package/tests/executor.test.ts +144 -0
  202. package/tests/export.test.ts +137 -0
  203. package/tests/helpers/mock-llm.ts +34 -0
  204. package/tests/helpers/test-db.ts +74 -0
  205. package/tests/integration/chat-flow.test.ts +48 -0
  206. package/tests/integrations.test.ts +97 -0
  207. package/tests/memory/engine.test.ts +114 -0
  208. package/tests/memory-engine.test.ts +57 -0
  209. package/tests/permissions.test.ts +21 -0
  210. package/tests/rate-limiter.test.ts +70 -0
  211. package/tests/registry.test.ts +67 -0
  212. package/tests/router.test.ts +36 -0
  213. package/tests/session.test.ts +58 -0
  214. package/tests/skill-loader.test.ts +44 -0
  215. package/tests/tokens.test.ts +30 -0
  216. package/tests/tools/executor.test.ts +130 -0
  217. package/tests/util/auth.test.ts +75 -0
  218. package/tests/util/rate-limiter.test.ts +73 -0
  219. package/tests/voice.test.ts +60 -0
  220. package/tests/webchat.test.ts +88 -0
  221. package/tests/workflow.test.ts +38 -0
  222. package/tsconfig.json +16 -0
@@ -0,0 +1,67 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { paths } from "../config/paths";
4
+ import { getRegistryEntry } from "./client";
5
+ import { logger } from "../util/logger";
6
+
7
+ export interface InstallResult {
8
+ success: boolean;
9
+ name: string;
10
+ path?: string;
11
+ requiredSecrets?: string[];
12
+ error?: string;
13
+ }
14
+
15
+ export async function installFromRegistry(name: string): Promise<InstallResult> {
16
+ // Validate skill name — only allow safe characters to prevent path traversal
17
+ if (!/^[a-z0-9_-]+$/.test(name)) {
18
+ return { success: false, name, error: "Invalid skill name — only lowercase letters, numbers, hyphens, and underscores allowed" };
19
+ }
20
+
21
+ const entry = await getRegistryEntry(name);
22
+ if (!entry) {
23
+ return { success: false, name, error: `Skill "${name}" not found in registry` };
24
+ }
25
+
26
+ const skillDir = join(paths.skills, name);
27
+ // Verify resolved path stays within skills directory
28
+ const { resolve } = await import("path");
29
+ if (!resolve(skillDir).startsWith(resolve(paths.skills))) {
30
+ return { success: false, name, error: "Path traversal detected" };
31
+ }
32
+
33
+ if (existsSync(skillDir)) {
34
+ return { success: false, name, error: `Skill "${name}" is already installed` };
35
+ }
36
+
37
+ try {
38
+ // Fetch SKILL.md from the repo
39
+ const baseUrl = `https://raw.githubusercontent.com/${entry.repo}/main`;
40
+ const [skillMdRes, handlerRes] = await Promise.all([
41
+ fetch(`${baseUrl}/SKILL.md`),
42
+ fetch(`${baseUrl}/handler.ts`),
43
+ ]);
44
+
45
+ if (!skillMdRes.ok || !handlerRes.ok) {
46
+ return { success: false, name, error: `Failed to download skill files from ${entry.repo}` };
47
+ }
48
+
49
+ const skillMd = await skillMdRes.text();
50
+ const handler = await handlerRes.text();
51
+
52
+ mkdirSync(skillDir, { recursive: true });
53
+ writeFileSync(join(skillDir, "SKILL.md"), skillMd);
54
+ writeFileSync(join(skillDir, "handler.ts"), handler);
55
+
56
+ logger.info(`Installed skill from registry: ${name}`);
57
+
58
+ return {
59
+ success: true,
60
+ name,
61
+ path: skillDir,
62
+ requiredSecrets: entry.secrets,
63
+ };
64
+ } catch (err: any) {
65
+ return { success: false, name, error: err.message };
66
+ }
67
+ }
@@ -0,0 +1,71 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { LlmProvider } from "../llm/provider";
3
+ import type { MessageRouter } from "../channels/router";
4
+ import { agentLoop } from "../agent/loop";
5
+ import { logger } from "../util/logger";
6
+
7
+ const BRIEFING_TASK = `Generate a morning briefing for the user. Include:
8
+ 1. Any upcoming calendar events today (if Google Calendar is connected)
9
+ 2. Important unread emails (if Gmail is connected)
10
+ 3. Recent GitHub notifications (if GitHub is connected)
11
+ 4. Any pending tasks or reminders
12
+ 5. A brief weather-appropriate greeting
13
+
14
+ Keep it concise and actionable. If services aren't connected, skip those sections gracefully.`;
15
+
16
+ export async function generateMorningBriefing(
17
+ db: Database,
18
+ router: MessageRouter,
19
+ llm: LlmProvider
20
+ ): Promise<string> {
21
+ logger.info("Generating morning briefing...");
22
+
23
+ try {
24
+ const result = await agentLoop(llm, "briefing", BRIEFING_TASK, {
25
+ maxRounds: 5,
26
+ });
27
+
28
+ if (result.reply && router.broadcastProactive) {
29
+ await router.broadcastProactive(result.reply);
30
+ }
31
+
32
+ // Log the briefing
33
+ try {
34
+ db.prepare(
35
+ "INSERT INTO proactive_log (type, message) VALUES ('briefing', ?)"
36
+ ).run(result.reply.slice(0, 5000));
37
+ } catch (err: any) {
38
+ logger.warn("Failed to log briefing to database", { error: (err as Error).message });
39
+ }
40
+
41
+ return result.reply;
42
+ } catch (err: any) {
43
+ logger.error("Morning briefing failed", { error: err.message });
44
+ return "";
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Ensure the morning briefing cron job exists.
50
+ * Called during startup to auto-create it.
51
+ */
52
+ export function ensureBriefingCron(db: Database): void {
53
+ try {
54
+ const existing = db.query(
55
+ "SELECT id FROM cron_jobs WHERE name = 'zubo_morning_briefing'"
56
+ ).get();
57
+
58
+ if (!existing) {
59
+ db.prepare(
60
+ "INSERT INTO cron_jobs (name, schedule, task, enabled) VALUES (?, ?, ?, 1)"
61
+ ).run(
62
+ "zubo_morning_briefing",
63
+ "0 9 * * 1-5",
64
+ "Generate a morning briefing with calendar events, emails, and tasks for today."
65
+ );
66
+ logger.info("Created default morning briefing cron (weekdays at 9am)");
67
+ }
68
+ } catch {
69
+ // cron_jobs table may not exist yet
70
+ }
71
+ }
@@ -0,0 +1,258 @@
1
+ import { Cron } from "croner";
2
+ import { Database } from "bun:sqlite";
3
+ import type { MessageRouter } from "../channels/router";
4
+ import type { ZuboConfig } from "../config/schema";
5
+ import type { LlmProvider } from "../llm/provider";
6
+ import { delegateToAgent } from "../agent/delegate";
7
+ import { logger } from "../util/logger";
8
+
9
+ export interface CronJob {
10
+ id: number;
11
+ name: string;
12
+ schedule: string;
13
+ task: string;
14
+ enabled: number;
15
+ last_run: string | null;
16
+ retry_count: number;
17
+ max_retries: number;
18
+ agent: string | null;
19
+ }
20
+
21
+ const activeCrons: Map<number, Cron> = new Map();
22
+
23
+ export function initCronScheduler(
24
+ db: Database,
25
+ router: MessageRouter,
26
+ config: ZuboConfig,
27
+ llm?: LlmProvider
28
+ ) {
29
+ const jobs = db
30
+ .query("SELECT * FROM cron_jobs WHERE enabled = 1")
31
+ .all() as CronJob[];
32
+
33
+ for (const job of jobs) {
34
+ scheduleJob(db, job, router, config, llm);
35
+ }
36
+
37
+ logger.info("Cron scheduler initialized", { jobCount: jobs.length });
38
+ }
39
+
40
+ function getOwnerSessionKey(config: ZuboConfig): string | null {
41
+ if (config.telegramAllowedUsers.length > 0) {
42
+ return `telegram:${config.telegramAllowedUsers[0]}`;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function scheduleJob(
48
+ db: Database,
49
+ job: CronJob,
50
+ router: MessageRouter,
51
+ config: ZuboConfig,
52
+ llm?: LlmProvider
53
+ ) {
54
+ if (activeCrons.has(job.id)) {
55
+ activeCrons.get(job.id)!.stop();
56
+ }
57
+
58
+ let cron: Cron;
59
+ try {
60
+ cron = new Cron(job.schedule, async () => {
61
+ logger.info(`Cron job firing: ${job.name}`);
62
+
63
+ const sessionKey = getOwnerSessionKey(config);
64
+ if (!sessionKey) {
65
+ logger.warn("No owner registered, skipping cron job");
66
+ return;
67
+ }
68
+
69
+ // Log start
70
+ const logResult = db
71
+ .prepare(
72
+ "INSERT INTO cron_logs (job_id, status) VALUES (?, 'running')"
73
+ )
74
+ .run(job.id);
75
+ const logId = Number(logResult.lastInsertRowid);
76
+
77
+ try {
78
+ let reply: string;
79
+
80
+ // If job has an agent and we have an LLM provider, delegate to the agent
81
+ if (job.agent && llm) {
82
+ reply = await delegateToAgent(llm, job.agent, job.task);
83
+ } else {
84
+ reply = await router.sendProactive(sessionKey, job.task);
85
+ }
86
+
87
+ // Log success
88
+ db.prepare(
89
+ "UPDATE cron_logs SET status = 'success', output = ?, finished_at = datetime('now') WHERE id = ?"
90
+ ).run(reply || "", logId);
91
+
92
+ // Update last_run
93
+ db.prepare(
94
+ "UPDATE cron_jobs SET last_run = datetime('now'), retry_count = 0 WHERE id = ?"
95
+ ).run(job.id);
96
+
97
+ // Send result to owner if delegated to agent
98
+ if (job.agent && llm && reply) {
99
+ const adapter = router as any;
100
+ try {
101
+ // Try to send through router's proactive channel
102
+ const sessionAdapter = (router as any).adapters ?? (router as any);
103
+ // Simplified: just log that the agent completed. The reply is already logged.
104
+ logger.info(`Agent "${job.agent}" completed cron task "${job.name}"`);
105
+ } catch {
106
+ // Non-critical, reply is already logged
107
+ }
108
+ }
109
+ } catch (err: any) {
110
+ logger.error(`Cron job failed: ${job.name}`, { error: err.message });
111
+
112
+ // Log failure
113
+ db.prepare(
114
+ "UPDATE cron_logs SET status = 'failed', error = ?, finished_at = datetime('now') WHERE id = ?"
115
+ ).run(err.message, logId);
116
+
117
+ // Increment retry count
118
+ const newRetry = job.retry_count + 1;
119
+ if (newRetry >= job.max_retries) {
120
+ logger.warn(
121
+ `Cron job disabled after ${job.max_retries} retries: ${job.name}`
122
+ );
123
+ db.prepare(
124
+ "UPDATE cron_jobs SET enabled = 0, retry_count = ? WHERE id = ?"
125
+ ).run(newRetry, job.id);
126
+ activeCrons.get(job.id)?.stop();
127
+ activeCrons.delete(job.id);
128
+ } else {
129
+ db.prepare(
130
+ "UPDATE cron_jobs SET retry_count = ? WHERE id = ?"
131
+ ).run(newRetry, job.id);
132
+ }
133
+ }
134
+ });
135
+ } catch (err: any) {
136
+ logger.error(`Invalid cron schedule for job '${job.name}'`, {
137
+ schedule: job.schedule,
138
+ error: err.message,
139
+ });
140
+ db.prepare("UPDATE cron_jobs SET enabled = 0 WHERE id = ?").run(job.id);
141
+ return;
142
+ }
143
+
144
+ activeCrons.set(job.id, cron);
145
+ }
146
+
147
+ const MAX_CRON_JOBS = 50;
148
+
149
+ /**
150
+ * Validate a cron schedule is not too aggressive.
151
+ * Rejects schedules that fire more than once per minute.
152
+ */
153
+ function validateCronSchedule(schedule: string): void {
154
+ // Quick check: if it has 6 fields (seconds), reject sub-minute schedules
155
+ const parts = schedule.trim().split(/\s+/);
156
+ if (parts.length === 6 && parts[0] !== "0") {
157
+ throw new Error(
158
+ "Sub-minute cron schedules are not allowed. Use at least 1-minute intervals."
159
+ );
160
+ }
161
+
162
+ // Try to parse and check next 2 runs are at least 60s apart
163
+ try {
164
+ const cron = new Cron(schedule);
165
+ const next1 = cron.nextRun();
166
+ const next2 = cron.nextRuns(2)[1];
167
+ if (next1 && next2) {
168
+ const gapMs = next2.getTime() - next1.getTime();
169
+ if (gapMs < 60_000) {
170
+ throw new Error(
171
+ "Cron schedule fires too frequently. Minimum interval is 1 minute."
172
+ );
173
+ }
174
+ }
175
+ cron.stop();
176
+ } catch (err: any) {
177
+ if (err.message?.includes("too frequently") || err.message?.includes("Sub-minute")) {
178
+ throw err;
179
+ }
180
+ throw new Error(`Invalid cron expression: ${schedule}`);
181
+ }
182
+ }
183
+
184
+ export function addCronJob(
185
+ db: Database,
186
+ name: string,
187
+ schedule: string,
188
+ task: string,
189
+ router: MessageRouter,
190
+ config: ZuboConfig,
191
+ agent?: string,
192
+ llm?: LlmProvider
193
+ ) {
194
+ // Validate schedule interval
195
+ validateCronSchedule(schedule);
196
+
197
+ // Enforce max active jobs
198
+ const activeCount = db
199
+ .query("SELECT COUNT(*) as c FROM cron_jobs WHERE enabled = 1")
200
+ .get() as { c: number };
201
+ if (activeCount.c >= MAX_CRON_JOBS) {
202
+ throw new Error(
203
+ `Maximum ${MAX_CRON_JOBS} active cron jobs allowed. Delete or disable some first.`
204
+ );
205
+ }
206
+
207
+ const result = db
208
+ .prepare(
209
+ "INSERT INTO cron_jobs (name, schedule, task, agent) VALUES (?, ?, ?, ?)"
210
+ )
211
+ .run(name, schedule, task, agent ?? null);
212
+
213
+ const id = Number(result.lastInsertRowid);
214
+ const job: CronJob = {
215
+ id,
216
+ name,
217
+ schedule,
218
+ task,
219
+ enabled: 1,
220
+ last_run: null,
221
+ retry_count: 0,
222
+ max_retries: 3,
223
+ agent: agent ?? null,
224
+ };
225
+
226
+ scheduleJob(db, job, router, config, llm);
227
+ logger.info(`Cron job added: ${name}`, { schedule, task, agent });
228
+ }
229
+
230
+ export function removeCronJob(db: Database, name: string): boolean {
231
+ const job = db
232
+ .query("SELECT id FROM cron_jobs WHERE name = ?")
233
+ .get(name) as { id: number } | null;
234
+ if (!job) return false;
235
+
236
+ if (activeCrons.has(job.id)) {
237
+ activeCrons.get(job.id)!.stop();
238
+ activeCrons.delete(job.id);
239
+ }
240
+
241
+ db.prepare("DELETE FROM cron_logs WHERE job_id = ?").run(job.id);
242
+ db.prepare("DELETE FROM cron_jobs WHERE id = ?").run(job.id);
243
+ logger.info(`Cron job removed: ${name}`);
244
+ return true;
245
+ }
246
+
247
+ export function listCronJobs(db: Database): CronJob[] {
248
+ return db
249
+ .query("SELECT * FROM cron_jobs ORDER BY created_at DESC")
250
+ .all() as CronJob[];
251
+ }
252
+
253
+ export function stopAllCrons() {
254
+ for (const [id, cron] of activeCrons) {
255
+ cron.stop();
256
+ }
257
+ activeCrons.clear();
258
+ }
@@ -0,0 +1,58 @@
1
+ import { logger } from "../util/logger";
2
+
3
+ type HeartbeatHandler = () => Promise<void>;
4
+
5
+ const handlers: HeartbeatHandler[] = [];
6
+ let timer: ReturnType<typeof setInterval> | null = null;
7
+ let currentIntervalMs = 30 * 60_000; // default 30 minutes
8
+
9
+ const DEFAULT_HEARTBEAT_MINUTES = 30;
10
+
11
+ export function onHeartbeat(handler: HeartbeatHandler) {
12
+ handlers.push(handler);
13
+ }
14
+
15
+ /**
16
+ * Start the heartbeat with a configurable interval.
17
+ * @param minutes — interval in minutes (default 30, min 1, max 1440)
18
+ */
19
+ export function startHeartbeat(minutes?: number) {
20
+ if (timer) return;
21
+
22
+ const mins = Math.max(1, Math.min(1440, minutes ?? DEFAULT_HEARTBEAT_MINUTES));
23
+ currentIntervalMs = mins * 60_000;
24
+
25
+ logger.info("Heartbeat started", {
26
+ intervalMinutes: mins,
27
+ });
28
+
29
+ timer = setInterval(async () => {
30
+ if (handlers.length === 0) return;
31
+
32
+ // Run all handlers in parallel — one slow handler shouldn't block others
33
+ const results = await Promise.allSettled(handlers.map((h) => h()));
34
+ for (const r of results) {
35
+ if (r.status === "rejected") {
36
+ logger.error("Heartbeat handler error", { error: r.reason?.message ?? String(r.reason) });
37
+ }
38
+ }
39
+ }, currentIntervalMs);
40
+ }
41
+
42
+ export function stopHeartbeat() {
43
+ if (timer) {
44
+ clearInterval(timer);
45
+ timer = null;
46
+ }
47
+ }
48
+
49
+ /** Restart heartbeat with a new interval. */
50
+ export function restartHeartbeat(minutes: number) {
51
+ stopHeartbeat();
52
+ startHeartbeat(minutes);
53
+ }
54
+
55
+ /** Get the current heartbeat interval in minutes. */
56
+ export function getHeartbeatMinutes(): number {
57
+ return currentIntervalMs / 60_000;
58
+ }
@@ -0,0 +1,100 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { MessageRouter } from "../channels/router";
3
+ import type { LlmProvider } from "../llm/provider";
4
+ import { agentLoop } from "../agent/loop";
5
+ import { logger } from "../util/logger";
6
+
7
+ export interface MemoryTrigger {
8
+ id: number;
9
+ pattern: string;
10
+ action: string;
11
+ schedule: string;
12
+ last_fired: string | null;
13
+ enabled: number;
14
+ }
15
+
16
+ export async function checkMemoryTriggers(
17
+ db: Database,
18
+ router: MessageRouter,
19
+ llm: LlmProvider
20
+ ): Promise<void> {
21
+ let triggers: MemoryTrigger[];
22
+ try {
23
+ triggers = db.query(
24
+ "SELECT * FROM memory_triggers WHERE enabled = 1"
25
+ ).all() as MemoryTrigger[];
26
+ } catch {
27
+ return; // Table may not exist
28
+ }
29
+
30
+ if (triggers.length === 0) return;
31
+
32
+ const now = new Date();
33
+
34
+ for (const trigger of triggers) {
35
+ if (!shouldFire(trigger, now)) continue;
36
+
37
+ logger.info(`Firing memory trigger: ${trigger.pattern}`);
38
+
39
+ try {
40
+ // Search memory for the pattern
41
+ const { searchMemory } = await import("../memory/engine");
42
+ const results = searchMemory(db, trigger.pattern, 3);
43
+
44
+ if (results.length > 0) {
45
+ const context = results.map((r) => r.content).join("\n");
46
+ const task = `${trigger.action}\n\nRelevant context:\n${context}`;
47
+
48
+ const result = await agentLoop(llm, "trigger", task, { maxRounds: 3 });
49
+
50
+ if (result.reply && router.broadcastProactive) {
51
+ await router.broadcastProactive(result.reply);
52
+ }
53
+
54
+ // Log
55
+ try {
56
+ db.prepare(
57
+ "INSERT INTO proactive_log (type, message) VALUES ('trigger', ?)"
58
+ ).run(`[${trigger.pattern}] ${result.reply.slice(0, 2000)}`);
59
+ } catch (err: any) {
60
+ logger.warn("Failed to log trigger result to database", { error: (err as Error).message });
61
+ }
62
+ }
63
+
64
+ // Update last_fired
65
+ db.prepare(
66
+ "UPDATE memory_triggers SET last_fired = datetime('now') WHERE id = ?"
67
+ ).run(trigger.id);
68
+
69
+ // Disable if one-shot
70
+ if (trigger.schedule === "once") {
71
+ db.prepare(
72
+ "UPDATE memory_triggers SET enabled = 0 WHERE id = ?"
73
+ ).run(trigger.id);
74
+ }
75
+ } catch (err: any) {
76
+ logger.error(`Trigger error (${trigger.pattern})`, { error: err.message });
77
+ }
78
+ }
79
+ }
80
+
81
+ function shouldFire(trigger: MemoryTrigger, now: Date): boolean {
82
+ if (!trigger.last_fired) return true;
83
+
84
+ const lastFired = new Date(trigger.last_fired);
85
+ const diffMs = now.getTime() - lastFired.getTime();
86
+ const diffHours = diffMs / 3_600_000;
87
+
88
+ switch (trigger.schedule) {
89
+ case "once":
90
+ return !trigger.last_fired;
91
+ case "daily":
92
+ return diffHours >= 24;
93
+ case "weekly":
94
+ return diffHours >= 168;
95
+ case "monthly":
96
+ return diffHours >= 720;
97
+ default:
98
+ return diffHours >= 24;
99
+ }
100
+ }