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.
Files changed (35) hide show
  1. package/README.md +3 -3
  2. package/dist/api/handlers/agents.js +82 -0
  3. package/dist/api/handlers/debug.js +69 -0
  4. package/dist/api/handlers/devices.js +79 -0
  5. package/dist/api/handlers/jobs.js +99 -0
  6. package/dist/api/handlers/status.js +149 -0
  7. package/dist/api/router.js +272 -2
  8. package/dist/api/runtime-event-producer.js +20 -0
  9. package/dist/api/shared.js +34 -0
  10. package/dist/api/websocket-hub.js +18 -7
  11. package/dist/config/json-config.js +393 -1
  12. package/dist/core/heartbeat.js +304 -8
  13. package/dist/core/lane-executor.js +18 -0
  14. package/dist/core/onboarding.js +2 -2
  15. package/dist/core/self-improve-cli.js +212 -0
  16. package/dist/core/simplified-onboarding.js +158 -16
  17. package/dist/index.js +61 -33
  18. package/dist/interfaces/dispatcher.js +4 -2
  19. package/dist/interfaces/whatsapp_handler.js +243 -2
  20. package/dist/services/auto-configurer.js +591 -0
  21. package/dist/services/device-pairing.js +378 -0
  22. package/dist/services/hooks.js +130 -0
  23. package/dist/services/job-scheduler.js +133 -1
  24. package/dist/services/learning-system.js +226 -0
  25. package/dist/services/self-healing.js +267 -0
  26. package/dist/services/skill-acquisition/intent-parser.js +115 -0
  27. package/dist/services/skill-acquisition/research-engine.js +319 -0
  28. package/dist/services/skill-builder.js +235 -0
  29. package/dist/services/sub-agent-service.js +215 -0
  30. package/dist/services/web-service.js +70 -0
  31. package/dist/services/webhook-service.js +127 -0
  32. package/dist/skills/builtin.js +827 -0
  33. package/dist/tools/agent-improvement.js +208 -0
  34. package/dist/types/skill-acquisition.js +4 -0
  35. 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, and runtime inspection.
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';