@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 +3 -10
- package/package.json +1 -1
- package/src/calls/voice-session-bridge.ts +10 -0
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +42 -2
- package/src/live-voice/__tests__/protocol.test.ts +67 -0
- package/src/live-voice/live-voice-session.ts +11 -1
- package/src/live-voice/protocol.ts +36 -0
- package/src/oauth/__tests__/seed-providers-managed.test.ts +12 -0
- package/src/oauth/seed-providers.ts +3 -2
- package/src/onboarding/onboarding-events-store.ts +14 -8
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +32 -0
- package/src/telemetry/live-voice-funnel.ts +33 -0
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
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
134
|
-
*
|
|
135
|
-
* disabled or the telemetry
|
|
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
|
-
/**
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|