ai-discovery-manager-cli 0.1.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/dist/doctor.js ADDED
@@ -0,0 +1,148 @@
1
+ import { MCPServerStdio } from "@openai/agents";
2
+ import { access, stat } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import path from "node:path";
5
+ import { MODEL_IDS, resolveModel } from "./models.js";
6
+ import { parseSafetyLevel } from "./safety.js";
7
+ function overallStatus(checks) {
8
+ if (checks.some((check) => check.status === "error")) {
9
+ return "error";
10
+ }
11
+ if (checks.some((check) => check.status === "warn")) {
12
+ return "warn";
13
+ }
14
+ return "ok";
15
+ }
16
+ function redactedApiKey(value) {
17
+ if (!value) {
18
+ return undefined;
19
+ }
20
+ if (value.length <= 8) {
21
+ return "set";
22
+ }
23
+ return `${value.slice(0, 3)}...${value.slice(-4)}`;
24
+ }
25
+ export async function runDoctor(options) {
26
+ const checks = [];
27
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
28
+ checks.push({
29
+ name: "node",
30
+ status: nodeMajor >= 22 ? "ok" : "error",
31
+ message: nodeMajor >= 22
32
+ ? `Node ${process.versions.node} satisfies >=22.`
33
+ : `Node ${process.versions.node} is below the required >=22.`,
34
+ });
35
+ const apiKey = process.env.OPENAI_API_KEY;
36
+ checks.push({
37
+ name: "apiKey",
38
+ status: apiKey ? "ok" : "warn",
39
+ message: apiKey
40
+ ? "OPENAI_API_KEY is configured."
41
+ : "OPENAI_API_KEY is not set; non-dry-run commands will fail before API access.",
42
+ details: apiKey ? { value: redactedApiKey(apiKey) } : undefined,
43
+ });
44
+ const workspaceInfo = await stat(options.workspace).catch(() => undefined);
45
+ checks.push({
46
+ name: "workspace",
47
+ status: workspaceInfo?.isDirectory() ? "ok" : "error",
48
+ message: workspaceInfo?.isDirectory()
49
+ ? `Workspace exists: ${options.workspace}`
50
+ : `Workspace does not exist or is not a directory: ${options.workspace}`,
51
+ });
52
+ if (workspaceInfo?.isDirectory()) {
53
+ const writable = await access(options.workspace, constants.W_OK)
54
+ .then(() => true)
55
+ .catch(() => false);
56
+ checks.push({
57
+ name: "workspaceWritable",
58
+ status: writable ? "ok" : "warn",
59
+ message: writable
60
+ ? "Workspace is writable for artifacts and checkpoints."
61
+ : "Workspace may not be writable; artifact or checkpoint writes can fail.",
62
+ details: { checkpointRoot: path.join(options.workspace, ".ai-discovery", "runs") },
63
+ });
64
+ }
65
+ try {
66
+ resolveModel(options.model, "--model");
67
+ resolveModel(options.managerModel, "--manager-model");
68
+ resolveModel(options.specialistModel, "--specialist-model");
69
+ checks.push({
70
+ name: "models",
71
+ status: "ok",
72
+ message: "Configured models are in the text-only allowlist.",
73
+ details: {
74
+ model: options.model,
75
+ managerModel: options.managerModel,
76
+ specialistModel: options.specialistModel,
77
+ availableModels: MODEL_IDS,
78
+ },
79
+ });
80
+ }
81
+ catch (error) {
82
+ checks.push({
83
+ name: "models",
84
+ status: "error",
85
+ message: error instanceof Error ? error.message : String(error),
86
+ details: { availableModels: MODEL_IDS },
87
+ });
88
+ }
89
+ try {
90
+ parseSafetyLevel(String(options.safetyLevel));
91
+ checks.push({
92
+ name: "safetyLevel",
93
+ status: "ok",
94
+ message: `Safety level ${options.safetyLevel} is valid.`,
95
+ });
96
+ }
97
+ catch (error) {
98
+ checks.push({
99
+ name: "safetyLevel",
100
+ status: "error",
101
+ message: error instanceof Error ? error.message : String(error),
102
+ });
103
+ }
104
+ const malformedVectorStores = options.vectorStoreIds.filter((id) => !/^vs_[A-Za-z0-9_-]+$/.test(id));
105
+ checks.push({
106
+ name: "vectorStores",
107
+ status: malformedVectorStores.length === 0 ? "ok" : "warn",
108
+ message: malformedVectorStores.length === 0
109
+ ? options.vectorStoreIds.length > 0
110
+ ? "Vector store IDs are syntactically plausible."
111
+ : "No vector store IDs configured; File Search will stay disabled."
112
+ : "Some vector store IDs do not look like OpenAI vector store IDs.",
113
+ details: options.vectorStoreIds.length > 0
114
+ ? {
115
+ count: options.vectorStoreIds.length,
116
+ malformed: malformedVectorStores,
117
+ }
118
+ : undefined,
119
+ });
120
+ checks.push({
121
+ name: "mcp",
122
+ status: typeof MCPServerStdio === "function" ? "ok" : "error",
123
+ message: typeof MCPServerStdio === "function"
124
+ ? "Agents SDK MCPServerStdio is available for chat /mcp connections."
125
+ : "Agents SDK MCPServerStdio is not available.",
126
+ });
127
+ const distCli = await stat(path.resolve(process.cwd(), "dist", "cli.js")).catch(() => undefined);
128
+ checks.push({
129
+ name: "dist",
130
+ status: distCli?.isFile() ? "ok" : "warn",
131
+ message: distCli?.isFile()
132
+ ? "Built dist/cli.js exists."
133
+ : "dist/cli.js is missing; run `npm.cmd run build` before package/bin use.",
134
+ });
135
+ return {
136
+ status: overallStatus(checks),
137
+ checks,
138
+ availableModels: MODEL_IDS,
139
+ workspace: options.workspace,
140
+ };
141
+ }
142
+ export function formatDoctorReport(report) {
143
+ const lines = [`AI Discovery doctor: ${report.status.toUpperCase()}`];
144
+ for (const check of report.checks) {
145
+ lines.push(`[${check.status}] ${check.name}: ${check.message}`);
146
+ }
147
+ return lines.join("\n");
148
+ }
@@ -0,0 +1,106 @@
1
+ export const HYPOTHESIS_OUTPUT_SCHEMA = `title: Short descriptive name
2
+
3
+ research_question: >
4
+ What question is this hypothesis trying to answer?
5
+
6
+ hypothesis_statement: >
7
+ If X is true or if we intervene on X, then Y should happen because of mechanism Z.
8
+
9
+ domain:
10
+ field: biology / chemistry / medicine / physics / AI / etc.
11
+ system: organism, model, dataset, material, algorithm, etc.
12
+
13
+ background_evidence:
14
+ supporting_evidence:
15
+ - claim:
16
+ source:
17
+ strength: weak / moderate / strong
18
+ conflicting_evidence:
19
+ - claim:
20
+ source:
21
+ strength: weak / moderate / strong
22
+
23
+ mechanism:
24
+ proposed_causal_chain:
25
+ - Step 1
26
+ - Step 2
27
+ - Step 3
28
+ key_assumptions:
29
+ - Assumption 1
30
+ - Assumption 2
31
+
32
+ predictions:
33
+ primary_prediction: >
34
+ What should be observed if the hypothesis is correct?
35
+ secondary_predictions:
36
+ - Additional expected outcome
37
+ - Another expected outcome
38
+ falsifying_observation: >
39
+ What result would make the hypothesis unlikely?
40
+
41
+ test_plan:
42
+ experiment_or_analysis: >
43
+ How would this be tested?
44
+ required_data:
45
+ - Dataset, measurement, simulation, or experiment needed
46
+ controls:
47
+ positive_control:
48
+ negative_control:
49
+ comparison_baseline:
50
+ success_criteria:
51
+
52
+ confounders_and_alternatives:
53
+ possible_confounders:
54
+ - Confounder 1
55
+ - Confounder 2
56
+ alternative_explanations:
57
+ - Alternative mechanism 1
58
+ - Alternative mechanism 2
59
+
60
+ feasibility:
61
+ required_resources:
62
+ - Data
63
+ - Tools
64
+ - Expertise
65
+ estimated_cost: low / medium / high
66
+ estimated_time: short / medium / long
67
+ risk_level: low / medium / high
68
+
69
+ evaluation:
70
+ novelty: 1-5
71
+ plausibility: 1-5
72
+ testability: 1-5
73
+ impact: 1-5
74
+ feasibility: 1-5
75
+ overall_priority: 1-5
76
+
77
+ uncertainty:
78
+ confidence_score: 0.0-1.0
79
+ main_uncertainty: >
80
+ What is the biggest thing we do not know?
81
+
82
+ status:
83
+ state: proposed / under_review / testing / supported / weakened / rejected
84
+ last_updated:`;
85
+ export const HYPOTHESIS_SCHEMA_INSTRUCTIONS = [
86
+ "The final answer must be only a YAML document matching this schema exactly, with the keys in this order. Do not wrap it in Markdown fences, and do not add commentary, summaries, citations sections, or extra fields outside the schema.",
87
+ "",
88
+ HYPOTHESIS_OUTPUT_SCHEMA,
89
+ "",
90
+ "Field requirements:",
91
+ "- Fill every top-level key in the schema, preserving nested key names and order.",
92
+ "- Use the enum values shown in the schema for evidence strength, cost, time, risk, and status state.",
93
+ "- Score novelty, plausibility, testability, impact, feasibility, and overall_priority as integers from 1 to 5.",
94
+ "- Score confidence_score as a decimal from 0.0 to 1.0.",
95
+ "- For last_updated, use an ISO date (YYYY-MM-DD) when the run date is known; otherwise leave the value empty.",
96
+ "- Keep the hypothesis causal and falsifiable: make the X, Y, and mechanism Z relationship explicit.",
97
+ "- Include supporting and conflicting evidence at the claim level. Each evidence item must include a source when verified; do not invent citations, URLs, DOIs, authors, or papers.",
98
+ "- If evidence cannot be verified with available tools, mark the source as unverified or unavailable and lower the evidence strength accordingly.",
99
+ "- Include at least one falsifying observation, at least one plausible confounder, and at least one alternative explanation.",
100
+ "- Keep experiments and analyses safe, non-harmful, and appropriate for computational, observational, or review settings unless the user has supplied an approved real-world protocol context.",
101
+ "",
102
+ "Scientific safety and validity:",
103
+ "- Treat biological, chemical, medical, and physical-world domains as safety-sensitive. Do not provide procedural wet-lab, clinical, chemical synthesis, or harmful operational instructions.",
104
+ "- Preserve units, assumptions, provenance, uncertainty, and limitations where they affect the hypothesis or test plan.",
105
+ "- Prefer bounded, reproducible, source-grounded analysis over speculative claims.",
106
+ ].join("\n");
@@ -0,0 +1,65 @@
1
+ export function emitJsonEvent(type, payload = {}) {
2
+ process.stdout.write(`${JSON.stringify({
3
+ type,
4
+ timestamp: new Date().toISOString(),
5
+ ...payload,
6
+ })}\n`);
7
+ }
8
+ export function serializeError(error) {
9
+ if (error instanceof Error) {
10
+ return {
11
+ message: error.message,
12
+ name: error.name,
13
+ stack: process.env.AI_DISCOVERY_DEBUG ? error.stack : undefined,
14
+ };
15
+ }
16
+ return { message: String(error) };
17
+ }
18
+ export function serializeUsage(usage) {
19
+ if (!usage) {
20
+ return undefined;
21
+ }
22
+ return {
23
+ requests: usage.requests,
24
+ inputTokens: usage.inputTokens,
25
+ outputTokens: usage.outputTokens,
26
+ totalTokens: usage.totalTokens,
27
+ inputTokensDetails: usage.inputTokensDetails.length > 0 ? usage.inputTokensDetails : undefined,
28
+ outputTokensDetails: usage.outputTokensDetails.length > 0
29
+ ? usage.outputTokensDetails
30
+ : undefined,
31
+ requestUsageEntries: usage.requestUsageEntries?.map((entry) => ({
32
+ endpoint: entry.endpoint,
33
+ inputTokens: entry.inputTokens,
34
+ outputTokens: entry.outputTokens,
35
+ totalTokens: entry.totalTokens,
36
+ })),
37
+ };
38
+ }
39
+ export function extractTraceId(result) {
40
+ const state = result.state;
41
+ return state?._trace?.traceId;
42
+ }
43
+ export function extractCitations(text) {
44
+ const urls = new Set();
45
+ const dois = new Set();
46
+ for (const match of text.matchAll(/\bhttps?:\/\/[^\s)<>"']+/g)) {
47
+ urls.add(match[0].replace(/[.,;:]+$/g, ""));
48
+ }
49
+ for (const match of text.matchAll(/\b10\.\d{4,9}\/[-._;()/:A-Z0-9]+/gi)) {
50
+ dois.add(match[0].replace(/[.,;:]+$/g, ""));
51
+ }
52
+ return {
53
+ urls: [...urls],
54
+ dois: [...dois],
55
+ };
56
+ }
57
+ export function tokenCostStats(usage) {
58
+ return {
59
+ currency: "USD",
60
+ amount: null,
61
+ note: usage
62
+ ? "Model-specific pricing is not bundled; use token usage for external cost calculation."
63
+ : "Usage was not reported by the SDK for this run.",
64
+ };
65
+ }
@@ -0,0 +1,196 @@
1
+ import { MCPServerStdio, getAllMcpTools, } from "@openai/agents";
2
+ export class SessionMcpManager {
3
+ servers = new Map();
4
+ has(name) {
5
+ return this.servers.has(name);
6
+ }
7
+ list() {
8
+ return [...this.servers.values()];
9
+ }
10
+ get size() {
11
+ return this.servers.size;
12
+ }
13
+ /** Connect a new stdio MCP server and register it for the session. */
14
+ async connect(spec) {
15
+ if (this.servers.has(spec.name)) {
16
+ throw new Error(`An MCP server named "${spec.name}" is already connected. Disconnect it first.`);
17
+ }
18
+ const server = new MCPServerStdio({
19
+ name: spec.name,
20
+ command: spec.command,
21
+ args: spec.args,
22
+ cwd: spec.cwd,
23
+ env: Object.keys(spec.env).length > 0 ? spec.env : undefined,
24
+ // Tools rarely change within a session; cache to avoid re-listing on
25
+ // every turn. The cache is dropped when the server disconnects.
26
+ cacheToolsList: true,
27
+ });
28
+ await server.connect();
29
+ const record = {
30
+ name: spec.name,
31
+ command: spec.command,
32
+ args: spec.args,
33
+ cwd: spec.cwd,
34
+ envKeys: spec.envKeys,
35
+ server,
36
+ };
37
+ this.servers.set(spec.name, record);
38
+ return record;
39
+ }
40
+ /** Disconnect and forget a single server. */
41
+ async disconnect(name) {
42
+ const record = this.servers.get(name);
43
+ if (!record) {
44
+ throw new Error(`No MCP server named "${name}" is connected.`);
45
+ }
46
+ this.servers.delete(name);
47
+ await record.server.close().catch(() => undefined);
48
+ }
49
+ /** List the tools published by one server (or throw if unknown). */
50
+ async toolsFor(name) {
51
+ const record = this.servers.get(name);
52
+ if (!record) {
53
+ throw new Error(`No MCP server named "${name}" is connected.`);
54
+ }
55
+ const tools = await record.server.listTools();
56
+ return tools.map((tool) => ({
57
+ name: tool.name,
58
+ description: tool.description,
59
+ }));
60
+ }
61
+ /**
62
+ * Build the agent-facing tool list across all connected servers, with
63
+ * server-name prefixes to prevent collisions. Returns an empty array when no
64
+ * servers are connected.
65
+ */
66
+ async agentTools() {
67
+ const mcpServers = this.list().map((record) => record.server);
68
+ if (mcpServers.length === 0) {
69
+ return [];
70
+ }
71
+ return getAllMcpTools({
72
+ mcpServers,
73
+ includeServerInToolNames: true,
74
+ });
75
+ }
76
+ /** Close every server (best-effort) and clear the registry. */
77
+ async closeAll() {
78
+ const records = this.list();
79
+ this.servers.clear();
80
+ await Promise.all(records.map((record) => record.server.close().catch(() => undefined)));
81
+ }
82
+ }
83
+ /** Split a command line into tokens, honoring simple single/double quoting. */
84
+ export function tokenizeMcpArgs(input) {
85
+ const tokens = [];
86
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
87
+ let match;
88
+ while ((match = re.exec(input)) !== null) {
89
+ tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
90
+ }
91
+ return tokens;
92
+ }
93
+ /**
94
+ * Parse the tokens that follow `/mcp connect`:
95
+ * <name> [--cwd <path>] [--env KEY=value | --env KEY]... -- <command> [args...]
96
+ *
97
+ * For `--env KEY` (no `=`), the value is read from the parent process env at
98
+ * parse time. Values are placed into `env` for the child process but only the
99
+ * key names are recorded in `envKeys` for later display.
100
+ */
101
+ export function parseMcpConnect(tokens) {
102
+ if (tokens.length === 0) {
103
+ throw new Error("Usage: /mcp connect <name> [--cwd <path>] [--env KEY=value | --env KEY]... -- <command> [args...]");
104
+ }
105
+ const [name, ...rest] = tokens;
106
+ if (name.startsWith("--")) {
107
+ throw new Error("MCP server name must come first, before any flags.");
108
+ }
109
+ const env = {};
110
+ const envKeys = [];
111
+ let cwd;
112
+ let index = 0;
113
+ for (; index < rest.length; index += 1) {
114
+ const token = rest[index];
115
+ if (token === "--") {
116
+ index += 1;
117
+ break;
118
+ }
119
+ if (token === "--cwd") {
120
+ const value = rest[index + 1];
121
+ if (value === undefined || value === "--") {
122
+ throw new Error("--cwd requires a path value.");
123
+ }
124
+ cwd = value;
125
+ index += 1;
126
+ continue;
127
+ }
128
+ if (token === "--env") {
129
+ const value = rest[index + 1];
130
+ if (value === undefined || value === "--") {
131
+ throw new Error("--env requires KEY=value or KEY.");
132
+ }
133
+ const eq = value.indexOf("=");
134
+ if (eq >= 0) {
135
+ const key = value.slice(0, eq);
136
+ if (!key) {
137
+ throw new Error(`Invalid --env entry "${value}".`);
138
+ }
139
+ env[key] = value.slice(eq + 1);
140
+ if (!envKeys.includes(key))
141
+ envKeys.push(key);
142
+ }
143
+ else {
144
+ const key = value;
145
+ const fromParent = process.env[key];
146
+ if (fromParent !== undefined) {
147
+ env[key] = fromParent;
148
+ }
149
+ if (!envKeys.includes(key))
150
+ envKeys.push(key);
151
+ }
152
+ index += 1;
153
+ continue;
154
+ }
155
+ throw new Error(`Unknown /mcp connect flag "${token}". Put the command after a "--" separator.`);
156
+ }
157
+ const command = rest[index];
158
+ if (!command) {
159
+ throw new Error('No MCP command provided. Place it after "--", e.g. /mcp connect docs -- npx -y my-mcp-server.');
160
+ }
161
+ const args = rest.slice(index + 1);
162
+ return { name, command, args, cwd, env, envKeys };
163
+ }
164
+ export const MCP_HELP = [
165
+ "MCP (session-only stdio servers):",
166
+ " /mcp connect <name> [--cwd <path>] [--env KEY=value | --env KEY]... -- <command> [args...]",
167
+ " Start and attach a stdio MCP server for this session.",
168
+ " --env KEY (no value) forwards KEY from the current env.",
169
+ " Env values are never printed or saved; only key names are shown.",
170
+ " /mcp status List connected servers (command, args, cwd, env key names).",
171
+ " /mcp tools [name] List tools for one server, or all connected servers.",
172
+ " /mcp disconnect <name>",
173
+ " Stop and detach a server.",
174
+ " /mcp help Show this help.",
175
+ "",
176
+ "MCP tools are exposed to the assistant prefixed by server name to avoid collisions.",
177
+ "Configs are session-only: nothing is persisted to disk.",
178
+ ].join("\n");
179
+ /** Render `/mcp status` text for the current set of connected servers. */
180
+ export function formatMcpStatus(records) {
181
+ if (records.length === 0) {
182
+ return "No MCP servers connected. Use `/mcp connect ...` (see `/mcp help`).";
183
+ }
184
+ const lines = [`Connected MCP servers (${records.length}):`];
185
+ for (const record of records) {
186
+ const cmd = [record.command, ...record.args].join(" ");
187
+ lines.push(` - ${record.name}: ${cmd}`);
188
+ if (record.cwd) {
189
+ lines.push(` cwd: ${record.cwd}`);
190
+ }
191
+ if (record.envKeys.length > 0) {
192
+ lines.push(` env keys: ${record.envKeys.join(", ")} (values hidden)`);
193
+ }
194
+ }
195
+ return lines.join("\n");
196
+ }
package/dist/models.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Text-only model allowlist for AI Discovery.
3
+ *
4
+ * The CLI and chat deliberately accept only this curated set of OpenAI text
5
+ * models. Image, audio, realtime, and embedding models are intentionally
6
+ * excluded — every workflow here produces or reasons over text artifacts, so a
7
+ * narrow allowlist keeps `--model`, `--manager-model`, `--specialist-model`, and
8
+ * the chat `/model` command from silently routing to an unsupported endpoint.
9
+ */
10
+ /**
11
+ * The complete, ordered catalog of allowed text models. Order is used for the
12
+ * numbered `/model <n>` chat shortcut and the `/models` listing.
13
+ */
14
+ export const MODEL_CATALOG = [
15
+ {
16
+ id: "gpt-5.5",
17
+ label: "GPT-5.5",
18
+ description: "Flagship text reasoning model. Default.",
19
+ },
20
+ {
21
+ id: "gpt-5.5-pro",
22
+ label: "GPT-5.5 Pro",
23
+ description: "Highest-effort GPT-5.5 variant for the hardest reasoning.",
24
+ },
25
+ {
26
+ id: "gpt-5.4",
27
+ label: "GPT-5.4",
28
+ description: "Previous-generation flagship text model.",
29
+ },
30
+ {
31
+ id: "gpt-5.4-pro",
32
+ label: "GPT-5.4 Pro",
33
+ description: "Highest-effort GPT-5.4 variant.",
34
+ },
35
+ {
36
+ id: "gpt-5.4-mini",
37
+ label: "GPT-5.4 mini",
38
+ description: "Faster, cheaper GPT-5.4 for lighter tasks.",
39
+ },
40
+ {
41
+ id: "gpt-5.4-nano",
42
+ label: "GPT-5.4 nano",
43
+ description: "Smallest, fastest GPT-5.4 for simple tasks.",
44
+ },
45
+ ];
46
+ /** Canonical IDs in catalog order. */
47
+ export const MODEL_IDS = MODEL_CATALOG.map((m) => m.id);
48
+ const CANONICAL_BY_ID = new Map(MODEL_CATALOG.map((m) => [m.id, m]));
49
+ /**
50
+ * Reduce a user-supplied model string to a comparable canonical key.
51
+ *
52
+ * Handles common display aliases such as `GPT-5.5 Pro`, `GPT 5.4 mini`, and
53
+ * `gpt_5.4_nano` by lowercasing, collapsing whitespace/underscores to single
54
+ * hyphens, and inserting the hyphen in a bare `gpt5.5` form. The result is only
55
+ * a lookup key; it is not assumed to be a valid model on its own.
56
+ */
57
+ function canonicalizeKey(input) {
58
+ return input
59
+ .trim()
60
+ .toLowerCase()
61
+ .replace(/[\s_]+/g, "-")
62
+ .replace(/^gpt(?=\d)/, "gpt-")
63
+ .replace(/-+/g, "-")
64
+ .replace(/^-|-$/g, "");
65
+ }
66
+ /**
67
+ * Resolve an arbitrary model string to a canonical catalog ID, or `undefined`
68
+ * if it is not an allowed text model.
69
+ */
70
+ export function normalizeModel(input) {
71
+ const key = canonicalizeKey(input);
72
+ return CANONICAL_BY_ID.has(key) ? key : undefined;
73
+ }
74
+ /**
75
+ * Resolve a model string to a canonical catalog ID or throw a descriptive
76
+ * error listing the allowed models. Used by every model-accepting input
77
+ * (`--model`, `--manager-model`, `--specialist-model`, `OPENAI_MODEL`, and the
78
+ * chat `/model` command).
79
+ */
80
+ export function resolveModel(input, source = "model") {
81
+ const resolved = normalizeModel(input);
82
+ if (!resolved) {
83
+ throw new Error(`Unknown ${source} "${input}". Allowed text models: ${MODEL_IDS.join(", ")}.`);
84
+ }
85
+ return resolved;
86
+ }
87
+ /** Look up display metadata for a canonical model ID. */
88
+ export function modelInfo(id) {
89
+ return CANONICAL_BY_ID.get(id);
90
+ }
91
+ /** Pretty label for a model ID, falling back to the raw ID. */
92
+ export function modelLabel(id) {
93
+ return CANONICAL_BY_ID.get(id)?.label ?? id;
94
+ }
95
+ /** Multi-line, numbered catalog listing for `/models` and help output. */
96
+ export function formatModelCatalog(activeId) {
97
+ return MODEL_CATALOG.map((m, index) => {
98
+ const marker = m.id === activeId ? "*" : " ";
99
+ return `${marker} ${index + 1}. ${m.label} (${m.id}) — ${m.description}`;
100
+ }).join("\n");
101
+ }
102
+ /**
103
+ * Resolve a `/model` argument that may be either a model name/alias or a
104
+ * 1-based catalog index. Throws on anything unrecognized.
105
+ */
106
+ export function resolveModelSelector(selector) {
107
+ const trimmed = selector.trim();
108
+ if (/^\d+$/.test(trimmed)) {
109
+ const index = Number.parseInt(trimmed, 10) - 1;
110
+ const picked = MODEL_CATALOG[index];
111
+ if (!picked) {
112
+ throw new Error(`Model number ${trimmed} is out of range (1-${MODEL_CATALOG.length}).`);
113
+ }
114
+ return picked.id;
115
+ }
116
+ return resolveModel(trimmed, "model");
117
+ }