@astermind/cybernetic-chatbot-client 2.2.36 → 2.2.48

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.
@@ -5,13 +5,14 @@ import 'path';
5
5
  // src/ApiClient.ts
6
6
  // HTTP client for AsterMind backend API
7
7
  /**
8
- * Map backend source format to client Source interface
8
+ * Map backend source format to client Source interface.
9
+ * Handles both canonical (title/score) and legacy (heading/no-score) field names.
9
10
  */
10
11
  function mapSource(raw) {
11
12
  return {
12
- title: raw.title,
13
- snippet: raw.content,
14
- relevance: raw.score,
13
+ title: raw.title || raw.heading || 'Document',
14
+ snippet: raw.content || '',
15
+ relevance: raw.score ?? 0,
15
16
  documentId: raw.documentId,
16
17
  fullContent: raw.fullContent,
17
18
  downloadUrl: raw.downloadUrl,
@@ -49,9 +50,10 @@ class ApiClient {
49
50
  throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
50
51
  }
51
52
  const data = await response.json();
52
- // Map backend source format to client Source interface
53
+ // Normalize response: accept both 'reply' (canonical) and 'answer' (legacy)
53
54
  return {
54
55
  ...data,
56
+ reply: data.reply || data.answer || '',
55
57
  sources: (data.sources || []).map(mapSource)
56
58
  };
57
59
  }
@@ -127,7 +129,8 @@ class ApiClient {
127
129
  }
128
130
  }
129
131
  /**
130
- * Get general documents for caching
132
+ * Get general documents for caching.
133
+ * Normalizes field names from both canonical (title) and legacy (name) formats.
131
134
  */
132
135
  async getGeneralDocs(since) {
133
136
  const params = new URLSearchParams();
@@ -144,10 +147,16 @@ class ApiClient {
144
147
  throw new Error(`HTTP ${response.status}`);
145
148
  }
146
149
  const data = await response.json();
147
- return data.documents;
150
+ // Normalize: accept both 'title' (canonical) and 'name' (legacy on-prem)
151
+ return (data.documents || []).map((doc) => ({
152
+ id: doc.id,
153
+ title: doc.title || doc.name || 'Untitled',
154
+ updatedAt: doc.updatedAt || doc.updated_at || new Date().toISOString(),
155
+ }));
148
156
  }
149
157
  /**
150
- * Get API status, quota, and system settings
158
+ * Get API status, quota, and system settings.
159
+ * Normalizes response from both canonical and legacy backend formats.
151
160
  */
152
161
  async getStatus() {
153
162
  const response = await fetch(`${this.baseUrl}/api/external/status`, {
@@ -158,7 +167,38 @@ class ApiClient {
158
167
  if (!response.ok) {
159
168
  throw new Error(`HTTP ${response.status}`);
160
169
  }
161
- return response.json();
170
+ const data = await response.json();
171
+ // Normalize: accept both canonical (apiKey) and legacy (key) structures
172
+ const apiKeyData = data.apiKey || data.key || {};
173
+ const scopes = apiKeyData.scopes || apiKeyData.permissions || ['chat'];
174
+ // Normalize quota: accept both canonical (perMinute/perDay) and legacy structures
175
+ const quota = data.quota || {};
176
+ const perMinute = quota.perMinute || {
177
+ limit: apiKeyData.rateLimit?.limit || quota.rateLimitPerMinute || 30,
178
+ remaining: apiKeyData.rateLimit?.remaining ?? quota.rateLimitPerMinute ?? 30,
179
+ };
180
+ const perDay = quota.perDay || {
181
+ limit: quota.rateLimitPerDay || perMinute.limit * 60 * 24,
182
+ remaining: quota.rateLimitPerDay || perMinute.limit * 60 * 24,
183
+ };
184
+ // Normalize systemSettings: accept both canonical and legacy (system) structures
185
+ const settings = data.systemSettings || data.system || {};
186
+ const cacheRetentionHours = settings.cacheRetentionHours ??
187
+ (settings.cacheRetentionDays ? settings.cacheRetentionDays * 24 : undefined);
188
+ return {
189
+ status: data.status || 'active',
190
+ apiKey: {
191
+ id: apiKeyData.id || '',
192
+ scopes,
193
+ },
194
+ quota: { perMinute, perDay },
195
+ systemSettings: {
196
+ cacheRetentionHours: cacheRetentionHours ?? 720,
197
+ maintenanceMode: settings.maintenanceMode ?? false,
198
+ maintenanceMessage: settings.maintenanceMessage,
199
+ forceOfflineClients: settings.forceOfflineClients ?? false,
200
+ },
201
+ };
162
202
  }
163
203
  /**
164
204
  * Health check (no auth required)
@@ -993,26 +1033,624 @@ class CyberneticOfflineStorage {
993
1033
  await db.clear('vectors');
994
1034
  console.log('[CyberneticOfflineStorage] Cache cleared');
995
1035
  }
996
- /**
997
- * Get the database connection
998
- */
999
- async getDB() {
1000
- if (this.db) {
1001
- return this.db;
1002
- }
1003
- return this.initDB();
1036
+ /**
1037
+ * Get the database connection
1038
+ */
1039
+ async getDB() {
1040
+ if (this.db) {
1041
+ return this.db;
1042
+ }
1043
+ return this.initDB();
1044
+ }
1045
+ /**
1046
+ * Close database connection
1047
+ */
1048
+ close() {
1049
+ if (this.db) {
1050
+ this.db.close();
1051
+ this.db = null;
1052
+ this.dbPromise = null;
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ // src/WebSocketTransport.ts
1058
+ // WebSocket transport for streaming chat via SaaS WebSocket API (chatws.astermind.ai)
1059
+ /**
1060
+ * WebSocket transport for streaming chat.
1061
+ *
1062
+ * Maps the chat-handler Lambda WebSocket protocol to the client's StreamCallbacks:
1063
+ * Server typing → ignored (UI has its own indicator)
1064
+ * Server chunk → onToken(content)
1065
+ * Server done → onSources(sources) + onComplete(response)
1066
+ * Server error → onError(error)
1067
+ *
1068
+ * Connection is lazy — established on the first chatStream() call.
1069
+ * Reuses the same connection across multiple messages.
1070
+ * Reconnects with exponential backoff on close/error.
1071
+ */
1072
+ class WebSocketTransport {
1073
+ constructor(wsUrl, apiKey, options) {
1074
+ this.ws = null;
1075
+ this.state = 'disconnected';
1076
+ this.reconnectAttempts = 0;
1077
+ this.connectionPromise = null;
1078
+ // Active streaming request tracking
1079
+ this.activeCallbacks = null;
1080
+ this.activeFullText = '';
1081
+ // Cleanup handler reference
1082
+ this.beforeUnloadHandler = null;
1083
+ // Promise callbacks for the active stream
1084
+ this._resolveStream = null;
1085
+ this._rejectStream = null;
1086
+ this.wsUrl = wsUrl;
1087
+ this.apiKey = apiKey;
1088
+ this.maxReconnectAttempts = options?.maxReconnectAttempts ?? 3;
1089
+ this.reconnectDelay = options?.reconnectDelay ?? 1000;
1090
+ this.connectionTimeout = options?.connectionTimeout ?? 10000;
1091
+ }
1092
+ /**
1093
+ * Current transport state
1094
+ */
1095
+ getState() {
1096
+ return this.state;
1097
+ }
1098
+ /**
1099
+ * Whether the WebSocket is currently connected
1100
+ */
1101
+ isConnected() {
1102
+ return this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN;
1103
+ }
1104
+ /**
1105
+ * Connect to the WebSocket endpoint.
1106
+ * Appends the API key as a query parameter for authentication.
1107
+ * Returns a promise that resolves when the connection is open.
1108
+ */
1109
+ connect() {
1110
+ // Already connected
1111
+ if (this.isConnected()) {
1112
+ return Promise.resolve();
1113
+ }
1114
+ // Connection already in progress
1115
+ if (this.connectionPromise) {
1116
+ return this.connectionPromise;
1117
+ }
1118
+ this.state = 'connecting';
1119
+ this.connectionPromise = new Promise((resolve, reject) => {
1120
+ try {
1121
+ // Build URL with API key
1122
+ const separator = this.wsUrl.includes('?') ? '&' : '?';
1123
+ const fullUrl = `${this.wsUrl}${separator}apiKey=${encodeURIComponent(this.apiKey)}`;
1124
+ const ws = new WebSocket(fullUrl);
1125
+ this.ws = ws;
1126
+ // Connection timeout
1127
+ const timeout = setTimeout(() => {
1128
+ if (ws.readyState !== WebSocket.OPEN) {
1129
+ ws.close();
1130
+ this.state = 'error';
1131
+ this.connectionPromise = null;
1132
+ reject(new Error('WebSocket connection timed out'));
1133
+ }
1134
+ }, this.connectionTimeout);
1135
+ ws.onopen = () => {
1136
+ clearTimeout(timeout);
1137
+ this.state = 'connected';
1138
+ this.reconnectAttempts = 0;
1139
+ this.connectionPromise = null;
1140
+ this.registerCleanup();
1141
+ resolve();
1142
+ };
1143
+ ws.onmessage = (event) => {
1144
+ this.handleMessage(event);
1145
+ };
1146
+ ws.onerror = () => {
1147
+ clearTimeout(timeout);
1148
+ this.state = 'error';
1149
+ this.connectionPromise = null;
1150
+ // The close event fires after error, which will trigger reconnect
1151
+ };
1152
+ ws.onclose = (event) => {
1153
+ clearTimeout(timeout);
1154
+ const wasConnected = this.state === 'connected';
1155
+ this.state = 'disconnected';
1156
+ this.connectionPromise = null;
1157
+ this.unregisterCleanup();
1158
+ // If we had an active request, report the error
1159
+ if (this.activeCallbacks) {
1160
+ this.activeCallbacks.onError?.({
1161
+ code: 'WS_ERROR',
1162
+ message: event.reason || `WebSocket closed (code: ${event.code})`
1163
+ });
1164
+ this.clearActiveRequest();
1165
+ }
1166
+ // Reject if we were trying to connect
1167
+ if (!wasConnected) {
1168
+ reject(new Error(event.reason || `WebSocket closed (code: ${event.code})`));
1169
+ }
1170
+ };
1171
+ }
1172
+ catch (error) {
1173
+ this.state = 'error';
1174
+ this.connectionPromise = null;
1175
+ reject(error);
1176
+ }
1177
+ });
1178
+ return this.connectionPromise;
1179
+ }
1180
+ /**
1181
+ * Stream a chat message over WebSocket.
1182
+ *
1183
+ * Sends: { type: "sendMessage", message, sessionId?, wordLimit?, context? }
1184
+ * Receives: typing → chunk × N → done (with sources and metadata)
1185
+ */
1186
+ async chatStream(message, options) {
1187
+ const { sessionId, context, wordLimit, onToken, onSources, onComplete, onError } = options;
1188
+ // Ensure connection is established (lazy connect)
1189
+ try {
1190
+ await this.connect();
1191
+ }
1192
+ catch (error) {
1193
+ onError?.({
1194
+ code: 'WS_ERROR',
1195
+ message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`
1196
+ });
1197
+ throw error;
1198
+ }
1199
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1200
+ const err = { code: 'WS_ERROR', message: 'WebSocket not connected' };
1201
+ onError?.(err);
1202
+ throw new Error(err.message);
1203
+ }
1204
+ // Set up active request tracking
1205
+ this.activeCallbacks = { onToken, onSources, onComplete, onError };
1206
+ this.activeFullText = '';
1207
+ this.activeSessionId = sessionId;
1208
+ // Build and send the message
1209
+ const payload = {
1210
+ type: 'sendMessage',
1211
+ message,
1212
+ };
1213
+ if (sessionId)
1214
+ payload.sessionId = sessionId;
1215
+ if (wordLimit)
1216
+ payload.wordLimit = wordLimit;
1217
+ if (context)
1218
+ payload.context = context;
1219
+ // Return a promise that resolves when done/error is received
1220
+ return new Promise((resolve, reject) => {
1221
+ // Store resolve/reject so handleMessage can call them
1222
+ this._resolveStream = resolve;
1223
+ this._rejectStream = reject;
1224
+ this.ws.send(JSON.stringify(payload));
1225
+ });
1226
+ }
1227
+ /**
1228
+ * Disconnect and clean up the WebSocket connection
1229
+ */
1230
+ disconnect() {
1231
+ this.unregisterCleanup();
1232
+ if (this.ws) {
1233
+ // Remove handlers to avoid triggering reconnect
1234
+ this.ws.onclose = null;
1235
+ this.ws.onerror = null;
1236
+ this.ws.onmessage = null;
1237
+ this.ws.close();
1238
+ this.ws = null;
1239
+ }
1240
+ this.state = 'disconnected';
1241
+ this.connectionPromise = null;
1242
+ this.clearActiveRequest();
1243
+ }
1244
+ /**
1245
+ * Attempt reconnection with exponential backoff.
1246
+ * Used internally after connection loss.
1247
+ */
1248
+ async reconnect() {
1249
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1250
+ this.state = 'error';
1251
+ throw new Error(`Max reconnection attempts (${this.maxReconnectAttempts}) exceeded`);
1252
+ }
1253
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
1254
+ this.reconnectAttempts++;
1255
+ console.log(`[Cybernetic WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
1256
+ await new Promise(resolve => setTimeout(resolve, delay));
1257
+ // Ensure old connection is cleaned up
1258
+ if (this.ws) {
1259
+ this.ws.onclose = null;
1260
+ this.ws.onerror = null;
1261
+ this.ws.close();
1262
+ this.ws = null;
1263
+ }
1264
+ return this.connect();
1265
+ }
1266
+ // ==================== INTERNAL ====================
1267
+ /**
1268
+ * Handle incoming WebSocket messages from the chat-handler Lambda.
1269
+ *
1270
+ * Protocol:
1271
+ * { type: 'typing', status: boolean } → ignored
1272
+ * { type: 'chunk', content: string } → onToken(content)
1273
+ * { type: 'done', sessionId, sources, metadata, messageId } → onSources + onComplete
1274
+ * { type: 'error', error: string } → onError
1275
+ */
1276
+ handleMessage(event) {
1277
+ if (!this.activeCallbacks)
1278
+ return;
1279
+ try {
1280
+ const data = JSON.parse(event.data);
1281
+ switch (data.type) {
1282
+ case 'typing':
1283
+ // Ignored — the chatbot-template manages its own typing indicator
1284
+ break;
1285
+ case 'chunk':
1286
+ if (data.content) {
1287
+ this.activeFullText += data.content;
1288
+ this.activeCallbacks.onToken?.(data.content);
1289
+ }
1290
+ break;
1291
+ case 'done': {
1292
+ // Map sources from chat-handler format to client Source format
1293
+ const sources = this.mapSources(data.sources || []);
1294
+ // Emit sources
1295
+ this.activeCallbacks.onSources?.(sources);
1296
+ // Build complete response
1297
+ const response = {
1298
+ reply: this.activeFullText,
1299
+ confidence: 'high',
1300
+ sources,
1301
+ offline: false,
1302
+ sessionId: data.sessionId || this.activeSessionId,
1303
+ };
1304
+ this.activeCallbacks.onComplete?.(response);
1305
+ this._resolveStream?.();
1306
+ this.clearActiveRequest();
1307
+ break;
1308
+ }
1309
+ case 'error': {
1310
+ const err = {
1311
+ code: 'WS_ERROR',
1312
+ message: data.error || 'Unknown WebSocket error'
1313
+ };
1314
+ this.activeCallbacks.onError?.(err);
1315
+ this._rejectStream?.(new Error(err.message));
1316
+ this.clearActiveRequest();
1317
+ break;
1318
+ }
1319
+ default:
1320
+ // Unknown message type — log and ignore
1321
+ console.warn('[Cybernetic WS] Unknown message type:', data.type);
1322
+ break;
1323
+ }
1324
+ }
1325
+ catch (error) {
1326
+ console.error('[Cybernetic WS] Failed to parse message:', error, event.data);
1327
+ }
1328
+ }
1329
+ /**
1330
+ * Map sources from the chat-handler WebSocket format to the client Source interface.
1331
+ *
1332
+ * Chat-handler sends: { heading, content, score, documentId, documentName }
1333
+ * Client expects: { title, snippet, relevance, documentId, documentName }
1334
+ */
1335
+ mapSources(wsSources) {
1336
+ if (!Array.isArray(wsSources))
1337
+ return [];
1338
+ return wsSources.map((s) => ({
1339
+ title: s.title || s.heading || s.documentName || 'Document',
1340
+ snippet: s.content || '',
1341
+ relevance: s.score ?? 0,
1342
+ documentId: s.documentId,
1343
+ documentName: s.documentName,
1344
+ sourceType: s.sourceType || 'document',
1345
+ chunkId: s.chunkId,
1346
+ }));
1347
+ }
1348
+ /**
1349
+ * Clear active request state after completion or error
1350
+ */
1351
+ clearActiveRequest() {
1352
+ this.activeCallbacks = null;
1353
+ this.activeFullText = '';
1354
+ this._resolveStream = null;
1355
+ this._rejectStream = null;
1356
+ }
1357
+ /**
1358
+ * Register a beforeunload handler to close the WebSocket on page unload
1359
+ */
1360
+ registerCleanup() {
1361
+ if (typeof window !== 'undefined' && !this.beforeUnloadHandler) {
1362
+ this.beforeUnloadHandler = () => this.disconnect();
1363
+ window.addEventListener('beforeunload', this.beforeUnloadHandler);
1364
+ }
1365
+ }
1366
+ /**
1367
+ * Remove the beforeunload handler
1368
+ */
1369
+ unregisterCleanup() {
1370
+ if (typeof window !== 'undefined' && this.beforeUnloadHandler) {
1371
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
1372
+ this.beforeUnloadHandler = null;
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ // src/config.ts
1378
+ // Configuration loading and validation
1379
+ /** Default API URL when not specified */
1380
+ const DEFAULT_API_URL = 'https://api.astermind.ai';
1381
+ /**
1382
+ * Derive WebSocket URL from API URL for known SaaS domains.
1383
+ * Maps REST API domains to their WebSocket counterparts:
1384
+ * chatapi.astermind.ai → wss://chatws.astermind.ai
1385
+ * chatapi-dev.astermind.ai → wss://chatws-dev.astermind.ai
1386
+ * api.astermind.ai → wss://chatws.astermind.ai
1387
+ *
1388
+ * Returns undefined for non-SaaS (on-prem) URLs.
1389
+ */
1390
+ function deriveWsUrl(apiUrl) {
1391
+ try {
1392
+ const url = new URL(apiUrl);
1393
+ // chatapi[-env].astermind.ai → chatws[-env].astermind.ai
1394
+ const match = url.hostname.match(/^chatapi(-[\w]+)?\.astermind\.ai$/);
1395
+ if (match) {
1396
+ const envSuffix = match[1] || '';
1397
+ return `wss://chatws${envSuffix}.astermind.ai`;
1398
+ }
1399
+ // api.astermind.ai → chatws.astermind.ai (default SaaS)
1400
+ if (url.hostname === 'api.astermind.ai') {
1401
+ return 'wss://chatws.astermind.ai';
1402
+ }
1403
+ }
1404
+ catch {
1405
+ // Invalid URL, no WS derivation
1406
+ }
1407
+ return undefined;
1408
+ }
1409
+ /**
1410
+ * Try to load wsUrl from environment or globals.
1411
+ * Returns the first found wsUrl or undefined.
1412
+ */
1413
+ function loadWsUrl() {
1414
+ // Vite env var
1415
+ try {
1416
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1417
+ const importMeta = globalThis.import?.meta?.env;
1418
+ if (importMeta?.VITE_ASTERMIND_RAG_WS_URL) {
1419
+ return importMeta.VITE_ASTERMIND_RAG_WS_URL;
1420
+ }
1421
+ }
1422
+ catch { /* not available */ }
1423
+ // Node.js / CRA env var
1424
+ if (typeof process !== 'undefined' && process.env) {
1425
+ if (process.env.ASTERMIND_RAG_WS_URL)
1426
+ return process.env.ASTERMIND_RAG_WS_URL;
1427
+ if (process.env.REACT_APP_ASTERMIND_RAG_WS_URL)
1428
+ return process.env.REACT_APP_ASTERMIND_RAG_WS_URL;
1429
+ }
1430
+ // SSR-injected config
1431
+ if (typeof window !== 'undefined') {
1432
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1433
+ const injected = window.__ASTERMIND_CONFIG__;
1434
+ if (injected?.wsUrl)
1435
+ return injected.wsUrl;
1436
+ // Global config object
1437
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1438
+ const globalConfig = window.astermindConfig;
1439
+ if (globalConfig?.wsUrl)
1440
+ return globalConfig.wsUrl;
1441
+ }
1442
+ // Script data attribute
1443
+ if (typeof document !== 'undefined') {
1444
+ const script = document.querySelector('script[data-astermind-ws-url]');
1445
+ if (script) {
1446
+ const wsUrl = script.getAttribute('data-astermind-ws-url');
1447
+ if (wsUrl)
1448
+ return wsUrl;
1449
+ }
1450
+ }
1451
+ return undefined;
1452
+ }
1453
+ /**
1454
+ * Resolve the WebSocket URL for a given config.
1455
+ * Priority: explicit wsUrl → env var → auto-derived from apiUrl
1456
+ */
1457
+ function resolveWsUrl(config) {
1458
+ // Explicit wsUrl takes priority
1459
+ if (config.wsUrl)
1460
+ return config.wsUrl;
1461
+ // Don't auto-detect if transport is forced to REST
1462
+ if (config.transport === 'rest')
1463
+ return undefined;
1464
+ // Try environment / globals
1465
+ const envWsUrl = loadWsUrl();
1466
+ if (envWsUrl)
1467
+ return envWsUrl;
1468
+ // Auto-derive from apiUrl for known SaaS domains
1469
+ return deriveWsUrl(config.apiUrl);
1470
+ }
1471
+ /**
1472
+ * Validate configuration
1473
+ */
1474
+ function validateConfig(config) {
1475
+ if (!config || typeof config !== 'object') {
1476
+ throw new Error('Config must be an object');
1477
+ }
1478
+ const c = config;
1479
+ if (!c.apiUrl || typeof c.apiUrl !== 'string') {
1480
+ throw new Error('apiUrl is required and must be a string');
1481
+ }
1482
+ if (!c.apiKey || typeof c.apiKey !== 'string') {
1483
+ throw new Error('apiKey is required and must be a string');
1484
+ }
1485
+ if (!c.apiKey.startsWith('am_')) {
1486
+ throw new Error('apiKey must start with "am_"');
1487
+ }
1488
+ return true;
1489
+ }
1490
+ /**
1491
+ * Load config from Node.js process.env (for bundlers that replace process.env)
1492
+ */
1493
+ function loadFromProcessEnv() {
1494
+ // Check if process.env exists (Node.js or bundler-injected)
1495
+ if (typeof process === 'undefined' || !process.env) {
1496
+ return null;
1497
+ }
1498
+ // Check for ASTERMIND_RAG_* env vars (non-prefixed, for Node.js/server environments)
1499
+ const apiKey = process.env.ASTERMIND_RAG_API_KEY;
1500
+ const apiUrl = process.env.ASTERMIND_RAG_API_SERVER_URL;
1501
+ if (apiKey) {
1502
+ return {
1503
+ apiKey,
1504
+ apiUrl: apiUrl || DEFAULT_API_URL,
1505
+ _source: 'env'
1506
+ };
1507
+ }
1508
+ // Check for CRA-style REACT_APP_* env vars
1509
+ const craApiKey = process.env.REACT_APP_ASTERMIND_RAG_API_KEY;
1510
+ const craApiUrl = process.env.REACT_APP_ASTERMIND_RAG_API_SERVER_URL;
1511
+ if (craApiKey) {
1512
+ return {
1513
+ apiKey: craApiKey,
1514
+ apiUrl: craApiUrl || DEFAULT_API_URL,
1515
+ _source: 'env'
1516
+ };
1517
+ }
1518
+ return null;
1519
+ }
1520
+ /**
1521
+ * Load config from Vite's import.meta.env (browser environment)
1522
+ * Note: This only works at build time when Vite replaces the variables
1523
+ */
1524
+ function loadFromViteEnv() {
1525
+ // Check for window.__ASTERMIND_CONFIG__ (SSR-injected config)
1526
+ if (typeof window !== 'undefined') {
1527
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1528
+ const injected = window.__ASTERMIND_CONFIG__;
1529
+ if (injected && typeof injected === 'object' && injected.apiKey) {
1530
+ return {
1531
+ ...injected,
1532
+ apiUrl: injected.apiUrl || DEFAULT_API_URL,
1533
+ _source: 'vite'
1534
+ };
1535
+ }
1536
+ }
1537
+ // Check for Vite's import.meta.env (replaced at build time)
1538
+ // Note: TypeScript doesn't know about import.meta.env, so we use a try-catch
1539
+ try {
1540
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1541
+ const importMeta = globalThis.import?.meta?.env;
1542
+ if (importMeta) {
1543
+ const apiKey = importMeta.VITE_ASTERMIND_RAG_API_KEY;
1544
+ const apiUrl = importMeta.VITE_ASTERMIND_RAG_API_SERVER_URL;
1545
+ if (apiKey) {
1546
+ return {
1547
+ apiKey,
1548
+ apiUrl: apiUrl || DEFAULT_API_URL,
1549
+ _source: 'vite'
1550
+ };
1551
+ }
1552
+ }
1553
+ }
1554
+ catch {
1555
+ // import.meta not available in this environment
1556
+ }
1557
+ return null;
1558
+ }
1559
+ /**
1560
+ * Load config from window.astermindConfig global object
1561
+ */
1562
+ function loadFromGlobalObject() {
1563
+ if (typeof window === 'undefined') {
1564
+ return null;
1565
+ }
1566
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1567
+ const globalConfig = window.astermindConfig;
1568
+ if (globalConfig && typeof globalConfig === 'object' && globalConfig.apiKey) {
1569
+ return {
1570
+ ...globalConfig,
1571
+ apiUrl: globalConfig.apiUrl || DEFAULT_API_URL,
1572
+ _source: 'window'
1573
+ };
1574
+ }
1575
+ return null;
1576
+ }
1577
+ /**
1578
+ * Load config from script tag data attributes
1579
+ */
1580
+ function loadFromScriptAttributes() {
1581
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
1582
+ return null;
1583
+ }
1584
+ const script = document.querySelector('script[data-astermind-key]');
1585
+ if (script) {
1586
+ const apiKey = script.getAttribute('data-astermind-key');
1587
+ if (apiKey) {
1588
+ return {
1589
+ apiKey,
1590
+ apiUrl: script.getAttribute('data-astermind-url') || DEFAULT_API_URL,
1591
+ _source: 'data-attr'
1592
+ };
1593
+ }
1594
+ }
1595
+ return null;
1596
+ }
1597
+ /**
1598
+ * Load config using priority-based fallback chain:
1599
+ * 1. Environment variables (process.env - for bundlers/Node.js)
1600
+ * 2. Vite environment variables (import.meta.env or window.__ASTERMIND_CONFIG__)
1601
+ * 3. Global object (window.astermindConfig)
1602
+ * 4. Script data attributes (data-astermind-key, data-astermind-url)
1603
+ *
1604
+ * @param options - Configuration options
1605
+ * @param options.throwOnMissingKey - If true (default), throws when no API key found. If false, returns null and logs a warning.
1606
+ * @returns Configuration object or null if not found and throwOnMissingKey is false
1607
+ */
1608
+ function loadConfig(options = {}) {
1609
+ const { throwOnMissingKey = true } = options;
1610
+ // Priority 1: Environment variables (Node.js/bundler)
1611
+ const envConfig = loadFromProcessEnv();
1612
+ if (envConfig) {
1613
+ return envConfig;
1614
+ }
1615
+ // Priority 2: Vite environment variables / SSR-injected config
1616
+ const viteConfig = loadFromViteEnv();
1617
+ if (viteConfig) {
1618
+ return viteConfig;
1619
+ }
1620
+ // Priority 3: Global object (window.astermindConfig)
1621
+ const globalConfig = loadFromGlobalObject();
1622
+ if (globalConfig) {
1623
+ validateConfig(globalConfig);
1624
+ return globalConfig;
1625
+ }
1626
+ // Priority 4: Script data attributes
1627
+ const scriptConfig = loadFromScriptAttributes();
1628
+ if (scriptConfig) {
1629
+ return scriptConfig;
1004
1630
  }
1005
- /**
1006
- * Close database connection
1007
- */
1008
- close() {
1009
- if (this.db) {
1010
- this.db.close();
1011
- this.db = null;
1012
- this.dbPromise = null;
1013
- }
1631
+ // No config found
1632
+ if (throwOnMissingKey) {
1633
+ throw new Error('AsterMind API key is required. Configure using one of these methods:\n' +
1634
+ ' 1. Set VITE_ASTERMIND_RAG_API_KEY environment variable (Vite)\n' +
1635
+ ' 2. Set REACT_APP_ASTERMIND_RAG_API_KEY environment variable (CRA)\n' +
1636
+ ' 3. Set window.astermindConfig = { apiKey: "am_...", apiUrl: "..." }\n' +
1637
+ ' 4. Add data-astermind-key attribute to your script tag\n' +
1638
+ ' 5. Pass apiKey directly to createClient() or CyberneticClient constructor');
1639
+ }
1640
+ else {
1641
+ console.warn('[AsterMind] No API key found. Chatbot will not function until configured.');
1642
+ return null;
1014
1643
  }
1015
1644
  }
1645
+ /**
1646
+ * Export individual loaders for testing and advanced use cases
1647
+ */
1648
+ const configLoaders = {
1649
+ loadFromProcessEnv,
1650
+ loadFromViteEnv,
1651
+ loadFromGlobalObject,
1652
+ loadFromScriptAttributes
1653
+ };
1016
1654
 
1017
1655
  // src/license/base64url.ts
1018
1656
  // Base64URL encoding/decoding utilities for JWT handling
@@ -1649,6 +2287,7 @@ async function createLicenseManager(config) {
1649
2287
  */
1650
2288
  class CyberneticClient {
1651
2289
  constructor(config) {
2290
+ this.wsTransport = null;
1652
2291
  this.status = 'connecting';
1653
2292
  this.lastError = null;
1654
2293
  // Maintenance mode tracking (ADR-200)
@@ -1663,10 +2302,15 @@ class CyberneticClient {
1663
2302
  this.omegaRAG = null;
1664
2303
  // Track if offline warning has been shown
1665
2304
  this.offlineWarningShown = false;
2305
+ // Resolve WebSocket URL (explicit → env → auto-derived from apiUrl)
2306
+ const wsUrl = resolveWsUrl(config);
2307
+ const transport = config.transport ?? 'auto';
1666
2308
  // Apply defaults
1667
2309
  this.config = {
1668
2310
  apiUrl: config.apiUrl,
1669
2311
  apiKey: config.apiKey,
2312
+ wsUrl,
2313
+ transport,
1670
2314
  fallback: {
1671
2315
  enabled: config.fallback?.enabled ?? true,
1672
2316
  cacheMaxAge: config.fallback?.cacheMaxAge ?? 86400000, // 24 hours
@@ -1709,6 +2353,11 @@ class CyberneticClient {
1709
2353
  }).catch(() => {
1710
2354
  // License verification errors are handled internally
1711
2355
  });
2356
+ // Initialize WebSocket transport if configured and not forced to REST
2357
+ if (wsUrl && transport !== 'rest' && typeof WebSocket !== 'undefined') {
2358
+ this.wsTransport = new WebSocketTransport(wsUrl, config.apiKey, config.websocket);
2359
+ console.log(`[Cybernetic] WebSocket transport enabled: ${wsUrl}`);
2360
+ }
1712
2361
  // Monitor connection status
1713
2362
  this.monitorConnection();
1714
2363
  // Pre-cache documents on init if enabled
@@ -2087,6 +2736,60 @@ class CyberneticClient {
2087
2736
  callbacks.onComplete?.(response);
2088
2737
  return;
2089
2738
  }
2739
+ // Try WebSocket transport first (if available and not forced to REST)
2740
+ if (this.wsTransport && this.config.transport !== 'rest') {
2741
+ try {
2742
+ await this.wsTransport.chatStream(message, {
2743
+ sessionId: options?.sessionId,
2744
+ context: options?.context,
2745
+ onToken: callbacks.onToken,
2746
+ onSources: callbacks.onSources,
2747
+ onComplete: (response) => {
2748
+ this.setStatus('online');
2749
+ // Process through license manager
2750
+ const processedReply = this.licenseManager.processResponse(response.reply);
2751
+ callbacks.onComplete?.({
2752
+ ...response,
2753
+ reply: processedReply
2754
+ });
2755
+ },
2756
+ onError: (error) => {
2757
+ // In 'auto' mode, fall back to SSE on WS error
2758
+ if (this.config.transport === 'auto') {
2759
+ console.warn('[Cybernetic] WebSocket error, falling back to SSE:', error.message);
2760
+ this.streamViaSSE(message, callbacks, options);
2761
+ }
2762
+ else {
2763
+ // 'websocket' mode — no fallback
2764
+ const normalizedError = this.normalizeError(error);
2765
+ this.config.onError(normalizedError);
2766
+ callbacks.onError?.(normalizedError);
2767
+ }
2768
+ }
2769
+ });
2770
+ return;
2771
+ }
2772
+ catch (error) {
2773
+ if (this.config.transport === 'websocket') {
2774
+ // Forced WebSocket mode — don't fall back
2775
+ const normalizedError = this.normalizeError(error);
2776
+ this.lastError = normalizedError;
2777
+ this.config.onError(normalizedError);
2778
+ callbacks.onError?.(normalizedError);
2779
+ return;
2780
+ }
2781
+ // 'auto' mode: fall through to SSE
2782
+ console.warn('[Cybernetic] WebSocket connect failed, using SSE fallback');
2783
+ }
2784
+ }
2785
+ // REST+SSE path (on-prem, or fallback from WebSocket)
2786
+ await this.streamViaSSE(message, callbacks, options);
2787
+ }
2788
+ /**
2789
+ * Stream chat via REST+SSE (original transport).
2790
+ * Used as the primary transport for on-prem, or as fallback for SaaS WebSocket failures.
2791
+ */
2792
+ async streamViaSSE(message, callbacks, options) {
2090
2793
  try {
2091
2794
  await this.apiClient.chatStream(message, {
2092
2795
  sessionId: options?.sessionId,
@@ -2170,6 +2873,16 @@ class CyberneticClient {
2170
2873
  await this.cache.clear();
2171
2874
  this.localRAG.reset();
2172
2875
  }
2876
+ /**
2877
+ * Clean up all resources (WebSocket connection, caches, event listeners).
2878
+ * Call this when the client is no longer needed to prevent memory leaks.
2879
+ */
2880
+ destroy() {
2881
+ if (this.wsTransport) {
2882
+ this.wsTransport.disconnect();
2883
+ this.wsTransport = null;
2884
+ }
2885
+ }
2173
2886
  /**
2174
2887
  * Manually check if backend is reachable
2175
2888
  */
@@ -2480,194 +3193,6 @@ class CyberneticClient {
2480
3193
  }
2481
3194
  }
2482
3195
 
2483
- // src/config.ts
2484
- // Configuration loading and validation
2485
- /** Default API URL when not specified */
2486
- const DEFAULT_API_URL = 'https://api.astermind.ai';
2487
- /**
2488
- * Validate configuration
2489
- */
2490
- function validateConfig(config) {
2491
- if (!config || typeof config !== 'object') {
2492
- throw new Error('Config must be an object');
2493
- }
2494
- const c = config;
2495
- if (!c.apiUrl || typeof c.apiUrl !== 'string') {
2496
- throw new Error('apiUrl is required and must be a string');
2497
- }
2498
- if (!c.apiKey || typeof c.apiKey !== 'string') {
2499
- throw new Error('apiKey is required and must be a string');
2500
- }
2501
- if (!c.apiKey.startsWith('am_')) {
2502
- throw new Error('apiKey must start with "am_"');
2503
- }
2504
- return true;
2505
- }
2506
- /**
2507
- * Load config from Node.js process.env (for bundlers that replace process.env)
2508
- */
2509
- function loadFromProcessEnv() {
2510
- // Check if process.env exists (Node.js or bundler-injected)
2511
- if (typeof process === 'undefined' || !process.env) {
2512
- return null;
2513
- }
2514
- // Check for ASTERMIND_RAG_* env vars (non-prefixed, for Node.js/server environments)
2515
- const apiKey = process.env.ASTERMIND_RAG_API_KEY;
2516
- const apiUrl = process.env.ASTERMIND_RAG_API_SERVER_URL;
2517
- if (apiKey) {
2518
- return {
2519
- apiKey,
2520
- apiUrl: apiUrl || DEFAULT_API_URL,
2521
- _source: 'env'
2522
- };
2523
- }
2524
- // Check for CRA-style REACT_APP_* env vars
2525
- const craApiKey = process.env.REACT_APP_ASTERMIND_RAG_API_KEY;
2526
- const craApiUrl = process.env.REACT_APP_ASTERMIND_RAG_API_SERVER_URL;
2527
- if (craApiKey) {
2528
- return {
2529
- apiKey: craApiKey,
2530
- apiUrl: craApiUrl || DEFAULT_API_URL,
2531
- _source: 'env'
2532
- };
2533
- }
2534
- return null;
2535
- }
2536
- /**
2537
- * Load config from Vite's import.meta.env (browser environment)
2538
- * Note: This only works at build time when Vite replaces the variables
2539
- */
2540
- function loadFromViteEnv() {
2541
- // Check for window.__ASTERMIND_CONFIG__ (SSR-injected config)
2542
- if (typeof window !== 'undefined') {
2543
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2544
- const injected = window.__ASTERMIND_CONFIG__;
2545
- if (injected && typeof injected === 'object' && injected.apiKey) {
2546
- return {
2547
- ...injected,
2548
- apiUrl: injected.apiUrl || DEFAULT_API_URL,
2549
- _source: 'vite'
2550
- };
2551
- }
2552
- }
2553
- // Check for Vite's import.meta.env (replaced at build time)
2554
- // Note: TypeScript doesn't know about import.meta.env, so we use a try-catch
2555
- try {
2556
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2557
- const importMeta = globalThis.import?.meta?.env;
2558
- if (importMeta) {
2559
- const apiKey = importMeta.VITE_ASTERMIND_RAG_API_KEY;
2560
- const apiUrl = importMeta.VITE_ASTERMIND_RAG_API_SERVER_URL;
2561
- if (apiKey) {
2562
- return {
2563
- apiKey,
2564
- apiUrl: apiUrl || DEFAULT_API_URL,
2565
- _source: 'vite'
2566
- };
2567
- }
2568
- }
2569
- }
2570
- catch {
2571
- // import.meta not available in this environment
2572
- }
2573
- return null;
2574
- }
2575
- /**
2576
- * Load config from window.astermindConfig global object
2577
- */
2578
- function loadFromGlobalObject() {
2579
- if (typeof window === 'undefined') {
2580
- return null;
2581
- }
2582
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2583
- const globalConfig = window.astermindConfig;
2584
- if (globalConfig && typeof globalConfig === 'object' && globalConfig.apiKey) {
2585
- return {
2586
- ...globalConfig,
2587
- apiUrl: globalConfig.apiUrl || DEFAULT_API_URL,
2588
- _source: 'window'
2589
- };
2590
- }
2591
- return null;
2592
- }
2593
- /**
2594
- * Load config from script tag data attributes
2595
- */
2596
- function loadFromScriptAttributes() {
2597
- if (typeof window === 'undefined' || typeof document === 'undefined') {
2598
- return null;
2599
- }
2600
- const script = document.querySelector('script[data-astermind-key]');
2601
- if (script) {
2602
- const apiKey = script.getAttribute('data-astermind-key');
2603
- if (apiKey) {
2604
- return {
2605
- apiKey,
2606
- apiUrl: script.getAttribute('data-astermind-url') || DEFAULT_API_URL,
2607
- _source: 'data-attr'
2608
- };
2609
- }
2610
- }
2611
- return null;
2612
- }
2613
- /**
2614
- * Load config using priority-based fallback chain:
2615
- * 1. Environment variables (process.env - for bundlers/Node.js)
2616
- * 2. Vite environment variables (import.meta.env or window.__ASTERMIND_CONFIG__)
2617
- * 3. Global object (window.astermindConfig)
2618
- * 4. Script data attributes (data-astermind-key, data-astermind-url)
2619
- *
2620
- * @param options - Configuration options
2621
- * @param options.throwOnMissingKey - If true (default), throws when no API key found. If false, returns null and logs a warning.
2622
- * @returns Configuration object or null if not found and throwOnMissingKey is false
2623
- */
2624
- function loadConfig(options = {}) {
2625
- const { throwOnMissingKey = true } = options;
2626
- // Priority 1: Environment variables (Node.js/bundler)
2627
- const envConfig = loadFromProcessEnv();
2628
- if (envConfig) {
2629
- return envConfig;
2630
- }
2631
- // Priority 2: Vite environment variables / SSR-injected config
2632
- const viteConfig = loadFromViteEnv();
2633
- if (viteConfig) {
2634
- return viteConfig;
2635
- }
2636
- // Priority 3: Global object (window.astermindConfig)
2637
- const globalConfig = loadFromGlobalObject();
2638
- if (globalConfig) {
2639
- validateConfig(globalConfig);
2640
- return globalConfig;
2641
- }
2642
- // Priority 4: Script data attributes
2643
- const scriptConfig = loadFromScriptAttributes();
2644
- if (scriptConfig) {
2645
- return scriptConfig;
2646
- }
2647
- // No config found
2648
- if (throwOnMissingKey) {
2649
- throw new Error('AsterMind API key is required. Configure using one of these methods:\n' +
2650
- ' 1. Set VITE_ASTERMIND_RAG_API_KEY environment variable (Vite)\n' +
2651
- ' 2. Set REACT_APP_ASTERMIND_RAG_API_KEY environment variable (CRA)\n' +
2652
- ' 3. Set window.astermindConfig = { apiKey: "am_...", apiUrl: "..." }\n' +
2653
- ' 4. Add data-astermind-key attribute to your script tag\n' +
2654
- ' 5. Pass apiKey directly to createClient() or CyberneticClient constructor');
2655
- }
2656
- else {
2657
- console.warn('[AsterMind] No API key found. Chatbot will not function until configured.');
2658
- return null;
2659
- }
2660
- }
2661
- /**
2662
- * Export individual loaders for testing and advanced use cases
2663
- */
2664
- const configLoaders = {
2665
- loadFromProcessEnv,
2666
- loadFromViteEnv,
2667
- loadFromGlobalObject,
2668
- loadFromScriptAttributes
2669
- };
2670
-
2671
3196
  // src/agentic/SiteMapDiscovery.ts
2672
3197
  // Multi-source sitemap discovery and merging for agentic navigation
2673
3198
  // Zero-config mode: automatically discovers routes on any site
@@ -6652,5 +7177,5 @@ function createClient(config) {
6652
7177
  return new CyberneticClient(config);
6653
7178
  }
6654
7179
 
6655
- export { ApiClient, CyberneticAgent, CyberneticCache, CyberneticClient, CyberneticIntentClassifier, CyberneticLocalRAG, CyberneticOfflineStorage, LicenseManager, OmegaOfflineRAG, REQUIRED_FEATURES, SiteMapDiscovery, configLoaders, createClient, createDiscoveryConfig, createLicenseManager, detectEnvironment, getEnforcementMode, getTokenExpiration, isValidJWTFormat, loadConfig, registerAgenticCapabilities, useSiteMapDiscovery, validateConfig, verifyLicenseToken };
7180
+ export { ApiClient, CyberneticAgent, CyberneticCache, CyberneticClient, CyberneticIntentClassifier, CyberneticLocalRAG, CyberneticOfflineStorage, LicenseManager, OmegaOfflineRAG, REQUIRED_FEATURES, SiteMapDiscovery, WebSocketTransport, configLoaders, createClient, createDiscoveryConfig, createLicenseManager, deriveWsUrl, detectEnvironment, getEnforcementMode, getTokenExpiration, isValidJWTFormat, loadConfig, registerAgenticCapabilities, resolveWsUrl, useSiteMapDiscovery, validateConfig, verifyLicenseToken };
6656
7181
  //# sourceMappingURL=cybernetic-chatbot-client.esm.js.map