@gotcos/glasses-server 6.79.1 → 6.80.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 +98 -0
- package/README.md +20 -1
- package/managed-runtime-contract.json +6 -1
- package/package.json +9 -1
- package/server/index.ts +11 -0
- 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/dictation-clean.ts +13 -6
- package/server/lib/dictation-fillers.ts +17 -10
- package/server/lib/dictation-polish-need.ts +48 -0
- package/server/lib/dictation-prewarm.ts +209 -0
- 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/live-cues-v2-settings.ts +51 -13
- package/server/lib/live-cues-v2.ts +147 -19
- 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/live-cues.ts +39 -3
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/prompt-drafts.ts +75 -8
- package/server/routes/query.ts +20 -6
- package/shared/key-preference.ts +57 -0
- package/shared/model-preference.ts +20 -2
|
@@ -44,6 +44,11 @@ import { dictationSlotForMode, legacyDictationKey, type DictationPlan } from '..
|
|
|
44
44
|
import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
|
|
45
45
|
import { fillerPassEnabled, stripDisfluencies } from '../lib/dictation-fillers.js'
|
|
46
46
|
import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from '../lib/dictation-polish-budget.js'
|
|
47
|
+
import { polishNeed, skipCleanEnabled } from '../lib/dictation-polish-need.js'
|
|
48
|
+
import {
|
|
49
|
+
discardPolishPrewarm, isPrewarmModel, prewarmEnabled, PrewarmUnavailableError, runWarmPolish,
|
|
50
|
+
startPolishPrewarm, takePolishPrewarm,
|
|
51
|
+
} from '../lib/dictation-prewarm.js'
|
|
47
52
|
import { getVocabulary } from '../lib/profile.js'
|
|
48
53
|
import { createBreaker } from '../lib/claude-circuit.js'
|
|
49
54
|
import { logTokenAudit } from '../lib/token-audit.js'
|
|
@@ -85,7 +90,9 @@ const autoCleanBreakers = {
|
|
|
85
90
|
const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
|
|
86
91
|
|
|
87
92
|
/** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish). */
|
|
88
|
-
export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed'
|
|
93
|
+
export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean'
|
|
94
|
+
/** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
|
|
95
|
+
export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
|
|
89
96
|
|
|
90
97
|
interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
|
|
91
98
|
function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
|
|
@@ -99,8 +106,20 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
|
|
|
99
106
|
|
|
100
107
|
async function cleanOutboundDictation(
|
|
101
108
|
text: string,
|
|
102
|
-
opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal },
|
|
103
|
-
): Promise<{ text: string; polishSkipped?: PolishSkipped }> {
|
|
109
|
+
opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
|
|
110
|
+
): Promise<{ text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome }> {
|
|
111
|
+
const result = await polishOutboundDictation(text, opts)
|
|
112
|
+
// A warm process the model call did not take must not sit until its TTL.
|
|
113
|
+
if (opts.prewarmToken && !result.prewarm) {
|
|
114
|
+
return { ...result, prewarm: discardPolishPrewarm(opts.prewarmToken) ? 'discarded' : 'missed' }
|
|
115
|
+
}
|
|
116
|
+
return result
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function polishOutboundDictation(
|
|
120
|
+
text: string,
|
|
121
|
+
opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
|
|
122
|
+
): Promise<{ text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome }> {
|
|
104
123
|
let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
|
|
105
124
|
// applyCorrections above is an EXACT string map, so it only fixes misspellings someone
|
|
106
125
|
// already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
|
|
@@ -129,22 +148,37 @@ async function cleanOutboundDictation(
|
|
|
129
148
|
const model = opts.model ?? 'haiku'
|
|
130
149
|
const autoCleanBreaker = autoCleanBreakers[model]
|
|
131
150
|
if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
|
|
151
|
+
// 6.79.2, off by default: nothing for the model to fix (see dictation-polish-need.ts).
|
|
152
|
+
if (skipCleanEnabled() && polishNeed(cleaned) === null) return { text: cleaned, polishSkipped: 'clean' }
|
|
132
153
|
if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
|
|
133
154
|
if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
|
|
134
155
|
const startedAt = Date.now()
|
|
135
156
|
recordPolishCall(opts.bucket)
|
|
157
|
+
// 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
|
|
158
|
+
const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
|
|
159
|
+
let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
|
|
136
160
|
try {
|
|
137
|
-
|
|
161
|
+
let polished: string | null = null
|
|
162
|
+
if (warmEntry) {
|
|
163
|
+
try {
|
|
164
|
+
polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal })).trim()
|
|
165
|
+
prewarm = 'used'
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// Nothing was sent: the cold path below is the same call, not a second one.
|
|
168
|
+
if (!(err instanceof PrewarmUnavailableError)) throw err
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
|
|
138
172
|
autoCleanBreaker.recordSuccess()
|
|
139
173
|
logTokenAudit({
|
|
140
174
|
source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
|
|
141
175
|
durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
|
|
142
176
|
})
|
|
143
|
-
return { text: polished || cleaned }
|
|
177
|
+
return { text: polished || cleaned, ...(prewarm ? { prewarm } : {}) }
|
|
144
178
|
} catch (err: any) {
|
|
145
179
|
autoCleanBreaker.recordFailure()
|
|
146
180
|
console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
|
|
147
|
-
return { text: cleaned, polishSkipped: 'failed' }
|
|
181
|
+
return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}) }
|
|
148
182
|
}
|
|
149
183
|
}
|
|
150
184
|
|
|
@@ -165,17 +199,50 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
|
|
|
165
199
|
if (model !== undefined && !isDictationCleanModel(model)) {
|
|
166
200
|
return res.status(400).json({ error: 'invalid_autoclean_model' })
|
|
167
201
|
}
|
|
202
|
+
const rawToken = req.body?.prewarmToken
|
|
203
|
+
if (rawToken !== undefined && (typeof rawToken !== 'string' || !PREWARM_TOKEN.test(rawToken))) {
|
|
204
|
+
return res.status(400).json({ error: 'invalid_prewarm_token' })
|
|
205
|
+
}
|
|
168
206
|
|
|
169
207
|
const abort = new AbortController()
|
|
170
208
|
res.on('close', () => { if (!res.writableEnded) abort.abort() })
|
|
171
|
-
const { text: finalText, polishSkipped } = await cleanOutboundDictation(text, {
|
|
209
|
+
const { text: finalText, polishSkipped, prewarm } = await cleanOutboundDictation(text, {
|
|
172
210
|
enabled: true,
|
|
173
211
|
model,
|
|
174
212
|
bucket: surface,
|
|
175
213
|
signal: abort.signal,
|
|
214
|
+
prewarmToken: rawToken,
|
|
176
215
|
})
|
|
177
216
|
// 6.79.1: polishSkipped only when the model did not polish, so a polished answer is unchanged.
|
|
178
|
-
|
|
217
|
+
// 6.79.2: prewarm only when the request carried a prewarmToken.
|
|
218
|
+
res.json({
|
|
219
|
+
text: finalText, surface, polished: finalText !== text,
|
|
220
|
+
...(polishSkipped ? { polishSkipped } : {}), ...(prewarm ? { prewarm } : {}),
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const PREWARM_TOKEN = /^[0-9a-f-]{36}$/
|
|
225
|
+
|
|
226
|
+
/** 6.79.2: start the polish process at key press. Fire and forget: every refusal is a normal
|
|
227
|
+
* answer, and finalize without a token (or with a dead one) spawns as before. */
|
|
228
|
+
promptDraftsRouter.post('/dictation/polish/prewarm', (req, res) => {
|
|
229
|
+
const model = req.body?.model ?? 'haiku'
|
|
230
|
+
const surface = req.body?.surface ?? 'message'
|
|
231
|
+
if (!isDictationCleanModel(model)) return res.status(400).json({ error: 'invalid_autoclean_model' })
|
|
232
|
+
if (surface !== 'message' && surface !== 'meeting') return res.status(400).json({ error: 'invalid_surface' })
|
|
233
|
+
if (!prewarmEnabled()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'disabled' })
|
|
234
|
+
if (!isPrewarmModel(model)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'model' })
|
|
235
|
+
if (autoCleanBreakers[model].isOpen()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'breaker_open' })
|
|
236
|
+
if (polishBudgetExhausted(surface)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'daily_cap' })
|
|
237
|
+
try {
|
|
238
|
+
res.json(startPolishPrewarm(model))
|
|
239
|
+
} catch (err: any) {
|
|
240
|
+
res.status(409).json({ error: 'prewarm_unavailable', reason: 'cli' })
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
promptDraftsRouter.delete('/dictation/polish/prewarm/:token', (req, res) => {
|
|
245
|
+
res.json({ discarded: discardPolishPrewarm(req.params.token) })
|
|
179
246
|
})
|
|
180
247
|
|
|
181
248
|
async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Promise<Buffer> {
|
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) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Key lanes (K1, plan v4 section 5.1): the ONE parser that may read a key profile out of a model
|
|
2
|
+
// preference. `isModelPreference` and `normalizeModelPreference` (model-preference.ts) stay
|
|
3
|
+
// closed and unchanged, so every site that does not call parsePreference keeps refusing a
|
|
4
|
+
// profile exactly as 6.79.0 does. Call this only at the sites plan 5.2 marks Accept.
|
|
5
|
+
//
|
|
6
|
+
// Encoding: a profile-bound preference is `profile:<id>`. The prefix keeps a profile id from ever
|
|
7
|
+
// colliding with a model slot name (`opus` is a valid id shape). On `/v1` a profile is offered as
|
|
8
|
+
// `cos-key-<id>` (plan 9.4), handled before `resolveModel` and only for profiles that opt in.
|
|
9
|
+
import { normalizeModelPreference, type ModelPreference } from './model-preference.js'
|
|
10
|
+
|
|
11
|
+
export const KEY_PROFILE_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
12
|
+
export const KEY_PROFILE_PREFERENCE_PREFIX = 'profile:'
|
|
13
|
+
export const V1_KEY_MODEL_PREFIX = 'cos-key-'
|
|
14
|
+
|
|
15
|
+
export type ParsedPreference =
|
|
16
|
+
| { kind: 'model'; value: ModelPreference }
|
|
17
|
+
| { kind: 'profile'; id: string }
|
|
18
|
+
|
|
19
|
+
export function isKeyProfileId(value: unknown): value is string {
|
|
20
|
+
return typeof value === 'string' && KEY_PROFILE_ID_RE.test(value)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `profile:<id>`, or throws on a malformed id (a caller bug, never user input). */
|
|
24
|
+
export function profilePreference(id: string): string {
|
|
25
|
+
if (!isKeyProfileId(id)) throw new Error('invalid key profile id')
|
|
26
|
+
return KEY_PROFILE_PREFERENCE_PREFIX + id
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A model slot (normalized exactly as normalizeModelPreference does, so `codex-high` still
|
|
31
|
+
* migrates), a `profile:<id>`, or undefined for anything else. Never throws.
|
|
32
|
+
*/
|
|
33
|
+
export function parsePreference(value: unknown): ParsedPreference | undefined {
|
|
34
|
+
const model = normalizeModelPreference(value)
|
|
35
|
+
if (model) return { kind: 'model', value: model }
|
|
36
|
+
if (typeof value !== 'string' || !value.startsWith(KEY_PROFILE_PREFERENCE_PREFIX)) return undefined
|
|
37
|
+
const id = value.slice(KEY_PROFILE_PREFERENCE_PREFIX.length)
|
|
38
|
+
return isKeyProfileId(id) ? { kind: 'profile', id } : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True only for a well-formed `profile:<id>`. A model slot is false. */
|
|
42
|
+
export function isProfilePreference(value: unknown): boolean {
|
|
43
|
+
return parsePreference(value)?.kind === 'profile'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `/v1` model name for a profile (plan 9.4). */
|
|
47
|
+
export function v1KeyModelName(id: string): string {
|
|
48
|
+
if (!isKeyProfileId(id)) throw new Error('invalid key profile id')
|
|
49
|
+
return V1_KEY_MODEL_PREFIX + id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The profile id in a `cos-key-<id>` `/v1` model name, or undefined. */
|
|
53
|
+
export function parseV1KeyModelName(value: unknown): string | undefined {
|
|
54
|
+
if (typeof value !== 'string' || !value.startsWith(V1_KEY_MODEL_PREFIX)) return undefined
|
|
55
|
+
const id = value.slice(V1_KEY_MODEL_PREFIX.length)
|
|
56
|
+
return isKeyProfileId(id) ? id : undefined
|
|
57
|
+
}
|
|
@@ -208,7 +208,23 @@ export function resetRuntimeCursorModelLabels(): void {
|
|
|
208
208
|
delete runtimeCursorLabels[CURSOR_COMPOSER_MODEL]
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Key lanes K1 (plan 5.2 labels): a caller that renders a key-profile turn passes the profile
|
|
213
|
+
* record, and its label wins. With no profile passed every label below is the 6.79.0 one,
|
|
214
|
+
* including the `'Opus'` default. `isModelPreference` stays closed: a profile is never a slot.
|
|
215
|
+
*/
|
|
216
|
+
export interface ModelLabelProfile {
|
|
217
|
+
label: string
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function profileLabel(profile: ModelLabelProfile | undefined, max: number): string | undefined {
|
|
221
|
+
const label = typeof profile?.label === 'string' ? profile.label.trim() : ''
|
|
222
|
+
return label ? Array.from(label).slice(0, max).join('') : undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function modelLabel(model: ModelPreference, profile?: ModelLabelProfile): string {
|
|
226
|
+
const fromProfile = profileLabel(profile, 40)
|
|
227
|
+
if (fromProfile) return fromProfile
|
|
212
228
|
switch (model) {
|
|
213
229
|
case 'fable': return 'Fable'
|
|
214
230
|
case 'sonnet': return 'Sonnet'
|
|
@@ -224,7 +240,9 @@ export function modelLabel(model: ModelPreference): string {
|
|
|
224
240
|
}
|
|
225
241
|
}
|
|
226
242
|
|
|
227
|
-
export function modelShortLabel(model: ModelPreference): string {
|
|
243
|
+
export function modelShortLabel(model: ModelPreference, profile?: ModelLabelProfile): string {
|
|
244
|
+
const fromProfile = profileLabel(profile, 12)
|
|
245
|
+
if (fromProfile) return fromProfile
|
|
228
246
|
switch (model) {
|
|
229
247
|
case 'fable': return 'Fable'
|
|
230
248
|
case 'sonnet': return 'Sonnet'
|