@dbx-tools/appkit-mastra 0.6.15 → 0.6.36

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/src/genie.ts CHANGED
@@ -48,14 +48,14 @@ import { plugin } from "@dbx-tools/appkit";
48
48
  import { chat, space as genieSpace } from "@dbx-tools/genie";
49
49
  import { error, log, string } from "@dbx-tools/shared-core";
50
50
  import { genieModel, type GenieMessage } from "@dbx-tools/shared-genie";
51
- import { wire, type MastraWriter, type StartedEvent } from "@dbx-tools/shared-mastra";
51
+ import type { MastraWriter, StartedEvent } from "@dbx-tools/shared-mastra";
52
52
  import type { RequestContext } from "@mastra/core/request-context";
53
53
  import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
54
54
  import { createTool } from "@mastra/core/tools";
55
55
  import { z } from "zod";
56
56
 
57
57
  import type { MastraTools } from "./agents.ts";
58
- import { chartPlannerRequestSchema, prepareChart } from "./chart.ts";
58
+ import { chartPlannerRequestSchema, chartToolOutputSchema, prepareChart } from "./chart.ts";
59
59
  import { MASTRA_USER_KEY, resolveUserKey } from "./config.ts";
60
60
  import type { MastraPluginConfig, User } from "./config.ts";
61
61
  import { fetchStatementData } from "./statement.ts";
@@ -687,9 +687,9 @@ function buildGetStatementTool() {
687
687
  * {@link prepareChart} that resolves the dataset by fetching the
688
688
  * Genie statement's rows on demand. The tool mints a `chartId`
689
689
  * synchronously, caches an empty placeholder, and kicks off the
690
- * planner in the background so the agent loop never blocks. The
691
- * host UI resolves `[chart:<chartId>]` markers by reading the
692
- * cached {@link Chart} entry (1h TTL).
690
+ * planner in the background so the agent loop never blocks. The result carries
691
+ * the complete marker to copy; the host UI resolves it by reading the cached
692
+ * {@link Chart} entry (1h TTL).
693
693
  *
694
694
  * Space-agnostic: a Genie `statement_id` is workspace-scoped, so
695
695
  * one shared `prepare_chart` tool covers every wired Genie space.
@@ -709,20 +709,21 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
709
709
  description: string.toDescription([
710
710
  `
711
711
  Queue a chart for the rows of a Genie statement. Mints a
712
- short \`chartId\` synchronously and kicks off a BACKGROUND
712
+ \`chartId\` plus its complete \`marker\` synchronously and
713
+ kicks off a BACKGROUND
713
714
  task that fetches the statement's rows, runs the
714
715
  chart-planner to pick a chart type and Echarts spec, and
715
716
  caches the result under the \`chartId\` for one hour. The
716
717
  host UI fetches the cached chart on its own once it lands.
717
718
  `,
718
719
  `
719
- To display the chart in your reply, embed
720
- \`[chart:<chartId>]\` on its own line at the position you
721
- want it to appear, using the EXACT \`chartId\` string this
722
- call returned. Never construct a chart id yourself (it is
723
- not the \`statement_id\` or any variation of it) - only a
724
- value returned by this tool resolves to a real chart. The
725
- tool returns immediately - do NOT wait or call it again to
720
+ To display the chart in your reply, copy the returned
721
+ \`marker\` VERBATIM onto its own line at the position you
722
+ want it to appear. Never construct, alter, or invent a
723
+ marker from \`chartId\` (and never use the \`statement_id\`
724
+ or any variation of it) - only the complete value returned
725
+ by this tool resolves to a real chart. The tool returns
726
+ immediately - do NOT wait or call it again to
726
727
  "check progress"; the chart resolves asynchronously on the
727
728
  host UI's side.
728
729
  `,
@@ -734,7 +735,7 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
734
735
  `,
735
736
  ]),
736
737
  inputSchema: prepareChartRequestSchema,
737
- outputSchema: wire.ChartSchema.pick({ chartId: true }),
738
+ outputSchema: chartToolOutputSchema,
738
739
  execute: async (request, ctxRaw) => {
739
740
  const ctx = ctxRaw as ToolExecuteCtx;
740
741
  const { client, requestContext } = requireClient(ctx, toolId);
@@ -835,20 +836,20 @@ export const GENIE_INSTRUCTIONS = string.toDescription([
835
836
  `,
836
837
  `
837
838
  \`[chart:<chartId>]\` - render the rows as a chart. To
838
- get a \`<chartId>\`, call \`prepare_chart\` with the
839
+ get one, call \`prepare_chart\` with the
839
840
  statement's id (and an optional \`title\` / one-line
840
841
  \`description\` of the insight to surface). The tool
841
- returns the \`chartId\` synchronously and prepares the
842
- chart spec in the background; embed the returned id as
843
- \`[chart:<chartId>]\` on its own line wherever the
844
- chart should appear. Use a chart when the data has a
842
+ returns the complete \`marker\` synchronously and
843
+ prepares the chart spec in the background; copy that
844
+ marker VERBATIM onto its own line wherever the chart
845
+ should appear. Use a chart when the data has a
845
846
  story a visual conveys better than a table (trends,
846
847
  rankings, distributions, parts-of-a-whole).
847
848
 
848
- NEVER invent or hand-build a \`<chartId>\`. A valid
849
- \`<chartId>\` is the opaque token a \`prepare_chart\`
850
- call returned to you in THIS turn - nothing else. It
851
- is NOT a \`statement_id\`, and it is NOT a
849
+ NEVER invent or hand-build a marker. A valid marker is
850
+ the complete opaque string a \`prepare_chart\` call
851
+ returned to you in THIS turn - nothing else. Its id is
852
+ NOT a \`statement_id\`, and it is NOT a
852
853
  \`statement_id\` prefix with a label appended (e.g.
853
854
  \`01f1...-region-fill\`). If you have not called
854
855
  \`prepare_chart\` and received an id back, do not write
package/src/plugin.ts CHANGED
@@ -103,10 +103,10 @@ import {
103
103
  isMastraRequestAllowed,
104
104
  MastraServer,
105
105
  } from "./server.ts";
106
- import { attachChatTurnTraceIo } from "./trace-io.ts";
107
106
  import { resolveServingConfig } from "./serving.ts";
108
107
  import { fetchStatementData, STATEMENT_ROW_CAP } from "./statement.ts";
109
108
  import { threadsRoute } from "./threads.ts";
109
+ import { attachChatTurnTraceIo } from "./trace-io.ts";
110
110
  import { invalidFields } from "./validation.ts";
111
111
 
112
112
  const GENIE_MANIFEST = plugin.data(genie).plugin.manifest;
@@ -595,10 +595,24 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
595
595
  const id = string.firstNonEmpty(req.params.id);
596
596
  const resolve = embedResolvers[type];
597
597
  if (!resolve) {
598
+ this.logger.warn("embed:unsupported", {
599
+ status: 404,
600
+ type,
601
+ id,
602
+ method: req.method,
603
+ path: req.path,
604
+ });
598
605
  res.status(404).json({ error: `unsupported embed type: ${type}` });
599
606
  return;
600
607
  }
601
608
  if (!id) {
609
+ this.logger.warn("embed:invalid", {
610
+ status: 400,
611
+ type,
612
+ method: req.method,
613
+ path: req.path,
614
+ error: "id is required",
615
+ });
602
616
  res.status(400).json({ error: "id is required" });
603
617
  return;
604
618
  }
@@ -616,6 +630,18 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
616
630
  return;
617
631
  }
618
632
  if (result.data === undefined) {
633
+ // A cache miss is security-deliberately ambiguous: unknown, expired,
634
+ // or owned by another identity all map to the same 404. Log the
635
+ // opaque id and those possible causes so a fabricated marker is
636
+ // diagnosable from the server without disclosing which cause applied.
637
+ this.logger.warn(`embed:${type}:not-found`, {
638
+ status: 404,
639
+ type,
640
+ id,
641
+ method: req.method,
642
+ path: req.path,
643
+ reason: "unknown, expired, or owned by another identity",
644
+ });
619
645
  res.status(404).json({ error: `${type} not found` });
620
646
  return;
621
647
  }
@@ -1005,7 +1031,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
1005
1031
  // every default-workspace agent via `extraSkillPaths`.
1006
1032
  const provisioned = await provisionRemoteSkills(this.config.remoteSkills);
1007
1033
  if (provisioned.skillNames.length > 0) {
1008
- this.logger.info("remote skills provisioned", {
1034
+ this.logger.info("remote skills configured", {
1009
1035
  skills: provisioned.skillNames,
1010
1036
  databricksBasePath: provisioned.databricksBasePath,
1011
1037
  localSkillPaths: provisioned.localSkillPaths.length,
@@ -61,15 +61,9 @@ import { error, hash, json, log, net, object, string } from "@dbx-tools/shared-c
61
61
  import type { OneOrMany } from "@dbx-tools/shared-core";
62
62
  import type { FileSystem } from "@dbx-tools/shared-fs";
63
63
 
64
- const logger = log.logger("mastra/remote-skills");
65
-
66
- /** Shared Assistant skills tree in the Databricks workspace (default target). */
67
- const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
64
+ import { ASSISTANT_SHARED_SKILLS_PATH, userAssistantSkillsPath } from "./skill-paths.ts";
68
65
 
69
- /** Assistant skills directory for a specific Databricks user. */
70
- function userAssistantSkillsPath(userEmail: string): string {
71
- return `/Users/${userEmail.trim()}/.assistant/skills`;
72
- }
66
+ const logger = log.logger("mastra/remote-skills");
73
67
 
74
68
  /**
75
69
  * Agent id the `skills` CLI installs a bare `SKILL.md` tree under.
@@ -452,14 +446,19 @@ export async function provisionRemoteSkills(
452
446
  if (cached && isFresh(cached, resolveRefreshTtl(sourceOptions, options))) {
453
447
  skillNames.push(...cached.skills);
454
448
  if (!destination) localSkillPaths.push(cacheFS.root);
455
- logger.debug("source:cached", {
449
+ logger.info("remote skill ready", {
456
450
  source: sourceOptions.source,
451
+ destination: databricksBasePath ?? cacheFS.root,
457
452
  downloadedAt: cached.downloadedAt,
458
453
  skills: cached.skills,
454
+ cached: true,
459
455
  });
460
456
  continue;
461
457
  }
462
458
 
459
+ logger.info("installing remote skill", {
460
+ source: sourceOptions.source,
461
+ });
463
462
  staging ??= await initializedScratch("mastra-remote-skills");
464
463
  const stagedDir = await stageSource(sourceOptions, staging.root, options);
465
464
  const staged = await collectSkillDirs(stagedDir);
@@ -482,9 +481,9 @@ export async function provisionRemoteSkills(
482
481
  localSkillPaths.push(await persistLocally(key, staged, record));
483
482
  }
484
483
  skillNames.push(...record.skills);
485
- logger.debug("source:provisioned", {
484
+ logger.info("remote skill installed", {
486
485
  source: sourceOptions.source,
487
- destination: databricksBasePath ?? "local-temp",
486
+ destination: databricksBasePath ?? localSkillPaths.at(-1),
488
487
  skills: record.skills,
489
488
  });
490
489
  } catch (err) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Where Databricks Assistant `SKILL.md` trees live.
3
+ *
4
+ * Two modules need these paths and must agree on them: `workspaces.ts` MOUNTS
5
+ * them per request (see `DEFAULT_SKILL_FOLDERS`), and `remote-skills.ts`
6
+ * WRITES provisioned skills into them at startup. They were previously spelled
7
+ * out in both, so a change to one silently provisioned skills into a tree the
8
+ * other never scanned.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /** Shared Assistant skills tree, readable by everyone in the workspace. */
14
+ export const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
15
+
16
+ /** Assistant skills tree owned by one user (the "save this as a skill" target). */
17
+ export function userAssistantSkillsPath(userEmail: string): string {
18
+ return `/Users/${userEmail.trim()}/.assistant/skills`;
19
+ }
package/src/workspaces.ts CHANGED
@@ -2,10 +2,14 @@
2
2
  * Mastra workspace factory for Databricks Apps.
3
3
  *
4
4
  * Builds a per-request {@link Workspace} whose filesystem is a
5
- * {@link CompositeFilesystem} over Databricks paths resolved from the
6
- * OBO client on {@link MASTRA_USER_KEY}. Optional mount resolver
7
- * contributions merge extra filesystems and skill scan roots; built-in
8
- * Assistant skill trees are toggled with `assistantSkills` (on by default).
5
+ * {@link CompositeFilesystem} over the NAMED skill folders resolved for that
6
+ * request. A skill folder maps a name to a location plus its readable /
7
+ * writable policy: a Databricks path mounted through the OBO client on
8
+ * {@link MASTRA_USER_KEY}, or any {@link WorkspaceFilesystem} a consuming
9
+ * library already owns. {@link DEFAULT_SKILL_FOLDERS} supplies the Assistant
10
+ * trees, and `skillFolders` merges over it - same name overrides, `false`
11
+ * disables, a new name adds. Optional mount resolvers contribute further
12
+ * filesystems and skill scan roots on top.
9
13
  *
10
14
  * Databricks mounts use `@dbx-tools/databricks` {@link DatabricksFileSystem}
11
15
  * wrapped by {@link filesystems}; missing roots fall back to
@@ -16,7 +20,7 @@
16
20
 
17
21
  import type { WorkspaceClient } from "@databricks/sdk-experimental";
18
22
  import { DatabricksFileSystem } from "@dbx-tools/databricks";
19
- import { log, string, token } from "@dbx-tools/shared-core";
23
+ import { error, log, string, token } from "@dbx-tools/shared-core";
20
24
  import type { RequestContext } from "@mastra/core/request-context";
21
25
  import {
22
26
  CompositeFilesystem,
@@ -28,18 +32,10 @@ import {
28
32
 
29
33
  import { MASTRA_SCOPES_KEY, MASTRA_USER_EMAIL_KEY, MASTRA_USER_KEY, type User } from "./config.ts";
30
34
  import { scratchFilesystem, filesystems } from "./filesystems.ts";
35
+ import { ASSISTANT_SHARED_SKILLS_PATH, userAssistantSkillsPath } from "./skill-paths.ts";
31
36
 
32
37
  /* ------------------------------ constants ------------------------------ */
33
38
 
34
- /** Shared Assistant skills tree in the workspace namespace. */
35
- const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
36
-
37
- /** Composite mount for {@link ASSISTANT_SHARED_SKILLS_PATH}. */
38
- const ASSISTANT_WORKSPACE_SKILLS_MOUNT = "/workspace_skills";
39
-
40
- /** Composite mount for the caller's `/.assistant/skills` tree. */
41
- const ASSISTANT_USER_SKILLS_MOUNT = "/workspace_user_skills";
42
-
43
39
  /** OAuth scopes that gate Databricks workspace file mounts. */
44
40
  const WORKSPACE_FILE_SCOPES = ["workspace", "workspace.workspace", "all-apis"] as const;
45
41
 
@@ -47,23 +43,60 @@ const logger = log.logger("mastra/workspaces");
47
43
 
48
44
  /* -------------------------------- types -------------------------------- */
49
45
 
50
- /** Per-request context for mount resolvers. */
51
- interface WorkspaceMountContext {
46
+ /** Per-request context for mount and skill-folder resolvers. */
47
+ export interface WorkspaceMountContext {
52
48
  requestContext?: RequestContext;
53
49
  }
54
50
 
51
+ /**
52
+ * A skill-folder field given either directly or as a per-request resolver.
53
+ * A resolver returning `undefined` skips the folder for that request.
54
+ */
55
+ export type SkillFolderValue<T> =
56
+ T | ((context: WorkspaceMountContext) => T | undefined | Promise<T | undefined>);
57
+
58
+ /**
59
+ * One named skill-folder location and its read / write policy.
60
+ *
61
+ * Give {@link path} for a Databricks workspace tree (mounted through the
62
+ * request's OBO client), or {@link filesystem} for a mount the consumer builds
63
+ * itself. {@link filesystem} wins when both are set.
64
+ */
65
+ export interface SkillFolderOptions {
66
+ /** Absolute Databricks workspace path for this folder. */
67
+ path?: SkillFolderValue<string>;
68
+ /** Ready-made mount, for locations the OBO client cannot reach. */
69
+ filesystem?: SkillFolderValue<WorkspaceFilesystem>;
70
+ /**
71
+ * Scan this mount for `SKILL.md` files. Defaults to `true`; `false` mounts
72
+ * the location for file tools without adding it to skill discovery.
73
+ */
74
+ readable?: boolean;
75
+ /**
76
+ * Allow writes to a {@link path} mount (and create the root when missing).
77
+ * Defaults to `false`. A supplied {@link filesystem} carries its own
78
+ * read-only flag instead.
79
+ */
80
+ writable?: boolean;
81
+ /** Mount point in the composite namespace. Defaults to `/<name>`. */
82
+ mount?: string;
83
+ }
84
+
55
85
  /** Mount map plus optional Mastra skill scan roots for one resolver. */
56
- interface WorkspaceMountContribution {
86
+ export interface WorkspaceMountContribution {
57
87
  mounts: Record<string, WorkspaceFilesystem>;
58
88
  /** Paths within the composite namespace where `SKILL.md` files are scanned. */
59
89
  skillPaths?: string[];
60
90
  }
61
91
 
62
92
  /** Contributes filesystem mounts (and optional skill paths) for one request. */
63
- type WorkspaceMountResolver = (
93
+ export type WorkspaceMountResolver = (
64
94
  context: WorkspaceMountContext,
65
95
  ) => WorkspaceMountContribution | Promise<WorkspaceMountContribution>;
66
96
 
97
+ /** Names carried by {@link DEFAULT_SKILL_FOLDERS}. */
98
+ export type DefaultSkillFolderName = "workspace-team" | "workspace-team-app";
99
+
67
100
  /** Options for {@link createWorkspace}. */
68
101
  export interface CreateWorkspaceOptions {
69
102
  /** Workspace id; derived from `name` or `"workspace"` when omitted. */
@@ -71,11 +104,17 @@ export interface CreateWorkspaceOptions {
71
104
  /** Display name; derived from `id` when omitted. */
72
105
  name?: string;
73
106
  /**
74
- * Mount read-only Assistant skill trees from `/Workspace/.assistant/skills`
75
- * and `/Users/<email>/.assistant/skills`. Defaults to `true`.
107
+ * Start from {@link DEFAULT_SKILL_FOLDERS}. Defaults to `true`; `false`
108
+ * starts from an empty map, leaving only the {@link skillFolders} given here.
76
109
  */
77
110
  assistantSkills?: boolean;
78
- /** Extra per-request mount resolvers (run after built-in options). */
111
+ /**
112
+ * Named skill folders merged over {@link DEFAULT_SKILL_FOLDERS}: a matching
113
+ * name overrides that default, `false` disables it, and any other name adds
114
+ * a folder.
115
+ */
116
+ skillFolders?: Record<string, SkillFolderOptions | false>;
117
+ /** Extra per-request mount resolvers (run after the skill-folder mounts). */
79
118
  mounts?: WorkspaceMountResolver[];
80
119
  /** Replace the auto-built dynamic skills resolver. */
81
120
  skills?: SkillsResolver;
@@ -93,18 +132,56 @@ export interface CreateWorkspaceOptions {
93
132
  extraSkillPaths?: string[];
94
133
  }
95
134
 
135
+ /* ------------------------------- defaults ------------------------------- */
136
+
137
+ /**
138
+ * The skill folders every workspace starts with.
139
+ *
140
+ * - `workspace-team` - the shared workspace Assistant tree, read-only because
141
+ * writing it is a workspace-admin action.
142
+ * - `workspace-team-app` - the requesting user's own Assistant tree, writable
143
+ * so the app can save skills back to it. Skipped when the request carries no
144
+ * user email.
145
+ */
146
+ export const DEFAULT_SKILL_FOLDERS: Readonly<Record<DefaultSkillFolderName, SkillFolderOptions>> = {
147
+ "workspace-team": {
148
+ path: ASSISTANT_SHARED_SKILLS_PATH,
149
+ readable: true,
150
+ writable: false,
151
+ },
152
+ "workspace-team-app": {
153
+ path: ({ requestContext }) => {
154
+ const email = resolveScopedEmail(requestContext);
155
+ return email ? userAssistantSkillsPath(email) : undefined;
156
+ },
157
+ readable: true,
158
+ writable: true,
159
+ },
160
+ };
161
+
96
162
  /**
97
163
  * Create a Mastra {@link Workspace} with per-request Databricks mounts.
98
164
  *
99
- * @example Assistant skills only (default for agents in this plugin)
165
+ * @example Default skill folders only
100
166
  * ```ts
101
167
  * createWorkspace()
102
168
  * ```
103
169
  *
104
- * @example Assistant skills plus a custom mount resolver
170
+ * @example Override a default, drop another, and add a location of your own
171
+ * ```ts
172
+ * createWorkspace({
173
+ * skillFolders: {
174
+ * "workspace-team": { path: "/Workspace/Shared/team-skills" },
175
+ * "workspace-team-app": false,
176
+ * runbooks: { path: "/Workspace/Shared/runbooks/skills", writable: true },
177
+ * volume: { filesystem: myVolumeFilesystem },
178
+ * },
179
+ * })
180
+ * ```
181
+ *
182
+ * @example Skill folders plus a custom mount resolver
105
183
  * ```ts
106
184
  * createWorkspace({
107
- * assistantSkills: true,
108
185
  * mounts: [
109
186
  * async ({ requestContext }) => ({
110
187
  * mounts: { "/data": myFilesystem },
@@ -116,20 +193,22 @@ export interface CreateWorkspaceOptions {
116
193
  */
117
194
  export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace {
118
195
  const { id, name } = resolveWorkspaceIdentity(options);
119
- const resolvers = buildMountResolvers(options);
196
+ const skillFolders = resolveSkillFolders(options);
197
+ const folderNames = Object.keys(skillFolders);
198
+ const resolvers = buildMountResolvers(skillFolders, options.mounts);
120
199
  const extraSkillPaths = options.extraSkillPaths ?? [];
121
200
  const skills =
122
201
  options.skills ??
123
202
  (resolvers.length > 0 || extraSkillPaths.length > 0
124
203
  ? buildWorkspaceSkillsResolver(resolvers, extraSkillPaths)
125
204
  : undefined);
126
- const checkSkillFileMtime = options.checkSkillFileMtime ?? options.assistantSkills !== false;
205
+ const checkSkillFileMtime = options.checkSkillFileMtime ?? folderNames.length > 0;
127
206
  const bm25 = options.bm25 !== false;
128
207
  logger.debug("workspace:create", {
129
208
  id,
130
209
  name,
131
210
  resolverCount: resolvers.length,
132
- assistantSkills: options.assistantSkills !== false,
211
+ skillFolders: folderNames,
133
212
  customMountResolvers: options.mounts?.length ?? 0,
134
213
  customSkillsResolver: Boolean(options.skills),
135
214
  checkSkillFileMtime,
@@ -151,16 +230,29 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
151
230
  });
152
231
  }
153
232
 
154
- /* ---------------------------- private helpers ---------------------------- */
155
-
156
233
  /**
157
- * Map an OBO user email to their Assistant skills directory in the
158
- * workspace namespace.
234
+ * Merge the configured skill folders over {@link DEFAULT_SKILL_FOLDERS}.
235
+ *
236
+ * `assistantSkills: false` drops the defaults, and a `false` value removes one
237
+ * entry by name.
159
238
  */
160
- function userAssistantSkillsPath(userEmail: string): string {
161
- return `/Users/${userEmail.trim()}/.assistant/skills`;
239
+ export function resolveSkillFolders(
240
+ options: Pick<CreateWorkspaceOptions, "assistantSkills" | "skillFolders"> = {},
241
+ ): Record<string, SkillFolderOptions> {
242
+ const merged: Record<string, SkillFolderOptions> =
243
+ options.assistantSkills === false ? {} : { ...DEFAULT_SKILL_FOLDERS };
244
+ for (const [name, folder] of Object.entries(options.skillFolders ?? {})) {
245
+ if (folder === false) {
246
+ delete merged[name];
247
+ } else {
248
+ merged[name] = folder;
249
+ }
250
+ }
251
+ return merged;
162
252
  }
163
253
 
254
+ /* ---------------------------- private helpers ---------------------------- */
255
+
164
256
  /**
165
257
  * Return whether the request token carries a scope that allows workspace
166
258
  * file API access (`workspace` or `all-apis` on {@link MASTRA_SCOPES_KEY}).
@@ -173,61 +265,81 @@ function hasWorkspaceFileScope(requestContext: RequestContext | undefined): bool
173
265
  }
174
266
 
175
267
  /**
176
- * Built-in mount resolver for Assistant `SKILL.md` trees.
268
+ * Mount resolver for the named skill folders.
177
269
  *
178
- * Mounts {@link ASSISTANT_SHARED_SKILLS_PATH} when scope checks pass and
179
- * `/Users/<email>/.assistant/skills` when {@link MASTRA_USER_EMAIL_KEY} is
180
- * set. Returns empty mounts when the OBO user or client is missing.
181
- * Mastra owns filesystem initialization.
270
+ * Gates on workspace file scope (or development mode), then mounts every
271
+ * folder whose location resolves for this request.
182
272
  */
183
- async function resolveAssistantSkillsMounts(
273
+ async function resolveSkillFolderMounts(
274
+ skillFolders: Record<string, SkillFolderOptions>,
184
275
  context: WorkspaceMountContext,
185
276
  ): Promise<WorkspaceMountContribution> {
186
277
  const mounts: Record<string, WorkspaceFilesystem> = {};
278
+ const skillPaths: string[] = [];
187
279
  const requestContext = context.requestContext;
188
280
 
189
- if (!shouldMountAssistantSkills(requestContext)) {
190
- logger.debug("assistant-skills:skipped", {
281
+ if (!requestContext || !shouldMountSkillFolders(requestContext)) {
282
+ logger.debug("skill-folders:skipped", {
191
283
  reason: !requestContext ? "no-request-context" : "missing-workspace-scope",
192
284
  nodeEnv: process.env.NODE_ENV,
193
- scopes: context.requestContext?.get(MASTRA_SCOPES_KEY),
285
+ scopes: requestContext?.get(MASTRA_SCOPES_KEY),
194
286
  });
195
- return { mounts, skillPaths: [] };
287
+ return { mounts, skillPaths };
196
288
  }
197
289
 
198
- const user = requestContext!.get(MASTRA_USER_KEY) as User | undefined;
290
+ const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
199
291
  const client = user?.executionContext.client as WorkspaceClient | undefined;
200
- if (!client) {
201
- logger.debug("assistant-skills:skipped", {
202
- reason: "missing-obo-client",
203
- userId: user?.id,
204
- });
205
- return { mounts, skillPaths: [] };
206
- }
207
-
208
- mounts[ASSISTANT_WORKSPACE_SKILLS_MOUNT] = await databricksFilesystem(
209
- client,
210
- ASSISTANT_SHARED_SKILLS_PATH,
211
- );
212
292
 
213
- const email = resolveScopedEmail(requestContext);
214
- if (email) {
215
- mounts[ASSISTANT_USER_SKILLS_MOUNT] = await databricksFilesystem(
216
- client,
217
- userAssistantSkillsPath(email),
218
- false,
219
- );
293
+ for (const [name, folder] of Object.entries(skillFolders)) {
294
+ const filesystem = await resolveSkillFolderFilesystem(name, folder, context, client);
295
+ if (!filesystem) continue;
296
+ const mount = folder.mount ?? `/${name}`;
297
+ mounts[mount] = filesystem;
298
+ if (folder.readable !== false) skillPaths.push(mount);
220
299
  }
221
300
 
222
- logger.debug("assistant-skills:mounted", {
223
- sharedPath: ASSISTANT_SHARED_SKILLS_PATH,
224
- sharedMount: ASSISTANT_WORKSPACE_SKILLS_MOUNT,
225
- userMount: email ? ASSISTANT_USER_SKILLS_MOUNT : undefined,
226
- userPath: email ? userAssistantSkillsPath(email) : undefined,
301
+ logger.debug("skill-folders:mounted", {
227
302
  mountKeys: Object.keys(mounts),
303
+ skillPaths,
228
304
  });
229
305
 
230
- return { mounts, skillPaths: Object.keys(mounts) };
306
+ return { mounts, skillPaths };
307
+ }
308
+
309
+ /**
310
+ * Resolve one skill folder to a Mastra filesystem, or `undefined` to skip it
311
+ * for this request.
312
+ */
313
+ async function resolveSkillFolderFilesystem(
314
+ name: string,
315
+ folder: SkillFolderOptions,
316
+ context: WorkspaceMountContext,
317
+ client: WorkspaceClient | undefined,
318
+ ): Promise<WorkspaceFilesystem | undefined> {
319
+ if (folder.filesystem !== undefined) {
320
+ return resolveSkillFolderValue(folder.filesystem, context);
321
+ }
322
+ // A path mount needs the request's OBO client to reach the workspace.
323
+ if (folder.path === undefined || !client) {
324
+ logger.debug("skill-folder:skipped", {
325
+ name,
326
+ reason: folder.path === undefined ? "no-location" : "missing-obo-client",
327
+ });
328
+ return undefined;
329
+ }
330
+ const root = string.trimToNull(await resolveSkillFolderValue(folder.path, context));
331
+ if (!root) return undefined;
332
+ return databricksFilesystem(client, root, folder.writable !== true);
333
+ }
334
+
335
+ /** Read a {@link SkillFolderValue}, calling it when it is a per-request resolver. */
336
+ function resolveSkillFolderValue<T>(
337
+ value: SkillFolderValue<T>,
338
+ context: WorkspaceMountContext,
339
+ ): T | undefined | Promise<T | undefined> {
340
+ return typeof value === "function"
341
+ ? (value as (context: WorkspaceMountContext) => T | undefined | Promise<T | undefined>)(context)
342
+ : value;
231
343
  }
232
344
 
233
345
  /**
@@ -249,19 +361,21 @@ function resolveWorkspaceIdentity(options: CreateWorkspaceOptions): {
249
361
  return { id, name };
250
362
  }
251
363
 
252
- /** Collect built-in and caller-supplied mount resolvers for one workspace. */
253
- function buildMountResolvers(options: CreateWorkspaceOptions): WorkspaceMountResolver[] {
364
+ /** Collect the skill-folder resolver and any caller-supplied ones. */
365
+ function buildMountResolvers(
366
+ skillFolders: Record<string, SkillFolderOptions>,
367
+ mounts: WorkspaceMountResolver[] | undefined,
368
+ ): WorkspaceMountResolver[] {
254
369
  const resolvers: WorkspaceMountResolver[] = [];
255
- const { assistantSkills = true, mounts } = options;
256
- if (assistantSkills) {
257
- resolvers.push(resolveAssistantSkillsMounts);
370
+ const folderCount = Object.keys(skillFolders).length;
371
+ if (folderCount > 0) {
372
+ resolvers.push((context) => resolveSkillFolderMounts(skillFolders, context));
258
373
  }
259
374
  if (mounts?.length) {
260
375
  resolvers.push(...mounts);
261
376
  }
262
377
  logger.debug("mounts:resolvers", {
263
- assistantSkills,
264
- builtInResolver: assistantSkills,
378
+ skillFolderCount: folderCount,
265
379
  customResolverCount: mounts?.length ?? 0,
266
380
  totalResolverCount: resolvers.length,
267
381
  });
@@ -269,23 +383,19 @@ function buildMountResolvers(options: CreateWorkspaceOptions): WorkspaceMountRes
269
383
  }
270
384
 
271
385
  /**
272
- * Gate Assistant skill mounts on request context.
386
+ * Gate skill-folder mounts on the request's token.
273
387
  *
274
388
  * Always allows mounts in development; in other environments requires
275
389
  * {@link hasWorkspaceFileScope}.
276
390
  */
277
- function shouldMountAssistantSkills(
278
- requestContext: RequestContext | undefined,
279
- ): requestContext is RequestContext {
280
- if (!requestContext) return false;
391
+ function shouldMountSkillFolders(requestContext: RequestContext): boolean {
281
392
  if (process.env.NODE_ENV === "development") return true;
282
393
  return hasWorkspaceFileScope(requestContext);
283
394
  }
284
395
 
285
396
  /** Read the trimmed OBO user email stamped on {@link MASTRA_USER_EMAIL_KEY}. */
286
397
  function resolveScopedEmail(requestContext: RequestContext | undefined): string | undefined {
287
- const email = requestContext?.get(MASTRA_USER_EMAIL_KEY) as string | undefined;
288
- return email?.trim() || undefined;
398
+ return string.trimToNull(requestContext?.get(MASTRA_USER_EMAIL_KEY)) ?? undefined;
289
399
  }
290
400
 
291
401
  /**
@@ -313,7 +423,7 @@ async function databricksFilesystem(
313
423
  logger.debug("databricks-mount:scratch-fallback", {
314
424
  root,
315
425
  readOnly,
316
- error: err instanceof Error ? err.message : String(err),
426
+ error: error.errorMessage(err),
317
427
  });
318
428
  }
319
429
  return scratchFilesystem();