@dbx-tools/appkit-mastra 0.9.118 → 0.9.120

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
@@ -56,7 +56,7 @@ import {
56
56
  type PluginManifest,
57
57
  type ResourceRequirement,
58
58
  } from "@databricks/appkit";
59
- import { pluginRegistry } from "@dbx-tools/appkit";
59
+ import { lakebaseResolver, pluginRegistry } from "@dbx-tools/appkit";
60
60
  import { modelCatalog } from "@dbx-tools/model";
61
61
  import { asyncUtils, errorUtils, log, object, stringUtils } from "@dbx-tools/shared-core";
62
62
  import {
@@ -98,7 +98,7 @@ import {
98
98
  type MastraIdentityMode,
99
99
  } from "./identity.ts";
100
100
  import { buildMcpServer, type ResolvedMcp } from "./mcp.ts";
101
- import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory.ts";
101
+ import { createMemoryBuilder, needsLakebase } from "./memory.ts";
102
102
  import {
103
103
  flushDirectMlflowTracing,
104
104
  logFeedback,
@@ -124,13 +124,6 @@ import { invalidFields } from "./validation.ts";
124
124
  const GENIE_MANIFEST = pluginRegistry.data(genie).plugin.manifest;
125
125
  const LAKEBASE_MANIFEST = pluginRegistry.data(lakebase).plugin.manifest;
126
126
 
127
- /**
128
- * Budget for draining the memory service-principal pool on shutdown. Well
129
- * inside AppKit's 15s graceful-shutdown window, so a stuck connection can
130
- * never be what holds the process open.
131
- */
132
- const POOL_DRAIN_TIMEOUT_MS = 5_000;
133
-
134
127
  /** Ceiling on the `?timeoutMs=` long-poll budget a client may request. */
135
128
  const MAX_EMBED_POLL_TIMEOUT_MS = 5 * 60_000;
136
129
 
@@ -243,15 +236,6 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
243
236
  * boot rather than the first request, and so every request agrees.
244
237
  */
245
238
  private identityMode: MastraIdentityMode = "user";
246
- /**
247
- * Dedicated service-principal Lakebase pool backing Mastra memory /
248
- * storage. Built once in {@link buildAgentAndServer} (outside any
249
- * `asUser` scope, so it never inherits a request's OBO identity) and
250
- * drained in {@link shutdown}. `null` until setup runs or when
251
- * Lakebase isn't needed.
252
- */
253
- private servicePrincipalPool: Pool | null = null;
254
-
255
239
  override async setup(): Promise<void> {
256
240
  // Resolve the identity mode up front so an invalid `genieIdentity` fails the
257
241
  // app boot with a clear message rather than 500-ing the first chat request.
@@ -298,44 +282,19 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
298
282
  if (this.config.memory === undefined) this.config.memory = true;
299
283
  }
300
284
 
301
- /**
302
- * Drain the memory service-principal pool. Idempotent: the handle is
303
- * cleared before the drain starts, so a second call is a no-op and a
304
- * later `setup()` rebuilds the pool. Bounded by
305
- * {@link POOL_DRAIN_TIMEOUT_MS} to stay well inside the 15s graceful
306
- * shutdown budget.
307
- */
285
+ /** Flush pending direct MLflow traces during graceful shutdown. */
308
286
  async shutdown(): Promise<void> {
309
287
  try {
310
288
  await flushDirectMlflowTracing();
311
289
  } catch (err) {
312
290
  this.logger.warn("error flushing direct MLflow traces", {
313
- error: errorUtils.errorMessage(err),
314
- });
315
- }
316
- const pool = this.servicePrincipalPool;
317
- if (!pool) return;
318
- this.servicePrincipalPool = null;
319
- this.logger.info("closing memory SP pool");
320
- // The budget timer is cancelled on the way out so a drain that finished
321
- // early cannot hold the event loop open for the rest of the window.
322
- const budget = new AbortController();
323
- try {
324
- await Promise.race([pool.end(), asyncUtils.sleep(POOL_DRAIN_TIMEOUT_MS, budget.signal)]);
325
- } catch (err) {
326
- this.logger.error("error closing memory SP pool", {
327
- error: errorUtils.errorMessage(err),
291
+ error: errorUtils.toError(err),
328
292
  });
329
- } finally {
330
- budget.abort();
331
293
  }
332
294
  }
333
295
 
334
296
  /**
335
- * Abort in-flight work. AppKit's graceful shutdown calls this hook
336
- * synchronously and never awaits {@link shutdown}, so the pool drain is
337
- * started here too; it cannot be awaited from a `void` hook, and
338
- * {@link shutdown} is idempotent so the duplicate call is free.
297
+ * Abort in-flight work and start the asynchronous trace flush.
339
298
  */
340
299
  override abortActiveOperations(): void {
341
300
  super.abortActiveOperations();
@@ -719,7 +678,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
719
678
  // the upstream status. Log and degrade.
720
679
  this.logger.warn("suggestions:error", {
721
680
  status: result.status,
722
- error: result.message,
681
+ error: errorUtils.toError(result.message),
723
682
  });
724
683
  res.json({ questions: [] });
725
684
  return;
@@ -765,7 +724,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
765
724
  if (!parsed.success) {
766
725
  // A Zod issue string quotes the received body back, so only the
767
726
  // offending field names travel; the full issue list is logged.
768
- this.logger.warn("feedback:invalid", { error: parsed.error.message });
727
+ this.logger.warn("feedback:invalid", { error: parsed.error });
769
728
  res.status(400).json({
770
729
  ok: false,
771
730
  error: INVALID_FEEDBACK_MESSAGE,
@@ -856,7 +815,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
856
815
  ): void {
857
816
  this.logger.warn(`${operation}:error`, {
858
817
  status: result.status,
859
- error: result.message,
818
+ error: errorUtils.toError(result.message),
860
819
  });
861
820
  if (res.headersSent) return;
862
821
  res.status(result.status).json({ error: clientMessage });
@@ -1074,32 +1033,18 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
1074
1033
  if (result.ok) return result.data;
1075
1034
  this.logger.warn("listModels:error", {
1076
1035
  status: result.status,
1077
- error: result.message,
1036
+ error: errorUtils.toError(result.message),
1078
1037
  });
1079
1038
  throw new ExecutionError(MODEL_CATALOGUE_FAILED_MESSAGE);
1080
1039
  }
1081
1040
 
1082
1041
  private async buildAgentAndServer(): Promise<void> {
1083
- // Per-agent memory factory. When any storage / memory setting needs
1084
- // Postgres, stand up a dedicated service-principal pool first so
1085
- // memory acts as the app SP (owner of the `mastra_*` schemas),
1086
- // never the per-request OBO identity the chat turn runs under.
1087
- // `getPgConfig()` is read here, outside any `asUser` scope, so it
1088
- // returns the SP connection target + token refresh plus any
1089
- // `lakebase({ pool })` overrides; `require` turns a missing
1090
- // sibling into a clear wiring error. The builder caches the shared
1091
- // `PgVector` singleton so registering N agents stays cheap. See
1092
- // `./memory.js`.
1093
- if (needsLakebase(this.config)) {
1094
- const spPgConfig = pluginRegistry
1095
- .require(this.context, lakebase, this.config)
1096
- .exports()
1097
- .getPgConfig();
1098
- this.servicePrincipalPool = await createServicePrincipalPool(spPgConfig);
1099
- }
1100
- const memoryBuilder = this.servicePrincipalPool
1101
- ? createMemoryBuilder(this.config, this.servicePrincipalPool)
1042
+ const memoryPool = needsLakebase(this.config)
1043
+ ? (lakebaseResolver.captureServicePrincipalPool(
1044
+ pluginRegistry.require(this.context, lakebase, this.config).exports().pool,
1045
+ ) as Pool)
1102
1046
  : undefined;
1047
+ const memoryBuilder = memoryPool ? createMemoryBuilder(this.config, memoryPool) : undefined;
1103
1048
 
1104
1049
  // Build every agent declared in `config.agents` (or the built-in
1105
1050
  // fallback when none are declared). Each agent's `model` resolves
@@ -1126,7 +1071,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
1126
1071
  context: this.context,
1127
1072
  memoryBuilder,
1128
1073
  log: this.logger,
1129
- extraSkillMounts: provisioned.localSkillMounts,
1074
+ extraSkillMounts: [...(this.config.localSkillMounts ?? []), ...provisioned.localSkillMounts],
1130
1075
  });
1131
1076
 
1132
1077
  // `mastra.server.apiRoutes` is only honored by Mastra's standalone
@@ -5,7 +5,7 @@
5
5
  * {@link AITOOLS_SOURCE} constant, a GitHub `owner/repo`, any git / GitLab URL,
6
6
  * or a direct download URL - and optional per-source policy.
7
7
  * {@link provisionRemoteSkills} materializes every source into a local
8
- * `SKILL.md` tree at app boot and returns read-only backing directories plus
8
+ * `SKILL.md` tree at app boot and returns read-only filesystem backings plus
9
9
  * their stable virtual workspace mounts.
10
10
  *
11
11
  * Resolution per source, in order:
@@ -26,25 +26,16 @@
26
26
  * source (or the top-level call) sets `failOnError: false`, in which case it is
27
27
  * logged and skipped so one bad source never takes the app down.
28
28
  *
29
- * Every provisioned tree carries a {@link METADATA_FILE} recording when each
30
- * source was last downloaded, and a source is only re-downloaded once that
31
- * record is older than {@link DEFAULT_REFRESH_TTL_MS} (seven days). This runs at app
32
- * BOOT, so without it a container that restarts a dozen times an hour re-pulls
33
- * the whole AI Tools set a dozen times for content that changes rarely. The
34
- * record travels WITH the tree rather than in process memory, so the reuse
35
- * survives a restart - which is the only way it helps a Databricks App at all.
36
- * Pass `refreshTtlMs: 0` to download on every boot.
29
+ * AI Tools uses the shared `@dbx-tools/cache` project cache with a one-day TTL,
30
+ * then hydrates one process-memory filesystem mounted at
31
+ * {@link AITOOLS_SKILLS_MOUNT} for every default workspace. Other sources keep
32
+ * a {@link METADATA_FILE} beside their provisioned tree and default to a
33
+ * seven-day refresh window. Pass `refreshTtlMs: 0` to download on every boot.
37
34
  *
38
- * The default destination is the Databricks workspace Assistant skills tree
39
- * (`/Workspace/.assistant/skills`), so provisioned skills persist across restarts and are
40
- * discovered by the built-in Assistant-skills mount. Pass `userEmail` (or an
41
- * explicit `databricksBasePath`) to target `/Workspace/Users/<email>/.assistant/skills`
42
- * instead. When no Databricks client is resolvable at startup - or the resolved
43
- * identity cannot WRITE the destination, which is the normal case for a
44
- * Databricks App service principal against the admin-owned shared tree (see
45
- * {@link openWritableWorkspace}) - the tree is written under
46
- * {@link localFS.tmpFS} and exposed through a stable read-only virtual mount
47
- * for the current process. Storage location degrades; the skills themselves do not.
35
+ * For non-AI-Tools sources, the default destination is the Databricks workspace
36
+ * Assistant skills tree (`/Workspace/.assistant/skills`). Pass `userEmail` or
37
+ * `databricksBasePath` to select another destination. When it is unavailable,
38
+ * the tree is exposed through a stable read-only local mount instead.
48
39
  *
49
40
  * @module
50
41
  */
@@ -55,6 +46,7 @@ import { dirname, join, posix } from "node:path";
55
46
  import type { WorkspaceClient } from "@databricks/appkit";
56
47
  import { appkit } from "@dbx-tools/appkit";
57
48
  import type { WorkspaceClientLike } from "@dbx-tools/appkit";
49
+ import { flushCache, getOrSet, projectCache } from "@dbx-tools/cache/cache";
58
50
  import { exec } from "@dbx-tools/core";
59
51
  import { DatabricksFileSystem, workspaceClient } from "@dbx-tools/databricks";
60
52
  import { localFS, type LocalFileSystem } from "@dbx-tools/fs";
@@ -70,7 +62,7 @@ import {
70
62
  stringUtils,
71
63
  } from "@dbx-tools/shared-core";
72
64
  import type { OneOrMany } from "@dbx-tools/shared-core";
73
- import type { FileSystem } from "@dbx-tools/shared-fs";
65
+ import { MemoryFileSystem, type FileSystem } from "@dbx-tools/shared-fs";
74
66
 
75
67
  import { ORGANIZATION_SKILLS_PATH, personalSkillsPath } from "./skill-paths.ts";
76
68
 
@@ -116,9 +108,17 @@ const METADATA_FILE = ".metadata.json";
116
108
  /** Shape marker for {@link METADATA_FILE}; an unknown version is ignored, not read. */
117
109
  const METADATA_VERSION = 1;
118
110
 
119
- /** How long a provisioned source is reused before being downloaded again. */
111
+ /** How long a general provisioned source is reused before being downloaded again. */
120
112
  const DEFAULT_REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
121
113
 
114
+ /** How long the shared Databricks AI Tools snapshot is reused. */
115
+ export const AITOOLS_REFRESH_TTL_MS = 24 * 60 * 60 * 1000;
116
+
117
+ const aiToolsCache = projectCache("mastra-aitools", {
118
+ ttl: AITOOLS_REFRESH_TTL_MS,
119
+ maxEntries: 32,
120
+ });
121
+
122
122
  /* ------------------------------ AI Tools ------------------------------ */
123
123
 
124
124
  /**
@@ -203,14 +203,25 @@ export type RemoteSkillsOption =
203
203
  /** A source entry with its `source` flattened to a plain string. */
204
204
  type NormalizedSource = Omit<RemoteSkillSourceOptions, "source"> & { source: string };
205
205
 
206
- /** Read-only local backing storage exposed at a stable virtual workspace path. */
207
- export interface LocalSkillMount {
206
+ /** Read-only local directory exposed at a stable virtual workspace path. */
207
+ export interface LocalSkillDirectoryMount {
208
208
  /** Physical local directory containing the provisioned `SKILL.md` tree. */
209
209
  root: string;
210
210
  /** Virtual path agents see inside the composed Mastra workspace. */
211
211
  mountPath: string;
212
212
  }
213
213
 
214
+ /** Read-only process-local filesystem exposed at a stable virtual workspace path. */
215
+ export interface MemorySkillMount {
216
+ /** Process-shared filesystem containing the provisioned `SKILL.md` tree. */
217
+ filesystem: FileSystem;
218
+ /** Virtual path agents see inside the composed Mastra workspace. */
219
+ mountPath: string;
220
+ }
221
+
222
+ /** Local skill backing accepted by every auto-created workspace. */
223
+ export type LocalSkillMount = LocalSkillDirectoryMount | MemorySkillMount;
224
+
214
225
  /** Top-level remote-skills provisioning options. */
215
226
  export interface ProvisionRemoteSkillsOptions {
216
227
  /** Sources to materialize at startup. */
@@ -233,8 +244,8 @@ export interface ProvisionRemoteSkillsOptions {
233
244
  maxDownloadBytes?: number;
234
245
  /**
235
246
  * How long a provisioned tree is reused before its source is downloaded
236
- * again, in milliseconds. Defaults to seven days; `0` re-downloads on every
237
- * boot. A per-source `refreshTtlMs` wins over this.
247
+ * again, in milliseconds. AI Tools defaults to one day and other sources to
248
+ * seven days; `0` re-downloads on every boot. A per-source `refreshTtlMs` wins.
238
249
  */
239
250
  refreshTtlMs?: number;
240
251
  }
@@ -242,9 +253,10 @@ export interface ProvisionRemoteSkillsOptions {
242
253
  /** What {@link provisionRemoteSkills} resolved. */
243
254
  export interface ProvisionedRemoteSkills {
244
255
  /**
245
- * Extra read-only LOCAL skill mounts to hand Mastra when a source couldn't
246
- * be written to Databricks. Physical temp/cache paths remain hidden behind
247
- * stable virtual workspace paths.
256
+ * Extra read-only local or process-memory skill mounts to hand Mastra. AI
257
+ * Tools always uses process memory; other sources use this fallback when
258
+ * they could not be written to Databricks. Physical paths remain hidden
259
+ * behind stable virtual workspace paths.
248
260
  */
249
261
  localSkillMounts: LocalSkillMount[];
250
262
  /** Absolute Databricks destination each source was written to, if any. */
@@ -257,8 +269,8 @@ export interface ProvisionedRemoteSkills {
257
269
  }
258
270
 
259
271
  /**
260
- * What one source's last download left behind, as recorded in
261
- * {@link METADATA_FILE}.
272
+ * Metadata for one resolved source snapshot. General remote sources store it
273
+ * in {@link METADATA_FILE}; AI Tools stores it with the cache-package value.
262
274
  */
263
275
  export interface RemoteSkillCacheEntry {
264
276
  /** The source string this entry was downloaded from, for readability. */
@@ -281,6 +293,20 @@ export interface RemoteSkillsMetadata {
281
293
  sources: Record<string, RemoteSkillCacheEntry>;
282
294
  }
283
295
 
296
+ interface CachedSkillFile {
297
+ path: string;
298
+ content: string;
299
+ }
300
+
301
+ interface CachedAiToolsTree {
302
+ files: CachedSkillFile[];
303
+ record: RemoteSkillCacheEntry;
304
+ }
305
+
306
+ interface ResolvedAiToolsTree extends CachedAiToolsTree {
307
+ cached: boolean;
308
+ }
309
+
284
310
  /* ------------------------------- helpers ------------------------------- */
285
311
 
286
312
  /** Normalize the `remoteSkills` option into a flat options bag. */
@@ -381,12 +407,16 @@ function remoteSourceIdentifier(source: string): string {
381
407
  return `${url.hostname}-${path}`;
382
408
  }
383
409
 
384
- /** The effective reuse window: per-source, then top-level, then seven days. */
410
+ /** The effective reuse window: per-source, top-level, then the source default. */
385
411
  function resolveRefreshTtl(
386
412
  sourceOptions: NormalizedSource,
387
413
  options: ProvisionRemoteSkillsOptions,
388
414
  ): number {
389
- return sourceOptions.refreshTtlMs ?? options.refreshTtlMs ?? DEFAULT_REFRESH_TTL_MS;
415
+ return (
416
+ sourceOptions.refreshTtlMs ??
417
+ options.refreshTtlMs ??
418
+ (isAiToolsSource(sourceOptions.source) ? AITOOLS_REFRESH_TTL_MS : DEFAULT_REFRESH_TTL_MS)
419
+ );
390
420
  }
391
421
 
392
422
  /**
@@ -406,7 +436,7 @@ async function readMetadata(fs: FileSystem): Promise<RemoteSkillsMetadata | unde
406
436
  if (!object.isRecord(parsed) || parsed.version !== METADATA_VERSION) return undefined;
407
437
  return object.isRecord(parsed.sources) ? parsed : undefined;
408
438
  } catch (err) {
409
- logger.debug("cache:unreadable", { error: errorUtils.errorMessage(err) });
439
+ logger.debug("cache:unreadable", { error: errorUtils.toError(err) });
410
440
  return undefined;
411
441
  }
412
442
  }
@@ -465,12 +495,17 @@ export async function provisionRemoteSkills(
465
495
  if (!options) return empty;
466
496
 
467
497
  const failDefault = options.failOnError !== false;
498
+ const sources = (Array.isArray(options.sources) ? options.sources : [options.sources]).map(
499
+ toSourceOptions,
500
+ );
468
501
  const client = options.client ?? appkit.tryGetExecutionContext()?.client;
469
502
  // Probed rather than assumed: an identity that cannot write the workspace
470
503
  // tree falls back to the local one instead of failing startup. The probe also
471
504
  // creates the root, which the first cache READ needs - `copySkillDirs` would
472
505
  // otherwise be the first thing to create it, which is too late.
473
- const requestedBasePath = resolveDatabricksBasePath(options, client);
506
+ const requestedBasePath = sources.some((source) => !isAiToolsSource(source.source))
507
+ ? resolveDatabricksBasePath(options, client)
508
+ : undefined;
474
509
  const destination = requestedBasePath
475
510
  ? await openWritableWorkspace(requestedBasePath, client as WorkspaceClient)
476
511
  : undefined;
@@ -481,15 +516,30 @@ export async function provisionRemoteSkills(
481
516
  let staging: LocalFileSystem | undefined;
482
517
 
483
518
  try {
484
- const sources = Array.isArray(options.sources) ? options.sources : [options.sources];
485
- for (const entry of sources) {
486
- const sourceOptions = toSourceOptions(entry);
519
+ for (const sourceOptions of sources) {
487
520
  const failOnError = sourceOptions.failOnError ?? failDefault;
488
521
  try {
489
522
  // Where this source's bookkeeping lives: the shared Databricks tree, or
490
523
  // the source's own stable local dir.
491
524
  const key = cacheKey(sourceOptions);
492
525
  const mountPath = localSkillMountPath(sourceOptions, key);
526
+ if (isAiToolsSource(sourceOptions.source)) {
527
+ staging ??= await initializedScratch("mastra-remote-skills");
528
+ const tree = await cachedAiToolsTree(sourceOptions, staging.root, options, key);
529
+ localSkillMounts.push({
530
+ filesystem: await memorySkillTree(tree.files, `aitools-${key}`),
531
+ mountPath,
532
+ });
533
+ skillNames.push(...tree.record.skills);
534
+ logger.info("remote skill ready", {
535
+ source: sourceOptions.source,
536
+ destination: mountPath,
537
+ downloadedAt: tree.record.downloadedAt,
538
+ skills: tree.record.skills,
539
+ cached: tree.cached,
540
+ });
541
+ continue;
542
+ }
493
543
  const cacheFS = destination ?? localSkillsFS(key);
494
544
  const metadata = await readMetadata(cacheFS);
495
545
  const cached = metadata?.sources[key];
@@ -549,7 +599,7 @@ export async function provisionRemoteSkills(
549
599
  }
550
600
  logger.warn("source:skipped", {
551
601
  source: sourceOptions.source,
552
- error: errorUtils.errorMessage(err),
602
+ error: errorUtils.toError(err),
553
603
  });
554
604
  }
555
605
  }
@@ -563,17 +613,90 @@ export async function provisionRemoteSkills(
563
613
  return { localSkillMounts, databricksBasePath, skillNames };
564
614
  }
565
615
 
616
+ async function cachedAiToolsTree(
617
+ sourceOptions: NormalizedSource,
618
+ stagingRoot: string,
619
+ options: ProvisionRemoteSkillsOptions,
620
+ key: string,
621
+ ): Promise<ResolvedAiToolsTree> {
622
+ const ttl = resolveRefreshTtl(sourceOptions, options);
623
+ let loaded = false;
624
+ const load = async (): Promise<CachedAiToolsTree> => {
625
+ loaded = true;
626
+ const stagedDir = await stageAiTools(
627
+ sourceOptions,
628
+ join(stagingRoot, `aitools-${hash.id()}`),
629
+ options,
630
+ );
631
+ const staged = await collectSkillDirs(stagedDir);
632
+ if (staged.length === 0) {
633
+ throw new Error(`no SKILL.md found for source "${sourceOptions.source}"`);
634
+ }
635
+ const policy = cachePolicy(sourceOptions);
636
+ return {
637
+ files: await serializeSkillDirs(staged),
638
+ record: {
639
+ source: sourceOptions.source,
640
+ downloadedAt: new Date().toISOString(),
641
+ skills: staged.map((dir) => dir.name),
642
+ ...(policy ? { policy } : {}),
643
+ },
644
+ };
645
+ };
646
+ const tree =
647
+ ttl <= 0
648
+ ? await load()
649
+ : await getOrSet(aiToolsCache, key, load, {
650
+ ttl,
651
+ throwErrors: true,
652
+ });
653
+ if (!tree) throw new Error(`AI Tools cache returned no tree for ${key}`);
654
+ await flushCache(aiToolsCache);
655
+ return { ...tree, cached: !loaded };
656
+ }
657
+
658
+ async function serializeSkillDirs(dirs: readonly StagedSkillDir[]): Promise<CachedSkillFile[]> {
659
+ const files: CachedSkillFile[] = [];
660
+ for (const dir of dirs) {
661
+ for await (const relative of find.findFiles("**/*", {
662
+ cwd: dir.absolutePath,
663
+ nodir: true,
664
+ })) {
665
+ files.push({
666
+ path: posix.join(dir.name, relative.split(/[\\/]/).join("/")),
667
+ content: (await readFile(join(dir.absolutePath, relative))).toString("base64"),
668
+ });
669
+ }
670
+ }
671
+ return files;
672
+ }
673
+
674
+ async function memorySkillTree(
675
+ files: readonly CachedSkillFile[],
676
+ id: string,
677
+ ): Promise<MemoryFileSystem> {
678
+ const filesystem = new MemoryFileSystem({ id, root: "/" });
679
+ await filesystem.init();
680
+ for (const file of files) {
681
+ await filesystem.writeFile(file.path, Buffer.from(file.content, "base64"), {
682
+ overwrite: true,
683
+ });
684
+ }
685
+ return filesystem;
686
+ }
687
+
566
688
  /** Fail before workspace composition when two sources claim one virtual mount. */
567
689
  function assertUniqueLocalSkillMounts(mounts: readonly LocalSkillMount[]): void {
568
690
  const rootsByMount = new Map<string, string>();
569
691
  for (const mount of mounts) {
692
+ const identity = "root" in mount ? mount.root : mount.filesystem.id;
570
693
  const previousRoot = rootsByMount.get(mount.mountPath);
571
- if (previousRoot && previousRoot !== mount.root) {
694
+ if (previousRoot && previousRoot !== identity) {
572
695
  throw new Error(
573
696
  `multiple remote skill sources resolve to virtual mount "${mount.mountPath}"; give each source a unique name`,
574
697
  );
575
698
  }
576
- rootsByMount.set(mount.mountPath, mount.root);
699
+ rootsByMount.set(mount.mountPath, identity);
577
700
  }
578
701
  }
579
702
 
@@ -632,7 +755,7 @@ async function openWritableWorkspace(
632
755
  } catch (err) {
633
756
  logger.warn("destination:unwritable", {
634
757
  destination: basePath,
635
- error: errorUtils.errorMessage(err),
758
+ error: errorUtils.toError(err),
636
759
  });
637
760
  return undefined;
638
761
  }
@@ -876,7 +999,10 @@ async function collectSkillDirs(root: string): Promise<StagedSkillDir[]> {
876
999
  async function copySkillDirs(destination: FileSystem, dirs: StagedSkillDir[]): Promise<void> {
877
1000
  await destination.init();
878
1001
  for (const dir of dirs) {
879
- for (const relative of find.findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
1002
+ for await (const relative of find.findFiles("**/*", {
1003
+ cwd: dir.absolutePath,
1004
+ nodir: true,
1005
+ })) {
880
1006
  const buffer = await readFile(join(dir.absolutePath, relative));
881
1007
  const skillPath = posix.join(dir.name, relative.split(/[\\/]/).join("/"));
882
1008
  await destination.writeFile(skillPath, buffer, { overwrite: true });
package/src/server.ts CHANGED
@@ -484,7 +484,7 @@ export function attachStaleResumeRecovery(app: express.Express): void {
484
484
  }
485
485
  logger.warn("ignored stale mastra resume", {
486
486
  path: req.path,
487
- error: errorUtils.errorMessage(error),
487
+ error: errorUtils.toError(error),
488
488
  });
489
489
  if (res.headersSent) {
490
490
  res.end();