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
@@ -1,13 +1,15 @@
1
1
  import { createServer } from 'node:http';
2
2
  import express from 'express';
3
3
  import { handleHealth, handleLiveness, handleReadiness } from './handlers/health.js';
4
+ import { handleStatus } from './handlers/status.js';
5
+ import { handleDebug } from './handlers/debug.js';
4
6
  import { handleBrowserSnapshot, handleBrowserClick } from './handlers/browser.js';
5
7
  import { handleWebhookCallback } from './handlers/callback.js';
6
8
  import { handleSkillPackageDiagnostics, handleSkillPackageInstall, handleSkillPackageUpgrade, handleSkillPackageUninstall, } from './handlers/skill-packages.js';
7
9
  import { handleLocalStateBackupDiagnostics, handleLocalStateCreateSnapshot, handleLocalStateRestoreSnapshot, } from './handlers/local-state-backup.js';
8
10
  import { handlePersonaStateGet, handlePersonaStateUpdate, } from './handlers/persona-state.js';
9
11
  import { handleConfigValidate } from './handlers/config-validate.js';
10
- import { requestLogger, requireSignature, sendError, sendOk, setRawRequestBody } from './shared.js';
12
+ import { requestLogger, requireAuth, sendError, sendOk, setRawRequestBody } from './shared.js';
11
13
  import { BrowserService } from '../services/browser-service.js';
12
14
  import { getPersonaStateService } from '../services/persona-state.js';
13
15
  import { logThought } from '../utils/logger.js';
@@ -15,6 +17,17 @@ import { getCallbackOutcomeCounts } from '../services/db.js';
15
17
  import { readFile } from 'node:fs/promises';
16
18
  import path from 'node:path';
17
19
  import { getConfigValue } from '../config/config-loader.js';
20
+ import { getLearningSystem } from '../services/learning-system.js';
21
+ import { getSkillBuilder } from '../services/skill-builder.js';
22
+ import { getAutoConfigurer } from '../services/auto-configurer.js';
23
+ import { getSelfHealingService } from '../services/self-healing.js';
24
+ import { getAgentImprovementTool } from '../tools/agent-improvement.js';
25
+ import { getIntentParser } from '../services/skill-acquisition/intent-parser.js';
26
+ import { getResearchEngine } from '../services/skill-acquisition/research-engine.js';
27
+ import { getDevicePairingService } from '../services/device-pairing.js';
28
+ import { handleJobsList, handleJobsGet, handleJobsRun, handleJobsStart, handleJobsStop, } from './handlers/jobs.js';
29
+ import { handleAgentsList, handleAgentsGet, handleAgentsCancel, } from './handlers/agents.js';
30
+ import { handleDevicesList, handleDevicesGet, handleDevicesCommand, } from './handlers/devices.js';
18
31
  const DEFAULT_PORT = 18789;
19
32
  const DEFAULT_BIND_HOST = '127.0.0.1';
20
33
  /**
@@ -77,11 +90,37 @@ export function startApiServer(deps) {
77
90
  const personaStateDeps = {
78
91
  personaStateService: getPersonaStateService(),
79
92
  };
93
+ const statusDeps = {
94
+ heartbeat: deps.heartbeat,
95
+ skillRegistry: deps.skillRegistry,
96
+ mcpManager: deps.mcpManager,
97
+ budgetGovernor: deps.budgetGovernor,
98
+ localStateBackup: deps.localStateBackup,
99
+ modelRouter: deps.modelRouter,
100
+ jobScheduler: deps.jobScheduler,
101
+ subAgentService: deps.subAgentService,
102
+ devicePairingService: getDevicePairingService(),
103
+ incidentManager: deps.incidentManager,
104
+ };
105
+ // Job Scheduler deps
106
+ const jobsDeps = {
107
+ scheduler: deps.jobScheduler,
108
+ };
109
+ // Sub-Agent deps
110
+ const agentsDeps = {
111
+ subAgentService: deps.subAgentService,
112
+ };
113
+ // Device Pairing deps
114
+ const devicesDeps = {
115
+ devicePairingService: getDevicePairingService(),
116
+ };
80
117
  // ── Routes ──────────────────────────────────────────────────────────────────
81
118
  app.get('/health', handleHealth(healthDeps));
82
119
  app.get('/health/live', handleLiveness());
83
120
  app.get('/health/ready', handleReadiness(healthDeps));
84
- app.use(requireSignature);
121
+ app.get('/status', handleStatus(statusDeps));
122
+ app.get('/debug', handleDebug(statusDeps));
123
+ app.use(requireAuth);
85
124
  app.get('/config/validate', handleConfigValidate());
86
125
  app.get('/backup/diagnostics', handleLocalStateBackupDiagnostics(localStateBackupDeps));
87
126
  app.post('/backup/snapshot', handleLocalStateCreateSnapshot(localStateBackupDeps));
@@ -272,6 +311,237 @@ export function startApiServer(deps) {
272
311
  }
273
312
  sendOk(res, deps.wsHub.getMetrics());
274
313
  });
314
+ // ── Self-Improving Agent Routes ─────────────────────────────────────────────
315
+ app.get('/self/stats', (_req, res) => {
316
+ try {
317
+ const learning = getLearningSystem();
318
+ const stats = learning.getStats();
319
+ sendOk(res, stats);
320
+ }
321
+ catch (err) {
322
+ sendError(res, `Failed to get stats: ${err instanceof Error ? err.message : String(err)}`, 500);
323
+ }
324
+ });
325
+ app.get('/self/health', async (_req, res) => {
326
+ try {
327
+ const healer = getSelfHealingService();
328
+ const metrics = await healer.performHealthCheck();
329
+ sendOk(res, { metrics });
330
+ }
331
+ catch (err) {
332
+ sendError(res, `Health check failed: ${err instanceof Error ? err.message : String(err)}`, 500);
333
+ }
334
+ });
335
+ app.get('/self/learn', (req, res) => {
336
+ const query = typeof req.query.q === 'string' ? req.query.q : '';
337
+ if (!query) {
338
+ sendError(res, 'Query parameter "q" is required.', 400);
339
+ return;
340
+ }
341
+ try {
342
+ const learning = getLearningSystem();
343
+ const result = learning.query(query);
344
+ sendOk(res, result);
345
+ }
346
+ catch (err) {
347
+ sendError(res, `Query failed: ${err instanceof Error ? err.message : String(err)}`, 500);
348
+ }
349
+ });
350
+ app.post('/self/learn', async (req, res) => {
351
+ const { pattern, solution, type = 'fix' } = req.body;
352
+ if (!pattern || !solution) {
353
+ sendError(res, 'Required fields: pattern, solution', 400);
354
+ return;
355
+ }
356
+ try {
357
+ const learning = getLearningSystem();
358
+ await learning.learn(type, pattern, {}, solution, 'success');
359
+ sendOk(res, { message: `Learned: ${pattern} -> ${solution}` });
360
+ }
361
+ catch (err) {
362
+ sendError(res, `Learn failed: ${err instanceof Error ? err.message : String(err)}`, 500);
363
+ }
364
+ });
365
+ app.get('/self/services', (_req, res) => {
366
+ try {
367
+ const configurer = getAutoConfigurer();
368
+ const services = configurer.listKnownServices();
369
+ sendOk(res, { services });
370
+ }
371
+ catch (err) {
372
+ sendError(res, `Failed to list services: ${err instanceof Error ? err.message : String(err)}`, 500);
373
+ }
374
+ });
375
+ app.get('/self/configure/:service', async (req, res) => {
376
+ const service = req.params.service;
377
+ try {
378
+ const configurer = getAutoConfigurer();
379
+ const { config, steps } = await configurer.fetchDocsAndConfig(service);
380
+ sendOk(res, { service, config, steps });
381
+ }
382
+ catch (err) {
383
+ sendError(res, `Configure failed: ${err instanceof Error ? err.message : String(err)}`, 500);
384
+ }
385
+ });
386
+ app.post('/self/configure/:service', async (req, res) => {
387
+ const service = req.params.service;
388
+ const config = req.body;
389
+ try {
390
+ const configurer = getAutoConfigurer();
391
+ const result = await configurer.saveServiceConfig(service, config);
392
+ sendOk(res, result);
393
+ }
394
+ catch (err) {
395
+ sendError(res, `Save config failed: ${err instanceof Error ? err.message : String(err)}`, 500);
396
+ }
397
+ });
398
+ app.post('/self/build-skill', async (req, res) => {
399
+ const { description, purpose } = req.body;
400
+ if (!description) {
401
+ sendError(res, 'Required field: description', 400);
402
+ return;
403
+ }
404
+ try {
405
+ const builder = getSkillBuilder();
406
+ const skill = await builder.buildFromRequest({ description, purpose });
407
+ sendOk(res, {
408
+ id: skill.id,
409
+ name: skill.name,
410
+ category: skill.category,
411
+ location: `${builder.getCustomSkillsDir()}/${skill.id}.ts`
412
+ });
413
+ }
414
+ catch (err) {
415
+ sendError(res, `Build skill failed: ${err instanceof Error ? err.message : String(err)}`, 500);
416
+ }
417
+ });
418
+ app.get('/self/skills', (_req, res) => {
419
+ try {
420
+ const builder = getSkillBuilder();
421
+ const skills = builder.getCustomSkills();
422
+ sendOk(res, { skills });
423
+ }
424
+ catch (err) {
425
+ sendError(res, `Failed to list skills: ${err instanceof Error ? err.message : String(err)}`, 500);
426
+ }
427
+ });
428
+ app.post('/self/improve', async (req, res) => {
429
+ const { goal, context } = req.body;
430
+ if (!goal) {
431
+ sendError(res, 'Required field: goal', 400);
432
+ return;
433
+ }
434
+ try {
435
+ const tool = getAgentImprovementTool();
436
+ const result = await tool.handleImprovementRequest({ goal, context });
437
+ sendOk(res, result);
438
+ }
439
+ catch (err) {
440
+ sendError(res, `Improve request failed: ${err instanceof Error ? err.message : String(err)}`, 500);
441
+ }
442
+ });
443
+ // ── Skill Acquisition Routes ───────────────────────────────────────────────
444
+ // POST /api/skills/acquire - Start acquiring a new skill
445
+ app.post('/skills/acquire', async (req, res) => {
446
+ const { request } = req.body;
447
+ if (!request) {
448
+ sendError(res, 'Required field: request (natural language description)', 400);
449
+ return;
450
+ }
451
+ try {
452
+ // Step 1: Parse intent
453
+ const intentParser = getIntentParser();
454
+ const intent = await intentParser.parseIntent(request);
455
+ // Step 2: Research
456
+ const researchEngine = getResearchEngine();
457
+ const research = await researchEngine.research(intent);
458
+ // Step 3: Generate skill code
459
+ const skillBuilder = getSkillBuilder();
460
+ const skill = await skillBuilder.buildFromRequest({
461
+ description: request,
462
+ purpose: 'user requested'
463
+ });
464
+ sendOk(res, {
465
+ status: 'generated',
466
+ intent,
467
+ research,
468
+ skill: {
469
+ id: skill.id,
470
+ name: skill.name,
471
+ description: skill.description,
472
+ category: skill.category
473
+ },
474
+ nextSteps: [
475
+ 'Review the generated skill',
476
+ 'Provide credentials if needed',
477
+ 'Approve to activate the skill'
478
+ ]
479
+ });
480
+ }
481
+ catch (err) {
482
+ sendError(res, `Skill acquisition failed: ${err instanceof Error ? err.message : String(err)}`, 500);
483
+ }
484
+ });
485
+ // GET /api/skills - List all skills (built-in + custom)
486
+ app.get('/skills', (_req, res) => {
487
+ try {
488
+ const builder = getSkillBuilder();
489
+ const customSkills = builder.getCustomSkills();
490
+ sendOk(res, {
491
+ customSkills,
492
+ total: customSkills.length
493
+ });
494
+ }
495
+ catch (err) {
496
+ sendError(res, `Failed to list skills: ${err instanceof Error ? err.message : String(err)}`, 500);
497
+ }
498
+ });
499
+ // GET /api/skills/:id - Get skill details
500
+ app.get('/skills/:id', (req, res) => {
501
+ const { id } = req.params;
502
+ try {
503
+ const builder = getSkillBuilder();
504
+ const skill = builder.getSkill(id);
505
+ if (!skill) {
506
+ sendError(res, 'Skill not found', 404);
507
+ return;
508
+ }
509
+ sendOk(res, skill);
510
+ }
511
+ catch (err) {
512
+ sendError(res, `Failed to get skill: ${err instanceof Error ? err.message : String(err)}`, 500);
513
+ }
514
+ });
515
+ // ── Job Scheduler Routes ─────────────────────────────────────────────────────
516
+ app.get('/jobs', handleJobsList(jobsDeps));
517
+ app.get('/jobs/:id', handleJobsGet(jobsDeps));
518
+ app.post('/jobs/:id/run', handleJobsRun(jobsDeps));
519
+ app.post('/jobs/:id/start', handleJobsStart(jobsDeps));
520
+ app.post('/jobs/:id/stop', handleJobsStop(jobsDeps));
521
+ // ── Sub-Agent Routes ─────────────────────────────────────────────────────
522
+ app.get('/agents', handleAgentsList(agentsDeps));
523
+ app.get('/agents/:id', handleAgentsGet(agentsDeps));
524
+ app.post('/agents/:id/cancel', handleAgentsCancel(agentsDeps));
525
+ // ── Device Pairing Routes ───────────────────────────────────────────────
526
+ app.get('/devices', handleDevicesList(devicesDeps));
527
+ app.get('/devices/:id', handleDevicesGet(devicesDeps));
528
+ app.post('/devices/:id/command', handleDevicesCommand(devicesDeps));
529
+ // DELETE /api/skills/:id - Delete a custom skill
530
+ app.delete('/skills/:id', async (req, res) => {
531
+ const { id } = req.params;
532
+ try {
533
+ const builder = getSkillBuilder();
534
+ const deleted = await builder.deleteSkill(id);
535
+ if (!deleted) {
536
+ sendError(res, 'Skill not found or could not be deleted', 404);
537
+ return;
538
+ }
539
+ sendOk(res, { message: 'Skill deleted successfully' });
540
+ }
541
+ catch (err) {
542
+ sendError(res, `Failed to delete skill: ${err instanceof Error ? err.message : String(err)}`, 500);
543
+ }
544
+ });
275
545
  // ── Catch-all 404 ──────────────────────────────────────────────────────────
276
546
  app.use((_req, res) => {
277
547
  sendError(res, 'Not found.', 404);
@@ -60,6 +60,16 @@ export class RuntimeEventProducer {
60
60
  if (include('reliability') && this.#deps.dispatcher) {
61
61
  snapshot.reliability = this.#buildReliabilityPayload();
62
62
  }
63
+ if (include('jobs') && this.#deps.jobScheduler) {
64
+ snapshot.jobs = {
65
+ list: this.#deps.jobScheduler.listJobs(),
66
+ };
67
+ }
68
+ if (include('agents') && this.#deps.subAgentService) {
69
+ snapshot.agents = {
70
+ list: this.#deps.subAgentService.list(),
71
+ };
72
+ }
63
73
  return snapshot;
64
74
  }
65
75
  // ── Private Helpers ────────────────────────────────────────────────────────
@@ -76,6 +86,16 @@ export class RuntimeEventProducer {
76
86
  if (this.#deps.dispatcher) {
77
87
  this.#hub.publish('reliability', this.#buildReliabilityPayload());
78
88
  }
89
+ if (this.#deps.jobScheduler) {
90
+ this.#hub.publish('jobs', {
91
+ list: this.#deps.jobScheduler.listJobs(),
92
+ });
93
+ }
94
+ if (this.#deps.subAgentService) {
95
+ this.#hub.publish('agents', {
96
+ list: this.#deps.subAgentService.list(),
97
+ });
98
+ }
79
99
  }
80
100
  #buildReliabilityPayload() {
81
101
  const queueMetrics = this.#deps.dispatcher.queue.getStats() ?? null;
@@ -1,6 +1,7 @@
1
1
  import { createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
2
2
  import { logThought, scrubSensitiveText } from '../utils/logger.js';
3
3
  import { getSecretVaultService } from '../services/secret-vault.js';
4
+ import { readConfig } from '../config/config-loader.js';
4
5
  function stableStringify(value) {
5
6
  if (value === null || typeof value !== 'object') {
6
7
  const serialized = JSON.stringify(value);
@@ -112,6 +113,39 @@ export function requireSignature(req, res, next) {
112
113
  }
113
114
  next();
114
115
  }
116
+ /**
117
+ * Validate token-based auth for API requests.
118
+ * Supports both HMAC and token modes based on gateway config.
119
+ */
120
+ export async function requireAuth(req, res, next) {
121
+ try {
122
+ const config = await readConfig();
123
+ const authMode = config.gateway?.auth?.mode ?? 'hmac';
124
+ if (authMode === 'token') {
125
+ const token = config.gateway?.auth?.token;
126
+ if (!token) {
127
+ sendError(res, 'Gateway token not configured.', 503);
128
+ return;
129
+ }
130
+ const providedToken = req.headers['x-api-token'];
131
+ if (!providedToken || providedToken !== token) {
132
+ void logThought('[API] Token request rejected — invalid token.');
133
+ sendError(res, 'Invalid API token.', 401);
134
+ return;
135
+ }
136
+ }
137
+ else {
138
+ requireSignature(req, res, next);
139
+ return;
140
+ }
141
+ next();
142
+ }
143
+ catch (err) {
144
+ const { status, message } = mapError(err);
145
+ void logThought(`[API] Auth error: ${message}`);
146
+ sendError(res, message, status);
147
+ }
148
+ }
115
149
  // ── Error Mapping ───────────────────────────────────────────────────────────
116
150
  /** Map a caught error to a status code and message. */
117
151
  export function mapError(err) {
@@ -3,12 +3,13 @@ import { WebSocketServer, WebSocket } from 'ws';
3
3
  import { logThought } from '../utils/logger.js';
4
4
  import { WsCloseCode } from '../types/websocket.js';
5
5
  import { getSecretVaultService } from '../services/secret-vault.js';
6
+ import { readConfig } from '../config/config-loader.js';
6
7
  // ── Constants ──────────────────────────────────────────────────────────────────
7
8
  const DEFAULT_AUTH_TIMEOUT_MS = 5_000;
8
9
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
9
10
  /** Default backpressure threshold in kilobytes (see WsHubConfig.maxClientQueue). */
10
11
  const DEFAULT_MAX_CLIENT_QUEUE = 200;
11
- const VALID_TOPICS = new Set(['health', 'reliability', 'incidents', 'routing']);
12
+ const VALID_TOPICS = new Set(['health', 'reliability', 'incidents', 'routing', 'jobs', 'agents', 'messages']);
12
13
  // ── WsHub ──────────────────────────────────────────────────────────────────────
13
14
  /**
14
15
  * Control-plane WebSocket hub.
@@ -155,7 +156,7 @@ export class WsHub {
155
156
  client.isAlive = true;
156
157
  });
157
158
  ws.on('message', (data) => {
158
- this.#handleMessage(client, data);
159
+ void this.#handleMessage(client, data);
159
160
  });
160
161
  ws.on('close', () => {
161
162
  this.#cleanupClient(client);
@@ -166,7 +167,7 @@ export class WsHub {
166
167
  });
167
168
  void logThought(`[WsHub] New connection: ${clientId}.`);
168
169
  }
169
- #handleMessage(client, rawData) {
170
+ async #handleMessage(client, rawData) {
170
171
  let msg;
171
172
  try {
172
173
  msg = JSON.parse(String(rawData));
@@ -181,7 +182,7 @@ export class WsHub {
181
182
  }
182
183
  switch (msg.type) {
183
184
  case 'auth':
184
- this.#handleAuth(client, msg);
185
+ await this.#handleAuth(client, msg);
185
186
  break;
186
187
  case 'subscribe':
187
188
  this.#handleSubscribe(client, msg);
@@ -193,10 +194,20 @@ export class WsHub {
193
194
  this.#sendError(client, 400, `Unknown message type: ${String(msg.type)}`);
194
195
  }
195
196
  }
196
- #handleAuth(client, msg) {
197
+ async #handleAuth(client, msg) {
197
198
  const token = typeof msg.token === 'string' ? msg.token : '';
198
- const apiSecret = getSecretVaultService().readSecret('API_SECRET') ?? '';
199
- if (!apiSecret || !token || token !== apiSecret) {
199
+ const config = await readConfig();
200
+ const authMode = config.gateway?.auth?.mode ?? 'hmac';
201
+ let authenticated = false;
202
+ if (authMode === 'token') {
203
+ const configuredToken = config.gateway?.auth?.token ?? '';
204
+ authenticated = !!configuredToken && token === configuredToken;
205
+ }
206
+ else {
207
+ const apiSecret = getSecretVaultService().readSecret('API_SECRET') ?? '';
208
+ authenticated = !!apiSecret && token === apiSecret;
209
+ }
210
+ if (!authenticated) {
200
211
  this.#metrics.authFailures++;
201
212
  void logThought(`[WsHub] Client ${client.id} authentication failed (invalid token).`);
202
213
  this.#sendError(client, WsCloseCode.AuthFailed, 'Authentication failed.');