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,386 @@
1
+ /**
2
+ * Embeddings Service — Vector-based semantic memory search.
3
+ *
4
+ * Uses Google's text-embedding-004 model for generating embeddings.
5
+ * Stores embeddings in a local JSON index file for fast cosine similarity search.
6
+ *
7
+ * Architecture:
8
+ * - Each memory entry (paragraph/section) gets an embedding vector
9
+ * - Vectors are stored in docs/memory/.embeddings.json
10
+ * - On query, the search text is embedded and compared via cosine similarity
11
+ * - Top-K results returned with similarity scores
12
+ */
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import { resolve } from "path";
16
+ import { config } from "../config.js";
17
+ import os from "os";
18
+ import { MEMORY_DIR, MEMORY_FILE, EMBEDDINGS_IDX as INDEX_FILE } from "../paths.js";
19
+ import { ASSETS_DIR, ASSETS_INDEX_MD } from "../paths.js";
20
+ // Hub memory directory (Claude Hub — read-only, additional context)
21
+ const HUB_MEMORY_DIR = resolve(os.homedir(), ".claude", "hub", "MEMORY");
22
+ // ── Google Embeddings API ───────────────────────────────
23
+ const EMBEDDING_MODEL = "gemini-embedding-001";
24
+ const EMBEDDING_DIMENSION = 3072;
25
+ /**
26
+ * Get embeddings for one or more texts via Google's API.
27
+ * Batches up to 100 texts per request.
28
+ */
29
+ async function getEmbeddings(texts) {
30
+ const apiKey = config.apiKeys.google;
31
+ if (!apiKey) {
32
+ throw new Error("Google API key not configured. Set GOOGLE_API_KEY in .env");
33
+ }
34
+ const results = [];
35
+ const batchSize = 100;
36
+ for (let i = 0; i < texts.length; i += batchSize) {
37
+ const batch = texts.slice(i, i + batchSize);
38
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${EMBEDDING_MODEL}:batchEmbedContents?key=${apiKey}`, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({
42
+ requests: batch.map(text => ({
43
+ model: `models/${EMBEDDING_MODEL}`,
44
+ content: { parts: [{ text }] },
45
+ taskType: "RETRIEVAL_DOCUMENT",
46
+ })),
47
+ }),
48
+ });
49
+ if (!response.ok) {
50
+ const err = await response.text();
51
+ throw new Error(`Embedding API error: ${response.status} — ${err}`);
52
+ }
53
+ const data = await response.json();
54
+ for (const emb of data.embeddings) {
55
+ results.push(emb.values);
56
+ }
57
+ }
58
+ return results;
59
+ }
60
+ /**
61
+ * Get embedding for a single query text.
62
+ */
63
+ async function getQueryEmbedding(text) {
64
+ const apiKey = config.apiKeys.google;
65
+ if (!apiKey) {
66
+ throw new Error("Google API key not configured");
67
+ }
68
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${EMBEDDING_MODEL}:embedContent?key=${apiKey}`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({
72
+ model: `models/${EMBEDDING_MODEL}`,
73
+ content: { parts: [{ text }] },
74
+ taskType: "RETRIEVAL_QUERY",
75
+ }),
76
+ });
77
+ if (!response.ok) {
78
+ const err = await response.text();
79
+ throw new Error(`Embedding API error: ${response.status} — ${err}`);
80
+ }
81
+ const data = await response.json();
82
+ return data.embedding.values;
83
+ }
84
+ // ── Vector Math ─────────────────────────────────────────
85
+ function cosineSimilarity(a, b) {
86
+ if (a.length !== b.length)
87
+ return 0;
88
+ let dotProduct = 0;
89
+ let normA = 0;
90
+ let normB = 0;
91
+ for (let i = 0; i < a.length; i++) {
92
+ dotProduct += a[i] * b[i];
93
+ normA += a[i] * a[i];
94
+ normB += b[i] * b[i];
95
+ }
96
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
97
+ return denom === 0 ? 0 : dotProduct / denom;
98
+ }
99
+ // ── Text Chunking ───────────────────────────────────────
100
+ /**
101
+ * Split a markdown file into meaningful chunks.
102
+ * Splits on ## headers, keeping each section as a chunk.
103
+ * Falls back to paragraph splitting for files without headers.
104
+ */
105
+ function chunkMarkdown(content, source) {
106
+ const chunks = [];
107
+ // Split on ## headers
108
+ const sections = content.split(/^(?=## )/gm);
109
+ for (let i = 0; i < sections.length; i++) {
110
+ const section = sections[i].trim();
111
+ if (!section || section.length < 20)
112
+ continue; // Skip tiny sections
113
+ // If section is too long (>1000 chars), split into paragraphs
114
+ if (section.length > 1000) {
115
+ const paragraphs = section.split(/\n\n+/);
116
+ let currentChunk = "";
117
+ let chunkIdx = 0;
118
+ for (const para of paragraphs) {
119
+ if (currentChunk.length + para.length > 800 && currentChunk.length > 100) {
120
+ chunks.push({
121
+ id: `${source}:${i}:${chunkIdx}`,
122
+ text: currentChunk.trim(),
123
+ });
124
+ currentChunk = "";
125
+ chunkIdx++;
126
+ }
127
+ currentChunk += para + "\n\n";
128
+ }
129
+ if (currentChunk.trim().length > 20) {
130
+ chunks.push({
131
+ id: `${source}:${i}:${chunkIdx}`,
132
+ text: currentChunk.trim(),
133
+ });
134
+ }
135
+ }
136
+ else {
137
+ chunks.push({
138
+ id: `${source}:${i}`,
139
+ text: section,
140
+ });
141
+ }
142
+ }
143
+ return chunks;
144
+ }
145
+ // ── Index Management ────────────────────────────────────
146
+ function loadIndex() {
147
+ try {
148
+ const raw = fs.readFileSync(INDEX_FILE, "utf-8");
149
+ return JSON.parse(raw);
150
+ }
151
+ catch {
152
+ return {
153
+ model: EMBEDDING_MODEL,
154
+ lastReindex: 0,
155
+ fileMtimes: {},
156
+ entries: [],
157
+ };
158
+ }
159
+ }
160
+ function saveIndex(index) {
161
+ fs.writeFileSync(INDEX_FILE, JSON.stringify(index));
162
+ }
163
+ /**
164
+ * Recursively walk a directory, returning file paths.
165
+ * Skips INDEX.json and INDEX.md at the directory root.
166
+ */
167
+ function walkAssetDir(dir) {
168
+ const results = [];
169
+ function walk(currentDir) {
170
+ let entries;
171
+ try {
172
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
173
+ }
174
+ catch {
175
+ return;
176
+ }
177
+ for (const entry of entries) {
178
+ const fullPath = resolve(currentDir, entry.name);
179
+ if (entry.isDirectory()) {
180
+ walk(fullPath);
181
+ }
182
+ else if (entry.isFile()) {
183
+ if (currentDir === dir && (entry.name === "INDEX.json" || entry.name === "INDEX.md"))
184
+ continue;
185
+ results.push({ name: entry.name, path: fullPath });
186
+ }
187
+ }
188
+ }
189
+ walk(dir);
190
+ return results;
191
+ }
192
+ const TEXT_EXTENSIONS = new Set([".md", ".html", ".txt", ".css", ".ts"]);
193
+ /**
194
+ * Get all files that should be indexed — memories + text-based assets.
195
+ */
196
+ function getIndexableFiles() {
197
+ const files = [];
198
+ // ── Memories (existing) ───────────────────────────────
199
+ // Alvin-Bot MEMORY.md
200
+ if (fs.existsSync(MEMORY_FILE)) {
201
+ files.push({ path: MEMORY_FILE, relativePath: "MEMORY.md" });
202
+ }
203
+ // Alvin-Bot daily logs
204
+ if (fs.existsSync(MEMORY_DIR)) {
205
+ const entries = fs.readdirSync(MEMORY_DIR);
206
+ for (const entry of entries) {
207
+ if (entry.endsWith(".md") && !entry.startsWith(".")) {
208
+ files.push({
209
+ path: resolve(MEMORY_DIR, entry),
210
+ relativePath: `memory/${entry}`,
211
+ });
212
+ }
213
+ }
214
+ }
215
+ // Hub memories (~/.claude/hub/MEMORY/) — Claude Hub knowledge base
216
+ if (fs.existsSync(HUB_MEMORY_DIR)) {
217
+ try {
218
+ const entries = fs.readdirSync(HUB_MEMORY_DIR);
219
+ for (const entry of entries) {
220
+ if (entry.endsWith(".md") && !entry.startsWith(".")) {
221
+ files.push({
222
+ path: resolve(HUB_MEMORY_DIR, entry),
223
+ relativePath: `hub/${entry}`,
224
+ });
225
+ }
226
+ }
227
+ }
228
+ catch { /* Hub not available — skip */ }
229
+ }
230
+ // ── Assets (new) ──────────────────────────────────────
231
+ // Asset INDEX.md — compact summary of all assets
232
+ if (fs.existsSync(ASSETS_INDEX_MD)) {
233
+ files.push({ path: ASSETS_INDEX_MD, relativePath: "assets/INDEX.md" });
234
+ }
235
+ // Text-based asset files (HTML, MD, TXT, CSS, TS)
236
+ if (fs.existsSync(ASSETS_DIR)) {
237
+ for (const entry of walkAssetDir(ASSETS_DIR)) {
238
+ if (TEXT_EXTENSIONS.has(path.extname(entry.name))) {
239
+ files.push({
240
+ path: entry.path,
241
+ relativePath: `assets/${path.relative(ASSETS_DIR, entry.path)}`,
242
+ });
243
+ }
244
+ }
245
+ }
246
+ return files;
247
+ }
248
+ /**
249
+ * Check which files need reindexing (new or modified).
250
+ */
251
+ function getStaleFiles(index) {
252
+ const allFiles = getIndexableFiles();
253
+ const stale = [];
254
+ for (const file of allFiles) {
255
+ try {
256
+ const stat = fs.statSync(file.path);
257
+ const mtime = stat.mtimeMs;
258
+ if (!index.fileMtimes[file.relativePath] || index.fileMtimes[file.relativePath] < mtime) {
259
+ stale.push(file);
260
+ }
261
+ }
262
+ catch {
263
+ // File disappeared — skip
264
+ }
265
+ }
266
+ return stale;
267
+ }
268
+ // ── Public API ──────────────────────────────────────────
269
+ /**
270
+ * Reindex all memory files (or just stale ones).
271
+ * Returns number of chunks indexed.
272
+ */
273
+ export async function reindexMemory(force = false) {
274
+ const index = loadIndex();
275
+ const filesToIndex = force ? getIndexableFiles() : getStaleFiles(index);
276
+ if (filesToIndex.length === 0) {
277
+ return { indexed: 0, total: index.entries.length };
278
+ }
279
+ // Remove old entries for files being reindexed
280
+ const reindexSources = new Set(filesToIndex.map(f => f.relativePath));
281
+ index.entries = index.entries.filter(e => !reindexSources.has(e.source));
282
+ // Chunk all files
283
+ const allChunks = [];
284
+ for (const file of filesToIndex) {
285
+ try {
286
+ const content = fs.readFileSync(file.path, "utf-8");
287
+ const chunks = chunkMarkdown(content, file.relativePath);
288
+ for (const chunk of chunks) {
289
+ allChunks.push({ ...chunk, source: file.relativePath });
290
+ }
291
+ // Update mtime
292
+ const stat = fs.statSync(file.path);
293
+ index.fileMtimes[file.relativePath] = stat.mtimeMs;
294
+ }
295
+ catch (err) {
296
+ console.error(`Failed to chunk ${file.relativePath}:`, err);
297
+ }
298
+ }
299
+ if (allChunks.length === 0) {
300
+ saveIndex(index);
301
+ return { indexed: 0, total: index.entries.length };
302
+ }
303
+ // Get embeddings for all chunks
304
+ const texts = allChunks.map(c => c.text);
305
+ const vectors = await getEmbeddings(texts);
306
+ // Add to index
307
+ for (let i = 0; i < allChunks.length; i++) {
308
+ index.entries.push({
309
+ id: allChunks[i].id,
310
+ source: allChunks[i].source,
311
+ text: allChunks[i].text,
312
+ vector: vectors[i],
313
+ indexedAt: Date.now(),
314
+ });
315
+ }
316
+ index.lastReindex = Date.now();
317
+ saveIndex(index);
318
+ return { indexed: allChunks.length, total: index.entries.length };
319
+ }
320
+ /**
321
+ * Semantic search across all indexed memory.
322
+ * Returns top-K results sorted by similarity.
323
+ */
324
+ export async function searchMemory(query, topK = 5, minScore = 0.3) {
325
+ const index = loadIndex();
326
+ if (index.entries.length === 0) {
327
+ // Auto-index if empty
328
+ await reindexMemory();
329
+ // Reload
330
+ const reloaded = loadIndex();
331
+ if (reloaded.entries.length === 0)
332
+ return [];
333
+ }
334
+ // Get query embedding
335
+ const queryVector = await getQueryEmbedding(query);
336
+ // Calculate similarities
337
+ const scored = index.entries.map(entry => ({
338
+ text: entry.text,
339
+ source: entry.source,
340
+ score: cosineSimilarity(queryVector, entry.vector),
341
+ }));
342
+ // Sort by score descending, filter by minScore, take topK
343
+ return scored
344
+ .filter(r => r.score >= minScore)
345
+ .sort((a, b) => b.score - a.score)
346
+ .slice(0, topK);
347
+ }
348
+ /**
349
+ * Get index stats for /status.
350
+ */
351
+ /**
352
+ * Auto-reindex on startup. Indexes only stale/new files (incremental).
353
+ * Runs in background — does not block bot startup.
354
+ */
355
+ export async function initEmbeddings() {
356
+ try {
357
+ const stale = getStaleFiles(loadIndex());
358
+ if (stale.length === 0) {
359
+ const idx = loadIndex();
360
+ if (idx.entries.length > 0)
361
+ return; // Already indexed, nothing stale
362
+ }
363
+ const result = await reindexMemory();
364
+ if (result.indexed > 0) {
365
+ console.log(`🔍 Embeddings: indexed ${result.indexed} chunks (${result.total} total)`);
366
+ }
367
+ }
368
+ catch (err) {
369
+ // Non-fatal — bot works without embeddings
370
+ console.warn("⚠️ Embeddings init failed:", err instanceof Error ? err.message : err);
371
+ }
372
+ }
373
+ export function getIndexStats() {
374
+ const index = loadIndex();
375
+ let sizeBytes = 0;
376
+ try {
377
+ sizeBytes = fs.statSync(INDEX_FILE).size;
378
+ }
379
+ catch { /* empty */ }
380
+ return {
381
+ entries: index.entries.length,
382
+ files: Object.keys(index.fileMtimes).length,
383
+ lastReindex: index.lastReindex,
384
+ sizeBytes,
385
+ };
386
+ }
@@ -0,0 +1,46 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { config } from "../config.js";
3
+ import { EXEC_ALLOWLIST_FILE } from "../paths.js";
4
+ const SAFE_BINS = [
5
+ "ls", "cat", "head", "tail", "grep", "rg", "find", "wc", "sort", "uniq",
6
+ "echo", "printf", "date", "which", "whoami", "hostname", "uname",
7
+ "curl", "wget", "git", "node", "npm", "npx", "python3", "pip3",
8
+ "jq", "ffmpeg", "ffprobe", "ffplay",
9
+ "mkdir", "touch", "cp", "mv", "ln", "chmod",
10
+ "tar", "zip", "unzip", "gzip", "gunzip",
11
+ "ssh", "scp", "rsync",
12
+ "docker", "docker-compose",
13
+ "brew", "open", "pbcopy", "pbpaste",
14
+ "osascript", "defaults", "launchctl",
15
+ ];
16
+ function loadUserAllowlist() {
17
+ if (!existsSync(EXEC_ALLOWLIST_FILE))
18
+ return [];
19
+ try {
20
+ return JSON.parse(readFileSync(EXEC_ALLOWLIST_FILE, "utf-8"));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ function extractBinary(command) {
27
+ // Get first word, strip env vars, handle pipes
28
+ const cleaned = command.replace(/^(env\s+\w+=\S+\s+)+/, "").trim();
29
+ const first = cleaned.split(/[\s|;&]/)[0];
30
+ // Strip path: /usr/bin/curl -> curl
31
+ return first.split("/").pop() || first;
32
+ }
33
+ export function checkExecAllowed(command) {
34
+ if (config.execSecurity === "full")
35
+ return { allowed: true };
36
+ if (config.execSecurity === "deny")
37
+ return { allowed: false, reason: "Shell execution is disabled" };
38
+ // allowlist mode
39
+ const binary = extractBinary(command);
40
+ if (SAFE_BINS.includes(binary))
41
+ return { allowed: true };
42
+ const userList = loadUserAllowlist();
43
+ if (userList.includes(binary))
44
+ return { allowed: true };
45
+ return { allowed: false, reason: `Binary "${binary}" not in allowlist. Add to ${EXEC_ALLOWLIST_FILE} or set EXEC_SECURITY=full` };
46
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Fallback Order Manager — Persistent, user-configurable provider fallback chain.
3
+ *
4
+ * Supports reading/writing the fallback order from:
5
+ * - Telegram (/fallback command)
6
+ * - Web UI (API endpoint)
7
+ * - CLI/Terminal
8
+ *
9
+ * Persists to docs/fallback-order.json and syncs with .env FALLBACK_PROVIDERS.
10
+ */
11
+ import fs from "fs";
12
+ import { FALLBACK_FILE, ENV_FILE, DATA_DIR } from "../paths.js";
13
+ // ── Public API ──────────────────────────────────────────────────────────────
14
+ /**
15
+ * Get the current fallback order.
16
+ */
17
+ export function getFallbackOrder() {
18
+ try {
19
+ if (fs.existsSync(FALLBACK_FILE)) {
20
+ return JSON.parse(fs.readFileSync(FALLBACK_FILE, "utf-8"));
21
+ }
22
+ }
23
+ catch { /* ignore */ }
24
+ // Default from env
25
+ return {
26
+ primary: process.env.PRIMARY_PROVIDER || "groq",
27
+ fallbacks: (process.env.FALLBACK_PROVIDERS || "")
28
+ .split(",")
29
+ .map(s => s.trim())
30
+ .filter(Boolean),
31
+ updatedAt: new Date().toISOString(),
32
+ updatedBy: "env",
33
+ };
34
+ }
35
+ /**
36
+ * Set the fallback order.
37
+ * Updates both docs/fallback-order.json and .env file.
38
+ */
39
+ export function setFallbackOrder(primary, fallbacks, updatedBy = "unknown") {
40
+ const config = {
41
+ primary,
42
+ fallbacks,
43
+ updatedAt: new Date().toISOString(),
44
+ updatedBy,
45
+ };
46
+ // Ensure data dir exists
47
+ if (!fs.existsSync(DATA_DIR)) {
48
+ fs.mkdirSync(DATA_DIR, { recursive: true });
49
+ }
50
+ // Write JSON
51
+ fs.writeFileSync(FALLBACK_FILE, JSON.stringify(config, null, 2));
52
+ // Sync to .env
53
+ syncToEnv(primary, fallbacks);
54
+ return config;
55
+ }
56
+ /**
57
+ * Move a provider up in the fallback order.
58
+ */
59
+ export function moveUp(providerKey, updatedBy = "unknown") {
60
+ const current = getFallbackOrder();
61
+ const idx = current.fallbacks.indexOf(providerKey);
62
+ if (idx > 0) {
63
+ // Swap with previous
64
+ [current.fallbacks[idx - 1], current.fallbacks[idx]] =
65
+ [current.fallbacks[idx], current.fallbacks[idx - 1]];
66
+ }
67
+ else if (idx === 0) {
68
+ // Move to primary, old primary becomes first fallback
69
+ const oldPrimary = current.primary;
70
+ current.primary = providerKey;
71
+ current.fallbacks[0] = oldPrimary;
72
+ }
73
+ return setFallbackOrder(current.primary, current.fallbacks, updatedBy);
74
+ }
75
+ /**
76
+ * Move a provider down in the fallback order.
77
+ */
78
+ export function moveDown(providerKey, updatedBy = "unknown") {
79
+ const current = getFallbackOrder();
80
+ if (providerKey === current.primary && current.fallbacks.length > 0) {
81
+ // Move primary to first fallback, first fallback becomes primary
82
+ const newPrimary = current.fallbacks[0];
83
+ current.fallbacks[0] = providerKey;
84
+ current.primary = newPrimary;
85
+ }
86
+ else {
87
+ const idx = current.fallbacks.indexOf(providerKey);
88
+ if (idx >= 0 && idx < current.fallbacks.length - 1) {
89
+ [current.fallbacks[idx], current.fallbacks[idx + 1]] =
90
+ [current.fallbacks[idx + 1], current.fallbacks[idx]];
91
+ }
92
+ }
93
+ return setFallbackOrder(current.primary, current.fallbacks, updatedBy);
94
+ }
95
+ /**
96
+ * Add a provider to the fallback chain (at the end).
97
+ */
98
+ export function addFallback(providerKey, updatedBy = "unknown") {
99
+ const current = getFallbackOrder();
100
+ if (!current.fallbacks.includes(providerKey) && providerKey !== current.primary) {
101
+ current.fallbacks.push(providerKey);
102
+ }
103
+ return setFallbackOrder(current.primary, current.fallbacks, updatedBy);
104
+ }
105
+ /**
106
+ * Remove a provider from the fallback chain.
107
+ */
108
+ export function removeFallback(providerKey, updatedBy = "unknown") {
109
+ const current = getFallbackOrder();
110
+ current.fallbacks = current.fallbacks.filter(k => k !== providerKey);
111
+ return setFallbackOrder(current.primary, current.fallbacks, updatedBy);
112
+ }
113
+ /**
114
+ * Format the current order as a human-readable string.
115
+ */
116
+ export function formatOrder() {
117
+ const config = getFallbackOrder();
118
+ const lines = [];
119
+ lines.push(`1. 🥇 ${config.primary} (Primary)`);
120
+ config.fallbacks.forEach((fb, i) => {
121
+ lines.push(`${i + 2}. ${i === 0 ? "🥈" : i === 1 ? "🥉" : " "} ${fb}`);
122
+ });
123
+ return lines.join("\n");
124
+ }
125
+ // ── Internal ────────────────────────────────────────────────────────────────
126
+ function syncToEnv(primary, fallbacks) {
127
+ try {
128
+ if (!fs.existsSync(ENV_FILE))
129
+ return;
130
+ let env = fs.readFileSync(ENV_FILE, "utf-8");
131
+ // Update PRIMARY_PROVIDER
132
+ if (env.match(/^PRIMARY_PROVIDER=.*/m)) {
133
+ env = env.replace(/^PRIMARY_PROVIDER=.*/m, `PRIMARY_PROVIDER=${primary}`);
134
+ }
135
+ else {
136
+ env += `\nPRIMARY_PROVIDER=${primary}`;
137
+ }
138
+ // Update FALLBACK_PROVIDERS
139
+ const fallbackStr = fallbacks.join(",");
140
+ if (env.match(/^FALLBACK_PROVIDERS=.*/m)) {
141
+ env = env.replace(/^FALLBACK_PROVIDERS=.*/m, `FALLBACK_PROVIDERS=${fallbackStr}`);
142
+ }
143
+ else {
144
+ env += `\nFALLBACK_PROVIDERS=${fallbackStr}`;
145
+ }
146
+ fs.writeFileSync(ENV_FILE, env);
147
+ }
148
+ catch (err) {
149
+ console.error("Failed to sync fallback order to .env:", err);
150
+ }
151
+ }