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,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
+ }