@vellumai/assistant 0.11.9-dev.202609041515.59c84dd → 0.11.9-dev.202609041713.aefee3e

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 CHANGED
@@ -33,16 +33,6 @@ COPY --parents packages/*/package.json ./
33
33
  RUN bun install --frozen-lockfile --ignore-scripts \
34
34
  --filter='./assistant' --filter='./assistant/src/api'
35
35
 
36
- # The repo-root .dockerignore decides which skills (and files) ship.
37
- COPY skills ./skills
38
- RUN set -eu; for pkg in /app/skills/*/package.json; do \
39
- [ -e "$pkg" ] || continue; \
40
- dir="$(dirname "$pkg")"; \
41
- echo "Installing dependencies for $dir"; \
42
- (cd "$dir" && (bun install --frozen-lockfile 2>/dev/null || bun install)); \
43
- done
44
-
45
-
46
36
  # Final stage
47
37
  FROM debian:trixie-slim@sha256:4ffb3a1511099754cddc70eb1b12e50ffdb67619aa0ab6c13fcd800a78ef7c7a AS runner
48
38
 
@@ -131,6 +121,9 @@ COPY packages/twilio-client /app/packages/twilio-client
131
121
 
132
122
  COPY assistant /app/assistant
133
123
 
124
+ # The repo-root .dockerignore decides which skills (and files) ship.
125
+ COPY skills /app/skills
126
+
134
127
  # Materialize the git-ignored offline plugin manifest from its canonical source.
135
128
  # `assistant/src/cli/lib/plugin-catalog-local.ts` statically imports this JSON
136
129
  # (loaded even when platform features are enabled), so it must exist in the image
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.9-dev.202609041515.59c84dd",
3
+ "version": "0.11.9-dev.202609041713.aefee3e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -325,6 +325,8 @@ export interface VoiceTurnOptions {
325
325
  voiceTelemetry?: {
326
326
  sessionId: string;
327
327
  client?: ClientOs;
328
+ /** The control the session was started from (the start frame's `entry`). */
329
+ entry?: string;
328
330
  };
329
331
  /** Per-turn control prompt. Undefined uses the phone prompt; null disables it. */
330
332
  voiceControlPrompt?: string | null;
@@ -1116,12 +1118,20 @@ export async function startVoiceTurn(
1116
1118
  // path already fills from the same `detectClientOs()` value, so
1117
1119
  // a voice turn reports its platform in the column existing turn
1118
1120
  // analytics read rather than one only voice knows about.
1121
+ //
1122
+ // `voice_entry` is voice's own: which control started the
1123
+ // session the turn belongs to, so per-turn analytics can split
1124
+ // the companion from the app without a join back to the
1125
+ // session row.
1119
1126
  client: {
1120
1127
  voice: true,
1121
1128
  voice_session_id: opts.voiceTelemetry.sessionId,
1122
1129
  ...(opts.voiceTelemetry.client
1123
1130
  ? { os: opts.voiceTelemetry.client }
1124
1131
  : {}),
1132
+ ...(opts.voiceTelemetry.entry
1133
+ ? { voice_entry: opts.voiceTelemetry.entry }
1134
+ : {}),
1125
1135
  },
1126
1136
  }
1127
1137
  : {}),
@@ -121,7 +121,30 @@ describe("live-voice session telemetry", () => {
121
121
 
122
122
  await session.start();
123
123
 
124
- expect(recordLiveVoiceSessionStarted).toHaveBeenCalledWith("session-123");
124
+ expect(recordLiveVoiceSessionStarted).toHaveBeenCalledWith({
125
+ sessionId: "session-123",
126
+ screen: "started_unknown:unknown",
127
+ });
128
+
129
+ await session.close("client_end");
130
+ });
131
+
132
+ test("stamps the started row with the client and entry the start frame declared", async () => {
133
+ // The dimension this row exists to carry: the same `macos` client covers
134
+ // the chat's voice button, the companion's Talk and the voice key, and
135
+ // only the entry says which one the user reached for.
136
+ const session = readySession({
137
+ ...START_FRAME,
138
+ client: "macos",
139
+ entry: "companion",
140
+ });
141
+
142
+ await session.start();
143
+
144
+ expect(recordLiveVoiceSessionStarted).toHaveBeenCalledWith({
145
+ sessionId: "session-123",
146
+ screen: "started_macos:companion",
147
+ });
125
148
 
126
149
  await session.close("client_end");
127
150
  });
@@ -138,7 +161,10 @@ describe("live-voice session telemetry", () => {
138
161
 
139
162
  // The denominator of the failure rate: a session rejected before `ready`
140
163
  // is exactly the one that must not go missing from the started count.
141
- expect(recordLiveVoiceSessionStarted).toHaveBeenCalledWith("session-123");
164
+ expect(recordLiveVoiceSessionStarted).toHaveBeenCalledWith({
165
+ sessionId: "session-123",
166
+ screen: "started_unknown:unknown",
167
+ });
142
168
  });
143
169
 
144
170
  test("records the close reason and a completed outcome on a clean end", async () => {
@@ -329,4 +355,18 @@ describe("live-voice turn attribution", () => {
329
355
 
330
356
  expect(options.voiceTelemetry).toEqual({ sessionId: "session-123" });
331
357
  });
358
+
359
+ test("carries the entry point onto the turn beside the client", async () => {
360
+ const options = await captureTurnOptions({
361
+ ...START_FRAME,
362
+ client: "macos",
363
+ entry: "voice_key",
364
+ });
365
+
366
+ expect(options.voiceTelemetry).toEqual({
367
+ sessionId: "session-123",
368
+ client: "macos",
369
+ entry: "voice_key",
370
+ });
371
+ });
332
372
  });
@@ -339,6 +339,73 @@ describe("parseLiveVoiceClientTextFrame", () => {
339
339
  expect("client" in result.frame).toBe(false);
340
340
  });
341
341
 
342
+ test("parses the entry point from the start frame", () => {
343
+ const result = parseLiveVoiceClientTextFrame(
344
+ JSON.stringify({
345
+ type: "start",
346
+ client: "macos",
347
+ entry: "companion",
348
+ audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
349
+ }),
350
+ );
351
+
352
+ expect(result.ok).toBe(true);
353
+ if (!result.ok) {
354
+ return;
355
+ }
356
+
357
+ expect(result.frame).toMatchObject({
358
+ type: "start",
359
+ client: "macos",
360
+ entry: "companion",
361
+ });
362
+ });
363
+
364
+ test("carries an entry token it has never seen, since the clients mint them", () => {
365
+ // An open set on purpose: a daemon older than the client that sent the
366
+ // value must carry it through rather than erase it.
367
+ const result = parseLiveVoiceClientTextFrame(
368
+ JSON.stringify({
369
+ type: "start",
370
+ entry: "some_future_surface",
371
+ audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
372
+ }),
373
+ );
374
+
375
+ expect(result.ok).toBe(true);
376
+ if (!result.ok) {
377
+ return;
378
+ }
379
+
380
+ expect(result.frame).toMatchObject({ entry: "some_future_surface" });
381
+ });
382
+
383
+ test.each([
384
+ ["not a string", 42],
385
+ ["empty", ""],
386
+ ["uppercase", "Companion"],
387
+ ["punctuation", "companion:talk"],
388
+ ["over-long", "a".repeat(33)],
389
+ ])(
390
+ "drops an off-shape entry (%s) rather than rejecting the session",
391
+ (_label, entry) => {
392
+ const result = parseLiveVoiceClientTextFrame(
393
+ JSON.stringify({
394
+ type: "start",
395
+ entry,
396
+ audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
397
+ }),
398
+ );
399
+
400
+ expect(result.ok).toBe(true);
401
+ if (!result.ok) {
402
+ return;
403
+ }
404
+
405
+ expect("entry" in result.frame).toBe(false);
406
+ },
407
+ );
408
+
342
409
  test("drops an unrecognized client rather than rejecting the session", () => {
343
410
  const result = parseLiveVoiceClientTextFrame(
344
411
  JSON.stringify({
@@ -80,6 +80,7 @@ import { getSubagentManager } from "../subagent/index.js";
80
80
  import {
81
81
  liveVoiceEndScreen,
82
82
  liveVoiceSilenceReason,
83
+ liveVoiceStartScreen,
83
84
  } from "../telemetry/live-voice-funnel.js";
84
85
  import { getToolOwner } from "../tools/registry.js";
85
86
  import {
@@ -1493,7 +1494,13 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1493
1494
  // credentials is precisely the one the failure rate needs to count, and
1494
1495
  // recording the start only once `ready` goes out would hide every such
1495
1496
  // session from both the numerator and the denominator.
1496
- recordLiveVoiceSessionStarted(this.context.sessionId);
1497
+ recordLiveVoiceSessionStarted({
1498
+ sessionId: this.context.sessionId,
1499
+ screen: liveVoiceStartScreen(
1500
+ this.context.startFrame.client,
1501
+ this.context.startFrame.entry,
1502
+ ),
1503
+ });
1497
1504
 
1498
1505
  if (this.resolveCredentialReadiness) {
1499
1506
  const readiness = await this.resolveCredentialReadiness();
@@ -5262,6 +5269,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5262
5269
  ...(this.context.startFrame.client
5263
5270
  ? { client: this.context.startFrame.client }
5264
5271
  : {}),
5272
+ ...(this.context.startFrame.entry
5273
+ ? { entry: this.context.startFrame.entry }
5274
+ : {}),
5265
5275
  },
5266
5276
  voiceControlPrompt: buildVoiceControlPrompt(activeTurn, {
5267
5277
  ...(leg.frontDoor !== undefined ? { frontDoor: leg.frontDoor } : {}),
@@ -135,6 +135,39 @@ export interface LiveVoiceClientStartFrame {
135
135
  * load-bearing for what a voice turn may do.
136
136
  */
137
137
  readonly client?: ClientOs;
138
+ /**
139
+ * Which control the session was asked from, as distinct from which client
140
+ * (`client`) it was asked on. The macOS app alone has three: the chat's
141
+ * voice button (`composer`), the companion surface's Talk (`companion`),
142
+ * and the voice key (`voice_key`, or `voice_key_ask` for a hold made over a
143
+ * selection). `deep_link` is Siri, a widget, the Action Button or a Live
144
+ * Activity tap, and `cli` the terminal client. Absent from clients that
145
+ * predate the field.
146
+ *
147
+ * Analytics only, exactly like `client`, and unlike `client` an open string
148
+ * rather than a closed set: the values are minted where the controls are,
149
+ * in the clients, and a daemon older than the client that sent one must
150
+ * carry the value through rather than erase it. The parser bounds the shape
151
+ * instead ({@link parseLiveVoiceEntry}) and drops anything outside it, so a
152
+ * malformed value costs a chart facet and never the session.
153
+ */
154
+ readonly entry?: string;
155
+ }
156
+
157
+ /**
158
+ * The shape a start frame's `entry` must have: a short snake_case token.
159
+ *
160
+ * The bound is set by where the value lands. The started telemetry row stamps
161
+ * `started_<client>:<entry>` into a 64-character wire field, and the longest
162
+ * `ClientOs` is seven characters, so 32 leaves the stamp well inside it.
163
+ */
164
+ const LIVE_VOICE_ENTRY_PATTERN = /^[a-z][a-z0-9_]{0,31}$/;
165
+
166
+ /** Parse a start frame's `entry`. Returns `null` for anything off-shape. */
167
+ export function parseLiveVoiceEntry(value: unknown): string | null {
168
+ return typeof value === "string" && LIVE_VOICE_ENTRY_PATTERN.test(value)
169
+ ? value
170
+ : null;
138
171
  }
139
172
 
140
173
  /**
@@ -1088,6 +1121,8 @@ function validateStartFrame(
1088
1121
  // analytics dimension, and failing a session's startup over it would trade a
1089
1122
  // gap in a chart for a user who cannot talk to their assistant.
1090
1123
  const client = parseClientOs(value.client);
1124
+ // Same policy for the same reason: a dimension, not a capability.
1125
+ const entry = parseLiveVoiceEntry(value.entry);
1091
1126
 
1092
1127
  return {
1093
1128
  ok: true,
@@ -1097,6 +1132,7 @@ function validateStartFrame(
1097
1132
  ? { conversationId: value.conversationId }
1098
1133
  : {}),
1099
1134
  ...(client ? { client } : {}),
1135
+ ...(entry ? { entry } : {}),
1100
1136
  audio: audioConfig.frame,
1101
1137
  ...(isLiveVoiceTurnDetectionMode(value.turnDetection)
1102
1138
  ? { turnDetection: value.turnDetection }
@@ -48,6 +48,18 @@ describe("PROVIDER_SEED_DATA managed mode wiring", () => {
48
48
  );
49
49
  expect(gated).toEqual([]);
50
50
 
51
+ // `selections:read` is documented by Figma but not offered in the app's
52
+ // OAuth scope list, so requesting it fails the whole authorization. It
53
+ // stays in availableScopes for BYO apps that do have it.
54
+ expect(figma.defaultScopes).not.toContain("selections:read");
55
+ const availableScopes = figma.availableScopes;
56
+ expect(Array.isArray(availableScopes)).toBe(true);
57
+ if (Array.isArray(availableScopes)) {
58
+ expect(availableScopes.map(({ scope }) => scope)).toContain(
59
+ "selections:read",
60
+ );
61
+ }
62
+
51
63
  // GET /v1/me backs both the ping and the identity label.
52
64
  expect(figma.defaultScopes).toContain("current_user:read");
53
65
  });
@@ -980,7 +980,9 @@ export const PROVIDER_SEED_DATA: Record<
980
980
  // on it. Enterprise-only and org-admin-only scopes (file_variables:*,
981
981
  // library_analytics:read, org:*) are offered but not requested by
982
982
  // default, because Figma rejects the whole authorization request if the
983
- // app cannot grant a requested scope.
983
+ // app cannot grant a requested scope. `selections:read` is withheld for
984
+ // that same reason: it is not offered in the app's OAuth scope list, so
985
+ // requesting it would fail the entire authorization.
984
986
  defaultScopes: [
985
987
  "current_user:read",
986
988
  "file_content:read",
@@ -995,7 +997,6 @@ export const PROVIDER_SEED_DATA: Record<
995
997
  "library_content:read",
996
998
  "library_assets:read",
997
999
  "team_library_content:read",
998
- "selections:read",
999
1000
  ],
1000
1001
  availableScopes: [
1001
1002
  {
@@ -130,9 +130,10 @@ export function recordActivationEvent(params: {
130
130
  * `telemetry/live-voice-funnel.ts` for why duration and turn count are derived
131
131
  * from that pairing rather than carried as fields.
132
132
  *
133
- * `screen` carries the end reason on the ended event (the started event has no
134
- * dimension beyond the step itself). Returns null when usage data collection is
135
- * disabled or the telemetry database is unavailable.
133
+ * `screen` carries the end reason on the ended event and the originating
134
+ * client and entry point on the started one (see `liveVoiceStartScreen`).
135
+ * Returns null when usage data collection is disabled or the telemetry
136
+ * database is unavailable.
136
137
  *
137
138
  * **Never throws.** These fire from `LiveVoiceSession.start()` and `.close()`,
138
139
  * which are the user's call itself: the outbox insert raises on a telemetry DB
@@ -176,14 +177,19 @@ export function recordLiveVoiceSessionEvent(params: {
176
177
  );
177
178
  }
178
179
 
179
- /** Record the "a live-voice session was attempted" milestone. */
180
- export function recordLiveVoiceSessionStarted(
181
- sessionId: string,
182
- ): OnboardingEvent | null {
180
+ /**
181
+ * Record the "a live-voice session was attempted" milestone, with where it
182
+ * was attempted from.
183
+ */
184
+ export function recordLiveVoiceSessionStarted(params: {
185
+ sessionId: string;
186
+ screen: string;
187
+ }): OnboardingEvent | null {
183
188
  return recordLiveVoiceSessionEvent({
184
189
  stepName: LIVE_VOICE_STEPS.sessionStarted.stepName,
185
190
  stepIndex: LIVE_VOICE_STEPS.sessionStarted.stepIndex,
186
- sessionId,
191
+ sessionId: params.sessionId,
192
+ screen: params.screen,
187
193
  });
188
194
  }
189
195
 
@@ -3,8 +3,40 @@ import { describe, expect, it } from "bun:test";
3
3
  import {
4
4
  liveVoiceEndScreen,
5
5
  liveVoiceSilenceReason,
6
+ liveVoiceStartScreen,
6
7
  } from "../live-voice-funnel.js";
7
8
 
9
+ describe("liveVoiceStartScreen", () => {
10
+ it("stamps the client and the entry point", () => {
11
+ expect(liveVoiceStartScreen("macos", "companion")).toBe(
12
+ "started_macos:companion",
13
+ );
14
+ });
15
+
16
+ it("keeps both halves present when a client sends neither", () => {
17
+ // A query splits the stamp on its colon; a stand-in for a missing half
18
+ // keeps that split from needing a null branch per side.
19
+ expect(liveVoiceStartScreen(undefined, undefined)).toBe(
20
+ "started_unknown:unknown",
21
+ );
22
+ expect(liveVoiceStartScreen(null, null)).toBe("started_unknown:unknown");
23
+ });
24
+
25
+ it("stands in for whichever half is missing on its own", () => {
26
+ expect(liveVoiceStartScreen("ios")).toBe("started_ios:unknown");
27
+ expect(liveVoiceStartScreen(undefined, "deep_link")).toBe(
28
+ "started_unknown:deep_link",
29
+ );
30
+ });
31
+
32
+ it("keeps the longest stamp inside the wire field's 64-char bound", () => {
33
+ // The parser caps `entry` at 32 characters and the longest `ClientOs` is
34
+ // `windows`; the stamp built from both must still fit the ingest cap.
35
+ const longest = liveVoiceStartScreen("windows", "a".repeat(32));
36
+ expect(longest.length).toBeLessThanOrEqual(64);
37
+ });
38
+ });
39
+
8
40
  describe("liveVoiceSilenceReason", () => {
9
41
  it("blames the transport when the session never went active", () => {
10
42
  // Nothing after `ready` can be held against a session that never got
@@ -24,6 +24,7 @@
24
24
  * unmatched `started` rows instead of treating them as open-ended sessions.
25
25
  */
26
26
 
27
+ import type { ClientOs } from "../channels/types.js";
27
28
  import type { LiveVoiceSessionCloseReason } from "../live-voice/live-voice-session-manager.js";
28
29
  import type { LiveVoiceProtocolErrorCode } from "../live-voice/protocol.js";
29
30
 
@@ -116,6 +117,38 @@ export function liveVoiceSilenceReason(signals: {
116
117
  return "no_turn";
117
118
  }
118
119
 
120
+ /**
121
+ * The half of a start stamp that stands in for a dimension the client did not
122
+ * send: an older client, or one that sends no identity at all.
123
+ */
124
+ const LIVE_VOICE_UNATTRIBUTED = "unknown";
125
+
126
+ /**
127
+ * The `screen` dimension for a started session: the client it started on and
128
+ * the control it started from, as `started_<client>:<entry>`.
129
+ *
130
+ * Both halves come off the start frame verbatim (`client` is a `ClientOs`;
131
+ * `entry` is the client's own token, see `protocol.ts`). Both are always
132
+ * present, with {@link LIVE_VOICE_UNATTRIBUTED} standing in for a missing one,
133
+ * so a query can split the stamp on its colon without a null branch per half.
134
+ *
135
+ * The client rides the started row and not only the turn rows because a
136
+ * quarter of sessions never produce a turn, and those are the ones the client
137
+ * dimension is most needed on: "voice did nothing for me" is a per-client
138
+ * question. Until now those sessions had no client at all (the admin
139
+ * dashboard's `(no turns)` bucket); this stamp gives every session one.
140
+ *
141
+ * The entry is what separates the macOS app's three ways in from each other:
142
+ * the same `client` covers the chat's voice button, the companion's Talk and
143
+ * the voice key, and only the entry says which one the user reached for.
144
+ */
145
+ export function liveVoiceStartScreen(
146
+ client?: ClientOs | null,
147
+ entry?: string | null,
148
+ ): string {
149
+ return `started_${client ?? LIVE_VOICE_UNATTRIBUTED}:${entry ?? LIVE_VOICE_UNATTRIBUTED}`;
150
+ }
151
+
119
152
  /**
120
153
  * The `screen` dimension for an ended session: how it closed, plus a detail
121
154
  * half. That detail is the protocol error code when a failure closed it, or the