twinclaw 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +66 -0
  2. package/bin/npm-twinclaw.js +17 -0
  3. package/bin/run-twinbot-cli.js +36 -0
  4. package/bin/twinbot.js +4 -0
  5. package/bin/twinclaw.js +4 -0
  6. package/dist/api/handlers/browser.js +160 -0
  7. package/dist/api/handlers/callback.js +80 -0
  8. package/dist/api/handlers/config-validate.js +19 -0
  9. package/dist/api/handlers/health.js +117 -0
  10. package/dist/api/handlers/local-state-backup.js +118 -0
  11. package/dist/api/handlers/persona-state.js +59 -0
  12. package/dist/api/handlers/skill-packages.js +94 -0
  13. package/dist/api/router.js +278 -0
  14. package/dist/api/runtime-event-producer.js +99 -0
  15. package/dist/api/shared.js +82 -0
  16. package/dist/api/websocket-hub.js +305 -0
  17. package/dist/config/config-loader.js +2 -0
  18. package/dist/config/env-schema.js +202 -0
  19. package/dist/config/env-validator.js +223 -0
  20. package/dist/config/identity-bootstrap.js +115 -0
  21. package/dist/config/json-config.js +344 -0
  22. package/dist/config/workspace.js +186 -0
  23. package/dist/core/channels-cli.js +77 -0
  24. package/dist/core/cli.js +119 -0
  25. package/dist/core/context-assembly.js +33 -0
  26. package/dist/core/doctor.js +365 -0
  27. package/dist/core/gateway-cli.js +323 -0
  28. package/dist/core/gateway.js +416 -0
  29. package/dist/core/heartbeat.js +54 -0
  30. package/dist/core/install-cli.js +320 -0
  31. package/dist/core/lane-executor.js +134 -0
  32. package/dist/core/logs-cli.js +70 -0
  33. package/dist/core/onboarding.js +760 -0
  34. package/dist/core/pairing-cli.js +78 -0
  35. package/dist/core/secret-vault-cli.js +204 -0
  36. package/dist/core/types.js +1 -0
  37. package/dist/index.js +404 -0
  38. package/dist/interfaces/dispatcher.js +214 -0
  39. package/dist/interfaces/telegram_handler.js +82 -0
  40. package/dist/interfaces/tui-dashboard.js +53 -0
  41. package/dist/interfaces/whatsapp_handler.js +94 -0
  42. package/dist/release/cli.js +97 -0
  43. package/dist/release/mvp-gate-cli.js +118 -0
  44. package/dist/release/twinbot-config-schema.js +162 -0
  45. package/dist/release/twinclaw-config-schema.js +162 -0
  46. package/dist/services/block-chunker.js +174 -0
  47. package/dist/services/browser-service.js +334 -0
  48. package/dist/services/context-lifecycle.js +314 -0
  49. package/dist/services/db.js +1055 -0
  50. package/dist/services/delivery-tracker.js +110 -0
  51. package/dist/services/dm-pairing.js +245 -0
  52. package/dist/services/embedding-service.js +125 -0
  53. package/dist/services/file-watcher.js +125 -0
  54. package/dist/services/inbound-debounce.js +92 -0
  55. package/dist/services/incident-manager.js +516 -0
  56. package/dist/services/job-scheduler.js +176 -0
  57. package/dist/services/local-state-backup.js +682 -0
  58. package/dist/services/mcp-client-adapter.js +291 -0
  59. package/dist/services/mcp-server-manager.js +143 -0
  60. package/dist/services/model-router.js +927 -0
  61. package/dist/services/mvp-gate.js +845 -0
  62. package/dist/services/orchestration-service.js +422 -0
  63. package/dist/services/persona-state.js +256 -0
  64. package/dist/services/policy-engine.js +92 -0
  65. package/dist/services/proactive-notifier.js +94 -0
  66. package/dist/services/queue-service.js +146 -0
  67. package/dist/services/release-pipeline.js +652 -0
  68. package/dist/services/runtime-budget-governor.js +415 -0
  69. package/dist/services/secret-vault.js +704 -0
  70. package/dist/services/semantic-memory.js +249 -0
  71. package/dist/services/skill-package-manager.js +806 -0
  72. package/dist/services/skill-registry.js +122 -0
  73. package/dist/services/streaming-output.js +75 -0
  74. package/dist/services/stt-service.js +39 -0
  75. package/dist/services/tts-service.js +44 -0
  76. package/dist/skills/builtin.js +250 -0
  77. package/dist/skills/shell.js +87 -0
  78. package/dist/skills/types.js +1 -0
  79. package/dist/types/api.js +1 -0
  80. package/dist/types/context-budget.js +1 -0
  81. package/dist/types/doctor.js +1 -0
  82. package/dist/types/file-watcher.js +1 -0
  83. package/dist/types/incident.js +1 -0
  84. package/dist/types/local-state-backup.js +1 -0
  85. package/dist/types/mcp.js +1 -0
  86. package/dist/types/messaging.js +1 -0
  87. package/dist/types/model-routing.js +1 -0
  88. package/dist/types/mvp-gate.js +2 -0
  89. package/dist/types/orchestration.js +1 -0
  90. package/dist/types/persona-state.js +22 -0
  91. package/dist/types/policy.js +1 -0
  92. package/dist/types/reasoning-graph.js +1 -0
  93. package/dist/types/release.js +1 -0
  94. package/dist/types/reliability.js +1 -0
  95. package/dist/types/runtime-budget.js +1 -0
  96. package/dist/types/scheduler.js +1 -0
  97. package/dist/types/secret-vault.js +1 -0
  98. package/dist/types/skill-packages.js +1 -0
  99. package/dist/types/websocket.js +14 -0
  100. package/dist/utils/logger.js +57 -0
  101. package/dist/utils/retry.js +61 -0
  102. package/dist/utils/secret-scan.js +208 -0
  103. package/mcp-servers.json +179 -0
  104. package/package.json +81 -0
  105. package/skill-packages.json +92 -0
  106. package/skill-packages.lock.json +5 -0
  107. package/src/skills/builtin.ts +275 -0
  108. package/src/skills/shell.ts +118 -0
  109. package/src/skills/types.ts +30 -0
  110. package/src/types/api.ts +252 -0
  111. package/src/types/blessed-contrib.d.ts +4 -0
  112. package/src/types/context-budget.ts +76 -0
  113. package/src/types/doctor.ts +29 -0
  114. package/src/types/file-watcher.ts +26 -0
  115. package/src/types/incident.ts +57 -0
  116. package/src/types/local-state-backup.ts +121 -0
  117. package/src/types/mcp.ts +106 -0
  118. package/src/types/messaging.ts +35 -0
  119. package/src/types/model-routing.ts +61 -0
  120. package/src/types/mvp-gate.ts +99 -0
  121. package/src/types/orchestration.ts +65 -0
  122. package/src/types/persona-state.ts +61 -0
  123. package/src/types/policy.ts +27 -0
  124. package/src/types/reasoning-graph.ts +58 -0
  125. package/src/types/release.ts +115 -0
  126. package/src/types/reliability.ts +43 -0
  127. package/src/types/runtime-budget.ts +85 -0
  128. package/src/types/scheduler.ts +47 -0
  129. package/src/types/secret-vault.ts +62 -0
  130. package/src/types/skill-packages.ts +81 -0
  131. package/src/types/sqlite-vec.d.ts +5 -0
  132. package/src/types/websocket.ts +122 -0
@@ -0,0 +1,59 @@
1
+ import { PersonaConflictError, PersonaValidationError, } from '../../types/persona-state.js';
2
+ import { sendError, sendOk } from '../shared.js';
3
+ function sendPersonaDiagnosticError(res, status, message, diagnostics) {
4
+ const correlationId = res.locals.correlationId;
5
+ const body = {
6
+ ok: false,
7
+ error: message,
8
+ data: diagnostics,
9
+ correlationId,
10
+ timestamp: new Date().toISOString(),
11
+ };
12
+ res.status(status).json(body);
13
+ }
14
+ export function handlePersonaStateGet(deps) {
15
+ return async (_req, res) => {
16
+ try {
17
+ const state = await deps.personaStateService.getState();
18
+ sendOk(res, state);
19
+ }
20
+ catch (error) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ sendError(res, `Failed to load persona state: ${message}`, 500);
23
+ }
24
+ };
25
+ }
26
+ export function handlePersonaStateUpdate(deps) {
27
+ return async (req, res) => {
28
+ const body = (req.body ?? {});
29
+ const input = {
30
+ expectedRevision: body.expectedRevision,
31
+ soul: body.soul,
32
+ identity: body.identity,
33
+ user: body.user,
34
+ };
35
+ try {
36
+ const result = await deps.personaStateService.updateState(input);
37
+ sendOk(res, result);
38
+ }
39
+ catch (error) {
40
+ if (error instanceof PersonaValidationError) {
41
+ sendPersonaDiagnosticError(res, 400, error.message, {
42
+ hints: error.hints,
43
+ });
44
+ return;
45
+ }
46
+ if (error instanceof PersonaConflictError) {
47
+ sendPersonaDiagnosticError(res, 409, error.message, {
48
+ hints: error.hints,
49
+ latestRevision: error.latestRevision,
50
+ });
51
+ return;
52
+ }
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ sendPersonaDiagnosticError(res, 500, `Failed to update persona state: ${message}`, {
55
+ hints: ['Retry the operation. If it persists, inspect local filesystem permissions.'],
56
+ });
57
+ }
58
+ };
59
+ }
@@ -0,0 +1,94 @@
1
+ import { sendError, sendOk } from '../shared.js';
2
+ function readPackageName(body) {
3
+ if (!body || typeof body !== 'object' || !('name' in body)) {
4
+ throw new Error("Request body must include 'name'.");
5
+ }
6
+ const candidate = body.name;
7
+ if (typeof candidate !== 'string' || candidate.trim().length === 0) {
8
+ throw new Error("Field 'name' must be a non-empty string.");
9
+ }
10
+ return candidate.trim();
11
+ }
12
+ function readVersionRange(body) {
13
+ if (!body || typeof body !== 'object' || !('versionRange' in body)) {
14
+ return undefined;
15
+ }
16
+ const candidate = body.versionRange;
17
+ if (candidate === undefined || candidate === null) {
18
+ return undefined;
19
+ }
20
+ if (typeof candidate !== 'string') {
21
+ throw new Error("Field 'versionRange' must be a string when provided.");
22
+ }
23
+ const trimmed = candidate.trim();
24
+ return trimmed.length > 0 ? trimmed : undefined;
25
+ }
26
+ function mapPackageErrorStatus(message) {
27
+ if (message.includes('must be') ||
28
+ message.includes('required') ||
29
+ message.includes('Invalid')) {
30
+ return 400;
31
+ }
32
+ if (message.includes('No compatible version') ||
33
+ message.includes('Compatibility gate blocked') ||
34
+ message.includes('Cannot uninstall')) {
35
+ return 409;
36
+ }
37
+ if (message.includes('not installed')) {
38
+ return 404;
39
+ }
40
+ return 500;
41
+ }
42
+ export function handleSkillPackageDiagnostics(deps) {
43
+ return async (_req, res) => {
44
+ try {
45
+ const diagnostics = await deps.mcpManager.getSkillPackageDiagnostics();
46
+ sendOk(res, diagnostics);
47
+ }
48
+ catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ sendError(res, message, mapPackageErrorStatus(message));
51
+ }
52
+ };
53
+ }
54
+ export function handleSkillPackageInstall(deps) {
55
+ return async (req, res) => {
56
+ try {
57
+ const packageName = readPackageName(req.body);
58
+ const versionRange = readVersionRange(req.body);
59
+ const result = await deps.mcpManager.installSkillPackage(packageName, versionRange);
60
+ sendOk(res, result, result.changed ? 201 : 200);
61
+ }
62
+ catch (error) {
63
+ const message = error instanceof Error ? error.message : String(error);
64
+ sendError(res, message, mapPackageErrorStatus(message));
65
+ }
66
+ };
67
+ }
68
+ export function handleSkillPackageUpgrade(deps) {
69
+ return async (req, res) => {
70
+ try {
71
+ const packageName = readPackageName(req.body);
72
+ const versionRange = readVersionRange(req.body);
73
+ const result = await deps.mcpManager.upgradeSkillPackage(packageName, versionRange);
74
+ sendOk(res, result);
75
+ }
76
+ catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ sendError(res, message, mapPackageErrorStatus(message));
79
+ }
80
+ };
81
+ }
82
+ export function handleSkillPackageUninstall(deps) {
83
+ return async (req, res) => {
84
+ try {
85
+ const packageName = readPackageName(req.body);
86
+ const result = await deps.mcpManager.uninstallSkillPackage(packageName);
87
+ sendOk(res, result);
88
+ }
89
+ catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ sendError(res, message, mapPackageErrorStatus(message));
92
+ }
93
+ };
94
+ }
@@ -0,0 +1,278 @@
1
+ import { createServer } from 'node:http';
2
+ import express from 'express';
3
+ import { handleHealth, handleLiveness, handleReadiness } from './handlers/health.js';
4
+ import { handleBrowserSnapshot, handleBrowserClick } from './handlers/browser.js';
5
+ import { handleWebhookCallback } from './handlers/callback.js';
6
+ import { handleSkillPackageDiagnostics, handleSkillPackageInstall, handleSkillPackageUpgrade, handleSkillPackageUninstall, } from './handlers/skill-packages.js';
7
+ import { handleLocalStateBackupDiagnostics, handleLocalStateCreateSnapshot, handleLocalStateRestoreSnapshot, } from './handlers/local-state-backup.js';
8
+ import { handlePersonaStateGet, handlePersonaStateUpdate, } from './handlers/persona-state.js';
9
+ import { handleConfigValidate } from './handlers/config-validate.js';
10
+ import { requestLogger, requireSignature, sendError } from './shared.js';
11
+ import { BrowserService } from '../services/browser-service.js';
12
+ import { getPersonaStateService } from '../services/persona-state.js';
13
+ import { logThought } from '../utils/logger.js';
14
+ import { getCallbackOutcomeCounts } from '../services/db.js';
15
+ import { sendOk } from './shared.js';
16
+ import { readFile } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { getConfigValue } from '../config/config-loader.js';
19
+ const DEFAULT_PORT = 18789;
20
+ /**
21
+ * Create and start the Control Plane HTTP API server.
22
+ *
23
+ * Endpoints:
24
+ * GET /health — System health snapshot
25
+ * GET /config/validate — Runtime config and env-key validation report
26
+ * GET /backup/diagnostics — Local backup + restore diagnostics
27
+ * POST /backup/snapshot — Trigger manual local-state snapshot
28
+ * POST /backup/restore — Restore local-state snapshot (dry-run supported)
29
+ * GET /skills/packages — Skill package diagnostics and compatibility state
30
+ * POST /skills/packages/* — Install/upgrade/uninstall skill packages
31
+ * GET /reliability — Delivery reliability metrics
32
+ * GET /budget/state — Runtime budget state snapshot
33
+ * GET /budget/events — Recent budget policy events
34
+ * POST /budget/profile — Manual profile override (signed)
35
+ * POST /budget/reset — Reset budget policy state (signed)
36
+ * GET /routing/telemetry — Model routing telemetry snapshot
37
+ * POST /routing/mode — Update model routing fallback mode (signed)
38
+ * GET /incidents/current — Active incident + safe-mode snapshot
39
+ * GET /incidents/history — Historical incidents and timeline events
40
+ * POST /incidents/evaluate — Force immediate incident detection cycle
41
+ * GET /persona/state — Read persona source-of-truth state
42
+ * PUT /persona/state — Safely update persona source-of-truth state
43
+ * POST /browser/snapshot — Take a browser screenshot + accessibility tree
44
+ * POST /browser/click — Click an element by selector or coordinates
45
+ * POST /callback/webhook — Ingest external task completion events (authenticated)
46
+ */
47
+ export function startApiServer(deps) {
48
+ const app = express();
49
+ const port = Number(getConfigValue('API_PORT')) || DEFAULT_PORT;
50
+ const server = createServer(app);
51
+ // Attach WebSocket hub if provided
52
+ if (deps.wsHub) {
53
+ deps.wsHub.attach(server);
54
+ }
55
+ // ── Global Middleware ───────────────────────────────────────────────────────
56
+ app.use(express.json());
57
+ app.use(requestLogger);
58
+ // ── Shared Services ─────────────────────────────────────────────────────────
59
+ const browserService = new BrowserService();
60
+ const healthDeps = {
61
+ heartbeat: deps.heartbeat,
62
+ skillRegistry: deps.skillRegistry,
63
+ mcpManager: deps.mcpManager,
64
+ budgetGovernor: deps.budgetGovernor,
65
+ localStateBackup: deps.localStateBackup,
66
+ modelRouter: deps.modelRouter,
67
+ };
68
+ const browserDeps = { browserService };
69
+ const callbackDeps = { gateway: deps.gateway };
70
+ const skillPackageDeps = { mcpManager: deps.mcpManager };
71
+ const localStateBackupDeps = { backupService: deps.localStateBackup };
72
+ const personaStateDeps = {
73
+ personaStateService: getPersonaStateService(),
74
+ };
75
+ // ── Routes ──────────────────────────────────────────────────────────────────
76
+ app.get('/health', handleHealth(healthDeps));
77
+ app.get('/health/live', handleLiveness());
78
+ app.get('/health/ready', handleReadiness(healthDeps));
79
+ app.get('/config/validate', handleConfigValidate());
80
+ app.get('/backup/diagnostics', handleLocalStateBackupDiagnostics(localStateBackupDeps));
81
+ app.post('/backup/snapshot', handleLocalStateCreateSnapshot(localStateBackupDeps));
82
+ app.post('/backup/restore', handleLocalStateRestoreSnapshot(localStateBackupDeps));
83
+ app.get('/skills/packages', handleSkillPackageDiagnostics(skillPackageDeps));
84
+ app.post('/skills/packages/install', handleSkillPackageInstall(skillPackageDeps));
85
+ app.post('/skills/packages/upgrade', handleSkillPackageUpgrade(skillPackageDeps));
86
+ app.post('/skills/packages/uninstall', handleSkillPackageUninstall(skillPackageDeps));
87
+ app.get('/reliability', (_req, res) => {
88
+ const queueMetrics = deps.dispatcher?.queue.getStats() ?? null;
89
+ const callbackCounts = getCallbackOutcomeCounts();
90
+ const callbackMetrics = {
91
+ totalAccepted: callbackCounts.accepted,
92
+ totalDuplicate: callbackCounts.duplicate,
93
+ totalRejected: callbackCounts.rejected,
94
+ };
95
+ sendOk(res, {
96
+ queue: queueMetrics,
97
+ callbacks: callbackMetrics
98
+ });
99
+ });
100
+ app.get('/budget/state', (req, res) => {
101
+ if (!deps.budgetGovernor) {
102
+ sendError(res, 'Runtime budget governor not initialized.', 503);
103
+ return;
104
+ }
105
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined;
106
+ sendOk(res, deps.budgetGovernor.getSnapshot(sessionId));
107
+ });
108
+ app.get('/budget/events', (req, res) => {
109
+ if (!deps.budgetGovernor) {
110
+ sendError(res, 'Runtime budget governor not initialized.', 503);
111
+ return;
112
+ }
113
+ const requestedLimit = Number(req.query.limit ?? 100);
114
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
115
+ ? Math.min(500, Math.floor(requestedLimit))
116
+ : 100;
117
+ sendOk(res, { events: deps.budgetGovernor.getRecentEvents(limit) });
118
+ });
119
+ app.post('/budget/profile', requireSignature, (req, res) => {
120
+ if (!deps.budgetGovernor) {
121
+ sendError(res, 'Runtime budget governor not initialized.', 503);
122
+ return;
123
+ }
124
+ const rawProfile = req.body?.profile;
125
+ if (rawProfile !== null && rawProfile !== undefined && typeof rawProfile !== 'string') {
126
+ sendError(res, 'Invalid profile. Expected one of: economy, balanced, performance, null.', 400);
127
+ return;
128
+ }
129
+ if (typeof rawProfile === 'string' && !['economy', 'balanced', 'performance'].includes(rawProfile)) {
130
+ sendError(res, 'Invalid profile. Expected one of: economy, balanced, performance, null.', 400);
131
+ return;
132
+ }
133
+ const profile = typeof rawProfile === 'string'
134
+ ? rawProfile
135
+ : null;
136
+ deps.budgetGovernor.setManualProfile(profile, typeof req.body?.sessionId === 'string' ? req.body.sessionId : undefined);
137
+ sendOk(res, {
138
+ message: profile
139
+ ? `Manual budget profile set to '${profile}'.`
140
+ : 'Manual budget profile override cleared.',
141
+ snapshot: deps.budgetGovernor.getSnapshot(typeof req.body?.sessionId === 'string' ? req.body.sessionId : undefined),
142
+ });
143
+ });
144
+ app.post('/budget/reset', requireSignature, (req, res) => {
145
+ if (!deps.budgetGovernor) {
146
+ sendError(res, 'Runtime budget governor not initialized.', 503);
147
+ return;
148
+ }
149
+ const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId : undefined;
150
+ deps.budgetGovernor.resetPolicyState(sessionId);
151
+ sendOk(res, {
152
+ message: 'Runtime budget policy state reset.',
153
+ snapshot: deps.budgetGovernor.getSnapshot(sessionId),
154
+ });
155
+ });
156
+ app.get('/routing/telemetry', (_req, res) => {
157
+ if (!deps.modelRouter) {
158
+ sendError(res, 'Model router not initialized.', 503);
159
+ return;
160
+ }
161
+ sendOk(res, deps.modelRouter.getHealthSnapshot());
162
+ });
163
+ app.post('/routing/mode', requireSignature, (req, res) => {
164
+ if (!deps.modelRouter) {
165
+ sendError(res, 'Model router not initialized.', 503);
166
+ return;
167
+ }
168
+ const mode = req.body?.mode;
169
+ if (typeof mode !== 'string' || !['intelligent_pacing', 'aggressive_fallback'].includes(mode)) {
170
+ sendError(res, 'Invalid mode. Expected one of: intelligent_pacing, aggressive_fallback.', 400);
171
+ return;
172
+ }
173
+ const snapshot = deps.modelRouter.setFallbackMode(mode);
174
+ sendOk(res, {
175
+ message: `Routing fallback mode set to '${mode}'.`,
176
+ snapshot,
177
+ });
178
+ });
179
+ app.get('/logs', async (_req, res) => {
180
+ try {
181
+ const dateIso = new Date().toISOString().slice(0, 10);
182
+ const logPath = path.resolve('memory', `${dateIso}.md`);
183
+ const content = await readFile(logPath, 'utf8').catch(() => 'No logs found for today.');
184
+ // Simple parsing to turn markdown into a list of "entries"
185
+ // Entries are separated by ## title @ timestamp
186
+ const sections = content.split(/\n## /).filter(Boolean);
187
+ const entries = sections.map(s => {
188
+ const [header, ...bodyLines] = s.split('\n');
189
+ const [type, timestamp] = header.split(' @ ');
190
+ return {
191
+ timestamp: timestamp || new Date().toISOString(),
192
+ level: type.toUpperCase(),
193
+ message: bodyLines.join('\n').trim()
194
+ };
195
+ }).reverse().slice(0, 100); // Return last 100 entries
196
+ sendOk(res, entries);
197
+ }
198
+ catch (err) {
199
+ sendError(res, 'Failed to read logs.', 500);
200
+ }
201
+ });
202
+ app.post('/reliability/replay/:id', (_req, res) => {
203
+ const id = _req.params.id;
204
+ if (!deps.dispatcher) {
205
+ sendError(res, 'Dispatcher not active.', 500);
206
+ return;
207
+ }
208
+ try {
209
+ deps.dispatcher.queue.requeueDeadLetter(id);
210
+ sendOk(res, { message: `Queued replay for message ${id}` });
211
+ }
212
+ catch (err) {
213
+ sendError(res, `Failed to requeue: ${err instanceof Error ? err.message : String(err)}`, 500);
214
+ }
215
+ });
216
+ app.get('/incidents/current', (_req, res) => {
217
+ if (!deps.incidentManager) {
218
+ sendError(res, 'Incident manager not initialized.', 503);
219
+ return;
220
+ }
221
+ sendOk(res, {
222
+ safeMode: deps.incidentManager.isSafeModeEnabled(),
223
+ incidents: deps.incidentManager.getCurrentIncidents(),
224
+ });
225
+ });
226
+ app.get('/incidents/history', (req, res) => {
227
+ if (!deps.incidentManager) {
228
+ sendError(res, 'Incident manager not initialized.', 503);
229
+ return;
230
+ }
231
+ const requestedLimit = Number(req.query.limit ?? 200);
232
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
233
+ ? Math.min(500, Math.floor(requestedLimit))
234
+ : 200;
235
+ sendOk(res, {
236
+ incidents: deps.incidentManager.getIncidentHistory(limit),
237
+ timeline: deps.incidentManager.getIncidentTimeline(limit),
238
+ });
239
+ });
240
+ app.post('/incidents/evaluate', (_req, res) => {
241
+ if (!deps.incidentManager) {
242
+ sendError(res, 'Incident manager not initialized.', 503);
243
+ return;
244
+ }
245
+ const incidents = deps.incidentManager.evaluateNow();
246
+ sendOk(res, {
247
+ safeMode: deps.incidentManager.isSafeModeEnabled(),
248
+ incidents,
249
+ });
250
+ });
251
+ app.get('/persona/state', handlePersonaStateGet(personaStateDeps));
252
+ app.put('/persona/state', handlePersonaStateUpdate(personaStateDeps));
253
+ app.post('/browser/snapshot', handleBrowserSnapshot(browserDeps));
254
+ app.post('/browser/click', handleBrowserClick(browserDeps));
255
+ app.post('/callback/webhook', requireSignature, handleWebhookCallback(callbackDeps));
256
+ app.post('/system/halt', requireSignature, (_req, res) => {
257
+ void logThought('[API] Received /system/halt request from Control Plane GUI. Halting node process.');
258
+ sendOk(res, { message: 'Agent halting...' });
259
+ // Give the response a moment to flush before killing process
260
+ setTimeout(() => process.exit(0), 500);
261
+ });
262
+ app.get('/ws/metrics', (_req, res) => {
263
+ if (!deps.wsHub) {
264
+ sendError(res, 'WebSocket hub not initialized.', 503);
265
+ return;
266
+ }
267
+ sendOk(res, deps.wsHub.getMetrics());
268
+ });
269
+ // ── Catch-all 404 ──────────────────────────────────────────────────────────
270
+ app.use((_req, res) => {
271
+ sendError(res, 'Not found.', 404);
272
+ });
273
+ // ── Start ──────────────────────────────────────────────────────────────────
274
+ server.listen(port, () => {
275
+ console.log(`[TwinBot API] Control plane listening on http://localhost:${port}`);
276
+ void logThought(`[API] HTTP server started on port ${port}.`);
277
+ });
278
+ }
@@ -0,0 +1,99 @@
1
+ import { logThought } from '../utils/logger.js';
2
+ import { getCallbackOutcomeCounts } from '../services/db.js';
3
+ const DEFAULT_PUBLISH_INTERVAL_MS = 5_000;
4
+ /**
5
+ * Collects state from existing runtime services on a fixed interval and
6
+ * publishes typed event envelopes through the WebSocket hub.
7
+ *
8
+ * Also responds to new-subscription callbacks from the hub by dispatching an
9
+ * immediate full-state snapshot so newly connected clients receive current
10
+ * state without waiting for the next polling cycle.
11
+ */
12
+ export class RuntimeEventProducer {
13
+ #hub;
14
+ #deps;
15
+ #intervalMs;
16
+ #timer = null;
17
+ constructor(deps, config = {}) {
18
+ const { hub, ...rest } = deps;
19
+ this.#hub = hub;
20
+ this.#deps = rest;
21
+ this.#intervalMs = config.publishIntervalMs ?? DEFAULT_PUBLISH_INTERVAL_MS;
22
+ // When a client subscribes, immediately send a full snapshot
23
+ this.#hub.onSubscribe = (clientId, topics) => {
24
+ this.#dispatchSnapshotTo(clientId, topics);
25
+ };
26
+ }
27
+ start() {
28
+ if (this.#timer)
29
+ return;
30
+ this.#timer = setInterval(() => {
31
+ this.#publishAll();
32
+ }, this.#intervalMs);
33
+ void logThought('[RuntimeEventProducer] Started periodic event publishing.');
34
+ }
35
+ stop() {
36
+ if (this.#timer) {
37
+ clearInterval(this.#timer);
38
+ this.#timer = null;
39
+ }
40
+ void logThought('[RuntimeEventProducer] Stopped.');
41
+ }
42
+ // ── Snapshot ───────────────────────────────────────────────────────────────
43
+ /**
44
+ * Build a full-state snapshot keyed by topic for the given set of topics.
45
+ * Used for initial snapshot on subscription and for testing.
46
+ */
47
+ collectSnapshot(topics) {
48
+ const all = !topics || topics.length === 0;
49
+ const include = (t) => all || topics.includes(t);
50
+ const snapshot = {};
51
+ if (include('incidents') && this.#deps.incidentManager) {
52
+ snapshot.incidents = {
53
+ safeMode: this.#deps.incidentManager.isSafeModeEnabled(),
54
+ current: this.#deps.incidentManager.getCurrentIncidents(),
55
+ };
56
+ }
57
+ if (include('routing') && this.#deps.modelRouter) {
58
+ snapshot.routing = this.#deps.modelRouter.getHealthSnapshot();
59
+ }
60
+ if (include('reliability') && this.#deps.dispatcher) {
61
+ snapshot.reliability = this.#buildReliabilityPayload();
62
+ }
63
+ return snapshot;
64
+ }
65
+ // ── Private Helpers ────────────────────────────────────────────────────────
66
+ #publishAll() {
67
+ if (this.#deps.incidentManager) {
68
+ this.#hub.publish('incidents', {
69
+ safeMode: this.#deps.incidentManager.isSafeModeEnabled(),
70
+ current: this.#deps.incidentManager.getCurrentIncidents(),
71
+ });
72
+ }
73
+ if (this.#deps.modelRouter) {
74
+ this.#hub.publish('routing', this.#deps.modelRouter.getHealthSnapshot());
75
+ }
76
+ if (this.#deps.dispatcher) {
77
+ this.#hub.publish('reliability', this.#buildReliabilityPayload());
78
+ }
79
+ }
80
+ #buildReliabilityPayload() {
81
+ const queueMetrics = this.#deps.dispatcher.queue.getStats() ?? null;
82
+ const callbackCounts = getCallbackOutcomeCounts();
83
+ return {
84
+ queue: queueMetrics,
85
+ callbacks: {
86
+ totalAccepted: callbackCounts.accepted,
87
+ totalDuplicate: callbackCounts.duplicate,
88
+ totalRejected: callbackCounts.rejected,
89
+ },
90
+ };
91
+ }
92
+ #dispatchSnapshotTo(clientId, subscribedTopics) {
93
+ const snapshot = this.collectSnapshot(subscribedTopics);
94
+ if (Object.keys(snapshot).length === 0)
95
+ return;
96
+ this.#hub.sendSnapshotTo(clientId, snapshot);
97
+ void logThought(`[RuntimeEventProducer] Dispatched initial snapshot to client ${clientId}.`);
98
+ }
99
+ }
@@ -0,0 +1,82 @@
1
+ import { createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
2
+ import { logThought, scrubSensitiveText } from '../utils/logger.js';
3
+ import { getSecretVaultService } from '../services/secret-vault.js';
4
+ // ── Response Helpers ────────────────────────────────────────────────────────
5
+ /** Send a successful JSON response using the standard envelope. */
6
+ export function sendOk(res, data, status = 200) {
7
+ const correlationId = res.locals.correlationId;
8
+ const body = {
9
+ ok: true,
10
+ data,
11
+ correlationId,
12
+ timestamp: new Date().toISOString(),
13
+ };
14
+ res.status(status).json(body);
15
+ }
16
+ /** Send an error JSON response using the standard envelope. */
17
+ export function sendError(res, message, status = 400) {
18
+ const correlationId = res.locals.correlationId;
19
+ const redactedMessage = scrubSensitiveText(message);
20
+ const body = {
21
+ ok: false,
22
+ error: redactedMessage,
23
+ correlationId,
24
+ timestamp: new Date().toISOString(),
25
+ };
26
+ res.status(status).json(body);
27
+ }
28
+ // ── Auth Middleware ──────────────────────────────────────────────────────────
29
+ /**
30
+ * Validate the `X-Signature` header on incoming webhook callbacks.
31
+ *
32
+ * Expected format: `sha256=<hex digest of HMAC-SHA256(body, API_SECRET)>`
33
+ *
34
+ * If API_SECRET is not configured, all callback requests are rejected.
35
+ */
36
+ export function requireSignature(req, res, next) {
37
+ const apiSecret = getSecretVaultService().readSecret('API_SECRET') ?? '';
38
+ if (!apiSecret) {
39
+ void logThought('[API] Webhook rejected — API_SECRET not configured.');
40
+ sendError(res, 'Webhook endpoint not configured (missing API_SECRET).', 503);
41
+ return;
42
+ }
43
+ const signatureHeader = req.headers['x-signature'];
44
+ if (typeof signatureHeader !== 'string' || !signatureHeader.startsWith('sha256=')) {
45
+ void logThought('[API] Webhook rejected — missing or malformed X-Signature header.');
46
+ sendError(res, 'Missing or malformed X-Signature header.', 401);
47
+ return;
48
+ }
49
+ const providedHex = signatureHeader.slice('sha256='.length);
50
+ const rawBody = JSON.stringify(req.body);
51
+ const expectedHex = createHmac('sha256', apiSecret).update(rawBody).digest('hex');
52
+ const provided = Buffer.from(providedHex, 'hex');
53
+ const expected = Buffer.from(expectedHex, 'hex');
54
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
55
+ void logThought('[API] Webhook rejected — signature mismatch.');
56
+ sendError(res, 'Invalid signature.', 403);
57
+ return;
58
+ }
59
+ next();
60
+ }
61
+ // ── Error Mapping ───────────────────────────────────────────────────────────
62
+ /** Map a caught error to a status code and message. */
63
+ export function mapError(err) {
64
+ if (err instanceof Error) {
65
+ if (err.message.includes('not initialized') || err.message.includes('not connected')) {
66
+ return { status: 503, message: scrubSensitiveText(err.message) };
67
+ }
68
+ return { status: 500, message: scrubSensitiveText(err.message) };
69
+ }
70
+ return { status: 500, message: scrubSensitiveText(String(err)) };
71
+ }
72
+ // ── Logging Middleware ───────────────────────────────────────────────────────
73
+ /** Log every incoming request and inject a correlation ID. */
74
+ export function requestLogger(req, res, next) {
75
+ const correlationId = randomUUID();
76
+ res.locals.correlationId = correlationId;
77
+ const method = req.method;
78
+ const path = req.path;
79
+ console.log(`[API] [${correlationId}] ${method} ${path}`);
80
+ void logThought(`[API] [${correlationId}] ${method} ${path}`);
81
+ next();
82
+ }