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
@@ -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,19 +196,51 @@ 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
  }
192
217
  return null;
193
218
  }
219
+ /**
220
+ * Shared project routing logic — resolves which tab/project to use for a message.
221
+ * Pulled out of channels/ so all routing decisions live in one place.
222
+ */
223
+ export async function resolveProjectRoute(rawPrompt, tabName, text, userId) {
224
+ if (tabName !== 'default' || text.startsWith('/tab ')) {
225
+ return { effectiveTabName: tabName };
226
+ }
227
+ try {
228
+ const decision = routeMessage(rawPrompt, { userId });
229
+ if (decision.needsConfirmation) {
230
+ const projects = listProjects().filter((p) => p.type === 'user-project');
231
+ const options = projects.map((p, i) => `${i + 1}) ${p.name}`).join('\n');
232
+ return {
233
+ effectiveTabName: tabName,
234
+ confirmationMessage: `Which project?\n${options}\n\nReply with the number, or just send your message with /project <name> first.`,
235
+ };
236
+ }
237
+ setUserContext(userId, decision.project.name, decision.tabName);
238
+ return {
239
+ effectiveTabName: decision.tabName,
240
+ projectPath: decision.project.path,
241
+ };
242
+ }
243
+ catch {
244
+ return { effectiveTabName: tabName };
245
+ }
246
+ }
@@ -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
+ };
@@ -0,0 +1,45 @@
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 { v4 as uuidv4 } from 'uuid';
11
+ import { logger } from '../util/logger.js';
12
+ export const ContextCompactor = {
13
+ MAX_DEPTH: 2,
14
+ /**
15
+ * Compact and continue the session. Returns the continuation result if
16
+ * compaction succeeds, or the original result if anything goes wrong so the
17
+ * user always gets *something* back.
18
+ *
19
+ * Important: callers should always run `processNextInQueue` (or equivalent)
20
+ * after this resolves regardless of which path it took — the queue drain
21
+ * obligation does not depend on compaction success.
22
+ */
23
+ async compact(tab, enrichedPrompt, originalResult, onTextChunk, currentDepth, sendMessage, notify, db) {
24
+ logger.info(`[${tab.name}] Compacting context (depth ${currentDepth + 1}/${ContextCompactor.MAX_DEPTH}) — requesting summary then restarting session`);
25
+ notify?.(`🔄 [${tab.name}] Context window full — compacting and continuing...`).catch((err) => logger.warn('Notify failed:', err));
26
+ try {
27
+ const summaryPrompt = 'Summarize your progress in this session concisely: completed steps, current state, remaining steps, and all important identifiers (file paths, URLs, variable names). Output ONLY the summary.';
28
+ const summaryResult = await sendMessage(tab.name, summaryPrompt, {
29
+ _compactionDepth: currentDepth + 1,
30
+ });
31
+ const newSessionId = uuidv4();
32
+ db.prepare('UPDATE tabs SET session_id = ? WHERE id = ?').run(newSessionId, tab.id);
33
+ logger.info(`[${tab.name}] Context compacted — new session ${newSessionId.slice(0, 8)}...`);
34
+ const continuationPrompt = `[CONTEXT RESTORED FROM PREVIOUS SESSION]\n${summaryResult.text}\n\n[Continue the original task: "${enrichedPrompt.slice(0, 500)}"]`;
35
+ return await sendMessage(tab.name, continuationPrompt, {
36
+ onTextChunk,
37
+ _compactionDepth: currentDepth + 1,
38
+ });
39
+ }
40
+ catch (err) {
41
+ logger.error(`[${tab.name}] Compaction failed:`, err);
42
+ return originalResult;
43
+ }
44
+ },
45
+ };
@@ -1,7 +1,7 @@
1
1
  import { logger } from '../util/logger.js';
2
2
  const DEFAULT_CONTEXT_WINDOW = 200_000; // Claude's context window in tokens
3
- const WARNING_THRESHOLD = 0.80;
4
- const CHECKPOINT_THRESHOLD = 0.90;
3
+ const WARNING_THRESHOLD = 0.8;
4
+ const CHECKPOINT_THRESHOLD = 0.9;
5
5
  export class ContextMonitor {
6
6
  tabName;
7
7
  cumulativeTokens = 0;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Session handoff helpers — used by both the CLI (`beecork attach`) and the
3
+ * shared command handler (`/handoff` from any channel).
4
+ *
5
+ * Moved here from `cli/handoff.ts` so daemon-shared code (channels, MCP) can
6
+ * import without reaching into the CLI layer. The CLI-specific `attachTab`
7
+ * (which spawns claude and calls process.exit) stays in `cli/handoff.ts`.
8
+ */
9
+ export interface TabHandoffInfo {
10
+ name: string;
11
+ sessionId: string;
12
+ workingDir: string;
13
+ status: string;
14
+ lastActivity: string;
15
+ recentMessages: Array<{
16
+ role: string;
17
+ content: string;
18
+ }>;
19
+ }
20
+ export declare function exportTab(tabName: string): TabHandoffInfo | null;
21
+ export declare function formatHandoffInfo(info: TabHandoffInfo): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Session handoff helpers — used by both the CLI (`beecork attach`) and the
3
+ * shared command handler (`/handoff` from any channel).
4
+ *
5
+ * Moved here from `cli/handoff.ts` so daemon-shared code (channels, MCP) can
6
+ * import without reaching into the CLI layer. The CLI-specific `attachTab`
7
+ * (which spawns claude and calls process.exit) stays in `cli/handoff.ts`.
8
+ */
9
+ import { getDb } from '../db/index.js';
10
+ import { TabStore } from './tab-store.js';
11
+ export function exportTab(tabName) {
12
+ const tab = TabStore.findByName(tabName);
13
+ if (!tab)
14
+ return null;
15
+ const messages = getDb()
16
+ .prepare('SELECT role, content FROM messages WHERE tab_id = ? ORDER BY created_at DESC LIMIT 5')
17
+ .all(tab.id);
18
+ return {
19
+ name: tab.name,
20
+ sessionId: tab.sessionId,
21
+ workingDir: tab.workingDir,
22
+ status: tab.status,
23
+ lastActivity: tab.lastActivityAt,
24
+ recentMessages: messages.reverse(),
25
+ };
26
+ }
27
+ export function formatHandoffInfo(info) {
28
+ const lines = [
29
+ `Session Handoff — tab "${info.name}"`,
30
+ '',
31
+ `Session ID: ${info.sessionId}`,
32
+ `Working dir: ${info.workingDir}`,
33
+ `Status: ${info.status}`,
34
+ `Last activity: ${info.lastActivity}`,
35
+ '',
36
+ 'To resume in terminal:',
37
+ ` beecork attach ${info.name}`,
38
+ '',
39
+ 'Or manually:',
40
+ ` cd ${info.workingDir}`,
41
+ ` claude --session-id ${info.sessionId} --resume`,
42
+ ];
43
+ if (info.recentMessages.length > 0) {
44
+ lines.push('', 'Recent context:');
45
+ for (const msg of info.recentMessages) {
46
+ lines.push(` [${msg.role}] ${msg.content.slice(0, 150)}${msg.content.length > 150 ? '...' : ''}`);
47
+ }
48
+ }
49
+ return lines.join('\n');
50
+ }