@dbx-tools/appkit-mastra 0.6.9 → 0.6.11

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/plugin.ts CHANGED
@@ -97,7 +97,6 @@ import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "
97
97
  import { logFeedback, resolveFeedbackEnabled } from "./mlflow.ts";
98
98
  import { buildObservability } from "./observability.ts";
99
99
  import { provisionRemoteSkills } from "./remote-skills.ts";
100
- import { provisionDatabricksAITools } from "./databricks-aitools.ts";
101
100
  import {
102
101
  attachRoutePatchMiddleware,
103
102
  createRequestContext,
@@ -1012,24 +1011,12 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
1012
1011
  });
1013
1012
  }
1014
1013
 
1015
- // Fold Databricks AI Tools skills (`databricks aitools`) in as extra local
1016
- // skill scan paths: reuse the CLI's installed tree when present, else shell
1017
- // out to the CLI to materialize a curated set.
1018
- const aiTools = await provisionDatabricksAITools(this.config.databricksAITools);
1019
- if (aiTools.localSkillPaths.length > 0) {
1020
- this.logger.info("databricks ai tools provisioned", {
1021
- source: aiTools.source,
1022
- localSkillPaths: aiTools.localSkillPaths.length,
1023
- });
1024
- }
1025
- const extraSkillPaths = [...provisioned.localSkillPaths, ...aiTools.localSkillPaths];
1026
-
1027
1014
  this.built = await buildAgents({
1028
1015
  config: this.config,
1029
1016
  context: this.context,
1030
1017
  memoryBuilder,
1031
1018
  log: this.logger,
1032
- extraSkillPaths,
1019
+ extraSkillPaths: provisioned.localSkillPaths,
1033
1020
  });
1034
1021
 
1035
1022
  // `mastra.server.apiRoutes` is only honored by Mastra's standalone
@@ -1,19 +1,24 @@
1
1
  /**
2
2
  * Startup provisioning of remote Agent-Skill sources for a Mastra workspace.
3
3
  *
4
- * A {@link RemoteSkillSource} names WHERE a `SKILL.md` tree comes from - a
5
- * GitHub `owner/repo`, any git / GitLab URL, or a direct download URL - and
6
- * optional per-source policy. {@link provisionRemoteSkills} materializes every
7
- * source into a local `SKILL.md` tree at app boot and returns the directories
8
- * to hand Mastra as extra skill scan paths.
4
+ * A {@link RemoteSkillSource} names WHERE a `SKILL.md` tree comes from - the
5
+ * {@link AITOOLS_SOURCE} constant, a GitHub `owner/repo`, any git / GitLab URL,
6
+ * or a direct download URL - and optional per-source policy.
7
+ * {@link provisionRemoteSkills} materializes every source into a local
8
+ * `SKILL.md` tree at app boot and returns the directories to hand Mastra as
9
+ * extra skill scan paths.
9
10
  *
10
11
  * Resolution per source, in order:
11
12
  *
12
- * 1. the OPTIONAL `skills` npm CLI (peer dep): if installed, each source is
13
+ * 1. the {@link AITOOLS_SOURCE} constant (`"aitools"`): Databricks' own skill
14
+ * set, read straight from the public repo the `databricks aitools` CLI
15
+ * sources from. No CLI, no Databricks auth - which is what makes it usable
16
+ * inside a Databricks App container, where the CLI is not installed;
17
+ * 2. the OPTIONAL `skills` npm CLI (peer dep): if installed, each source is
13
18
  * copied into a staging dir with `skills add <source> --agent <dir> --copy`,
14
19
  * which understands every source format the ecosystem does (GitHub
15
20
  * shorthand, git URLs, archive/download URLs);
16
- * 2. otherwise a plain {@link fetch} of the source URL (built with
21
+ * 3. otherwise a plain {@link fetch} of the source URL (built with
17
22
  * {@link net.urlBuilder}), writing the downloaded `SKILL.md` to a staging
18
23
  * dir.
19
24
  *
@@ -27,23 +32,25 @@
27
32
  * discovered by the built-in Assistant-skills mount. Pass `userEmail` (or an
28
33
  * explicit `databricksBasePath`) to target `/Users/<email>/.assistant/skills`
29
34
  * instead. When no Databricks client is resolvable at startup, the tree is
30
- * written to a local temp dir and returned as an extra local skill path for
31
- * the current process.
35
+ * written under {@link localFS.tmpFS} and returned as an extra local skill path
36
+ * for the current process.
32
37
  *
33
38
  * @module
34
39
  */
35
40
 
36
- import { mkdtemp, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
41
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
37
42
  import { createRequire } from "node:module";
38
- import { tmpdir } from "node:os";
39
- import { join, posix } from "node:path";
43
+ import { dirname, join, posix } from "node:path";
44
+ import type { WorkspaceClient } from "@databricks/sdk-experimental";
40
45
  import { appkit } from "@dbx-tools/appkit";
41
46
  import type { WorkspaceClientLike } from "@dbx-tools/appkit";
42
- import { spawn } from "@dbx-tools/core";
43
- import { findFiles } from "@dbx-tools/path";
44
- import { error, log, net, string } from "@dbx-tools/shared-core";
45
-
46
- import { DatabricksWorkspaceFilesystem } from "./filesystems.ts";
47
+ import { exec } from "@dbx-tools/core";
48
+ import { DatabricksFileSystem } from "@dbx-tools/databricks";
49
+ import { localFS, type LocalFileSystem } from "@dbx-tools/fs";
50
+ import { find } from "@dbx-tools/path";
51
+ import { error, hash, json, log, net, object, string } from "@dbx-tools/shared-core";
52
+ import type { OneOrMany } from "@dbx-tools/shared-core";
53
+ import type { FileSystem } from "@dbx-tools/shared-fs";
47
54
 
48
55
  const logger = log.logger("mastra/remote-skills");
49
56
 
@@ -64,20 +71,58 @@ const SKILLS_CLI_LAYOUT = [".agents", "skills"] as const;
64
71
  /** Cap on a direct-fetch download body, matching the `skills` CLI default. */
65
72
  const DEFAULT_MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024;
66
73
 
74
+ /** Stable temp directory holding one rebuilt skill tree per remote source. */
75
+ const LOCAL_SKILLS_DIR = "mastra-local-skills";
76
+
77
+ /* ------------------------------ AI Tools ------------------------------ */
78
+
79
+ /**
80
+ * Source constant selecting Databricks' own AI Tools skill set.
81
+ *
82
+ * @example
83
+ * mastra({ remoteSkills: "aitools" });
84
+ */
85
+ export const AITOOLS_SOURCE = "aitools";
86
+
87
+ /** The {@link AITOOLS_SOURCE} literal, for callers spelling the union out. */
88
+ export type AiToolsSource = typeof AITOOLS_SOURCE;
89
+
90
+ /**
91
+ * The PUBLIC repo `databricks aitools install` sources from. Reading it
92
+ * directly is what lets an app get the same skills the CLI installs without
93
+ * the CLI - which a Databricks App container does not ship.
94
+ */
95
+ const AITOOLS_REPO = "databricks/databricks-agent-skills";
96
+
97
+ /** Repo ref the skills are read from. Override per source with `ref`. */
98
+ const AITOOLS_REF = "main";
99
+
100
+ /** Skill files downloaded at once; the set is ~30 skills of a few files each. */
101
+ const AITOOLS_CONCURRENCY = 8;
102
+
103
+ /** The subset of the repo's generated `manifest.json` this module reads. */
104
+ interface AiToolsManifest {
105
+ skills?: Record<string, { files?: string[]; repo_dir?: string }>;
106
+ }
107
+
67
108
  /* -------------------------------- types -------------------------------- */
68
109
 
69
110
  /**
70
111
  * One remote skill source and its per-source policy.
71
112
  *
72
- * `source` is anything the `skills` ecosystem understands: a GitHub
73
- * `owner/repo` shorthand, a full GitHub / GitLab / git URL, or a direct
74
- * download URL to a `SKILL.md` or archive.
113
+ * `source` is {@link AITOOLS_SOURCE} or anything the `skills` ecosystem
114
+ * understands: a GitHub `owner/repo` shorthand, a full GitHub / GitLab / git
115
+ * URL, or a direct download URL to a `SKILL.md` or archive.
75
116
  */
76
117
  export interface RemoteSkillSourceOptions {
77
- /** GitHub shorthand, git / GitLab URL, or a direct download URL. */
78
- source: string;
79
- /** Install only these skill names from the source (CLI path only). */
118
+ /** `"aitools"`, a GitHub shorthand, a git / GitLab URL, or a download URL. */
119
+ source: net.UrlLike | AiToolsSource;
120
+ /** Install only these skill names from the source. */
80
121
  skills?: string | string[];
122
+ /** Include experimental skills. `"aitools"` only. */
123
+ experimental?: boolean;
124
+ /** Pin the repo ref (tag / branch / sha). `"aitools"` only; defaults to `main`. */
125
+ ref?: string;
81
126
  /** Override the byte ceiling on a direct-fetch download for this source. */
82
127
  maxDownloadBytes?: number;
83
128
  /**
@@ -88,22 +133,26 @@ export interface RemoteSkillSourceOptions {
88
133
  }
89
134
 
90
135
  /**
91
- * A remote skill source: a bare source string (GitHub shorthand / URL) or a
92
- * {@link RemoteSkillSourceOptions} with per-source policy.
136
+ * A remote skill source: the {@link AITOOLS_SOURCE} constant, a URL-like
137
+ * (string / `URL` / `{ url }`), or a {@link RemoteSkillSourceOptions} bag with
138
+ * per-source policy.
93
139
  */
94
- export type RemoteSkillSource = string | RemoteSkillSourceOptions;
140
+ export type RemoteSkillSource = net.UrlLike | AiToolsSource | RemoteSkillSourceOptions;
95
141
 
96
142
  /**
97
- * The workspace `remoteSkills` option. A single source, a list, or a
98
- * {@link ProvisionRemoteSkillsOptions} bag when top-level policy is needed.
143
+ * The workspace `remoteSkills` option: one source, a non-empty list of them, or
144
+ * a {@link ProvisionRemoteSkillsOptions} bag when top-level policy is needed.
99
145
  */
100
146
  export type RemoteSkillsOption =
101
- RemoteSkillSource | RemoteSkillSource[] | ProvisionRemoteSkillsOptions;
147
+ RemoteSkillSource | OneOrMany<RemoteSkillSource> | ProvisionRemoteSkillsOptions;
148
+
149
+ /** A source entry with its `source` flattened to a plain string. */
150
+ type NormalizedSource = Omit<RemoteSkillSourceOptions, "source"> & { source: string };
102
151
 
103
152
  /** Top-level remote-skills provisioning options. */
104
153
  export interface ProvisionRemoteSkillsOptions {
105
154
  /** Sources to materialize at startup. */
106
- sources: RemoteSkillSource | RemoteSkillSource[];
155
+ sources: RemoteSkillSource | OneOrMany<RemoteSkillSource>;
107
156
  /**
108
157
  * Fail app startup when a source can't be resolved. Defaults to `true`. A
109
158
  * per-source `failOnError` wins over this.
@@ -143,34 +192,50 @@ export function normalizeRemoteSkillsOption(
143
192
  option: RemoteSkillsOption | undefined,
144
193
  ): ProvisionRemoteSkillsOptions | undefined {
145
194
  if (option === undefined) return undefined;
146
- if (typeof option === "string") return { sources: [option] };
147
- if (Array.isArray(option)) return option.length > 0 ? { sources: option } : undefined;
148
- if (isProvisionOptions(option)) {
149
- const sources = Array.isArray(option.sources) ? option.sources : [option.sources];
150
- return sources.length > 0 ? { ...option, sources } : undefined;
151
- }
152
- // A lone RemoteSkillSourceOptions object.
153
- return { sources: [option] };
195
+ const bag = isProvisionOptions(option) ? option : undefined;
196
+ const sources = toSourceList(bag ? bag.sources : (option as RemoteSkillSource));
197
+ // An empty list is the same as no configuration at all.
198
+ if (!object.isOneOrMany(sources)) return undefined;
199
+ return { ...bag, sources };
154
200
  }
155
201
 
156
202
  /** A `sources`-bearing bag is the top-level options shape, not a single source. */
157
- function isProvisionOptions(
158
- value: RemoteSkillSourceOptions | ProvisionRemoteSkillsOptions,
159
- ): value is ProvisionRemoteSkillsOptions {
160
- return "sources" in value;
203
+ function isProvisionOptions(value: unknown): value is ProvisionRemoteSkillsOptions {
204
+ return object.isRecord(value) && "sources" in value;
205
+ }
206
+
207
+ /** One source or many, as a plain array. A source is never itself an array. */
208
+ function toSourceList(
209
+ input: RemoteSkillSource | OneOrMany<RemoteSkillSource>,
210
+ ): RemoteSkillSource[] {
211
+ return Array.isArray(input) ? [...input] : [input];
161
212
  }
162
213
 
163
- /** Coerce a source entry to its normalized {@link RemoteSkillSourceOptions}. */
164
- function toSourceOptions(source: RemoteSkillSource): RemoteSkillSourceOptions {
165
- return typeof source === "string" ? { source } : source;
214
+ /** Flatten a source entry - string, `URL`, `{ url }`, or options bag - to a {@link NormalizedSource}. */
215
+ function toSourceOptions(source: RemoteSkillSource): NormalizedSource {
216
+ if (typeof source === "string") return { source };
217
+ if (source instanceof URL) return { source: source.toString() };
218
+ if ("source" in source) return { ...source, source: toSourceString(source.source) };
219
+ return { source: source.url };
220
+ }
221
+
222
+ /** A {@link net.UrlLike} as the plain string the staging paths work with. */
223
+ function toSourceString(source: net.UrlLike): string {
224
+ if (typeof source === "string") return source;
225
+ return source instanceof URL ? source.toString() : source.url;
226
+ }
227
+
228
+ /** True when a source selects the built-in Databricks AI Tools skill set. */
229
+ function isAiToolsSource(source: string): boolean {
230
+ return source.trim().toLowerCase() === AITOOLS_SOURCE;
166
231
  }
167
232
 
168
233
  /**
169
234
  * Materialize every configured remote skill source at app startup.
170
235
  *
171
236
  * Writes each resolved `SKILL.md` tree to the Databricks user Assistant skills
172
- * folder when a writable workspace is available, else to a local temp dir
173
- * returned in {@link ProvisionedRemoteSkills.localSkillPaths}.
237
+ * folder when a writable workspace is available, else under
238
+ * {@link localFS.tmpFS} returned in {@link ProvisionedRemoteSkills.localSkillPaths}.
174
239
  */
175
240
  export async function provisionRemoteSkills(
176
241
  option: RemoteSkillsOption | undefined,
@@ -183,12 +248,17 @@ export async function provisionRemoteSkills(
183
248
  const client = options.client ?? appkit.tryGetExecutionContext()?.client;
184
249
  const databricksBasePath = resolveDatabricksBasePath(options, client);
185
250
  const destination = databricksBasePath
186
- ? new DatabricksWorkspaceFilesystem({ client, basePath: databricksBasePath, readOnly: false })
251
+ ? new DatabricksFileSystem({
252
+ client: client as WorkspaceClient,
253
+ root: databricksBasePath,
254
+ readOnly: false,
255
+ createRoot: true,
256
+ })
187
257
  : undefined;
188
258
 
189
259
  const localSkillPaths: string[] = [];
190
260
  const skillNames: string[] = [];
191
- let staging: string | undefined;
261
+ let staging: LocalFileSystem | undefined;
192
262
 
193
263
  try {
194
264
  const sources = Array.isArray(options.sources) ? options.sources : [options.sources];
@@ -196,17 +266,17 @@ export async function provisionRemoteSkills(
196
266
  const sourceOptions = toSourceOptions(entry);
197
267
  const failOnError = sourceOptions.failOnError ?? failDefault;
198
268
  try {
199
- staging ??= await mkdtemp(join(tmpdir(), "mastra-remote-skills-"));
200
- const stagedDir = await stageSource(sourceOptions, staging, options);
269
+ staging ??= await initializedScratch("mastra-remote-skills");
270
+ const stagedDir = await stageSource(sourceOptions, staging.root, options);
201
271
  const staged = await collectSkillDirs(stagedDir);
202
272
  if (staged.length === 0) {
203
273
  throw new Error(`no SKILL.md found for source "${sourceOptions.source}"`);
204
274
  }
205
275
  if (destination && databricksBasePath) {
206
- await uploadSkillDirs(destination, staged);
276
+ await copySkillDirs(destination, staged);
207
277
  skillNames.push(...staged.map((dir) => dir.name));
208
278
  } else {
209
- const localDir = await persistLocally(staged);
279
+ const localDir = await persistLocally(sourceOptions.source, staged);
210
280
  localSkillPaths.push(localDir);
211
281
  skillNames.push(...staged.map((dir) => dir.name));
212
282
  }
@@ -229,7 +299,9 @@ export async function provisionRemoteSkills(
229
299
  }
230
300
  }
231
301
  } finally {
232
- if (staging) await rm(staging, { recursive: true, force: true }).catch(() => undefined);
302
+ if (staging) {
303
+ await rm(staging.root, { recursive: true, force: true }).catch(() => undefined);
304
+ }
233
305
  }
234
306
 
235
307
  return { localSkillPaths, databricksBasePath, skillNames };
@@ -250,27 +322,96 @@ function resolveDatabricksBasePath(
250
322
  }
251
323
 
252
324
  /**
253
- * Stage one source into a fresh dir under `staging`, preferring the optional
325
+ * Stage one source into a fresh dir under `stagingRoot`, preferring the optional
254
326
  * `skills` CLI and falling back to a direct fetch.
255
327
  */
256
328
  async function stageSource(
257
- sourceOptions: RemoteSkillSourceOptions,
258
- staging: string,
329
+ sourceOptions: NormalizedSource,
330
+ stagingRoot: string,
259
331
  options: ProvisionRemoteSkillsOptions,
260
332
  ): Promise<string> {
261
- const target = await mkdtemp(join(staging, "src-"));
333
+ const target = join(stagingRoot, `src-${hash.id()}`);
334
+ await mkdir(target, { recursive: true });
335
+ // AI Tools resolves from a known repo layout, so it never needs the CLI.
336
+ if (isAiToolsSource(sourceOptions.source)) return stageAiTools(sourceOptions, target, options);
262
337
  const viaCli = await stageViaSkillsCli(sourceOptions, target);
263
338
  if (viaCli) return viaCli;
264
339
  return stageViaFetch(sourceOptions, target, options);
265
340
  }
266
341
 
342
+ /**
343
+ * Materialize the Databricks AI Tools skills into `target`.
344
+ *
345
+ * Reads the repo's own generated `manifest.json` (which names each skill's
346
+ * files and whether it lives under `skills/` or `experimental/`) and downloads
347
+ * them - the same thing `databricks aitools install` does, minus the CLI and
348
+ * minus any Databricks auth, since the repo is public.
349
+ */
350
+ async function stageAiTools(
351
+ sourceOptions: NormalizedSource,
352
+ target: string,
353
+ options: ProvisionRemoteSkillsOptions,
354
+ ): Promise<string> {
355
+ const maxBytes = resolveMaxBytes(sourceOptions, options);
356
+ const ref = string.trimToNull(sourceOptions.ref) ?? AITOOLS_REF;
357
+ const manifest = json.parse(
358
+ (await download(aiToolsRawUrl(ref, "manifest.json"), maxBytes)).toString("utf8"),
359
+ undefined,
360
+ ) as AiToolsManifest | undefined;
361
+
362
+ const wanted = new Set(string.parseList(sourceOptions.skills));
363
+ const selected = Object.entries(manifest?.skills ?? {}).filter(([name, entry]) =>
364
+ wanted.size > 0
365
+ ? wanted.has(name)
366
+ : entry.repo_dir !== "experimental" || sourceOptions.experimental === true,
367
+ );
368
+ if (selected.length === 0) {
369
+ throw new Error(`no matching skills in ${AITOOLS_REPO}@${ref}`);
370
+ }
371
+
372
+ const files = selected.flatMap(([name, entry]) =>
373
+ (entry.files ?? []).map((file) => ({
374
+ url: aiToolsRawUrl(ref, `${entry.repo_dir ?? "skills"}/${name}/${file}`),
375
+ path: join(target, name, ...file.split("/")),
376
+ })),
377
+ );
378
+ await mapConcurrent(files, AITOOLS_CONCURRENCY, async (item) => {
379
+ const body = await download(item.url, maxBytes);
380
+ await mkdir(dirname(item.path), { recursive: true });
381
+ await writeFile(item.path, body);
382
+ });
383
+
384
+ logger.debug("aitools:staged", { ref, skills: selected.length, files: files.length });
385
+ return target;
386
+ }
387
+
388
+ /** Raw-content URL for a path in the AI Tools repo at `ref`. */
389
+ function aiToolsRawUrl(ref: string, path: string): string {
390
+ return `https://raw.githubusercontent.com/${AITOOLS_REPO}/${ref}/${path}`;
391
+ }
392
+
393
+ /** Run `worker` over `items`, at most `limit` in flight. */
394
+ async function mapConcurrent<T>(
395
+ items: readonly T[],
396
+ limit: number,
397
+ worker: (item: T) => Promise<void>,
398
+ ): Promise<void> {
399
+ let cursor = 0;
400
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
401
+ while (cursor < items.length) {
402
+ await worker(items[cursor++]!);
403
+ }
404
+ });
405
+ await Promise.all(runners);
406
+ }
407
+
267
408
  /**
268
409
  * Copy a source into `target` with the `skills` CLI when it is installed.
269
410
  * Returns the dir holding the copied `SKILL.md` trees, or `undefined` when the
270
411
  * CLI is absent (so the caller can fall back to a fetch).
271
412
  */
272
413
  async function stageViaSkillsCli(
273
- sourceOptions: RemoteSkillSourceOptions,
414
+ sourceOptions: NormalizedSource,
274
415
  target: string,
275
416
  ): Promise<string | undefined> {
276
417
  const cli = await resolveSkillsCli();
@@ -290,7 +431,7 @@ async function stageViaSkillsCli(
290
431
  }
291
432
  if (string.parseList(sourceOptions.skills).length === 0) args.push("--skill", "*");
292
433
 
293
- const result = await spawn(cli.command, args, {
434
+ const result = await exec.spawn(cli.command, args, {
294
435
  cwd: target,
295
436
  stdout: "capture",
296
437
  stderr: "capture",
@@ -326,7 +467,7 @@ async function resolveSkillsCli(): Promise<{ command: string; args: string[] } |
326
467
  * Used when the `skills` CLI isn't installed; supports a direct `SKILL.md` URL.
327
468
  */
328
469
  async function stageViaFetch(
329
- sourceOptions: RemoteSkillSourceOptions,
470
+ sourceOptions: NormalizedSource,
330
471
  target: string,
331
472
  options: ProvisionRemoteSkillsOptions,
332
473
  ): Promise<string> {
@@ -336,18 +477,7 @@ async function stageViaFetch(
336
477
  `source "${sourceOptions.source}" is not a URL and the optional "skills" package is not installed`,
337
478
  );
338
479
  }
339
- const maxBytes =
340
- sourceOptions.maxDownloadBytes ?? options.maxDownloadBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
341
- const response = await fetch(url.toString());
342
- if (!response.ok) {
343
- throw new Error(
344
- `download failed: ${response.status} ${response.statusText} (${url.toString()})`,
345
- );
346
- }
347
- const buffer = Buffer.from(await response.arrayBuffer());
348
- if (buffer.byteLength > maxBytes) {
349
- throw new Error(`download exceeds ${maxBytes} bytes (${url.toString()})`);
350
- }
480
+ const buffer = await download(url.toString(), resolveMaxBytes(sourceOptions, options));
351
481
  const name = string.toSlug(deriveSkillName(url.toString())) || "remote-skill";
352
482
  const skillDir = join(target, name);
353
483
  await mkdir(skillDir, { recursive: true });
@@ -355,6 +485,27 @@ async function stageViaFetch(
355
485
  return target;
356
486
  }
357
487
 
488
+ /** The effective download ceiling: per-source, then top-level, then the default. */
489
+ function resolveMaxBytes(
490
+ sourceOptions: NormalizedSource,
491
+ options: ProvisionRemoteSkillsOptions,
492
+ ): number {
493
+ return sourceOptions.maxDownloadBytes ?? options.maxDownloadBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
494
+ }
495
+
496
+ /** GET a URL, failing loudly on a non-2xx status or an oversized body. */
497
+ async function download(url: string, maxBytes: number): Promise<Buffer> {
498
+ const response = await fetch(url);
499
+ if (!response.ok) {
500
+ throw new Error(`download failed: ${response.status} ${response.statusText} (${url})`);
501
+ }
502
+ const buffer = Buffer.from(await response.arrayBuffer());
503
+ if (buffer.byteLength > maxBytes) {
504
+ throw new Error(`download exceeds ${maxBytes} bytes (${url})`);
505
+ }
506
+ return buffer;
507
+ }
508
+
358
509
  /** Derive a skill directory name from a download URL's last path segment. */
359
510
  function deriveSkillName(urlString: string): string {
360
511
  const url = net.urlBuilder(urlString);
@@ -389,28 +540,47 @@ async function collectSkillDirs(root: string): Promise<StagedSkillDir[]> {
389
540
  return dirs;
390
541
  }
391
542
 
392
- /** Upload each staged skill directory into the Databricks destination tree. */
393
- async function uploadSkillDirs(
394
- destination: DatabricksWorkspaceFilesystem,
395
- dirs: StagedSkillDir[],
396
- ): Promise<void> {
397
- await destination.init?.();
543
+ /**
544
+ * Copy every staged skill directory into {@link destination}, preserving the
545
+ * `<skill>/<relative>` layout. The destination is just a {@link FileSystem},
546
+ * so the Databricks Assistant tree and the local fallback share one copier.
547
+ */
548
+ async function copySkillDirs(destination: FileSystem, dirs: StagedSkillDir[]): Promise<void> {
549
+ await destination.init();
398
550
  for (const dir of dirs) {
399
- for (const relative of findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
551
+ for (const relative of find.findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
400
552
  const buffer = await readFile(join(dir.absolutePath, relative));
401
- const remotePath = posix.join("/", dir.name, relative.split(/[\\/]/).join("/"));
402
- await destination.writeFile(remotePath, buffer, { overwrite: true });
553
+ const skillPath = posix.join(dir.name, relative.split(/[\\/]/).join("/"));
554
+ await destination.writeFile(skillPath, buffer, { overwrite: true });
403
555
  }
404
556
  }
405
557
  }
406
558
 
407
- /** Copy staged skill dirs into a persistent local temp dir; return its path. */
408
- async function persistLocally(dirs: StagedSkillDir[]): Promise<string> {
409
- const base = await mkdtemp(join(tmpdir(), "mastra-local-skills-"));
410
- for (const dir of dirs) {
411
- await cp(dir.absolutePath, join(base, dir.name), { recursive: true });
412
- }
413
- return base;
559
+ /**
560
+ * Copy staged skill dirs into a STABLE local tree keyed by the source, and
561
+ * return its root path.
562
+ *
563
+ * A given source resolves to the same content on almost every boot, so each
564
+ * one owns a directory named for its {@link hash.fnvHash} rather than leaving
565
+ * a fresh scratch dir behind per restart. Keying on the source (not the
566
+ * content) is what keeps two different sources from overwriting each other.
567
+ */
568
+ async function persistLocally(source: string, dirs: StagedSkillDir[]): Promise<string> {
569
+ const stable = await localFS.rebuildFS(`${LOCAL_SKILLS_DIR}/${hash.fnvHash(source)}`, (scratch) =>
570
+ copySkillDirs(scratch, dirs),
571
+ );
572
+ return stable.root;
573
+ }
574
+
575
+ /**
576
+ * A scratch filesystem whose root exists on disk, for the paths that hand a
577
+ * real directory to `node:fs` or a child process rather than going through
578
+ * the {@link FileSystem} API.
579
+ */
580
+ async function initializedScratch(prefix: string): Promise<LocalFileSystem> {
581
+ const scratch = localFS.scratchFS(prefix);
582
+ await scratch.init();
583
+ return scratch;
414
584
  }
415
585
 
416
586
  /** Best-effort existence check. */