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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin System — Drop-in extensible capabilities.
|
|
3
|
+
*
|
|
4
|
+
* Plugins are loaded from the `plugins/` directory.
|
|
5
|
+
* Each plugin is a directory with an `index.js` (or `index.ts` compiled) file
|
|
6
|
+
* that exports a PluginDefinition.
|
|
7
|
+
*
|
|
8
|
+
* Plugin structure:
|
|
9
|
+
* plugins/
|
|
10
|
+
* weather/
|
|
11
|
+
* index.js — Plugin entry (exports PluginDefinition)
|
|
12
|
+
* package.json — Optional: dependencies
|
|
13
|
+
* finance/
|
|
14
|
+
* index.js
|
|
15
|
+
*
|
|
16
|
+
* Plugin API:
|
|
17
|
+
* - name: unique identifier
|
|
18
|
+
* - description: what the plugin does
|
|
19
|
+
* - version: semver
|
|
20
|
+
* - commands: Telegram commands the plugin registers
|
|
21
|
+
* - tools: Functions the AI can call
|
|
22
|
+
* - onMessage: Optional hook for every message
|
|
23
|
+
* - onInit/onDestroy: Lifecycle hooks
|
|
24
|
+
*/
|
|
25
|
+
import fs from "fs";
|
|
26
|
+
import { resolve } from "path";
|
|
27
|
+
import { PLUGINS_DIR } from "../paths.js";
|
|
28
|
+
// ── Plugin Registry ─────────────────────────────────────
|
|
29
|
+
const loadedPlugins = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Load all plugins from the plugins/ directory.
|
|
32
|
+
*/
|
|
33
|
+
export async function loadPlugins() {
|
|
34
|
+
const loaded = [];
|
|
35
|
+
const errors = [];
|
|
36
|
+
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
37
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
38
|
+
return { loaded, errors };
|
|
39
|
+
}
|
|
40
|
+
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (!entry.isDirectory())
|
|
43
|
+
continue;
|
|
44
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("_"))
|
|
45
|
+
continue;
|
|
46
|
+
const pluginDir = resolve(PLUGINS_DIR, entry.name);
|
|
47
|
+
const indexFile = resolve(pluginDir, "index.js");
|
|
48
|
+
if (!fs.existsSync(indexFile)) {
|
|
49
|
+
errors.push({ name: entry.name, error: "Missing index.js" });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
// Dynamic import
|
|
54
|
+
const module = await import(`file://${indexFile}`);
|
|
55
|
+
const definition = module.default || module;
|
|
56
|
+
if (!definition.name) {
|
|
57
|
+
errors.push({ name: entry.name, error: "Plugin has no name" });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Run init hook
|
|
61
|
+
if (definition.onInit) {
|
|
62
|
+
await definition.onInit();
|
|
63
|
+
}
|
|
64
|
+
loadedPlugins.set(definition.name, definition);
|
|
65
|
+
loaded.push(definition.name);
|
|
66
|
+
console.log(`✅ Plugin loaded: ${definition.name} v${definition.version}`);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
70
|
+
errors.push({ name: entry.name, error: msg });
|
|
71
|
+
console.error(`❌ Plugin failed: ${entry.name} — ${msg}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { loaded, errors };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Register all plugin commands with the bot.
|
|
78
|
+
*/
|
|
79
|
+
export function registerPluginCommands(bot) {
|
|
80
|
+
for (const [, plugin] of loadedPlugins) {
|
|
81
|
+
if (!plugin.commands)
|
|
82
|
+
continue;
|
|
83
|
+
for (const cmd of plugin.commands) {
|
|
84
|
+
bot.command(cmd.command, async (ctx) => {
|
|
85
|
+
const args = ctx.match?.toString().trim() || "";
|
|
86
|
+
await cmd.handler(ctx, args);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Run plugin message hooks.
|
|
93
|
+
* Returns true if any plugin handled the message (stops propagation).
|
|
94
|
+
*/
|
|
95
|
+
export async function runPluginMessageHooks(ctx, text) {
|
|
96
|
+
for (const [, plugin] of loadedPlugins) {
|
|
97
|
+
if (plugin.onMessage) {
|
|
98
|
+
try {
|
|
99
|
+
const handled = await plugin.onMessage(ctx, text);
|
|
100
|
+
if (handled === true)
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.error(`Plugin ${plugin.name} onMessage error:`, err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get all registered plugin tools (for AI function calling).
|
|
112
|
+
*/
|
|
113
|
+
export function getPluginTools() {
|
|
114
|
+
const tools = [];
|
|
115
|
+
for (const [, plugin] of loadedPlugins) {
|
|
116
|
+
if (plugin.tools) {
|
|
117
|
+
tools.push(...plugin.tools);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return tools;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Execute a plugin tool by name.
|
|
124
|
+
*/
|
|
125
|
+
export async function executePluginTool(name, params) {
|
|
126
|
+
for (const [, plugin] of loadedPlugins) {
|
|
127
|
+
const tool = plugin.tools?.find(t => t.name === name);
|
|
128
|
+
if (tool) {
|
|
129
|
+
return tool.execute(params);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Plugin tool "${name}" not found`);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get loaded plugin info for /plugins command.
|
|
136
|
+
*/
|
|
137
|
+
export function getLoadedPlugins() {
|
|
138
|
+
const result = [];
|
|
139
|
+
for (const [, plugin] of loadedPlugins) {
|
|
140
|
+
result.push({
|
|
141
|
+
name: plugin.name,
|
|
142
|
+
description: plugin.description,
|
|
143
|
+
version: plugin.version,
|
|
144
|
+
commands: plugin.commands?.map(c => `/${c.command}`) || [],
|
|
145
|
+
tools: plugin.tools?.map(t => t.name) || [],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Unload all plugins (for graceful shutdown).
|
|
152
|
+
*/
|
|
153
|
+
export async function unloadPlugins() {
|
|
154
|
+
for (const [name, plugin] of loadedPlugins) {
|
|
155
|
+
try {
|
|
156
|
+
if (plugin.onDestroy)
|
|
157
|
+
await plugin.onDestroy();
|
|
158
|
+
console.log(`Plugin unloaded: ${name}`);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
console.error(`Plugin ${name} destroy error:`, err);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
loadedPlugins.clear();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Get the plugins directory path (for documentation).
|
|
168
|
+
*/
|
|
169
|
+
export function getPluginsDir() {
|
|
170
|
+
return PLUGINS_DIR;
|
|
171
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reminder Service — Simple in-memory reminder system.
|
|
3
|
+
*
|
|
4
|
+
* /remind 30m Call mom
|
|
5
|
+
* /remind 2h Check deployment
|
|
6
|
+
* /remind 1d Send invoice
|
|
7
|
+
*/
|
|
8
|
+
let nextId = 1;
|
|
9
|
+
const reminders = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Parse a duration string like "30m", "2h", "1d", "90s" into milliseconds.
|
|
12
|
+
*/
|
|
13
|
+
export function parseDuration(input) {
|
|
14
|
+
const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)s?$/i);
|
|
15
|
+
if (!match)
|
|
16
|
+
return null;
|
|
17
|
+
const value = parseFloat(match[1]);
|
|
18
|
+
const unit = match[2].toLowerCase();
|
|
19
|
+
const multipliers = {
|
|
20
|
+
s: 1000, sec: 1000,
|
|
21
|
+
m: 60_000, min: 60_000,
|
|
22
|
+
h: 3_600_000, hr: 3_600_000,
|
|
23
|
+
d: 86_400_000, day: 86_400_000,
|
|
24
|
+
};
|
|
25
|
+
return value * (multipliers[unit] || 60_000);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Format milliseconds into a human-readable string.
|
|
29
|
+
*/
|
|
30
|
+
function formatDuration(ms) {
|
|
31
|
+
if (ms < 60_000)
|
|
32
|
+
return `${Math.round(ms / 1000)}s`;
|
|
33
|
+
if (ms < 3_600_000)
|
|
34
|
+
return `${Math.round(ms / 60_000)} Min`;
|
|
35
|
+
if (ms < 86_400_000)
|
|
36
|
+
return `${(ms / 3_600_000).toFixed(1)}h`;
|
|
37
|
+
return `${(ms / 86_400_000).toFixed(1)} Tage`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create a reminder that fires after a delay.
|
|
41
|
+
*/
|
|
42
|
+
export function createReminder(chatId, userId, text, delayMs, api) {
|
|
43
|
+
const id = nextId++;
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const timer = setTimeout(async () => {
|
|
46
|
+
try {
|
|
47
|
+
await api.sendMessage(chatId, `⏰ *Erinnerung:* ${text}`, { parse_mode: "Markdown" });
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
console.error(`Failed to send reminder ${id}:`, err);
|
|
51
|
+
}
|
|
52
|
+
reminders.delete(id);
|
|
53
|
+
}, delayMs);
|
|
54
|
+
const reminder = {
|
|
55
|
+
id,
|
|
56
|
+
chatId,
|
|
57
|
+
userId,
|
|
58
|
+
text,
|
|
59
|
+
createdAt: now,
|
|
60
|
+
triggerAt: now + delayMs,
|
|
61
|
+
timer,
|
|
62
|
+
};
|
|
63
|
+
reminders.set(id, reminder);
|
|
64
|
+
return reminder;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* List all pending reminders for a user.
|
|
68
|
+
*/
|
|
69
|
+
export function listReminders(userId) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
return Array.from(reminders.values())
|
|
72
|
+
.filter(r => r.userId === userId && r.triggerAt > now)
|
|
73
|
+
.sort((a, b) => a.triggerAt - b.triggerAt)
|
|
74
|
+
.map(r => ({
|
|
75
|
+
id: r.id,
|
|
76
|
+
text: r.text,
|
|
77
|
+
triggerAt: r.triggerAt,
|
|
78
|
+
remaining: formatDuration(r.triggerAt - now),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Cancel a reminder by ID.
|
|
83
|
+
*/
|
|
84
|
+
export function cancelReminder(id, userId) {
|
|
85
|
+
const r = reminders.get(id);
|
|
86
|
+
if (!r || r.userId !== userId)
|
|
87
|
+
return false;
|
|
88
|
+
clearTimeout(r.timer);
|
|
89
|
+
reminders.delete(id);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get count of pending reminders for a user.
|
|
94
|
+
*/
|
|
95
|
+
export function reminderCount(userId) {
|
|
96
|
+
return Array.from(reminders.values()).filter(r => r.userId === userId).length;
|
|
97
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graceful Self-Restart — Ensures Grammy acknowledges Telegram updates before exit.
|
|
3
|
+
*
|
|
4
|
+
* Problem: When the AI calls `pm2 restart alvin-bot`, PM2 kills the process
|
|
5
|
+
* externally (SIGTERM → SIGKILL) before Grammy can commit the update offset.
|
|
6
|
+
* This causes a restart loop where the same "restart" message is re-processed.
|
|
7
|
+
*
|
|
8
|
+
* Solution: Instead of `pm2 restart`, we exit gracefully from inside the process.
|
|
9
|
+
* Grammy's bot.stop() commits the offset, then process.exit(0) triggers PM2 auto-restart.
|
|
10
|
+
*/
|
|
11
|
+
let _shutdownFn = null;
|
|
12
|
+
let _restartScheduled = false;
|
|
13
|
+
/**
|
|
14
|
+
* Register the graceful shutdown function (called once from index.ts).
|
|
15
|
+
*/
|
|
16
|
+
export function registerShutdownHandler(fn) {
|
|
17
|
+
_shutdownFn = fn;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Schedule a graceful restart. Waits for the given delay (ms) to allow
|
|
21
|
+
* the AI to finish its response, then shuts down cleanly.
|
|
22
|
+
* PM2's autorestart brings the bot back.
|
|
23
|
+
*
|
|
24
|
+
* Returns true if restart was scheduled, false if already pending.
|
|
25
|
+
*/
|
|
26
|
+
export function scheduleGracefulRestart(delayMs = 1500) {
|
|
27
|
+
if (_restartScheduled)
|
|
28
|
+
return false;
|
|
29
|
+
_restartScheduled = true;
|
|
30
|
+
setTimeout(async () => {
|
|
31
|
+
console.log("Graceful self-restart initiated...");
|
|
32
|
+
if (_shutdownFn) {
|
|
33
|
+
await _shutdownFn();
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
}, delayMs);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check if a shell command is a self-restart command.
|
|
43
|
+
*/
|
|
44
|
+
export function isSelfRestartCommand(command) {
|
|
45
|
+
const normalized = command.trim().toLowerCase();
|
|
46
|
+
// Match: pm2 restart alvin-bot, pm2 restart 0, pm2 reload alvin-bot
|
|
47
|
+
return /pm2\s+(restart|reload)\s+(alvin-bot|0)\b/.test(normalized);
|
|
48
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
import { DATA_DIR } from "../paths.js";
|
|
5
|
+
export function runAudit() {
|
|
6
|
+
const checks = [];
|
|
7
|
+
// 1. .env file permissions
|
|
8
|
+
const envFile = resolve(DATA_DIR, ".env");
|
|
9
|
+
if (fs.existsSync(envFile)) {
|
|
10
|
+
const stat = fs.statSync(envFile);
|
|
11
|
+
const mode = (stat.mode & 0o777).toString(8);
|
|
12
|
+
checks.push(mode === "600"
|
|
13
|
+
? { name: ".env permissions", status: "PASS", message: `Mode ${mode} (secure)` }
|
|
14
|
+
: { name: ".env permissions", status: "WARN", message: `Mode ${mode} — should be 600. Run: chmod 600 ${envFile}` });
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
checks.push({ name: ".env file", status: "WARN", message: "No .env file found" });
|
|
18
|
+
}
|
|
19
|
+
// 2. Check for secrets in git
|
|
20
|
+
try {
|
|
21
|
+
const gitOutput = execSync("git diff HEAD --cached --diff-filter=ACM -- . | grep -iE '(api.key|token|password|secret)\\s*=' || true", { cwd: DATA_DIR, stdio: "pipe" }).toString();
|
|
22
|
+
checks.push(gitOutput.trim()
|
|
23
|
+
? { name: "Secrets in git", status: "FAIL", message: `Possible secrets in staged files:\n${gitOutput.trim()}` }
|
|
24
|
+
: { name: "Secrets in git", status: "PASS", message: "No secrets detected in staged files" });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
checks.push({ name: "Secrets in git", status: "PASS", message: "Not a git repo or no staged changes" });
|
|
28
|
+
}
|
|
29
|
+
// 3. ALLOWED_USERS set
|
|
30
|
+
const allowedUsers = process.env.ALLOWED_USERS || "";
|
|
31
|
+
checks.push(allowedUsers
|
|
32
|
+
? { name: "ALLOWED_USERS", status: "PASS", message: `${allowedUsers.split(",").length} user(s) configured` }
|
|
33
|
+
: { name: "ALLOWED_USERS", status: "WARN", message: "Not set — anyone can message the bot" });
|
|
34
|
+
// 4. WEB_PASSWORD
|
|
35
|
+
const webPassword = process.env.WEB_PASSWORD || "";
|
|
36
|
+
checks.push(webPassword
|
|
37
|
+
? { name: "WEB_PASSWORD", status: "PASS", message: "Set" }
|
|
38
|
+
: { name: "WEB_PASSWORD", status: "WARN", message: "Not set — Web UI is unprotected" });
|
|
39
|
+
// 5. WEBHOOK_TOKEN
|
|
40
|
+
if (process.env.WEBHOOK_ENABLED === "true") {
|
|
41
|
+
checks.push(process.env.WEBHOOK_TOKEN
|
|
42
|
+
? { name: "WEBHOOK_TOKEN", status: "PASS", message: "Set" }
|
|
43
|
+
: { name: "WEBHOOK_TOKEN", status: "FAIL", message: "Webhooks enabled but no token set — anyone can trigger!" });
|
|
44
|
+
}
|
|
45
|
+
// 6. Data dir permissions
|
|
46
|
+
if (fs.existsSync(DATA_DIR)) {
|
|
47
|
+
const stat = fs.statSync(DATA_DIR);
|
|
48
|
+
const mode = (stat.mode & 0o777).toString(8);
|
|
49
|
+
checks.push(parseInt(mode, 8) <= 0o755
|
|
50
|
+
? { name: "Data dir permissions", status: "PASS", message: `${DATA_DIR} mode ${mode}` }
|
|
51
|
+
: { name: "Data dir permissions", status: "WARN", message: `${DATA_DIR} mode ${mode} — consider restricting` });
|
|
52
|
+
}
|
|
53
|
+
return checks;
|
|
54
|
+
}
|
|
55
|
+
export function formatAuditReport(checks) {
|
|
56
|
+
const icons = { PASS: "✅", WARN: "⚠️", FAIL: "❌" };
|
|
57
|
+
let report = "Security Audit Report\n" + "=".repeat(40) + "\n\n";
|
|
58
|
+
for (const c of checks) {
|
|
59
|
+
report += `${icons[c.status]} ${c.name}: ${c.message}\n`;
|
|
60
|
+
}
|
|
61
|
+
const fails = checks.filter(c => c.status === "FAIL").length;
|
|
62
|
+
const warns = checks.filter(c => c.status === "WARN").length;
|
|
63
|
+
report += `\n${"=".repeat(40)}\n`;
|
|
64
|
+
report += `${checks.length} checks: ${checks.length - fails - warns} passed, ${warns} warnings, ${fails} failures\n`;
|
|
65
|
+
return report;
|
|
66
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-Search — Unified search across all of Alvin-Bot's knowledge.
|
|
3
|
+
*
|
|
4
|
+
* Combines three search strategies:
|
|
5
|
+
* 1. Semantic (embeddings) — finds memories AND assets by meaning
|
|
6
|
+
* 2. Capability (skills) — finds matching skills by keyword triggers
|
|
7
|
+
* 3. Keyword fallback — finds assets by filename/category match
|
|
8
|
+
*
|
|
9
|
+
* Used by:
|
|
10
|
+
* - CLI: `alvin-bot search "query"` (for SDK agents to call via Bash)
|
|
11
|
+
* - Internal: personality.ts for prompt enrichment
|
|
12
|
+
*/
|
|
13
|
+
import { searchMemory } from "./embeddings.js";
|
|
14
|
+
import { matchSkills } from "./skills.js";
|
|
15
|
+
import { loadAssetIndex } from "./asset-index.js";
|
|
16
|
+
// ── Search Strategies ───────────────────────────────────
|
|
17
|
+
/**
|
|
18
|
+
* Semantic search via embeddings (memories + assets).
|
|
19
|
+
* Results from asset sources get type "asset", others get "memory".
|
|
20
|
+
*/
|
|
21
|
+
async function searchSemantic(query, topK, minScore) {
|
|
22
|
+
try {
|
|
23
|
+
const results = await searchMemory(query, topK, minScore);
|
|
24
|
+
const index = loadAssetIndex();
|
|
25
|
+
// Build a lookup map for absolute paths
|
|
26
|
+
const assetPathMap = new Map();
|
|
27
|
+
for (const a of index.assets) {
|
|
28
|
+
assetPathMap.set(`assets/${a.path}`, a.absolutePath);
|
|
29
|
+
}
|
|
30
|
+
return results.map(r => {
|
|
31
|
+
const isAsset = r.source.startsWith("assets/");
|
|
32
|
+
return {
|
|
33
|
+
type: isAsset ? "asset" : "memory",
|
|
34
|
+
text: r.text.length > 200 ? r.text.slice(0, 200) + "..." : r.text,
|
|
35
|
+
source: r.source,
|
|
36
|
+
score: r.score,
|
|
37
|
+
absolutePath: isAsset ? assetPathMap.get(r.source) : undefined,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Embeddings unavailable — return empty (keyword fallback will catch)
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Capability search — match skills by their trigger keywords.
|
|
48
|
+
*/
|
|
49
|
+
function searchCapabilities(query) {
|
|
50
|
+
const matched = matchSkills(query, 3);
|
|
51
|
+
return matched.map(s => ({
|
|
52
|
+
type: "capability",
|
|
53
|
+
text: `Skill: ${s.name} — ${s.description}`,
|
|
54
|
+
source: `skills/${s.id}`,
|
|
55
|
+
score: 0.5,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Keyword fallback — match assets by filename, category, or description.
|
|
60
|
+
* Used when embeddings are unavailable or as a supplement.
|
|
61
|
+
*/
|
|
62
|
+
function searchKeyword(query) {
|
|
63
|
+
const index = loadAssetIndex();
|
|
64
|
+
if (index.assets.length === 0)
|
|
65
|
+
return [];
|
|
66
|
+
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length >= 3);
|
|
67
|
+
if (keywords.length === 0)
|
|
68
|
+
return [];
|
|
69
|
+
return index.assets
|
|
70
|
+
.filter(a => keywords.some(k => a.filename.toLowerCase().includes(k) ||
|
|
71
|
+
a.category.toLowerCase().includes(k) ||
|
|
72
|
+
a.description.toLowerCase().includes(k)))
|
|
73
|
+
.map(a => {
|
|
74
|
+
// Score based on match quality: filename hits rank higher than category-only
|
|
75
|
+
const filenameLower = a.filename.toLowerCase();
|
|
76
|
+
const matchCount = keywords.filter(k => filenameLower.includes(k)).length;
|
|
77
|
+
const score = matchCount >= 2 ? 0.75 : matchCount === 1 ? 0.65 : 0.5;
|
|
78
|
+
return {
|
|
79
|
+
type: "asset",
|
|
80
|
+
text: a.description,
|
|
81
|
+
source: `assets/${a.path}`,
|
|
82
|
+
score,
|
|
83
|
+
absolutePath: a.absolutePath,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// ── Public API ──────────────────────────────────────────
|
|
88
|
+
/**
|
|
89
|
+
* Search across all knowledge sources: memories, assets, capabilities.
|
|
90
|
+
* Merges results, deduplicates by source, sorts by score.
|
|
91
|
+
*/
|
|
92
|
+
export async function searchSelf(query, topK = 5, minScore = 0.3) {
|
|
93
|
+
// Run all searches (semantic is async, others are sync)
|
|
94
|
+
const [semantic, capabilities, keyword] = await Promise.all([
|
|
95
|
+
searchSemantic(query, topK, minScore),
|
|
96
|
+
Promise.resolve(searchCapabilities(query)),
|
|
97
|
+
Promise.resolve(searchKeyword(query)),
|
|
98
|
+
]);
|
|
99
|
+
// Merge all results
|
|
100
|
+
const all = [...semantic, ...capabilities, ...keyword];
|
|
101
|
+
// Deduplicate by source (keep highest score)
|
|
102
|
+
const deduped = new Map();
|
|
103
|
+
for (const r of all) {
|
|
104
|
+
const existing = deduped.get(r.source);
|
|
105
|
+
if (!existing || r.score > existing.score) {
|
|
106
|
+
deduped.set(r.source, r);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Sort by score descending, take topK
|
|
110
|
+
return [...deduped.values()]
|
|
111
|
+
.sort((a, b) => b.score - a.score)
|
|
112
|
+
.slice(0, topK);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Format search results for CLI output.
|
|
116
|
+
*/
|
|
117
|
+
export function formatSearchResults(results) {
|
|
118
|
+
if (results.length === 0)
|
|
119
|
+
return "No results found.";
|
|
120
|
+
return results.map(r => {
|
|
121
|
+
const score = `[${r.score.toFixed(2)}]`;
|
|
122
|
+
const type = r.type.padEnd(10);
|
|
123
|
+
const source = r.source;
|
|
124
|
+
const detail = r.absolutePath
|
|
125
|
+
? `\n${"".padEnd(16)}${r.absolutePath}`
|
|
126
|
+
: `\n${"".padEnd(16)}"${r.text.slice(0, 80)}${r.text.length > 80 ? "..." : ""}"`;
|
|
127
|
+
return `${score} ${type} ${source}${detail}`;
|
|
128
|
+
}).join("\n");
|
|
129
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
/** Max history entries to keep (to avoid token overflow) */
|
|
3
|
+
const MAX_HISTORY = 100;
|
|
4
|
+
const sessions = new Map();
|
|
5
|
+
export function buildSessionKey(platform, channelId, userId) {
|
|
6
|
+
switch (config.sessionMode) {
|
|
7
|
+
case "per-channel":
|
|
8
|
+
return `${platform}:${channelId}`;
|
|
9
|
+
case "per-channel-peer":
|
|
10
|
+
return `${platform}:${channelId}:${userId}`;
|
|
11
|
+
case "per-user":
|
|
12
|
+
default:
|
|
13
|
+
return String(userId);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function getSession(key) {
|
|
17
|
+
const k = String(key);
|
|
18
|
+
let session = sessions.get(k);
|
|
19
|
+
if (!session) {
|
|
20
|
+
session = {
|
|
21
|
+
sessionId: null,
|
|
22
|
+
workingDir: config.defaultWorkingDir,
|
|
23
|
+
isProcessing: false,
|
|
24
|
+
abortController: null,
|
|
25
|
+
lastActivity: Date.now(),
|
|
26
|
+
startedAt: Date.now(),
|
|
27
|
+
totalCost: 0,
|
|
28
|
+
costByProvider: {},
|
|
29
|
+
queriesByProvider: {},
|
|
30
|
+
effort: "high",
|
|
31
|
+
voiceReply: false,
|
|
32
|
+
messageCount: 0,
|
|
33
|
+
toolUseCount: 0,
|
|
34
|
+
totalInputTokens: 0,
|
|
35
|
+
totalOutputTokens: 0,
|
|
36
|
+
history: [],
|
|
37
|
+
language: "en",
|
|
38
|
+
messageQueue: [],
|
|
39
|
+
};
|
|
40
|
+
sessions.set(k, session);
|
|
41
|
+
}
|
|
42
|
+
return session;
|
|
43
|
+
}
|
|
44
|
+
export function resetSession(key) {
|
|
45
|
+
const session = getSession(key);
|
|
46
|
+
session.sessionId = null;
|
|
47
|
+
session.totalCost = 0;
|
|
48
|
+
session.costByProvider = {};
|
|
49
|
+
session.queriesByProvider = {};
|
|
50
|
+
session.messageCount = 0;
|
|
51
|
+
session.toolUseCount = 0;
|
|
52
|
+
session.totalInputTokens = 0;
|
|
53
|
+
session.totalOutputTokens = 0;
|
|
54
|
+
session.history = [];
|
|
55
|
+
session.startedAt = Date.now();
|
|
56
|
+
}
|
|
57
|
+
/** Track cost, query count, and tokens for a provider. */
|
|
58
|
+
export function trackProviderUsage(key, providerKey, cost, inputTokens, outputTokens) {
|
|
59
|
+
const session = getSession(key);
|
|
60
|
+
session.costByProvider[providerKey] = (session.costByProvider[providerKey] || 0) + cost;
|
|
61
|
+
session.queriesByProvider[providerKey] = (session.queriesByProvider[providerKey] || 0) + 1;
|
|
62
|
+
if (inputTokens)
|
|
63
|
+
session.totalInputTokens += inputTokens;
|
|
64
|
+
if (outputTokens)
|
|
65
|
+
session.totalOutputTokens += outputTokens;
|
|
66
|
+
}
|
|
67
|
+
/** Add a message to conversation history (for non-SDK providers). */
|
|
68
|
+
export function addToHistory(key, message) {
|
|
69
|
+
const session = getSession(key);
|
|
70
|
+
session.history.push(message);
|
|
71
|
+
// Trim oldest messages if history gets too long
|
|
72
|
+
if (session.history.length > MAX_HISTORY) {
|
|
73
|
+
session.history = session.history.slice(-MAX_HISTORY);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Get all active sessions (for web UI session browser). */
|
|
77
|
+
export function getAllSessions() {
|
|
78
|
+
return sessions;
|
|
79
|
+
}
|
|
80
|
+
/** Kill a user session completely — abort running query, clear history, remove from map. */
|
|
81
|
+
export function killSession(key) {
|
|
82
|
+
const k = String(key);
|
|
83
|
+
const session = sessions.get(k);
|
|
84
|
+
if (!session)
|
|
85
|
+
return { aborted: false, hadSession: false };
|
|
86
|
+
let aborted = false;
|
|
87
|
+
if (session.abortController) {
|
|
88
|
+
session.abortController.abort();
|
|
89
|
+
aborted = true;
|
|
90
|
+
}
|
|
91
|
+
sessions.delete(k);
|
|
92
|
+
return { aborted, hadSession: true };
|
|
93
|
+
}
|