@dbx-tools/genie 0.1.58 → 0.1.67

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.
@@ -1,134 +0,0 @@
1
- /**
2
- * `@dbx-tools/genie` chat driver.
3
- *
4
- * Drives a single turn against a Genie space from one `content`
5
- * string; multi-turn conversations are the caller's job (thread
6
- * the `conversation_id` returned on each `GenieMessage` back into
7
- * the next turn's `options.conversationId`).
8
- *
9
- * Two layers serve two kinds of consumer. The low-level layer
10
- * yields every poll-observed `GenieMessage` (validated against
11
- * `GenieMessageSchema`, falling back to the raw snapshot on a schema
12
- * miss) and owns the messy parts - cancellation, conversation
13
- * seeding, distinct-filtering, and SDK quirks (Waiter stripping);
14
- * reach for it when you want the raw stream. The high-level layer wraps it
15
- * and emits semantic, deduplicated `{ type, payload }` events
16
- * (see {@link GenieChatEvent}), always closing a successful turn
17
- * with a terminal `result` event carrying the final
18
- * `GenieMessage`; errors propagate by throwing, with no `error`
19
- * variant. Iterating UI / agent code that wants every message
20
- * verbatim takes the low-level stream; subscribers reacting to
21
- * "Genie is thinking about X" or "Genie produced text Y" take the
22
- * event layer.
23
- */
24
- import { WorkspaceClient } from "@databricks/sdk-experimental";
25
- import { type GenieChatEvent, type GenieMessage } from "@dbx-tools/genie-shared";
26
- import { apiUtils } from "@dbx-tools/shared";
27
- /** Options accepted by both {@link genieChat} and {@link genieEventChat}. */
28
- export interface GenieChatOptions {
29
- /**
30
- * Seed conversation id. When set, this turn appends to the
31
- * existing conversation (via `createMessage`) instead of opening
32
- * a new one. Use it to thread a multi-turn conversation: read
33
- * `conversation_id` off the prior turn's terminal `GenieMessage`
34
- * (or the `result` event's `payload.conversation_id`) and pass
35
- * it into the next call.
36
- */
37
- conversationId?: string;
38
- /**
39
- * Explicit `WorkspaceClient`. Defaults to AppKit's per-request
40
- * execution-context client when AppKit is installed and we're
41
- * inside a request; falls back to a fresh `new WorkspaceClient({})`
42
- * (env-var auth) otherwise.
43
- */
44
- workspaceClient?: WorkspaceClient;
45
- /** Poll cadence in milliseconds between successive `getMessage` calls (default 500). */
46
- pollIntervalMs?: number;
47
- /**
48
- * External cancellation. Accepts a WHATWG `AbortSignal` or a
49
- * fully-built SDK `Context` (see `apiUtils.ContextLike`).
50
- * Aborting it cancels every in-flight SDK call and the next
51
- * inter-poll sleep.
52
- */
53
- context?: apiUtils.ContextLike;
54
- }
55
- /**
56
- * One turn against a Genie space, yielded as a stream of
57
- * `GenieMessage` snapshots.
58
- *
59
- * Turn lifecycle:
60
- *
61
- * - No `options.conversationId`: open a new conversation via
62
- * `client.genie.startConversation`. The opened conversation id
63
- * surfaces on every yielded `GenieMessage` (`.conversation_id`)
64
- * so the caller can thread it into a follow-up call.
65
- * - With `options.conversationId`: append to that conversation
66
- * via `client.genie.createMessage`.
67
- * - In both cases, after the create/start the driver polls
68
- * `client.genie.getMessage` every `options.pollIntervalMs`
69
- * (default 500ms) until the message reaches a terminal
70
- * status, then yields the terminal snapshot and returns.
71
- *
72
- * Cancellation: a single internal `AbortController` covers the
73
- * whole turn. `options.context` is tied into that controller so an
74
- * external abort tears down every in-flight SDK call AND the
75
- * inter-poll sleep. Breaking out of the `for await` does the same
76
- * via the `try / finally`.
77
- *
78
- * @example
79
- * // Single turn.
80
- * for await (const m of genieChat(spaceId, "Top 5 stores?")) {
81
- * render(m);
82
- * }
83
- *
84
- * @example
85
- * // Multi-turn: caller threads the conversation id.
86
- * let conversationId: string | undefined;
87
- * for (const question of questions) {
88
- * for await (const m of genieChat(spaceId, question, { conversationId })) {
89
- * conversationId = m.conversation_id ?? conversationId;
90
- * render(m);
91
- * }
92
- * }
93
- */
94
- export declare function genieChat(space_id: string, content: string, options?: GenieChatOptions): AsyncGenerator<GenieMessage, void, void>;
95
- /**
96
- * One turn against a Genie space, yielded as a typed
97
- * {@link GenieChatEvent} stream. Drives {@link genieChat}
98
- * underneath and decorates each snapshot with the derived events
99
- * the field-level diff produced. Stream order:
100
- *
101
- * 1. `{ type: "message", message }` - the raw `GenieMessage`,
102
- * once per poll yield.
103
- * 2. `{ type: "question", content, message_id, ... }` fires
104
- * exactly once, on the FIRST `message` yield. We read
105
- * `content` and `message_id` straight off the snapshot so
106
- * every downstream event for this turn shares the same
107
- * `message_id` (the question included) - subscribers can
108
- * group everything for one Genie call under that one key.
109
- * 3. Any of `status` / `attachment` / `thinking` / `text` /
110
- * `query` / `statement` / `rows` / `suggested_questions` the
111
- * diff against the prior snapshot produced.
112
- * 4. On the terminal snapshot, `{ type: "result", ... }` as
113
- * the final yield.
114
- *
115
- * Errors propagate by the generator throwing - there's no
116
- * `"error"` variant. Wrap the `for await` in `try/catch` if you
117
- * need to handle failures.
118
- *
119
- * @example
120
- * for await (const event of genieEventChat(spaceId, "Top stores?")) {
121
- * switch (event.type) {
122
- * case "thinking":
123
- * console.log("[thinking]", event.thought_type, event.text);
124
- * break;
125
- * case "text":
126
- * console.log("[text]", event.text);
127
- * break;
128
- * case "result":
129
- * console.log("[done]", event.status);
130
- * break;
131
- * }
132
- * }
133
- */
134
- export declare function genieEventChat(space_id: string, content: string, options?: GenieChatOptions): AsyncGenerator<GenieChatEvent, void, void>;
package/dist/src/chat.js DELETED
@@ -1,284 +0,0 @@
1
- /**
2
- * `@dbx-tools/genie` chat driver.
3
- *
4
- * Drives a single turn against a Genie space from one `content`
5
- * string; multi-turn conversations are the caller's job (thread
6
- * the `conversation_id` returned on each `GenieMessage` back into
7
- * the next turn's `options.conversationId`).
8
- *
9
- * Two layers serve two kinds of consumer. The low-level layer
10
- * yields every poll-observed `GenieMessage` (validated against
11
- * `GenieMessageSchema`, falling back to the raw snapshot on a schema
12
- * miss) and owns the messy parts - cancellation, conversation
13
- * seeding, distinct-filtering, and SDK quirks (Waiter stripping);
14
- * reach for it when you want the raw stream. The high-level layer wraps it
15
- * and emits semantic, deduplicated `{ type, payload }` events
16
- * (see {@link GenieChatEvent}), always closing a successful turn
17
- * with a terminal `result` event carrying the final
18
- * `GenieMessage`; errors propagate by throwing, with no `error`
19
- * variant. Iterating UI / agent code that wants every message
20
- * verbatim takes the low-level stream; subscribers reacting to
21
- * "Genie is thinking about X" or "Genie produced text Y" take the
22
- * event layer.
23
- */
24
- import { WorkspaceClient } from "@databricks/sdk-experimental";
25
- import { eventsFromMessage, GenieMessageSchema, isTerminalStatus, } from "@dbx-tools/genie-shared";
26
- import { apiUtils, commonUtils, logUtils } from "@dbx-tools/shared";
27
- const log = logUtils.logger("genie/chat");
28
- /**
29
- * Validate a polled wire snapshot against {@link GenieMessageSchema}
30
- * and return the schema-normalized message. Genie's wire occasionally
31
- * ships a shape the (SDK-derived) schema doesn't model exactly - e.g.
32
- * an early poll that omits the SDK-required `message_id` - so a miss
33
- * degrades to the raw snapshot rather than throwing, keeping a single
34
- * odd poll from aborting the whole turn.
35
- */
36
- function validateMessage(raw) {
37
- const result = GenieMessageSchema.safeParse(raw);
38
- if (result.success)
39
- return result.data;
40
- log.debug("wire-message:schema-miss", {
41
- message_id: raw.message_id ?? raw.id,
42
- issues: result.error.issues.length,
43
- });
44
- return raw;
45
- }
46
- /* ----------------------- low-level: genieChat ----------------------- */
47
- /**
48
- * One turn against a Genie space, yielded as a stream of
49
- * `GenieMessage` snapshots.
50
- *
51
- * Turn lifecycle:
52
- *
53
- * - No `options.conversationId`: open a new conversation via
54
- * `client.genie.startConversation`. The opened conversation id
55
- * surfaces on every yielded `GenieMessage` (`.conversation_id`)
56
- * so the caller can thread it into a follow-up call.
57
- * - With `options.conversationId`: append to that conversation
58
- * via `client.genie.createMessage`.
59
- * - In both cases, after the create/start the driver polls
60
- * `client.genie.getMessage` every `options.pollIntervalMs`
61
- * (default 500ms) until the message reaches a terminal
62
- * status, then yields the terminal snapshot and returns.
63
- *
64
- * Cancellation: a single internal `AbortController` covers the
65
- * whole turn. `options.context` is tied into that controller so an
66
- * external abort tears down every in-flight SDK call AND the
67
- * inter-poll sleep. Breaking out of the `for await` does the same
68
- * via the `try / finally`.
69
- *
70
- * @example
71
- * // Single turn.
72
- * for await (const m of genieChat(spaceId, "Top 5 stores?")) {
73
- * render(m);
74
- * }
75
- *
76
- * @example
77
- * // Multi-turn: caller threads the conversation id.
78
- * let conversationId: string | undefined;
79
- * for (const question of questions) {
80
- * for await (const m of genieChat(spaceId, question, { conversationId })) {
81
- * conversationId = m.conversation_id ?? conversationId;
82
- * render(m);
83
- * }
84
- * }
85
- */
86
- export async function* genieChat(space_id, content, options) {
87
- const controller = new AbortController();
88
- try {
89
- const client = await getWorkspaceClient(options);
90
- // Build the SDK Context ONCE. Building it inside the poll
91
- // producer would re-attach an abort listener to
92
- // `options.context` on every poll iteration (via
93
- // `apiUtils.toContext` -> `tieAbortSignal`), eventually
94
- // tripping Node's `MaxListenersExceededWarning`.
95
- const context = apiUtils.toContext(controller, options?.context);
96
- let conversationId = options?.conversationId;
97
- let messageId;
98
- const pollProducer = async (ctx) => {
99
- if (!conversationId) {
100
- // First poll: open the conversation. Refuse to retry: if
101
- // `startConversation` returned a response without a
102
- // `conversation_id`, retrying would just open conversation
103
- // after conversation.
104
- if (ctx.attempt > 0) {
105
- throw new Error("Genie did not return a conversation id; refusing to retry");
106
- }
107
- const startResponse = await client.genie.startConversation({ space_id, content }, context);
108
- conversationId = startResponse.conversation_id;
109
- messageId = startResponse.message_id;
110
- return startResponse.message;
111
- }
112
- if (!messageId) {
113
- // First poll of a follow-up turn: append to the seeded
114
- // conversation. `client.genie.createMessage` returns a
115
- // `Waiter<GenieMessage>` (`{ ...message, wait: async () =>
116
- // ... }`). Strip `wait` here so downstream serializers
117
- // (e.g. yaml.stringify in poll-chat) don't choke on the
118
- // AsyncFunction value.
119
- const { wait: _wait, ...createResponse } = await client.genie.createMessage({ space_id, conversation_id: conversationId, content }, context);
120
- messageId = createResponse.message_id;
121
- return createResponse;
122
- }
123
- // Subsequent polls: re-fetch the current message until its
124
- // status becomes terminal.
125
- return await client.genie.getMessage({
126
- space_id,
127
- conversation_id: conversationId,
128
- message_id: messageId,
129
- }, context);
130
- };
131
- yield* commonUtils.poll(async (ctx) => validateMessage(await pollProducer(ctx)), {
132
- intervalMs: options?.pollIntervalMs ?? 500,
133
- // Skip yielding identical consecutive snapshots; Genie
134
- // often returns the exact same payload twice during quiet
135
- // periods. `poll` does a deep equal on the previous yield.
136
- filter: "distinct",
137
- // Stop after the terminal message is yielded. `poll` checks
138
- // the predicate AFTER yielding, so the terminal message
139
- // still reaches the consumer.
140
- predicate: (m) => !isTerminalStatus(m.status),
141
- // Wake the inter-poll sleep on abort so a `for await` break
142
- // (or external abort) tears down promptly instead of waiting
143
- // out the interval.
144
- signal: controller.signal,
145
- });
146
- }
147
- finally {
148
- // Cancels any still-pending SDK call and the inter-poll sleep
149
- // whether we're unwinding from a normal return, a consumer
150
- // break, or a thrown error. Idempotent.
151
- controller.abort();
152
- }
153
- }
154
- /* ---------------------- high-level: genieEventChat ------------------ */
155
- /**
156
- * One turn against a Genie space, yielded as a typed
157
- * {@link GenieChatEvent} stream. Drives {@link genieChat}
158
- * underneath and decorates each snapshot with the derived events
159
- * the field-level diff produced. Stream order:
160
- *
161
- * 1. `{ type: "message", message }` - the raw `GenieMessage`,
162
- * once per poll yield.
163
- * 2. `{ type: "question", content, message_id, ... }` fires
164
- * exactly once, on the FIRST `message` yield. We read
165
- * `content` and `message_id` straight off the snapshot so
166
- * every downstream event for this turn shares the same
167
- * `message_id` (the question included) - subscribers can
168
- * group everything for one Genie call under that one key.
169
- * 3. Any of `status` / `attachment` / `thinking` / `text` /
170
- * `query` / `statement` / `rows` / `suggested_questions` the
171
- * diff against the prior snapshot produced.
172
- * 4. On the terminal snapshot, `{ type: "result", ... }` as
173
- * the final yield.
174
- *
175
- * Errors propagate by the generator throwing - there's no
176
- * `"error"` variant. Wrap the `for await` in `try/catch` if you
177
- * need to handle failures.
178
- *
179
- * @example
180
- * for await (const event of genieEventChat(spaceId, "Top stores?")) {
181
- * switch (event.type) {
182
- * case "thinking":
183
- * console.log("[thinking]", event.thought_type, event.text);
184
- * break;
185
- * case "text":
186
- * console.log("[text]", event.text);
187
- * break;
188
- * case "result":
189
- * console.log("[done]", event.status);
190
- * break;
191
- * }
192
- * }
193
- */
194
- export async function* genieEventChat(space_id, content, options) {
195
- // Diff source for the current turn. Always `undefined` on the
196
- // first snapshot so the initial status / attachments emit
197
- // fresh; updated to the most recent snapshot after each yield.
198
- let previous;
199
- // The `question` event is deferred to the first `message` yield
200
- // so it can carry the assigned `message_id` (subscribers use it
201
- // as the grouping key for every event in this turn). The first
202
- // snapshot is the earliest point that id exists.
203
- let questionEmitted = false;
204
- for await (const rawMessage of genieChat(space_id, content, options)) {
205
- // Normalize `message_id` from the legacy `id` field when Genie's
206
- // wire response only populates one of them. The SDK schema marks
207
- // both as required, but in practice the `startConversation` /
208
- // `createMessage` inner `message` payload sometimes ships only
209
- // `id` while the new `message_id` field lands undefined. Every
210
- // downstream event detector keys grouping off `message_id`; the
211
- // fallback keeps one Genie turn's events from splitting across
212
- // an anon group + the real-id group when subscribers bucket by
213
- // `message_id` (see `summarizeProgress` in the demo UI).
214
- const message = rawMessage.message_id
215
- ? rawMessage
216
- : { ...rawMessage, message_id: rawMessage.id };
217
- yield {
218
- type: "message",
219
- space_id: message.space_id,
220
- message_id: message.message_id,
221
- message,
222
- };
223
- if (!questionEmitted) {
224
- yield {
225
- type: "question",
226
- space_id: message.space_id,
227
- ...(message.conversation_id
228
- ? { conversation_id: message.conversation_id }
229
- : {}),
230
- ...(message.message_id ? { message_id: message.message_id } : {}),
231
- content: message.content,
232
- };
233
- questionEmitted = true;
234
- }
235
- yield* eventsFromMessage(message, previous, message.space_id);
236
- if (isTerminalStatus(message.status)) {
237
- yield {
238
- type: "result",
239
- space_id: message.space_id,
240
- conversation_id: message.conversation_id,
241
- message_id: message.message_id,
242
- status: message.status,
243
- message,
244
- };
245
- }
246
- previous = message;
247
- }
248
- }
249
- /* ---------------------- workspace client helper --------------------- */
250
- /**
251
- * Resolve a `WorkspaceClient` in this preference order:
252
- *
253
- * 1. Caller-supplied `options.workspaceClient`.
254
- * 2. AppKit's per-request execution-context client, when AppKit
255
- * is installed AND we're inside a request scope.
256
- * 3. Fresh `new WorkspaceClient({})` (env-var auth via
257
- * `DATABRICKS_CONFIG_PROFILE` / `DATABRICKS_HOST` /
258
- * `DATABRICKS_TOKEN`).
259
- *
260
- * AppKit is loaded lazily so this package stays usable in
261
- * non-AppKit environments (e.g. the `poll-chat` smoke test).
262
- */
263
- async function getWorkspaceClient(options) {
264
- if (options?.workspaceClient)
265
- return options.workspaceClient;
266
- const appkit = await getAppKit();
267
- if (appkit) {
268
- try {
269
- return appkit.getExecutionContext().client;
270
- }
271
- catch {
272
- // Not inside an AppKit request context; fall through to env.
273
- }
274
- }
275
- return new WorkspaceClient({});
276
- }
277
- async function getAppKit() {
278
- try {
279
- return await import("@databricks/appkit");
280
- }
281
- catch {
282
- return undefined;
283
- }
284
- }
@@ -1,55 +0,0 @@
1
- /**
2
- * `@dbx-tools/genie` space metadata helpers.
3
- *
4
- * Fetches a Genie space's definition (including the opt-in
5
- * `serialized_space` blob) and extracts the curated starter
6
- * questions an author configured on the space. The typed SDK
7
- * `client.genie.getSpace` only returns the directory-listing surface
8
- * (`title` / `description` / `warehouse_id`); the sample questions
9
- * live inside `serialized_space`, which the REST API returns only
10
- * when `include_serialized_space=true`. We hit that endpoint through
11
- * the workspace client's raw `apiClient` since the typed request
12
- * shape has no flag for it.
13
- */
14
- import { WorkspaceClient } from "@databricks/sdk-experimental";
15
- import { type GenieSpace } from "@dbx-tools/genie-shared";
16
- import { apiUtils } from "@dbx-tools/shared";
17
- /** Options for {@link getGenieSpace}. */
18
- export interface GetGenieSpaceOptions {
19
- /**
20
- * Explicit `WorkspaceClient`. Defaults to a fresh
21
- * `new WorkspaceClient({})` (env-var auth). Server callers should
22
- * pass their OBO-scoped client so the lookup runs as the user.
23
- */
24
- workspaceClient?: WorkspaceClient;
25
- /**
26
- * Request the `serialized_space` blob (catalogs, tables, sample
27
- * questions, prompts). Defaults to `true` - the only reason to
28
- * skip it is when the caller just needs title / description and
29
- * wants the smaller payload.
30
- */
31
- serialized?: boolean;
32
- /**
33
- * External cancellation. Accepts a WHATWG `AbortSignal` or a
34
- * fully-built SDK `Context` (see `apiUtils.ContextLike`).
35
- */
36
- context?: apiUtils.ContextLike;
37
- }
38
- /**
39
- * Fetch a Genie space by id, optionally including its serialized
40
- * definition. Hits `GET /api/2.0/genie/spaces/<id>` with
41
- * `include_serialized_space=true` through the raw `apiClient`, then
42
- * validates the response against {@link GenieSpaceSchema} (unknown
43
- * fields like `etag` / `parent_path` are stripped).
44
- */
45
- export declare function getGenieSpace(spaceId: string, options?: GetGenieSpaceOptions): Promise<GenieSpace>;
46
- /**
47
- * Extract the curated starter questions an author configured on a
48
- * Genie space. Reads `serialized_space -> config.sample_questions[*]
49
- * .question`. Returns `[]` when the space carries no serialized blob,
50
- * the blob is unparseable, or no sample questions are configured -
51
- * so a missing or misconfigured space degrades to "no suggestions"
52
- * rather than throwing. Order is preserved (the author's ordering)
53
- * and duplicates are dropped.
54
- */
55
- export declare function genieSampleQuestions(space: GenieSpace): string[];
package/dist/src/space.js DELETED
@@ -1,90 +0,0 @@
1
- /**
2
- * `@dbx-tools/genie` space metadata helpers.
3
- *
4
- * Fetches a Genie space's definition (including the opt-in
5
- * `serialized_space` blob) and extracts the curated starter
6
- * questions an author configured on the space. The typed SDK
7
- * `client.genie.getSpace` only returns the directory-listing surface
8
- * (`title` / `description` / `warehouse_id`); the sample questions
9
- * live inside `serialized_space`, which the REST API returns only
10
- * when `include_serialized_space=true`. We hit that endpoint through
11
- * the workspace client's raw `apiClient` since the typed request
12
- * shape has no flag for it.
13
- */
14
- import { WorkspaceClient } from "@databricks/sdk-experimental";
15
- import { GenieSpaceSchema } from "@dbx-tools/genie-shared";
16
- import { apiUtils, commonUtils, logUtils } from "@dbx-tools/shared";
17
- const log = logUtils.logger("genie/space");
18
- /**
19
- * Fetch a Genie space by id, optionally including its serialized
20
- * definition. Hits `GET /api/2.0/genie/spaces/<id>` with
21
- * `include_serialized_space=true` through the raw `apiClient`, then
22
- * validates the response against {@link GenieSpaceSchema} (unknown
23
- * fields like `etag` / `parent_path` are stripped).
24
- */
25
- export async function getGenieSpace(spaceId, options) {
26
- const client = options?.workspaceClient ?? new WorkspaceClient({});
27
- const serialized = options?.serialized !== false;
28
- const context = options?.context ? apiUtils.toContext(options.context) : undefined;
29
- const raw = await client.apiClient.request({
30
- path: `/api/2.0/genie/spaces/${encodeURIComponent(spaceId)}`,
31
- method: "GET",
32
- query: serialized ? { include_serialized_space: true } : {},
33
- headers: new Headers(),
34
- raw: false,
35
- }, context);
36
- return GenieSpaceSchema.parse(raw);
37
- }
38
- /** Pull the first non-empty string out of a `question` field (string | string[]). */
39
- function questionText(question) {
40
- if (typeof question === "string") {
41
- const trimmed = question.trim();
42
- return trimmed.length > 0 ? trimmed : undefined;
43
- }
44
- if (Array.isArray(question)) {
45
- for (const part of question) {
46
- if (typeof part === "string" && part.trim().length > 0)
47
- return part.trim();
48
- }
49
- }
50
- return undefined;
51
- }
52
- /**
53
- * Extract the curated starter questions an author configured on a
54
- * Genie space. Reads `serialized_space -> config.sample_questions[*]
55
- * .question`. Returns `[]` when the space carries no serialized blob,
56
- * the blob is unparseable, or no sample questions are configured -
57
- * so a missing or misconfigured space degrades to "no suggestions"
58
- * rather than throwing. Order is preserved (the author's ordering)
59
- * and duplicates are dropped.
60
- */
61
- export function genieSampleQuestions(space) {
62
- const serialized = space.serialized_space;
63
- if (!serialized)
64
- return [];
65
- let parsed;
66
- try {
67
- parsed = JSON.parse(serialized);
68
- }
69
- catch (err) {
70
- log.warn("serialized-space:parse-error", {
71
- spaceId: space.space_id,
72
- error: commonUtils.errorMessage(err),
73
- });
74
- return [];
75
- }
76
- const sampleQuestions = parsed
77
- ?.config?.sample_questions;
78
- if (!Array.isArray(sampleQuestions))
79
- return [];
80
- const seen = new Set();
81
- const out = [];
82
- for (const entry of sampleQuestions) {
83
- const text = questionText(entry?.question);
84
- if (!text || seen.has(text))
85
- continue;
86
- seen.add(text);
87
- out.push(text);
88
- }
89
- return out;
90
- }