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
@@ -17,14 +17,14 @@ export class RecraftGenerator {
17
17
  const response = await fetch(endpoint, {
18
18
  method: 'POST',
19
19
  headers: {
20
- 'Authorization': `Bearer ${this.apiKey}`,
20
+ Authorization: `Bearer ${this.apiKey}`,
21
21
  'Content-Type': 'application/json',
22
22
  },
23
23
  body: JSON.stringify({
24
24
  prompt: prompt.slice(0, 2000),
25
25
  model: isVector ? 'recraftv4' : 'recraftv4',
26
26
  response_format: isVector ? 'url' : 'b64_json',
27
- style: isVector ? 'vector_illustration' : (options?.style || 'realistic_image'),
27
+ style: isVector ? 'vector_illustration' : options?.style || 'realistic_image',
28
28
  size: options?.width && options?.height ? `${options.width}x${options.height}` : '1024x1024',
29
29
  }),
30
30
  signal: AbortSignal.timeout(120000),
@@ -33,7 +33,7 @@ export class RecraftGenerator {
33
33
  const err = await response.text();
34
34
  throw new Error(`Recraft error ${response.status}: ${err.slice(0, 200)}`);
35
35
  }
36
- const data = await response.json();
36
+ const data = (await response.json());
37
37
  const image = data.data?.[0];
38
38
  if (isVector && image?.url) {
39
39
  // Download SVG from URL
@@ -15,7 +15,7 @@ export class RunwayGenerator {
15
15
  const startResponse = await fetch('https://api.runwayml.com/v1/image_to_video', {
16
16
  method: 'POST',
17
17
  headers: {
18
- 'Authorization': `Bearer ${this.apiKey}`,
18
+ Authorization: `Bearer ${this.apiKey}`,
19
19
  'Content-Type': 'application/json',
20
20
  'X-Runway-Version': '2024-11-06',
21
21
  },
@@ -30,14 +30,14 @@ export class RunwayGenerator {
30
30
  const err = await startResponse.text();
31
31
  throw new Error(`Runway error ${startResponse.status}: ${err.slice(0, 200)}`);
32
32
  }
33
- const { id: taskId } = await startResponse.json();
33
+ const { id: taskId } = (await startResponse.json());
34
34
  // Poll for completion (max 5 minutes)
35
- const headers = { 'Authorization': `Bearer ${this.apiKey}`, 'X-Runway-Version': '2024-11-06' };
35
+ const headers = { Authorization: `Bearer ${this.apiKey}`, 'X-Runway-Version': '2024-11-06' };
36
36
  const videoUrl = await pollForCompletion({
37
37
  statusUrl: `https://api.runwayml.com/v1/tasks/${taskId}`,
38
38
  headers,
39
39
  isComplete: (data) => data.status === 'SUCCEEDED' && !!data.output?.[0],
40
- isFailed: (data) => data.status === 'FAILED' ? 'generation failed' : null,
40
+ isFailed: (data) => (data.status === 'FAILED' ? 'generation failed' : null),
41
41
  getResultUrl: (data) => data.output[0],
42
42
  label: 'Runway',
43
43
  });
@@ -18,8 +18,8 @@ export class StableDiffusionGenerator {
18
18
  const response = await fetch('https://api.stability.ai/v2beta/stable-image/generate/core', {
19
19
  method: 'POST',
20
20
  headers: {
21
- 'Authorization': `Bearer ${this.apiKey}`,
22
- 'Accept': 'image/*',
21
+ Authorization: `Bearer ${this.apiKey}`,
22
+ Accept: 'image/*',
23
23
  },
24
24
  body: formData,
25
25
  signal: AbortSignal.timeout(120000),
@@ -24,7 +24,7 @@ export class VeoGenerator {
24
24
  const err = await response.text();
25
25
  throw new Error(`Veo error ${response.status}: ${err.slice(0, 200)}`);
26
26
  }
27
- const data = await response.json();
27
+ const data = (await response.json());
28
28
  const buffer = Buffer.from(data.predictions[0].bytesBase64Encoded, 'base64');
29
29
  const filePath = saveMedia(buffer, 'mp4', 'generated-video.mp4');
30
30
  return { filePath, mimeType: 'video/mp4', durationMs: (options?.duration || 5) * 1000 };
@@ -1,11 +1,6 @@
1
1
  export type { MediaGenerator, MediaType, GenerateOptions, GenerateResult } from './types.js';
2
2
  export { createMediaGenerator } from './factory.js';
3
- export { saveMedia, cleanupMedia, ensureMediaDir, getMediaDir, isOversized } from './store.js';
3
+ export { saveMedia, cleanupMedia, ensureMediaDir, isOversized } from './store.js';
4
4
  import type { MediaGenerator } from './types.js';
5
- export interface MediaGeneratorConfig {
6
- provider: string;
7
- apiKey?: string;
8
- model?: string;
9
- style?: string;
10
- }
5
+ import type { MediaGeneratorConfig } from '../types.js';
11
6
  export declare function initMediaGenerators(configs?: MediaGeneratorConfig[]): MediaGenerator[];
@@ -1,8 +1,8 @@
1
1
  export { createMediaGenerator } from './factory.js';
2
- export { saveMedia, cleanupMedia, ensureMediaDir, getMediaDir, isOversized } from './store.js';
2
+ export { saveMedia, cleanupMedia, ensureMediaDir, isOversized } from './store.js';
3
3
  import { createMediaGenerator } from './factory.js';
4
4
  export function initMediaGenerators(configs) {
5
5
  if (!configs || configs.length === 0)
6
6
  return [];
7
- return configs.map(c => createMediaGenerator(c)).filter((g) => g !== null);
7
+ return configs.map((c) => createMediaGenerator(c)).filter((g) => g !== null);
8
8
  }
@@ -8,3 +8,10 @@ export declare function isOversized(sizeBytes: number, maxSizeMb?: number): bool
8
8
  export declare function cleanupMedia(ttlMs?: number): number;
9
9
  /** Get the media directory path */
10
10
  export declare function getMediaDir(): string;
11
+ /**
12
+ * Resolve a file path and confirm it lives inside the beecork media directory.
13
+ * Throws otherwise. Used at trust boundaries (MCP tools, voice transcription)
14
+ * where a caller could otherwise hand us an arbitrary path to read or upload.
15
+ * Returns the resolved (absolute) path on success.
16
+ */
17
+ export declare function assertInsideMediaDir(filePath: string): string;
@@ -17,9 +17,7 @@ export function saveMedia(buffer, extension, originalName) {
17
17
  const safeName = originalName
18
18
  ? originalName.replace(/[\/\\]/g, '_').replace(/\.\./g, '_')
19
19
  : undefined;
20
- const name = safeName
21
- ? `${timestamp}-${safeName}`
22
- : `${timestamp}.${extension}`;
20
+ const name = safeName ? `${timestamp}-${safeName}` : `${timestamp}.${extension}`;
23
21
  const filePath = path.join(MEDIA_DIR, name);
24
22
  fs.writeFileSync(filePath, buffer);
25
23
  return filePath;
@@ -43,7 +41,9 @@ export function cleanupMedia(ttlMs = DEFAULT_TTL_MS) {
43
41
  cleaned++;
44
42
  }
45
43
  }
46
- catch { /* file may have been deleted by another process */ }
44
+ catch {
45
+ /* file may have been deleted by another process */
46
+ }
47
47
  }
48
48
  if (cleaned > 0)
49
49
  logger.info(`Media cleanup: removed ${cleaned} expired files`);
@@ -53,3 +53,17 @@ export function cleanupMedia(ttlMs = DEFAULT_TTL_MS) {
53
53
  export function getMediaDir() {
54
54
  return MEDIA_DIR;
55
55
  }
56
+ /**
57
+ * Resolve a file path and confirm it lives inside the beecork media directory.
58
+ * Throws otherwise. Used at trust boundaries (MCP tools, voice transcription)
59
+ * where a caller could otherwise hand us an arbitrary path to read or upload.
60
+ * Returns the resolved (absolute) path on success.
61
+ */
62
+ export function assertInsideMediaDir(filePath) {
63
+ const resolved = path.resolve(filePath);
64
+ const root = path.resolve(MEDIA_DIR) + path.sep;
65
+ if (!resolved.startsWith(root)) {
66
+ throw new Error(`filePath must be inside the beecork media directory (${MEDIA_DIR})`);
67
+ }
68
+ return resolved;
69
+ }
@@ -19,3 +19,25 @@ export interface MediaGenerator {
19
19
  readonly supportedTypes: MediaType[];
20
20
  generate(type: MediaType, prompt: string, options?: GenerateOptions): Promise<GenerateResult>;
21
21
  }
22
+ /** Gemini `:generateContent` response shape (used by lyria + nano-banana). */
23
+ export interface GeminiInlineDataPart {
24
+ inlineData?: {
25
+ mimeType?: string;
26
+ data: string;
27
+ };
28
+ }
29
+ export interface GeminiCandidate {
30
+ content?: {
31
+ parts?: GeminiInlineDataPart[];
32
+ };
33
+ }
34
+ export interface GeminiGenerateContentResponse {
35
+ candidates?: GeminiCandidate[];
36
+ }
37
+ /** Recraft `/v1/images/generations` response. */
38
+ export interface RecraftGenerateResponse {
39
+ data?: Array<{
40
+ url?: string;
41
+ b64_json?: string;
42
+ }>;
43
+ }
@@ -1,6 +1,4 @@
1
1
  export type { NotificationProvider } from './types.js';
2
- export { PushoverProvider } from './pushover.js';
3
- export { NtfyProvider } from './ntfy.js';
4
- export { WebhookNotificationProvider } from './webhook-provider.js';
5
2
  import type { NotificationProvider } from './types.js';
6
- export declare function createNotificationProvider(config: any): NotificationProvider | null;
3
+ import type { NotificationConfig } from '../types.js';
4
+ export declare function createNotificationProvider(config: NotificationConfig): NotificationProvider | null;
@@ -1,34 +1,21 @@
1
- export { PushoverProvider } from './pushover.js';
2
- export { NtfyProvider } from './ntfy.js';
3
- export { WebhookNotificationProvider } from './webhook-provider.js';
4
1
  import { logger } from '../util/logger.js';
5
2
  import { PushoverProvider } from './pushover.js';
6
3
  import { NtfyProvider } from './ntfy.js';
7
4
  import { WebhookNotificationProvider } from './webhook-provider.js';
8
5
  export function createNotificationProvider(config) {
9
- if (!config?.type)
10
- return null;
11
6
  switch (config.type) {
12
7
  case 'pushover':
13
- if (!config.userKey || !config.appToken) {
14
- logger.warn('Pushover: missing userKey or appToken');
15
- return null;
16
- }
17
8
  return new PushoverProvider(config.userKey, config.appToken);
18
9
  case 'ntfy':
19
- if (!config.topic) {
20
- logger.warn('ntfy: missing topic');
21
- return null;
22
- }
23
10
  return new NtfyProvider(config.topic, config.server);
24
11
  case 'webhook':
25
- if (!config.url) {
26
- logger.warn('Webhook notification: missing url');
27
- return null;
28
- }
29
12
  return new WebhookNotificationProvider(config.url, config.headers);
30
- default:
31
- logger.warn(`Unknown notification provider: ${config.type}`);
13
+ default: {
14
+ // Exhaustiveness check — TS will error here if NotificationConfig gains
15
+ // a new variant without a matching case above.
16
+ const _exhaustive = config;
17
+ logger.warn(`Unknown notification provider: ${JSON.stringify(_exhaustive)}`);
32
18
  return null;
19
+ }
33
20
  }
34
21
  }
@@ -11,9 +11,9 @@ export class NtfyProvider {
11
11
  const response = await fetch(`${this.server}/${this.topic}`, {
12
12
  method: 'POST',
13
13
  headers: {
14
- 'Title': 'Beecork',
15
- 'Priority': urgent ? '5' : '3',
16
- 'Tags': urgent ? 'warning' : 'robot',
14
+ Title: 'Beecork',
15
+ Priority: urgent ? '5' : '3',
16
+ Tags: urgent ? 'warning' : 'robot',
17
17
  },
18
18
  body: message,
19
19
  signal: AbortSignal.timeout(10000),
@@ -13,7 +13,7 @@ export interface ActivitySummary {
13
13
  period: string;
14
14
  messagesReceived: number;
15
15
  messagesFromAssistant: number;
16
- cronJobsFired: number;
16
+ tasksFired: number;
17
17
  memoriesCreated: number;
18
18
  totalCost: number;
19
19
  activeTabsCount: number;
@@ -1,30 +1,49 @@
1
1
  import { getDb } from '../db/index.js';
2
2
  export function getCostSummary() {
3
3
  const db = getDb();
4
- const today = db.prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now')").get().total;
5
- const last7 = db.prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now', '-7 days')").get().total;
6
- const last30 = db.prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now', '-30 days')").get().total;
7
- const perTab = db.prepare(`
4
+ const today = db
5
+ .prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now')")
6
+ .get().total;
7
+ const last7 = db
8
+ .prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now', '-7 days')")
9
+ .get().total;
10
+ const last30 = db
11
+ .prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now', '-30 days')")
12
+ .get().total;
13
+ // Per-tab summed over the last 30 days only — the unbounded version grew
14
+ // linearly with the messages table and made /cost noticeably slow over time.
15
+ const perTab = db
16
+ .prepare(`
8
17
  SELECT t.name, COALESCE(SUM(m.cost_usd), 0) as cost, COUNT(m.id) as messages
9
18
  FROM tabs t LEFT JOIN messages m ON m.tab_id = t.id
19
+ AND m.created_at > date('now', '-30 days')
10
20
  GROUP BY t.id ORDER BY cost DESC
11
- `).all();
21
+ `)
22
+ .all();
12
23
  return { today, last7Days: last7, last30Days: last30, perTab };
13
24
  }
14
25
  export function getActivitySummary(hours = 24) {
15
26
  const db = getDb();
16
27
  const sinceDate = new Date(Date.now() - hours * 3600000).toISOString();
17
- const messagesReceived = db.prepare('SELECT COUNT(*) as c FROM messages WHERE role = ? AND created_at > ?').get('user', sinceDate).c;
18
- const messagesFromAssistant = db.prepare('SELECT COUNT(*) as c FROM messages WHERE role = ? AND created_at > ?').get('assistant', sinceDate).c;
19
- const cronJobsFired = db.prepare('SELECT COUNT(*) as c FROM tasks WHERE last_run_at > ?').get(sinceDate).c;
28
+ const messagesReceived = db
29
+ .prepare('SELECT COUNT(*) as c FROM messages WHERE role = ? AND created_at > ?')
30
+ .get('user', sinceDate).c;
31
+ const messagesFromAssistant = db
32
+ .prepare('SELECT COUNT(*) as c FROM messages WHERE role = ? AND created_at > ?')
33
+ .get('assistant', sinceDate).c;
34
+ const tasksFired = db.prepare('SELECT COUNT(*) as c FROM tasks WHERE last_run_at > ?').get(sinceDate).c;
20
35
  const memoriesCreated = db.prepare('SELECT COUNT(*) as c FROM memories WHERE created_at > ?').get(sinceDate).c;
21
- const totalCost = db.prepare('SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > ?').get(sinceDate).total;
22
- const activeTabsCount = db.prepare('SELECT COUNT(DISTINCT tab_id) as c FROM messages WHERE created_at > ?').get(sinceDate).c;
36
+ const totalCost = db
37
+ .prepare('SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > ?')
38
+ .get(sinceDate).total;
39
+ const activeTabsCount = db
40
+ .prepare('SELECT COUNT(DISTINCT tab_id) as c FROM messages WHERE created_at > ?')
41
+ .get(sinceDate).c;
23
42
  return {
24
43
  period: `Last ${hours} hours`,
25
44
  messagesReceived,
26
45
  messagesFromAssistant,
27
- cronJobsFired,
46
+ tasksFired,
28
47
  memoriesCreated,
29
48
  totalCost,
30
49
  activeTabsCount,
@@ -37,19 +56,25 @@ const ANOMALY_STATE_KEY = 'anomaly_spend_state';
37
56
  */
38
57
  export function checkAnomaliesWithDb(db) {
39
58
  // Today's spend
40
- const todaySpend = db.prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now')").get().total;
59
+ const todaySpend = db
60
+ .prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now')")
61
+ .get().total;
41
62
  // 7-day rolling average (excluding today)
42
- const avgSpend = db.prepare(`
63
+ const avgSpend = db
64
+ .prepare(`
43
65
  SELECT COALESCE(AVG(daily_total), 0) as avg FROM (
44
66
  SELECT date(created_at) as day, SUM(cost_usd) as daily_total
45
67
  FROM messages
46
68
  WHERE created_at > date('now', '-7 days') AND created_at < date('now')
47
69
  GROUP BY date(created_at)
48
70
  )
49
- `).get().avg;
71
+ `)
72
+ .get().avg;
50
73
  const isBreach = avgSpend > 0 && todaySpend > avgSpend * 2;
51
74
  const newState = isBreach ? 'breach' : 'ok';
52
- const prevRow = db.prepare('SELECT value FROM preferences WHERE key = ?').get(ANOMALY_STATE_KEY);
75
+ const prevRow = db
76
+ .prepare('SELECT value FROM preferences WHERE key = ?')
77
+ .get(ANOMALY_STATE_KEY);
53
78
  const prevState = prevRow?.value ?? 'ok';
54
79
  if (newState === prevState)
55
80
  return null;
@@ -85,7 +110,7 @@ export function formatActivitySummary(summary) {
85
110
  `📊 Activity (${summary.period})`,
86
111
  ` Messages in: ${summary.messagesReceived}`,
87
112
  ` Messages out: ${summary.messagesFromAssistant}`,
88
- ` Cron jobs fired: ${summary.cronJobsFired}`,
113
+ ` Tasks fired: ${summary.tasksFired}`,
89
114
  ` Memories created: ${summary.memoriesCreated}`,
90
115
  ` Active tabs: ${summary.activeTabsCount}`,
91
116
  ` Cost: $${summary.totalCost.toFixed(4)}`,
@@ -1,3 +1,4 @@
1
1
  export type { Project, RouteDecision, RoutingContext } from './types.js';
2
- export { discoverProjects, createProject, listProjects, getProject, ensureCategory, touchProject, closeTab, getWorkspaceRoot, getManagedWorkspace } from './manager.js';
3
- export { routeMessage, setUserContext } from './router.js';
2
+ export { discoverProjects, createProject, listProjects, getProject, ensureCategory, touchProject, } from './manager.js';
3
+ export { routeMessage, setUserContext, resolveProjectRoute } from './router.js';
4
+ export type { RouteResult } from './router.js';
@@ -1,2 +1,2 @@
1
- export { discoverProjects, createProject, listProjects, getProject, ensureCategory, touchProject, closeTab, getWorkspaceRoot, getManagedWorkspace } from './manager.js';
2
- export { routeMessage, setUserContext } from './router.js';
1
+ export { discoverProjects, createProject, listProjects, getProject, ensureCategory, touchProject, } from './manager.js';
2
+ export { routeMessage, setUserContext, resolveProjectRoute } from './router.js';
@@ -1,11 +1,7 @@
1
1
  import type { Project } from './types.js';
2
- /** Get the workspace root from config */
3
- export declare function getWorkspaceRoot(): string;
4
- /** Get the managed workspace path (.beecork/ under workspace root) */
5
- export declare function getManagedWorkspace(): string;
6
2
  /** Discover projects in scan paths (look for git repos, package.json, etc.) */
7
3
  export declare function discoverProjects(scanPaths?: string[]): Project[];
8
- /** Create a new project */
4
+ /** Create a new project. parentDir must resolve under an allowed root. */
9
5
  export declare function createProject(name: string, parentDir?: string): Project;
10
6
  /** Ensure a managed category exists (lazy creation) */
11
7
  export declare function ensureCategory(name: string): Project;
@@ -15,5 +11,3 @@ export declare function listProjects(): Project[];
15
11
  export declare function getProject(name: string): Project | null;
16
12
  /** Update last used timestamp */
17
13
  export declare function touchProject(name: string): void;
18
- /** Close (permanently delete) a tab */
19
- export declare function closeTab(tabName: string): boolean;
@@ -3,16 +3,28 @@ import path from 'node:path';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
  import { getDb } from '../db/index.js';
5
5
  import { getConfig } from '../config.js';
6
+ import { expandHome } from '../util/paths.js';
6
7
  import { logger } from '../util/logger.js';
7
- /** Get the workspace root from config */
8
- export function getWorkspaceRoot() {
8
+ import { invalidateProjectCache } from './router.js';
9
+ function rowToProject(r) {
10
+ return {
11
+ id: r.id,
12
+ name: r.name,
13
+ path: r.path,
14
+ type: r.type,
15
+ lastUsedAt: r.last_used_at,
16
+ createdAt: r.created_at,
17
+ };
18
+ }
19
+ /** Get the workspace root from config (module-local) */
20
+ function getWorkspaceRoot() {
9
21
  const config = getConfig();
10
22
  // Use the default tab's workingDir as workspace root
11
23
  const root = config.tabs?.default?.workingDir || process.env.HOME || '';
12
- return root.startsWith('~') ? root.replace('~', process.env.HOME || '') : root;
24
+ return expandHome(root);
13
25
  }
14
- /** Get the managed workspace path (.beecork/ under workspace root) */
15
- export function getManagedWorkspace() {
26
+ /** Get the managed workspace path (.beecork/ under workspace root) (module-local) */
27
+ function getManagedWorkspace() {
16
28
  return path.join(getWorkspaceRoot(), '.beecork');
17
29
  }
18
30
  /** Discover projects in scan paths (look for git repos, package.json, etc.) */
@@ -21,7 +33,7 @@ export function discoverProjects(scanPaths) {
21
33
  const projects = [];
22
34
  const db = getDb();
23
35
  for (let scanPath of paths) {
24
- scanPath = scanPath.startsWith('~') ? scanPath.replace('~', process.env.HOME || '') : scanPath;
36
+ scanPath = expandHome(scanPath);
25
37
  if (!fs.existsSync(scanPath))
26
38
  continue;
27
39
  try {
@@ -35,13 +47,13 @@ export function discoverProjects(scanPaths) {
35
47
  continue;
36
48
  const dirPath = path.join(scanPath, entry.name);
37
49
  // Check if it looks like a project (has .git, package.json, or similar)
38
- const isProject = fs.existsSync(path.join(dirPath, '.git'))
39
- || fs.existsSync(path.join(dirPath, 'package.json'))
40
- || fs.existsSync(path.join(dirPath, 'Cargo.toml'))
41
- || fs.existsSync(path.join(dirPath, 'go.mod'))
42
- || fs.existsSync(path.join(dirPath, 'requirements.txt'))
43
- || fs.existsSync(path.join(dirPath, 'pyproject.toml'))
44
- || fs.existsSync(path.join(dirPath, 'CLAUDE.md'));
50
+ const isProject = fs.existsSync(path.join(dirPath, '.git')) ||
51
+ fs.existsSync(path.join(dirPath, 'package.json')) ||
52
+ fs.existsSync(path.join(dirPath, 'Cargo.toml')) ||
53
+ fs.existsSync(path.join(dirPath, 'go.mod')) ||
54
+ fs.existsSync(path.join(dirPath, 'requirements.txt')) ||
55
+ fs.existsSync(path.join(dirPath, 'pyproject.toml')) ||
56
+ fs.existsSync(path.join(dirPath, 'CLAUDE.md'));
45
57
  if (isProject) {
46
58
  projects.push({
47
59
  id: uuidv4(),
@@ -65,18 +77,27 @@ export function discoverProjects(scanPaths) {
65
77
  ON CONFLICT(name) DO UPDATE SET path = excluded.path, last_used_at = datetime('now')
66
78
  `).run(project.id, project.name, project.path, project.type);
67
79
  }
80
+ invalidateProjectCache();
68
81
  return projects;
69
82
  }
70
- /** Create a new project */
83
+ /** Create a new project. parentDir must resolve under an allowed root. */
71
84
  export function createProject(name, parentDir) {
72
- const parent = parentDir || getWorkspaceRoot();
85
+ const requestedParent = parentDir || getWorkspaceRoot();
86
+ const resolvedParent = path.resolve(expandHome(requestedParent));
87
+ // Allowlist: parent must resolve under workspace root or one of the configured scan paths.
88
+ const config = getConfig();
89
+ const allowedRoots = [getWorkspaceRoot(), ...(config.projectScanPaths ?? [])].map((r) => path.resolve(expandHome(r)));
90
+ const isAllowed = allowedRoots.some((root) => resolvedParent === root || resolvedParent.startsWith(root + path.sep));
91
+ if (!isAllowed) {
92
+ throw new Error(`Project parent directory must be under workspace root or a configured scan path. Allowed: ${allowedRoots.join(', ')}`);
93
+ }
73
94
  // Sanitize name to prevent path traversal
74
95
  const safeName = path.basename(name.replace(/\.\./g, ''));
75
96
  if (!safeName)
76
97
  throw new Error('Invalid project name');
77
- const projectPath = path.resolve(parent, safeName);
98
+ const projectPath = path.resolve(resolvedParent, safeName);
78
99
  // Ensure resolved path is within the parent directory
79
- if (!projectPath.startsWith(path.resolve(parent))) {
100
+ if (!projectPath.startsWith(resolvedParent)) {
80
101
  throw new Error('Project path must be within the parent directory');
81
102
  }
82
103
  if (fs.existsSync(projectPath)) {
@@ -93,50 +114,53 @@ export function createProject(name, parentDir) {
93
114
  INSERT INTO projects (id, name, path, type) VALUES (?, ?, ?, ?)
94
115
  ON CONFLICT(name) DO UPDATE SET path = excluded.path
95
116
  `).run(id, name, projectPath, 'user-project');
96
- return { id, name, path: projectPath, type: 'user-project', lastUsedAt: new Date().toISOString(), createdAt: new Date().toISOString() };
117
+ invalidateProjectCache();
118
+ return {
119
+ id,
120
+ name,
121
+ path: projectPath,
122
+ type: 'user-project',
123
+ lastUsedAt: new Date().toISOString(),
124
+ createdAt: new Date().toISOString(),
125
+ };
97
126
  }
98
127
  /** Ensure a managed category exists (lazy creation) */
99
128
  export function ensureCategory(name) {
100
129
  const categoryPath = path.join(getManagedWorkspace(), name);
101
130
  fs.mkdirSync(categoryPath, { recursive: true });
102
131
  const db = getDb();
103
- const existing = db.prepare('SELECT * FROM projects WHERE name = ? AND type = ?').get(name, 'category');
104
- if (existing) {
105
- return { id: existing.id, name: existing.name, path: existing.path, type: 'category', lastUsedAt: existing.last_used_at, createdAt: existing.created_at };
106
- }
132
+ const existing = db
133
+ .prepare('SELECT * FROM projects WHERE name = ? AND type = ?')
134
+ .get(name, 'category');
135
+ if (existing)
136
+ return rowToProject(existing);
107
137
  const id = uuidv4();
108
138
  db.prepare('INSERT INTO projects (id, name, path, type) VALUES (?, ?, ?, ?)').run(id, name, categoryPath, 'category');
109
- return { id, name, path: categoryPath, type: 'category', lastUsedAt: new Date().toISOString(), createdAt: new Date().toISOString() };
139
+ invalidateProjectCache();
140
+ return {
141
+ id,
142
+ name,
143
+ path: categoryPath,
144
+ type: 'category',
145
+ lastUsedAt: new Date().toISOString(),
146
+ createdAt: new Date().toISOString(),
147
+ };
110
148
  }
111
149
  /** List all projects */
112
150
  export function listProjects() {
113
151
  const db = getDb();
114
- const rows = db.prepare('SELECT * FROM projects ORDER BY type, last_used_at DESC').all();
115
- return rows.map(r => ({
116
- id: r.id, name: r.name, path: r.path, type: r.type,
117
- lastUsedAt: r.last_used_at, createdAt: r.created_at,
118
- }));
152
+ const rows = db
153
+ .prepare('SELECT * FROM projects ORDER BY type, last_used_at DESC')
154
+ .all();
155
+ return rows.map(rowToProject);
119
156
  }
120
157
  /** Get a project by name */
121
158
  export function getProject(name) {
122
159
  const db = getDb();
123
160
  const row = db.prepare('SELECT * FROM projects WHERE name = ?').get(name);
124
- if (!row)
125
- return null;
126
- return { id: row.id, name: row.name, path: row.path, type: row.type, lastUsedAt: row.last_used_at, createdAt: row.created_at };
161
+ return row ? rowToProject(row) : null;
127
162
  }
128
163
  /** Update last used timestamp */
129
164
  export function touchProject(name) {
130
165
  getDb().prepare("UPDATE projects SET last_used_at = datetime('now') WHERE name = ?").run(name);
131
166
  }
132
- /** Close (permanently delete) a tab */
133
- export function closeTab(tabName) {
134
- const db = getDb();
135
- const tab = db.prepare('SELECT id FROM tabs WHERE name = ?').get(tabName);
136
- if (!tab)
137
- return false;
138
- db.prepare('DELETE FROM messages WHERE tab_id = ?').run(tab.id);
139
- db.prepare('DELETE FROM tabs WHERE id = ?').run(tab.id);
140
- logger.info(`Tab permanently closed: ${tabName}`);
141
- return true;
142
- }
@@ -1,5 +1,17 @@
1
1
  import type { RouteDecision, RoutingContext } from './types.js';
2
+ /** Invalidate the project cache. Call after createProject/discoverProjects/etc. */
3
+ export declare function invalidateProjectCache(): void;
2
4
  /** Route a message to the right project and tab */
3
5
  export declare function routeMessage(message: string, context?: RoutingContext): RouteDecision;
4
6
  /** Update current context for a user */
5
7
  export declare function setUserContext(userId: string, projectName: string, tabName: string): void;
8
+ export interface RouteResult {
9
+ effectiveTabName: string;
10
+ projectPath?: string;
11
+ confirmationMessage?: string;
12
+ }
13
+ /**
14
+ * Shared project routing logic — resolves which tab/project to use for a message.
15
+ * Pulled out of channels/ so all routing decisions live in one place.
16
+ */
17
+ export declare function resolveProjectRoute(rawPrompt: string, tabName: string, text: string, userId: string): Promise<RouteResult>;