alvin-bot 4.4.1

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 (136) hide show
  1. package/.env.example +43 -0
  2. package/BACKLOG.md +223 -0
  3. package/CHANGELOG.md +63 -0
  4. package/CLAUDE.example.md +152 -0
  5. package/CODE_OF_CONDUCT.md +52 -0
  6. package/CONTRIBUTING.md +72 -0
  7. package/LICENSE +21 -0
  8. package/README.md +529 -0
  9. package/SECURITY.md +38 -0
  10. package/SOUL.example.md +60 -0
  11. package/TOOLS.example.md +42 -0
  12. package/alvin-bot.config.example.json +24 -0
  13. package/bin/cli.js +1088 -0
  14. package/dist/.metadata_never_index +0 -0
  15. package/dist/claude.js +102 -0
  16. package/dist/config.js +65 -0
  17. package/dist/engine.js +90 -0
  18. package/dist/find-claude-binary.js +98 -0
  19. package/dist/handlers/commands.js +1489 -0
  20. package/dist/handlers/document.js +187 -0
  21. package/dist/handlers/message.js +200 -0
  22. package/dist/handlers/photo.js +154 -0
  23. package/dist/handlers/platform-message.js +275 -0
  24. package/dist/handlers/video.js +237 -0
  25. package/dist/handlers/voice.js +148 -0
  26. package/dist/i18n.js +299 -0
  27. package/dist/index.js +442 -0
  28. package/dist/init-data-dir.js +81 -0
  29. package/dist/middleware/auth.js +215 -0
  30. package/dist/migrate.js +139 -0
  31. package/dist/paths.js +87 -0
  32. package/dist/platforms/discord.js +161 -0
  33. package/dist/platforms/index.js +130 -0
  34. package/dist/platforms/signal.js +205 -0
  35. package/dist/platforms/slack.js +318 -0
  36. package/dist/platforms/telegram.js +111 -0
  37. package/dist/platforms/types.js +8 -0
  38. package/dist/platforms/whatsapp.js +648 -0
  39. package/dist/providers/claude-sdk-provider.js +173 -0
  40. package/dist/providers/codex-cli-provider.js +121 -0
  41. package/dist/providers/index.js +7 -0
  42. package/dist/providers/openai-compatible.js +388 -0
  43. package/dist/providers/registry.js +209 -0
  44. package/dist/providers/tool-executor.js +450 -0
  45. package/dist/providers/types.js +205 -0
  46. package/dist/services/access.js +144 -0
  47. package/dist/services/asset-index.js +230 -0
  48. package/dist/services/browser-manager.js +161 -0
  49. package/dist/services/browser.js +121 -0
  50. package/dist/services/compaction.js +129 -0
  51. package/dist/services/cron.js +462 -0
  52. package/dist/services/custom-tools.js +317 -0
  53. package/dist/services/delivery-queue.js +154 -0
  54. package/dist/services/elevenlabs.js +58 -0
  55. package/dist/services/embeddings.js +386 -0
  56. package/dist/services/exec-guard.js +46 -0
  57. package/dist/services/fallback-order.js +151 -0
  58. package/dist/services/heartbeat.js +192 -0
  59. package/dist/services/hooks.js +44 -0
  60. package/dist/services/imagegen.js +72 -0
  61. package/dist/services/language-detect.js +144 -0
  62. package/dist/services/markdown.js +63 -0
  63. package/dist/services/mcp.js +252 -0
  64. package/dist/services/memory.js +133 -0
  65. package/dist/services/personality.js +227 -0
  66. package/dist/services/plugins.js +171 -0
  67. package/dist/services/reminders.js +97 -0
  68. package/dist/services/restart.js +48 -0
  69. package/dist/services/security-audit.js +66 -0
  70. package/dist/services/self-search.js +129 -0
  71. package/dist/services/session.js +93 -0
  72. package/dist/services/skills.js +287 -0
  73. package/dist/services/standing-orders.js +29 -0
  74. package/dist/services/subagents.js +142 -0
  75. package/dist/services/sudo.js +243 -0
  76. package/dist/services/telegram.js +113 -0
  77. package/dist/services/tool-discovery.js +214 -0
  78. package/dist/services/usage-tracker.js +137 -0
  79. package/dist/services/users.js +199 -0
  80. package/dist/services/voice.js +95 -0
  81. package/dist/tui/index.js +507 -0
  82. package/dist/web/canvas.js +30 -0
  83. package/dist/web/doctor-api.js +606 -0
  84. package/dist/web/openai-compat.js +252 -0
  85. package/dist/web/server.js +1351 -0
  86. package/dist/web/setup-api.js +1078 -0
  87. package/docs/mcp.example.json +16 -0
  88. package/docs/screenshots/00-Login.png +0 -0
  89. package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
  90. package/docs/screenshots/02-Chat.png +0 -0
  91. package/docs/screenshots/03-Dashboard-Overview.png +0 -0
  92. package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
  93. package/docs/screenshots/05-Personality-Editor.png +0 -0
  94. package/docs/screenshots/06-Memory-Manager.png +0 -0
  95. package/docs/screenshots/07-Active-Sessions.png +0 -0
  96. package/docs/screenshots/08-File-Browser.png +0 -0
  97. package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
  98. package/docs/screenshots/10-Custom-Tools.png +0 -0
  99. package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
  100. package/docs/screenshots/12-Messaging-Platforms.png +0 -0
  101. package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
  102. package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
  103. package/docs/screenshots/13-User-Management.png +0 -0
  104. package/docs/screenshots/14-Web-Terminal.png +0 -0
  105. package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
  106. package/docs/screenshots/16-Settings-and-Env.png +0 -0
  107. package/docs/screenshots/TG-commands.png +0 -0
  108. package/docs/screenshots/TG.png +0 -0
  109. package/docs/screenshots/_Mac-Installer.png +0 -0
  110. package/docs/tools.example.json +33 -0
  111. package/install.sh +165 -0
  112. package/package.json +190 -0
  113. package/plugins/calendar/index.js +270 -0
  114. package/plugins/email/index.js +231 -0
  115. package/plugins/finance/index.js +254 -0
  116. package/plugins/notes/index.js +227 -0
  117. package/plugins/smarthome/index.js +230 -0
  118. package/plugins/weather/index.js +122 -0
  119. package/skills/apple-notes/SKILL.md +31 -0
  120. package/skills/browse/SKILL.md +136 -0
  121. package/skills/code-project/SKILL.md +43 -0
  122. package/skills/data-analysis/SKILL.md +39 -0
  123. package/skills/document-creation/SKILL.md +48 -0
  124. package/skills/email-summary/SKILL.md +46 -0
  125. package/skills/github/SKILL.md +42 -0
  126. package/skills/summarize/SKILL.md +28 -0
  127. package/skills/system-admin/SKILL.md +39 -0
  128. package/skills/weather/SKILL.md +34 -0
  129. package/skills/web-research/SKILL.md +35 -0
  130. package/web/public/canvas.html +52 -0
  131. package/web/public/css/style.css +555 -0
  132. package/web/public/index.html +189 -0
  133. package/web/public/js/app.js +3102 -0
  134. package/web/public/js/i18n.js +1048 -0
  135. package/web/public/js/icons.js +104 -0
  136. package/web/public/login.html +48 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Claude Agent SDK Provider
3
+ *
4
+ * Wraps the existing Claude Agent SDK integration as a provider.
5
+ * This is the "premium" provider with full tool use (Read, Write, Bash, etc.)
6
+ *
7
+ * Requires: Claude CLI installed & logged in (Max subscription)
8
+ */
9
+ import { query } from "@anthropic-ai/claude-agent-sdk";
10
+ import { readFileSync } from "fs";
11
+ import { resolve, dirname } from "path";
12
+ import { fileURLToPath } from "url";
13
+ import { findClaudeBinary } from "../find-claude-binary.js";
14
+ const BOT_PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
15
+ // Load CLAUDE.md once at startup
16
+ let botClaudeMd = "";
17
+ try {
18
+ botClaudeMd = readFileSync(resolve(BOT_PROJECT_ROOT, "CLAUDE.md"), "utf-8");
19
+ botClaudeMd = botClaudeMd.replaceAll("docs/", `${BOT_PROJECT_ROOT}/docs/`);
20
+ }
21
+ catch {
22
+ // CLAUDE.md not found — continue without
23
+ }
24
+ // Checkpoint thresholds
25
+ const CHECKPOINT_TOOL_THRESHOLD = 15;
26
+ const CHECKPOINT_MSG_THRESHOLD = 10;
27
+ export class ClaudeSDKProvider {
28
+ config;
29
+ constructor(config) {
30
+ this.config = {
31
+ type: "claude-sdk",
32
+ name: "Claude (Agent SDK)",
33
+ model: "claude-opus-4-6",
34
+ supportsTools: true,
35
+ supportsVision: true,
36
+ supportsStreaming: true,
37
+ ...config,
38
+ };
39
+ }
40
+ async *query(options) {
41
+ // Clean env to prevent nested session errors
42
+ const cleanEnv = { ...process.env };
43
+ delete cleanEnv.CLAUDECODE;
44
+ delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
45
+ // Build prompt with optional checkpoint reminder
46
+ let prompt = options.prompt;
47
+ const sessionState = options._sessionState;
48
+ if (sessionState) {
49
+ const needsCheckpoint = sessionState.toolUseCount >= CHECKPOINT_TOOL_THRESHOLD ||
50
+ sessionState.messageCount >= CHECKPOINT_MSG_THRESHOLD;
51
+ if (needsCheckpoint) {
52
+ prompt = `[CHECKPOINT] Du hast bereits ${sessionState.toolUseCount} Tool-Aufrufe und ${sessionState.messageCount} Nachrichten in dieser Session. Schreibe jetzt einen Checkpoint in deine Memory-Datei (docs/memory/YYYY-MM-DD.md) bevor du diese Anfrage bearbeitest.\n\n${prompt}`;
53
+ }
54
+ }
55
+ // Build system prompt
56
+ const systemPrompt = options.systemPrompt
57
+ ? `${options.systemPrompt}\n\n${botClaudeMd}`
58
+ : botClaudeMd;
59
+ try {
60
+ const claudePath = findClaudeBinary();
61
+ const q = query({
62
+ prompt,
63
+ options: {
64
+ cwd: options.workingDir || process.cwd(),
65
+ abortController: options.abortSignal
66
+ ? { signal: options.abortSignal }
67
+ : undefined,
68
+ resume: options.sessionId ?? undefined,
69
+ pathToClaudeCodeExecutable: claudePath,
70
+ permissionMode: "bypassPermissions",
71
+ allowDangerouslySkipPermissions: true,
72
+ env: cleanEnv,
73
+ settingSources: ["user", "project"],
74
+ allowedTools: [
75
+ "Read", "Write", "Edit", "Bash", "Glob", "Grep",
76
+ "WebSearch", "WebFetch", "Task",
77
+ ],
78
+ systemPrompt,
79
+ effort: (options.effort || "high"),
80
+ maxTurns: 50,
81
+ betas: ["context-1m-2025-08-07"],
82
+ },
83
+ });
84
+ let accumulatedText = "";
85
+ let capturedSessionId = options.sessionId || "";
86
+ let localToolUseCount = 0;
87
+ for await (const message of q) {
88
+ // System init — capture session ID
89
+ if (message.type === "system" && "subtype" in message && message.subtype === "init") {
90
+ const sysMsg = message;
91
+ capturedSessionId = sysMsg.session_id;
92
+ }
93
+ // Assistant message — text + tool use
94
+ if (message.type === "assistant") {
95
+ const assistantMsg = message;
96
+ capturedSessionId = assistantMsg.session_id;
97
+ if (assistantMsg.message?.content) {
98
+ for (const block of assistantMsg.message.content) {
99
+ if ("text" in block && block.text) {
100
+ accumulatedText += block.text;
101
+ yield {
102
+ type: "text",
103
+ text: accumulatedText,
104
+ delta: block.text,
105
+ sessionId: capturedSessionId,
106
+ };
107
+ }
108
+ if ("name" in block) {
109
+ localToolUseCount++;
110
+ yield {
111
+ type: "tool_use",
112
+ toolName: block.name,
113
+ sessionId: capturedSessionId,
114
+ };
115
+ }
116
+ }
117
+ }
118
+ }
119
+ // Result — done (extract full usage including cache tokens)
120
+ if (message.type === "result") {
121
+ const resultMsg = message;
122
+ const usage = "usage" in resultMsg ? resultMsg.usage : null;
123
+ const inputTok = usage
124
+ ? (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0)
125
+ : 0;
126
+ const outputTok = usage?.output_tokens || 0;
127
+ yield {
128
+ type: "done",
129
+ text: accumulatedText,
130
+ sessionId: resultMsg.session_id || capturedSessionId,
131
+ costUsd: "total_cost_usd" in resultMsg ? resultMsg.total_cost_usd : 0,
132
+ inputTokens: inputTok,
133
+ outputTokens: outputTok,
134
+ };
135
+ }
136
+ }
137
+ }
138
+ catch (err) {
139
+ if (err instanceof Error && err.message.includes("abort")) {
140
+ yield { type: "error", error: "Request aborted" };
141
+ }
142
+ else {
143
+ yield {
144
+ type: "error",
145
+ error: `Claude SDK error: ${err instanceof Error ? err.message : String(err)}`,
146
+ };
147
+ }
148
+ }
149
+ }
150
+ async isAvailable() {
151
+ // Check if native Claude binary exists and responds to --version.
152
+ // NOTE: Don't test with `claude -p "ping"` — CLI login and SDK auth
153
+ // are separate. The SDK uses its own auth via bypassPermissions.
154
+ try {
155
+ const claudePath = findClaudeBinary();
156
+ if (!claudePath)
157
+ return false;
158
+ const { execSync } = await import("child_process");
159
+ execSync(`"${claudePath}" --version`, { stdio: "pipe", timeout: 5000 });
160
+ return true;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
166
+ getInfo() {
167
+ return {
168
+ name: this.config.name,
169
+ model: this.config.model,
170
+ status: "✅ Agent SDK (CLI auth)",
171
+ };
172
+ }
173
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Codex CLI Provider
3
+ *
4
+ * Wraps OpenAI's Codex CLI as a provider, similar to how Claude SDK Provider
5
+ * wraps the Claude CLI. Uses `codex exec` for non-interactive completions.
6
+ *
7
+ * Requires: Codex CLI installed & logged in (`codex login --device-auth`)
8
+ */
9
+ import { spawn } from "child_process";
10
+ export class CodexCLIProvider {
11
+ config;
12
+ constructor(config) {
13
+ this.config = {
14
+ type: "codex-cli",
15
+ name: "Codex CLI (OpenAI)",
16
+ model: "gpt-5.4",
17
+ supportsTools: true,
18
+ supportsVision: false,
19
+ supportsStreaming: true,
20
+ ...config,
21
+ };
22
+ }
23
+ async *query(options) {
24
+ const args = [
25
+ "exec",
26
+ "--skip-git-repo-check",
27
+ "--ephemeral",
28
+ "-s", "read-only",
29
+ "-m", this.config.model,
30
+ ];
31
+ if (options.workingDir) {
32
+ args.push("-C", options.workingDir);
33
+ }
34
+ // Build the prompt with system context
35
+ let fullPrompt = options.prompt;
36
+ if (options.systemPrompt) {
37
+ fullPrompt = `${options.systemPrompt}\n\n${fullPrompt}`;
38
+ }
39
+ args.push(fullPrompt);
40
+ try {
41
+ const result = await this.execCodex(args, options.abortSignal);
42
+ if (result.trim()) {
43
+ yield {
44
+ type: "text",
45
+ text: result,
46
+ delta: result,
47
+ };
48
+ }
49
+ yield {
50
+ type: "done",
51
+ text: result,
52
+ };
53
+ }
54
+ catch (err) {
55
+ if (err instanceof Error && err.message.includes("abort")) {
56
+ yield { type: "error", error: "Request aborted" };
57
+ }
58
+ else {
59
+ yield {
60
+ type: "error",
61
+ error: `Codex CLI error: ${err instanceof Error ? err.message : String(err)}`,
62
+ };
63
+ }
64
+ }
65
+ }
66
+ async isAvailable() {
67
+ try {
68
+ const { execSync } = await import("child_process");
69
+ const output = execSync("codex login status 2>&1", {
70
+ stdio: "pipe",
71
+ timeout: 5000,
72
+ encoding: "utf-8",
73
+ });
74
+ return output.includes("Logged in");
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ getInfo() {
81
+ return {
82
+ name: this.config.name,
83
+ model: this.config.model,
84
+ status: "✅ Codex CLI (ChatGPT auth)",
85
+ };
86
+ }
87
+ execCodex(args, abortSignal) {
88
+ return new Promise((resolve, reject) => {
89
+ const proc = spawn("codex", args, {
90
+ stdio: ["pipe", "pipe", "pipe"],
91
+ timeout: 120_000,
92
+ env: { ...process.env, NO_COLOR: "1" },
93
+ });
94
+ let stdout = "";
95
+ let stderr = "";
96
+ proc.stdout.on("data", (data) => {
97
+ stdout += data.toString();
98
+ });
99
+ proc.stderr.on("data", (data) => {
100
+ stderr += data.toString();
101
+ });
102
+ proc.on("close", (code) => {
103
+ if (code === 0 || stdout.trim()) {
104
+ resolve(stdout.trim());
105
+ }
106
+ else {
107
+ reject(new Error(stderr.trim() || `codex exec exited with code ${code}`));
108
+ }
109
+ });
110
+ proc.on("error", (err) => {
111
+ reject(new Error(`Failed to spawn codex: ${err.message}`));
112
+ });
113
+ if (abortSignal) {
114
+ abortSignal.addEventListener("abort", () => {
115
+ proc.kill("SIGTERM");
116
+ reject(new Error("abort"));
117
+ });
118
+ }
119
+ });
120
+ }
121
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Provider system — public API
3
+ */
4
+ export { PROVIDER_PRESETS } from "./types.js";
5
+ export { ClaudeSDKProvider } from "./claude-sdk-provider.js";
6
+ export { OpenAICompatibleProvider } from "./openai-compatible.js";
7
+ export { ProviderRegistry, createRegistry } from "./registry.js";
@@ -0,0 +1,388 @@
1
+ /**
2
+ * OpenAI-Compatible Provider
3
+ *
4
+ * Works with: OpenAI, Groq, Gemini, NVIDIA NIM, Ollama, OpenRouter, LM Studio,
5
+ * and any other endpoint that implements the OpenAI Chat Completions API.
6
+ *
7
+ * Supports function calling (tool use) for providers that support it,
8
+ * giving non-Claude models full agent capabilities (shell, files, web).
9
+ */
10
+ import { AGENT_TOOLS, executeTool } from "./tool-executor.js";
11
+ import { updateRateLimits } from "../services/usage-tracker.js";
12
+ // Max tool call rounds to prevent infinite loops
13
+ const MAX_TOOL_ROUNDS = 10;
14
+ // Providers known to support function calling
15
+ const TOOL_CAPABLE_PROVIDERS = [
16
+ "api.openai.com",
17
+ "api.groq.com",
18
+ "generativelanguage.googleapis.com",
19
+ "openrouter.ai",
20
+ "integrate.api.nvidia.com",
21
+ "api.mistral.ai",
22
+ "api.together.xyz",
23
+ "api.fireworks.ai",
24
+ ];
25
+ export class OpenAICompatibleProvider {
26
+ config;
27
+ constructor(config) {
28
+ this.config = {
29
+ maxTokens: 4096,
30
+ temperature: 0.7,
31
+ supportsStreaming: true,
32
+ supportsVision: false,
33
+ supportsTools: false,
34
+ ...config,
35
+ };
36
+ }
37
+ /** Check if this provider's endpoint likely supports function calling */
38
+ supportsToolUse() {
39
+ if (this.config.supportsTools)
40
+ return true;
41
+ const url = this.config.baseUrl || "";
42
+ return TOOL_CAPABLE_PROVIDERS.some(p => url.includes(p));
43
+ }
44
+ async *query(options) {
45
+ const useTools = this.supportsToolUse();
46
+ if (useTools) {
47
+ // Tool-use loop: send messages, get response, execute tools, repeat
48
+ yield* this.queryWithTools(options);
49
+ }
50
+ else {
51
+ // Simple text-only query
52
+ yield* this.querySimple(options);
53
+ }
54
+ }
55
+ // ── Tool-Use Query Loop ─────────────────────────────────────────────────
56
+ async *queryWithTools(options) {
57
+ const messages = this.buildMessages(options);
58
+ let accumulatedText = "";
59
+ let totalCost = 0;
60
+ let totalInputTokens = 0;
61
+ let totalOutputTokens = 0;
62
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
63
+ // Non-streaming request for tool use (streaming + tools is complex)
64
+ const body = {
65
+ model: this.config.model,
66
+ messages,
67
+ max_tokens: this.config.maxTokens,
68
+ temperature: this.config.temperature,
69
+ tools: AGENT_TOOLS,
70
+ tool_choice: "auto",
71
+ };
72
+ const headers = this.buildHeaders();
73
+ const url = `${this.config.baseUrl}/chat/completions`;
74
+ let response;
75
+ try {
76
+ response = await fetch(url, {
77
+ method: "POST",
78
+ headers,
79
+ body: JSON.stringify(body),
80
+ signal: options.abortSignal,
81
+ });
82
+ }
83
+ catch (err) {
84
+ // If tool call fails, retry without tools
85
+ if (round === 0) {
86
+ yield* this.querySimple(options);
87
+ return;
88
+ }
89
+ yield { type: "error", error: `Network error: ${err instanceof Error ? err.message : err}` };
90
+ return;
91
+ }
92
+ if (!response.ok) {
93
+ const errorBody = await response.text().catch(() => "");
94
+ // If 400/422 (tools not supported), fall back to simple
95
+ if ((response.status === 400 || response.status === 422) && round === 0) {
96
+ yield* this.querySimple(options);
97
+ return;
98
+ }
99
+ yield { type: "error", error: `${this.config.name} error (${response.status}): ${errorBody}` };
100
+ return;
101
+ }
102
+ // Extract rate limits from response headers
103
+ const rlInfo = this.extractRateLimits(response);
104
+ const data = await response.json();
105
+ const choice = data.choices?.[0];
106
+ if (!choice) {
107
+ yield { type: "error", error: "No response from provider" };
108
+ return;
109
+ }
110
+ const msg = choice.message;
111
+ totalCost += this.estimateCostFromUsage(data.usage);
112
+ if (data.usage) {
113
+ totalInputTokens += data.usage.prompt_tokens || 0;
114
+ totalOutputTokens += data.usage.completion_tokens || 0;
115
+ }
116
+ // Check for tool calls
117
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
118
+ // Add assistant message with tool calls to history
119
+ messages.push(msg);
120
+ // Execute each tool call
121
+ for (const toolCall of msg.tool_calls) {
122
+ const fn = toolCall.function;
123
+ let args = {};
124
+ try {
125
+ args = JSON.parse(fn.arguments || "{}");
126
+ }
127
+ catch {
128
+ args = {};
129
+ }
130
+ // Notify about tool use
131
+ yield {
132
+ type: "tool_use",
133
+ toolName: fn.name,
134
+ toolInput: JSON.stringify(args).substring(0, 200),
135
+ };
136
+ // Execute the tool
137
+ const result = executeTool(fn.name, args, options.workingDir);
138
+ // Notify about result
139
+ yield {
140
+ type: "tool_result",
141
+ toolName: fn.name,
142
+ text: result.result.substring(0, 200),
143
+ };
144
+ // Add tool result to conversation
145
+ messages.push({
146
+ role: "tool",
147
+ tool_call_id: toolCall.id,
148
+ content: result.result,
149
+ });
150
+ }
151
+ // Continue loop — let the model process tool results
152
+ continue;
153
+ }
154
+ // No tool calls — this is the final text response
155
+ if (msg.content) {
156
+ accumulatedText += msg.content;
157
+ yield { type: "text", text: accumulatedText };
158
+ }
159
+ yield { type: "done", text: accumulatedText, costUsd: totalCost, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, rateLimits: rlInfo };
160
+ return;
161
+ }
162
+ // Max rounds reached
163
+ if (accumulatedText) {
164
+ yield { type: "done", text: accumulatedText, costUsd: totalCost, inputTokens: totalInputTokens, outputTokens: totalOutputTokens };
165
+ }
166
+ else {
167
+ yield { type: "error", error: "Max tool call rounds reached" };
168
+ }
169
+ }
170
+ // ── Simple Text-Only Query ──────────────────────────────────────────────
171
+ async *querySimple(options) {
172
+ const messages = this.buildMessages(options);
173
+ const body = {
174
+ model: this.config.model,
175
+ messages,
176
+ max_tokens: this.config.maxTokens,
177
+ temperature: this.config.temperature,
178
+ stream: true,
179
+ };
180
+ const headers = this.buildHeaders();
181
+ const url = `${this.config.baseUrl}/chat/completions`;
182
+ try {
183
+ const response = await fetch(url, {
184
+ method: "POST",
185
+ headers,
186
+ body: JSON.stringify(body),
187
+ signal: options.abortSignal,
188
+ });
189
+ if (!response.ok) {
190
+ const errorBody = await response.text().catch(() => "Unknown error");
191
+ yield {
192
+ type: "error",
193
+ error: `${this.config.name} API error (${response.status}): ${errorBody}`,
194
+ };
195
+ return;
196
+ }
197
+ // Extract rate limits from streaming response headers
198
+ const streamRlInfo = this.extractRateLimits(response);
199
+ if (!response.body) {
200
+ yield { type: "error", error: "No response body (streaming not supported?)" };
201
+ return;
202
+ }
203
+ let accumulatedText = "";
204
+ let streamInputTokens = 0;
205
+ let streamOutputTokens = 0;
206
+ const reader = response.body.getReader();
207
+ const decoder = new TextDecoder();
208
+ let buffer = "";
209
+ while (true) {
210
+ const { done, value } = await reader.read();
211
+ if (done)
212
+ break;
213
+ buffer += decoder.decode(value, { stream: true });
214
+ const lines = buffer.split("\n");
215
+ buffer = lines.pop() || "";
216
+ for (const line of lines) {
217
+ const trimmed = line.trim();
218
+ if (!trimmed || !trimmed.startsWith("data: "))
219
+ continue;
220
+ const data = trimmed.slice(6);
221
+ if (data === "[DONE]") {
222
+ yield { type: "done", text: accumulatedText };
223
+ return;
224
+ }
225
+ try {
226
+ const json = JSON.parse(data);
227
+ const delta = json.choices?.[0]?.delta;
228
+ if (delta?.content) {
229
+ accumulatedText += delta.content;
230
+ yield { type: "text", text: accumulatedText, delta: delta.content };
231
+ }
232
+ // Some providers include usage in the final streaming chunk
233
+ if (json.usage) {
234
+ streamInputTokens = json.usage.prompt_tokens || 0;
235
+ streamOutputTokens = json.usage.completion_tokens || 0;
236
+ }
237
+ if (json.choices?.[0]?.finish_reason) {
238
+ const estOut = streamOutputTokens || Math.ceil(accumulatedText.length / 4);
239
+ const estIn = streamInputTokens || Math.ceil((options.prompt?.length || 0) / 4);
240
+ yield {
241
+ type: "done",
242
+ text: accumulatedText,
243
+ costUsd: this.estimateCost(accumulatedText),
244
+ inputTokens: estIn,
245
+ outputTokens: estOut,
246
+ rateLimits: streamRlInfo,
247
+ };
248
+ return;
249
+ }
250
+ }
251
+ catch {
252
+ // Skip unparseable chunks
253
+ }
254
+ }
255
+ }
256
+ if (accumulatedText) {
257
+ const estOut = streamOutputTokens || Math.ceil(accumulatedText.length / 4);
258
+ const estIn = streamInputTokens || Math.ceil((options.prompt?.length || 0) / 4);
259
+ yield { type: "done", text: accumulatedText, costUsd: this.estimateCost(accumulatedText), inputTokens: estIn, outputTokens: estOut };
260
+ }
261
+ }
262
+ catch (err) {
263
+ if (err instanceof Error && err.name === "AbortError") {
264
+ yield { type: "error", error: "Request aborted" };
265
+ }
266
+ else {
267
+ yield {
268
+ type: "error",
269
+ error: `${this.config.name} error: ${err instanceof Error ? err.message : String(err)}`,
270
+ };
271
+ }
272
+ }
273
+ }
274
+ // ── Provider Interface ──────────────────────────────────────────────────
275
+ async isAvailable() {
276
+ if (this.config.baseUrl?.includes("localhost") || this.config.baseUrl?.includes("127.0.0.1")) {
277
+ try {
278
+ const res = await fetch(`${this.config.baseUrl}/models`, { signal: AbortSignal.timeout(3000) });
279
+ return res.ok;
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ }
285
+ return !!this.config.apiKey;
286
+ }
287
+ getInfo() {
288
+ const tools = this.supportsToolUse() ? " 🔧" : "";
289
+ return {
290
+ name: this.config.name + tools,
291
+ model: this.config.model,
292
+ status: this.config.apiKey ? "✅ configured" : "❌ no API key",
293
+ };
294
+ }
295
+ // ── Rate Limit Extraction ───────────────────────────────────────────────
296
+ extractRateLimits(response) {
297
+ // OpenAI / Groq style: x-ratelimit-*
298
+ const rl = response.headers.get("x-ratelimit-remaining-requests");
299
+ const tl = response.headers.get("x-ratelimit-remaining-tokens");
300
+ // Anthropic style: anthropic-ratelimit-*
301
+ const arl = response.headers.get("anthropic-ratelimit-requests-remaining");
302
+ const atl = response.headers.get("anthropic-ratelimit-tokens-remaining");
303
+ if (!rl && !tl && !arl && !atl)
304
+ return undefined;
305
+ const limits = {};
306
+ if (rl || arl) {
307
+ limits.requestsRemaining = parseInt(rl || arl || "0");
308
+ limits.requestsLimit = parseInt(response.headers.get("x-ratelimit-limit-requests")
309
+ || response.headers.get("anthropic-ratelimit-requests-limit")
310
+ || "0") || undefined;
311
+ limits.requestsReset =
312
+ response.headers.get("x-ratelimit-reset-requests")
313
+ || response.headers.get("anthropic-ratelimit-requests-reset")
314
+ || undefined;
315
+ }
316
+ if (tl || atl) {
317
+ limits.tokensRemaining = parseInt(tl || atl || "0");
318
+ limits.tokensLimit = parseInt(response.headers.get("x-ratelimit-limit-tokens")
319
+ || response.headers.get("anthropic-ratelimit-tokens-limit")
320
+ || "0") || undefined;
321
+ limits.tokensReset =
322
+ response.headers.get("x-ratelimit-reset-tokens")
323
+ || response.headers.get("anthropic-ratelimit-tokens-reset")
324
+ || undefined;
325
+ }
326
+ // Persist to usage tracker
327
+ const providerKey = this.config.name;
328
+ updateRateLimits(providerKey, {
329
+ ...limits,
330
+ });
331
+ return limits;
332
+ }
333
+ // ── Helpers ─────────────────────────────────────────────────────────────
334
+ buildHeaders() {
335
+ const headers = {
336
+ "Content-Type": "application/json",
337
+ };
338
+ if (this.config.apiKey) {
339
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
340
+ }
341
+ if (this.config.baseUrl?.includes("openrouter.ai")) {
342
+ headers["HTTP-Referer"] = "https://github.com/alvbln/Alvin-Bot";
343
+ headers["X-Title"] = "Alvin Bot";
344
+ }
345
+ return headers;
346
+ }
347
+ buildMessages(options) {
348
+ const messages = [];
349
+ if (options.systemPrompt) {
350
+ messages.push({ role: "system", content: options.systemPrompt });
351
+ }
352
+ if (options.history && options.history.length > 0) {
353
+ for (const msg of options.history) {
354
+ if (this.config.supportsVision && msg.images && msg.images.length > 0) {
355
+ const content = [
356
+ { type: "text", text: msg.content },
357
+ ];
358
+ for (const img of msg.images) {
359
+ content.push({
360
+ type: "image_url",
361
+ image_url: { url: img.startsWith("http") ? img : `data:image/jpeg;base64,${img}` },
362
+ });
363
+ }
364
+ messages.push({ role: msg.role, content });
365
+ }
366
+ else {
367
+ messages.push({ role: msg.role, content: msg.content });
368
+ }
369
+ }
370
+ }
371
+ messages.push({ role: "user", content: options.prompt });
372
+ return messages;
373
+ }
374
+ estimateCost(text) {
375
+ const tokens = text.length / 4;
376
+ const costs = {
377
+ "gpt-4o": 0.01, "gpt-4o-mini": 0.0003,
378
+ "gemini-2.5-pro": 0.005, "gemini-2.5-flash": 0.0005,
379
+ };
380
+ return (tokens / 1000) * (costs[this.config.model] || 0.001);
381
+ }
382
+ estimateCostFromUsage(usage) {
383
+ if (!usage)
384
+ return 0;
385
+ const total = (usage.prompt_tokens || 0) + (usage.completion_tokens || 0);
386
+ return (total / 1000) * 0.001; // rough estimate
387
+ }
388
+ }