@dbx-tools/genie 0.1.18

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,33 @@
1
+ /**
2
+ * `@dbx-tools/genie` public surface.
3
+ *
4
+ * - {@link genieChat}: low-level async generator. Yields every
5
+ * poll-observed `GenieMessage` for a single turn against a
6
+ * Genie space. Multi-turn conversations are driven by the
7
+ * caller (thread the `conversation_id` off each yielded
8
+ * message into the next call's `options.conversationId`).
9
+ * - {@link genieEventChat}: high-level async generator. Drives
10
+ * `genieChat` and yields a strongly-typed
11
+ * {@link GenieChatEvent} stream of flat `{type, ...fields}`
12
+ * records (`message`, `status`, `attachment`, `thinking`,
13
+ * `text`, `query`, `statement`, `rows`,
14
+ * `suggested_questions`, `result`). The final yield for any
15
+ * terminal turn is always `{ type: "result", ... }`.
16
+ *
17
+ * Wire-format types, the {@link GenieChatEvent} discriminated
18
+ * union, per-event detectors (`detectStatus`, `detectThinking`,
19
+ * `detectAttachmentAdded`, `detectText`, `detectQuery`,
20
+ * `detectStatement`, `detectRows`, `detectSuggestedQuestions`),
21
+ * the `eventsFromMessage` sync generator, and terminal-status
22
+ * helpers all live in `@dbx-tools/genie-shared` and are
23
+ * re-exported here so a single `from "@dbx-tools/genie"` import
24
+ * works for server-side consumers. Browser-side consumers should
25
+ * import from `@dbx-tools/genie-shared` directly to avoid pulling
26
+ * in the Node-only `chat.ts` runtime.
27
+ *
28
+ * Browser safety: `chat.ts` pulls in `WorkspaceClient` and is
29
+ * Node-only; the re-exports from `@dbx-tools/genie-shared` are
30
+ * pure (types + sync functions) and safe for any runtime.
31
+ */
32
+ export * from "./src/chat.js";
33
+ export * from "@dbx-tools/genie-shared";
package/dist/index.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `@dbx-tools/genie` public surface.
3
+ *
4
+ * - {@link genieChat}: low-level async generator. Yields every
5
+ * poll-observed `GenieMessage` for a single turn against a
6
+ * Genie space. Multi-turn conversations are driven by the
7
+ * caller (thread the `conversation_id` off each yielded
8
+ * message into the next call's `options.conversationId`).
9
+ * - {@link genieEventChat}: high-level async generator. Drives
10
+ * `genieChat` and yields a strongly-typed
11
+ * {@link GenieChatEvent} stream of flat `{type, ...fields}`
12
+ * records (`message`, `status`, `attachment`, `thinking`,
13
+ * `text`, `query`, `statement`, `rows`,
14
+ * `suggested_questions`, `result`). The final yield for any
15
+ * terminal turn is always `{ type: "result", ... }`.
16
+ *
17
+ * Wire-format types, the {@link GenieChatEvent} discriminated
18
+ * union, per-event detectors (`detectStatus`, `detectThinking`,
19
+ * `detectAttachmentAdded`, `detectText`, `detectQuery`,
20
+ * `detectStatement`, `detectRows`, `detectSuggestedQuestions`),
21
+ * the `eventsFromMessage` sync generator, and terminal-status
22
+ * helpers all live in `@dbx-tools/genie-shared` and are
23
+ * re-exported here so a single `from "@dbx-tools/genie"` import
24
+ * works for server-side consumers. Browser-side consumers should
25
+ * import from `@dbx-tools/genie-shared` directly to avoid pulling
26
+ * in the Node-only `chat.ts` runtime.
27
+ *
28
+ * Browser safety: `chat.ts` pulls in `WorkspaceClient` and is
29
+ * Node-only; the re-exports from `@dbx-tools/genie-shared` are
30
+ * pure (types + sync functions) and safe for any runtime.
31
+ */
32
+ export * from "./src/chat.js";
33
+ export * from "@dbx-tools/genie-shared";
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `@dbx-tools/genie` chat driver.
3
+ *
4
+ * Two async generators ship from this file. Both take a single
5
+ * `content` string for one turn against a Genie space; for
6
+ * multi-turn conversations, the caller drives the loop and
7
+ * threads the `conversation_id` returned on each
8
+ * `GenieMessage` into the next call's `options.conversationId`.
9
+ *
10
+ * - {@link genieChat}: low-level. Yields every poll-observed
11
+ * `GenieMessage` verbatim. Cancellation, conversation seeding,
12
+ * distinct-filtering, and SDK quirks (Waiter stripping) live
13
+ * here. Use directly when you want the raw stream.
14
+ * - {@link genieEventChat}: high-level. Wraps `genieChat` and
15
+ * emits semantic, deduplicated events as a typed
16
+ * `{ type, payload }` stream (see {@link GenieChatEvent}). The
17
+ * final yield for a successful turn is always
18
+ * `{ type: "result", payload }` carrying the terminal
19
+ * `GenieMessage`. Errors propagate by the generator throwing -
20
+ * there is no `"error"` variant.
21
+ *
22
+ * Pick the layer that matches your consumer. Iterating UI / agent
23
+ * code that wants every message verbatim should use `genieChat`.
24
+ * Subscribers that want to react to "Genie is thinking about X"
25
+ * or "Genie produced text Y" should use `genieEventChat`.
26
+ */
27
+ import { WorkspaceClient } from "@databricks/sdk-experimental";
28
+ import { type GenieChatEvent, type GenieMessage } from "@dbx-tools/genie-shared";
29
+ import { apiUtils } from "@dbx-tools/shared";
30
+ /** Options accepted by both {@link genieChat} and {@link genieEventChat}. */
31
+ export interface GenieChatOptions {
32
+ /**
33
+ * Seed conversation id. When set, this turn appends to the
34
+ * existing conversation (via `createMessage`) instead of opening
35
+ * a new one. Use it to thread a multi-turn conversation: read
36
+ * `conversation_id` off the prior turn's terminal `GenieMessage`
37
+ * (or the `result` event's `payload.conversation_id`) and pass
38
+ * it into the next call.
39
+ */
40
+ conversationId?: string;
41
+ /**
42
+ * Explicit `WorkspaceClient`. Defaults to AppKit's per-request
43
+ * execution-context client when AppKit is installed and we're
44
+ * inside a request; falls back to a fresh `new WorkspaceClient({})`
45
+ * (env-var auth) otherwise.
46
+ */
47
+ workspaceClient?: WorkspaceClient;
48
+ /** Poll cadence in milliseconds between successive `getMessage` calls (default 500). */
49
+ pollIntervalMs?: number;
50
+ /**
51
+ * External cancellation. Accepts a WHATWG `AbortSignal` or a
52
+ * fully-built SDK `Context` (see `apiUtils.ContextLike`).
53
+ * Aborting it cancels every in-flight SDK call and the next
54
+ * inter-poll sleep.
55
+ */
56
+ context?: apiUtils.ContextLike;
57
+ }
58
+ /**
59
+ * One turn against a Genie space, yielded as a stream of
60
+ * `GenieMessage` snapshots.
61
+ *
62
+ * Turn lifecycle:
63
+ *
64
+ * - No `options.conversationId`: open a new conversation via
65
+ * `client.genie.startConversation`. The opened conversation id
66
+ * surfaces on every yielded `GenieMessage` (`.conversation_id`)
67
+ * so the caller can thread it into a follow-up call.
68
+ * - With `options.conversationId`: append to that conversation
69
+ * via `client.genie.createMessage`.
70
+ * - In both cases, after the create/start the driver polls
71
+ * `client.genie.getMessage` every `options.pollIntervalMs`
72
+ * (default 500ms) until the message reaches a terminal
73
+ * status, then yields the terminal snapshot and returns.
74
+ *
75
+ * Cancellation: a single internal `AbortController` covers the
76
+ * whole turn. `options.context` is tied into that controller so an
77
+ * external abort tears down every in-flight SDK call AND the
78
+ * inter-poll sleep. Breaking out of the `for await` does the same
79
+ * via the `try / finally`.
80
+ *
81
+ * @example
82
+ * // Single turn.
83
+ * for await (const m of genieChat(spaceId, "Top 5 stores?")) {
84
+ * render(m);
85
+ * }
86
+ *
87
+ * @example
88
+ * // Multi-turn: caller threads the conversation id.
89
+ * let conversationId: string | undefined;
90
+ * for (const question of questions) {
91
+ * for await (const m of genieChat(spaceId, question, { conversationId })) {
92
+ * conversationId = m.conversation_id ?? conversationId;
93
+ * render(m);
94
+ * }
95
+ * }
96
+ */
97
+ export declare function genieChat(space_id: string, content: string, options?: GenieChatOptions): AsyncGenerator<GenieMessage, void, void>;
98
+ /**
99
+ * One turn against a Genie space, yielded as a typed
100
+ * {@link GenieChatEvent} stream. Drives {@link genieChat}
101
+ * underneath and decorates each snapshot with the derived events
102
+ * the field-level diff produced. Stream order:
103
+ *
104
+ * 1. `{ type: "message", message }` - the raw `GenieMessage`,
105
+ * once per poll yield.
106
+ * 2. `{ type: "question", content, message_id, ... }` fires
107
+ * exactly once, on the FIRST `message` yield. We read
108
+ * `content` and `message_id` straight off the snapshot so
109
+ * every downstream event for this turn shares the same
110
+ * `message_id` (the question included) - subscribers can
111
+ * group everything for one Genie call under that one key.
112
+ * 3. Any of `status` / `attachment` / `thinking` / `text` /
113
+ * `query` / `statement` / `rows` / `suggested_questions` the
114
+ * diff against the prior snapshot produced.
115
+ * 4. On the terminal snapshot, `{ type: "result", ... }` as
116
+ * the final yield.
117
+ *
118
+ * Errors propagate by the generator throwing - there's no
119
+ * `"error"` variant. Wrap the `for await` in `try/catch` if you
120
+ * need to handle failures.
121
+ *
122
+ * @example
123
+ * for await (const event of genieEventChat(spaceId, "Top stores?")) {
124
+ * switch (event.type) {
125
+ * case "thinking":
126
+ * console.log("[thinking]", event.thought_type, event.text);
127
+ * break;
128
+ * case "text":
129
+ * console.log("[text]", event.text);
130
+ * break;
131
+ * case "result":
132
+ * console.log("[done]", event.status);
133
+ * break;
134
+ * }
135
+ * }
136
+ */
137
+ export declare function genieEventChat(space_id: string, content: string, options?: GenieChatOptions): AsyncGenerator<GenieChatEvent, void, void>;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * `@dbx-tools/genie` chat driver.
3
+ *
4
+ * Two async generators ship from this file. Both take a single
5
+ * `content` string for one turn against a Genie space; for
6
+ * multi-turn conversations, the caller drives the loop and
7
+ * threads the `conversation_id` returned on each
8
+ * `GenieMessage` into the next call's `options.conversationId`.
9
+ *
10
+ * - {@link genieChat}: low-level. Yields every poll-observed
11
+ * `GenieMessage` verbatim. Cancellation, conversation seeding,
12
+ * distinct-filtering, and SDK quirks (Waiter stripping) live
13
+ * here. Use directly when you want the raw stream.
14
+ * - {@link genieEventChat}: high-level. Wraps `genieChat` and
15
+ * emits semantic, deduplicated events as a typed
16
+ * `{ type, payload }` stream (see {@link GenieChatEvent}). The
17
+ * final yield for a successful turn is always
18
+ * `{ type: "result", payload }` carrying the terminal
19
+ * `GenieMessage`. Errors propagate by the generator throwing -
20
+ * there is no `"error"` variant.
21
+ *
22
+ * Pick the layer that matches your consumer. Iterating UI / agent
23
+ * code that wants every message verbatim should use `genieChat`.
24
+ * Subscribers that want to react to "Genie is thinking about X"
25
+ * or "Genie produced text Y" should use `genieEventChat`.
26
+ */
27
+ import { WorkspaceClient } from "@databricks/sdk-experimental";
28
+ import { eventsFromMessage, isTerminalStatus, } from "@dbx-tools/genie-shared";
29
+ import { apiUtils, commonUtils } from "@dbx-tools/shared";
30
+ /* ----------------------- low-level: genieChat ----------------------- */
31
+ /**
32
+ * One turn against a Genie space, yielded as a stream of
33
+ * `GenieMessage` snapshots.
34
+ *
35
+ * Turn lifecycle:
36
+ *
37
+ * - No `options.conversationId`: open a new conversation via
38
+ * `client.genie.startConversation`. The opened conversation id
39
+ * surfaces on every yielded `GenieMessage` (`.conversation_id`)
40
+ * so the caller can thread it into a follow-up call.
41
+ * - With `options.conversationId`: append to that conversation
42
+ * via `client.genie.createMessage`.
43
+ * - In both cases, after the create/start the driver polls
44
+ * `client.genie.getMessage` every `options.pollIntervalMs`
45
+ * (default 500ms) until the message reaches a terminal
46
+ * status, then yields the terminal snapshot and returns.
47
+ *
48
+ * Cancellation: a single internal `AbortController` covers the
49
+ * whole turn. `options.context` is tied into that controller so an
50
+ * external abort tears down every in-flight SDK call AND the
51
+ * inter-poll sleep. Breaking out of the `for await` does the same
52
+ * via the `try / finally`.
53
+ *
54
+ * @example
55
+ * // Single turn.
56
+ * for await (const m of genieChat(spaceId, "Top 5 stores?")) {
57
+ * render(m);
58
+ * }
59
+ *
60
+ * @example
61
+ * // Multi-turn: caller threads the conversation id.
62
+ * let conversationId: string | undefined;
63
+ * for (const question of questions) {
64
+ * for await (const m of genieChat(spaceId, question, { conversationId })) {
65
+ * conversationId = m.conversation_id ?? conversationId;
66
+ * render(m);
67
+ * }
68
+ * }
69
+ */
70
+ export async function* genieChat(space_id, content, options) {
71
+ const controller = new AbortController();
72
+ try {
73
+ const client = await getWorkspaceClient(options);
74
+ // Build the SDK Context ONCE. Building it inside the poll
75
+ // producer would re-attach an abort listener to
76
+ // `options.context` on every poll iteration (via
77
+ // `apiUtils.toContext` -> `tieAbortSignal`), eventually
78
+ // tripping Node's `MaxListenersExceededWarning`.
79
+ const context = apiUtils.toContext(controller, options?.context);
80
+ let conversationId = options?.conversationId;
81
+ let messageId;
82
+ const pollProducer = async (ctx) => {
83
+ if (!conversationId) {
84
+ // First poll: open the conversation. Refuse to retry: if
85
+ // `startConversation` returned a response without a
86
+ // `conversation_id`, retrying would just open conversation
87
+ // after conversation.
88
+ if (ctx.attempt > 0) {
89
+ throw new Error("Genie did not return a conversation id; refusing to retry");
90
+ }
91
+ const startResponse = await client.genie.startConversation({ space_id, content }, context);
92
+ conversationId = startResponse.conversation_id;
93
+ messageId = startResponse.message_id;
94
+ return startResponse.message;
95
+ }
96
+ if (!messageId) {
97
+ // First poll of a follow-up turn: append to the seeded
98
+ // conversation. `client.genie.createMessage` returns a
99
+ // `Waiter<GenieMessage>` (`{ ...message, wait: async () =>
100
+ // ... }`). Strip `wait` here so downstream serializers
101
+ // (e.g. yaml.stringify in poll-chat) don't choke on the
102
+ // AsyncFunction value.
103
+ const { wait: _wait, ...createResponse } = await client.genie.createMessage({ space_id, conversation_id: conversationId, content }, context);
104
+ messageId = createResponse.message_id;
105
+ return createResponse;
106
+ }
107
+ // Subsequent polls: re-fetch the current message until its
108
+ // status becomes terminal.
109
+ return await client.genie.getMessage({
110
+ space_id,
111
+ conversation_id: conversationId,
112
+ message_id: messageId,
113
+ }, context);
114
+ };
115
+ yield* commonUtils.poll(pollProducer, {
116
+ intervalMs: options?.pollIntervalMs ?? 500,
117
+ // Skip yielding identical consecutive snapshots; Genie
118
+ // often returns the exact same payload twice during quiet
119
+ // periods. `poll` does a deep equal on the previous yield.
120
+ filter: "distinct",
121
+ // Stop after the terminal message is yielded. `poll` checks
122
+ // the predicate AFTER yielding, so the terminal message
123
+ // still reaches the consumer.
124
+ predicate: (m) => !isTerminalStatus(m.status),
125
+ // Wake the inter-poll sleep on abort so a `for await` break
126
+ // (or external abort) tears down promptly instead of waiting
127
+ // out the interval.
128
+ signal: controller.signal,
129
+ });
130
+ }
131
+ finally {
132
+ // Cancels any still-pending SDK call and the inter-poll sleep
133
+ // whether we're unwinding from a normal return, a consumer
134
+ // break, or a thrown error. Idempotent.
135
+ controller.abort();
136
+ }
137
+ }
138
+ /* ---------------------- high-level: genieEventChat ------------------ */
139
+ /**
140
+ * One turn against a Genie space, yielded as a typed
141
+ * {@link GenieChatEvent} stream. Drives {@link genieChat}
142
+ * underneath and decorates each snapshot with the derived events
143
+ * the field-level diff produced. Stream order:
144
+ *
145
+ * 1. `{ type: "message", message }` - the raw `GenieMessage`,
146
+ * once per poll yield.
147
+ * 2. `{ type: "question", content, message_id, ... }` fires
148
+ * exactly once, on the FIRST `message` yield. We read
149
+ * `content` and `message_id` straight off the snapshot so
150
+ * every downstream event for this turn shares the same
151
+ * `message_id` (the question included) - subscribers can
152
+ * group everything for one Genie call under that one key.
153
+ * 3. Any of `status` / `attachment` / `thinking` / `text` /
154
+ * `query` / `statement` / `rows` / `suggested_questions` the
155
+ * diff against the prior snapshot produced.
156
+ * 4. On the terminal snapshot, `{ type: "result", ... }` as
157
+ * the final yield.
158
+ *
159
+ * Errors propagate by the generator throwing - there's no
160
+ * `"error"` variant. Wrap the `for await` in `try/catch` if you
161
+ * need to handle failures.
162
+ *
163
+ * @example
164
+ * for await (const event of genieEventChat(spaceId, "Top stores?")) {
165
+ * switch (event.type) {
166
+ * case "thinking":
167
+ * console.log("[thinking]", event.thought_type, event.text);
168
+ * break;
169
+ * case "text":
170
+ * console.log("[text]", event.text);
171
+ * break;
172
+ * case "result":
173
+ * console.log("[done]", event.status);
174
+ * break;
175
+ * }
176
+ * }
177
+ */
178
+ export async function* genieEventChat(space_id, content, options) {
179
+ // Diff source for the current turn. Always `undefined` on the
180
+ // first snapshot so the initial status / attachments emit
181
+ // fresh; updated to the most recent snapshot after each yield.
182
+ let previous;
183
+ // The `question` event is deferred to the first `message` yield
184
+ // so it can carry the assigned `message_id` (subscribers use it
185
+ // as the grouping key for every event in this turn). The first
186
+ // snapshot is the earliest point that id exists.
187
+ let questionEmitted = false;
188
+ for await (const message of genieChat(space_id, content, options)) {
189
+ yield { type: "message", message };
190
+ if (!questionEmitted) {
191
+ yield {
192
+ type: "question",
193
+ space_id: message.space_id,
194
+ ...(message.conversation_id
195
+ ? { conversation_id: message.conversation_id }
196
+ : {}),
197
+ ...(message.message_id ? { message_id: message.message_id } : {}),
198
+ content: message.content,
199
+ };
200
+ questionEmitted = true;
201
+ }
202
+ yield* eventsFromMessage(message, previous, message.space_id);
203
+ if (isTerminalStatus(message.status)) {
204
+ yield {
205
+ type: "result",
206
+ space_id: message.space_id,
207
+ conversation_id: message.conversation_id,
208
+ message_id: message.message_id,
209
+ status: message.status,
210
+ message,
211
+ };
212
+ }
213
+ previous = message;
214
+ }
215
+ }
216
+ /* ---------------------- workspace client helper --------------------- */
217
+ /**
218
+ * Resolve a `WorkspaceClient` in this preference order:
219
+ *
220
+ * 1. Caller-supplied `options.workspaceClient`.
221
+ * 2. AppKit's per-request execution-context client, when AppKit
222
+ * is installed AND we're inside a request scope.
223
+ * 3. Fresh `new WorkspaceClient({})` (env-var auth via
224
+ * `DATABRICKS_CONFIG_PROFILE` / `DATABRICKS_HOST` /
225
+ * `DATABRICKS_TOKEN`).
226
+ *
227
+ * AppKit is loaded lazily so this package stays usable in
228
+ * non-AppKit environments (e.g. the `poll-chat` smoke test).
229
+ */
230
+ async function getWorkspaceClient(options) {
231
+ if (options?.workspaceClient)
232
+ return options.workspaceClient;
233
+ const appkit = await getAppKit();
234
+ if (appkit) {
235
+ try {
236
+ return appkit.getExecutionContext().client;
237
+ }
238
+ catch {
239
+ // Not inside an AppKit request context; fall through to env.
240
+ }
241
+ }
242
+ return new WorkspaceClient({});
243
+ }
244
+ async function getAppKit() {
245
+ try {
246
+ return await import("@databricks/appkit");
247
+ }
248
+ catch {
249
+ return undefined;
250
+ }
251
+ }