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
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # 🦅 TwinClaw
2
+
3
+ **Native Agentic Gateway | Multi-Modal Hooks | Proactive Memory**
4
+
5
+ TwinClaw is a highly autonomous local-first AI agent designed for power users who want an "unstoppable" personal assistant that navigates between multiple LLMs, messaging platforms (Telegram/WhatsApp), and its own persistent RAG memory.
6
+
7
+ ---
8
+
9
+ ## ⚡ Quick Start (Windows Only)
10
+
11
+ To install and run TwinClaw with a single command, run the following in your terminal:
12
+
13
+ ### **Windows (PowerShell)**
14
+ ```powershell
15
+ npm twinclaw install
16
+ ```
17
+
18
+ ---
19
+
20
+ ## 🛠 Features
21
+
22
+ - **Double-Layered Intelligence**: Native model routing through **OpenRouter**, **Gemini**, and **Modal** with automatic 429 retries and fallbacks.
23
+ - **Voice-First**: Seamless integration with **ElevenLabs** (Text-to-Speech) and **Groq** (Speech-to-Text).
24
+ - **Ubiquitous Access**: Control your agent or receive proactive notifications via **Telegram** and **WhatsApp**.
25
+ - **Agentic Skills**: Extensible Skill system (MCP-compatible) allowing the agent to read files, search the web, and manage its own memory.
26
+ - **Local Persistence**: Zero-cloud knowledge graph and memory storage using **SQLite** with **sqlite-vec**.
27
+
28
+ ---
29
+
30
+ ## 📖 Guided Setup
31
+
32
+ When you first run TwinClaw, it will automatically start a **Guided Setup Wizard**. You don't need to manually create any `.env` files. The wizard will prompt you for:
33
+
34
+ 1. **API Keys**: Groq, OpenRouter, Gemini, ElevenLabs.
35
+ 2. **Messaging**: Telegram Bot Token and optional bootstrap allowlist IDs.
36
+ 3. **Security**: Generates a master encryption key for your local vault.
37
+ 4. **Skills**: Auto-registers built-in skills for immediate use.
38
+
39
+ TwinClaw now defaults to `dmPolicy: "pairing"` for Telegram/WhatsApp DMs. Unknown senders receive a pairing code and must be explicitly approved:
40
+
41
+ ```powershell
42
+ twinclaw pairing list telegram
43
+ twinclaw pairing approve telegram <CODE>
44
+ ```
45
+
46
+ ---
47
+
48
+ ## 🏗 Developer Architecture
49
+
50
+ TwinClaw is built with:
51
+ - **Runtime**: Node.js (v22+)
52
+ - **ORCH**: AntiGravity / OpenClaw-inspired
53
+ - **Database**: SQLite (Vector-ready)
54
+ - **Framework**: TypeScript (ESM)
55
+
56
+ For detailed technical specifications, see [docs/TwinBot-blueprint.md](docs/TwinBot-blueprint.md).
57
+
58
+ Operational runbooks:
59
+ - [MVP Release Checklist](docs/mvp-release-checklist.md)
60
+ - [Release & Rollback Runbook](docs/release-rollback-runbook.md)
61
+ - [Credential Rotation Runbook](docs/rotation-runbook.md)
62
+
63
+ ---
64
+
65
+ ## License
66
+ ISC
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { runTwinbotCli } from './run-twinbot-cli.js';
3
+
4
+ const args = process.argv.slice(2);
5
+
6
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
7
+ console.log(`npm twinclaw usage:
8
+ npm twinclaw install [--non-interactive] [--skip-onboard] [--json]
9
+ `);
10
+ process.exit(0);
11
+ }
12
+
13
+ if (args[0] === 'install') {
14
+ runTwinbotCli(['install', ...args.slice(1)]);
15
+ } else {
16
+ runTwinbotCli(args);
17
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ function resolvePackageRoot() {
8
+ const currentFile = fileURLToPath(import.meta.url);
9
+ return path.resolve(path.dirname(currentFile), '..');
10
+ }
11
+
12
+ export function runTwinbotCli(args) {
13
+ const packageRoot = resolvePackageRoot();
14
+ const sourceEntry = path.join(packageRoot, 'src', 'index.ts');
15
+ const tsxCli = path.join(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
16
+ const distEntry = path.join(packageRoot, 'dist', 'index.js');
17
+
18
+ let nodeArgs;
19
+ if (existsSync(sourceEntry) && existsSync(tsxCli)) {
20
+ nodeArgs = [tsxCli, sourceEntry, ...args];
21
+ } else if (existsSync(distEntry)) {
22
+ nodeArgs = [distEntry, ...args];
23
+ } else {
24
+ console.error('[TwinClaw] Unable to locate CLI entrypoint (expected src/index.ts or dist/index.js).');
25
+ process.exit(1);
26
+ return;
27
+ }
28
+
29
+ const result = spawnSync(process.execPath, nodeArgs, {
30
+ cwd: process.cwd(),
31
+ env: process.env,
32
+ stdio: 'inherit',
33
+ });
34
+
35
+ process.exit(result.status ?? 1);
36
+ }
package/bin/twinbot.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runTwinbotCli } from './run-twinbot-cli.js';
3
+
4
+ runTwinbotCli(process.argv.slice(2));
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runTwinbotCli } from './run-twinbot-cli.js';
3
+
4
+ runTwinbotCli(process.argv.slice(2));
@@ -0,0 +1,160 @@
1
+ import { BrowserReferenceError } from '../../services/browser-service.js';
2
+ import { sendOk, sendError, mapError } from '../shared.js';
3
+ import { logThought } from '../../utils/logger.js';
4
+ import path from 'node:path';
5
+ /**
6
+ * POST /browser/snapshot
7
+ *
8
+ * Takes a screenshot and returns the accessibility tree of the current
9
+ * (or newly navigated) page.
10
+ *
11
+ * Body:
12
+ * url?: string — Navigate to this URL before taking a snapshot.
13
+ * fullPage?: boolean — Whether to capture the full page. Default true.
14
+ */
15
+ export function handleBrowserSnapshot(deps) {
16
+ return async (req, res) => {
17
+ try {
18
+ const body = (req.body ?? {});
19
+ if (body.url !== undefined && typeof body.url !== 'string') {
20
+ sendError(res, 'Field "url" must be a string.', 400);
21
+ return;
22
+ }
23
+ if (body.fullPage !== undefined && typeof body.fullPage !== 'boolean') {
24
+ sendError(res, 'Field "fullPage" must be a boolean.', 400);
25
+ return;
26
+ }
27
+ if (body.url) {
28
+ await deps.browserService.navigate(body.url);
29
+ await logThought(`[API] Browser navigated to: ${body.url}`);
30
+ }
31
+ const screenshotPath = path.resolve('memory', `snapshot_${Date.now()}.png`);
32
+ const fullPage = body.fullPage !== false;
33
+ const result = await deps.browserService.takeScreenshotForVlm(screenshotPath, fullPage);
34
+ const tree = await deps.browserService.getAccessibilityTree();
35
+ const referenceContext = await deps.browserService.captureSnapshotReferenceContext();
36
+ const data = {
37
+ snapshotId: referenceContext.snapshotId,
38
+ screenshotPath: result.path,
39
+ viewport: result.viewport,
40
+ accessibilityTree: typeof tree === 'string' ? tree : JSON.stringify(tree),
41
+ references: referenceContext.references,
42
+ };
43
+ await logThought(`[API] Browser snapshot taken: ${screenshotPath} (snapshotId=${referenceContext.snapshotId}, refs=${referenceContext.references.length}).`);
44
+ sendOk(res, data, 200);
45
+ }
46
+ catch (err) {
47
+ const mapped = mapError(err);
48
+ await logThought(`[API] Browser snapshot failed: ${mapped.message}`);
49
+ sendError(res, mapped.message, mapped.status);
50
+ }
51
+ };
52
+ }
53
+ /**
54
+ * POST /browser/click
55
+ *
56
+ * Clicks an element on the current browser page.
57
+ *
58
+ * Body (one of):
59
+ * selector?: string — CSS selector to click.
60
+ * x?, y?: number — Absolute viewport coordinates to click.
61
+ */
62
+ export function handleBrowserClick(deps) {
63
+ return async (req, res) => {
64
+ try {
65
+ const body = (req.body ?? {});
66
+ if (body.selector !== undefined && typeof body.selector !== 'string') {
67
+ sendError(res, 'Field "selector" must be a string when provided.', 400);
68
+ return;
69
+ }
70
+ if (body.ref !== undefined && typeof body.ref !== 'string') {
71
+ sendError(res, 'Field "ref" must be a string when provided.', 400);
72
+ return;
73
+ }
74
+ if (body.snapshotId !== undefined && typeof body.snapshotId !== 'string') {
75
+ sendError(res, 'Field "snapshotId" must be a string when provided.', 400);
76
+ return;
77
+ }
78
+ if (body.x !== undefined && typeof body.x !== 'number') {
79
+ sendError(res, 'Field "x" must be a number when provided.', 400);
80
+ return;
81
+ }
82
+ if (body.y !== undefined && typeof body.y !== 'number') {
83
+ sendError(res, 'Field "y" must be a number when provided.', 400);
84
+ return;
85
+ }
86
+ const hasReferenceMode = typeof body.ref === 'string' && body.ref.trim().length > 0;
87
+ const hasSelectorMode = typeof body.selector === 'string' && body.selector.trim().length > 0;
88
+ const hasCoordinateMode = typeof body.x === 'number' && typeof body.y === 'number';
89
+ const selectedModes = [hasReferenceMode, hasSelectorMode, hasCoordinateMode].filter(Boolean).length;
90
+ if (selectedModes > 1) {
91
+ sendError(res, 'Provide only one click mode: "ref", "selector", or both "x" and "y".', 400);
92
+ return;
93
+ }
94
+ if (hasReferenceMode && body.ref) {
95
+ const result = await deps.browserService.clickByReference({
96
+ ref: body.ref,
97
+ snapshotId: body.snapshotId,
98
+ });
99
+ await logThought(`[API] Browser clicked reference ${body.ref} on snapshot ${result.snapshotId} (selector=${result.reference.selector}).`);
100
+ const data = {
101
+ clicked: true,
102
+ method: 'reference',
103
+ detail: result.reference.selector,
104
+ ref: result.reference.ref,
105
+ snapshotId: result.snapshotId,
106
+ };
107
+ sendOk(res, data, 200);
108
+ return;
109
+ }
110
+ if (hasSelectorMode && body.selector) {
111
+ await deps.browserService.click(body.selector);
112
+ await logThought(`[API] Browser clicked selector: ${body.selector}. Prefer reference mode from /browser/snapshot for deterministic actions.`);
113
+ const data = {
114
+ clicked: true,
115
+ method: 'selector',
116
+ detail: `${body.selector} (selector mode; prefer ref mode for deterministic targeting)`,
117
+ };
118
+ sendOk(res, data, 200);
119
+ return;
120
+ }
121
+ if (hasCoordinateMode && typeof body.x === 'number' && typeof body.y === 'number') {
122
+ await deps.browserService.clickAt({ x: body.x, y: body.y });
123
+ await logThought(`[API] Browser clicked at (${body.x}, ${body.y}). Prefer reference mode from /browser/snapshot when available.`);
124
+ const data = {
125
+ clicked: true,
126
+ method: 'coordinates',
127
+ detail: `(${body.x}, ${body.y}) (coordinates mode; prefer ref mode for deterministic targeting)`,
128
+ };
129
+ sendOk(res, data, 200);
130
+ return;
131
+ }
132
+ sendError(res, 'Provide one click mode: "ref" (string), "selector" (string), or "x" and "y" (number).', 400);
133
+ }
134
+ catch (err) {
135
+ if (err instanceof BrowserReferenceError) {
136
+ const mapped = mapBrowserReferenceError(err);
137
+ await logThought(`[API] Browser click reference failed: ${mapped.message}`);
138
+ sendError(res, mapped.message, mapped.status);
139
+ return;
140
+ }
141
+ const mapped = mapError(err);
142
+ await logThought(`[API] Browser click failed: ${mapped.message}`);
143
+ sendError(res, mapped.message, mapped.status);
144
+ }
145
+ };
146
+ }
147
+ function mapBrowserReferenceError(error) {
148
+ switch (error.code) {
149
+ case 'snapshot_context_missing':
150
+ return { status: 409, message: error.message };
151
+ case 'snapshot_context_stale':
152
+ return { status: 409, message: error.message };
153
+ case 'reference_not_found':
154
+ return { status: 404, message: error.message };
155
+ case 'reference_unresolved':
156
+ return { status: 422, message: error.message };
157
+ default:
158
+ return { status: 500, message: error.message };
159
+ }
160
+ }
@@ -0,0 +1,80 @@
1
+ import { sendOk, sendError } from '../shared.js';
2
+ import { logThought } from '../../utils/logger.js';
3
+ import { recordCallbackReceipt, getCallbackReceipt, getDelivery, updateDeliveryState } from '../../services/db.js';
4
+ /**
5
+ * POST /callback/webhook
6
+ *
7
+ * Ingests completion events from long-running external tasks.
8
+ * This endpoint is protected by the `requireSignature` middleware.
9
+ *
10
+ * Body:
11
+ * eventType: string — e.g. 'deploy.complete', 'scrape.done'
12
+ * taskId: string — Correlating task identifier.
13
+ * status: 'completed' | 'failed' | 'progress'
14
+ * result?: unknown — Arbitrary payload on success.
15
+ * error?: string — Error description on failure.
16
+ */
17
+ export function handleWebhookCallback(deps) {
18
+ return async (req, res) => {
19
+ const body = req.body;
20
+ // ── Validate required fields ────────────────────────────────────────────
21
+ if (!body.eventType || typeof body.eventType !== 'string') {
22
+ sendError(res, 'Missing or invalid "eventType" (string).', 400);
23
+ return;
24
+ }
25
+ if (!body.taskId || typeof body.taskId !== 'string') {
26
+ sendError(res, 'Missing or invalid "taskId" (string).', 400);
27
+ return;
28
+ }
29
+ const validStatuses = new Set(['completed', 'failed', 'progress']);
30
+ if (!body.status || !validStatuses.has(body.status)) {
31
+ sendError(res, 'Missing or invalid "status" (completed | failed | progress).', 400);
32
+ return;
33
+ }
34
+ // ── Idempotency Check ───────────────────────────────────────────────────
35
+ const idempotencyKey = `${body.taskId}:${body.eventType}:${body.status}`;
36
+ const existingReceipt = getCallbackReceipt(idempotencyKey);
37
+ if (existingReceipt) {
38
+ await logThought(`[API] Webhook rejected — duplicate payload detected for key: ${idempotencyKey}`);
39
+ const data = {
40
+ accepted: true,
41
+ eventType: body.eventType,
42
+ taskId: body.taskId,
43
+ outcome: 'duplicate',
44
+ };
45
+ sendOk(res, data, 200);
46
+ return;
47
+ }
48
+ await logThought(`[API] Webhook received — event: ${body.eventType}, task: ${body.taskId}, status: ${body.status}`);
49
+ // ── Forward into the gateway as a system-level message ──────────────────
50
+ try {
51
+ const sessionId = `webhook:${body.taskId}`;
52
+ const summaryText = `[Webhook Callback] Event: ${body.eventType} | Task: ${body.taskId} | Status: ${body.status}` +
53
+ (body.result ? `\nResult: ${JSON.stringify(body.result)}` : '') +
54
+ (body.error ? `\nError: ${body.error}` : '');
55
+ // ── Reconciliation ───────────────────────────────────────────────────
56
+ const delivery = getDelivery(body.taskId);
57
+ if (delivery) {
58
+ const newState = body.status === 'completed' ? 'sent' : body.status === 'failed' ? 'failed' : delivery.state;
59
+ updateDeliveryState(body.taskId, newState, newState === 'sent' ? new Date().toISOString() : null);
60
+ await logThought(`[API] Webhook reconciled delivery queue item: ${body.taskId} -> ${newState}`);
61
+ }
62
+ // Fire-and-forget: process the webhook payload as a conversation turn
63
+ void deps.gateway.processText(sessionId, summaryText);
64
+ recordCallbackReceipt(idempotencyKey, 202, 'accepted');
65
+ const data = {
66
+ accepted: true,
67
+ eventType: body.eventType,
68
+ taskId: body.taskId,
69
+ outcome: 'accepted',
70
+ };
71
+ sendOk(res, data, 202);
72
+ }
73
+ catch (err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ await logThought(`[API] Webhook processing error: ${message}`);
76
+ recordCallbackReceipt(idempotencyKey, 500, 'rejected');
77
+ sendError(res, `Webhook processing error: ${message}`, 500);
78
+ }
79
+ };
80
+ }
@@ -0,0 +1,19 @@
1
+ import { validateRuntimeConfig } from '../../config/env-validator.js';
2
+ import { sendOk } from '../shared.js';
3
+ /** GET /config/validate — Returns a full runtime config validation report. */
4
+ export function handleConfigValidate() {
5
+ return (_req, res) => {
6
+ const result = validateRuntimeConfig();
7
+ const data = {
8
+ ok: result.ok,
9
+ presentKeys: result.presentKeys,
10
+ issues: result.issues,
11
+ activeFeatures: result.activeFeatures,
12
+ fatalIssues: result.fatalIssues,
13
+ validatedAt: result.validatedAt,
14
+ };
15
+ // Use 200 even when validation has issues; the `ok` field in the body
16
+ // communicates the config health to callers.
17
+ sendOk(res, data);
18
+ };
19
+ }
@@ -0,0 +1,117 @@
1
+ import { getSecretVaultService } from '../../services/secret-vault.js';
2
+ import { sendOk } from '../shared.js';
3
+ const startTime = Date.now();
4
+ /** GET /health — Returns system health status and subsystem summaries. */
5
+ export function handleHealth(deps) {
6
+ return async (_req, res) => {
7
+ const summary = deps.skillRegistry.summary();
8
+ const servers = deps.mcpManager.listServers();
9
+ const packageDiagnostics = await deps.mcpManager.getSkillPackageDiagnostics();
10
+ const secretDiagnostics = getSecretVaultService().getDiagnostics(['API_SECRET']);
11
+ const budgetSnapshot = deps.budgetGovernor?.getSnapshot('health');
12
+ const routingSnapshot = deps.modelRouter?.getHealthSnapshot();
13
+ const backupDiagnostics = deps.localStateBackup
14
+ ? await deps.localStateBackup.getDiagnostics(5)
15
+ : null;
16
+ const heartbeatRunning = deps.heartbeat.scheduler
17
+ .listJobs()
18
+ .some((j) => j.status === 'running');
19
+ const data = {
20
+ status: servers.some((s) => s.state === 'error') ||
21
+ packageDiagnostics.blockedPackageCount > 0 ||
22
+ secretDiagnostics.health.hasIssues ||
23
+ budgetSnapshot?.directive.severity === 'hard_limit' ||
24
+ (routingSnapshot?.consecutiveFailures ?? 0) >= 3 ||
25
+ backupDiagnostics?.status === 'degraded'
26
+ ? 'degraded'
27
+ : 'ok',
28
+ uptimeSec: Math.floor((Date.now() - startTime) / 1000),
29
+ memoryUsageMb: Math.round(process.memoryUsage().rss / 1024 / 1024),
30
+ heartbeat: { running: heartbeatRunning },
31
+ skills: {
32
+ builtin: summary.builtin ?? 0,
33
+ mcp: summary.mcp ?? 0,
34
+ total: deps.skillRegistry.size,
35
+ },
36
+ skillPackages: {
37
+ installed: packageDiagnostics.installed.length,
38
+ active: packageDiagnostics.activePackageCount,
39
+ blocked: packageDiagnostics.blockedPackageCount,
40
+ warnings: packageDiagnostics.warnings,
41
+ violations: packageDiagnostics.violations.map((violation) => ({
42
+ packageName: violation.packageName,
43
+ version: violation.version,
44
+ code: violation.code,
45
+ message: violation.message,
46
+ remediation: violation.remediation,
47
+ })),
48
+ },
49
+ secrets: {
50
+ status: secretDiagnostics.health.hasIssues ? 'degraded' : 'ok',
51
+ missingRequired: secretDiagnostics.health.missingRequired,
52
+ expired: secretDiagnostics.health.expired,
53
+ warnings: secretDiagnostics.health.warnings,
54
+ total: secretDiagnostics.total,
55
+ active: secretDiagnostics.active,
56
+ dueForRotation: secretDiagnostics.dueForRotation,
57
+ },
58
+ budget: budgetSnapshot
59
+ ? {
60
+ severity: budgetSnapshot.directive.severity,
61
+ profile: budgetSnapshot.directive.profile,
62
+ pacingDelayMs: budgetSnapshot.directive.pacingDelayMs,
63
+ manualProfile: budgetSnapshot.manualProfile,
64
+ daily: budgetSnapshot.daily,
65
+ session: budgetSnapshot.session,
66
+ providers: budgetSnapshot.providers,
67
+ }
68
+ : undefined,
69
+ routing: routingSnapshot,
70
+ backups: backupDiagnostics
71
+ ? {
72
+ status: backupDiagnostics.status,
73
+ lastSnapshotAt: backupDiagnostics.lastSnapshotAt,
74
+ lastRestoreAt: backupDiagnostics.lastRestoreAt,
75
+ validationFailureCount: backupDiagnostics.validationFailureCount,
76
+ recommendationCount: backupDiagnostics.recommendations.length,
77
+ }
78
+ : undefined,
79
+ mcpServers: servers.map((s) => ({
80
+ id: s.id,
81
+ name: s.name,
82
+ state: s.state,
83
+ toolCount: s.toolCount,
84
+ health: {
85
+ circuit: s.health.state,
86
+ failureCount: s.health.metrics.failureCount,
87
+ remainingCooldownMs: s.health.remainingCooldownMs,
88
+ },
89
+ })),
90
+ };
91
+ sendOk(res, data);
92
+ };
93
+ }
94
+ /** GET /health/live — Fast liveness probe for orchestrators (e.g. k8s) */
95
+ export function handleLiveness() {
96
+ return (_req, res) => {
97
+ sendOk(res, { status: 'alive', uptimeSec: Math.floor((Date.now() - startTime) / 1000) });
98
+ };
99
+ }
100
+ /** GET /health/ready — Readiness probe for load balancers */
101
+ export function handleReadiness(deps) {
102
+ return (_req, res) => {
103
+ const heartbeatRunning = deps.heartbeat.scheduler
104
+ .listJobs()
105
+ .some((j) => j.status === 'running');
106
+ if (!heartbeatRunning) {
107
+ res.status(503).json({ status: 'not_ready', reason: 'Heartbeat scheduler offline' });
108
+ return;
109
+ }
110
+ const secretDiagnostics = getSecretVaultService().getDiagnostics(['API_SECRET']);
111
+ if (secretDiagnostics.health.missingRequired.length > 0) {
112
+ res.status(503).json({ status: 'not_ready', reason: 'Missing critical secrets' });
113
+ return;
114
+ }
115
+ sendOk(res, { status: 'ready' });
116
+ };
117
+ }
@@ -0,0 +1,118 @@
1
+ import { sendError, sendOk } from '../shared.js';
2
+ const ALLOWED_SCOPES = new Set([
3
+ 'identity',
4
+ 'memory',
5
+ 'runtime-db',
6
+ 'policy-profiles',
7
+ 'mcp-config',
8
+ 'skill-packages',
9
+ ]);
10
+ function requireBackupService(deps, res) {
11
+ if (!deps.backupService) {
12
+ sendError(res, 'Local state backup service is not initialized.', 503);
13
+ return null;
14
+ }
15
+ return deps.backupService;
16
+ }
17
+ function parseSnapshotRequestBody(body) {
18
+ if (!body || typeof body !== 'object') {
19
+ return {};
20
+ }
21
+ const candidate = body;
22
+ if (candidate.retentionLimit === undefined) {
23
+ return {};
24
+ }
25
+ if (typeof candidate.retentionLimit !== 'number' ||
26
+ !Number.isFinite(candidate.retentionLimit) ||
27
+ candidate.retentionLimit <= 0) {
28
+ throw new Error("Field 'retentionLimit' must be a positive number.");
29
+ }
30
+ return { retentionLimit: Math.floor(candidate.retentionLimit) };
31
+ }
32
+ function parseRestoreRequestBody(body) {
33
+ if (!body || typeof body !== 'object') {
34
+ return {};
35
+ }
36
+ const candidate = body;
37
+ const request = {};
38
+ if (candidate.snapshotId !== undefined) {
39
+ if (typeof candidate.snapshotId !== 'string' || candidate.snapshotId.trim().length === 0) {
40
+ throw new Error("Field 'snapshotId' must be a non-empty string when provided.");
41
+ }
42
+ request.snapshotId = candidate.snapshotId.trim();
43
+ }
44
+ if (candidate.dryRun !== undefined) {
45
+ if (typeof candidate.dryRun !== 'boolean') {
46
+ throw new Error("Field 'dryRun' must be boolean when provided.");
47
+ }
48
+ request.dryRun = candidate.dryRun;
49
+ }
50
+ if (candidate.scopes !== undefined) {
51
+ if (!Array.isArray(candidate.scopes)) {
52
+ throw new Error("Field 'scopes' must be an array when provided.");
53
+ }
54
+ const scopes = [];
55
+ for (const scope of candidate.scopes) {
56
+ if (typeof scope !== 'string' || !ALLOWED_SCOPES.has(scope)) {
57
+ throw new Error(`Unsupported backup scope '${String(scope)}'.`);
58
+ }
59
+ scopes.push(scope);
60
+ }
61
+ request.scopes = [...new Set(scopes)];
62
+ }
63
+ return request;
64
+ }
65
+ export function handleLocalStateBackupDiagnostics(deps) {
66
+ return async (_req, res) => {
67
+ const service = requireBackupService(deps, res);
68
+ if (!service) {
69
+ return;
70
+ }
71
+ const diagnostics = await service.getDiagnostics();
72
+ const data = { diagnostics };
73
+ sendOk(res, data);
74
+ };
75
+ }
76
+ export function handleLocalStateCreateSnapshot(deps) {
77
+ return async (req, res) => {
78
+ const service = requireBackupService(deps, res);
79
+ if (!service) {
80
+ return;
81
+ }
82
+ try {
83
+ const request = parseSnapshotRequestBody(req.body);
84
+ const snapshot = await service.createSnapshot({
85
+ trigger: 'manual',
86
+ retentionLimit: request.retentionLimit,
87
+ });
88
+ const data = { snapshot };
89
+ sendOk(res, data, 201);
90
+ }
91
+ catch (error) {
92
+ const message = error instanceof Error ? error.message : String(error);
93
+ sendError(res, message, 400);
94
+ }
95
+ };
96
+ }
97
+ export function handleLocalStateRestoreSnapshot(deps) {
98
+ return async (req, res) => {
99
+ const service = requireBackupService(deps, res);
100
+ if (!service) {
101
+ return;
102
+ }
103
+ try {
104
+ const request = parseRestoreRequestBody(req.body);
105
+ const result = await service.restoreSnapshot(request);
106
+ const data = { request, result };
107
+ if (result.status === 'failed') {
108
+ sendError(res, result.validationErrors.join(' | ') || 'Restore failed.', 409);
109
+ return;
110
+ }
111
+ sendOk(res, data);
112
+ }
113
+ catch (error) {
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ sendError(res, message, 400);
116
+ }
117
+ };
118
+ }