@dbx-tools/genie-shared 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,32 @@
1
+ /**
2
+ * `@dbx-tools/genie-shared`: pure-types + sync-helpers surface of
3
+ * the `@dbx-tools/genie` package. Safe to import from browser
4
+ * bundles (no `node:*`, no `WorkspaceClient`, no I/O).
5
+ *
6
+ * What lives here:
7
+ *
8
+ * - {@link ./src/protocol.js}: wire-format zod schemas + types
9
+ * extending the generated `@dbx-tools/sdk-shared` Genie shapes
10
+ * (`GenieMessageSchema`, `GenieAttachmentSchema`,
11
+ * `GenieQueryAttachmentSchema`, `GenieThoughtSchema`,
12
+ * `messageStatusSchema`, ...) plus the high-level event
13
+ * vocabulary the `genieEventChat` driver emits
14
+ * (`GenieChatEvent`, `GenieChatLocation`, per-variant payload
15
+ * interfaces) and terminal-status / attachment-discriminator
16
+ * helpers (`TERMINAL_STATUSES`, `isTerminalStatus`,
17
+ * `detectAttachmentType`, `tagAttachment`).
18
+ * - {@link ./src/event.js}: pure sync detectors
19
+ * (`detectStatus`, `detectThinking`, `detectAttachmentAdded`,
20
+ * `detectText`, `detectQuery`, `detectStatement`,
21
+ * `detectRows`, `detectSuggestedQuestions`) and the
22
+ * `eventsFromMessage` orchestrator generator. Used by
23
+ * `genieEventChat` server-side; also reusable from the
24
+ * browser when consumers want to derive UI events from
25
+ * `GenieMessage` snapshots themselves.
26
+ *
27
+ * Server-only chat driving (`genieChat`, `genieEventChat`) lives
28
+ * in `@dbx-tools/genie` and pulls these types in. Frontends only
29
+ * need this package.
30
+ */
31
+ export * from "./src/event.js";
32
+ export * from "./src/protocol.js";
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `@dbx-tools/genie-shared`: pure-types + sync-helpers surface of
3
+ * the `@dbx-tools/genie` package. Safe to import from browser
4
+ * bundles (no `node:*`, no `WorkspaceClient`, no I/O).
5
+ *
6
+ * What lives here:
7
+ *
8
+ * - {@link ./src/protocol.js}: wire-format zod schemas + types
9
+ * extending the generated `@dbx-tools/sdk-shared` Genie shapes
10
+ * (`GenieMessageSchema`, `GenieAttachmentSchema`,
11
+ * `GenieQueryAttachmentSchema`, `GenieThoughtSchema`,
12
+ * `messageStatusSchema`, ...) plus the high-level event
13
+ * vocabulary the `genieEventChat` driver emits
14
+ * (`GenieChatEvent`, `GenieChatLocation`, per-variant payload
15
+ * interfaces) and terminal-status / attachment-discriminator
16
+ * helpers (`TERMINAL_STATUSES`, `isTerminalStatus`,
17
+ * `detectAttachmentType`, `tagAttachment`).
18
+ * - {@link ./src/event.js}: pure sync detectors
19
+ * (`detectStatus`, `detectThinking`, `detectAttachmentAdded`,
20
+ * `detectText`, `detectQuery`, `detectStatement`,
21
+ * `detectRows`, `detectSuggestedQuestions`) and the
22
+ * `eventsFromMessage` orchestrator generator. Used by
23
+ * `genieEventChat` server-side; also reusable from the
24
+ * browser when consumers want to derive UI events from
25
+ * `GenieMessage` snapshots themselves.
26
+ *
27
+ * Server-only chat driving (`genieChat`, `genieEventChat`) lives
28
+ * in `@dbx-tools/genie` and pulls these types in. Frontends only
29
+ * need this package.
30
+ */
31
+ export * from "./src/event.js";
32
+ export * from "./src/protocol.js";
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Pure event-detection for `genieEventChat`. Given two
3
+ * `GenieMessage` snapshots (current + prior) and the surrounding
4
+ * `space_id`, derive the semantic deltas (status transitions, new
5
+ * attachments, new thoughts, SQL emission, warehouse submission,
6
+ * row-count progress, follow-up suggestions, text deltas) and
7
+ * yield them as typed {@link GenieChatEvent}s.
8
+ *
9
+ * Architecture:
10
+ *
11
+ * - Each detector is built with {@link eventDetector}, which
12
+ * takes the event name as a literal string and a `detect`
13
+ * callback. TS infers `T extends GenieChatEventType` from the
14
+ * literal, looks up its scope in {@link DetectorScope}, and
15
+ * resolves `detect`'s parameter list (message vs
16
+ * per-attachment) and return type
17
+ * ({@link DetectorResult}<T>) accordingly. Pass an unknown
18
+ * name (`"status2"`) and the call fails to compile; pass a
19
+ * payload shape that doesn't match the named event and the
20
+ * return fails to compile.
21
+ * - {@link eventsFromMessage}: sync generator. Walks the
22
+ * snapshot diff and yields flat `{type, ...fields}` events
23
+ * for every detector that fires, in a stable order (status
24
+ * first, then per-attachment field deltas) so a subscriber
25
+ * that simply logs events as they arrive sees them in a
26
+ * sensible sequence.
27
+ * - Private helpers (`matchPrevAttachment`, `thoughtKey`):
28
+ * diff plumbing shared across detectors.
29
+ *
30
+ * The module is intentionally pure: no `EventEmitter`, no `this`,
31
+ * no I/O, no module-level mutable state. The `message` and
32
+ * `result` events are NOT derived here - they belong to the chat
33
+ * lifecycle layer (`chat.ts`, `genieEventChat`) because they track
34
+ * per-yield / per-turn-completion semantics rather than a
35
+ * field-level snapshot diff.
36
+ */
37
+ import { type GenieAttachment, type GenieChatEvent, type GenieChatEventFields, type GenieChatEventType, type GenieChatLocation, type GenieMessage } from "./protocol.js";
38
+ /**
39
+ * What a single detector call returns: zero (`undefined`), one
40
+ * (fields object), or many (`fields[]`) events of the same type.
41
+ * Each result is the variant's payload fields **without** the
42
+ * `type` discriminator - the orchestrator stamps `type` when it
43
+ * yields the event.
44
+ */
45
+ type DetectorResult<T extends GenieChatEventType> = GenieChatEventFields<T> | GenieChatEventFields<T>[] | undefined;
46
+ /**
47
+ * Where in the wire shape a given event is derived from. Drives
48
+ * which arguments `detect` receives. `"message"` events watch
49
+ * `GenieMessage` itself; `"attachment"` events watch one slot of
50
+ * `message.attachments[]`. `"lifecycle"` events (`message`,
51
+ * `result`) are emitted by `chat.ts` directly and can't be built
52
+ * with {@link eventDetector} - they have no diff signature.
53
+ */
54
+ interface DetectorScope {
55
+ status: "message";
56
+ attachment: "attachment";
57
+ thinking: "attachment";
58
+ text: "attachment";
59
+ query: "attachment";
60
+ statement: "attachment";
61
+ rows: "attachment";
62
+ suggested_questions: "attachment";
63
+ question: "lifecycle";
64
+ message: "lifecycle";
65
+ result: "lifecycle";
66
+ }
67
+ /**
68
+ * `detect` callback signature for a given event type. Resolved
69
+ * from {@link DetectorScope}: `"message"` events get the top-level
70
+ * snapshot triple, `"attachment"` events get the per-slot quad,
71
+ * `"lifecycle"` events resolve to `never` (no diff-based detector
72
+ * exists for them).
73
+ */
74
+ type DetectFn<T extends GenieChatEventType> = DetectorScope[T] extends "message" ? (current: GenieMessage, previous: GenieMessage | undefined, space_id: string) => DetectorResult<T> : DetectorScope[T] extends "attachment" ? (current: GenieAttachment, previous: GenieAttachment | undefined, location: GenieChatLocation, index: number) => DetectorResult<T> : never;
75
+ /**
76
+ * Typed detector for one event in the {@link GenieChatEvent}
77
+ * union. The `type` field is the event name; `detect`'s signature
78
+ * is picked from {@link DetectorScope} based on `T`.
79
+ */
80
+ interface EventDetector<T extends GenieChatEventType> {
81
+ readonly type: T;
82
+ detect: DetectFn<T>;
83
+ }
84
+ /**
85
+ * Build an {@link EventDetector}. Pass the event name as the
86
+ * literal first arg and the matching `detect` callback as the
87
+ * second. TS infers `T` from the literal, narrows `detect`'s
88
+ * signature accordingly, and types the return as
89
+ * `EventDetector<T>`.
90
+ *
91
+ * Build-time guarantees:
92
+ *
93
+ * - `eventDetector("status2", ...)` fails - the name isn't in
94
+ * {@link GenieChatEvent}.
95
+ * - `eventDetector("status", attachmentArgsCallback)` fails -
96
+ * `"status"` is message-scoped, so `detect` must take
97
+ * `(GenieMessage, GenieMessage | undefined, string)`.
98
+ * - Returning a `ThinkingEvent`-shaped fields object from a
99
+ * `"status"` detector fails - the return type is constrained
100
+ * to `DetectorResult<"status">`.
101
+ *
102
+ * Lifecycle event names (`"message"`, `"result"`) resolve `detect`
103
+ * to `never` and won't compile, which is intentional: those have
104
+ * no diff signature and are emitted directly by `chat.ts`.
105
+ */
106
+ export declare function eventDetector<T extends GenieChatEventType>(type: T, detect: DetectFn<T>): EventDetector<T>;
107
+ /** Top-level `message.status` transitioned. */
108
+ export declare const detectStatus: EventDetector<"status">;
109
+ /** First time we see an attachment slot. */
110
+ export declare const detectAttachmentAdded: EventDetector<"attachment">;
111
+ /**
112
+ * One emit per new `(thought_type, content)` tuple on a query
113
+ * attachment. Value-based set diff: Genie can mutate existing
114
+ * thought slots in place (e.g. re-typing index 0 from
115
+ * `DATA_SOURCING` to `DESCRIPTION` while re-appending the
116
+ * original at index 1), so positional / append-only diff would
117
+ * miss re-types and double-count re-orders.
118
+ */
119
+ export declare const detectThinking: EventDetector<"thinking">;
120
+ /** Text-attachment `content` appeared or changed. */
121
+ export declare const detectText: EventDetector<"text">;
122
+ /** SQL transitioned undefined -> string, or changed. */
123
+ export declare const detectQuery: EventDetector<"query">;
124
+ /** Warehouse-statement id assigned. */
125
+ export declare const detectStatement: EventDetector<"statement">;
126
+ /**
127
+ * `row_count` changed - fires on every transition including the
128
+ * initial `undefined -> 0` and the post-execution `0 -> N`.
129
+ * Carries the statement id when available for correlation.
130
+ */
131
+ export declare const detectRows: EventDetector<"rows">;
132
+ /**
133
+ * Follow-up suggested-questions array appeared or changed.
134
+ * Compares JSON-stringified arrays so a length-preserving content
135
+ * rewrite still fires.
136
+ */
137
+ export declare const detectSuggestedQuestions: EventDetector<"suggested_questions">;
138
+ /**
139
+ * Walk the diff between `current` and `previous` and yield every
140
+ * derived event the snapshot produced. Detector order mirrors
141
+ * Genie's wire ordering (status first, then per-attachment field
142
+ * deltas) so a subscriber that simply logs events as they arrive
143
+ * sees them in a sensible sequence.
144
+ *
145
+ * Caller responsibilities (not handled here):
146
+ *
147
+ * - Yield `{ type: "message", message: current }` BEFORE
148
+ * calling this, once per poll yield.
149
+ * - Yield `{ type: "result", ... }` AFTER calling this when
150
+ * `isTerminalStatus(current.status)` - per-turn lifecycle,
151
+ * not a per-snapshot field diff.
152
+ * - Decide what counts as a "fresh turn" and pass `undefined`
153
+ * for `previous` on turn boundaries, so anonymous-attachment
154
+ * state from a prior turn doesn't bleed in.
155
+ *
156
+ * Sync generator: the diff is pure CPU work, no awaits. Use
157
+ * `yield*` from an async generator to splice the events into a
158
+ * stream.
159
+ */
160
+ export declare function eventsFromMessage(current: GenieMessage, previous: GenieMessage | undefined, space_id: string): Generator<GenieChatEvent, void, void>;
161
+ export {};
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Pure event-detection for `genieEventChat`. Given two
3
+ * `GenieMessage` snapshots (current + prior) and the surrounding
4
+ * `space_id`, derive the semantic deltas (status transitions, new
5
+ * attachments, new thoughts, SQL emission, warehouse submission,
6
+ * row-count progress, follow-up suggestions, text deltas) and
7
+ * yield them as typed {@link GenieChatEvent}s.
8
+ *
9
+ * Architecture:
10
+ *
11
+ * - Each detector is built with {@link eventDetector}, which
12
+ * takes the event name as a literal string and a `detect`
13
+ * callback. TS infers `T extends GenieChatEventType` from the
14
+ * literal, looks up its scope in {@link DetectorScope}, and
15
+ * resolves `detect`'s parameter list (message vs
16
+ * per-attachment) and return type
17
+ * ({@link DetectorResult}<T>) accordingly. Pass an unknown
18
+ * name (`"status2"`) and the call fails to compile; pass a
19
+ * payload shape that doesn't match the named event and the
20
+ * return fails to compile.
21
+ * - {@link eventsFromMessage}: sync generator. Walks the
22
+ * snapshot diff and yields flat `{type, ...fields}` events
23
+ * for every detector that fires, in a stable order (status
24
+ * first, then per-attachment field deltas) so a subscriber
25
+ * that simply logs events as they arrive sees them in a
26
+ * sensible sequence.
27
+ * - Private helpers (`matchPrevAttachment`, `thoughtKey`):
28
+ * diff plumbing shared across detectors.
29
+ *
30
+ * The module is intentionally pure: no `EventEmitter`, no `this`,
31
+ * no I/O, no module-level mutable state. The `message` and
32
+ * `result` events are NOT derived here - they belong to the chat
33
+ * lifecycle layer (`chat.ts`, `genieEventChat`) because they track
34
+ * per-yield / per-turn-completion semantics rather than a
35
+ * field-level snapshot diff.
36
+ */
37
+ import { detectAttachmentType, } from "./protocol.js";
38
+ /* ----------------------- detector factory ----------------------- */
39
+ /**
40
+ * Build an {@link EventDetector}. Pass the event name as the
41
+ * literal first arg and the matching `detect` callback as the
42
+ * second. TS infers `T` from the literal, narrows `detect`'s
43
+ * signature accordingly, and types the return as
44
+ * `EventDetector<T>`.
45
+ *
46
+ * Build-time guarantees:
47
+ *
48
+ * - `eventDetector("status2", ...)` fails - the name isn't in
49
+ * {@link GenieChatEvent}.
50
+ * - `eventDetector("status", attachmentArgsCallback)` fails -
51
+ * `"status"` is message-scoped, so `detect` must take
52
+ * `(GenieMessage, GenieMessage | undefined, string)`.
53
+ * - Returning a `ThinkingEvent`-shaped fields object from a
54
+ * `"status"` detector fails - the return type is constrained
55
+ * to `DetectorResult<"status">`.
56
+ *
57
+ * Lifecycle event names (`"message"`, `"result"`) resolve `detect`
58
+ * to `never` and won't compile, which is intentional: those have
59
+ * no diff signature and are emitted directly by `chat.ts`.
60
+ */
61
+ export function eventDetector(type, detect) {
62
+ return { type, detect };
63
+ }
64
+ /* ---------------------------- detectors ---------------------------- */
65
+ /** Top-level `message.status` transitioned. */
66
+ export const detectStatus = eventDetector("status", (current, previous, space_id) => {
67
+ if (!current.status || current.status === previous?.status)
68
+ return;
69
+ return {
70
+ status: current.status,
71
+ previous_status: previous?.status,
72
+ space_id,
73
+ conversation_id: current.conversation_id,
74
+ message_id: current.message_id,
75
+ };
76
+ });
77
+ /** First time we see an attachment slot. */
78
+ export const detectAttachmentAdded = eventDetector("attachment", (current, previous, location, index) => {
79
+ if (previous)
80
+ return;
81
+ return {
82
+ ...location,
83
+ index,
84
+ attachment_type: detectAttachmentType(current),
85
+ };
86
+ });
87
+ /**
88
+ * One emit per new `(thought_type, content)` tuple on a query
89
+ * attachment. Value-based set diff: Genie can mutate existing
90
+ * thought slots in place (e.g. re-typing index 0 from
91
+ * `DATA_SOURCING` to `DESCRIPTION` while re-appending the
92
+ * original at index 1), so positional / append-only diff would
93
+ * miss re-types and double-count re-orders.
94
+ */
95
+ export const detectThinking = eventDetector("thinking", (current, previous, location) => {
96
+ const currThoughts = current.query?.thoughts ?? [];
97
+ if (currThoughts.length === 0)
98
+ return;
99
+ const seen = new Set((previous?.query?.thoughts ?? []).map(thoughtKey));
100
+ const out = [];
101
+ for (const t of currThoughts) {
102
+ const key = thoughtKey(t);
103
+ if (seen.has(key))
104
+ continue;
105
+ // Defensive: dedupe within a single snapshot in case Genie
106
+ // ever ships the same thought twice in one `thoughts[]`.
107
+ seen.add(key);
108
+ out.push({ ...location, text: t.content, thought_type: t.thought_type });
109
+ }
110
+ return out;
111
+ });
112
+ /** Text-attachment `content` appeared or changed. */
113
+ export const detectText = eventDetector("text", (current, previous, location) => {
114
+ const curr = current.text?.content;
115
+ const prev = previous?.text?.content;
116
+ if (curr === undefined || curr === prev)
117
+ return;
118
+ return { ...location, text: curr };
119
+ });
120
+ /** SQL transitioned undefined -> string, or changed. */
121
+ export const detectQuery = eventDetector("query", (current, previous, location) => {
122
+ const curr = current.query?.query;
123
+ const prev = previous?.query?.query;
124
+ if (!curr || curr === prev)
125
+ return;
126
+ return {
127
+ ...location,
128
+ sql: curr,
129
+ title: current.query?.title,
130
+ description: current.query?.description,
131
+ };
132
+ });
133
+ /** Warehouse-statement id assigned. */
134
+ export const detectStatement = eventDetector("statement", (current, previous, location) => {
135
+ const curr = current.query?.statement_id;
136
+ const prev = previous?.query?.statement_id;
137
+ if (!curr || curr === prev)
138
+ return;
139
+ return { ...location, statement_id: curr };
140
+ });
141
+ /**
142
+ * `row_count` changed - fires on every transition including the
143
+ * initial `undefined -> 0` and the post-execution `0 -> N`.
144
+ * Carries the statement id when available for correlation.
145
+ */
146
+ export const detectRows = eventDetector("rows", (current, previous, location) => {
147
+ const curr = current.query?.query_result_metadata?.row_count;
148
+ const prev = previous?.query?.query_result_metadata?.row_count;
149
+ if (curr === undefined || curr === prev)
150
+ return;
151
+ return {
152
+ ...location,
153
+ row_count: curr,
154
+ previous_row_count: prev,
155
+ statement_id: current.query?.statement_id ?? previous?.query?.statement_id,
156
+ };
157
+ });
158
+ /**
159
+ * Follow-up suggested-questions array appeared or changed.
160
+ * Compares JSON-stringified arrays so a length-preserving content
161
+ * rewrite still fires.
162
+ */
163
+ export const detectSuggestedQuestions = eventDetector("suggested_questions", (current, previous, location) => {
164
+ const curr = current.suggested_questions?.questions;
165
+ const prev = previous?.suggested_questions?.questions;
166
+ if (!curr || curr.length === 0)
167
+ return;
168
+ if (JSON.stringify(curr) === JSON.stringify(prev))
169
+ return;
170
+ return { ...location, questions: curr };
171
+ });
172
+ /* --------------------------- orchestrator --------------------------- */
173
+ /**
174
+ * Walk the diff between `current` and `previous` and yield every
175
+ * derived event the snapshot produced. Detector order mirrors
176
+ * Genie's wire ordering (status first, then per-attachment field
177
+ * deltas) so a subscriber that simply logs events as they arrive
178
+ * sees them in a sensible sequence.
179
+ *
180
+ * Caller responsibilities (not handled here):
181
+ *
182
+ * - Yield `{ type: "message", message: current }` BEFORE
183
+ * calling this, once per poll yield.
184
+ * - Yield `{ type: "result", ... }` AFTER calling this when
185
+ * `isTerminalStatus(current.status)` - per-turn lifecycle,
186
+ * not a per-snapshot field diff.
187
+ * - Decide what counts as a "fresh turn" and pass `undefined`
188
+ * for `previous` on turn boundaries, so anonymous-attachment
189
+ * state from a prior turn doesn't bleed in.
190
+ *
191
+ * Sync generator: the diff is pure CPU work, no awaits. Use
192
+ * `yield*` from an async generator to splice the events into a
193
+ * stream.
194
+ */
195
+ export function* eventsFromMessage(current, previous, space_id) {
196
+ // Stamp `type` onto each detector result and yield it. Returning
197
+ // a typed generator keeps the `yield*` callsite tidy. The double
198
+ // cast (`unknown` -> `GenieChatEvent`) is needed because the
199
+ // generic merge `{type: T} & GenieChatEventFields<T>` doesn't
200
+ // structurally narrow back to a discriminated-union member -
201
+ // each detector's runtime output is shaped correctly by
202
+ // construction, so the cast is sound.
203
+ function* emit(detector, result) {
204
+ if (result === undefined)
205
+ return;
206
+ if (Array.isArray(result)) {
207
+ for (const fields of result) {
208
+ yield { type: detector.type, ...fields };
209
+ }
210
+ }
211
+ else {
212
+ yield { type: detector.type, ...result };
213
+ }
214
+ }
215
+ // Message-scoped detectors run once per snapshot.
216
+ yield* emit(detectStatus, detectStatus.detect(current, previous, space_id));
217
+ // Per-attachment detectors run once per attachment slot.
218
+ const currAtts = current.attachments ?? [];
219
+ const prevAtts = previous?.attachments ?? [];
220
+ for (let i = 0; i < currAtts.length; i++) {
221
+ const curr = currAtts[i];
222
+ const prev = matchPrevAttachment(curr, prevAtts, i);
223
+ const location = {
224
+ space_id,
225
+ conversation_id: current.conversation_id,
226
+ message_id: current.message_id,
227
+ attachment_id: curr.attachment_id,
228
+ };
229
+ yield* emit(detectAttachmentAdded, detectAttachmentAdded.detect(curr, prev, location, i));
230
+ yield* emit(detectThinking, detectThinking.detect(curr, prev, location, i));
231
+ yield* emit(detectText, detectText.detect(curr, prev, location, i));
232
+ yield* emit(detectQuery, detectQuery.detect(curr, prev, location, i));
233
+ yield* emit(detectStatement, detectStatement.detect(curr, prev, location, i));
234
+ yield* emit(detectRows, detectRows.detect(curr, prev, location, i));
235
+ yield* emit(detectSuggestedQuestions, detectSuggestedQuestions.detect(curr, prev, location, i));
236
+ }
237
+ }
238
+ /* ----------------------------- helpers ----------------------------- */
239
+ /**
240
+ * Find the prior version of `curr` in `prevAtts`. Attachments
241
+ * with ids match by id (Genie keeps ids stable across polls);
242
+ * anonymous attachments (Genie's main-answer text doesn't get
243
+ * one) match positionally against an anonymous prev at the same
244
+ * index, so they don't accidentally bind to an id'd predecessor
245
+ * that happened to share the slot.
246
+ */
247
+ function matchPrevAttachment(curr, prevAtts, i) {
248
+ if (curr.attachment_id) {
249
+ return prevAtts.find((a) => a.attachment_id === curr.attachment_id);
250
+ }
251
+ const p = prevAtts[i];
252
+ return p && !p.attachment_id ? p : undefined;
253
+ }
254
+ /** Stable key for {@link detectThinking}'s value-based set diff. */
255
+ function thoughtKey(t) {
256
+ return `${t.thought_type}|${t.content}`;
257
+ }