@vellumai/assistant 0.12.2-staging.4 → 0.12.2-staging.5

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.
@@ -160,6 +160,11 @@ import {
160
160
  } from "./protocol.js";
161
161
  import {
162
162
  type ClientSessionControlRequest,
163
+ isLookSessionControl,
164
+ LOOK_FOLLOW_UP_CONTENT,
165
+ LOOK_FRAME_REASON,
166
+ lookFollowUpNote,
167
+ type LookSessionControl,
163
168
  progressConfigForCadence,
164
169
  requestedSessionControl,
165
170
  sessionControlTeaching,
@@ -270,6 +275,18 @@ const CONTINUATION_ANNOUNCE_MAX_DRAIN_REARMS = 3;
270
275
  // persisted on the user side is this marker, and it persists hidden.
271
276
  export const CONTINUATION_DELIVERY_CONTENT =
272
277
  "(background work finished — deliver it now)";
278
+ // How long a look control waits for the fresh frame the client takes for it.
279
+ // Long enough for a camera that has to open and warm up before its first keep;
280
+ // past it the look is dropped rather than answered into a later, unrelated
281
+ // silence.
282
+ const LOOK_FRAME_WAIT_MS = 10_000;
283
+ // How long after the look was asked for its answer may still start. Past it
284
+ // the floor has been taken for so long (a user talking through noise that never
285
+ // became a turn, say) that the look no longer answers what is on screen.
286
+ const LOOK_ANSWER_DEADLINE_MS = 15_000;
287
+ // The shortest wait before checking the floor again, for a blocker with no
288
+ // known end time (the look's own turn clearing, an utterance in capture).
289
+ const LOOK_FOLLOW_UP_REARM_MS = 250;
273
290
 
274
291
  export type LiveVoiceStreamingTranscriberResolver = (
275
292
  options: ResolveStreamingTranscriberOptions,
@@ -697,6 +714,10 @@ interface ActiveAssistantTurn {
697
714
  // no user utterance behind it — `content` is CONTINUATION_DELIVERY_CONTENT and
698
715
  // the answer rides the control prompt (buildLiveDeliveryNote).
699
716
  continuationDelivery: ContinuationDelivery | null;
717
+ // Set only on the turn that answers a look: which look it answers. The turn
718
+ // has no user utterance behind it; the instruction rides the control prompt
719
+ // (lookFollowUpNote).
720
+ lookFollowUp: LookSessionControl | null;
700
721
  // The turn's content is an internal instruction rather than user speech (the
701
722
  // greeting that opens a session, say). The row still persists and the model
702
723
  // still sees it; `hiddenSyntheticPrompt` keeps it out of the transcript.
@@ -838,6 +859,14 @@ function describeInterruptedRequest(request: string): string {
838
859
  : "their earlier request";
839
860
  }
840
861
 
862
+ // A look control waiting on its fresh frame: which look, when it was asked
863
+ // for, and the wait's bound.
864
+ interface PendingLook {
865
+ action: LookSessionControl;
866
+ armedAtMs: number;
867
+ timer: ReturnType<typeof setTimeout>;
868
+ }
869
+
841
870
  // A finished background continuation waiting to be delivered: the request it
842
871
  // took over and the answer it produced.
843
872
  interface ContinuationDelivery {
@@ -866,13 +895,14 @@ function buildVoiceControlPrompt(
866
895
  turn: ActiveAssistantTurn,
867
896
  leg: { frontDoor?: boolean },
868
897
  sessionControls: readonly LiveVoiceSessionControl[],
898
+ client: { lookFrames: boolean },
869
899
  ): string {
870
900
  let prompt =
871
901
  LIVE_VOICE_CONTROL_PROMPT_BASE +
872
902
  (leg.frontDoor === true
873
903
  ? ""
874
904
  : LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING) +
875
- sessionControlTeaching(sessionControls, leg);
905
+ sessionControlTeaching(sessionControls, leg, client);
876
906
  if (turn.language !== undefined) {
877
907
  prompt = `${prompt}\n\nThe caller has been speaking the language with code "${turn.language}" this turn. Reply in that language unless they clearly switch to another.`;
878
908
  }
@@ -885,6 +915,9 @@ function buildVoiceControlPrompt(
885
915
  if (turn.handedOffRequest) {
886
916
  prompt = `${prompt}\n\n${buildHandoffAnnouncementNote(turn.handedOffRequest)}`;
887
917
  }
918
+ if (turn.lookFollowUp !== null) {
919
+ prompt = `${prompt}\n\n${lookFollowUpNote(turn.lookFollowUp)}`;
920
+ }
888
921
  if (turn.continuationDelivery) {
889
922
  prompt = `${prompt}\n\n${buildLiveDeliveryNote(
890
923
  turn.continuationDelivery.request,
@@ -1135,6 +1168,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1135
1168
  // The session controls the client declared it can carry out; the only ones
1136
1169
  // the model is taught and the only ones a reply's marker can trigger.
1137
1170
  private readonly sessionControls: readonly LiveVoiceSessionControl[];
1171
+ // The client declared `lookFrames`: it sends a fresh frame for every look
1172
+ // control it carries out, so the session answers a look on its own turn and
1173
+ // teaches the model that the reply asking for one is only the acknowledgement.
1174
+ private readonly lookFrames: boolean;
1138
1175
  // How often progress updates are spoken, as the user last asked out loud.
1139
1176
  // Session-scoped: it applies from the next turn to the end of the call.
1140
1177
  private progressCadence: "fewer" | "normal" = "normal";
@@ -1222,6 +1259,13 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1222
1259
  // answer.
1223
1260
  private pendingAnnouncement: ContinuationDelivery | null = null;
1224
1261
  private announcementTimer: ReturnType<typeof setTimeout> | null = null;
1262
+ // A look control sent to a client that declared `lookFrames`, waiting for
1263
+ // the fresh frame the client takes for it. The session answers the look on a
1264
+ // turn of its own once that frame is in the conversation (see
1265
+ // answerLookWhenFloorIsFree). Cleared when the frame lands, when the wait
1266
+ // runs out, and when the session closes.
1267
+ private pendingLook: PendingLook | null = null;
1268
+ private lookFollowUpTimer: ReturnType<typeof setTimeout> | null = null;
1225
1269
  // Set when a continuation actually spawns: the request it took over, so the
1226
1270
  // NEXT turn can tell the user the work is still running. Consumed by that
1227
1271
  // turn; cleared when the continuation finishes (by then the result note
@@ -1236,6 +1280,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1236
1280
  // the distance from it to a keep arriving is the client leg of the frame the
1237
1281
  // onset asked for, measured from the daemon's own clock.
1238
1282
  private lastSpeechStartedAtMs: number | null = null;
1283
+ // How many assistant turns have launched, so a look can tell whether one has
1284
+ // started since its frame landed. A count rather than a time: two launches
1285
+ // and a frame can share a millisecond.
1286
+ private turnsLaunched = 0;
1239
1287
  private readonly maxPendingAudioBytes: number;
1240
1288
  // Set on VAD speech onset; consumed when the first speech chunk is routed
1241
1289
  // to an utterance so the metric lands on the right turn.
@@ -1413,6 +1461,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1413
1461
  SERVER_VAD_PENDING_AUDIO_MAX_SECONDS;
1414
1462
  this.textInput = context.startFrame.textInput === true;
1415
1463
  this.sessionControls = context.startFrame.sessionControls ?? [];
1464
+ this.lookFrames = context.startFrame.lookFrames === true;
1416
1465
  }
1417
1466
 
1418
1467
  get finalTranscriptText(): string {
@@ -1661,6 +1710,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1661
1710
  },
1662
1711
  "Sight frame timing",
1663
1712
  );
1713
+ // Only once the row is in: the turn that answers the look reads the
1714
+ // conversation, so the frame has to be there before that turn starts.
1715
+ if (result.ok && frame.timing?.reason === LOOK_FRAME_REASON) {
1716
+ this.lookFrameLanded();
1717
+ }
1664
1718
  if (!result.ok && !this.isClosed) {
1665
1719
  void this.sendFrame({
1666
1720
  type: "error",
@@ -1866,6 +1920,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1866
1920
  // cleared: the announcement is dead either way.
1867
1921
  await this.deliverPendingContinuationToConversation();
1868
1922
  this.clearContinuationAnnouncement();
1923
+ this.clearPendingLook();
1869
1924
  this.stopSessionTranscriber();
1870
1925
  // Detached continuations outlive the call. A deliberate `interrupt()`
1871
1926
  // aborts them; ending the session leaves them running. With no next voice
@@ -2909,16 +2964,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2909
2964
  // already finished, so bargeIn returns it to the stash instead.
2910
2965
  turn.continuationDelivery !== null
2911
2966
  ? "announcement_turn"
2912
- : // The model already finished generating (barge-in during TTS playback
2913
- // of a complete reply): there is nothing to continue, so a
2914
- // continuation would just re-do a finished answer.
2915
- turn.assistantCompleted
2916
- ? "assistant_already_completed"
2917
- : // A stop (interrupt/close) or a superseding invalidation landed
2918
- // during the barge-in teardown: honor it.
2919
- this.detachStopGeneration !== stopGeneration
2920
- ? "invalidated_during_barge_teardown"
2921
- : null;
2967
+ : // Nor over the answer to a look: there is no request behind it
2968
+ // either, and the user talking over it is them moving on.
2969
+ turn.lookFollowUp !== null
2970
+ ? "look_follow_up"
2971
+ : // The model already finished generating (barge-in during TTS playback
2972
+ // of a complete reply): there is nothing to continue, so a
2973
+ // continuation would just re-do a finished answer.
2974
+ turn.assistantCompleted
2975
+ ? "assistant_already_completed"
2976
+ : // A stop (interrupt/close) or a superseding invalidation landed
2977
+ // during the barge-in teardown: honor it.
2978
+ this.detachStopGeneration !== stopGeneration
2979
+ ? "invalidated_during_barge_teardown"
2980
+ : null;
2922
2981
  if (skipReason !== null) {
2923
2982
  log.info(
2924
2983
  { turnId: turn.turnId, skipReason },
@@ -3565,6 +3624,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3565
3624
  // own it leaves manual mode blind to a user who is talking right now and
3566
3625
  // has no text yet — hence the captured-audio flag, which manual ingress
3567
3626
  // sets from the first chunk.
3627
+ //
3628
+ // A partial counts only when it has words. Deepgram Flux sends interim
3629
+ // updates through silence too, each an empty partial, and one of those
3630
+ // would otherwise hold the floor until the user next spoke.
3568
3631
  if (
3569
3632
  utterance !== null &&
3570
3633
  !utterance.completed &&
@@ -3572,13 +3635,129 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3572
3635
  utterance.assistantTurnStarted ||
3573
3636
  (utterance.manualAudioCaptured && opts?.ignoreManualCapture !== true) ||
3574
3637
  utterance.finalTranscriptSegments.length > 0 ||
3575
- utterance.latestPartialText !== null)
3638
+ (utterance.latestPartialText?.trim() ?? "").length > 0)
3576
3639
  ) {
3577
3640
  return "utterance_in_flight";
3578
3641
  }
3579
3642
  return null;
3580
3643
  }
3581
3644
 
3645
+ /**
3646
+ * Wait for the fresh frame a look control asks the client for.
3647
+ *
3648
+ * Only for a client that declared `lookFrames`: any other sends no such
3649
+ * frame, and its look keeps the old shape, where the user's next words are
3650
+ * what the frame gets answered on. A newer look replaces an older one still
3651
+ * waiting, and the wait is bounded so a frame that never comes (a camera
3652
+ * that would not open, a share the desktop refused) cannot turn up minutes
3653
+ * later as a reply to nothing.
3654
+ */
3655
+ private awaitLookFrame(action: LookSessionControl): void {
3656
+ if (!this.lookFrames) {
3657
+ return;
3658
+ }
3659
+ this.clearPendingLook();
3660
+ const timer = setTimeout(() => {
3661
+ if (this.pendingLook?.timer !== timer) {
3662
+ return;
3663
+ }
3664
+ this.pendingLook = null;
3665
+ log.info(
3666
+ { conversationId: this.conversationId, action },
3667
+ "Live voice look dropped: no frame arrived",
3668
+ );
3669
+ }, LOOK_FRAME_WAIT_MS);
3670
+ this.pendingLook = { action, armedAtMs: Date.now(), timer };
3671
+ }
3672
+
3673
+ private clearPendingLook(): void {
3674
+ if (this.pendingLook !== null) {
3675
+ clearTimeout(this.pendingLook.timer);
3676
+ this.pendingLook = null;
3677
+ }
3678
+ if (this.lookFollowUpTimer !== null) {
3679
+ clearTimeout(this.lookFollowUpTimer);
3680
+ this.lookFollowUpTimer = null;
3681
+ }
3682
+ }
3683
+
3684
+ /**
3685
+ * The frame a look asked for is in the conversation: answer the look.
3686
+ *
3687
+ * A look frame with no look waiting (the wait ran out, or the session never
3688
+ * armed one) is only a frame, like any other keep.
3689
+ */
3690
+ private lookFrameLanded(): void {
3691
+ const pending = this.pendingLook;
3692
+ if (pending === null || this.isClosed) {
3693
+ return;
3694
+ }
3695
+ clearTimeout(pending.timer);
3696
+ this.pendingLook = null;
3697
+ this.answerLookWhenFloorIsFree(pending, this.turnsLaunched, 0);
3698
+ }
3699
+
3700
+ /**
3701
+ * Start the turn that answers a look, once nothing else holds the floor.
3702
+ *
3703
+ * The acknowledgement ("taking a look") can still be playing when the frame
3704
+ * lands, and the look's own turn can still be clearing, so both are waited
3705
+ * out, as is the user mid-utterance. A turn that starts once the frame is in
3706
+ * the conversation is not: it reads the frame, so answering the look as well
3707
+ * would answer it twice. A turn that started before the frame landed did not
3708
+ * see it, so the look is still answered once that turn is done.
3709
+ *
3710
+ * `turnsAtFrame` is how many turns had launched when the frame landed.
3711
+ */
3712
+ private answerLookWhenFloorIsFree(
3713
+ look: PendingLook,
3714
+ turnsAtFrame: number,
3715
+ rearms: number,
3716
+ ): void {
3717
+ if (this.lookFollowUpTimer !== null) {
3718
+ clearTimeout(this.lookFollowUpTimer);
3719
+ this.lookFollowUpTimer = null;
3720
+ }
3721
+ const { action, armedAtMs } = look;
3722
+ // A turn launched since the frame landed read it already. Speech that
3723
+ // never became a turn (a cough, noise that transcribed to nothing) is only
3724
+ // waited out.
3725
+ const blockedBy =
3726
+ this.turnsLaunched > turnsAtFrame
3727
+ ? "turn_since_look"
3728
+ : this.sessionTurnFloorBlocker();
3729
+ if (blockedBy === null) {
3730
+ void this.launchAssistantTurn(
3731
+ createSyntheticUtterance(),
3732
+ LOOK_FOLLOW_UP_CONTENT,
3733
+ { lookFollowUp: action, hiddenPrompt: true },
3734
+ ).catch((err: unknown) => {
3735
+ log.warn(
3736
+ { err, conversationId: this.conversationId, action },
3737
+ "Live voice look follow-up failed to start",
3738
+ );
3739
+ });
3740
+ return;
3741
+ }
3742
+ const waitable =
3743
+ blockedBy !== "turn_since_look" && blockedBy !== "session_unavailable";
3744
+ if (!waitable || Date.now() - armedAtMs >= LOOK_ANSWER_DEADLINE_MS) {
3745
+ log.info(
3746
+ { conversationId: this.conversationId, action, blockedBy, rearms },
3747
+ "Live voice look follow-up skipped",
3748
+ );
3749
+ return;
3750
+ }
3751
+ const drainMs = Math.max(0, this.assistantPlaybackTailUntilMs - Date.now());
3752
+ this.lookFollowUpTimer = setTimeout(
3753
+ () => {
3754
+ this.lookFollowUpTimer = null;
3755
+ this.answerLookWhenFloorIsFree(look, turnsAtFrame, rearms + 1);
3756
+ },
3757
+ Math.max(drainMs, LOOK_FOLLOW_UP_REARM_MS),
3758
+ );
3759
+ }
3760
+
3582
3761
  // Speak the finished continuation's result on a turn the session starts
3583
3762
  // itself. The turn rides a synthetic cycle rather than the armed VAD one —
3584
3763
  // `currentUtterance` belongs to the capture loop, and overwriting it would
@@ -4770,6 +4949,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4770
4949
  this.pendingInterruptedRequest = null;
4771
4950
  // ...and it hard-stops any detached background continuations.
4772
4951
  this.abortDetachedRuns({ reason: "client_interrupt" });
4952
+ // ...and a look still waiting to be answered: the user stopped the call
4953
+ // talking, and a reply starting on its own a moment later is not a stop.
4954
+ this.clearPendingLook();
4773
4955
  const utterance = this.currentUtterance;
4774
4956
  this.stopSessionTranscriber();
4775
4957
  if (utterance) {
@@ -4991,6 +5173,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4991
5173
  // Set on an announcement turn: the finished continuation this turn exists
4992
5174
  // to deliver. Its answer goes in the control prompt, not in `content`.
4993
5175
  continuationDelivery?: ContinuationDelivery | null;
5176
+ // Set on the turn that answers a look: which look. Its instruction goes
5177
+ // in the control prompt, not in `content`.
5178
+ lookFollowUp?: LookSessionControl;
4994
5179
  // Unified front-door: dispatch without releasing the utterance. The
4995
5180
  // thinking frame and floor-holding timers are deferred until the leg's
4996
5181
  // leading verdict commits the turn (see commitSpeculativeTurn); a hold
@@ -5085,6 +5270,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5085
5270
  consumedAnnouncement: pending?.announcement ?? null,
5086
5271
  pendingContextStopGeneration: this.detachStopGeneration,
5087
5272
  continuationDelivery: opts?.continuationDelivery ?? null,
5273
+ lookFollowUp: opts?.lookFollowUp ?? null,
5088
5274
  hiddenPrompt: opts?.hiddenPrompt === true,
5089
5275
  deltaEpoch: 0,
5090
5276
  frontDoor: null,
@@ -5100,6 +5286,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5100
5286
  assistantAudioMimeType: "audio/pcm",
5101
5287
  };
5102
5288
  this.activeAssistantTurn = activeTurn;
5289
+ this.turnsLaunched += 1;
5103
5290
 
5104
5291
  // A speculative turn defers the thinking frame and both floor-holding
5105
5292
  // timers to commitSpeculativeTurn: until the verdict arrives, the pause
@@ -5342,6 +5529,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5342
5529
  : {}),
5343
5530
  },
5344
5531
  this.sessionControls,
5532
+ { lookFrames: this.lookFrames },
5345
5533
  ),
5346
5534
  onApprovalPending: (requestId) => {
5347
5535
  this.revealRoomForPendingApproval(activeTurn, requestId);
@@ -5897,6 +6085,16 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5897
6085
  if (sessionControl.action === "end") {
5898
6086
  currentTurn.minimizeRequested = false;
5899
6087
  }
6088
+ // Armed before the send, so a client quick enough to answer with
6089
+ // its frame before this await resolves still finds the look waiting.
6090
+ // Never from the turn that answers a look: that turn is the answer,
6091
+ // and a marker it emits anyway must not chain another.
6092
+ if (
6093
+ isLookSessionControl(sessionControl.action) &&
6094
+ currentTurn.lookFollowUp === null
6095
+ ) {
6096
+ this.awaitLookFrame(sessionControl.action);
6097
+ }
5900
6098
  await this.sendFrame(
5901
6099
  {
5902
6100
  type: "session_control",
@@ -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
  }
@@ -33,6 +33,55 @@ const CLIENT_CONTROL_LINES: Record<LiveVoiceSessionControl, string> = {
33
33
  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
34
  };
35
35
 
36
+ /**
37
+ * The look lines for a client that declared `lookFrames`: one that sends a
38
+ * fresh frame the moment it carries out a look, which the session answers on
39
+ * a turn of its own (see {@link LOOK_FRAME_REASON}). The reply that asks for
40
+ * the look is therefore only the acknowledgement, and asking to look again is
41
+ * how the model gets a current view of a share that is already running.
42
+ */
43
+ const LOOK_FRAME_CONTROL_LINES: Partial<
44
+ Record<LiveVoiceSessionControl, string>
45
+ > = {
46
+ 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.`,
47
+ 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.`,
48
+ };
49
+
50
+ /**
51
+ * The `reason` a client's sight frame timing carries when the frame is the
52
+ * fresh view it took for a look control. Only a client that declared
53
+ * `lookFrames` sends one, and the session answers the look from it.
54
+ */
55
+ export const LOOK_FRAME_REASON = "look";
56
+
57
+ /** The controls that end in a fresh frame the session answers from. */
58
+ export type LookSessionControl = Extract<
59
+ LiveVoiceSessionControl,
60
+ "look_screen" | "look_camera"
61
+ >;
62
+
63
+ export function isLookSessionControl(
64
+ action: string,
65
+ ): action is LookSessionControl {
66
+ return action === "look_screen" || action === "look_camera";
67
+ }
68
+
69
+ /**
70
+ * The persisted `content` of the turn that answers a look. Hidden: there is no
71
+ * user utterance behind the turn, and the instruction rides the control prompt
72
+ * ({@link lookFollowUpNote}).
73
+ */
74
+ export const LOOK_FOLLOW_UP_CONTENT = "(fresh view taken; answer from it now)";
75
+
76
+ /**
77
+ * Appended to the control prompt of the turn that answers a look: the reply
78
+ * that asked for it only acknowledged, and the frame it asked for has landed.
79
+ */
80
+ export function lookFollowUpNote(action: LookSessionControl): string {
81
+ const what = action === "look_screen" ? "their screen" : "their camera";
82
+ 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.`;
83
+ }
84
+
36
85
  // Always taught: narration is the session's own, so no client has to be able
37
86
  // to carry it out.
38
87
  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}.`;
@@ -49,10 +98,15 @@ const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (fo
49
98
  export function sessionControlTeaching(
50
99
  controls: readonly LiveVoiceSessionControl[],
51
100
  leg: { frontDoor?: boolean },
101
+ client: { lookFrames?: boolean } = {},
52
102
  ): string {
103
+ const lines =
104
+ client.lookFrames === true
105
+ ? { ...CLIENT_CONTROL_LINES, ...LOOK_FRAME_CONTROL_LINES }
106
+ : CLIENT_CONTROL_LINES;
53
107
  return [
54
108
  "The user can also control this call by asking you. Only when they clearly ask:",
55
- ...controls.map((control) => CLIENT_CONTROL_LINES[control]),
109
+ ...controls.map((control) => lines[control]),
56
110
  ...lookGuidance(controls),
57
111
  UPDATES_LINE,
58
112
  `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."}`,
@@ -115,6 +115,16 @@ 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
+ );
118
128
  // An UPDATE is announced by a notice whose only account of the change is
119
129
  // what the pass passes here, so the instruction has to ask for it.
120
130
  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: call \`scaffold_managed_skill\` with that \`skill_id\` and \`overwrite: true\`, rewriting \`body_markdown\` from what you actually observed in the trace, 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.
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 every other \`current\` field you are not changing (\`emoji\`, \`category\`, \`includes\`, \`avoid_when\`) since an overwrite replaces the whole file, 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
 
@@ -14,10 +14,12 @@ import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
14
14
 
15
15
  import { stringify as stringifyYaml } from "yaml";
16
16
 
17
+ import { parseFrontmatter } from "../config/skills.js";
17
18
  import { deleteSkillCapabilityNode } from "../plugins/defaults/memory/graph/capability-seed.js";
18
19
  import { isDeniedBasename } from "../tools/shared/filesystem/path-policy.js";
19
20
  import { getLogger } from "../util/logger.js";
20
21
  import { getWorkspaceDir, getWorkspaceSkillsDir } from "../util/platform.js";
22
+ import { parseFrontmatterFields } from "./frontmatter.js";
21
23
  import { writeInstallMeta } from "./install-meta.js";
22
24
 
23
25
  const log = getLogger("managed-store");
@@ -450,6 +452,60 @@ export function createManagedSkill(
450
452
  return { created: true, path: skillFilePath };
451
453
  }
452
454
 
455
+ /**
456
+ * A managed skill as it is on disk. Frontmatter fields come through the
457
+ * catalog's parser so they are exactly what routing and the Skills UI see;
458
+ * `body` is the stored text after the frontmatter, verbatim except for the
459
+ * separator newline the store writes before it and the trailing newline it
460
+ * guarantees. Verbatim matters: the skill loader substitutes `{baseDir}` and
461
+ * `{workspaceDir}` and strips feature-gated sections, and a caller that wrote
462
+ * that output back would bake absolute paths into the skill; and a first line
463
+ * that opens an indented code block must keep its indentation or a copy turns
464
+ * it into prose.
465
+ */
466
+ export interface StoredManagedSkill {
467
+ name: string;
468
+ description: string;
469
+ emoji?: string;
470
+ includes?: string[];
471
+ activationHints?: string[];
472
+ avoidWhen?: string[];
473
+ category?: string;
474
+ body: string;
475
+ }
476
+
477
+ /**
478
+ * Read a managed skill from disk. Best-effort: a missing file or frontmatter
479
+ * that does not parse resolves to null, so a caller enriching or patching one
480
+ * skill never fails on a bad one.
481
+ */
482
+ export function readStoredManagedSkill(
483
+ skillId: string,
484
+ ): StoredManagedSkill | null {
485
+ const skillFilePath = join(getManagedSkillDir(skillId), "SKILL.md");
486
+ try {
487
+ const content = readFileSync(skillFilePath, "utf-8");
488
+ const parsed = parseFrontmatter(content, skillFilePath);
489
+ const raw = parseFrontmatterFields(content);
490
+ if (!parsed || !raw) {
491
+ return null;
492
+ }
493
+ return {
494
+ name: parsed.name,
495
+ description: parsed.description,
496
+ emoji: parsed.emoji,
497
+ includes: parsed.includes,
498
+ activationHints: parsed.activationHints,
499
+ avoidWhen: parsed.avoidWhen,
500
+ category: parsed.category,
501
+ body: raw.body.replace(/^(?:\r?\n)+/, "").replace(/(?:\r?\n)+$/, ""),
502
+ };
503
+ } catch (err) {
504
+ log.warn({ err, skillFilePath }, "Could not read managed skill");
505
+ return null;
506
+ }
507
+ }
508
+
453
509
  interface DeleteManagedSkillResult {
454
510
  deleted: boolean;
455
511
  error?: string;