@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
|
@@ -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
|
-
/**
|
|
17
|
-
export
|
|
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
|
|
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
|
-
|
|
25
|
-
if (this.cache) return this.cache
|
|
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)
|
|
31
|
-
|
|
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
|
|
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(
|
|
43
|
-
this.cache = { value
|
|
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 {
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
710
|
+
const lim = this.limits()
|
|
624
711
|
if (s.breaker) return 'breaker_open'
|
|
625
|
-
if (counters.cues >=
|
|
626
|
-
|
|
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
|
-
|
|
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 >=
|
|
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 <
|
|
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
|
-
|
|
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().
|
|
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
|
-
|
|
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?:
|
|
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 =
|
|
79
|
-
const resolvedModel =
|
|
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
|
-
...
|
|
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
|
|
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
|
|