loreli 0.0.0 → 2.0.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 (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +710 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +77 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/prompts/action.md +172 -0
  8. package/packages/action/src/index.js +684 -0
  9. package/packages/agent/README.md +606 -0
  10. package/packages/agent/src/backends/claude.js +387 -0
  11. package/packages/agent/src/backends/codex.js +351 -0
  12. package/packages/agent/src/backends/cursor.js +371 -0
  13. package/packages/agent/src/backends/index.js +486 -0
  14. package/packages/agent/src/base.js +138 -0
  15. package/packages/agent/src/cli.js +275 -0
  16. package/packages/agent/src/discover.js +396 -0
  17. package/packages/agent/src/factory.js +124 -0
  18. package/packages/agent/src/index.js +12 -0
  19. package/packages/agent/src/models.js +159 -0
  20. package/packages/agent/src/output.js +62 -0
  21. package/packages/agent/src/session.js +162 -0
  22. package/packages/agent/src/trace.js +186 -0
  23. package/packages/classify/README.md +136 -0
  24. package/packages/classify/prompts/blocker.md +12 -0
  25. package/packages/classify/prompts/feedback.md +14 -0
  26. package/packages/classify/prompts/pane-state.md +20 -0
  27. package/packages/classify/src/index.js +81 -0
  28. package/packages/config/README.md +898 -0
  29. package/packages/config/src/defaults.js +145 -0
  30. package/packages/config/src/index.js +223 -0
  31. package/packages/config/src/schema.js +291 -0
  32. package/packages/config/src/validate.js +160 -0
  33. package/packages/context/README.md +165 -0
  34. package/packages/context/src/index.js +198 -0
  35. package/packages/hub/README.md +338 -0
  36. package/packages/hub/src/base.js +154 -0
  37. package/packages/hub/src/github.js +1597 -0
  38. package/packages/hub/src/index.js +79 -0
  39. package/packages/hub/src/labels.js +48 -0
  40. package/packages/identity/README.md +288 -0
  41. package/packages/identity/src/index.js +620 -0
  42. package/packages/identity/src/themes/avatar.js +217 -0
  43. package/packages/identity/src/themes/digimon.js +217 -0
  44. package/packages/identity/src/themes/dragonball.js +217 -0
  45. package/packages/identity/src/themes/lotr.js +217 -0
  46. package/packages/identity/src/themes/marvel.js +217 -0
  47. package/packages/identity/src/themes/pokemon.js +217 -0
  48. package/packages/identity/src/themes/starwars.js +217 -0
  49. package/packages/identity/src/themes/transformers.js +217 -0
  50. package/packages/identity/src/themes/zelda.js +217 -0
  51. package/packages/knowledge/README.md +217 -0
  52. package/packages/knowledge/src/index.js +243 -0
  53. package/packages/log/README.md +93 -0
  54. package/packages/log/src/index.js +252 -0
  55. package/packages/marker/README.md +200 -0
  56. package/packages/marker/src/index.js +184 -0
  57. package/packages/mcp/README.md +323 -0
  58. package/packages/mcp/instructions.md +126 -0
  59. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  60. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  61. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  62. package/packages/mcp/scaffolding/loreli.yml +491 -0
  63. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
  64. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
  65. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
  66. package/packages/mcp/scaffolding/pull-request.md +23 -0
  67. package/packages/mcp/src/index.js +600 -0
  68. package/packages/mcp/src/tools/agent-context.js +44 -0
  69. package/packages/mcp/src/tools/agents.js +450 -0
  70. package/packages/mcp/src/tools/context.js +200 -0
  71. package/packages/mcp/src/tools/github.js +1163 -0
  72. package/packages/mcp/src/tools/hitl.js +162 -0
  73. package/packages/mcp/src/tools/index.js +18 -0
  74. package/packages/mcp/src/tools/refactor.js +227 -0
  75. package/packages/mcp/src/tools/repo.js +44 -0
  76. package/packages/mcp/src/tools/start.js +904 -0
  77. package/packages/mcp/src/tools/status.js +149 -0
  78. package/packages/mcp/src/tools/work.js +134 -0
  79. package/packages/orchestrator/README.md +192 -0
  80. package/packages/orchestrator/src/index.js +1492 -0
  81. package/packages/planner/README.md +251 -0
  82. package/packages/planner/prompts/plan-reviewer.md +109 -0
  83. package/packages/planner/prompts/planner.md +191 -0
  84. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  85. package/packages/planner/src/index.js +1381 -0
  86. package/packages/review/README.md +129 -0
  87. package/packages/review/prompts/reviewer.md +158 -0
  88. package/packages/review/src/index.js +1403 -0
  89. package/packages/risk/README.md +178 -0
  90. package/packages/risk/prompts/risk.md +272 -0
  91. package/packages/risk/src/index.js +439 -0
  92. package/packages/session/README.md +165 -0
  93. package/packages/session/src/index.js +215 -0
  94. package/packages/test-utils/README.md +96 -0
  95. package/packages/test-utils/src/index.js +354 -0
  96. package/packages/tmux/README.md +261 -0
  97. package/packages/tmux/src/index.js +501 -0
  98. package/packages/workflow/README.md +317 -0
  99. package/packages/workflow/prompts/preamble.md +14 -0
  100. package/packages/workflow/src/index.js +660 -0
  101. package/packages/workflow/src/proof-of-life.js +74 -0
  102. package/packages/workspace/README.md +143 -0
  103. package/packages/workspace/src/index.js +1127 -0
  104. package/index.js +0 -8
@@ -0,0 +1,275 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import { Tmux } from 'loreli/tmux';
3
+ import { Agent } from './base.js';
4
+ import { inline } from './models.js';
5
+ import { logger } from 'loreli/log';
6
+
7
+ const log = logger('agent');
8
+
9
+ /**
10
+ * CLI-backed agent that runs inside a tmux pane.
11
+ *
12
+ * Each agent gets its own window in the loreli tmux session.
13
+ * Commands are passed directly to tmux as shell strings —
14
+ * no launcher scripts on disk. Tmux handles working directory
15
+ * via its `-c` flag, and env vars are inlined as `export K='V' && ...`.
16
+ *
17
+ * Backends override `buildCommand()` (via constructor) for their
18
+ * specific CLI flags, and optionally override `spawn()` and
19
+ * `_navigate()` for custom readiness detection.
20
+ *
21
+ * Backends that need workspace scaffolding (config files, hooks,
22
+ * token propagation) override the static `scaffold()` method to
23
+ * return a declarative descriptor consumed by `workspace.prepare()`.
24
+ *
25
+ * @extends Agent
26
+ */
27
+ export class CliAgent extends Agent {
28
+ /**
29
+ * Return a scaffold descriptor for this backend's workspace needs.
30
+ *
31
+ * Each CLI backend overrides this to declare the config files,
32
+ * hooks, and additional files it needs written into the agent's
33
+ * working directory. Workspace code writes them generically —
34
+ * no backend-specific knowledge required.
35
+ *
36
+ * @param {object} [context] - Agent context for env/token injection.
37
+ * @param {string} [context.session] - Session ID.
38
+ * @param {string} [context.agent] - Agent identity name.
39
+ * @param {string} [context.repo] - Target repository (owner/name).
40
+ * @param {string} [context.home] - Loreli home directory.
41
+ * @param {string} [context.token] - GitHub token.
42
+ * @param {string[]} [context.denied] - Commands to block.
43
+ * @returns {object|null} Scaffold descriptor or null if no scaffolding needed.
44
+ * @property {Array<{path: string, content: string, marker: string, format: string}>} configs - Config files.
45
+ * @property {Array<{path: string, key: string, entry: object, marker: string, defaults?: object}>} hooks - Hook entries to merge.
46
+ * @property {Array<{path: string, content: string, mode?: number}>} files - Additional files (e.g. .git/loreli.env).
47
+ */
48
+ static scaffold() {
49
+ return null;
50
+ }
51
+ /**
52
+ * Run a CLI binary and return its stdout.
53
+ *
54
+ * Shared subprocess execution for all backend `oneshot()` methods.
55
+ * Each backend calls this with its own binary, args, and env vars.
56
+ *
57
+ * When `input` is provided it is written to the child's stdin and
58
+ * the stream is closed. CLI tools like `claude -p` and
59
+ * `cursor-agent -p` expect their prompt on stdin — passing it as
60
+ * a positional argument causes the process to block indefinitely
61
+ * waiting for piped input that never arrives.
62
+ *
63
+ * @param {string} binary - CLI binary name (e.g. 'claude', 'codex').
64
+ * @param {string[]} args - Command-line arguments.
65
+ * @param {object} [vars] - Additional environment variables.
66
+ * @param {number} [timeout=60000] - Max execution time in ms.
67
+ * @param {string} [input] - Text to write to stdin before closing.
68
+ * @returns {Promise<string>} Trimmed stdout.
69
+ */
70
+ static _exec(binary, args, vars, timeout, input) {
71
+ return new Promise(function run(resolve, reject) {
72
+ const child = execFileCb(binary, args, {
73
+ env: { ...process.env, ...vars },
74
+ timeout: timeout ?? 60000,
75
+ maxBuffer: 1024 * 1024
76
+ }, function done(err, stdout, stderrOutput) {
77
+ if (err) {
78
+ const code = err.code ?? 'none';
79
+ const signal = err.signal ?? 'none';
80
+ const killed = Boolean(err.killed);
81
+ const stderr = String(err.stderr ?? stderrOutput ?? '').slice(0, 200);
82
+ const wrapped = Object.assign(
83
+ new Error(`${binary} failed (code=${code}, signal=${signal}, killed=${killed}): ${stderr}`),
84
+ {
85
+ cause: err,
86
+ code: err.code,
87
+ signal: err.signal,
88
+ killed: err.killed,
89
+ stderr: err.stderr
90
+ }
91
+ );
92
+ return reject(wrapped);
93
+ }
94
+ resolve(stdout.trim());
95
+ });
96
+
97
+ if (input != null) {
98
+ child.stdin.write(input);
99
+ child.stdin.end();
100
+ }
101
+ });
102
+ }
103
+
104
+ /**
105
+ * One-shot LLM call via the CLI's non-interactive mode.
106
+ *
107
+ * Backends override this with their own binary, flags, model
108
+ * resolution, and env var collection. The base implementation
109
+ * throws to signal that the backend has not implemented it.
110
+ *
111
+ * @param {string} _prompt - Text prompt to send.
112
+ * @param {object} [_opts] - Options (model, config, timeout).
113
+ * @returns {Promise<string>} LLM response text.
114
+ */
115
+ static async oneshot(_prompt, _opts) {
116
+ throw new Error('Backend does not support oneshot — override static oneshot()');
117
+ }
118
+
119
+ /**
120
+ * @param {object} opts
121
+ * @param {object} opts.identity - Agent identity.
122
+ * @param {string} opts.role - Agent role.
123
+ * @param {string} opts.cwd - Working directory.
124
+ * @param {string} opts.command - CLI command to execute in the pane.
125
+ * @param {string} [opts.session='loreli'] - Tmux session name.
126
+ */
127
+ constructor(opts) {
128
+ super(opts);
129
+
130
+ /** @type {string} CLI command to execute. */
131
+ this.command = opts.command;
132
+
133
+ /** @type {string} Tmux session name. */
134
+ this.session = opts.session ?? 'loreli';
135
+
136
+ /** @type {Tmux} Tmux instance. */
137
+ this.tmux = new Tmux();
138
+
139
+ /** @type {string|null} Assigned tmux pane ID. */
140
+ this.paneId = null;
141
+ }
142
+
143
+ /**
144
+ * Spawn the agent in a tmux window.
145
+ *
146
+ * Builds an inline shell command with env exports and passes it
147
+ * directly to tmux — no launcher scripts written to disk.
148
+ *
149
+ * 1. If the session doesn't exist, create it with the command as the
150
+ * initial window (no garbage default window — tmux auto-destroys
151
+ * the session when all windows die)
152
+ * 2. If the session already exists, add a new window
153
+ * 3. Transition to `spawned` state
154
+ *
155
+ * Backends that need custom behavior (e.g. Claude dialog navigation,
156
+ * Codex readiness detection) override this method but follow the
157
+ * same pattern via {@link CliAgent#_launch}.
158
+ *
159
+ * @returns {Promise<void>}
160
+ */
161
+ async spawn() {
162
+ const cmd = this._shell(`exec ${this.command}`);
163
+ this.paneId = await this._launch(cmd);
164
+
165
+ log.info(`spawning ${this.identity?.name ?? '?'} in pane ${this.paneId}: ${this.command}`);
166
+ this.transition('spawned');
167
+ }
168
+
169
+ /**
170
+ * Build an inline shell command string with optional env exports.
171
+ *
172
+ * Joins `export K='V'` statements with ` && ` and appends the body.
173
+ * No files written to disk — the string is passed directly to tmux
174
+ * which runs it via `/bin/sh -c`.
175
+ *
176
+ * @param {string} body - Shell command to execute.
177
+ * @param {object} [vars] - Environment variables to export.
178
+ * @returns {string} Complete shell command string.
179
+ */
180
+ _shell(body, vars) {
181
+ const exports = inline(vars);
182
+ return exports ? `${exports} && ${body}` : body;
183
+ }
184
+
185
+ /**
186
+ * Launch a command in the tmux session.
187
+ *
188
+ * When the session does not exist, the command becomes the initial
189
+ * window — no empty default window is created. This lets tmux
190
+ * auto-destroy the session when all windows die, preventing orphans.
191
+ *
192
+ * When the session already exists, a new window is added.
193
+ *
194
+ * Sets `remain-on-exit` on every pane so dead process output is
195
+ * preserved for diagnosis. Backend subclasses override spawn() but
196
+ * all call _launch(), so this is the single point of enforcement.
197
+ *
198
+ * @param {string} command - Shell command string.
199
+ * @returns {Promise<string>} The assigned pane ID.
200
+ */
201
+ async _launch(command) {
202
+ let paneId;
203
+ if (!await this.tmux.has(this.session)) {
204
+ paneId = await this.tmux.create(this.session, { cwd: this.cwd, command });
205
+ } else {
206
+ paneId = await this.tmux.window(this.session, { cwd: this.cwd, command });
207
+ }
208
+
209
+ await this.tmux.set(paneId, 'remain-on-exit', 'on');
210
+ return paneId;
211
+ }
212
+
213
+ /**
214
+ * Send a message to the agent's tmux pane.
215
+ *
216
+ * @param {string} message - Text to send via tmux send-keys.
217
+ * @returns {Promise<void>}
218
+ */
219
+ async send(message) {
220
+ if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
221
+ if (this.canTransition('working')) this.transition('working');
222
+ await this.tmux.send(this.paneId, message);
223
+ }
224
+
225
+ /**
226
+ * Capture the current output of the agent's tmux pane.
227
+ *
228
+ * @param {number} [lines] - Number of history lines to capture.
229
+ * @returns {Promise<string>} Captured terminal output.
230
+ */
231
+ async capture(lines) {
232
+ if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
233
+ return this.tmux.capture(this.paneId, lines);
234
+ }
235
+
236
+ /**
237
+ * Check if the agent's tmux pane is still alive.
238
+ *
239
+ * @returns {Promise<boolean>}
240
+ */
241
+ async alive() {
242
+ if (!this.paneId) return false;
243
+ return this.tmux.alive(this.paneId);
244
+ }
245
+
246
+ /**
247
+ * Kill the agent's tmux pane and reap the session if it is now empty.
248
+ *
249
+ * After removing the pane, checks whether the session still has any
250
+ * remaining panes. If not, the session is killed — this prevents
251
+ * orphaned sessions from accumulating when all agents have stopped.
252
+ *
253
+ * @returns {Promise<void>}
254
+ */
255
+ async stop() {
256
+ log.info(`stopping ${this.identity?.name ?? '?'} pane=${this.paneId}`);
257
+ if (this.paneId) {
258
+ try {
259
+ await this.tmux.killPane(this.paneId);
260
+ } catch { /* pane may already be dead */ }
261
+ this.paneId = null;
262
+
263
+ // Reap the session when this was the last pane — prevents orphans
264
+ try {
265
+ const remaining = await this.tmux.panes(this.session);
266
+ if (remaining.length === 0) {
267
+ log.info(`session "${this.session}" is empty — reaping`);
268
+ await this.tmux.kill(this.session);
269
+ }
270
+ } catch { /* session may already be gone (tmux auto-destroyed it) */ }
271
+ }
272
+
273
+ if (this.state !== 'dormant') this.transition('dormant');
274
+ }
275
+ }
@@ -0,0 +1,396 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { logger } from 'loreli/log';
4
+ import { defaults } from 'loreli/config';
5
+
6
+ const execFile = promisify(execFileCb);
7
+ const log = logger('discover');
8
+
9
+ const PROXY_BACKENDS = [
10
+ {
11
+ backend: 'claude',
12
+ baseKey: 'ANTHROPIC_BASE_URL',
13
+ keyOrder: ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']
14
+ },
15
+ {
16
+ backend: 'codex',
17
+ baseKey: 'OPENAI_BASE_URL',
18
+ keyOrder: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY']
19
+ }
20
+ ];
21
+
22
+ /**
23
+ * Known tiers in priority order (lowest capability first).
24
+ * @type {string[]}
25
+ */
26
+ const TIERS = ['fast', 'balanced', 'powerful'];
27
+
28
+ /**
29
+ * Detect AI provider from a cursor-agent model ID.
30
+ *
31
+ * @param {string} id - Model identifier.
32
+ * @returns {'openai'|'anthropic'|null} Provider, or null for unsupported models.
33
+ */
34
+ function provider(id) {
35
+ if (/^(gpt-|o[134]|codex-)/.test(id)) return 'openai';
36
+ if (/^(opus-|sonnet-|haiku-|claude-)/.test(id)) return 'anthropic';
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Read an env value with config override precedence.
42
+ *
43
+ * @param {object|undefined} config - Config instance with `get()`.
44
+ * @param {string} backend - Backend name.
45
+ * @param {string} key - Env key name.
46
+ * @returns {string|undefined} Resolved env value.
47
+ */
48
+ function envValue(config, backend, key) {
49
+ const fromConfig = config?.get?.(`backends.${backend}.env.${key}`);
50
+ if (typeof fromConfig === 'string' && fromConfig.length) return fromConfig;
51
+ const fromProcess = process.env[key];
52
+ if (typeof fromProcess === 'string' && fromProcess.length) return fromProcess;
53
+ return undefined;
54
+ }
55
+
56
+ /**
57
+ * Resolve timeout for proxy model discovery.
58
+ *
59
+ * @param {object|undefined} config - Config instance with `get()`.
60
+ * @returns {number} Timeout in milliseconds.
61
+ */
62
+ function proxyTimeout(config) {
63
+ const configured = config?.get?.('timeouts.proxyDiscovery');
64
+ if (typeof configured === 'number' && configured > 0) return configured;
65
+ const fallback = defaults?.timeouts?.proxyDiscovery;
66
+ if (typeof fallback === 'number' && fallback > 0) return fallback;
67
+ return 5000;
68
+ }
69
+
70
+ /**
71
+ * Build endpoint candidates for proxy model discovery.
72
+ *
73
+ * Handles base URLs with and without `/v1` to avoid producing
74
+ * invalid `/v1/v1/models` paths.
75
+ *
76
+ * @param {string} baseUrl - Proxy base URL.
77
+ * @returns {string[]} Candidate absolute URLs in priority order.
78
+ */
79
+ function endpointCandidates(baseUrl) {
80
+ const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
81
+ const parsed = new URL(base);
82
+ const path = parsed.pathname.replace(/\/+$/, '');
83
+ const hasV1 = path.endsWith('/v1');
84
+ const candidates = [];
85
+
86
+ if (hasV1) {
87
+ candidates.push(new URL('models', base).toString());
88
+ candidates.push(new URL('../models', base).toString());
89
+ } else {
90
+ candidates.push(new URL('v1/models', base).toString());
91
+ candidates.push(new URL('models', base).toString());
92
+ }
93
+
94
+ return [...new Set(candidates)];
95
+ }
96
+
97
+ /**
98
+ * Normalize a base URL for cache keying.
99
+ *
100
+ * @param {string} baseUrl - Base URL.
101
+ * @returns {string} Normalized URL string.
102
+ */
103
+ function normalizeBase(baseUrl) {
104
+ const parsed = new URL(baseUrl);
105
+ parsed.search = '';
106
+ parsed.hash = '';
107
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
108
+ return parsed.toString();
109
+ }
110
+
111
+ /**
112
+ * Query one proxy endpoint candidate for model listing.
113
+ *
114
+ * @param {string} endpoint - Candidate endpoint URL.
115
+ * @param {string|undefined} apiKey - Optional bearer token.
116
+ * @param {number} timeout - Request timeout in milliseconds.
117
+ * @returns {Promise<null|object[]>} Raw model objects.
118
+ */
119
+ async function requestModels(endpoint, apiKey, timeout) {
120
+ const headers = {};
121
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
122
+
123
+ const response = await fetch(endpoint, {
124
+ headers,
125
+ signal: AbortSignal.timeout(timeout)
126
+ });
127
+
128
+ if (!response.ok) return null;
129
+
130
+ let body;
131
+ try {
132
+ body = await response.json();
133
+ } catch {
134
+ return null;
135
+ }
136
+
137
+ return Array.isArray(body?.data) ? body.data : null;
138
+ }
139
+
140
+ /**
141
+ * Convert proxy `/models` payload into internal discovery shape.
142
+ *
143
+ * Keeps all model IDs for `validate()`; provider-aware tier mapping
144
+ * is derived from recognizable IDs only.
145
+ *
146
+ * @param {object[]} entries - Raw model entries.
147
+ * @returns {{models: object[], tiers: object}|null} Discovery result.
148
+ */
149
+ function toDiscovery(entries) {
150
+ const models = [];
151
+
152
+ for (const entry of entries) {
153
+ if (!entry?.id) continue;
154
+ const id = String(entry.id);
155
+ models.push({
156
+ id,
157
+ name: id,
158
+ provider: provider(id),
159
+ tier: tier(id),
160
+ marker: null
161
+ });
162
+ }
163
+
164
+ if (!models.length) return null;
165
+ return { models, tiers: tiers(models) };
166
+ }
167
+
168
+ /**
169
+ * Discover proxy models using endpoint candidates and optional auth.
170
+ *
171
+ * @param {string} baseUrl - Proxy base URL.
172
+ * @param {string|undefined} apiKey - Optional bearer token.
173
+ * @param {number} timeout - Request timeout in milliseconds.
174
+ * @returns {Promise<{models: object[], tiers: object}|null>} Discovery result.
175
+ */
176
+ async function discoverProxy(baseUrl, apiKey, timeout) {
177
+ for (const endpoint of endpointCandidates(baseUrl)) {
178
+ const data = await requestModels(endpoint, apiKey, timeout);
179
+ if (!data) continue;
180
+ const discovery = toDiscovery(data);
181
+ if (discovery) return discovery;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ /**
187
+ * Return unique auth key candidates in order.
188
+ *
189
+ * @param {object|undefined} config - Config instance with `get()`.
190
+ * @param {string} backend - Backend name.
191
+ * @param {string[]} order - Env key names in priority order.
192
+ * @returns {(string|undefined)[]} Ordered key candidates.
193
+ */
194
+ function authKeys(config, backend, order) {
195
+ const keys = [];
196
+ for (const keyName of order) {
197
+ const value = envValue(config, backend, keyName);
198
+ if (value && !keys.includes(value)) keys.push(value);
199
+ }
200
+ if (!keys.length) keys.push(undefined);
201
+ return keys;
202
+ }
203
+
204
+ /**
205
+ * Patterns that force a model into a specific tier.
206
+ * Checked in order — first match wins.
207
+ * @type {Array<{pattern: RegExp, tier: string}>}
208
+ */
209
+ const TIER_RULES = [
210
+ { pattern: /-(low|mini)\b/, tier: 'fast' },
211
+ { pattern: /(^|-)haiku-/, tier: 'fast' },
212
+ { pattern: /\bflash\b/, tier: 'fast' },
213
+ { pattern: /-(xhigh|max)\b/, tier: 'powerful' },
214
+ { pattern: /(^|-)opus-/, tier: 'powerful' },
215
+ { pattern: /^o3\b/, tier: 'powerful' },
216
+ { pattern: /-high\b/, tier: 'powerful' }
217
+ ];
218
+
219
+ /**
220
+ * Classify a model ID into a capability tier.
221
+ *
222
+ * @param {string} id - Model identifier.
223
+ * @returns {string} Tier name ('fast', 'balanced', or 'powerful').
224
+ */
225
+ function tier(id) {
226
+ for (const rule of TIER_RULES) {
227
+ if (rule.pattern.test(id)) return rule.tier;
228
+ }
229
+ return 'balanced';
230
+ }
231
+
232
+ /**
233
+ * Parse the structured output of `cursor-agent --list-models`.
234
+ *
235
+ * Each line is `id - Human Name` with optional `(default)` or `(current)` markers.
236
+ * Only models from supported providers (openai, anthropic) are returned.
237
+ *
238
+ * @param {string} output - Raw stdout from `cursor-agent --list-models`.
239
+ * @returns {Array<{id: string, name: string, provider: string, tier: string, marker: string|null}>}
240
+ */
241
+ export function parseCursor(output) {
242
+ const models = [];
243
+ for (const line of output.split('\n')) {
244
+ const match = line.match(/^(\S+)\s+-\s+(.+?)(?:\s+\((default|current)\))?\s*$/);
245
+ if (!match) continue;
246
+
247
+ const [, id, name, marker] = match;
248
+ const p = provider(id);
249
+ if (!p) continue;
250
+
251
+ models.push({
252
+ id,
253
+ name: name.trim(),
254
+ provider: p,
255
+ tier: tier(id),
256
+ marker: marker ?? null
257
+ });
258
+ }
259
+ return models;
260
+ }
261
+
262
+ /**
263
+ * Build a tier map from a flat list of discovered models.
264
+ *
265
+ * Groups models by provider, then for each tier picks the best candidate:
266
+ * models marked as `default` or `current` are preferred, then the
267
+ * lexicographically last ID (usually the latest generation).
268
+ *
269
+ * @param {Array<{id: string, provider: string, tier: string, marker: string|null}>} models
270
+ * @returns {Record<string, Record<string, string>>} `{ fast: { openai: 'gpt-...', anthropic: '...' }, ... }`
271
+ */
272
+ export function tiers(models) {
273
+ const grouped = {};
274
+ for (const m of models) {
275
+ const key = `${m.provider}:${m.tier}`;
276
+ if (!grouped[key]) grouped[key] = [];
277
+ grouped[key].push(m);
278
+ }
279
+
280
+ const result = {};
281
+ for (const t of TIERS) {
282
+ for (const prov of ['openai', 'anthropic']) {
283
+ const candidates = grouped[`${prov}:${t}`];
284
+ if (!candidates?.length) continue;
285
+
286
+ const best = candidates.find(function marked(m) { return m.marker; })
287
+ ?? candidates.sort(function latest(a, b) { return b.id.localeCompare(a.id); })[0];
288
+
289
+ if (!result[t]) result[t] = {};
290
+ result[t][prov] = best.id;
291
+ }
292
+ }
293
+ return result;
294
+ }
295
+
296
+ /**
297
+ * Discover available models from cursor-agent CLI.
298
+ *
299
+ * @returns {Promise<{models: object[], tiers: object}|null>} Discovery result, or null on failure.
300
+ */
301
+ export async function discoverCursor() {
302
+ try {
303
+ const { stdout } = await execFile('cursor-agent', ['--list-models'], {
304
+ timeout: 10000,
305
+ maxBuffer: 64 * 1024
306
+ });
307
+
308
+ const models = parseCursor(stdout);
309
+ if (!models.length) {
310
+ log.warn('cursor-agent --list-models returned no supported models');
311
+ return null;
312
+ }
313
+
314
+ log.info(`cursor: discovered ${models.length} models`);
315
+ return { models, tiers: tiers(models) };
316
+ } catch (err) {
317
+ log.warn(`cursor model discovery failed: ${err.message}`);
318
+ return null;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Run model discovery for all available backends.
324
+ *
325
+ * Discovery sources:
326
+ * - `cursor`: `cursor-agent --list-models`
327
+ * - `claude` / `codex`: proxy model listing when corresponding
328
+ * base URLs are configured (ANTHROPIC_BASE_URL / OPENAI_BASE_URL)
329
+ *
330
+ * Results are cached on the registry for use during model resolution.
331
+ *
332
+ * @param {import('./backends/index.js').BackendRegistry} registry - Backend registry with discovered binaries.
333
+ * @param {object} [opts] - Discovery options.
334
+ * @param {object} [opts.config] - Config instance with `get()`.
335
+ * @returns {Promise<Map<string, {models: object[], tiers: object}>>} Per-backend discovery results.
336
+ */
337
+ export async function discover(registry, opts = {}) {
338
+ const { config } = opts;
339
+ const cache = new Map();
340
+ const successfulProxy = new Map();
341
+ const timeout = proxyTimeout(config);
342
+
343
+ if (registry.discovered.has('cursor')) {
344
+ const result = await discoverCursor();
345
+ if (result) cache.set('cursor', result);
346
+ }
347
+
348
+ for (const def of PROXY_BACKENDS) {
349
+ const { backend, baseKey, keyOrder } = def;
350
+ if (!registry.discovered.has(backend)) continue;
351
+
352
+ const baseUrl = envValue(config, backend, baseKey);
353
+ if (!baseUrl) continue;
354
+
355
+ const normalized = normalizeBase(baseUrl);
356
+ const cached = successfulProxy.get(normalized);
357
+ if (cached) {
358
+ cache.set(backend, cached);
359
+ continue;
360
+ }
361
+
362
+ const keys = authKeys(config, backend, keyOrder);
363
+ let found = null;
364
+
365
+ for (const key of keys) {
366
+ try {
367
+ found = await discoverProxy(baseUrl, key, timeout);
368
+ } catch (err) {
369
+ log.warn(`${backend} proxy discovery failed: ${err.message}`);
370
+ }
371
+ if (found) break;
372
+ }
373
+
374
+ if (found) {
375
+ cache.set(backend, found);
376
+ successfulProxy.set(normalized, found);
377
+ log.info(`${backend}: discovered ${found.models.length} models via ${baseKey}`);
378
+ }
379
+ }
380
+
381
+ return cache;
382
+ }
383
+
384
+ /**
385
+ * Check if a resolved model ID exists in the discovered model list.
386
+ *
387
+ * @param {string} model - Resolved model identifier.
388
+ * @param {string} backend - Backend name.
389
+ * @param {Map<string, {models: object[]}>} discovered - Discovery cache.
390
+ * @returns {boolean} True if the model is known or discovery is unavailable.
391
+ */
392
+ export function validate(model, backend, discovered) {
393
+ const entry = discovered?.get(backend);
394
+ if (!entry?.models?.length) return true;
395
+ return entry.models.some(function known(m) { return m.id === model; });
396
+ }