@ekodb/ekodb-client 0.20.0 → 0.21.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/dist/client.js CHANGED
@@ -402,8 +402,8 @@ class EkoDBClient {
402
402
  params.append("transaction_id", options.transactionId);
403
403
  }
404
404
  const url = params.toString()
405
- ? `/api/insert/${collection}?${params.toString()}`
406
- : `/api/insert/${collection}`;
405
+ ? `/api/insert/${encodeURIComponent(collection)}?${params.toString()}`
406
+ : `/api/insert/${encodeURIComponent(collection)}`;
407
407
  return this.makeRequest("POST", url, data);
408
408
  }
409
409
  /**
@@ -428,15 +428,57 @@ class EkoDBClient {
428
428
  * const results = await client.find("users", { limit: 10 });
429
429
  * ```
430
430
  */
431
- async find(collection, query = {}) {
431
+ async find(collection, query = {}, options) {
432
432
  const queryObj = query instanceof query_builder_1.QueryBuilder ? query.build() : query;
433
- return this.makeRequest("POST", `/api/find/${collection}`, queryObj);
433
+ // bypass_ripple and transaction_id are query parameters — the same way every
434
+ // other method (insert/update/findById) carries bypass_ripple — not part of
435
+ // the FindBody. Hoist any bypass_ripple carried on the query object (e.g. from
436
+ // QueryBuilder.bypassRipple()) out of the body so it is ALWAYS sent as a query
437
+ // param; an explicit options.bypassRipple wins.
438
+ let body = queryObj;
439
+ let bypassRipple = options?.bypassRipple;
440
+ if (body && typeof body === "object" && "bypass_ripple" in body) {
441
+ const { bypass_ripple, ...rest } = body;
442
+ body = rest;
443
+ if (bypassRipple === undefined)
444
+ bypassRipple = bypass_ripple;
445
+ }
446
+ const params = new URLSearchParams();
447
+ if (options?.transactionId)
448
+ params.append("transaction_id", options.transactionId);
449
+ if (bypassRipple !== undefined)
450
+ params.append("bypass_ripple", String(bypassRipple));
451
+ const qs = params.toString();
452
+ const url = qs
453
+ ? `/api/find/${encodeURIComponent(collection)}?${qs}`
454
+ : `/api/find/${encodeURIComponent(collection)}`;
455
+ return this.makeRequest("POST", url, body);
434
456
  }
435
457
  /**
436
- * Find a document by ID
458
+ * Find a document by ID.
459
+ * @param options - Optional read options. `transactionId` reads within a
460
+ * transaction (read-your-writes); see {@link FindByIdOptions}.
437
461
  */
438
- async findById(collection, id) {
439
- return this.makeRequest("GET", `/api/find/${collection}/${id}`);
462
+ async findById(collection, id, options) {
463
+ const params = new URLSearchParams();
464
+ if (options?.selectFields?.length) {
465
+ params.append("select_fields", options.selectFields.join(","));
466
+ }
467
+ if (options?.excludeFields?.length) {
468
+ params.append("exclude_fields", options.excludeFields.join(","));
469
+ }
470
+ // bypass_ripple is a GET query param, the same way the non-transactional
471
+ // findById carries it; it rides alongside transaction_id when both are set.
472
+ if (options?.bypassRipple !== undefined) {
473
+ params.append("bypass_ripple", String(options.bypassRipple));
474
+ }
475
+ if (options?.transactionId) {
476
+ params.append("transaction_id", options.transactionId);
477
+ }
478
+ const url = params.toString()
479
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
480
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
481
+ return this.makeRequest("GET", url);
440
482
  }
441
483
  /**
442
484
  * Find a document by ID with field projection
@@ -444,8 +486,9 @@ class EkoDBClient {
444
486
  * @param id - Document ID
445
487
  * @param selectFields - Fields to include in the result
446
488
  * @param excludeFields - Fields to exclude from the result
489
+ * @param transactionId - Read within a transaction (read-your-writes)
447
490
  */
448
- async findByIdWithProjection(collection, id, selectFields, excludeFields) {
491
+ async findByIdWithProjection(collection, id, selectFields, excludeFields, transactionId) {
449
492
  const params = new URLSearchParams();
450
493
  if (selectFields?.length) {
451
494
  params.append("select_fields", selectFields.join(","));
@@ -453,9 +496,12 @@ class EkoDBClient {
453
496
  if (excludeFields?.length) {
454
497
  params.append("exclude_fields", excludeFields.join(","));
455
498
  }
499
+ if (transactionId) {
500
+ params.append("transaction_id", transactionId);
501
+ }
456
502
  const url = params.toString()
457
- ? `/api/find/${collection}/${id}?${params.toString()}`
458
- : `/api/find/${collection}/${id}`;
503
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
504
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
459
505
  return this.makeRequest("GET", url);
460
506
  }
461
507
  /**
@@ -474,8 +520,8 @@ class EkoDBClient {
474
520
  params.append("transaction_id", options.transactionId);
475
521
  }
476
522
  const url = params.toString()
477
- ? `/api/update/${collection}/${id}?${params.toString()}`
478
- : `/api/update/${collection}/${id}`;
523
+ ? `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
524
+ : `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
479
525
  return this.makeRequest("PUT", url, record);
480
526
  }
481
527
  /**
@@ -492,7 +538,7 @@ class EkoDBClient {
492
538
  * @param value - The value for the action (omit for pop/shift/clear)
493
539
  */
494
540
  async updateWithAction(collection, id, action, field, value) {
495
- const url = `/api/update/${collection}/${id}/action/${action}`;
541
+ const url = `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/action/${encodeURIComponent(action)}`;
496
542
  return this.makeRequest("PUT", url, {
497
543
  field,
498
544
  value: value ?? null,
@@ -509,7 +555,7 @@ class EkoDBClient {
509
555
  * @param actions - Array of [action, field, value] tuples
510
556
  */
511
557
  async updateWithActionSequence(collection, id, actions) {
512
- const url = `/api/update/sequence/${collection}/${id}`;
558
+ const url = `/api/update/sequence/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
513
559
  return this.makeRequest("PUT", url, actions);
514
560
  }
515
561
  /**
@@ -527,8 +573,8 @@ class EkoDBClient {
527
573
  params.append("transaction_id", options.transactionId);
528
574
  }
529
575
  const url = params.toString()
530
- ? `/api/delete/${collection}/${id}?${params.toString()}`
531
- : `/api/delete/${collection}/${id}`;
576
+ ? `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
577
+ : `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
532
578
  await this.makeRequest("DELETE", url);
533
579
  }
534
580
  /**
@@ -547,8 +593,8 @@ class EkoDBClient {
547
593
  }
548
594
  const inserts = records.map((data) => ({ data }));
549
595
  const url = params.toString()
550
- ? `/api/batch/insert/${collection}?${params.toString()}`
551
- : `/api/batch/insert/${collection}`;
596
+ ? `/api/batch/insert/${encodeURIComponent(collection)}?${params.toString()}`
597
+ : `/api/batch/insert/${encodeURIComponent(collection)}`;
552
598
  return this.makeRequest("POST", url, { inserts });
553
599
  }
554
600
  /**
@@ -560,7 +606,7 @@ class EkoDBClient {
560
606
  data: u.data,
561
607
  bypass_ripple: u.bypassRipple,
562
608
  }));
563
- return this.makeRequest("PUT", `/api/batch/update/${collection}`, { updates: formattedUpdates });
609
+ return this.makeRequest("PUT", `/api/batch/update/${encodeURIComponent(collection)}`, { updates: formattedUpdates });
564
610
  }
565
611
  /**
566
612
  * Batch delete multiple documents
@@ -570,7 +616,7 @@ class EkoDBClient {
570
616
  id: id,
571
617
  bypass_ripple: bypassRipple,
572
618
  }));
573
- return this.makeRequest("DELETE", `/api/batch/delete/${collection}`, { deletes });
619
+ return this.makeRequest("DELETE", `/api/batch/delete/${encodeURIComponent(collection)}`, { deletes });
574
620
  }
575
621
  /**
576
622
  * Set a key-value pair with optional TTL
@@ -598,6 +644,12 @@ class EkoDBClient {
598
644
  async kvDelete(key) {
599
645
  await this.makeRequest("DELETE", `/api/kv/delete/${encodeURIComponent(key)}`, undefined, 0, true);
600
646
  }
647
+ /**
648
+ * Clear the entire KV store (all keys in the namespace).
649
+ */
650
+ async kvClear() {
651
+ await this.makeRequest("DELETE", "/api/kv/clear", undefined, 0, true);
652
+ }
601
653
  /**
602
654
  * Batch get multiple keys
603
655
  * @param keys - Array of keys to retrieve
@@ -663,7 +715,18 @@ class EkoDBClient {
663
715
  // Transaction Operations
664
716
  // ============================================================================
665
717
  /**
666
- * Begin a new transaction
718
+ * Begin a new transaction.
719
+ *
720
+ * Transactions are buffered: statements issued with this `transactionId`
721
+ * (passed via the `transactionId` option on insert/update/delete/find/…) are
722
+ * staged and applied atomically only at {@link commitTransaction}. They are
723
+ * invisible to everyone else until commit, and visible to this transaction's
724
+ * own reads (read-your-writes) only when those reads also carry the
725
+ * `transactionId`. {@link rollbackTransaction} discards the staged writes.
726
+ * `commitTransaction` may reject with a conflict (HTTP 409) if a record this
727
+ * transaction read or wrote was changed by another committed transaction —
728
+ * retry the transaction in that case.
729
+ *
667
730
  * @param isolationLevel - Transaction isolation level (default: "ReadCommitted")
668
731
  * @returns Transaction ID
669
732
  */
@@ -687,12 +750,31 @@ class EkoDBClient {
687
750
  await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/commit`, undefined, 0, true);
688
751
  }
689
752
  /**
690
- * Rollback a transaction
753
+ * Rollback a transaction (discards all staged writes; nothing was applied).
691
754
  * @param transactionId - The transaction ID to rollback
692
755
  */
693
756
  async rollbackTransaction(transactionId) {
694
757
  await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/rollback`, undefined, 0, true);
695
758
  }
759
+ /**
760
+ * Create a savepoint within a transaction. A later
761
+ * {@link rollbackToSavepoint} discards everything staged after it.
762
+ */
763
+ async createSavepoint(transactionId, name) {
764
+ await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints`, { name }, 0, true);
765
+ }
766
+ /**
767
+ * Roll the transaction back to a savepoint, discarding writes staged after it.
768
+ */
769
+ async rollbackToSavepoint(transactionId, name) {
770
+ await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}/rollback`, undefined, 0, true);
771
+ }
772
+ /**
773
+ * Release (forget) a savepoint. Staged work is unaffected.
774
+ */
775
+ async releaseSavepoint(transactionId, name) {
776
+ await this.makeRequest("DELETE", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}`, undefined, 0, true);
777
+ }
696
778
  // ============================================================================
697
779
  // Convenience Methods
698
780
  // ============================================================================
@@ -833,11 +915,18 @@ class EkoDBClient {
833
915
  const result = await this.makeRequest("GET", "/api/collections", undefined, 0, true);
834
916
  return result.collections;
835
917
  }
918
+ /**
919
+ * List collections, excluding internal chat/system collections.
920
+ */
921
+ async listUserCollections() {
922
+ const result = await this.makeRequest("GET", "/api/collections?exclude_internal=true", undefined, 0, true);
923
+ return result.collections;
924
+ }
836
925
  /**
837
926
  * Delete a collection
838
927
  */
839
928
  async deleteCollection(collection) {
840
- await this.makeRequest("DELETE", `/api/collections/${collection}`, undefined, 0, true);
929
+ await this.makeRequest("DELETE", `/api/collections/${encodeURIComponent(collection)}`, undefined, 0, true);
841
930
  }
842
931
  /**
843
932
  * Restore a deleted record from trash
@@ -848,7 +937,7 @@ class EkoDBClient {
848
937
  * @returns true if restored successfully
849
938
  */
850
939
  async restoreRecord(collection, id) {
851
- const result = await this.makeRequest("POST", `/api/trash/${collection}/${id}`, undefined, 0, true);
940
+ const result = await this.makeRequest("POST", `/api/trash/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, undefined, 0, true);
852
941
  return result.status === "restored";
853
942
  }
854
943
  /**
@@ -859,7 +948,7 @@ class EkoDBClient {
859
948
  * @returns Number of records restored
860
949
  */
861
950
  async restoreCollection(collection) {
862
- const result = await this.makeRequest("POST", `/api/trash/${collection}`, undefined, 0, true);
951
+ const result = await this.makeRequest("POST", `/api/trash/${encodeURIComponent(collection)}`, undefined, 0, true);
863
952
  return { recordsRestored: result.records_restored };
864
953
  }
865
954
  /**
@@ -880,7 +969,7 @@ class EkoDBClient {
880
969
  */
881
970
  async createCollection(collection, schema) {
882
971
  const schemaObj = schema instanceof schema_1.SchemaBuilder ? schema.build() : schema;
883
- await this.makeRequest("POST", `/api/collections/${collection}`, schemaObj, 0, true);
972
+ await this.makeRequest("POST", `/api/collections/${encodeURIComponent(collection)}`, schemaObj, 0, true);
884
973
  }
885
974
  /**
886
975
  * Get collection metadata and schema
@@ -889,7 +978,7 @@ class EkoDBClient {
889
978
  * @returns Collection metadata including schema and analytics
890
979
  */
891
980
  async getCollection(collection) {
892
- return this.makeRequest("GET", `/api/collections/${collection}`, undefined, 0, true);
981
+ return this.makeRequest("GET", `/api/collections/${encodeURIComponent(collection)}`, undefined, 0, true);
893
982
  }
894
983
  /**
895
984
  * Get collection schema
@@ -936,7 +1025,7 @@ class EkoDBClient {
936
1025
  */
937
1026
  async search(collection, query) {
938
1027
  // Ensure all parameters from SearchQuery are sent to server
939
- return this.makeRequest("POST", `/api/search/${collection}`, query, 0, true);
1028
+ return this.makeRequest("POST", `/api/search/${encodeURIComponent(collection)}`, query, 0, true);
940
1029
  }
941
1030
  /**
942
1031
  * Get distinct (unique) values for a field across all records in a collection.
@@ -966,7 +1055,7 @@ class EkoDBClient {
966
1055
  body.bypass_ripple = options.bypassRipple;
967
1056
  if (options.bypassCache !== undefined)
968
1057
  body.bypass_cache = options.bypassCache;
969
- return this.makeRequest("POST", `/api/distinct/${collection}/${field}`, body, 0, true);
1058
+ return this.makeRequest("POST", `/api/distinct/${encodeURIComponent(collection)}/${encodeURIComponent(field)}`, body, 0, true);
970
1059
  }
971
1060
  /**
972
1061
  * Health check - verify the ekoDB server is responding
@@ -1146,14 +1235,14 @@ class EkoDBClient {
1146
1235
  * Send a message in an existing chat session
1147
1236
  */
1148
1237
  async chatMessage(sessionId, request) {
1149
- return this.makeRequest("POST", `/api/chat/${sessionId}/messages`, request, 0, true);
1238
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(sessionId)}/messages`, request, 0, true);
1150
1239
  }
1151
1240
  /**
1152
1241
  * Submit a client tool result for an in-flight SSE chat stream.
1153
1242
  * Unblocks ekoDB's tool loop so it can feed the result to the LLM.
1154
1243
  */
1155
1244
  async submitChatToolResult(chatId, callId, success, result, error) {
1156
- await this.makeRequest("POST", `/api/chat/${chatId}/tool-result`, {
1245
+ await this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/tool-result`, {
1157
1246
  call_id: callId,
1158
1247
  success,
1159
1248
  ...(result !== undefined && { result }),
@@ -1180,7 +1269,7 @@ class EkoDBClient {
1180
1269
  await this.refreshToken();
1181
1270
  token = await this.getToken();
1182
1271
  }
1183
- const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
1272
+ const url = `${this.baseURL}/api/chat/${encodeURIComponent(chatId)}/messages/stream`;
1184
1273
  const response = await fetch(url, {
1185
1274
  method: "POST",
1186
1275
  headers: {
@@ -1277,7 +1366,7 @@ class EkoDBClient {
1277
1366
  * Get a chat session by ID
1278
1367
  */
1279
1368
  async getChatSession(sessionId) {
1280
- return this.makeRequest("GET", `/api/chat/${sessionId}`, undefined, 0, true);
1369
+ return this.makeRequest("GET", `/api/chat/${encodeURIComponent(sessionId)}`, undefined, 0, true);
1281
1370
  }
1282
1371
  /**
1283
1372
  * List all chat sessions
@@ -1307,15 +1396,15 @@ class EkoDBClient {
1307
1396
  params.append("sort", query.sort);
1308
1397
  const queryString = params.toString();
1309
1398
  const path = queryString
1310
- ? `/api/chat/${sessionId}/messages?${queryString}`
1311
- : `/api/chat/${sessionId}/messages`;
1399
+ ? `/api/chat/${encodeURIComponent(sessionId)}/messages?${queryString}`
1400
+ : `/api/chat/${encodeURIComponent(sessionId)}/messages`;
1312
1401
  return this.makeRequest("GET", path, undefined, 0, true);
1313
1402
  }
1314
1403
  /**
1315
1404
  * Update a chat session
1316
1405
  */
1317
1406
  async updateChatSession(sessionId, request) {
1318
- return this.makeRequest("PUT", `/api/chat/${sessionId}`, request, 0, true);
1407
+ return this.makeRequest("PUT", `/api/chat/${encodeURIComponent(sessionId)}`, request, 0, true);
1319
1408
  }
1320
1409
  /**
1321
1410
  * Branch a chat session
@@ -1327,31 +1416,31 @@ class EkoDBClient {
1327
1416
  * Delete a chat session
1328
1417
  */
1329
1418
  async deleteChatSession(sessionId) {
1330
- await this.makeRequest("DELETE", `/api/chat/${sessionId}`, undefined, 0, true);
1419
+ await this.makeRequest("DELETE", `/api/chat/${encodeURIComponent(sessionId)}`, undefined, 0, true);
1331
1420
  }
1332
1421
  /**
1333
1422
  * Regenerate an AI response message
1334
1423
  */
1335
1424
  async regenerateMessage(sessionId, messageId) {
1336
- return this.makeRequest("POST", `/api/chat/${sessionId}/messages/${messageId}/regenerate`, undefined, 0, true);
1425
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/regenerate`, undefined, 0, true);
1337
1426
  }
1338
1427
  /**
1339
1428
  * Update a specific message
1340
1429
  */
1341
1430
  async updateChatMessage(sessionId, messageId, content) {
1342
- await this.makeRequest("PUT", `/api/chat/${sessionId}/messages/${messageId}`, { content }, 0, true);
1431
+ await this.makeRequest("PUT", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, { content }, 0, true);
1343
1432
  }
1344
1433
  /**
1345
1434
  * Delete a specific message
1346
1435
  */
1347
1436
  async deleteChatMessage(sessionId, messageId) {
1348
- await this.makeRequest("DELETE", `/api/chat/${sessionId}/messages/${messageId}`, undefined, 0, true);
1437
+ await this.makeRequest("DELETE", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, undefined, 0, true);
1349
1438
  }
1350
1439
  /**
1351
1440
  * Toggle the "forgotten" status of a message
1352
1441
  */
1353
1442
  async toggleForgottenMessage(sessionId, messageId, forgotten) {
1354
- await this.makeRequest("PATCH", `/api/chat/${sessionId}/messages/${messageId}/forgotten`, { forgotten }, 0, true);
1443
+ await this.makeRequest("PATCH", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/forgotten`, { forgotten }, 0, true);
1355
1444
  }
1356
1445
  /**
1357
1446
  * Compact a chat session's history on demand.
@@ -1368,7 +1457,7 @@ class EkoDBClient {
1368
1457
  if (keepRecent !== undefined) {
1369
1458
  body.keep_recent = keepRecent;
1370
1459
  }
1371
- return this.makeRequest("POST", `/api/chat/${chatId}/compact`, body, 0, true);
1460
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/compact`, body, 0, true);
1372
1461
  }
1373
1462
  /**
1374
1463
  * Merge multiple chat sessions into one
@@ -1406,7 +1495,7 @@ class EkoDBClient {
1406
1495
  * @returns The chat message record
1407
1496
  */
1408
1497
  async getChatMessage(sessionId, messageId) {
1409
- return this.makeRequest("GET", `/api/chat/${sessionId}/messages/${messageId}`, undefined, 0, true);
1498
+ return this.makeRequest("GET", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, undefined, 0, true);
1410
1499
  }
1411
1500
  // ========================================================================
1412
1501
  // SCRIPTS API
@@ -1422,7 +1511,7 @@ class EkoDBClient {
1422
1511
  * Get a function by ID
1423
1512
  */
1424
1513
  async getFunction(id) {
1425
- return this.makeRequest("GET", `/api/functions/${id}`);
1514
+ return this.makeRequest("GET", `/api/functions/${encodeURIComponent(id)}`);
1426
1515
  }
1427
1516
  /**
1428
1517
  * List all functions, optionally filtered by tags
@@ -1435,19 +1524,19 @@ class EkoDBClient {
1435
1524
  * Update an existing function by ID
1436
1525
  */
1437
1526
  async updateFunction(id, script) {
1438
- await this.makeRequest("PUT", `/api/functions/${id}`, script);
1527
+ await this.makeRequest("PUT", `/api/functions/${encodeURIComponent(id)}`, script);
1439
1528
  }
1440
1529
  /**
1441
1530
  * Delete a function by ID
1442
1531
  */
1443
1532
  async deleteFunction(id) {
1444
- await this.makeRequest("DELETE", `/api/functions/${id}`);
1533
+ await this.makeRequest("DELETE", `/api/functions/${encodeURIComponent(id)}`);
1445
1534
  }
1446
1535
  /**
1447
1536
  * Call a saved function by ID or label
1448
1537
  */
1449
1538
  async callFunction(idOrLabel, params) {
1450
- return this.makeRequest("POST", `/api/functions/${idOrLabel}`, params || {});
1539
+ return this.makeRequest("POST", `/api/functions/${encodeURIComponent(idOrLabel)}`, params || {});
1451
1540
  }
1452
1541
  // ========================================================================
1453
1542
  // USER FUNCTIONS API
@@ -1556,15 +1645,15 @@ class EkoDBClient {
1556
1645
  }
1557
1646
  /** Start a goal step (status -> in_progress) */
1558
1647
  async goalStepStart(id, stepIndex) {
1559
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/start`, undefined, 0, true);
1648
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/start`, undefined, 0, true);
1560
1649
  }
1561
1650
  /** Complete a goal step with result */
1562
1651
  async goalStepComplete(id, stepIndex, data) {
1563
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/complete`, data, 0, true);
1652
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/complete`, data, 0, true);
1564
1653
  }
1565
1654
  /** Fail a goal step with error */
1566
1655
  async goalStepFail(id, stepIndex, data) {
1567
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/fail`, data, 0, true);
1656
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/fail`, data, 0, true);
1568
1657
  }
1569
1658
  // ========================================================================
1570
1659
  // TASK API
@@ -2099,6 +2188,13 @@ class WebSocketClient {
2099
2188
  this.ws = null;
2100
2189
  this.dispatcherRunning = false;
2101
2190
  this.schemaCache = null;
2191
+ /**
2192
+ * Per-connection wire format, set by negotiateFormat() on every (re)connect:
2193
+ * true once the server has Welcomed msgpack, so frames are sent/received as
2194
+ * binary msgpack; false (JSON text) otherwise, including against an older
2195
+ * server that never Welcomes. Keeps the transport fully back-compatible.
2196
+ */
2197
+ this.binary = false;
2102
2198
  // Reconnect state
2103
2199
  /** Set while close() is in progress so the close handler doesn't reconnect. */
2104
2200
  this.closed = false;
@@ -2180,8 +2276,66 @@ class WebSocketClient {
2180
2276
  this.ws.on("open", () => resolve());
2181
2277
  this.ws.on("error", (err) => reject(err));
2182
2278
  });
2279
+ // Negotiate the wire format before the dispatcher starts so the Welcome is
2280
+ // consumed here (not by routeMessage), and before any real frame is sent
2281
+ // (resubscribeAll runs only after this resolves).
2282
+ await this.negotiateFormat(this.ws);
2183
2283
  this.spawnDispatcher();
2184
2284
  }
2285
+ /**
2286
+ * Additive capability handshake: offer msgpack and, if the server Welcomes
2287
+ * it, switch this connection to binary msgpack frames; otherwise stay on JSON
2288
+ * text. The Welcome (a text frame) is read with a one-shot listener and a
2289
+ * timeout so an older server that never answers — or answers with an Error —
2290
+ * simply leaves the connection on JSON. Best-effort and never throws: JSON
2291
+ * always works.
2292
+ */
2293
+ async negotiateFormat(socket) {
2294
+ this.binary = false;
2295
+ const welcome = await new Promise((resolve) => {
2296
+ const onMsg = (data) => {
2297
+ clearTimeout(timer);
2298
+ try {
2299
+ resolve(JSON.parse(data.toString()));
2300
+ }
2301
+ catch {
2302
+ resolve(null);
2303
+ }
2304
+ };
2305
+ // Only caps the wait when no Welcome comes (a silent/old server); the
2306
+ // listener resolves immediately when it does arrive. 2s comfortably exceeds
2307
+ // the handshake round-trip even on high-latency links.
2308
+ const timer = setTimeout(() => {
2309
+ socket.off("message", onMsg);
2310
+ resolve(null);
2311
+ }, 2000);
2312
+ socket.once("message", onMsg);
2313
+ try {
2314
+ socket.send(JSON.stringify({
2315
+ type: "Hello",
2316
+ payload: { formats: ["msgpack", "json"] },
2317
+ }));
2318
+ }
2319
+ catch {
2320
+ clearTimeout(timer);
2321
+ socket.off("message", onMsg);
2322
+ resolve(null);
2323
+ }
2324
+ });
2325
+ if (welcome &&
2326
+ welcome.type === "Welcome" &&
2327
+ welcome.payload?.format === "msgpack") {
2328
+ this.binary = true;
2329
+ }
2330
+ }
2331
+ /**
2332
+ * Send a request object on the active socket using the negotiated format:
2333
+ * binary msgpack when the server Welcomed it, JSON text otherwise. The single
2334
+ * write point so every request honors the negotiated transport.
2335
+ */
2336
+ sendFrame(obj) {
2337
+ this.ws.send(this.binary ? (0, msgpack_1.encode)(obj) : JSON.stringify(obj));
2338
+ }
2185
2339
  spawnDispatcher() {
2186
2340
  if (this.dispatcherRunning)
2187
2341
  return;
@@ -2190,11 +2344,16 @@ class WebSocketClient {
2190
2344
  // socket may still emit late close/error events; ignore them so they don't
2191
2345
  // tear down the replacement connection.
2192
2346
  const socket = this.ws;
2193
- socket.on("message", (data) => {
2347
+ socket.on("message", (data, isBinary) => {
2194
2348
  if (this.ws !== socket)
2195
2349
  return;
2196
2350
  try {
2197
- const msg = JSON.parse(data.toString());
2351
+ // A binary frame is msgpack (the server only sends binary once it has
2352
+ // Welcomed msgpack); a text frame is JSON. Decode by frame type so the
2353
+ // routed value is identical regardless of negotiated transport.
2354
+ const msg = isBinary
2355
+ ? (0, msgpack_1.decode)(data)
2356
+ : JSON.parse(data.toString());
2198
2357
  this.routeMessage(msg);
2199
2358
  }
2200
2359
  catch {
@@ -2477,7 +2636,7 @@ class WebSocketClient {
2477
2636
  }
2478
2637
  this.pendingRequests.set(messageId, { resolve, reject, timer });
2479
2638
  try {
2480
- this.ws.send(JSON.stringify(request));
2639
+ this.sendFrame(request);
2481
2640
  }
2482
2641
  catch (err) {
2483
2642
  this.pendingRequests.delete(messageId);
@@ -2560,6 +2719,27 @@ class WebSocketClient {
2560
2719
  if (stream && !stream.closed) {
2561
2720
  stream.close();
2562
2721
  }
2722
+ // Best-effort: tell the server to stop streaming this collection (the
2723
+ // server already handles an Unsubscribe frame). If the socket isn't open
2724
+ // the local teardown above suffices, since the server drops subscriptions
2725
+ // when the connection closes. A unique messageId is attached so the
2726
+ // server's Success ack carries a correlation id: it has no pending request
2727
+ // to match, so it is simply ignored — and because the id is present, the
2728
+ // single-pending fallback can't misroute it to an unrelated request.
2729
+ if (this.ws && this.ws.readyState === 1 /* WebSocket.OPEN */) {
2730
+ try {
2731
+ this.sendFrame({
2732
+ type: "Unsubscribe",
2733
+ messageId: this.genMessageId(),
2734
+ payload: { collection },
2735
+ });
2736
+ }
2737
+ catch {
2738
+ // Best-effort: the socket can close between the readyState check and the
2739
+ // send. Local teardown already happened, so swallow the failure rather
2740
+ // than throw out of a void teardown call.
2741
+ }
2742
+ }
2563
2743
  }
2564
2744
  /**
2565
2745
  * Send a chat message and receive a streaming response.
@@ -2588,7 +2768,7 @@ class WebSocketClient {
2588
2768
  ...(options?.excludeTools && { exclude_tools: options.excludeTools }),
2589
2769
  },
2590
2770
  };
2591
- this.ws.send(JSON.stringify(request));
2771
+ this.sendFrame(request);
2592
2772
  return stream;
2593
2773
  }
2594
2774
  /**
@@ -2608,7 +2788,7 @@ class WebSocketClient {
2608
2788
  resolve: () => resolve(),
2609
2789
  reject: (err) => reject(err),
2610
2790
  };
2611
- this.ws.send(JSON.stringify(request));
2791
+ this.sendFrame(request);
2612
2792
  });
2613
2793
  }
2614
2794
  /**
@@ -2626,7 +2806,24 @@ class WebSocketClient {
2626
2806
  ...(error !== undefined && { error }),
2627
2807
  },
2628
2808
  };
2629
- this.ws.send(JSON.stringify(request));
2809
+ this.sendFrame(request);
2810
+ }
2811
+ /**
2812
+ * Cancel an in-flight streaming chat. Fire-and-forget: tells the server to
2813
+ * stop generating tokens for the given chat.
2814
+ */
2815
+ async cancelChat(chatId) {
2816
+ await this.ensureConnected();
2817
+ // Attach a unique messageId (same generator as unsubscribe). Any Success ack
2818
+ // from the server then carries a correlation id: it has no pending request to
2819
+ // match, so it is ignored — and because the id is present, the dispatcher's
2820
+ // single-pending fallback can't misroute the ack to an unrelated request.
2821
+ const request = {
2822
+ type: "CancelChat",
2823
+ messageId: this.genMessageId(),
2824
+ payload: { chat_id: chatId },
2825
+ };
2826
+ this.sendFrame(request);
2630
2827
  }
2631
2828
  /**
2632
2829
  * Stateless raw LLM completion via WebSocket.