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.
- package/README.md +66 -0
- package/bin/npm-twinclaw.js +17 -0
- package/bin/run-twinbot-cli.js +36 -0
- package/bin/twinbot.js +4 -0
- package/bin/twinclaw.js +4 -0
- package/dist/api/handlers/browser.js +160 -0
- package/dist/api/handlers/callback.js +80 -0
- package/dist/api/handlers/config-validate.js +19 -0
- package/dist/api/handlers/health.js +117 -0
- package/dist/api/handlers/local-state-backup.js +118 -0
- package/dist/api/handlers/persona-state.js +59 -0
- package/dist/api/handlers/skill-packages.js +94 -0
- package/dist/api/router.js +278 -0
- package/dist/api/runtime-event-producer.js +99 -0
- package/dist/api/shared.js +82 -0
- package/dist/api/websocket-hub.js +305 -0
- package/dist/config/config-loader.js +2 -0
- package/dist/config/env-schema.js +202 -0
- package/dist/config/env-validator.js +223 -0
- package/dist/config/identity-bootstrap.js +115 -0
- package/dist/config/json-config.js +344 -0
- package/dist/config/workspace.js +186 -0
- package/dist/core/channels-cli.js +77 -0
- package/dist/core/cli.js +119 -0
- package/dist/core/context-assembly.js +33 -0
- package/dist/core/doctor.js +365 -0
- package/dist/core/gateway-cli.js +323 -0
- package/dist/core/gateway.js +416 -0
- package/dist/core/heartbeat.js +54 -0
- package/dist/core/install-cli.js +320 -0
- package/dist/core/lane-executor.js +134 -0
- package/dist/core/logs-cli.js +70 -0
- package/dist/core/onboarding.js +760 -0
- package/dist/core/pairing-cli.js +78 -0
- package/dist/core/secret-vault-cli.js +204 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +404 -0
- package/dist/interfaces/dispatcher.js +214 -0
- package/dist/interfaces/telegram_handler.js +82 -0
- package/dist/interfaces/tui-dashboard.js +53 -0
- package/dist/interfaces/whatsapp_handler.js +94 -0
- package/dist/release/cli.js +97 -0
- package/dist/release/mvp-gate-cli.js +118 -0
- package/dist/release/twinbot-config-schema.js +162 -0
- package/dist/release/twinclaw-config-schema.js +162 -0
- package/dist/services/block-chunker.js +174 -0
- package/dist/services/browser-service.js +334 -0
- package/dist/services/context-lifecycle.js +314 -0
- package/dist/services/db.js +1055 -0
- package/dist/services/delivery-tracker.js +110 -0
- package/dist/services/dm-pairing.js +245 -0
- package/dist/services/embedding-service.js +125 -0
- package/dist/services/file-watcher.js +125 -0
- package/dist/services/inbound-debounce.js +92 -0
- package/dist/services/incident-manager.js +516 -0
- package/dist/services/job-scheduler.js +176 -0
- package/dist/services/local-state-backup.js +682 -0
- package/dist/services/mcp-client-adapter.js +291 -0
- package/dist/services/mcp-server-manager.js +143 -0
- package/dist/services/model-router.js +927 -0
- package/dist/services/mvp-gate.js +845 -0
- package/dist/services/orchestration-service.js +422 -0
- package/dist/services/persona-state.js +256 -0
- package/dist/services/policy-engine.js +92 -0
- package/dist/services/proactive-notifier.js +94 -0
- package/dist/services/queue-service.js +146 -0
- package/dist/services/release-pipeline.js +652 -0
- package/dist/services/runtime-budget-governor.js +415 -0
- package/dist/services/secret-vault.js +704 -0
- package/dist/services/semantic-memory.js +249 -0
- package/dist/services/skill-package-manager.js +806 -0
- package/dist/services/skill-registry.js +122 -0
- package/dist/services/streaming-output.js +75 -0
- package/dist/services/stt-service.js +39 -0
- package/dist/services/tts-service.js +44 -0
- package/dist/skills/builtin.js +250 -0
- package/dist/skills/shell.js +87 -0
- package/dist/skills/types.js +1 -0
- package/dist/types/api.js +1 -0
- package/dist/types/context-budget.js +1 -0
- package/dist/types/doctor.js +1 -0
- package/dist/types/file-watcher.js +1 -0
- package/dist/types/incident.js +1 -0
- package/dist/types/local-state-backup.js +1 -0
- package/dist/types/mcp.js +1 -0
- package/dist/types/messaging.js +1 -0
- package/dist/types/model-routing.js +1 -0
- package/dist/types/mvp-gate.js +2 -0
- package/dist/types/orchestration.js +1 -0
- package/dist/types/persona-state.js +22 -0
- package/dist/types/policy.js +1 -0
- package/dist/types/reasoning-graph.js +1 -0
- package/dist/types/release.js +1 -0
- package/dist/types/reliability.js +1 -0
- package/dist/types/runtime-budget.js +1 -0
- package/dist/types/scheduler.js +1 -0
- package/dist/types/secret-vault.js +1 -0
- package/dist/types/skill-packages.js +1 -0
- package/dist/types/websocket.js +14 -0
- package/dist/utils/logger.js +57 -0
- package/dist/utils/retry.js +61 -0
- package/dist/utils/secret-scan.js +208 -0
- package/mcp-servers.json +179 -0
- package/package.json +81 -0
- package/skill-packages.json +92 -0
- package/skill-packages.lock.json +5 -0
- package/src/skills/builtin.ts +275 -0
- package/src/skills/shell.ts +118 -0
- package/src/skills/types.ts +30 -0
- package/src/types/api.ts +252 -0
- package/src/types/blessed-contrib.d.ts +4 -0
- package/src/types/context-budget.ts +76 -0
- package/src/types/doctor.ts +29 -0
- package/src/types/file-watcher.ts +26 -0
- package/src/types/incident.ts +57 -0
- package/src/types/local-state-backup.ts +121 -0
- package/src/types/mcp.ts +106 -0
- package/src/types/messaging.ts +35 -0
- package/src/types/model-routing.ts +61 -0
- package/src/types/mvp-gate.ts +99 -0
- package/src/types/orchestration.ts +65 -0
- package/src/types/persona-state.ts +61 -0
- package/src/types/policy.ts +27 -0
- package/src/types/reasoning-graph.ts +58 -0
- package/src/types/release.ts +115 -0
- package/src/types/reliability.ts +43 -0
- package/src/types/runtime-budget.ts +85 -0
- package/src/types/scheduler.ts +47 -0
- package/src/types/secret-vault.ts +62 -0
- package/src/types/skill-packages.ts +81 -0
- package/src/types/sqlite-vec.d.ts +5 -0
- package/src/types/websocket.ts +122 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
+
import { logThought } from '../utils/logger.js';
|
|
4
|
+
import { WsCloseCode } from '../types/websocket.js';
|
|
5
|
+
import { getSecretVaultService } from '../services/secret-vault.js';
|
|
6
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
7
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 5_000;
|
|
8
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
9
|
+
/** Default backpressure threshold in kilobytes (see WsHubConfig.maxClientQueue). */
|
|
10
|
+
const DEFAULT_MAX_CLIENT_QUEUE = 200;
|
|
11
|
+
const VALID_TOPICS = new Set(['health', 'reliability', 'incidents', 'routing']);
|
|
12
|
+
// ── WsHub ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Control-plane WebSocket hub.
|
|
15
|
+
*
|
|
16
|
+
* Responsibilities:
|
|
17
|
+
* - Authenticate clients via a shared-secret handshake within a configurable timeout.
|
|
18
|
+
* - Maintain a registry of authenticated, subscribed connections.
|
|
19
|
+
* - Fan-out typed event envelopes to clients subscribed to a given topic.
|
|
20
|
+
* - Enforce per-client bounded send queues to prevent slow-consumer back-pressure.
|
|
21
|
+
* - Run a ping/pong heartbeat and evict stale (unresponsive) clients.
|
|
22
|
+
* - Expose operator-facing diagnostics metrics.
|
|
23
|
+
* - Invoke an optional `onSubscribe` callback so the event producer can push an
|
|
24
|
+
* initial state snapshot immediately after a client subscribes.
|
|
25
|
+
*/
|
|
26
|
+
export class WsHub {
|
|
27
|
+
#config;
|
|
28
|
+
#clients = new Map();
|
|
29
|
+
#wss = null;
|
|
30
|
+
#heartbeatTimer = null;
|
|
31
|
+
#seq = 0;
|
|
32
|
+
#metrics = {
|
|
33
|
+
totalConnections: 0,
|
|
34
|
+
authFailures: 0,
|
|
35
|
+
droppedEvents: 0,
|
|
36
|
+
staleCleaned: 0,
|
|
37
|
+
lastEventAt: null,
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Called when a client successfully subscribes to topics.
|
|
41
|
+
* The event producer uses this to dispatch an initial snapshot.
|
|
42
|
+
*/
|
|
43
|
+
onSubscribe = null;
|
|
44
|
+
constructor(config = {}) {
|
|
45
|
+
this.#config = {
|
|
46
|
+
authTimeoutMs: config.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS,
|
|
47
|
+
heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
48
|
+
maxClientQueue: config.maxClientQueue ?? DEFAULT_MAX_CLIENT_QUEUE,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* Attach the WebSocket server to an existing HTTP server.
|
|
54
|
+
* Must be called before the HTTP server starts accepting connections.
|
|
55
|
+
*/
|
|
56
|
+
attach(server) {
|
|
57
|
+
this.#wss = new WebSocketServer({ server, path: '/ws' });
|
|
58
|
+
this.#wss.on('connection', (ws, req) => {
|
|
59
|
+
this.#handleConnection(ws, req);
|
|
60
|
+
});
|
|
61
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
62
|
+
this.#runHeartbeat();
|
|
63
|
+
}, this.#config.heartbeatIntervalMs);
|
|
64
|
+
void logThought('[WsHub] WebSocket control-plane attached on /ws.');
|
|
65
|
+
}
|
|
66
|
+
/** Gracefully close all connections and shut down the hub. */
|
|
67
|
+
stop() {
|
|
68
|
+
if (this.#heartbeatTimer) {
|
|
69
|
+
clearInterval(this.#heartbeatTimer);
|
|
70
|
+
this.#heartbeatTimer = null;
|
|
71
|
+
}
|
|
72
|
+
for (const client of this.#clients.values()) {
|
|
73
|
+
this.#closeClient(client, WsCloseCode.ServerShutdown, 'Server shutting down.');
|
|
74
|
+
}
|
|
75
|
+
this.#clients.clear();
|
|
76
|
+
this.#wss?.close();
|
|
77
|
+
this.#wss = null;
|
|
78
|
+
void logThought('[WsHub] WebSocket hub stopped.');
|
|
79
|
+
}
|
|
80
|
+
// ── Publishing ─────────────────────────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* Publish a runtime event to all authenticated clients subscribed to `topic`.
|
|
83
|
+
* Drops the oldest queued message for a client whose send buffer is full.
|
|
84
|
+
*/
|
|
85
|
+
publish(topic, payload) {
|
|
86
|
+
const seq = ++this.#seq;
|
|
87
|
+
const envelope = JSON.stringify({
|
|
88
|
+
type: 'event',
|
|
89
|
+
v: 1,
|
|
90
|
+
topic,
|
|
91
|
+
seq,
|
|
92
|
+
ts: new Date().toISOString(),
|
|
93
|
+
payload,
|
|
94
|
+
});
|
|
95
|
+
this.#metrics.lastEventAt = new Date().toISOString();
|
|
96
|
+
for (const client of this.#clients.values()) {
|
|
97
|
+
if (!client.authenticated || !client.subscriptions.has(topic))
|
|
98
|
+
continue;
|
|
99
|
+
if (client.ws.readyState !== WebSocket.OPEN)
|
|
100
|
+
continue;
|
|
101
|
+
this.#sendRaw(client, envelope);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Send a full-state snapshot directly to a specific client.
|
|
106
|
+
* Used for the initial snapshot immediately after subscription.
|
|
107
|
+
*/
|
|
108
|
+
sendSnapshotTo(clientId, snapshot) {
|
|
109
|
+
const client = this.#clients.get(clientId);
|
|
110
|
+
if (!client || !client.authenticated || client.ws.readyState !== WebSocket.OPEN)
|
|
111
|
+
return;
|
|
112
|
+
const msg = JSON.stringify({
|
|
113
|
+
type: 'snapshot',
|
|
114
|
+
v: 1,
|
|
115
|
+
ts: new Date().toISOString(),
|
|
116
|
+
...snapshot,
|
|
117
|
+
});
|
|
118
|
+
this.#sendRaw(client, msg);
|
|
119
|
+
}
|
|
120
|
+
// ── Diagnostics ────────────────────────────────────────────────────────────
|
|
121
|
+
/** Returns operator-facing connection and reliability metrics. */
|
|
122
|
+
getMetrics() {
|
|
123
|
+
return {
|
|
124
|
+
activeClients: this.#clients.size,
|
|
125
|
+
totalConnections: this.#metrics.totalConnections,
|
|
126
|
+
authFailures: this.#metrics.authFailures,
|
|
127
|
+
droppedEvents: this.#metrics.droppedEvents,
|
|
128
|
+
staleCleaned: this.#metrics.staleCleaned,
|
|
129
|
+
lastEventAt: this.#metrics.lastEventAt,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ── Connection Handling ─────────────────────────────────────────────────────
|
|
133
|
+
#handleConnection(ws, _req) {
|
|
134
|
+
const clientId = randomUUID();
|
|
135
|
+
this.#metrics.totalConnections++;
|
|
136
|
+
const client = {
|
|
137
|
+
id: clientId,
|
|
138
|
+
ws,
|
|
139
|
+
authenticated: false,
|
|
140
|
+
subscriptions: new Set(),
|
|
141
|
+
authTimer: null,
|
|
142
|
+
isAlive: true,
|
|
143
|
+
connectedAt: Date.now(),
|
|
144
|
+
};
|
|
145
|
+
this.#clients.set(clientId, client);
|
|
146
|
+
// Enforce auth within timeout window
|
|
147
|
+
client.authTimer = setTimeout(() => {
|
|
148
|
+
if (!client.authenticated) {
|
|
149
|
+
this.#metrics.authFailures++;
|
|
150
|
+
void logThought(`[WsHub] Client ${clientId} auth timeout — closing connection.`);
|
|
151
|
+
this.#closeClient(client, WsCloseCode.AuthRequired, 'Authentication required.');
|
|
152
|
+
}
|
|
153
|
+
}, this.#config.authTimeoutMs);
|
|
154
|
+
ws.on('pong', () => {
|
|
155
|
+
client.isAlive = true;
|
|
156
|
+
});
|
|
157
|
+
ws.on('message', (data) => {
|
|
158
|
+
this.#handleMessage(client, data);
|
|
159
|
+
});
|
|
160
|
+
ws.on('close', () => {
|
|
161
|
+
this.#cleanupClient(client);
|
|
162
|
+
});
|
|
163
|
+
ws.on('error', (err) => {
|
|
164
|
+
void logThought(`[WsHub] Client ${clientId} socket error: ${err.message}`);
|
|
165
|
+
this.#cleanupClient(client);
|
|
166
|
+
});
|
|
167
|
+
void logThought(`[WsHub] New connection: ${clientId}.`);
|
|
168
|
+
}
|
|
169
|
+
#handleMessage(client, rawData) {
|
|
170
|
+
let msg;
|
|
171
|
+
try {
|
|
172
|
+
msg = JSON.parse(String(rawData));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
this.#sendError(client, 400, 'Invalid JSON.');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (typeof msg !== 'object' || msg === null || typeof msg.type !== 'string') {
|
|
179
|
+
this.#sendError(client, 400, 'Malformed message: missing "type" field.');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
switch (msg.type) {
|
|
183
|
+
case 'auth':
|
|
184
|
+
this.#handleAuth(client, msg);
|
|
185
|
+
break;
|
|
186
|
+
case 'subscribe':
|
|
187
|
+
this.#handleSubscribe(client, msg);
|
|
188
|
+
break;
|
|
189
|
+
case 'ping':
|
|
190
|
+
this.#sendRaw(client, JSON.stringify({ type: 'pong', ts: new Date().toISOString() }));
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
this.#sendError(client, 400, `Unknown message type: ${String(msg.type)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
#handleAuth(client, msg) {
|
|
197
|
+
const token = typeof msg.token === 'string' ? msg.token : '';
|
|
198
|
+
const apiSecret = getSecretVaultService().readSecret('API_SECRET') ?? '';
|
|
199
|
+
if (!apiSecret || !token || token !== apiSecret) {
|
|
200
|
+
this.#metrics.authFailures++;
|
|
201
|
+
void logThought(`[WsHub] Client ${client.id} authentication failed (invalid token).`);
|
|
202
|
+
this.#sendError(client, WsCloseCode.AuthFailed, 'Authentication failed.');
|
|
203
|
+
this.#closeClient(client, WsCloseCode.AuthFailed, 'Authentication failed.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (client.authTimer) {
|
|
207
|
+
clearTimeout(client.authTimer);
|
|
208
|
+
client.authTimer = null;
|
|
209
|
+
}
|
|
210
|
+
client.authenticated = true;
|
|
211
|
+
this.#sendRaw(client, JSON.stringify({ type: 'auth_ok', clientId: client.id, ts: new Date().toISOString() }));
|
|
212
|
+
void logThought(`[WsHub] Client ${client.id} authenticated.`);
|
|
213
|
+
}
|
|
214
|
+
#handleSubscribe(client, msg) {
|
|
215
|
+
if (!client.authenticated) {
|
|
216
|
+
this.#sendError(client, WsCloseCode.AuthRequired, 'Not authenticated.');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const rawTopics = Array.isArray(msg.topics) ? msg.topics : [];
|
|
220
|
+
const validTopics = [];
|
|
221
|
+
const invalidTopics = [];
|
|
222
|
+
for (const t of rawTopics) {
|
|
223
|
+
if (typeof t === 'string' && VALID_TOPICS.has(t)) {
|
|
224
|
+
validTopics.push(t);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
invalidTopics.push(String(t));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (invalidTopics.length > 0) {
|
|
231
|
+
this.#sendError(client, WsCloseCode.InvalidSubscription, `Invalid topics: ${invalidTopics.join(', ')}. Valid topics: ${[...VALID_TOPICS].join(', ')}.`);
|
|
232
|
+
if (validTopics.length === 0)
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
for (const topic of validTopics) {
|
|
236
|
+
client.subscriptions.add(topic);
|
|
237
|
+
}
|
|
238
|
+
this.#sendRaw(client, JSON.stringify({ type: 'subscribed', topics: validTopics, ts: new Date().toISOString() }));
|
|
239
|
+
void logThought(`[WsHub] Client ${client.id} subscribed to [${validTopics.join(', ')}].`);
|
|
240
|
+
if (this.onSubscribe && validTopics.length > 0) {
|
|
241
|
+
this.onSubscribe(client.id, validTopics);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// ── Heartbeat ──────────────────────────────────────────────────────────────
|
|
245
|
+
#runHeartbeat() {
|
|
246
|
+
for (const client of [...this.#clients.values()]) {
|
|
247
|
+
if (!client.isAlive) {
|
|
248
|
+
void logThought(`[WsHub] Client ${client.id} did not respond to ping — evicting stale connection.`);
|
|
249
|
+
this.#metrics.staleCleaned++;
|
|
250
|
+
this.#closeClient(client, WsCloseCode.StaleConnection, 'Stale connection.');
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
client.isAlive = false;
|
|
254
|
+
try {
|
|
255
|
+
client.ws.ping();
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// If ping fails the close/error event will clean up
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
263
|
+
#closeClient(client, code, reason) {
|
|
264
|
+
if (client.authTimer) {
|
|
265
|
+
clearTimeout(client.authTimer);
|
|
266
|
+
client.authTimer = null;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
client.ws.close(code, reason);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// ignore — socket may already be closed
|
|
273
|
+
}
|
|
274
|
+
this.#clients.delete(client.id);
|
|
275
|
+
}
|
|
276
|
+
#cleanupClient(client) {
|
|
277
|
+
if (client.authTimer) {
|
|
278
|
+
clearTimeout(client.authTimer);
|
|
279
|
+
client.authTimer = null;
|
|
280
|
+
}
|
|
281
|
+
this.#clients.delete(client.id);
|
|
282
|
+
void logThought(`[WsHub] Client ${client.id} disconnected.`);
|
|
283
|
+
}
|
|
284
|
+
#sendRaw(client, data) {
|
|
285
|
+
if (client.ws.readyState !== WebSocket.OPEN) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Per-client backpressure: if the socket's internal buffer is large,
|
|
289
|
+
// record a dropped event rather than allowing unbounded queue growth.
|
|
290
|
+
if (client.ws.bufferedAmount > this.#config.maxClientQueue * 1024) {
|
|
291
|
+
this.#metrics.droppedEvents++;
|
|
292
|
+
void logThought(`[WsHub] Client ${client.id} backpressure limit hit — dropping event.`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
client.ws.send(data);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
this.#metrics.droppedEvents++;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
#sendError(client, code, message) {
|
|
303
|
+
this.#sendRaw(client, JSON.stringify({ type: 'error', code, message, ts: new Date().toISOString() }));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { DEFAULT_CONFIG, getConfigPath, ensureConfigDir, readConfig as loadTwinBotJson, readConfig, writeConfig, getConfigValue, reloadConfigSync as reloadConfig, checkAndMigrateWorkspace, hasLegacyConfig, migrateLegacyConfig, } from './json-config.js';
|
|
2
|
+
export { getProfileName, getWorkspaceDir, getWorkspaceSubdir, getDatabasePath, getIdentityDir, getSecretsVaultPath, getTranscriptsDir, ensureWorkspaceDir, ensureWorkspaceSubdirs, initializeWorkspace, initializeWorkspaceGitignore, getWorkspaceSummary, WORKSPACE_GITIGNORE, } from './workspace.js';
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized registry of all environment and secret keys consumed by TwinBot.
|
|
3
|
+
*
|
|
4
|
+
* Each entry declares:
|
|
5
|
+
* - `key` The exact env variable name.
|
|
6
|
+
* - `type` Whether the value is a sensitive secret or a plain env var.
|
|
7
|
+
* - `class` 'required' | 'optional' | 'conditional'.
|
|
8
|
+
* - `scope` Subsystem that owns the key.
|
|
9
|
+
* - `condition` Feature gate that makes a conditional key applicable.
|
|
10
|
+
* - `description` Human-readable purpose.
|
|
11
|
+
* - `remediation` Actionable hint when the key is missing or invalid.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Complete inventory of environment and secret keys for TwinBot.
|
|
15
|
+
* Consumed by `EnvValidator` for startup/doctor/health diagnostics.
|
|
16
|
+
*/
|
|
17
|
+
export const CONFIG_SCHEMA = [
|
|
18
|
+
// ── Runtime Core ────────────────────────────────────────────────────────────
|
|
19
|
+
{
|
|
20
|
+
key: 'API_SECRET',
|
|
21
|
+
type: 'secret',
|
|
22
|
+
class: 'required',
|
|
23
|
+
scope: 'runtime',
|
|
24
|
+
description: 'Master HMAC secret for webhook signature verification. Also used as the secret-vault encryption fallback when SECRET_VAULT_MASTER_KEY is unset.',
|
|
25
|
+
remediation: "Set API_SECRET in your .env file or register it via `secret set API_SECRET <value>` before starting.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: 'SECRET_VAULT_MASTER_KEY',
|
|
29
|
+
type: 'env',
|
|
30
|
+
class: 'optional',
|
|
31
|
+
scope: 'runtime',
|
|
32
|
+
description: 'Dedicated AES-256 encryption key for the secret vault. Falls back to API_SECRET if unset.',
|
|
33
|
+
remediation: 'Optionally set SECRET_VAULT_MASTER_KEY for a dedicated vault encryption key independent of API_SECRET.',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: 'SECRET_VAULT_REQUIRED',
|
|
37
|
+
type: 'env',
|
|
38
|
+
class: 'optional',
|
|
39
|
+
scope: 'runtime',
|
|
40
|
+
description: 'Comma-separated list of additional secret names that must be present at startup.',
|
|
41
|
+
remediation: 'Set SECRET_VAULT_REQUIRED to enforce presence of extra secrets, e.g. SECRET_VAULT_REQUIRED=DB_PASSWORD,SOME_TOKEN.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: 'API_PORT',
|
|
45
|
+
type: 'env',
|
|
46
|
+
class: 'optional',
|
|
47
|
+
scope: 'runtime',
|
|
48
|
+
description: 'Listening port for the HTTP control plane API (default: 3100).',
|
|
49
|
+
remediation: 'Set API_PORT to change the control plane port, e.g. API_PORT=8080.',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: 'TOOLS_ALLOW',
|
|
53
|
+
type: 'env',
|
|
54
|
+
class: 'optional',
|
|
55
|
+
scope: 'runtime',
|
|
56
|
+
description: 'Comma-separated allow-list selectors for tool exposure (exact tool names, group:*, source:*, mcp:<serverId>).',
|
|
57
|
+
remediation: 'Set TOOLS_ALLOW to restrict model-visible tools, e.g. TOOLS_ALLOW=group:fs,fs.apply_patch.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: 'TOOLS_DENY',
|
|
61
|
+
type: 'env',
|
|
62
|
+
class: 'optional',
|
|
63
|
+
scope: 'runtime',
|
|
64
|
+
description: 'Comma-separated deny-list selectors for tool exposure, applied after TOOLS_ALLOW.',
|
|
65
|
+
remediation: 'Set TOOLS_DENY to hide risky tools, e.g. TOOLS_DENY=runtime.exec,group:runtime.',
|
|
66
|
+
},
|
|
67
|
+
// ── Model Routing ────────────────────────────────────────────────────────────
|
|
68
|
+
{
|
|
69
|
+
key: 'MODAL_API_KEY',
|
|
70
|
+
type: 'secret',
|
|
71
|
+
class: 'conditional',
|
|
72
|
+
condition: 'model:primary',
|
|
73
|
+
scope: 'model',
|
|
74
|
+
description: 'API key for the Modal-hosted primary model (GLM-5-FP8).',
|
|
75
|
+
remediation: 'Set MODAL_API_KEY to enable the primary model provider. At least one model API key is required for AI functionality.',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: 'OPENROUTER_API_KEY',
|
|
79
|
+
type: 'secret',
|
|
80
|
+
class: 'conditional',
|
|
81
|
+
condition: 'model:fallback_1',
|
|
82
|
+
scope: 'model',
|
|
83
|
+
description: 'API key for the OpenRouter fallback model (stepfun/step-3.5-flash).',
|
|
84
|
+
remediation: 'Set OPENROUTER_API_KEY to enable OpenRouter as a fallback model provider.',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: 'GEMINI_API_KEY',
|
|
88
|
+
type: 'secret',
|
|
89
|
+
class: 'conditional',
|
|
90
|
+
condition: 'model:fallback_2',
|
|
91
|
+
scope: 'model',
|
|
92
|
+
description: 'API key for the Google Gemini fallback model (gemini-flash-lite-latest).',
|
|
93
|
+
remediation: 'Set GEMINI_API_KEY to enable Gemini as a secondary fallback model provider.',
|
|
94
|
+
},
|
|
95
|
+
// ── Messaging: Telegram ──────────────────────────────────────────────────────
|
|
96
|
+
{
|
|
97
|
+
key: 'TELEGRAM_BOT_TOKEN',
|
|
98
|
+
type: 'secret',
|
|
99
|
+
class: 'conditional',
|
|
100
|
+
condition: 'messaging:telegram',
|
|
101
|
+
scope: 'messaging',
|
|
102
|
+
description: 'Telegram Bot API token obtained from @BotFather.',
|
|
103
|
+
remediation: 'Set TELEGRAM_BOT_TOKEN to enable Telegram messaging. Create a bot at https://t.me/BotFather.',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: 'TELEGRAM_USER_ID',
|
|
107
|
+
type: 'env',
|
|
108
|
+
class: 'optional',
|
|
109
|
+
scope: 'messaging',
|
|
110
|
+
description: 'Optional Telegram user ID allowlist seed (integer) for pre-authorizing yourself before pairing approvals.',
|
|
111
|
+
remediation: 'Optional: set TELEGRAM_USER_ID to your numeric Telegram user ID. Find it by messaging @userinfobot on Telegram.',
|
|
112
|
+
},
|
|
113
|
+
// ── Messaging: WhatsApp ──────────────────────────────────────────────────────
|
|
114
|
+
{
|
|
115
|
+
key: 'WHATSAPP_PHONE_NUMBER',
|
|
116
|
+
type: 'secret',
|
|
117
|
+
class: 'conditional',
|
|
118
|
+
condition: 'messaging:whatsapp',
|
|
119
|
+
scope: 'messaging',
|
|
120
|
+
description: 'Phone number for the WhatsApp native client (whatsapp-web.js). E.164 format recommended.',
|
|
121
|
+
remediation: 'Set WHATSAPP_PHONE_NUMBER to enable WhatsApp messaging integration.',
|
|
122
|
+
},
|
|
123
|
+
// ── Voice / Audio (Groq) ─────────────────────────────────────────────────────
|
|
124
|
+
{
|
|
125
|
+
key: 'GROQ_API_KEY',
|
|
126
|
+
type: 'secret',
|
|
127
|
+
class: 'conditional',
|
|
128
|
+
condition: 'messaging:voice',
|
|
129
|
+
scope: 'messaging',
|
|
130
|
+
description: 'Groq API key for Speech-to-Text (Whisper) and Text-to-Speech (Orpheus). Required when Telegram or WhatsApp voice messaging is active.',
|
|
131
|
+
remediation: 'Set GROQ_API_KEY. A free-tier key is available at https://console.groq.com.',
|
|
132
|
+
},
|
|
133
|
+
// ── Embedding ────────────────────────────────────────────────────────────────
|
|
134
|
+
{
|
|
135
|
+
key: 'EMBEDDING_PROVIDER',
|
|
136
|
+
type: 'env',
|
|
137
|
+
class: 'optional',
|
|
138
|
+
scope: 'integration',
|
|
139
|
+
description: 'Embedding backend: "openai" (default) or "ollama" for local-only operation.',
|
|
140
|
+
remediation: 'Set EMBEDDING_PROVIDER=ollama to use Ollama for embeddings without any API key.',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
key: 'EMBEDDING_API_KEY',
|
|
144
|
+
type: 'secret',
|
|
145
|
+
class: 'conditional',
|
|
146
|
+
condition: 'embedding:openai',
|
|
147
|
+
scope: 'integration',
|
|
148
|
+
description: 'API key for the OpenAI-compatible embedding endpoint. Takes priority over OPENAI_API_KEY.',
|
|
149
|
+
remediation: 'Set EMBEDDING_API_KEY (or OPENAI_API_KEY) to enable remote embedding with an OpenAI-compatible provider.',
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
key: 'OPENAI_API_KEY',
|
|
153
|
+
type: 'secret',
|
|
154
|
+
class: 'conditional',
|
|
155
|
+
condition: 'embedding:openai_fallback',
|
|
156
|
+
scope: 'integration',
|
|
157
|
+
description: 'OpenAI API key used as a fallback for embeddings when EMBEDDING_API_KEY is unset.',
|
|
158
|
+
remediation: 'Set OPENAI_API_KEY as an alternative to EMBEDDING_API_KEY for OpenAI embedding access.',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
key: 'EMBEDDING_API_URL',
|
|
162
|
+
type: 'env',
|
|
163
|
+
class: 'optional',
|
|
164
|
+
scope: 'integration',
|
|
165
|
+
description: 'Custom OpenAI-compatible embedding endpoint URL (default: https://api.openai.com/v1/embeddings).',
|
|
166
|
+
remediation: 'Override EMBEDDING_API_URL to point at a self-hosted or alternative embedding API.',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
key: 'EMBEDDING_MODEL',
|
|
170
|
+
type: 'env',
|
|
171
|
+
class: 'optional',
|
|
172
|
+
scope: 'integration',
|
|
173
|
+
description: 'Model name used for OpenAI-compatible embeddings (default: text-embedding-3-small).',
|
|
174
|
+
remediation: 'Set EMBEDDING_MODEL to use a different OpenAI-compatible embedding model.',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
key: 'MEMORY_EMBEDDING_DIM',
|
|
178
|
+
type: 'env',
|
|
179
|
+
class: 'optional',
|
|
180
|
+
scope: 'storage',
|
|
181
|
+
description: 'Expected embedding vector dimensionality for sqlite-vec storage (default: 1536). Must match the chosen embedding model.',
|
|
182
|
+
remediation: "Set MEMORY_EMBEDDING_DIM to match your embedding model's output dimensions to avoid vector shape mismatches.",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
key: 'OLLAMA_BASE_URL',
|
|
186
|
+
type: 'env',
|
|
187
|
+
class: 'optional',
|
|
188
|
+
scope: 'integration',
|
|
189
|
+
description: 'Base URL for a local Ollama server (default: http://localhost:11434).',
|
|
190
|
+
remediation: 'Set OLLAMA_BASE_URL if your Ollama instance runs on a non-default host or port.',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
key: 'OLLAMA_EMBEDDING_MODEL',
|
|
194
|
+
type: 'env',
|
|
195
|
+
class: 'optional',
|
|
196
|
+
scope: 'integration',
|
|
197
|
+
description: 'Ollama model name used for local embeddings (default: mxbai-embed-large).',
|
|
198
|
+
remediation: 'Set OLLAMA_EMBEDDING_MODEL to override the local Ollama embedding model.',
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
/** Quick lookup map by key name for O(1) resolution. */
|
|
202
|
+
export const CONFIG_SCHEMA_MAP = new Map(CONFIG_SCHEMA.map((spec) => [spec.key, spec]));
|