@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.
Files changed (70) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +10 -0
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +10 -1
  6. package/server/index.ts +14 -1
  7. package/server/lib/agent-session-search.ts +8 -5
  8. package/server/lib/agent-session-store.ts +45 -4
  9. package/server/lib/archive.ts +2 -1
  10. package/server/lib/attached-provider-adapter.ts +11 -0
  11. package/server/lib/claude-bridge.ts +112 -8
  12. package/server/lib/claude-settings-guard.ts +94 -0
  13. package/server/lib/claude-steer.ts +27 -0
  14. package/server/lib/codex-bridge.ts +205 -32
  15. package/server/lib/codex-engine-sessions.ts +27 -6
  16. package/server/lib/codex-homes.ts +134 -0
  17. package/server/lib/codex-live-queue.ts +14 -0
  18. package/server/lib/codex-profile-home.ts +452 -0
  19. package/server/lib/codex-queue-control.ts +17 -1
  20. package/server/lib/codex-subagent-activity.ts +22 -0
  21. package/server/lib/conversation.ts +92 -15
  22. package/server/lib/direct-bridge.ts +853 -0
  23. package/server/lib/direct-sse.ts +262 -0
  24. package/server/lib/fork-thread.ts +11 -0
  25. package/server/lib/key-answer-fields.ts +49 -0
  26. package/server/lib/key-catalog.ts +301 -0
  27. package/server/lib/key-lanes-pricing.ts +12 -0
  28. package/server/lib/key-lanes-wiring.ts +346 -0
  29. package/server/lib/key-lanes.ts +145 -0
  30. package/server/lib/key-ledger.ts +123 -0
  31. package/server/lib/key-notices.ts +486 -0
  32. package/server/lib/key-profile-rejects.ts +25 -0
  33. package/server/lib/key-profile-routing.ts +173 -0
  34. package/server/lib/key-profiles.ts +182 -0
  35. package/server/lib/key-relay-contract.ts +262 -0
  36. package/server/lib/key-relay-errors.ts +211 -0
  37. package/server/lib/key-relay-meter.ts +303 -0
  38. package/server/lib/key-relay.ts +1018 -0
  39. package/server/lib/key-secrets-guard.ts +52 -0
  40. package/server/lib/key-store.ts +228 -0
  41. package/server/lib/keyed-sessions.ts +154 -0
  42. package/server/lib/lane-b-env.ts +169 -0
  43. package/server/lib/meeting-autonomous-finalize.ts +267 -0
  44. package/server/lib/meeting-stop-intents.ts +277 -0
  45. package/server/lib/model-router.ts +96 -7
  46. package/server/lib/morning-brief-config.ts +3 -0
  47. package/server/lib/native-head.ts +13 -1
  48. package/server/lib/occupancy-probes.ts +4 -0
  49. package/server/lib/ollama-run-ledger.ts +96 -13
  50. package/server/lib/prompt-edit.ts +2 -0
  51. package/server/lib/query-job-coordinator.ts +2 -1
  52. package/server/lib/query-job-runtime.ts +67 -15
  53. package/server/lib/query-job-store.ts +7 -1
  54. package/server/lib/query-job-types.ts +15 -1
  55. package/server/lib/session-hook-events.ts +28 -0
  56. package/server/lib/session-hook-ledger.ts +10 -0
  57. package/server/lib/thread-occupancy.ts +22 -1
  58. package/server/lib/token-audit.ts +18 -4
  59. package/server/lib/work-handoff-requests.ts +3 -0
  60. package/server/routes/agent-session-bindings.ts +15 -0
  61. package/server/routes/agent-sessions.ts +17 -5
  62. package/server/routes/handoffs.ts +22 -1
  63. package/server/routes/health.ts +5 -0
  64. package/server/routes/key-lanes.ts +442 -0
  65. package/server/routes/meeting.ts +326 -47
  66. package/server/routes/openai-compat.ts +28 -3
  67. package/server/routes/query.ts +20 -6
  68. package/server/routes/transcribe-stream.ts +48 -1
  69. package/shared/key-preference.ts +57 -0
  70. package/shared/model-preference.ts +20 -2
@@ -0,0 +1,486 @@
1
+ // Key lanes (K1, plan v4 section 11, 14.2 row P8): the ONE source of every string COS shows when an
2
+ // answer deviates from the user's plan. First-time notices, status labels, profile-specific error
3
+ // lines, the plan-limit offer, the keyed-session refusal, the removed-profile break, the phone and
4
+ // Mac-away lines, connectors off, and what leaves this Mac. P3, P5, P9 and P10 call this; nobody
5
+ // else writes these words.
6
+ //
7
+ // PURE. No file, env, clock, timer or socket: the caller passes seen-state, the time and the time
8
+ // zone in, and persists what comes back (`deviation-notices.json` on the Mac, bridge storage on the
9
+ // phone). The only import is the frozen P1 contract (types plus BREAKER_LIMITS). Importing this
10
+ // module with key lanes off changes nothing, and the app can copy it as is.
11
+ //
12
+ // LENS BUDGET (tested in key-notices.test.ts against Even's own font metrics, copied read only from
13
+ // `@evenrealities/pretext` 0.1.4 as pinned by cos-glasses-app f02fab64):
14
+ // - a first-time body is at most 6 rendered lines, every body line is ONE lens row;
15
+ // - a status label, header or footer fits the 568 px chrome row;
16
+ // - no glyph the lens font lacks (it draws them as ~4 px of nothing);
17
+ // - no em dash, en dash or arrow (house rule), never "sign in" in key copy.
18
+ // Fields the user typed (profile label, model label, a settings name) are cleaned of blank glyphs
19
+ // and cut with `…` by an upper-bound pixel width, so no input can push a line past its row.
20
+ import { BREAKER_LIMITS } from './key-relay-contract.js'
21
+ import type { KeyKind, RelayFailureKind } from './key-relay-contract.js'
22
+
23
+ // ── Budgets ──────────────────────────────────────────────────────────────────
24
+
25
+ /** Rendered body lines a lens notice may take (the detail body holds 6). */
26
+ export const KEY_NOTICE_BODY_LINES = 6
27
+ /** What one body line may measure: the firmware wraps at 548 px, less the app's 2 px margin. */
28
+ export const KEY_NOTICE_BODY_PX = 546
29
+ /** Lens columns per body line, the app's `JOB_TRAIL_WIDTH`. */
30
+ export const KEY_NOTICE_BODY_COLUMNS = 62
31
+ /** A full-width chrome row (header, status, footer): 576 px less 4 px a side, the app's `LENS_CHROME_ROW_PX`. */
32
+ export const KEY_NOTICE_CHROME_PX = 568
33
+
34
+ /** Glyphs the G2 lens font lacks (plan 11.2, brief rule 8). Never written by this module. */
35
+ export const KEY_NOTICE_BLANK_GLYPHS = ['♦', '✓', '⚠', '↻', '↺', '⌛', '⋯', '░', '▓', '⬡'] as const
36
+
37
+ /** The status label mark (drawn, 20 px) and the ASCII separator. */
38
+ export const KEY_LABEL_MARK = '◈'
39
+ export const KEY_LABEL_SEPARATOR = ' | '
40
+
41
+ // ── Width (upper bound) ──────────────────────────────────────────────────────
42
+ // Even's LVGL font metrics (`@evenrealities/pretext` 0.1.4, the version cos-glasses-app f02fab64
43
+ // pins), copied read only: the pixel advance of printable ASCII (the same table as the app's
44
+ // `lens-text-width.ts`) and of the symbols this module draws. Kerning narrows almost every pair; it
45
+ // widens a pair by at most 1 px, and only after the glyphs in KERN_WIDENS (41 of 10,404 pairs, all
46
+ // +1). So the sum of advances plus 1 px after those glyphs never measures below Even's own layout,
47
+ // and stays within about 10% of it. An unlisted glyph takes its class width and 1 px a side, as the
48
+ // app's bound does. key-notices.test.ts checks the bound against the replayed metrics.
49
+
50
+ const ASCII_PX = [
51
+ 5, 4, 6, 15, 13, 14, 16, 4, 7, 7, 8, 10, 5, 10, 5, 8,
52
+ 12, 8, 12, 12, 13, 12, 12, 13, 12, 12, 4, 5, 10, 10, 10, 12,
53
+ 17, 14, 12, 12, 12, 11, 11, 12, 12, 6, 9, 12, 10, 16, 12, 12,
54
+ 12, 12, 12, 12, 12, 12, 14, 16, 14, 14, 13, 7, 8, 7, 10, 9,
55
+ 4, 12, 11, 11, 11, 11, 10, 11, 11, 4, 7, 10, 4, 16, 11, 11,
56
+ 11, 11, 8, 11, 8, 12, 12, 16, 12, 12, 10, 9, 4, 9, 16,
57
+ ] as const
58
+ const SYMBOL_PX: Readonly<Record<string, number>> = { '◈': 20, '…': 10, '·': 5, '√': 13, '×': 10, '◐': 20, '▒': 20 }
59
+ const KERN_WIDENS = new Set([' ', '1', 'H', 'M', 'N', ']', 'd', 'i', 'j', 'l', '|'])
60
+
61
+ /** The advance of a measured glyph, or undefined for one this table does not carry. */
62
+ function knownPx(glyph: string): number | undefined {
63
+ const cp = glyph.codePointAt(0) ?? 0
64
+ if (cp >= 0x20 && cp <= 0x7e) return ASCII_PX[cp - 0x20]!
65
+ return Object.prototype.hasOwnProperty.call(SYMBOL_PX, glyph) ? SYMBOL_PX[glyph] : undefined
66
+ }
67
+
68
+ function classPx(glyph: string): number {
69
+ const cp = glyph.codePointAt(0) ?? 0
70
+ if (cp >= 0x1f000) return 24
71
+ return cp < 0x2000 ? 16 : 20
72
+ }
73
+
74
+ /** Upper-bound width in px of `text` on one lens row. */
75
+ export function keyNoticeWidthPx(text: string): number {
76
+ const glyphs = [...text]
77
+ let width = 0
78
+ for (let i = 0; i < glyphs.length; i++) {
79
+ const g = glyphs[i]!
80
+ const px = knownPx(g)
81
+ width += px ?? classPx(g)
82
+ const next = glyphs[i + 1]
83
+ if (next !== undefined && (px === undefined || knownPx(next) === undefined || KERN_WIDENS.has(g))) width += 1
84
+ }
85
+ return width
86
+ }
87
+
88
+ // ── Cleaning user text for the lens ──────────────────────────────────────────
89
+
90
+ /** Odd spaces, zero-width and direction marks, line and paragraph separators, the BOM. */
91
+ const SPACE_LIKE = (cp: number) =>
92
+ (cp >= 0x2000 && cp <= 0x200f) || (cp >= 0x2028 && cp <= 0x202f) || (cp >= 0x205f && cp <= 0x206f) || cp === 0xfeff
93
+
94
+ /** Blank glyph (and the backtick, which the font also lacks) to a drawn look-alike, else `?`. */
95
+ const BLANK_TO_DRAWN: Readonly<Record<string, string>> = {
96
+ '`': "'", '✓': '√', '⚠': '!', '⋯': '…', '⌛': '◐', '░': '▒', '▓': '▒', '⬡': '◈', '♦': '?', '↻': '?', '↺': '?',
97
+ }
98
+
99
+ /**
100
+ * A user-typed field as one lens-safe row: no line breaks or control characters, no blank glyph,
101
+ * no em or en dash, spaces collapsed. Never used for anything sent back to a provider.
102
+ */
103
+ export function keyNoticeField(value: unknown): string {
104
+ if (typeof value !== 'string') return ''
105
+ let out = ''
106
+ for (const ch of value) {
107
+ const cp = ch.codePointAt(0) ?? 0
108
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || SPACE_LIKE(cp)) out += ' '
109
+ else if (ch === '—' || ch === '–') out += '-'
110
+ else out += BLANK_TO_DRAWN[ch] ?? ch
111
+ }
112
+ return out.replace(/ {2,}/g, ' ').trim()
113
+ }
114
+
115
+ /** `text` cut to fit `maxPx` and `maxColumns`, with `…` when cut. */
116
+ function fitField(text: string, maxPx: number, maxColumns: number): string {
117
+ const fits = (s: string) => keyNoticeWidthPx(s) <= maxPx && [...s].length <= maxColumns
118
+ if (fits(text)) return text
119
+ const glyphs = [...text]
120
+ for (let n = glyphs.length - 1; n > 0; n--) {
121
+ const candidate = `${glyphs.slice(0, n).join('').trimEnd()}…`
122
+ if (fits(candidate)) return candidate
123
+ }
124
+ return fits('…') ? '…' : ''
125
+ }
126
+
127
+ /** Fill `{f}` in `template` with `field`, cutting the field so the line fits one row. */
128
+ function fillRow(template: string, field: string, maxPx: number, maxColumns: number): string {
129
+ const [before, after] = template.split('{f}') as [string, string]
130
+ const restPx = keyNoticeWidthPx(before + after) + 2 // each join can add 1 px (KERN_WIDENS, unlisted glyphs)
131
+ const restCols = [...before].length + [...after].length
132
+ return before + fitField(field, maxPx - restPx, maxColumns - restCols) + after
133
+ }
134
+ const bodyRow = (template: string, field: string) => fillRow(template, field, KEY_NOTICE_BODY_PX, KEY_NOTICE_BODY_COLUMNS)
135
+ /** A closed-set field (a provider name, a formatted amount or clock): never cut; the tests prove every value fits. */
136
+ const put = (template: string, value: string) => template.replace('{f}', () => value)
137
+ const chromeRow = (template: string, field: string) => fillRow(template, field, KEY_NOTICE_CHROME_PX, 1000)
138
+
139
+ // ── Providers ────────────────────────────────────────────────────────────────
140
+
141
+ export interface KeyProviderCopy {
142
+ /** "your OpenRouter key" is `your ${key}`. */
143
+ key: string
144
+ /** Where the question goes, mid-sentence. */
145
+ dest: string
146
+ /** The same at the start of a sentence. */
147
+ Dest: string
148
+ /** No daily usage API: spend is the local ledger, labeled as an estimate (plan 4.5). */
149
+ estimate: boolean
150
+ }
151
+
152
+ export const KEY_PROVIDER_COPY: Readonly<Record<KeyKind, KeyProviderCopy>> = {
153
+ openrouter: { key: 'OpenRouter key', dest: 'OpenRouter', Dest: 'OpenRouter', estimate: false },
154
+ anthropic: { key: 'Anthropic key', dest: 'Anthropic', Dest: 'Anthropic', estimate: true },
155
+ openai: { key: 'OpenAI key', dest: 'OpenAI', Dest: 'OpenAI', estimate: true },
156
+ 'openai-compatible': { key: 'endpoint key', dest: 'your endpoint', Dest: 'Your endpoint', estimate: true },
157
+ }
158
+ const UNKNOWN_PROVIDER: KeyProviderCopy = { key: 'key', dest: 'the provider', Dest: 'The provider', estimate: true }
159
+ const providerCopy = (kind: KeyKind | undefined): KeyProviderCopy =>
160
+ (kind && Object.prototype.hasOwnProperty.call(KEY_PROVIDER_COPY, kind) ? KEY_PROVIDER_COPY[kind] : UNKNOWN_PROVIDER)
161
+
162
+ // ── Money and time ───────────────────────────────────────────────────────────
163
+
164
+ /** A cap or a day's spend: `$1.00`. */
165
+ export function formatKeyUsd(usd: number): string {
166
+ return `$${(Number.isFinite(usd) && usd > 0 ? usd : 0).toFixed(2)}`
167
+ }
168
+
169
+ /** One answer's cost: `$0.004` under a cent, `$0.12` above, `<$0.001` for a trace. */
170
+ export function formatAnswerUsd(usd: number): string {
171
+ if (!Number.isFinite(usd) || usd <= 0) return '$0.000'
172
+ if (usd < 0.001) return '<$0.001'
173
+ return usd < 0.01 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`
174
+ }
175
+
176
+ function timeParts(at: Date, timeZone: string | undefined): { hour: string; minute: string; dayPeriod: string } {
177
+ const parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone }).formatToParts(at)
178
+ const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''
179
+ // Built from parts, never from `format()`: ICU puts U+202F before AM/PM.
180
+ return { hour: get('hour'), minute: get('minute'), dayPeriod: get('dayPeriod').toUpperCase() }
181
+ }
182
+
183
+ /** The local time the key's day resets: the next UTC midnight (OpenRouter `usage_daily`), e.g. `7 PM`. */
184
+ export function keyCapResetTime(now: Date, timeZone?: string): string {
185
+ const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
186
+ const { hour, minute, dayPeriod } = timeParts(next, timeZone)
187
+ return minute === '00' ? `${hour} ${dayPeriod}` : `${hour}:${minute} ${dayPeriod}`
188
+ }
189
+
190
+ /** `2:14`, the plan 11.1 / 11.2 clock for "Mac away since". */
191
+ export function keyClock(at: Date, timeZone?: string): string {
192
+ const { hour, minute } = timeParts(at, timeZone)
193
+ return `${hour}:${minute}`
194
+ }
195
+
196
+ // ── First-time notice and status label (plan 11.1, 11.2) ─────────────────────
197
+
198
+ /** Plan 11.1 rows. `lane-b-claude` is the base notice. */
199
+ export type KeyNoticeVariant =
200
+ | 'lane-b-claude'
201
+ | 'lane-b-codex'
202
+ | 'c1-tools-off'
203
+ | 'c1-tools-on'
204
+ | 'c2-phone'
205
+ | 'k3-second-opinion'
206
+ export const KEY_NOTICE_VARIANTS: readonly KeyNoticeVariant[] = [
207
+ 'lane-b-claude', 'lane-b-codex', 'c1-tools-off', 'c1-tools-on', 'c2-phone', 'k3-second-opinion',
208
+ ]
209
+
210
+ /** Body line 1: who answered. */
211
+ type Answered = 'mac' | 'phone'
212
+ /** Body line 3: what the answer could use. */
213
+ type Tools = 'harness-claude' | 'harness-codex' | 'none' | 'phone'
214
+ /** Body line 4: what leaves this Mac. */
215
+ type Egress = 'question' | 'question-and-answer'
216
+ /** Body line 5: meetings and memories. */
217
+ type Meetings = 'tool-may-read' | 'tool-reads-goes' | 'stay'
218
+
219
+ const VARIANT_SHAPE: Readonly<Record<KeyNoticeVariant, { answered: Answered; tools: Tools; egress: Egress; meetings: Meetings; lane: 'Key' | 'Direct' }>> = {
220
+ 'lane-b-claude': { answered: 'mac', tools: 'harness-claude', egress: 'question', meetings: 'tool-may-read', lane: 'Key' },
221
+ 'lane-b-codex': { answered: 'mac', tools: 'harness-codex', egress: 'question', meetings: 'tool-may-read', lane: 'Key' },
222
+ 'c1-tools-off': { answered: 'mac', tools: 'none', egress: 'question', meetings: 'stay', lane: 'Direct' },
223
+ 'c1-tools-on': { answered: 'mac', tools: 'harness-claude', egress: 'question', meetings: 'tool-reads-goes', lane: 'Direct' },
224
+ 'c2-phone': { answered: 'phone', tools: 'phone', egress: 'question', meetings: 'tool-may-read', lane: 'Key' },
225
+ 'k3-second-opinion': { answered: 'mac', tools: 'none', egress: 'question-and-answer', meetings: 'stay', lane: 'Key' },
226
+ }
227
+
228
+ /** The static copy, one place. `{f}` is the one field a row carries. */
229
+ export const KEY_NOTICE_COPY = {
230
+ header: 'Your key, not your plan',
231
+ footer: 'Tap: got it Hold: settings',
232
+ answeredMac: 'This answer runs on your {f}.',
233
+ answeredPhone: 'Your phone answered. Mac away since {f}.',
234
+ answeredPhoneNoTime: 'Your phone answered. The Mac was away.',
235
+ cost: 'Cost comes off the key. Cap {f} a day.',
236
+ costEstimate: 'Cost comes off the key. Cap {f} a day (estimate).',
237
+ toolsHarnessClaude: 'Mac tools on. Claude connectors are off.',
238
+ toolsHarnessCodex: 'Mac tools on. Steer lands after this turn.',
239
+ toolsNone: 'No Mac tools.',
240
+ toolsPhone: 'No Mac tools. No meetings, no tasks.',
241
+ egressQuestion: 'Your question goes to {f}.',
242
+ egressQuestionAndAnswer: 'Your question and this answer go to {f}.',
243
+ meetingsToolMayRead: 'Meetings stay on the Mac unless a tool reads one.',
244
+ meetingsToolReadsGoes: 'Meeting text it reads goes to {f}.',
245
+ meetingsStay: 'Meetings and memories stay on the Mac.',
246
+ labelLaneKey: 'Key',
247
+ labelLaneDirect: 'Direct',
248
+ labelPhone: 'Phone',
249
+ labelMacAway: 'Mac away {f}',
250
+ labelMacAwayNoTime: 'Mac away',
251
+ labelNear: '{f} today',
252
+ /** D14: the footer offer on a Lane A plan-limit error, only when a key profile exists. */
253
+ planLimitOffer: 'Tap: run this one on your key',
254
+ /** Plan 12: the context break a bound session gets when its profile is removed. */
255
+ profileRemovedBreak: 'Key profile removed',
256
+ /** An id this build does not know (a newer caller): still never a sign-in line. */
257
+ unknownError: 'Your key could not answer this one.',
258
+ } as const
259
+
260
+ /** Spend at or above this share of the cap switches the label to the "Near" form (plan 11.2). */
261
+ export const KEY_NEAR_CAP_SHARE = 0.8
262
+
263
+ export interface KeyNoticeContext {
264
+ variant: KeyNoticeVariant
265
+ profileId: string
266
+ keyKind: KeyKind
267
+ capUsd: number
268
+ /** Short model name for the label, e.g. `Sonnet 5.5` (see keyModelLabel). */
269
+ modelLabel: string
270
+ /** This answer's cost, when known. */
271
+ costUsd?: number
272
+ spentTodayUsd?: number
273
+ /** C2 only: when the phone lost the Mac. */
274
+ macAwaySince?: Date
275
+ /** IANA zone for clock copy; the process zone when omitted. */
276
+ timeZone?: string
277
+ }
278
+
279
+ /**
280
+ * What the caller persists: notice fingerprint to the ISO time it was first shown. The file
281
+ * (`deviation-notices.json`) and bridge storage hold exactly this object. A missing or unreadable
282
+ * file is `undefined`, which shows the notice again (over-telling is the safe direction).
283
+ */
284
+ export type KeyNoticeSeen = Readonly<Record<string, string>>
285
+
286
+ /**
287
+ * The notice's identity. It changes, and so re-arms the notice, when the profile, key kind, tools,
288
+ * egress (both carried by the variant) or the cap change (plan 11.1).
289
+ */
290
+ export function keyNoticeFingerprint(ctx: Pick<KeyNoticeContext, 'variant' | 'profileId' | 'keyKind' | 'capUsd'>): string {
291
+ const cents = Math.round((Number.isFinite(ctx.capUsd) ? ctx.capUsd : 0) * 100)
292
+ return `key-notice:v1:${ctx.profileId}:${ctx.keyKind}:${ctx.variant}:cap${cents}`
293
+ }
294
+
295
+ export function keyNoticeSeen(seen: KeyNoticeSeen | undefined, fingerprint: string): boolean {
296
+ return !!seen && typeof seen === 'object' && Object.prototype.hasOwnProperty.call(seen, fingerprint)
297
+ }
298
+
299
+ /** A new seen-state with `fingerprint` recorded (the input is never changed). */
300
+ export function markKeyNoticeSeen(seen: KeyNoticeSeen | undefined, fingerprint: string, at: Date): KeyNoticeSeen {
301
+ if (keyNoticeSeen(seen, fingerprint)) return { ...seen }
302
+ return { ...(seen ?? {}), [fingerprint]: at.toISOString() }
303
+ }
304
+
305
+ /** The five body lines of the first-time notice (plan 11.1). */
306
+ export function keyNoticeBody(ctx: KeyNoticeContext): string[] {
307
+ const shape = VARIANT_SHAPE[ctx.variant]
308
+ const p = providerCopy(ctx.keyKind)
309
+ const C = KEY_NOTICE_COPY
310
+ const line1 = shape.answered === 'phone'
311
+ ? (ctx.macAwaySince ? put(C.answeredPhone, keyClock(ctx.macAwaySince, ctx.timeZone)) : C.answeredPhoneNoTime)
312
+ : put(C.answeredMac, p.key)
313
+ const line2 = put(p.estimate ? C.costEstimate : C.cost, formatKeyUsd(ctx.capUsd))
314
+ const line3 = shape.tools === 'harness-claude' ? C.toolsHarnessClaude
315
+ : shape.tools === 'harness-codex' ? C.toolsHarnessCodex
316
+ : shape.tools === 'phone' ? C.toolsPhone
317
+ : C.toolsNone
318
+ const line4 = put(shape.egress === 'question' ? C.egressQuestion : C.egressQuestionAndAnswer, p.dest)
319
+ const line5 = shape.meetings === 'tool-may-read' ? C.meetingsToolMayRead
320
+ : shape.meetings === 'tool-reads-goes' ? put(C.meetingsToolReadsGoes, p.dest)
321
+ : C.meetingsStay
322
+ return [line1, line2, line3, line4, line5]
323
+ }
324
+
325
+ /** The status label (plan 11.2): never the body. */
326
+ export function keyStatusLabel(ctx: KeyNoticeContext): string {
327
+ const C = KEY_NOTICE_COPY
328
+ const shape = VARIANT_SHAPE[ctx.variant]
329
+ const sep = KEY_LABEL_SEPARATOR
330
+ if (shape.answered === 'phone') {
331
+ const away = ctx.macAwaySince ? C.labelMacAway.replace('{f}', keyClock(ctx.macAwaySince, ctx.timeZone)) : C.labelMacAwayNoTime
332
+ return `${KEY_LABEL_MARK} ${C.labelPhone}${sep}${away}`
333
+ }
334
+ const lane = `${KEY_LABEL_MARK} ${shape.lane === 'Direct' ? C.labelLaneDirect : C.labelLaneKey}`
335
+ const tilde = providerCopy(ctx.keyKind).estimate ? '~' : ''
336
+ const spent = ctx.spentTodayUsd
337
+ if (typeof spent === 'number' && Number.isFinite(spent) && ctx.capUsd > 0 && spent >= ctx.capUsd * KEY_NEAR_CAP_SHARE) {
338
+ return `${lane}${sep}${C.labelNear.replace('{f}', `${tilde}${formatKeyUsd(spent)} of ${formatKeyUsd(ctx.capUsd)}`)}`
339
+ }
340
+ const cost = typeof ctx.costUsd === 'number' ? `${sep}${tilde}${formatAnswerUsd(ctx.costUsd)}` : ''
341
+ return chromeRow(`${lane}${sep}{f}${cost}`, keyNoticeField(ctx.modelLabel) || 'model')
342
+ }
343
+
344
+ export type KeyLaneNoticeView =
345
+ | { form: 'first-time'; fingerprint: string; header: string; body: string[]; footer: string; label: string }
346
+ | { form: 'compact'; fingerprint: string; label: string }
347
+
348
+ /**
349
+ * The notice for one keyed answer: the first-time long form when `seen` has not recorded this
350
+ * fingerprint, else the compact label. The caller shows it, then persists
351
+ * `markKeyNoticeSeen(seen, view.fingerprint, now)` once the wearer taps "got it".
352
+ */
353
+ export function keyLaneNotice(ctx: KeyNoticeContext, seen: KeyNoticeSeen | undefined): KeyLaneNoticeView {
354
+ const fingerprint = keyNoticeFingerprint(ctx)
355
+ const label = keyStatusLabel(ctx)
356
+ if (keyNoticeSeen(seen, fingerprint)) return { form: 'compact', fingerprint, label }
357
+ return { form: 'first-time', fingerprint, header: KEY_NOTICE_COPY.header, body: keyNoticeBody(ctx), footer: KEY_NOTICE_COPY.footer, label }
358
+ }
359
+
360
+ /** Short model name: `anthropic/claude-sonnet-5.5` to `Sonnet 5.5`; anything else, its last segment. */
361
+ export function keyModelLabel(slug: string): string {
362
+ const last = (typeof slug === 'string' ? slug : '').split('/').pop() ?? ''
363
+ const claude = /^claude-([a-z]+)-(\d+(?:[.-]\d+)*)$/i.exec(last)
364
+ if (claude) return `${claude[1]!.charAt(0).toUpperCase()}${claude[1]!.slice(1).toLowerCase()} ${claude[2]!.replace(/-/g, '.')}`
365
+ return keyNoticeField(last) || 'model'
366
+ }
367
+
368
+ // ── Plan-limit offer (D14) ───────────────────────────────────────────────────
369
+
370
+ /**
371
+ * The footer offer on a Lane A plan-limit error. Only when a key profile exists; with none the
372
+ * caller keeps today's message byte for byte. Never automatic: this is a label, not an action.
373
+ */
374
+ export function planLimitOfferFooter(input: { enabledKeyProfiles: number }): string | undefined {
375
+ return Number.isInteger(input.enabledKeyProfiles) && input.enabledKeyProfiles > 0 ? KEY_NOTICE_COPY.planLimitOffer : undefined
376
+ }
377
+
378
+ // ── Error lines (plan 11.3 and the other profile-specific refusals) ──────────
379
+
380
+ /** Every profile-specific error line. A superset of the relay's failure kinds (plan 4.6). */
381
+ export type KeyNoticeErrorId =
382
+ | RelayFailureKind
383
+ | 'breaker_open'
384
+ | 'profile_off'
385
+ | 'profile_removed'
386
+ | 'keyed_session'
387
+ | 'settings_override'
388
+ | 'mac_has_turn'
389
+ | 'dictated_key'
390
+ | 'no_credit_limit'
391
+ | 'downgrade'
392
+ // QA N10 / U2: the relay ended the turn's token, or the provider refused without a reason COS names.
393
+ | 'nonce_expired'
394
+ | 'nonce_revoked'
395
+ | 'upstream_refused'
396
+ | 'relay_busy'
397
+
398
+ export interface KeyNoticeErrorParams {
399
+ keyKind?: KeyKind
400
+ capUsd?: number
401
+ modelLabel?: string
402
+ profileLabel?: string
403
+ /** `settings_override`: the env name or `apiKeyHelper` a Claude settings file sets. */
404
+ settingName?: string
405
+ /** `cap_refused`: now, for the reset time. */
406
+ now?: Date
407
+ timeZone?: string
408
+ }
409
+
410
+ const hours = Math.round(BREAKER_LIMITS.openMs / 3_600_000)
411
+
412
+ /** One template per id; `{f}` is the field `render` fills. */
413
+ const ERRORS: { readonly [K in KeyNoticeErrorId]: (p: KeyNoticeErrorParams) => string } = {
414
+ key_rejected: p => put('Your {f} was rejected. Fix it in COS Control.', providerCopy(p.keyKind).key),
415
+ out_of_credit: p => put('Your {f} is out of credit.', providerCopy(p.keyKind).key),
416
+ cap_refused: p => {
417
+ const reset = p.now ? ` Resets ${keyCapResetTime(p.now, p.timeZone)}.` : ''
418
+ return put(`Today's {f} cap on this key is used.${reset}`, formatKeyUsd(p.capUsd ?? 0))
419
+ },
420
+ model_gone: p => bodyRow(`{f} is no longer on ${providerCopy(p.keyKind).dest}. Pick a model.`, keyNoticeField(p.modelLabel) || 'This model'),
421
+ rate_limited: p => put('{f} is busy for this key. Try again in a minute.', providerCopy(p.keyKind).Dest),
422
+ upstream_error: p => put('{f} had an error. Your plan was not used.', providerCopy(p.keyKind).Dest),
423
+ network_error: p => put('Could not reach {f}. Your plan was not used.', providerCopy(p.keyKind).dest),
424
+ model_not_allowed: () => 'COS stopped a model this key profile does not allow.',
425
+ breaker_open: () => `Key paused ${hours} hour${hours === 1 ? '' : 's'} after ${BREAKER_LIMITS.failures} failures.`,
426
+ profile_off: p => bodyRow('Key profile {f} is off. Pick a model.', keyNoticeField(p.profileLabel) || 'for this thread'),
427
+ profile_removed: () => "This thread's key profile was removed. Pick a model.",
428
+ keyed_session: () => 'This session ran on your key. Continue it from COS.',
429
+ // Two rows: the plan's sentence is wider than one (plan 7.2).
430
+ settings_override: p => `${bodyRow('A Claude settings file sets {f}.', keyNoticeField(p.settingName) || 'a provider name')}\nRemove it to use a key.`,
431
+ mac_has_turn: () => 'Mac has this one. Waiting.',
432
+ dictated_key: () => 'Keys only by paste, on the phone.',
433
+ no_credit_limit: () => 'Set a credit limit on this key at openrouter.ai first.',
434
+ downgrade: () => 'Remove your keys first. Older COS cannot see them.',
435
+ nonce_expired: () => 'This key turn ran past its time limit. Your plan was not used.',
436
+ nonce_revoked: () => 'This key turn was stopped. Your plan was not used.',
437
+ upstream_refused: p => put('{f} refused this request. Your plan was not used.', providerCopy(p.keyKind).Dest),
438
+ relay_busy: () => 'Too many steps at once. Your plan was not used.',
439
+ }
440
+ export const KEY_NOTICE_ERROR_IDS = Object.keys(ERRORS) as KeyNoticeErrorId[]
441
+
442
+ /** The lens line for a key-lane failure or refusal. Never mentions signing in. */
443
+ export function keyNoticeError(id: KeyNoticeErrorId, params: KeyNoticeErrorParams = {}): string {
444
+ const render = Object.prototype.hasOwnProperty.call(ERRORS, id) ? ERRORS[id] : undefined
445
+ return render ? render(params) : KEY_NOTICE_COPY.unknownError
446
+ }
447
+
448
+ // ── Lane refusal lines (plan 11.3, 5.2, 9.4) ─────────────────────────────────
449
+ // Moved here by P11 so this module stays the one source: P3 (lane-b-env.ts LANE_B_COPY), P4
450
+ // (codex-profile-home.ts CODEX_LANE_B_COPY), P5 (direct-bridge.ts DIRECT_COPY) and P6
451
+ // (key-profile-routing.ts, key-profile-rejects.ts) now read their lines from this object. Each line
452
+ // is one lens row with a typical label; no em dashes, no arrows, no sign-in wording.
453
+
454
+ /**
455
+ * QA B1 (main session decision): the plain same-Mac residual risk, shown by COS Control and in the
456
+ * changelog. Exact wording agreed with Control; do not edit without its pin.
457
+ */
458
+ export const KEY_LANE_SAME_MAC_COPY = "Your key is saved on this Mac. Apps and agents that run on this Mac as you can read it. Your provider's spending limit is the real stop."
459
+
460
+ /**
461
+ * Canary rerun item 2: the cap is checked as a reply streams, so the reply already in progress can
462
+ * pass it by up to one response. Shown by COS Control beside the cap (and in the changelog).
463
+ */
464
+ export const KEY_CAP_OVERSHOOT_COPY = 'The cap can be exceeded by the last reply in progress. Your provider limit is the hard stop.'
465
+
466
+ export const KEY_LANE_REFUSAL_COPY = {
467
+ lanesOff: 'Key lanes are off. Pick a model.',
468
+ profileOff: (label: string) => `Key profile ${label} is off. Pick a model.`,
469
+ profileUnavailable: (label: string) => `Key profile ${label} cannot answer on this server yet. Pick a model.`,
470
+ wrongEngineClaude: (label: string) => `Key profile ${label} is not a Claude profile. Pick a model.`,
471
+ wrongEngineCodex: (label: string) => `Key profile ${label} is not a Codex profile. Pick a model.`,
472
+ wrongEngineDirect: 'This key profile is not a direct profile. Pick a model.',
473
+ keyMissing: (label: string) => `The key for profile ${label} is not saved on this Mac. Fix it in COS Control.`,
474
+ codexModelNotAllowed: (label: string) => `Key profile ${label} has a model Codex cannot run. Pick a model.`,
475
+ scheduled: 'Key profiles cannot run scheduled briefs.',
476
+ workNotYet: 'Key sessions are not in Work yet.',
477
+ relayUnavailable: 'Your key could not start this turn. Your plan was not used.',
478
+ codexProjectConfig: (name: string) => `A Codex project config sets ${name}. Remove it to use a key.`,
479
+ v1NotOffered: 'That key profile is not offered on /v1. Turn on Offer on /v1 in COS Control.',
480
+ directCopyUnavailable: 'Your key could not answer this one.',
481
+ directNoEndpoint: 'This key cannot answer direct. Pick a model.',
482
+ directTextOnly: 'Direct key answers are text only. Remove the photo and retry.',
483
+ directEmpty: 'The model finished without an answer. Your plan was not used.',
484
+ directToolCap: 'The model hit its tool round cap without an answer. Ask a narrower question.',
485
+ directCancelled: 'That answer was cancelled.',
486
+ } as const
@@ -0,0 +1,25 @@
1
+ // Key lanes K1, package P6 (plan v4 5.2): the explicit rejects, as a leaf module. The scheduled
2
+ // brief, task dispatch and Work paths import only this (never conversation or model-router), so
3
+ // importing it changes nothing at module load for a server with key lanes off.
4
+ //
5
+ // A reject applies only to a well formed `profile:<id>` (a pure string test) AND only while key
6
+ // lanes are enabled. With lanes off, the input falls through to the site's existing closed
7
+ // refusal, byte for byte as 6.79.0 answers it.
8
+ import { keyLanesEnabled } from './key-lanes.js'
9
+ import { KEY_LANE_REFUSAL_COPY } from './key-notices.js'
10
+ import { isProfilePreference } from '../../shared/key-preference.js'
11
+
12
+ /** Plan 5.2: morning brief and task dispatch. */
13
+ export const KEY_PROFILE_SCHEDULED_REJECT = KEY_LANE_REFUSAL_COPY.scheduled
14
+ /** Plan 5.2: Work handoff requests and drafts, K1. */
15
+ export const KEY_PROFILE_WORK_REJECT = KEY_LANE_REFUSAL_COPY.workNotYet
16
+ /** The error code the rejecting routes answer with. */
17
+ export const KEY_PROFILE_REJECT_CODE = 'key_profile_not_allowed'
18
+
19
+ /** True when `value` names a key profile and the site should say so instead of its generic refusal. */
20
+ export function keyProfileRejectApplies(value: unknown): boolean {
21
+ const applies = isProfilePreference(value) && keyLanesEnabled()
22
+ // QA G4: one line per reject (a closed site met a profile).
23
+ if (applies) console.info('[key-lanes] refused kind=key_profile_not_allowed (scheduled, dispatch or Work site)')
24
+ return applies
25
+ }