@dbx-tools/appkit-mastra 0.1.26 → 0.1.27

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.
package/package.json CHANGED
@@ -9,13 +9,13 @@
9
9
  }
10
10
  },
11
11
  "name": "@dbx-tools/appkit-mastra",
12
- "version": "0.1.26",
12
+ "version": "0.1.27",
13
13
  "dependencies": {
14
14
  "@databricks/sdk-experimental": "^0.17",
15
- "@dbx-tools/appkit-mastra-shared": "0.1.26",
16
- "@dbx-tools/genie": "0.1.26",
17
- "@dbx-tools/genie-shared": "0.1.26",
18
- "@dbx-tools/shared": "0.1.26",
15
+ "@dbx-tools/appkit-mastra-shared": "0.1.27",
16
+ "@dbx-tools/genie": "0.1.27",
17
+ "@dbx-tools/genie-shared": "0.1.27",
18
+ "@dbx-tools/shared": "0.1.27",
19
19
  "@mastra/ai-sdk": "^1",
20
20
  "@mastra/core": "^1",
21
21
  "@mastra/express": "^1",
package/src/agents.ts CHANGED
@@ -431,7 +431,7 @@ export async function buildAgents(opts: {
431
431
  const style = resolveStyleInstructions(config);
432
432
  // Default-on protection against the model copying turn-scoped
433
433
  // chartIds from prior assistant tool results into the new
434
- // turn's `[[chart:<id>]]` markers. Opt out per-plugin via
434
+ // turn's `[chart:<id>]` markers. Opt out per-plugin via
435
435
  // `config.stripStaleCharts: false`.
436
436
  const inputProcessors =
437
437
  config.stripStaleCharts === false ? [] : [stripStaleChartsProcessor];
package/src/chart.ts CHANGED
@@ -238,13 +238,11 @@ type ChartPlan = z.infer<typeof chartPlanSchema>;
238
238
  * sees a planner request, only the resolved {@link Chart}.
239
239
  */
240
240
  export const chartPlannerRequestSchema = z.object({
241
- title: z
242
- .string()
243
- .describe(
244
- stringUtils.toDescription(`
241
+ title: z.string().describe(
242
+ stringUtils.toDescription(`
245
243
  Concise title shown above the chart (e.g. "Top 10 SKUs by Revenue").
246
244
  `),
247
- ),
245
+ ),
248
246
  description: z
249
247
  .string()
250
248
  .optional()
@@ -676,7 +674,7 @@ function planToEchartsOption(
676
674
  * an empty placeholder, and kicks off the chart-planner in the
677
675
  * background. Returns just the `chartId`; the host UI resolves
678
676
  * `[chart:<chartId>]` markers by hitting the plugin's
679
- * `/charts/:chartId` route.
677
+ * `/embed/chart/:id` route.
680
678
  *
681
679
  * For Genie statement results, prefer the Genie agent's
682
680
  * `prepare_chart` tool, which accepts a `statement_id` and
package/src/config.ts CHANGED
@@ -216,7 +216,7 @@ export interface MastraPluginConfig extends BasePluginConfig {
216
216
  * processor that strips `chartId` fields from prior assistant
217
217
  * tool-invocation results before they reach the model. This
218
218
  * prevents the model from reusing turn-scoped chartIds it sees
219
- * in memory recall (which would leave `[[chart:<id>]]` markers
219
+ * in memory recall (which would leave `[chart:<id>]` markers
220
220
  * pointing at writer events that no longer exist).
221
221
  *
222
222
  * Set to `false` to opt out - useful if a non-default agent
package/src/genie.ts CHANGED
@@ -725,9 +725,13 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
725
725
  `
726
726
  To display the chart in your reply, embed
727
727
  \`[chart:<chartId>]\` on its own line at the position you
728
- want it to appear. The tool returns immediately - do NOT
729
- wait or call it again to "check progress"; the chart
730
- resolves asynchronously on the host UI's side.
728
+ want it to appear, using the EXACT \`chartId\` string this
729
+ call returned. Never construct a chart id yourself (it is
730
+ not the \`statement_id\` or any variation of it) - only a
731
+ value returned by this tool resolves to a real chart. The
732
+ tool returns immediately - do NOT wait or call it again to
733
+ "check progress"; the chart resolves asynchronously on the
734
+ host UI's side.
731
735
  `,
732
736
  `
733
737
  Use this only when the data has a story a chart conveys
@@ -842,6 +846,17 @@ export const GENIE_INSTRUCTIONS = stringUtils.toDescription([
842
846
  chart should appear. Use a chart when the data has a
843
847
  story a visual conveys better than a table (trends,
844
848
  rankings, distributions, parts-of-a-whole).
849
+
850
+ NEVER invent or hand-build a \`<chartId>\`. A valid
851
+ \`<chartId>\` is the opaque token a \`prepare_chart\`
852
+ call returned to you in THIS turn - nothing else. It
853
+ is NOT a \`statement_id\`, and it is NOT a
854
+ \`statement_id\` prefix with a label appended (e.g.
855
+ \`01f1...-region-fill\`). If you have not called
856
+ \`prepare_chart\` and received an id back, do not write
857
+ a \`[chart:...]\` marker at all - use \`[data:...]\`
858
+ instead. A fabricated chart id renders nothing and
859
+ wastes a request.
845
860
  `,
846
861
  ],
847
862
  },
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Server-side Server-Sent-Events frame interceptor for the native
3
+ * Mastra agent `/stream` endpoint.
4
+ *
5
+ * The Mastra stream (`@mastra/express`) emits one `data: <json>\n\n`
6
+ * SSE frame per chunk, where the decoded JSON carries a
7
+ * discriminating `type` (`text-delta`, `reasoning-delta`,
8
+ * `tool-call`, `step-finish`, ...) and is terminated by a
9
+ * `data: [DONE]` sentinel. {@link installStreamEventInterceptor} wraps
10
+ * an Express response and runs a caller-supplied
11
+ * {@link StreamFrameInterceptor} over each decoded frame so the caller
12
+ * can keep, rewrite, or drop it; every non-`data:` byte (SSE
13
+ * keep-alive comments, the `[DONE]` sentinel, blank padding) passes
14
+ * through verbatim.
15
+ *
16
+ * The wrap is inert unless the response turns out to be a streamed
17
+ * `200 text/event-stream` body, so non-streaming JSON routes and
18
+ * error responses are never touched.
19
+ */
20
+
21
+ import { StringDecoder } from "node:string_decoder";
22
+
23
+ import type express from "express";
24
+
25
+ /** SSE event separator used by the Mastra stream wire format. */
26
+ const FRAME_DELIMITER = "\n\n";
27
+
28
+ /**
29
+ * Matches a whole SSE frame whose `data:` payload is a JSON object,
30
+ * capturing it in three parts: the `data:` field prefix (including
31
+ * whatever inter-token whitespace the producer used), the JSON object
32
+ * body, and any trailing whitespace. Anchored end-to-end so it only
33
+ * matches a single self-contained object frame; anything else (SSE
34
+ * comments, the `[DONE]` sentinel, text/array payloads) fails the
35
+ * match and is forwarded verbatim without a parse attempt. On rewrite
36
+ * the captured prefix/suffix are re-emitted as-is so only the body
37
+ * changes.
38
+ */
39
+ const DATA_OBJECT_RE = /^(data:\s*)(\{[\s\S]*\})(\s*)$/;
40
+
41
+ /**
42
+ * Per-frame interceptor invoked with the `JSON.parse`d object of every
43
+ * `data: {json}` frame on the Mastra `/stream` response. The return
44
+ * value decides the frame's fate:
45
+ *
46
+ * - `true`: forward the frame's original bytes unchanged (no
47
+ * re-serialization).
48
+ * - `false`: drop the frame entirely.
49
+ * - `{ replace }`: re-emit the frame with the original `data:` prefix
50
+ * and trailing whitespace preserved and only the JSON body replaced
51
+ * by `JSON.stringify(replace)`.
52
+ */
53
+ export type StreamFrameInterceptor = (
54
+ chunk: unknown,
55
+ ) => { replace: unknown } | true | false;
56
+
57
+ /**
58
+ * True when `res` is a `200 text/event-stream` body - the only
59
+ * response shape we intercept. Content-type based so it tracks the
60
+ * Mastra `/stream` route without hard-coding the path, and naturally
61
+ * skips the JSON routes (history, models, charts, statements) and any
62
+ * non-200 error response.
63
+ */
64
+ function isEventStream(res: express.Response): boolean {
65
+ if (res.statusCode !== 200) return false;
66
+ const contentType = String(res.getHeader("content-type") ?? "").toLowerCase();
67
+ return contentType.includes("text/event-stream");
68
+ }
69
+
70
+ /**
71
+ * Run `interceptor` over a single SSE frame. Forwards anything that
72
+ * isn't a parseable `data: {json}` object frame verbatim (comments,
73
+ * the `[DONE]` sentinel, blank padding, unparseable / non-object
74
+ * payloads) so the interceptor only ever sees real chunk objects.
75
+ * Returns the frame string to emit, or `undefined` to drop the frame.
76
+ */
77
+ function interceptFrame(
78
+ frame: string,
79
+ interceptor: StreamFrameInterceptor,
80
+ ): string | undefined {
81
+ const match = DATA_OBJECT_RE.exec(frame);
82
+ if (!match) return frame;
83
+ const [, prefix, body, suffix] = match;
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(body!);
87
+ } catch {
88
+ // Forward unparseable payloads untouched - feeding a partial /
89
+ // malformed frame to the interceptor risks corrupting bytes the
90
+ // client could still have handled.
91
+ return frame;
92
+ }
93
+ const result = interceptor(parsed);
94
+ if (result === true) return frame;
95
+ if (result === false) return undefined;
96
+ // Preserve the exact captured prefix/suffix so only the JSON body
97
+ // changes; the framing the client parses stays byte-identical.
98
+ return `${prefix}${JSON.stringify(result.replace)}${suffix}`;
99
+ }
100
+
101
+ /**
102
+ * Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
103
+ * response is run through `interceptor` (keep / rewrite / drop).
104
+ *
105
+ * Interception only engages once the response is confirmed to be a
106
+ * `200 text/event-stream` body (decided lazily on the first write,
107
+ * after the handler has set status + content-type); any other
108
+ * response streams through unchanged. A {@link StringDecoder}
109
+ * reassembles multi-byte UTF-8 sequences that straddle chunk
110
+ * boundaries, and a running buffer holds the trailing partial frame
111
+ * until its `\n\n` arrives.
112
+ */
113
+ export function installStreamEventInterceptor(
114
+ res: express.Response,
115
+ interceptor: StreamFrameInterceptor,
116
+ ): void {
117
+ const origWrite = res.write.bind(res);
118
+ const origEnd = res.end.bind(res);
119
+ const decoder = new StringDecoder("utf8");
120
+ let engaged: boolean | undefined;
121
+ let buffer = "";
122
+
123
+ // Append decoded text, emit every whole `\n\n`-delimited frame the
124
+ // interceptor keeps, and retain any trailing partial frame in
125
+ // `buffer`. On `final`, the residual buffer is flushed as the last
126
+ // frame so a stream that doesn't end on a delimiter isn't dropped.
127
+ const intercept = (text: string, final: boolean): string => {
128
+ buffer += text;
129
+
130
+ const out: string[] = [];
131
+
132
+ let idx: number;
133
+ while ((idx = buffer.indexOf(FRAME_DELIMITER)) !== -1) {
134
+ const frame = buffer.slice(0, idx);
135
+ buffer = buffer.slice(idx + FRAME_DELIMITER.length);
136
+
137
+ const kept = interceptFrame(frame, interceptor);
138
+ if (kept !== undefined) {
139
+ out.push(kept, FRAME_DELIMITER);
140
+ }
141
+ }
142
+
143
+ if (final && buffer.length > 0) {
144
+ const kept = interceptFrame(buffer, interceptor);
145
+ if (kept !== undefined) out.push(kept);
146
+ buffer = "";
147
+ }
148
+
149
+ return out.length === 0 ? "" : out.join("");
150
+ };
151
+
152
+ const toText = (chunk: unknown): string => {
153
+ if (typeof chunk === "string") return chunk;
154
+ if (Buffer.isBuffer(chunk)) return decoder.write(chunk);
155
+ if (chunk instanceof Uint8Array) {
156
+ return decoder.write(
157
+ Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength),
158
+ );
159
+ }
160
+ return "";
161
+ };
162
+
163
+ res.write = function patchedWrite(
164
+ chunk: unknown,
165
+ encodingOrCb?: BufferEncoding | ((error?: Error | null) => void),
166
+ cb?: (error?: Error | null) => void,
167
+ ): boolean {
168
+ if (engaged === undefined) engaged = isEventStream(res);
169
+ if (!engaged) {
170
+ return origWrite(chunk as never, encodingOrCb as never, cb as never);
171
+ }
172
+ const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
173
+ const out = intercept(toText(chunk), false);
174
+ if (out.length === 0) {
175
+ // The chunk only advanced a partial frame; nothing to forward
176
+ // yet. Honor the write callback so backpressure-aware writers
177
+ // don't stall waiting on an ack that never comes.
178
+ if (callback) queueMicrotask(() => callback());
179
+ return true;
180
+ }
181
+ return origWrite(out, callback as never);
182
+ } as typeof res.write;
183
+
184
+ res.end = function patchedEnd(
185
+ chunk?: unknown | (() => void),
186
+ encodingOrCb?: BufferEncoding | (() => void),
187
+ cb?: () => void,
188
+ ): express.Response {
189
+ if (engaged === undefined) engaged = isEventStream(res);
190
+ if (!engaged) {
191
+ return origEnd(chunk as never, encodingOrCb as never, cb as never);
192
+ }
193
+ const callback =
194
+ typeof chunk === "function"
195
+ ? chunk
196
+ : typeof encodingOrCb === "function"
197
+ ? encodingOrCb
198
+ : cb;
199
+ const text =
200
+ (typeof chunk !== "function" && chunk !== undefined ? toText(chunk) : "") +
201
+ decoder.end();
202
+ const out = intercept(text, true);
203
+ if (out.length > 0) origWrite(out);
204
+ return origEnd(callback as never);
205
+ } as typeof res.end;
206
+ }
package/src/plugin.ts CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  type PluginManifest,
38
38
  type ResourceRequirement,
39
39
  } from "@databricks/appkit";
40
- import { appkitUtils, logUtils } from "@dbx-tools/shared";
40
+ import { appkitUtils, commonUtils, logUtils } from "@dbx-tools/shared";
41
41
  import { chatRoute } from "@mastra/ai-sdk";
42
42
  import type { Agent } from "@mastra/core/agent";
43
43
  import { Mastra } from "@mastra/core/mastra";
@@ -54,6 +54,10 @@ import { historyRoute } from "./history.js";
54
54
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
55
55
  import { buildObservability } from "./observability.js";
56
56
  import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
57
+ import {
58
+ installStreamEventInterceptor,
59
+ type StreamFrameInterceptor,
60
+ } from "./intercept.js";
57
61
  import {
58
62
  clearServingEndpointsCache,
59
63
  listServingEndpoints,
@@ -208,8 +212,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
208
212
  modelsPath: `${basePath}/models`,
209
213
  historyPath: `${basePath}/route/history`,
210
214
  historyPathTemplate: `${basePath}/route/history/:agentId`,
211
- chartsPathTemplate: `${basePath}/charts/:chartId`,
212
- statementsPathTemplate: `${basePath}/statements/:statementId`,
215
+ embedPathTemplate: `${basePath}/embed/:type/:id`,
213
216
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
214
217
  agents: Object.keys(this.built?.agents ?? {}),
215
218
  };
@@ -229,114 +232,97 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
229
232
  .catch(next);
230
233
  });
231
234
 
232
- // `GET /charts/:chartId` long-polls the chart cache.
233
- //
234
- // Charts kicked off by the Genie agent's `prepare_chart` tool
235
- // (or the system `render_data` tool) resolve asynchronously
236
- // and land in the AppKit cache under a chartId. The host UI
237
- // resolves `[chart:<chartId>]` markers by hitting this route,
238
- // which blocks until the entry settles (`result` or `error`
239
- // set) or the server-side budget elapses (then returns the
240
- // last seen still-processing entry so the client can poll
241
- // again).
235
+ // `GET /embed/:type/:id` is the single resolver for every embed
236
+ // marker the agent emits in prose (`[chart:<id>]`,
237
+ // `[data:<id>]`, ...). `:type` selects a resolver from the
238
+ // registry below; `:id` is that resolver's lookup key. The
239
+ // grammar (see `marker.ts`) is type-agnostic on purpose - new
240
+ // embed kinds are added by registering a resolver here, with no
241
+ // client or grammar change.
242
242
  //
243
243
  // Status codes:
244
- // - 200 with JSON body when the entry is found (any state).
245
- // - 404 when the chartId doesn't exist or its 1h TTL elapsed.
244
+ // - 200 with the resolver's JSON body when the id resolves.
245
+ // - 404 when `:type` isn't registered (unsupported embed
246
+ // type) OR a registered resolver can't find `:id` (unknown
247
+ // / expired - e.g. a chart past its 1h TTL or a fabricated
248
+ // id the model never minted).
249
+ // - 400 when `:id` is empty.
246
250
  //
247
- // Query params:
248
- // - `timeoutMs` (optional, default 60000): how long to long-poll
249
- // before returning a still-processing entry.
251
+ // Per-type query knobs and behavior:
252
+ // - `chart`: long-polls the chart cache until the entry
253
+ // settles (`result` / `error`) or the budget elapses (then
254
+ // returns the still-processing entry to poll again).
255
+ // `?timeoutMs=<n>` (default 60s, capped 5min) tunes it.
256
+ // - `data`: one OBO-scoped Statement Execution fetch.
257
+ // `?limit=<n>` caps rows (clamped to STATEMENT_ROW_CAP).
250
258
  //
251
- // Wired with `req.signal` so a closed connection unblocks the
252
- // poll immediately and the request thread frees up. Cache
253
- // failures bubble through `next(err)` to Express's default
254
- // error handler.
255
- router.get("/charts/:chartId", (req, res, next) => {
256
- const chartId = req.params["chartId"];
257
- if (!chartId) {
258
- res.status(400).json({ error: "chartId is required" });
259
+ // Built once (this handler is registered once) and keyed by the
260
+ // raw `:type` token. Each resolver gets the request (for query
261
+ // parsing + OBO scoping) and an `AbortSignal` bridged off the
262
+ // connection `close` event so a long-poll unblocks the instant
263
+ // the client disconnects. `undefined` from a resolver maps to a
264
+ // clean 404; thrown errors bubble through `next(err)`.
265
+ const embedResolvers: Record<string, EmbedResolver> = {
266
+ chart: (req, id, signal) => {
267
+ const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
268
+ return fetchChart(id, {
269
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
270
+ signal,
271
+ });
272
+ },
273
+ data: (req, id, signal) => {
274
+ const limit = parseStatementLimit(req.query["limit"]);
275
+ return this.userScopedSelf(req).fetchStatement(id, {
276
+ ...(limit !== undefined ? { limit } : {}),
277
+ signal,
278
+ });
279
+ },
280
+ };
281
+
282
+ router.get("/embed/:type/:id", (req, res, next) => {
283
+ const type = req.params["type"] ?? "";
284
+ const id = req.params["id"];
285
+ const resolve = embedResolvers[type];
286
+ if (!resolve) {
287
+ res.status(404).json({ error: `unsupported embed type: ${type}` });
259
288
  return;
260
289
  }
261
- const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
262
- // Express's `req` predates `AbortSignal`. Bridge the
263
- // `close` event onto an `AbortController` so a closed
264
- // connection unblocks the long-poll immediately and frees
265
- // the request thread. The `close` listener also fires on
266
- // a normal completion, but at that point we no longer
267
- // care about the abort and the listener is GC'd with the
268
- // request.
269
- const controller = new AbortController();
270
- req.on("close", () => controller.abort());
271
- fetchChart(chartId, {
272
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
273
- signal: controller.signal,
274
- })
275
- .then((entry) => {
276
- if (!entry) {
277
- res.status(404).json({ error: "chart not found" });
278
- return;
279
- }
280
- res.json(entry);
281
- })
282
- .catch(next);
283
- });
284
-
285
- // `GET /statements/:statementId` resolves a Genie / Statement
286
- // Execution result for inline rendering as a table.
287
- //
288
- // The agent embeds `[data:<statement_id>]` markers in prose
289
- // (see `GENIE_INSTRUCTIONS`) whenever it wants the host UI to
290
- // display a result set without the model re-spelling every
291
- // row in markdown. This route is what those markers resolve
292
- // against - one OBO-scoped fetch returns the columns + rows
293
- // so the client renders an AppKit Table inline.
294
- //
295
- // Status codes:
296
- // - 200 with `StatementData` body when the statement is found.
297
- // - 404 when the workspace can't find / read the statement.
298
- //
299
- // Query params:
300
- // - `limit` (optional): row cap. Clamped to
301
- // {@link STATEMENT_ROW_CAP} so a runaway result set
302
- // can't hose the response.
303
- //
304
- // OBO-scoped via {@link userScopedSelf} so the caller's
305
- // workspace permissions apply; SDK errors propagate through
306
- // `next(err)` to Express's default error handler.
307
- router.get("/statements/:statementId", (req, res, next) => {
308
- const statementId = req.params["statementId"];
309
- if (!statementId) {
310
- res.status(400).json({ error: "statementId is required" });
290
+ if (!id) {
291
+ res.status(400).json({ error: "id is required" });
311
292
  return;
312
293
  }
313
- const limit = parseStatementLimit(req.query["limit"]);
294
+ // Express's `req` predates `AbortSignal`; bridge the `close`
295
+ // event onto an `AbortController` so a closed connection
296
+ // unblocks any long-poll immediately and frees the request
297
+ // thread. The listener is GC'd with the request on normal
298
+ // completion.
314
299
  const controller = new AbortController();
315
300
  req.on("close", () => controller.abort());
316
- this.userScopedSelf(req)
317
- .fetchStatement(statementId, {
318
- ...(limit !== undefined ? { limit } : {}),
319
- signal: controller.signal,
320
- })
321
- .then((data) => {
322
- if (!data) {
323
- res.status(404).json({ error: "statement not found" });
301
+ resolve(req, id, controller.signal)
302
+ .then((entry) => {
303
+ if (entry === undefined) {
304
+ res.status(404).json({ error: `${type} not found` });
324
305
  return;
325
306
  }
326
- res.json(data);
307
+ res.json(entry);
327
308
  })
328
309
  .catch(next);
329
310
  });
330
311
 
331
312
  router.use("", (req, res, next) => {
332
313
  if (!this.mastraApp) return res.status(503).end();
314
+ // Run each Mastra `/stream` SSE frame through the interceptor
315
+ // (keep / rewrite / drop). Only engages on a
316
+ // `200 text/event-stream` body, so the JSON routes above and any
317
+ // error response are untouched.
318
+ installStreamEventInterceptor(res, interceptStreamFrame);
333
319
  return this.userScopedSelf(req).mastraApp!(req, res, next);
334
320
  });
335
321
  }
336
322
 
337
323
  /**
338
- * Implementation backing the `/statements/:statementId` route.
339
- * Runs inside the AppKit user-context proxy so
324
+ * Implementation backing the `data` embed resolver
325
+ * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
340
326
  * `getExecutionContext()` returns the OBO-scoped workspace
341
327
  * client, then reuses the same `fetchStatementData` pipeline
342
328
  * the `get_statement` tool runs so the LLM and the UI see the
@@ -472,9 +458,22 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
472
458
  }
473
459
  }
474
460
 
461
+ /**
462
+ * Resolver for one embed `<type>` behind the generic
463
+ * `GET /embed/:type/:id` route. Returns the JSON body to send on
464
+ * success, or `undefined` to signal a 404 (unknown / expired id).
465
+ * `signal` aborts when the client disconnects so long-polling
466
+ * resolvers (e.g. `chart`) unblock immediately.
467
+ */
468
+ type EmbedResolver = (
469
+ req: express.Request,
470
+ id: string,
471
+ signal: AbortSignal,
472
+ ) => Promise<unknown | undefined>;
473
+
475
474
  /**
476
475
  * Parse the optional `?timeoutMs=<n>` query parameter from a
477
- * `GET /charts/:chartId` request. Accepts a positive integer up
476
+ * `GET /embed/chart/:id` request. Accepts a positive integer up
478
477
  * to 5 minutes (clamped) and rejects everything else as
479
478
  * `undefined` so {@link fetchChart} falls back to its default.
480
479
  * Express produces `string | string[] | undefined`; we normalize
@@ -490,7 +489,7 @@ function parseTimeoutMs(raw: unknown): number | undefined {
490
489
 
491
490
  /**
492
491
  * Parse the optional `?limit=<n>` query parameter from a
493
- * `GET /statements/:statementId` request. Accepts a non-negative
492
+ * `GET /embed/data/:id` request. Accepts a non-negative
494
493
  * integer and lets the route clamp to `STATEMENT_ROW_CAP`;
495
494
  * rejects anything else as `undefined` so the route falls back
496
495
  * to the server-side cap.
@@ -503,4 +502,36 @@ function parseStatementLimit(raw: unknown): number | undefined {
503
502
  return Math.floor(n);
504
503
  }
505
504
 
505
+ /**
506
+ * {@link StreamFrameInterceptor} for Mastra `/stream` responses.
507
+ *
508
+ * Removes large, redundant payloads from terminal `step-finish`,
509
+ * `finish`, and `tool-result` frames before they reach the browser.
510
+ * These frames often repeat information already delivered incrementally
511
+ * via streamed text and tool events, including full tool outputs,
512
+ * accumulated responses, message history, SQL results, chart data, and
513
+ * other large result payloads.
514
+ *
515
+ * The interceptor deletes heavyweight payload properties
516
+ * (`output`, `messages`, `response`, and `result`) while preserving the
517
+ * event envelope and lifecycle metadata required by the client.
518
+ *
519
+ * Deletion is key based, so streams that do not contain these
520
+ * fields are passed through unchanged.
521
+ */
522
+ const interceptStreamFrame: StreamFrameInterceptor = (chunk) => {
523
+ if (!commonUtils.isRecord(chunk)) return true;
524
+ if (!["step-finish", "finish", "tool-result"].find((type) => type === chunk.type))
525
+ return true;
526
+ const payload = chunk.payload;
527
+ if (!commonUtils.isRecord(payload)) return true;
528
+ const trimmedPayload = commonUtils.deleteKeys(payload, [
529
+ "output",
530
+ "messages",
531
+ "response",
532
+ "result",
533
+ ]);
534
+ return trimmedPayload ? { replace: chunk } : true;
535
+ };
536
+
506
537
  export const mastra = toPlugin(MastraPlugin);
package/src/statement.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Wraps `client.statementExecution.getStatement` with the shape
5
5
  * + size + error handling the plugin's tools and the
6
- * `/statements/:statementId` route both need:
6
+ * `/embed/data/:id` route both need:
7
7
  *
8
8
  * - {@link fetchStatementData}: low-level fetch that returns the
9
9
  * raw `{columns, rows, rowCount}` shape used by the
@@ -12,7 +12,7 @@
12
12
  * numeric strings to numbers so downstream charts /
13
13
  * aggregations don't have to.
14
14
  * - {@link STATEMENT_ROW_CAP}: hard cap callers (notably the
15
- * `/statements/:statementId` route) clamp `limit` to so a
15
+ * `/embed/data/:id` route) clamp `limit` to so a
16
16
  * runaway result set can't hose a response.
17
17
  * - {@link isStatementNotFoundError}: structural detector that
18
18
  * normalizes the SDK's two error classes plus the loose
@@ -33,7 +33,7 @@ import { apiUtils } from "@dbx-tools/shared";
33
33
 
34
34
  /**
35
35
  * Hard server-side cap on rows returned by the
36
- * `/statements/:statementId` route. Sized to keep responses small
36
+ * `/embed/data/:id` route. Sized to keep responses small
37
37
  * enough for inline tables to render snappily; the route surfaces
38
38
  * a `truncated` flag whenever the upstream `rowCount` exceeds
39
39
  * this so end users know they're seeing a sample.
@@ -66,7 +66,7 @@ function coerceCell(cell: string | null): unknown {
66
66
  *
67
67
  * Exported because every consumer in the plugin (the
68
68
  * `get_statement` tool, the `prepare_chart` dataset resolver, and
69
- * the `/statements/:statementId` route) needs the exact same
69
+ * the `/embed/data/:id` route) needs the exact same
70
70
  * fetch + coercion pipeline so LLM-side `get_statement` output
71
71
  * and UI-side `[data:<id>]` rendering stay shape-identical for
72
72
  * the same `statement_id`.
@@ -110,7 +110,7 @@ export async function fetchStatementData(
110
110
  * catalogued.
111
111
  *
112
112
  * Pulled into its own helper so callers (notably the
113
- * `/statements/:statementId` route) stay decoupled from SDK
113
+ * `/embed/data/:id` route) stay decoupled from SDK
114
114
  * error-class identity, and the conversion logic stays testable
115
115
  * in isolation.
116
116
  */