@vellumai/assistant 0.12.2-staging.4 → 0.12.2-staging.6
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/Dockerfile +7 -7
- package/openapi.yaml +151 -53
- package/package.json +5 -4
- package/scripts/bundled-plugin-packages.ts +154 -0
- package/scripts/generate-bundled-plugin-packages.ts +29 -0
- package/scripts/test.ts +15 -13
- package/src/__tests__/managed-store.test.ts +121 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
- package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
- package/src/calls/voice-control-protocol.ts +13 -4
- package/src/calls/voice-session-bridge.ts +27 -6
- package/src/cli/commands/__tests__/plugins.test.ts +66 -0
- package/src/cli/commands/plugins.ts +50 -18
- package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
- package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
- package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
- package/src/cli/lib/bundled-plugin-packages.json +4 -0
- package/src/cli/lib/bundled-plugin-packages.ts +87 -0
- package/src/cli/lib/diff-plugin.ts +1 -1
- package/src/cli/lib/inspect-plugin.ts +80 -12
- package/src/cli/lib/install-from-github.ts +133 -76
- package/src/cli/lib/plugin-catalog-cache.ts +22 -3
- package/src/cli/lib/plugin-catalog-local.ts +13 -3
- package/src/cli/lib/plugin-catalog-platform.ts +6 -1
- package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
- package/src/cli/lib/plugin-details.ts +12 -0
- package/src/cli/lib/plugin-marketplace.ts +121 -21
- package/src/cli/lib/plugin-pin-history.ts +5 -2
- package/src/cli/lib/search-plugins.ts +58 -16
- package/src/cli/lib/upgrade-plugin.ts +48 -3
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +7 -7
- package/src/daemon/conversation-tool-setup.ts +4 -22
- package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +658 -3
- package/src/live-voice/__tests__/protocol.test.ts +48 -0
- package/src/live-voice/__tests__/session-controls.test.ts +43 -0
- package/src/live-voice/live-voice-session.ts +784 -64
- package/src/live-voice/protocol.ts +20 -0
- package/src/live-voice/session-controls.ts +61 -3
- package/src/monitoring/plugin-auto-update.ts +6 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +15 -0
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
- package/src/runtime/routes/plugins-routes.ts +69 -49
- package/src/skills/managed-store.ts +153 -31
- package/src/tools/skills/find-similar-skills.test.ts +214 -2
- package/src/tools/skills/find-similar-skills.ts +62 -9
- package/src/tools/skills/resolve-execute-invocation.ts +24 -0
- package/src/tools/skills/scaffold-managed.ts +16 -13
|
@@ -166,6 +166,16 @@ export interface LiveVoiceClientStartFrame {
|
|
|
166
166
|
* has never heard of.
|
|
167
167
|
*/
|
|
168
168
|
readonly sessionControls?: readonly LiveVoiceSessionControl[];
|
|
169
|
+
/**
|
|
170
|
+
* This client sends a fresh `sight_frame` right after it carries out a look
|
|
171
|
+
* control, with timing reason `look`, whether or not a share or the camera
|
|
172
|
+
* was already running. The session answers the look from that frame on a
|
|
173
|
+
* turn of its own, so the reply that asked for the look only acknowledges it.
|
|
174
|
+
*
|
|
175
|
+
* Absent means false: a client that predates the field sends no such frame,
|
|
176
|
+
* and a session waiting on one would promise a look that never comes.
|
|
177
|
+
*/
|
|
178
|
+
readonly lookFrames?: boolean;
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
const LIVE_VOICE_SESSION_CONTROLS = [
|
|
@@ -1269,6 +1279,15 @@ function validateStartFrame(
|
|
|
1269
1279
|
);
|
|
1270
1280
|
}
|
|
1271
1281
|
|
|
1282
|
+
if ("lookFrames" in value && typeof value.lookFrames !== "boolean") {
|
|
1283
|
+
return protocolError(
|
|
1284
|
+
"invalid_field",
|
|
1285
|
+
"start frame field lookFrames must be a boolean",
|
|
1286
|
+
"lookFrames",
|
|
1287
|
+
"start",
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1272
1291
|
if ("textInput" in value && typeof value.textInput !== "boolean") {
|
|
1273
1292
|
return protocolError(
|
|
1274
1293
|
"invalid_field",
|
|
@@ -1308,6 +1327,7 @@ function validateStartFrame(
|
|
|
1308
1327
|
: {}),
|
|
1309
1328
|
...(value.textInput === true ? { textInput: true } : {}),
|
|
1310
1329
|
...(sessionControls.length > 0 ? { sessionControls } : {}),
|
|
1330
|
+
...(value.lookFrames === true ? { lookFrames: true } : {}),
|
|
1311
1331
|
},
|
|
1312
1332
|
};
|
|
1313
1333
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
NORMAL_UPDATES_MARKER,
|
|
9
9
|
parseTerminalSessionControl,
|
|
10
10
|
type SessionControlRequest,
|
|
11
|
+
TASK_STOP_MARKER,
|
|
11
12
|
} from "../calls/voice-control-protocol.js";
|
|
12
13
|
import type { VoiceProgressConfig } from "../config/schemas/voice.js";
|
|
13
14
|
import type { LiveVoiceSessionControl } from "./protocol.js";
|
|
@@ -33,10 +34,61 @@ const CLIENT_CONTROL_LINES: Record<LiveVoiceSessionControl, string> = {
|
|
|
33
34
|
mute: `- To mute their microphone (for example "mute for 30 seconds" or "mute yourself, I need to take this"), confirm in a few words, then end your reply with [MUTE:<seconds>] when they gave a duration or ${MUTE_MARKER} when they did not. While muted you cannot hear them, so mention they can unmute from the call controls unless the mute is timed.`,
|
|
34
35
|
};
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The look lines for a client that declared `lookFrames`: one that sends a
|
|
39
|
+
* fresh frame the moment it carries out a look, which the session answers on
|
|
40
|
+
* a turn of its own (see {@link LOOK_FRAME_REASON}). The reply that asks for
|
|
41
|
+
* the look is therefore only the acknowledgement, and asking to look again is
|
|
42
|
+
* how the model gets a current view of a share that is already running.
|
|
43
|
+
*/
|
|
44
|
+
const LOOK_FRAME_CONTROL_LINES: Partial<
|
|
45
|
+
Record<LiveVoiceSessionControl, string>
|
|
46
|
+
> = {
|
|
47
|
+
look_screen: `- To look at their screen (for example "take a look at my screen", or "can you see it now?" after something on it changed), say in a few words that you are taking a look, then end your reply with ${LOOK_SCREEN_MARKER}. Use it even when their screen is already shared with you, to see it as it is now. You get a fresh view right after you finish speaking and answer from that, so do not describe the screen yet and do not ask them to say anything more.`,
|
|
48
|
+
look_camera: `- To look through their camera (for example "look at this" or "can you see this?" while they hold something up), say in a few words that you are taking a look, then end your reply with ${LOOK_CAMERA_MARKER}. Use it even when the camera is already on, to see what it shows now. You get a fresh view right after you finish speaking and answer from that, so do not describe it yet and do not ask them to say anything more.`,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The `reason` a client's sight frame timing carries when the frame is the
|
|
53
|
+
* fresh view it took for a look control. Only a client that declared
|
|
54
|
+
* `lookFrames` sends one, and the session answers the look from it.
|
|
55
|
+
*/
|
|
56
|
+
export const LOOK_FRAME_REASON = "look";
|
|
57
|
+
|
|
58
|
+
/** The controls that end in a fresh frame the session answers from. */
|
|
59
|
+
export type LookSessionControl = Extract<
|
|
60
|
+
LiveVoiceSessionControl,
|
|
61
|
+
"look_screen" | "look_camera"
|
|
62
|
+
>;
|
|
63
|
+
|
|
64
|
+
export function isLookSessionControl(
|
|
65
|
+
action: string,
|
|
66
|
+
): action is LookSessionControl {
|
|
67
|
+
return action === "look_screen" || action === "look_camera";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The persisted `content` of the turn that answers a look. Hidden: there is no
|
|
72
|
+
* user utterance behind the turn, and the instruction rides the control prompt
|
|
73
|
+
* ({@link lookFollowUpNote}).
|
|
74
|
+
*/
|
|
75
|
+
export const LOOK_FOLLOW_UP_CONTENT = "(fresh view taken; answer from it now)";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Appended to the control prompt of the turn that answers a look: the reply
|
|
79
|
+
* that asked for it only acknowledged, and the frame it asked for has landed.
|
|
80
|
+
*/
|
|
81
|
+
export function lookFollowUpNote(action: LookSessionControl): string {
|
|
82
|
+
const what = action === "look_screen" ? "their screen" : "their camera";
|
|
83
|
+
return `You just took a fresh look at ${what}, and the newest image in the conversation is what it shows right now. Answer what they wanted you to look at, out loud, in a few spoken sentences. If the image does not show what they meant, say briefly what you do see and ask. You have already said you were taking a look, so do not say it again and do not end with a look marker.`;
|
|
84
|
+
}
|
|
85
|
+
|
|
36
86
|
// Always taught: narration is the session's own, so no client has to be able
|
|
37
87
|
// to carry it out.
|
|
38
88
|
const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (for example "don't give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with ${FEWER_UPDATES_MARKER}. If they later want regular updates back, confirm and end with ${NORMAL_UPDATES_MARKER}.`;
|
|
39
89
|
|
|
90
|
+
const TASK_STOP_LINE = `- To abandon the unfinished task while keeping the call open (for example "stop that" or "never mind about that task"), confirm briefly, then end your reply with ${TASK_STOP_MARKER}. Use this only when they want the task abandoned, not when they interrupt with a question or correction.`;
|
|
91
|
+
|
|
40
92
|
/**
|
|
41
93
|
* The control-prompt block that teaches the session controls: the ones the
|
|
42
94
|
* client declared plus the progress-update cadence. Every leg gets it,
|
|
@@ -49,12 +101,18 @@ const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (fo
|
|
|
49
101
|
export function sessionControlTeaching(
|
|
50
102
|
controls: readonly LiveVoiceSessionControl[],
|
|
51
103
|
leg: { frontDoor?: boolean },
|
|
104
|
+
client: { lookFrames?: boolean; unfinishedTaskPending?: boolean } = {},
|
|
52
105
|
): string {
|
|
106
|
+
const lines =
|
|
107
|
+
client.lookFrames === true
|
|
108
|
+
? { ...CLIENT_CONTROL_LINES, ...LOOK_FRAME_CONTROL_LINES }
|
|
109
|
+
: CLIENT_CONTROL_LINES;
|
|
53
110
|
return [
|
|
54
111
|
"The user can also control this call by asking you. Only when they clearly ask:",
|
|
55
|
-
...controls.map((control) =>
|
|
112
|
+
...controls.map((control) => lines[control]),
|
|
56
113
|
...lookGuidance(controls),
|
|
57
114
|
UPDATES_LINE,
|
|
115
|
+
...(client.unfinishedTaskPending === true ? [TASK_STOP_LINE] : []),
|
|
58
116
|
`The marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.${leg.frontDoor === true ? "" : " Never emit any other bracketed marker."}`,
|
|
59
117
|
].join("\n");
|
|
60
118
|
}
|
|
@@ -83,7 +141,7 @@ function lookGuidance(controls: readonly LiveVoiceSessionControl[]): string[] {
|
|
|
83
141
|
/** A session control the client carries out, sent as a `session_control` frame. */
|
|
84
142
|
export type ClientSessionControlRequest = Exclude<
|
|
85
143
|
SessionControlRequest,
|
|
86
|
-
{ action: "updates" }
|
|
144
|
+
{ action: "updates" } | { action: "task_stop" }
|
|
87
145
|
>;
|
|
88
146
|
|
|
89
147
|
/**
|
|
@@ -100,7 +158,7 @@ export function requestedSessionControl(
|
|
|
100
158
|
if (request === null) {
|
|
101
159
|
return null;
|
|
102
160
|
}
|
|
103
|
-
if (request.action === "updates") {
|
|
161
|
+
if (request.action === "updates" || request.action === "task_stop") {
|
|
104
162
|
return request;
|
|
105
163
|
}
|
|
106
164
|
return controls.includes(request.action) ? request : null;
|
|
@@ -155,6 +155,12 @@ function tracksCuratedSource(inspection: PluginInspection): boolean {
|
|
|
155
155
|
if (!remote) {
|
|
156
156
|
return false;
|
|
157
157
|
}
|
|
158
|
+
if (source.kind === "local") {
|
|
159
|
+
return remote.kind === "local" && source.path === remote.path;
|
|
160
|
+
}
|
|
161
|
+
if (remote.kind === "local") {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
158
164
|
return (
|
|
159
165
|
`${source.owner}/${source.repo}`.toLowerCase() ===
|
|
160
166
|
remote.repo.toLowerCase() && (source.path ?? "") === remote.path
|
|
@@ -115,6 +115,21 @@ For everything else in your review window, use the \`remember\` tool on facts, p
|
|
|
115
115
|
expect(out).toContain(
|
|
116
116
|
"\n---\n\nIf your review window contains a PROCEDURE you actually carried out",
|
|
117
117
|
);
|
|
118
|
+
// A refinement overwrites the whole file and the pass has no other read
|
|
119
|
+
// path to the skill, so the instruction has to point at `current`.
|
|
120
|
+
expect(out).toContain(
|
|
121
|
+
"rewriting `body_markdown` from `current.body_markdown` plus what you actually observed in the trace",
|
|
122
|
+
);
|
|
123
|
+
// Hints are the retrieval signal; a refinement carries them forward
|
|
124
|
+
// rather than regenerating them from one trace.
|
|
125
|
+
expect(out).toContain(
|
|
126
|
+
"restate `current.activation_hints` (revised only if the procedure's triggers changed",
|
|
127
|
+
);
|
|
128
|
+
// The store keeps omitted frontmatter on an overwrite, so the pass is told
|
|
129
|
+
// to leave unchanged fields out rather than restate them from memory.
|
|
130
|
+
expect(out).toContain(
|
|
131
|
+
"an omitted field keeps its current value and an empty one clears it",
|
|
132
|
+
);
|
|
118
133
|
// An UPDATE is announced by a notice whose only account of the change is
|
|
119
134
|
// what the pass passes here, so the instruction has to ask for it.
|
|
120
135
|
expect(out).toContain(
|
|
@@ -223,7 +223,7 @@ If your review window contains a PROCEDURE you actually carried out — a sequen
|
|
|
223
223
|
|
|
224
224
|
When you do capture a procedure:
|
|
225
225
|
|
|
226
|
-
1. Deduplicate against existing skills first. Call \`find_similar_skills\` with the procedure's goal as its \`goal\` argument. Each hit carries a \`source\` (bundled, managed, plugin, workspace, or extra), and a managed hit also carries \`author\` (\`"assistant"\` if you authored it, \`"user"\` if a person did, omitted if untagged). You may only overwrite or refine a skill YOU authored: a hit with \`source: "managed"\` AND \`author: "assistant"\`. ANY other hit means the procedure is ALREADY COVERED: a non-managed source (bundled, plugin, workspace, or extra), OR a managed skill that is NOT \`author: "assistant"\` (a person wrote it, or it is untagged). For an ALREADY COVERED hit do not \`overwrite\` it, do not shadow it by creating a skill with its \`skill_id\`, and do not create a near-duplicate. Skip it. Only when a returned skill is one of your own (\`source: "managed"\`, \`author: "assistant"\`) and is the SAME procedure, UPDATE it
|
|
226
|
+
1. Deduplicate against existing skills first. Call \`find_similar_skills\` with the procedure's goal as its \`goal\` argument. Each hit carries a \`source\` (bundled, managed, plugin, workspace, or extra), and a managed hit also carries \`author\` (\`"assistant"\` if you authored it, \`"user"\` if a person did, omitted if untagged). You may only overwrite or refine a skill YOU authored: a hit with \`source: "managed"\` AND \`author: "assistant"\`. ANY other hit means the procedure is ALREADY COVERED: a non-managed source (bundled, plugin, workspace, or extra), OR a managed skill that is NOT \`author: "assistant"\` (a person wrote it, or it is untagged). For an ALREADY COVERED hit do not \`overwrite\` it, do not shadow it by creating a skill with its \`skill_id\`, and do not create a near-duplicate. Skip it. Only when a returned skill is one of your own (\`source: "managed"\`, \`author: "assistant"\`) and is the SAME procedure, UPDATE it. Such a hit carries \`current\`: the skill as it is now, in \`scaffold_managed_skill\`'s own argument names. Call \`scaffold_managed_skill\` with that \`skill_id\` and \`overwrite: true\`, rewriting \`body_markdown\` from \`current.body_markdown\` plus what you actually observed in the trace (keep the steps the trace did not contradict; correct or add the ones it did), restate \`current.activation_hints\` (revised only if the procedure's triggers changed; they are the skill's retrieval signal, not something to regenerate) and leave out any other \`current\` field you are not changing (\`emoji\`, \`category\`, \`includes\`, \`avoid_when\`): an omitted field keeps its current value and an empty one clears it, and pass \`change_summary\` (the update is rejected without it): one or two short sentences (under 200 characters) for the person who reads the "Skill updated" notice, naming what you changed and what in the trace prompted it (for example "Added the retry after an expired session and the export endpoint that held steady."). The notice shows nothing else about the change, so name the concrete step, value, or gotcha rather than saying the skill was refined. Only CREATE a new skill (fresh \`skill_id\`) when no existing skill of any source covers the procedure. Bias strongly toward reusing or refining your own skills over spawning near-duplicates.
|
|
227
227
|
|
|
228
228
|
2. Capture procedure-scoped knowledge alongside the body. Failure modes, gotchas, and cached values you observed in the trace (error signatures and how you recovered, preconditions, IDs/paths/endpoints that held steady) belong in companion files passed via \`scaffold_managed_skill\`'s \`files\` input (for example \`references/failure-modes.md\`), and the SKILL.md body should reference them so a future load surfaces them.
|
|
229
229
|
|
|
@@ -1082,6 +1082,64 @@ describe("GET /v1/plugins/search", () => {
|
|
|
1082
1082
|
expect("icon" in byName.get("plain-plugin")!).toBe(false);
|
|
1083
1083
|
});
|
|
1084
1084
|
|
|
1085
|
+
test("preserves a bundled MCP package source and integration metadata", async () => {
|
|
1086
|
+
getCatalogSpy.mockImplementation(async (ref) =>
|
|
1087
|
+
catalog(ref, [
|
|
1088
|
+
{
|
|
1089
|
+
name: "fathom",
|
|
1090
|
+
path: "local:plugins/mcp-catalog/fathom@1.0.0",
|
|
1091
|
+
description: "Search meeting transcripts.",
|
|
1092
|
+
category: null,
|
|
1093
|
+
integration: {
|
|
1094
|
+
kind: "mcp",
|
|
1095
|
+
displayName: "Fathom",
|
|
1096
|
+
documentationUrl: "https://example.com/fathom/docs",
|
|
1097
|
+
verifiedAt: "2026-09-15",
|
|
1098
|
+
verification: "documentation-only",
|
|
1099
|
+
setup: {
|
|
1100
|
+
mode: "oauth",
|
|
1101
|
+
instructions: "Connect your Fathom account.",
|
|
1102
|
+
},
|
|
1103
|
+
logo: "fathom.png",
|
|
1104
|
+
},
|
|
1105
|
+
source: {
|
|
1106
|
+
kind: "local",
|
|
1107
|
+
path: "plugins/mcp-catalog/fathom",
|
|
1108
|
+
version: "1.0.0",
|
|
1109
|
+
},
|
|
1110
|
+
},
|
|
1111
|
+
]),
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
const result = await invokeSearch({ queryParams: { q: "fathom" } });
|
|
1115
|
+
|
|
1116
|
+
expect(result.matches).toEqual([
|
|
1117
|
+
{
|
|
1118
|
+
name: "fathom",
|
|
1119
|
+
path: "local:plugins/mcp-catalog/fathom@1.0.0",
|
|
1120
|
+
description: "Search meeting transcripts.",
|
|
1121
|
+
category: null,
|
|
1122
|
+
integration: {
|
|
1123
|
+
kind: "mcp",
|
|
1124
|
+
displayName: "Fathom",
|
|
1125
|
+
documentationUrl: "https://example.com/fathom/docs",
|
|
1126
|
+
verifiedAt: "2026-09-15",
|
|
1127
|
+
verification: "documentation-only",
|
|
1128
|
+
setup: {
|
|
1129
|
+
mode: "oauth",
|
|
1130
|
+
instructions: "Connect your Fathom account.",
|
|
1131
|
+
},
|
|
1132
|
+
logo: "fathom.png",
|
|
1133
|
+
},
|
|
1134
|
+
source: {
|
|
1135
|
+
kind: "local",
|
|
1136
|
+
path: "plugins/mcp-catalog/fathom",
|
|
1137
|
+
version: "1.0.0",
|
|
1138
|
+
},
|
|
1139
|
+
},
|
|
1140
|
+
]);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1085
1143
|
test("normalizes match categories to the Skills taxonomy (`developer` → `development`, `hobby` → null)", async () => {
|
|
1086
1144
|
getCatalogSpy.mockImplementation(async (ref) =>
|
|
1087
1145
|
catalog(ref, [
|
|
@@ -1607,6 +1665,50 @@ describe("POST /v1/plugins/install", () => {
|
|
|
1607
1665
|
}
|
|
1608
1666
|
});
|
|
1609
1667
|
|
|
1668
|
+
test("installs a bundled package from the exact catalog source", async () => {
|
|
1669
|
+
getCatalogSpy.mockImplementation(async (ref) =>
|
|
1670
|
+
catalog(ref, [
|
|
1671
|
+
{
|
|
1672
|
+
name: "fathom",
|
|
1673
|
+
path: "local:plugins/mcp-catalog/fathom@1.0.0",
|
|
1674
|
+
category: null,
|
|
1675
|
+
source: {
|
|
1676
|
+
kind: "local",
|
|
1677
|
+
path: "plugins/mcp-catalog/fathom",
|
|
1678
|
+
version: "1.0.0",
|
|
1679
|
+
},
|
|
1680
|
+
},
|
|
1681
|
+
]),
|
|
1682
|
+
);
|
|
1683
|
+
installSpy.mockImplementation(async (opts) => ({
|
|
1684
|
+
name: opts.name,
|
|
1685
|
+
target: `/workspace/.vellum/plugins/${opts.name}`,
|
|
1686
|
+
fileCount: 2,
|
|
1687
|
+
ref: "1.0.0",
|
|
1688
|
+
commit: null,
|
|
1689
|
+
committedAt: null,
|
|
1690
|
+
}));
|
|
1691
|
+
|
|
1692
|
+
const result = await invokeInstall({ body: { name: "fathom" } });
|
|
1693
|
+
|
|
1694
|
+
expect(installSpy.mock.calls[0]?.[0]).toEqual({
|
|
1695
|
+
name: "fathom",
|
|
1696
|
+
force: undefined,
|
|
1697
|
+
trustedSource: {
|
|
1698
|
+
kind: "local",
|
|
1699
|
+
path: "plugins/mcp-catalog/fathom",
|
|
1700
|
+
version: "1.0.0",
|
|
1701
|
+
},
|
|
1702
|
+
});
|
|
1703
|
+
expect(result).toEqual({
|
|
1704
|
+
ok: true,
|
|
1705
|
+
name: "fathom",
|
|
1706
|
+
target: "/workspace/.vellum/plugins/fathom",
|
|
1707
|
+
fileCount: 2,
|
|
1708
|
+
ref: "1.0.0",
|
|
1709
|
+
});
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1610
1712
|
test("publishes sync_changed(plugins:list) on a successful install", async () => {
|
|
1611
1713
|
installSpy.mockImplementation(async (opts) => trustedInstallResult(opts));
|
|
1612
1714
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Route handlers for the assistant plugins surface.
|
|
3
3
|
*
|
|
4
4
|
* GET /v1/plugins — list installed plugins under `<workspaceDir>/plugins/`.
|
|
5
|
-
* GET /v1/plugins/search
|
|
5
|
+
* GET /v1/plugins/search - search the reviewed catalog of installable plugins.
|
|
6
6
|
* GET /v1/plugins/:name — resolve a single plugin's detail view (metadata + README).
|
|
7
7
|
* POST /v1/plugins/install — install a plugin by name from the canonical source.
|
|
8
8
|
* DELETE /v1/plugins/:name — uninstall a plugin from `<workspaceDir>/plugins/<name>/`.
|
|
@@ -192,30 +192,47 @@ const pluginsListResponseSchema = z.object({
|
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
const pluginMatchSourceSchema = z
|
|
195
|
-
.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
195
|
+
.discriminatedUnion("kind", [
|
|
196
|
+
z.object({
|
|
197
|
+
kind: z.literal("github"),
|
|
198
|
+
repo: z
|
|
199
|
+
.string()
|
|
200
|
+
.describe("`owner/repo` of the external plugin repository."),
|
|
201
|
+
path: z
|
|
202
|
+
.string()
|
|
203
|
+
.optional()
|
|
204
|
+
.describe(
|
|
205
|
+
"Directory within the repo, when the plugin is not at the root.",
|
|
206
|
+
),
|
|
207
|
+
ref: z.string().describe("Pinned git ref the plugin is fetched from."),
|
|
208
|
+
}),
|
|
209
|
+
z.object({
|
|
210
|
+
kind: z.literal("local"),
|
|
211
|
+
path: z.string().describe("Package key in the assistant bundle."),
|
|
212
|
+
version: z.string().describe("Bundled package version."),
|
|
213
|
+
}),
|
|
214
|
+
])
|
|
215
|
+
.describe("Reviewed source of the marketplace entry.");
|
|
216
|
+
|
|
217
|
+
const mcpPluginIntegrationSchema = z.object({
|
|
218
|
+
kind: z.literal("mcp"),
|
|
219
|
+
displayName: z.string(),
|
|
220
|
+
documentationUrl: z.string(),
|
|
221
|
+
verifiedAt: z.string(),
|
|
222
|
+
verification: z.literal("documentation-only"),
|
|
223
|
+
setup: z.object({
|
|
224
|
+
mode: z.enum(["oauth", "manual"]),
|
|
225
|
+
instructions: z.string(),
|
|
226
|
+
}),
|
|
227
|
+
logo: z.string(),
|
|
228
|
+
oauthProvider: z.string().optional(),
|
|
229
|
+
});
|
|
209
230
|
|
|
210
231
|
const pluginSearchMatchSchema = z.object({
|
|
211
232
|
name: z
|
|
212
233
|
.string()
|
|
213
234
|
.describe("Install name. Matches `assistant plugins install <name>`."),
|
|
214
|
-
path: z
|
|
215
|
-
.string()
|
|
216
|
-
.describe(
|
|
217
|
-
"Human-readable origin: a `github:owner/repo@ref` locator for the external plugin.",
|
|
218
|
-
),
|
|
235
|
+
path: z.string().describe("Human-readable catalog origin locator."),
|
|
219
236
|
description: z
|
|
220
237
|
.string()
|
|
221
238
|
.optional()
|
|
@@ -232,6 +249,7 @@ const pluginSearchMatchSchema = z.object({
|
|
|
232
249
|
.describe(
|
|
233
250
|
"Marketplace category slug (Skills taxonomy); null when the entry declares none.",
|
|
234
251
|
),
|
|
252
|
+
integration: mcpPluginIntegrationSchema.optional(),
|
|
235
253
|
source: pluginMatchSourceSchema,
|
|
236
254
|
});
|
|
237
255
|
|
|
@@ -418,22 +436,20 @@ const fingerprintComparisonSchema = z
|
|
|
418
436
|
);
|
|
419
437
|
|
|
420
438
|
const installMetaSourceSchema = z
|
|
421
|
-
.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
.string()
|
|
427
|
-
.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
),
|
|
431
|
-
|
|
432
|
-
.string()
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
),
|
|
436
|
-
})
|
|
439
|
+
.discriminatedUnion("kind", [
|
|
440
|
+
z.object({
|
|
441
|
+
kind: z.literal("github"),
|
|
442
|
+
owner: z.string(),
|
|
443
|
+
repo: z.string(),
|
|
444
|
+
path: z.string().optional(),
|
|
445
|
+
ref: z.string(),
|
|
446
|
+
}),
|
|
447
|
+
z.object({
|
|
448
|
+
kind: z.literal("local"),
|
|
449
|
+
path: z.string(),
|
|
450
|
+
version: z.string(),
|
|
451
|
+
}),
|
|
452
|
+
])
|
|
437
453
|
.describe(
|
|
438
454
|
"Source coordinates recorded in the install-time provenance sidecar.",
|
|
439
455
|
);
|
|
@@ -489,6 +505,7 @@ const pluginLocalInfoSchema = z
|
|
|
489
505
|
|
|
490
506
|
const pluginRemoteInfoSchema = z
|
|
491
507
|
.object({
|
|
508
|
+
kind: z.literal("local").optional(),
|
|
492
509
|
repo: z
|
|
493
510
|
.string()
|
|
494
511
|
.describe("`owner/repo` of the external plugin repository."),
|
|
@@ -517,6 +534,7 @@ const pluginRemoteInfoSchema = z
|
|
|
517
534
|
.describe(
|
|
518
535
|
"Ref of the canonical repo the marketplace manifest was read from.",
|
|
519
536
|
),
|
|
537
|
+
version: z.string().optional().describe("Bundled package version."),
|
|
520
538
|
})
|
|
521
539
|
.describe("The marketplace's current pin and advertised metadata.");
|
|
522
540
|
|
|
@@ -870,7 +888,8 @@ interface PluginMatchView {
|
|
|
870
888
|
description?: string;
|
|
871
889
|
icon?: string;
|
|
872
890
|
category: string | null;
|
|
873
|
-
|
|
891
|
+
integration?: PluginSearchMatch["integration"];
|
|
892
|
+
source: PluginSearchMatch["source"];
|
|
874
893
|
}
|
|
875
894
|
|
|
876
895
|
/**
|
|
@@ -886,12 +905,7 @@ function projectMatch(
|
|
|
886
905
|
name: m.name,
|
|
887
906
|
path: m.path,
|
|
888
907
|
category: normalizeMarketplaceCategory(m.category, validSlugs),
|
|
889
|
-
source: {
|
|
890
|
-
kind: "github",
|
|
891
|
-
repo: m.source.repo,
|
|
892
|
-
ref: m.source.ref,
|
|
893
|
-
...(m.source.path !== undefined ? { path: m.source.path } : {}),
|
|
894
|
-
},
|
|
908
|
+
source: { ...m.source },
|
|
895
909
|
};
|
|
896
910
|
if (m.description !== undefined) {
|
|
897
911
|
view.description = m.description;
|
|
@@ -899,6 +913,9 @@ function projectMatch(
|
|
|
899
913
|
if (m.icon !== undefined) {
|
|
900
914
|
view.icon = m.icon;
|
|
901
915
|
}
|
|
916
|
+
if (m.integration !== undefined) {
|
|
917
|
+
view.integration = m.integration;
|
|
918
|
+
}
|
|
902
919
|
return view;
|
|
903
920
|
}
|
|
904
921
|
|
|
@@ -1276,12 +1293,15 @@ async function handleInstallPlugin({ body = {}, headers }: RouteHandlerArgs) {
|
|
|
1276
1293
|
{
|
|
1277
1294
|
name,
|
|
1278
1295
|
force,
|
|
1279
|
-
trustedSource:
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1296
|
+
trustedSource:
|
|
1297
|
+
source.kind === "local"
|
|
1298
|
+
? source
|
|
1299
|
+
: {
|
|
1300
|
+
owner: source.owner,
|
|
1301
|
+
repo: source.repo,
|
|
1302
|
+
rootPath: source.path,
|
|
1303
|
+
ref: source.ref,
|
|
1304
|
+
},
|
|
1285
1305
|
},
|
|
1286
1306
|
{ fetch: globalThis.fetch.bind(globalThis) },
|
|
1287
1307
|
);
|