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.
- package/.github/workflows/ci.yml +35 -0
- package/README.md +149 -0
- package/bun.lock +216 -0
- package/desktop/README.md +57 -0
- package/desktop/package.json +12 -0
- package/desktop/src-tauri/Cargo.toml +25 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/icons/README.md +17 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/main.rs +189 -0
- package/desktop/src-tauri/tauri.conf.json +68 -0
- package/docs/ROADMAP.md +490 -0
- package/migrations/001_init.sql +9 -0
- package/migrations/002_memory.sql +33 -0
- package/migrations/003_cron.sql +24 -0
- package/migrations/004_usage.sql +12 -0
- package/migrations/005_secrets.sql +8 -0
- package/migrations/006_agents.sql +1 -0
- package/migrations/007_workflows.sql +22 -0
- package/migrations/008_proactive.sql +24 -0
- package/migrations/009_uploads.sql +9 -0
- package/migrations/010_observability.sql +22 -0
- package/migrations/011_api_keys.sql +7 -0
- package/migrations/012_indexes.sql +5 -0
- package/migrations/013_budget.sql +11 -0
- package/migrations/014_usage_session_idx.sql +2 -0
- package/package.json +39 -0
- package/site/404.html +156 -0
- package/site/CNAME +1 -0
- package/site/docs/agents.html +294 -0
- package/site/docs/api.html +446 -0
- package/site/docs/channels.html +345 -0
- package/site/docs/cli.html +238 -0
- package/site/docs/config.html +1034 -0
- package/site/docs/index.html +433 -0
- package/site/docs/integrations.html +381 -0
- package/site/docs/memory.html +254 -0
- package/site/docs/security.html +375 -0
- package/site/docs/skills.html +322 -0
- package/site/docs.css +412 -0
- package/site/index.html +638 -0
- package/site/install.sh +98 -0
- package/site/logo.svg +1 -0
- package/site/og-image.png +0 -0
- package/site/robots.txt +4 -0
- package/site/script.js +361 -0
- package/site/sitemap.xml +63 -0
- package/site/skills.html +532 -0
- package/site/style.css +1686 -0
- package/src/agent/agents.ts +159 -0
- package/src/agent/compaction.ts +53 -0
- package/src/agent/context.ts +18 -0
- package/src/agent/delegate.ts +118 -0
- package/src/agent/loop.ts +318 -0
- package/src/agent/prompts.ts +111 -0
- package/src/agent/session.ts +87 -0
- package/src/agent/teams.ts +116 -0
- package/src/agent/workflow-executor.ts +192 -0
- package/src/agent/workflow.ts +175 -0
- package/src/channels/adapter.ts +21 -0
- package/src/channels/dashboard.html.ts +2969 -0
- package/src/channels/discord.ts +137 -0
- package/src/channels/optional-deps.d.ts +17 -0
- package/src/channels/router.ts +199 -0
- package/src/channels/signal.ts +133 -0
- package/src/channels/slack.ts +101 -0
- package/src/channels/telegram.ts +102 -0
- package/src/channels/utils.ts +18 -0
- package/src/channels/webchat.ts +1797 -0
- package/src/channels/whatsapp.ts +119 -0
- package/src/config/loader.ts +22 -0
- package/src/config/paths.ts +43 -0
- package/src/config/schema.ts +121 -0
- package/src/db/connection.ts +20 -0
- package/src/db/export.ts +148 -0
- package/src/db/migrations.ts +42 -0
- package/src/index.ts +261 -0
- package/src/llm/claude.ts +193 -0
- package/src/llm/factory.ts +115 -0
- package/src/llm/failover.ts +101 -0
- package/src/llm/openai-compat.ts +409 -0
- package/src/llm/provider.ts +83 -0
- package/src/llm/smart-router.ts +241 -0
- package/src/logs.ts +53 -0
- package/src/memory/chunker.ts +58 -0
- package/src/memory/document-parser.ts +115 -0
- package/src/memory/embedder.ts +235 -0
- package/src/memory/engine.ts +170 -0
- package/src/memory/fts-index.ts +55 -0
- package/src/memory/hybrid-search.ts +72 -0
- package/src/memory/store.ts +56 -0
- package/src/memory/vector-index.ts +72 -0
- package/src/model.ts +118 -0
- package/src/registry/cli.ts +43 -0
- package/src/registry/client.ts +54 -0
- package/src/registry/installer.ts +67 -0
- package/src/scheduler/briefing.ts +71 -0
- package/src/scheduler/cron.ts +258 -0
- package/src/scheduler/heartbeat.ts +58 -0
- package/src/scheduler/memory-triggers.ts +100 -0
- package/src/scheduler/natural-cron.ts +163 -0
- package/src/scheduler/proactive.ts +25 -0
- package/src/scheduler/recipes.ts +110 -0
- package/src/secrets/store.ts +64 -0
- package/src/setup.ts +413 -0
- package/src/skills.ts +293 -0
- package/src/start.ts +373 -0
- package/src/status.ts +165 -0
- package/src/tools/builtin/connect-service.ts +205 -0
- package/src/tools/builtin/cron.ts +126 -0
- package/src/tools/builtin/datetime.ts +36 -0
- package/src/tools/builtin/delegate-task.ts +81 -0
- package/src/tools/builtin/delegate.ts +42 -0
- package/src/tools/builtin/diagnose.ts +41 -0
- package/src/tools/builtin/google-oauth.ts +379 -0
- package/src/tools/builtin/manage-agents.ts +149 -0
- package/src/tools/builtin/manage-skills.ts +294 -0
- package/src/tools/builtin/manage-teams.ts +89 -0
- package/src/tools/builtin/manage-triggers.ts +94 -0
- package/src/tools/builtin/manage-workflows.ts +119 -0
- package/src/tools/builtin/memory-search.ts +38 -0
- package/src/tools/builtin/memory-write.ts +30 -0
- package/src/tools/builtin/run-workflow.ts +36 -0
- package/src/tools/builtin/secrets.ts +122 -0
- package/src/tools/builtin/skill-registry.ts +75 -0
- package/src/tools/builtin-integrations/api-helpers.ts +26 -0
- package/src/tools/builtin-integrations/github/github_issues/SKILL.md +56 -0
- package/src/tools/builtin-integrations/github/github_issues/handler.ts +108 -0
- package/src/tools/builtin-integrations/github/github_prs/SKILL.md +57 -0
- package/src/tools/builtin-integrations/github/github_prs/handler.ts +113 -0
- package/src/tools/builtin-integrations/github/github_repos/SKILL.md +37 -0
- package/src/tools/builtin-integrations/github/github_repos/handler.ts +88 -0
- package/src/tools/builtin-integrations/google/gmail/SKILL.md +51 -0
- package/src/tools/builtin-integrations/google/gmail/handler.ts +125 -0
- package/src/tools/builtin-integrations/google/google_calendar/SKILL.md +35 -0
- package/src/tools/builtin-integrations/google/google_calendar/handler.ts +105 -0
- package/src/tools/builtin-integrations/google/google_docs/SKILL.md +35 -0
- package/src/tools/builtin-integrations/google/google_docs/handler.ts +108 -0
- package/src/tools/builtin-integrations/google/google_drive/SKILL.md +39 -0
- package/src/tools/builtin-integrations/google/google_drive/handler.ts +106 -0
- package/src/tools/builtin-integrations/google/google_sheets/SKILL.md +36 -0
- package/src/tools/builtin-integrations/google/google_sheets/handler.ts +116 -0
- package/src/tools/builtin-integrations/jira/jira_boards/SKILL.md +21 -0
- package/src/tools/builtin-integrations/jira/jira_boards/handler.ts +74 -0
- package/src/tools/builtin-integrations/jira/jira_issues/SKILL.md +28 -0
- package/src/tools/builtin-integrations/jira/jira_issues/handler.ts +140 -0
- package/src/tools/builtin-integrations/linear/linear_issues/SKILL.md +30 -0
- package/src/tools/builtin-integrations/linear/linear_issues/handler.ts +75 -0
- package/src/tools/builtin-integrations/linear/linear_projects/SKILL.md +21 -0
- package/src/tools/builtin-integrations/linear/linear_projects/handler.ts +43 -0
- package/src/tools/builtin-integrations/notion/notion_databases/SKILL.md +39 -0
- package/src/tools/builtin-integrations/notion/notion_databases/handler.ts +83 -0
- package/src/tools/builtin-integrations/notion/notion_pages/SKILL.md +43 -0
- package/src/tools/builtin-integrations/notion/notion_pages/handler.ts +130 -0
- package/src/tools/builtin-integrations/notion/notion_search/SKILL.md +27 -0
- package/src/tools/builtin-integrations/notion/notion_search/handler.ts +69 -0
- package/src/tools/builtin-integrations/slack/slack_messages/SKILL.md +42 -0
- package/src/tools/builtin-integrations/slack/slack_messages/handler.ts +72 -0
- package/src/tools/builtin-integrations/twitter/twitter_posts/SKILL.md +24 -0
- package/src/tools/builtin-integrations/twitter/twitter_posts/handler.ts +133 -0
- package/src/tools/builtin-skills/file-read/SKILL.md +26 -0
- package/src/tools/builtin-skills/file-read/handler.ts +66 -0
- package/src/tools/builtin-skills/file-write/SKILL.md +30 -0
- package/src/tools/builtin-skills/file-write/handler.ts +64 -0
- package/src/tools/builtin-skills/http-request/SKILL.md +34 -0
- package/src/tools/builtin-skills/http-request/handler.ts +87 -0
- package/src/tools/builtin-skills/shell/SKILL.md +26 -0
- package/src/tools/builtin-skills/shell/handler.ts +96 -0
- package/src/tools/builtin-skills/url-fetch/SKILL.md +26 -0
- package/src/tools/builtin-skills/url-fetch/handler.ts +37 -0
- package/src/tools/builtin-skills/web-search/SKILL.md +26 -0
- package/src/tools/builtin-skills/web-search/handler.ts +50 -0
- package/src/tools/executor.ts +205 -0
- package/src/tools/integration-installer.ts +106 -0
- package/src/tools/permissions.ts +45 -0
- package/src/tools/registry.ts +39 -0
- package/src/tools/sandbox-runner.ts +56 -0
- package/src/tools/sandbox.ts +82 -0
- package/src/tools/skill-installer.ts +52 -0
- package/src/tools/skill-loader.ts +259 -0
- package/src/types/optional-deps.d.ts +23 -0
- package/src/util/auth.ts +121 -0
- package/src/util/costs.ts +59 -0
- package/src/util/error-buffer.ts +32 -0
- package/src/util/google-tokens.ts +180 -0
- package/src/util/logger.ts +73 -0
- package/src/util/perf-collector.ts +35 -0
- package/src/util/rate-limiter.ts +70 -0
- package/src/util/tokens.ts +17 -0
- package/src/voice/stt.ts +57 -0
- package/src/voice/tts.ts +103 -0
- package/tests/agent/session.test.ts +109 -0
- package/tests/agent-loop.test.ts +54 -0
- package/tests/auth.test.ts +89 -0
- package/tests/channels.test.ts +67 -0
- package/tests/compaction.test.ts +44 -0
- package/tests/config.test.ts +51 -0
- package/tests/costs.test.ts +19 -0
- package/tests/cron.test.ts +55 -0
- package/tests/db/export.test.ts +219 -0
- package/tests/executor.test.ts +144 -0
- package/tests/export.test.ts +137 -0
- package/tests/helpers/mock-llm.ts +34 -0
- package/tests/helpers/test-db.ts +74 -0
- package/tests/integration/chat-flow.test.ts +48 -0
- package/tests/integrations.test.ts +97 -0
- package/tests/memory/engine.test.ts +114 -0
- package/tests/memory-engine.test.ts +57 -0
- package/tests/permissions.test.ts +21 -0
- package/tests/rate-limiter.test.ts +70 -0
- package/tests/registry.test.ts +67 -0
- package/tests/router.test.ts +36 -0
- package/tests/session.test.ts +58 -0
- package/tests/skill-loader.test.ts +44 -0
- package/tests/tokens.test.ts +30 -0
- package/tests/tools/executor.test.ts +130 -0
- package/tests/util/auth.test.ts +75 -0
- package/tests/util/rate-limiter.test.ts +73 -0
- package/tests/voice.test.ts +60 -0
- package/tests/webchat.test.ts +88 -0
- package/tests/workflow.test.ts +38 -0
- 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
|
+
}
|
package/src/util/auth.ts
ADDED
|
@@ -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
|
+
}
|