beecork 1.7.0 → 1.7.1
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/cron/scheduler.d.ts +22 -0
- package/dist/cron/scheduler.js +220 -0
- package/dist/cron/store.d.ts +12 -0
- package/dist/cron/store.js +83 -0
- package/dist/machines/forwarder.d.ts +7 -0
- package/dist/machines/forwarder.js +35 -0
- package/dist/machines/index.d.ts +1 -0
- package/dist/machines/index.js +1 -0
- package/dist/machines/registry.d.ts +15 -0
- package/dist/machines/registry.js +46 -0
- package/dist/media/loader.d.ts +8 -0
- package/dist/media/loader.js +46 -0
- package/dist/memory/extractor.d.ts +5 -0
- package/dist/memory/extractor.js +157 -0
- package/dist/pipe/anthropic-client.d.ts +9 -0
- package/dist/pipe/anthropic-client.js +50 -0
- package/dist/pipe/brain.d.ts +16 -0
- package/dist/pipe/brain.js +89 -0
- package/dist/pipe/memory-store.d.ts +8 -0
- package/dist/pipe/memory-store.js +39 -0
- package/dist/pipe/project-scanner.d.ts +6 -0
- package/dist/pipe/project-scanner.js +26 -0
- package/dist/pipe/types.d.ts +34 -0
- package/dist/pipe/types.js +1 -0
- package/dist/session/manager.js +37 -0
- package/dist/session/subprocess.d.ts +7 -0
- package/dist/session/subprocess.js +41 -0
- package/dist/session/tool-classifier.d.ts +4 -0
- package/dist/session/tool-classifier.js +56 -0
- package/dist/types.d.ts +9 -0
- package/dist/users/index.d.ts +2 -0
- package/dist/users/index.js +1 -0
- package/dist/users/service.d.ts +17 -0
- package/dist/users/service.js +46 -0
- package/package.json +1 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TabManager, NotifyCallback } from '../session/manager.js';
|
|
2
|
+
export declare class CronScheduler {
|
|
3
|
+
private tabManager;
|
|
4
|
+
private onNotify;
|
|
5
|
+
private scheduledJobs;
|
|
6
|
+
private store;
|
|
7
|
+
constructor(tabManager: TabManager, onNotify: NotifyCallback | null);
|
|
8
|
+
/** Load all cron jobs from store and schedule them */
|
|
9
|
+
loadAndSchedule(): void;
|
|
10
|
+
/** Check for the reload signal file and reload if present */
|
|
11
|
+
checkForReload(): void;
|
|
12
|
+
/** Stop all scheduled jobs */
|
|
13
|
+
stopAll(): void;
|
|
14
|
+
private scheduleJob;
|
|
15
|
+
private fireJob;
|
|
16
|
+
private handleSystemEvent;
|
|
17
|
+
}
|
|
18
|
+
/** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
|
|
19
|
+
export declare function intervalToMs(interval: string): number | null;
|
|
20
|
+
/** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */
|
|
21
|
+
export declare function intervalToCron(interval: string): string | null;
|
|
22
|
+
//# sourceMappingURL=scheduler.d.ts.map
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import cron from 'node-cron';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CronStore } from './store.js';
|
|
5
|
+
import { getCronReloadSignalPath, getLogsDir } from '../util/paths.js';
|
|
6
|
+
import { logger } from '../util/logger.js';
|
|
7
|
+
export class CronScheduler {
|
|
8
|
+
tabManager;
|
|
9
|
+
onNotify;
|
|
10
|
+
scheduledJobs = new Map();
|
|
11
|
+
store = new CronStore();
|
|
12
|
+
constructor(tabManager, onNotify) {
|
|
13
|
+
this.tabManager = tabManager;
|
|
14
|
+
this.onNotify = onNotify;
|
|
15
|
+
}
|
|
16
|
+
/** Load all cron jobs from store and schedule them */
|
|
17
|
+
loadAndSchedule() {
|
|
18
|
+
// Cancel existing
|
|
19
|
+
for (const [, task] of this.scheduledJobs) {
|
|
20
|
+
task.stop();
|
|
21
|
+
}
|
|
22
|
+
this.scheduledJobs.clear();
|
|
23
|
+
const jobs = this.store.list();
|
|
24
|
+
let scheduled = 0;
|
|
25
|
+
let missedFires = 0;
|
|
26
|
+
for (const job of jobs) {
|
|
27
|
+
if (!job.enabled)
|
|
28
|
+
continue;
|
|
29
|
+
// Detect missed fires: one-time "at" jobs whose time has passed but never ran
|
|
30
|
+
if (job.scheduleType === 'at' && !job.lastRunAt) {
|
|
31
|
+
const targetTime = new Date(job.schedule).getTime();
|
|
32
|
+
if (targetTime <= Date.now()) {
|
|
33
|
+
logger.warn(`Cron: missed fire detected for "${job.name}" (was scheduled for ${job.schedule}), firing now`);
|
|
34
|
+
missedFires++;
|
|
35
|
+
this.fireJob(job);
|
|
36
|
+
this.store.update(job.id, { enabled: false }); // Disable after one-time execution
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
this.scheduleJob(job);
|
|
41
|
+
scheduled++;
|
|
42
|
+
}
|
|
43
|
+
logger.info(`Cron: loaded ${scheduled} active jobs (${jobs.length} total)${missedFires > 0 ? `, fired ${missedFires} missed` : ''}`);
|
|
44
|
+
}
|
|
45
|
+
/** Check for the reload signal file and reload if present */
|
|
46
|
+
checkForReload() {
|
|
47
|
+
const signalPath = getCronReloadSignalPath();
|
|
48
|
+
if (fs.existsSync(signalPath)) {
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(signalPath);
|
|
51
|
+
}
|
|
52
|
+
catch { /* race condition, ok */ }
|
|
53
|
+
logger.info('Cron: reload signal detected, reloading schedules');
|
|
54
|
+
this.loadAndSchedule();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Stop all scheduled jobs */
|
|
58
|
+
stopAll() {
|
|
59
|
+
for (const [, task] of this.scheduledJobs) {
|
|
60
|
+
task.stop();
|
|
61
|
+
}
|
|
62
|
+
this.scheduledJobs.clear();
|
|
63
|
+
}
|
|
64
|
+
scheduleJob(job) {
|
|
65
|
+
switch (job.scheduleType) {
|
|
66
|
+
case 'cron': {
|
|
67
|
+
if (!cron.validate(job.schedule)) {
|
|
68
|
+
logger.error(`Cron: invalid expression for "${job.name}": ${job.schedule}`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const task = cron.schedule(job.schedule, () => this.fireJob(job));
|
|
72
|
+
this.scheduledJobs.set(job.id, task);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'every': {
|
|
76
|
+
const cronExpr = intervalToCron(job.schedule);
|
|
77
|
+
if (cronExpr) {
|
|
78
|
+
if (!cron.validate(cronExpr)) {
|
|
79
|
+
logger.error(`Cron: invalid cron expression for "${job.name}": ${cronExpr}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const task = cron.schedule(cronExpr, () => this.fireJob(job));
|
|
83
|
+
this.scheduledJobs.set(job.id, task);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Use setInterval for non-cron-expressible intervals
|
|
87
|
+
const totalMs = intervalToMs(job.schedule);
|
|
88
|
+
if (totalMs) {
|
|
89
|
+
const timer = setInterval(() => this.fireJob(job), totalMs);
|
|
90
|
+
this.scheduledJobs.set(job.id, { stop: () => clearInterval(timer) });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
logger.error(`Cron: invalid interval for "${job.name}": ${job.schedule}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case 'at': {
|
|
99
|
+
const targetTime = new Date(job.schedule).getTime();
|
|
100
|
+
const delay = targetTime - Date.now();
|
|
101
|
+
if (delay <= 0) {
|
|
102
|
+
logger.warn(`Cron: one-time job "${job.name}" is in the past, skipping`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
this.fireJob(job);
|
|
107
|
+
this.store.update(job.id, { enabled: false });
|
|
108
|
+
}, delay);
|
|
109
|
+
this.scheduledJobs.set(job.id, { stop: () => clearTimeout(timer) });
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async fireJob(job) {
|
|
115
|
+
logger.info(`Cron firing: "${job.name}" (${job.payloadType || 'agentTurn'}) → tab:${job.tabName}`);
|
|
116
|
+
const logFile = path.join(getLogsDir(), `cron-${job.name}.log`);
|
|
117
|
+
// Handle systemEvent — internal Beecork actions, not Claude Code
|
|
118
|
+
if (job.payloadType === 'systemEvent') {
|
|
119
|
+
try {
|
|
120
|
+
await this.handleSystemEvent(job);
|
|
121
|
+
this.store.update(job.id, { lastRunAt: new Date().toISOString() });
|
|
122
|
+
fs.appendFileSync(logFile, `[${new Date().toISOString()}] SYSTEM: ${job.message}\n`);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
126
|
+
logger.error(`System event "${job.name}" failed:`, err);
|
|
127
|
+
fs.appendFileSync(logFile, `[${new Date().toISOString()}] ERROR: ${errMsg}\n`);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
this.tabManager.ensureTab(job.tabName);
|
|
133
|
+
const result = await this.tabManager.sendMessage(job.tabName, job.message);
|
|
134
|
+
this.store.update(job.id, { lastRunAt: new Date().toISOString() });
|
|
135
|
+
const firstLine = result.text.split('\n')[0]?.slice(0, 200) || '(no output)';
|
|
136
|
+
// Log result
|
|
137
|
+
fs.appendFileSync(logFile, `[${new Date().toISOString()}] SUCCESS: ${firstLine}\n`);
|
|
138
|
+
// Notify (separate try/catch — notification failure shouldn't be reported as job failure)
|
|
139
|
+
try {
|
|
140
|
+
if (this.onNotify) {
|
|
141
|
+
if (result.error) {
|
|
142
|
+
await this.onNotify(`❌ [${job.name}] Failed — ${firstLine}`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
await this.onNotify(`✅ [${job.name}] Done — ${firstLine}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (notifyErr) {
|
|
150
|
+
logger.warn(`Cron job "${job.name}" notification failed:`, notifyErr);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
155
|
+
logger.error(`Cron job "${job.name}" failed:`, err);
|
|
156
|
+
fs.appendFileSync(logFile, `[${new Date().toISOString()}] ERROR: ${errMsg}\n`);
|
|
157
|
+
try {
|
|
158
|
+
await this.onNotify?.(`❌ [${job.name}] Failed — ${errMsg}`);
|
|
159
|
+
}
|
|
160
|
+
catch { /* notification best-effort */ }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async handleSystemEvent(job) {
|
|
164
|
+
switch (job.message) {
|
|
165
|
+
case 'health_check':
|
|
166
|
+
logger.info('System event: health check — daemon alive');
|
|
167
|
+
if (this.onNotify) {
|
|
168
|
+
await this.onNotify('🟢 Beecork health check: all systems operational');
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
case 'memory_compaction':
|
|
172
|
+
logger.info('System event: memory compaction (not yet implemented)');
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
logger.warn(`Unknown system event: ${job.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
|
|
180
|
+
export function intervalToMs(interval) {
|
|
181
|
+
const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
|
|
182
|
+
if (!match || match.slice(1).every(g => g === undefined))
|
|
183
|
+
return null;
|
|
184
|
+
const weeks = parseInt(match[1] || '0', 10);
|
|
185
|
+
const days = parseInt(match[2] || '0', 10);
|
|
186
|
+
const hours = parseInt(match[3] || '0', 10);
|
|
187
|
+
const mins = parseInt(match[4] || '0', 10);
|
|
188
|
+
const totalMs = ((weeks * 7 * 24 * 60) + (days * 24 * 60) + (hours * 60) + mins) * 60 * 1000;
|
|
189
|
+
return totalMs > 0 ? totalMs : null;
|
|
190
|
+
}
|
|
191
|
+
/** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */
|
|
192
|
+
export function intervalToCron(interval) {
|
|
193
|
+
// Try combined format: 1h30m, 2h, 30m, 1d, 2w
|
|
194
|
+
const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
|
|
195
|
+
if (!match || match.slice(1).every(g => g === undefined))
|
|
196
|
+
return null;
|
|
197
|
+
const weeks = parseInt(match[1] || '0', 10);
|
|
198
|
+
const days = parseInt(match[2] || '0', 10);
|
|
199
|
+
const hours = parseInt(match[3] || '0', 10);
|
|
200
|
+
const mins = parseInt(match[4] || '0', 10);
|
|
201
|
+
// Convert to total minutes for simple intervals
|
|
202
|
+
const totalMins = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + mins;
|
|
203
|
+
if (totalMins <= 0)
|
|
204
|
+
return null;
|
|
205
|
+
// Simple minute interval
|
|
206
|
+
if (totalMins <= 59)
|
|
207
|
+
return `*/${totalMins} * * * *`;
|
|
208
|
+
// Hourly intervals
|
|
209
|
+
if (mins === 0 && days === 0 && weeks === 0 && hours > 0 && hours <= 23)
|
|
210
|
+
return `0 */${hours} * * *`;
|
|
211
|
+
// Daily intervals
|
|
212
|
+
if (mins === 0 && hours === 0 && weeks === 0 && days > 0)
|
|
213
|
+
return `0 0 */${days} * *`;
|
|
214
|
+
// Weekly intervals
|
|
215
|
+
if (mins === 0 && hours === 0 && days === 0 && weeks > 0)
|
|
216
|
+
return `0 0 * * 0`;
|
|
217
|
+
// Combined or large intervals — return null, handled by setInterval in scheduleJob
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CronJob } from '../types.js';
|
|
2
|
+
export declare class CronStore {
|
|
3
|
+
constructor();
|
|
4
|
+
list(): CronJob[];
|
|
5
|
+
get(id: string): CronJob | undefined;
|
|
6
|
+
add(job: CronJob): void;
|
|
7
|
+
update(id: string, updates: Partial<CronJob>): boolean;
|
|
8
|
+
delete(id: string): boolean;
|
|
9
|
+
/** One-time migration from crontab.json to SQLite */
|
|
10
|
+
private migrateFromJson;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
import { getCrontabPath } from '../util/paths.js';
|
|
4
|
+
import { logger } from '../util/logger.js';
|
|
5
|
+
function rowToJob(row) {
|
|
6
|
+
return {
|
|
7
|
+
id: row.id, name: row.name,
|
|
8
|
+
scheduleType: row.schedule_type,
|
|
9
|
+
schedule: row.schedule, tabName: row.tab_name, message: row.message,
|
|
10
|
+
payloadType: row.payload_type || 'agentTurn',
|
|
11
|
+
enabled: row.enabled === 1, createdAt: row.created_at,
|
|
12
|
+
lastRunAt: row.last_run_at, nextRunAt: row.next_run_at,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export class CronStore {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.migrateFromJson();
|
|
18
|
+
}
|
|
19
|
+
list() {
|
|
20
|
+
const db = getDb();
|
|
21
|
+
return db.prepare('SELECT * FROM cron_jobs WHERE user_id = ? ORDER BY created_at').all('local').map(rowToJob);
|
|
22
|
+
}
|
|
23
|
+
get(id) {
|
|
24
|
+
const db = getDb();
|
|
25
|
+
const row = db.prepare('SELECT * FROM cron_jobs WHERE id = ?').get(id);
|
|
26
|
+
return row ? rowToJob(row) : undefined;
|
|
27
|
+
}
|
|
28
|
+
add(job) {
|
|
29
|
+
const db = getDb();
|
|
30
|
+
db.prepare(`INSERT INTO cron_jobs (id, name, schedule_type, schedule, tab_name, message, payload_type, enabled, user_id, created_at, last_run_at, next_run_at)
|
|
31
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(job.id, job.name, job.scheduleType, job.schedule, job.tabName, job.message, job.payloadType || 'agentTurn', job.enabled ? 1 : 0, 'local', job.createdAt, job.lastRunAt, job.nextRunAt);
|
|
32
|
+
}
|
|
33
|
+
update(id, updates) {
|
|
34
|
+
const db = getDb();
|
|
35
|
+
const existing = this.get(id);
|
|
36
|
+
if (!existing)
|
|
37
|
+
return false;
|
|
38
|
+
const merged = { ...existing, ...updates };
|
|
39
|
+
db.prepare(`UPDATE cron_jobs SET name=?, schedule_type=?, schedule=?, tab_name=?, message=?, enabled=?, last_run_at=?, next_run_at=? WHERE id=?`).run(merged.name, merged.scheduleType, merged.schedule, merged.tabName, merged.message, merged.enabled ? 1 : 0, merged.lastRunAt, merged.nextRunAt, id);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
delete(id) {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const result = db.prepare('DELETE FROM cron_jobs WHERE id = ?').run(id);
|
|
45
|
+
return result.changes > 0;
|
|
46
|
+
}
|
|
47
|
+
/** One-time migration from crontab.json to SQLite */
|
|
48
|
+
migrateFromJson() {
|
|
49
|
+
const jsonPath = getCrontabPath();
|
|
50
|
+
if (!fs.existsSync(jsonPath))
|
|
51
|
+
return;
|
|
52
|
+
const db = getDb();
|
|
53
|
+
const count = db.prepare('SELECT COUNT(*) as count FROM cron_jobs').get();
|
|
54
|
+
if (count.count > 0) {
|
|
55
|
+
// Already migrated, clean up JSON
|
|
56
|
+
try {
|
|
57
|
+
fs.renameSync(jsonPath, jsonPath + '.bak');
|
|
58
|
+
}
|
|
59
|
+
catch { /* ok */ }
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
64
|
+
const jobs = data.jobs || [];
|
|
65
|
+
if (jobs.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
const insert = db.prepare(`INSERT OR IGNORE INTO cron_jobs (id, name, schedule_type, schedule, tab_name, message, enabled, user_id, created_at, last_run_at, next_run_at)
|
|
68
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
69
|
+
const tx = db.transaction(() => {
|
|
70
|
+
for (const j of jobs) {
|
|
71
|
+
insert.run(j.id, j.name, j.scheduleType, j.schedule, j.tabName || 'default', j.message, j.enabled ? 1 : 0, 'local', j.createdAt, j.lastRunAt, j.nextRunAt);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
tx();
|
|
75
|
+
fs.renameSync(jsonPath, jsonPath + '.bak');
|
|
76
|
+
logger.info(`Migrated ${jobs.length} cron jobs from JSON to SQLite`);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
logger.error('Failed to migrate cron jobs from JSON:', err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Machine } from './registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Forward a message to a remote Beecork instance via SSH.
|
|
4
|
+
* Writes to the remote machine's pending_messages table.
|
|
5
|
+
*/
|
|
6
|
+
export declare function forwardToMachine(machine: Machine, tabName: string, message: string): Promise<boolean>;
|
|
7
|
+
//# sourceMappingURL=forwarder.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { logger } from '../util/logger.js';
|
|
3
|
+
import { validateTabName } from '../config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Forward a message to a remote Beecork instance via SSH.
|
|
6
|
+
* Writes to the remote machine's pending_messages table.
|
|
7
|
+
*/
|
|
8
|
+
export async function forwardToMachine(machine, tabName, message) {
|
|
9
|
+
if (!machine.host || !machine.sshUser) {
|
|
10
|
+
logger.warn(`Cannot forward to ${machine.name}: no SSH config`);
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
// Validate inputs to prevent injection via execFileSync args
|
|
14
|
+
if (tabName !== 'default') {
|
|
15
|
+
const tabError = validateTabName(tabName);
|
|
16
|
+
if (tabError) {
|
|
17
|
+
logger.error(`Invalid tab name for forwarding: ${tabError}`);
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
// Use execFileSync with array args to prevent shell injection
|
|
23
|
+
execFileSync('ssh', [
|
|
24
|
+
`${machine.sshUser}@${machine.host}`,
|
|
25
|
+
'beecork', 'send', tabName, message,
|
|
26
|
+
], { timeout: 30000, encoding: 'utf-8' });
|
|
27
|
+
logger.info(`Message forwarded to ${machine.name} (${machine.host}), tab: ${tabName}`);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
logger.error(`Failed to forward to ${machine.name}:`, err);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=forwarder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { getMachineId, registerThisMachine, listMachines, type Machine } from './registry.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { getMachineId, registerThisMachine, listMachines } from './registry.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface Machine {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
host: string | null;
|
|
5
|
+
sshUser: string | null;
|
|
6
|
+
projectPaths: string[];
|
|
7
|
+
isPrimary: boolean;
|
|
8
|
+
lastSeenAt: string;
|
|
9
|
+
}
|
|
10
|
+
/** Get or create this machine's unique ID */
|
|
11
|
+
export declare function getMachineId(): string;
|
|
12
|
+
/** Register this machine in the database */
|
|
13
|
+
export declare function registerThisMachine(projectPaths: string[]): Machine;
|
|
14
|
+
/** List all machines */
|
|
15
|
+
export declare function listMachines(): Machine[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { getDb } from '../db/index.js';
|
|
4
|
+
import { getBeecorkHome } from '../util/paths.js';
|
|
5
|
+
import { logger } from '../util/logger.js';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
const MACHINE_ID_PATH = `${getBeecorkHome()}/machine-id`;
|
|
8
|
+
/** Get or create this machine's unique ID */
|
|
9
|
+
export function getMachineId() {
|
|
10
|
+
if (fs.existsSync(MACHINE_ID_PATH)) {
|
|
11
|
+
return fs.readFileSync(MACHINE_ID_PATH, 'utf-8').trim();
|
|
12
|
+
}
|
|
13
|
+
const id = uuidv4();
|
|
14
|
+
fs.writeFileSync(MACHINE_ID_PATH, id, { mode: 0o600 });
|
|
15
|
+
return id;
|
|
16
|
+
}
|
|
17
|
+
/** Register this machine in the database */
|
|
18
|
+
export function registerThisMachine(projectPaths) {
|
|
19
|
+
const db = getDb();
|
|
20
|
+
const id = getMachineId();
|
|
21
|
+
const name = os.hostname();
|
|
22
|
+
db.prepare(`
|
|
23
|
+
INSERT INTO machines (id, name, project_paths, last_seen_at)
|
|
24
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
25
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
26
|
+
name = excluded.name,
|
|
27
|
+
project_paths = excluded.project_paths,
|
|
28
|
+
last_seen_at = datetime('now')
|
|
29
|
+
`).run(id, name, JSON.stringify(projectPaths));
|
|
30
|
+
logger.info(`Machine registered: ${name} (${id.slice(0, 8)}) with ${projectPaths.length} project paths`);
|
|
31
|
+
return { id, name, host: null, sshUser: null, projectPaths, isPrimary: false, lastSeenAt: new Date().toISOString() };
|
|
32
|
+
}
|
|
33
|
+
/** List all machines */
|
|
34
|
+
export function listMachines() {
|
|
35
|
+
const db = getDb();
|
|
36
|
+
const rows = db.prepare('SELECT * FROM machines ORDER BY is_primary DESC, name').all();
|
|
37
|
+
return rows.map(r => ({
|
|
38
|
+
id: r.id,
|
|
39
|
+
name: r.name,
|
|
40
|
+
host: r.host,
|
|
41
|
+
sshUser: r.ssh_user,
|
|
42
|
+
projectPaths: JSON.parse(r.project_paths || '[]'),
|
|
43
|
+
isPrimary: !!r.is_primary,
|
|
44
|
+
lastSeenAt: r.last_seen_at,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MediaGenerator } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Discover and load community media generator packages from node_modules.
|
|
4
|
+
* Convention: packages named `beecork-media-<name>` must export a default
|
|
5
|
+
* class implementing the MediaGenerator interface.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadCommunityGenerators(apiKeys?: Record<string, string>): Promise<MediaGenerator[]>;
|
|
8
|
+
//# sourceMappingURL=loader.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { logger } from '../util/logger.js';
|
|
4
|
+
const MEDIA_PREFIX = 'beecork-media-';
|
|
5
|
+
/**
|
|
6
|
+
* Discover and load community media generator packages from node_modules.
|
|
7
|
+
* Convention: packages named `beecork-media-<name>` must export a default
|
|
8
|
+
* class implementing the MediaGenerator interface.
|
|
9
|
+
*/
|
|
10
|
+
export async function loadCommunityGenerators(apiKeys) {
|
|
11
|
+
const generators = [];
|
|
12
|
+
const searchPaths = [path.join(process.cwd(), 'node_modules')];
|
|
13
|
+
for (const searchPath of searchPaths) {
|
|
14
|
+
if (!fs.existsSync(searchPath))
|
|
15
|
+
continue;
|
|
16
|
+
try {
|
|
17
|
+
for (const dir of fs.readdirSync(searchPath)) {
|
|
18
|
+
if (!dir.startsWith(MEDIA_PREFIX))
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
const pkgPath = path.join(searchPath, dir);
|
|
22
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgPath, 'package.json'), 'utf-8'));
|
|
23
|
+
const entryPath = path.join(pkgPath, pkgJson.main || 'index.js');
|
|
24
|
+
if (!fs.existsSync(entryPath))
|
|
25
|
+
continue;
|
|
26
|
+
const module = await import(entryPath);
|
|
27
|
+
const GeneratorClass = module.default || module[Object.keys(module)[0]];
|
|
28
|
+
if (!GeneratorClass || typeof GeneratorClass !== 'function')
|
|
29
|
+
continue;
|
|
30
|
+
const name = dir.slice(MEDIA_PREFIX.length);
|
|
31
|
+
const instance = new GeneratorClass(apiKeys?.[name]);
|
|
32
|
+
if (!instance.id || !instance.name || typeof instance.generate !== 'function')
|
|
33
|
+
continue;
|
|
34
|
+
generators.push(instance);
|
|
35
|
+
logger.info(`Community media generator loaded: ${instance.name} (${instance.id})`);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
logger.warn(`Failed to load community generator ${dir}:`, err);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
}
|
|
44
|
+
return generators;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=loader.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BeecorkConfig } from '../types.js';
|
|
2
|
+
/** Auto-extract memories from a completed session */
|
|
3
|
+
export declare function extractMemories(config: BeecorkConfig, tabName: string, sessionText: string, durationMs: number): Promise<void>;
|
|
4
|
+
/** Inject relevant memories into a prompt */
|
|
5
|
+
export declare function getRelevantMemories(tabName: string): string[];
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
import { logger } from '../util/logger.js';
|
|
4
|
+
const EXTRACTION_PROMPT = `Extract 0-5 key facts, decisions, or outcomes from this session transcript that would be useful in future sessions. Output ONLY a JSON array of strings. If nothing is worth remembering, output an empty array [].
|
|
5
|
+
|
|
6
|
+
Examples of what to extract:
|
|
7
|
+
- Server addresses, credentials, file paths
|
|
8
|
+
- User preferences ("prefers deployments after 11pm")
|
|
9
|
+
- Decisions made ("chose PostgreSQL over MySQL for X reason")
|
|
10
|
+
- Outcomes ("deploy succeeded", "bug was in auth middleware")
|
|
11
|
+
|
|
12
|
+
Session transcript:
|
|
13
|
+
`;
|
|
14
|
+
/** Auto-extract memories from a completed session */
|
|
15
|
+
export async function extractMemories(config, tabName, sessionText, durationMs) {
|
|
16
|
+
// Only extract from non-trivial sessions
|
|
17
|
+
if (durationMs < 10000 || sessionText.length < 200)
|
|
18
|
+
return;
|
|
19
|
+
// Rate limit: check if we extracted recently for this tab
|
|
20
|
+
const db = getDb();
|
|
21
|
+
const recent = db.prepare(`SELECT COUNT(*) as count FROM memories
|
|
22
|
+
WHERE tab_name = ? AND source = 'auto'
|
|
23
|
+
AND created_at > datetime('now', '-5 minutes')`).get(tabName);
|
|
24
|
+
if (recent.count > 0) {
|
|
25
|
+
logger.debug(`[${tabName}] Skipping memory extraction — too recent`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const prompt = EXTRACTION_PROMPT + sessionText.slice(0, 5000); // Limit transcript size
|
|
30
|
+
const facts = await runExtractionSession(config, prompt);
|
|
31
|
+
if (facts.length === 0)
|
|
32
|
+
return;
|
|
33
|
+
for (const fact of facts) {
|
|
34
|
+
db.prepare('INSERT INTO memories (content, tab_name, source) VALUES (?, ?, ?)').run(fact, tabName, 'auto');
|
|
35
|
+
}
|
|
36
|
+
// Enforce maxLongTermEntries limit
|
|
37
|
+
const maxEntries = config.memory.maxLongTermEntries ?? 1000;
|
|
38
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM memories').get().c;
|
|
39
|
+
if (count > maxEntries) {
|
|
40
|
+
const excess = count - maxEntries;
|
|
41
|
+
db.prepare('DELETE FROM memories WHERE rowid IN (SELECT rowid FROM memories ORDER BY created_at ASC LIMIT ?)').run(excess);
|
|
42
|
+
logger.info(`[${tabName}] Evicted ${excess} oldest memories (limit: ${maxEntries})`);
|
|
43
|
+
}
|
|
44
|
+
logger.info(`[${tabName}] Auto-extracted ${facts.length} memories`);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
logger.error(`[${tabName}] Memory extraction failed:`, err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Inject relevant memories into a prompt */
|
|
51
|
+
export function getRelevantMemories(tabName) {
|
|
52
|
+
const db = getDb();
|
|
53
|
+
// Get recent global memories + tab-specific memories
|
|
54
|
+
const memories = db.prepare(`SELECT content FROM memories
|
|
55
|
+
WHERE tab_name IS NULL OR tab_name = ?
|
|
56
|
+
ORDER BY created_at DESC LIMIT 20`).all(tabName);
|
|
57
|
+
return memories.map(m => m.content);
|
|
58
|
+
}
|
|
59
|
+
async function runExtractionSession(config, prompt) {
|
|
60
|
+
// Prefer direct API call (cheaper, faster) when API key is available
|
|
61
|
+
if (config.pipe?.anthropicApiKey) {
|
|
62
|
+
return runExtractionViaApi(config.pipe.anthropicApiKey, config.pipe.routingModel, prompt);
|
|
63
|
+
}
|
|
64
|
+
// Fallback: spawn Claude Code subprocess
|
|
65
|
+
return runExtractionViaSubprocess(config, prompt);
|
|
66
|
+
}
|
|
67
|
+
function parseFactsFromText(text) {
|
|
68
|
+
const jsonMatch = text.match(/\[[\s\S]*?\]/);
|
|
69
|
+
if (jsonMatch) {
|
|
70
|
+
const facts = JSON.parse(jsonMatch[0]);
|
|
71
|
+
if (Array.isArray(facts) && facts.every(f => typeof f === 'string')) {
|
|
72
|
+
return facts.slice(0, 5);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
let cachedClient = null;
|
|
78
|
+
let cachedApiKey = '';
|
|
79
|
+
async function runExtractionViaApi(apiKey, model, prompt) {
|
|
80
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
81
|
+
if (!cachedClient || cachedApiKey !== apiKey) {
|
|
82
|
+
cachedClient = new Anthropic({ apiKey });
|
|
83
|
+
cachedApiKey = apiKey;
|
|
84
|
+
}
|
|
85
|
+
const client = cachedClient;
|
|
86
|
+
try {
|
|
87
|
+
const response = await client.messages.create({
|
|
88
|
+
model,
|
|
89
|
+
max_tokens: 500,
|
|
90
|
+
system: 'Extract 0-5 key facts from this session transcript worth remembering across sessions. Output ONLY a JSON array of strings. If nothing is worth remembering, output [].',
|
|
91
|
+
messages: [{ role: 'user', content: prompt }],
|
|
92
|
+
}, { timeout: 15000 });
|
|
93
|
+
const text = response.content.find((b) => b.type === 'text')?.text ?? '[]';
|
|
94
|
+
return parseFactsFromText(text);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
logger.warn('API-based memory extraction failed, falling back to subprocess:', err);
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function runExtractionViaSubprocess(config, prompt) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
let resolved = false;
|
|
104
|
+
const safeResolve = (val) => { if (!resolved) {
|
|
105
|
+
resolved = true;
|
|
106
|
+
resolve(val);
|
|
107
|
+
} };
|
|
108
|
+
const safeReject = (err) => { if (!resolved) {
|
|
109
|
+
resolved = true;
|
|
110
|
+
reject(err);
|
|
111
|
+
} };
|
|
112
|
+
const args = [
|
|
113
|
+
'-p',
|
|
114
|
+
'--output-format', 'stream-json',
|
|
115
|
+
'--verbose',
|
|
116
|
+
...config.claudeCode.defaultFlags,
|
|
117
|
+
'--no-session-persistence',
|
|
118
|
+
prompt,
|
|
119
|
+
];
|
|
120
|
+
const proc = spawn(config.claudeCode.bin, args, {
|
|
121
|
+
cwd: process.cwd(),
|
|
122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
+
});
|
|
124
|
+
let output = '';
|
|
125
|
+
let stdoutBuffer = '';
|
|
126
|
+
proc.stdout.on('data', (chunk) => {
|
|
127
|
+
stdoutBuffer += chunk.toString();
|
|
128
|
+
const lines = stdoutBuffer.split('\n');
|
|
129
|
+
stdoutBuffer = lines.pop() || '';
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
if (!line.trim())
|
|
132
|
+
continue;
|
|
133
|
+
try {
|
|
134
|
+
const event = JSON.parse(line);
|
|
135
|
+
if (event.type === 'result' && event.result) {
|
|
136
|
+
output = event.result;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch { /* skip non-JSON */ }
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
proc.on('exit', () => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
try {
|
|
145
|
+
safeResolve(parseFactsFromText(output));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
safeResolve([]);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
proc.on('error', safeReject);
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
proc.kill('SIGTERM');
|
|
154
|
+
safeResolve([]);
|
|
155
|
+
}, 30000);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RouteDecision, Project } from './types.js';
|
|
2
|
+
export declare class PipeAnthropicClient {
|
|
3
|
+
private client;
|
|
4
|
+
private routingModel;
|
|
5
|
+
constructor(apiKey: string, routingModel: string);
|
|
6
|
+
/** Route a message to the right project/tab (Haiku — fast, cheap) */
|
|
7
|
+
route(message: string, projects: Project[], recentRouting: string[]): Promise<RouteDecision>;
|
|
8
|
+
private complete;
|
|
9
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { logger } from '../util/logger.js';
|
|
3
|
+
import { retryWithBackoff } from '../util/retry.js';
|
|
4
|
+
export class PipeAnthropicClient {
|
|
5
|
+
client;
|
|
6
|
+
routingModel;
|
|
7
|
+
constructor(apiKey, routingModel) {
|
|
8
|
+
this.client = new Anthropic({ apiKey });
|
|
9
|
+
this.routingModel = routingModel;
|
|
10
|
+
}
|
|
11
|
+
/** Route a message to the right project/tab (Haiku — fast, cheap) */
|
|
12
|
+
async route(message, projects, recentRouting) {
|
|
13
|
+
const projectList = projects.map(p => `- ${p.name}: ${p.path}${p.languages?.length ? ` (${p.languages.join(', ')})` : ''}${p.description ? ` — ${p.description}` : ''}`).join('\n');
|
|
14
|
+
const response = await this.complete(`You are a message router. Given a user message, determine which project it relates to.
|
|
15
|
+
|
|
16
|
+
Available projects:
|
|
17
|
+
${projectList || '(no projects discovered yet)'}
|
|
18
|
+
|
|
19
|
+
Recent routing decisions:
|
|
20
|
+
${recentRouting.slice(0, 5).join('\n') || '(none)'}
|
|
21
|
+
|
|
22
|
+
Respond with ONLY valid JSON: {"tabName": "project-name", "projectPath": "/path", "confidence": 0.0-1.0, "reason": "brief explanation", "needsConfirmation": false}
|
|
23
|
+
|
|
24
|
+
If the message is a general question not related to any project, use tabName "default" with the user's home directory.
|
|
25
|
+
If unsure which project, set confidence below 0.5 and needsConfirmation to true.`, message);
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(response);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
logger.warn('Failed to parse routing response:', response);
|
|
31
|
+
return { tabName: 'default', projectPath: null, confidence: 0.3, reason: 'Could not parse routing', needsConfirmation: true };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async complete(systemPrompt, userMessage) {
|
|
35
|
+
try {
|
|
36
|
+
const response = await retryWithBackoff(() => this.client.messages.create({
|
|
37
|
+
model: this.routingModel,
|
|
38
|
+
max_tokens: 500,
|
|
39
|
+
system: systemPrompt,
|
|
40
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
41
|
+
}, { timeout: 30000 }), [1000, 5000, 15000], `Pipe API call (routing)`);
|
|
42
|
+
const textBlock = response.content.find(b => b.type === 'text');
|
|
43
|
+
return textBlock?.text ?? '';
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
logger.error(`Pipe API call failed (routing):`, err);
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TabManager } from '../session/manager.js';
|
|
2
|
+
import type { BeecorkConfig } from '../types.js';
|
|
3
|
+
import type { ChatContext, PipeResult } from './types.js';
|
|
4
|
+
export declare class PipeBrain {
|
|
5
|
+
private client;
|
|
6
|
+
private memory;
|
|
7
|
+
private config;
|
|
8
|
+
private tabManager;
|
|
9
|
+
constructor(config: BeecorkConfig, tabManager: TabManager);
|
|
10
|
+
/** Main entry point: route a message to the right tab and send it to Claude. */
|
|
11
|
+
process(message: string, _context: ChatContext): Promise<PipeResult>;
|
|
12
|
+
/** Route a message to the right project/tab */
|
|
13
|
+
private route;
|
|
14
|
+
/** Discover projects on the filesystem */
|
|
15
|
+
discoverProjects(): Promise<number>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { PipeAnthropicClient } from './anthropic-client.js';
|
|
2
|
+
import { PipeMemoryStore } from './memory-store.js';
|
|
3
|
+
import { scanForProjects } from './project-scanner.js';
|
|
4
|
+
import { parseTabMessage } from '../util/text.js';
|
|
5
|
+
import { logger } from '../util/logger.js';
|
|
6
|
+
export class PipeBrain {
|
|
7
|
+
client;
|
|
8
|
+
memory;
|
|
9
|
+
config;
|
|
10
|
+
tabManager;
|
|
11
|
+
constructor(config, tabManager) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.tabManager = tabManager;
|
|
14
|
+
this.client = new PipeAnthropicClient(config.pipe.anthropicApiKey, config.pipe.routingModel);
|
|
15
|
+
this.memory = new PipeMemoryStore();
|
|
16
|
+
}
|
|
17
|
+
/** Main entry point: route a message to the right tab and send it to Claude. */
|
|
18
|
+
async process(message, _context) {
|
|
19
|
+
const decisions = [];
|
|
20
|
+
// Step 1: Route the message to the right tab/project
|
|
21
|
+
const route = await this.route(message, decisions);
|
|
22
|
+
// Step 2: Ensure the tab exists with the right working directory
|
|
23
|
+
if (route.projectPath) {
|
|
24
|
+
this.tabManager.ensureTab(route.tabName, route.projectPath);
|
|
25
|
+
this.memory.updateProjectLastUsed(route.projectPath);
|
|
26
|
+
}
|
|
27
|
+
// Step 3: Send to Claude Code
|
|
28
|
+
let result;
|
|
29
|
+
try {
|
|
30
|
+
result = await this.tabManager.sendMessage(route.tabName, message, { projectPath: route.projectPath ?? undefined });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return {
|
|
34
|
+
tabName: route.tabName,
|
|
35
|
+
response: { text: `Error: ${err instanceof Error ? err.message : err}`, error: true, costUsd: 0, durationMs: 0 },
|
|
36
|
+
decisions,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
tabName: route.tabName,
|
|
41
|
+
response: { text: result.text, error: result.error, costUsd: result.costUsd, durationMs: result.durationMs },
|
|
42
|
+
decisions,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Route a message to the right project/tab */
|
|
46
|
+
async route(message, decisions) {
|
|
47
|
+
// Check for manual /tab override first
|
|
48
|
+
if (message.startsWith('/tab ')) {
|
|
49
|
+
const parsed = parseTabMessage(message);
|
|
50
|
+
if (parsed.tabName !== 'default') {
|
|
51
|
+
decisions.push(`📌 Manual routing to "${parsed.tabName}"`);
|
|
52
|
+
return { tabName: parsed.tabName, projectPath: null, confidence: 1.0, reason: 'Manual override', needsConfirmation: false };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projects = this.memory.getProjects();
|
|
56
|
+
const recentRouting = this.memory.getRecentRouting(5);
|
|
57
|
+
// If no projects and no API key, just use default
|
|
58
|
+
if (projects.length === 0 || !this.config.pipe.anthropicApiKey) {
|
|
59
|
+
decisions.push('📍 Routing to default tab (no projects discovered)');
|
|
60
|
+
return { tabName: 'default', projectPath: null, confidence: 1.0, reason: 'No projects', needsConfirmation: false };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const route = await this.client.route(message, projects, recentRouting);
|
|
64
|
+
// Record the routing decision
|
|
65
|
+
this.memory.recordRouting(message, route.tabName, route.projectPath, route.confidence);
|
|
66
|
+
if (route.confidence >= this.config.pipe.confidenceThreshold) {
|
|
67
|
+
decisions.push(`🧠 Routing to "${route.tabName}" (${Math.round(route.confidence * 100)}% confidence) — ${route.reason}`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
decisions.push(`🤔 Low confidence routing to "${route.tabName}" (${Math.round(route.confidence * 100)}%) — ${route.reason}. Using default.`);
|
|
71
|
+
route.tabName = 'default';
|
|
72
|
+
}
|
|
73
|
+
return route;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
logger.error('Pipe routing failed, using default:', err);
|
|
77
|
+
decisions.push('⚠️ Routing failed, using default tab');
|
|
78
|
+
return { tabName: 'default', projectPath: null, confidence: 0.5, reason: 'Routing error', needsConfirmation: false };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Discover projects on the filesystem */
|
|
82
|
+
async discoverProjects() {
|
|
83
|
+
const projects = scanForProjects(this.config.pipe.projectScanPaths);
|
|
84
|
+
for (const project of projects) {
|
|
85
|
+
this.memory.upsertProject(project);
|
|
86
|
+
}
|
|
87
|
+
return projects.length;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Project } from './types.js';
|
|
2
|
+
export declare class PipeMemoryStore {
|
|
3
|
+
getProjects(): Project[];
|
|
4
|
+
upsertProject(project: Project): void;
|
|
5
|
+
updateProjectLastUsed(path: string): void;
|
|
6
|
+
getRecentRouting(limit?: number): string[];
|
|
7
|
+
recordRouting(messagePreview: string, tabName: string, projectPath: string | null, confidence: number): void;
|
|
8
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
export class PipeMemoryStore {
|
|
4
|
+
// ─── Projects ───
|
|
5
|
+
getProjects() {
|
|
6
|
+
const db = getDb();
|
|
7
|
+
const rows = db.prepare('SELECT * FROM projects ORDER BY last_used_at DESC NULLS LAST').all();
|
|
8
|
+
return rows.map(r => ({
|
|
9
|
+
id: r.id,
|
|
10
|
+
name: r.name,
|
|
11
|
+
path: r.path,
|
|
12
|
+
type: r.type || 'user-project',
|
|
13
|
+
lastUsedAt: r.last_used_at,
|
|
14
|
+
createdAt: r.created_at,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
upsertProject(project) {
|
|
18
|
+
const db = getDb();
|
|
19
|
+
db.prepare(`INSERT INTO projects (id, name, path, type)
|
|
20
|
+
VALUES (?, ?, ?, ?)
|
|
21
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
22
|
+
path=excluded.path, type=excluded.type, last_used_at=datetime('now')
|
|
23
|
+
`).run(project.id || uuidv4(), project.name, project.path, project.type || 'user-project');
|
|
24
|
+
}
|
|
25
|
+
updateProjectLastUsed(path) {
|
|
26
|
+
const db = getDb();
|
|
27
|
+
db.prepare('UPDATE projects SET last_used_at = datetime("now") WHERE path = ?').run(path);
|
|
28
|
+
}
|
|
29
|
+
// ─── Routing History ───
|
|
30
|
+
getRecentRouting(limit = 10) {
|
|
31
|
+
const db = getDb();
|
|
32
|
+
const rows = db.prepare('SELECT message_preview, tab_name, confidence FROM routing_history ORDER BY created_at DESC LIMIT ?').all(limit);
|
|
33
|
+
return rows.map(r => `"${r.message_preview}" → ${r.tab_name} (${Math.round(r.confidence * 100)}%)`);
|
|
34
|
+
}
|
|
35
|
+
recordRouting(messagePreview, tabName, projectPath, confidence) {
|
|
36
|
+
const db = getDb();
|
|
37
|
+
db.prepare('INSERT INTO routing_history (message_preview, tab_name, project_path, confidence) VALUES (?, ?, ?, ?)').run(messagePreview.slice(0, 200), tabName, projectPath, confidence);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Project } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Read projects from the database (populated by discoverProjects() at daemon startup).
|
|
4
|
+
* Falls back to an empty list if the DB is not yet initialized.
|
|
5
|
+
*/
|
|
6
|
+
export declare function scanForProjects(_scanPaths: string[]): Project[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getDb } from '../db/index.js';
|
|
2
|
+
import { logger } from '../util/logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Read projects from the database (populated by discoverProjects() at daemon startup).
|
|
5
|
+
* Falls back to an empty list if the DB is not yet initialized.
|
|
6
|
+
*/
|
|
7
|
+
export function scanForProjects(_scanPaths) {
|
|
8
|
+
try {
|
|
9
|
+
const db = getDb();
|
|
10
|
+
const rows = db.prepare('SELECT * FROM projects ORDER BY last_used_at DESC').all();
|
|
11
|
+
const projects = rows.map(r => ({
|
|
12
|
+
id: r.id,
|
|
13
|
+
name: r.name,
|
|
14
|
+
path: r.path,
|
|
15
|
+
type: r.type,
|
|
16
|
+
lastUsedAt: r.last_used_at,
|
|
17
|
+
createdAt: r.created_at,
|
|
18
|
+
}));
|
|
19
|
+
logger.info(`Project scanner: found ${projects.length} projects from DB`);
|
|
20
|
+
return projects;
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
logger.warn('Project scanner: failed to read from DB, returning empty list:', err);
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface Project {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
path: string;
|
|
5
|
+
type: string;
|
|
6
|
+
lastUsedAt: string;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
/** Populated at scan time, not persisted in DB */
|
|
9
|
+
description?: string;
|
|
10
|
+
/** Populated at scan time, not persisted in DB */
|
|
11
|
+
languages?: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface RouteDecision {
|
|
14
|
+
tabName: string;
|
|
15
|
+
projectPath: string | null;
|
|
16
|
+
confidence: number;
|
|
17
|
+
reason: string;
|
|
18
|
+
needsConfirmation: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ChatContext {
|
|
21
|
+
chatId: number;
|
|
22
|
+
userId: number;
|
|
23
|
+
messageId: number;
|
|
24
|
+
}
|
|
25
|
+
export interface PipeResult {
|
|
26
|
+
tabName: string;
|
|
27
|
+
response: {
|
|
28
|
+
text: string;
|
|
29
|
+
error: boolean;
|
|
30
|
+
costUsd: number;
|
|
31
|
+
durationMs: number;
|
|
32
|
+
};
|
|
33
|
+
decisions: string[];
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/session/manager.js
CHANGED
|
@@ -273,6 +273,43 @@ export class TabManager {
|
|
|
273
273
|
.catch(reject);
|
|
274
274
|
return;
|
|
275
275
|
}
|
|
276
|
+
// Silent network-stall recovery. The startup watchdog killed a
|
|
277
|
+
// subprocess that never emitted a single event — almost always a
|
|
278
|
+
// transient overnight DNS/connection blip that recovers within
|
|
279
|
+
// seconds. Mirror the stale-session retry above (capped by retryDepth)
|
|
280
|
+
// so a blip recovers inside a single fire instead of burning the full
|
|
281
|
+
// maxRuntime and counting toward the 5-failure auto-disable.
|
|
282
|
+
if (subprocess.killReason === 'silent') {
|
|
283
|
+
if (retryDepth === 0) {
|
|
284
|
+
logger.warn(`[${tab.name}] Subprocess produced no output (suspected transient network stall) — retrying once.`);
|
|
285
|
+
// Don't touch the session — the subprocess may never have
|
|
286
|
+
// initialized one. Retry with identical args.
|
|
287
|
+
this.executeMessage(tab, prompt, resume, onTextChunk, onToolUse, compactionDepth, forceFresh, retryDepth + 1)
|
|
288
|
+
.then(resolve)
|
|
289
|
+
.catch(reject);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Retry already attempted and it stalled again — surface a
|
|
293
|
+
// network-specific failure instead of an empty "(no output)" so the
|
|
294
|
+
// user checks connectivity, not their auth/subscription. Resolve
|
|
295
|
+
// (not reject) with error:true; the scheduler counts this toward
|
|
296
|
+
// auto-disable exactly like the maxRuntime path, and reports it via
|
|
297
|
+
// the existing task-failure notification.
|
|
298
|
+
logger.warn(`[${tab.name}] Still no output after retry — surfacing suspected network stall.`);
|
|
299
|
+
db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?').run('idle', new Date().toISOString(), tab.name);
|
|
300
|
+
logActivity('task_failed', 'Subprocess silent stall (retry exhausted)', {
|
|
301
|
+
tabName: tab.name,
|
|
302
|
+
});
|
|
303
|
+
resolve({
|
|
304
|
+
text: 'Subprocess produced no output, even after a retry — suspected network/DNS stall. Check connectivity.',
|
|
305
|
+
costUsd: 0,
|
|
306
|
+
durationMs: 0,
|
|
307
|
+
sessionId: subprocess.sessionId,
|
|
308
|
+
error: true,
|
|
309
|
+
});
|
|
310
|
+
this.processNextInQueue(tab.name);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
276
313
|
// Store assistant response. Skip empty content (typically failed/error runs) so
|
|
277
314
|
// it doesn't trigger the hasDbHistory shouldResume override on future calls.
|
|
278
315
|
if (result.text.trim() !== '') {
|
|
@@ -15,6 +15,13 @@ export declare class ClaudeSubprocess {
|
|
|
15
15
|
private buffer;
|
|
16
16
|
private killTimer;
|
|
17
17
|
private runtimeTimer;
|
|
18
|
+
private startupTimer;
|
|
19
|
+
/**
|
|
20
|
+
* Set when the startup watchdog kills a subprocess that never emitted a
|
|
21
|
+
* single event — distinguishes a transient network/connection stall from a
|
|
22
|
+
* normal exit or a real wall-clock overrun, so the manager can retry once.
|
|
23
|
+
*/
|
|
24
|
+
killReason: 'silent' | null;
|
|
18
25
|
readonly sessionId: string;
|
|
19
26
|
constructor(tabName: string, workingDir: string, config: BeecorkConfig, sessionId?: string, tabSystemPrompt?: string | null | undefined);
|
|
20
27
|
send(prompt: string, callbacks: SubprocessCallbacks, resume?: boolean): Promise<void>;
|
|
@@ -45,6 +45,13 @@ export class ClaudeSubprocess {
|
|
|
45
45
|
buffer = '';
|
|
46
46
|
killTimer = null;
|
|
47
47
|
runtimeTimer = null;
|
|
48
|
+
startupTimer = null;
|
|
49
|
+
/**
|
|
50
|
+
* Set when the startup watchdog kills a subprocess that never emitted a
|
|
51
|
+
* single event — distinguishes a transient network/connection stall from a
|
|
52
|
+
* normal exit or a real wall-clock overrun, so the manager can retry once.
|
|
53
|
+
*/
|
|
54
|
+
killReason = null;
|
|
48
55
|
sessionId;
|
|
49
56
|
constructor(tabName, workingDir, config, sessionId, tabSystemPrompt) {
|
|
50
57
|
this.tabName = tabName;
|
|
@@ -75,6 +82,14 @@ export class ClaudeSubprocess {
|
|
|
75
82
|
continue;
|
|
76
83
|
try {
|
|
77
84
|
const event = JSON.parse(line);
|
|
85
|
+
// First real event proves claude initialized and the socket is alive
|
|
86
|
+
// — the startup-stall window is over, so disarm the watchdog for good.
|
|
87
|
+
// It never re-arms, so legitimate multi-minute tool runs (which emit
|
|
88
|
+
// nothing on stdout until the tool returns) are never killed.
|
|
89
|
+
if (this.startupTimer) {
|
|
90
|
+
clearTimeout(this.startupTimer);
|
|
91
|
+
this.startupTimer = null;
|
|
92
|
+
}
|
|
78
93
|
callbacks.onEvent(event);
|
|
79
94
|
}
|
|
80
95
|
catch {
|
|
@@ -99,6 +114,10 @@ export class ClaudeSubprocess {
|
|
|
99
114
|
clearTimeout(this.runtimeTimer);
|
|
100
115
|
this.runtimeTimer = null;
|
|
101
116
|
}
|
|
117
|
+
if (this.startupTimer) {
|
|
118
|
+
clearTimeout(this.startupTimer);
|
|
119
|
+
this.startupTimer = null;
|
|
120
|
+
}
|
|
102
121
|
callbacks.onError(err);
|
|
103
122
|
});
|
|
104
123
|
this.proc.on('exit', (code) => {
|
|
@@ -111,6 +130,10 @@ export class ClaudeSubprocess {
|
|
|
111
130
|
clearTimeout(this.runtimeTimer);
|
|
112
131
|
this.runtimeTimer = null;
|
|
113
132
|
}
|
|
133
|
+
if (this.startupTimer) {
|
|
134
|
+
clearTimeout(this.startupTimer);
|
|
135
|
+
this.startupTimer = null;
|
|
136
|
+
}
|
|
114
137
|
logger.info(`[${this.tabName}] Claude subprocess exited (code: ${code})`);
|
|
115
138
|
callbacks.onExit(code);
|
|
116
139
|
});
|
|
@@ -126,6 +149,24 @@ export class ClaudeSubprocess {
|
|
|
126
149
|
this.kill();
|
|
127
150
|
}, maxRuntimeMs);
|
|
128
151
|
}
|
|
152
|
+
// Startup watchdog. A healthy claude emits its init event within seconds of
|
|
153
|
+
// spawn. Total silence this long means it wedged on a stalled network socket
|
|
154
|
+
// (DNS/connection blip with no client-side timeout) and would otherwise sit
|
|
155
|
+
// idle until the 30-min maxRuntime kill. Unlike maxRuntime, this routes
|
|
156
|
+
// through onExit (kill only, no onError) so the manager retries once. It is
|
|
157
|
+
// disarmed on the first event, so it can only fire before claude initializes
|
|
158
|
+
// — long-running tools, which emit nothing on stdout until they return, are
|
|
159
|
+
// never affected.
|
|
160
|
+
const silentTimeoutMs = this.config.claudeCode.silentTimeoutMs ?? 120_000;
|
|
161
|
+
if (silentTimeoutMs > 0) {
|
|
162
|
+
this.startupTimer = setTimeout(() => {
|
|
163
|
+
if (!this.proc)
|
|
164
|
+
return;
|
|
165
|
+
logger.warn(`[${this.tabName}] No output ${silentTimeoutMs}ms after spawn — killing as silent network stall`);
|
|
166
|
+
this.killReason = 'silent';
|
|
167
|
+
this.kill();
|
|
168
|
+
}, silentTimeoutMs);
|
|
169
|
+
}
|
|
129
170
|
}
|
|
130
171
|
kill() {
|
|
131
172
|
if (!this.proc)
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Reserved for future approval mode implementation. Not currently wired into the runtime. */
|
|
2
|
+
export type ToolRisk = 'safe' | 'dangerous';
|
|
3
|
+
/** Classify a tool call as safe or dangerous */
|
|
4
|
+
export declare function classifyTool(toolName: string, input: Record<string, unknown>): ToolRisk;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const SAFE_TOOLS = new Set([
|
|
2
|
+
'Read', 'Glob', 'Grep', 'LSP', 'WebFetch', 'WebSearch',
|
|
3
|
+
'ToolSearch', 'TaskGet', 'TaskList',
|
|
4
|
+
]);
|
|
5
|
+
const DANGEROUS_TOOLS = new Set([
|
|
6
|
+
'Write', 'Edit', 'NotebookEdit',
|
|
7
|
+
'TaskCreate', 'TaskUpdate', 'TaskStop',
|
|
8
|
+
]);
|
|
9
|
+
const SAFE_BASH_PATTERNS = [
|
|
10
|
+
/^(ls|cat|head|tail|wc|file|stat|which|type|echo|printf)\b/,
|
|
11
|
+
/^git\s+(status|log|diff|show|branch|tag|remote)\b/,
|
|
12
|
+
/^(pwd|whoami|hostname|date|uname|env|printenv)\b/,
|
|
13
|
+
/^(find|grep|rg|fd|ag)\b/,
|
|
14
|
+
/^(node|python|ruby|go)\s+--?(version|help)/,
|
|
15
|
+
/^npm\s+(list|ls|view|info|outdated|audit)\b/,
|
|
16
|
+
/^curl\s.*-X\s*GET\b/,
|
|
17
|
+
/^curl\s+(?!.*-X\s*(POST|PUT|DELETE|PATCH))(?!.*--data)(?!.*-d\s)/,
|
|
18
|
+
];
|
|
19
|
+
const DANGEROUS_BASH_PATTERNS = [
|
|
20
|
+
/^rm\b/,
|
|
21
|
+
/^(mv|cp)\b.*--?(force|f)\b/,
|
|
22
|
+
/^chmod\b/,
|
|
23
|
+
/^chown\b/,
|
|
24
|
+
/^git\s+(push|reset|rebase|merge|checkout\s+--)\b/,
|
|
25
|
+
/^(docker|kubectl|terraform|ansible)\b/,
|
|
26
|
+
/^(sudo|su)\b/,
|
|
27
|
+
/^npm\s+(publish|install|uninstall|link)\b/,
|
|
28
|
+
/^(kill|killall|pkill)\b/,
|
|
29
|
+
/^curl\s.*-X\s*(POST|PUT|DELETE|PATCH)\b/,
|
|
30
|
+
];
|
|
31
|
+
/** Classify a tool call as safe or dangerous */
|
|
32
|
+
export function classifyTool(toolName, input) {
|
|
33
|
+
if (SAFE_TOOLS.has(toolName))
|
|
34
|
+
return 'safe';
|
|
35
|
+
if (DANGEROUS_TOOLS.has(toolName))
|
|
36
|
+
return 'dangerous';
|
|
37
|
+
// Bash tool: inspect the command
|
|
38
|
+
if (toolName === 'Bash') {
|
|
39
|
+
const command = String(input.command || '').trim();
|
|
40
|
+
for (const pattern of SAFE_BASH_PATTERNS) {
|
|
41
|
+
if (pattern.test(command))
|
|
42
|
+
return 'safe';
|
|
43
|
+
}
|
|
44
|
+
for (const pattern of DANGEROUS_BASH_PATTERNS) {
|
|
45
|
+
if (pattern.test(command))
|
|
46
|
+
return 'dangerous';
|
|
47
|
+
}
|
|
48
|
+
// Default: unknown bash commands are dangerous
|
|
49
|
+
return 'dangerous';
|
|
50
|
+
}
|
|
51
|
+
// MCP tools from external servers: default to dangerous
|
|
52
|
+
if (toolName.startsWith('mcp__'))
|
|
53
|
+
return 'dangerous';
|
|
54
|
+
// Unknown tools: default to dangerous
|
|
55
|
+
return 'dangerous';
|
|
56
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -10,6 +10,15 @@ export interface ClaudeCodeConfig {
|
|
|
10
10
|
computerUse?: boolean;
|
|
11
11
|
/** Hard timeout per subprocess turn. Default 30 min. Set to 0 to disable. */
|
|
12
12
|
maxRuntimeMs?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Startup watchdog: kill (and retry once) a subprocess that produces ZERO
|
|
15
|
+
* events this long after spawn. A healthy claude emits its init event within
|
|
16
|
+
* seconds; prolonged total silence means it wedged on a stalled network
|
|
17
|
+
* socket (DNS/connection blip with no client-side timeout). Default 2 min.
|
|
18
|
+
* Set to 0 to disable. Disarms on the first event, so it never fires
|
|
19
|
+
* mid-task — long-running tools are unaffected.
|
|
20
|
+
*/
|
|
21
|
+
silentTimeoutMs?: number;
|
|
13
22
|
}
|
|
14
23
|
export interface MemoryConfig {
|
|
15
24
|
dbPath: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveUser, registerUser, linkIdentity, listUsers, hasAdmin } from './service.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface User {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
role: 'admin' | 'user';
|
|
5
|
+
budgetUsd: number | null;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
}
|
|
8
|
+
/** Get or create a user from a channel identity */
|
|
9
|
+
export declare function resolveUser(channelId: string, peerId: string): User | null;
|
|
10
|
+
/** Register a new user */
|
|
11
|
+
export declare function registerUser(name: string, channelId: string, peerId: string, role?: 'admin' | 'user'): User;
|
|
12
|
+
/** Link an additional channel identity to an existing user */
|
|
13
|
+
export declare function linkIdentity(userId: string, channelId: string, peerId: string): boolean;
|
|
14
|
+
/** Get all users */
|
|
15
|
+
export declare function listUsers(): User[];
|
|
16
|
+
/** Check if any admin exists */
|
|
17
|
+
export declare function hasAdmin(): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
import { logger } from '../util/logger.js';
|
|
4
|
+
function rowToUser(row) {
|
|
5
|
+
return { id: row.id, name: row.name, role: row.role, budgetUsd: row.budget_usd, createdAt: row.created_at };
|
|
6
|
+
}
|
|
7
|
+
/** Get or create a user from a channel identity */
|
|
8
|
+
export function resolveUser(channelId, peerId) {
|
|
9
|
+
const db = getDb();
|
|
10
|
+
const identity = db.prepare('SELECT user_id FROM identities WHERE channel_id = ? AND peer_id = ?').get(channelId, peerId);
|
|
11
|
+
if (!identity)
|
|
12
|
+
return null;
|
|
13
|
+
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(identity.user_id);
|
|
14
|
+
return row ? rowToUser(row) : null;
|
|
15
|
+
}
|
|
16
|
+
/** Register a new user */
|
|
17
|
+
export function registerUser(name, channelId, peerId, role = 'user') {
|
|
18
|
+
const db = getDb();
|
|
19
|
+
const id = uuidv4();
|
|
20
|
+
db.prepare('INSERT INTO users (id, name, role) VALUES (?, ?, ?)').run(id, name, role);
|
|
21
|
+
db.prepare('INSERT INTO identities (user_id, channel_id, peer_id) VALUES (?, ?, ?)').run(id, channelId, peerId);
|
|
22
|
+
logger.info(`User registered: ${name} (${role}) via ${channelId}:${peerId}`);
|
|
23
|
+
return { id, name, role, budgetUsd: null, createdAt: new Date().toISOString() };
|
|
24
|
+
}
|
|
25
|
+
/** Link an additional channel identity to an existing user */
|
|
26
|
+
export function linkIdentity(userId, channelId, peerId) {
|
|
27
|
+
const db = getDb();
|
|
28
|
+
try {
|
|
29
|
+
db.prepare('INSERT INTO identities (user_id, channel_id, peer_id) VALUES (?, ?, ?)').run(userId, channelId, peerId);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false; // Already linked or conflict
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Get all users */
|
|
37
|
+
export function listUsers() {
|
|
38
|
+
const db = getDb();
|
|
39
|
+
return db.prepare('SELECT * FROM users ORDER BY created_at').all().map(rowToUser);
|
|
40
|
+
}
|
|
41
|
+
/** Check if any admin exists */
|
|
42
|
+
export function hasAdmin() {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const row = db.prepare("SELECT COUNT(*) as c FROM users WHERE role = 'admin'").get();
|
|
45
|
+
return row.c > 0;
|
|
46
|
+
}
|