beecork 1.4.11 → 1.6.0

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 (138) hide show
  1. package/dist/capabilities/index.d.ts +1 -1
  2. package/dist/capabilities/index.js +1 -1
  3. package/dist/capabilities/manager.js +13 -9
  4. package/dist/capabilities/packs.js +3 -1
  5. package/dist/channels/admin.d.ts +10 -0
  6. package/dist/channels/admin.js +20 -0
  7. package/dist/channels/command-handler.d.ts +2 -10
  8. package/dist/channels/command-handler.js +90 -84
  9. package/dist/channels/discord.d.ts +4 -9
  10. package/dist/channels/discord.js +59 -42
  11. package/dist/channels/index.d.ts +1 -1
  12. package/dist/channels/loader.js +13 -4
  13. package/dist/channels/pipeline.js +14 -5
  14. package/dist/channels/registry.d.ts +17 -1
  15. package/dist/channels/registry.js +33 -4
  16. package/dist/channels/send-helpers.d.ts +19 -0
  17. package/dist/channels/send-helpers.js +21 -0
  18. package/dist/channels/telegram.d.ts +21 -14
  19. package/dist/channels/telegram.js +214 -104
  20. package/dist/channels/types.d.ts +13 -38
  21. package/dist/channels/voice-state.d.ts +29 -0
  22. package/dist/channels/voice-state.js +45 -0
  23. package/dist/channels/webhook.d.ts +2 -5
  24. package/dist/channels/webhook.js +88 -29
  25. package/dist/channels/whatsapp.d.ts +9 -7
  26. package/dist/channels/whatsapp.js +141 -100
  27. package/dist/cli/capabilities.js +4 -4
  28. package/dist/cli/channel.js +16 -6
  29. package/dist/cli/commands.js +12 -9
  30. package/dist/cli/doctor.js +85 -27
  31. package/dist/cli/handoff.d.ts +7 -14
  32. package/dist/cli/handoff.js +9 -44
  33. package/dist/cli/mcp.js +5 -5
  34. package/dist/cli/media.js +21 -8
  35. package/dist/cli/setup.js +9 -8
  36. package/dist/cli/store.js +29 -12
  37. package/dist/config.d.ts +5 -1
  38. package/dist/config.js +20 -22
  39. package/dist/daemon.js +113 -51
  40. package/dist/dashboard/html.js +100 -20
  41. package/dist/dashboard/routes.d.ts +17 -0
  42. package/dist/dashboard/routes.js +623 -0
  43. package/dist/dashboard/server.js +38 -489
  44. package/dist/db/connection.d.ts +29 -0
  45. package/dist/db/connection.js +37 -0
  46. package/dist/db/index.js +43 -11
  47. package/dist/db/migrations.js +114 -22
  48. package/dist/delegation/manager.js +10 -4
  49. package/dist/index.js +39 -59
  50. package/dist/knowledge/manager.js +26 -12
  51. package/dist/mcp/handlers.d.ts +37 -0
  52. package/dist/mcp/handlers.js +520 -0
  53. package/dist/mcp/server.js +44 -858
  54. package/dist/mcp/tool-definitions.d.ts +1225 -0
  55. package/dist/mcp/tool-definitions.js +412 -0
  56. package/dist/mcp/validate.d.ts +23 -0
  57. package/dist/mcp/validate.js +65 -0
  58. package/dist/media/factory.js +18 -14
  59. package/dist/media/generators/dall-e.js +2 -2
  60. package/dist/media/generators/kling.js +4 -4
  61. package/dist/media/generators/lyria.js +1 -1
  62. package/dist/media/generators/nano-banana.d.ts +1 -1
  63. package/dist/media/generators/nano-banana.js +2 -2
  64. package/dist/media/generators/poll-util.js +4 -4
  65. package/dist/media/generators/recraft.js +3 -3
  66. package/dist/media/generators/runway.js +4 -4
  67. package/dist/media/generators/stable-diffusion.js +2 -2
  68. package/dist/media/generators/veo.js +1 -1
  69. package/dist/media/index.d.ts +2 -7
  70. package/dist/media/index.js +2 -2
  71. package/dist/media/store.d.ts +7 -0
  72. package/dist/media/store.js +18 -4
  73. package/dist/media/types.d.ts +22 -0
  74. package/dist/notifications/index.d.ts +2 -4
  75. package/dist/notifications/index.js +6 -19
  76. package/dist/notifications/ntfy.js +3 -3
  77. package/dist/observability/analytics.d.ts +1 -1
  78. package/dist/observability/analytics.js +41 -16
  79. package/dist/projects/index.d.ts +3 -2
  80. package/dist/projects/index.js +2 -2
  81. package/dist/projects/manager.d.ts +1 -7
  82. package/dist/projects/manager.js +66 -42
  83. package/dist/projects/router.d.ts +12 -0
  84. package/dist/projects/router.js +98 -45
  85. package/dist/service/install.js +15 -5
  86. package/dist/service/windows.js +1 -1
  87. package/dist/session/budget-guard.d.ts +20 -0
  88. package/dist/session/budget-guard.js +31 -0
  89. package/dist/session/circuit-breaker.d.ts +5 -3
  90. package/dist/session/circuit-breaker.js +45 -20
  91. package/dist/session/context-compactor.d.ts +32 -0
  92. package/dist/session/context-compactor.js +45 -0
  93. package/dist/session/context-monitor.js +2 -2
  94. package/dist/session/handoff.d.ts +21 -0
  95. package/dist/session/handoff.js +50 -0
  96. package/dist/session/manager.d.ts +21 -5
  97. package/dist/session/manager.js +166 -153
  98. package/dist/session/memory-store.d.ts +29 -0
  99. package/dist/session/memory-store.js +45 -0
  100. package/dist/session/message-queue.d.ts +28 -0
  101. package/dist/session/message-queue.js +52 -0
  102. package/dist/session/pending-dispatcher.d.ts +31 -0
  103. package/dist/session/pending-dispatcher.js +120 -0
  104. package/dist/session/pending-store.d.ts +60 -0
  105. package/dist/session/pending-store.js +118 -0
  106. package/dist/session/stale-session.d.ts +31 -0
  107. package/dist/session/stale-session.js +45 -0
  108. package/dist/session/subprocess.d.ts +3 -0
  109. package/dist/session/subprocess.js +54 -11
  110. package/dist/session/tab-store.d.ts +28 -0
  111. package/dist/session/tab-store.js +78 -0
  112. package/dist/tasks/scheduler.d.ts +13 -0
  113. package/dist/tasks/scheduler.js +97 -18
  114. package/dist/tasks/store.js +26 -12
  115. package/dist/timeline/logger.js +3 -1
  116. package/dist/timeline/query.js +15 -5
  117. package/dist/types.d.ts +49 -9
  118. package/dist/util/auto-heal.js +15 -5
  119. package/dist/util/install-info.js +3 -1
  120. package/dist/util/logger.d.ts +1 -1
  121. package/dist/util/logger.js +63 -24
  122. package/dist/util/paths.d.ts +2 -0
  123. package/dist/util/paths.js +16 -3
  124. package/dist/util/rate-limiter.js +8 -0
  125. package/dist/util/retry.js +1 -1
  126. package/dist/util/text.d.ts +21 -1
  127. package/dist/util/text.js +38 -8
  128. package/dist/voice/index.js +5 -1
  129. package/dist/voice/stt.js +14 -6
  130. package/dist/voice/tts.js +1 -1
  131. package/dist/watchers/scheduler.js +11 -5
  132. package/package.json +6 -1
  133. package/dist/session/tool-classifier.d.ts +0 -4
  134. package/dist/session/tool-classifier.js +0 -56
  135. package/dist/users/index.d.ts +0 -2
  136. package/dist/users/index.js +0 -1
  137. package/dist/users/service.d.ts +0 -17
  138. package/dist/users/service.js +0 -46
@@ -1,3 +1,3 @@
1
1
  export type { CapabilityPack, EnabledCapability } from './types.js';
2
2
  export { CAPABILITY_PACKS } from './packs.js';
3
- export { getAvailablePacks, getEnabledCapabilities, isEnabled, enablePack, disablePack, updateMcpConfig } from './manager.js';
3
+ export { getAvailablePacks, getEnabledCapabilities, isEnabled, enablePack, disablePack, updateMcpConfig, } from './manager.js';
@@ -1,2 +1,2 @@
1
1
  export { CAPABILITY_PACKS } from './packs.js';
2
- export { getAvailablePacks, getEnabledCapabilities, isEnabled, enablePack, disablePack, updateMcpConfig } from './manager.js';
2
+ export { getAvailablePacks, getEnabledCapabilities, isEnabled, enablePack, disablePack, updateMcpConfig, } from './manager.js';
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import { execSync } from 'node:child_process';
2
+ import { execFileSync } from 'node:child_process';
3
3
  import { getConfig, saveConfig } from '../config.js';
4
+ const SAFE_NPM_PACKAGE = /^[@a-zA-Z0-9_/.-]+$/;
4
5
  import { getMcpConfigPath } from '../util/paths.js';
5
6
  import { logger } from '../util/logger.js';
6
7
  import { CAPABILITY_PACKS } from './packs.js';
@@ -15,28 +16,31 @@ export function getEnabledCapabilities() {
15
16
  }
16
17
  /** Check if a pack is enabled */
17
18
  export function isEnabled(packId) {
18
- return getEnabledCapabilities().some(c => c.packId === packId);
19
+ return getEnabledCapabilities().some((c) => c.packId === packId);
19
20
  }
20
21
  /** Enable a capability pack */
21
22
  export function enablePack(packId, apiKey) {
22
- const pack = CAPABILITY_PACKS.find(p => p.id === packId);
23
+ const pack = CAPABILITY_PACKS.find((p) => p.id === packId);
23
24
  if (!pack)
24
25
  throw new Error(`Unknown capability: ${packId}. Use 'beecork capabilities' to list.`);
25
26
  if (pack.requiresApiKey && !apiKey) {
26
27
  throw new Error(`${pack.name} requires an API key. Hint: ${pack.apiKeyHint}`);
27
28
  }
28
29
  // Install the MCP server package
30
+ if (!SAFE_NPM_PACKAGE.test(pack.mcpServer.package)) {
31
+ throw new Error(`Invalid package name in capability pack: ${pack.mcpServer.package}`);
32
+ }
29
33
  try {
30
34
  console.log(`Installing ${pack.mcpServer.package}...`);
31
- execSync(`npm install -g ${pack.mcpServer.package}`, { stdio: 'pipe' });
35
+ execFileSync('npm', ['install', '-g', pack.mcpServer.package], { stdio: 'pipe' });
32
36
  }
33
- catch (err) {
37
+ catch {
34
38
  logger.warn(`Package install skipped (may already be available via npx): ${pack.mcpServer.package}`);
35
39
  }
36
40
  // Update config
37
41
  const config = getConfig();
38
42
  const capabilities = config.capabilities || [];
39
- const existing = capabilities.findIndex(c => c.packId === packId);
43
+ const existing = capabilities.findIndex((c) => c.packId === packId);
40
44
  const entry = { packId, apiKey, enabledAt: new Date().toISOString() };
41
45
  if (existing >= 0) {
42
46
  capabilities[existing] = entry;
@@ -54,7 +58,7 @@ export function enablePack(packId, apiKey) {
54
58
  export function disablePack(packId) {
55
59
  const config = getConfig();
56
60
  const capabilities = config.capabilities || [];
57
- config.capabilities = capabilities.filter(c => c.packId !== packId);
61
+ config.capabilities = capabilities.filter((c) => c.packId !== packId);
58
62
  saveConfig(config);
59
63
  updateMcpConfig();
60
64
  logger.info(`Capability disabled: ${packId}`);
@@ -78,7 +82,7 @@ export function updateMcpConfig() {
78
82
  // Add enabled capability servers
79
83
  const capabilities = getEnabledCapabilities();
80
84
  for (const cap of capabilities) {
81
- const pack = CAPABILITY_PACKS.find(p => p.id === cap.packId);
85
+ const pack = CAPABILITY_PACKS.find((p) => p.id === cap.packId);
82
86
  if (!pack)
83
87
  continue;
84
88
  // Resolve env vars
@@ -96,7 +100,7 @@ export function updateMcpConfig() {
96
100
  env[pack.apiKeyEnvVar] = cap.apiKey;
97
101
  }
98
102
  // Resolve args templates
99
- const args = (pack.mcpServer.args || []).map(arg => arg.replace(/\$\{(\w+)\}/g, (_, varName) => {
103
+ const args = (pack.mcpServer.args || []).map((arg) => arg.replace(/\$\{(\w+)\}/g, (_, varName) => {
100
104
  if (varName === pack.apiKeyEnvVar)
101
105
  return cap.apiKey || '';
102
106
  return process.env[varName] || '';
@@ -9,7 +9,9 @@ export const CAPABILITY_PACKS = [
9
9
  package: '@notionhq/notion-mcp-server',
10
10
  command: 'npx',
11
11
  args: ['-y', '@notionhq/notion-mcp-server'],
12
- env: { OPENAPI_MCP_HEADERS: '{"Authorization":"Bearer ${NOTION_API_KEY}","Notion-Version":"2022-06-28"}' },
12
+ env: {
13
+ OPENAPI_MCP_HEADERS: '{"Authorization":"Bearer ${NOTION_API_KEY}","Notion-Version":"2022-06-28"}',
14
+ },
13
15
  },
14
16
  requiresApiKey: true,
15
17
  apiKeyHint: 'Notion integration token (from notion.so/my-integrations)',
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared admin check for channel commands. Single source of truth so the
3
+ * "who can run admin commands?" rule doesn't drift between channels.
4
+ *
5
+ * Policy:
6
+ * - If an explicit admin peer ID is configured, only they are admin.
7
+ * - Else the first allowed peer in the allowlist is admin.
8
+ * - If the allowlist is empty, no one is admin (fail closed).
9
+ */
10
+ export declare function isChannelAdmin(allowList: Iterable<string | number>, peerId: string | number | undefined, explicitAdmin?: string | number): boolean;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared admin check for channel commands. Single source of truth so the
3
+ * "who can run admin commands?" rule doesn't drift between channels.
4
+ *
5
+ * Policy:
6
+ * - If an explicit admin peer ID is configured, only they are admin.
7
+ * - Else the first allowed peer in the allowlist is admin.
8
+ * - If the allowlist is empty, no one is admin (fail closed).
9
+ */
10
+ export function isChannelAdmin(allowList, peerId, explicitAdmin) {
11
+ if (peerId === undefined || peerId === null)
12
+ return false;
13
+ if (explicitAdmin !== undefined && explicitAdmin !== null) {
14
+ return String(peerId) === String(explicitAdmin);
15
+ }
16
+ const first = [...allowList][0];
17
+ if (first === undefined)
18
+ return false;
19
+ return String(peerId) === String(first);
20
+ }
@@ -9,19 +9,11 @@ export interface CommandResult {
9
9
  handled: boolean;
10
10
  response?: string;
11
11
  }
12
- export interface RouteResult {
13
- effectiveTabName: string;
14
- projectPath?: string;
15
- confirmationMessage?: string;
16
- }
12
+ export type { RouteResult } from '../projects/router.js';
17
13
  /**
18
14
  * Handle shared commands that work identically across all channels.
19
15
  * Returns { handled: true, response } if a command was matched.
20
16
  * The channel is responsible for sending the response via its own API.
21
17
  */
22
18
  export declare function handleSharedCommand(ctx: CommandContext, tabManager: TabManager): Promise<CommandResult>;
23
- /**
24
- * Shared project routing logic — resolves which tab/project to use for a message.
25
- * Extracted from the identical blocks in Telegram, WhatsApp, and Discord channels.
26
- */
27
- export declare function resolveProjectRoute(rawPrompt: string, tabName: string, text: string, userId: string): Promise<RouteResult>;
19
+ export { resolveProjectRoute } from '../projects/router.js';
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { timeAgo } from '../util/text.js';
6
6
  import { validateTabName } from '../config.js';
7
+ import { exportTab, formatHandoffInfo } from '../session/handoff.js';
8
+ import { logActivity } from '../timeline/index.js';
7
9
  /**
8
10
  * Handle shared commands that work identically across all channels.
9
11
  * Returns { handled: true, response } if a command was matched.
@@ -16,7 +18,9 @@ export async function handleSharedCommand(ctx, tabManager) {
16
18
  const tabs = tabManager.listTabs();
17
19
  if (tabs.length === 0)
18
20
  return { handled: true, response: 'No tabs.' };
19
- const list = tabs.map(t => `• ${t.name} [${t.status}] — ${timeAgo(t.lastActivityAt)}`).join('\n');
21
+ const list = tabs
22
+ .map((t) => `• ${t.name} [${t.status}] — ${timeAgo(t.lastActivityAt)}`)
23
+ .join('\n');
20
24
  return { handled: true, response: list };
21
25
  }
22
26
  // /stop <name>
@@ -32,12 +36,22 @@ export async function handleSharedCommand(ctx, tabManager) {
32
36
  const rest = text.slice(5);
33
37
  const setPromptMatch = rest.match(/^(\S+)\s+--set-prompt\s+"([^"]+)"/);
34
38
  if (setPromptMatch) {
39
+ if (!isAdmin)
40
+ return { handled: true, response: 'Only admin can change system prompts.' };
35
41
  const tabName = setPromptMatch[1];
42
+ if (tabName !== 'default') {
43
+ const nameErr = validateTabName(tabName);
44
+ if (nameErr)
45
+ return { handled: true, response: `Invalid tab name: ${nameErr}` };
46
+ }
36
47
  const systemPrompt = setPromptMatch[2];
37
- const { getDb } = await import('../db/index.js');
38
- const db = getDb();
39
- db.prepare('UPDATE tabs SET system_prompt = ? WHERE name = ?').run(systemPrompt, tabName);
40
- return { handled: true, response: `System prompt updated for tab "${tabName}"` };
48
+ const updated = tabManager.setSystemPrompt(tabName, systemPrompt);
49
+ return {
50
+ handled: true,
51
+ response: updated
52
+ ? `System prompt updated for tab "${tabName}"`
53
+ : `Tab "${tabName}" not found.`,
54
+ };
41
55
  }
42
56
  const spaceIdx = rest.indexOf(' ');
43
57
  if (spaceIdx === -1) {
@@ -51,42 +65,9 @@ export async function handleSharedCommand(ctx, tabManager) {
51
65
  // /tab with a valid name + message — not handled here, falls through to message handling
52
66
  return { handled: false };
53
67
  }
54
- // /register [name]
55
- if (text === '/register' || text.startsWith('/register ')) {
56
- const { resolveUser, registerUser, hasAdmin } = await import('../users/index.js');
57
- const existing = resolveUser(ctx.channelId, userId);
58
- if (existing) {
59
- return { handled: true, response: `You're already registered as "${existing.name}" (${existing.role}).` };
60
- }
61
- const name = text.slice(10).trim() || `user-${userId}`;
62
- const role = hasAdmin() ? 'user' : 'admin';
63
- const user = registerUser(name, ctx.channelId, userId, role);
64
- return { handled: true, response: `Registered as "${user.name}" (${user.role}).${role === 'admin' ? ' You are the admin.' : ''}` };
65
- }
66
- // /link channel:peerId
67
- if (text.startsWith('/link ')) {
68
- const { resolveUser, linkIdentity } = await import('../users/index.js');
69
- const user = resolveUser(ctx.channelId, userId);
70
- if (!user)
71
- return { handled: true, response: 'Register first: /register' };
72
- const parts = text.slice(6).trim().split(':');
73
- if (parts.length !== 2) {
74
- return { handled: true, response: 'Usage: /link channel:peerId (e.g., /link discord:123456789)' };
75
- }
76
- const success = linkIdentity(user.id, parts[0], parts[1]);
77
- return { handled: true, response: success ? `Linked ${parts[0]} identity.` : 'Failed to link — already linked or invalid.' };
78
- }
79
- // /users (admin only)
80
- if (text === '/users') {
81
- if (!isAdmin)
82
- return { handled: true, response: 'Admin only.' };
83
- const { listUsers } = await import('../users/index.js');
84
- const users = listUsers();
85
- if (users.length === 0)
86
- return { handled: true, response: 'No registered users.' };
87
- const list = users.map(u => `• ${u.name} [${u.role}] — ${u.id.slice(0, 8)}`).join('\n');
88
- return { handled: true, response: `${users.length} user(s):\n${list}` };
89
- }
68
+ // /register, /link, /users were part of unused multi-user scaffolding —
69
+ // removed in the audit fix pass. Beecork is single-user; admin is the first
70
+ // allowedUserId on Telegram (or config.telegram.adminUserId).
90
71
  // /watches
91
72
  if (text === '/watches' || text.startsWith('/watches@')) {
92
73
  const { getDb } = await import('../db/index.js');
@@ -94,23 +75,27 @@ export async function handleSharedCommand(ctx, tabManager) {
94
75
  const watchers = db.prepare('SELECT * FROM watchers ORDER BY created_at').all();
95
76
  if (watchers.length === 0)
96
77
  return { handled: true, response: 'No watchers configured.' };
97
- const watchList = watchers.map((w) => {
78
+ const watchList = watchers
79
+ .map((w) => {
98
80
  const status = w.enabled ? 'active' : 'disabled';
99
81
  return `[${status}] ${w.name} -- ${w.schedule} (triggers: ${w.trigger_count})`;
100
- }).join('\n');
82
+ })
83
+ .join('\n');
101
84
  return { handled: true, response: `Watchers:\n${watchList}` };
102
85
  }
103
86
  // /tasks
104
87
  if (text === '/tasks' || text.startsWith('/tasks@')) {
105
88
  const { getDb } = await import('../db/index.js');
106
89
  const db = getDb();
107
- const tasks = db.prepare('SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at').all('local');
90
+ const tasks = db.prepare('SELECT * FROM tasks ORDER BY created_at').all();
108
91
  if (tasks.length === 0)
109
92
  return { handled: true, response: 'No tasks scheduled.' };
110
- const taskList = tasks.map((t) => {
93
+ const taskList = tasks
94
+ .map((t) => {
111
95
  const status = t.enabled ? 'enabled' : 'disabled';
112
96
  return `[${status}] ${t.name} (${t.schedule_type}: ${t.schedule}) -> tab:${t.tab_name}`;
113
- }).join('\n');
97
+ })
98
+ .join('\n');
114
99
  return { handled: true, response: `Tasks:\n${taskList}` };
115
100
  }
116
101
  // /cost
@@ -127,14 +112,17 @@ export async function handleSharedCommand(ctx, tabManager) {
127
112
  // /handoff [tab]
128
113
  if (text.startsWith('/handoff')) {
129
114
  const tabName = text.slice(9).trim() || 'default';
130
- const { exportTab, formatHandoffInfo } = await import('../cli/handoff.js');
131
115
  const info = exportTab(tabName);
132
116
  if (!info)
133
117
  return { handled: true, response: `Tab "${tabName}" not found.` };
118
+ logActivity('user_command', '/handoff', { tabName });
134
119
  return { handled: true, response: formatHandoffInfo(info) };
135
120
  }
136
121
  // /folders (also accept legacy /projects)
137
- if (text === '/folders' || text === '/projects' || text.startsWith('/folders@') || text.startsWith('/projects@')) {
122
+ if (text === '/folders' ||
123
+ text === '/projects' ||
124
+ text.startsWith('/folders@') ||
125
+ text.startsWith('/projects@')) {
138
126
  const { listProjects } = await import('../projects/index.js');
139
127
  const projects = listProjects();
140
128
  if (projects.length === 0)
@@ -151,15 +139,22 @@ export async function handleSharedCommand(ctx, tabManager) {
151
139
  return { handled: true, response: msg };
152
140
  }
153
141
  // /folder <name> (also accept legacy /project <name>)
154
- if ((text.startsWith('/folder ') && !text.startsWith('/folders')) || (text.startsWith('/project ') && !text.startsWith('/projects'))) {
142
+ if ((text.startsWith('/folder ') && !text.startsWith('/folders')) ||
143
+ (text.startsWith('/project ') && !text.startsWith('/projects'))) {
155
144
  const prefix = text.startsWith('/folder ') ? '/folder ' : '/project ';
156
145
  const name = text.slice(prefix.length).trim();
157
146
  const { getProject, setUserContext } = await import('../projects/index.js');
158
147
  const project = getProject(name);
159
148
  if (!project)
160
- return { handled: true, response: `Folder "${name}" not found. Use /folders to list or /newfolder to create.` };
149
+ return {
150
+ handled: true,
151
+ response: `Folder "${name}" not found. Use /folders to list or /newfolder to create.`,
152
+ };
161
153
  setUserContext(userId, project.name, project.name);
162
- return { handled: true, response: `Switched to folder: ${project.name}\nPath: ${project.path}\n\nNext messages will work in this folder.` };
154
+ return {
155
+ handled: true,
156
+ response: `Switched to folder: ${project.name}\nPath: ${project.path}\n\nNext messages will work in this folder.`,
157
+ };
163
158
  }
164
159
  // /newfolder <name> [path] (also accept legacy /newproject)
165
160
  if (text.startsWith('/newfolder ') || text.startsWith('/newproject ')) {
@@ -172,18 +167,23 @@ export async function handleSharedCommand(ctx, tabManager) {
172
167
  const { createProject, setUserContext } = await import('../projects/index.js');
173
168
  const project = createProject(name, customPath);
174
169
  setUserContext(userId, project.name, project.name);
175
- return { handled: true, response: `✓ Folder "${name}" created at ${project.path}\nSwitched to this folder.` };
170
+ return {
171
+ handled: true,
172
+ response: `✓ Folder "${name}" created at ${project.path}\nSwitched to this folder.`,
173
+ };
176
174
  }
177
175
  // /close <tab>
178
176
  if (text.startsWith('/close ')) {
179
177
  const tabNameToClose = text.slice(7).trim();
180
178
  if (!tabNameToClose)
181
179
  return { handled: true, response: 'Usage: /close <tabname>' };
182
- // Stop any running subprocess before deleting records
183
- tabManager.stopTab(tabNameToClose);
184
- const { closeTab } = await import('../projects/index.js');
185
- const closed = closeTab(tabNameToClose);
186
- return { handled: true, response: closed ? `Tab "${tabNameToClose}" permanently closed. History deleted.` : `Tab "${tabNameToClose}" not found.` };
180
+ const closed = tabManager.closeTab(tabNameToClose);
181
+ return {
182
+ handled: true,
183
+ response: closed
184
+ ? `Tab "${tabNameToClose}" permanently closed. History deleted.`
185
+ : `Tab "${tabNameToClose}" not found.`,
186
+ };
187
187
  }
188
188
  // /fresh <folder>
189
189
  if (text.startsWith('/fresh ')) {
@@ -194,36 +194,42 @@ export async function handleSharedCommand(ctx, tabManager) {
194
194
  return { handled: true, response: `Folder "${folderName}" not found.` };
195
195
  const freshTabName = `${folderName}-${Date.now().toString(36).slice(-4)}`;
196
196
  setUserContext(userId, project.name, freshTabName);
197
- return { handled: true, response: `Fresh start in "${folderName}" (tab: ${freshTabName})\nSend your message now.` };
198
- }
199
- return { handled: false };
200
- }
201
- /**
202
- * Shared project routing logic — resolves which tab/project to use for a message.
203
- * Extracted from the identical blocks in Telegram, WhatsApp, and Discord channels.
204
- */
205
- export async function resolveProjectRoute(rawPrompt, tabName, text, userId) {
206
- if (tabName !== 'default' || text.startsWith('/tab ')) {
207
- return { effectiveTabName: tabName };
197
+ return {
198
+ handled: true,
199
+ response: `Fresh start in "${folderName}" (tab: ${freshTabName})\nSend your message now.`,
200
+ };
208
201
  }
209
- try {
210
- const { routeMessage, setUserContext, listProjects } = await import('../projects/index.js');
211
- const decision = routeMessage(rawPrompt, { userId });
212
- if (decision.needsConfirmation) {
213
- const projects = listProjects().filter((p) => p.type === 'user-project');
214
- const options = projects.map((p, i) => `${i + 1}) ${p.name}`).join('\n');
202
+ // /history [date|yesterday]
203
+ if (text === '/history' || text.startsWith('/history ')) {
204
+ const dateArg = text.slice(9).trim();
205
+ const { getTimeline, formatTimeline } = await import('../timeline/index.js');
206
+ let date;
207
+ if (dateArg === 'yesterday') {
208
+ date = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
209
+ }
210
+ else if (dateArg) {
211
+ date = dateArg;
212
+ }
213
+ else {
214
+ date = new Date().toISOString().slice(0, 10);
215
+ }
216
+ const events = getTimeline({ date, limit: 30 });
217
+ return { handled: true, response: formatTimeline(events) };
218
+ }
219
+ // /knowledge
220
+ if (text === '/knowledge') {
221
+ const { getAllKnowledge, formatKnowledgeForContext } = await import('../knowledge/index.js');
222
+ const entries = getAllKnowledge();
223
+ if (entries.length === 0) {
215
224
  return {
216
- effectiveTabName: tabName,
217
- confirmationMessage: `Which project?\n${options}\n\nReply with the number, or just send your message with /project <name> first.`,
225
+ handled: true,
226
+ response: 'No knowledge stored yet. Beecork learns from your conversations.',
218
227
  };
219
228
  }
220
- setUserContext(userId, decision.project.name, decision.tabName);
221
- return {
222
- effectiveTabName: decision.tabName,
223
- projectPath: decision.project.path,
224
- };
225
- }
226
- catch {
227
- return { effectiveTabName: tabName };
229
+ return { handled: true, response: formatKnowledgeForContext(entries).slice(0, 4000) };
228
230
  }
231
+ return { handled: false };
229
232
  }
233
+ // resolveProjectRoute lives at src/projects/router.ts — re-exported here so
234
+ // existing callers (channels/pipeline.ts) don't need to update their import path.
235
+ export { resolveProjectRoute } from '../projects/router.js';
@@ -1,22 +1,17 @@
1
- import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from './types.js';
1
+ import type { Channel, ChannelContext, SendOptions } from './types.js';
2
2
  export declare class DiscordChannel implements Channel {
3
3
  readonly id = "discord";
4
4
  readonly name = "Discord";
5
5
  readonly maxMessageLength = 2000;
6
- readonly supportsStreaming = false;
7
- readonly supportsMedia = true;
8
6
  private client;
9
7
  private ctx;
10
8
  private allowedUserIds;
11
- private sttProvider;
12
- private ttsProvider;
13
- private sttWarmedUp;
9
+ private voice;
14
10
  constructor(ctx: ChannelContext);
15
11
  start(): Promise<void>;
16
12
  stop(): void;
17
- onMessage(_handler: InboundMessageHandler): void;
18
- sendMessage(peerId: string, text: string, options?: SendOptions): Promise<void>;
19
- sendNotification(message: string, urgent?: boolean): Promise<void>;
13
+ sendMessage(peerId: string, text: string, _options?: SendOptions): Promise<void>;
14
+ sendNotification(message: string, _urgent?: boolean): Promise<void>;
20
15
  setTyping(peerId: string, active: boolean): Promise<void>;
21
16
  private sendResponse;
22
17
  }
@@ -1,22 +1,30 @@
1
+ /*
2
+ * Discord integration via discord.js (peer-optional, dynamic-imported).
3
+ *
4
+ * discord.js's strict union types (PartialGroupDMChannel, TextChannel, etc.)
5
+ * require narrowing at every send/sendTyping callsite. The runtime channel
6
+ * objects we receive in handlers all support these methods, but expressing
7
+ * that to TypeScript via the published types is a significant refactor with
8
+ * no runtime benefit. We accept `any` at this library boundary — same pattern
9
+ * as the WhatsApp/baileys integration.
10
+ */
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
12
  import { logger } from '../util/logger.js';
2
- import { chunkText, parseTabMessage } from '../util/text.js';
3
- import { retryWithBackoff } from '../util/retry.js';
13
+ import { parseTabMessage } from '../util/text.js';
14
+ import { sendChunkedResponse } from './send-helpers.js';
4
15
  import { inboundLimiter } from '../util/rate-limiter.js';
5
16
  import { saveMedia, isOversized } from '../media/store.js';
6
- import { initVoiceProviders } from '../voice/index.js';
17
+ import { VoiceState } from './voice-state.js';
7
18
  import { processInboundMessage } from './pipeline.js';
19
+ import { isChannelAdmin } from './admin.js';
8
20
  export class DiscordChannel {
9
21
  id = 'discord';
10
22
  name = 'Discord';
11
23
  maxMessageLength = 2000;
12
- supportsStreaming = false; // Discord message editing is rate-limited
13
- supportsMedia = true;
14
24
  client = null; // Discord.js Client
15
25
  ctx;
16
26
  allowedUserIds;
17
- sttProvider = null;
18
- ttsProvider = null;
19
- sttWarmedUp = false;
27
+ voice = new VoiceState('discord');
20
28
  constructor(ctx) {
21
29
  this.ctx = ctx;
22
30
  this.allowedUserIds = new Set((ctx.config.discord?.allowedUserIds ?? []).map(String));
@@ -37,10 +45,8 @@ export class DiscordChannel {
37
45
  GatewayIntentBits.MessageContent,
38
46
  ],
39
47
  });
40
- // Voice providers
41
- const { stt, tts } = initVoiceProviders(this.ctx.config.voice);
42
- this.sttProvider = stt;
43
- this.ttsProvider = tts;
48
+ // Voice providers (STT + TTS)
49
+ this.voice.init(this.ctx.config);
44
50
  this.client.on(Events.MessageCreate, async (message) => {
45
51
  // Ignore bot messages
46
52
  if (message.author.bot)
@@ -59,17 +65,18 @@ export class DiscordChannel {
59
65
  }
60
66
  // Rate limit
61
67
  if (!inboundLimiter.check(this.id)) {
62
- await message.reply("I'm receiving too many messages right now. Please wait a moment.").catch(() => { });
68
+ await message
69
+ .reply("I'm receiving too many messages right now. Please wait a moment.")
70
+ .catch(() => { });
63
71
  return;
64
72
  }
65
73
  const text = message.content
66
74
  .replace(/<@!?\d+>/g, '') // Remove mentions
67
75
  .trim();
68
- // Warm up STT connection on first message with attachments
69
- if (this.sttProvider && !this.sttWarmedUp && message.attachments.size > 0) {
70
- this.sttProvider.warmup?.();
71
- this.sttWarmedUp = true;
72
- }
76
+ // Warm up STT connection on first message with attachments.
77
+ // (Discord intentionally only warms up; it doesn't transcribe like Telegram/WhatsApp.)
78
+ if (message.attachments.size > 0)
79
+ await this.voice.warmup();
73
80
  // Download attachments
74
81
  const media = [];
75
82
  for (const attachment of message.attachments.values()) {
@@ -115,7 +122,7 @@ export class DiscordChannel {
115
122
  const cmdResult = await handleSharedCommand({
116
123
  userId: message.author.id,
117
124
  text,
118
- isAdmin: this.allowedUserIds.size > 0 && message.author.id === [...this.allowedUserIds][0],
125
+ isAdmin: isChannelAdmin(this.allowedUserIds, message.author.id, this.ctx.config.discord?.adminUserId),
119
126
  channelId: 'discord',
120
127
  }, this.ctx.tabManager);
121
128
  if (cmdResult.handled) {
@@ -127,12 +134,16 @@ export class DiscordChannel {
127
134
  // Discord-specific: use thread name as tab if in a thread
128
135
  let overrideTabName;
129
136
  if (message.channel.isThread?.()) {
130
- const threadName = (message.channel.name || '')
137
+ const sanitized = (message.channel.name || '')
131
138
  .replace(/[^a-zA-Z0-9-]/g, '-')
132
139
  .replace(/^-+|-+$/g, '')
133
140
  .slice(0, 32);
134
- if (threadName && tabName === 'default')
135
- overrideTabName = threadName;
141
+ // Run the synthesized name through validateTabName so weird thread
142
+ // names (empty, starts with hyphen, "default") don't blow up downstream.
143
+ const { validateTabName } = await import('../config.js');
144
+ if (sanitized && tabName === 'default' && !validateTabName(sanitized)) {
145
+ overrideTabName = sanitized;
146
+ }
136
147
  }
137
148
  // Typing indicator refresh
138
149
  const typingInterval = setInterval(() => {
@@ -146,7 +157,7 @@ export class DiscordChannel {
146
157
  channelId: 'discord',
147
158
  tabManager: this.ctx.tabManager,
148
159
  voiceReplyMode: this.ctx.config.voice?.replyMode,
149
- ttsProvider: this.ttsProvider,
160
+ ttsProvider: this.voice.tts,
150
161
  userId: message.author.id,
151
162
  sendProgress: (msg) => {
152
163
  message.channel.send(msg).catch(() => { });
@@ -188,26 +199,25 @@ export class DiscordChannel {
188
199
  }
189
200
  logger.info('Discord bot stopped');
190
201
  }
191
- onMessage(_handler) {
192
- // Messages are handled directly in start()
193
- }
194
- async sendMessage(peerId, text, options) {
202
+ async sendMessage(peerId, text, _options) {
195
203
  if (!this.client)
196
204
  return;
197
205
  try {
198
206
  const channel = await this.client.channels.fetch(peerId);
199
207
  if (!channel?.isTextBased())
200
208
  return;
201
- const chunks = chunkText(text, this.maxMessageLength);
202
- for (const chunk of chunks) {
203
- await retryWithBackoff(() => channel.send(chunk), [1000, 5000, 15000], 'discord-send');
204
- }
209
+ await sendChunkedResponse({
210
+ text,
211
+ maxLength: this.maxMessageLength,
212
+ retryLabel: 'discord-send',
213
+ sendChunk: (chunk) => channel.send(chunk),
214
+ });
205
215
  }
206
216
  catch (err) {
207
217
  logger.error(`Discord send failed for ${peerId}:`, err);
208
218
  }
209
219
  }
210
- async sendNotification(message, urgent) {
220
+ async sendNotification(message, _urgent) {
211
221
  if (!this.client)
212
222
  return;
213
223
  // Send to all allowed users via DM
@@ -235,15 +245,22 @@ export class DiscordChannel {
235
245
  catch { }
236
246
  }
237
247
  async sendResponse(message, text, tabName) {
238
- const prefix = tabName && tabName !== 'default' ? `[${tabName}] ` : '';
239
- const fullText = prefix + text;
240
- const chunks = chunkText(fullText, this.maxMessageLength);
241
- // First chunk as reply, rest as follow-ups
242
- if (chunks.length > 0) {
243
- await retryWithBackoff(() => message.reply(chunks[0]), [1000, 5000], 'discord-reply');
244
- }
245
- for (let i = 1; i < chunks.length; i++) {
246
- await retryWithBackoff(() => message.channel.send(chunks[i]), [1000, 5000], 'discord-send');
247
- }
248
+ // Discord quirk: first chunk uses message.reply so it threads under the
249
+ // original user message; follow-ups use channel.send. sendChunkedResponse
250
+ // handles prefix + chunking + retry envelope; the closure-tracked chunkIdx
251
+ // keeps the "first chunk replies, rest send" behavior.
252
+ let chunkIdx = 0;
253
+ await sendChunkedResponse({
254
+ text,
255
+ tabName,
256
+ maxLength: this.maxMessageLength,
257
+ retryDelays: [1000, 5000],
258
+ retryLabel: 'discord-send',
259
+ sendChunk: (chunk) => {
260
+ const isFirst = chunkIdx === 0;
261
+ chunkIdx++;
262
+ return isFirst ? message.reply(chunk) : message.channel.send(chunk);
263
+ },
264
+ });
248
265
  }
249
266
  }
@@ -4,4 +4,4 @@ export { WhatsAppChannel } from './whatsapp.js';
4
4
  export { WebhookChannel } from './webhook.js';
5
5
  export { DiscordChannel } from './discord.js';
6
6
  export { loadCommunityChannels } from './loader.js';
7
- export type { Channel, ChannelContext, InboundMessage, InboundMessageHandler, MediaAttachment, SendOptions } from './types.js';
7
+ export type { Channel, ChannelContext, MediaAttachment, SendOptions } from './types.js';