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.
- package/dist/capabilities/index.d.ts +1 -1
- package/dist/capabilities/index.js +1 -1
- package/dist/capabilities/manager.js +13 -9
- package/dist/capabilities/packs.js +3 -1
- package/dist/channels/command-handler.js +46 -14
- package/dist/channels/discord.d.ts +3 -6
- package/dist/channels/discord.js +40 -23
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/loader.js +13 -3
- package/dist/channels/pipeline.js +14 -5
- package/dist/channels/registry.d.ts +17 -1
- package/dist/channels/registry.js +33 -4
- package/dist/channels/telegram.d.ts +20 -5
- package/dist/channels/telegram.js +177 -42
- package/dist/channels/types.d.ts +11 -28
- package/dist/channels/voice-state.js +3 -1
- package/dist/channels/webhook.d.ts +1 -4
- package/dist/channels/webhook.js +26 -11
- package/dist/channels/whatsapp.d.ts +8 -4
- package/dist/channels/whatsapp.js +65 -29
- package/dist/cli/capabilities.js +4 -4
- package/dist/cli/channel.js +16 -6
- package/dist/cli/commands.js +12 -9
- package/dist/cli/doctor.js +80 -25
- package/dist/cli/handoff.d.ts +7 -14
- package/dist/cli/handoff.js +9 -44
- package/dist/cli/mcp.js +5 -5
- package/dist/cli/media.js +21 -8
- package/dist/cli/setup.js +9 -8
- package/dist/cli/store.js +29 -12
- package/dist/config.js +5 -10
- package/dist/daemon.js +88 -38
- package/dist/dashboard/html.js +80 -12
- package/dist/dashboard/routes.js +143 -79
- package/dist/dashboard/server.js +5 -1
- package/dist/db/connection.d.ts +29 -0
- package/dist/db/connection.js +37 -0
- package/dist/db/index.js +30 -12
- package/dist/db/migrations.js +84 -28
- package/dist/delegation/manager.js +10 -4
- package/dist/index.js +39 -59
- package/dist/knowledge/manager.js +26 -12
- package/dist/mcp/handlers.js +126 -57
- package/dist/mcp/server.js +20 -10
- package/dist/mcp/tool-definitions.js +68 -20
- package/dist/mcp/validate.d.ts +23 -0
- package/dist/mcp/validate.js +65 -0
- package/dist/media/factory.js +18 -14
- package/dist/media/generators/dall-e.js +2 -2
- package/dist/media/generators/kling.js +4 -4
- package/dist/media/generators/lyria.js +1 -1
- package/dist/media/generators/nano-banana.d.ts +1 -1
- package/dist/media/generators/nano-banana.js +2 -2
- package/dist/media/generators/poll-util.js +4 -4
- package/dist/media/generators/recraft.js +3 -3
- package/dist/media/generators/runway.js +4 -4
- package/dist/media/generators/stable-diffusion.js +2 -2
- package/dist/media/generators/veo.js +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/store.d.ts +7 -0
- package/dist/media/store.js +18 -4
- package/dist/media/types.d.ts +22 -0
- package/dist/notifications/index.d.ts +2 -4
- package/dist/notifications/index.js +6 -19
- package/dist/notifications/ntfy.js +3 -3
- package/dist/observability/analytics.js +35 -13
- package/dist/projects/index.d.ts +1 -1
- package/dist/projects/index.js +1 -1
- package/dist/projects/manager.d.ts +0 -4
- package/dist/projects/manager.js +51 -28
- package/dist/projects/router.d.ts +2 -0
- package/dist/projects/router.js +70 -45
- package/dist/service/install.js +15 -5
- package/dist/service/windows.js +1 -1
- package/dist/session/budget-guard.d.ts +20 -0
- package/dist/session/budget-guard.js +31 -0
- package/dist/session/circuit-breaker.d.ts +5 -3
- package/dist/session/circuit-breaker.js +45 -20
- package/dist/session/context-compactor.d.ts +32 -0
- package/dist/session/context-compactor.js +45 -0
- package/dist/session/context-monitor.js +2 -2
- package/dist/session/handoff.d.ts +21 -0
- package/dist/session/handoff.js +50 -0
- package/dist/session/manager.d.ts +17 -5
- package/dist/session/manager.js +153 -146
- package/dist/session/memory-store.d.ts +29 -0
- package/dist/session/memory-store.js +45 -0
- package/dist/session/message-queue.d.ts +28 -0
- package/dist/session/message-queue.js +52 -0
- package/dist/session/pending-dispatcher.d.ts +31 -0
- package/dist/session/pending-dispatcher.js +120 -0
- package/dist/session/pending-store.d.ts +60 -0
- package/dist/session/pending-store.js +118 -0
- package/dist/session/stale-session.d.ts +31 -0
- package/dist/session/stale-session.js +45 -0
- package/dist/session/subprocess.d.ts +2 -0
- package/dist/session/subprocess.js +33 -11
- package/dist/session/tab-store.js +4 -3
- package/dist/tasks/scheduler.d.ts +7 -0
- package/dist/tasks/scheduler.js +46 -6
- package/dist/tasks/store.js +20 -6
- package/dist/timeline/logger.js +3 -1
- package/dist/timeline/query.js +9 -3
- package/dist/types.d.ts +34 -9
- package/dist/util/auto-heal.js +15 -5
- package/dist/util/install-info.js +3 -1
- package/dist/util/logger.d.ts +1 -1
- package/dist/util/logger.js +63 -24
- package/dist/util/paths.d.ts +1 -0
- package/dist/util/paths.js +12 -2
- package/dist/util/retry.js +1 -1
- package/dist/util/text.js +13 -7
- package/dist/voice/index.js +5 -1
- package/dist/voice/stt.js +14 -6
- package/dist/voice/tts.js +1 -1
- package/dist/watchers/scheduler.js +9 -2
- package/package.json +6 -1
- package/dist/session/tool-classifier.d.ts +0 -4
- package/dist/session/tool-classifier.js +0 -56
package/dist/projects/manager.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
24
|
+
return expandHome(root);
|
|
16
25
|
}
|
|
17
|
-
/** Get the managed workspace path (.beecork/ under workspace root) */
|
|
18
|
-
|
|
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 =
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 */
|
package/dist/projects/router.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
36
|
+
const mentionedProject = findMentionedProject(messageLower, projects);
|
|
16
37
|
if (mentionedProject) {
|
|
17
|
-
const tabName = resolveTabInProject(mentionedProject
|
|
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: !
|
|
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) {
|
|
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(
|
|
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
|
|
72
|
+
const { tabName, exists } = resolveTabInProject(project);
|
|
51
73
|
touchProject(project.name);
|
|
52
74
|
return {
|
|
53
75
|
project,
|
|
54
76
|
tabName,
|
|
55
|
-
isNewTab: !
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
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(
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
/**
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
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 =>
|
|
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
|
|
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(
|
|
199
|
+
/** Check learned routing preferences (messageLower already lower-cased) */
|
|
200
|
+
function checkLearnedRouting(messageLower) {
|
|
180
201
|
const db = getDb();
|
|
181
|
-
const words =
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
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
|
}
|
package/dist/service/install.js
CHANGED
|
@@ -82,13 +82,17 @@ export function stopService() {
|
|
|
82
82
|
try {
|
|
83
83
|
execSync(`launchctl unload "${plistPath}"`, { stdio: 'inherit' });
|
|
84
84
|
}
|
|
85
|
-
catch {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
133
|
+
catch {
|
|
134
|
+
/* ok */
|
|
135
|
+
}
|
|
128
136
|
try {
|
|
129
137
|
execSync('systemctl --user stop beecork', { stdio: 'pipe' });
|
|
130
138
|
}
|
|
131
|
-
catch {
|
|
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' });
|
package/dist/service/windows.js
CHANGED
|
@@ -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
|
|
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
|
|
5
|
+
private ring;
|
|
6
|
+
private ringHead;
|
|
7
|
+
private ringSize;
|
|
6
8
|
private config;
|
|
7
9
|
private tripped;
|
|
8
|
-
private
|
|
9
|
-
private
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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 =
|
|
24
|
-
this.
|
|
25
|
-
|
|
26
|
-
if (this.
|
|
27
|
-
this.
|
|
28
|
-
|
|
29
|
-
//
|
|
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 =
|
|
33
|
-
|
|
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.
|
|
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.
|
|
70
|
+
this.notified = true;
|
|
48
71
|
return 'notify';
|
|
49
72
|
}
|
|
50
|
-
if (repeatCount >= this.WARN_THRESHOLD && this.
|
|
73
|
+
if (repeatCount >= this.WARN_THRESHOLD && !this.warned) {
|
|
51
74
|
logger.info(`[${this.tabName}] Loop detected: ${toolUse.name} repeated ${repeatCount} times — warning`);
|
|
52
|
-
this.
|
|
75
|
+
this.warned = true;
|
|
53
76
|
return 'warn';
|
|
54
77
|
}
|
|
55
78
|
return 'ok';
|
|
56
79
|
}
|
|
57
80
|
reset() {
|
|
58
|
-
this.
|
|
81
|
+
this.ring.fill(undefined);
|
|
82
|
+
this.ringHead = 0;
|
|
83
|
+
this.ringSize = 0;
|
|
59
84
|
this.tripped = false;
|
|
60
|
-
this.
|
|
61
|
-
this.
|
|
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
|
+
};
|