@dbx-tools/appkit-mastra 0.6.5 → 0.6.7
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/README.md +54 -0
- package/index.ts +3 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -1
- package/lib/src/agents.d.ts +6 -0
- package/lib/src/agents.js +25 -4
- package/lib/src/config.d.ts +28 -0
- package/lib/src/config.js +5 -1
- package/lib/src/plugin.js +16 -1
- package/lib/src/remote-skills.d.ts +109 -0
- package/lib/src/remote-skills.js +302 -0
- package/lib/src/workspaces.d.ts +8 -0
- package/lib/src/workspaces.js +11 -5
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +19 -9
- package/src/agents.ts +32 -3
- package/src/config.ts +33 -0
- package/src/plugin.ts +16 -0
- package/src/remote-skills.ts +424 -0
- package/src/workspaces.ts +21 -4
package/src/agents.ts
CHANGED
|
@@ -153,10 +153,29 @@ function deriveToolId(description: string): string {
|
|
|
153
153
|
* type inference and to match the AppKit API surface.
|
|
154
154
|
*/
|
|
155
155
|
export function createAgent<T extends MastraAgentDefinition>(def: T): T {
|
|
156
|
-
|
|
156
|
+
if (def.workspace) return { ...def };
|
|
157
|
+
const workspace = createWorkspace();
|
|
158
|
+
markDefaultWorkspace(workspace);
|
|
157
159
|
return { ...def, workspace };
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Brand for a {@link Workspace} that `createAgent` built with no caller
|
|
164
|
+
* options, so {@link buildAgents} may rebuild it with startup-provisioned
|
|
165
|
+
* `extraSkillPaths` (a caller-supplied workspace is never touched).
|
|
166
|
+
*/
|
|
167
|
+
const DEFAULT_WORKSPACE = Symbol.for("dbx-tools/appkit-mastra/default-workspace");
|
|
168
|
+
|
|
169
|
+
/** Brand `workspace` as the auto-created default. */
|
|
170
|
+
function markDefaultWorkspace(workspace: Workspace): void {
|
|
171
|
+
(workspace as unknown as Record<symbol, boolean>)[DEFAULT_WORKSPACE] = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether `workspace` was auto-created by {@link createAgent}. */
|
|
175
|
+
function isDefaultWorkspace(workspace: Workspace | undefined): boolean {
|
|
176
|
+
return Boolean(workspace && (workspace as unknown as Record<symbol, boolean>)[DEFAULT_WORKSPACE]);
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
/**
|
|
161
180
|
* Filter / rename options accepted by every plugin's `.toolkit()`
|
|
162
181
|
* method. Mirrors AppKit's `ToolkitOptions` verbatim so options pass
|
|
@@ -449,8 +468,14 @@ export async function buildAgents(opts: {
|
|
|
449
468
|
context: plugin.PluginContextLike | undefined;
|
|
450
469
|
memoryBuilder?: MemoryBuilder;
|
|
451
470
|
log: log.Logger;
|
|
471
|
+
/**
|
|
472
|
+
* Local skill scan paths (remote skills provisioned to a temp dir at
|
|
473
|
+
* startup) folded into every agent that uses the auto-created default
|
|
474
|
+
* workspace.
|
|
475
|
+
*/
|
|
476
|
+
extraSkillPaths?: string[];
|
|
452
477
|
}): Promise<BuiltAgents> {
|
|
453
|
-
const { config, context, memoryBuilder, log } = opts;
|
|
478
|
+
const { config, context, memoryBuilder, log, extraSkillPaths } = opts;
|
|
454
479
|
const definitions = resolveDefinitions(config);
|
|
455
480
|
const ids = Object.keys(definitions);
|
|
456
481
|
const defaultAgentId = config.defaultAgent ?? ids[0] ?? FALLBACK_AGENT_ID;
|
|
@@ -485,7 +510,11 @@ export async function buildAgents(opts: {
|
|
|
485
510
|
|
|
486
511
|
for (const [id, def] of Object.entries(definitions)) {
|
|
487
512
|
const tools = await resolveTools(def.tools, plugins, ambientTools);
|
|
488
|
-
|
|
513
|
+
let workspace = resolveAgentWorkspace(def.workspace);
|
|
514
|
+
if (extraSkillPaths?.length && isDefaultWorkspace(workspace)) {
|
|
515
|
+
workspace = createWorkspace({ extraSkillPaths });
|
|
516
|
+
markDefaultWorkspace(workspace);
|
|
517
|
+
}
|
|
489
518
|
const gated = approvalGatedToolIds(tools);
|
|
490
519
|
if (gated.length > 0) approvalGatedByAgent.push({ agentId: id, toolIds: gated });
|
|
491
520
|
const memory = memoryBuilder?.forAgent(id, def);
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { PgVectorConfig, PostgresStoreConfig } from "@mastra/pg";
|
|
|
25
25
|
import type { MastraAgentDefinition, MastraTools } from "./agents.ts";
|
|
26
26
|
import type { GenieSpacesConfig } from "./genie.ts";
|
|
27
27
|
import type { MastraIdentityMode } from "./identity.ts";
|
|
28
|
+
import type { RemoteSkillsOption } from "./remote-skills.ts";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* `RequestContext` key under which {@link MastraServer} stores the
|
|
@@ -552,6 +553,33 @@ export interface MastraPluginConfig extends BasePluginConfig {
|
|
|
552
553
|
* source.
|
|
553
554
|
*/
|
|
554
555
|
brand?: BrandContext;
|
|
556
|
+
/**
|
|
557
|
+
* Remote Agent-Skill sources materialized once at app startup. A single
|
|
558
|
+
* source, a list, or a `{ sources, failOnError?, ... }` bag.
|
|
559
|
+
*
|
|
560
|
+
* Each source is a GitHub `owner/repo`, a git / GitLab URL, or a direct
|
|
561
|
+
* `SKILL.md` / archive download URL. Resolution prefers the OPTIONAL `skills`
|
|
562
|
+
* npm CLI (a peer dep) which is copied into a temp dir and then persisted;
|
|
563
|
+
* without it, a URL source is fetched directly. A source that resolves
|
|
564
|
+
* through neither fails startup unless `failOnError: false`.
|
|
565
|
+
*
|
|
566
|
+
* Provisioned skills are written to the Databricks user's Assistant skills
|
|
567
|
+
* folder (`/Users/<email>/.assistant/skills`, the "save this as a skill"
|
|
568
|
+
* target) so they persist and are picked up by the built-in Assistant-skills
|
|
569
|
+
* mount. With no writable workspace, they go to a local temp dir instead.
|
|
570
|
+
*
|
|
571
|
+
* @example
|
|
572
|
+
* ```ts
|
|
573
|
+
* mastra({ remoteSkills: "vercel-labs/agent-skills" });
|
|
574
|
+
* mastra({
|
|
575
|
+
* remoteSkills: {
|
|
576
|
+
* sources: ["vercel-labs/agent-skills", "https://example.com/skill.md"],
|
|
577
|
+
* failOnError: false,
|
|
578
|
+
* },
|
|
579
|
+
* });
|
|
580
|
+
* ```
|
|
581
|
+
*/
|
|
582
|
+
remoteSkills?: RemoteSkillsOption;
|
|
555
583
|
}
|
|
556
584
|
|
|
557
585
|
/**
|
|
@@ -570,6 +598,11 @@ export const MASTRA_CONFIG_SCHEMA: ConfigSchema = {
|
|
|
570
598
|
type: "string",
|
|
571
599
|
description: 'Mastra OpenAI-compatible provider id. Defaults to "databricks".',
|
|
572
600
|
},
|
|
601
|
+
remoteSkills: {
|
|
602
|
+
type: ["string", "array", "object"],
|
|
603
|
+
description:
|
|
604
|
+
"Remote Agent-Skill sources provisioned at startup (GitHub owner/repo, git/GitLab URL, or a direct SKILL.md/archive URL). Prefers the optional `skills` npm CLI, else a direct fetch; fails startup on error unless failOnError:false. Written to the Databricks user Assistant skills folder, or a local temp dir when no workspace is writable.",
|
|
605
|
+
},
|
|
573
606
|
storage: {
|
|
574
607
|
type: ["boolean", "object"],
|
|
575
608
|
description:
|
package/src/plugin.ts
CHANGED
|
@@ -96,6 +96,7 @@ import { buildMcpServer, type ResolvedMcp } from "./mcp.ts";
|
|
|
96
96
|
import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory.ts";
|
|
97
97
|
import { logFeedback, resolveFeedbackEnabled } from "./mlflow.ts";
|
|
98
98
|
import { buildObservability } from "./observability.ts";
|
|
99
|
+
import { provisionRemoteSkills } from "./remote-skills.ts";
|
|
99
100
|
import {
|
|
100
101
|
attachRoutePatchMiddleware,
|
|
101
102
|
createRequestContext,
|
|
@@ -996,11 +997,26 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
996
997
|
// distinct user identities; the `asUser(req)` scope around
|
|
997
998
|
// `handleChat` is what lets `getExecutionContext()` return the
|
|
998
999
|
// right user inside the resolver.
|
|
1000
|
+
// Materialize any remote Agent-Skill sources ONCE at startup. Runs
|
|
1001
|
+
// outside a request scope, so it uses the app service principal's client
|
|
1002
|
+
// and defaults to the shared workspace Assistant skills tree; a source
|
|
1003
|
+
// that lands on a local temp dir (no writable workspace) is folded into
|
|
1004
|
+
// every default-workspace agent via `extraSkillPaths`.
|
|
1005
|
+
const provisioned = await provisionRemoteSkills(this.config.remoteSkills);
|
|
1006
|
+
if (provisioned.skillNames.length > 0) {
|
|
1007
|
+
this.logger.info("remote skills provisioned", {
|
|
1008
|
+
skills: provisioned.skillNames,
|
|
1009
|
+
databricksBasePath: provisioned.databricksBasePath,
|
|
1010
|
+
localSkillPaths: provisioned.localSkillPaths.length,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
999
1014
|
this.built = await buildAgents({
|
|
1000
1015
|
config: this.config,
|
|
1001
1016
|
context: this.context,
|
|
1002
1017
|
memoryBuilder,
|
|
1003
1018
|
log: this.logger,
|
|
1019
|
+
extraSkillPaths: provisioned.localSkillPaths,
|
|
1004
1020
|
});
|
|
1005
1021
|
|
|
1006
1022
|
// `mastra.server.apiRoutes` is only honored by Mastra's standalone
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup provisioning of remote Agent-Skill sources for a Mastra workspace.
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* Resolution per source, in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. the OPTIONAL `skills` npm CLI (peer dep): if installed, each source is
|
|
13
|
+
* copied into a staging dir with `skills add <source> --agent <dir> --copy`,
|
|
14
|
+
* which understands every source format the ecosystem does (GitHub
|
|
15
|
+
* shorthand, git URLs, archive/download URLs);
|
|
16
|
+
* 2. otherwise a plain {@link fetch} of the source URL (built with
|
|
17
|
+
* {@link net.urlBuilder}), writing the downloaded `SKILL.md` to a staging
|
|
18
|
+
* dir.
|
|
19
|
+
*
|
|
20
|
+
* A source that resolves through neither path fails app startup, unless the
|
|
21
|
+
* source (or the top-level call) sets `failOnError: false`, in which case it is
|
|
22
|
+
* logged and skipped so one bad source never takes the app down.
|
|
23
|
+
*
|
|
24
|
+
* The default destination is the Databricks workspace Assistant skills tree
|
|
25
|
+
* (`/Workspace/.assistant/skills`, the same tree a "save this as a skill"
|
|
26
|
+
* action writes to), so provisioned skills persist across restarts and are
|
|
27
|
+
* discovered by the built-in Assistant-skills mount. Pass `userEmail` (or an
|
|
28
|
+
* explicit `databricksBasePath`) to target `/Users/<email>/.assistant/skills`
|
|
29
|
+
* 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.
|
|
32
|
+
*
|
|
33
|
+
* @module
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { mkdtemp, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
37
|
+
import { createRequire } from "node:module";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
39
|
+
import { join, posix } from "node:path";
|
|
40
|
+
import { appkit } from "@dbx-tools/appkit";
|
|
41
|
+
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
|
+
|
|
48
|
+
const logger = log.logger("mastra/remote-skills");
|
|
49
|
+
|
|
50
|
+
/** Shared Assistant skills tree in the Databricks workspace (default target). */
|
|
51
|
+
const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
|
|
52
|
+
|
|
53
|
+
/** Assistant skills directory for a specific Databricks user. */
|
|
54
|
+
function userAssistantSkillsPath(userEmail: string): string {
|
|
55
|
+
return `/Users/${userEmail.trim()}/.assistant/skills`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** npm agent id the `skills` CLI installs a bare `SKILL.md` tree under. */
|
|
59
|
+
const SKILLS_CLI_AGENT = "agents";
|
|
60
|
+
|
|
61
|
+
/** Where the `skills` CLI drops a plain `SKILL.md` tree under a target dir. */
|
|
62
|
+
const SKILLS_CLI_LAYOUT = [".agents", "skills"] as const;
|
|
63
|
+
|
|
64
|
+
/** Cap on a direct-fetch download body, matching the `skills` CLI default. */
|
|
65
|
+
const DEFAULT_MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024;
|
|
66
|
+
|
|
67
|
+
/* -------------------------------- types -------------------------------- */
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One remote skill source and its per-source policy.
|
|
71
|
+
*
|
|
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.
|
|
75
|
+
*/
|
|
76
|
+
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). */
|
|
80
|
+
skills?: string | string[];
|
|
81
|
+
/** Override the byte ceiling on a direct-fetch download for this source. */
|
|
82
|
+
maxDownloadBytes?: number;
|
|
83
|
+
/**
|
|
84
|
+
* When `false`, a source that fails to resolve is logged and skipped instead
|
|
85
|
+
* of failing app startup. Overrides the top-level {@link ProvisionRemoteSkillsOptions.failOnError}.
|
|
86
|
+
*/
|
|
87
|
+
failOnError?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A remote skill source: a bare source string (GitHub shorthand / URL) or a
|
|
92
|
+
* {@link RemoteSkillSourceOptions} with per-source policy.
|
|
93
|
+
*/
|
|
94
|
+
export type RemoteSkillSource = string | RemoteSkillSourceOptions;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The workspace `remoteSkills` option. A single source, a list, or a
|
|
98
|
+
* {@link ProvisionRemoteSkillsOptions} bag when top-level policy is needed.
|
|
99
|
+
*/
|
|
100
|
+
export type RemoteSkillsOption =
|
|
101
|
+
RemoteSkillSource | RemoteSkillSource[] | ProvisionRemoteSkillsOptions;
|
|
102
|
+
|
|
103
|
+
/** Top-level remote-skills provisioning options. */
|
|
104
|
+
export interface ProvisionRemoteSkillsOptions {
|
|
105
|
+
/** Sources to materialize at startup. */
|
|
106
|
+
sources: RemoteSkillSource | RemoteSkillSource[];
|
|
107
|
+
/**
|
|
108
|
+
* Fail app startup when a source can't be resolved. Defaults to `true`. A
|
|
109
|
+
* per-source `failOnError` wins over this.
|
|
110
|
+
*/
|
|
111
|
+
failOnError?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Absolute Databricks path that roots the destination Assistant skills tree.
|
|
114
|
+
* Defaults to the OBO user's `/Users/<email>/.assistant/skills`.
|
|
115
|
+
*/
|
|
116
|
+
databricksBasePath?: string;
|
|
117
|
+
/** Auth-scoped Databricks client. Defaults to the AppKit execution context. */
|
|
118
|
+
client?: WorkspaceClientLike;
|
|
119
|
+
/** User email used to derive the default Databricks destination. */
|
|
120
|
+
userEmail?: string;
|
|
121
|
+
/** Byte ceiling on a direct-fetch download. Defaults to 10 MiB. */
|
|
122
|
+
maxDownloadBytes?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What {@link provisionRemoteSkills} resolved. */
|
|
126
|
+
export interface ProvisionedRemoteSkills {
|
|
127
|
+
/**
|
|
128
|
+
* Extra LOCAL skill scan paths to hand Mastra (a temp dir per source that
|
|
129
|
+
* couldn't be written to Databricks). Empty when everything landed in the
|
|
130
|
+
* Databricks Assistant tree, which the built-in mount already scans.
|
|
131
|
+
*/
|
|
132
|
+
localSkillPaths: string[];
|
|
133
|
+
/** Absolute Databricks destination each source was written to, if any. */
|
|
134
|
+
databricksBasePath?: string;
|
|
135
|
+
/** Names of every skill directory that was provisioned. */
|
|
136
|
+
skillNames: string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ------------------------------- helpers ------------------------------- */
|
|
140
|
+
|
|
141
|
+
/** Normalize the `remoteSkills` option into a flat options bag. */
|
|
142
|
+
export function normalizeRemoteSkillsOption(
|
|
143
|
+
option: RemoteSkillsOption | undefined,
|
|
144
|
+
): ProvisionRemoteSkillsOptions | undefined {
|
|
145
|
+
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] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 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;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Coerce a source entry to its normalized {@link RemoteSkillSourceOptions}. */
|
|
164
|
+
function toSourceOptions(source: RemoteSkillSource): RemoteSkillSourceOptions {
|
|
165
|
+
return typeof source === "string" ? { source } : source;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Materialize every configured remote skill source at app startup.
|
|
170
|
+
*
|
|
171
|
+
* 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}.
|
|
174
|
+
*/
|
|
175
|
+
export async function provisionRemoteSkills(
|
|
176
|
+
option: RemoteSkillsOption | undefined,
|
|
177
|
+
): Promise<ProvisionedRemoteSkills> {
|
|
178
|
+
const options = normalizeRemoteSkillsOption(option);
|
|
179
|
+
const empty: ProvisionedRemoteSkills = { localSkillPaths: [], skillNames: [] };
|
|
180
|
+
if (!options) return empty;
|
|
181
|
+
|
|
182
|
+
const failDefault = options.failOnError !== false;
|
|
183
|
+
const client = options.client ?? appkit.tryGetExecutionContext()?.client;
|
|
184
|
+
const databricksBasePath = resolveDatabricksBasePath(options, client);
|
|
185
|
+
const destination = databricksBasePath
|
|
186
|
+
? new DatabricksWorkspaceFilesystem({ client, basePath: databricksBasePath, readOnly: false })
|
|
187
|
+
: undefined;
|
|
188
|
+
|
|
189
|
+
const localSkillPaths: string[] = [];
|
|
190
|
+
const skillNames: string[] = [];
|
|
191
|
+
let staging: string | undefined;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const sources = Array.isArray(options.sources) ? options.sources : [options.sources];
|
|
195
|
+
for (const entry of sources) {
|
|
196
|
+
const sourceOptions = toSourceOptions(entry);
|
|
197
|
+
const failOnError = sourceOptions.failOnError ?? failDefault;
|
|
198
|
+
try {
|
|
199
|
+
staging ??= await mkdtemp(join(tmpdir(), "mastra-remote-skills-"));
|
|
200
|
+
const stagedDir = await stageSource(sourceOptions, staging, options);
|
|
201
|
+
const staged = await collectSkillDirs(stagedDir);
|
|
202
|
+
if (staged.length === 0) {
|
|
203
|
+
throw new Error(`no SKILL.md found for source "${sourceOptions.source}"`);
|
|
204
|
+
}
|
|
205
|
+
if (destination && databricksBasePath) {
|
|
206
|
+
await uploadSkillDirs(destination, staged);
|
|
207
|
+
skillNames.push(...staged.map((dir) => dir.name));
|
|
208
|
+
} else {
|
|
209
|
+
const localDir = await persistLocally(staged);
|
|
210
|
+
localSkillPaths.push(localDir);
|
|
211
|
+
skillNames.push(...staged.map((dir) => dir.name));
|
|
212
|
+
}
|
|
213
|
+
logger.debug("source:provisioned", {
|
|
214
|
+
source: sourceOptions.source,
|
|
215
|
+
destination: databricksBasePath ?? "local-temp",
|
|
216
|
+
skills: staged.map((dir) => dir.name),
|
|
217
|
+
});
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (failOnError) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`failed to provision remote skill source "${sourceOptions.source}": ${error.errorMessage(err)}`,
|
|
222
|
+
{ cause: error.toError(err) },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
logger.warn("source:skipped", {
|
|
226
|
+
source: sourceOptions.source,
|
|
227
|
+
error: error.errorMessage(err),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
if (staging) await rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { localSkillPaths, databricksBasePath, skillNames };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Resolve the Databricks Assistant skills destination, or `undefined` for local temp. */
|
|
239
|
+
function resolveDatabricksBasePath(
|
|
240
|
+
options: ProvisionRemoteSkillsOptions,
|
|
241
|
+
client: WorkspaceClientLike | undefined,
|
|
242
|
+
): string | undefined {
|
|
243
|
+
if (!client) return undefined;
|
|
244
|
+
if (options.databricksBasePath) return options.databricksBasePath.trim() || undefined;
|
|
245
|
+
const email = string.trimToNull(options.userEmail);
|
|
246
|
+
// A named user targets their personal Assistant tree (the "save a skill"
|
|
247
|
+
// target); otherwise the shared workspace Assistant tree, which the built-in
|
|
248
|
+
// Assistant-skills mount already scans.
|
|
249
|
+
return email ? userAssistantSkillsPath(email) : ASSISTANT_SHARED_SKILLS_PATH;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Stage one source into a fresh dir under `staging`, preferring the optional
|
|
254
|
+
* `skills` CLI and falling back to a direct fetch.
|
|
255
|
+
*/
|
|
256
|
+
async function stageSource(
|
|
257
|
+
sourceOptions: RemoteSkillSourceOptions,
|
|
258
|
+
staging: string,
|
|
259
|
+
options: ProvisionRemoteSkillsOptions,
|
|
260
|
+
): Promise<string> {
|
|
261
|
+
const target = await mkdtemp(join(staging, "src-"));
|
|
262
|
+
const viaCli = await stageViaSkillsCli(sourceOptions, target);
|
|
263
|
+
if (viaCli) return viaCli;
|
|
264
|
+
return stageViaFetch(sourceOptions, target, options);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Copy a source into `target` with the `skills` CLI when it is installed.
|
|
269
|
+
* Returns the dir holding the copied `SKILL.md` trees, or `undefined` when the
|
|
270
|
+
* CLI is absent (so the caller can fall back to a fetch).
|
|
271
|
+
*/
|
|
272
|
+
async function stageViaSkillsCli(
|
|
273
|
+
sourceOptions: RemoteSkillSourceOptions,
|
|
274
|
+
target: string,
|
|
275
|
+
): Promise<string | undefined> {
|
|
276
|
+
const cli = await resolveSkillsCli();
|
|
277
|
+
if (!cli) return undefined;
|
|
278
|
+
|
|
279
|
+
const args = [
|
|
280
|
+
...cli.args,
|
|
281
|
+
"add",
|
|
282
|
+
sourceOptions.source,
|
|
283
|
+
"--agent",
|
|
284
|
+
SKILLS_CLI_AGENT,
|
|
285
|
+
"--copy",
|
|
286
|
+
"-y",
|
|
287
|
+
];
|
|
288
|
+
for (const skill of string.parseList(sourceOptions.skills)) {
|
|
289
|
+
args.push("--skill", skill);
|
|
290
|
+
}
|
|
291
|
+
if (string.parseList(sourceOptions.skills).length === 0) args.push("--skill", "*");
|
|
292
|
+
|
|
293
|
+
const result = await spawn(cli.command, args, {
|
|
294
|
+
cwd: target,
|
|
295
|
+
stdout: "capture",
|
|
296
|
+
stderr: "capture",
|
|
297
|
+
check: false,
|
|
298
|
+
});
|
|
299
|
+
if (result.exitCode !== 0) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`skills CLI failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return join(target, ...SKILLS_CLI_LAYOUT);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Locate the optional `skills` CLI bin, resolved from THIS package's module
|
|
309
|
+
* graph. Returns `undefined` when the peer dep isn't installed, so the caller
|
|
310
|
+
* falls back to a direct fetch.
|
|
311
|
+
*/
|
|
312
|
+
async function resolveSkillsCli(): Promise<{ command: string; args: string[] } | undefined> {
|
|
313
|
+
try {
|
|
314
|
+
const require = createRequire(import.meta.url);
|
|
315
|
+
const pkgPath = require.resolve("skills/package.json");
|
|
316
|
+
const bin = join(pkgPath, "..", "bin", "cli.mjs");
|
|
317
|
+
if (await pathExists(bin)) return { command: process.execPath, args: [bin] };
|
|
318
|
+
} catch {
|
|
319
|
+
// peer dep absent or unresolvable - fetch fallback handles it
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Fetch a source URL and write the downloaded `SKILL.md` under `target`.
|
|
326
|
+
* Used when the `skills` CLI isn't installed; supports a direct `SKILL.md` URL.
|
|
327
|
+
*/
|
|
328
|
+
async function stageViaFetch(
|
|
329
|
+
sourceOptions: RemoteSkillSourceOptions,
|
|
330
|
+
target: string,
|
|
331
|
+
options: ProvisionRemoteSkillsOptions,
|
|
332
|
+
): Promise<string> {
|
|
333
|
+
const url = net.urlBuilder(sourceOptions.source);
|
|
334
|
+
if (!url) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`source "${sourceOptions.source}" is not a URL and the optional "skills" package is not installed`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
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
|
+
}
|
|
351
|
+
const name = string.toSlug(deriveSkillName(url.toString())) || "remote-skill";
|
|
352
|
+
const skillDir = join(target, name);
|
|
353
|
+
await mkdir(skillDir, { recursive: true });
|
|
354
|
+
await writeFile(join(skillDir, "SKILL.md"), buffer);
|
|
355
|
+
return target;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Derive a skill directory name from a download URL's last path segment. */
|
|
359
|
+
function deriveSkillName(urlString: string): string {
|
|
360
|
+
const url = net.urlBuilder(urlString);
|
|
361
|
+
const last = url?.pathname.split("/").filter(Boolean).pop() ?? "";
|
|
362
|
+
return last.replace(/\.(md|zip|tar|tgz|gz)$/i, "");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A skill directory (holding a `SKILL.md`) staged on local disk. */
|
|
366
|
+
interface StagedSkillDir {
|
|
367
|
+
name: string;
|
|
368
|
+
absolutePath: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Collect every skill directory under `root`: a `root/SKILL.md` (root IS the
|
|
373
|
+
* skill) or each immediate child dir that contains a `SKILL.md`.
|
|
374
|
+
*/
|
|
375
|
+
async function collectSkillDirs(root: string): Promise<StagedSkillDir[]> {
|
|
376
|
+
if (!(await pathExists(root))) return [];
|
|
377
|
+
if (await pathExists(join(root, "SKILL.md"))) {
|
|
378
|
+
const name = posix.basename(root.split(/[\\/]/).join("/"));
|
|
379
|
+
return [{ name, absolutePath: root }];
|
|
380
|
+
}
|
|
381
|
+
const dirs: StagedSkillDir[] = [];
|
|
382
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
383
|
+
if (!entry.isDirectory()) continue;
|
|
384
|
+
const dir = join(root, entry.name);
|
|
385
|
+
if (await pathExists(join(dir, "SKILL.md"))) {
|
|
386
|
+
dirs.push({ name: entry.name, absolutePath: dir });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return dirs;
|
|
390
|
+
}
|
|
391
|
+
|
|
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?.();
|
|
398
|
+
for (const dir of dirs) {
|
|
399
|
+
for (const relative of findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
|
|
400
|
+
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 });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
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;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Best-effort existence check. */
|
|
417
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
418
|
+
try {
|
|
419
|
+
await stat(path);
|
|
420
|
+
return true;
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
package/src/workspaces.ts
CHANGED
|
@@ -78,6 +78,14 @@ export interface CreateWorkspaceOptions {
|
|
|
78
78
|
checkSkillFileMtime?: boolean;
|
|
79
79
|
/** Enable BM25 keyword search over indexed workspace content. */
|
|
80
80
|
bm25?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Extra LOCAL skill scan paths added to every request's skill discovery.
|
|
83
|
+
* Used by the plugin to surface remote skills provisioned to a local temp
|
|
84
|
+
* dir at startup (see `remote-skills.ts`). Databricks-hosted remote skills
|
|
85
|
+
* need no entry here - they land in the Assistant tree the built-in mount
|
|
86
|
+
* already scans.
|
|
87
|
+
*/
|
|
88
|
+
extraSkillPaths?: string[];
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
/**
|
|
@@ -104,8 +112,12 @@ export interface CreateWorkspaceOptions {
|
|
|
104
112
|
export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace {
|
|
105
113
|
const { id, name } = resolveWorkspaceIdentity(options);
|
|
106
114
|
const resolvers = buildMountResolvers(options);
|
|
115
|
+
const extraSkillPaths = options.extraSkillPaths ?? [];
|
|
107
116
|
const skills =
|
|
108
|
-
options.skills ??
|
|
117
|
+
options.skills ??
|
|
118
|
+
(resolvers.length > 0 || extraSkillPaths.length > 0
|
|
119
|
+
? buildWorkspaceSkillsResolver(resolvers, extraSkillPaths)
|
|
120
|
+
: undefined);
|
|
109
121
|
const checkSkillFileMtime = options.checkSkillFileMtime ?? options.assistantSkills !== false;
|
|
110
122
|
const bm25 = options.bm25 !== false;
|
|
111
123
|
logger.debug("workspace:create", {
|
|
@@ -117,6 +129,7 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
|
|
|
117
129
|
customSkillsResolver: Boolean(options.skills),
|
|
118
130
|
checkSkillFileMtime,
|
|
119
131
|
bm25,
|
|
132
|
+
extraSkillPaths: extraSkillPaths.length,
|
|
120
133
|
});
|
|
121
134
|
|
|
122
135
|
return new Workspace({
|
|
@@ -339,10 +352,14 @@ async function resolveWorkspaceFilesystem(
|
|
|
339
352
|
* Build the dynamic {@link SkillsResolver} that collects `skillPaths` from
|
|
340
353
|
* every mount resolver on each request.
|
|
341
354
|
*/
|
|
342
|
-
function buildWorkspaceSkillsResolver(
|
|
355
|
+
function buildWorkspaceSkillsResolver(
|
|
356
|
+
resolvers: WorkspaceMountResolver[],
|
|
357
|
+
extraSkillPaths: string[] = [],
|
|
358
|
+
): SkillsResolver {
|
|
343
359
|
return async (context: SkillsContext) => {
|
|
344
360
|
const { skillPaths } = await resolveWorkspaceContribution(resolvers, context);
|
|
345
|
-
|
|
346
|
-
|
|
361
|
+
const merged = [...(skillPaths ?? []), ...extraSkillPaths];
|
|
362
|
+
logger.debug("skills:resolved", { skillPaths, extraSkillPaths });
|
|
363
|
+
return merged;
|
|
347
364
|
};
|
|
348
365
|
}
|