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,259 @@
1
+ import { readdirSync, readFileSync, existsSync, watch } from "fs";
2
+ import { join } from "path";
3
+ import { paths } from "../config/paths";
4
+ import { logger } from "../util/logger";
5
+ import { registerTool, getTool, unregisterTool } from "./registry";
6
+
7
+ /** Tracks all skill names loaded via loadSkills so hot-reload can unregister them. */
8
+ const loadedSkillNames = new Set<string>();
9
+
10
+ export interface SkillDef {
11
+ name: string;
12
+ description: string;
13
+ inputSchema: Record<string, unknown>;
14
+ dirPath: string;
15
+ }
16
+
17
+ export interface SkillParamDef {
18
+ type: string;
19
+ description?: string;
20
+ required?: boolean;
21
+ }
22
+
23
+ export function paramsToJsonSchema(params: Record<string, SkillParamDef>): Record<string, unknown> {
24
+ const properties: Record<string, { type: string; description?: string }> = {};
25
+ const required: string[] = [];
26
+
27
+ for (const [key, def] of Object.entries(params)) {
28
+ properties[key] = { type: def.type || "string" };
29
+ if (def.description) properties[key].description = def.description;
30
+ if (def.required) required.push(key);
31
+ }
32
+
33
+ return { type: "object", properties, required };
34
+ }
35
+
36
+ export function parseSkillExport(
37
+ skillConfig: { name: string; description: string; params?: Record<string, SkillParamDef> },
38
+ dirPath: string
39
+ ): SkillDef | null {
40
+ const { name, description, params } = skillConfig;
41
+
42
+ if (!name || !/^[a-z0-9_]+$/.test(name)) return null;
43
+ if (!description) return null;
44
+
45
+ const inputSchema = params ? paramsToJsonSchema(params) : { type: "object", properties: {}, required: [] };
46
+
47
+ return { name, description, inputSchema, dirPath };
48
+ }
49
+
50
+ export function parseSkillMd(content: string, dirPath: string): SkillDef | null {
51
+ const lines = content.split("\n");
52
+
53
+ // H1 = tool name
54
+ const h1Line = lines.find((l) => /^# /.test(l));
55
+ if (!h1Line) return null;
56
+ const name = h1Line.replace(/^# /, "").trim();
57
+ if (!/^[a-z0-9_]+$/.test(name)) return null;
58
+
59
+ // Description = text between H1 and first H2
60
+ const h1Index = lines.indexOf(h1Line);
61
+ let descLines: string[] = [];
62
+ for (let i = h1Index + 1; i < lines.length; i++) {
63
+ if (/^## /.test(lines[i])) break;
64
+ descLines.push(lines[i]);
65
+ }
66
+ const description = descLines.join("\n").trim();
67
+ if (!description) return null;
68
+
69
+ // Input Schema = fenced JSON inside ## Input Schema
70
+ let inputSchema: Record<string, unknown> = {
71
+ type: "object",
72
+ properties: {},
73
+ required: [],
74
+ };
75
+ const schemaHeading = lines.findIndex((l) => /^## Input Schema/.test(l));
76
+ if (schemaHeading !== -1) {
77
+ let inFence = false;
78
+ let jsonLines: string[] = [];
79
+ for (let i = schemaHeading + 1; i < lines.length; i++) {
80
+ if (/^## /.test(lines[i]) && !inFence) break;
81
+ if (/^```/.test(lines[i])) {
82
+ if (inFence) break;
83
+ inFence = true;
84
+ continue;
85
+ }
86
+ if (inFence) jsonLines.push(lines[i]);
87
+ }
88
+ if (jsonLines.length) {
89
+ try {
90
+ inputSchema = JSON.parse(jsonLines.join("\n"));
91
+ } catch {
92
+ // Fall back to empty schema
93
+ }
94
+ }
95
+ }
96
+
97
+ // Usage Hints (optional) = appended to description
98
+ let fullDescription = description;
99
+ const hintsHeading = lines.findIndex((l) => /^## Usage Hints/.test(l));
100
+ if (hintsHeading !== -1) {
101
+ let hintLines: string[] = [];
102
+ for (let i = hintsHeading + 1; i < lines.length; i++) {
103
+ if (/^## /.test(lines[i])) break;
104
+ hintLines.push(lines[i]);
105
+ }
106
+ const hints = hintLines.join("\n").trim();
107
+ if (hints) {
108
+ fullDescription += "\n\n" + hints;
109
+ }
110
+ }
111
+
112
+ return { name, description: fullDescription, inputSchema, dirPath };
113
+ }
114
+
115
+ export async function loadSkills(skillsDir: string): Promise<string[]> {
116
+ if (!existsSync(skillsDir)) return [];
117
+
118
+ const loaded: string[] = [];
119
+ let entries: string[];
120
+
121
+ try {
122
+ entries = readdirSync(skillsDir);
123
+ } catch {
124
+ return [];
125
+ }
126
+
127
+ for (const entry of entries) {
128
+ const dirPath = join(skillsDir, entry);
129
+ const skillMdPath = join(dirPath, "SKILL.md");
130
+ const handlerPath = join(dirPath, "handler.ts");
131
+
132
+ if (!existsSync(handlerPath)) continue;
133
+
134
+ try {
135
+ let skill: SkillDef | null = null;
136
+ let handler: ((input: Record<string, unknown>) => Promise<string>) | undefined;
137
+
138
+ // Try SKILL.md first (backward compat)
139
+ if (existsSync(skillMdPath)) {
140
+ const mdContent = readFileSync(skillMdPath, "utf-8");
141
+ skill = parseSkillMd(mdContent, dirPath);
142
+ if (!skill) {
143
+ logger.warn(
144
+ `Skipping skill in ${entry}: invalid SKILL.md (name must match [a-z0-9_], description required)`
145
+ );
146
+ continue;
147
+ }
148
+ } else {
149
+ // Single-file: read metadata from handler.ts exports
150
+ const mod = await import(handlerPath + "?t=" + Date.now());
151
+ if (mod.skill && typeof mod.skill === "object") {
152
+ skill = parseSkillExport(mod.skill, dirPath);
153
+ handler = mod.default;
154
+ if (!skill) {
155
+ logger.warn(
156
+ `Skipping skill in ${entry}: invalid skill export (name must match [a-z0-9_], description required)`
157
+ );
158
+ continue;
159
+ }
160
+ } else {
161
+ logger.warn(`Skipping skill in ${entry}: no SKILL.md and no exported skill config`);
162
+ continue;
163
+ }
164
+ }
165
+
166
+ // Warn on name collision
167
+ if (getTool(skill.name)) {
168
+ logger.warn(`Skill "${skill.name}" conflicts with existing tool, skipping`);
169
+ continue;
170
+ }
171
+
172
+ // Dynamically import handler (if not already loaded from single-file path)
173
+ if (!handler) {
174
+ const mod = await import(handlerPath + "?t=" + Date.now());
175
+ handler = mod.default;
176
+ }
177
+ if (typeof handler !== "function") {
178
+ logger.warn(`Skipping skill "${skill.name}": handler.ts must export a default function`);
179
+ continue;
180
+ }
181
+
182
+ // Wrap handler with validation: ensure it returns a string
183
+ const rawHandler = handler;
184
+ const safeHandler = async (input: Record<string, unknown>): Promise<string> => {
185
+ const result = await rawHandler(input);
186
+ if (typeof result !== "string") {
187
+ logger.warn(`Skill "${skill.name}" returned ${typeof result}, expected string. Coercing to JSON.`);
188
+ return JSON.stringify(result);
189
+ }
190
+ return result;
191
+ };
192
+
193
+ registerTool({
194
+ definition: {
195
+ name: skill.name,
196
+ description: skill.description,
197
+ input_schema: skill.inputSchema,
198
+ },
199
+ execute: safeHandler,
200
+ }, true);
201
+
202
+ loadedSkillNames.add(skill.name);
203
+ loaded.push(skill.name);
204
+ } catch (err: any) {
205
+ logger.error(`Failed to load skill from ${entry}: ${err.message}`);
206
+ }
207
+ }
208
+
209
+ return loaded;
210
+ }
211
+
212
+ /**
213
+ * Unregisters all previously loaded skills, then reloads them from disk.
214
+ * Used by the hot-reload watcher to pick up file changes.
215
+ */
216
+ export async function reloadAllSkills(): Promise<string[]> {
217
+ for (const name of loadedSkillNames) {
218
+ unregisterTool(name);
219
+ }
220
+ loadedSkillNames.clear();
221
+
222
+ return loadSkills(paths.skills);
223
+ }
224
+
225
+ /**
226
+ * Watches the skills directory for file changes and reloads all skills
227
+ * after a short debounce. Returns a cleanup function to stop watching.
228
+ */
229
+ export function watchSkills(): () => void {
230
+ const skillsDir = paths.skills;
231
+
232
+ if (!existsSync(skillsDir)) {
233
+ logger.warn("Skills directory does not exist, skipping hot-reload watcher");
234
+ return () => {};
235
+ }
236
+
237
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
238
+
239
+ const watcher = watch(skillsDir, { recursive: true }, (_eventType, filename) => {
240
+ if (debounceTimer) clearTimeout(debounceTimer);
241
+
242
+ debounceTimer = setTimeout(async () => {
243
+ logger.info("Skill file changed, reloading skills", { filename });
244
+ try {
245
+ const skillNames = await reloadAllSkills();
246
+ logger.info("Skills reloaded successfully", { skills: skillNames });
247
+ } catch (err: any) {
248
+ logger.error("Failed to reload skills", { error: err.message });
249
+ }
250
+ }, 500);
251
+ });
252
+
253
+ logger.info("Skill hot-reload watcher started", { dir: skillsDir });
254
+
255
+ return () => {
256
+ if (debounceTimer) clearTimeout(debounceTimer);
257
+ watcher.close();
258
+ };
259
+ }
@@ -0,0 +1,23 @@
1
+ // Type declarations for optional dependencies that may not be installed.
2
+ // These modules are dynamically imported inside try/catch blocks.
3
+
4
+ declare module "pdf-parse" {
5
+ interface PdfData {
6
+ numpages: number;
7
+ numrender: number;
8
+ info: Record<string, unknown>;
9
+ metadata: unknown;
10
+ text: string;
11
+ version: string;
12
+ }
13
+ function pdfParse(buffer: Buffer): Promise<PdfData>;
14
+ export default pdfParse;
15
+ }
16
+
17
+ declare module "mammoth" {
18
+ interface ExtractResult {
19
+ value: string;
20
+ messages: unknown[];
21
+ }
22
+ export function extractRawText(options: { buffer: Buffer }): Promise<ExtractResult>;
23
+ }
@@ -0,0 +1,121 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ /**
4
+ * API key authentication for the Zubo HTTP API.
5
+ * Keys are stored as SHA-256 hashes — the raw key is only shown at creation time.
6
+ */
7
+
8
+ export function generateApiKey(): string {
9
+ const bytes = new Uint8Array(32);
10
+ crypto.getRandomValues(bytes);
11
+ return Array.from(bytes)
12
+ .map((b) => b.toString(16).padStart(2, "0"))
13
+ .join("");
14
+ }
15
+
16
+ export function hashApiKey(key: string): string {
17
+ const hasher = new Bun.CryptoHasher("sha256");
18
+ hasher.update(key);
19
+ return hasher.digest("hex");
20
+ }
21
+
22
+ export function initAuth(db: Database): void {
23
+ db.run(`
24
+ CREATE TABLE IF NOT EXISTS api_keys (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ label TEXT NOT NULL DEFAULT '',
27
+ key_hash TEXT NOT NULL UNIQUE,
28
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
29
+ last_used_at TEXT
30
+ )
31
+ `);
32
+ }
33
+
34
+ export function createApiKey(
35
+ db: Database,
36
+ label: string
37
+ ): { key: string; id: number } {
38
+ initAuth(db);
39
+ const key = generateApiKey();
40
+ const keyHash = hashApiKey(key);
41
+ const result = db
42
+ .prepare("INSERT INTO api_keys (label, key_hash) VALUES (?, ?)")
43
+ .run(label, keyHash);
44
+ return { key, id: Number(result.lastInsertRowid) };
45
+ }
46
+
47
+ export function listApiKeys(
48
+ db: Database
49
+ ): { id: number; label: string; created_at: string; last_used_at: string | null }[] {
50
+ initAuth(db);
51
+ return db
52
+ .query("SELECT id, label, created_at, last_used_at FROM api_keys ORDER BY id")
53
+ .all() as any[];
54
+ }
55
+
56
+ export function deleteApiKey(db: Database, id: number): boolean {
57
+ initAuth(db);
58
+ const result = db.prepare("DELETE FROM api_keys WHERE id = ?").run(id);
59
+ return result.changes > 0;
60
+ }
61
+
62
+ export function validateRequest(db: Database, req: Request): boolean {
63
+ const authHeader = req.headers.get("Authorization");
64
+ if (!authHeader) return false;
65
+
66
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
67
+ if (!match) return false;
68
+
69
+ const key = match[1];
70
+ const keyHash = hashApiKey(key);
71
+
72
+ initAuth(db);
73
+ const row = db
74
+ .query("SELECT id FROM api_keys WHERE key_hash = ?")
75
+ .get(keyHash) as { id: number } | null;
76
+
77
+ if (!row) return false;
78
+
79
+ // Update last_used_at
80
+ db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(
81
+ row.id
82
+ );
83
+ return true;
84
+ }
85
+
86
+ // Session tokens for dashboard embedding (single-use, short-lived)
87
+ const sessionTokens = new Map<string, number>(); // token -> expiry timestamp
88
+
89
+ const MAX_SESSION_TOKENS = 1000;
90
+
91
+ export function generateSessionToken(): string {
92
+ const token = crypto.randomUUID();
93
+ // 1-hour expiry
94
+ sessionTokens.set(token, Date.now() + 3600_000);
95
+
96
+ // Evict oldest tokens if over capacity
97
+ if (sessionTokens.size > MAX_SESSION_TOKENS) {
98
+ const iter = sessionTokens.keys();
99
+ const oldest = iter.next().value;
100
+ if (oldest) sessionTokens.delete(oldest);
101
+ }
102
+
103
+ return token;
104
+ }
105
+
106
+ export function validateSessionToken(token: string): boolean {
107
+ const expiry = sessionTokens.get(token);
108
+ if (!expiry) return false;
109
+ // Delete immediately to prevent race condition
110
+ sessionTokens.delete(token);
111
+ if (Date.now() > expiry) return false;
112
+ return true;
113
+ }
114
+
115
+ // Clean expired session tokens periodically
116
+ setInterval(() => {
117
+ const now = Date.now();
118
+ for (const [token, expiry] of sessionTokens) {
119
+ if (now > expiry) sessionTokens.delete(token);
120
+ }
121
+ }, 300_000).unref?.();
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Model pricing table (per 1M tokens, in USD).
3
+ * Updated as of late 2025.
4
+ */
5
+ const MODEL_PRICING: Record<string, { input: number; output: number }> = {
6
+ // Anthropic
7
+ "claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
8
+ "claude-haiku-4-5-20251001": { input: 0.8, output: 4.0 },
9
+ "claude-opus-4-6": { input: 15.0, output: 75.0 },
10
+
11
+ // OpenAI
12
+ "gpt-4.1": { input: 2.0, output: 8.0 },
13
+ "gpt-4.1-mini": { input: 0.4, output: 1.6 },
14
+ "gpt-4.1-nano": { input: 0.1, output: 0.4 },
15
+ "gpt-4o": { input: 2.5, output: 10.0 },
16
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
17
+ "o1-mini": { input: 1.1, output: 4.4 },
18
+ "o3-mini": { input: 1.1, output: 4.4 },
19
+
20
+ // xAI
21
+ "grok-4.1-fast": { input: 0.2, output: 0.5 },
22
+
23
+ // DeepSeek
24
+ "deepseek-chat": { input: 0.56, output: 1.68 },
25
+ "deepseek-reasoner": { input: 0.55, output: 2.19 },
26
+
27
+ // Groq
28
+ "llama-3.3-70b-versatile": { input: 0.59, output: 0.79 },
29
+ "llama-3.1-8b-instant": { input: 0.05, output: 0.08 },
30
+
31
+ // Together
32
+ "meta-llama/Llama-3.3-70B-Instruct-Turbo": { input: 0.88, output: 0.88 },
33
+
34
+ // OpenRouter — pricing depends on underlying model, use provider's pricing
35
+ "anthropic/claude-sonnet-4-5": { input: 3.0, output: 15.0 },
36
+
37
+ // Default fallback (free / local)
38
+ _default: { input: 0, output: 0 },
39
+ };
40
+
41
+ /**
42
+ * Estimate cost in USD for a given model and token counts.
43
+ */
44
+ export function estimateCost(
45
+ model: string,
46
+ inputTokens: number,
47
+ outputTokens: number
48
+ ): number {
49
+ // Try exact match first, then prefix match
50
+ let pricing = MODEL_PRICING[model];
51
+ if (!pricing) {
52
+ const key = Object.keys(MODEL_PRICING).find((k) => model.startsWith(k));
53
+ pricing = key ? MODEL_PRICING[key] : MODEL_PRICING._default;
54
+ }
55
+
56
+ const inputCost = (inputTokens / 1_000_000) * pricing.input;
57
+ const outputCost = (outputTokens / 1_000_000) * pricing.output;
58
+ return Math.round((inputCost + outputCost) * 1_000_000) / 1_000_000; // 6 decimal places
59
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A ring buffer of recent errors for agent visibility.
3
+ * The agent can check this to explain what went wrong.
4
+ */
5
+
6
+ interface ErrorEntry {
7
+ source: string;
8
+ message: string;
9
+ timestamp: string;
10
+ }
11
+
12
+ const MAX_ENTRIES = 20;
13
+ const buffer: ErrorEntry[] = [];
14
+
15
+ export function recordError(source: string, message: string): void {
16
+ buffer.push({
17
+ source,
18
+ message,
19
+ timestamp: new Date().toISOString(),
20
+ });
21
+ if (buffer.length > MAX_ENTRIES) {
22
+ buffer.shift();
23
+ }
24
+ }
25
+
26
+ export function getRecentErrors(limit: number = 10): ErrorEntry[] {
27
+ return buffer.slice(-limit);
28
+ }
29
+
30
+ export function clearErrors(): void {
31
+ buffer.length = 0;
32
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Shared Google OAuth 2.0 token management.
3
+ *
4
+ * All Google integration handlers should call `getGoogleAccessToken()`
5
+ * instead of reading secrets directly. This module handles token refresh
6
+ * and the initial authorization code exchange.
7
+ */
8
+
9
+ import { getSecret, setSecret } from "../secrets/store";
10
+
11
+ const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
12
+ const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
13
+
14
+ /** Scopes requested for all Google integrations */
15
+ const SCOPES = [
16
+ "https://www.googleapis.com/auth/gmail.modify",
17
+ "https://www.googleapis.com/auth/calendar",
18
+ "https://www.googleapis.com/auth/spreadsheets",
19
+ "https://www.googleapis.com/auth/documents",
20
+ "https://www.googleapis.com/auth/drive",
21
+ ];
22
+
23
+ /**
24
+ * Returns a valid Google access token, refreshing if necessary.
25
+ *
26
+ * This is the single entry point every Google handler should use.
27
+ * Throws if no refresh token is stored (user has not connected Google yet).
28
+ */
29
+ export async function getGoogleAccessToken(): Promise<string> {
30
+ const accessToken = getSecret("google_access_token");
31
+ const expiresAtRaw = getSecret("google_token_expires_at");
32
+
33
+ if (accessToken && expiresAtRaw) {
34
+ const expiresAt = parseInt(expiresAtRaw, 10);
35
+ // Refresh 5 minutes before actual expiry to avoid race conditions
36
+ const bufferMs = 5 * 60 * 1000;
37
+ if (Date.now() < expiresAt - bufferMs) {
38
+ return accessToken;
39
+ }
40
+ }
41
+
42
+ // Token missing or expired -- attempt refresh
43
+ return refreshGoogleToken();
44
+ }
45
+
46
+ /**
47
+ * Refreshes the Google access token using the stored refresh token.
48
+ * Stores the new access token and its expiry time.
49
+ */
50
+ export async function refreshGoogleToken(): Promise<string> {
51
+ const refreshToken = getSecret("google_refresh_token");
52
+ if (!refreshToken) {
53
+ throw new Error(
54
+ "Google not connected. Use the google_oauth tool to set up Google."
55
+ );
56
+ }
57
+
58
+ const clientId = getSecret("google_client_id");
59
+ const clientSecret = getSecret("google_client_secret");
60
+
61
+ if (!clientId || !clientSecret) {
62
+ throw new Error(
63
+ "Google client credentials missing. Use the google_oauth tool to set up Google."
64
+ );
65
+ }
66
+
67
+ const res = await fetch(GOOGLE_TOKEN_URL, {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
70
+ body: new URLSearchParams({
71
+ grant_type: "refresh_token",
72
+ refresh_token: refreshToken,
73
+ client_id: clientId,
74
+ client_secret: clientSecret,
75
+ }),
76
+ });
77
+
78
+ if (!res.ok) {
79
+ const body = await res.text().catch(() => "");
80
+ console.error(`[Google] Token refresh failed ${res.status}: ${body.slice(0, 500)}`);
81
+ throw new Error(
82
+ `Google token refresh failed (${res.status}). You may need to reconnect Google using the google_oauth tool.`
83
+ );
84
+ }
85
+
86
+ const data = (await res.json()) as {
87
+ access_token: string;
88
+ expires_in: number;
89
+ token_type: string;
90
+ refresh_token?: string;
91
+ };
92
+
93
+ // Store new access token and computed expiry timestamp
94
+ setSecret("google_access_token", data.access_token, "google");
95
+ const expiresAt = Date.now() + data.expires_in * 1000;
96
+ setSecret("google_token_expires_at", String(expiresAt), "google");
97
+
98
+ // Google occasionally rotates refresh tokens -- store the new one if provided
99
+ if (data.refresh_token) {
100
+ setSecret("google_refresh_token", data.refresh_token, "google");
101
+ }
102
+
103
+ return data.access_token;
104
+ }
105
+
106
+ /**
107
+ * Exchanges an authorization code for access + refresh tokens.
108
+ * Called once after the user completes the OAuth consent screen.
109
+ */
110
+ export async function exchangeGoogleCode(
111
+ code: string,
112
+ redirectUri: string
113
+ ): Promise<void> {
114
+ const clientId = getSecret("google_client_id");
115
+ const clientSecret = getSecret("google_client_secret");
116
+
117
+ if (!clientId || !clientSecret) {
118
+ throw new Error(
119
+ "Google client credentials not found. Store google_client_id and google_client_secret first."
120
+ );
121
+ }
122
+
123
+ const res = await fetch(GOOGLE_TOKEN_URL, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
126
+ body: new URLSearchParams({
127
+ grant_type: "authorization_code",
128
+ code,
129
+ redirect_uri: redirectUri,
130
+ client_id: clientId,
131
+ client_secret: clientSecret,
132
+ }),
133
+ });
134
+
135
+ if (!res.ok) {
136
+ const body = await res.text().catch(() => "");
137
+ console.error(`[Google] Code exchange failed ${res.status}: ${body.slice(0, 500)}`);
138
+ throw new Error(
139
+ `Google authorization failed (${res.status}). Please try the OAuth flow again.`
140
+ );
141
+ }
142
+
143
+ const data = (await res.json()) as {
144
+ access_token: string;
145
+ expires_in: number;
146
+ refresh_token?: string;
147
+ token_type: string;
148
+ };
149
+
150
+ setSecret("google_access_token", data.access_token, "google");
151
+ const expiresAt = Date.now() + data.expires_in * 1000;
152
+ setSecret("google_token_expires_at", String(expiresAt), "google");
153
+
154
+ if (data.refresh_token) {
155
+ setSecret("google_refresh_token", data.refresh_token, "google");
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Builds the Google OAuth 2.0 authorization URL.
161
+ * Uses `access_type=offline` and `prompt=consent` to ensure a refresh token
162
+ * is always returned, even if the user has previously authorized the app.
163
+ */
164
+ export function getGoogleAuthUrl(redirectUri: string): string {
165
+ const clientId = getSecret("google_client_id");
166
+ if (!clientId) {
167
+ throw new Error("google_client_id not found in secrets.");
168
+ }
169
+
170
+ const params = new URLSearchParams({
171
+ client_id: clientId,
172
+ redirect_uri: redirectUri,
173
+ response_type: "code",
174
+ scope: SCOPES.join(" "),
175
+ access_type: "offline",
176
+ prompt: "consent",
177
+ });
178
+
179
+ return `${GOOGLE_AUTH_URL}?${params.toString()}`;
180
+ }