beecork 1.5.0 → 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 (119) 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/command-handler.js +46 -14
  6. package/dist/channels/discord.d.ts +3 -6
  7. package/dist/channels/discord.js +40 -23
  8. package/dist/channels/index.d.ts +1 -1
  9. package/dist/channels/loader.js +13 -3
  10. package/dist/channels/pipeline.js +14 -5
  11. package/dist/channels/registry.d.ts +17 -1
  12. package/dist/channels/registry.js +33 -4
  13. package/dist/channels/telegram.d.ts +20 -5
  14. package/dist/channels/telegram.js +177 -42
  15. package/dist/channels/types.d.ts +11 -28
  16. package/dist/channels/voice-state.js +3 -1
  17. package/dist/channels/webhook.d.ts +1 -4
  18. package/dist/channels/webhook.js +26 -11
  19. package/dist/channels/whatsapp.d.ts +8 -4
  20. package/dist/channels/whatsapp.js +65 -29
  21. package/dist/cli/capabilities.js +4 -4
  22. package/dist/cli/channel.js +16 -6
  23. package/dist/cli/commands.js +12 -9
  24. package/dist/cli/doctor.js +80 -25
  25. package/dist/cli/handoff.d.ts +7 -14
  26. package/dist/cli/handoff.js +9 -44
  27. package/dist/cli/mcp.js +5 -5
  28. package/dist/cli/media.js +21 -8
  29. package/dist/cli/setup.js +9 -8
  30. package/dist/cli/store.js +29 -12
  31. package/dist/config.js +5 -10
  32. package/dist/daemon.js +88 -38
  33. package/dist/dashboard/html.js +80 -12
  34. package/dist/dashboard/routes.js +143 -79
  35. package/dist/dashboard/server.js +5 -1
  36. package/dist/db/connection.d.ts +29 -0
  37. package/dist/db/connection.js +37 -0
  38. package/dist/db/index.js +30 -12
  39. package/dist/db/migrations.js +84 -28
  40. package/dist/delegation/manager.js +10 -4
  41. package/dist/index.js +39 -59
  42. package/dist/knowledge/manager.js +26 -12
  43. package/dist/mcp/handlers.js +126 -57
  44. package/dist/mcp/server.js +20 -10
  45. package/dist/mcp/tool-definitions.js +68 -20
  46. package/dist/mcp/validate.d.ts +23 -0
  47. package/dist/mcp/validate.js +65 -0
  48. package/dist/media/factory.js +18 -14
  49. package/dist/media/generators/dall-e.js +2 -2
  50. package/dist/media/generators/kling.js +4 -4
  51. package/dist/media/generators/lyria.js +1 -1
  52. package/dist/media/generators/nano-banana.d.ts +1 -1
  53. package/dist/media/generators/nano-banana.js +2 -2
  54. package/dist/media/generators/poll-util.js +4 -4
  55. package/dist/media/generators/recraft.js +3 -3
  56. package/dist/media/generators/runway.js +4 -4
  57. package/dist/media/generators/stable-diffusion.js +2 -2
  58. package/dist/media/generators/veo.js +1 -1
  59. package/dist/media/index.js +1 -1
  60. package/dist/media/store.d.ts +7 -0
  61. package/dist/media/store.js +18 -4
  62. package/dist/media/types.d.ts +22 -0
  63. package/dist/notifications/index.d.ts +2 -4
  64. package/dist/notifications/index.js +6 -19
  65. package/dist/notifications/ntfy.js +3 -3
  66. package/dist/observability/analytics.js +35 -13
  67. package/dist/projects/index.d.ts +1 -1
  68. package/dist/projects/index.js +1 -1
  69. package/dist/projects/manager.d.ts +0 -4
  70. package/dist/projects/manager.js +51 -28
  71. package/dist/projects/router.d.ts +2 -0
  72. package/dist/projects/router.js +70 -45
  73. package/dist/service/install.js +15 -5
  74. package/dist/service/windows.js +1 -1
  75. package/dist/session/budget-guard.d.ts +20 -0
  76. package/dist/session/budget-guard.js +31 -0
  77. package/dist/session/circuit-breaker.d.ts +5 -3
  78. package/dist/session/circuit-breaker.js +45 -20
  79. package/dist/session/context-compactor.d.ts +32 -0
  80. package/dist/session/context-compactor.js +45 -0
  81. package/dist/session/context-monitor.js +2 -2
  82. package/dist/session/handoff.d.ts +21 -0
  83. package/dist/session/handoff.js +50 -0
  84. package/dist/session/manager.d.ts +17 -5
  85. package/dist/session/manager.js +153 -146
  86. package/dist/session/memory-store.d.ts +29 -0
  87. package/dist/session/memory-store.js +45 -0
  88. package/dist/session/message-queue.d.ts +28 -0
  89. package/dist/session/message-queue.js +52 -0
  90. package/dist/session/pending-dispatcher.d.ts +31 -0
  91. package/dist/session/pending-dispatcher.js +120 -0
  92. package/dist/session/pending-store.d.ts +60 -0
  93. package/dist/session/pending-store.js +118 -0
  94. package/dist/session/stale-session.d.ts +31 -0
  95. package/dist/session/stale-session.js +45 -0
  96. package/dist/session/subprocess.d.ts +2 -0
  97. package/dist/session/subprocess.js +33 -11
  98. package/dist/session/tab-store.js +4 -3
  99. package/dist/tasks/scheduler.d.ts +7 -0
  100. package/dist/tasks/scheduler.js +46 -6
  101. package/dist/tasks/store.js +20 -6
  102. package/dist/timeline/logger.js +3 -1
  103. package/dist/timeline/query.js +9 -3
  104. package/dist/types.d.ts +34 -9
  105. package/dist/util/auto-heal.js +15 -5
  106. package/dist/util/install-info.js +3 -1
  107. package/dist/util/logger.d.ts +1 -1
  108. package/dist/util/logger.js +63 -24
  109. package/dist/util/paths.d.ts +1 -0
  110. package/dist/util/paths.js +12 -2
  111. package/dist/util/retry.js +1 -1
  112. package/dist/util/text.js +13 -7
  113. package/dist/voice/index.js +5 -1
  114. package/dist/voice/stt.js +14 -6
  115. package/dist/voice/tts.js +1 -1
  116. package/dist/watchers/scheduler.js +9 -2
  117. package/package.json +6 -1
  118. package/dist/session/tool-classifier.d.ts +0 -4
  119. package/dist/session/tool-classifier.js +0 -56
@@ -3,19 +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';
8
+ import { invalidateProjectCache } from './router.js';
7
9
  function rowToProject(r) {
8
- return { id: r.id, name: r.name, path: r.path, type: r.type, lastUsedAt: r.last_used_at, createdAt: r.created_at };
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
+ };
9
18
  }
10
- /** Get the workspace root from config */
11
- export function getWorkspaceRoot() {
19
+ /** Get the workspace root from config (module-local) */
20
+ function getWorkspaceRoot() {
12
21
  const config = getConfig();
13
22
  // Use the default tab's workingDir as workspace root
14
23
  const root = config.tabs?.default?.workingDir || process.env.HOME || '';
15
- return root.startsWith('~') ? root.replace('~', process.env.HOME || '') : root;
24
+ return expandHome(root);
16
25
  }
17
- /** Get the managed workspace path (.beecork/ under workspace root) */
18
- export function getManagedWorkspace() {
26
+ /** Get the managed workspace path (.beecork/ under workspace root) (module-local) */
27
+ function getManagedWorkspace() {
19
28
  return path.join(getWorkspaceRoot(), '.beecork');
20
29
  }
21
30
  /** Discover projects in scan paths (look for git repos, package.json, etc.) */
@@ -24,7 +33,7 @@ export function discoverProjects(scanPaths) {
24
33
  const projects = [];
25
34
  const db = getDb();
26
35
  for (let scanPath of paths) {
27
- scanPath = scanPath.startsWith('~') ? scanPath.replace('~', process.env.HOME || '') : scanPath;
36
+ scanPath = expandHome(scanPath);
28
37
  if (!fs.existsSync(scanPath))
29
38
  continue;
30
39
  try {
@@ -38,13 +47,13 @@ export function discoverProjects(scanPaths) {
38
47
  continue;
39
48
  const dirPath = path.join(scanPath, entry.name);
40
49
  // Check if it looks like a project (has .git, package.json, or similar)
41
- const isProject = fs.existsSync(path.join(dirPath, '.git'))
42
- || fs.existsSync(path.join(dirPath, 'package.json'))
43
- || fs.existsSync(path.join(dirPath, 'Cargo.toml'))
44
- || fs.existsSync(path.join(dirPath, 'go.mod'))
45
- || fs.existsSync(path.join(dirPath, 'requirements.txt'))
46
- || fs.existsSync(path.join(dirPath, 'pyproject.toml'))
47
- || 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'));
48
57
  if (isProject) {
49
58
  projects.push({
50
59
  id: uuidv4(),
@@ -68,21 +77,17 @@ export function discoverProjects(scanPaths) {
68
77
  ON CONFLICT(name) DO UPDATE SET path = excluded.path, last_used_at = datetime('now')
69
78
  `).run(project.id, project.name, project.path, project.type);
70
79
  }
80
+ invalidateProjectCache();
71
81
  return projects;
72
82
  }
73
83
  /** Create a new project. parentDir must resolve under an allowed root. */
74
84
  export function createProject(name, parentDir) {
75
85
  const requestedParent = parentDir || getWorkspaceRoot();
76
- const resolvedParent = path.resolve(requestedParent.startsWith('~')
77
- ? requestedParent.replace('~', process.env.HOME || '')
78
- : requestedParent);
86
+ const resolvedParent = path.resolve(expandHome(requestedParent));
79
87
  // Allowlist: parent must resolve under workspace root or one of the configured scan paths.
80
88
  const config = getConfig();
81
- const allowedRoots = [
82
- getWorkspaceRoot(),
83
- ...(config.projectScanPaths ?? []),
84
- ].map(r => path.resolve(r.startsWith('~') ? r.replace('~', process.env.HOME || '') : r));
85
- const isAllowed = allowedRoots.some(root => resolvedParent === root || resolvedParent.startsWith(root + path.sep));
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));
86
91
  if (!isAllowed) {
87
92
  throw new Error(`Project parent directory must be under workspace root or a configured scan path. Allowed: ${allowedRoots.join(', ')}`);
88
93
  }
@@ -109,24 +114,44 @@ export function createProject(name, parentDir) {
109
114
  INSERT INTO projects (id, name, path, type) VALUES (?, ?, ?, ?)
110
115
  ON CONFLICT(name) DO UPDATE SET path = excluded.path
111
116
  `).run(id, name, projectPath, 'user-project');
112
- 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
+ };
113
126
  }
114
127
  /** Ensure a managed category exists (lazy creation) */
115
128
  export function ensureCategory(name) {
116
129
  const categoryPath = path.join(getManagedWorkspace(), name);
117
130
  fs.mkdirSync(categoryPath, { recursive: true });
118
131
  const db = getDb();
119
- const existing = db.prepare('SELECT * FROM projects WHERE name = ? AND type = ?').get(name, 'category');
132
+ const existing = db
133
+ .prepare('SELECT * FROM projects WHERE name = ? AND type = ?')
134
+ .get(name, 'category');
120
135
  if (existing)
121
136
  return rowToProject(existing);
122
137
  const id = uuidv4();
123
138
  db.prepare('INSERT INTO projects (id, name, path, type) VALUES (?, ?, ?, ?)').run(id, name, categoryPath, 'category');
124
- 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
+ };
125
148
  }
126
149
  /** List all projects */
127
150
  export function listProjects() {
128
151
  const db = getDb();
129
- const rows = db.prepare('SELECT * FROM projects ORDER BY type, last_used_at DESC').all();
152
+ const rows = db
153
+ .prepare('SELECT * FROM projects ORDER BY type, last_used_at DESC')
154
+ .all();
130
155
  return rows.map(rowToProject);
131
156
  }
132
157
  /** Get a project by name */
@@ -139,5 +164,3 @@ export function getProject(name) {
139
164
  export function touchProject(name) {
140
165
  getDb().prepare("UPDATE projects SET last_used_at = datetime('now') WHERE name = ?").run(name);
141
166
  }
142
- // closeTab moved to TabManager.closeTab — see src/session/manager.ts. It now kills
143
- // the subprocess and deletes the rows in one place rather than the caller doing both.
@@ -1,4 +1,6 @@
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 */
@@ -7,20 +7,41 @@ const CATEGORY_KEYWORDS = {
7
7
  };
8
8
  // Per-user current context tracking (in-memory, resets on daemon restart)
9
9
  const userContext = new Map();
10
+ // Project list cache. listProjects() ran on every inbound message and re-did a
11
+ // full SELECT + ORDER BY across the projects table; with N projects each call
12
+ // then re-lowercased every name. The cache is invalidated explicitly via
13
+ // invalidateProjectCache() at the few sites that mutate the table.
14
+ let projectsCache = null;
15
+ const PROJECTS_CACHE_TTL_MS = 30_000;
16
+ function getUserProjectsCached() {
17
+ const now = Date.now();
18
+ if (projectsCache && projectsCache.expiresAt > now)
19
+ return projectsCache.user;
20
+ const user = listProjects()
21
+ .filter((p) => p.type === 'user-project')
22
+ .map((p) => ({ ...p, nameLower: p.name.toLowerCase() }));
23
+ projectsCache = { user, expiresAt: now + PROJECTS_CACHE_TTL_MS };
24
+ return user;
25
+ }
26
+ /** Invalidate the project cache. Call after createProject/discoverProjects/etc. */
27
+ export function invalidateProjectCache() {
28
+ projectsCache = null;
29
+ }
10
30
  /** Route a message to the right project and tab */
11
31
  export function routeMessage(message, context) {
12
- const projects = listProjects().filter(p => p.type === 'user-project');
32
+ const projects = getUserProjectsCached();
13
33
  const userId = context?.userId || 'default';
34
+ const messageLower = message.toLowerCase();
14
35
  // 1. Check for explicit project mention by name
15
- const mentionedProject = findMentionedProject(message, projects);
36
+ const mentionedProject = findMentionedProject(messageLower, projects);
16
37
  if (mentionedProject) {
17
- const tabName = resolveTabInProject(mentionedProject, message, context);
38
+ const { tabName, exists } = resolveTabInProject(mentionedProject);
18
39
  touchProject(mentionedProject.name);
19
40
  recordRouting(message, mentionedProject.name);
20
41
  return {
21
42
  project: mentionedProject,
22
43
  tabName,
23
- isNewTab: !tabExists(tabName),
44
+ isNewTab: !exists,
24
45
  confidence: 0.95,
25
46
  needsConfirmation: false,
26
47
  reason: `Message mentions project "${mentionedProject.name}"`,
@@ -28,7 +49,8 @@ export function routeMessage(message, context) {
28
49
  }
29
50
  // 2. Check if continuing current context
30
51
  const currentCtx = userContext.get(userId);
31
- if (currentCtx && Date.now() - currentCtx.updatedAt < 10 * 60 * 1000) { // 10 min window
52
+ if (currentCtx && Date.now() - currentCtx.updatedAt < 10 * 60 * 1000) {
53
+ // 10 min window
32
54
  const project = getProject(currentCtx.projectName);
33
55
  if (project) {
34
56
  touchProject(project.name);
@@ -43,16 +65,16 @@ export function routeMessage(message, context) {
43
65
  }
44
66
  }
45
67
  // 3. Check routing preferences (learned patterns)
46
- const learned = checkLearnedRouting(message);
68
+ const learned = checkLearnedRouting(messageLower);
47
69
  if (learned) {
48
70
  const project = getProject(learned.projectName);
49
71
  if (project && learned.confidence >= 0.9) {
50
- const tabName = resolveTabInProject(project, message, context);
72
+ const { tabName, exists } = resolveTabInProject(project);
51
73
  touchProject(project.name);
52
74
  return {
53
75
  project,
54
76
  tabName,
55
- isNewTab: !tabExists(tabName),
77
+ isNewTab: !exists,
56
78
  confidence: learned.confidence,
57
79
  needsConfirmation: false,
58
80
  reason: `Learned pattern → "${project.name}"`,
@@ -60,7 +82,7 @@ export function routeMessage(message, context) {
60
82
  }
61
83
  }
62
84
  // 4. Multiple projects could match — ask user
63
- const possibleMatches = findPossibleMatches(message, projects);
85
+ const possibleMatches = findPossibleMatches(messageLower, projects);
64
86
  if (possibleMatches.length > 1) {
65
87
  return {
66
88
  project: possibleMatches[0],
@@ -68,11 +90,11 @@ export function routeMessage(message, context) {
68
90
  isNewTab: false,
69
91
  confidence: 0.5,
70
92
  needsConfirmation: true,
71
- reason: `Multiple projects could match: ${possibleMatches.map(p => p.name).join(', ')}`,
93
+ reason: `Multiple projects could match: ${possibleMatches.map((p) => p.name).join(', ')}`,
72
94
  };
73
95
  }
74
96
  // 5. Check for category keywords (non-project)
75
- const category = detectCategory(message);
97
+ const category = detectCategory(messageLower);
76
98
  if (category) {
77
99
  const project = ensureCategory(category);
78
100
  const tabName = category;
@@ -113,37 +135,34 @@ export function setUserContext(userId, projectName, tabName) {
113
135
  userContext.delete(oldestKey);
114
136
  }
115
137
  }
116
- /** Find a project explicitly mentioned by name in the message */
117
- function findMentionedProject(message, projects) {
118
- const lower = message.toLowerCase();
119
- // Check for exact project name mentions
138
+ /** Find a project explicitly mentioned by name in the message (messageLower already lower-cased) */
139
+ function findMentionedProject(messageLower, projects) {
120
140
  for (const project of projects) {
121
- if (lower.includes(project.name.toLowerCase())) {
141
+ if (messageLower.includes(project.nameLower))
122
142
  return project;
123
- }
124
143
  }
125
144
  return null;
126
145
  }
127
146
  /** Find possible project matches (for ambiguity) */
128
- function findPossibleMatches(message, projects) {
129
- const lower = message.toLowerCase();
130
- const words = lower.split(/\s+/);
131
- return projects.filter(p => {
132
- const nameParts = p.name.toLowerCase().split(/[-_]/);
133
- return nameParts.some(part => words.includes(part));
147
+ function findPossibleMatches(messageLower, projects) {
148
+ const words = messageLower.split(/\s+/);
149
+ return projects.filter((p) => {
150
+ const nameParts = p.nameLower.split(/[-_]/);
151
+ return nameParts.some((part) => words.includes(part));
134
152
  });
135
153
  }
136
- /** Resolve which tab to use within a project */
137
- function resolveTabInProject(project, _message, _context) {
154
+ /**
155
+ * Resolve which tab to use within a project. Coalesces the previous
156
+ * "find tabs + tabExists" into one query — the caller used to fire both
157
+ * back-to-back, and the working_dir scan had no covering index.
158
+ * (Index added by migration v28; see src/db/migrations.ts.)
159
+ */
160
+ function resolveTabInProject(project) {
138
161
  const db = getDb();
139
- // Find existing tabs for this project
140
- const tabs = db.prepare('SELECT name, last_activity_at FROM tabs WHERE working_dir = ? ORDER BY last_activity_at DESC').all(project.path);
141
- if (tabs.length === 0) {
142
- // No tabs yet use project name as tab name
143
- return project.name;
144
- }
145
- // Use most recently active tab in this project
146
- return tabs[0].name;
162
+ const row = db
163
+ .prepare('SELECT name FROM tabs WHERE working_dir = ? ORDER BY last_activity_at DESC LIMIT 1')
164
+ .get(project.path);
165
+ return row ? { tabName: row.name, exists: true } : { tabName: project.name, exists: false };
147
166
  }
148
167
  /** Check if a tab exists in the database */
149
168
  function tabExists(tabName) {
@@ -151,13 +170,11 @@ function tabExists(tabName) {
151
170
  const row = db.prepare('SELECT 1 FROM tabs WHERE name = ?').get(tabName);
152
171
  return !!row;
153
172
  }
154
- /** Detect a category from keywords */
155
- function detectCategory(message) {
156
- const lower = message.toLowerCase();
173
+ /** Detect a category from keywords (messageLower already lower-cased) */
174
+ function detectCategory(messageLower) {
157
175
  for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
158
- if (keywords.some(kw => lower.includes(kw))) {
176
+ if (keywords.some((kw) => messageLower.includes(kw)))
159
177
  return category;
160
- }
161
178
  }
162
179
  return null;
163
180
  }
@@ -165,7 +182,11 @@ function detectCategory(message) {
165
182
  function recordRouting(message, projectName) {
166
183
  const db = getDb();
167
184
  // Extract key words from message for pattern matching
168
- const words = message.toLowerCase().split(/\s+/).filter(w => w.length > 3).slice(0, 5);
185
+ const words = message
186
+ .toLowerCase()
187
+ .split(/\s+/)
188
+ .filter((w) => w.length > 3)
189
+ .slice(0, 5);
169
190
  const pattern = words.join(' ');
170
191
  if (!pattern)
171
192
  return;
@@ -175,17 +196,21 @@ function recordRouting(message, projectName) {
175
196
  ON CONFLICT(pattern, project_name) DO UPDATE SET hit_count = hit_count + 1
176
197
  `).run(pattern, projectName);
177
198
  }
178
- /** Check learned routing preferences */
179
- function checkLearnedRouting(message) {
199
+ /** Check learned routing preferences (messageLower already lower-cased) */
200
+ function checkLearnedRouting(messageLower) {
180
201
  const db = getDb();
181
- const words = message.toLowerCase().split(/\s+/).filter(w => w.length > 3);
202
+ const words = messageLower.split(/\s+/).filter((w) => w.length > 3);
182
203
  if (words.length === 0)
183
204
  return null;
184
205
  // Fetch all preferences in a single query and match in JS
185
- const allPrefs = db.prepare('SELECT pattern, project_name, confidence, hit_count FROM routing_preferences WHERE hit_count >= 3 ORDER BY hit_count DESC LIMIT 200').all();
206
+ const allPrefs = db
207
+ .prepare('SELECT pattern, project_name, confidence, hit_count FROM routing_preferences WHERE hit_count >= 3 ORDER BY hit_count DESC LIMIT 200')
208
+ .all();
186
209
  for (const pref of allPrefs) {
187
- const patternLower = pref.pattern.toLowerCase();
188
- if (words.some(word => patternLower.includes(word) || word.includes(patternLower))) {
210
+ // pref.pattern was written via .toLowerCase() in recordRouting(), so no
211
+ // re-lowercase here (saves N allocations per inbound message).
212
+ const patternLower = pref.pattern;
213
+ if (words.some((word) => patternLower.includes(word) || word.includes(patternLower))) {
189
214
  return { projectName: pref.project_name, confidence: Math.min(pref.confidence, 0.9) };
190
215
  }
191
216
  }
@@ -82,13 +82,17 @@ export function stopService() {
82
82
  try {
83
83
  execSync(`launchctl unload "${plistPath}"`, { stdio: 'inherit' });
84
84
  }
85
- catch { /* not loaded */ }
85
+ catch {
86
+ /* not loaded */
87
+ }
86
88
  }
87
89
  else {
88
90
  try {
89
91
  execSync('systemctl --user stop beecork', { stdio: 'inherit' });
90
92
  }
91
- catch { /* not running */ }
93
+ catch {
94
+ /* not running */
95
+ }
92
96
  }
93
97
  }
94
98
  function installLaunchd(nodePath, daemonPath) {
@@ -104,7 +108,9 @@ function uninstallLaunchd() {
104
108
  try {
105
109
  execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe' });
106
110
  }
107
- catch { /* ok */ }
111
+ catch {
112
+ /* ok */
113
+ }
108
114
  if (fs.existsSync(plistPath))
109
115
  fs.unlinkSync(plistPath);
110
116
  return plistPath;
@@ -124,11 +130,15 @@ function uninstallSystemd() {
124
130
  try {
125
131
  execSync('systemctl --user disable beecork', { stdio: 'pipe' });
126
132
  }
127
- catch { /* ok */ }
133
+ catch {
134
+ /* ok */
135
+ }
128
136
  try {
129
137
  execSync('systemctl --user stop beecork', { stdio: 'pipe' });
130
138
  }
131
- catch { /* ok */ }
139
+ catch {
140
+ /* ok */
141
+ }
132
142
  if (fs.existsSync(unitPath))
133
143
  fs.unlinkSync(unitPath);
134
144
  execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
@@ -29,7 +29,7 @@ export function startWindowsService() {
29
29
  execSync(`schtasks /Run /TN "${TASK_NAME}"`, { stdio: 'inherit' });
30
30
  console.log('Beecork daemon started.');
31
31
  }
32
- catch (err) {
32
+ catch {
33
33
  console.error('Failed to start. Run manually: beecork daemon');
34
34
  }
35
35
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Per-tab spend budget enforcement, extracted from TabManager.
3
+ *
4
+ * Pure function (with a tiny stateful wrapper) so it can be unit-tested without
5
+ * a database or a running subprocess: feed in current spend + tab name, get a
6
+ * decision back. The caller (TabManager.executeMessage) handles the side
7
+ * effects (notify + early return).
8
+ */
9
+ export interface BudgetDecision {
10
+ allowed: boolean;
11
+ /** When allowed=false, the message to surface to the caller. */
12
+ reason?: string;
13
+ /** When the tab has crossed the 80% threshold, the warning text to notify. */
14
+ warning?: string;
15
+ }
16
+ export declare class BudgetGuard {
17
+ private maxBudgetUsd;
18
+ constructor(maxBudgetUsd: number | undefined);
19
+ check(currentSpend: number, tabName: string): BudgetDecision;
20
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Per-tab spend budget enforcement, extracted from TabManager.
3
+ *
4
+ * Pure function (with a tiny stateful wrapper) so it can be unit-tested without
5
+ * a database or a running subprocess: feed in current spend + tab name, get a
6
+ * decision back. The caller (TabManager.executeMessage) handles the side
7
+ * effects (notify + early return).
8
+ */
9
+ export class BudgetGuard {
10
+ maxBudgetUsd;
11
+ constructor(maxBudgetUsd) {
12
+ this.maxBudgetUsd = maxBudgetUsd;
13
+ }
14
+ check(currentSpend, tabName) {
15
+ if (!this.maxBudgetUsd)
16
+ return { allowed: true };
17
+ if (currentSpend >= this.maxBudgetUsd) {
18
+ return {
19
+ allowed: false,
20
+ reason: `Budget limit reached for tab "${tabName}": $${currentSpend.toFixed(2)} / $${this.maxBudgetUsd.toFixed(2)}`,
21
+ };
22
+ }
23
+ if (currentSpend >= this.maxBudgetUsd * 0.8) {
24
+ return {
25
+ allowed: true,
26
+ warning: `⚠️ Budget warning: tab "${tabName}" at $${currentSpend.toFixed(2)} / $${this.maxBudgetUsd.toFixed(2)} (80%)`,
27
+ };
28
+ }
29
+ return { allowed: true };
30
+ }
31
+ }
@@ -2,11 +2,13 @@ import type { CircuitBreakerConfig, StreamContentToolUse } from '../types.js';
2
2
  export type CircuitBreakerAction = 'ok' | 'warn' | 'notify' | 'break';
3
3
  export declare class CircuitBreaker {
4
4
  private tabName;
5
- private recentCalls;
5
+ private ring;
6
+ private ringHead;
7
+ private ringSize;
6
8
  private config;
7
9
  private tripped;
8
- private warnedAt;
9
- private notifiedAt;
10
+ private warned;
11
+ private notified;
10
12
  private readonly WARN_THRESHOLD;
11
13
  private readonly NOTIFY_THRESHOLD;
12
14
  constructor(tabName: string, config?: Partial<CircuitBreakerConfig>);
@@ -3,34 +3,57 @@ const DEFAULT_CONFIG = {
3
3
  maxRepeats: 20,
4
4
  windowSize: 30,
5
5
  };
6
+ /**
7
+ * Detects loops in claude tool-use streams using consecutive-identical-call
8
+ * counting. The signature is a hash of the tool name + input — JSON.stringify
9
+ * would be O(input-size) per call and a 50KB Write tool input would allocate
10
+ * 50KB just to compare equality, so we use a quick-hash strategy that's
11
+ * length-independent.
12
+ */
13
+ function hashSignature(toolUse) {
14
+ // FNV-1a 32-bit hash of the JSON repr — fast, collision-rare for short inputs.
15
+ // For correctness we only need equality (was this the same call as before),
16
+ // not reversibility, so a hash is sufficient.
17
+ const s = `${toolUse.name}:${JSON.stringify(toolUse.input)}`;
18
+ let h = 0x811c9dc5;
19
+ for (let i = 0; i < s.length; i++) {
20
+ h ^= s.charCodeAt(i);
21
+ h = Math.imul(h, 0x01000193);
22
+ }
23
+ return `${toolUse.name}#${(h >>> 0).toString(36)}`;
24
+ }
6
25
  export class CircuitBreaker {
7
26
  tabName;
8
- recentCalls = [];
27
+ // Ring buffer to avoid per-call array reslice
28
+ ring;
29
+ ringHead = 0;
30
+ ringSize = 0;
9
31
  config;
10
32
  tripped = false;
11
- warnedAt = 0;
12
- notifiedAt = 0;
33
+ warned = false;
34
+ notified = false;
13
35
  WARN_THRESHOLD = 5;
14
36
  NOTIFY_THRESHOLD = 10;
15
37
  constructor(tabName, config) {
16
38
  this.tabName = tabName;
17
39
  this.config = { ...DEFAULT_CONFIG, ...config };
40
+ this.ring = new Array(this.config.windowSize);
18
41
  }
19
42
  /** Record a tool call. Returns the action to take. */
20
43
  recordToolCall(toolUse) {
21
44
  if (this.tripped)
22
45
  return 'break';
23
- const signature = `${toolUse.name}:${JSON.stringify(toolUse.input)}`;
24
- this.recentCalls.push(signature);
25
- // Keep only the last windowSize calls
26
- if (this.recentCalls.length > this.config.windowSize) {
27
- this.recentCalls = this.recentCalls.slice(-this.config.windowSize);
28
- }
29
- // Count consecutive identical calls from the end
30
- const lastCall = this.recentCalls[this.recentCalls.length - 1];
46
+ const signature = hashSignature(toolUse);
47
+ this.ring[this.ringHead] = signature;
48
+ this.ringHead = (this.ringHead + 1) % this.config.windowSize;
49
+ if (this.ringSize < this.config.windowSize)
50
+ this.ringSize++;
51
+ // Count consecutive identical signatures walking backwards from the last
52
+ // write. (ringHead points at the NEXT slot, so the most-recent is head-1.)
31
53
  let repeatCount = 0;
32
- for (let i = this.recentCalls.length - 1; i >= 0; i--) {
33
- if (this.recentCalls[i] === lastCall) {
54
+ for (let i = 0; i < this.ringSize; i++) {
55
+ const idx = (this.ringHead - 1 - i + this.config.windowSize) % this.config.windowSize;
56
+ if (this.ring[idx] === signature) {
34
57
  repeatCount++;
35
58
  }
36
59
  else {
@@ -42,23 +65,25 @@ export class CircuitBreaker {
42
65
  this.tripped = true;
43
66
  return 'break';
44
67
  }
45
- if (repeatCount >= this.NOTIFY_THRESHOLD && this.notifiedAt < this.NOTIFY_THRESHOLD) {
68
+ if (repeatCount >= this.NOTIFY_THRESHOLD && !this.notified) {
46
69
  logger.warn(`[${this.tabName}] Loop detected: ${toolUse.name} repeated ${repeatCount} times — notifying user`);
47
- this.notifiedAt = repeatCount;
70
+ this.notified = true;
48
71
  return 'notify';
49
72
  }
50
- if (repeatCount >= this.WARN_THRESHOLD && this.warnedAt < this.WARN_THRESHOLD) {
73
+ if (repeatCount >= this.WARN_THRESHOLD && !this.warned) {
51
74
  logger.info(`[${this.tabName}] Loop detected: ${toolUse.name} repeated ${repeatCount} times — warning`);
52
- this.warnedAt = repeatCount;
75
+ this.warned = true;
53
76
  return 'warn';
54
77
  }
55
78
  return 'ok';
56
79
  }
57
80
  reset() {
58
- this.recentCalls = [];
81
+ this.ring.fill(undefined);
82
+ this.ringHead = 0;
83
+ this.ringSize = 0;
59
84
  this.tripped = false;
60
- this.warnedAt = 0;
61
- this.notifiedAt = 0;
85
+ this.warned = false;
86
+ this.notified = false;
62
87
  }
63
88
  get isTripped() {
64
89
  return this.tripped;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Context-window compaction: when Claude's context fills, summarize the
3
+ * conversation, rotate to a fresh session, and continue with the summary as
4
+ * the new system seed.
5
+ *
6
+ * Extracted from TabManager.executeMessage. Owns the recursion-depth limit
7
+ * (MAX_DEPTH = 2) and the "fall back to original result" failure mode so
8
+ * compaction errors degrade gracefully instead of orphaning the message.
9
+ */
10
+ import type Database from 'better-sqlite3';
11
+ import type { SendResult } from './manager.js';
12
+ import type { Tab } from '../types.js';
13
+ export type NotifyFn = ((text: string) => Promise<void>) | null;
14
+ export interface SendMessageFn {
15
+ (tabName: string, prompt: string, options?: {
16
+ onTextChunk?: (text: string) => void;
17
+ _compactionDepth?: number;
18
+ }): Promise<SendResult>;
19
+ }
20
+ export declare const ContextCompactor: {
21
+ MAX_DEPTH: number;
22
+ /**
23
+ * Compact and continue the session. Returns the continuation result if
24
+ * compaction succeeds, or the original result if anything goes wrong so the
25
+ * user always gets *something* back.
26
+ *
27
+ * Important: callers should always run `processNextInQueue` (or equivalent)
28
+ * after this resolves regardless of which path it took — the queue drain
29
+ * obligation does not depend on compaction success.
30
+ */
31
+ compact(tab: Tab, enrichedPrompt: string, originalResult: SendResult, onTextChunk: ((text: string) => void) | undefined, currentDepth: number, sendMessage: SendMessageFn, notify: NotifyFn, db: Database.Database): Promise<SendResult>;
32
+ };