@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
@@ -0,0 +1,853 @@
1
+ // Key lanes K1 P5 (plan v4 9.1): Lane C1, direct answers on the Mac.
2
+ //
3
+ // A key profile with `engine: 'direct'` answers with one OpenAI-style chat-completions call per
4
+ // round to the profile's endpoint (OpenRouter, OpenAI, Anthropic's compatible endpoint, or a
5
+ // custom compatible URL), with the read-only COS tools when the profile may use them. No CLI, no
6
+ // child process.
7
+ //
8
+ // Key This module never sees one. Every request goes through the in-process DirectRelay
9
+ // (registered by P11 from P2's relay): it reserves against the key's daily cap,
10
+ // forwards with the real key, and settles what this module measured. Unregistered,
11
+ // every direct turn is refused before any history, ledger row or request.
12
+ // Tools Reused from the Ollama path (buildOllamaToolDefs, executeOllamaTool). D7: OFF for
13
+ // any non-loopback endpoint unless the profile opted in (`directTools`). A tool call
14
+ // the model makes when no tools were offered is never executed.
15
+ // Wire direct-sse.ts. Tool calls arrive as indexed fragments with string arguments; each
16
+ // result goes back as `{ role: 'tool', tool_call_id }`, never Ollama's `tool_name`.
17
+ // Posts At most 5 per turn (the Ollama MAX_CHAT_POSTS rule): posts 1 to 4 may run a tool
18
+ // batch, post 5 is the closing fetch and its calls are never run. Retries do not count.
19
+ // Retries Own loop (plan 4.6): 2 retries on 429, 5xx and network errors, honoring
20
+ // Retry-After up to 30 s; a longer Retry-After stops with the 429 line. 401, 402 and
21
+ // model-gone end the turn at once. The relay never retries.
22
+ // Copy Every key-lane line comes from P8's key-notices.ts (see loadDirectNotices).
23
+ //
24
+ // HARD RULE: nothing here runs unless a caller hands it a key profile, and the first thing it
25
+ // does is check keyLanesActive(). With key lanes off this module is never called and does nothing
26
+ // at import beyond what ollama-bridge.ts already loads.
27
+
28
+ import type { CallOptions, ModelRunMetadata, StreamCallbacks } from './claude-bridge.js'
29
+ import {
30
+ addExchange,
31
+ formatHistoryForPrompt,
32
+ getHistory,
33
+ getOrCreateSession,
34
+ getSessionRaw,
35
+ isNewSession,
36
+ markSessionNotified,
37
+ reconcileExchangeByJobIdentity,
38
+ type PromptReference,
39
+ } from './conversation.js'
40
+ import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
41
+ import { finishDirectRun, startDirectRun } from './ollama-run-ledger.js'
42
+ import { notifyExchange, notifySessionStart } from './telegram-notify.js'
43
+ import type { ModelPreference } from '../../shared/model-preference.js'
44
+ import { profilePreference } from '../../shared/key-preference.js'
45
+ import { getCachedContextInstant } from './context-builder.js'
46
+ import { getOwnerName } from './profile.js'
47
+ import {
48
+ buildOllamaToolDefs,
49
+ executeOllamaTool,
50
+ OLLAMA_NO_TOOLS_SENTENCE,
51
+ ollamaCosPipelineConfigured,
52
+ parseToolArguments,
53
+ type OllamaToolDef,
54
+ } from './ollama-tools.js'
55
+ import { historyToOllamaMessages } from './ollama-bridge.js'
56
+ import { TOOL_HONESTY_CLAUSE, UNTRUSTED_CONTENT_CLAUSE } from './claude-tool-access.js'
57
+ import { ollamaTrailOutcome, ollamaTrailStep, type JobTrailDraft } from './job-trail.js'
58
+ import { keyLanesActive } from './key-lanes.js'
59
+ import * as keyNotices from './key-notices.js'
60
+ import { KEY_LANE_REFUSAL_COPY } from './key-notices.js'
61
+ import { classifyUpstreamStatus } from './key-relay-errors.js'
62
+ import { getPublicKey } from './key-store.js'
63
+ import { logTokenAudit } from './token-audit.js'
64
+ import { getKeyCatalog, keyModelPrice, keyModelSupportsTools, type KeyCatalog } from './key-catalog.js'
65
+ import { DirectSseParser, parseDirectJsonBody, type DirectToolCall } from './direct-sse.js'
66
+ import type {
67
+ AnswerKeyFields,
68
+ KeyKind,
69
+ KeyProfile,
70
+ KeyRef,
71
+ PublicKeyRecord,
72
+ RelayFailureKind,
73
+ } from './key-relay-contract.js'
74
+
75
+ // ── Limits ───────────────────────────────────────────────────────────────────
76
+
77
+ /** Requests per turn, counted as fetches like Ollama's MAX_CHAT_POSTS. Post 5 is the closing fetch. */
78
+ export const DIRECT_MAX_POSTS = 5
79
+ /** Plan 4.6: C1 retries 429, 5xx and network errors twice. */
80
+ export const DIRECT_MAX_RETRIES = 2
81
+ /** Plan 4.6: honor Retry-After up to this; a longer one stops the turn with the 429 line. */
82
+ export const DIRECT_RETRY_AFTER_MAX_MS = 30_000
83
+ /** Wait before a retry when the server sent no Retry-After. */
84
+ export const DIRECT_RETRY_BACKOFF_MS = [1_000, 3_000] as const
85
+ /** `max_tokens` on every request, so the relay's reservation is bounded (plan 4.5). */
86
+ export const DIRECT_MAX_OUTPUT_TOKENS = 4096
87
+ const INACTIVITY_MS = 60_000
88
+ const WALL_MAX_MS = 180_000
89
+ const WALL_HARD_MAX_MS = 300_000
90
+ const TOOL_HEARTBEAT_MS = 5_000
91
+ const ERROR_BODY_MAX_CHARS = 8_192
92
+
93
+ // ── Endpoints and D7 ─────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Chat-completions URL per key kind. A custom compatible key uses its own base URL. No Anthropic
97
+ * entry: P1's profile policy (key-profiles.ts engineModelProblem) refuses a direct profile on an
98
+ * Anthropic key, so an Anthropic key here answers "cannot answer direct".
99
+ */
100
+ export const DIRECT_ENDPOINTS: Readonly<Partial<Record<KeyKind, string>>> = {
101
+ openrouter: 'https://openrouter.ai/api/v1/chat/completions',
102
+ openai: 'https://api.openai.com/v1/chat/completions',
103
+ }
104
+
105
+ /** Where a direct turn on this key posts, or null when the key cannot answer direct. */
106
+ export function directEndpointFor(key: Pick<PublicKeyRecord, 'kind' | 'baseUrl'>): string | null {
107
+ if (key.kind === 'openai-compatible') {
108
+ if (!key.baseUrl) return null
109
+ try {
110
+ const u = new URL(key.baseUrl)
111
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null
112
+ // QA N5: a key saved before the https rule still never posts in the clear to another host.
113
+ if (u.protocol === 'http:' && !isLoopbackEndpoint(u.toString())) return null
114
+ return `${key.baseUrl.replace(/\/+$/, '')}/chat/completions`
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+ return Object.prototype.hasOwnProperty.call(DIRECT_ENDPOINTS, key.kind) ? DIRECT_ENDPOINTS[key.kind] ?? null : null
120
+ }
121
+
122
+ /** True only for 127.0.0.0/8, ::1 and `localhost`. Anything unparseable is remote. */
123
+ export function isLoopbackEndpoint(url: string): boolean {
124
+ let host: string
125
+ try {
126
+ host = new URL(url).hostname.toLowerCase()
127
+ } catch {
128
+ return false
129
+ }
130
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
131
+ if (host === 'localhost' || host === '::1') return true
132
+ const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
133
+ return !!m && m.slice(1).every(o => Number(o) <= 255)
134
+ }
135
+
136
+ /**
137
+ * Decision D7: COS read tools (meetings, memories) are OFF for any non-loopback endpoint unless
138
+ * this profile opted in. A loopback endpoint keeps meeting text on this Mac, so it gets them.
139
+ */
140
+ export function directToolsAllowed(profile: Pick<KeyProfile, 'directTools'>, endpoint: string): boolean {
141
+ if (profile.directTools === true) return true
142
+ return isLoopbackEndpoint(endpoint)
143
+ }
144
+
145
+ // ── The relay port (in process; P2's meter and upstream client implement it) ──
146
+
147
+ /**
148
+ * What a direct turn is bound to. P2's KeyRelay (`issueTurn`) is the HTTP nonce relay for CLI
149
+ * children and its RelayEngine has no `direct`; plan 4.1 puts C1 in process ("calls the same
150
+ * meter and upstream client, no HTTP hop"). This is that in-process door. P11 registers an
151
+ * implementation backed by P2's shared meter, breakers and readKeySecretForRelay().
152
+ */
153
+ export interface DirectTurnBinding {
154
+ keyRef: KeyRef
155
+ keyKind: KeyKind
156
+ profileId: string
157
+ turnId: string
158
+ sessionId: string
159
+ /** The profile's model; the relay forwards only this. */
160
+ model: string
161
+ /** The exact chat-completions URL this module decided D7 for. The relay posts only here. */
162
+ endpoint: string
163
+ }
164
+
165
+ /** Why a direct turn or post was refused before any byte went upstream. */
166
+ export type DirectRefusalKind = RelayFailureKind | 'breaker_open' | 'lanes_inactive' | 'key_missing'
167
+
168
+ /** What this module measured for one post. `null` from the caller means none arrived. */
169
+ export interface DirectUsage {
170
+ input: number
171
+ output: number
172
+ /** Provider-reported cost (OpenRouter `usage.cost`); the relay prices tokens when absent. */
173
+ costUsd?: number
174
+ generationId?: string
175
+ }
176
+
177
+ export interface DirectRelayPost {
178
+ /** The upstream response, untouched (status, headers, streamed body). */
179
+ response: Response
180
+ /**
181
+ * Call exactly once per post. `null` means no usage arrived (stream cut): the relay settles the
182
+ * full reservation. A non-2xx response is settled as `{ input: 0, output: 0 }` (not billed).
183
+ */
184
+ settle(usage: DirectUsage | null): void
185
+ }
186
+
187
+ export interface DirectRelayTurn {
188
+ /**
189
+ * Reserve against the key's daily cap, then forward `body` to the binding's endpoint with the
190
+ * real key. Never retries. `{ refused }` when the cap, breaker or key says no before sending.
191
+ */
192
+ post(body: Record<string, unknown>, signal: AbortSignal): Promise<DirectRelayPost | { refused: DirectRefusalKind }>
193
+ /** Spent today on this key including open reservations, for the near-cap label. */
194
+ spentTodayUsd?(): number
195
+ close(): void
196
+ }
197
+
198
+ export interface DirectRelay {
199
+ openTurn(binding: DirectTurnBinding): Promise<{ ok: true; turn: DirectRelayTurn } | { ok: false; refused: DirectRefusalKind }>
200
+ }
201
+
202
+ type DirectRelayResolver = () => DirectRelay | Promise<DirectRelay>
203
+ let relayResolver: DirectRelayResolver | null = null
204
+
205
+ /** P11 wires the real relay. Null unregisters (tests). */
206
+ export function registerDirectRelay(resolver: DirectRelayResolver | null): void {
207
+ relayResolver = resolver
208
+ }
209
+
210
+ export function directRelayRegistered(): boolean {
211
+ return relayResolver !== null
212
+ }
213
+
214
+ // ── Copy (P8 key-notices.ts) ─────────────────────────────────────────────────
215
+
216
+ /**
217
+ * The part of P8's `server/lib/key-notices.ts` this lane uses, by its exported names and shapes.
218
+ * P8 is the one source of key-lane strings; the module is imported statically (P11) and these
219
+ * types are kept as the narrow seam tests inject through `options.notices`.
220
+ */
221
+ export type DirectNoticeVariant = 'c1-tools-off' | 'c1-tools-on'
222
+ export interface DirectNoticeContext {
223
+ variant: DirectNoticeVariant
224
+ profileId: string
225
+ keyKind: KeyKind
226
+ capUsd: number
227
+ modelLabel: string
228
+ costUsd?: number
229
+ spentTodayUsd?: number
230
+ timeZone?: string
231
+ }
232
+ export type DirectNoticeSeen = Readonly<Record<string, string>>
233
+ export type DirectNoticeView =
234
+ | { form: 'first-time'; fingerprint: string; header: string; body: string[]; footer: string; label: string }
235
+ | { form: 'compact'; fingerprint: string; label: string }
236
+ export type DirectNoticeErrorId = RelayFailureKind | 'breaker_open' | 'profile_off' | 'profile_removed'
237
+ export interface DirectNoticeErrorParams {
238
+ keyKind?: KeyKind
239
+ capUsd?: number
240
+ modelLabel?: string
241
+ profileLabel?: string
242
+ now?: Date
243
+ timeZone?: string
244
+ }
245
+ export interface DirectNoticeCopy {
246
+ keyLaneNotice(ctx: DirectNoticeContext, seen: DirectNoticeSeen | undefined): DirectNoticeView
247
+ keyNoticeError(id: DirectNoticeErrorId, params?: DirectNoticeErrorParams): string
248
+ keyModelLabel(slug: string): string
249
+ }
250
+
251
+ /** P8's module, imported statically since the P11 merge (it is pure: no file, env, clock or socket). */
252
+ export async function loadDirectNotices(): Promise<DirectNoticeCopy | null> {
253
+ return keyNotices
254
+ }
255
+
256
+ /** The direct-only lines, from key-notices.ts (P11 moved them there). */
257
+ export const DIRECT_COPY = {
258
+ copyUnavailable: KEY_LANE_REFUSAL_COPY.directCopyUnavailable,
259
+ relayUnavailable: KEY_LANE_REFUSAL_COPY.relayUnavailable,
260
+ wrongEngine: KEY_LANE_REFUSAL_COPY.wrongEngineDirect,
261
+ noEndpoint: KEY_LANE_REFUSAL_COPY.directNoEndpoint,
262
+ scheduled: KEY_LANE_REFUSAL_COPY.scheduled,
263
+ textOnly: KEY_LANE_REFUSAL_COPY.directTextOnly,
264
+ empty: KEY_LANE_REFUSAL_COPY.directEmpty,
265
+ toolCap: KEY_LANE_REFUSAL_COPY.directToolCap,
266
+ cancelled: KEY_LANE_REFUSAL_COPY.directCancelled,
267
+ } as const
268
+
269
+ // ── Status mapping (plan 4.6) ────────────────────────────────────────────────
270
+
271
+ /**
272
+ * P2's classifyUpstreamStatus (key-relay-errors.ts), the one model-gone pattern. Any other non-2xx
273
+ * it passes through (403 moderation, a plain 400) ends the turn with the upstream-error line, which
274
+ * never counts toward a breaker.
275
+ */
276
+ export function classifyDirectStatus(status: number, bodyText = ''): RelayFailureKind | null {
277
+ if (status >= 200 && status < 300) return null
278
+ return classifyUpstreamStatus(status, bodyText) ?? 'upstream_error'
279
+ }
280
+
281
+ const RETRYABLE: ReadonlySet<DirectRefusalKind> = new Set(['rate_limited', 'upstream_error', 'network_error'])
282
+
283
+ /** Retry-After as milliseconds (seconds or an HTTP date), or null when absent or unreadable. */
284
+ export function parseRetryAfterMs(value: string | null | undefined, now: () => number = Date.now): number | null {
285
+ if (!value || !value.trim()) return null
286
+ const v = value.trim()
287
+ if (/^\d+(\.\d+)?$/.test(v)) return Math.round(Number(v) * 1000)
288
+ const at = Date.parse(v)
289
+ return Number.isFinite(at) ? Math.max(0, at - now()) : null
290
+ }
291
+
292
+ // ── Messages ─────────────────────────────────────────────────────────────────
293
+
294
+ export type DirectMessage =
295
+ | { role: 'system' | 'user'; content: string }
296
+ | { role: 'assistant'; content: string | null; tool_calls?: DirectToolCall[] }
297
+ | { role: 'tool'; tool_call_id: string; content: string }
298
+
299
+ /** One tool result, paired to its call by id (OpenAI wire). Never Ollama's `tool_name`. */
300
+ export function buildDirectToolMessage(call: DirectToolCall, result: string): DirectMessage {
301
+ return { role: 'tool', tool_call_id: call.id, content: result }
302
+ }
303
+
304
+ /**
305
+ * The direct system prompt. Not buildOllamaSystemPrompt: that one says "READ-ONLY Ollama path"
306
+ * and offers to escalate to Opus or Codex, both wrong here. Same glasses display rules.
307
+ */
308
+ export function buildDirectSystemPrompt(input: {
309
+ ownerName: string
310
+ cachedContext: string
311
+ historyPrompt: string
312
+ toolNames: readonly string[]
313
+ }): string {
314
+ const owner = input.ownerName.trim() || 'the wearer'
315
+ const parts = [
316
+ `You are COS, ${owner}'s chief of staff, answering on smart glasses.`,
317
+ 'Answer in plain text. No markdown, no bullet characters, no emoji.',
318
+ 'Aim for 300 to 600 characters. Up to about 2000 when the answer genuinely needs it.',
319
+ ]
320
+ if (input.cachedContext.trim()) {
321
+ parts.push(input.cachedContext.trim())
322
+ parts.push(
323
+ "The context block above is TODAY'S calendar and tasks. It is not the meeting archive. \"No more meetings today\" describes today's schedule only.",
324
+ )
325
+ }
326
+ if (input.historyPrompt.trim()) parts.push(input.historyPrompt.trim())
327
+ if (input.toolNames.length > 0) {
328
+ parts.push([
329
+ 'This request runs on a direct, read-only path.',
330
+ `Available tools: ${input.toolNames.join(', ')}. Writes, Bash, MCP, web search, web fetch, and photos are not available here.`,
331
+ 'An absent name means it is not on this path. Do not claim a connector is down.',
332
+ 'Empty search hits plus a reason report the result of THIS call; they are not proof that no meetings or memories exist.',
333
+ 'A read_meeting miss means not found for that path, not that the meeting does not exist.',
334
+ TOOL_HONESTY_CLAUSE,
335
+ UNTRUSTED_CONTENT_CLAUSE,
336
+ ].join('\n'))
337
+ } else {
338
+ parts.push(OLLAMA_NO_TOOLS_SENTENCE)
339
+ }
340
+ return parts.join('\n\n')
341
+ }
342
+
343
+ // ── The turn ─────────────────────────────────────────────────────────────────
344
+
345
+ export interface DirectCallOptions extends CallOptions {
346
+ /** Tests inject; production uses the registered relay. */
347
+ relay?: DirectRelay
348
+ /** Tests inject; production loads P8's key-notices.ts. */
349
+ notices?: DirectNoticeCopy
350
+ /** Seen-state from `deviation-notices.json`; the caller persists after "got it". */
351
+ noticeSeen?: DirectNoticeSeen
352
+ /** The notice for this answer, before the first chunk (first-time form or compact label). */
353
+ onKeyNotice?: (view: DirectNoticeView) => void
354
+ catalog?: KeyCatalog
355
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>
356
+ timeZone?: string
357
+ }
358
+
359
+ function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
360
+ return new Promise(resolveSleep => {
361
+ if (signal.aborted) return resolveSleep()
362
+ const timer = setTimeout(resolveSleep, ms)
363
+ signal.addEventListener('abort', () => { clearTimeout(timer); resolveSleep() }, { once: true })
364
+ })
365
+ }
366
+
367
+ function endpointOrigin(endpoint: string): string {
368
+ try { return new URL(endpoint).origin } catch { return 'invalid' }
369
+ }
370
+
371
+ type PostOutcome =
372
+ | { kind: 'text' }
373
+ | { kind: 'tools'; calls: DirectToolCall[]; text: string }
374
+ | { kind: 'empty' }
375
+ | { kind: 'failed'; failure: DirectRefusalKind | 'tool_overflow'; detail: string }
376
+ | { kind: 'aborted' }
377
+
378
+ export async function callDirectStreaming(
379
+ query: string,
380
+ sessionId: string | undefined,
381
+ callbacks: StreamCallbacks,
382
+ profile: KeyProfile,
383
+ images?: ModelImageInput[],
384
+ reference?: PromptReference,
385
+ globalMsgNum?: number,
386
+ options?: DirectCallOptions,
387
+ ): Promise<string> {
388
+ const sid = getOrCreateSession(sessionId)
389
+ const imageInputs = images ?? []
390
+ // The predicate comes first, before any key-lane file read or import. P8's module is loaded
391
+ // after it only to word the refusal (it is pure: no file, env, clock or socket).
392
+ const active = keyLanesActive()
393
+ const notices = options?.notices ?? await loadDirectNotices()
394
+ const modelLabel = notices ? notices.keyModelLabel(profile.model) : profile.model
395
+ const key = active ? getPublicKey(profile.keyRef) : undefined
396
+ const errorParams = (): DirectNoticeErrorParams => ({
397
+ ...(key ? { keyKind: key.kind, capUsd: key.capUsd } : {}),
398
+ modelLabel,
399
+ profileLabel: profile.label,
400
+ now: new Date(),
401
+ ...(options?.timeZone ? { timeZone: options.timeZone } : {}),
402
+ })
403
+ const lensLine = (id: DirectNoticeErrorId): string => notices ? notices.keyNoticeError(id, errorParams()) : DIRECT_COPY.copyUnavailable
404
+ const refuse = async (line: string): Promise<string> => {
405
+ // QA G4: one line per refusal (the lens copy only, never a body or a key).
406
+ console.info(`[direct-bridge] refused profile=${profile.id}: ${line}`)
407
+ cleanupModelImageInputs(imageInputs)
408
+ await callbacks.onError(line)
409
+ return sid
410
+ }
411
+
412
+ // Every refusal runs here, before history, the run ledger or a request. The subscription is
413
+ // never the fallback.
414
+ if (!active) return refuse(lensLine('profile_off'))
415
+ if (profile.engine !== 'direct') return refuse(DIRECT_COPY.wrongEngine)
416
+ if (!profile.enabled) return refuse(lensLine('profile_off'))
417
+ if (options?.dispatch) return refuse(DIRECT_COPY.scheduled)
418
+ if (!key) return refuse(lensLine('profile_removed'))
419
+ if (!notices) return refuse(DIRECT_COPY.copyUnavailable)
420
+ if (imageInputs.length > 0 || (options?.requestAttachments?.length ?? 0) > 0) return refuse(DIRECT_COPY.textOnly)
421
+ const endpoint = directEndpointFor(key)
422
+ if (!endpoint) return refuse(DIRECT_COPY.noEndpoint)
423
+ let relay: DirectRelay | null = options?.relay ?? null
424
+ if (!relay && relayResolver) {
425
+ try { relay = await relayResolver() } catch { relay = null }
426
+ }
427
+ if (!relay) return refuse(DIRECT_COPY.relayUnavailable)
428
+
429
+ const turnId = options?.turnId ?? `direct-${Date.now().toString(36)}`
430
+ let opened: Awaited<ReturnType<DirectRelay['openTurn']>>
431
+ try {
432
+ opened = await relay.openTurn({
433
+ keyRef: profile.keyRef,
434
+ keyKind: key.kind,
435
+ profileId: profile.id,
436
+ turnId,
437
+ sessionId: sid,
438
+ model: profile.model,
439
+ endpoint,
440
+ })
441
+ } catch {
442
+ return refuse(DIRECT_COPY.relayUnavailable)
443
+ }
444
+ if (!opened.ok) return refuse(refusalLine(opened.refused, lensLine))
445
+ const relayTurn = opened.turn
446
+
447
+ const catalog = options?.catalog ?? getKeyCatalog()
448
+ catalog.refreshInBackground()
449
+ const catalogModels = catalog.peek()
450
+ const price = keyModelPrice(profile.model, catalogModels)
451
+
452
+ // D7, then the pipeline and the model. A model the catalog says cannot call tools gets none.
453
+ const toolsOn = directToolsAllowed(profile, endpoint)
454
+ && ollamaCosPipelineConfigured()
455
+ && keyModelSupportsTools(profile.model, catalogModels) !== false
456
+ // OpenAI's tool schema is the Ollama one (`{ type: 'function', function: {...} }`).
457
+ const toolDefs: OllamaToolDef[] = toolsOn ? buildOllamaToolDefs() : []
458
+ const toolNames = toolDefs.map(def => def.function.name)
459
+
460
+ const noticeContext = (extra: { costUsd?: number } = {}): DirectNoticeContext => ({
461
+ variant: toolDefs.length > 0 ? 'c1-tools-on' : 'c1-tools-off',
462
+ profileId: profile.id,
463
+ keyKind: key.kind,
464
+ capUsd: key.capUsd,
465
+ modelLabel,
466
+ ...(relayTurn.spentTodayUsd ? { spentTodayUsd: relayTurn.spentTodayUsd() } : {}),
467
+ ...(options?.timeZone ? { timeZone: options.timeZone } : {}),
468
+ ...extra,
469
+ })
470
+
471
+ const history = getHistory(sid)
472
+ const session = getSessionRaw(sid)
473
+ const contextBreaks = session?.contextBreaks ?? []
474
+ const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
475
+ const handoffPrompt = options?.handoffContext?.promptBlock ? `\n\n${options.handoffContext.promptBlock}` : ''
476
+ const systemPrompt = buildDirectSystemPrompt({
477
+ ownerName: getOwnerName(),
478
+ cachedContext: getCachedContextInstant(),
479
+ historyPrompt: `${historyPrompt}${handoffPrompt}`,
480
+ toolNames,
481
+ })
482
+
483
+ // A keyed exchange is stamped with its profile (P6 persists it by syntax), never a model slot.
484
+ const exchangeModel = profilePreference(profile.id) as unknown as ModelPreference
485
+ const keyLane: AnswerKeyFields = { lane: 'direct', credential: { kind: key.kind, ref: key.ref, profileId: profile.id } }
486
+ const startTime = Date.now()
487
+ const run = startDirectRun({
488
+ turnId: options?.turnId,
489
+ clientJobId: options?.clientJobId,
490
+ cosSessionId: sid,
491
+ model: profilePreference(profile.id),
492
+ profile: profile.id,
493
+ providerModel: profile.model,
494
+ origin: endpointOrigin(endpoint),
495
+ query,
496
+ })
497
+ const startMeta: ModelRunMetadata = {
498
+ clientJobId: options?.clientJobId,
499
+ generation: options?.generation,
500
+ keyLane,
501
+ directRunId: run.runId,
502
+ }
503
+ callbacks.onStart?.(exchangeModel, sid, undefined, startMeta)
504
+ try { options?.onKeyNotice?.(notices.keyLaneNotice(noticeContext(), options?.noticeSeen)) } catch { /* display only */ }
505
+
506
+ const jobGeneration = options?.jobGeneration ?? options?.generation
507
+ const durableIdentity = options?.clientJobId && Number.isSafeInteger(jobGeneration) && jobGeneration! > 0
508
+ ? { clientJobId: options.clientJobId, generation: jobGeneration! } : undefined
509
+ if (durableIdentity) {
510
+ reconcileExchangeByJobIdentity(sid, durableIdentity, 'user', query, globalMsgNum, undefined, undefined, exchangeModel)
511
+ } else {
512
+ addExchange(sid, 'user', query, globalMsgNum, undefined, durableIdentity, exchangeModel)
513
+ }
514
+ if (isNewSession(sid)) {
515
+ notifySessionStart(sid, query)
516
+ markSessionNotified(sid)
517
+ }
518
+
519
+ const messages: DirectMessage[] = [
520
+ { role: 'system', content: systemPrompt },
521
+ ...historyToOllamaMessages(history, contextBreaks),
522
+ { role: 'user', content: query },
523
+ ]
524
+
525
+ const abort = new AbortController()
526
+ const onExternalAbort = () => abort.abort()
527
+ options?.abortSignal?.addEventListener('abort', onExternalAbort, { once: true })
528
+ const sleep = options?.sleep ?? defaultSleep
529
+
530
+ let inactivityTimer: ReturnType<typeof setTimeout> | undefined
531
+ let wallTimer: ReturnType<typeof setTimeout> | undefined
532
+ let toolHeartbeat: ReturnType<typeof setInterval> | undefined
533
+ let timedOut = false
534
+ const clearTimers = () => {
535
+ if (inactivityTimer) clearTimeout(inactivityTimer)
536
+ if (wallTimer) clearTimeout(wallTimer)
537
+ if (toolHeartbeat) { clearInterval(toolHeartbeat); toolHeartbeat = undefined }
538
+ }
539
+ const bumpInactivity = () => {
540
+ if (inactivityTimer) clearTimeout(inactivityTimer)
541
+ inactivityTimer = setTimeout(() => { timedOut = true; abort.abort() }, INACTIVITY_MS)
542
+ }
543
+ const bumpWall = () => {
544
+ const elapsed = Date.now() - startTime
545
+ const remainingToHardMax = WALL_HARD_MAX_MS - elapsed
546
+ if (remainingToHardMax <= 0) return
547
+ const next = Math.min(remainingToHardMax, Math.max(0, WALL_MAX_MS - elapsed) + 60_000)
548
+ if (wallTimer) clearTimeout(wallTimer)
549
+ wallTimer = setTimeout(() => { timedOut = true; abort.abort() }, next)
550
+ }
551
+
552
+ let fullText = ''
553
+ let finalized = false
554
+ const usageTotals = { input: 0, output: 0, usd: 0, measured: false }
555
+ const trail = (draft: JobTrailDraft) => {
556
+ if (!callbacks.onTrail) return
557
+ try { callbacks.onTrail(draft) } catch { /* observation only */ }
558
+ }
559
+
560
+ const audit = (text: string) => {
561
+ logTokenAudit({
562
+ source: options?.lightweight ? 'g2-voice' : 'g2-query',
563
+ model: profile.model,
564
+ inputChars: messages.reduce((n, m) => n + (typeof m.content === 'string' ? m.content.length : 0), 0),
565
+ outputChars: text.length,
566
+ durationMs: Date.now() - startTime,
567
+ caller: 'direct_query',
568
+ ...(options?.turnId ? { turnId: options.turnId } : {}),
569
+ runId: run.runId,
570
+ sessionId: sid,
571
+ ...(options?.clientJobId ? { clientJobId: options.clientJobId } : {}),
572
+ usageKind: usageTotals.measured ? 'provider_final' : 'estimated',
573
+ profile: profile.id,
574
+ lane: 'direct',
575
+ credentialKind: key.kind,
576
+ usd: Math.round(usageTotals.usd * 1e6) / 1e6,
577
+ })
578
+ }
579
+
580
+ const finalizeError = async (ledgerError: string, line: string) => {
581
+ if (finalized) return
582
+ finalized = true
583
+ clearTimers()
584
+ finishDirectRun(run.runId, { status: 'failed', startedAtMs: startTime, error: ledgerError })
585
+ audit(fullText)
586
+ await callbacks.onError(line)
587
+ }
588
+ const finalizeDone = async () => {
589
+ if (finalized) return
590
+ finalized = true
591
+ clearTimers()
592
+ const text = fullText.trim()
593
+ if (!text) {
594
+ finishDirectRun(run.runId, { status: 'failed', startedAtMs: startTime, error: 'direct-bridge: [empty] no answer' })
595
+ audit('')
596
+ await callbacks.onError(DIRECT_COPY.empty)
597
+ return
598
+ }
599
+ if (durableIdentity) {
600
+ reconcileExchangeByJobIdentity(sid, durableIdentity, 'assistant', text, globalMsgNum, undefined, undefined, exchangeModel)
601
+ } else {
602
+ addExchange(sid, 'assistant', text, globalMsgNum, undefined, durableIdentity, exchangeModel)
603
+ }
604
+ finishDirectRun(run.runId, { status: 'completed', startedAtMs: startTime, output: text })
605
+ audit(text)
606
+ notifyExchange(sid, query, text)
607
+ let keyNotice: DirectNoticeView | undefined
608
+ try { keyNotice = notices.keyLaneNotice(noticeContext({ costUsd: usageTotals.usd }), options?.noticeSeen) } catch { /* display only */ }
609
+ const doneMeta: ModelRunMetadata & { keyNotice?: DirectNoticeView } = {
610
+ clientJobId: options?.clientJobId,
611
+ generation: options?.generation,
612
+ keyLane,
613
+ directRunId: run.runId,
614
+ usd: usageTotals.usd,
615
+ ...(keyNotice ? { keyNotice } : {}),
616
+ }
617
+ await callbacks.onDone(text, exchangeModel, undefined, doneMeta)
618
+ }
619
+
620
+ const failureLine = (failure: DirectRefusalKind | 'tool_overflow'): string =>
621
+ failure === 'tool_overflow' ? lensLine('upstream_error') : refusalLine(failure, lensLine)
622
+
623
+ let postCount = 0
624
+ let toolsRetried = false
625
+ let sendTools = toolDefs.length > 0
626
+
627
+ const settleOnce = (post: DirectRelayPost) => {
628
+ let done = false
629
+ return (usage: DirectUsage | null) => {
630
+ if (done) return
631
+ done = true
632
+ try { post.settle(usage) } catch { /* metering failure never breaks the answer */ }
633
+ }
634
+ }
635
+
636
+ /** One request, with the plan 4.6 retry loop around the status. */
637
+ const runPost = async (): Promise<PostOutcome> => {
638
+ postCount += 1
639
+ const body: Record<string, unknown> = {
640
+ model: profile.model,
641
+ messages,
642
+ stream: true,
643
+ stream_options: { include_usage: true },
644
+ max_tokens: DIRECT_MAX_OUTPUT_TOKENS,
645
+ }
646
+ if (sendTools && toolDefs.length > 0) body.tools = toolDefs
647
+
648
+ let attempt = 0
649
+ for (;;) {
650
+ if (abort.signal.aborted) return { kind: 'aborted' }
651
+ bumpInactivity()
652
+ let result: DirectRelayPost | { refused: DirectRefusalKind }
653
+ try {
654
+ result = await relayTurn.post(body, abort.signal)
655
+ } catch (error) {
656
+ if (abort.signal.aborted) return { kind: 'aborted' }
657
+ const detail = error instanceof Error ? error.message.slice(0, 160) : 'network error'
658
+ if (attempt < DIRECT_MAX_RETRIES) {
659
+ await sleep(DIRECT_RETRY_BACKOFF_MS[attempt] ?? 3_000, abort.signal)
660
+ attempt += 1
661
+ continue
662
+ }
663
+ return { kind: 'failed', failure: 'network_error', detail }
664
+ }
665
+ if ('refused' in result) return { kind: 'failed', failure: result.refused, detail: 'refused by relay' }
666
+ const settle = settleOnce(result)
667
+ const response = result.response
668
+ if (!response.ok) {
669
+ const detail = (await response.text().catch(() => '')).slice(0, ERROR_BODY_MAX_CHARS)
670
+ settle({ input: 0, output: 0 })
671
+ // A server that rejects the tools key gets ONE retry without it, first post only, and the
672
+ // retry does not take a post slot (the Ollama rule).
673
+ if (response.status === 400 && body.tools && !toolsRetried && postCount === 1 && /tool/i.test(detail)) {
674
+ toolsRetried = true
675
+ sendTools = false
676
+ delete body.tools
677
+ continue
678
+ }
679
+ const failure = classifyDirectStatus(response.status, detail) ?? 'upstream_error'
680
+ if (RETRYABLE.has(failure) && attempt < DIRECT_MAX_RETRIES) {
681
+ const retryAfter = parseRetryAfterMs(response.headers.get('retry-after'))
682
+ if (retryAfter !== null && retryAfter > DIRECT_RETRY_AFTER_MAX_MS) {
683
+ return { kind: 'failed', failure, detail: `HTTP ${response.status} retry-after ${retryAfter}ms` }
684
+ }
685
+ await sleep(retryAfter ?? DIRECT_RETRY_BACKOFF_MS[attempt] ?? 3_000, abort.signal)
686
+ attempt += 1
687
+ continue
688
+ }
689
+ return { kind: 'failed', failure, detail: `HTTP ${response.status} ${detail.replace(/\s+/g, ' ').slice(0, 160)}` }
690
+ }
691
+ return readStream(response, settle)
692
+ }
693
+ }
694
+
695
+ const readStream = async (response: Response, settle: (u: DirectUsage | null) => void): Promise<PostOutcome> => {
696
+ const idPrefix = `call_${run.runId}_${postCount}`
697
+ const roundStart = fullText.length
698
+ const onText = (text: string) => {
699
+ fullText += text
700
+ callbacks.onChunk(text)
701
+ }
702
+ let parser: DirectSseParser
703
+ let streamError: string | null = null
704
+ try {
705
+ const contentType = response.headers.get('content-type') ?? ''
706
+ if (/application\/json/i.test(contentType) && !/event-stream/i.test(contentType)) {
707
+ const parsed = parseDirectJsonBody(await response.text(), idPrefix)
708
+ parser = parsed.parser
709
+ for (const ev of parsed.events) {
710
+ if (ev.type === 'text') onText(ev.text)
711
+ else if (ev.type === 'error') streamError = ev.message
712
+ }
713
+ } else {
714
+ if (!response.body) {
715
+ settle(null)
716
+ return { kind: 'failed', failure: 'upstream_error', detail: 'empty stream' }
717
+ }
718
+ parser = new DirectSseParser(idPrefix)
719
+ const reader = response.body.getReader()
720
+ let ended = false
721
+ while (!ended) {
722
+ const { done, value } = await reader.read()
723
+ if (done) break
724
+ bumpInactivity()
725
+ for (const ev of parser.push(value)) {
726
+ if (ev.type === 'text') onText(ev.text)
727
+ else if (ev.type === 'error') { streamError = ev.message; ended = true }
728
+ else if (ev.type === 'done') ended = true
729
+ }
730
+ }
731
+ if (ended) await reader.cancel().catch(() => undefined)
732
+ else for (const ev of parser.finish()) {
733
+ if (ev.type === 'text') onText(ev.text)
734
+ else if (ev.type === 'error') streamError = ev.message
735
+ }
736
+ }
737
+ } catch (error) {
738
+ settle(null)
739
+ if (abort.signal.aborted) return { kind: 'aborted' }
740
+ return { kind: 'failed', failure: 'network_error', detail: error instanceof Error ? error.message.slice(0, 160) : 'stream failed' }
741
+ }
742
+ if (parser.usage) {
743
+ const usd = parser.usage.costUsd ?? (parser.usage.input * price.inUsdPerM + parser.usage.output * price.outUsdPerM) / 1e6
744
+ usageTotals.input += parser.usage.input
745
+ usageTotals.output += parser.usage.output
746
+ usageTotals.usd += usd
747
+ usageTotals.measured = true
748
+ settle({
749
+ input: parser.usage.input,
750
+ output: parser.usage.output,
751
+ ...(parser.usage.costUsd !== undefined ? { costUsd: parser.usage.costUsd } : {}),
752
+ ...((response.headers.get('x-generation-id') || parser.generationId) ? { generationId: response.headers.get('x-generation-id') || parser.generationId! } : {}),
753
+ })
754
+ } else {
755
+ settle(null)
756
+ }
757
+ const roundText = fullText.slice(roundStart)
758
+ if (streamError) {
759
+ return parser.tools.error()
760
+ ? { kind: 'failed', failure: 'tool_overflow', detail: streamError }
761
+ : { kind: 'failed', failure: 'upstream_error', detail: streamError }
762
+ }
763
+ const calls = parser.tools.calls()
764
+ if (calls.length > 0) return { kind: 'tools', calls, text: roundText }
765
+ return roundText.trim() ? { kind: 'text' } : { kind: 'empty' }
766
+ }
767
+
768
+ bumpInactivity()
769
+ wallTimer = setTimeout(() => { timedOut = true; abort.abort() }, WALL_MAX_MS)
770
+
771
+ try {
772
+ for (;;) {
773
+ const roundStart = fullText.length
774
+ const round = await runPost()
775
+ if (round.kind === 'aborted') {
776
+ await finalizeError(
777
+ timedOut ? 'direct-bridge: [network_error] timed out' : 'direct-bridge: [cancelled] request aborted',
778
+ timedOut ? lensLine('network_error') : DIRECT_COPY.cancelled,
779
+ )
780
+ return sid
781
+ }
782
+ if (round.kind === 'failed') {
783
+ await finalizeError(`direct-bridge: [${round.failure}] ${round.detail}`, failureLine(round.failure))
784
+ return sid
785
+ }
786
+ if (callbacks.onTrail && (round.kind === 'text' || round.kind === 'tools')) {
787
+ const roundText = fullText.slice(roundStart)
788
+ if (roundText.trim()) trail({ kind: 'prose', text: roundText })
789
+ }
790
+ if (round.kind === 'text') { await finalizeDone(); return sid }
791
+ if (round.kind === 'empty') {
792
+ await finalizeError('direct-bridge: [empty] no answer', DIRECT_COPY.empty)
793
+ return sid
794
+ }
795
+
796
+ // D7 backstop: tools were not offered, so a call is never run. The text (if any) stands.
797
+ if (toolDefs.length === 0 || !sendTools) {
798
+ if (round.text.trim()) { await finalizeDone(); return sid }
799
+ await finalizeError('direct-bridge: [empty] tool call without tools', DIRECT_COPY.empty)
800
+ return sid
801
+ }
802
+ // Post 5 is the closing fetch: its calls are not run and there is never a sixth post.
803
+ if (postCount >= DIRECT_MAX_POSTS) {
804
+ await finalizeError('direct-bridge: tool round cap reached without a final answer.', DIRECT_COPY.toolCap)
805
+ return sid
806
+ }
807
+
808
+ messages.push({ role: 'assistant', content: round.text.trim() ? round.text : null, tool_calls: round.calls })
809
+ for (const call of round.calls) {
810
+ if (finalized || abort.signal.aborted) break
811
+ const name = call.function.name
812
+ const args = parseToolArguments(call.function.arguments)
813
+ // executeOllamaTool answers any name outside its closed allowlist with `unknown tool`.
814
+ callbacks.onToolStatus?.(name)
815
+ if (callbacks.onTrail) trail(ollamaTrailStep(name, args))
816
+ toolHeartbeat = setInterval(bumpInactivity, TOOL_HEARTBEAT_MS)
817
+ let result: string
818
+ try {
819
+ result = await executeOllamaTool(name, args, abort.signal)
820
+ } finally {
821
+ if (toolHeartbeat) { clearInterval(toolHeartbeat); toolHeartbeat = undefined }
822
+ }
823
+ if (result === 'aborted' || abort.signal.aborted) {
824
+ await finalizeError('direct-bridge: tool call aborted.', timedOut ? lensLine('network_error') : DIRECT_COPY.cancelled)
825
+ return sid
826
+ }
827
+ if (callbacks.onTrail) trail(ollamaTrailOutcome(result))
828
+ messages.push(buildDirectToolMessage(call, result))
829
+ }
830
+ if (finalized) return sid
831
+ if (abort.signal.aborted) {
832
+ await finalizeError('direct-bridge: tool call aborted.', timedOut ? lensLine('network_error') : DIRECT_COPY.cancelled)
833
+ return sid
834
+ }
835
+ bumpWall()
836
+ }
837
+ } catch (error: unknown) {
838
+ const detail = error instanceof Error ? error.message.slice(0, 160) : 'failed'
839
+ await finalizeError(`direct-bridge: [upstream_error] ${detail}`, lensLine('upstream_error'))
840
+ return sid
841
+ } finally {
842
+ clearTimers()
843
+ options?.abortSignal?.removeEventListener('abort', onExternalAbort)
844
+ try { relayTurn.close() } catch { /* best effort */ }
845
+ }
846
+ }
847
+
848
+ /** The lens line for a refusal kind. */
849
+ function refusalLine(kind: DirectRefusalKind, lensLine: (id: DirectNoticeErrorId) => string): string {
850
+ if (kind === 'lanes_inactive') return lensLine('profile_off')
851
+ if (kind === 'key_missing') return lensLine('profile_removed')
852
+ return lensLine(kind)
853
+ }