@gotcos/glasses-server 6.79.2 → 6.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/CHANGELOG.md +31 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +10 -1
- package/server/index.ts +14 -1
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/server/routes/transcribe-stream.ts +48 -1
- package/shared/key-preference.ts +57 -0
- package/shared/model-preference.ts +20 -2
|
@@ -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
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, resolve } from 'node:path'
|
|
4
|
-
import { OLLAMA_MODEL
|
|
4
|
+
import { OLLAMA_MODEL } from '../../shared/model-preference.js'
|
|
5
|
+
import { dataPath } from './data-dir.js'
|
|
6
|
+
import type { KeyLane } from './key-relay-contract.js'
|
|
5
7
|
|
|
6
8
|
const DEFAULT_MAX_RUNS = 100
|
|
7
9
|
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
|
|
@@ -10,6 +12,13 @@ const RUNNING_STALE_MS = 30 * 60_000
|
|
|
10
12
|
|
|
11
13
|
export type OllamaRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'client_disconnected'
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* One run, Ollama or (key lanes K1 P5, plan v4 9.1) direct. `model` was OllamaModelPreference; it
|
|
17
|
+
* is a string now so a direct run can carry its `profile:<id>` preference. Ollama rows still write
|
|
18
|
+
* exactly `model: 'ollama'` and never a `lane`, `profile` or `providerModel` key, in the 6.79.0
|
|
19
|
+
* key order (pinned byte for byte by ollama-run-ledger.test.ts). Direct rows live in their own
|
|
20
|
+
* file (`direct-runs.jsonl` under the data dir), so the Ollama ledger only ever holds Ollama runs.
|
|
21
|
+
*/
|
|
13
22
|
export interface OllamaRunRecord {
|
|
14
23
|
runId: string
|
|
15
24
|
turnId?: string
|
|
@@ -18,8 +27,15 @@ export interface OllamaRunRecord {
|
|
|
18
27
|
status: OllamaRunStatus
|
|
19
28
|
createdAt: string
|
|
20
29
|
updatedAt: string
|
|
21
|
-
model:
|
|
22
|
-
|
|
30
|
+
model: string
|
|
31
|
+
/** Direct rows only. */
|
|
32
|
+
lane?: KeyLane
|
|
33
|
+
/** Direct rows only: the key profile id. */
|
|
34
|
+
profile?: string
|
|
35
|
+
/** Ollama rows: the pulled tag. Direct rows leave it out and use providerModel. */
|
|
36
|
+
ollamaModel?: string
|
|
37
|
+
/** Direct rows only: the provider model id or OpenRouter slug. */
|
|
38
|
+
providerModel?: string
|
|
23
39
|
origin: string
|
|
24
40
|
queryPreview?: string
|
|
25
41
|
outputPreview?: string
|
|
@@ -42,6 +58,11 @@ export function getOllamaLedgerPath(): string {
|
|
|
42
58
|
return resolve(process.env.COS_OLLAMA_RUN_LEDGER_FILE || resolve(import.meta.dirname, '..', 'data', 'ollama-runs.jsonl'))
|
|
43
59
|
}
|
|
44
60
|
|
|
61
|
+
/** Direct (Lane C1) runs. Written only by a keyed turn, so nothing is created while lanes are off. */
|
|
62
|
+
export function getDirectRunLedgerPath(): string {
|
|
63
|
+
return resolve(process.env.COS_DIRECT_RUN_LEDGER_FILE || dataPath('direct-runs.jsonl'))
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
function getMaxRuns(): number {
|
|
46
67
|
const raw = Number(process.env.COS_OLLAMA_RUN_LEDGER_MAX ?? DEFAULT_MAX_RUNS)
|
|
47
68
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_RUNS
|
|
@@ -52,9 +73,8 @@ function getTtlMs(): number {
|
|
|
52
73
|
return Number.isFinite(rawDays) && rawDays > 0 ? rawDays * 24 * 60 * 60_000 : DEFAULT_TTL_MS
|
|
53
74
|
}
|
|
54
75
|
|
|
55
|
-
function appendEvent(event: OllamaRunEvent): void {
|
|
76
|
+
function appendEvent(event: OllamaRunEvent, path: string = getOllamaLedgerPath()): void {
|
|
56
77
|
try {
|
|
57
|
-
const path = getOllamaLedgerPath()
|
|
58
78
|
mkdirSync(dirname(path), { recursive: true })
|
|
59
79
|
appendFileSync(path, JSON.stringify(event) + '\n')
|
|
60
80
|
} catch (err) {
|
|
@@ -62,8 +82,7 @@ function appendEvent(event: OllamaRunEvent): void {
|
|
|
62
82
|
}
|
|
63
83
|
}
|
|
64
84
|
|
|
65
|
-
function readEvents(): OllamaRunEvent[] {
|
|
66
|
-
const path = getOllamaLedgerPath()
|
|
85
|
+
function readEvents(path: string = getOllamaLedgerPath()): OllamaRunEvent[] {
|
|
67
86
|
if (!existsSync(path)) return []
|
|
68
87
|
try {
|
|
69
88
|
const events: OllamaRunEvent[] = []
|
|
@@ -129,23 +148,29 @@ export function startOllamaRun(input: {
|
|
|
129
148
|
return run
|
|
130
149
|
}
|
|
131
150
|
|
|
132
|
-
|
|
151
|
+
interface FinishRunInput {
|
|
133
152
|
status: Exclude<OllamaRunStatus, 'running'>
|
|
134
153
|
startedAtMs: number
|
|
135
154
|
output?: string
|
|
136
155
|
error?: string
|
|
137
|
-
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function finishRun(path: string, classify: (message: string) => string, runId: string, input: FinishRunInput): void {
|
|
138
159
|
const patch: Partial<OllamaRunRecord> = {
|
|
139
160
|
status: input.status,
|
|
140
161
|
durationMs: Math.max(0, Date.now() - input.startedAtMs),
|
|
141
162
|
}
|
|
142
163
|
if (input.output) patch.outputPreview = redactForOllamaLedger(input.output)
|
|
143
164
|
if (input.error) {
|
|
144
|
-
patch.errorCode =
|
|
165
|
+
patch.errorCode = classify(input.error)
|
|
145
166
|
patch.errorPreview = redactForOllamaLedger(input.error)
|
|
146
167
|
}
|
|
147
168
|
const ts = new Date().toISOString()
|
|
148
|
-
appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } })
|
|
169
|
+
appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } }, path)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function finishOllamaRun(runId: string, input: FinishRunInput): OllamaRunRecord | null {
|
|
173
|
+
finishRun(getOllamaLedgerPath(), classifyOllamaError, runId, input)
|
|
149
174
|
return getOllamaRun(runId)
|
|
150
175
|
}
|
|
151
176
|
|
|
@@ -154,10 +179,14 @@ export function getOllamaRun(runId: string): OllamaRunRecord | null {
|
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[] {
|
|
182
|
+
return listRuns(getOllamaLedgerPath(), 'ollama.timeout', limit, sessionId)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function listRuns(path: string, staleCode: string, limit: number, sessionId?: string): OllamaRunRecord[] {
|
|
157
186
|
const runs = new Map<string, OllamaRunRecord>()
|
|
158
187
|
const order = new Map<string, number>()
|
|
159
188
|
let eventIndex = 0
|
|
160
|
-
for (const event of readEvents()) {
|
|
189
|
+
for (const event of readEvents(path)) {
|
|
161
190
|
eventIndex += 1
|
|
162
191
|
const existing = runs.get(event.runId)
|
|
163
192
|
const next = { ...(existing ?? {}), ...event.patch, runId: event.runId } as OllamaRunRecord
|
|
@@ -175,7 +204,7 @@ export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[
|
|
|
175
204
|
return {
|
|
176
205
|
...run,
|
|
177
206
|
status: 'client_disconnected' as const,
|
|
178
|
-
errorCode: run.errorCode ??
|
|
207
|
+
errorCode: run.errorCode ?? staleCode,
|
|
179
208
|
}
|
|
180
209
|
}
|
|
181
210
|
return run
|
|
@@ -183,3 +212,57 @@ export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[
|
|
|
183
212
|
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
|
184
213
|
.slice(0, limit)
|
|
185
214
|
}
|
|
215
|
+
|
|
216
|
+
// ── Direct runs (key lanes K1 P5, plan v4 9.1) ───────────────────────────────
|
|
217
|
+
|
|
218
|
+
/** `direct.<failure>` for a key-lane failure kind, else the closest generic code. */
|
|
219
|
+
export function classifyDirectError(message: string): string {
|
|
220
|
+
const m = /^direct-bridge: \[([a-z_]+)\]/.exec(message)
|
|
221
|
+
if (m) return `direct.${m[1]}`
|
|
222
|
+
const text = message.toLowerCase()
|
|
223
|
+
if (/tool round cap/.test(text)) return 'direct.tool_cap'
|
|
224
|
+
if (/tool aborted|tool call aborted/.test(text)) return 'direct.tool_abort'
|
|
225
|
+
if (/timeout|timed out|aborted/.test(text)) return 'direct.timeout'
|
|
226
|
+
return 'direct.error'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function startDirectRun(input: {
|
|
230
|
+
turnId?: string
|
|
231
|
+
clientJobId?: string
|
|
232
|
+
cosSessionId: string
|
|
233
|
+
/** The COS preference the exchange is stamped with, `profile:<id>`. */
|
|
234
|
+
model: string
|
|
235
|
+
profile: string
|
|
236
|
+
providerModel: string
|
|
237
|
+
/** Endpoint origin only (scheme, host, port). Never a path, query or key. */
|
|
238
|
+
origin: string
|
|
239
|
+
query: string
|
|
240
|
+
}): OllamaRunRecord {
|
|
241
|
+
const now = new Date().toISOString()
|
|
242
|
+
const run: OllamaRunRecord = {
|
|
243
|
+
runId: `direct-${crypto.randomUUID().slice(0, 8)}`,
|
|
244
|
+
turnId: input.turnId,
|
|
245
|
+
clientJobId: input.clientJobId,
|
|
246
|
+
cosSessionId: input.cosSessionId,
|
|
247
|
+
status: 'running',
|
|
248
|
+
createdAt: now,
|
|
249
|
+
updatedAt: now,
|
|
250
|
+
model: input.model,
|
|
251
|
+
lane: 'direct',
|
|
252
|
+
profile: input.profile,
|
|
253
|
+
providerModel: input.providerModel,
|
|
254
|
+
origin: input.origin,
|
|
255
|
+
queryPreview: redactForOllamaLedger(input.query),
|
|
256
|
+
}
|
|
257
|
+
appendEvent({ runId: run.runId, ts: now, patch: run }, getDirectRunLedgerPath())
|
|
258
|
+
return run
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function finishDirectRun(runId: string, input: FinishRunInput): OllamaRunRecord | null {
|
|
262
|
+
finishRun(getDirectRunLedgerPath(), classifyDirectError, runId, input)
|
|
263
|
+
return listDirectRuns(getMaxRuns()).find(run => run.runId === runId) ?? null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function listDirectRuns(limit = 20, sessionId?: string): OllamaRunRecord[] {
|
|
267
|
+
return listRuns(getDirectRunLedgerPath(), 'direct.timeout', limit, sessionId)
|
|
268
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
+
import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
|
|
2
3
|
|
|
3
4
|
export const PROMPT_EDIT_DRAFT_MAX_CHARS = 24_000
|
|
4
5
|
export const PROMPT_EDIT_INSTRUCTION_MAX_CHARS = 2_000
|
|
@@ -90,6 +91,7 @@ export function spawnClaudeText(prompt: string, opts: SpawnClaudeTextOptions = {
|
|
|
90
91
|
const args = ['-p', '--model', model, '--effort', effort, '--output-format', 'text', '--no-session-persistence',
|
|
91
92
|
'--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}']
|
|
92
93
|
if (opts.systemPrompt) args.push('--system-prompt', opts.systemPrompt)
|
|
94
|
+
args.push(...claudeKeySecretsGuardArgs())
|
|
93
95
|
|
|
94
96
|
const proc = spawn('claude', args, {
|
|
95
97
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type QueryJobSteerState,
|
|
32
32
|
type QueryJobStoreHealth,
|
|
33
33
|
} from './query-job-types.js'
|
|
34
|
+
import type { QueryJobKeyAnswerFields } from './key-answer-fields.js'
|
|
34
35
|
import { SteerPort, steerClock, steerCutoffMs, type PendingSteer, type QueryProvider } from './claude-steer.js'
|
|
35
36
|
import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
|
|
36
37
|
import { applyTrailToolMode, type JobTrailDraft } from './job-trail.js'
|
|
@@ -43,7 +44,7 @@ export interface QueryJobRunnerStart extends QueryJobProviderLinkage {
|
|
|
43
44
|
sessionId?: string
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export interface QueryJobRunnerCompletion extends QueryJobProviderLinkage {
|
|
47
|
+
export interface QueryJobRunnerCompletion extends QueryJobProviderLinkage, QueryJobKeyAnswerFields {
|
|
47
48
|
text: string
|
|
48
49
|
attachments?: unknown
|
|
49
50
|
outputImageStats?: QueryJobOutputImageStats
|
|
@@ -9,7 +9,7 @@ import { dataPath } from './data-dir.js'
|
|
|
9
9
|
import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
|
|
10
10
|
import { durableQueryJobsEnabled, messagesSteerEnabled, messagesTrailEnabled } from './query-job-feature.js'
|
|
11
11
|
import { claudeSupportsSteer } from './claude-cli-support.js'
|
|
12
|
-
import {
|
|
12
|
+
import { steerProviderFor } from './claude-steer.js'
|
|
13
13
|
import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
|
|
14
14
|
import { QueryJobStore, createQueryJobJournalStorage } from './query-job-store.js'
|
|
15
15
|
import {
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
isCursorModel,
|
|
18
18
|
isOllamaModel,
|
|
19
19
|
normalizeEffortPreference,
|
|
20
|
-
normalizeModelPreference,
|
|
21
20
|
type CursorExecutionMode,
|
|
22
21
|
type ModelPreference,
|
|
23
22
|
} from '../../shared/model-preference.js'
|
|
@@ -26,10 +25,15 @@ import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/
|
|
|
26
25
|
import {
|
|
27
26
|
findExchangesByJobIdentity,
|
|
28
27
|
flushConversationToDisk,
|
|
28
|
+
getSessionBinding,
|
|
29
29
|
getSessionModel,
|
|
30
30
|
reconcileExchangeByJobIdentity,
|
|
31
31
|
removeExchangesByJobIdentity,
|
|
32
|
+
type KeyProfileBinding,
|
|
33
|
+
type SessionModelBinding,
|
|
32
34
|
} from './conversation.js'
|
|
35
|
+
import { parsePreference, profilePreference } from '../../shared/key-preference.js'
|
|
36
|
+
import { linkageProviderForEngine, profileSteerProvider, type KeyProfileTurn } from './key-profile-routing.js'
|
|
33
37
|
import {
|
|
34
38
|
isTerminalQueryJobStatus,
|
|
35
39
|
type QueryJobProviderLinkage,
|
|
@@ -40,6 +44,7 @@ import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
|
|
|
40
44
|
import type { JobTrailDraft } from './job-trail.js'
|
|
41
45
|
import { registerMessageReservationSource } from './message-reservations.js'
|
|
42
46
|
import { bootResumeGate } from './boot-resume.js'
|
|
47
|
+
import { keyAnswerCompletionFields, keyAnswerDoneFields } from './key-answer-fields.js'
|
|
43
48
|
|
|
44
49
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
45
50
|
WebSearch: 'Searching web...',
|
|
@@ -118,6 +123,38 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
|
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Plan 5.2 Accept by syntax for the terminal projection: a model slot exactly as
|
|
128
|
+
* normalizeModelPreference gives it, or `profile:<id>`. Same rule as conversation's
|
|
129
|
+
* normalizeSessionBinding, computed here so the projection needs nothing new from the store.
|
|
130
|
+
*/
|
|
131
|
+
function requestBinding(model: unknown): SessionModelBinding | undefined {
|
|
132
|
+
const parsed = parsePreference(model)
|
|
133
|
+
if (!parsed) return undefined
|
|
134
|
+
return parsed.kind === 'model' ? parsed.value : profilePreference(parsed.id) as KeyProfileBinding
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Linkage provider for a keyed turn: the profile's engine. Direct (Lane C) carries none. */
|
|
138
|
+
function keyProfileLinkageProvider(turn: KeyProfileTurn): { provider?: 'claude' | 'codex' } {
|
|
139
|
+
const provider = linkageProviderForEngine(turn.profile.engine)
|
|
140
|
+
return provider ? { provider } : {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The steer gate's provider for a job (P6 owns this; P3 consumes it). With no profile anywhere
|
|
145
|
+
* this is exactly `providerOfModel(resolveRunModel(...))`, as in 6.78.0.
|
|
146
|
+
*/
|
|
147
|
+
export function steerProviderForJob(request: Pick<QueryJobRequest, 'model' | 'sessionId'>): ReturnType<typeof profileSteerProvider> {
|
|
148
|
+
const verdict = steerProviderFor(
|
|
149
|
+
request.model,
|
|
150
|
+
getSessionModel(request.sessionId),
|
|
151
|
+
process.env.COS_G2_DEFAULT_MODEL,
|
|
152
|
+
() => getSessionBinding(request.sessionId),
|
|
153
|
+
)
|
|
154
|
+
if (verdict.kind === 'profile_session') return profileSteerProvider(verdict.profileId)
|
|
155
|
+
return verdict.provider
|
|
156
|
+
}
|
|
157
|
+
|
|
121
158
|
function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' | 'ollama' {
|
|
122
159
|
if (isOllamaModel(model)) return 'ollama'
|
|
123
160
|
if (isCursorModel(model)) return 'cursor'
|
|
@@ -167,7 +204,7 @@ export async function projectPublicConversationTerminal(
|
|
|
167
204
|
request.globalMsgNum,
|
|
168
205
|
request.attachmentRefs,
|
|
169
206
|
request.messageEra,
|
|
170
|
-
|
|
207
|
+
requestBinding(request.model),
|
|
171
208
|
request.origin,
|
|
172
209
|
)
|
|
173
210
|
reconcileExchangeByJobIdentity(
|
|
@@ -178,7 +215,7 @@ export async function projectPublicConversationTerminal(
|
|
|
178
215
|
request.globalMsgNum,
|
|
179
216
|
mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
|
|
180
217
|
request.messageEra,
|
|
181
|
-
|
|
218
|
+
requestBinding(request.model),
|
|
182
219
|
request.origin,
|
|
183
220
|
)
|
|
184
221
|
flushConversationToDisk()
|
|
@@ -204,19 +241,29 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
204
241
|
sessionId: request.sessionId,
|
|
205
242
|
})
|
|
206
243
|
const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
|
|
207
|
-
|
|
244
|
+
// Key lanes K1 (plan 5.2): Accept. A `profile:<id>` goes to model-router, which runs it on the
|
|
245
|
+
// profile's engine or refuses it; the closed slot keeps every family guard below unchanged.
|
|
246
|
+
const requestedBinding = parsePreference(request.model)
|
|
247
|
+
const validModel = requestedBinding?.kind === 'model' ? requestedBinding.value : undefined
|
|
208
248
|
const validEffort = normalizeEffortPreference(request.effort)
|
|
209
249
|
let activeModel = validModel
|
|
250
|
+
// Set only by model-router's onKeyProfileTurn, so a subscription turn never sees it.
|
|
251
|
+
let keyTurn: KeyProfileTurn | undefined
|
|
252
|
+
const linkageFor = (model: ModelPreference) => keyTurn
|
|
253
|
+
? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
|
|
254
|
+
: { provider: providerFor(model), resolvedModel: model }
|
|
210
255
|
|
|
211
256
|
await callModelStreaming(
|
|
212
257
|
request.query,
|
|
213
258
|
request.sessionId,
|
|
214
259
|
{
|
|
260
|
+
...(requestedBinding?.kind === 'model' ? {} : {
|
|
261
|
+
onKeyProfileTurn: (turn: KeyProfileTurn) => { keyTurn = turn },
|
|
262
|
+
}),
|
|
215
263
|
onStart: async (model, sessionId, cliSessionId, metadata) => {
|
|
216
264
|
activeModel = model
|
|
217
265
|
const linkage = {
|
|
218
|
-
|
|
219
|
-
resolvedModel: model,
|
|
266
|
+
...linkageFor(model),
|
|
220
267
|
cliSessionId,
|
|
221
268
|
claudeRunId: metadata?.claudeRunId,
|
|
222
269
|
codexRunId: metadata?.codexRunId,
|
|
@@ -232,7 +279,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
232
279
|
turnId,
|
|
233
280
|
messageEra: request.messageEra,
|
|
234
281
|
globalMsgNum: request.globalMsgNum,
|
|
235
|
-
model,
|
|
282
|
+
model: keyTurn ? keyTurn.binding : model,
|
|
236
283
|
sessionId,
|
|
237
284
|
cliSessionId,
|
|
238
285
|
...metadata,
|
|
@@ -241,7 +288,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
241
288
|
},
|
|
242
289
|
onProviderProcess: metadata => callbacks.onProviderProcess({
|
|
243
290
|
provider: metadata.provider,
|
|
244
|
-
...(activeModel ? { resolvedModel: activeModel } : {}),
|
|
291
|
+
...(keyTurn ? { resolvedModel: keyTurn.binding } : activeModel ? { resolvedModel: activeModel } : {}),
|
|
245
292
|
...(metadata.provider === 'claude'
|
|
246
293
|
? { claudeRunId: metadata.runId }
|
|
247
294
|
: metadata.provider === 'cursor'
|
|
@@ -273,7 +320,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
273
320
|
onTrail: (draft: JobTrailDraft) => callbacks.onTrail(draft),
|
|
274
321
|
} : {}),
|
|
275
322
|
onAnswerReady: text => callbacks.onAnswerReady(text, {
|
|
276
|
-
...(
|
|
323
|
+
...(keyTurn ? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
|
|
324
|
+
: activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
|
|
277
325
|
}),
|
|
278
326
|
onDone: async (fullText, model, cliSessionId, metadata) => {
|
|
279
327
|
const attachments = mergeMediaAttachmentRefs(
|
|
@@ -287,8 +335,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
287
335
|
? acquireMaintenanceWork('query_attachment_write', { allowDuringDrain: true })
|
|
288
336
|
: undefined
|
|
289
337
|
const linkage = {
|
|
290
|
-
|
|
291
|
-
resolvedModel: model,
|
|
338
|
+
...linkageFor(model),
|
|
292
339
|
cliSessionId,
|
|
293
340
|
claudeRunId: metadata?.claudeRunId,
|
|
294
341
|
codexRunId: metadata?.codexRunId,
|
|
@@ -304,6 +351,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
304
351
|
attachments,
|
|
305
352
|
outputImageStats: metadata?.outputImageStats,
|
|
306
353
|
...linkage,
|
|
354
|
+
...keyAnswerCompletionFields(metadata),
|
|
307
355
|
})
|
|
308
356
|
if (!terminalOwned) return
|
|
309
357
|
if (resolvedAttachments.ids.length > 0) {
|
|
@@ -323,9 +371,10 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
323
371
|
globalMsgNum: request.globalMsgNum,
|
|
324
372
|
text: fullText,
|
|
325
373
|
sessionId: request.sessionId,
|
|
326
|
-
model,
|
|
374
|
+
model: keyTurn ? keyTurn.binding : model,
|
|
327
375
|
cliSessionId,
|
|
328
376
|
...runMetadata,
|
|
377
|
+
...keyAnswerDoneFields(runMetadata),
|
|
329
378
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
330
379
|
...originStamp(request),
|
|
331
380
|
} })
|
|
@@ -348,7 +397,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
348
397
|
} })
|
|
349
398
|
},
|
|
350
399
|
},
|
|
351
|
-
validModel,
|
|
400
|
+
requestedBinding?.kind === 'profile' ? requestBinding(request.model) : validModel,
|
|
352
401
|
imageInputs,
|
|
353
402
|
request.reference,
|
|
354
403
|
request.globalMsgNum,
|
|
@@ -371,6 +420,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
371
420
|
sessionLockHeld: true,
|
|
372
421
|
...(request.dispatch ? { dispatch: request.dispatch } : {}),
|
|
373
422
|
...(request.sessionName ? { sessionName: request.sessionName } : {}),
|
|
423
|
+
...(request.keyTurnOnly ? { keyTurnOnly: true } : {}),
|
|
374
424
|
// 6.78.0: only on a run the coordinator decided is steerable.
|
|
375
425
|
...(steer ? { steer } : {}),
|
|
376
426
|
},
|
|
@@ -399,9 +449,11 @@ export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner
|
|
|
399
449
|
bootResumeGate,
|
|
400
450
|
// 6.78.0 Messages steer (COS_MESSAGES_STEER, default off). The provider is resolved as
|
|
401
451
|
// model-router resolves it, so a run is steerable only when it will run on Claude.
|
|
452
|
+
// Key lanes K1 (plan 5.2): claude-steer stays closed and names a profile-bound job
|
|
453
|
+
// `profile_session`; the profile's engine then decides (only Lane B Claude steers).
|
|
402
454
|
steer: {
|
|
403
455
|
enabled: () => messagesSteerEnabled(),
|
|
404
|
-
provider: request =>
|
|
456
|
+
provider: request => steerProviderForJob(request),
|
|
405
457
|
cliSupported: () => claudeSupportsSteer(),
|
|
406
458
|
},
|
|
407
459
|
})
|