@cosmoops/connect-sdk 0.28.0 → 0.30.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.
@@ -30,6 +30,10 @@ export class AiResource {
30
30
  json: {
31
31
  workspaceId: input.workspaceId,
32
32
  messages: input.messages,
33
+ // Omitted when absent, which is how a caller opts out of history: no
34
+ // id, nothing stored. Connect also stores nothing for a call carrying
35
+ // no `asUser`, because a conversation belongs to a person.
36
+ ...(input.conversationId ? { conversationId: input.conversationId } : {}),
33
37
  // Omitted entirely when absent, rather than sent as `undefined` - an
34
38
  // application that describes no screens should send no field.
35
39
  ...(input.screens ? { screens: input.screens } : {}),
@@ -40,6 +44,57 @@ export class AiResource {
40
44
  signal: options.signal,
41
45
  });
42
46
  }
47
+ /**
48
+ * The signed-in user's past conversations in your application, most recent
49
+ * first.
50
+ *
51
+ * `asUser` is required rather than optional here, unlike on `chat`: the rows
52
+ * are scoped to a person, and there is no application-wide view of them to
53
+ * fall back to. Connect reads the user and the workspace from the token.
54
+ *
55
+ * Cursor-paginated with no total - the list is ordered by last use, which
56
+ * moves while it is being read.
57
+ */
58
+ listConversations(input) {
59
+ const query = new URLSearchParams();
60
+ if (input.limit !== undefined)
61
+ query.set("limit", String(input.limit));
62
+ if (input.cursor)
63
+ query.set("cursor", input.cursor);
64
+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
65
+ return request(this.transport, {
66
+ method: "GET",
67
+ path: `/ai/conversations${suffix}`,
68
+ headers: actingAs(input.asUser),
69
+ });
70
+ }
71
+ /**
72
+ * One stored transcript, oldest turn first.
73
+ *
74
+ * `turns[].parts` comes back exactly as the model produced it - prose,
75
+ * reasoning and tool calls in order - so it can be handed straight back to a
76
+ * chat UI. Not this user's, or not your application's, is a `404` either way.
77
+ */
78
+ getConversation(input) {
79
+ return request(this.transport, {
80
+ method: "GET",
81
+ path: `/ai/conversations/${encodeURIComponent(input.conversationId)}`,
82
+ headers: actingAs(input.asUser),
83
+ });
84
+ }
85
+ /**
86
+ * Deletes a conversation and every turn beneath it.
87
+ *
88
+ * Idempotent - one already gone answers `{ deleted: true }`, so a retry after
89
+ * a dropped response does not read as a failure.
90
+ */
91
+ deleteConversation(input) {
92
+ return request(this.transport, {
93
+ method: "DELETE",
94
+ path: `/ai/conversations/${encodeURIComponent(input.conversationId)}`,
95
+ headers: actingAs(input.asUser),
96
+ });
97
+ }
43
98
  /** Extract structured JSON from a file or image URL, against a platform extraction schema referenced by id. */
44
99
  async extract(input) {
45
100
  const result = await request(this.transport, {
@@ -0,0 +1,66 @@
1
+ import { request } from "../http.js";
2
+ import { actingAs } from "./acting-as.js";
3
+ /**
4
+ * What a workspace's agents are saying in writing.
5
+ *
6
+ * **The sibling of `calls`, with one difference that decides how it is
7
+ * gated: these are the *workspace's* threads, not your application's.** A call
8
+ * arrived on a channel you hold, so `calls.list` is scoped to your
9
+ * application. A conversation lives in the workspace's own subtree and belongs
10
+ * to it the way its contact book does - so reading one needs a live
11
+ * subscription in that workspace, and, when the call comes from a browser
12
+ * through the bridge, the signed-in member's own `agent:read`.
13
+ *
14
+ * Read-only, and deliberately. Sending on a thread is not a missing verb:
15
+ * inside WhatsApp's 24-hour window a reply is the agent's to make, outside it
16
+ * only an approved template may be sent, and posting into a thread would mean
17
+ * saying which of those it was and what it means for the agent still
18
+ * answering.
19
+ */
20
+ export class ConversationsResource {
21
+ transport;
22
+ constructor(transport) {
23
+ this.transport = transport;
24
+ }
25
+ /**
26
+ * The workspace's threads, newest activity first.
27
+ *
28
+ * **Page until `hasMore` is `false`; do not stop on an empty `items`.** An
29
+ * inbox grows without bound, so there is no total count to page against.
30
+ */
31
+ list(input) {
32
+ return request(this.transport, {
33
+ method: "GET",
34
+ path: "/conversations",
35
+ query: {
36
+ workspaceId: input.workspaceId,
37
+ contactAddress: input.contactAddress,
38
+ status: input.status,
39
+ cursor: input.cursor,
40
+ limit: input.limit === undefined ? undefined : String(input.limit),
41
+ },
42
+ headers: actingAs(input.asUser),
43
+ });
44
+ }
45
+ /**
46
+ * One thread and its recent messages, oldest first.
47
+ *
48
+ * **Capped, not paged** - you get the same window the agent itself sees when
49
+ * it composes a reply, so you are never shown less context than it used.
50
+ *
51
+ * Turns that produced no message are included and carry `failure`. Render
52
+ * them: a transcript that silently drops them reads as a customer being
53
+ * ignored, which is the opposite of what happened.
54
+ *
55
+ * Throws `ConnectError` with `code: "not_found"` for a thread this workspace
56
+ * does not hold.
57
+ */
58
+ get(conversationId, options) {
59
+ return request(this.transport, {
60
+ method: "GET",
61
+ path: `/conversations/${encodeURIComponent(conversationId)}`,
62
+ query: { workspaceId: options.workspaceId },
63
+ headers: actingAs(options.asUser),
64
+ });
65
+ }
66
+ }
@@ -0,0 +1,25 @@
1
+ import type { AiConversationDetail } from "../../types.js";
2
+ /**
3
+ * Past Ask AI conversations, for reopening or deleting.
4
+ *
5
+ * ## It fetches only while it is open
6
+ *
7
+ * `<ConnectAskAi>` is mounted for the whole application shell, so an
8
+ * unconditional read here would put a request on every page load to populate a
9
+ * list most people never open. It reads on open, and again whenever the panel
10
+ * has written a new conversation since - which is what `refreshKey` is for.
11
+ *
12
+ * ## Deletion confirms inline, not in a dialog
13
+ *
14
+ * Destructive actions confirm; this one does it at the weight it deserves.
15
+ * Losing a transcript is recoverable by asking again, not a cancelled
16
+ * subscription, and a modal over a 400px panel would be heavier than the thing
17
+ * it guards. The row swaps to "Delete? / Cancel" and stays there until
18
+ * answered.
19
+ */
20
+ export declare function AskAiHistory({ refreshKey, activeConversationId, onOpenConversation, }: Readonly<{
21
+ /** Changes when the panel has written something, so the list re-reads. */
22
+ refreshKey: number;
23
+ activeConversationId: string;
24
+ onOpenConversation: (id: string, detail: AiConversationDetail) => void;
25
+ }>): import("react").JSX.Element;
@@ -0,0 +1,55 @@
1
+ import type { AiConversationTurn } from "../../types.js";
2
+ /**
3
+ * One piece of an assistant turn, in the order it arrived.
4
+ *
5
+ * Blocks rather than one text string, because an answer interleaves prose and
6
+ * drawings - a sentence, a table, the sentence that reads the table - and
7
+ * flattening it would silently reorder the model's own argument.
8
+ */
9
+ export type AskAiBlock = {
10
+ kind: "text";
11
+ text: string;
12
+ } | {
13
+ kind: "tool";
14
+ id: string;
15
+ name: string;
16
+ input: unknown | null;
17
+ };
18
+ export interface AskAiTurn {
19
+ id: string;
20
+ role: "user" | "assistant";
21
+ blocks: AskAiBlock[];
22
+ /** The model's streamed thoughts, if it sent any. */
23
+ reasoning: string;
24
+ }
25
+ /**
26
+ * Index of the final text block, or `-1`. A reverse loop rather than
27
+ * `findLastIndex`, which is ES2023 - this package targets ES2022 so its
28
+ * published output stays usable on the runtimes its `engines` field claims.
29
+ */
30
+ export declare function lastTextBlockIndex(blocks: AskAiBlock[]): number;
31
+ /** Appends a text delta to the open text block, or opens one after a widget. */
32
+ export declare function appendText(blocks: AskAiBlock[], text: string): AskAiBlock[];
33
+ /**
34
+ * Rebuilds the panel's turns from a stored transcript.
35
+ *
36
+ * The inverse of what the stream reader does live, and it has to agree with it
37
+ * or a reopened conversation renders differently from the one the user just
38
+ * had. Two shapes, one source: the stream delivers `text-delta`,
39
+ * `reasoning-delta` and `tool-input-available` frames, and storage keeps the
40
+ * settled `UIMessage` parts those frames built - `{ type: "text" }`,
41
+ * `{ type: "reasoning" }`, and `{ type: "tool-<name>" }` carrying the input the
42
+ * widget was drawn from.
43
+ *
44
+ * Three rules, each of which is a thing that would otherwise render wrong:
45
+ *
46
+ * - **A tool part with no input is dropped, not restored.** `input: null` means
47
+ * "still arriving" to the renderer, which draws a skeleton - correct live,
48
+ * and a shimmer that never resolves on a transcript that finished days ago.
49
+ * - **Reasoning is hoisted per turn**, not left in place. It is rendered above
50
+ * the answer in a disclosure, which is where it sat while streaming.
51
+ * - **A part this build cannot place is skipped rather than guessed at.**
52
+ * `parts` is the AI SDK's own shape and gains kinds over time; an unknown one
53
+ * costs a widget, and inventing a rendering for it costs the whole turn.
54
+ */
55
+ export declare function turnsFromStored(turns: readonly AiConversationTurn[]): AskAiTurn[];
@@ -99,8 +99,15 @@ export interface ConnectContextValue {
99
99
  * `path` through `useConnectResource` still needs to call that hook's own
100
100
  * `refetch()` afterward; clearing the cache entry alone does not re-render
101
101
  * anything by itself.
102
+ *
103
+ * `method` widens this to the one other verb the bridge answers, `DELETE`,
104
+ * for the routes where removal is the whole operation and a body would be
105
+ * ceremony - Ask AI's history is the first. It stays a two-value union
106
+ * rather than `string`: everything else on this bridge is a POST, and a
107
+ * free-form verb here would be an invitation to invent REST semantics the
108
+ * bridge's exact-match route table does not have.
102
109
  */
103
- mutateResource<T>(path: string, body?: unknown): Promise<T>;
110
+ mutateResource<T>(path: string, body?: unknown, method?: "POST" | "DELETE"): Promise<T>;
104
111
  /**
105
112
  * Drops one path's cached read so the next `fetchResource` for it goes to
106
113
  * the bridge again.
@@ -9,6 +9,7 @@ import { SchedulingResource } from "./resources/scheduling.js";
9
9
  import { ContactsResource } from "./resources/contacts.js";
10
10
  import { ChannelsResource } from "./resources/channels.js";
11
11
  import { CallsResource } from "./resources/calls.js";
12
+ import { ConversationsResource } from "./resources/conversations.js";
12
13
  import { KvResource } from "./resources/kv.js";
13
14
  import { BillingResource } from "./resources/billing.js";
14
15
  import { DriveResource } from "./resources/drive.js";
@@ -88,6 +89,8 @@ export declare class ConnectClient {
88
89
  readonly channels: ChannelsResource;
89
90
  /** The calls that arrived on those channels, with their transcripts. */
90
91
  readonly calls: CallsResource;
92
+ /** The message threads a subscribing workspace's agents are holding. */
93
+ readonly conversations: ConversationsResource;
91
94
  /** RAG chat and structured extraction. */
92
95
  readonly ai: AiResource;
93
96
  readonly enrichment: EnrichmentResource;
@@ -123,4 +126,4 @@ export declare class ConnectClient {
123
126
  constructor(options: ConnectClientOptions);
124
127
  }
125
128
  /** Kept in step with `package.json` by `sdk.test.ts`. */
126
- export declare const SDK_VERSION = "0.28.0";
129
+ export declare const SDK_VERSION = "0.30.0";
@@ -1,5 +1,5 @@
1
1
  import { type TransportOptions } from "../http.js";
2
- import type { ChatMessage, ConnectScreen } from "../../types.js";
2
+ import type { AiConversationDetail, AiConversationList, ChatMessage, ConnectScreen } from "../../types.js";
3
3
  /**
4
4
  * RAG chat and structured extraction against a workspace's data.
5
5
  *
@@ -21,6 +21,15 @@ export declare class AiResource {
21
21
  workspaceId: string;
22
22
  messages: ChatMessage[];
23
23
  asUser?: string;
24
+ /**
25
+ * Your own id for this conversation, sent with every turn of it.
26
+ *
27
+ * What makes the conversation *resumable*: Connect upserts against it and
28
+ * `conversations.list`/`get` read it back. Persisted only alongside
29
+ * `asUser` - a conversation belongs to a person, and a server-to-server
30
+ * call acts as nobody. Omit it to keep a turn out of history entirely.
31
+ */
32
+ conversationId?: string;
24
33
  /**
25
34
  * Your application's screens, so the assistant answers in your product's
26
35
  * words and can link into it - see `ConnectScreen`. Prose and links only:
@@ -34,6 +43,45 @@ export declare class AiResource {
34
43
  }, options?: {
35
44
  signal?: AbortSignal;
36
45
  }): Promise<Response>;
46
+ /**
47
+ * The signed-in user's past conversations in your application, most recent
48
+ * first.
49
+ *
50
+ * `asUser` is required rather than optional here, unlike on `chat`: the rows
51
+ * are scoped to a person, and there is no application-wide view of them to
52
+ * fall back to. Connect reads the user and the workspace from the token.
53
+ *
54
+ * Cursor-paginated with no total - the list is ordered by last use, which
55
+ * moves while it is being read.
56
+ */
57
+ listConversations(input: {
58
+ asUser: string;
59
+ limit?: number;
60
+ cursor?: string;
61
+ }): Promise<AiConversationList>;
62
+ /**
63
+ * One stored transcript, oldest turn first.
64
+ *
65
+ * `turns[].parts` comes back exactly as the model produced it - prose,
66
+ * reasoning and tool calls in order - so it can be handed straight back to a
67
+ * chat UI. Not this user's, or not your application's, is a `404` either way.
68
+ */
69
+ getConversation(input: {
70
+ conversationId: string;
71
+ asUser: string;
72
+ }): Promise<AiConversationDetail>;
73
+ /**
74
+ * Deletes a conversation and every turn beneath it.
75
+ *
76
+ * Idempotent - one already gone answers `{ deleted: true }`, so a retry after
77
+ * a dropped response does not read as a failure.
78
+ */
79
+ deleteConversation(input: {
80
+ conversationId: string;
81
+ asUser: string;
82
+ }): Promise<{
83
+ deleted: true;
84
+ }>;
37
85
  /** Extract structured JSON from a file or image URL, against a platform extraction schema referenced by id. */
38
86
  extract<T = unknown>(input: {
39
87
  schemaId: string;
@@ -0,0 +1,68 @@
1
+ import { type TransportOptions } from "../http.js";
2
+ import type { ConversationDetail, ConversationsPage } from "../../types.js";
3
+ export interface ListConversationsInput {
4
+ workspaceId: string;
5
+ /**
6
+ * Narrow to one party's threads.
7
+ *
8
+ * Any reasonable spelling - `+919000000001`, `919000000001`,
9
+ * `+91 90000 00001` all resolve to the same threads, because the number is
10
+ * normalized server-side before the match. This is the filter to reach for
11
+ * beside your own customer record: you hold a phone number, and you cannot
12
+ * derive a conversation id from one because the id is built from a *hashed*
13
+ * key.
14
+ */
15
+ contactAddress?: string;
16
+ status?: "open" | "snoozed" | "closed";
17
+ /** From a previous page's `nextCursor`. */
18
+ cursor?: string;
19
+ /** 1-50, default 20. */
20
+ limit?: number;
21
+ /** See `ActingAsOption` on the scheduling resource - same contract. */
22
+ asUser?: string;
23
+ }
24
+ /**
25
+ * What a workspace's agents are saying in writing.
26
+ *
27
+ * **The sibling of `calls`, with one difference that decides how it is
28
+ * gated: these are the *workspace's* threads, not your application's.** A call
29
+ * arrived on a channel you hold, so `calls.list` is scoped to your
30
+ * application. A conversation lives in the workspace's own subtree and belongs
31
+ * to it the way its contact book does - so reading one needs a live
32
+ * subscription in that workspace, and, when the call comes from a browser
33
+ * through the bridge, the signed-in member's own `agent:read`.
34
+ *
35
+ * Read-only, and deliberately. Sending on a thread is not a missing verb:
36
+ * inside WhatsApp's 24-hour window a reply is the agent's to make, outside it
37
+ * only an approved template may be sent, and posting into a thread would mean
38
+ * saying which of those it was and what it means for the agent still
39
+ * answering.
40
+ */
41
+ export declare class ConversationsResource {
42
+ private readonly transport;
43
+ constructor(transport: TransportOptions);
44
+ /**
45
+ * The workspace's threads, newest activity first.
46
+ *
47
+ * **Page until `hasMore` is `false`; do not stop on an empty `items`.** An
48
+ * inbox grows without bound, so there is no total count to page against.
49
+ */
50
+ list(input: ListConversationsInput): Promise<ConversationsPage>;
51
+ /**
52
+ * One thread and its recent messages, oldest first.
53
+ *
54
+ * **Capped, not paged** - you get the same window the agent itself sees when
55
+ * it composes a reply, so you are never shown less context than it used.
56
+ *
57
+ * Turns that produced no message are included and carry `failure`. Render
58
+ * them: a transcript that silently drops them reads as a customer being
59
+ * ignored, which is the opposite of what happened.
60
+ *
61
+ * Throws `ConnectError` with `code: "not_found"` for a thread this workspace
62
+ * does not hold.
63
+ */
64
+ get(conversationId: string, options: {
65
+ workspaceId: string;
66
+ asUser?: string;
67
+ }): Promise<ConversationDetail>;
68
+ }
@@ -538,6 +538,54 @@ export interface ChatMessage {
538
538
  role: "user" | "assistant" | "system";
539
539
  [field: string]: unknown;
540
540
  }
541
+ /**
542
+ * One row of Ask AI history.
543
+ *
544
+ * A conversation exists only if the turns that made it carried a
545
+ * `conversationId` *and* were sent on a user's behalf - see `AiResource.chat`.
546
+ * It belongs to one person in one of your workspaces, and never leaves your
547
+ * application: a workspace subscribing to two products keeps two lists.
548
+ */
549
+ export interface AiConversation {
550
+ id: string;
551
+ /** Derived from the first thing the user said - no model call, so it costs nothing. */
552
+ title: string;
553
+ /** ISO-8601. */
554
+ createdAt: string;
555
+ /**
556
+ * ISO-8601, and the sort key - a conversation returned to a week later is at
557
+ * the top, not where it was created.
558
+ */
559
+ updatedAt: string;
560
+ }
561
+ export interface AiConversationList {
562
+ conversations: AiConversation[];
563
+ /** Pass back as `cursor` for the next page; `null` at the end. */
564
+ nextCursor: string | null;
565
+ }
566
+ /** One stored turn, in the AI SDK's own shape. */
567
+ export interface AiConversationTurn {
568
+ id: string;
569
+ role: "user" | "assistant";
570
+ /**
571
+ * The message's parts exactly as the model produced them - prose, reasoning
572
+ * and tool calls, in the order they arrived.
573
+ *
574
+ * Opaque on purpose. The shape is the AI SDK's and moves with it, so a type
575
+ * restating it here would start rejecting transcripts the moment the SDK adds
576
+ * a part kind. Flattening it to text is the other mistake: the order is the
577
+ * model's own argument, and a flattened turn cannot be resumed.
578
+ */
579
+ parts: unknown[];
580
+ /** Position in the conversation. Turns arrive in this order. */
581
+ sequence: number;
582
+ /** ISO-8601. */
583
+ createdAt: string;
584
+ }
585
+ export interface AiConversationDetail {
586
+ conversation: AiConversation;
587
+ turns: AiConversationTurn[];
588
+ }
541
589
  export interface WorkspaceApp {
542
590
  applicationId: string;
543
591
  /** Stable, human-readable key - branch on this, not `applicationId`, which is per-environment. */
@@ -1822,3 +1870,85 @@ export interface CallsPage {
1822
1870
  nextCursor: string | null;
1823
1871
  hasMore: boolean;
1824
1872
  }
1873
+ /**
1874
+ * One message thread a workspace's agents are holding.
1875
+ *
1876
+ * Hand-written rather than imported from Connect, like every other type in
1877
+ * this file - `conformance.test.ts` type-checks it against the server DTO, so
1878
+ * drift fails the build here rather than in your integration.
1879
+ *
1880
+ * Dates are ISO-8601 strings. JSON has no date type, and a UTC ISO string
1881
+ * sorts lexicographically in chronological order, so these compare and
1882
+ * range-filter without being parsed first.
1883
+ */
1884
+ export interface ConnectConversation {
1885
+ id: string;
1886
+ /** The channel the thread arrived on. */
1887
+ channelId: string;
1888
+ /** Today always `whatsapp` - a voice channel produces calls, not threads. */
1889
+ kind: "whatsapp";
1890
+ /** The other party, normalized to E.164. */
1891
+ contactAddress: string;
1892
+ /**
1893
+ * The Connect contact this thread is about, once one has been matched.
1894
+ *
1895
+ * The join to your own records - store this on yours rather than matching
1896
+ * phone numbers. See `ContactsResource`.
1897
+ */
1898
+ contactId: string | null;
1899
+ /** As the provider reported it, when it did. */
1900
+ contactName: string | null;
1901
+ status: "open" | "snoozed" | "closed";
1902
+ /** `null` means nothing is answering: no routing, or a human took over. */
1903
+ agentId: string | null;
1904
+ lastInboundAt: string;
1905
+ lastOutboundAt: string | null;
1906
+ lastMessagePreview: string;
1907
+ /**
1908
+ * Whether a free-form reply is still allowed, computed against **Connect's**
1909
+ * clock.
1910
+ *
1911
+ * WhatsApp allows one only inside 24 hours of the customer's last message;
1912
+ * outside it, only an approved template may be sent. Do not re-derive this
1913
+ * from `lastInboundAt` against your own clock - a skew shows up as a thread
1914
+ * your UI calls answerable when it is not.
1915
+ */
1916
+ canReplyFreely: boolean;
1917
+ createdAt: string;
1918
+ updatedAt: string;
1919
+ }
1920
+ /**
1921
+ * Why a turn produced no message.
1922
+ *
1923
+ * `refused` is not a failure at all - it is the agent deciding not to answer,
1924
+ * which is what it is for.
1925
+ */
1926
+ export type ConnectMessageFailure = "window_closed" | "no_agent" | "model_error" | "send_failed" | "refused";
1927
+ /** One message on a thread. A turn that sent nothing still appears, carrying `failure`. */
1928
+ export interface ConnectConversationMessage {
1929
+ id: string;
1930
+ direction: "inbound" | "outbound";
1931
+ /**
1932
+ * Who produced it.
1933
+ *
1934
+ * `agent` and `human` are both outbound and deliberately distinguished - a
1935
+ * thread that cannot show which replies were automatic is one nobody trusts
1936
+ * enough to leave running. `system` is neither party: a turn Connect itself
1937
+ * recorded, which is what a `failure` row is.
1938
+ */
1939
+ author: "contact" | "agent" | "human" | "system";
1940
+ text: string;
1941
+ failure: ConnectMessageFailure | null;
1942
+ createdAt: string;
1943
+ }
1944
+ /** A thread and its recent messages, oldest first. Capped rather than paged. */
1945
+ export interface ConversationDetail {
1946
+ conversation: ConnectConversation;
1947
+ messages: ConnectConversationMessage[];
1948
+ }
1949
+ /** One page of threads, newest activity first. Page until `hasMore` is `false`. */
1950
+ export interface ConversationsPage {
1951
+ items: ConnectConversation[];
1952
+ nextCursor: string | null;
1953
+ hasMore: boolean;
1954
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",