@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.
Files changed (57) hide show
  1. package/Dockerfile +7 -7
  2. package/openapi.yaml +151 -53
  3. package/package.json +5 -4
  4. package/scripts/bundled-plugin-packages.ts +154 -0
  5. package/scripts/generate-bundled-plugin-packages.ts +29 -0
  6. package/scripts/test.ts +15 -13
  7. package/src/__tests__/managed-store.test.ts +121 -0
  8. package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
  9. package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
  10. package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
  11. package/src/calls/voice-control-protocol.ts +13 -4
  12. package/src/calls/voice-session-bridge.ts +27 -6
  13. package/src/cli/commands/__tests__/plugins.test.ts +66 -0
  14. package/src/cli/commands/plugins.ts +50 -18
  15. package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
  16. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
  17. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
  18. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
  19. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
  20. package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
  21. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
  22. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
  23. package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
  24. package/src/cli/lib/bundled-plugin-packages.json +4 -0
  25. package/src/cli/lib/bundled-plugin-packages.ts +87 -0
  26. package/src/cli/lib/diff-plugin.ts +1 -1
  27. package/src/cli/lib/inspect-plugin.ts +80 -12
  28. package/src/cli/lib/install-from-github.ts +133 -76
  29. package/src/cli/lib/plugin-catalog-cache.ts +22 -3
  30. package/src/cli/lib/plugin-catalog-local.ts +13 -3
  31. package/src/cli/lib/plugin-catalog-platform.ts +6 -1
  32. package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
  33. package/src/cli/lib/plugin-details.ts +12 -0
  34. package/src/cli/lib/plugin-marketplace.ts +121 -21
  35. package/src/cli/lib/plugin-pin-history.ts +5 -2
  36. package/src/cli/lib/search-plugins.ts +58 -16
  37. package/src/cli/lib/upgrade-plugin.ts +48 -3
  38. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  39. package/src/config/bundled-skills/skill-management/TOOLS.json +7 -7
  40. package/src/daemon/conversation-tool-setup.ts +4 -22
  41. package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
  42. package/src/live-voice/__tests__/live-voice-vad.test.ts +658 -3
  43. package/src/live-voice/__tests__/protocol.test.ts +48 -0
  44. package/src/live-voice/__tests__/session-controls.test.ts +43 -0
  45. package/src/live-voice/live-voice-session.ts +784 -64
  46. package/src/live-voice/protocol.ts +20 -0
  47. package/src/live-voice/session-controls.ts +61 -3
  48. package/src/monitoring/plugin-auto-update.ts +6 -0
  49. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +15 -0
  50. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  51. package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
  52. package/src/runtime/routes/plugins-routes.ts +69 -49
  53. package/src/skills/managed-store.ts +153 -31
  54. package/src/tools/skills/find-similar-skills.test.ts +214 -2
  55. package/src/tools/skills/find-similar-skills.ts +62 -9
  56. package/src/tools/skills/resolve-execute-invocation.ts +24 -0
  57. package/src/tools/skills/scaffold-managed.ts +16 -13
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Answering a look (`[LOOK:SCREEN]`, `[LOOK:CAMERA]`) on a turn of its own.
3
+ *
4
+ * The reply that asks for a look only acknowledges it. A client that declared
5
+ * `lookFrames` sends a fresh frame once it has carried the look out, and the
6
+ * session answers from that frame without waiting for the user to speak again.
7
+ * Without the declaration, or without the frame, nothing extra runs.
8
+ */
9
+
10
+ import { describe, expect, mock, test } from "bun:test";
11
+
12
+ import {
13
+ createMockProvider,
14
+ textResponse,
15
+ } from "../../__tests__/helpers/mock-provider.js";
16
+ import { setConfig } from "../../__tests__/helpers/set-config.js";
17
+ import { waitFor } from "../../__tests__/helpers/wait-for.js";
18
+
19
+ setConfig("memory", { enabled: false });
20
+
21
+ import type {
22
+ VoiceTurnCallbacks,
23
+ VoiceTurnOptions,
24
+ } from "../../calls/voice-session-bridge.js";
25
+ import { Conversation } from "../../daemon/conversation.js";
26
+ import {
27
+ deleteConversation,
28
+ setConversation,
29
+ } from "../../daemon/conversation-registry.js";
30
+ import { uploadAttachment } from "../../persistence/attachments-store.js";
31
+ import {
32
+ createConversation,
33
+ getMessages,
34
+ } from "../../persistence/conversation-crud.js";
35
+ import { initializeDb } from "../../persistence/db-init.js";
36
+ import type {
37
+ StreamingTranscriber,
38
+ SttStreamServerEvent,
39
+ } from "../../stt/types.js";
40
+ import {
41
+ LiveVoiceSession,
42
+ type LiveVoiceTtsStreamer,
43
+ } from "../live-voice-session.js";
44
+ import type { LiveVoiceSessionFactoryContext } from "../live-voice-session-manager.js";
45
+ import type { LiveVoiceTtsOptions } from "../live-voice-tts.js";
46
+ import {
47
+ createLiveVoiceServerFrameSequencer,
48
+ type LiveVoiceServerFrame,
49
+ } from "../protocol.js";
50
+ import {
51
+ LOOK_FOLLOW_UP_CONTENT,
52
+ LOOK_FRAME_REASON,
53
+ } from "../session-controls.js";
54
+
55
+ await initializeDb();
56
+
57
+ const IMAGE_BASE64 =
58
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk";
59
+
60
+ /** Hands over one spoken sentence each time a capture is released. */
61
+ class MockStreamingTranscriber implements StreamingTranscriber {
62
+ readonly providerId = "deepgram" as const;
63
+ readonly boundaryId = "daemon-streaming" as const;
64
+ private onEvent: ((event: SttStreamServerEvent) => void) | null = null;
65
+
66
+ async start(onEvent: (event: SttStreamServerEvent) => void): Promise<void> {
67
+ this.onEvent = onEvent;
68
+ }
69
+
70
+ sendAudio(): void {}
71
+
72
+ stop(): void {
73
+ this.onEvent?.({ type: "final", text: "look at my screen" });
74
+ this.onEvent?.({ type: "closed" });
75
+ }
76
+ }
77
+
78
+ function createHarness(options: { lookFrames: boolean }) {
79
+ const conversation = createConversation("Look follow-up");
80
+ const { provider } = createMockProvider([textResponse("")]);
81
+ const activeConversation = new Conversation(
82
+ conversation.id,
83
+ provider,
84
+ "system prompt",
85
+ () => {},
86
+ "/tmp",
87
+ { maxTokens: 4096 },
88
+ );
89
+ activeConversation.setTrustContext({
90
+ trustClass: "guardian",
91
+ sourceChannel: "vellum",
92
+ });
93
+ setConversation(conversation.id, activeConversation);
94
+
95
+ const sequencer = createLiveVoiceServerFrameSequencer();
96
+ const frames: LiveVoiceServerFrame[] = [];
97
+ const context: LiveVoiceSessionFactoryContext = {
98
+ sessionId: "session-look",
99
+ startFrame: {
100
+ type: "start",
101
+ conversationId: conversation.id,
102
+ audio: { mimeType: "audio/pcm", sampleRate: 24_000, channels: 1 },
103
+ textInput: true,
104
+ sessionControls: ["look_screen", "look_camera", "look_stop"],
105
+ ...(options.lookFrames ? { lookFrames: true } : {}),
106
+ },
107
+ sendFrame: mock(async (payload) => {
108
+ const frame = sequencer.next(payload);
109
+ frames.push(frame);
110
+ return frame;
111
+ }),
112
+ };
113
+
114
+ const turns: VoiceTurnOptions[] = [];
115
+ const startVoiceTurn = mock(async (turnOptions: VoiceTurnOptions) => {
116
+ turns.push(turnOptions);
117
+ return { turnId: `bridge-turn-${turns.length}`, abort: mock() };
118
+ });
119
+ const streamTtsAudio: LiveVoiceTtsStreamer = mock(
120
+ async (ttsOptions: LiveVoiceTtsOptions) => ({
121
+ provider: "fish-audio" as const,
122
+ contentType: "audio/pcm",
123
+ sampleRate: 24_000,
124
+ chunks: 1,
125
+ bytes: Buffer.byteLength(ttsOptions.text),
126
+ }),
127
+ );
128
+
129
+ let turnCount = 0;
130
+ const session = new LiveVoiceSession(context, {
131
+ resolveTranscriber: mock(async () => new MockStreamingTranscriber()),
132
+ startVoiceTurn,
133
+ streamTtsAudio,
134
+ createTurnId: () => `live-turn-${++turnCount}`,
135
+ emitMetrics: false,
136
+ });
137
+
138
+ const callbacks = (index: number): VoiceTurnCallbacks | undefined =>
139
+ turns[index]?.callbacks;
140
+
141
+ /** Finish turn `index` with `text` and wait for its speech to drain. */
142
+ const reply = async (index: number, text: string): Promise<void> => {
143
+ const turnCallbacks = callbacks(index);
144
+ turnCallbacks?.assistant_text_delta?.({
145
+ type: "assistant_text_delta",
146
+ text,
147
+ conversationId: conversation.id,
148
+ });
149
+ turnCallbacks?.message_complete?.({
150
+ type: "message_complete",
151
+ conversationId: conversation.id,
152
+ messageId: `assistant-message-${index}`,
153
+ });
154
+ const doneCount = index + 1;
155
+ await waitFor(
156
+ () =>
157
+ frames.filter((frame) => frame.type === "tts_done").length >= doneCount,
158
+ { message: `Timed out waiting for turn ${index} to drain` },
159
+ );
160
+ };
161
+
162
+ /** Ask for a look, and wait for the control to reach the client. */
163
+ const askForLook = async (): Promise<void> => {
164
+ await session.start();
165
+ await session.handleClientFrame({ type: "ptt_release" });
166
+ await waitFor(() => turns.length === 1, {
167
+ message: "Timed out waiting for the spoken turn",
168
+ });
169
+ await reply(0, "Taking a look. [LOOK:SCREEN]");
170
+ await waitFor(
171
+ () => frames.some((frame) => frame.type === "session_control"),
172
+ { message: "Timed out waiting for the look control" },
173
+ );
174
+ };
175
+
176
+ /** Send a real kept frame with the given timing reason. */
177
+ const sendFrame = async (reason: string): Promise<void> => {
178
+ const attachment = await uploadAttachment(
179
+ "frame.png",
180
+ "image/png",
181
+ IMAGE_BASE64,
182
+ );
183
+ const before = getMessages(conversation.id).length;
184
+ await session.handleClientFrame({
185
+ type: "sight_frame",
186
+ attachmentId: attachment.id,
187
+ timing: {
188
+ reason,
189
+ keepToEncodedMs: 1,
190
+ encodedToUploadedMs: 1,
191
+ uploadedToSentMs: 0,
192
+ bytes: 1,
193
+ },
194
+ });
195
+ await waitFor(() => getMessages(conversation.id).length > before, {
196
+ message: "Timed out waiting for the frame to persist",
197
+ });
198
+ };
199
+
200
+ return {
201
+ frames,
202
+ session,
203
+ turns,
204
+ reply,
205
+ askForLook,
206
+ sendFrame,
207
+ dispose: async () => {
208
+ await session.close("client_end");
209
+ deleteConversation(conversation.id);
210
+ activeConversation.dispose();
211
+ },
212
+ };
213
+ }
214
+
215
+ /** Long enough for a follow-up that was going to start to have started. */
216
+ async function settle(): Promise<void> {
217
+ await new Promise((resolve) => setTimeout(resolve, 600));
218
+ }
219
+
220
+ describe("live-voice look follow-up", () => {
221
+ test("answers the look from its frame on a hidden turn of its own", async () => {
222
+ const harness = createHarness({ lookFrames: true });
223
+ try {
224
+ await harness.askForLook();
225
+ expect(harness.turns).toHaveLength(1);
226
+
227
+ await harness.sendFrame(LOOK_FRAME_REASON);
228
+ await waitFor(() => harness.turns.length === 2, {
229
+ message: "Timed out waiting for the look to be answered",
230
+ });
231
+
232
+ const followUp = harness.turns[1];
233
+ expect(followUp?.content).toBe(LOOK_FOLLOW_UP_CONTENT);
234
+ expect(followUp?.hiddenSyntheticPrompt).toBe(true);
235
+ expect(followUp?.voiceControlPrompt).toContain(
236
+ "You just took a fresh look at their screen",
237
+ );
238
+ } finally {
239
+ await harness.dispose();
240
+ }
241
+ });
242
+
243
+ test("the reply that asks for a look is taught to only acknowledge it", async () => {
244
+ const harness = createHarness({ lookFrames: true });
245
+ try {
246
+ await harness.askForLook();
247
+ expect(harness.turns[0]?.voiceControlPrompt).toContain(
248
+ "Use it even when their screen is already shared with you",
249
+ );
250
+ } finally {
251
+ await harness.dispose();
252
+ }
253
+ });
254
+
255
+ test("a client that did not declare lookFrames keeps the old look", async () => {
256
+ const harness = createHarness({ lookFrames: false });
257
+ try {
258
+ await harness.askForLook();
259
+ expect(harness.turns[0]?.voiceControlPrompt).toContain(
260
+ "say you will take it from their next words",
261
+ );
262
+
263
+ await harness.sendFrame(LOOK_FRAME_REASON);
264
+ await settle();
265
+ expect(harness.turns).toHaveLength(1);
266
+ } finally {
267
+ await harness.dispose();
268
+ }
269
+ });
270
+
271
+ test("an ambient frame does not answer the look", async () => {
272
+ const harness = createHarness({ lookFrames: true });
273
+ try {
274
+ await harness.askForLook();
275
+ await harness.sendFrame("heartbeat");
276
+ await settle();
277
+ expect(harness.turns).toHaveLength(1);
278
+ } finally {
279
+ await harness.dispose();
280
+ }
281
+ });
282
+
283
+ // The turn started before the frame was in the conversation, so it could
284
+ // not have read it: the look is still owed an answer once that turn is done.
285
+ test("a turn that started before the frame landed does not answer the look", async () => {
286
+ const harness = createHarness({ lookFrames: true });
287
+ try {
288
+ await harness.askForLook();
289
+ await harness.session.handleClientFrame({
290
+ type: "text",
291
+ text: "the second dropdown",
292
+ });
293
+ await waitFor(() => harness.turns.length === 2, {
294
+ message: "Timed out waiting for the typed turn",
295
+ });
296
+
297
+ await harness.sendFrame(LOOK_FRAME_REASON);
298
+ await harness.reply(1, "Which one do you mean?");
299
+ await waitFor(() => harness.turns.length === 3, {
300
+ timeoutMs: 2_000,
301
+ message: "Timed out waiting for the look to be answered",
302
+ });
303
+ expect(harness.turns[2]?.content).toBe(LOOK_FOLLOW_UP_CONTENT);
304
+ } finally {
305
+ await harness.dispose();
306
+ }
307
+ });
308
+
309
+ test("a turn that starts after the frame landed is not answered twice", async () => {
310
+ const harness = createHarness({ lookFrames: true });
311
+ try {
312
+ await harness.askForLook();
313
+ await harness.session.handleClientFrame({
314
+ type: "text",
315
+ text: "the second dropdown",
316
+ });
317
+ await waitFor(() => harness.turns.length === 2, {
318
+ message: "Timed out waiting for the typed turn",
319
+ });
320
+
321
+ // Lands while the typed turn holds the floor, so the look waits.
322
+ await harness.sendFrame(LOOK_FRAME_REASON);
323
+ await harness.reply(1, "Which one do you mean?");
324
+ // The user's next turn starts before the look's wait checks again, and
325
+ // it reads the frame that is now in the conversation.
326
+ await harness.session.handleClientFrame({
327
+ type: "text",
328
+ text: "the plan picker",
329
+ });
330
+ await waitFor(() => harness.turns.length === 3, {
331
+ message: "Timed out waiting for the second typed turn",
332
+ });
333
+ await harness.reply(2, "That one sets the billing plan.");
334
+ await settle();
335
+
336
+ expect(harness.turns).toHaveLength(3);
337
+ expect(harness.turns[2]?.content).toBe("the plan picker");
338
+ } finally {
339
+ await harness.dispose();
340
+ }
341
+ });
342
+
343
+ test("an interrupt drops a look still waiting on its frame", async () => {
344
+ const harness = createHarness({ lookFrames: true });
345
+ try {
346
+ await harness.askForLook();
347
+ await harness.session.handleClientFrame({ type: "interrupt" });
348
+
349
+ await harness.sendFrame(LOOK_FRAME_REASON);
350
+ await settle();
351
+ expect(harness.turns).toHaveLength(1);
352
+ } finally {
353
+ await harness.dispose();
354
+ }
355
+ });
356
+
357
+ test("the turn that answers a look cannot chain another look", async () => {
358
+ const harness = createHarness({ lookFrames: true });
359
+ try {
360
+ await harness.askForLook();
361
+ await harness.sendFrame(LOOK_FRAME_REASON);
362
+ await waitFor(() => harness.turns.length === 2, {
363
+ message: "Timed out waiting for the look to be answered",
364
+ });
365
+
366
+ await harness.reply(1, "It is the plan picker. [LOOK:SCREEN]");
367
+ await harness.sendFrame(LOOK_FRAME_REASON);
368
+ await settle();
369
+ expect(harness.turns).toHaveLength(2);
370
+ } finally {
371
+ await harness.dispose();
372
+ }
373
+ });
374
+ });