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,73 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { paths } from "../config/paths";
|
|
4
|
+
|
|
5
|
+
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
6
|
+
|
|
7
|
+
const LEVELS: Record<LogLevel, number> = {
|
|
8
|
+
debug: 0,
|
|
9
|
+
info: 1,
|
|
10
|
+
warn: 2,
|
|
11
|
+
error: 3,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
let currentLevel: LogLevel = "info";
|
|
15
|
+
let fileLogging = false;
|
|
16
|
+
|
|
17
|
+
export function setLogLevel(level: LogLevel) {
|
|
18
|
+
currentLevel = level;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function enableFileLogging() {
|
|
22
|
+
const dir = dirname(paths.logFile);
|
|
23
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
24
|
+
fileLogging = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SENSITIVE_KEYS = ["value", "secret", "password", "token", "key", "credential", "apikey", "api_key", "input"];
|
|
28
|
+
|
|
29
|
+
function sanitizeLogData(data: Record<string, unknown>): Record<string, unknown> {
|
|
30
|
+
const sanitized: Record<string, unknown> = {};
|
|
31
|
+
for (const [k, v] of Object.entries(data)) {
|
|
32
|
+
const lower = k.toLowerCase();
|
|
33
|
+
if (SENSITIVE_KEYS.some((s) => lower.includes(s)) && typeof v === "string" && v.length > 20) {
|
|
34
|
+
sanitized[k] = v.slice(0, 8) + "…[REDACTED]";
|
|
35
|
+
} else if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
36
|
+
sanitized[k] = sanitizeLogData(v as Record<string, unknown>);
|
|
37
|
+
} else {
|
|
38
|
+
sanitized[k] = v;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return sanitized;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function log(level: LogLevel, msg: string, data?: Record<string, unknown>) {
|
|
45
|
+
if (LEVELS[level] < LEVELS[currentLevel]) return;
|
|
46
|
+
const ts = new Date().toISOString();
|
|
47
|
+
const prefix = `[${ts}] [${level.toUpperCase()}]`;
|
|
48
|
+
const safeData = data ? sanitizeLogData(data) : undefined;
|
|
49
|
+
const line = safeData
|
|
50
|
+
? `${prefix} ${msg} ${JSON.stringify(safeData)}`
|
|
51
|
+
: `${prefix} ${msg}`;
|
|
52
|
+
|
|
53
|
+
console.log(line);
|
|
54
|
+
|
|
55
|
+
if (fileLogging) {
|
|
56
|
+
try {
|
|
57
|
+
appendFileSync(paths.logFile, line + "\n");
|
|
58
|
+
} catch {
|
|
59
|
+
// don't crash if log write fails
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const logger = {
|
|
65
|
+
debug: (msg: string, data?: Record<string, unknown>) =>
|
|
66
|
+
log("debug", msg, data),
|
|
67
|
+
info: (msg: string, data?: Record<string, unknown>) =>
|
|
68
|
+
log("info", msg, data),
|
|
69
|
+
warn: (msg: string, data?: Record<string, unknown>) =>
|
|
70
|
+
log("warn", msg, data),
|
|
71
|
+
error: (msg: string, data?: Record<string, unknown>) =>
|
|
72
|
+
log("error", msg, data),
|
|
73
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { statSync } from "fs";
|
|
2
|
+
import { onHeartbeat } from "../scheduler/heartbeat";
|
|
3
|
+
import { getDb } from "../db/connection";
|
|
4
|
+
import { paths } from "../config/paths";
|
|
5
|
+
import { logger } from "./logger";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register a heartbeat handler that records performance snapshots.
|
|
9
|
+
*/
|
|
10
|
+
export function initPerfCollector(): void {
|
|
11
|
+
onHeartbeat(async () => {
|
|
12
|
+
try {
|
|
13
|
+
const db = getDb();
|
|
14
|
+
const memUsage = process.memoryUsage();
|
|
15
|
+
const rssMb = Math.round((memUsage.rss / 1_048_576) * 100) / 100;
|
|
16
|
+
const heapMb = Math.round((memUsage.heapUsed / 1_048_576) * 100) / 100;
|
|
17
|
+
|
|
18
|
+
let dbSizeMb = 0;
|
|
19
|
+
try {
|
|
20
|
+
const stat = statSync(paths.db);
|
|
21
|
+
dbSizeMb = Math.round((stat.size / 1_048_576) * 100) / 100;
|
|
22
|
+
} catch (err: any) {
|
|
23
|
+
logger.warn("Failed to stat database file for perf snapshot", { error: (err as Error).message });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
db.prepare(
|
|
27
|
+
"INSERT INTO perf_snapshots (rss_mb, heap_mb, db_size_mb) VALUES (?, ?, ?)"
|
|
28
|
+
).run(rssMb, heapMb, dbSizeMb);
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
logger.debug("Perf snapshot failed", { error: err.message });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
logger.info("Performance collector initialized");
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding-window rate limiter with per-key tracking.
|
|
3
|
+
* Used for HTTP endpoints (per-IP) and channels (per-userId).
|
|
4
|
+
*/
|
|
5
|
+
export class RateLimiter {
|
|
6
|
+
private static readonly MAX_KEYS = 10_000;
|
|
7
|
+
private windows = new Map<string, number[]>();
|
|
8
|
+
private maxRequests: number;
|
|
9
|
+
private windowMs: number;
|
|
10
|
+
private pruneTimer: ReturnType<typeof setInterval>;
|
|
11
|
+
|
|
12
|
+
constructor(maxRequests: number, windowMs: number) {
|
|
13
|
+
this.maxRequests = maxRequests;
|
|
14
|
+
this.windowMs = windowMs;
|
|
15
|
+
|
|
16
|
+
// Auto-prune expired entries every 60s
|
|
17
|
+
this.pruneTimer = setInterval(() => this.prune(), 60_000);
|
|
18
|
+
// Allow process to exit without waiting for timer
|
|
19
|
+
if (this.pruneTimer.unref) this.pruneTimer.unref();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
check(key: string): { allowed: boolean; retryAfterMs?: number } {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const cutoff = now - this.windowMs;
|
|
25
|
+
|
|
26
|
+
// Evict oldest entry if map is at capacity and this is a new key
|
|
27
|
+
if (this.windows.size >= RateLimiter.MAX_KEYS && !this.windows.has(key)) {
|
|
28
|
+
const firstKey = this.windows.keys().next().value;
|
|
29
|
+
if (firstKey !== undefined) this.windows.delete(firstKey);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let timestamps = this.windows.get(key);
|
|
33
|
+
if (!timestamps) {
|
|
34
|
+
timestamps = [];
|
|
35
|
+
this.windows.set(key, timestamps);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Remove expired timestamps
|
|
39
|
+
while (timestamps.length > 0 && timestamps[0] <= cutoff) {
|
|
40
|
+
timestamps.shift();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (timestamps.length >= this.maxRequests) {
|
|
44
|
+
const oldestValid = timestamps[0];
|
|
45
|
+
const retryAfterMs = oldestValid + this.windowMs - now;
|
|
46
|
+
return { allowed: false, retryAfterMs: Math.max(retryAfterMs, 1) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
timestamps.push(now);
|
|
50
|
+
return { allowed: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private prune(): void {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const cutoff = now - this.windowMs;
|
|
56
|
+
for (const [key, timestamps] of this.windows) {
|
|
57
|
+
while (timestamps.length > 0 && timestamps[0] <= cutoff) {
|
|
58
|
+
timestamps.shift();
|
|
59
|
+
}
|
|
60
|
+
if (timestamps.length === 0) {
|
|
61
|
+
this.windows.delete(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
destroy(): void {
|
|
67
|
+
clearInterval(this.pruneTimer);
|
|
68
|
+
this.windows.clear();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rough token count estimation.
|
|
3
|
+
* ~4 chars per token for English text (GPT/Claude approximation).
|
|
4
|
+
*/
|
|
5
|
+
export function estimateTokens(text: string): number {
|
|
6
|
+
return Math.ceil(text.length / 4);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function estimateMessagesTokens(
|
|
10
|
+
messages: Array<{ role: string; content: string }>
|
|
11
|
+
): number {
|
|
12
|
+
let total = 0;
|
|
13
|
+
for (const msg of messages) {
|
|
14
|
+
total += estimateTokens(msg.content) + 4; // overhead per message
|
|
15
|
+
}
|
|
16
|
+
return total;
|
|
17
|
+
}
|
package/src/voice/stt.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { logger } from "../util/logger";
|
|
2
|
+
|
|
3
|
+
export interface SttProvider {
|
|
4
|
+
transcribe(audioBuffer: Buffer, format?: string): Promise<string>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class WhisperApiProvider implements SttProvider {
|
|
8
|
+
private apiKey: string;
|
|
9
|
+
private model: string;
|
|
10
|
+
|
|
11
|
+
constructor(apiKey: string, model: string = "whisper-1") {
|
|
12
|
+
this.apiKey = apiKey;
|
|
13
|
+
this.model = model;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async transcribe(audioBuffer: Buffer, format: string = "webm"): Promise<string> {
|
|
17
|
+
const formData = new FormData();
|
|
18
|
+
const blob = new Blob([new Uint8Array(audioBuffer)], { type: `audio/${format}` });
|
|
19
|
+
formData.append("file", blob, `audio.${format}`);
|
|
20
|
+
formData.append("model", this.model);
|
|
21
|
+
|
|
22
|
+
const res = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: {
|
|
25
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
26
|
+
},
|
|
27
|
+
body: formData,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const text = await res.text();
|
|
32
|
+
throw new Error(`Whisper API error ${res.status}: ${text}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const data = (await res.json()) as { text: string };
|
|
36
|
+
logger.debug("STT transcription", { length: data.text.length });
|
|
37
|
+
return data.text;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let sttProvider: SttProvider | null = null;
|
|
42
|
+
|
|
43
|
+
export function initStt(config: { provider: string; apiKey: string; model?: string }): void {
|
|
44
|
+
switch (config.provider) {
|
|
45
|
+
case "whisper":
|
|
46
|
+
case "openai":
|
|
47
|
+
sttProvider = new WhisperApiProvider(config.apiKey, config.model);
|
|
48
|
+
logger.info("STT initialized: Whisper API");
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
logger.warn(`Unknown STT provider: ${config.provider}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getSttProvider(): SttProvider | null {
|
|
56
|
+
return sttProvider;
|
|
57
|
+
}
|
package/src/voice/tts.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { logger } from "../util/logger";
|
|
2
|
+
|
|
3
|
+
export interface TtsProvider {
|
|
4
|
+
synthesize(text: string): Promise<Buffer>;
|
|
5
|
+
format: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class OpenAiTtsProvider implements TtsProvider {
|
|
9
|
+
private apiKey: string;
|
|
10
|
+
private model: string;
|
|
11
|
+
private voice: string;
|
|
12
|
+
format = "mp3";
|
|
13
|
+
|
|
14
|
+
constructor(apiKey: string, voice: string = "alloy", model: string = "tts-1") {
|
|
15
|
+
this.apiKey = apiKey;
|
|
16
|
+
this.voice = voice;
|
|
17
|
+
this.model = model;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async synthesize(text: string): Promise<Buffer> {
|
|
21
|
+
const res = await fetch("https://api.openai.com/v1/audio/speech", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
model: this.model,
|
|
29
|
+
input: text,
|
|
30
|
+
voice: this.voice,
|
|
31
|
+
response_format: "mp3",
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const errText = await res.text();
|
|
37
|
+
throw new Error(`OpenAI TTS error ${res.status}: ${errText}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
41
|
+
logger.debug("TTS synthesis complete", { bytes: arrayBuffer.byteLength });
|
|
42
|
+
return Buffer.from(arrayBuffer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ElevenLabsTtsProvider implements TtsProvider {
|
|
47
|
+
private apiKey: string;
|
|
48
|
+
private voice: string;
|
|
49
|
+
format = "mp3";
|
|
50
|
+
|
|
51
|
+
constructor(apiKey: string, voice: string = "21m00Tcm4TlvDq8ikWAM") {
|
|
52
|
+
this.apiKey = apiKey;
|
|
53
|
+
this.voice = voice;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async synthesize(text: string): Promise<Buffer> {
|
|
57
|
+
const res = await fetch(
|
|
58
|
+
`https://api.elevenlabs.io/v1/text-to-speech/${this.voice}`,
|
|
59
|
+
{
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
"xi-api-key": this.apiKey,
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
Accept: "audio/mpeg",
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
text,
|
|
68
|
+
model_id: "eleven_monolingual_v1",
|
|
69
|
+
}),
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const errText = await res.text();
|
|
75
|
+
throw new Error(`ElevenLabs TTS error ${res.status}: ${errText}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
79
|
+
logger.debug("ElevenLabs TTS complete", { bytes: arrayBuffer.byteLength });
|
|
80
|
+
return Buffer.from(arrayBuffer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let ttsProvider: TtsProvider | null = null;
|
|
85
|
+
|
|
86
|
+
export function initTts(config: { provider: string; apiKey: string; voice?: string }): void {
|
|
87
|
+
switch (config.provider) {
|
|
88
|
+
case "openai":
|
|
89
|
+
ttsProvider = new OpenAiTtsProvider(config.apiKey, config.voice);
|
|
90
|
+
logger.info("TTS initialized: OpenAI");
|
|
91
|
+
break;
|
|
92
|
+
case "elevenlabs":
|
|
93
|
+
ttsProvider = new ElevenLabsTtsProvider(config.apiKey, config.voice);
|
|
94
|
+
logger.info("TTS initialized: ElevenLabs");
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
logger.warn(`Unknown TTS provider: ${config.provider}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getTtsProvider(): TtsProvider | null {
|
|
102
|
+
return ttsProvider;
|
|
103
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
|
|
6
|
+
// Override paths.sessions before importing session module
|
|
7
|
+
import { paths } from "../../src/config/paths";
|
|
8
|
+
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
// Patch the sessions path to a temp directory before each test
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tempDir = mkdtempSync(join(tmpdir(), "orba-session-test-"));
|
|
14
|
+
(paths as any).sessions = tempDir;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Import after paths is available (the module reads paths at call time, not import time)
|
|
22
|
+
import { appendMessage, loadSession, sessionExists } from "../../src/agent/session";
|
|
23
|
+
import type { SessionMessage } from "../../src/agent/session";
|
|
24
|
+
|
|
25
|
+
describe("session management", () => {
|
|
26
|
+
test("appendMessage creates a session file and appends a message", () => {
|
|
27
|
+
const msg: SessionMessage = {
|
|
28
|
+
role: "user",
|
|
29
|
+
content: "Hello",
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
appendMessage("test-session", msg);
|
|
34
|
+
expect(sessionExists("test-session")).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("loadSession returns messages in order", () => {
|
|
38
|
+
const messages: SessionMessage[] = [
|
|
39
|
+
{ role: "user", content: "First", timestamp: "2025-01-01T00:00:00Z" },
|
|
40
|
+
{ role: "assistant", content: "Second", timestamp: "2025-01-01T00:00:01Z" },
|
|
41
|
+
{ role: "user", content: "Third", timestamp: "2025-01-01T00:00:02Z" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const msg of messages) {
|
|
45
|
+
appendMessage("ordered-session", msg);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const loaded = loadSession("ordered-session");
|
|
49
|
+
expect(loaded).toHaveLength(3);
|
|
50
|
+
expect(loaded[0].content).toBe("First");
|
|
51
|
+
expect(loaded[1].content).toBe("Second");
|
|
52
|
+
expect(loaded[2].content).toBe("Third");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("loadSession respects the limit parameter", () => {
|
|
56
|
+
for (let i = 0; i < 10; i++) {
|
|
57
|
+
appendMessage("limited-session", {
|
|
58
|
+
role: "user",
|
|
59
|
+
content: `Message ${i}`,
|
|
60
|
+
timestamp: new Date().toISOString(),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const loaded = loadSession("limited-session", 3);
|
|
65
|
+
expect(loaded).toHaveLength(3);
|
|
66
|
+
// Should return the last 3 messages
|
|
67
|
+
expect(loaded[0].content).toBe("Message 7");
|
|
68
|
+
expect(loaded[1].content).toBe("Message 8");
|
|
69
|
+
expect(loaded[2].content).toBe("Message 9");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("loadSession returns empty array for nonexistent session", () => {
|
|
73
|
+
const loaded = loadSession("does-not-exist");
|
|
74
|
+
expect(loaded).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("session ID validation rejects path traversal attempts", () => {
|
|
78
|
+
expect(() => {
|
|
79
|
+
appendMessage("../../etc/passwd", {
|
|
80
|
+
role: "user",
|
|
81
|
+
content: "malicious",
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
});
|
|
84
|
+
}).toThrow("Invalid session ID");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("session ID validation rejects slashes", () => {
|
|
88
|
+
expect(() => {
|
|
89
|
+
appendMessage("foo/bar", {
|
|
90
|
+
role: "user",
|
|
91
|
+
content: "malicious",
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
});
|
|
94
|
+
}).toThrow("Invalid session ID");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("session ID allows colons for channel:userId format", () => {
|
|
98
|
+
const msg: SessionMessage = {
|
|
99
|
+
role: "user",
|
|
100
|
+
content: "Hello from channel",
|
|
101
|
+
timestamp: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
appendMessage("discord:user123", msg);
|
|
105
|
+
const loaded = loadSession("discord:user123");
|
|
106
|
+
expect(loaded).toHaveLength(1);
|
|
107
|
+
expect(loaded[0].content).toBe("Hello from channel");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
2
|
+
import { createMockLlm, textResponse, toolCallResponse } from "./helpers/mock-llm";
|
|
3
|
+
import { createTestDb } from "./helpers/test-db";
|
|
4
|
+
|
|
5
|
+
// We need to mock the DB and session before importing the agent loop
|
|
6
|
+
// Since Bun's module system caches imports, we test the logic indirectly
|
|
7
|
+
|
|
8
|
+
describe("Agent Loop", () => {
|
|
9
|
+
it("should return text response when LLM returns text", async () => {
|
|
10
|
+
const llm = createMockLlm([textResponse("Hello, world!")]);
|
|
11
|
+
const response = await llm.chat({ system: "test", messages: [{ role: "user", content: "Hi" }] });
|
|
12
|
+
expect(response.content[0].type).toBe("text");
|
|
13
|
+
expect(response.content[0].text).toBe("Hello, world!");
|
|
14
|
+
expect(response.stopReason).toBe("end_turn");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should return tool_use when LLM wants to call a tool", async () => {
|
|
18
|
+
const llm = createMockLlm([
|
|
19
|
+
toolCallResponse("memory_write", "tc1", { content: "test" }),
|
|
20
|
+
textResponse("Done!"),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const response1 = await llm.chat({ system: "test", messages: [{ role: "user", content: "remember this" }] });
|
|
24
|
+
expect(response1.content[0].type).toBe("tool_use");
|
|
25
|
+
expect(response1.content[0].name).toBe("memory_write");
|
|
26
|
+
expect(response1.stopReason).toBe("tool_use");
|
|
27
|
+
|
|
28
|
+
const response2 = await llm.chat({ system: "test", messages: [] });
|
|
29
|
+
expect(response2.content[0].type).toBe("text");
|
|
30
|
+
expect(response2.content[0].text).toBe("Done!");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should handle multiple tool rounds", async () => {
|
|
34
|
+
const llm = createMockLlm([
|
|
35
|
+
toolCallResponse("memory_search", "tc1", { query: "test" }),
|
|
36
|
+
toolCallResponse("memory_write", "tc2", { content: "found" }),
|
|
37
|
+
textResponse("All done"),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
let response = await llm.chat({ system: "", messages: [] });
|
|
41
|
+
expect(response.content[0].name).toBe("memory_search");
|
|
42
|
+
response = await llm.chat({ system: "", messages: [] });
|
|
43
|
+
expect(response.content[0].name).toBe("memory_write");
|
|
44
|
+
response = await llm.chat({ system: "", messages: [] });
|
|
45
|
+
expect(response.content[0].text).toBe("All done");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should track usage correctly", async () => {
|
|
49
|
+
const llm = createMockLlm([textResponse("test")]);
|
|
50
|
+
const response = await llm.chat({ system: "", messages: [] });
|
|
51
|
+
expect(response.usage.inputTokens).toBe(100);
|
|
52
|
+
expect(response.usage.outputTokens).toBe(50);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import {
|
|
4
|
+
generateApiKey,
|
|
5
|
+
hashApiKey,
|
|
6
|
+
initAuth,
|
|
7
|
+
createApiKey,
|
|
8
|
+
listApiKeys,
|
|
9
|
+
deleteApiKey,
|
|
10
|
+
validateRequest,
|
|
11
|
+
generateSessionToken,
|
|
12
|
+
validateSessionToken,
|
|
13
|
+
} from "../src/util/auth";
|
|
14
|
+
|
|
15
|
+
describe("Auth", () => {
|
|
16
|
+
let db: Database;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
db = new Database(":memory:");
|
|
20
|
+
initAuth(db);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should generate a 64-character hex API key", () => {
|
|
24
|
+
const key = generateApiKey();
|
|
25
|
+
expect(key).toHaveLength(64);
|
|
26
|
+
expect(/^[a-f0-9]{64}$/.test(key)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should hash an API key deterministically", () => {
|
|
30
|
+
const key = "abc123";
|
|
31
|
+
const hash1 = hashApiKey(key);
|
|
32
|
+
const hash2 = hashApiKey(key);
|
|
33
|
+
expect(hash1).toBe(hash2);
|
|
34
|
+
expect(hash1).not.toBe(key);
|
|
35
|
+
expect(hash1).toHaveLength(64); // SHA-256 hex
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should validate a valid Bearer token", () => {
|
|
39
|
+
const result = createApiKey(db, "test-key");
|
|
40
|
+
const req = new Request("http://localhost/api/chat", {
|
|
41
|
+
headers: { Authorization: `Bearer ${result.key}` },
|
|
42
|
+
});
|
|
43
|
+
expect(validateRequest(db, req)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should reject a missing token", () => {
|
|
47
|
+
const req = new Request("http://localhost/api/chat");
|
|
48
|
+
expect(validateRequest(db, req)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should reject an invalid token", () => {
|
|
52
|
+
createApiKey(db, "real-key");
|
|
53
|
+
const req = new Request("http://localhost/api/chat", {
|
|
54
|
+
headers: { Authorization: "Bearer invalidtoken123" },
|
|
55
|
+
});
|
|
56
|
+
expect(validateRequest(db, req)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should create, list, and delete keys", () => {
|
|
60
|
+
const k1 = createApiKey(db, "key-1");
|
|
61
|
+
const k2 = createApiKey(db, "key-2");
|
|
62
|
+
|
|
63
|
+
const keys = listApiKeys(db);
|
|
64
|
+
expect(keys).toHaveLength(2);
|
|
65
|
+
expect(keys[0].label).toBe("key-1");
|
|
66
|
+
expect(keys[1].label).toBe("key-2");
|
|
67
|
+
|
|
68
|
+
const deleted = deleteApiKey(db, k1.id);
|
|
69
|
+
expect(deleted).toBe(true);
|
|
70
|
+
|
|
71
|
+
const remaining = listApiKeys(db);
|
|
72
|
+
expect(remaining).toHaveLength(1);
|
|
73
|
+
expect(remaining[0].id).toBe(k2.id);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should handle dashboard session tokens", () => {
|
|
77
|
+
const token = generateSessionToken();
|
|
78
|
+
expect(typeof token).toBe("string");
|
|
79
|
+
|
|
80
|
+
// Valid on first use
|
|
81
|
+
expect(validateSessionToken(token)).toBe(true);
|
|
82
|
+
// Invalid on second use (single-use)
|
|
83
|
+
expect(validateSessionToken(token)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should reject invalid session tokens", () => {
|
|
87
|
+
expect(validateSessionToken("nonexistent-token")).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { splitMessage } from "../src/channels/utils";
|
|
3
|
+
|
|
4
|
+
describe("Channel Utilities", () => {
|
|
5
|
+
describe("splitMessage", () => {
|
|
6
|
+
it("should return single chunk for short messages", () => {
|
|
7
|
+
const result = splitMessage("Hello world", 100);
|
|
8
|
+
expect(result).toEqual(["Hello world"]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("should split at newlines when possible", () => {
|
|
12
|
+
const msg = "Line 1\nLine 2\nLine 3";
|
|
13
|
+
const result = splitMessage(msg, 14);
|
|
14
|
+
expect(result.length).toBeGreaterThan(1);
|
|
15
|
+
// Each chunk should be within limit
|
|
16
|
+
for (const chunk of result) {
|
|
17
|
+
expect(chunk.length).toBeLessThanOrEqual(14);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should force-split long lines without newlines", () => {
|
|
22
|
+
const msg = "A".repeat(100);
|
|
23
|
+
const result = splitMessage(msg, 30);
|
|
24
|
+
expect(result.length).toBeGreaterThan(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should handle empty string", () => {
|
|
28
|
+
const result = splitMessage("", 100);
|
|
29
|
+
expect(result).toEqual([""]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should handle exact boundary length", () => {
|
|
33
|
+
const msg = "A".repeat(50);
|
|
34
|
+
const result = splitMessage(msg, 50);
|
|
35
|
+
expect(result).toEqual([msg]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("Router", () => {
|
|
41
|
+
it("should export createRouter function", async () => {
|
|
42
|
+
const { createRouter } = await import("../src/channels/router");
|
|
43
|
+
expect(typeof createRouter).toBe("function");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should handle addAdapter and stopAll", async () => {
|
|
47
|
+
const { createRouter } = await import("../src/channels/router");
|
|
48
|
+
const { createMockLlm, textResponse } = await import("./helpers/mock-llm");
|
|
49
|
+
const { createTestDb } = await import("./helpers/test-db");
|
|
50
|
+
|
|
51
|
+
const llm = createMockLlm([textResponse("hello")]);
|
|
52
|
+
const db = createTestDb();
|
|
53
|
+
const router = createRouter(llm, db);
|
|
54
|
+
|
|
55
|
+
let stopped = false;
|
|
56
|
+
const mockAdapter = {
|
|
57
|
+
channelName: "test",
|
|
58
|
+
start() {},
|
|
59
|
+
stop() { stopped = true; },
|
|
60
|
+
async sendMessage() {},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
router.addAdapter(mockAdapter);
|
|
64
|
+
router.stopAll();
|
|
65
|
+
expect(stopped).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|