amicus 1.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 (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. package/src/utils/validators.js +300 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Sidecar Start Operations - Handles starting new sidecar sessions
3
+ * Spec Reference: §4.1, §9
4
+ */
5
+
6
+ const crypto = require('crypto');
7
+ const fs = require('fs');
8
+
9
+ const { buildContext } = require('./context-builder');
10
+ const {
11
+ SessionPaths,
12
+ saveInitialContext,
13
+ finalizeSession,
14
+ outputSummary,
15
+ createHeartbeat,
16
+ HEARTBEAT_INTERVAL
17
+ } = require('./session-utils');
18
+ const { runInteractive, checkElectronAvailable } = require('./interactive');
19
+ const { buildPrompts } = require('../prompt-builder');
20
+ const { runHeadless } = require('../headless');
21
+ const { logger } = require('../utils/logger');
22
+ const { acquireLock, releaseLock } = require('../utils/session-lock');
23
+ const { loadMcpConfig, parseMcpSpec } = require('../opencode-client');
24
+ const { mapAgentToOpenCode } = require('../utils/agent-mapping');
25
+ const { discoverParentMcps } = require('../utils/mcp-discovery');
26
+
27
+ /** Generate a unique 8-character hex task ID */
28
+ function generateTaskId() {
29
+ return crypto.randomBytes(4).toString('hex');
30
+ }
31
+
32
+ /** Create session directory and save metadata */
33
+ function createSessionMetadata(taskId, project, options) {
34
+ const { model, prompt, briefing, noUi, headless, agent, thinking } = options;
35
+
36
+ const sessionDir = SessionPaths.sessionDir(project, taskId);
37
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
38
+
39
+ const effectiveBriefing = prompt || briefing;
40
+ const isHeadless = noUi !== undefined ? noUi : headless;
41
+
42
+ // Preserve fields from existing metadata (e.g., pid written by MCP handler)
43
+ const metaPath = SessionPaths.metadataFile(sessionDir);
44
+ let existing = {};
45
+ if (fs.existsSync(metaPath)) {
46
+ try {
47
+ existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
48
+ } catch {
49
+ // ignore corrupt metadata
50
+ }
51
+ }
52
+
53
+ const metadata = {
54
+ ...existing,
55
+ taskId,
56
+ model,
57
+ project,
58
+ briefing: effectiveBriefing,
59
+ mode: isHeadless ? 'headless' : 'interactive',
60
+ agent: agent || (isHeadless ? 'build' : 'chat'),
61
+ thinking: thinking || 'medium',
62
+ status: 'running',
63
+ pid: existing.pid || process.pid,
64
+ createdAt: existing.createdAt || new Date().toISOString()
65
+ };
66
+
67
+ fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
68
+
69
+ return sessionDir;
70
+ }
71
+
72
+ /**
73
+ * Build MCP configuration from options.
74
+ * Merge priority: CLI --mcp > --mcp-config > file config > discovered parent MCPs
75
+ *
76
+ * @param {object} options
77
+ * @param {string} [options.mcp] - CLI --mcp spec
78
+ * @param {string} [options.mcpConfig] - CLI --mcp-config path
79
+ * @param {string} [options.clientType] - Parent client type for discovery
80
+ * @param {boolean} [options.noMcp] - Skip MCP inheritance from parent
81
+ * @param {string[]} [options.excludeMcp] - Server names to exclude
82
+ * @returns {object|null} MCP server configs or null
83
+ */
84
+ function buildMcpConfig(options) {
85
+ const { mcp, mcpConfig, clientType, noMcp, excludeMcp } = options;
86
+ let mcpServers = null;
87
+
88
+ // Layer 1: Discover parent MCPs (unless --no-mcp)
89
+ if (!noMcp) {
90
+ const discovered = discoverParentMcps(clientType);
91
+ if (discovered) {
92
+ mcpServers = { ...discovered };
93
+ logger.info('Discovered parent MCP servers', { serverCount: Object.keys(mcpServers).length });
94
+ }
95
+ }
96
+
97
+ // Layer 2: File config (opencode.json) overrides discovered
98
+ const fileConfig = loadMcpConfig(mcpConfig);
99
+ if (fileConfig) {
100
+ mcpServers = mcpServers ? { ...mcpServers, ...fileConfig } : { ...fileConfig };
101
+ logger.debug('Loaded MCP config from file', { serverCount: Object.keys(fileConfig).length });
102
+ }
103
+
104
+ // Layer 3: CLI --mcp (highest priority)
105
+ if (mcp) {
106
+ const parsed = parseMcpSpec(mcp);
107
+ if (parsed) {
108
+ mcpServers = mcpServers || {};
109
+ mcpServers[parsed.name] = parsed.config;
110
+ logger.debug('Added CLI MCP server', { name: parsed.name });
111
+ } else {
112
+ logger.warn('Invalid MCP server spec', { mcp });
113
+ }
114
+ }
115
+
116
+ // Always exclude the sidecar itself to prevent recursive spawning.
117
+ // When launched from Cowork, the discovered MCP list includes "sidecar"
118
+ // which would cause an infinite spawn loop.
119
+ if (mcpServers && mcpServers.sidecar) {
120
+ delete mcpServers.sidecar;
121
+ logger.debug('Auto-excluded sidecar MCP (recursive spawn prevention)');
122
+ }
123
+
124
+ // Apply explicit exclusions
125
+ if (excludeMcp && Array.isArray(excludeMcp) && mcpServers) {
126
+ for (const name of excludeMcp) {
127
+ if (mcpServers[name]) {
128
+ delete mcpServers[name];
129
+ logger.debug('Excluded MCP server', { name });
130
+ }
131
+ }
132
+ }
133
+
134
+ // Return null if all servers were excluded
135
+ if (mcpServers && Object.keys(mcpServers).length === 0) {
136
+ mcpServers = null;
137
+ }
138
+
139
+ return mcpServers;
140
+ }
141
+
142
+ /** Start a new sidecar session - Spec Reference: §4.1, §9 */
143
+ async function startSidecar(options) {
144
+ const {
145
+ model, prompt, briefing, sessionId, session = 'current',
146
+ cwd, project = process.cwd(), contextTurns = 50, contextSince,
147
+ contextMaxTokens = 80000, noUi, headless = false, timeout = 15,
148
+ agent, mcp, mcpConfig, summaryLength = 'normal', thinking,
149
+ client, sessionDir, noMcp, excludeMcp, opencodePort, coworkProcess, includeContext = true,
150
+ position = 'right', json = false, modelInput = null
151
+ } = options;
152
+
153
+ const effectivePrompt = prompt || briefing;
154
+ const effectiveSession = sessionId || session;
155
+ const effectiveProject = cwd || project;
156
+ const effectiveHeadless = noUi !== undefined ? noUi : headless;
157
+ const mcpServers = buildMcpConfig({ mcp, mcpConfig, clientType: client, noMcp, excludeMcp });
158
+ const taskId = options.taskId || generateTaskId();
159
+ const reasoning = thinking ? { effort: thinking } : undefined;
160
+
161
+ logger.info('Starting task', { taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive' });
162
+
163
+ const context = includeContext !== false
164
+ ? buildContext(effectiveProject, effectiveSession, { contextTurns, contextSince, contextMaxTokens, sessionDir, client, coworkProcess })
165
+ : '[Context excluded by caller - briefing is self-contained]';
166
+ const { system: systemPrompt, userMessage } = buildPrompts(
167
+ effectivePrompt, context, effectiveProject, effectiveHeadless, agent, summaryLength, client
168
+ );
169
+
170
+ const sessDir = createSessionMetadata(taskId, effectiveProject, {
171
+ model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking
172
+ });
173
+ saveInitialContext(sessDir, systemPrompt, userMessage);
174
+ acquireLock(sessDir, effectiveHeadless ? 'headless' : 'interactive');
175
+
176
+ const heartbeat = createHeartbeat(HEARTBEAT_INTERVAL, sessDir);
177
+ let summary;
178
+ let result;
179
+
180
+ try {
181
+ if (effectiveHeadless) {
182
+ try {
183
+ result = await runHeadless(
184
+ model, systemPrompt, userMessage, taskId, effectiveProject,
185
+ timeout * 60 * 1000, agent || 'build', { mcp: mcpServers, summaryLength, reasoning, port: opencodePort }
186
+ );
187
+ } catch (err) {
188
+ if (!json) { throw err; }
189
+ // --json contract: stdout must always carry a parseable run doc,
190
+ // even when the engine throws rather than returning {error}.
191
+ result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId };
192
+ }
193
+ summary = result.summary || '## Sidecar Results: No Output\n\nHeadless mode completed without summary.';
194
+ if (result.timedOut) { logger.warn('Task timed out', { taskId }); }
195
+ if (result.error) { logger.error('Task error', { taskId, error: result.error }); }
196
+ } else {
197
+ const effectiveAgent = mapAgentToOpenCode(agent).agent;
198
+ logger.info('Launching interactive sidecar', { taskId, model, agent: effectiveAgent });
199
+ result = await runInteractive(
200
+ model, systemPrompt, userMessage, taskId, effectiveProject,
201
+ { agent, mcp: mcpServers, reasoning, client, windowPosition: position }
202
+ );
203
+ summary = result.summary || '';
204
+ if (result.error) { logger.error('Interactive task error', { taskId, error: result.error }); }
205
+ }
206
+ } finally {
207
+ heartbeat.stop();
208
+ releaseLock(sessDir);
209
+ }
210
+
211
+ if (!json) { outputSummary(summary); }
212
+ const metaPath = SessionPaths.metadataFile(sessDir);
213
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
214
+
215
+ // Persist OpenCode session ID for resume capability
216
+ if (result && result.opencodeSessionId) {
217
+ meta.opencodeSessionId = result.opencodeSessionId;
218
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
219
+ }
220
+
221
+ // Mark error results as 'error' instead of 'complete'
222
+ if (result && result.error) {
223
+ meta.status = 'error';
224
+ meta.reason = result.error;
225
+ meta.completedAt = new Date().toISOString();
226
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
227
+ logger.error('Session completed with error', { taskId, error: result.error });
228
+ } else {
229
+ finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json });
230
+ }
231
+
232
+ if (json) {
233
+ const { buildRunResult } = require('../utils/result-schema');
234
+ const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
235
+ const doc = buildRunResult({
236
+ taskId, metadata: finalMeta, result, summary,
237
+ modelInput, sessionDir: sessDir,
238
+ });
239
+ console.log(JSON.stringify(doc, null, 2));
240
+ }
241
+ }
242
+
243
+ module.exports = {
244
+ generateTaskId,
245
+ createSessionMetadata,
246
+ buildMcpConfig,
247
+ checkElectronAvailable,
248
+ runInteractive,
249
+ startSidecar,
250
+ HEARTBEAT_INTERVAL
251
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Agent Mapping Module
3
+ *
4
+ * Maps agent names to OpenCode's native agent framework.
5
+ *
6
+ * OpenCode Native Agents (https://opencode.ai/docs/agents/):
7
+ * - Chat: Reads auto, writes/bash ask permission (interactive default)
8
+ * - Build: Default primary agent with full tool access
9
+ * - Plan: Read-only agent for analysis and planning
10
+ * - General: Full-access agent for research
11
+ * - Explore: Read-only agent for codebase exploration
12
+ *
13
+ * Custom agents defined in ~/.config/opencode/agents/ or .opencode/agents/
14
+ * are passed through directly to OpenCode.
15
+ */
16
+
17
+ /**
18
+ * OpenCode's primary agent names (for main sessions)
19
+ * Note: OpenCode API expects lowercase agent names
20
+ * 'chat' is a custom sidecar agent: reads auto, writes/bash ask. When client=cowork, gets a general-purpose prompt.
21
+ */
22
+ const PRIMARY_AGENTS = ['chat', 'build', 'plan'];
23
+
24
+ /**
25
+ * All OpenCode native agent names (lowercase)
26
+ */
27
+ const OPENCODE_AGENTS = [...PRIMARY_AGENTS, 'general', 'explore'];
28
+
29
+ /**
30
+ * Agents safe for headless (--no-ui) mode.
31
+ * 'chat' requires user permission for writes/bash and stalls in headless.
32
+ */
33
+ const HEADLESS_SAFE_AGENTS = ['build', 'plan', 'explore', 'general'];
34
+
35
+ /**
36
+ * Map an agent name to OpenCode native agent configuration
37
+ *
38
+ * @param {string} agent - Agent name (OpenCode native or custom)
39
+ * @returns {{agent: string}} OpenCode agent configuration
40
+ *
41
+ * @example
42
+ * mapAgentToOpenCode('Build') // { agent: 'build' }
43
+ * mapAgentToOpenCode('Plan') // { agent: 'plan' }
44
+ * mapAgentToOpenCode('custom') // { agent: 'custom' }
45
+ */
46
+ function mapAgentToOpenCode(agent) {
47
+ // Handle undefined/null/empty - default to chat (reads auto, writes ask)
48
+ if (!agent || (typeof agent === 'string' && agent.trim() === '')) {
49
+ return { agent: 'chat' };
50
+ }
51
+
52
+ // Normalize for case-insensitive matching of native agents
53
+ const normalized = agent.toLowerCase();
54
+
55
+ // Check if it's an OpenCode native agent (case-insensitive match)
56
+ const nativeMatch = OPENCODE_AGENTS.find(
57
+ native => native === normalized
58
+ );
59
+ if (nativeMatch) {
60
+ return { agent: nativeMatch };
61
+ }
62
+
63
+ // Pass through custom agent names as lowercase (OpenCode API expects lowercase)
64
+ return { agent: normalized };
65
+ }
66
+
67
+ /**
68
+ * Check if an agent is safe for headless (--no-ui) mode
69
+ *
70
+ * @param {string} agent - Agent name to check
71
+ * @returns {boolean|null} true if safe, false if unsafe (chat), null if unknown/custom
72
+ */
73
+ function isHeadlessSafe(agent) {
74
+ if (!agent || (typeof agent === 'string' && agent.trim() === '')) {
75
+ return null;
76
+ }
77
+
78
+ const normalized = agent.toLowerCase();
79
+
80
+ if (HEADLESS_SAFE_AGENTS.includes(normalized)) {
81
+ return true;
82
+ }
83
+
84
+ if (normalized === 'chat') {
85
+ return false;
86
+ }
87
+
88
+ // Unknown/custom agents — we can't determine safety
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Check if an agent name is valid for primary sessions
94
+ *
95
+ * @param {string} agent - Agent name to validate
96
+ * @returns {boolean} True if valid primary agent or custom agent
97
+ */
98
+ function isValidPrimaryAgent(agent) {
99
+ if (!isValidAgent(agent)) {
100
+ return false;
101
+ }
102
+
103
+ // All non-empty strings are valid (custom agents allowed)
104
+ return true;
105
+ }
106
+
107
+ /**
108
+ * Check if an agent name is valid (non-empty string)
109
+ *
110
+ * All non-empty agent names are considered valid because:
111
+ * 1. OpenCode native agents (Build, Plan, General, Explore) are always valid
112
+ * 2. Custom agents defined in user's agent directory should be allowed
113
+ * (OpenCode will validate at runtime)
114
+ *
115
+ * @param {string} agent - Agent name to validate
116
+ * @returns {boolean} True if valid (non-empty string)
117
+ */
118
+ function isValidAgent(agent) {
119
+ if (agent === null || agent === undefined) {
120
+ return false;
121
+ }
122
+
123
+ if (typeof agent !== 'string') {
124
+ return false;
125
+ }
126
+
127
+ return agent.trim().length > 0;
128
+ }
129
+
130
+ module.exports = {
131
+ PRIMARY_AGENTS,
132
+ OPENCODE_AGENTS,
133
+ HEADLESS_SAFE_AGENTS,
134
+ mapAgentToOpenCode,
135
+ isValidAgent,
136
+ isHeadlessSafe,
137
+ isValidPrimaryAgent
138
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Alias Audit (F5) — report + suggest, never auto-repair.
3
+ *
4
+ * Finds aliases/routes pointing at models absent from the catalog and
5
+ * suggests current same-vendor replacements. Pure functions over inputs;
6
+ * collectAliasSources() does the gathering. Consumed by `amicus models
7
+ * --check` and the npm wrapper scripts.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ /**
13
+ * @returns {Array<{alias,model,source}>} every alias mapping we ship or the user set
14
+ * Identical (alias, model) pairs are deduped, first source wins — defaults derive from curated
15
+ * routes, so every default would otherwise double-report. A user --add-alias override creates a
16
+ * distinct user-config row; shipped defaults/curated routes stay audited until curated-models.js
17
+ * itself is updated.
18
+ */
19
+ function collectAliasSources() {
20
+ const { getDefaultAliases, loadConfig } = require('./config');
21
+ const { listCuratedRoutes } = require('./curated-models');
22
+ const out = [];
23
+ for (const [alias, model] of Object.entries(getDefaultAliases())) {
24
+ out.push({ alias, model, source: 'defaults' });
25
+ }
26
+ const cfg = loadConfig();
27
+ for (const [alias, model] of Object.entries((cfg && cfg.aliases) || {})) {
28
+ if (typeof model === 'string' && model) {
29
+ out.push({ alias, model, source: 'user-config' });
30
+ }
31
+ }
32
+ for (const r of listCuratedRoutes()) {
33
+ out.push({ alias: r.alias, model: r.model, source: `curated-route (${r.provider})` });
34
+ }
35
+ const seen = new Set();
36
+ return out.filter(({ alias, model }) => {
37
+ const key = `${alias} ${model}`;
38
+ if (seen.has(key)) { return false; }
39
+ seen.add(key);
40
+ return true;
41
+ });
42
+ }
43
+
44
+ /** Catalog ids grouped by leading provider segment, e.g. 'openrouter', 'google'. */
45
+ function idsByProvider(catalog) {
46
+ const map = new Map();
47
+ for (const m of catalog) {
48
+ if (!(m && typeof m.id === 'string')) { continue; }
49
+ const provider = m.id.split('/')[0];
50
+ if (!map.has(provider)) { map.set(provider, new Set()); }
51
+ map.get(provider).add(m.id);
52
+ }
53
+ return map;
54
+ }
55
+
56
+ /**
57
+ * Entries whose model is absent from the catalog. A model is only checkable
58
+ * when its provider has rows in the catalog (unkeyed providers never produce
59
+ * false stales). Empty catalog → [] (cannot check).
60
+ * @param {Array<{alias,model,source}>} sources
61
+ * @param {Array<{id:string}>} catalog
62
+ */
63
+ function findStaleAliases(sources, catalog) {
64
+ if (!catalog || catalog.length === 0) { return []; }
65
+ const byProvider = idsByProvider(catalog);
66
+ return sources.filter(({ model }) => {
67
+ const provider = model.split('/')[0];
68
+ const ids = byProvider.get(provider);
69
+ if (!ids) { return false; } // provider unverifiable
70
+ return !ids.has(model);
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Same-vendor replacement candidates for a stale model id, ranked by
76
+ * shared-prefix length with the stale id (desc), then id descending so
77
+ * higher version numbers sort first. Deterministic; max n.
78
+ * @param {string} staleModel - e.g. 'openrouter/x-ai/grok-4.1-fast'
79
+ * @param {Array<{id:string}>} catalog
80
+ * @param {number} [n=3]
81
+ * @returns {string[]} candidate ids
82
+ */
83
+ function suggestReplacements(staleModel, catalog, n = 3) {
84
+ const vendorPrefix = staleModel.split('/').slice(0, -1).join('/') + '/';
85
+ const sharedLen = (a, b) => {
86
+ let i = 0;
87
+ while (i < a.length && i < b.length && a[i] === b[i]) { i++; }
88
+ return i;
89
+ };
90
+ return catalog
91
+ .filter(m => m && typeof m.id === 'string').map(m => m.id)
92
+ .filter(id => id.startsWith(vendorPrefix) && id !== staleModel)
93
+ .sort((a, b) =>
94
+ (sharedLen(b, staleModel) - sharedLen(a, staleModel)) || b.localeCompare(a, 'en', { numeric: true }))
95
+ .slice(0, n);
96
+ }
97
+
98
+ module.exports = { collectAliasSources, findStaleAliases, suggestReplacements };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Alias Resolver Utilities
3
+ *
4
+ * Handles alias auto-repair and direct API fallback logic,
5
+ * extracted from config.js to keep it under the 300-line limit.
6
+ */
7
+
8
+ const { PROVIDER_ENV_MAP, readApiKeyValues } = require('./api-key-store');
9
+ const { logger } = require('./logger');
10
+
11
+ /**
12
+ * Strip openrouter/ prefix when direct provider API key is available
13
+ * but OPENROUTER_API_KEY is not.
14
+ * @param {string} model - Full model identifier
15
+ * @returns {string} Model with or without openrouter/ prefix
16
+ */
17
+ function applyDirectApiFallback(model) {
18
+ if (!model.startsWith('openrouter/')) {
19
+ return model;
20
+ }
21
+ const persistedKeys = readApiKeyValues();
22
+ if (process.env.OPENROUTER_API_KEY || persistedKeys.openrouter) {
23
+ return model;
24
+ }
25
+ const direct = model.slice('openrouter/'.length);
26
+ const provider = direct.split('/')[0];
27
+ const envVar = PROVIDER_ENV_MAP[provider];
28
+ if (envVar && (process.env[envVar] || persistedKeys[provider])) {
29
+ logger.warn({ msg: 'Using direct provider API (OPENROUTER_API_KEY not set)', original: model, resolved: direct });
30
+ process.stderr.write(
31
+ `Notice: Using direct ${provider} API (OPENROUTER_API_KEY not set). ` +
32
+ 'Model availability is validated automatically; pass --no-validate-model to skip.\n'
33
+ );
34
+ return direct;
35
+ }
36
+ return model;
37
+ }
38
+
39
+ /**
40
+ * Auto-repair a null alias by falling back to DEFAULT_ALIASES.
41
+ * Updates config on disk and warns to stderr.
42
+ * @param {string} alias - The alias name with null value
43
+ * @param {object|null} config - Current config object
44
+ * @param {object} defaultAliases - DEFAULT_ALIASES map
45
+ * @param {Function} saveConfig - saveConfig function reference
46
+ * @returns {string} Repaired model string
47
+ * @throws {Error} If no default exists for this alias
48
+ */
49
+ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
50
+ const defaultModel = defaultAliases[alias];
51
+ if (defaultModel) {
52
+ process.stderr.write(
53
+ `Notice: Auto-repaired null alias '${alias}' -> '${defaultModel}'\n`
54
+ );
55
+ if (config && config.aliases) {
56
+ config.aliases[alias] = defaultModel;
57
+ try {
58
+ saveConfig(config);
59
+ } catch (err) {
60
+ process.stderr.write(
61
+ `Notice: Could not persist repaired alias '${alias}' (${err.message}). ` +
62
+ 'Using default for this session only.\n'
63
+ );
64
+ }
65
+ }
66
+ return applyDirectApiFallback(defaultModel);
67
+ }
68
+ throw new Error(
69
+ `Alias '${alias}' is configured but has no model value. ` +
70
+ `Fix with: sidecar setup --add-alias ${alias}=provider/model`
71
+ );
72
+ }
73
+
74
+ module.exports = {
75
+ applyDirectApiFallback,
76
+ autoRepairAlias,
77
+ };