@oxygen-agent/cli 1.1013.94 → 1.1014.18

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 (33) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +190 -70
  3. package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +115 -0
  4. package/node_modules/@oxygen/shared/dist/agent-sessions.js +133 -0
  5. package/node_modules/@oxygen/shared/dist/app-storage-maintenance.d.ts +3 -0
  6. package/node_modules/@oxygen/shared/dist/app-storage-maintenance.js +16 -0
  7. package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -4
  8. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +72 -0
  9. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +35 -0
  10. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
  11. package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
  12. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +3 -0
  13. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +16 -4
  14. package/node_modules/@oxygen/shared/dist/copilot-access.d.ts +33 -0
  15. package/node_modules/@oxygen/shared/dist/copilot-access.js +33 -0
  16. package/node_modules/@oxygen/shared/dist/copilot-questions.d.ts +91 -0
  17. package/node_modules/@oxygen/shared/dist/copilot-questions.js +246 -0
  18. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +28 -18
  19. package/node_modules/@oxygen/shared/dist/hosted-ai.js +96 -45
  20. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +1 -1
  21. package/node_modules/@oxygen/shared/dist/langfuse.js +37 -9
  22. package/node_modules/@oxygen/shared/dist/object-storage.js +9 -0
  23. package/node_modules/@oxygen/shared/dist/sequences.d.ts +5 -1
  24. package/node_modules/@oxygen/shared/dist/sequences.js +91 -0
  25. package/node_modules/@oxygen/shared/dist/tool-call-markup.d.ts +23 -0
  26. package/node_modules/@oxygen/shared/dist/tool-call-markup.js +213 -0
  27. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +5 -0
  28. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  29. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  30. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  31. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  32. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +4 -0
  33. package/package.json +1 -1
@@ -0,0 +1,91 @@
1
+ export declare const COPILOT_ASK_USER_TOOL_NAME = "ask_user";
2
+ export declare const COPILOT_QUESTION_LIMITS: {
3
+ readonly maxQuestions: 4;
4
+ readonly minOptions: 2;
5
+ readonly maxOptions: 4;
6
+ readonly questionChars: 300;
7
+ readonly headerChars: 24;
8
+ readonly labelChars: 80;
9
+ readonly descriptionChars: 200;
10
+ /** What the user may type into "Other". */
11
+ readonly otherAnswerChars: 1000;
12
+ };
13
+ export type CopilotQuestionOption = {
14
+ label: string;
15
+ description: string | null;
16
+ recommended: boolean;
17
+ };
18
+ export type CopilotQuestion = {
19
+ question: string;
20
+ /** A short chip label, e.g. "Send time". */
21
+ header: string;
22
+ multiSelect: boolean;
23
+ options: CopilotQuestionOption[];
24
+ };
25
+ /** The question a session is waiting on, as every surface reads it. */
26
+ export type CopilotPendingQuestion = {
27
+ turnId: string;
28
+ callId: string | null;
29
+ questions: CopilotQuestion[];
30
+ };
31
+ /** What the user picked for one question, plus anything they typed into "Other". */
32
+ export type CopilotQuestionAnswer = {
33
+ selected: string[];
34
+ other: string | null;
35
+ };
36
+ /** The tool's input schema, as the model reads it. */
37
+ export declare const COPILOT_ASK_USER_INPUT_SCHEMA: Record<string, unknown>;
38
+ /**
39
+ * Validate the model's questions, or a stored copy of them. Strict about what
40
+ * would make the card unanswerable (no question, fewer than two real options,
41
+ * duplicates) and forgiving about the rest: an over-long label is shortened, an
42
+ * "Other" option is dropped because the card adds one, and a second
43
+ * `recommended` loses its mark. Accepts `multi_select` (the tool's input) and
44
+ * `multiSelect` (the stored shape) alike.
45
+ */
46
+ export declare function normalizeCopilotQuestions(raw: unknown): {
47
+ ok: true;
48
+ questions: CopilotQuestion[];
49
+ } | {
50
+ ok: false;
51
+ message: string;
52
+ };
53
+ /**
54
+ * The questions as the turn's reply: what the CLI and MCP print, what the
55
+ * session keeps, and what the model reads back on the next turn, which replays
56
+ * earlier turns as plain text.
57
+ */
58
+ export declare function renderCopilotQuestionsText(questions: readonly CopilotQuestion[]): string;
59
+ /**
60
+ * The user's reply to a question card: one line per question, `header: answer`,
61
+ * so the model can match every answer to what it asked. Picks follow the order
62
+ * the options were offered in, not the order they were clicked. "Other" text
63
+ * stands on its own; it is the user's own wording.
64
+ */
65
+ export declare function formatCopilotQuestionAnswers(questions: readonly CopilotQuestion[], answers: readonly CopilotQuestionAnswer[]): string;
66
+ /** Is every question answered, so the card can be sent? */
67
+ export declare function copilotQuestionAnswersComplete(questions: readonly CopilotQuestion[], answers: readonly CopilotQuestionAnswer[]): boolean;
68
+ type ProjectionTurn = {
69
+ id: string;
70
+ status: string;
71
+ turnIndex: number;
72
+ };
73
+ type ProjectionEvent = {
74
+ turnId: string | null;
75
+ kind: string;
76
+ payload: Record<string, unknown>;
77
+ };
78
+ /**
79
+ * The question the session is waiting on, or null.
80
+ *
81
+ * Only the NEWEST turn can be waiting, and only when it completed by asking: a
82
+ * newer turn means the user already replied (by the card or by typing), and a
83
+ * turn that failed or was cancelled after asking is not waiting on anything.
84
+ * Stored questions are re-validated, so a malformed row renders nothing rather
85
+ * than a card that cannot be answered.
86
+ */
87
+ export declare function projectCopilotPendingQuestion(input: {
88
+ turns: readonly ProjectionTurn[];
89
+ events: readonly ProjectionEvent[];
90
+ }): CopilotPendingQuestion | null;
91
+ export {};
@@ -0,0 +1,246 @@
1
+ // The Copilot's questions to the person it works for.
2
+ //
3
+ // When the Copilot needs a decision only the user can make, it calls `ask_user`
4
+ // with a few multiple-choice questions instead of burying them at the end of a
5
+ // reply (Tim, 2026-10-10, after Claude Code's AskUserQuestion). The call ENDS the
6
+ // turn; the web shows the questions as a card above the composer, with an
7
+ // automatic "Other" answer; the user's choice arrives as their next message.
8
+ //
9
+ // Ending the turn rather than parking it is the whole design. A parked turn
10
+ // blocks the session (one live turn per session), runs a wall clock while the
11
+ // human thinks, and needs its own resume route on every surface. A question
12
+ // that ends the turn needs none of that: the CLI and MCP answer it the way they
13
+ // send any message, an ignored card is simply superseded by whatever the user
14
+ // types next, and the model reads the question back from the turn's reply,
15
+ // because prior turns replay as user/assistant text.
16
+ //
17
+ // One owner for the contract because four readers depend on it: the tool that
18
+ // validates the model's input, the reply text the CLI and MCP print, the session
19
+ // read that projects the pending question, and the web card that answers it.
20
+ export const COPILOT_ASK_USER_TOOL_NAME = "ask_user";
21
+ export const COPILOT_QUESTION_LIMITS = {
22
+ maxQuestions: 4,
23
+ minOptions: 2,
24
+ maxOptions: 4,
25
+ questionChars: 300,
26
+ headerChars: 24,
27
+ labelChars: 80,
28
+ descriptionChars: 200,
29
+ /** What the user may type into "Other". */
30
+ otherAnswerChars: 1_000,
31
+ };
32
+ /** The tool's input schema, as the model reads it. */
33
+ export const COPILOT_ASK_USER_INPUT_SCHEMA = {
34
+ type: "object",
35
+ additionalProperties: false,
36
+ properties: {
37
+ questions: {
38
+ type: "array",
39
+ minItems: 1,
40
+ maxItems: COPILOT_QUESTION_LIMITS.maxQuestions,
41
+ items: {
42
+ type: "object",
43
+ additionalProperties: false,
44
+ properties: {
45
+ question: {
46
+ type: "string",
47
+ minLength: 1,
48
+ maxLength: COPILOT_QUESTION_LIMITS.questionChars,
49
+ description: "The complete question, ending with a question mark.",
50
+ },
51
+ header: {
52
+ type: "string",
53
+ minLength: 1,
54
+ maxLength: COPILOT_QUESTION_LIMITS.headerChars,
55
+ description: "A one-to-three word label for the question, e.g. \"Send time\".",
56
+ },
57
+ multi_select: {
58
+ type: "boolean",
59
+ description: "True when the user may pick more than one option.",
60
+ },
61
+ options: {
62
+ type: "array",
63
+ minItems: COPILOT_QUESTION_LIMITS.minOptions,
64
+ maxItems: COPILOT_QUESTION_LIMITS.maxOptions,
65
+ items: {
66
+ type: "object",
67
+ additionalProperties: false,
68
+ properties: {
69
+ label: {
70
+ type: "string",
71
+ minLength: 1,
72
+ maxLength: COPILOT_QUESTION_LIMITS.labelChars,
73
+ description: "The choice itself, in one to five words.",
74
+ },
75
+ description: {
76
+ type: "string",
77
+ maxLength: COPILOT_QUESTION_LIMITS.descriptionChars,
78
+ description: "What picking it means, in one short line.",
79
+ },
80
+ recommended: {
81
+ type: "boolean",
82
+ description: "Your recommendation; at most one per question, listed first.",
83
+ },
84
+ },
85
+ required: ["label"],
86
+ },
87
+ },
88
+ },
89
+ required: ["question", "header", "options"],
90
+ },
91
+ },
92
+ },
93
+ required: ["questions"],
94
+ };
95
+ function isRecord(value) {
96
+ return value !== null && typeof value === "object" && !Array.isArray(value);
97
+ }
98
+ /** Collapse whitespace and cap at `max` characters, marking a cut with an ellipsis. */
99
+ function clean(value, max) {
100
+ if (typeof value !== "string")
101
+ return "";
102
+ const flat = value.replace(/\s+/g, " ").trim();
103
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`;
104
+ }
105
+ /** The automatic answer; an option the model spells this way is redundant. */
106
+ const OTHER_LABEL = /^other\b/i;
107
+ /**
108
+ * Validate the model's questions, or a stored copy of them. Strict about what
109
+ * would make the card unanswerable (no question, fewer than two real options,
110
+ * duplicates) and forgiving about the rest: an over-long label is shortened, an
111
+ * "Other" option is dropped because the card adds one, and a second
112
+ * `recommended` loses its mark. Accepts `multi_select` (the tool's input) and
113
+ * `multiSelect` (the stored shape) alike.
114
+ */
115
+ export function normalizeCopilotQuestions(raw) {
116
+ if (!Array.isArray(raw) || raw.length === 0) {
117
+ return { ok: false, message: "questions must be a non-empty array." };
118
+ }
119
+ if (raw.length > COPILOT_QUESTION_LIMITS.maxQuestions) {
120
+ return { ok: false, message: `Ask at most ${COPILOT_QUESTION_LIMITS.maxQuestions} questions at once.` };
121
+ }
122
+ const questions = [];
123
+ const seenQuestions = new Set();
124
+ for (const [index, entry] of raw.entries()) {
125
+ const number = index + 1;
126
+ if (!isRecord(entry))
127
+ return { ok: false, message: `Question ${number} must be an object.` };
128
+ const question = clean(entry.question, COPILOT_QUESTION_LIMITS.questionChars);
129
+ if (!question)
130
+ return { ok: false, message: `Question ${number} has no question text.` };
131
+ if (seenQuestions.has(question.toLowerCase())) {
132
+ return { ok: false, message: `Question ${number} repeats an earlier question.` };
133
+ }
134
+ seenQuestions.add(question.toLowerCase());
135
+ if (!Array.isArray(entry.options)) {
136
+ return { ok: false, message: `Question ${number} needs an options array.` };
137
+ }
138
+ const options = [];
139
+ const seenLabels = new Set();
140
+ let recommended = false;
141
+ for (const rawOption of entry.options) {
142
+ const option = isRecord(rawOption) ? rawOption : { label: rawOption };
143
+ const label = clean(option.label, COPILOT_QUESTION_LIMITS.labelChars);
144
+ if (!label || OTHER_LABEL.test(label))
145
+ continue;
146
+ if (seenLabels.has(label.toLowerCase())) {
147
+ return { ok: false, message: `Question ${number} lists "${label}" twice.` };
148
+ }
149
+ seenLabels.add(label.toLowerCase());
150
+ const description = clean(option.description, COPILOT_QUESTION_LIMITS.descriptionChars);
151
+ const isRecommended = option.recommended === true && !recommended;
152
+ recommended ||= isRecommended;
153
+ options.push({ label, description: description || null, recommended: isRecommended });
154
+ }
155
+ if (options.length < COPILOT_QUESTION_LIMITS.minOptions) {
156
+ return {
157
+ ok: false,
158
+ message: `Question ${number} needs at least ${COPILOT_QUESTION_LIMITS.minOptions} real options; "Other" is added for the user automatically.`,
159
+ };
160
+ }
161
+ if (options.length > COPILOT_QUESTION_LIMITS.maxOptions) {
162
+ return { ok: false, message: `Question ${number} offers more than ${COPILOT_QUESTION_LIMITS.maxOptions} options.` };
163
+ }
164
+ questions.push({
165
+ question,
166
+ header: clean(entry.header, COPILOT_QUESTION_LIMITS.headerChars) || `Question ${number}`,
167
+ multiSelect: entry.multi_select === true || entry.multiSelect === true,
168
+ options,
169
+ });
170
+ }
171
+ return { ok: true, questions };
172
+ }
173
+ /**
174
+ * The questions as the turn's reply: what the CLI and MCP print, what the
175
+ * session keeps, and what the model reads back on the next turn, which replays
176
+ * earlier turns as plain text.
177
+ */
178
+ export function renderCopilotQuestionsText(questions) {
179
+ const blocks = questions.map((question) => {
180
+ const lines = [`**${question.question}**${question.multiSelect ? " (choose any)" : ""}`];
181
+ for (const option of question.options) {
182
+ const mark = option.recommended ? " (recommended)" : "";
183
+ lines.push(`- ${option.label}${mark}${option.description ? `: ${option.description}` : ""}`);
184
+ }
185
+ return lines.join("\n");
186
+ });
187
+ return [...blocks, "Pick an option, or answer in your own words."].join("\n\n");
188
+ }
189
+ /**
190
+ * The user's reply to a question card: one line per question, `header: answer`,
191
+ * so the model can match every answer to what it asked. Picks follow the order
192
+ * the options were offered in, not the order they were clicked. "Other" text
193
+ * stands on its own; it is the user's own wording.
194
+ */
195
+ export function formatCopilotQuestionAnswers(questions, answers) {
196
+ return questions
197
+ .map((question, index) => {
198
+ const answer = answers[index];
199
+ const selected = new Set(answer?.selected ?? []);
200
+ const picked = question.options.map((option) => option.label).filter((label) => selected.has(label));
201
+ const other = answer?.other?.replace(/\s+/g, " ").trim().slice(0, COPILOT_QUESTION_LIMITS.otherAnswerChars) ?? "";
202
+ const parts = question.multiSelect ? [...picked, ...(other ? [other] : [])] : [other || picked[0] || ""];
203
+ return `${question.header}: ${parts.filter(Boolean).join(", ")}`;
204
+ })
205
+ .join("\n");
206
+ }
207
+ /** Is every question answered, so the card can be sent? */
208
+ export function copilotQuestionAnswersComplete(questions, answers) {
209
+ return questions.every((_, index) => {
210
+ const answer = answers[index];
211
+ return Boolean(answer && (answer.selected.length > 0 || answer.other?.trim()));
212
+ });
213
+ }
214
+ /**
215
+ * The question the session is waiting on, or null.
216
+ *
217
+ * Only the NEWEST turn can be waiting, and only when it completed by asking: a
218
+ * newer turn means the user already replied (by the card or by typing), and a
219
+ * turn that failed or was cancelled after asking is not waiting on anything.
220
+ * Stored questions are re-validated, so a malformed row renders nothing rather
221
+ * than a card that cannot be answered.
222
+ */
223
+ export function projectCopilotPendingQuestion(input) {
224
+ const newest = input.turns.reduce((latest, turn) => (latest === null || turn.turnIndex > latest.turnIndex ? turn : latest), null);
225
+ if (!newest || newest.status !== "completed")
226
+ return null;
227
+ for (let index = input.events.length - 1; index >= 0; index -= 1) {
228
+ const event = input.events[index];
229
+ if (!event || event.turnId !== newest.id || event.kind !== "tool_call_finished")
230
+ continue;
231
+ if (event.payload.tool !== COPILOT_ASK_USER_TOOL_NAME)
232
+ continue;
233
+ const result = event.payload.result;
234
+ if (!isRecord(result) || result.ok === false)
235
+ continue;
236
+ const normalized = normalizeCopilotQuestions(result.questions);
237
+ if (!normalized.ok)
238
+ return null;
239
+ return {
240
+ turnId: newest.id,
241
+ callId: typeof event.payload.call_id === "string" ? event.payload.call_id : null,
242
+ questions: normalized.questions,
243
+ };
244
+ }
245
+ return null;
246
+ }
@@ -17,16 +17,15 @@
17
17
  * overrides (`OXYGEN_COPILOT_MODEL_*` / `OXYGEN_COPILOT_FALLBACK_*`) applied
18
18
  * here by `resolveHostedAiModel`.
19
19
  *
20
- * Kept as a leaf module (mirrors ./pricing-sheet.ts): no imports, so any package
21
- * can re-source the catalog without dragging in the shared barrel.
20
+ * Kept as a leaf module (mirrors ./pricing-sheet.ts): it imports only the
21
+ * dependency-free Copilot access leaf, never the shared barrel, so any package
22
+ * can re-source the catalog without dragging the barrel in.
22
23
  */
23
24
  export type HostedAiLevel = "low" | "medium" | "high";
24
25
  export type HostedAiUseCase = "ai_column" | "copilot" | "agent";
25
26
  /**
26
- * The Copilot's own level set. `auto` is the only tier a customer can land on
27
- * since 2026-09-21 ("Oxygen Auto"); low/medium/high survive because sessions
28
- * created before that release carry them in `copilot_sessions.reasoning_level`
29
- * and must keep resolving to the model they were priced against.
27
+ * The Copilot's own level set. Balanced is the default; the composer also
28
+ * offers explicit Auto/Fast/Max choices (Philipp, 2026-10-10).
30
29
  *
31
30
  * Deliberately NOT folded into `HostedAiLevel`: that type is shared with
32
31
  * `AiReasoningLevel` and with the ai_column/agent registries, neither of which
@@ -100,19 +99,14 @@ export declare const MANAGED_AI_TIER_LABELS: Record<HostedAiLevel, string>;
100
99
  *
101
100
  * Separate from MANAGED_AI_TIER_LABELS rather than folded into it because the
102
101
  * Copilot's level set is not the AI column's: only the Copilot has `auto`, and
103
- * only the Copilot has stopped offering a choice. AI columns and Agents still
104
- * sell Fast/Balanced/Max and still mean it.
105
- *
106
- * Every current session reads "Oxygen Auto". The other three exist so a session
107
- * created before 2026-09-21 still renders as the thing its owner picked, rather
108
- * than being relabelled under them.
102
+ * the Copilot offers Auto alongside Fast/Balanced/Max.
109
103
  */
110
104
  export declare const COPILOT_TIER_LABELS: Record<CopilotAiLevel, string>;
111
105
  /**
112
- * The tier every new Copilot session gets. There is no longer a picker: "Oxygen
113
- * Auto" is the whole customer-facing choice (Philipp, 2026-09-21).
106
+ * The tier a new Copilot session gets unless its owner chooses another.
114
107
  *
115
- * It was `high` until then, which is why 83% of production sessions ran the most
108
+ * Balanced is the founder-selected default (2026-10-10), replacing Auto.
109
+ * Historically it was `high`, which is why 83% of production sessions ran the most
116
110
  * expensive tier -- 110 of 132 sessions across 69 of 81 tenants -- while the
117
111
  * composer's own dropdown told the user that medium was "The default for most
118
112
  * GTM work". Nobody chose that; we defaulted them into it.
@@ -161,6 +155,21 @@ export declare const AGENT_DEFAULT_LEVEL: HostedAiLevel;
161
155
  * credit reservation for the wrong model and breaks the spend ceiling.
162
156
  */
163
157
  export declare function findHostedAiModelSpec(model: string, preferredUseCase?: HostedAiUseCase): HostedAiModelSpec | null;
158
+ /**
159
+ * The models the registry runs (a tier's model or fallback, or a retained
160
+ * Copilot spec) that accept image input: OpenRouter lists "image" in their
161
+ * `architecture.input_modalities` (read 2026-10-10).
162
+ *
163
+ * Whether a model can look at a rendered page belongs to the model, so it is
164
+ * kept here beside it. It used to be only the deploy allowlist
165
+ * OXYGEN_VISUAL_VISION_MODELS, which named the Kimi models alone while the
166
+ * Copilot moved to Sol and then to Haiku on 2026-10-10. The Copilot then stopped
167
+ * seeing its own renders without an error anywhere, and production never set
168
+ * the allowlist at all (Tim, 2026-10-10: the model catalog decides). The
169
+ * allowlist still adds exact model ids.
170
+ */
171
+ export declare const IMAGE_INPUT_MODELS: ReadonlySet<string>;
172
+ export declare function modelAcceptsImages(model: string): boolean;
164
173
  export declare function resolveHostedAiModel(input: {
165
174
  useCase: HostedAiUseCase;
166
175
  level: CopilotAiLevel;
@@ -222,9 +231,10 @@ export declare function redactManagedModelIds<T>(value: T, options?: {
222
231
  */
223
232
  export declare function hostedAiDefaultModels(useCase: HostedAiUseCase): Record<HostedAiLevel, string>;
224
233
  /**
225
- * Staff dev experiment: only the authoritative selfhost-dev deployment and an
226
- * explicit enable flag can run Copilot. NODE_ENV and request hosts cannot open
227
- * it; production and shadow remain closed even with an enabled flag.
234
+ * Whether this deployment runs the Copilot for anyone. Who may use it is
235
+ * `copilotAudience` / `isCopilotAvailableTo` in `./copilot-access.ts`; only the
236
+ * self-hosted dev and production deployments read the flag, and NODE_ENV or a
237
+ * request host can never open it.
228
238
  */
229
239
  export declare function isCopilotEnabled(env?: Record<string, string | undefined>): boolean;
230
240
  /** Historical cost reconciliation may settle receipts without running Copilot. */
@@ -17,9 +17,11 @@
17
17
  * overrides (`OXYGEN_COPILOT_MODEL_*` / `OXYGEN_COPILOT_FALLBACK_*`) applied
18
18
  * here by `resolveHostedAiModel`.
19
19
  *
20
- * Kept as a leaf module (mirrors ./pricing-sheet.ts): no imports, so any package
21
- * can re-source the catalog without dragging in the shared barrel.
20
+ * Kept as a leaf module (mirrors ./pricing-sheet.ts): it imports only the
21
+ * dependency-free Copilot access leaf, never the shared barrel, so any package
22
+ * can re-source the catalog without dragging the barrel in.
22
23
  */
24
+ import { copilotAudience, isCopilotFlagOn } from "./copilot-access.js";
23
25
  export const HOSTED_AI_MODEL_REGISTRY = {
24
26
  /**
25
27
  * Historical calibration of the September AI-column tiers, re-picked
@@ -112,8 +114,8 @@ export const HOSTED_AI_MODEL_REGISTRY = {
112
114
  },
113
115
  copilot: {
114
116
  /**
115
- * "Oxygen Auto" -- the only tier a new session gets, and the one the main
116
- * conversation thread runs on for its whole life. It is NOT a router: the
117
+ * "Oxygen Auto" -- the earlier default, retained as an explicit choice.
118
+ * Its conversation thread stays fixed for its whole life. It is NOT a router: the
117
119
  * model never changes mid-session, because an OpenRouter prompt cache is
118
120
  * model-scoped and a mid-turn switch would turn a ~66% cache HIT rate into a
119
121
  * cache WRITE on a fresh model, ten times a turn. Models only change below
@@ -138,12 +140,16 @@ export const HOSTED_AI_MODEL_REGISTRY = {
138
140
  model: "openai/gpt-6.1-sol",
139
141
  displayName: "Oxygen Auto",
140
142
  // Approved 2026-10-10 for NEW sessions. Existing sessions retain their
141
- // persisted resolved_model; Kimi's priced spec survives under high.
143
+ // persisted resolved_model; Kimi's priced spec survives in the legacy map.
142
144
  fallbackModels: ["anthropic/claude-sonnet-5.5"],
143
145
  reasoningEffort: "medium",
144
146
  estPromptUsdPerM: 2,
145
147
  estCompletionUsdPerM: 10,
146
- maxOutputTokens: 8192,
148
+ // Reasoning tokens count against this cap, so 8,192 left a long tool call
149
+ // (a full sequence draft, a wide table write) to be cut off mid-argument,
150
+ // which fails the turn on the second time. Credit holds are sized from the
151
+ // expected output (billing.ts), so this moves only the conservative clamp.
152
+ maxOutputTokens: 16_384,
147
153
  },
148
154
  low: {
149
155
  // The errand model: sub-agents, compaction summaries and auto-titles. A
@@ -177,29 +183,24 @@ export const HOSTED_AI_MODEL_REGISTRY = {
177
183
  maxOutputTokens: 8192,
178
184
  },
179
185
  medium: {
180
- model: "deepseek/deepseek-v4-pro",
186
+ model: "anthropic/claude-haiku-5.5",
181
187
  displayName: "Oxygen Balanced",
182
- fallbackModels: [],
183
- reasoningEffort: "high",
184
- estPromptUsdPerM: 0.435,
185
- estCompletionUsdPerM: 0.87,
186
- maxOutputTokens: 8192,
188
+ fallbackModels: ["deepseek/deepseek-v4.1-flash"],
189
+ reasoningEffort: "medium",
190
+ estPromptUsdPerM: 0.1,
191
+ estCompletionUsdPerM: 0.5,
192
+ // A conversation tier (the default since #5225), so Auto's cap (see Auto).
193
+ maxOutputTokens: 16_384,
187
194
  },
188
- /**
189
- * Retained only for sessions created before "Oxygen Auto" shipped, whose
190
- * `reasoning_level` is already 'high' on the row, and for Auto sessions
191
- * pinned to Kimi before the 2026-10-10 refresh. Keep its pricing, effort
192
- * and GLM fallback so changing the new-session default never switches an
193
- * existing conversation's model or loses its prompt cache.
194
- */
195
195
  high: {
196
- model: "moonshotai/kimi-k3",
196
+ model: "openai/gpt-6.1-sol",
197
197
  displayName: "Oxygen Max",
198
- fallbackModels: ["z-ai/glm-5.3"],
198
+ fallbackModels: ["anthropic/claude-sonnet-5.5"],
199
199
  reasoningEffort: "medium",
200
- estPromptUsdPerM: 1.7,
201
- estCompletionUsdPerM: 8.5,
202
- maxOutputTokens: 8192,
200
+ estPromptUsdPerM: 2,
201
+ estCompletionUsdPerM: 10,
202
+ // A conversation tier, so Auto's cap (see Auto).
203
+ maxOutputTokens: 16_384,
203
204
  },
204
205
  },
205
206
  agent: {
@@ -231,6 +232,27 @@ export const HOSTED_AI_MODEL_REGISTRY = {
231
232
  },
232
233
  },
233
234
  };
235
+ /** Persisted conversations keep their original pricing, effort and fallbacks. */
236
+ const LEGACY_COPILOT_MODEL_SPECS = [
237
+ {
238
+ model: "deepseek/deepseek-v4-pro",
239
+ displayName: "Oxygen Balanced",
240
+ fallbackModels: [],
241
+ reasoningEffort: "high",
242
+ estPromptUsdPerM: 0.435,
243
+ estCompletionUsdPerM: 0.87,
244
+ maxOutputTokens: 8192,
245
+ },
246
+ {
247
+ model: "moonshotai/kimi-k3",
248
+ displayName: "Oxygen Max",
249
+ fallbackModels: ["z-ai/glm-5.3"],
250
+ reasoningEffort: "medium",
251
+ estPromptUsdPerM: 1.7,
252
+ estCompletionUsdPerM: 8.5,
253
+ maxOutputTokens: 8192,
254
+ },
255
+ ];
234
256
  /**
235
257
  * Managed AI-column model ids that USED to back a tier, mapped to the tier they
236
258
  * billed at. A managed column saved with one of these pinned keeps validating
@@ -293,27 +315,22 @@ export const MANAGED_AI_TIER_LABELS = {
293
315
  *
294
316
  * Separate from MANAGED_AI_TIER_LABELS rather than folded into it because the
295
317
  * Copilot's level set is not the AI column's: only the Copilot has `auto`, and
296
- * only the Copilot has stopped offering a choice. AI columns and Agents still
297
- * sell Fast/Balanced/Max and still mean it.
298
- *
299
- * Every current session reads "Oxygen Auto". The other three exist so a session
300
- * created before 2026-09-21 still renders as the thing its owner picked, rather
301
- * than being relabelled under them.
318
+ * the Copilot offers Auto alongside Fast/Balanced/Max.
302
319
  */
303
320
  export const COPILOT_TIER_LABELS = {
304
321
  ...MANAGED_AI_TIER_LABELS,
305
322
  auto: "Oxygen Auto",
306
323
  };
307
324
  /**
308
- * The tier every new Copilot session gets. There is no longer a picker: "Oxygen
309
- * Auto" is the whole customer-facing choice (Philipp, 2026-09-21).
325
+ * The tier a new Copilot session gets unless its owner chooses another.
310
326
  *
311
- * It was `high` until then, which is why 83% of production sessions ran the most
327
+ * Balanced is the founder-selected default (2026-10-10), replacing Auto.
328
+ * Historically it was `high`, which is why 83% of production sessions ran the most
312
329
  * expensive tier -- 110 of 132 sessions across 69 of 81 tenants -- while the
313
330
  * composer's own dropdown told the user that medium was "The default for most
314
331
  * GTM work". Nobody chose that; we defaulted them into it.
315
332
  */
316
- export const COPILOT_DEFAULT_LEVEL = "auto";
333
+ export const COPILOT_DEFAULT_LEVEL = "medium";
317
334
  /**
318
335
  * The tier the Copilot's ERRANDS run at: sub-agent children, compaction
319
336
  * summaries, and auto-titles.
@@ -385,9 +402,40 @@ export function findHostedAiModelSpec(model, preferredUseCase) {
385
402
  if (spec.model === model)
386
403
  return spec;
387
404
  }
405
+ if (useCase === "copilot") {
406
+ const legacy = LEGACY_COPILOT_MODEL_SPECS.find((spec) => spec.model === model);
407
+ if (legacy)
408
+ return legacy;
409
+ }
388
410
  }
389
411
  return null;
390
412
  }
413
+ /**
414
+ * The models the registry runs (a tier's model or fallback, or a retained
415
+ * Copilot spec) that accept image input: OpenRouter lists "image" in their
416
+ * `architecture.input_modalities` (read 2026-10-10).
417
+ *
418
+ * Whether a model can look at a rendered page belongs to the model, so it is
419
+ * kept here beside it. It used to be only the deploy allowlist
420
+ * OXYGEN_VISUAL_VISION_MODELS, which named the Kimi models alone while the
421
+ * Copilot moved to Sol and then to Haiku on 2026-10-10. The Copilot then stopped
422
+ * seeing its own renders without an error anywhere, and production never set
423
+ * the allowlist at all (Tim, 2026-10-10: the model catalog decides). The
424
+ * allowlist still adds exact model ids.
425
+ */
426
+ export const IMAGE_INPUT_MODELS = new Set([
427
+ "openai/gpt-6.1-sol",
428
+ "openai/gpt-6-luna",
429
+ "anthropic/claude-sonnet-5.5",
430
+ "anthropic/claude-haiku-5.5",
431
+ "moonshotai/kimi-k3",
432
+ "moonshotai/kimi-k2.6",
433
+ "deepseek/deepseek-v4.1-flash",
434
+ "google/gemini-3.8-flash",
435
+ ]);
436
+ export function modelAcceptsImages(model) {
437
+ return IMAGE_INPUT_MODELS.has(model);
438
+ }
391
439
  export function resolveHostedAiModel(input) {
392
440
  // `auto` exists only on the copilot registry. Asking ai_column or agent for it
393
441
  // is a caller bug, and resolving `undefined` here would hand the request
@@ -485,6 +533,14 @@ function managedModelLabels(preferredUseCase, env = typeof process === "undefine
485
533
  }
486
534
  }
487
535
  }
536
+ for (const spec of LEGACY_COPILOT_MODEL_SPECS) {
537
+ if (!labels.has(spec.model))
538
+ labels.set(spec.model, spec.displayName);
539
+ for (const fallback of spec.fallbackModels) {
540
+ if (!labels.has(fallback))
541
+ labels.set(fallback, BACKUP_MODEL_LABEL);
542
+ }
543
+ }
488
544
  return labels;
489
545
  }
490
546
  /**
@@ -590,20 +646,15 @@ export function hostedAiDefaultModels(useCase) {
590
646
  };
591
647
  }
592
648
  /**
593
- * Staff dev experiment: only the authoritative selfhost-dev deployment and an
594
- * explicit enable flag can run Copilot. NODE_ENV and request hosts cannot open
595
- * it; production and shadow remain closed even with an enabled flag.
649
+ * Whether this deployment runs the Copilot for anyone. Who may use it is
650
+ * `copilotAudience` / `isCopilotAvailableTo` in `./copilot-access.ts`; only the
651
+ * self-hosted dev and production deployments read the flag, and NODE_ENV or a
652
+ * request host can never open it.
596
653
  */
597
654
  export function isCopilotEnabled(env = process.env) {
598
- if (env.OXYGEN_DEPLOY_ENV?.trim().toLowerCase() !== "selfhost-dev")
599
- return false;
600
- return isCopilotAccountingEnabled(env);
655
+ return copilotAudience(env) !== "off";
601
656
  }
602
657
  /** Historical cost reconciliation may settle receipts without running Copilot. */
603
658
  export function isCopilotAccountingEnabled(env = process.env) {
604
- const raw = env.OXYGEN_COPILOT_ENABLED;
605
- if (typeof raw !== "string")
606
- return false;
607
- const normalized = raw.trim().toLowerCase();
608
- return normalized === "1" || normalized === "true";
659
+ return isCopilotFlagOn(env);
609
660
  }
@@ -110,7 +110,7 @@ export type LlmTracingClient = {
110
110
  };
111
111
  /**
112
112
  * FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
113
- * "1"/"true" (trimmed, case-insensitive) AND both Langfuse keys are present.
113
+ * "1"/"true" (trimmed, case-insensitive), both keys and an explicit self-hosted URL.
114
114
  */
115
115
  export declare function isLlmTracingEnabled(env?: EnvMap): boolean;
116
116
  export declare function resolveLlmTracingEnvironment(env?: EnvMap): string;