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.
- package/.env.example +43 -0
- package/BACKLOG.md +223 -0
- package/CHANGELOG.md +63 -0
- package/CLAUDE.example.md +152 -0
- package/CODE_OF_CONDUCT.md +52 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/SECURITY.md +38 -0
- package/SOUL.example.md +60 -0
- package/TOOLS.example.md +42 -0
- package/alvin-bot.config.example.json +24 -0
- package/bin/cli.js +1088 -0
- package/dist/.metadata_never_index +0 -0
- package/dist/claude.js +102 -0
- package/dist/config.js +65 -0
- package/dist/engine.js +90 -0
- package/dist/find-claude-binary.js +98 -0
- package/dist/handlers/commands.js +1489 -0
- package/dist/handlers/document.js +187 -0
- package/dist/handlers/message.js +200 -0
- package/dist/handlers/photo.js +154 -0
- package/dist/handlers/platform-message.js +275 -0
- package/dist/handlers/video.js +237 -0
- package/dist/handlers/voice.js +148 -0
- package/dist/i18n.js +299 -0
- package/dist/index.js +442 -0
- package/dist/init-data-dir.js +81 -0
- package/dist/middleware/auth.js +215 -0
- package/dist/migrate.js +139 -0
- package/dist/paths.js +87 -0
- package/dist/platforms/discord.js +161 -0
- package/dist/platforms/index.js +130 -0
- package/dist/platforms/signal.js +205 -0
- package/dist/platforms/slack.js +318 -0
- package/dist/platforms/telegram.js +111 -0
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/whatsapp.js +648 -0
- package/dist/providers/claude-sdk-provider.js +173 -0
- package/dist/providers/codex-cli-provider.js +121 -0
- package/dist/providers/index.js +7 -0
- package/dist/providers/openai-compatible.js +388 -0
- package/dist/providers/registry.js +209 -0
- package/dist/providers/tool-executor.js +450 -0
- package/dist/providers/types.js +205 -0
- package/dist/services/access.js +144 -0
- package/dist/services/asset-index.js +230 -0
- package/dist/services/browser-manager.js +161 -0
- package/dist/services/browser.js +121 -0
- package/dist/services/compaction.js +129 -0
- package/dist/services/cron.js +462 -0
- package/dist/services/custom-tools.js +317 -0
- package/dist/services/delivery-queue.js +154 -0
- package/dist/services/elevenlabs.js +58 -0
- package/dist/services/embeddings.js +386 -0
- package/dist/services/exec-guard.js +46 -0
- package/dist/services/fallback-order.js +151 -0
- package/dist/services/heartbeat.js +192 -0
- package/dist/services/hooks.js +44 -0
- package/dist/services/imagegen.js +72 -0
- package/dist/services/language-detect.js +144 -0
- package/dist/services/markdown.js +63 -0
- package/dist/services/mcp.js +252 -0
- package/dist/services/memory.js +133 -0
- package/dist/services/personality.js +227 -0
- package/dist/services/plugins.js +171 -0
- package/dist/services/reminders.js +97 -0
- package/dist/services/restart.js +48 -0
- package/dist/services/security-audit.js +66 -0
- package/dist/services/self-search.js +129 -0
- package/dist/services/session.js +93 -0
- package/dist/services/skills.js +287 -0
- package/dist/services/standing-orders.js +29 -0
- package/dist/services/subagents.js +142 -0
- package/dist/services/sudo.js +243 -0
- package/dist/services/telegram.js +113 -0
- package/dist/services/tool-discovery.js +214 -0
- package/dist/services/usage-tracker.js +137 -0
- package/dist/services/users.js +199 -0
- package/dist/services/voice.js +95 -0
- package/dist/tui/index.js +507 -0
- package/dist/web/canvas.js +30 -0
- package/dist/web/doctor-api.js +606 -0
- package/dist/web/openai-compat.js +252 -0
- package/dist/web/server.js +1351 -0
- package/dist/web/setup-api.js +1078 -0
- package/docs/mcp.example.json +16 -0
- package/docs/screenshots/00-Login.png +0 -0
- package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
- package/docs/screenshots/02-Chat.png +0 -0
- package/docs/screenshots/03-Dashboard-Overview.png +0 -0
- package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
- package/docs/screenshots/05-Personality-Editor.png +0 -0
- package/docs/screenshots/06-Memory-Manager.png +0 -0
- package/docs/screenshots/07-Active-Sessions.png +0 -0
- package/docs/screenshots/08-File-Browser.png +0 -0
- package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
- package/docs/screenshots/10-Custom-Tools.png +0 -0
- package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
- package/docs/screenshots/12-Messaging-Platforms.png +0 -0
- package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
- package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
- package/docs/screenshots/13-User-Management.png +0 -0
- package/docs/screenshots/14-Web-Terminal.png +0 -0
- package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
- package/docs/screenshots/16-Settings-and-Env.png +0 -0
- package/docs/screenshots/TG-commands.png +0 -0
- package/docs/screenshots/TG.png +0 -0
- package/docs/screenshots/_Mac-Installer.png +0 -0
- package/docs/tools.example.json +33 -0
- package/install.sh +165 -0
- package/package.json +190 -0
- package/plugins/calendar/index.js +270 -0
- package/plugins/email/index.js +231 -0
- package/plugins/finance/index.js +254 -0
- package/plugins/notes/index.js +227 -0
- package/plugins/smarthome/index.js +230 -0
- package/plugins/weather/index.js +122 -0
- package/skills/apple-notes/SKILL.md +31 -0
- package/skills/browse/SKILL.md +136 -0
- package/skills/code-project/SKILL.md +43 -0
- package/skills/data-analysis/SKILL.md +39 -0
- package/skills/document-creation/SKILL.md +48 -0
- package/skills/email-summary/SKILL.md +46 -0
- package/skills/github/SKILL.md +42 -0
- package/skills/summarize/SKILL.md +28 -0
- package/skills/system-admin/SKILL.md +39 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/web-research/SKILL.md +35 -0
- package/web/public/canvas.html +52 -0
- package/web/public/css/style.css +555 -0
- package/web/public/index.html +189 -0
- package/web/public/js/app.js +3102 -0
- package/web/public/js/i18n.js +1048 -0
- package/web/public/js/icons.js +104 -0
- package/web/public/login.html +48 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sudo / Elevated Access Service
|
|
3
|
+
*
|
|
4
|
+
* Manages superadmin privileges for Alvin Bot on the host system.
|
|
5
|
+
* Password is stored securely in the macOS Keychain (or encrypted file on Linux).
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Store/retrieve sudo password securely
|
|
9
|
+
* - Execute commands with sudo
|
|
10
|
+
* - Grant/revoke elevated access
|
|
11
|
+
* - OS dialog handling (Accessibility, Full Disk Access, etc.)
|
|
12
|
+
* - Status check (is sudo configured? does it work?)
|
|
13
|
+
*/
|
|
14
|
+
import { execSync, spawn } from "child_process";
|
|
15
|
+
import os from "os";
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import crypto from "crypto";
|
|
18
|
+
import { resolve } from "path";
|
|
19
|
+
import { SUDO_ENC_FILE as ENCRYPTED_PASS_FILE, SUDO_KEY_FILE as ENCRYPTION_KEY_FILE, RUNTIME_DIR } from "../paths.js";
|
|
20
|
+
const PLATFORM = os.platform();
|
|
21
|
+
const KEYCHAIN_SERVICE = "alvin-bot-sudo";
|
|
22
|
+
const KEYCHAIN_ACCOUNT = "alvin-bot";
|
|
23
|
+
// ── Password Storage ────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Store sudo password securely.
|
|
26
|
+
* macOS: Uses Keychain. Linux: Uses encrypted file.
|
|
27
|
+
*/
|
|
28
|
+
export function storePassword(password) {
|
|
29
|
+
try {
|
|
30
|
+
if (PLATFORM === "darwin") {
|
|
31
|
+
// macOS: Store in Keychain
|
|
32
|
+
// First try to delete existing entry
|
|
33
|
+
try {
|
|
34
|
+
execSync(`security delete-generic-password -a "${KEYCHAIN_ACCOUNT}" -s "${KEYCHAIN_SERVICE}" 2>/dev/null`, { stdio: "pipe" });
|
|
35
|
+
}
|
|
36
|
+
catch { /* didn't exist */ }
|
|
37
|
+
execSync(`security add-generic-password -a "${KEYCHAIN_ACCOUNT}" -s "${KEYCHAIN_SERVICE}" -w "${password.replace(/"/g, '\\"')}" -U`, { stdio: "pipe", timeout: 5000 });
|
|
38
|
+
return { ok: true, method: "macOS Keychain" };
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Linux/other: Encrypted file
|
|
42
|
+
const key = crypto.randomBytes(32);
|
|
43
|
+
const iv = crypto.randomBytes(16);
|
|
44
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
45
|
+
let encrypted = cipher.update(password, "utf-8", "hex");
|
|
46
|
+
encrypted += cipher.final("hex");
|
|
47
|
+
const authTag = cipher.getAuthTag();
|
|
48
|
+
if (!fs.existsSync(RUNTIME_DIR))
|
|
49
|
+
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
50
|
+
// Store key separately (basic separation of concerns)
|
|
51
|
+
fs.writeFileSync(ENCRYPTION_KEY_FILE, Buffer.concat([key, iv]).toString("hex"), { mode: 0o600 });
|
|
52
|
+
fs.writeFileSync(ENCRYPTED_PASS_FILE, encrypted + ":" + authTag.toString("hex"), { mode: 0o600 });
|
|
53
|
+
return { ok: true, method: "Encrypted file" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return { ok: false, method: "none", error: err instanceof Error ? err.message : String(err) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Retrieve stored sudo password.
|
|
62
|
+
*/
|
|
63
|
+
export function retrievePassword() {
|
|
64
|
+
try {
|
|
65
|
+
if (PLATFORM === "darwin") {
|
|
66
|
+
const pw = execSync(`security find-generic-password -a "${KEYCHAIN_ACCOUNT}" -s "${KEYCHAIN_SERVICE}" -w`, { stdio: "pipe", timeout: 5000 }).toString().trim();
|
|
67
|
+
return pw || null;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
if (!fs.existsSync(ENCRYPTED_PASS_FILE) || !fs.existsSync(ENCRYPTION_KEY_FILE))
|
|
71
|
+
return null;
|
|
72
|
+
const keyData = Buffer.from(fs.readFileSync(ENCRYPTION_KEY_FILE, "utf-8"), "hex");
|
|
73
|
+
const key = keyData.subarray(0, 32);
|
|
74
|
+
const iv = keyData.subarray(32, 48);
|
|
75
|
+
const [encrypted, authTagHex] = fs.readFileSync(ENCRYPTED_PASS_FILE, "utf-8").split(":");
|
|
76
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
77
|
+
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
|
78
|
+
let decrypted = decipher.update(encrypted, "hex", "utf-8");
|
|
79
|
+
decrypted += decipher.final("utf-8");
|
|
80
|
+
return decrypted;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Delete stored password (revoke sudo access).
|
|
89
|
+
*/
|
|
90
|
+
export function revokePassword() {
|
|
91
|
+
try {
|
|
92
|
+
if (PLATFORM === "darwin") {
|
|
93
|
+
execSync(`security delete-generic-password -a "${KEYCHAIN_ACCOUNT}" -s "${KEYCHAIN_SERVICE}"`, { stdio: "pipe", timeout: 5000 });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
if (fs.existsSync(ENCRYPTED_PASS_FILE))
|
|
97
|
+
fs.unlinkSync(ENCRYPTED_PASS_FILE);
|
|
98
|
+
if (fs.existsSync(ENCRYPTION_KEY_FILE))
|
|
99
|
+
fs.unlinkSync(ENCRYPTION_KEY_FILE);
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ── Sudo Execution ──────────────────────────────────────
|
|
108
|
+
/**
|
|
109
|
+
* Execute a command with sudo.
|
|
110
|
+
* Returns { ok, output, error }.
|
|
111
|
+
*/
|
|
112
|
+
export async function sudoExec(command, timeoutMs = 30000) {
|
|
113
|
+
const password = retrievePassword();
|
|
114
|
+
if (!password) {
|
|
115
|
+
return { ok: false, output: "", error: "No sudo password stored. Please set it up first (/setup sudo or Settings → Sudo)." };
|
|
116
|
+
}
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const child = spawn("sudo", ["-S", "bash", "-c", command], {
|
|
119
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
120
|
+
timeout: timeoutMs,
|
|
121
|
+
env: { ...process.env, PATH: process.env.PATH + ":/opt/homebrew/bin:/usr/local/bin" },
|
|
122
|
+
});
|
|
123
|
+
let stdout = "";
|
|
124
|
+
let stderr = "";
|
|
125
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
126
|
+
child.stderr.on("data", (d) => {
|
|
127
|
+
const text = d.toString();
|
|
128
|
+
// sudo prompts on stderr — don't include the prompt in error output
|
|
129
|
+
if (!text.includes("Password:") && !text.includes("password for")) {
|
|
130
|
+
stderr += text;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// Send password to sudo's stdin
|
|
134
|
+
child.stdin.write(password + "\n");
|
|
135
|
+
child.stdin.end();
|
|
136
|
+
child.on("close", (code) => {
|
|
137
|
+
if (code === 0) {
|
|
138
|
+
resolve({ ok: true, output: stdout.trim() });
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// Check for wrong password
|
|
142
|
+
if (stderr.includes("incorrect password") || stderr.includes("Sorry, try again")) {
|
|
143
|
+
resolve({ ok: false, output: "", error: "Wrong sudo password! Please reconfigure." });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
resolve({ ok: false, output: stdout.trim(), error: stderr.trim() || `Exit code: ${code}` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
child.on("error", (err) => {
|
|
151
|
+
resolve({ ok: false, output: "", error: err.message });
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// ── macOS Permission Dialogs ────────────────────────────
|
|
156
|
+
/**
|
|
157
|
+
* Request admin privileges via macOS dialog (osascript).
|
|
158
|
+
* Shows the native macOS password prompt.
|
|
159
|
+
*/
|
|
160
|
+
export async function requestAdminViaDialog(reason) {
|
|
161
|
+
if (PLATFORM !== "darwin") {
|
|
162
|
+
return { ok: false, error: "Only available on macOS" };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
execSync(`osascript -e 'do shell script "echo ok" with administrator privileges with prompt "${reason.replace(/"/g, '\\"')}"'`, { stdio: "pipe", timeout: 60000 });
|
|
166
|
+
return { ok: true };
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Open System Settings to a specific pane (macOS).
|
|
174
|
+
*/
|
|
175
|
+
export function openSystemSettings(pane) {
|
|
176
|
+
if (PLATFORM !== "darwin")
|
|
177
|
+
return false;
|
|
178
|
+
try {
|
|
179
|
+
const paneUrls = {
|
|
180
|
+
"accessibility": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
|
|
181
|
+
"full-disk-access": "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles",
|
|
182
|
+
"automation": "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation",
|
|
183
|
+
"security": "x-apple.systempreferences:com.apple.preference.security",
|
|
184
|
+
"privacy": "x-apple.systempreferences:com.apple.preference.security?Privacy",
|
|
185
|
+
};
|
|
186
|
+
const url = paneUrls[pane] || `x-apple.systempreferences:${pane}`;
|
|
187
|
+
execSync(`open "${url}"`, { stdio: "pipe" });
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get comprehensive sudo status.
|
|
196
|
+
*/
|
|
197
|
+
export async function getSudoStatus() {
|
|
198
|
+
const configured = retrievePassword() !== null;
|
|
199
|
+
const user = os.userInfo().username;
|
|
200
|
+
let verified = false;
|
|
201
|
+
if (configured) {
|
|
202
|
+
const test = await sudoExec("echo SUDO_OK", 10000);
|
|
203
|
+
verified = test.ok && test.output.includes("SUDO_OK");
|
|
204
|
+
}
|
|
205
|
+
// Check macOS permissions
|
|
206
|
+
let accessibility = null;
|
|
207
|
+
let fullDiskAccess = null;
|
|
208
|
+
if (PLATFORM === "darwin") {
|
|
209
|
+
try {
|
|
210
|
+
// Check Accessibility (approximate — try to use cliclick)
|
|
211
|
+
execSync("cliclick p:.", { stdio: "pipe", timeout: 3000 });
|
|
212
|
+
accessibility = true;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
accessibility = false;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
// Check Full Disk Access (try to read a protected file)
|
|
219
|
+
fs.accessSync(resolve(os.homedir(), "Library/Mail"), fs.constants.R_OK);
|
|
220
|
+
fullDiskAccess = true;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
fullDiskAccess = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
configured,
|
|
228
|
+
storageMethod: PLATFORM === "darwin" ? "macOS Keychain" : "Encrypted file",
|
|
229
|
+
verified,
|
|
230
|
+
platform: PLATFORM,
|
|
231
|
+
user,
|
|
232
|
+
permissions: { accessibility, fullDiskAccess },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Verify the stored password works.
|
|
237
|
+
*/
|
|
238
|
+
export async function verifyPassword() {
|
|
239
|
+
const result = await sudoExec("echo VERIFIED", 10000);
|
|
240
|
+
if (result.ok)
|
|
241
|
+
return { ok: true };
|
|
242
|
+
return { ok: false, error: result.error };
|
|
243
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { sanitizeTelegramMarkdown } from "./markdown.js";
|
|
3
|
+
export class TelegramStreamer {
|
|
4
|
+
messageId = null;
|
|
5
|
+
chatId;
|
|
6
|
+
api;
|
|
7
|
+
replyTo;
|
|
8
|
+
lastEditTime = 0;
|
|
9
|
+
pendingText = null;
|
|
10
|
+
editTimer = null;
|
|
11
|
+
lastSentText = "";
|
|
12
|
+
constructor(chatId, api, replyToMessageId) {
|
|
13
|
+
this.chatId = chatId;
|
|
14
|
+
this.api = api;
|
|
15
|
+
this.replyTo = replyToMessageId;
|
|
16
|
+
}
|
|
17
|
+
async update(fullText) {
|
|
18
|
+
const displayText = sanitizeTelegramMarkdown(this.truncate(fullText) || "...");
|
|
19
|
+
if (!this.messageId) {
|
|
20
|
+
const opts = { parse_mode: "Markdown" };
|
|
21
|
+
if (this.replyTo)
|
|
22
|
+
opts.reply_to_message_id = this.replyTo;
|
|
23
|
+
const msg = await this.api.sendMessage(this.chatId, displayText, opts).catch(() => this.api.sendMessage(this.chatId, displayText, this.replyTo ? { reply_to_message_id: this.replyTo } : undefined));
|
|
24
|
+
this.messageId = msg.message_id;
|
|
25
|
+
this.lastSentText = displayText;
|
|
26
|
+
this.lastEditTime = Date.now();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (displayText === this.lastSentText)
|
|
30
|
+
return;
|
|
31
|
+
this.pendingText = displayText;
|
|
32
|
+
if (!this.editTimer) {
|
|
33
|
+
const elapsed = Date.now() - this.lastEditTime;
|
|
34
|
+
const delay = Math.max(0, config.streamThrottleMs - elapsed);
|
|
35
|
+
this.editTimer = setTimeout(() => this.flush(), delay);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async flush() {
|
|
39
|
+
this.editTimer = null;
|
|
40
|
+
if (this.pendingText && this.messageId && this.pendingText !== this.lastSentText) {
|
|
41
|
+
try {
|
|
42
|
+
await this.api.editMessageText(this.chatId, this.messageId, this.pendingText, {
|
|
43
|
+
parse_mode: "Markdown",
|
|
44
|
+
}).catch(() => this.api.editMessageText(this.chatId, this.messageId, this.pendingText));
|
|
45
|
+
this.lastSentText = this.pendingText;
|
|
46
|
+
this.lastEditTime = Date.now();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Ignore edit failures (message unchanged, etc.)
|
|
50
|
+
}
|
|
51
|
+
this.pendingText = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async finalize(fullText) {
|
|
55
|
+
if (this.editTimer) {
|
|
56
|
+
clearTimeout(this.editTimer);
|
|
57
|
+
this.editTimer = null;
|
|
58
|
+
}
|
|
59
|
+
if (!fullText || fullText.trim().length === 0) {
|
|
60
|
+
if (!this.messageId) {
|
|
61
|
+
await this.api.sendMessage(this.chatId, "(Keine Antwort)");
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Sanitize final text
|
|
66
|
+
const safeText = sanitizeTelegramMarkdown(fullText);
|
|
67
|
+
// If text fits in one message, just update the existing one
|
|
68
|
+
if (safeText.length <= config.telegramMaxLength && this.messageId) {
|
|
69
|
+
if (safeText !== this.lastSentText) {
|
|
70
|
+
await this.api.editMessageText(this.chatId, this.messageId, safeText, {
|
|
71
|
+
parse_mode: "Markdown",
|
|
72
|
+
}).catch(() => this.api.editMessageText(this.chatId, this.messageId, safeText));
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Long text: delete streaming message and send chunked
|
|
77
|
+
if (this.messageId) {
|
|
78
|
+
await this.api.deleteMessage(this.chatId, this.messageId).catch(() => { });
|
|
79
|
+
}
|
|
80
|
+
const chunks = this.splitText(safeText);
|
|
81
|
+
for (const chunk of chunks) {
|
|
82
|
+
await this.api.sendMessage(this.chatId, chunk, {
|
|
83
|
+
parse_mode: "Markdown",
|
|
84
|
+
}).catch(() => this.api.sendMessage(this.chatId, chunk));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
truncate(text) {
|
|
88
|
+
if (text.length <= config.telegramMaxLength)
|
|
89
|
+
return text;
|
|
90
|
+
return "...\n" + text.slice(-(config.telegramMaxLength - 10));
|
|
91
|
+
}
|
|
92
|
+
splitText(text) {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let remaining = text;
|
|
95
|
+
const maxLen = config.telegramMaxLength;
|
|
96
|
+
while (remaining.length > 0) {
|
|
97
|
+
if (remaining.length <= maxLen) {
|
|
98
|
+
chunks.push(remaining);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
let splitAt = remaining.lastIndexOf("\n", maxLen);
|
|
102
|
+
if (splitAt < maxLen * 0.5) {
|
|
103
|
+
splitAt = remaining.lastIndexOf(" ", maxLen);
|
|
104
|
+
}
|
|
105
|
+
if (splitAt < maxLen * 0.3) {
|
|
106
|
+
splitAt = maxLen;
|
|
107
|
+
}
|
|
108
|
+
chunks.push(remaining.substring(0, splitAt));
|
|
109
|
+
remaining = remaining.substring(splitAt).trimStart();
|
|
110
|
+
}
|
|
111
|
+
return chunks;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Discovery Service
|
|
3
|
+
*
|
|
4
|
+
* Scans the system at startup for available CLI tools, configured plugins,
|
|
5
|
+
* and custom tools. Injects a summary into the system prompt so the AI
|
|
6
|
+
* knows exactly what it can use — instead of guessing or saying "I'd need X".
|
|
7
|
+
*
|
|
8
|
+
* Philosophy: An agent that doesn't know its own capabilities is useless.
|
|
9
|
+
*/
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
import { TOOLS_MD, TOOLS_JSON, PLUGINS_DIR } from "../paths.js";
|
|
14
|
+
// Cache the report — only scan once per process lifetime
|
|
15
|
+
let cachedReport = null;
|
|
16
|
+
/**
|
|
17
|
+
* CLI tools to probe for. Grouped by category.
|
|
18
|
+
* Each entry: [binary, description]
|
|
19
|
+
*/
|
|
20
|
+
const TOOL_PROBES = {
|
|
21
|
+
"📧 Email & Communication": [
|
|
22
|
+
["himalaya", "Email CLI (IMAP/SMTP) — list, read, send, search emails"],
|
|
23
|
+
["wacli", "WhatsApp CLI — send messages, search history"],
|
|
24
|
+
["signal-cli", "Signal messenger CLI"],
|
|
25
|
+
],
|
|
26
|
+
"🌐 Web & Network": [
|
|
27
|
+
["curl", "HTTP requests"],
|
|
28
|
+
["wget", "File downloads"],
|
|
29
|
+
["httpie", "Modern HTTP client"],
|
|
30
|
+
["jq", "JSON processor"],
|
|
31
|
+
],
|
|
32
|
+
"📄 Document Processing": [
|
|
33
|
+
["pandoc", "Universal document converter (Markdown, HTML, PDF, DOCX, LaTeX)"],
|
|
34
|
+
["pdftotext", "Extract text from PDFs"],
|
|
35
|
+
["pdfinfo", "PDF metadata"],
|
|
36
|
+
["pdftoppm", "PDF to images"],
|
|
37
|
+
["gs", "Ghostscript — PDF manipulation (merge, split, compress)"],
|
|
38
|
+
["wkhtmltopdf", "HTML to PDF renderer"],
|
|
39
|
+
["libreoffice", "Office document conversion"],
|
|
40
|
+
],
|
|
41
|
+
"🎨 Image Processing": [
|
|
42
|
+
["sips", "macOS image processing (resize, convert, rotate)"],
|
|
43
|
+
["magick", "ImageMagick — advanced image manipulation"],
|
|
44
|
+
["convert", "ImageMagick convert (legacy)"],
|
|
45
|
+
["ffmpeg", "Audio/Video/Image swiss army knife"],
|
|
46
|
+
],
|
|
47
|
+
"🎬 Audio & Video": [
|
|
48
|
+
["ffmpeg", "Audio/Video conversion, extraction, streaming"],
|
|
49
|
+
["ffprobe", "Media file analysis (duration, codecs, bitrate)"],
|
|
50
|
+
["yt-dlp", "Download videos from YouTube and 1000+ sites"],
|
|
51
|
+
["whisper", "OpenAI Whisper — local speech-to-text"],
|
|
52
|
+
],
|
|
53
|
+
"💻 Development": [
|
|
54
|
+
["node", "Node.js runtime"],
|
|
55
|
+
["npm", "Node package manager"],
|
|
56
|
+
["npx", "Node package executor"],
|
|
57
|
+
["python3", "Python 3"],
|
|
58
|
+
["pip3", "Python package manager"],
|
|
59
|
+
["git", "Version control"],
|
|
60
|
+
["gh", "GitHub CLI — issues, PRs, repos, actions"],
|
|
61
|
+
["docker", "Container runtime"],
|
|
62
|
+
["pm2", "Process manager"],
|
|
63
|
+
],
|
|
64
|
+
"🖥️ macOS Automation": [
|
|
65
|
+
["osascript", "AppleScript / JXA automation"],
|
|
66
|
+
["cliclick", "Mouse/keyboard automation (click, type, key press)"],
|
|
67
|
+
["screencapture", "Take screenshots"],
|
|
68
|
+
["brightness", "Display brightness control"],
|
|
69
|
+
["blueutil", "Bluetooth control"],
|
|
70
|
+
["SwitchAudioSource", "Audio device switching"],
|
|
71
|
+
["mas", "Mac App Store CLI"],
|
|
72
|
+
["pbcopy", "Copy to clipboard"],
|
|
73
|
+
["pbpaste", "Paste from clipboard"],
|
|
74
|
+
],
|
|
75
|
+
"📊 Data & Analysis": [
|
|
76
|
+
["sqlite3", "SQLite database CLI"],
|
|
77
|
+
["psql", "PostgreSQL client"],
|
|
78
|
+
["mysql", "MySQL client"],
|
|
79
|
+
["csvtool", "CSV processing"],
|
|
80
|
+
["mlr", "Miller — CSV/JSON/TSV data processor"],
|
|
81
|
+
],
|
|
82
|
+
"🔧 System": [
|
|
83
|
+
["ssh", "Remote shell access"],
|
|
84
|
+
["sshpass", "SSH with password (non-interactive)"],
|
|
85
|
+
["rsync", "Fast file sync/transfer"],
|
|
86
|
+
["trash", "Safe file deletion (recoverable)"],
|
|
87
|
+
["htop", "System monitor"],
|
|
88
|
+
["lsof", "Open file/port inspector"],
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
/** Check if a binary exists on the system */
|
|
92
|
+
function whichTool(name) {
|
|
93
|
+
try {
|
|
94
|
+
return execSync(`which ${name} 2>/dev/null`, { encoding: "utf-8", timeout: 3000 }).trim() || null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Load custom tool names from TOOLS.md (with legacy tools.json fallback) */
|
|
101
|
+
function loadCustomTools() {
|
|
102
|
+
// Prefer TOOLS.md — extract tool names from ## headings
|
|
103
|
+
if (fs.existsSync(TOOLS_MD)) {
|
|
104
|
+
try {
|
|
105
|
+
const content = fs.readFileSync(TOOLS_MD, "utf-8");
|
|
106
|
+
const names = [];
|
|
107
|
+
for (const match of content.matchAll(/^## (.+)$/gm)) {
|
|
108
|
+
const name = match[1].trim().replace(/\s+/g, "_").toLowerCase();
|
|
109
|
+
if (name)
|
|
110
|
+
names.push(name);
|
|
111
|
+
}
|
|
112
|
+
if (names.length > 0)
|
|
113
|
+
return names;
|
|
114
|
+
}
|
|
115
|
+
catch { /* fall through */ }
|
|
116
|
+
}
|
|
117
|
+
// Legacy fallback: docs/tools.json
|
|
118
|
+
try {
|
|
119
|
+
const data = JSON.parse(fs.readFileSync(TOOLS_JSON, "utf-8"));
|
|
120
|
+
const tools = data.tools || data.items || (Array.isArray(data) ? data : []);
|
|
121
|
+
return tools.map((t) => t.name || t.id || "unknown");
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Get list of loaded plugins (read plugin directory) */
|
|
128
|
+
function discoverPlugins() {
|
|
129
|
+
try {
|
|
130
|
+
return fs.readdirSync(PLUGINS_DIR)
|
|
131
|
+
.filter(d => fs.statSync(resolve(PLUGINS_DIR, d)).isDirectory())
|
|
132
|
+
.filter(d => fs.existsSync(resolve(PLUGINS_DIR, d, "index.js")));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Scan the system for available tools. Cached after first call.
|
|
140
|
+
*/
|
|
141
|
+
export function discoverTools(forceRescan = false) {
|
|
142
|
+
if (cachedReport && !forceRescan)
|
|
143
|
+
return cachedReport;
|
|
144
|
+
const cliTools = [];
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
for (const [category, probes] of Object.entries(TOOL_PROBES)) {
|
|
147
|
+
for (const [name, description] of probes) {
|
|
148
|
+
if (seen.has(name))
|
|
149
|
+
continue;
|
|
150
|
+
seen.add(name);
|
|
151
|
+
const path = whichTool(name);
|
|
152
|
+
if (path) {
|
|
153
|
+
cliTools.push({ name, path, description, category });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const customTools = loadCustomTools();
|
|
158
|
+
const plugins = discoverPlugins();
|
|
159
|
+
// Build human-readable summary for the system prompt
|
|
160
|
+
const lines = ["## Available Tools & Capabilities\n"];
|
|
161
|
+
lines.push("The following tools are installed and ready to use on this system.\n");
|
|
162
|
+
lines.push("**IMPORTANT:** Use these tools DIRECTLY. Do NOT say 'I would need X' when X is listed here.\n");
|
|
163
|
+
// Group CLI tools by category
|
|
164
|
+
const byCategory = new Map();
|
|
165
|
+
for (const tool of cliTools) {
|
|
166
|
+
const list = byCategory.get(tool.category) || [];
|
|
167
|
+
list.push(tool);
|
|
168
|
+
byCategory.set(tool.category, list);
|
|
169
|
+
}
|
|
170
|
+
for (const [category, tools] of byCategory) {
|
|
171
|
+
lines.push(`### ${category}`);
|
|
172
|
+
for (const t of tools) {
|
|
173
|
+
lines.push(`- **${t.name}** — ${t.description}`);
|
|
174
|
+
}
|
|
175
|
+
lines.push("");
|
|
176
|
+
}
|
|
177
|
+
// Plugins
|
|
178
|
+
if (plugins.length > 0) {
|
|
179
|
+
lines.push("### 🔌 Active Plugins");
|
|
180
|
+
for (const p of plugins) {
|
|
181
|
+
lines.push(`- **${p}** — Use \`/${p}\` or ask directly about ${p} features`);
|
|
182
|
+
}
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
// Custom tools
|
|
186
|
+
if (customTools.length > 0) {
|
|
187
|
+
lines.push(`### 🛠️ Custom Tools (${customTools.length} defined in TOOLS.md)`);
|
|
188
|
+
lines.push(`Available via Web UI or by name. Examples: ${customTools.slice(0, 10).join(", ")}${customTools.length > 10 ? "..." : ""}`);
|
|
189
|
+
lines.push("");
|
|
190
|
+
}
|
|
191
|
+
// Usage guidelines
|
|
192
|
+
lines.push("### 💡 Usage Guidelines");
|
|
193
|
+
lines.push("- **Act first, ask later.** If a tool is available → use it directly.");
|
|
194
|
+
lines.push("- **`which <tool>`** if unsure whether something is installed.");
|
|
195
|
+
lines.push("- **Combine tools:** e.g. `curl` + `jq` for APIs, `ffmpeg` + `ffprobe` for media.");
|
|
196
|
+
lines.push("- **Missing tools:** Suggest installation, do NOT give up.");
|
|
197
|
+
lines.push("");
|
|
198
|
+
const summary = lines.join("\n");
|
|
199
|
+
cachedReport = { cliTools, customTools, plugins, summary, scannedAt: Date.now() };
|
|
200
|
+
console.log(`🔍 Tool discovery: ${cliTools.length} CLI tools, ${plugins.length} plugins, ${customTools.length} custom tools`);
|
|
201
|
+
return cachedReport;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Get the tool summary for injection into the system prompt.
|
|
205
|
+
*/
|
|
206
|
+
export function getToolSummary() {
|
|
207
|
+
return discoverTools().summary;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Force rescan (e.g., after plugin install).
|
|
211
|
+
*/
|
|
212
|
+
export function rescanTools() {
|
|
213
|
+
return discoverTools(true);
|
|
214
|
+
}
|