twinclaw 1.1.1 → 1.1.2
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/README.md +3 -3
- package/dist/api/handlers/agents.js +82 -0
- package/dist/api/handlers/debug.js +69 -0
- package/dist/api/handlers/devices.js +79 -0
- package/dist/api/handlers/jobs.js +99 -0
- package/dist/api/handlers/status.js +149 -0
- package/dist/api/router.js +272 -2
- package/dist/api/runtime-event-producer.js +20 -0
- package/dist/api/shared.js +34 -0
- package/dist/api/websocket-hub.js +18 -7
- package/dist/config/json-config.js +393 -1
- package/dist/core/heartbeat.js +304 -8
- package/dist/core/lane-executor.js +18 -0
- package/dist/core/onboarding.js +2 -2
- package/dist/core/self-improve-cli.js +212 -0
- package/dist/core/simplified-onboarding.js +158 -16
- package/dist/index.js +61 -33
- package/dist/interfaces/dispatcher.js +4 -2
- package/dist/interfaces/whatsapp_handler.js +243 -2
- package/dist/services/auto-configurer.js +591 -0
- package/dist/services/device-pairing.js +378 -0
- package/dist/services/hooks.js +130 -0
- package/dist/services/job-scheduler.js +133 -1
- package/dist/services/learning-system.js +226 -0
- package/dist/services/self-healing.js +267 -0
- package/dist/services/skill-acquisition/intent-parser.js +115 -0
- package/dist/services/skill-acquisition/research-engine.js +319 -0
- package/dist/services/skill-builder.js +235 -0
- package/dist/services/sub-agent-service.js +215 -0
- package/dist/services/web-service.js +70 -0
- package/dist/services/webhook-service.js +127 -0
- package/dist/skills/builtin.js +827 -0
- package/dist/tools/agent-improvement.js +208 -0
- package/dist/types/skill-acquisition.js +4 -0
- package/package.json +3 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { logThought } from '../utils/logger.js';
|
|
5
|
+
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
6
|
+
const CODE_LENGTH = 8;
|
|
7
|
+
const DEFAULT_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes for device pairing
|
|
8
|
+
const DEFAULT_MAX_PENDING = 5;
|
|
9
|
+
const DEFAULT_CAPABILITIES = {
|
|
10
|
+
camera: false,
|
|
11
|
+
screen: false,
|
|
12
|
+
location: false,
|
|
13
|
+
commands: true,
|
|
14
|
+
notifications: true,
|
|
15
|
+
sensors: false,
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Device pairing service for managing paired nodes/devices.
|
|
19
|
+
*
|
|
20
|
+
* Allows controlling paired devices for:
|
|
21
|
+
* - Camera capture
|
|
22
|
+
* - Screen capture
|
|
23
|
+
* - Location tracking
|
|
24
|
+
* - Command execution
|
|
25
|
+
* - Notifications
|
|
26
|
+
*/
|
|
27
|
+
export class DevicePairingService {
|
|
28
|
+
#credentialsDir;
|
|
29
|
+
#codeTtlMs;
|
|
30
|
+
#maxPending;
|
|
31
|
+
#devices = new Map();
|
|
32
|
+
#pendingRequests = new Map();
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.#credentialsDir = options.credentialsDir
|
|
35
|
+
? path.resolve(options.credentialsDir)
|
|
36
|
+
: path.resolve('memory', 'devices');
|
|
37
|
+
this.#codeTtlMs = options.codeTtlMs ?? DEFAULT_CODE_TTL_MS;
|
|
38
|
+
this.#maxPending = options.maxPendingPerChannel ?? DEFAULT_MAX_PENDING;
|
|
39
|
+
// Load existing devices
|
|
40
|
+
this.#loadDevices();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Generate a pairing request for a new device
|
|
44
|
+
*/
|
|
45
|
+
requestPairing(deviceId, publicKey, platform, clientId, clientMode, displayName) {
|
|
46
|
+
// Check if device already paired
|
|
47
|
+
if (this.#devices.has(deviceId)) {
|
|
48
|
+
throw new Error(`Device ${deviceId} is already paired`);
|
|
49
|
+
}
|
|
50
|
+
// Check pending limit
|
|
51
|
+
if (this.#pendingRequests.size >= this.#maxPending) {
|
|
52
|
+
throw new Error('Maximum pending pairing requests reached');
|
|
53
|
+
}
|
|
54
|
+
// Generate code
|
|
55
|
+
const code = this.#generateCode();
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const expiresAt = new Date(now + this.#codeTtlMs).toISOString();
|
|
58
|
+
const request = {
|
|
59
|
+
deviceId,
|
|
60
|
+
displayName,
|
|
61
|
+
publicKey,
|
|
62
|
+
platform,
|
|
63
|
+
clientId,
|
|
64
|
+
clientMode,
|
|
65
|
+
requestedAt: new Date(now).toISOString(),
|
|
66
|
+
expiresAt,
|
|
67
|
+
code,
|
|
68
|
+
};
|
|
69
|
+
this.#pendingRequests.set(code, request);
|
|
70
|
+
this.#savePendingRequests();
|
|
71
|
+
return { code, expiresAt };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Approve a pairing request with the code
|
|
75
|
+
*/
|
|
76
|
+
approve(code) {
|
|
77
|
+
const request = this.#pendingRequests.get(code.toUpperCase());
|
|
78
|
+
if (!request) {
|
|
79
|
+
throw new Error('Invalid or expired pairing code');
|
|
80
|
+
}
|
|
81
|
+
// Check if expired
|
|
82
|
+
if (Date.parse(request.expiresAt) < Date.now()) {
|
|
83
|
+
this.#pendingRequests.delete(code.toUpperCase());
|
|
84
|
+
this.#savePendingRequests();
|
|
85
|
+
throw new Error('Pairing code has expired');
|
|
86
|
+
}
|
|
87
|
+
// Determine roles and scopes based on client mode
|
|
88
|
+
const { roles, scopes, capabilities } = this.#deriveAccess(request);
|
|
89
|
+
// Create the device
|
|
90
|
+
const device = {
|
|
91
|
+
deviceId: request.deviceId,
|
|
92
|
+
displayName: request.displayName,
|
|
93
|
+
publicKey: request.publicKey,
|
|
94
|
+
platform: request.platform,
|
|
95
|
+
clientId: request.clientId,
|
|
96
|
+
clientMode: request.clientMode,
|
|
97
|
+
roles,
|
|
98
|
+
scopes,
|
|
99
|
+
capabilities,
|
|
100
|
+
status: 'paired',
|
|
101
|
+
pairedAt: new Date().toISOString(),
|
|
102
|
+
createdAt: new Date().toISOString(),
|
|
103
|
+
};
|
|
104
|
+
// Store and cleanup
|
|
105
|
+
this.#devices.set(device.deviceId, device);
|
|
106
|
+
this.#pendingRequests.delete(code.toUpperCase());
|
|
107
|
+
this.#saveDevices();
|
|
108
|
+
this.#savePendingRequests();
|
|
109
|
+
logThought(`[DevicePairing] Device ${device.deviceId} paired successfully`);
|
|
110
|
+
return device;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Derive roles, scopes, and capabilities from client mode
|
|
114
|
+
*/
|
|
115
|
+
#deriveAccess(request) {
|
|
116
|
+
const clientMode = request.clientMode;
|
|
117
|
+
switch (clientMode) {
|
|
118
|
+
case 'webchat':
|
|
119
|
+
return {
|
|
120
|
+
roles: ['operator'],
|
|
121
|
+
scopes: ['operator.admin', 'operator.pairing'],
|
|
122
|
+
capabilities: { ...DEFAULT_CAPABILITIES, commands: true },
|
|
123
|
+
};
|
|
124
|
+
case 'sensor':
|
|
125
|
+
return {
|
|
126
|
+
roles: ['sensor'],
|
|
127
|
+
scopes: ['sensor.read'],
|
|
128
|
+
capabilities: {
|
|
129
|
+
camera: true,
|
|
130
|
+
location: true,
|
|
131
|
+
sensors: true,
|
|
132
|
+
notifications: false,
|
|
133
|
+
commands: false,
|
|
134
|
+
screen: false,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
case 'actuator':
|
|
138
|
+
return {
|
|
139
|
+
roles: ['actuator'],
|
|
140
|
+
scopes: ['actuator.execute'],
|
|
141
|
+
capabilities: {
|
|
142
|
+
commands: true,
|
|
143
|
+
notifications: true,
|
|
144
|
+
camera: false,
|
|
145
|
+
location: false,
|
|
146
|
+
sensors: false,
|
|
147
|
+
screen: false,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
case 'backend':
|
|
151
|
+
default:
|
|
152
|
+
return {
|
|
153
|
+
roles: ['controller'],
|
|
154
|
+
scopes: ['controller.admin', 'controller.pairing', 'controller.execute'],
|
|
155
|
+
capabilities: DEFAULT_CAPABILITIES,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Get a device by ID
|
|
161
|
+
*/
|
|
162
|
+
get(deviceId) {
|
|
163
|
+
return this.#devices.get(deviceId);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* List all paired devices
|
|
167
|
+
*/
|
|
168
|
+
list() {
|
|
169
|
+
return Array.from(this.#devices.values());
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* List devices by status
|
|
173
|
+
*/
|
|
174
|
+
listByStatus(status) {
|
|
175
|
+
return this.list().filter(d => d.status === status);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Update device status
|
|
179
|
+
*/
|
|
180
|
+
updateStatus(deviceId, status) {
|
|
181
|
+
const device = this.#devices.get(deviceId);
|
|
182
|
+
if (!device)
|
|
183
|
+
return false;
|
|
184
|
+
device.status = status;
|
|
185
|
+
if (status === 'paired') {
|
|
186
|
+
device.lastSeenAt = new Date().toISOString();
|
|
187
|
+
}
|
|
188
|
+
this.#saveDevices();
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Revoke a device pairing
|
|
193
|
+
*/
|
|
194
|
+
revoke(deviceId) {
|
|
195
|
+
const device = this.#devices.get(deviceId);
|
|
196
|
+
if (!device)
|
|
197
|
+
return false;
|
|
198
|
+
device.status = 'revoked';
|
|
199
|
+
this.#saveDevices();
|
|
200
|
+
logThought(`[DevicePairing] Device ${deviceId} revoked`);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Execute a command on a device
|
|
205
|
+
*/
|
|
206
|
+
async executeCommand(deviceId, command, args) {
|
|
207
|
+
const device = this.#devices.get(deviceId);
|
|
208
|
+
if (!device) {
|
|
209
|
+
return { success: false, error: 'Device not found' };
|
|
210
|
+
}
|
|
211
|
+
if (device.status !== 'paired') {
|
|
212
|
+
return { success: false, error: 'Device is not paired' };
|
|
213
|
+
}
|
|
214
|
+
if (!device.capabilities.commands) {
|
|
215
|
+
return { success: false, error: 'Device does not support command execution' };
|
|
216
|
+
}
|
|
217
|
+
// Check scope
|
|
218
|
+
if (!device.scopes.includes('controller.execute') && !device.scopes.includes('operator.admin')) {
|
|
219
|
+
return { success: false, error: 'Device does not have execute scope' };
|
|
220
|
+
}
|
|
221
|
+
// In a real implementation, this would send the command to the device
|
|
222
|
+
// via WebSocket, HTTP, or another mechanism
|
|
223
|
+
logThought(`[DevicePairing] Would execute command on ${deviceId}: ${command}`);
|
|
224
|
+
return {
|
|
225
|
+
success: true,
|
|
226
|
+
output: `Command ${command} would be executed on device ${device.displayName || deviceId}`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Request camera capture from a device
|
|
231
|
+
*/
|
|
232
|
+
async captureCamera(deviceId) {
|
|
233
|
+
const device = this.#devices.get(deviceId);
|
|
234
|
+
if (!device) {
|
|
235
|
+
return { success: false, error: 'Device not found' };
|
|
236
|
+
}
|
|
237
|
+
if (!device.capabilities.camera) {
|
|
238
|
+
return { success: false, error: 'Device does not have camera capability' };
|
|
239
|
+
}
|
|
240
|
+
logThought(`[DevicePairing] Would request camera capture from ${deviceId}`);
|
|
241
|
+
return {
|
|
242
|
+
success: true,
|
|
243
|
+
imageUrl: `Device ${device.displayName || deviceId} would capture and send image`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Request screen capture from a device
|
|
248
|
+
*/
|
|
249
|
+
async captureScreen(deviceId) {
|
|
250
|
+
const device = this.#devices.get(deviceId);
|
|
251
|
+
if (!device) {
|
|
252
|
+
return { success: false, error: 'Device not found' };
|
|
253
|
+
}
|
|
254
|
+
if (!device.capabilities.screen) {
|
|
255
|
+
return { success: false, error: 'Device does not have screen capture capability' };
|
|
256
|
+
}
|
|
257
|
+
logThought(`[DevicePairing] Would request screen capture from ${deviceId}`);
|
|
258
|
+
return {
|
|
259
|
+
success: true,
|
|
260
|
+
imageUrl: `Device ${device.displayName || deviceId} would capture and send screen`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Request location from a device
|
|
265
|
+
*/
|
|
266
|
+
async getLocation(deviceId) {
|
|
267
|
+
const device = this.#devices.get(deviceId);
|
|
268
|
+
if (!device) {
|
|
269
|
+
return { success: false, error: 'Device not found' };
|
|
270
|
+
}
|
|
271
|
+
if (!device.capabilities.location) {
|
|
272
|
+
return { success: false, error: 'Device does not have location capability' };
|
|
273
|
+
}
|
|
274
|
+
logThought(`[DevicePairing] Would request location from ${deviceId}`);
|
|
275
|
+
return {
|
|
276
|
+
success: true,
|
|
277
|
+
location: { lat: 0, lng: 0 }, // Placeholder
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Generate a unique pairing code
|
|
282
|
+
*/
|
|
283
|
+
#generateCode() {
|
|
284
|
+
const bytes = randomBytes(CODE_LENGTH);
|
|
285
|
+
let code = '';
|
|
286
|
+
for (let i = 0; i < CODE_LENGTH; i++) {
|
|
287
|
+
const index = bytes[i] % CODE_ALPHABET.length;
|
|
288
|
+
code += CODE_ALPHABET[index];
|
|
289
|
+
}
|
|
290
|
+
return code;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Load devices from disk
|
|
294
|
+
*/
|
|
295
|
+
#loadDevices() {
|
|
296
|
+
try {
|
|
297
|
+
const filePath = path.join(this.#credentialsDir, 'paired.json');
|
|
298
|
+
if (!fs.existsSync(filePath))
|
|
299
|
+
return;
|
|
300
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
301
|
+
for (const [id, device] of Object.entries(data)) {
|
|
302
|
+
this.#devices.set(id, device);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
console.warn('[DevicePairing] Failed to load devices:', err);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Save devices to disk
|
|
311
|
+
*/
|
|
312
|
+
#saveDevices() {
|
|
313
|
+
try {
|
|
314
|
+
const filePath = path.join(this.#credentialsDir, 'paired.json');
|
|
315
|
+
const dir = path.dirname(filePath);
|
|
316
|
+
if (!fs.existsSync(dir)) {
|
|
317
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
318
|
+
}
|
|
319
|
+
const data = {};
|
|
320
|
+
for (const [id, device] of this.#devices) {
|
|
321
|
+
data[id] = device;
|
|
322
|
+
}
|
|
323
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
console.error('[DevicePairing] Failed to save devices:', err);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Load pending requests from disk
|
|
331
|
+
*/
|
|
332
|
+
#loadPendingRequests() {
|
|
333
|
+
try {
|
|
334
|
+
const filePath = path.join(this.#credentialsDir, 'pending.json');
|
|
335
|
+
if (!fs.existsSync(filePath))
|
|
336
|
+
return;
|
|
337
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
338
|
+
if (Array.isArray(data)) {
|
|
339
|
+
for (const request of data) {
|
|
340
|
+
// Only load non-expired requests
|
|
341
|
+
if (Date.parse(request.expiresAt) > Date.now()) {
|
|
342
|
+
this.#pendingRequests.set(request.code, request);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
console.warn('[DevicePairing] Failed to load pending requests:', err);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Save pending requests to disk
|
|
353
|
+
*/
|
|
354
|
+
#savePendingRequests() {
|
|
355
|
+
try {
|
|
356
|
+
const filePath = path.join(this.#credentialsDir, 'pending.json');
|
|
357
|
+
const dir = path.dirname(filePath);
|
|
358
|
+
if (!fs.existsSync(dir)) {
|
|
359
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
360
|
+
}
|
|
361
|
+
const data = Array.from(this.#pendingRequests.values());
|
|
362
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
console.error('[DevicePairing] Failed to save pending requests:', err);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
let devicePairingServiceSingleton = null;
|
|
370
|
+
export function getDevicePairingService() {
|
|
371
|
+
if (!devicePairingServiceSingleton) {
|
|
372
|
+
devicePairingServiceSingleton = new DevicePairingService();
|
|
373
|
+
}
|
|
374
|
+
return devicePairingServiceSingleton;
|
|
375
|
+
}
|
|
376
|
+
export function resetDevicePairingService() {
|
|
377
|
+
devicePairingServiceSingleton = null;
|
|
378
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readConfig } from '../config/json-config.js';
|
|
2
|
+
import { logThought } from '../utils/logger.js';
|
|
3
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { getWorkspaceDir } from '../config/workspace.js';
|
|
7
|
+
class HooksService {
|
|
8
|
+
#hooks = new Map();
|
|
9
|
+
#workspaceDir = '';
|
|
10
|
+
async initialize() {
|
|
11
|
+
this.#workspaceDir = getWorkspaceDir();
|
|
12
|
+
try {
|
|
13
|
+
const config = await readConfig();
|
|
14
|
+
const hooksConfig = config.hooks || {};
|
|
15
|
+
for (const [eventName, entries] of Object.entries(hooksConfig)) {
|
|
16
|
+
const event = eventName;
|
|
17
|
+
if (Array.isArray(entries)) {
|
|
18
|
+
this.#hooks.set(event, entries);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
await this.#ensureHooksDir();
|
|
22
|
+
await this.runHook('boot', { event: 'boot', timestamp: new Date().toISOString() });
|
|
23
|
+
void logThought(`[Hooks] Initialized with ${this.#hooks.size} event types.`);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.warn('[Hooks] Failed to initialize hooks system:', err);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async #ensureHooksDir() {
|
|
30
|
+
const hooksDir = path.join(this.#workspaceDir, 'hooks');
|
|
31
|
+
if (!existsSync(hooksDir)) {
|
|
32
|
+
await mkdir(hooksDir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
const bootScript = path.join(hooksDir, 'boot.md');
|
|
35
|
+
if (!existsSync(bootScript)) {
|
|
36
|
+
await writeFile(bootScript, '# TwinClaw Boot Hook\n\nWrite your boot initialization script here.\n', 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async runHook(event, context) {
|
|
40
|
+
const hooks = this.#hooks.get(event) || [];
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const hook of hooks) {
|
|
43
|
+
if (!hook.enabled)
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
let output;
|
|
47
|
+
if (hook.script) {
|
|
48
|
+
output = await this.#runScript(hook.script, context);
|
|
49
|
+
}
|
|
50
|
+
else if (hook.command) {
|
|
51
|
+
output = await this.#runCommand(hook.command, context);
|
|
52
|
+
}
|
|
53
|
+
results.push({ success: true, output });
|
|
54
|
+
void logThought(`[Hooks] Executed ${hook.name} for ${event}: ${output?.slice(0, 50) || 'no output'}`);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
58
|
+
results.push({ success: false, error });
|
|
59
|
+
void logThought(`[Hooks] Hook ${hook.name} failed for ${event}: ${error}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
async #runScript(scriptPath, context) {
|
|
65
|
+
const fullPath = path.isAbsolute(scriptPath)
|
|
66
|
+
? scriptPath
|
|
67
|
+
: path.join(this.#workspaceDir, 'hooks', scriptPath);
|
|
68
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
69
|
+
return content;
|
|
70
|
+
}
|
|
71
|
+
async #runCommand(command, context) {
|
|
72
|
+
const { exec } = await import('child_process');
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const env = {
|
|
75
|
+
...process.env,
|
|
76
|
+
TWINCLAW_EVENT: context.event,
|
|
77
|
+
TWINCLAW_TIMESTAMP: context.timestamp,
|
|
78
|
+
TWINCLAW_SESSION_ID: context.sessionId || '',
|
|
79
|
+
};
|
|
80
|
+
exec(command, { env }, (error, stdout, stderr) => {
|
|
81
|
+
if (error) {
|
|
82
|
+
reject(new Error(stderr || error.message));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
resolve(stdout);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async shutdown() {
|
|
91
|
+
await this.runHook('shutdown', { event: 'shutdown', timestamp: new Date().toISOString() });
|
|
92
|
+
void logThought('[Hooks] Shutdown complete.');
|
|
93
|
+
}
|
|
94
|
+
getHooks(event) {
|
|
95
|
+
return this.#hooks.get(event) || [];
|
|
96
|
+
}
|
|
97
|
+
async registerHook(event, hook) {
|
|
98
|
+
const hooks = this.#hooks.get(event) || [];
|
|
99
|
+
hooks.push(hook);
|
|
100
|
+
this.#hooks.set(event, hooks);
|
|
101
|
+
}
|
|
102
|
+
async unregisterHook(event, hookName) {
|
|
103
|
+
const hooks = this.#hooks.get(event) || [];
|
|
104
|
+
const filtered = hooks.filter(h => h.name !== hookName);
|
|
105
|
+
this.#hooks.set(event, filtered);
|
|
106
|
+
}
|
|
107
|
+
async toggleHook(event, hookName, enabled) {
|
|
108
|
+
const hooks = this.#hooks.get(event) || [];
|
|
109
|
+
const hook = hooks.find(h => h.name === hookName);
|
|
110
|
+
if (hook) {
|
|
111
|
+
hook.enabled = enabled;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let hooksService = null;
|
|
116
|
+
export function getHooksService() {
|
|
117
|
+
if (!hooksService) {
|
|
118
|
+
hooksService = new HooksService();
|
|
119
|
+
}
|
|
120
|
+
return hooksService;
|
|
121
|
+
}
|
|
122
|
+
export async function initializeHooks() {
|
|
123
|
+
const service = getHooksService();
|
|
124
|
+
await service.initialize();
|
|
125
|
+
}
|
|
126
|
+
export async function shutdownHooks() {
|
|
127
|
+
if (hooksService) {
|
|
128
|
+
await hooksService.shutdown();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import cron from 'node-cron';
|
|
2
2
|
import { logThought } from '../utils/logger.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
/**
|
|
4
6
|
* Centralized job scheduler for TwinClaw's proactive execution layer.
|
|
5
7
|
*
|
|
6
8
|
* Wraps `node-cron` to manage multiple named, repeating background jobs
|
|
7
|
-
* with event emission, error isolation,
|
|
9
|
+
* with event emission, error isolation, runtime inspection, and persistence.
|
|
8
10
|
*
|
|
9
11
|
* Usage:
|
|
10
12
|
* ```ts
|
|
@@ -16,11 +18,21 @@ import { logThought } from '../utils/logger.js';
|
|
|
16
18
|
* handler: async () => { … },
|
|
17
19
|
* });
|
|
18
20
|
* scheduler.startAll();
|
|
21
|
+
*
|
|
22
|
+
* // Jobs are persisted to disk and restored on restart
|
|
19
23
|
* ```
|
|
20
24
|
*/
|
|
21
25
|
export class JobScheduler {
|
|
22
26
|
#jobs = new Map();
|
|
23
27
|
#listeners = new Map();
|
|
28
|
+
#persistencePath;
|
|
29
|
+
/**
|
|
30
|
+
* @param persistenceDir - Directory to store job persistence files
|
|
31
|
+
*/
|
|
32
|
+
constructor(persistenceDir) {
|
|
33
|
+
this.#persistencePath = path.resolve(persistenceDir || './memory', 'scheduler-jobs.json');
|
|
34
|
+
this.#loadPersistedJobs();
|
|
35
|
+
}
|
|
24
36
|
/** Register a new repeating job. Throws if a job with the same ID already exists. */
|
|
25
37
|
register(config) {
|
|
26
38
|
if (this.#jobs.has(config.id)) {
|
|
@@ -41,6 +53,8 @@ export class JobScheduler {
|
|
|
41
53
|
if (autoStart) {
|
|
42
54
|
this.#startJob(entry);
|
|
43
55
|
}
|
|
56
|
+
// Persist job configuration
|
|
57
|
+
this.#persistJobs();
|
|
44
58
|
}
|
|
45
59
|
/** Unregister and stop a job by ID. */
|
|
46
60
|
unregister(jobId) {
|
|
@@ -71,6 +85,14 @@ export class JobScheduler {
|
|
|
71
85
|
entry.status = 'stopped';
|
|
72
86
|
}
|
|
73
87
|
}
|
|
88
|
+
/** Run a specific job immediately (outside of its cron schedule). */
|
|
89
|
+
async runNow(jobId) {
|
|
90
|
+
const entry = this.#jobs.get(jobId);
|
|
91
|
+
if (!entry) {
|
|
92
|
+
throw new Error(`[JobScheduler] Job '${jobId}' is not registered.`);
|
|
93
|
+
}
|
|
94
|
+
await this.#executeJob(entry);
|
|
95
|
+
}
|
|
74
96
|
/** Start all registered jobs that are not currently running. */
|
|
75
97
|
startAll() {
|
|
76
98
|
for (const entry of this.#jobs.values()) {
|
|
@@ -130,6 +152,64 @@ export class JobScheduler {
|
|
|
130
152
|
set?.delete(listener);
|
|
131
153
|
};
|
|
132
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Schedule a one-time job to run after a delay (for reminders).
|
|
157
|
+
* @param id - Unique job identifier
|
|
158
|
+
* @param delayMs - Delay in milliseconds
|
|
159
|
+
* @param handler - Function to execute
|
|
160
|
+
* @param description - Optional description
|
|
161
|
+
*/
|
|
162
|
+
scheduleOnce(id, delayMs, handler, description) {
|
|
163
|
+
if (this.#jobs.has(id)) {
|
|
164
|
+
throw new Error(`[JobScheduler] Job '${id}' is already registered.`);
|
|
165
|
+
}
|
|
166
|
+
// Convert to cron-like format for one-time execution
|
|
167
|
+
const entry = {
|
|
168
|
+
config: {
|
|
169
|
+
id,
|
|
170
|
+
cronExpression: 'once', // Special marker for one-time jobs
|
|
171
|
+
description: description || `One-time job: ${id}`,
|
|
172
|
+
handler,
|
|
173
|
+
autoStart: true,
|
|
174
|
+
},
|
|
175
|
+
task: null,
|
|
176
|
+
status: 'idle',
|
|
177
|
+
lastRunAt: null,
|
|
178
|
+
lastError: null,
|
|
179
|
+
};
|
|
180
|
+
this.#jobs.set(id, entry);
|
|
181
|
+
// Schedule the one-time execution
|
|
182
|
+
setTimeout(async () => {
|
|
183
|
+
entry.status = 'running';
|
|
184
|
+
this.#emit({ type: 'job:start', jobId: id, timestamp: new Date() });
|
|
185
|
+
try {
|
|
186
|
+
await handler();
|
|
187
|
+
entry.status = 'idle';
|
|
188
|
+
this.#emit({ type: 'job:done', jobId: id, timestamp: new Date() });
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
entry.status = 'error';
|
|
192
|
+
entry.lastError = err instanceof Error ? err.message : String(err);
|
|
193
|
+
this.#emit({ type: 'job:error', jobId: id, timestamp: new Date(), error: entry.lastError });
|
|
194
|
+
}
|
|
195
|
+
// Remove one-time job after execution
|
|
196
|
+
this.#jobs.delete(id);
|
|
197
|
+
this.#persistJobs();
|
|
198
|
+
}, delayMs);
|
|
199
|
+
this.#persistJobs();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Schedule a reminder job (convenience method)
|
|
203
|
+
* @param id - Unique job identifier
|
|
204
|
+
* @param delayMs - Delay in milliseconds
|
|
205
|
+
* @param message - Reminder message to send
|
|
206
|
+
* @param onReminder - Callback to execute when reminder fires
|
|
207
|
+
*/
|
|
208
|
+
scheduleReminder(id, delayMs, message, onReminder) {
|
|
209
|
+
this.scheduleOnce(id, delayMs, async () => {
|
|
210
|
+
await onReminder(message);
|
|
211
|
+
}, `Reminder: ${message}`);
|
|
212
|
+
}
|
|
133
213
|
// ── Private Helpers ────────────────────────────────────────────────────────
|
|
134
214
|
#startJob(entry) {
|
|
135
215
|
if (entry.task)
|
|
@@ -139,6 +219,58 @@ export class JobScheduler {
|
|
|
139
219
|
});
|
|
140
220
|
entry.status = 'idle';
|
|
141
221
|
}
|
|
222
|
+
/** Load persisted jobs from disk */
|
|
223
|
+
#loadPersistedJobs() {
|
|
224
|
+
try {
|
|
225
|
+
if (!fs.existsSync(this.#persistencePath)) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const data = fs.readFileSync(this.#persistencePath, 'utf8');
|
|
229
|
+
const persisted = JSON.parse(data);
|
|
230
|
+
if (!persisted.jobs || !Array.isArray(persisted.jobs)) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
for (const job of persisted.jobs) {
|
|
234
|
+
// Only restore jobs that should auto-start
|
|
235
|
+
if (job.autoStart !== false) {
|
|
236
|
+
// Recreate the job config (without handler)
|
|
237
|
+
// Handlers need to be re-registered by the caller
|
|
238
|
+
console.log(`[JobScheduler] Found persisted job: ${job.id}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
logThought(`[JobScheduler] Loaded ${persisted.jobs?.length || 0} persisted jobs`);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
console.warn('[JobScheduler] Failed to load persisted jobs:', err);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** Save jobs to disk */
|
|
248
|
+
#persistJobs() {
|
|
249
|
+
try {
|
|
250
|
+
const jobsData = Array.from(this.#jobs.values()).map(entry => ({
|
|
251
|
+
id: entry.config.id,
|
|
252
|
+
cronExpression: entry.config.cronExpression,
|
|
253
|
+
description: entry.config.description,
|
|
254
|
+
autoStart: entry.config.autoStart,
|
|
255
|
+
status: entry.status,
|
|
256
|
+
lastRunAt: entry.lastRunAt?.toISOString(),
|
|
257
|
+
lastError: entry.lastError,
|
|
258
|
+
}));
|
|
259
|
+
const data = {
|
|
260
|
+
version: 1,
|
|
261
|
+
savedAt: new Date().toISOString(),
|
|
262
|
+
jobs: jobsData,
|
|
263
|
+
};
|
|
264
|
+
const dir = path.dirname(this.#persistencePath);
|
|
265
|
+
if (!fs.existsSync(dir)) {
|
|
266
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
267
|
+
}
|
|
268
|
+
fs.writeFileSync(this.#persistencePath, JSON.stringify(data, null, 2));
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
console.warn('[JobScheduler] Failed to persist jobs:', err);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
142
274
|
async #executeJob(entry) {
|
|
143
275
|
const { config } = entry;
|
|
144
276
|
entry.status = 'running';
|