@astermind/cybernetic-chatbot-client 2.2.36 → 2.2.44

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.
@@ -1013,6 +1013,594 @@ class CyberneticOfflineStorage {
1013
1013
  }
1014
1014
  }
1015
1015
 
1016
+ // src/WebSocketTransport.ts
1017
+ // WebSocket transport for streaming chat via SaaS WebSocket API (chatws.astermind.ai)
1018
+ /**
1019
+ * WebSocket transport for streaming chat.
1020
+ *
1021
+ * Maps the chat-handler Lambda WebSocket protocol to the client's StreamCallbacks:
1022
+ * Server typing → ignored (UI has its own indicator)
1023
+ * Server chunk → onToken(content)
1024
+ * Server done → onSources(sources) + onComplete(response)
1025
+ * Server error → onError(error)
1026
+ *
1027
+ * Connection is lazy — established on the first chatStream() call.
1028
+ * Reuses the same connection across multiple messages.
1029
+ * Reconnects with exponential backoff on close/error.
1030
+ */
1031
+ class WebSocketTransport {
1032
+ constructor(wsUrl, apiKey, options) {
1033
+ this.ws = null;
1034
+ this.state = 'disconnected';
1035
+ this.reconnectAttempts = 0;
1036
+ this.connectionPromise = null;
1037
+ // Active streaming request tracking
1038
+ this.activeCallbacks = null;
1039
+ this.activeFullText = '';
1040
+ // Cleanup handler reference
1041
+ this.beforeUnloadHandler = null;
1042
+ // Promise callbacks for the active stream
1043
+ this._resolveStream = null;
1044
+ this._rejectStream = null;
1045
+ this.wsUrl = wsUrl;
1046
+ this.apiKey = apiKey;
1047
+ this.maxReconnectAttempts = options?.maxReconnectAttempts ?? 3;
1048
+ this.reconnectDelay = options?.reconnectDelay ?? 1000;
1049
+ this.connectionTimeout = options?.connectionTimeout ?? 10000;
1050
+ }
1051
+ /**
1052
+ * Current transport state
1053
+ */
1054
+ getState() {
1055
+ return this.state;
1056
+ }
1057
+ /**
1058
+ * Whether the WebSocket is currently connected
1059
+ */
1060
+ isConnected() {
1061
+ return this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN;
1062
+ }
1063
+ /**
1064
+ * Connect to the WebSocket endpoint.
1065
+ * Appends the API key as a query parameter for authentication.
1066
+ * Returns a promise that resolves when the connection is open.
1067
+ */
1068
+ connect() {
1069
+ // Already connected
1070
+ if (this.isConnected()) {
1071
+ return Promise.resolve();
1072
+ }
1073
+ // Connection already in progress
1074
+ if (this.connectionPromise) {
1075
+ return this.connectionPromise;
1076
+ }
1077
+ this.state = 'connecting';
1078
+ this.connectionPromise = new Promise((resolve, reject) => {
1079
+ try {
1080
+ // Build URL with API key
1081
+ const separator = this.wsUrl.includes('?') ? '&' : '?';
1082
+ const fullUrl = `${this.wsUrl}${separator}apiKey=${encodeURIComponent(this.apiKey)}`;
1083
+ const ws = new WebSocket(fullUrl);
1084
+ this.ws = ws;
1085
+ // Connection timeout
1086
+ const timeout = setTimeout(() => {
1087
+ if (ws.readyState !== WebSocket.OPEN) {
1088
+ ws.close();
1089
+ this.state = 'error';
1090
+ this.connectionPromise = null;
1091
+ reject(new Error('WebSocket connection timed out'));
1092
+ }
1093
+ }, this.connectionTimeout);
1094
+ ws.onopen = () => {
1095
+ clearTimeout(timeout);
1096
+ this.state = 'connected';
1097
+ this.reconnectAttempts = 0;
1098
+ this.connectionPromise = null;
1099
+ this.registerCleanup();
1100
+ resolve();
1101
+ };
1102
+ ws.onmessage = (event) => {
1103
+ this.handleMessage(event);
1104
+ };
1105
+ ws.onerror = () => {
1106
+ clearTimeout(timeout);
1107
+ this.state = 'error';
1108
+ this.connectionPromise = null;
1109
+ // The close event fires after error, which will trigger reconnect
1110
+ };
1111
+ ws.onclose = (event) => {
1112
+ clearTimeout(timeout);
1113
+ const wasConnected = this.state === 'connected';
1114
+ this.state = 'disconnected';
1115
+ this.connectionPromise = null;
1116
+ this.unregisterCleanup();
1117
+ // If we had an active request, report the error
1118
+ if (this.activeCallbacks) {
1119
+ this.activeCallbacks.onError?.({
1120
+ code: 'WS_ERROR',
1121
+ message: event.reason || `WebSocket closed (code: ${event.code})`
1122
+ });
1123
+ this.clearActiveRequest();
1124
+ }
1125
+ // Reject if we were trying to connect
1126
+ if (!wasConnected) {
1127
+ reject(new Error(event.reason || `WebSocket closed (code: ${event.code})`));
1128
+ }
1129
+ };
1130
+ }
1131
+ catch (error) {
1132
+ this.state = 'error';
1133
+ this.connectionPromise = null;
1134
+ reject(error);
1135
+ }
1136
+ });
1137
+ return this.connectionPromise;
1138
+ }
1139
+ /**
1140
+ * Stream a chat message over WebSocket.
1141
+ *
1142
+ * Sends: { type: "sendMessage", message, sessionId?, wordLimit?, context? }
1143
+ * Receives: typing → chunk × N → done (with sources and metadata)
1144
+ */
1145
+ async chatStream(message, options) {
1146
+ const { sessionId, context, wordLimit, onToken, onSources, onComplete, onError } = options;
1147
+ // Ensure connection is established (lazy connect)
1148
+ try {
1149
+ await this.connect();
1150
+ }
1151
+ catch (error) {
1152
+ onError?.({
1153
+ code: 'WS_ERROR',
1154
+ message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`
1155
+ });
1156
+ throw error;
1157
+ }
1158
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1159
+ const err = { code: 'WS_ERROR', message: 'WebSocket not connected' };
1160
+ onError?.(err);
1161
+ throw new Error(err.message);
1162
+ }
1163
+ // Set up active request tracking
1164
+ this.activeCallbacks = { onToken, onSources, onComplete, onError };
1165
+ this.activeFullText = '';
1166
+ this.activeSessionId = sessionId;
1167
+ // Build and send the message
1168
+ const payload = {
1169
+ type: 'sendMessage',
1170
+ message,
1171
+ };
1172
+ if (sessionId)
1173
+ payload.sessionId = sessionId;
1174
+ if (wordLimit)
1175
+ payload.wordLimit = wordLimit;
1176
+ if (context)
1177
+ payload.context = context;
1178
+ // Return a promise that resolves when done/error is received
1179
+ return new Promise((resolve, reject) => {
1180
+ // Store resolve/reject so handleMessage can call them
1181
+ this._resolveStream = resolve;
1182
+ this._rejectStream = reject;
1183
+ this.ws.send(JSON.stringify(payload));
1184
+ });
1185
+ }
1186
+ /**
1187
+ * Disconnect and clean up the WebSocket connection
1188
+ */
1189
+ disconnect() {
1190
+ this.unregisterCleanup();
1191
+ if (this.ws) {
1192
+ // Remove handlers to avoid triggering reconnect
1193
+ this.ws.onclose = null;
1194
+ this.ws.onerror = null;
1195
+ this.ws.onmessage = null;
1196
+ this.ws.close();
1197
+ this.ws = null;
1198
+ }
1199
+ this.state = 'disconnected';
1200
+ this.connectionPromise = null;
1201
+ this.clearActiveRequest();
1202
+ }
1203
+ /**
1204
+ * Attempt reconnection with exponential backoff.
1205
+ * Used internally after connection loss.
1206
+ */
1207
+ async reconnect() {
1208
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1209
+ this.state = 'error';
1210
+ throw new Error(`Max reconnection attempts (${this.maxReconnectAttempts}) exceeded`);
1211
+ }
1212
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
1213
+ this.reconnectAttempts++;
1214
+ console.log(`[Cybernetic WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
1215
+ await new Promise(resolve => setTimeout(resolve, delay));
1216
+ // Ensure old connection is cleaned up
1217
+ if (this.ws) {
1218
+ this.ws.onclose = null;
1219
+ this.ws.onerror = null;
1220
+ this.ws.close();
1221
+ this.ws = null;
1222
+ }
1223
+ return this.connect();
1224
+ }
1225
+ // ==================== INTERNAL ====================
1226
+ /**
1227
+ * Handle incoming WebSocket messages from the chat-handler Lambda.
1228
+ *
1229
+ * Protocol:
1230
+ * { type: 'typing', status: boolean } → ignored
1231
+ * { type: 'chunk', content: string } → onToken(content)
1232
+ * { type: 'done', sessionId, sources, metadata, messageId } → onSources + onComplete
1233
+ * { type: 'error', error: string } → onError
1234
+ */
1235
+ handleMessage(event) {
1236
+ if (!this.activeCallbacks)
1237
+ return;
1238
+ try {
1239
+ const data = JSON.parse(event.data);
1240
+ switch (data.type) {
1241
+ case 'typing':
1242
+ // Ignored — the chatbot-template manages its own typing indicator
1243
+ break;
1244
+ case 'chunk':
1245
+ if (data.content) {
1246
+ this.activeFullText += data.content;
1247
+ this.activeCallbacks.onToken?.(data.content);
1248
+ }
1249
+ break;
1250
+ case 'done': {
1251
+ // Map sources from chat-handler format to client Source format
1252
+ const sources = this.mapSources(data.sources || []);
1253
+ // Emit sources
1254
+ this.activeCallbacks.onSources?.(sources);
1255
+ // Build complete response
1256
+ const response = {
1257
+ reply: this.activeFullText,
1258
+ confidence: 'high',
1259
+ sources,
1260
+ offline: false,
1261
+ sessionId: data.sessionId || this.activeSessionId,
1262
+ };
1263
+ this.activeCallbacks.onComplete?.(response);
1264
+ this._resolveStream?.();
1265
+ this.clearActiveRequest();
1266
+ break;
1267
+ }
1268
+ case 'error': {
1269
+ const err = {
1270
+ code: 'WS_ERROR',
1271
+ message: data.error || 'Unknown WebSocket error'
1272
+ };
1273
+ this.activeCallbacks.onError?.(err);
1274
+ this._rejectStream?.(new Error(err.message));
1275
+ this.clearActiveRequest();
1276
+ break;
1277
+ }
1278
+ default:
1279
+ // Unknown message type — log and ignore
1280
+ console.warn('[Cybernetic WS] Unknown message type:', data.type);
1281
+ break;
1282
+ }
1283
+ }
1284
+ catch (error) {
1285
+ console.error('[Cybernetic WS] Failed to parse message:', error, event.data);
1286
+ }
1287
+ }
1288
+ /**
1289
+ * Map sources from the chat-handler WebSocket format to the client Source interface.
1290
+ *
1291
+ * Chat-handler sends: { heading, content, score, documentId, documentName }
1292
+ * Client expects: { title, snippet, relevance, documentId, documentName }
1293
+ */
1294
+ mapSources(wsSources) {
1295
+ if (!Array.isArray(wsSources))
1296
+ return [];
1297
+ return wsSources.map((s) => ({
1298
+ title: s.heading || s.documentName || 'Document',
1299
+ snippet: s.content || '',
1300
+ relevance: s.score ?? 0,
1301
+ documentId: s.documentId,
1302
+ documentName: s.documentName,
1303
+ sourceType: 'document',
1304
+ }));
1305
+ }
1306
+ /**
1307
+ * Clear active request state after completion or error
1308
+ */
1309
+ clearActiveRequest() {
1310
+ this.activeCallbacks = null;
1311
+ this.activeFullText = '';
1312
+ this._resolveStream = null;
1313
+ this._rejectStream = null;
1314
+ }
1315
+ /**
1316
+ * Register a beforeunload handler to close the WebSocket on page unload
1317
+ */
1318
+ registerCleanup() {
1319
+ if (typeof window !== 'undefined' && !this.beforeUnloadHandler) {
1320
+ this.beforeUnloadHandler = () => this.disconnect();
1321
+ window.addEventListener('beforeunload', this.beforeUnloadHandler);
1322
+ }
1323
+ }
1324
+ /**
1325
+ * Remove the beforeunload handler
1326
+ */
1327
+ unregisterCleanup() {
1328
+ if (typeof window !== 'undefined' && this.beforeUnloadHandler) {
1329
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
1330
+ this.beforeUnloadHandler = null;
1331
+ }
1332
+ }
1333
+ }
1334
+
1335
+ // src/config.ts
1336
+ // Configuration loading and validation
1337
+ /** Default API URL when not specified */
1338
+ const DEFAULT_API_URL = 'https://api.astermind.ai';
1339
+ /**
1340
+ * Derive WebSocket URL from API URL for known SaaS domains.
1341
+ * Maps REST API domains to their WebSocket counterparts:
1342
+ * chatapi.astermind.ai → wss://chatws.astermind.ai
1343
+ * chatapi-dev.astermind.ai → wss://chatws-dev.astermind.ai
1344
+ * api.astermind.ai → wss://chatws.astermind.ai
1345
+ *
1346
+ * Returns undefined for non-SaaS (on-prem) URLs.
1347
+ */
1348
+ function deriveWsUrl(apiUrl) {
1349
+ try {
1350
+ const url = new URL(apiUrl);
1351
+ // chatapi[-env].astermind.ai → chatws[-env].astermind.ai
1352
+ const match = url.hostname.match(/^chatapi(-[\w]+)?\.astermind\.ai$/);
1353
+ if (match) {
1354
+ const envSuffix = match[1] || '';
1355
+ return `wss://chatws${envSuffix}.astermind.ai`;
1356
+ }
1357
+ // api.astermind.ai → chatws.astermind.ai (default SaaS)
1358
+ if (url.hostname === 'api.astermind.ai') {
1359
+ return 'wss://chatws.astermind.ai';
1360
+ }
1361
+ }
1362
+ catch {
1363
+ // Invalid URL, no WS derivation
1364
+ }
1365
+ return undefined;
1366
+ }
1367
+ /**
1368
+ * Try to load wsUrl from environment or globals.
1369
+ * Returns the first found wsUrl or undefined.
1370
+ */
1371
+ function loadWsUrl() {
1372
+ // Vite env var
1373
+ try {
1374
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1375
+ const importMeta = globalThis.import?.meta?.env;
1376
+ if (importMeta?.VITE_ASTERMIND_RAG_WS_URL) {
1377
+ return importMeta.VITE_ASTERMIND_RAG_WS_URL;
1378
+ }
1379
+ }
1380
+ catch { /* not available */ }
1381
+ // Node.js / CRA env var
1382
+ if (typeof process !== 'undefined' && process.env) {
1383
+ if (process.env.ASTERMIND_RAG_WS_URL)
1384
+ return process.env.ASTERMIND_RAG_WS_URL;
1385
+ if (process.env.REACT_APP_ASTERMIND_RAG_WS_URL)
1386
+ return process.env.REACT_APP_ASTERMIND_RAG_WS_URL;
1387
+ }
1388
+ // SSR-injected config
1389
+ if (typeof window !== 'undefined') {
1390
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1391
+ const injected = window.__ASTERMIND_CONFIG__;
1392
+ if (injected?.wsUrl)
1393
+ return injected.wsUrl;
1394
+ // Global config object
1395
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1396
+ const globalConfig = window.astermindConfig;
1397
+ if (globalConfig?.wsUrl)
1398
+ return globalConfig.wsUrl;
1399
+ }
1400
+ // Script data attribute
1401
+ if (typeof document !== 'undefined') {
1402
+ const script = document.querySelector('script[data-astermind-ws-url]');
1403
+ if (script) {
1404
+ const wsUrl = script.getAttribute('data-astermind-ws-url');
1405
+ if (wsUrl)
1406
+ return wsUrl;
1407
+ }
1408
+ }
1409
+ return undefined;
1410
+ }
1411
+ /**
1412
+ * Resolve the WebSocket URL for a given config.
1413
+ * Priority: explicit wsUrl → env var → auto-derived from apiUrl
1414
+ */
1415
+ function resolveWsUrl(config) {
1416
+ // Explicit wsUrl takes priority
1417
+ if (config.wsUrl)
1418
+ return config.wsUrl;
1419
+ // Don't auto-detect if transport is forced to REST
1420
+ if (config.transport === 'rest')
1421
+ return undefined;
1422
+ // Try environment / globals
1423
+ const envWsUrl = loadWsUrl();
1424
+ if (envWsUrl)
1425
+ return envWsUrl;
1426
+ // Auto-derive from apiUrl for known SaaS domains
1427
+ return deriveWsUrl(config.apiUrl);
1428
+ }
1429
+ /**
1430
+ * Validate configuration
1431
+ */
1432
+ function validateConfig(config) {
1433
+ if (!config || typeof config !== 'object') {
1434
+ throw new Error('Config must be an object');
1435
+ }
1436
+ const c = config;
1437
+ if (!c.apiUrl || typeof c.apiUrl !== 'string') {
1438
+ throw new Error('apiUrl is required and must be a string');
1439
+ }
1440
+ if (!c.apiKey || typeof c.apiKey !== 'string') {
1441
+ throw new Error('apiKey is required and must be a string');
1442
+ }
1443
+ if (!c.apiKey.startsWith('am_')) {
1444
+ throw new Error('apiKey must start with "am_"');
1445
+ }
1446
+ return true;
1447
+ }
1448
+ /**
1449
+ * Load config from Node.js process.env (for bundlers that replace process.env)
1450
+ */
1451
+ function loadFromProcessEnv() {
1452
+ // Check if process.env exists (Node.js or bundler-injected)
1453
+ if (typeof process === 'undefined' || !process.env) {
1454
+ return null;
1455
+ }
1456
+ // Check for ASTERMIND_RAG_* env vars (non-prefixed, for Node.js/server environments)
1457
+ const apiKey = process.env.ASTERMIND_RAG_API_KEY;
1458
+ const apiUrl = process.env.ASTERMIND_RAG_API_SERVER_URL;
1459
+ if (apiKey) {
1460
+ return {
1461
+ apiKey,
1462
+ apiUrl: apiUrl || DEFAULT_API_URL,
1463
+ _source: 'env'
1464
+ };
1465
+ }
1466
+ // Check for CRA-style REACT_APP_* env vars
1467
+ const craApiKey = process.env.REACT_APP_ASTERMIND_RAG_API_KEY;
1468
+ const craApiUrl = process.env.REACT_APP_ASTERMIND_RAG_API_SERVER_URL;
1469
+ if (craApiKey) {
1470
+ return {
1471
+ apiKey: craApiKey,
1472
+ apiUrl: craApiUrl || DEFAULT_API_URL,
1473
+ _source: 'env'
1474
+ };
1475
+ }
1476
+ return null;
1477
+ }
1478
+ /**
1479
+ * Load config from Vite's import.meta.env (browser environment)
1480
+ * Note: This only works at build time when Vite replaces the variables
1481
+ */
1482
+ function loadFromViteEnv() {
1483
+ // Check for window.__ASTERMIND_CONFIG__ (SSR-injected config)
1484
+ if (typeof window !== 'undefined') {
1485
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1486
+ const injected = window.__ASTERMIND_CONFIG__;
1487
+ if (injected && typeof injected === 'object' && injected.apiKey) {
1488
+ return {
1489
+ ...injected,
1490
+ apiUrl: injected.apiUrl || DEFAULT_API_URL,
1491
+ _source: 'vite'
1492
+ };
1493
+ }
1494
+ }
1495
+ // Check for Vite's import.meta.env (replaced at build time)
1496
+ // Note: TypeScript doesn't know about import.meta.env, so we use a try-catch
1497
+ try {
1498
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1499
+ const importMeta = globalThis.import?.meta?.env;
1500
+ if (importMeta) {
1501
+ const apiKey = importMeta.VITE_ASTERMIND_RAG_API_KEY;
1502
+ const apiUrl = importMeta.VITE_ASTERMIND_RAG_API_SERVER_URL;
1503
+ if (apiKey) {
1504
+ return {
1505
+ apiKey,
1506
+ apiUrl: apiUrl || DEFAULT_API_URL,
1507
+ _source: 'vite'
1508
+ };
1509
+ }
1510
+ }
1511
+ }
1512
+ catch {
1513
+ // import.meta not available in this environment
1514
+ }
1515
+ return null;
1516
+ }
1517
+ /**
1518
+ * Load config from window.astermindConfig global object
1519
+ */
1520
+ function loadFromGlobalObject() {
1521
+ if (typeof window === 'undefined') {
1522
+ return null;
1523
+ }
1524
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1525
+ const globalConfig = window.astermindConfig;
1526
+ if (globalConfig && typeof globalConfig === 'object' && globalConfig.apiKey) {
1527
+ return {
1528
+ ...globalConfig,
1529
+ apiUrl: globalConfig.apiUrl || DEFAULT_API_URL,
1530
+ _source: 'window'
1531
+ };
1532
+ }
1533
+ return null;
1534
+ }
1535
+ /**
1536
+ * Load config from script tag data attributes
1537
+ */
1538
+ function loadFromScriptAttributes() {
1539
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
1540
+ return null;
1541
+ }
1542
+ const script = document.querySelector('script[data-astermind-key]');
1543
+ if (script) {
1544
+ const apiKey = script.getAttribute('data-astermind-key');
1545
+ if (apiKey) {
1546
+ return {
1547
+ apiKey,
1548
+ apiUrl: script.getAttribute('data-astermind-url') || DEFAULT_API_URL,
1549
+ _source: 'data-attr'
1550
+ };
1551
+ }
1552
+ }
1553
+ return null;
1554
+ }
1555
+ /**
1556
+ * Load config using priority-based fallback chain:
1557
+ * 1. Environment variables (process.env - for bundlers/Node.js)
1558
+ * 2. Vite environment variables (import.meta.env or window.__ASTERMIND_CONFIG__)
1559
+ * 3. Global object (window.astermindConfig)
1560
+ * 4. Script data attributes (data-astermind-key, data-astermind-url)
1561
+ *
1562
+ * @param options - Configuration options
1563
+ * @param options.throwOnMissingKey - If true (default), throws when no API key found. If false, returns null and logs a warning.
1564
+ * @returns Configuration object or null if not found and throwOnMissingKey is false
1565
+ */
1566
+ function loadConfig(options = {}) {
1567
+ const { throwOnMissingKey = true } = options;
1568
+ // Priority 1: Environment variables (Node.js/bundler)
1569
+ const envConfig = loadFromProcessEnv();
1570
+ if (envConfig) {
1571
+ return envConfig;
1572
+ }
1573
+ // Priority 2: Vite environment variables / SSR-injected config
1574
+ const viteConfig = loadFromViteEnv();
1575
+ if (viteConfig) {
1576
+ return viteConfig;
1577
+ }
1578
+ // Priority 3: Global object (window.astermindConfig)
1579
+ const globalConfig = loadFromGlobalObject();
1580
+ if (globalConfig) {
1581
+ validateConfig(globalConfig);
1582
+ return globalConfig;
1583
+ }
1584
+ // Priority 4: Script data attributes
1585
+ const scriptConfig = loadFromScriptAttributes();
1586
+ if (scriptConfig) {
1587
+ return scriptConfig;
1588
+ }
1589
+ // No config found
1590
+ if (throwOnMissingKey) {
1591
+ throw new Error('AsterMind API key is required. Configure using one of these methods:\n' +
1592
+ ' 1. Set VITE_ASTERMIND_RAG_API_KEY environment variable (Vite)\n' +
1593
+ ' 2. Set REACT_APP_ASTERMIND_RAG_API_KEY environment variable (CRA)\n' +
1594
+ ' 3. Set window.astermindConfig = { apiKey: "am_...", apiUrl: "..." }\n' +
1595
+ ' 4. Add data-astermind-key attribute to your script tag\n' +
1596
+ ' 5. Pass apiKey directly to createClient() or CyberneticClient constructor');
1597
+ }
1598
+ else {
1599
+ console.warn('[AsterMind] No API key found. Chatbot will not function until configured.');
1600
+ return null;
1601
+ }
1602
+ }
1603
+
1016
1604
  // src/license/base64url.ts
1017
1605
  // Base64URL encoding/decoding utilities for JWT handling
1018
1606
  /**
@@ -1648,6 +2236,7 @@ async function createLicenseManager(config) {
1648
2236
  */
1649
2237
  class CyberneticClient {
1650
2238
  constructor(config) {
2239
+ this.wsTransport = null;
1651
2240
  this.status = 'connecting';
1652
2241
  this.lastError = null;
1653
2242
  // Maintenance mode tracking (ADR-200)
@@ -1662,10 +2251,15 @@ class CyberneticClient {
1662
2251
  this.omegaRAG = null;
1663
2252
  // Track if offline warning has been shown
1664
2253
  this.offlineWarningShown = false;
2254
+ // Resolve WebSocket URL (explicit → env → auto-derived from apiUrl)
2255
+ const wsUrl = resolveWsUrl(config);
2256
+ const transport = config.transport ?? 'auto';
1665
2257
  // Apply defaults
1666
2258
  this.config = {
1667
2259
  apiUrl: config.apiUrl,
1668
2260
  apiKey: config.apiKey,
2261
+ wsUrl,
2262
+ transport,
1669
2263
  fallback: {
1670
2264
  enabled: config.fallback?.enabled ?? true,
1671
2265
  cacheMaxAge: config.fallback?.cacheMaxAge ?? 86400000, // 24 hours
@@ -1708,6 +2302,11 @@ class CyberneticClient {
1708
2302
  }).catch(() => {
1709
2303
  // License verification errors are handled internally
1710
2304
  });
2305
+ // Initialize WebSocket transport if configured and not forced to REST
2306
+ if (wsUrl && transport !== 'rest' && typeof WebSocket !== 'undefined') {
2307
+ this.wsTransport = new WebSocketTransport(wsUrl, config.apiKey, config.websocket);
2308
+ console.log(`[Cybernetic] WebSocket transport enabled: ${wsUrl}`);
2309
+ }
1711
2310
  // Monitor connection status
1712
2311
  this.monitorConnection();
1713
2312
  // Pre-cache documents on init if enabled
@@ -2086,6 +2685,60 @@ class CyberneticClient {
2086
2685
  callbacks.onComplete?.(response);
2087
2686
  return;
2088
2687
  }
2688
+ // Try WebSocket transport first (if available and not forced to REST)
2689
+ if (this.wsTransport && this.config.transport !== 'rest') {
2690
+ try {
2691
+ await this.wsTransport.chatStream(message, {
2692
+ sessionId: options?.sessionId,
2693
+ context: options?.context,
2694
+ onToken: callbacks.onToken,
2695
+ onSources: callbacks.onSources,
2696
+ onComplete: (response) => {
2697
+ this.setStatus('online');
2698
+ // Process through license manager
2699
+ const processedReply = this.licenseManager.processResponse(response.reply);
2700
+ callbacks.onComplete?.({
2701
+ ...response,
2702
+ reply: processedReply
2703
+ });
2704
+ },
2705
+ onError: (error) => {
2706
+ // In 'auto' mode, fall back to SSE on WS error
2707
+ if (this.config.transport === 'auto') {
2708
+ console.warn('[Cybernetic] WebSocket error, falling back to SSE:', error.message);
2709
+ this.streamViaSSE(message, callbacks, options);
2710
+ }
2711
+ else {
2712
+ // 'websocket' mode — no fallback
2713
+ const normalizedError = this.normalizeError(error);
2714
+ this.config.onError(normalizedError);
2715
+ callbacks.onError?.(normalizedError);
2716
+ }
2717
+ }
2718
+ });
2719
+ return;
2720
+ }
2721
+ catch (error) {
2722
+ if (this.config.transport === 'websocket') {
2723
+ // Forced WebSocket mode — don't fall back
2724
+ const normalizedError = this.normalizeError(error);
2725
+ this.lastError = normalizedError;
2726
+ this.config.onError(normalizedError);
2727
+ callbacks.onError?.(normalizedError);
2728
+ return;
2729
+ }
2730
+ // 'auto' mode: fall through to SSE
2731
+ console.warn('[Cybernetic] WebSocket connect failed, using SSE fallback');
2732
+ }
2733
+ }
2734
+ // REST+SSE path (on-prem, or fallback from WebSocket)
2735
+ await this.streamViaSSE(message, callbacks, options);
2736
+ }
2737
+ /**
2738
+ * Stream chat via REST+SSE (original transport).
2739
+ * Used as the primary transport for on-prem, or as fallback for SaaS WebSocket failures.
2740
+ */
2741
+ async streamViaSSE(message, callbacks, options) {
2089
2742
  try {
2090
2743
  await this.apiClient.chatStream(message, {
2091
2744
  sessionId: options?.sessionId,
@@ -2169,6 +2822,16 @@ class CyberneticClient {
2169
2822
  await this.cache.clear();
2170
2823
  this.localRAG.reset();
2171
2824
  }
2825
+ /**
2826
+ * Clean up all resources (WebSocket connection, caches, event listeners).
2827
+ * Call this when the client is no longer needed to prevent memory leaks.
2828
+ */
2829
+ destroy() {
2830
+ if (this.wsTransport) {
2831
+ this.wsTransport.disconnect();
2832
+ this.wsTransport = null;
2833
+ }
2834
+ }
2172
2835
  /**
2173
2836
  * Manually check if backend is reachable
2174
2837
  */
@@ -2479,185 +3142,6 @@ class CyberneticClient {
2479
3142
  }
2480
3143
  }
2481
3144
 
2482
- // src/config.ts
2483
- // Configuration loading and validation
2484
- /** Default API URL when not specified */
2485
- const DEFAULT_API_URL = 'https://api.astermind.ai';
2486
- /**
2487
- * Validate configuration
2488
- */
2489
- function validateConfig(config) {
2490
- if (!config || typeof config !== 'object') {
2491
- throw new Error('Config must be an object');
2492
- }
2493
- const c = config;
2494
- if (!c.apiUrl || typeof c.apiUrl !== 'string') {
2495
- throw new Error('apiUrl is required and must be a string');
2496
- }
2497
- if (!c.apiKey || typeof c.apiKey !== 'string') {
2498
- throw new Error('apiKey is required and must be a string');
2499
- }
2500
- if (!c.apiKey.startsWith('am_')) {
2501
- throw new Error('apiKey must start with "am_"');
2502
- }
2503
- return true;
2504
- }
2505
- /**
2506
- * Load config from Node.js process.env (for bundlers that replace process.env)
2507
- */
2508
- function loadFromProcessEnv() {
2509
- // Check if process.env exists (Node.js or bundler-injected)
2510
- if (typeof process === 'undefined' || !process.env) {
2511
- return null;
2512
- }
2513
- // Check for ASTERMIND_RAG_* env vars (non-prefixed, for Node.js/server environments)
2514
- const apiKey = process.env.ASTERMIND_RAG_API_KEY;
2515
- const apiUrl = process.env.ASTERMIND_RAG_API_SERVER_URL;
2516
- if (apiKey) {
2517
- return {
2518
- apiKey,
2519
- apiUrl: apiUrl || DEFAULT_API_URL,
2520
- _source: 'env'
2521
- };
2522
- }
2523
- // Check for CRA-style REACT_APP_* env vars
2524
- const craApiKey = process.env.REACT_APP_ASTERMIND_RAG_API_KEY;
2525
- const craApiUrl = process.env.REACT_APP_ASTERMIND_RAG_API_SERVER_URL;
2526
- if (craApiKey) {
2527
- return {
2528
- apiKey: craApiKey,
2529
- apiUrl: craApiUrl || DEFAULT_API_URL,
2530
- _source: 'env'
2531
- };
2532
- }
2533
- return null;
2534
- }
2535
- /**
2536
- * Load config from Vite's import.meta.env (browser environment)
2537
- * Note: This only works at build time when Vite replaces the variables
2538
- */
2539
- function loadFromViteEnv() {
2540
- // Check for window.__ASTERMIND_CONFIG__ (SSR-injected config)
2541
- if (typeof window !== 'undefined') {
2542
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2543
- const injected = window.__ASTERMIND_CONFIG__;
2544
- if (injected && typeof injected === 'object' && injected.apiKey) {
2545
- return {
2546
- ...injected,
2547
- apiUrl: injected.apiUrl || DEFAULT_API_URL,
2548
- _source: 'vite'
2549
- };
2550
- }
2551
- }
2552
- // Check for Vite's import.meta.env (replaced at build time)
2553
- // Note: TypeScript doesn't know about import.meta.env, so we use a try-catch
2554
- try {
2555
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2556
- const importMeta = globalThis.import?.meta?.env;
2557
- if (importMeta) {
2558
- const apiKey = importMeta.VITE_ASTERMIND_RAG_API_KEY;
2559
- const apiUrl = importMeta.VITE_ASTERMIND_RAG_API_SERVER_URL;
2560
- if (apiKey) {
2561
- return {
2562
- apiKey,
2563
- apiUrl: apiUrl || DEFAULT_API_URL,
2564
- _source: 'vite'
2565
- };
2566
- }
2567
- }
2568
- }
2569
- catch {
2570
- // import.meta not available in this environment
2571
- }
2572
- return null;
2573
- }
2574
- /**
2575
- * Load config from window.astermindConfig global object
2576
- */
2577
- function loadFromGlobalObject() {
2578
- if (typeof window === 'undefined') {
2579
- return null;
2580
- }
2581
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2582
- const globalConfig = window.astermindConfig;
2583
- if (globalConfig && typeof globalConfig === 'object' && globalConfig.apiKey) {
2584
- return {
2585
- ...globalConfig,
2586
- apiUrl: globalConfig.apiUrl || DEFAULT_API_URL,
2587
- _source: 'window'
2588
- };
2589
- }
2590
- return null;
2591
- }
2592
- /**
2593
- * Load config from script tag data attributes
2594
- */
2595
- function loadFromScriptAttributes() {
2596
- if (typeof window === 'undefined' || typeof document === 'undefined') {
2597
- return null;
2598
- }
2599
- const script = document.querySelector('script[data-astermind-key]');
2600
- if (script) {
2601
- const apiKey = script.getAttribute('data-astermind-key');
2602
- if (apiKey) {
2603
- return {
2604
- apiKey,
2605
- apiUrl: script.getAttribute('data-astermind-url') || DEFAULT_API_URL,
2606
- _source: 'data-attr'
2607
- };
2608
- }
2609
- }
2610
- return null;
2611
- }
2612
- /**
2613
- * Load config using priority-based fallback chain:
2614
- * 1. Environment variables (process.env - for bundlers/Node.js)
2615
- * 2. Vite environment variables (import.meta.env or window.__ASTERMIND_CONFIG__)
2616
- * 3. Global object (window.astermindConfig)
2617
- * 4. Script data attributes (data-astermind-key, data-astermind-url)
2618
- *
2619
- * @param options - Configuration options
2620
- * @param options.throwOnMissingKey - If true (default), throws when no API key found. If false, returns null and logs a warning.
2621
- * @returns Configuration object or null if not found and throwOnMissingKey is false
2622
- */
2623
- function loadConfig(options = {}) {
2624
- const { throwOnMissingKey = true } = options;
2625
- // Priority 1: Environment variables (Node.js/bundler)
2626
- const envConfig = loadFromProcessEnv();
2627
- if (envConfig) {
2628
- return envConfig;
2629
- }
2630
- // Priority 2: Vite environment variables / SSR-injected config
2631
- const viteConfig = loadFromViteEnv();
2632
- if (viteConfig) {
2633
- return viteConfig;
2634
- }
2635
- // Priority 3: Global object (window.astermindConfig)
2636
- const globalConfig = loadFromGlobalObject();
2637
- if (globalConfig) {
2638
- validateConfig(globalConfig);
2639
- return globalConfig;
2640
- }
2641
- // Priority 4: Script data attributes
2642
- const scriptConfig = loadFromScriptAttributes();
2643
- if (scriptConfig) {
2644
- return scriptConfig;
2645
- }
2646
- // No config found
2647
- if (throwOnMissingKey) {
2648
- throw new Error('AsterMind API key is required. Configure using one of these methods:\n' +
2649
- ' 1. Set VITE_ASTERMIND_RAG_API_KEY environment variable (Vite)\n' +
2650
- ' 2. Set REACT_APP_ASTERMIND_RAG_API_KEY environment variable (CRA)\n' +
2651
- ' 3. Set window.astermindConfig = { apiKey: "am_...", apiUrl: "..." }\n' +
2652
- ' 4. Add data-astermind-key attribute to your script tag\n' +
2653
- ' 5. Pass apiKey directly to createClient() or CyberneticClient constructor');
2654
- }
2655
- else {
2656
- console.warn('[AsterMind] No API key found. Chatbot will not function until configured.');
2657
- return null;
2658
- }
2659
- }
2660
-
2661
3145
  // src/agentic/SiteMapDiscovery.ts
2662
3146
  // Multi-source sitemap discovery and merging for agentic navigation
2663
3147
  // Zero-config mode: automatically discovers routes on any site