@cubos/agent-sdk-react 0.0.1136563

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.
@@ -0,0 +1,358 @@
1
+ import { type Activity, type Block, type ClientTool, type Conversation, type Message, type PlanSnapshot, type Todo, type ToolActivity, type WorkspaceEntry } from "@cubos/agent-sdk";
2
+ import { type ReactNode } from "react";
3
+ import { type ComponentMap } from "./blocks.js";
4
+ export interface UseConversationOptions {
5
+ /**
6
+ * How many events a history page asks for. Counts events, not messages, so a
7
+ * page of a tool-heavy turn can yield only a couple of bubbles — which is why
8
+ * a UI should keep calling `loadOlder` until `hasOlder` is false rather than
9
+ * assuming one page fills the viewport.
10
+ */
11
+ pageSize?: number;
12
+ /** Which agent a conversation created by the first `send` talks to. Optional
13
+ * when the token names exactly one. */
14
+ agentSlug?: string;
15
+ /**
16
+ * Called once, with the conversation the first `send` created, when this hook
17
+ * was given `null`. Point it at whatever owns the selection — `setState`, a
18
+ * router navigation — and pass the id back on the next render.
19
+ *
20
+ * The messages already on screen survive that hand-off, so the user's first
21
+ * message doesn't blink out while the conversation is adopted.
22
+ *
23
+ * **Awaited.** It runs after the conversation exists and before the message
24
+ * is posted, which is the only window in which an app can set something up
25
+ * that the very first turn depends on — declaring its own client tools, for
26
+ * one. Returning a promise holds the send until it resolves; a throw fails
27
+ * the send, which is the honest outcome for setup that did not happen.
28
+ */
29
+ onCreated?: (conversation: Conversation) => void | Promise<void>;
30
+ /**
31
+ * Functions the agent may call, keyed by tool name. Declared on the
32
+ * conversation, then executed here as the agent calls them: the turn suspends
33
+ * on the call and resumes with whatever the handler returns.
34
+ *
35
+ * They ride the subscription this hook already holds — no second stream.
36
+ */
37
+ clientTools?: Record<string, ClientTool<never, unknown>>;
38
+ /**
39
+ * Whether this hook declares `clientTools` on the conversation. Defaults to
40
+ * true, which is what an app with a fixed tool set wants.
41
+ *
42
+ * Turn it off to declare the set yourself, with `setClientTools`, and pass
43
+ * every handler the agent might ever call here. That splits two things this
44
+ * hook otherwise ties together: *what can run* (the handlers, which must
45
+ * cover any call that arrives) and *what the agent is told about* (the
46
+ * declaration). An app whose tools depend on the open screen needs that
47
+ * split — the declaration should change on navigation, while the handlers
48
+ * must stay complete, or a call issued a moment earlier would find no
49
+ * implementation.
50
+ */
51
+ declareClientTools?: boolean;
52
+ /**
53
+ * What your app currently has on screen — the open page, the selected record,
54
+ * the filters in force. Free-form: prose or JSON, whatever the agent reads
55
+ * best.
56
+ *
57
+ * Set it and forget it — update it as often as your screen moves. Nothing is
58
+ * sent while nobody is listening: the value is held and flushed at the two
59
+ * moments the agent can actually read it, just before a message and on any
60
+ * change while a turn is running. A user browsing eleven screens before typing
61
+ * anything costs one request, and the agent is told once.
62
+ *
63
+ * Undefined leaves it alone; `null` clears it.
64
+ */
65
+ context?: string | null;
66
+ /**
67
+ * What draws each component, keyed by tag name (PascalCase, as the agent
68
+ * writes them). Renderers only — what the agent is *told* about a component
69
+ * comes from the libraries below.
70
+ *
71
+ * Extra entries are fine and useful: they draw components of older messages
72
+ * that no enabled library offers any more.
73
+ */
74
+ components?: ComponentMap;
75
+ /**
76
+ * Slugs of the component libraries this screen can render. Replaces the
77
+ * conversation's list, so pass the complete one; `[]` takes the agent back to
78
+ * plain markdown.
79
+ *
80
+ * The libraries are authored by the operator over the admin API, because their
81
+ * entries reach the model verbatim. This is the half that depends on which of
82
+ * your screens is open, which is why it is yours. Narrow it as the app
83
+ * navigates — the hook only re-declares at moments the change can't disturb a
84
+ * turn in flight.
85
+ *
86
+ * A tag the libraries offer with no renderer in `components` is a block the
87
+ * agent will write and nothing will draw, so the hook logs a warning naming
88
+ * it. The reverse is never warned about.
89
+ */
90
+ componentLibraries?: string[];
91
+ /** Draws the prose around those components. Without it markdown renders as
92
+ * plain text — see `renderBlocks`. */
93
+ renderMarkdown?: (text: string) => ReactNode;
94
+ /** Wraps each block of an agent message, so the app can lay prose and
95
+ * components out differently — see `renderBlocks`. */
96
+ wrapBlock?: (node: ReactNode, block: Block) => ReactNode;
97
+ /** A client tool's handler threw, or its result couldn't be delivered. The
98
+ * agent is told either way; this is for your logs. */
99
+ onClientToolError?: (err: unknown) => void;
100
+ }
101
+ export interface UseConversationResult {
102
+ conversation: Conversation | null;
103
+ /** Oldest first, deduped by id, including any message still in flight. */
104
+ messages: Message[];
105
+ /** Every tool the agent used, folded and ordered by `seq`. Use `trailFor` to
106
+ * get the ones behind a given reply; this is the whole conversation's. */
107
+ toolActivity: ToolActivity[];
108
+ /**
109
+ * The steps behind one reply — what the agent did between the message before
110
+ * it and this one — for a "how I got here" disclosure.
111
+ *
112
+ * Empty for a user message: a trail belongs to the answer, not the question.
113
+ * Every entry carries its `arguments` once the call that issued them has been
114
+ * seen — which for a step still running can be a moment later, so phrase from
115
+ * the tool name and let the arguments sharpen it.
116
+ */
117
+ trailFor: (message: Message) => ToolActivity[];
118
+ /**
119
+ * The steps no reply has claimed yet — what the agent has done since the last
120
+ * message. Non-empty while a turn is running, which is what lets a UI draw the
121
+ * trail as it happens instead of only once the answer lands, and non-empty
122
+ * after a turn that produced no message at all.
123
+ */
124
+ pendingTrail: ToolActivity[];
125
+ /** The plan the given reply was written under, or null when the agent kept
126
+ * none — the last revision inside the same window as `trailFor`, so an old
127
+ * answer never shows a newer plan. */
128
+ planFor: (message: Message) => Todo[] | null;
129
+ /** The plan of the turn still running, for the same reason `pendingTrail`
130
+ * exists. */
131
+ pendingPlan: Todo[] | null;
132
+ /** The newest plan in the conversation, regardless of which turn wrote it. */
133
+ todos: Todo[];
134
+ activity: Activity;
135
+ /**
136
+ * Whether a turn is running right now — the signal to key a "working"
137
+ * indicator on.
138
+ *
139
+ * The server decides this, not you and not the SDK: `hasPendingTurn` is a
140
+ * trigger over the event log ("a user message newer than the last
141
+ * `turn_done`"), pushed on connect and on every change, so it cannot read
142
+ * false while a turn is in flight — not between two replies of one turn, not
143
+ * while a tool runs, not while a client tool suspends it.
144
+ *
145
+ * Not the same as `activity.isProcessing`, and the difference is the whole
146
+ * reason this exists: a client tool **suspends** the turn while your app
147
+ * answers it, so the conversation stops being "processing" for as long as
148
+ * that takes. A UI keyed on activity alone stops its spinner in the middle of
149
+ * the work and starts it again a second later.
150
+ *
151
+ * True from the moment a message is sent until the `turn_done` that follows
152
+ * it, and true whenever the server says it is processing — either is enough.
153
+ */
154
+ isTurnRunning: boolean;
155
+ /** True until the first history page and the first stream frame land. False
156
+ * immediately when the conversation came from the client's cache. */
157
+ isLoading: boolean;
158
+ /** There is older history behind what is loaded. */
159
+ hasOlder: boolean;
160
+ /** A `loadOlder` is in flight. */
161
+ isLoadingOlder: boolean;
162
+ /**
163
+ * Prepends the page before the oldest message held. Safe to call while the
164
+ * agent is mid-turn — live messages keep arriving and land in order.
165
+ *
166
+ * Resolves to the number of messages added, so a list can tell "nothing came
167
+ * back" from "we reached the beginning" without watching `hasOlder`.
168
+ */
169
+ loadOlder: () => Promise<number>;
170
+ /** Set on a failed send or an unrecoverable load. Stream hiccups don't land
171
+ * here — the SDK reconnects on its own. */
172
+ error: Error | null;
173
+ /**
174
+ * Appends the message optimistically, then reconciles with the server's echo.
175
+ * Rejects on failure, having already rolled the optimistic copy back.
176
+ *
177
+ * On the blank slate (`null` id) it creates the conversation first and reports
178
+ * it through `onCreated`, so a UI never branches on "no conversation yet".
179
+ */
180
+ send: (content: string) => Promise<void>;
181
+ /** Uploads a recorded clip. The words appear once the agent's STT model has
182
+ * transcribed it, so nothing is echoed optimistically — there is no text to
183
+ * echo yet. Creates the conversation on the blank slate, like `send`. */
184
+ sendAudio: (audio: Blob) => Promise<void>;
185
+ /**
186
+ * Up to 10 images as ONE message, so the agent reasons over the set instead of
187
+ * one turn per picture. `caption` becomes the message's text, and a per-image
188
+ * `label` names it for the model.
189
+ *
190
+ * Nothing is echoed optimistically: the bubble needs the server's attachment
191
+ * ids to fetch the bytes back, so it appears with the echo. Creates the
192
+ * conversation on the blank slate, like `send`.
193
+ */
194
+ sendImages: (images: Array<{
195
+ image: Blob;
196
+ filename?: string;
197
+ label?: string;
198
+ }>, caption?: string) => Promise<void>;
199
+ steer: (content: string) => Promise<void>;
200
+ /** True while a send is in flight. */
201
+ isSending: boolean;
202
+ /**
203
+ * The agent's reply as React nodes: your components where it used them, your
204
+ * markdown renderer around them.
205
+ *
206
+ * Null for a message with no blocks — every user message, and any agent
207
+ * message from a server that predates them. Render `content` for those.
208
+ */
209
+ renderMessage: (message: Message) => ReactNode[] | null;
210
+ /**
211
+ * Declare the current `clientTools` now.
212
+ *
213
+ * Rarely needed: the hook already declares when the session starts and before
214
+ * every message, which covers an app whose tool set follows the open screen.
215
+ * This is for the one moment it cannot see — inside a tool that just moved the
216
+ * app, where the verbs of the new screen have to be available for the step the
217
+ * agent takes next, and where declaring is safe because the call running it
218
+ * holds the lease.
219
+ */
220
+ syncClientTools: () => Promise<void>;
221
+ /**
222
+ * Bumped whenever the conversation's files change, from any source — the
223
+ * agent writing one, this app uploading one, another tab deleting one.
224
+ *
225
+ * The hook does not hold the listing itself, for the same reason it does not
226
+ * hold which conversation is open: the directory the user is looking at is
227
+ * the app's state. What only the hook can supply is *when* to look again,
228
+ * since it already has the stream. Use it as a dependency:
229
+ *
230
+ * ```ts
231
+ * useEffect(() => { void listFiles(path).then(setEntries) }, [listFiles, path, workspaceRevision])
232
+ * ```
233
+ */
234
+ workspaceRevision: number;
235
+ /** One directory, never recursive. Empty on the blank slate — there is no
236
+ * conversation yet, and listing is not a reason to create one. */
237
+ listFiles: (path?: string) => Promise<WorkspaceEntry[]>;
238
+ /** One file's bytes. */
239
+ readFile: (path: string) => Promise<Blob>;
240
+ /**
241
+ * Uploads files. Creates the conversation on the blank slate, like `send`.
242
+ *
243
+ * Starts no turn: the user dropping a file is not asking a question. The
244
+ * agent is told what changed at the start of its next request, so upload
245
+ * first and then `send` if you want it acted on — those are one turn, not two.
246
+ */
247
+ writeFiles: (files: Array<{
248
+ path: string;
249
+ file: Blob;
250
+ filename?: string;
251
+ }>) => Promise<void>;
252
+ deleteFile: (path: string) => Promise<void>;
253
+ moveFile: (from: string, to: string) => Promise<void>;
254
+ }
255
+ /** A message can arrive twice — once in a history page, once as a stream frame —
256
+ * and a `loadOlder` page lands in front of what is already held. Key by id and
257
+ * sort by seq so both are non-events. */
258
+ export declare function mergeMessages(existing: Message[], incoming: Message[]): Message[];
259
+ /**
260
+ * The steps behind one reply: what the agent did between the message before it
261
+ * and this one.
262
+ *
263
+ * The previous message is what bounds the turn. Without it the first reply
264
+ * would claim every step ever taken, and each later one would claim the steps
265
+ * of the reply before it. Exported for its test — the hook is the only caller.
266
+ *
267
+ * Expects `messages` ordered by `seq`.
268
+ */
269
+ export declare function trailOf(message: Message, messages: Message[], activity: ToolActivity[]): ToolActivity[];
270
+ /**
271
+ * The steps that no reply has claimed yet: everything after the last message
272
+ * held.
273
+ *
274
+ * This is what makes a trail watchable while it happens. `trailOf` needs a
275
+ * message to hang steps on, and mid-turn there is none — the agent is still
276
+ * working. These are those steps.
277
+ *
278
+ * It also covers the turn that ends with **no** message at all: an agent can
279
+ * call tools and stop without writing a reply, and without this those steps
280
+ * would never be shown by anything, because nothing would ever arrive to anchor
281
+ * them.
282
+ *
283
+ * The optimistic copy of a just-sent message is skipped: it carries
284
+ * `MAX_SAFE_INTEGER` as its seq, so counting it would put the boundary past
285
+ * every real step and this would always come back empty.
286
+ *
287
+ * Expects `messages` ordered by `seq`.
288
+ */
289
+ export declare function trailAfterLast(messages: Message[], activity: ToolActivity[]): ToolActivity[];
290
+ /**
291
+ * Is a turn running?
292
+ *
293
+ * Three signals, and none of them is a comparison this file makes up.
294
+ *
295
+ * `hasPendingTurn` is the server's, and the trigger behind it computes exactly
296
+ * "there is a user message newer than the last `turn_done`" — which is what
297
+ * being owed a reply means, and it stays true while a client tool suspends the
298
+ * turn, when nothing is processing and the agent has answered nothing yet.
299
+ * `isProcessing` says the work is happening right now. The optimistic copy
300
+ * covers the moment between pressing enter and the first frame.
301
+ *
302
+ * Both arrive as `conversation_status` frames on the *event* stream, which is
303
+ * what makes reading them here safe. Published on the metadata stream instead —
304
+ * a second connection — they raced the reply they were about, and the UI showed
305
+ * a finished turn with nothing in it for as long as that race lasted.
306
+ *
307
+ * What is deliberately *not* here is a rule deriving "finished" from the log.
308
+ * Twice now that has been wrong in opposite directions: reading a missing turn
309
+ * cursor as "no turn has ended" left the dots on a conversation idle for hours,
310
+ * and reading a *previous* turn's `turn_done` as this turn's end collapsed the
311
+ * trail mid-work, while the agent was paused waiting on a client tool. The
312
+ * server already publishes the answer; recomputing it from a cursor the client
313
+ * keeps separately only creates two halves that can disagree.
314
+ *
315
+ * Exported for its test; the hook is the only caller.
316
+ */
317
+ export declare function turnIsRunning(messages: Message[], activity: Activity): boolean;
318
+ /**
319
+ * Plan revisions, deduped by `seq` and ordered.
320
+ *
321
+ * The same revision arrives twice routinely — once from the history page and
322
+ * again from the stream frame that follows it — and two identical plans against
323
+ * one turn would make `planOf` pick arbitrarily between them.
324
+ */
325
+ export declare function mergePlans(existing: PlanSnapshot[], incoming: PlanSnapshot[]): PlanSnapshot[];
326
+ /**
327
+ * The plan as it stood when a reply was written.
328
+ *
329
+ * The agent revises the list several times per turn — one revision per step it
330
+ * ticks off. Showing them all would be a diff log; showing the newest against
331
+ * an old answer would be a lie. So: the last revision in the same window
332
+ * `trailOf` uses, which is the plan the reply was written under.
333
+ *
334
+ * Returns null rather than an empty list when there is none, so a caller can
335
+ * tell "no plan" from "a plan with nothing in it".
336
+ *
337
+ * Expects `messages` and `plans` ordered by `seq`.
338
+ */
339
+ export declare function planOf(message: Message, messages: Message[], plans: PlanSnapshot[]): Todo[] | null;
340
+ /** The plan of the turn still running — the counterpart of `trailAfterLast`. */
341
+ export declare function planAfterLast(messages: Message[], plans: PlanSnapshot[]): Todo[] | null;
342
+ /**
343
+ * One conversation, live: history, streamed messages, the agent's plan and
344
+ * whether it is working right now.
345
+ *
346
+ * The id is yours to own — component state, a route param, whatever. Pass
347
+ * `null` for the blank slate: nothing connects, and the first `send` creates the
348
+ * conversation and hands it to `onCreated`.
349
+ *
350
+ * ```tsx
351
+ * const [id, setId] = useState<string | null>(null);
352
+ * const { messages, send } = useConversation(id, {
353
+ * agentSlug: "support",
354
+ * onCreated: (c) => setId(c.id),
355
+ * });
356
+ * ```
357
+ */
358
+ export declare function useConversation(conversationId: string | null, options?: UseConversationOptions): UseConversationResult;
@@ -0,0 +1,9 @@
1
+ import type { Identity } from "@cubos/agent-sdk";
2
+ export interface UseIdentityResult {
3
+ identity: Identity | null;
4
+ isLoading: boolean;
5
+ error: Error | null;
6
+ }
7
+ /** Who the current token acts as, and which agents it may talk to. Resolved once
8
+ * per client and cached inside it. */
9
+ export declare function useIdentity(): UseIdentityResult;
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@cubos/agent-sdk-react",
3
+ "version": "0.0.1136563",
4
+ "type": "module",
5
+ "description": "Headless React hooks for the Cubos Agent conversation API. No DOM — works in React Native too.",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "keywords": [
8
+ "cubos",
9
+ "agent",
10
+ "react",
11
+ "react-native",
12
+ "chat",
13
+ "hooks",
14
+ "sdk",
15
+ "headless"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://git.cubos.io/agent/cubos-agent.git",
20
+ "directory": "packages/sdk-react"
21
+ },
22
+ "homepage": "https://git.cubos.io/agent/cubos-agent/-/tree/main/packages/sdk-react",
23
+ "bugs": {
24
+ "url": "https://git.cubos.io/agent/cubos-agent/-/issues"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "scripts": {
36
+ "build": "bun scripts/build.ts",
37
+ "typecheck": "tsgo --noEmit",
38
+ "test": "bun test"
39
+ },
40
+ "peerDependencies": {
41
+ "@cubos/agent-sdk": "0.0.x",
42
+ "react": ">=18"
43
+ },
44
+ "devDependencies": {
45
+ "@cubos/agent-sdk": "0.0.x",
46
+ "@types/bun": "^1.4.0",
47
+ "@types/react": "^19.2.18",
48
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
49
+ "react": "^19.2.8"
50
+ }
51
+ }