@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.
- package/package.json +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +1 -1
- package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -0
- package/src/live-voice/__tests__/protocol.test.ts +48 -0
- package/src/live-voice/__tests__/session-controls.test.ts +25 -0
- package/src/live-voice/live-voice-session.ts +210 -12
- package/src/live-voice/protocol.ts +20 -0
- package/src/live-voice/session-controls.ts +55 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +10 -0
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/skills/managed-store.ts +56 -0
- package/src/tools/skills/find-similar-skills.test.ts +211 -2
- package/src/tools/skills/find-similar-skills.ts +62 -9
|
@@ -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
|
-
: //
|
|
2913
|
-
//
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
//
|
|
2919
|
-
|
|
2920
|
-
? "
|
|
2921
|
-
:
|
|
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
|
|
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) =>
|
|
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
|
|
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;
|