@gotcos/glasses-server 6.79.2 → 6.81.0
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/.env.example +9 -0
- package/CHANGELOG.md +31 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +10 -1
- package/server/index.ts +14 -1
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/server/routes/transcribe-stream.ts +48 -1
- package/shared/key-preference.ts +57 -0
- package/shared/model-preference.ts +20 -2
package/server/routes/meeting.ts
CHANGED
|
@@ -11,6 +11,16 @@ import { createHash } from 'node:crypto'
|
|
|
11
11
|
import { Router } from 'express'
|
|
12
12
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
13
13
|
import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
|
|
14
|
+
import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
|
|
15
|
+
import {
|
|
16
|
+
AUTONOMOUS_FINALIZE_POLL_MS,
|
|
17
|
+
AUTONOMOUS_FINALIZE_SETTLE_MS,
|
|
18
|
+
createAutonomousFinalizer,
|
|
19
|
+
indicesFromRanges,
|
|
20
|
+
type AutonomousFinalizer,
|
|
21
|
+
type AutonomousSaveOutcome,
|
|
22
|
+
type SessionLedger,
|
|
23
|
+
} from '../lib/meeting-autonomous-finalize.js'
|
|
14
24
|
import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
15
25
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
16
26
|
import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
@@ -231,10 +241,25 @@ interface MeetingSessionSource {
|
|
|
231
241
|
delete(sessionId: string, options?: { preserveAudio?: boolean }): void
|
|
232
242
|
/** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
|
|
233
243
|
getState?(sessionId: string): 'active' | 'closed' | 'missing'
|
|
244
|
+
/** 6.81.0: the received/ASR ledger the server's own save of a stopped meeting reads. */
|
|
245
|
+
getLedger?(sessionId: string): SessionLedger
|
|
234
246
|
}
|
|
235
247
|
|
|
236
248
|
const defaultSessionState = (sessionId: string): 'active' | 'closed' | 'missing' => getMeetingSessionStatus(sessionId).state
|
|
237
249
|
|
|
250
|
+
/** 6.81.0: the live ledger as the autonomous finalizer reads it (the status route's own numbers). */
|
|
251
|
+
function defaultSessionLedger(sessionId: string): SessionLedger {
|
|
252
|
+
const status = getMeetingSessionStatus(sessionId)
|
|
253
|
+
const lastActivityAt = status.lastActivityAt ? Date.parse(status.lastActivityAt) : NaN
|
|
254
|
+
return {
|
|
255
|
+
state: status.state,
|
|
256
|
+
received: indicesFromRanges(status.receivedRanges),
|
|
257
|
+
asrCompleted: indicesFromRanges(status.asrCompletedRanges),
|
|
258
|
+
maxChunkIndex: status.maxChunkIndex,
|
|
259
|
+
lastActivityAt: Number.isFinite(lastActivityAt) ? lastActivityAt : null,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
238
263
|
export interface MeetingRouteDependencies {
|
|
239
264
|
store?: MeetingStore
|
|
240
265
|
sessions?: MeetingSessionSource
|
|
@@ -252,6 +277,16 @@ export interface MeetingRouteDependencies {
|
|
|
252
277
|
stopRequests?: StopRequestStore
|
|
253
278
|
/** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
|
|
254
279
|
transcriptCleanup?: TranscriptCleanupRunner
|
|
280
|
+
/** 6.81.0: the phone's Stop, durable (lib/meeting-stop-intents.ts). */
|
|
281
|
+
stopIntents?: StopIntentStore
|
|
282
|
+
/** 6.81.0: the server's own save of a stopped meeting. Tests inject the clock, timer and gate. */
|
|
283
|
+
autonomousFinalize?: {
|
|
284
|
+
now?: () => number
|
|
285
|
+
schedule?: (fn: () => void, ms: number) => () => void
|
|
286
|
+
ready?: () => boolean
|
|
287
|
+
log?: (event: string, fields: Record<string, unknown>) => void
|
|
288
|
+
onCreate?: (finalizer: AutonomousFinalizer) => void
|
|
289
|
+
}
|
|
255
290
|
}
|
|
256
291
|
|
|
257
292
|
const activeFinalizationJobs = new Set<string>()
|
|
@@ -584,6 +619,21 @@ function publicDisposition(disposition: CaptureDisposition | null): Record<strin
|
|
|
584
619
|
}
|
|
585
620
|
}
|
|
586
621
|
|
|
622
|
+
/** 6.81.0: the Stop the server holds, as the status and stop routes show it. Never a path. */
|
|
623
|
+
function publicStopIntent(intent: StopIntent): Record<string, unknown> {
|
|
624
|
+
return {
|
|
625
|
+
finalChunkIndex: intent.finalChunkIndex,
|
|
626
|
+
state: intent.state,
|
|
627
|
+
receivedAt: new Date(intent.receivedAt).toISOString(),
|
|
628
|
+
savedBy: intent.savedBy ?? null,
|
|
629
|
+
savedAt: intent.savedAt ? new Date(intent.savedAt).toISOString() : null,
|
|
630
|
+
reason: intent.reason ?? null,
|
|
631
|
+
waitingFor: intent.state === 'waiting' && intent.lastWait
|
|
632
|
+
? { reason: intent.lastWait.reason, missingCount: intent.lastWait.missingCount, missingSample: intent.lastWait.missingSample }
|
|
633
|
+
: null,
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
587
637
|
function suppliedServerPin(
|
|
588
638
|
req: { get: (name: string) => string | undefined },
|
|
589
639
|
fallback: unknown,
|
|
@@ -608,6 +658,83 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
608
658
|
const stopRequests = deps.stopRequests ?? createStopRequestStore()
|
|
609
659
|
setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
|
|
610
660
|
|
|
661
|
+
// ── 6.81.0: the server finishes a stopped meeting on its own ─────────────────────
|
|
662
|
+
// lib/meeting-stop-intents.ts says what a Stop intent is; lib/meeting-autonomous-
|
|
663
|
+
// finalize.ts says when the server may save (only a complete capture, never early).
|
|
664
|
+
const stopIntents = deps.stopIntents ?? getStopIntentStore()
|
|
665
|
+
const autoDeps = deps.autonomousFinalize ?? {}
|
|
666
|
+
const autoNow = autoDeps.now ?? Date.now
|
|
667
|
+
const ledgerFor = (sessionId: string): SessionLedger => (sessions.getLedger ?? defaultSessionLedger)(sessionId)
|
|
668
|
+
const autoLog = autoDeps.log ?? ((event: string, fields: Record<string, unknown>) => {
|
|
669
|
+
const line = `[meeting/auto-save] ${event} ${String(fields.sessionId ?? '')} ${JSON.stringify(fields)}`
|
|
670
|
+
if (event === 'finalize_failed' || event === 'revoked' || event === 'late_chunk_refused') console.warn(line)
|
|
671
|
+
else console.log(line)
|
|
672
|
+
appendAutonomousSaveLog(event, fields)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
/** Someone saved a meeting the phone had told us about before the server did: theirs, not ours. */
|
|
676
|
+
const notePhoneSavedFirst = (sessionId: string, savedBy: 'phone' | 'other') => {
|
|
677
|
+
const intent = stopIntents.get(sessionId)
|
|
678
|
+
if (!intent || intent.state !== 'waiting') return
|
|
679
|
+
const at = autoNow()
|
|
680
|
+
stopIntents.update(sessionId, { state: 'saved', savedBy, savedAt: at, lastWait: null })
|
|
681
|
+
autoLog(savedBy === 'phone' ? 'phone_saved' : 'client_saved', { sessionId, waitedMs: at - intent.receivedAt, finalChunkIndex: intent.finalChunkIndex })
|
|
682
|
+
}
|
|
683
|
+
/** The phone came back after the server saved on its own. Logged once, with how it showed up. */
|
|
684
|
+
const notePhoneAfterAutonomousSave = (sessionId: string, via: string) => {
|
|
685
|
+
const intent = stopIntents.get(sessionId)
|
|
686
|
+
if (!intent || intent.state !== 'saved' || intent.savedBy !== 'server' || intent.phoneConfirmedAt) return
|
|
687
|
+
const at = autoNow()
|
|
688
|
+
stopIntents.update(sessionId, { phoneConfirmedAt: at, phoneConfirmedBy: via })
|
|
689
|
+
autoLog('phone_confirmed', { sessionId, via, sinceSaveMs: at - (intent.savedAt ?? at), lateReplays: intent.lateReplays ?? 0, lateRefused: intent.lateRefused ?? 0 })
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const autonomousSave = async (intent: StopIntent): Promise<AutonomousSaveOutcome> => {
|
|
693
|
+
// The phone pinned one server instance. A different one (a reinstall) is not its Mac.
|
|
694
|
+
if (intent.serverInstanceId !== getServerInstanceId()) return { ok: false, status: 409, reason: 'server_instance_mismatch' }
|
|
695
|
+
const entries = sessions.getChunkEntries(intent.sessionId) ?? []
|
|
696
|
+
const canonicalCount = entries.filter(entry => entry?.chunk?.text).length
|
|
697
|
+
const integrity = sessions.getIntegrity(intent.sessionId)
|
|
698
|
+
let status = 0
|
|
699
|
+
let payload: Record<string, unknown> = {}
|
|
700
|
+
const saved = await performSave(
|
|
701
|
+
intent.sessionId,
|
|
702
|
+
{ ...(intent.title ? { title: intent.title } : {}), ...(intent.domain ? { domain: intent.domain } : {}) },
|
|
703
|
+
(code, body) => { status = code; payload = body },
|
|
704
|
+
'server',
|
|
705
|
+
)
|
|
706
|
+
if (!saved || status !== 200) return { ok: false, status, reason: String(payload.reason ?? 'meeting_save_error') }
|
|
707
|
+
// Someone saved it between the decision and the lock: theirs, not ours.
|
|
708
|
+
if (payload.replayed === true) return { ok: false, status: 200, reason: 'already_saved' }
|
|
709
|
+
return {
|
|
710
|
+
ok: true,
|
|
711
|
+
filename: saved.filename,
|
|
712
|
+
sidecarPath: saved.sidecarPath,
|
|
713
|
+
chunkCount: intent.finalChunkIndex + 1,
|
|
714
|
+
canonicalCount,
|
|
715
|
+
missingIndices: integrity?.missingIndices ?? [],
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const autonomousFinalizer = createAutonomousFinalizer({
|
|
720
|
+
waiting: () => stopIntents.waiting(),
|
|
721
|
+
get: sessionId => stopIntents.get(sessionId),
|
|
722
|
+
update: (sessionId, patch) => { stopIntents.update(sessionId, patch) },
|
|
723
|
+
ledger: ledgerFor,
|
|
724
|
+
alreadySaved: sessionId => store.findBySessionId(sessionId) !== null,
|
|
725
|
+
saving: sessionId => savingSessions.has(sessionId),
|
|
726
|
+
save: autonomousSave,
|
|
727
|
+
ready: autoDeps.ready ?? (() => maintenanceAdmissionsOpen() && durableSessionRecoveryHasRun()),
|
|
728
|
+
now: autoNow,
|
|
729
|
+
schedule: autoDeps.schedule ?? ((fn, ms) => {
|
|
730
|
+
const timer = setTimeout(fn, ms)
|
|
731
|
+
timer.unref?.()
|
|
732
|
+
return () => clearTimeout(timer)
|
|
733
|
+
}),
|
|
734
|
+
log: autoLog,
|
|
735
|
+
})
|
|
736
|
+
autoDeps.onCreate?.(autonomousFinalizer)
|
|
737
|
+
|
|
611
738
|
let recoveryPendingLogged = false
|
|
612
739
|
router.get('/meeting/sessions/:sessionId/status', (req, res) => {
|
|
613
740
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -651,6 +778,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
651
778
|
// A saved meeting or a live session is the truth for itself; the quarantine is
|
|
652
779
|
// read only when neither is.
|
|
653
780
|
const disposition = state === 'saved' || state === 'active' ? null : readCaptureDisposition(sessionId)
|
|
781
|
+
// 6.81.0: a pinned status read is the phone; one after the server saved on its own is its return.
|
|
782
|
+
if (saved && pin) notePhoneAfterAutonomousSave(sessionId, 'status')
|
|
783
|
+
const intent = stopIntents.get(sessionId)
|
|
654
784
|
res.set('Cache-Control', 'private, no-store')
|
|
655
785
|
res.json({
|
|
656
786
|
sessionId,
|
|
@@ -670,6 +800,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
670
800
|
// (null otherwise). Older clients ignore both.
|
|
671
801
|
outcome: captureOutcome(state, disposition),
|
|
672
802
|
disposition: publicDisposition(disposition),
|
|
803
|
+
// 6.81.0, additive: the Stop the server holds for this meeting, if any. Older clients ignore it.
|
|
804
|
+
stopIntent: intent ? publicStopIntent(intent) : null,
|
|
673
805
|
})
|
|
674
806
|
})
|
|
675
807
|
|
|
@@ -781,43 +913,124 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
781
913
|
res.json(stopRequestBody(request, savingSessions.has(sessionId) ? { finalizing: true } : {}))
|
|
782
914
|
})
|
|
783
915
|
|
|
784
|
-
|
|
916
|
+
// ── 6.81.0: the phone's Stop ──────────────────────────────────────────────────────
|
|
917
|
+
// POST /meeting/sessions/:id/stop {finalChunkIndex, serverInstanceId}. One small fetch the
|
|
918
|
+
// phone can send the moment Stop is pressed, before any IndexedDB work, so a phone that is
|
|
919
|
+
// off screen still tells the Mac. It never closes the session and never refuses a chunk:
|
|
920
|
+
// the server saves on its own only once it holds every chunk 0..finalChunkIndex.
|
|
921
|
+
router.post('/meeting/sessions/:sessionId/stop', (req, res) => {
|
|
922
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
923
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
924
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
if (MAC_CAPTURE_ID.test(sessionId)) {
|
|
928
|
+
res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
|
|
929
|
+
return
|
|
930
|
+
}
|
|
931
|
+
const body = req.body as Record<string, unknown> | undefined
|
|
932
|
+
const finalChunkIndex = body?.finalChunkIndex
|
|
933
|
+
if (!Number.isInteger(finalChunkIndex) || (finalChunkIndex as number) < -1 || (finalChunkIndex as number) > 1_000_000) {
|
|
934
|
+
res.status(400).json({ error: 'finalChunkIndex must be a whole number, -1 or more', reason: 'invalid_final_chunk_index' })
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
if ((body?.title !== undefined && typeof body.title !== 'string') || (body?.domain !== undefined && typeof body.domain !== 'string')) {
|
|
938
|
+
res.status(400).json({ error: 'title and domain must be strings', reason: 'invalid_metadata' })
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
const serverInstanceId = getServerInstanceId()
|
|
942
|
+
if (!serverInstanceId) {
|
|
943
|
+
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
// Required, not optional: only a phone pinned to THIS Mac may hand it a Stop.
|
|
947
|
+
const pin = suppliedServerPin(req, body?.serverInstanceId)
|
|
948
|
+
if (!pin) {
|
|
949
|
+
res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
if (pin !== serverInstanceId) {
|
|
953
|
+
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
res.set('Cache-Control', 'private, no-store')
|
|
957
|
+
const saved = store.findBySessionId(sessionId)
|
|
958
|
+
if (saved) {
|
|
959
|
+
notePhoneAfterAutonomousSave(sessionId, 'stop_retry')
|
|
960
|
+
res.json({ sessionId, state: 'saved', saveReceipt: publicSaveResponse(saved, true) })
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
const ready = (autoDeps.ready ?? (() => maintenanceAdmissionsOpen() && durableSessionRecoveryHasRun()))()
|
|
964
|
+
// Before recovery has run the ledger is empty by construction, so it proves nothing:
|
|
965
|
+
// record the Stop and let the finalizer judge it once the gate opens.
|
|
966
|
+
const ledger = ready ? ledgerFor(sessionId) : null
|
|
967
|
+
if (ledger?.state === 'closed') {
|
|
968
|
+
res.status(409).json({ error: 'This meeting is closed without a save', reason: 'not_live', state: 'closed' })
|
|
969
|
+
return
|
|
970
|
+
}
|
|
971
|
+
if (ledger && ledger.maxChunkIndex > (finalChunkIndex as number)) {
|
|
972
|
+
// The Mac holds audio past the phone's last chunk. Never take that Stop.
|
|
973
|
+
res.status(409).json({ error: 'The Mac holds chunks past finalChunkIndex', reason: 'final_index_behind_server', maxChunkIndex: ledger.maxChunkIndex })
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
const source = typeof body?.source === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(body.source) ? body.source : 'companion'
|
|
977
|
+
let result: ReturnType<StopIntentStore['record']>
|
|
978
|
+
try {
|
|
979
|
+
result = stopIntents.record({
|
|
980
|
+
sessionId,
|
|
981
|
+
finalChunkIndex: finalChunkIndex as number,
|
|
982
|
+
source,
|
|
983
|
+
...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.slice(0, 160) } : {}),
|
|
984
|
+
...(typeof body?.domain === 'string' && body.domain.trim() ? { domain: body.domain } : {}),
|
|
985
|
+
serverInstanceId,
|
|
986
|
+
})
|
|
987
|
+
} catch (error) {
|
|
988
|
+
// Not durable means not received: the phone must keep its own Stop path.
|
|
989
|
+
console.error(`[meeting/auto-save] Could not record the Stop for ${sessionId}: ${error instanceof Error ? error.message : error}`)
|
|
990
|
+
res.status(503).json({ error: 'Stop could not be recorded; retry', reason: 'stop_persistence_failed', retryable: true })
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
if (!result.ok) {
|
|
994
|
+
res.json({ sessionId, state: 'saved', stopIntent: publicStopIntent(result.intent) })
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
const intent = result.intent
|
|
998
|
+
if (result.created || result.raised) {
|
|
999
|
+
autoLog(result.created ? 'stop_received' : 'stop_raised', {
|
|
1000
|
+
sessionId, source, finalChunkIndex: intent.finalChunkIndex, posts: intent.posts,
|
|
1001
|
+
received: ledger?.received.length ?? null, replaced: intent.replaced ?? null,
|
|
1002
|
+
})
|
|
1003
|
+
}
|
|
1004
|
+
void autonomousFinalizer.tick('stop_received')
|
|
1005
|
+
res.status(202).json({
|
|
1006
|
+
sessionId,
|
|
1007
|
+
state: 'stop_received',
|
|
1008
|
+
finalChunkIndex: intent.finalChunkIndex,
|
|
1009
|
+
receivedAt: new Date(intent.receivedAt).toISOString(),
|
|
1010
|
+
posts: intent.posts,
|
|
1011
|
+
autonomousSave: { settleMs: AUTONOMOUS_FINALIZE_SETTLE_MS, pollMs: AUTONOMOUS_FINALIZE_POLL_MS },
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The save itself, shared by the phone's POST /meeting/save and the server's own save of a
|
|
1017
|
+
* stopped meeting (6.81.0). One body so the two can never drift: the same lock, the same
|
|
1018
|
+
* idempotent receipt, the same durable sidecar-then-job order, and the same maintenance
|
|
1019
|
+
* lease order (the finalization job's lease is taken before the save lease is released).
|
|
1020
|
+
*
|
|
1021
|
+
* `respond` is called exactly once, before any background work is scheduled, as the route
|
|
1022
|
+
* always did. `origin` changes only bookkeeping: who is recorded as having saved, and the
|
|
1023
|
+
* phone-confirmed line when the phone replays a save the server already made.
|
|
1024
|
+
*/
|
|
1025
|
+
async function performSave(
|
|
1026
|
+
sessionId: string,
|
|
1027
|
+
body: Record<string, unknown> | undefined,
|
|
1028
|
+
respond: (status: number, payload: Record<string, unknown>, noStore?: boolean) => void,
|
|
1029
|
+
origin: 'phone' | 'client' | 'server',
|
|
1030
|
+
): Promise<SavedMeeting | null> {
|
|
785
1031
|
let lockedSessionId: string | null = null
|
|
786
1032
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
787
1033
|
try {
|
|
788
|
-
const body = req.body as Record<string, unknown> | undefined
|
|
789
|
-
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
790
|
-
if (!sessionId) {
|
|
791
|
-
res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
|
|
792
|
-
return
|
|
793
|
-
}
|
|
794
|
-
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
795
|
-
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
796
|
-
return
|
|
797
|
-
}
|
|
798
|
-
if (body?.title !== undefined && typeof body.title !== 'string') {
|
|
799
|
-
res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
|
|
800
|
-
return
|
|
801
|
-
}
|
|
802
|
-
if (body?.domain !== undefined && typeof body.domain !== 'string') {
|
|
803
|
-
res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
|
|
804
|
-
return
|
|
805
|
-
}
|
|
806
|
-
const serverInstanceId = getServerInstanceId()
|
|
807
|
-
if (!serverInstanceId) {
|
|
808
|
-
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
809
|
-
return
|
|
810
|
-
}
|
|
811
|
-
const pin = suppliedServerPin(req, body?.serverInstanceId)
|
|
812
|
-
if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
|
|
813
|
-
res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
|
|
814
|
-
return
|
|
815
|
-
}
|
|
816
|
-
if (pin && pin !== serverInstanceId) {
|
|
817
|
-
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
818
|
-
return
|
|
819
|
-
}
|
|
820
|
-
|
|
821
1034
|
// A response can be lost after both durable files were committed. Find
|
|
822
1035
|
// the sidecar by session ID so client retry/restart is idempotent.
|
|
823
1036
|
const alreadySaved = store.findBySessionId(sessionId)
|
|
@@ -833,16 +1046,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
833
1046
|
sessions,
|
|
834
1047
|
finalDiarize,
|
|
835
1048
|
})
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
return
|
|
1049
|
+
if (origin === 'phone') notePhoneAfterAutonomousSave(sessionId, 'save_replay')
|
|
1050
|
+
respond(200, publicSaveResponse(alreadySaved, true), true)
|
|
1051
|
+
return alreadySaved
|
|
839
1052
|
}
|
|
840
1053
|
if (MAC_CAPTURE_ID.test(sessionId)) {
|
|
841
1054
|
requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
|
|
842
1055
|
}
|
|
843
1056
|
if (savingSessions.has(sessionId)) {
|
|
844
|
-
|
|
845
|
-
return
|
|
1057
|
+
respond(409, { error: 'Meeting save already in progress', reason: 'save_in_progress' })
|
|
1058
|
+
return null
|
|
846
1059
|
}
|
|
847
1060
|
// Saving/finalizing an already-recording session is a drain
|
|
848
1061
|
// continuation. It must remain admitted so existing audio can reach a
|
|
@@ -858,10 +1071,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
858
1071
|
// lets a client stop asking ("nothing to save") instead of retrying a 404 all day.
|
|
859
1072
|
// Still 404, so every existing client reads it exactly as before.
|
|
860
1073
|
const known = (sessions.getState ?? defaultSessionState)(sessionId)
|
|
861
|
-
|
|
1074
|
+
respond(404, known === 'active' || known === 'closed'
|
|
862
1075
|
? { error: `Nothing to save for session ${sessionId}`, reason: 'nothing_to_save', state: known }
|
|
863
1076
|
: { error: `No transcript found for session ${sessionId}`, reason: 'session_not_found' })
|
|
864
|
-
return
|
|
1077
|
+
return null
|
|
865
1078
|
}
|
|
866
1079
|
|
|
867
1080
|
const chunks = sessions.getChunks(sessionId) ?? []
|
|
@@ -913,6 +1126,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
913
1126
|
finalizationRequired,
|
|
914
1127
|
claimPending,
|
|
915
1128
|
})
|
|
1129
|
+
// 6.81.0: who saved. A pinned save is the phone's; an unpinned one (the stranded sweep's
|
|
1130
|
+
// own POST, a manual curl) is someone else's. Either closes a waiting Stop. The server's
|
|
1131
|
+
// own save is recorded by its finalizer.
|
|
1132
|
+
if (origin !== 'server') notePhoneSavedFirst(sessionId, origin === 'phone' ? 'phone' : 'other')
|
|
916
1133
|
|
|
917
1134
|
// Two-phase replay intent: the canonical sidecar above is the recovery
|
|
918
1135
|
// source of truth, and this indexed job is its fast execution ledger.
|
|
@@ -965,6 +1182,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
965
1182
|
filename: saved.filename,
|
|
966
1183
|
durationMin: saved.durationMin,
|
|
967
1184
|
domain: saved.domain,
|
|
1185
|
+
// 6.81.0, additive: the server saved this on its own after the phone's Stop.
|
|
1186
|
+
...(origin === 'server' ? { savedBy: 'server' } : {}),
|
|
968
1187
|
},
|
|
969
1188
|
})
|
|
970
1189
|
} catch (error) {
|
|
@@ -994,8 +1213,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
994
1213
|
finalizationJob = null
|
|
995
1214
|
}
|
|
996
1215
|
|
|
997
|
-
|
|
998
|
-
res.json(publicSaveResponse(saved))
|
|
1216
|
+
respond(200, publicSaveResponse(saved), true)
|
|
999
1217
|
|
|
1000
1218
|
if (finalizationJob) {
|
|
1001
1219
|
scheduleFinalizationJob(finalizationJob, {
|
|
@@ -1007,17 +1225,68 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1007
1225
|
transcriptCleanup,
|
|
1008
1226
|
})
|
|
1009
1227
|
}
|
|
1228
|
+
return saved
|
|
1010
1229
|
} catch (error) {
|
|
1011
1230
|
if (error instanceof MeetingStoreError) {
|
|
1012
|
-
|
|
1013
|
-
return
|
|
1231
|
+
respond(error.status, { error: error.message, reason: error.code })
|
|
1232
|
+
return null
|
|
1014
1233
|
}
|
|
1015
1234
|
console.error('[meeting/save] Finalization failed:', error)
|
|
1016
|
-
|
|
1235
|
+
respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
|
|
1236
|
+
return null
|
|
1017
1237
|
} finally {
|
|
1018
1238
|
maintenanceLease?.release()
|
|
1019
1239
|
if (lockedSessionId) savingSessions.delete(lockedSessionId)
|
|
1020
1240
|
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
router.post('/meeting/save', async (req, res) => {
|
|
1244
|
+
let responded = false
|
|
1245
|
+
const respond = (status: number, payload: Record<string, unknown>, noStore = false) => {
|
|
1246
|
+
if (responded) return
|
|
1247
|
+
responded = true
|
|
1248
|
+
if (noStore) res.set('Cache-Control', 'private, no-store')
|
|
1249
|
+
res.status(status).json(payload)
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
const body = req.body as Record<string, unknown> | undefined
|
|
1253
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
1254
|
+
if (!sessionId) {
|
|
1255
|
+
res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
|
|
1256
|
+
return
|
|
1257
|
+
}
|
|
1258
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1259
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1260
|
+
return
|
|
1261
|
+
}
|
|
1262
|
+
if (body?.title !== undefined && typeof body.title !== 'string') {
|
|
1263
|
+
res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1266
|
+
if (body?.domain !== undefined && typeof body.domain !== 'string') {
|
|
1267
|
+
res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
|
|
1268
|
+
return
|
|
1269
|
+
}
|
|
1270
|
+
const serverInstanceId = getServerInstanceId()
|
|
1271
|
+
if (!serverInstanceId) {
|
|
1272
|
+
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
const pin = suppliedServerPin(req, body?.serverInstanceId)
|
|
1276
|
+
if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
|
|
1277
|
+
res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
|
|
1278
|
+
return
|
|
1279
|
+
}
|
|
1280
|
+
if (pin && pin !== serverInstanceId) {
|
|
1281
|
+
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1284
|
+
// Only the phone pins its saves (the local-first contract); the stranded sweep does not.
|
|
1285
|
+
await performSave(sessionId, body, respond, pin ? 'phone' : 'client')
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
console.error('[meeting/save] Finalization failed:', error)
|
|
1288
|
+
respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
|
|
1289
|
+
}
|
|
1021
1290
|
})
|
|
1022
1291
|
|
|
1023
1292
|
// ── Record identity for the speaker surfaces (6.47.0) ─────────────────
|
|
@@ -2669,7 +2938,17 @@ async function finalizeBatch(options: {
|
|
|
2669
2938
|
}
|
|
2670
2939
|
}
|
|
2671
2940
|
|
|
2672
|
-
|
|
2941
|
+
let defaultAutonomousFinalizer: AutonomousFinalizer | null = null
|
|
2942
|
+
export const meetingRouter = createMeetingRouter({ autonomousFinalize: { onCreate: finalizer => { defaultAutonomousFinalizer = finalizer } } })
|
|
2943
|
+
|
|
2944
|
+
/**
|
|
2945
|
+
* 6.81.0: at boot (once admissions open), pick up every Stop the server was holding when it
|
|
2946
|
+
* last exited and check it again. A Stop is durable, so a restart between the Stop and the
|
|
2947
|
+
* last chunk loses nothing: the finalizer waits for recovery, then for the chunks.
|
|
2948
|
+
*/
|
|
2949
|
+
export function resumeAutonomousMeetingSaves(): void {
|
|
2950
|
+
void defaultAutonomousFinalizer?.tick('boot')
|
|
2951
|
+
}
|
|
2673
2952
|
|
|
2674
2953
|
/** 6.77.0: what the transcript cleanup routes need from the meeting layer. */
|
|
2675
2954
|
export function transcriptCleanupRouteDeps(store: MeetingStore = getMeetingStore()): TranscriptCleanupRouteDeps {
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
} from '../lib/codex-model-catalog.js'
|
|
14
14
|
import { resolveCursorPreferenceForModelId } from '../lib/cursor-model-catalog.js'
|
|
15
15
|
import { tryInstantResponse } from '../lib/response-cache.js'
|
|
16
|
+
import { keyLanesActive } from '../lib/key-lanes.js'
|
|
17
|
+
import { KEY_PROFILE_V1_NOT_OFFERED, v1OfferedProfile, v1OfferedProfiles } from '../lib/key-profile-routing.js'
|
|
18
|
+
import type { SessionModelBinding } from '../lib/conversation.js'
|
|
19
|
+
import { parseV1KeyModelName, profilePreference, v1KeyModelName } from '../../shared/key-preference.js'
|
|
16
20
|
|
|
17
21
|
import crypto from 'node:crypto'
|
|
18
22
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
@@ -188,10 +192,22 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
188
192
|
model,
|
|
189
193
|
typeof req.query.model === 'string' ? req.query.model : undefined,
|
|
190
194
|
)
|
|
191
|
-
|
|
195
|
+
// Key lanes K1 (plan 9.4): `cos-key-<id>` is handled BEFORE resolveModel, which stays closed.
|
|
196
|
+
// Only an active profile with "Offer on /v1" on runs; any other `cos-key-<id>` is refused, never
|
|
197
|
+
// answered on the subscription. Every other model name takes the 6.79.0 path unchanged.
|
|
198
|
+
const keyProfileId = parseV1KeyModelName(selectedModel)
|
|
199
|
+
if (keyProfileId !== undefined && !v1OfferedProfile(keyProfileId)) {
|
|
200
|
+
maintenanceLease.release()
|
|
201
|
+
return res.status(400).json({
|
|
202
|
+
error: { message: KEY_PROFILE_V1_NOT_OFFERED, type: 'invalid_request_error', code: 'model_not_offered' },
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
const resolvedModel: SessionModelBinding = keyProfileId !== undefined
|
|
206
|
+
? profilePreference(keyProfileId) as SessionModelBinding
|
|
207
|
+
: resolveModel(selectedModel, query)
|
|
192
208
|
const completionId = `chatcmpl-${crypto.randomUUID().slice(0, 12)}`
|
|
193
209
|
const timestamp = Math.floor(Date.now() / 1000)
|
|
194
|
-
const responseModel = MODEL_NAMES[resolvedModel]
|
|
210
|
+
const responseModel = keyProfileId !== undefined ? v1KeyModelName(keyProfileId) : MODEL_NAMES[resolvedModel as ModelPreference]
|
|
195
211
|
|
|
196
212
|
// ── Predictive response cache — bypass Claude for common queries ──
|
|
197
213
|
const cached = tryInstantResponse(query)
|
|
@@ -557,7 +573,10 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
|
557
573
|
})
|
|
558
574
|
|
|
559
575
|
// GET /v1/models — required by some clients for model discovery
|
|
560
|
-
|
|
576
|
+
// Key lanes K1 (plan 9.4, D8): open and byte-identical until a key lane is active; then it asks
|
|
577
|
+
// for the chat Bearer and lists `cos-key-<id>` for each profile offered on /v1.
|
|
578
|
+
openaiCompatRouter.get('/v1/models', async (req, res) => {
|
|
579
|
+
if (keyLanesActive() && !validateAuth(req, res)) return
|
|
561
580
|
const catalog = await getCodexModelCatalog()
|
|
562
581
|
res.json({
|
|
563
582
|
object: 'list',
|
|
@@ -574,6 +593,12 @@ openaiCompatRouter.get('/v1/models', async (_req, res) => {
|
|
|
574
593
|
created: 1709251200,
|
|
575
594
|
owned_by: 'openai',
|
|
576
595
|
})),
|
|
596
|
+
...v1OfferedProfiles().map(profile => ({
|
|
597
|
+
id: v1KeyModelName(profile.id),
|
|
598
|
+
object: 'model',
|
|
599
|
+
created: 1709251200,
|
|
600
|
+
owned_by: 'cos-key',
|
|
601
|
+
})),
|
|
577
602
|
],
|
|
578
603
|
})
|
|
579
604
|
})
|
package/server/routes/query.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { errMsg } from '../lib/utils.js'
|
|
|
8
8
|
import {
|
|
9
9
|
isCursorModel,
|
|
10
10
|
normalizeEffortPreference,
|
|
11
|
-
normalizeModelPreference,
|
|
12
11
|
} from '../../shared/model-preference.js'
|
|
12
|
+
import { parsePreference } from '../../shared/key-preference.js'
|
|
13
|
+
import type { KeyProfileTurn } from '../lib/key-profile-routing.js'
|
|
13
14
|
import { QueryAttachmentError, resolveQueryAttachments } from '../lib/query-attachments.js'
|
|
14
15
|
import { getMediaStore } from '../lib/media-store.js'
|
|
15
16
|
import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
|
|
@@ -19,6 +20,8 @@ import {
|
|
|
19
20
|
maintenanceErrorPayload,
|
|
20
21
|
} from '../lib/maintenance-lifecycle.js'
|
|
21
22
|
import { currentMessageEra, LEGACY_MESSAGE_ERA } from '../lib/message-era.js'
|
|
23
|
+
import { keyAnswerDoneFields } from '../lib/key-answer-fields.js'
|
|
24
|
+
import { isProfilePreference } from '../../shared/key-preference.js'
|
|
22
25
|
|
|
23
26
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
24
27
|
WebSearch: 'Searching web...',
|
|
@@ -92,8 +95,11 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
92
95
|
return res.status(400).json({ error: 'query string or attachment required' })
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
// Validate model if provided
|
|
96
|
-
|
|
98
|
+
// Validate model if provided. Key lanes K1 (plan 5.2): Accept. A `profile:<id>` is passed to
|
|
99
|
+
// model-router as is (it runs on the profile or is refused there); every family guard below
|
|
100
|
+
// reads the closed slot, so a model slot behaves exactly as before.
|
|
101
|
+
const requestedBinding = parsePreference(model)
|
|
102
|
+
const validModel = requestedBinding?.kind === 'model' ? requestedBinding.value : undefined
|
|
97
103
|
const validEffort = normalizeEffortPreference(effort)
|
|
98
104
|
|
|
99
105
|
// Validate globalMsgNum if provided
|
|
@@ -136,12 +142,17 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
136
142
|
done = true
|
|
137
143
|
})
|
|
138
144
|
|
|
145
|
+
// Set only by model-router's onKeyProfileTurn: the binding is the turn's model on the stream.
|
|
146
|
+
let keyTurn: KeyProfileTurn | undefined
|
|
139
147
|
try {
|
|
140
148
|
const sid = await callModelStreaming(resolvedQuery || '', sessionId, {
|
|
149
|
+
...(requestedBinding?.kind === 'model' ? {} : {
|
|
150
|
+
onKeyProfileTurn: (turn: KeyProfileTurn) => { keyTurn = turn },
|
|
151
|
+
}),
|
|
141
152
|
onStart: (model, sid, cliSessionId, metadata) => {
|
|
142
153
|
streamSessionId = sid
|
|
143
154
|
if (!done) {
|
|
144
|
-
const payload = { model, sessionId: sid, cliSessionId, ...metadata }
|
|
155
|
+
const payload = { model: keyTurn ? keyTurn.binding : model, sessionId: sid, cliSessionId, ...metadata }
|
|
145
156
|
res.write(`event: start\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
146
157
|
emitDisplay({ type: 'start', data: payload })
|
|
147
158
|
}
|
|
@@ -192,7 +203,8 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
192
203
|
const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
|
|
193
204
|
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
194
205
|
const payload = {
|
|
195
|
-
text: fullText, sessionId: streamSessionId, model, cliSessionId, ...runMetadata,
|
|
206
|
+
text: fullText, sessionId: streamSessionId, model: keyTurn ? keyTurn.binding : model, cliSessionId, ...runMetadata,
|
|
207
|
+
...keyAnswerDoneFields(runMetadata),
|
|
196
208
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
197
209
|
}
|
|
198
210
|
res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
@@ -215,7 +227,7 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
215
227
|
maintenanceLease.release()
|
|
216
228
|
}
|
|
217
229
|
},
|
|
218
|
-
}, validModel, imageInputs,
|
|
230
|
+
}, requestedBinding?.kind === 'profile' ? model as KeyProfileTurn['binding'] : validModel, imageInputs,
|
|
219
231
|
// Pass reference if provided (for "recall message N" feature)
|
|
220
232
|
reference && typeof reference === 'object' && reference.query && reference.response
|
|
221
233
|
? { query: String(reference.query), response: String(reference.response) }
|
|
@@ -228,6 +240,8 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
228
240
|
requestAttachments: attachmentRefs,
|
|
229
241
|
attachmentPromptBlock: resolvedAttachments.promptBlock,
|
|
230
242
|
...(validModel && isCursorModel(validModel) ? { cursorExecutionMode } : {}),
|
|
243
|
+
// QA U9: the one-turn plan-limit offer runs on the key without binding the session.
|
|
244
|
+
...(req.body.keyTurnOnly === true && isProfilePreference(model) ? { keyTurnOnly: true } : {}),
|
|
231
245
|
},
|
|
232
246
|
)
|
|
233
247
|
} catch (err: unknown) {
|