@ekodb/ekodb-client 0.12.0 → 0.14.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/src/client.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { encode, decode } from "@msgpack/msgpack";
6
6
  import { QueryBuilder, Query as QueryBuilderQuery } from "./query-builder";
7
- import { SearchQuery, SearchQueryBuilder, SearchResponse } from "./search";
7
+ import { SearchQuery, SearchResponse } from "./search";
8
8
  import { Schema, SchemaBuilder, CollectionMetadata } from "./schema";
9
9
  import { Script, FunctionResult } from "./functions";
10
10
 
@@ -46,8 +46,6 @@ export interface ClientConfig {
46
46
  shouldRetry?: boolean;
47
47
  /** Maximum number of retry attempts (default: 3) */
48
48
  maxRetries?: number;
49
- /** Request timeout in milliseconds (default: 30000) */
50
- timeout?: number;
51
49
  /** Serialization format (default: MessagePack for best performance, use Json for debugging) */
52
50
  format?: SerializationFormat;
53
51
  }
@@ -134,6 +132,26 @@ export interface BatchDeleteOptions {
134
132
  transactionId?: string;
135
133
  }
136
134
 
135
+ export interface DistinctValuesOptions {
136
+ /** Optional filter expression (same format as find() filter). */
137
+ filter?: any;
138
+ /** Bypass ripple propagation for this query. */
139
+ bypassRipple?: boolean;
140
+ /** Bypass cache for this query. */
141
+ bypassCache?: boolean;
142
+ }
143
+
144
+ export interface DistinctValuesResponse {
145
+ /** Collection that was queried. */
146
+ collection: string;
147
+ /** Field whose distinct values were returned. */
148
+ field: string;
149
+ /** Unique values, sorted alphabetically. */
150
+ values: any[];
151
+ /** Number of distinct values. */
152
+ count: number;
153
+ }
154
+
137
155
  // ========== Chat Interfaces ==========
138
156
 
139
157
  export interface CollectionConfig {
@@ -298,6 +316,24 @@ export interface EmbedResponse {
298
316
  dimensions: number;
299
317
  }
300
318
 
319
+ /**
320
+ * Request for stateless raw LLM completion — no session, no history, no RAG.
321
+ */
322
+ export interface RawCompletionRequest {
323
+ system_prompt: string;
324
+ message: string;
325
+ provider?: string;
326
+ model?: string;
327
+ max_tokens?: number;
328
+ }
329
+
330
+ /**
331
+ * Response from a raw LLM completion request.
332
+ */
333
+ export interface RawCompletionResponse {
334
+ content: string;
335
+ }
336
+
301
337
  /**
302
338
  * User function definition - reusable sequence of Functions that can be called by Scripts
303
339
  */
@@ -335,9 +371,9 @@ export class EkoDBClient {
335
371
  private baseURL: string;
336
372
  private apiKey: string;
337
373
  private token: string | null = null;
374
+ private tokenExpiry: number = 0;
338
375
  private shouldRetry: boolean;
339
376
  private maxRetries: number;
340
- private timeout: number;
341
377
  private format: SerializationFormat;
342
378
  private rateLimitInfo: RateLimitInfo | null = null;
343
379
 
@@ -348,14 +384,12 @@ export class EkoDBClient {
348
384
  this.apiKey = apiKey!;
349
385
  this.shouldRetry = true;
350
386
  this.maxRetries = 3;
351
- this.timeout = 30000;
352
387
  this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
353
388
  } else {
354
389
  this.baseURL = config.baseURL;
355
390
  this.apiKey = config.apiKey;
356
391
  this.shouldRetry = config.shouldRetry ?? true;
357
392
  this.maxRetries = config.maxRetries ?? 3;
358
- this.timeout = config.timeout ?? 30000;
359
393
  this.format = config.format ?? SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
360
394
  }
361
395
  }
@@ -386,7 +420,7 @@ export class EkoDBClient {
386
420
  /**
387
421
  * Refresh the authentication token
388
422
  */
389
- private async refreshToken(): Promise<void> {
423
+ async refreshToken(): Promise<void> {
390
424
  const response = await fetch(`${this.baseURL}/api/auth/token`, {
391
425
  method: "POST",
392
426
  headers: { "Content-Type": "application/json" },
@@ -408,6 +442,74 @@ export class EkoDBClient {
408
442
 
409
443
  const result = (await response.json()) as { token: string };
410
444
  this.token = result.token;
445
+
446
+ // Extract and cache JWT expiry for proactive refresh
447
+ const expiry = this.extractJWTExpiry(result.token);
448
+ this.tokenExpiry = expiry ?? Math.floor(Date.now() / 1000) + 3600; // fallback: 1 hour
449
+ }
450
+
451
+ /**
452
+ * Get a valid authentication token.
453
+ *
454
+ * Returns a cached token if it has more than 60s of validity remaining.
455
+ * Otherwise fetches a new one via refreshToken(). This means callers
456
+ * never need to handle token refresh themselves — every getToken() call
457
+ * returns a token that's valid for at least 60 more seconds.
458
+ */
459
+ async getToken(): Promise<string | null> {
460
+ if (this.token) {
461
+ const now = Math.floor(Date.now() / 1000);
462
+ if (now + 60 >= this.tokenExpiry) {
463
+ // Token is about to expire or already expired — refresh proactively
464
+ await this.refreshToken();
465
+ }
466
+ } else {
467
+ // No token yet — fetch one
468
+ await this.refreshToken();
469
+ }
470
+ return this.token;
471
+ }
472
+
473
+ /**
474
+ * Clear the cached authentication token and expiry.
475
+ * The next request will trigger a fresh token exchange.
476
+ */
477
+ clearTokenCache(): void {
478
+ this.token = null;
479
+ this.tokenExpiry = 0;
480
+ }
481
+
482
+ /**
483
+ * Extract the `exp` claim from a JWT without verifying the signature.
484
+ * Returns the Unix timestamp (seconds) of expiry, or null if parsing fails.
485
+ */
486
+ private extractJWTExpiry(token: string): number | null {
487
+ try {
488
+ const parts = token.split(".");
489
+ if (parts.length !== 3) {
490
+ return null;
491
+ }
492
+
493
+ // Convert base64url to standard base64
494
+ let payload = parts[1];
495
+ payload = payload.replace(/-/g, "+").replace(/_/g, "/");
496
+
497
+ // Pad to multiple of 4
498
+ const pad = payload.length % 4;
499
+ if (pad) {
500
+ payload += "=".repeat(4 - pad);
501
+ }
502
+
503
+ const decoded = atob(payload);
504
+ const claims = JSON.parse(decoded);
505
+
506
+ if (typeof claims.exp === "number") {
507
+ return claims.exp;
508
+ }
509
+ return null;
510
+ } catch {
511
+ return null;
512
+ }
411
513
  }
412
514
 
413
515
  /**
@@ -660,6 +762,32 @@ export class EkoDBClient {
660
762
  return this.makeRequest<Record>("GET", `/api/find/${collection}/${id}`);
661
763
  }
662
764
 
765
+ /**
766
+ * Find a document by ID with field projection
767
+ * @param collection - Collection name
768
+ * @param id - Document ID
769
+ * @param selectFields - Fields to include in the result
770
+ * @param excludeFields - Fields to exclude from the result
771
+ */
772
+ async findByIdWithProjection(
773
+ collection: string,
774
+ id: string,
775
+ selectFields?: string[],
776
+ excludeFields?: string[],
777
+ ): Promise<Record> {
778
+ const params = new URLSearchParams();
779
+ if (selectFields?.length) {
780
+ params.append("select_fields", selectFields.join(","));
781
+ }
782
+ if (excludeFields?.length) {
783
+ params.append("exclude_fields", excludeFields.join(","));
784
+ }
785
+ const url = params.toString()
786
+ ? `/api/find/${collection}/${id}?${params.toString()}`
787
+ : `/api/find/${collection}/${id}`;
788
+ return this.makeRequest<Record>("GET", url);
789
+ }
790
+
663
791
  /**
664
792
  * Update a document
665
793
  * @param collection - Collection name
@@ -688,6 +816,52 @@ export class EkoDBClient {
688
816
  return this.makeRequest<Record>("PUT", url, record);
689
817
  }
690
818
 
819
+ /**
820
+ * Apply an atomic field action to a single field of a record.
821
+ *
822
+ * Use this instead of `update()` for safe concurrent modifications like
823
+ * incrementing counters, pushing to arrays, or arithmetic operations.
824
+ *
825
+ * @param collection - Collection name
826
+ * @param id - Record ID
827
+ * @param action - The atomic action: increment, decrement, multiply, divide, modulo,
828
+ * push, pop, shift, unshift, remove, append, clear
829
+ * @param field - The field name to apply the action to
830
+ * @param value - The value for the action (omit for pop/shift/clear)
831
+ */
832
+ async updateWithAction(
833
+ collection: string,
834
+ id: string,
835
+ action: string,
836
+ field: string,
837
+ value?: any,
838
+ ): Promise<Record> {
839
+ const url = `/api/update/${collection}/${id}/action/${action}`;
840
+ return this.makeRequest<Record>("PUT", url, {
841
+ field,
842
+ value: value ?? null,
843
+ });
844
+ }
845
+
846
+ /**
847
+ * Apply a sequence of atomic field actions to a record in a single request.
848
+ *
849
+ * All actions are applied atomically — the record is fetched once, all actions
850
+ * run in order, and the result is persisted in a single update.
851
+ *
852
+ * @param collection - Collection name
853
+ * @param id - Record ID
854
+ * @param actions - Array of [action, field, value] tuples
855
+ */
856
+ async updateWithActionSequence(
857
+ collection: string,
858
+ id: string,
859
+ actions: [string, string, any][],
860
+ ): Promise<Record> {
861
+ const url = `/api/update/sequence/${collection}/${id}`;
862
+ return this.makeRequest<Record>("PUT", url, actions);
863
+ }
864
+
691
865
  /**
692
866
  * Delete a document
693
867
  * @param collection - Collection name
@@ -1313,6 +1487,51 @@ export class EkoDBClient {
1313
1487
  );
1314
1488
  }
1315
1489
 
1490
+ /**
1491
+ * Get distinct (unique) values for a field across all records in a collection.
1492
+ *
1493
+ * Results are deduplicated and sorted alphabetically. Supports an optional filter
1494
+ * to restrict which records are examined.
1495
+ *
1496
+ * @param collection - Collection name
1497
+ * @param field - Field to get distinct values for
1498
+ * @param options - Optional filter and bypass flags
1499
+ *
1500
+ * @example
1501
+ * // All distinct statuses
1502
+ * const resp = await client.distinctValues("orders", "status");
1503
+ * console.log(resp.values); // ["active", "cancelled", "shipped"]
1504
+ *
1505
+ * // Only statuses for US orders
1506
+ * const resp = await client.distinctValues("orders", "status", {
1507
+ * filter: { type: "Condition", content: { field: "region", operator: "Eq", value: "us" } }
1508
+ * });
1509
+ */
1510
+ async distinctValues(
1511
+ collection: string,
1512
+ field: string,
1513
+ options: DistinctValuesOptions = {},
1514
+ ): Promise<DistinctValuesResponse> {
1515
+ const body: {
1516
+ filter?: any;
1517
+ bypass_ripple?: boolean;
1518
+ bypass_cache?: boolean;
1519
+ } = {};
1520
+ if (options.filter !== undefined) body.filter = options.filter;
1521
+ if (options.bypassRipple !== undefined)
1522
+ body.bypass_ripple = options.bypassRipple;
1523
+ if (options.bypassCache !== undefined)
1524
+ body.bypass_cache = options.bypassCache;
1525
+
1526
+ return this.makeRequest<DistinctValuesResponse>(
1527
+ "POST",
1528
+ `/api/distinct/${collection}/${field}`,
1529
+ body,
1530
+ 0,
1531
+ true, // Force JSON
1532
+ );
1533
+ }
1534
+
1316
1535
  /**
1317
1536
  * Health check - verify the ekoDB server is responding
1318
1537
  */
@@ -1348,6 +1567,143 @@ export class EkoDBClient {
1348
1567
  );
1349
1568
  }
1350
1569
 
1570
+ /**
1571
+ * Stateless raw LLM completion — no session, no history, no RAG.
1572
+ *
1573
+ * Sends a system prompt and user message directly to the LLM via ekoDB
1574
+ * and returns the raw text response without any context injection or
1575
+ * conversation management. Use this for structured-output tasks such as
1576
+ * planning where the response must be parsed programmatically.
1577
+ *
1578
+ * @example
1579
+ * const resp = await client.rawCompletion({
1580
+ * system_prompt: "You are a helpful assistant.",
1581
+ * message: "Summarize this in JSON.",
1582
+ * max_tokens: 2048,
1583
+ * });
1584
+ * console.log(resp.content);
1585
+ */
1586
+ async rawCompletion(
1587
+ request: RawCompletionRequest,
1588
+ ): Promise<RawCompletionResponse> {
1589
+ return this.makeRequest<RawCompletionResponse>(
1590
+ "POST",
1591
+ "/api/chat/complete",
1592
+ request,
1593
+ 0,
1594
+ true, // Force JSON
1595
+ );
1596
+ }
1597
+
1598
+ /**
1599
+ * Stateless raw LLM completion via SSE streaming.
1600
+ *
1601
+ * Same as rawCompletion() but uses Server-Sent Events to keep the
1602
+ * connection alive. Preferred for deployed instances where reverse proxies
1603
+ * may kill idle HTTP connections before the LLM responds.
1604
+ */
1605
+ async rawCompletionStream(
1606
+ request: RawCompletionRequest,
1607
+ ): Promise<RawCompletionResponse> {
1608
+ let token = await this.getToken();
1609
+ const url = `${this.baseURL}/api/chat/complete/stream`;
1610
+
1611
+ const response = await fetch(url, {
1612
+ method: "POST",
1613
+ headers: {
1614
+ "Content-Type": "application/json",
1615
+ Accept: "text/event-stream",
1616
+ Authorization: `Bearer ${token}`,
1617
+ },
1618
+ body: JSON.stringify(request),
1619
+ });
1620
+
1621
+ if (!response.ok) {
1622
+ const body = await response.text();
1623
+ throw new Error(
1624
+ `SSE raw completion failed (${response.status}): ${body}`,
1625
+ );
1626
+ }
1627
+
1628
+ const body = await response.text();
1629
+ let content = "";
1630
+ let lastError: string | null = null;
1631
+
1632
+ for (const line of body.split("\n")) {
1633
+ if (line.startsWith("data:")) {
1634
+ const dataStr = line.slice(5).trim();
1635
+ if (!dataStr) continue;
1636
+ try {
1637
+ const eventData = JSON.parse(dataStr);
1638
+ if (eventData.token) content += eventData.token;
1639
+ if (eventData.content) content = eventData.content;
1640
+ if (eventData.error) lastError = eventData.error;
1641
+ } catch {
1642
+ // skip malformed SSE data
1643
+ }
1644
+ }
1645
+ }
1646
+
1647
+ if (lastError) throw new Error(lastError);
1648
+ return { content };
1649
+ }
1650
+
1651
+ /**
1652
+ * Stateless raw LLM completion via SSE streaming with token-level progress.
1653
+ *
1654
+ * Same as rawCompletionStream() but invokes `onToken` with each token as it
1655
+ * arrives, allowing callers to show real-time progress.
1656
+ */
1657
+ async rawCompletionStreamWithProgress(
1658
+ request: RawCompletionRequest,
1659
+ onToken: (token: string) => void,
1660
+ ): Promise<RawCompletionResponse> {
1661
+ let token = await this.getToken();
1662
+ const url = `${this.baseURL}/api/chat/complete/stream`;
1663
+
1664
+ const response = await fetch(url, {
1665
+ method: "POST",
1666
+ headers: {
1667
+ "Content-Type": "application/json",
1668
+ Accept: "text/event-stream",
1669
+ Authorization: `Bearer ${token}`,
1670
+ },
1671
+ body: JSON.stringify(request),
1672
+ });
1673
+
1674
+ if (!response.ok) {
1675
+ const body = await response.text();
1676
+ throw new Error(
1677
+ `SSE raw completion failed (${response.status}): ${body}`,
1678
+ );
1679
+ }
1680
+
1681
+ const body = await response.text();
1682
+ let content = "";
1683
+ let lastError: string | null = null;
1684
+
1685
+ for (const line of body.split("\n")) {
1686
+ if (line.startsWith("data:")) {
1687
+ const dataStr = line.slice(5).trim();
1688
+ if (!dataStr) continue;
1689
+ try {
1690
+ const eventData = JSON.parse(dataStr);
1691
+ if (eventData.token) {
1692
+ content += eventData.token;
1693
+ onToken(eventData.token);
1694
+ }
1695
+ if (eventData.content) content = eventData.content;
1696
+ if (eventData.error) lastError = eventData.error;
1697
+ } catch {
1698
+ // skip malformed SSE data
1699
+ }
1700
+ }
1701
+ }
1702
+
1703
+ if (lastError) throw new Error(lastError);
1704
+ return { content };
1705
+ }
1706
+
1351
1707
  /**
1352
1708
  * Send a message in an existing chat session
1353
1709
  */
@@ -1364,6 +1720,96 @@ export class EkoDBClient {
1364
1720
  );
1365
1721
  }
1366
1722
 
1723
+ /**
1724
+ * Send a message in an existing chat session via SSE streaming.
1725
+ *
1726
+ * Returns an EventStream that emits ChatStreamEvent objects as they arrive:
1727
+ * - `{ type: "chunk", content: "..." }` for each token
1728
+ * - `{ type: "end", messageId, executionTimeMs, tokenUsage?, contextWindow? }` when complete
1729
+ * - `{ type: "error", error: "..." }` on failure
1730
+ *
1731
+ * Preferred over chatMessage() for long-running responses where reverse
1732
+ * proxies may kill idle HTTP connections before the LLM responds.
1733
+ */
1734
+ chatMessageStream(
1735
+ chatId: string,
1736
+ request: ChatMessageRequest,
1737
+ ): EventStream<ChatStreamEvent> {
1738
+ const stream = new EventStream<ChatStreamEvent>();
1739
+
1740
+ (async () => {
1741
+ try {
1742
+ let token = this.getToken();
1743
+ if (!token) {
1744
+ await this.refreshToken();
1745
+ token = this.getToken();
1746
+ }
1747
+ const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
1748
+
1749
+ const response = await fetch(url, {
1750
+ method: "POST",
1751
+ headers: {
1752
+ "Content-Type": "application/json",
1753
+ Accept: "text/event-stream",
1754
+ Authorization: `Bearer ${token}`,
1755
+ },
1756
+ body: JSON.stringify(request),
1757
+ });
1758
+
1759
+ if (!response.ok) {
1760
+ const body = await response.text();
1761
+ stream.emit("event", {
1762
+ type: "error",
1763
+ error: `SSE chat message stream failed (${response.status}): ${body}`,
1764
+ } as ChatStreamEvent);
1765
+ stream.close();
1766
+ return;
1767
+ }
1768
+
1769
+ const body = await response.text();
1770
+ for (const line of body.split("\n")) {
1771
+ if (!line.startsWith("data:")) continue;
1772
+ const dataStr = line.slice(5).trim();
1773
+ if (!dataStr) continue;
1774
+ try {
1775
+ const eventData = JSON.parse(dataStr);
1776
+ if (eventData.error) {
1777
+ stream.emit("event", {
1778
+ type: "error",
1779
+ error: eventData.error,
1780
+ } as ChatStreamEvent);
1781
+ } else if (eventData.content && eventData.message_id) {
1782
+ // Done event — has full content + message_id
1783
+ stream.emit("event", {
1784
+ type: "end",
1785
+ messageId: eventData.message_id,
1786
+ executionTimeMs: eventData.execution_time_ms ?? 0,
1787
+ tokenUsage: eventData.token_usage,
1788
+ contextWindow: eventData.context_window,
1789
+ } as ChatStreamEvent);
1790
+ } else if (eventData.token) {
1791
+ stream.emit("event", {
1792
+ type: "chunk",
1793
+ content: eventData.token,
1794
+ } as ChatStreamEvent);
1795
+ }
1796
+ } catch {
1797
+ // skip malformed SSE data
1798
+ }
1799
+ }
1800
+ stream.close();
1801
+ } catch (err: any) {
1802
+ stream.emit("event", {
1803
+ type: "error",
1804
+ error: err.message ?? String(err),
1805
+ } as ChatStreamEvent);
1806
+ stream.close();
1807
+ }
1808
+ })();
1809
+
1810
+ return stream;
1811
+ }
1812
+
1367
1813
  /**
1368
1814
  * Get a chat session by ID
1369
1815
  */
@@ -1560,6 +2006,21 @@ export class EkoDBClient {
1560
2006
  );
1561
2007
  }
1562
2008
 
2009
+ /**
2010
+ * Get all built-in server-side chat tool definitions.
2011
+ * Returns a list of tool objects with name, description, and parameters fields.
2012
+ * Used by planning agents to discover available tools dynamically.
2013
+ */
2014
+ async getChatTools(): Promise<object[]> {
2015
+ return this.makeRequest<object[]>(
2016
+ "GET",
2017
+ "/api/chat/tools",
2018
+ undefined,
2019
+ 0,
2020
+ true, // Force JSON
2021
+ );
2022
+ }
2023
+
1563
2024
  /**
1564
2025
  * Get available models for a specific provider
1565
2026
  * @param provider - Provider name (e.g., "openai", "anthropic", "perplexity")
@@ -1734,74 +2195,585 @@ export class EkoDBClient {
1734
2195
  }
1735
2196
 
1736
2197
  // ========================================================================
1737
- // COLLECTION UTILITIES
2198
+ // GOAL API
1738
2199
  // ========================================================================
1739
2200
 
1740
- /**
1741
- * Check if a collection exists
1742
- * @param collection - Collection name to check
1743
- * @returns true if the collection exists, false otherwise
1744
- */
1745
- async collectionExists(collection: string): Promise<boolean> {
1746
- try {
1747
- const collections = await this.listCollections();
1748
- return collections.includes(collection);
1749
- } catch {
1750
- return false;
1751
- }
2201
+ /** Create a new goal */
2202
+ async goalCreate(data: Record): Promise<Record> {
2203
+ return this.makeRequest<Record>("POST", "/api/chat/goals", data, 0, true);
1752
2204
  }
1753
2205
 
1754
- /**
1755
- * Count documents in a collection
1756
- * @param collection - Collection name
1757
- * @returns Number of documents in the collection
1758
- */
1759
- async countDocuments(collection: string): Promise<number> {
1760
- const query = new QueryBuilder().limit(100000).build();
1761
- const records = await this.find(collection, query);
1762
- return records.length;
2206
+ /** List all goals */
2207
+ async goalList(): Promise<Record> {
2208
+ return this.makeRequest<Record>(
2209
+ "GET",
2210
+ "/api/chat/goals",
2211
+ undefined,
2212
+ 0,
2213
+ true,
2214
+ );
1763
2215
  }
1764
2216
 
1765
- /**
1766
- * Create a WebSocket client
1767
- */
1768
- websocket(wsURL: string): WebSocketClient {
1769
- return new WebSocketClient(wsURL, this.token!);
2217
+ /** Get a goal by ID */
2218
+ async goalGet(id: string): Promise<Record> {
2219
+ return this.makeRequest<Record>(
2220
+ "GET",
2221
+ `/api/chat/goals/${encodeURIComponent(id)}`,
2222
+ undefined,
2223
+ 0,
2224
+ true,
2225
+ );
1770
2226
  }
1771
2227
 
1772
- // ========== RAG Helper Methods ==========
2228
+ /** Update a goal by ID */
2229
+ async goalUpdate(id: string, data: Record): Promise<Record> {
2230
+ return this.makeRequest<Record>(
2231
+ "PUT",
2232
+ `/api/chat/goals/${encodeURIComponent(id)}`,
2233
+ data,
2234
+ 0,
2235
+ true,
2236
+ );
2237
+ }
1773
2238
 
1774
- /**
1775
- * Generate embeddings for a single text
1776
- *
1777
- * @param text - The text to generate embeddings for
1778
- * @param model - The embedding model to use (e.g., "text-embedding-3-small")
1779
- * @returns Array of floats representing the embedding vector
1780
- *
1781
- * @example
1782
- * ```typescript
1783
- * const embedding = await client.embed(
1784
- * "Hello world",
1785
- * "text-embedding-3-small"
1786
- * );
1787
- * console.log(`Generated ${embedding.length} dimensions`);
1788
- * ```
1789
- */
1790
- async embed(text: string, model: string): Promise<number[]> {
1791
- const response = await this.embedRequest({ text, model });
1792
- if (response.embeddings.length === 0) {
1793
- throw new Error("No embedding returned");
1794
- }
1795
- return response.embeddings[0];
2239
+ /** Delete a goal by ID */
2240
+ async goalDelete(id: string): Promise<void> {
2241
+ await this.makeRequest<void>(
2242
+ "DELETE",
2243
+ `/api/chat/goals/${encodeURIComponent(id)}`,
2244
+ undefined,
2245
+ 0,
2246
+ true,
2247
+ );
1796
2248
  }
1797
2249
 
1798
- /**
1799
- * Generate embeddings for multiple texts in a single batch request
1800
- *
1801
- * @param texts - Array of texts to generate embeddings for
1802
- * @param model - The embedding model to use
1803
- * @returns Array of embedding vectors
1804
- */
2250
+ // ── Goal Templates ──
2251
+
2252
+ /** Create a new goal template */
2253
+ async goalTemplateCreate(data: Record): Promise<Record> {
2254
+ return this.makeRequest<Record>(
2255
+ "POST",
2256
+ "/api/chat/goal-templates",
2257
+ data,
2258
+ 0,
2259
+ true,
2260
+ );
2261
+ }
2262
+
2263
+ /** List all goal templates */
2264
+ async goalTemplateList(): Promise<Record> {
2265
+ return this.makeRequest<Record>(
2266
+ "GET",
2267
+ "/api/chat/goal-templates",
2268
+ undefined,
2269
+ 0,
2270
+ true,
2271
+ );
2272
+ }
2273
+
2274
+ /** Get a goal template by ID */
2275
+ async goalTemplateGet(id: string): Promise<Record> {
2276
+ return this.makeRequest<Record>(
2277
+ "GET",
2278
+ `/api/chat/goal-templates/${encodeURIComponent(id)}`,
2279
+ undefined,
2280
+ 0,
2281
+ true,
2282
+ );
2283
+ }
2284
+
2285
+ /** Update a goal template by ID */
2286
+ async goalTemplateUpdate(id: string, data: Record): Promise<Record> {
2287
+ return this.makeRequest<Record>(
2288
+ "PUT",
2289
+ `/api/chat/goal-templates/${encodeURIComponent(id)}`,
2290
+ data,
2291
+ 0,
2292
+ true,
2293
+ );
2294
+ }
2295
+
2296
+ /** Delete a goal template by ID */
2297
+ async goalTemplateDelete(id: string): Promise<void> {
2298
+ await this.makeRequest<void>(
2299
+ "DELETE",
2300
+ `/api/chat/goal-templates/${encodeURIComponent(id)}`,
2301
+ undefined,
2302
+ 0,
2303
+ true,
2304
+ );
2305
+ }
2306
+
2307
+ /** Search goals */
2308
+ async goalSearch(query: string): Promise<Record> {
2309
+ const params = new URLSearchParams({ q: query });
2310
+ return this.makeRequest<Record>(
2311
+ "GET",
2312
+ `/api/chat/goals/search?${params}`,
2313
+ undefined,
2314
+ 0,
2315
+ true,
2316
+ );
2317
+ }
2318
+
2319
+ /** Mark a goal as complete (status -> pending_review) */
2320
+ async goalComplete(id: string, data: Record): Promise<Record> {
2321
+ return this.makeRequest<Record>(
2322
+ "POST",
2323
+ `/api/chat/goals/${encodeURIComponent(id)}/complete`,
2324
+ data,
2325
+ 0,
2326
+ true,
2327
+ );
2328
+ }
2329
+
2330
+ /** Approve a goal (status -> in_progress) */
2331
+ async goalApprove(id: string): Promise<Record> {
2332
+ return this.makeRequest<Record>(
2333
+ "POST",
2334
+ `/api/chat/goals/${encodeURIComponent(id)}/approve`,
2335
+ undefined,
2336
+ 0,
2337
+ true,
2338
+ );
2339
+ }
2340
+
2341
+ /** Reject a goal (status -> failed) */
2342
+ async goalReject(id: string, data: Record): Promise<Record> {
2343
+ return this.makeRequest<Record>(
2344
+ "POST",
2345
+ `/api/chat/goals/${encodeURIComponent(id)}/reject`,
2346
+ data,
2347
+ 0,
2348
+ true,
2349
+ );
2350
+ }
2351
+
2352
+ /** Start a goal step (status -> in_progress) */
2353
+ async goalStepStart(id: string, stepIndex: number): Promise<Record> {
2354
+ return this.makeRequest<Record>(
2355
+ "POST",
2356
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/start`,
2357
+ undefined,
2358
+ 0,
2359
+ true,
2360
+ );
2361
+ }
2362
+
2363
+ /** Complete a goal step with result */
2364
+ async goalStepComplete(
2365
+ id: string,
2366
+ stepIndex: number,
2367
+ data: Record,
2368
+ ): Promise<Record> {
2369
+ return this.makeRequest<Record>(
2370
+ "POST",
2371
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/complete`,
2372
+ data,
2373
+ 0,
2374
+ true,
2375
+ );
2376
+ }
2377
+
2378
+ /** Fail a goal step with error */
2379
+ async goalStepFail(
2380
+ id: string,
2381
+ stepIndex: number,
2382
+ data: Record,
2383
+ ): Promise<Record> {
2384
+ return this.makeRequest<Record>(
2385
+ "POST",
2386
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/fail`,
2387
+ data,
2388
+ 0,
2389
+ true,
2390
+ );
2391
+ }
2392
+
2393
+ // ========================================================================
2394
+ // TASK API
2395
+ // ========================================================================
2396
+
2397
+ /** Create a new scheduled task */
2398
+ async taskCreate(data: Record): Promise<Record> {
2399
+ return this.makeRequest<Record>("POST", "/api/chat/tasks", data, 0, true);
2400
+ }
2401
+
2402
+ /** List all scheduled tasks */
2403
+ async taskList(): Promise<Record> {
2404
+ return this.makeRequest<Record>(
2405
+ "GET",
2406
+ "/api/chat/tasks",
2407
+ undefined,
2408
+ 0,
2409
+ true,
2410
+ );
2411
+ }
2412
+
2413
+ /** Get a task by ID */
2414
+ async taskGet(id: string): Promise<Record> {
2415
+ return this.makeRequest<Record>(
2416
+ "GET",
2417
+ `/api/chat/tasks/${encodeURIComponent(id)}`,
2418
+ undefined,
2419
+ 0,
2420
+ true,
2421
+ );
2422
+ }
2423
+
2424
+ /** Update a task by ID */
2425
+ async taskUpdate(id: string, data: Record): Promise<Record> {
2426
+ return this.makeRequest<Record>(
2427
+ "PUT",
2428
+ `/api/chat/tasks/${encodeURIComponent(id)}`,
2429
+ data,
2430
+ 0,
2431
+ true,
2432
+ );
2433
+ }
2434
+
2435
+ /** Delete a task by ID */
2436
+ async taskDelete(id: string): Promise<void> {
2437
+ await this.makeRequest<void>(
2438
+ "DELETE",
2439
+ `/api/chat/tasks/${encodeURIComponent(id)}`,
2440
+ undefined,
2441
+ 0,
2442
+ true,
2443
+ );
2444
+ }
2445
+
2446
+ /** Get tasks that are due at the given time */
2447
+ async taskDue(now: string): Promise<Record> {
2448
+ const params = new URLSearchParams({ now });
2449
+ return this.makeRequest<Record>(
2450
+ "GET",
2451
+ `/api/chat/tasks/due?${params}`,
2452
+ undefined,
2453
+ 0,
2454
+ true,
2455
+ );
2456
+ }
2457
+
2458
+ /** Start a task (status -> running) */
2459
+ async taskStart(id: string): Promise<Record> {
2460
+ return this.makeRequest<Record>(
2461
+ "POST",
2462
+ `/api/chat/tasks/${encodeURIComponent(id)}/start`,
2463
+ undefined,
2464
+ 0,
2465
+ true,
2466
+ );
2467
+ }
2468
+
2469
+ /** Mark a task as succeeded */
2470
+ async taskSucceed(id: string, data: Record): Promise<Record> {
2471
+ return this.makeRequest<Record>(
2472
+ "POST",
2473
+ `/api/chat/tasks/${encodeURIComponent(id)}/succeed`,
2474
+ data,
2475
+ 0,
2476
+ true,
2477
+ );
2478
+ }
2479
+
2480
+ /** Mark a task as failed */
2481
+ async taskFail(id: string, data: Record): Promise<Record> {
2482
+ return this.makeRequest<Record>(
2483
+ "POST",
2484
+ `/api/chat/tasks/${encodeURIComponent(id)}/fail`,
2485
+ data,
2486
+ 0,
2487
+ true,
2488
+ );
2489
+ }
2490
+
2491
+ /** Pause a task */
2492
+ async taskPause(id: string): Promise<Record> {
2493
+ return this.makeRequest<Record>(
2494
+ "POST",
2495
+ `/api/chat/tasks/${encodeURIComponent(id)}/pause`,
2496
+ undefined,
2497
+ 0,
2498
+ true,
2499
+ );
2500
+ }
2501
+
2502
+ /** Resume a paused task */
2503
+ async taskResume(id: string, data: Record): Promise<Record> {
2504
+ return this.makeRequest<Record>(
2505
+ "POST",
2506
+ `/api/chat/tasks/${encodeURIComponent(id)}/resume`,
2507
+ data,
2508
+ 0,
2509
+ true,
2510
+ );
2511
+ }
2512
+
2513
+ // ========================================================================
2514
+ // AGENT API
2515
+ // ========================================================================
2516
+
2517
+ /** Create a new agent */
2518
+ async agentCreate(data: Record): Promise<Record> {
2519
+ return this.makeRequest<Record>("POST", "/api/chat/agents", data, 0, true);
2520
+ }
2521
+
2522
+ /** List all agents */
2523
+ async agentList(): Promise<Record> {
2524
+ return this.makeRequest<Record>(
2525
+ "GET",
2526
+ "/api/chat/agents",
2527
+ undefined,
2528
+ 0,
2529
+ true,
2530
+ );
2531
+ }
2532
+
2533
+ /** Get an agent by ID */
2534
+ async agentGet(id: string): Promise<Record> {
2535
+ return this.makeRequest<Record>(
2536
+ "GET",
2537
+ `/api/chat/agents/${encodeURIComponent(id)}`,
2538
+ undefined,
2539
+ 0,
2540
+ true,
2541
+ );
2542
+ }
2543
+
2544
+ /** Get an agent by name */
2545
+ async agentGetByName(name: string): Promise<Record> {
2546
+ return this.makeRequest<Record>(
2547
+ "GET",
2548
+ `/api/chat/agents/by-name/${encodeURIComponent(name)}`,
2549
+ undefined,
2550
+ 0,
2551
+ true,
2552
+ );
2553
+ }
2554
+
2555
+ /** Update an agent by ID */
2556
+ async agentUpdate(id: string, data: Record): Promise<Record> {
2557
+ return this.makeRequest<Record>(
2558
+ "PUT",
2559
+ `/api/chat/agents/${encodeURIComponent(id)}`,
2560
+ data,
2561
+ 0,
2562
+ true,
2563
+ );
2564
+ }
2565
+
2566
+ /** Delete an agent by ID */
2567
+ async agentDelete(id: string): Promise<void> {
2568
+ await this.makeRequest<void>(
2569
+ "DELETE",
2570
+ `/api/chat/agents/${encodeURIComponent(id)}`,
2571
+ undefined,
2572
+ 0,
2573
+ true,
2574
+ );
2575
+ }
2576
+
2577
+ /** Get agents by deployment ID */
2578
+ async agentsByDeployment(deploymentId: string): Promise<Record> {
2579
+ return this.makeRequest<Record>(
2580
+ "GET",
2581
+ `/api/chat/agents/by-deployment/${encodeURIComponent(deploymentId)}`,
2582
+ undefined,
2583
+ 0,
2584
+ true,
2585
+ );
2586
+ }
2587
+
2588
+ // ========================================================================
2589
+ // KV DOCUMENT LINKING
2590
+ // ========================================================================
2591
+
2592
+ /** Get documents linked to a KV key */
2593
+ async kvGetLinks(key: string): Promise<Record> {
2594
+ return this.makeRequest<Record>(
2595
+ "GET",
2596
+ `/api/kv/links/${encodeURIComponent(key)}`,
2597
+ undefined,
2598
+ 0,
2599
+ true,
2600
+ );
2601
+ }
2602
+
2603
+ /** Link a document to a KV key */
2604
+ async kvLink(
2605
+ key: string,
2606
+ collection: string,
2607
+ documentId: string,
2608
+ ): Promise<Record> {
2609
+ return this.makeRequest<Record>(
2610
+ "POST",
2611
+ `/api/kv/link`,
2612
+ { key, collection, document_id: documentId },
2613
+ 0,
2614
+ true,
2615
+ );
2616
+ }
2617
+
2618
+ /** Unlink a document from a KV key */
2619
+ async kvUnlink(
2620
+ key: string,
2621
+ collection: string,
2622
+ documentId: string,
2623
+ ): Promise<Record> {
2624
+ return this.makeRequest<Record>(
2625
+ "POST",
2626
+ `/api/kv/unlink`,
2627
+ { key, collection, document_id: documentId },
2628
+ 0,
2629
+ true,
2630
+ );
2631
+ }
2632
+
2633
+ // ========================================================================
2634
+ // SCHEDULE MANAGEMENT
2635
+ // ========================================================================
2636
+
2637
+ /** Create a new schedule */
2638
+ async createSchedule(data: Record): Promise<Record> {
2639
+ return this.makeRequest<Record>("POST", `/api/schedules`, data, 0, true);
2640
+ }
2641
+
2642
+ /** List all schedules */
2643
+ async listSchedules(): Promise<Record> {
2644
+ return this.makeRequest<Record>(
2645
+ "GET",
2646
+ `/api/schedules`,
2647
+ undefined,
2648
+ 0,
2649
+ true,
2650
+ );
2651
+ }
2652
+
2653
+ /** Get a schedule by ID */
2654
+ async getSchedule(id: string): Promise<Record> {
2655
+ return this.makeRequest<Record>(
2656
+ "GET",
2657
+ `/api/schedules/${encodeURIComponent(id)}`,
2658
+ undefined,
2659
+ 0,
2660
+ true,
2661
+ );
2662
+ }
2663
+
2664
+ /** Update a schedule */
2665
+ async updateSchedule(id: string, data: Record): Promise<Record> {
2666
+ return this.makeRequest<Record>(
2667
+ "PUT",
2668
+ `/api/schedules/${encodeURIComponent(id)}`,
2669
+ data,
2670
+ 0,
2671
+ true,
2672
+ );
2673
+ }
2674
+
2675
+ /** Delete a schedule */
2676
+ async deleteSchedule(id: string): Promise<void> {
2677
+ await this.makeRequest<void>(
2678
+ "DELETE",
2679
+ `/api/schedules/${encodeURIComponent(id)}`,
2680
+ undefined,
2681
+ 0,
2682
+ true,
2683
+ );
2684
+ }
2685
+
2686
+ /** Pause a schedule */
2687
+ async pauseSchedule(id: string): Promise<Record> {
2688
+ return this.makeRequest<Record>(
2689
+ "POST",
2690
+ `/api/schedules/${encodeURIComponent(id)}/pause`,
2691
+ undefined,
2692
+ 0,
2693
+ true,
2694
+ );
2695
+ }
2696
+
2697
+ /** Resume a schedule */
2698
+ async resumeSchedule(id: string): Promise<Record> {
2699
+ return this.makeRequest<Record>(
2700
+ "POST",
2701
+ `/api/schedules/${encodeURIComponent(id)}/resume`,
2702
+ undefined,
2703
+ 0,
2704
+ true,
2705
+ );
2706
+ }
2707
+
2708
+ // ========================================================================
2709
+ // COLLECTION UTILITIES
2710
+ // ========================================================================
2711
+
2712
+ /**
2713
+ * Check if a collection exists
2714
+ * @param collection - Collection name to check
2715
+ * @returns true if the collection exists, false otherwise
2716
+ */
2717
+ async collectionExists(collection: string): Promise<boolean> {
2718
+ try {
2719
+ const collections = await this.listCollections();
2720
+ return collections.includes(collection);
2721
+ } catch {
2722
+ return false;
2723
+ }
2724
+ }
2725
+
2726
+ /**
2727
+ * Count documents in a collection
2728
+ * @param collection - Collection name
2729
+ * @returns Number of documents in the collection
2730
+ */
2731
+ async countDocuments(collection: string): Promise<number> {
2732
+ const query = new QueryBuilder().limit(100000).build();
2733
+ const records = await this.find(collection, query);
2734
+ return records.length;
2735
+ }
2736
+
2737
+ /**
2738
+ * Create a WebSocket client
2739
+ */
2740
+ websocket(wsURL: string): WebSocketClient {
2741
+ return new WebSocketClient(wsURL, this.token!);
2742
+ }
2743
+
2744
+ // ========== RAG Helper Methods ==========
2745
+
2746
+ /**
2747
+ * Generate embeddings for a single text
2748
+ *
2749
+ * @param text - The text to generate embeddings for
2750
+ * @param model - The embedding model to use (e.g., "text-embedding-3-small")
2751
+ * @returns Array of floats representing the embedding vector
2752
+ *
2753
+ * @example
2754
+ * ```typescript
2755
+ * const embedding = await client.embed(
2756
+ * "Hello world",
2757
+ * "text-embedding-3-small"
2758
+ * );
2759
+ * console.log(`Generated ${embedding.length} dimensions`);
2760
+ * ```
2761
+ */
2762
+ async embed(text: string, model: string): Promise<number[]> {
2763
+ const response = await this.embedRequest({ text, model });
2764
+ if (response.embeddings.length === 0) {
2765
+ throw new Error("No embedding returned");
2766
+ }
2767
+ return response.embeddings[0];
2768
+ }
2769
+
2770
+ /**
2771
+ * Generate embeddings for multiple texts in a single batch request
2772
+ *
2773
+ * @param texts - Array of texts to generate embeddings for
2774
+ * @param model - The embedding model to use
2775
+ * @returns Array of embedding vectors
2776
+ */
1805
2777
  async embedBatch(texts: string[], model: string): Promise<number[][]> {
1806
2778
  const response = await this.embedRequest({ texts, model });
1807
2779
  return response.embeddings;
@@ -1916,26 +2888,132 @@ export class EkoDBClient {
1916
2888
  }
1917
2889
  }
1918
2890
 
2891
+ /** Mutation notification from a subscription. */
2892
+ export interface MutationNotification {
2893
+ collection: string;
2894
+ event: string;
2895
+ recordIds: string[];
2896
+ records?: any;
2897
+ timestamp: string;
2898
+ }
2899
+
2900
+ /** A chunk/event from a streaming chat response. */
2901
+ export type ChatStreamEvent =
2902
+ | { type: "chunk"; content: string }
2903
+ | {
2904
+ type: "end";
2905
+ messageId: string;
2906
+ tokenUsage?: any;
2907
+ toolCallHistory?: any;
2908
+ executionTimeMs: number;
2909
+ /** Model's context window size in tokens. */
2910
+ contextWindow?: number;
2911
+ }
2912
+ | {
2913
+ type: "toolCall";
2914
+ chatId: string;
2915
+ callId: string;
2916
+ toolName: string;
2917
+ arguments: any;
2918
+ }
2919
+ | { type: "error"; error: string };
2920
+
2921
+ /** Definition for a client-side tool the LLM can call. */
2922
+ export interface ClientToolDefinition {
2923
+ name: string;
2924
+ description: string;
2925
+ parameters: any;
2926
+ }
2927
+
2928
+ /** Options for chatSend. */
2929
+ export interface ChatSendOptions {
2930
+ bypassRipple?: boolean;
2931
+ clientTools?: ClientToolDefinition[];
2932
+ maxIterations?: number;
2933
+ confirmTools?: string[];
2934
+ excludeTools?: string[];
2935
+ }
2936
+
2937
+ /** Options for subscribe. */
2938
+ export interface SubscribeOptions {
2939
+ filterField?: string;
2940
+ filterValue?: string;
2941
+ }
2942
+
2943
+ /** EventEmitter-like interface for subscriptions and chat streams. */
2944
+ export class EventStream<_T = unknown> {
2945
+ private listeners: Map<string, Array<(data: any) => void>> = new Map();
2946
+ private _closed = false;
2947
+
2948
+ on(event: string, listener: (data: any) => void): this {
2949
+ if (!this.listeners.has(event)) {
2950
+ this.listeners.set(event, []);
2951
+ }
2952
+ this.listeners.get(event)!.push(listener);
2953
+ return this;
2954
+ }
2955
+
2956
+ /** @internal */
2957
+ emit(event: string, data?: any): void {
2958
+ const handlers = this.listeners.get(event);
2959
+ if (handlers) {
2960
+ for (const handler of handlers) {
2961
+ handler(data);
2962
+ }
2963
+ }
2964
+ }
2965
+
2966
+ get closed(): boolean {
2967
+ return this._closed;
2968
+ }
2969
+
2970
+ /** @internal */
2971
+ close(): void {
2972
+ this._closed = true;
2973
+ this.emit("close");
2974
+ }
2975
+ }
2976
+
1919
2977
  /**
1920
- * WebSocket client for real-time queries
2978
+ * WebSocket client for real-time queries, subscriptions, and chat streaming.
1921
2979
  */
1922
2980
  export class WebSocketClient {
1923
2981
  private wsURL: string;
1924
2982
  private token: string;
1925
2983
  private ws: any = null;
2984
+ private dispatcherRunning = false;
2985
+
2986
+ // Dispatcher state
2987
+ private pendingRequests: Map<
2988
+ string,
2989
+ { resolve: (value: any) => void; reject: (reason: any) => void }
2990
+ > = new Map();
2991
+ private subscriptions: Map<string, EventStream<MutationNotification>> =
2992
+ new Map();
2993
+ private chatStreams: Map<string, EventStream<ChatStreamEvent>> = new Map();
2994
+ private registerToolsAck: {
2995
+ resolve: (value: any) => void;
2996
+ reject: (reason: any) => void;
2997
+ } | null = null;
1926
2998
 
1927
2999
  constructor(wsURL: string, token: string) {
1928
3000
  this.wsURL = wsURL;
1929
3001
  this.token = token;
1930
3002
  }
1931
3003
 
3004
+ private messageCounter = 0;
3005
+
3006
+ private genMessageId(): string {
3007
+ const counter = this.messageCounter++;
3008
+ return `${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`;
3009
+ }
3010
+
1932
3011
  /**
1933
- * Connect to WebSocket
3012
+ * Connect and start the dispatcher.
1934
3013
  */
1935
- private async connect(): Promise<void> {
1936
- if (this.ws) return;
3014
+ private async ensureConnected(): Promise<void> {
3015
+ if (this.ws && this.dispatcherRunning) return;
1937
3016
 
1938
- // Dynamic import for Node.js WebSocket
1939
3017
  const WebSocket = (await import("ws")).default;
1940
3018
 
1941
3019
  let url = this.wsURL;
@@ -1949,49 +3027,369 @@ export class WebSocketClient {
1949
3027
  },
1950
3028
  });
1951
3029
 
1952
- return new Promise((resolve, reject) => {
3030
+ await new Promise<void>((resolve, reject) => {
1953
3031
  this.ws.on("open", () => resolve());
1954
3032
  this.ws.on("error", (err: Error) => reject(err));
1955
3033
  });
3034
+
3035
+ this.spawnDispatcher();
3036
+ }
3037
+
3038
+ private spawnDispatcher(): void {
3039
+ if (this.dispatcherRunning) return;
3040
+ this.dispatcherRunning = true;
3041
+
3042
+ this.ws.on("message", (data: Buffer) => {
3043
+ try {
3044
+ const msg = JSON.parse(data.toString());
3045
+ this.routeMessage(msg);
3046
+ } catch {
3047
+ // Ignore malformed messages
3048
+ }
3049
+ });
3050
+
3051
+ this.ws.on("close", () => {
3052
+ this.dispatcherRunning = false;
3053
+ // Notify all pending requests
3054
+ for (const [, pending] of this.pendingRequests) {
3055
+ pending.reject(new Error("WebSocket connection closed"));
3056
+ }
3057
+ this.pendingRequests.clear();
3058
+ // Close all chat streams
3059
+ for (const [, stream] of this.chatStreams) {
3060
+ stream.emit("event", { type: "error", error: "Connection closed" });
3061
+ stream.close();
3062
+ }
3063
+ this.chatStreams.clear();
3064
+ // Close all subscriptions
3065
+ for (const [, stream] of this.subscriptions) {
3066
+ stream.close();
3067
+ }
3068
+ this.subscriptions.clear();
3069
+ this.ws = null;
3070
+ });
3071
+ }
3072
+
3073
+ private routeMessage(msg: any): void {
3074
+ switch (msg.type) {
3075
+ case "Success":
3076
+ case "Error": {
3077
+ // Try messageId from top-level, then from payload
3078
+ const messageId =
3079
+ msg.messageId ||
3080
+ msg.message_id ||
3081
+ msg.payload?.message_id ||
3082
+ msg.payload?.messageId;
3083
+ let matched = false;
3084
+ if (messageId && this.pendingRequests.has(messageId)) {
3085
+ const pending = this.pendingRequests.get(messageId)!;
3086
+ this.pendingRequests.delete(messageId);
3087
+ if (msg.type === "Error") {
3088
+ pending.reject(new Error(msg.message || "Unknown error"));
3089
+ } else {
3090
+ pending.resolve(msg.payload);
3091
+ }
3092
+ matched = true;
3093
+ }
3094
+ if (!matched && this.registerToolsAck) {
3095
+ const ack = this.registerToolsAck;
3096
+ this.registerToolsAck = null;
3097
+ if (msg.type === "Error") {
3098
+ ack.reject(new Error(msg.message || "Tool registration failed"));
3099
+ } else {
3100
+ ack.resolve(msg.payload);
3101
+ }
3102
+ matched = true;
3103
+ }
3104
+ // Server doesn't echo messageId — if there's exactly one pending
3105
+ // request, deliver the response to it (sequential request/response).
3106
+ if (!matched && this.pendingRequests.size === 1) {
3107
+ const entry = this.pendingRequests.entries().next().value!;
3108
+ const key = entry[0];
3109
+ const pending = entry[1];
3110
+ this.pendingRequests.delete(key);
3111
+ if (msg.type === "Error") {
3112
+ pending.reject(new Error(msg.message || "Unknown error"));
3113
+ } else {
3114
+ pending.resolve(msg.payload);
3115
+ }
3116
+ }
3117
+ break;
3118
+ }
3119
+
3120
+ case "MutationNotification": {
3121
+ const payload = msg.payload;
3122
+ const notification: MutationNotification = {
3123
+ collection: payload.collection,
3124
+ event: payload.event,
3125
+ recordIds: payload.record_ids || payload.recordIds || [],
3126
+ records: payload.records,
3127
+ timestamp: payload.timestamp,
3128
+ };
3129
+ for (const [collection, stream] of this.subscriptions) {
3130
+ if (collection === notification.collection) {
3131
+ stream.emit("mutation", notification);
3132
+ }
3133
+ }
3134
+ break;
3135
+ }
3136
+
3137
+ case "ChatStreamChunk": {
3138
+ const chatId = msg.payload?.chat_id || msg.payload?.chatId;
3139
+ const stream = this.chatStreams.get(chatId);
3140
+ if (stream) {
3141
+ stream.emit("event", {
3142
+ type: "chunk",
3143
+ content: msg.payload.content,
3144
+ } as ChatStreamEvent);
3145
+ }
3146
+ break;
3147
+ }
3148
+
3149
+ case "ChatStreamEnd": {
3150
+ const chatId = msg.payload?.chat_id || msg.payload?.chatId;
3151
+ const stream = this.chatStreams.get(chatId);
3152
+ if (stream) {
3153
+ stream.emit("event", {
3154
+ type: "end",
3155
+ messageId: msg.payload.message_id || msg.payload.messageId || "",
3156
+ tokenUsage: msg.payload.token_usage || msg.payload.tokenUsage,
3157
+ toolCallHistory:
3158
+ msg.payload.tool_call_history || msg.payload.toolCallHistory,
3159
+ executionTimeMs:
3160
+ msg.payload.execution_time_ms || msg.payload.executionTimeMs || 0,
3161
+ contextWindow:
3162
+ msg.payload.context_window || msg.payload.contextWindow,
3163
+ } as ChatStreamEvent);
3164
+ this.chatStreams.delete(chatId);
3165
+ stream.close();
3166
+ }
3167
+ break;
3168
+ }
3169
+
3170
+ case "ChatStreamError": {
3171
+ const chatId = msg.payload?.chat_id || msg.payload?.chatId;
3172
+ const stream = this.chatStreams.get(chatId);
3173
+ if (stream) {
3174
+ stream.emit("event", {
3175
+ type: "error",
3176
+ error: msg.payload.error || msg.payload.message || "Unknown error",
3177
+ } as ChatStreamEvent);
3178
+ this.chatStreams.delete(chatId);
3179
+ stream.close();
3180
+ }
3181
+ break;
3182
+ }
3183
+
3184
+ case "ClientToolCall": {
3185
+ const chatId = msg.payload?.chat_id || msg.payload?.chatId;
3186
+ const stream = this.chatStreams.get(chatId);
3187
+ if (stream) {
3188
+ stream.emit("event", {
3189
+ type: "toolCall",
3190
+ chatId,
3191
+ callId: msg.payload.call_id || msg.payload.callId,
3192
+ toolName: msg.payload.tool_name || msg.payload.toolName,
3193
+ arguments: msg.payload.arguments,
3194
+ } as ChatStreamEvent);
3195
+ }
3196
+ break;
3197
+ }
3198
+ }
3199
+ }
3200
+
3201
+ private async sendRequest(request: any): Promise<any> {
3202
+ await this.ensureConnected();
3203
+ const messageId = request.messageId || request.message_id;
3204
+
3205
+ return new Promise((resolve, reject) => {
3206
+ this.pendingRequests.set(messageId, { resolve, reject });
3207
+ try {
3208
+ this.ws.send(JSON.stringify(request));
3209
+ } catch (err) {
3210
+ this.pendingRequests.delete(messageId);
3211
+ reject(err);
3212
+ }
3213
+ });
1956
3214
  }
1957
3215
 
1958
3216
  /**
1959
- * Find all records in a collection via WebSocket
3217
+ * Find all records in a collection via WebSocket.
1960
3218
  */
1961
3219
  async findAll(collection: string): Promise<Record[]> {
1962
- await this.connect();
1963
-
1964
- const messageId = Date.now().toString();
1965
- const request = {
3220
+ const messageId = this.genMessageId();
3221
+ const payload = await this.sendRequest({
1966
3222
  type: "FindAll",
1967
3223
  messageId,
1968
3224
  payload: { collection },
3225
+ });
3226
+ return payload?.data || [];
3227
+ }
3228
+
3229
+ /**
3230
+ * Subscribe to mutation notifications on a collection.
3231
+ * Returns an EventStream that emits "mutation" events.
3232
+ */
3233
+ async subscribe(
3234
+ collection: string,
3235
+ options?: SubscribeOptions,
3236
+ ): Promise<EventStream<MutationNotification>> {
3237
+ await this.ensureConnected();
3238
+
3239
+ if (this.subscriptions.has(collection)) {
3240
+ throw new Error(`Already subscribed to collection "${collection}"`);
3241
+ }
3242
+
3243
+ const messageId = this.genMessageId();
3244
+ const stream = new EventStream<MutationNotification>();
3245
+ this.subscriptions.set(collection, stream);
3246
+
3247
+ const request: any = {
3248
+ type: "Subscribe",
3249
+ messageId,
3250
+ payload: {
3251
+ collection,
3252
+ ...(options?.filterField && { filter_field: options.filterField }),
3253
+ ...(options?.filterValue && { filter_value: options.filterValue }),
3254
+ },
1969
3255
  };
1970
3256
 
1971
- return new Promise((resolve, reject) => {
3257
+ // Send subscribe request and wait for ack
3258
+ try {
3259
+ await this.sendRequest(request);
3260
+ } catch (err) {
3261
+ this.subscriptions.delete(collection);
3262
+ throw err;
3263
+ }
3264
+ return stream;
3265
+ }
3266
+
3267
+ /**
3268
+ * Send a chat message and receive a streaming response.
3269
+ * Returns an EventStream that emits "event" with ChatStreamEvent objects.
3270
+ */
3271
+ async chatSend(
3272
+ chatId: string,
3273
+ message: string,
3274
+ options?: ChatSendOptions,
3275
+ ): Promise<EventStream<ChatStreamEvent>> {
3276
+ await this.ensureConnected();
3277
+
3278
+ if (this.chatStreams.has(chatId)) {
3279
+ throw new Error(`Chat stream already active for chatId "${chatId}"`);
3280
+ }
3281
+
3282
+ const stream = new EventStream<ChatStreamEvent>();
3283
+ this.chatStreams.set(chatId, stream);
3284
+
3285
+ const request: any = {
3286
+ type: "ChatSend",
3287
+ payload: {
3288
+ chat_id: chatId,
3289
+ message,
3290
+ ...(options?.bypassRipple != null && {
3291
+ bypass_ripple: options.bypassRipple,
3292
+ }),
3293
+ ...(options?.clientTools && { client_tools: options.clientTools }),
3294
+ ...(options?.maxIterations != null && {
3295
+ max_iterations: options.maxIterations,
3296
+ }),
3297
+ ...(options?.confirmTools && { confirm_tools: options.confirmTools }),
3298
+ ...(options?.excludeTools && { exclude_tools: options.excludeTools }),
3299
+ },
3300
+ };
3301
+
3302
+ this.ws.send(JSON.stringify(request));
3303
+ return stream;
3304
+ }
3305
+
3306
+ /**
3307
+ * Register client-side tools for a chat session.
3308
+ */
3309
+ async registerClientTools(
3310
+ chatId: string,
3311
+ tools: ClientToolDefinition[],
3312
+ ): Promise<void> {
3313
+ await this.ensureConnected();
3314
+
3315
+ const request = {
3316
+ type: "RegisterClientTools",
3317
+ payload: {
3318
+ chat_id: chatId,
3319
+ tools,
3320
+ },
3321
+ };
3322
+
3323
+ await new Promise<void>((resolve, reject) => {
3324
+ this.registerToolsAck = {
3325
+ resolve: () => resolve(),
3326
+ reject: (err) => reject(err),
3327
+ };
1972
3328
  this.ws.send(JSON.stringify(request));
3329
+ });
3330
+ }
1973
3331
 
1974
- this.ws.once("message", (data: Buffer) => {
1975
- const response = JSON.parse(data.toString());
3332
+ /**
3333
+ * Send a tool result back to the server during a chat stream.
3334
+ */
3335
+ async sendToolResult(
3336
+ chatId: string,
3337
+ callId: string,
3338
+ success: boolean,
3339
+ result?: any,
3340
+ error?: string,
3341
+ ): Promise<void> {
3342
+ await this.ensureConnected();
1976
3343
 
1977
- if (response.type === "Error") {
1978
- reject(new Error(response.message));
1979
- } else {
1980
- resolve(response.payload?.data || []);
1981
- }
1982
- });
3344
+ const request = {
3345
+ type: "ClientToolResult",
3346
+ payload: {
3347
+ chat_id: chatId,
3348
+ call_id: callId,
3349
+ success,
3350
+ ...(result !== undefined && { result }),
3351
+ ...(error !== undefined && { error }),
3352
+ },
3353
+ };
3354
+
3355
+ this.ws.send(JSON.stringify(request));
3356
+ }
1983
3357
 
1984
- this.ws.once("error", reject);
3358
+ /**
3359
+ * Stateless raw LLM completion via WebSocket.
3360
+ *
3361
+ * Sends a RawComplete message and waits for the Success response.
3362
+ * Preferred over HTTP for deployed instances: the persistent WSS
3363
+ * connection is already authenticated and won't be killed by reverse
3364
+ * proxy timeouts.
3365
+ */
3366
+ async rawCompletion(
3367
+ request: RawCompletionRequest,
3368
+ ): Promise<RawCompletionResponse> {
3369
+ await this.ensureConnected();
3370
+ const messageId = this.genMessageId();
3371
+ const payload = await this.sendRequest({
3372
+ type: "RawComplete",
3373
+ messageId,
3374
+ payload: {
3375
+ system_prompt: request.system_prompt,
3376
+ message: request.message,
3377
+ ...(request.provider && { provider: request.provider }),
3378
+ ...(request.model && { model: request.model }),
3379
+ ...(request.max_tokens != null && { max_tokens: request.max_tokens }),
3380
+ },
1985
3381
  });
3382
+ return { content: payload?.data?.content || "" };
1986
3383
  }
1987
3384
 
1988
3385
  /**
1989
- * Close the WebSocket connection
3386
+ * Close the WebSocket connection.
1990
3387
  */
1991
3388
  close(): void {
1992
3389
  if (this.ws) {
1993
3390
  this.ws.close();
1994
3391
  this.ws = null;
3392
+ this.dispatcherRunning = false;
1995
3393
  }
1996
3394
  }
1997
3395
  }