@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.
Files changed (74) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +98 -0
  3. package/README.md +20 -1
  4. package/managed-runtime-contract.json +6 -1
  5. package/package.json +9 -1
  6. package/server/index.ts +11 -0
  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/dictation-clean.ts +13 -6
  23. package/server/lib/dictation-fillers.ts +17 -10
  24. package/server/lib/dictation-polish-need.ts +48 -0
  25. package/server/lib/dictation-prewarm.ts +209 -0
  26. package/server/lib/direct-bridge.ts +853 -0
  27. package/server/lib/direct-sse.ts +262 -0
  28. package/server/lib/fork-thread.ts +11 -0
  29. package/server/lib/key-answer-fields.ts +49 -0
  30. package/server/lib/key-catalog.ts +301 -0
  31. package/server/lib/key-lanes-pricing.ts +12 -0
  32. package/server/lib/key-lanes-wiring.ts +346 -0
  33. package/server/lib/key-lanes.ts +145 -0
  34. package/server/lib/key-ledger.ts +123 -0
  35. package/server/lib/key-notices.ts +486 -0
  36. package/server/lib/key-profile-rejects.ts +25 -0
  37. package/server/lib/key-profile-routing.ts +173 -0
  38. package/server/lib/key-profiles.ts +182 -0
  39. package/server/lib/key-relay-contract.ts +262 -0
  40. package/server/lib/key-relay-errors.ts +211 -0
  41. package/server/lib/key-relay-meter.ts +303 -0
  42. package/server/lib/key-relay.ts +1018 -0
  43. package/server/lib/key-secrets-guard.ts +52 -0
  44. package/server/lib/key-store.ts +228 -0
  45. package/server/lib/keyed-sessions.ts +154 -0
  46. package/server/lib/lane-b-env.ts +169 -0
  47. package/server/lib/live-cues-v2-settings.ts +51 -13
  48. package/server/lib/live-cues-v2.ts +147 -19
  49. package/server/lib/model-router.ts +96 -7
  50. package/server/lib/morning-brief-config.ts +3 -0
  51. package/server/lib/native-head.ts +13 -1
  52. package/server/lib/occupancy-probes.ts +4 -0
  53. package/server/lib/ollama-run-ledger.ts +96 -13
  54. package/server/lib/prompt-edit.ts +2 -0
  55. package/server/lib/query-job-coordinator.ts +2 -1
  56. package/server/lib/query-job-runtime.ts +67 -15
  57. package/server/lib/query-job-store.ts +7 -1
  58. package/server/lib/query-job-types.ts +15 -1
  59. package/server/lib/session-hook-events.ts +28 -0
  60. package/server/lib/session-hook-ledger.ts +10 -0
  61. package/server/lib/thread-occupancy.ts +22 -1
  62. package/server/lib/token-audit.ts +18 -4
  63. package/server/lib/work-handoff-requests.ts +3 -0
  64. package/server/routes/agent-session-bindings.ts +15 -0
  65. package/server/routes/agent-sessions.ts +17 -5
  66. package/server/routes/handoffs.ts +22 -1
  67. package/server/routes/health.ts +5 -0
  68. package/server/routes/key-lanes.ts +442 -0
  69. package/server/routes/live-cues.ts +39 -3
  70. package/server/routes/openai-compat.ts +28 -3
  71. package/server/routes/prompt-drafts.ts +75 -8
  72. package/server/routes/query.ts +20 -6
  73. package/shared/key-preference.ts +57 -0
  74. package/shared/model-preference.ts +20 -2
@@ -5,6 +5,12 @@
5
5
  // 3. off (source 'default').
6
6
  // Only the exact value '1' forces. Any other env value (unset, '0', 'true') defers to the setting, as it always read as off.
7
7
  // An unreadable or malformed settings file reads as no setting, so it fails toward off, never toward spending.
8
+ //
9
+ // 6.79.2: the same file holds COS Control's spend limits (gate, cues per meeting, cooldown, writer calls per local day).
10
+ // Each saved value is validated on read; one out of bounds reads as unset (the default), never as a looser limit. An env
11
+ // var for a limit wins over the saved value and locks it. A file holding only `enabled` is still written as schema 1, so
12
+ // a 6.79.1 server reads it exactly as before; any limit makes it schema 2, which 6.79.1 also reads (it looks only at
13
+ // `enabled`).
8
14
 
9
15
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
10
16
  import { dirname } from 'node:path'
@@ -13,34 +19,66 @@ import { dataPath } from './data-dir.js'
13
19
 
14
20
  export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
15
21
 
16
- /** What the engine and the routes need from the store: the saved choice, or null when there is none. */
17
- export interface LiveCuesV2SettingReader { stored(): boolean | null }
22
+ /** COS Control's live-cue spend limits (6.79.2). Bounds are inclusive; integer fields take whole numbers only. */
23
+ export const LIVE_CUES_V2_LIMIT_BOUNDS = {
24
+ gate: { min: 0.5, max: 0.95, default: 0.8, integer: false, env: 'COS_LIVE_CUES_V2_GATE' },
25
+ maxCuesPerMeeting: { min: 1, max: 25, default: 25, integer: true, env: 'COS_LIVE_CUES_V2_MAX_PER_MEETING' },
26
+ cooldownSeconds: { min: 20, max: 900, default: 45, integer: true, env: 'COS_LIVE_CUES_V2_COOLDOWN_S' },
27
+ dailyWriterCalls: { min: 1, max: 500, default: 150, integer: true, env: 'COS_LIVE_CUES_V2_DAILY_WRITER_CALLS' },
28
+ } as const
29
+ export type LiveCuesV2LimitName = keyof typeof LIVE_CUES_V2_LIMIT_BOUNDS
30
+ export const LIVE_CUES_V2_LIMIT_NAMES = Object.keys(LIVE_CUES_V2_LIMIT_BOUNDS) as LiveCuesV2LimitName[]
31
+ export type LiveCuesV2StoredLimits = Partial<Record<LiveCuesV2LimitName, number>>
32
+
33
+ /** Inside the bounds, and whole where the field is whole. */
34
+ export function limitInBounds(name: LiveCuesV2LimitName, value: unknown): value is number {
35
+ const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
36
+ return typeof value === 'number' && Number.isFinite(value) && value >= b.min && value <= b.max && (!b.integer || Number.isInteger(value))
37
+ }
38
+
39
+ /** What the engine and the routes need from the store: the saved choice, or null when there is none, and (6.79.2) the
40
+ * saved limits. `limits` is optional so a reader without it means "no saved limits". */
41
+ export interface LiveCuesV2SettingReader { stored(): boolean | null; limits?(): LiveCuesV2StoredLimits }
18
42
 
19
43
  export class LiveCuesV2Settings implements LiveCuesV2SettingReader {
20
- /** Loaded once; only this server writes the file, and every write goes through save(). */
21
- private cache: { value: boolean | null } | null = null
44
+ /** Loaded once; only this server writes the file, and every write goes through update(). */
45
+ private cache: { value: boolean | null; limits: LiveCuesV2StoredLimits } | null = null
22
46
  constructor(readonly file: string) {}
23
47
 
24
- stored(): boolean | null {
25
- if (this.cache) return this.cache.value
48
+ private load(): { value: boolean | null; limits: LiveCuesV2StoredLimits } {
49
+ if (this.cache) return this.cache
26
50
  let value: boolean | null = null
51
+ const limits: LiveCuesV2StoredLimits = {}
27
52
  try {
28
53
  if (existsSync(this.file)) {
29
54
  const raw = JSON.parse(readFileSync(this.file, 'utf8')) as unknown
30
- if (raw && typeof raw === 'object' && !Array.isArray(raw) && typeof (raw as { enabled?: unknown }).enabled === 'boolean') {
31
- value = (raw as { enabled: boolean }).enabled
55
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
56
+ const r = raw as Record<string, unknown>
57
+ if (typeof r.enabled === 'boolean') value = r.enabled
58
+ for (const name of LIVE_CUES_V2_LIMIT_NAMES) if (limitInBounds(name, r[name])) limits[name] = r[name] as number
32
59
  }
33
60
  }
34
61
  } catch { value = null }
35
- this.cache = { value }
36
- return value
62
+ this.cache = { value, limits }
63
+ return this.cache
37
64
  }
38
65
 
66
+ stored(): boolean | null { return this.load().value }
67
+ limits(): LiveCuesV2StoredLimits { return { ...this.load().limits } }
68
+
39
69
  /** Atomic and durable. The in-memory value changes only after the file is written, so a failed write changes nothing. */
40
- save(enabled: boolean, now: Date = new Date()): void {
70
+ save(enabled: boolean, now: Date = new Date()): void { this.update({ enabled }, now) }
71
+
72
+ /** One write for any mix of the switch and the limits; unnamed fields keep their saved values. */
73
+ update(patch: { enabled?: boolean; limits?: LiveCuesV2StoredLimits }, now: Date = new Date()): void {
74
+ const current = this.load()
75
+ const value = patch.enabled ?? current.value
76
+ const limits = { ...current.limits, ...patch.limits }
77
+ const hasLimits = Object.keys(limits).length > 0
78
+ const body = { schema: hasLimits ? 2 : 1, ...(value === null ? {} : { enabled: value }), ...limits, updatedAt: now.toISOString() }
41
79
  mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
42
- durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
43
- this.cache = { value: enabled }
80
+ durableAtomicWriteFileSync(this.file, JSON.stringify(body) + '\n', { mode: 0o600 })
81
+ this.cache = { value, limits }
44
82
  }
45
83
  }
46
84
 
@@ -26,7 +26,10 @@ import { JevClient, JevError, estimateJevTokens, resolveJevKey, type JevAnswers
26
26
  import { DecisionProviderError, SubqDecisionClient, trackSubqDecisionClient, type DecisionClient } from './decision-provider.js'
27
27
  import type { DecisionProvider } from './decision-settings.js'
28
28
  import { resolveSubqKey } from './subq-key.js'
29
- import { liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource } from './live-cues-v2-settings.js'
29
+ import {
30
+ LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2,
31
+ type LiveCuesV2LimitName, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource, type LiveCuesV2StoredLimits,
32
+ } from './live-cues-v2-settings.js'
30
33
  import { notesMemory } from './live-cues-v2-notes.js'
31
34
  import { meetingLibraryConfigured } from './cos-operations-meetings.js'
32
35
  import { getMeetingStore } from './meeting-store.js'
@@ -87,9 +90,13 @@ export const LIVE_CUES_V2_LIMITS = {
87
90
  export function liveCuesV2Enabled(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): boolean {
88
91
  return resolveLiveCuesV2(env, settings).enabled
89
92
  }
90
- export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
93
+ /** The gate from env alone, or null when env does not set a usable one (unchanged acceptance: 0.3 to 0.99). */
94
+ function envGate(env: NodeJS.ProcessEnv): number | null {
91
95
  const raw = Number(env.COS_LIVE_CUES_V2_GATE)
92
- return Number.isFinite(raw) && raw >= 0.3 && raw <= 0.99 ? raw : LIVE_CUES_V2_LIMITS.defaultGate
96
+ return env.COS_LIVE_CUES_V2_GATE !== undefined && env.COS_LIVE_CUES_V2_GATE.trim() !== '' && Number.isFinite(raw) && raw >= 0.3 && raw <= 0.99 ? raw : null
97
+ }
98
+ export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
99
+ return envGate(env) ?? LIVE_CUES_V2_LIMITS.defaultGate
93
100
  }
94
101
  /** 6.79.0 tuning knobs for Friday's rated-cue review. Unset (or empty, which a managed env may pass for an unset optional
95
102
  * variable) is the calibrated default with no log. Anything else outside the accepted form falls back to the default with
@@ -102,12 +109,49 @@ function knobFallback<T>(name: string, raw: string, fallback: T, accepted: strin
102
109
  }
103
110
  return fallback
104
111
  }
105
- export function liveCuesV2CooldownMs(env: NodeJS.ProcessEnv = process.env): number {
112
+ /** A whole-number env knob (6.79.2 limits): null when unset or empty, the value when inside [min, max], else null with
113
+ * one log line per value (the saved setting or the default then applies). */
114
+ function envWhole(env: NodeJS.ProcessEnv, name: string, min: number, max: number): number | null {
115
+ const raw = env[name]
116
+ if (raw === undefined || raw.trim() === '') return null
117
+ const n = /^\d{1,4}$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN
118
+ return n >= min && n <= max ? n : knobFallback<number | null>(name, raw, null, `a whole number from ${min} to ${max}`)
119
+ }
120
+ /** The cooldown from env alone (whole seconds 20..900), or null when unset, empty or invalid (one log line per value). */
121
+ function envCooldownSeconds(env: NodeJS.ProcessEnv): number | null {
106
122
  const raw = env.COS_LIVE_CUES_V2_COOLDOWN_S
107
- if (raw === undefined || raw.trim() === '') return LIVE_CUES_V2_LIMITS.cooldownAfterCueMs
123
+ if (raw === undefined || raw.trim() === '') return null
108
124
  const seconds = /^\d{1,3}$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN // whole seconds only
109
- return seconds >= 20 && seconds <= 900 ? seconds * 1000
110
- : knobFallback('COS_LIVE_CUES_V2_COOLDOWN_S', raw, LIVE_CUES_V2_LIMITS.cooldownAfterCueMs, 'whole seconds from 20 to 900')
125
+ return seconds >= 20 && seconds <= 900 ? seconds : knobFallback<number | null>('COS_LIVE_CUES_V2_COOLDOWN_S', raw, null, 'whole seconds from 20 to 900')
126
+ }
127
+ export function liveCuesV2CooldownMs(env: NodeJS.ProcessEnv = process.env): number {
128
+ const seconds = envCooldownSeconds(env)
129
+ return seconds === null ? LIVE_CUES_V2_LIMITS.cooldownAfterCueMs : seconds * 1000
130
+ }
131
+
132
+ /** One resolved limit: where its value came from, and whether COS Control may change it (an env var locks it). */
133
+ export interface LiveCuesV2Limit { value: number; source: LiveCuesV2SettingSource; locked: boolean; min: number; max: number; default: number }
134
+ export type LiveCuesV2Limits = Record<LiveCuesV2LimitName, LiveCuesV2Limit>
135
+ /** 6.79.2: env wins and locks; else the saved setting; else the default. Read on every evaluation, so a saved change
136
+ * applies from the next one with no restart. */
137
+ export function resolveLiveCuesV2Limits(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): LiveCuesV2Limits {
138
+ let saved: LiveCuesV2StoredLimits = {}
139
+ try { saved = settings?.limits?.() ?? {} } catch { saved = {} }
140
+ const fromEnv: Record<LiveCuesV2LimitName, number | null> = {
141
+ gate: envGate(env),
142
+ maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env, 1, 25),
143
+ cooldownSeconds: envCooldownSeconds(env),
144
+ dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env, 1, 500),
145
+ }
146
+ const out = {} as LiveCuesV2Limits
147
+ for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
148
+ const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
149
+ const e = fromEnv[name], v = saved[name]
150
+ out[name] = e !== null ? { value: e, source: 'env', locked: true, min: b.min, max: b.max, default: b.default }
151
+ : v !== undefined ? { value: v, source: 'setting', locked: false, min: b.min, max: b.max, default: b.default }
152
+ : { value: b.default, source: 'default', locked: false, min: b.min, max: b.max, default: b.default }
153
+ }
154
+ return out
111
155
  }
112
156
  export function liveCuesV2JudgeKeep(env: NodeJS.ProcessEnv = process.env): number {
113
157
  const raw = env.COS_LIVE_CUES_V2_JUDGE_KEEP
@@ -244,11 +288,22 @@ export function localWeekStart(now: Date, zone: string): string {
244
288
 
245
289
  // ── Durable state: weekly ceiling, tombstones, pauses, per-meeting counters ─────
246
290
  interface MeetingCounters { cues: number; writerCalls: number; at: number }
291
+ /** 6.79.2: usage on the local calendar day (in the stored zone), for the daily writer ceiling and COS Control's readout. */
292
+ export interface LiveCuesV2DayUsage { writerCalls: number; writerTokensIn: number; writerTokensOut: number; cues: number; jevTokens: number }
293
+ const DAY_FIELDS = ['writerCalls', 'writerTokensIn', 'writerTokensOut', 'cues', 'jevTokens'] as const
247
294
  interface DurableState {
248
295
  zone: string; weekStart: string; writerCalls: number
249
296
  ended: Record<string, number>; paused: Record<string, boolean>; pauseSeq: Record<string, number>
250
297
  meetings: Record<string, MeetingCounters>
298
+ day: string; today: LiveCuesV2DayUsage
251
299
  }
300
+ /** Local calendar day (YYYY-MM-DD) in `zone`. Intl, never toISOString: a UTC key flips the day at 19:00 Central. */
301
+ export function localDay(now: Date, zone: string): string {
302
+ return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now)
303
+ }
304
+ const zeroDay = (): LiveCuesV2DayUsage => ({ writerCalls: 0, writerTokensIn: 0, writerTokensOut: 0, cues: 0, jevTokens: 0 })
305
+ /** Saturated writer calls: an unreadable, future-dated or malformed day fails toward NOT spending, like the week. */
306
+ const spentDay = (): LiveCuesV2DayUsage => ({ ...zeroDay(), writerCalls: Number.MAX_SAFE_INTEGER })
252
307
  function validZone(zone: unknown): string | null {
253
308
  if (typeof zone !== 'string' || !zone) return null
254
309
  try { new Intl.DateTimeFormat('en-CA', { timeZone: zone }); return zone } catch { return null }
@@ -278,11 +333,20 @@ export class LiveCuesV2Store {
278
333
  const calls = Number(s.writerCalls ?? 0)
279
334
  // A future week key or a negative/non-numeric count fails toward NOT spending: the week is saturated.
280
335
  const saturated = futureWeek || (sameWeek && !(calls >= 0))
281
- return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings }
336
+ const day = localDay(this.now(), zone)
337
+ const t = (s.today ?? {}) as Partial<Record<string, unknown>>
338
+ let today: LiveCuesV2DayUsage
339
+ if (s.day === undefined || (typeof s.day === 'string' && s.day < day)) today = zeroDay()
340
+ else if (s.day !== day) today = spentDay() // a future or malformed day
341
+ else today = DAY_FIELDS.every(f => t[f] === undefined || Number(t[f]) >= 0)
342
+ ? Object.fromEntries(DAY_FIELDS.map(f => [f, Number(t[f] ?? 0)])) as unknown as LiveCuesV2DayUsage
343
+ : spentDay()
344
+ return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings, day, today }
282
345
  } catch {
283
- // An unreadable file fails closed for this week (no spending) but keeps cues off nothing else.
346
+ // An unreadable file fails closed for this week and this day (no spending) but keeps cues off nothing else.
284
347
  const zone = validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC'
285
- return { zone, weekStart: localWeekStart(this.now(), zone), writerCalls: Number.MAX_SAFE_INTEGER, ended: {}, paused: {}, pauseSeq: {}, meetings: {} }
348
+ return { zone, weekStart: localWeekStart(this.now(), zone), writerCalls: Number.MAX_SAFE_INTEGER, ended: {}, paused: {}, pauseSeq: {}, meetings: {},
349
+ day: localDay(this.now(), zone), today: spentDay() }
286
350
  }
287
351
  }
288
352
  private write(s: DurableState): void {
@@ -294,6 +358,7 @@ export class LiveCuesV2Store {
294
358
  const s = this.read()
295
359
  if (s.writerCalls >= cap) return false
296
360
  s.writerCalls += 1
361
+ s.today.writerCalls += 1
297
362
  const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
298
363
  s.meetings[sessionId] = { ...m, writerCalls: m.writerCalls + 1, at: this.now().getTime() }
299
364
  this.write(s)
@@ -303,8 +368,21 @@ export class LiveCuesV2Store {
303
368
  const s = this.read()
304
369
  const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
305
370
  s.meetings[sessionId] = { ...m, cues: m.cues + 1, at: this.now().getTime() }
371
+ s.today.cues += 1
372
+ this.write(s)
373
+ }
374
+ /** Tokens one evaluation spent (writer in and out, the decision provider's gate and judge). One write; nothing when zero. */
375
+ recordUsage(u: { writerTokensIn?: number; writerTokensOut?: number; jevTokens?: number }): void {
376
+ const add = (n: number | undefined) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.round(n) : 0)
377
+ const inc = { writerTokensIn: add(u.writerTokensIn), writerTokensOut: add(u.writerTokensOut), jevTokens: add(u.jevTokens) }
378
+ if (!inc.writerTokensIn && !inc.writerTokensOut && !inc.jevTokens) return
379
+ const s = this.read()
380
+ s.today.writerTokensIn += inc.writerTokensIn; s.today.writerTokensOut += inc.writerTokensOut; s.today.jevTokens += inc.jevTokens
306
381
  this.write(s)
307
382
  }
383
+ /** Today's usage (local day in the stored zone). A saturated (fail-closed) day reads its writer calls as the sentinel. */
384
+ today(): { day: string } & LiveCuesV2DayUsage { const s = this.read(); return { day: s.day, ...s.today } }
385
+ dailyWriterCalls(): number { return this.read().today.writerCalls }
308
386
  counters(sessionId: string): { cues: number; writerCalls: number } {
309
387
  const m = this.read().meetings[sessionId]
310
388
  return { cues: m?.cues ?? 0, writerCalls: m?.writerCalls ?? 0 }
@@ -544,6 +622,15 @@ export class LiveCuesV2Engine {
544
622
  constructor(private readonly deps: LiveCuesV2Deps) {}
545
623
 
546
624
  private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, this.deps.settings) }
625
+ /** 6.79.2: the spend limits as they stand now (env, else COS Control's saved values, else the defaults). The writer
626
+ * cap per meeting stays twice the cue cap. */
627
+ private limits(): { gate: number; maxCues: number; writerCap: number; cooldownMs: number; dailyWriterCalls: number } {
628
+ const r = resolveLiveCuesV2Limits(this.deps.env, this.deps.settings)
629
+ return { gate: r.gate.value, maxCues: r.maxCuesPerMeeting.value, writerCap: 2 * r.maxCuesPerMeeting.value,
630
+ cooldownMs: r.cooldownSeconds.value * 1000, dailyWriterCalls: r.dailyWriterCalls.value }
631
+ }
632
+ /** Today's usage for COS Control (local day in the stored zone). */
633
+ today(): { day: string } & LiveCuesV2DayUsage { return this.deps.store.today() }
547
634
 
548
635
  capability(): LiveCuesV2Capability {
549
636
  const model = liveCuesV2Model(this.deps.env)
@@ -620,10 +707,12 @@ export class LiveCuesV2Engine {
620
707
  }
621
708
 
622
709
  private stopReasonOf(s: Session, counters: { cues: number; writerCalls: number }): LiveCuesV2StopReason | null {
623
- const L = LIVE_CUES_V2_LIMITS
710
+ const lim = this.limits()
624
711
  if (s.breaker) return 'breaker_open'
625
- if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return 'meeting_cap'
626
- if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return 'budget_exhausted'
712
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) return 'meeting_cap'
713
+ // The daily ceiling reads as budget_exhausted on the wire: the shared stop-reason union (pinned with the companion's
714
+ // copy) has no daily_cap. The trace says daily_cap.
715
+ if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env) || this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) return 'budget_exhausted'
627
716
  return s.stopReason
628
717
  }
629
718
 
@@ -665,10 +754,12 @@ export class LiveCuesV2Engine {
665
754
  if (s.breaker) return this.traceSkip(s, 'breaker_open', nowMs)
666
755
  if (this.deps.store.isPaused(sessionId)) return this.traceSkip(s, 'paused', nowMs)
667
756
  if (s.wordsSinceEval < L.minNewWords) return
668
- if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < liveCuesV2CooldownMs(this.deps.env)) return
757
+ const lim = this.limits()
758
+ if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < lim.cooldownMs) return
669
759
  const counters = this.deps.store.counters(sessionId)
670
- if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return this.traceSkip(s, 'meeting_cap', nowMs)
760
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) return this.traceSkip(s, 'meeting_cap', nowMs)
671
761
  if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return this.traceSkip(s, 'budget_exhausted', nowMs) // before the gate
762
+ if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) return this.traceSkip(s, 'daily_cap', nowMs) // before the gate
672
763
  if (this.deps.busy()) return this.traceSkip(s, 'busy', nowMs)
673
764
  await this.evaluate(s)
674
765
  }
@@ -704,7 +795,7 @@ export class LiveCuesV2Engine {
704
795
  const gate = await this.deps.gate(s.title, window, abort.signal, provider)
705
796
  const pCue = Number.isFinite(gate.pCue) ? gate.pCue : 0
706
797
  Object.assign(trace, { gateMs: Date.now() - t0, pCue: round(pCue), cueKind: gate.kind, lookup: gate.lookup, gateTokens: gate.tokens })
707
- if (pCue < liveCuesV2Gate(this.deps.env)) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
798
+ if (pCue < this.limits().gate) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
708
799
  if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
709
800
 
710
801
  const t1 = Date.now()
@@ -731,6 +822,10 @@ export class LiveCuesV2Engine {
731
822
  trace.kept = kept.length
732
823
  if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
733
824
 
825
+ // 6.79.2: limits lowered while this evaluation ran apply before the writer is charged.
826
+ const lim = this.limits(), counters = this.deps.store.counters(s.sessionId)
827
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) { trace.outcome = 'meeting_cap'; return }
828
+ if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) { trace.outcome = 'daily_cap'; s.stopReason = 'budget_exhausted'; return }
734
829
  if (!this.deps.store.charge(liveCuesV2WeeklyCap(this.deps.env), s.sessionId)) { trace.outcome = 'budget_exhausted'; s.stopReason = 'budget_exhausted'; return }
735
830
  const { version, system } = liveCuesV2WriterSystem(this.deps.env)
736
831
  const model = liveCuesV2Model(this.deps.env)
@@ -791,6 +886,11 @@ export class LiveCuesV2Engine {
791
886
  if (error instanceof LensGistLimitError || s.failures >= L.breakerFailures) s.breaker = true
792
887
  } finally {
793
888
  clearTimeout(wall)
889
+ // 6.79.2: today's spend for COS Control, from the numbers this evaluation traced. One write; best effort.
890
+ try {
891
+ this.deps.store.recordUsage({ writerTokensIn: trace.writerIn as number | undefined, writerTokensOut: trace.writerOut as number | undefined,
892
+ jevTokens: (Number(trace.gateTokens) || 0) + (Number(trace.judgeTokens) || 0) })
893
+ } catch { /* the readout is best effort; the trace row still has the numbers */ }
794
894
  if (wallHit && trace.outcome === 'cancelled') { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true }
795
895
  if (trace.outcome === 'cancelled' && abort.signal.reason === V2_OFF) trace.cancelReason = V2_OFF
796
896
  s.inFlight = null
@@ -850,16 +950,44 @@ export function liveCuesV2SettingsView(env: NodeJS.ProcessEnv = process.env): Li
850
950
  /** Save the switch. Off while env forces it on is refused and changes nothing. Turning it off ends cue evaluation for running
851
951
  * meetings at once; turning it on applies from each meeting's next evaluation. */
852
952
  export function setLiveCuesV2Enabled(enabled: boolean, env: NodeJS.ProcessEnv = process.env): { ok: true; view: LiveCuesV2SettingsResponse } | { ok: false; error: 'env_forced' } {
853
- if (!enabled && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
953
+ const r = applyLiveCuesV2Settings({ enabled, limits: {} }, env)
954
+ if (!r.ok) return { ok: false, error: 'env_forced' }
955
+ return { ok: true, view: liveCuesV2SettingsView(env) }
956
+ }
957
+
958
+ /** GET /api/live-cues/v2/settings?v=2 and POST {v: 2, ...} (6.79.2): the v1 body plus the spend limits and today's usage.
959
+ * Without v=2 the routes answer exactly the v1 body, so an older COS Control is unaffected. */
960
+ export interface LiveCuesV2SettingsResponseV2 extends LiveCuesV2SettingsResponse {
961
+ v: 2
962
+ limits: LiveCuesV2Limits
963
+ /** Always twice the cue cap. */
964
+ writerCallsPerMeeting: number
965
+ today: { day: string } & LiveCuesV2DayUsage
966
+ }
967
+ export function liveCuesV2SettingsViewV2(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponseV2 {
968
+ const limits = resolveLiveCuesV2Limits(env, liveCuesV2Settings())
969
+ return { ...liveCuesV2SettingsView(env), v: 2, limits, writerCallsPerMeeting: 2 * limits.maxCuesPerMeeting.value, today: liveCuesV2().today() }
970
+ }
971
+
972
+ /** Save the switch and any limits in ONE write, after every check passed: nothing is saved when any part is refused.
973
+ * Turning v2 off stops evaluations in flight; limits apply from each meeting's next evaluation. */
974
+ export function applyLiveCuesV2Settings(patch: { enabled?: boolean; limits: LiveCuesV2StoredLimits }, env: NodeJS.ProcessEnv = process.env):
975
+ { ok: true } | { ok: false; error: 'env_forced' } | { ok: false; error: 'env_locked'; fields: LiveCuesV2LimitName[] } {
976
+ if (patch.enabled === false && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
977
+ const current = resolveLiveCuesV2Limits(env, liveCuesV2Settings())
978
+ const locked = (Object.keys(patch.limits) as LiveCuesV2LimitName[]).filter(name => current[name].locked)
979
+ if (locked.length) return { ok: false, error: 'env_locked', fields: locked }
854
980
  const wasOn = liveCuesV2Enabled(env)
855
- liveCuesV2Settings().save(enabled)
981
+ liveCuesV2Settings().update(patch)
856
982
  if (wasOn && !liveCuesV2Enabled(env)) {
857
983
  const aborted = engine?.suspend() ?? 0
858
984
  console.log(`[live-cues-v2] switched off by setting; ${aborted} evaluation(s) stopped`)
859
985
  } else if (!wasOn && liveCuesV2Enabled(env)) {
860
986
  console.log('[live-cues-v2] switched on by setting')
861
987
  }
862
- return { ok: true, view: liveCuesV2SettingsView(env) }
988
+ const changed = Object.entries(patch.limits).map(([k, v]) => `${k}=${v}`).join(' ')
989
+ if (changed) console.log(`[live-cues-v2] limits saved: ${changed}`)
990
+ return { ok: true }
863
991
  }
864
992
  /** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642
865
993
  * companion treats the server exactly like a v1 server (v1 cues keep their own capability and indicator). */
@@ -4,10 +4,12 @@ import { callCursorStreaming } from './cursor-bridge.js'
4
4
  import { callOllamaStreaming } from './ollama-bridge.js'
5
5
  import {
6
6
  getOrCreateSession,
7
+ getSessionBinding,
7
8
  getSessionModel,
8
9
  setSessionModel,
9
10
  type ModelPreference,
10
11
  type PromptReference,
12
+ type SessionModelBinding,
11
13
  } from './conversation.js'
12
14
  import {
13
15
  DEFAULT_MODEL,
@@ -15,7 +17,6 @@ import {
15
17
  isClaudeModel,
16
18
  isCursorModel,
17
19
  isOllamaModel,
18
- normalizeModelPreference,
19
20
  } from '../../shared/model-preference.js'
20
21
  import {
21
22
  getCursorModelCatalog,
@@ -27,6 +28,14 @@ import {
27
28
  isOllamaProviderReady,
28
29
  } from './ollama-catalog.js'
29
30
  import type { ModelImageInput } from './model-image-input.js'
31
+ import { parsePreference } from '../../shared/key-preference.js'
32
+ import {
33
+ closedEnvDefaultModel,
34
+ keyProfileEngineRunner,
35
+ keyProfileUnavailableLine,
36
+ resolveKeyProfileTurn,
37
+ type KeyProfileTurnCallbacks,
38
+ } from './key-profile-routing.js'
30
39
 
31
40
  // Bridges return as soon as their subprocess is spawned, while completion is
32
41
  // delivered later through callbacks. This keyed tail queue therefore releases
@@ -57,8 +66,8 @@ export async function acquireModelSessionRunLock(sessionId: string): Promise<()
57
66
  export async function callModelStreaming(
58
67
  query: string,
59
68
  sessionId: string | undefined,
60
- callbacks: StreamCallbacks,
61
- model?: ModelPreference,
69
+ callbacks: StreamCallbacks & KeyProfileTurnCallbacks,
70
+ model?: SessionModelBinding,
62
71
  images?: ModelImageInput[],
63
72
  reference?: PromptReference,
64
73
  globalMsgNum?: number,
@@ -71,12 +80,21 @@ export async function callModelStreaming(
71
80
  throw new Error('model-router: request aborted before the model run started.')
72
81
  }
73
82
 
83
+ // Key lanes K1 (plan 5.2): the request model is ACCEPTED here. parsePreference returns exactly
84
+ // normalizeModelPreference for every model slot; only a well formed `profile:<id>` is new.
85
+ const requested = parsePreference(model)
74
86
  const sessionModel = getSessionModel(sid)
87
+ // A session bound to a profile reads null from the closed getSessionModel. It is looked up
88
+ // only when nothing else names the model, so every 6.79.0 path stays exactly as it was.
89
+ const bound = requested ?? (sessionModel === null ? parsePreference(getSessionBinding(sid)) : undefined)
90
+ const profileId = bound?.kind === 'profile' ? bound.id : undefined
91
+ if (profileId !== undefined) return await runKeyProfileRoute(query, sid, callbacks, profileId, release, images, reference, globalMsgNum, options)
92
+
75
93
  // COS_G2_DEFAULT_MODEL is the documented default-model switch (CHANGELOG 6.1.0);
76
94
  // it must win over the hardcoded DEFAULT_MODEL on this primary query path, not
77
- // just the OpenAI-compat surface.
78
- const envDefault = normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL)
79
- const resolvedModel = normalizeModelPreference(model) ?? sessionModel ?? envDefault ?? DEFAULT_MODEL
95
+ // just the OpenAI-compat surface. A profile id there is ignored (plan 5.2: closed).
96
+ const envDefault = closedEnvDefaultModel(process.env.COS_G2_DEFAULT_MODEL)
97
+ const resolvedModel = (requested?.kind === 'model' ? requested.value : undefined) ?? sessionModel ?? envDefault ?? DEFAULT_MODEL
80
98
 
81
99
  setSessionModel(sid, resolvedModel)
82
100
 
@@ -86,8 +104,9 @@ export async function callModelStreaming(
86
104
  terminal = true
87
105
  release()
88
106
  }
107
+ const { onKeyProfileTurn: _onKeyProfileTurn, ...streamCallbacks } = callbacks
89
108
  const lockedCallbacks: StreamCallbacks = {
90
- ...callbacks,
109
+ ...streamCallbacks,
91
110
  onDone: async (fullText, completedModel, cliSessionId, metadata) => {
92
111
  try {
93
112
  return await callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
@@ -142,3 +161,73 @@ export async function callModelStreaming(
142
161
  throw err
143
162
  }
144
163
  }
164
+
165
+ /**
166
+ * Key lanes K1 (plan 5.2): a turn bound to `profile:<id>`. Refused with a lens line, never
167
+ * answered on the subscription, when lanes are off, the profile is unknown, disabled or keyless,
168
+ * no runner is registered for its engine, or it is a scheduled dispatch.
169
+ */
170
+ async function runKeyProfileRoute(
171
+ query: string,
172
+ sid: string,
173
+ callbacks: StreamCallbacks & KeyProfileTurnCallbacks,
174
+ profileId: string,
175
+ release: () => void,
176
+ images?: ModelImageInput[],
177
+ reference?: PromptReference,
178
+ globalMsgNum?: number,
179
+ options?: CallOptions,
180
+ ): Promise<string> {
181
+ let terminal = false
182
+ const releaseTerminal = () => {
183
+ if (terminal) return
184
+ terminal = true
185
+ release()
186
+ }
187
+ const { onKeyProfileTurn, ...streamCallbacks } = callbacks
188
+ const lockedCallbacks: StreamCallbacks = {
189
+ ...streamCallbacks,
190
+ onDone: async (fullText, completedModel, cliSessionId, metadata) => {
191
+ try {
192
+ return await callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
193
+ } finally {
194
+ releaseTerminal()
195
+ }
196
+ },
197
+ onError: async (error) => {
198
+ try {
199
+ await callbacks.onError(error)
200
+ } finally {
201
+ releaseTerminal()
202
+ }
203
+ },
204
+ }
205
+ try {
206
+ if (options?.dispatch) {
207
+ console.info(`[key-lanes] refused kind=dispatch profile=${profileId}`)
208
+ await lockedCallbacks.onError('dispatch_requires_claude')
209
+ return sid
210
+ }
211
+ const resolved = resolveKeyProfileTurn(profileId)
212
+ if (!resolved.ok) {
213
+ // QA G4: one line per refusal kind.
214
+ console.info(`[key-lanes] refused kind=${resolved.code} profile=${profileId}`)
215
+ await lockedCallbacks.onError(resolved.message)
216
+ return sid
217
+ }
218
+ const runner = keyProfileEngineRunner(resolved.turn.profile.engine)
219
+ if (!runner) {
220
+ console.info(`[key-lanes] refused kind=no_runner engine=${resolved.turn.profile.engine}`)
221
+ await lockedCallbacks.onError(keyProfileUnavailableLine(resolved.turn.profile.label))
222
+ return sid
223
+ }
224
+ // Persisted by syntax (conversation.ts) so the next turn of this session stays on the key,
225
+ // unless this is the one-turn plan-limit offer (QA U9): then the session keeps its model.
226
+ if (!options?.keyTurnOnly) setSessionModel(sid, resolved.turn.binding)
227
+ onKeyProfileTurn?.(resolved.turn)
228
+ return await runner(query, sid, lockedCallbacks, resolved.turn, images, reference, globalMsgNum, options)
229
+ } catch (err) {
230
+ releaseTerminal()
231
+ throw err
232
+ }
233
+ }
@@ -19,6 +19,7 @@ import { chmodSync, mkdirSync } from 'node:fs'
19
19
  import { dirname } from 'node:path'
20
20
  import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
21
21
  import { dataPath } from './data-dir.js'
22
+ import { KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT, keyProfileRejectApplies } from './key-profile-rejects.js'
22
23
  import {
23
24
  normalizeEffortPreference,
24
25
  normalizeModelPreference,
@@ -410,6 +411,8 @@ export function applyMorningBriefPatch(current: MorningBriefConfig, raw: unknown
410
411
  delete next.model
411
412
  } else {
412
413
  const model = normalizeModelPreference(patch.model)
414
+ // Key lanes K1 (plan 5.2): Reject, and say why. A brief always runs on the subscription.
415
+ if (!model && keyProfileRejectApplies(patch.model)) throw new MorningBriefConfigError(KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT)
413
416
  if (!model) throw new MorningBriefConfigError('invalid_model', 'Unknown model preference.')
414
417
  next.model = model
415
418
  }
@@ -195,6 +195,8 @@ export interface NativeHeadDirs {
195
195
  claudeProjectsDir: string
196
196
  /** `<COS_AGENT_SESSIONS_HOME|~>/.codex/sessions` */
197
197
  codexSessionsDir: string
198
+ /** Key lanes (plan v4 8.2): each keyed profile home's `sessions`, searched after the server's. Absent with no profile home. */
199
+ codexProfileSessionsDirs?: string[]
198
200
  /** `<COS_AGENT_SESSIONS_HOME|~>/.cursor/projects` */
199
201
  cursorProjectsDir: string
200
202
  }
@@ -495,7 +497,16 @@ function claudeTranscriptPath(threadId: string, deps: NativeHeadDeps): string |
495
497
  * certified.
496
498
  */
497
499
  function codexRolloutPath(threadId: string, deps: NativeHeadDeps): string | null {
498
- const root = deps.dirs.codexSessionsDir
500
+ const found = codexRolloutPathIn(deps.dirs.codexSessionsDir, threadId, deps)
501
+ if (found !== null || !deps.dirs.codexProfileSessionsDirs) return found
502
+ for (const root of deps.dirs.codexProfileSessionsDirs) {
503
+ const keyed = codexRolloutPathIn(root, threadId, deps)
504
+ if (keyed !== null) return keyed
505
+ }
506
+ return null
507
+ }
508
+
509
+ function codexRolloutPathIn(root: string, threadId: string, deps: NativeHeadDeps): string | null {
499
510
  if (!deps.dirExists(root)) return null
500
511
 
501
512
  let scanned = 0
@@ -626,6 +637,7 @@ export function realNativeHeadDirs(): NativeHeadDirs {
626
637
  claudeProjectsDir: roots.claudeProjects,
627
638
  codexSessionsDir: roots.codexSessions,
628
639
  cursorProjectsDir: roots.cursorProjects,
640
+ ...(roots.codexProfileSessions ? { codexProfileSessionsDirs: roots.codexProfileSessions } : {}),
629
641
  }
630
642
  }
631
643
 
@@ -63,6 +63,7 @@ import { activeSqliteReaderPids, cliChatLastWriteMs } from './cursor-session-mod
63
63
  // about which directory "empty" was observed in.
64
64
  export { claudeSessionsDir } from '../routes/claude-sessions.js'
65
65
  import { claudeSessionsDir } from '../routes/claude-sessions.js'
66
+ import { profileCodexHomeSubdirs } from './codex-homes.js'
66
67
 
67
68
  /**
68
69
  * `<CODEX_HOME|~/.codex>/thread-writer-locks`.
@@ -78,10 +79,13 @@ export function codexLocksDir(): string {
78
79
 
79
80
  export function realOccupancyDirs(): OccupancyDirs {
80
81
  const home = process.env.COS_AGENT_SESSIONS_HOME?.trim() || homedir()
82
+ const profileLocks = profileCodexHomeSubdirs('thread-writer-locks')
81
83
  return {
82
84
  claudeSessionsDir: claudeSessionsDir(),
83
85
  codexLocksDir: codexLocksDir(),
84
86
  cursorChatsDir: join(home, '.cursor', 'chats'),
87
+ // Key lanes K1 (plan v4 8.2): absent with no keyed profile home, so the dirs are 963fe25's.
88
+ ...(profileLocks.length ? { codexProfileLocksDirs: profileLocks } : {}),
85
89
  }
86
90
  }
87
91