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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- package/src/utils/validators.js +300 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Amicus Config Module
|
|
3
|
+
*
|
|
4
|
+
* Config directory resolution, file I/O, model alias resolution,
|
|
5
|
+
* config hashing, and alias table formatting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { applyDirectApiFallback, autoRepairAlias } = require('./alias-resolver');
|
|
12
|
+
const { getCompatEnv } = require('./env-compat');
|
|
13
|
+
|
|
14
|
+
/** Default model alias map — derived from the curated-models single source (F5) */
|
|
15
|
+
const { toDefaultAliases } = require('./curated-models');
|
|
16
|
+
const DEFAULT_ALIASES = toDefaultAliases();
|
|
17
|
+
|
|
18
|
+
/** @returns {string} Config directory path */
|
|
19
|
+
function getConfigDir() {
|
|
20
|
+
const override = getCompatEnv('CONFIG_DIR');
|
|
21
|
+
if (override) {
|
|
22
|
+
const resolved = path.resolve(override);
|
|
23
|
+
if (resolved.includes('\0')) {
|
|
24
|
+
throw new Error('Invalid AMICUS_CONFIG_DIR: null bytes not allowed');
|
|
25
|
+
}
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
29
|
+
const amicusDir = path.join(homeDir, '.config', 'amicus');
|
|
30
|
+
// DEPRECATED(amicus-shim): fall back to the legacy ~/.config/sidecar dir if it
|
|
31
|
+
// exists and the new one does not, so pre-rebrand credentials keep working.
|
|
32
|
+
// Remove in a future revision — see docs/SHIMS.md.
|
|
33
|
+
if (!fs.existsSync(amicusDir)) {
|
|
34
|
+
const legacyDir = path.join(homeDir, '.config', 'sidecar');
|
|
35
|
+
if (fs.existsSync(legacyDir)) {
|
|
36
|
+
return legacyDir;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return amicusDir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @returns {string} Full path to config.json */
|
|
43
|
+
function getConfigPath() {
|
|
44
|
+
return path.join(getConfigDir(), 'config.json');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {object|null} Parsed config data, or null if missing/invalid */
|
|
48
|
+
function loadConfig() {
|
|
49
|
+
const configPath = getConfigPath();
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.existsSync(configPath)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
55
|
+
if (!content || content.trim().length === 0) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return JSON.parse(content);
|
|
59
|
+
} catch (_err) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Save config data to disk, creating the directory if needed. Strips invalid aliases. */
|
|
65
|
+
function saveConfig(configData) {
|
|
66
|
+
if (configData && configData.aliases) {
|
|
67
|
+
const cleaned = {};
|
|
68
|
+
for (const [key, value] of Object.entries(configData.aliases)) {
|
|
69
|
+
if (key === 'null' || !value || typeof value !== 'string' || value === 'null') {
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
`Notice: Removing invalid alias '${key}' (value: ${JSON.stringify(value)}) from config.\n`
|
|
72
|
+
);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
cleaned[key] = value;
|
|
76
|
+
}
|
|
77
|
+
configData.aliases = cleaned;
|
|
78
|
+
}
|
|
79
|
+
const configDir = getConfigDir();
|
|
80
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
81
|
+
const configPath = getConfigPath();
|
|
82
|
+
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), { mode: 0o600 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @returns {object} Copy of the default alias map */
|
|
86
|
+
function getDefaultAliases() {
|
|
87
|
+
return { ...DEFAULT_ALIASES };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a model argument to a full model identifier
|
|
92
|
+
*
|
|
93
|
+
* Resolution order:
|
|
94
|
+
* 1. If modelArg contains '/' -> return as-is (full model string)
|
|
95
|
+
* 2. If modelArg is a key in config.aliases -> return resolved string
|
|
96
|
+
* 3. If modelArg is unknown alias -> throw Error mentioning 'sidecar setup'
|
|
97
|
+
* 4. If modelArg is undefined and config.default exists -> resolve that alias
|
|
98
|
+
* 5. If no default -> throw Error
|
|
99
|
+
*
|
|
100
|
+
* @param {string|undefined} modelArg - Model argument from CLI or undefined
|
|
101
|
+
* @returns {string} Resolved full model identifier
|
|
102
|
+
* @throws {Error} When alias is unknown or no default configured
|
|
103
|
+
*/
|
|
104
|
+
function resolveModel(modelArg) {
|
|
105
|
+
const config = loadConfig();
|
|
106
|
+
|
|
107
|
+
const effectiveAliases = getEffectiveAliases();
|
|
108
|
+
|
|
109
|
+
// If modelArg is provided
|
|
110
|
+
if (modelArg !== undefined && modelArg !== null) {
|
|
111
|
+
// Full model string with slash - return as-is
|
|
112
|
+
if (modelArg.includes('/')) {
|
|
113
|
+
return modelArg;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Try to resolve as alias (user config + defaults)
|
|
117
|
+
if (effectiveAliases[modelArg] !== undefined) {
|
|
118
|
+
const resolved = effectiveAliases[modelArg];
|
|
119
|
+
if (!resolved || resolved === 'null') {
|
|
120
|
+
return autoRepairAlias(modelArg, config, DEFAULT_ALIASES, saveConfig);
|
|
121
|
+
}
|
|
122
|
+
return applyDirectApiFallback(resolved);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Unknown alias
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Unknown model alias '${modelArg}'. Run 'sidecar setup' to configure aliases.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// modelArg is undefined - use default
|
|
132
|
+
if (!config || !config.default) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
'No model specified and no default configured. Run \'sidecar setup\' to set a default model.'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const defaultValue = config.default;
|
|
139
|
+
|
|
140
|
+
// Default is a full model string
|
|
141
|
+
if (defaultValue.includes('/')) {
|
|
142
|
+
return defaultValue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Default is an alias - resolve via user config + defaults
|
|
146
|
+
if (effectiveAliases[defaultValue] !== undefined) {
|
|
147
|
+
const resolved = effectiveAliases[defaultValue];
|
|
148
|
+
if (!resolved || resolved === 'null') {
|
|
149
|
+
return autoRepairAlias(defaultValue, config, DEFAULT_ALIASES, saveConfig);
|
|
150
|
+
}
|
|
151
|
+
return applyDirectApiFallback(resolved);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Default alias not found anywhere
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Default alias '${defaultValue}' not found in aliases. Run 'sidecar setup' to fix configuration.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** @returns {string|null} 8-char hex hash of config file, or null if missing */
|
|
161
|
+
function computeConfigHash() {
|
|
162
|
+
const configPath = getConfigPath();
|
|
163
|
+
try {
|
|
164
|
+
if (!fs.existsSync(configPath)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
168
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
169
|
+
} catch (_err) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @returns {string} Markdown alias table with (default) marker, or empty string */
|
|
175
|
+
function buildAliasTable() {
|
|
176
|
+
const config = loadConfig();
|
|
177
|
+
if (!config || !config.aliases || Object.keys(config.aliases).length === 0) {
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const defaultAlias = config.default || null;
|
|
182
|
+
const lines = [];
|
|
183
|
+
|
|
184
|
+
lines.push('| Alias | Model |');
|
|
185
|
+
lines.push('|-------|-------|');
|
|
186
|
+
|
|
187
|
+
for (const [alias, model] of Object.entries(config.aliases)) {
|
|
188
|
+
const marker = (alias === defaultAlias) ? ' (default)' : '';
|
|
189
|
+
lines.push(`| ${alias}${marker} | ${model} |`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Check whether the config file has changed compared to a known hash */
|
|
196
|
+
function checkConfigChanged(currentHash) {
|
|
197
|
+
const newHash = computeConfigHash();
|
|
198
|
+
|
|
199
|
+
if (currentHash === newHash) {
|
|
200
|
+
return { changed: false, newHash };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Config has changed (or was created/removed)
|
|
204
|
+
const aliasTable = buildAliasTable();
|
|
205
|
+
const hashComment = newHash ? `<!-- amicus-config-hash: ${newHash} -->` : '';
|
|
206
|
+
const updateData = [hashComment, aliasTable].filter(Boolean).join('\n');
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
changed: true,
|
|
210
|
+
newHash,
|
|
211
|
+
updateData: updateData || undefined,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get effective aliases: defaults merged with user config (user wins)
|
|
217
|
+
* @returns {object} Merged alias map
|
|
218
|
+
*/
|
|
219
|
+
function getEffectiveAliases() {
|
|
220
|
+
const config = loadConfig();
|
|
221
|
+
const userAliases = (config && config.aliases) || {};
|
|
222
|
+
return { ...DEFAULT_ALIASES, ...userAliases };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Format alias names as a comma-separated string for tool descriptions
|
|
227
|
+
* @returns {string} e.g. "gemini, opus, gpt, deepseek, ..."
|
|
228
|
+
*/
|
|
229
|
+
function formatAliasNames() {
|
|
230
|
+
return Object.keys(getEffectiveAliases()).join(', ');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Non-throwing wrapper around resolveModel
|
|
235
|
+
* @param {string|undefined} modelArg - Model argument
|
|
236
|
+
* @returns {{model?: string, error?: string}} Resolved model or error message
|
|
237
|
+
*/
|
|
238
|
+
function tryResolveModel(modelArg) {
|
|
239
|
+
try {
|
|
240
|
+
return { model: resolveModel(modelArg) };
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return { error: err.message };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Build OpenCode provider.models config from sidecar aliases.
|
|
247
|
+
* @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
|
|
248
|
+
function buildProviderModels() {
|
|
249
|
+
const aliases = getEffectiveAliases();
|
|
250
|
+
const providers = {};
|
|
251
|
+
|
|
252
|
+
for (const fullModel of Object.values(aliases)) {
|
|
253
|
+
if (!fullModel || typeof fullModel !== 'string') { continue; }
|
|
254
|
+
const parts = fullModel.split('/');
|
|
255
|
+
if (parts.length < 2) { continue; }
|
|
256
|
+
|
|
257
|
+
const providerID = parts[0];
|
|
258
|
+
const modelID = parts.slice(1).join('/');
|
|
259
|
+
|
|
260
|
+
if (!providers[providerID]) {
|
|
261
|
+
providers[providerID] = { models: {} };
|
|
262
|
+
}
|
|
263
|
+
providers[providerID].models[modelID] = {};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return providers;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Detect if direct API fallback was applied during alias resolution */
|
|
270
|
+
function detectFallback(alias, resolvedModel) {
|
|
271
|
+
if (!alias || alias.includes('/')) { return false; }
|
|
272
|
+
const val = getEffectiveAliases()[alias];
|
|
273
|
+
return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = {
|
|
277
|
+
getConfigDir,
|
|
278
|
+
getConfigPath,
|
|
279
|
+
loadConfig,
|
|
280
|
+
saveConfig,
|
|
281
|
+
getDefaultAliases,
|
|
282
|
+
resolveModel,
|
|
283
|
+
detectFallback,
|
|
284
|
+
computeConfigHash,
|
|
285
|
+
buildAliasTable,
|
|
286
|
+
checkConfigChanged,
|
|
287
|
+
getEffectiveAliases,
|
|
288
|
+
formatAliasNames,
|
|
289
|
+
tryResolveModel,
|
|
290
|
+
buildProviderModels,
|
|
291
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated Models — THE single source of truth for default model lists.
|
|
3
|
+
*
|
|
4
|
+
* Three consumers derive from this module (F5 anti-drift):
|
|
5
|
+
* - src/utils/config.js DEFAULT_ALIASES (toDefaultAliases)
|
|
6
|
+
* - electron/setup-ui-model.js MODEL_CHOICES (getCuratedModels)
|
|
7
|
+
* - src/sidecar/setup.js MODEL_CHOICES (getCuratedModels)
|
|
8
|
+
* Never hand-edit a model id anywhere else. `amicus models --check`
|
|
9
|
+
* audits every route here against the live catalog.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Card entries (shown as wizard quick picks). `routes` maps provider →
|
|
16
|
+
* full model id; the openrouter route doubles as the default alias target.
|
|
17
|
+
* Direct (non-openrouter) route ids MUST be verified against the provider
|
|
18
|
+
* whenever they change.
|
|
19
|
+
*/
|
|
20
|
+
const CARDS = [
|
|
21
|
+
{ alias: 'gemini', label: 'Gemini 3.1 Flash Lite', blurb: 'fast, large context',
|
|
22
|
+
routes: { openrouter: 'openrouter/google/gemini-3.1-flash-lite-preview',
|
|
23
|
+
google: 'google/gemini-3.1-flash-lite-preview' } },
|
|
24
|
+
{ alias: 'gemini-pro', label: 'Gemini 3.1 Pro', blurb: 'advanced reasoning',
|
|
25
|
+
routes: { openrouter: 'openrouter/google/gemini-3.1-pro-preview',
|
|
26
|
+
google: 'google/gemini-3.1-pro-preview' } },
|
|
27
|
+
{ alias: 'gpt', label: 'GPT-5.4', blurb: 'strong coding',
|
|
28
|
+
routes: { openrouter: 'openrouter/openai/gpt-5.4',
|
|
29
|
+
openai: 'openai/gpt-5.4' } },
|
|
30
|
+
{ alias: 'opus', label: 'Claude Opus 4.6', blurb: 'deep analysis',
|
|
31
|
+
routes: { openrouter: 'openrouter/anthropic/claude-opus-4.6',
|
|
32
|
+
anthropic: 'anthropic/claude-opus-4-6' } },
|
|
33
|
+
{ alias: 'deepseek', label: 'DeepSeek v3.2', blurb: 'open-source',
|
|
34
|
+
routes: { openrouter: 'openrouter/deepseek/deepseek-v3.2' } },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Alias-only entries (no wizard card); openrouter route only. */
|
|
38
|
+
const CARDLESS = [
|
|
39
|
+
{ alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.4-pro' } },
|
|
40
|
+
// codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
|
|
41
|
+
{ alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
|
|
42
|
+
{ alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
|
|
43
|
+
{ alias: 'sonnet', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
|
|
44
|
+
{ alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5' } },
|
|
45
|
+
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.5-397b-a17b' } },
|
|
46
|
+
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
47
|
+
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.5-flash-02-23' } },
|
|
48
|
+
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-large-2512' } },
|
|
49
|
+
{ alias: 'devstral', routes: { openrouter: 'openrouter/mistralai/devstral-2512' } },
|
|
50
|
+
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5' } },
|
|
51
|
+
{ alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.5' } },
|
|
52
|
+
{ alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
|
|
53
|
+
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.5' } },
|
|
54
|
+
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-mini' } },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** @returns {Array<{alias,label,blurb,routes}>} card entries (wizard quick picks) */
|
|
58
|
+
function getCuratedModels() {
|
|
59
|
+
return CARDS.map(c => ({ ...c, routes: { ...c.routes } }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @returns {Object<string,string>} alias → preferred route (openrouter first) */
|
|
63
|
+
function toDefaultAliases() {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const e of [...CARDS, ...CARDLESS]) {
|
|
66
|
+
out[e.alias] = e.routes.openrouter || Object.values(e.routes)[0];
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @returns {Array<{alias,provider,model}>} every route of every entry, flattened */
|
|
72
|
+
function listCuratedRoutes() {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const e of [...CARDS, ...CARDLESS]) {
|
|
75
|
+
for (const [provider, model] of Object.entries(e.routes)) {
|
|
76
|
+
out.push({ alias: e.alias, provider, model });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { getCuratedModels, toDefaultAliases, listCuratedRoutes };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-variable compatibility shim (Amicus rebrand).
|
|
3
|
+
*
|
|
4
|
+
* DEPRECATED(amicus-shim): the SIDECAR_* fallbacks exist only for backward
|
|
5
|
+
* compatibility with pre-rebrand setups. Remove in a future revision once users
|
|
6
|
+
* have migrated to the AMICUS_* names. See docs/SHIMS.md.
|
|
7
|
+
*/
|
|
8
|
+
const { logger } = require('./logger');
|
|
9
|
+
|
|
10
|
+
const warned = new Set();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read an env var by its canonical AMICUS_<suffix> name, falling back to the
|
|
14
|
+
* legacy SIDECAR_<suffix> name (with a one-time deprecation warning) if unset.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} suffix - e.g. 'CONFIG_DIR' (no AMICUS_/SIDECAR_ prefix)
|
|
17
|
+
* @returns {string|undefined}
|
|
18
|
+
*/
|
|
19
|
+
function getCompatEnv(suffix) {
|
|
20
|
+
const amicusName = `AMICUS_${suffix}`;
|
|
21
|
+
if (process.env[amicusName] !== undefined) {
|
|
22
|
+
return process.env[amicusName];
|
|
23
|
+
}
|
|
24
|
+
const legacyName = `SIDECAR_${suffix}`;
|
|
25
|
+
if (process.env[legacyName] !== undefined) {
|
|
26
|
+
if (!warned.has(legacyName)) {
|
|
27
|
+
warned.add(legacyName);
|
|
28
|
+
logger.warn(
|
|
29
|
+
`${legacyName} is deprecated; use ${amicusName} instead. ` +
|
|
30
|
+
'Support will be removed in a future Amicus release.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return process.env[legacyName];
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { getCompatEnv };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads API keys from multiple sources into process.env at CLI bootstrap.
|
|
5
|
+
* Priority: process.env (already set) > amicus .env > auth.json
|
|
6
|
+
* Never overwrites existing process.env values.
|
|
7
|
+
*/
|
|
8
|
+
const { logger } = require('./logger');
|
|
9
|
+
const { loadEnvEntries, PROVIDER_ENV_MAP, LEGACY_KEY_NAMES } = require('./api-key-store');
|
|
10
|
+
const { readAuthJsonKeys } = require('./auth-json');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load credentials from all sources into process.env.
|
|
14
|
+
* Call once at CLI startup, before any validation.
|
|
15
|
+
*
|
|
16
|
+
* Sources (in priority order):
|
|
17
|
+
* 1. process.env - already set, never overwritten
|
|
18
|
+
* 2. ~/.config/amicus/.env - user-configured via `amicus setup`
|
|
19
|
+
* (DEPRECATED(amicus-shim): falls back to ~/.config/sidecar/.env if the amicus .env absent)
|
|
20
|
+
* 3. ~/.local/share/opencode/auth.json - OpenCode SDK fallback
|
|
21
|
+
*/
|
|
22
|
+
function loadCredentials() {
|
|
23
|
+
// Step 1: Load from sidecar .env file
|
|
24
|
+
const fileEntries = loadEnvEntries();
|
|
25
|
+
for (const [, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
|
|
26
|
+
if (!process.env[envVar]) {
|
|
27
|
+
const fromFile = fileEntries.get(envVar);
|
|
28
|
+
if (fromFile && fromFile.length > 0) {
|
|
29
|
+
process.env[envVar] = fromFile;
|
|
30
|
+
logger.info(`Loaded ${envVar} from amicus .env`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Step 1b: Handle legacy key names in process.env
|
|
36
|
+
for (const [oldName, newName] of Object.entries(LEGACY_KEY_NAMES)) {
|
|
37
|
+
if (process.env[oldName] && !process.env[newName]) {
|
|
38
|
+
process.env[newName] = process.env[oldName];
|
|
39
|
+
logger.info(`Migrated ${oldName} to ${newName}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Step 2: Import from auth.json (lowest priority)
|
|
44
|
+
const authKeys = readAuthJsonKeys();
|
|
45
|
+
for (const [provider, key] of Object.entries(authKeys)) {
|
|
46
|
+
const envVar = PROVIDER_ENV_MAP[provider];
|
|
47
|
+
if (envVar && !process.env[envVar]) {
|
|
48
|
+
process.env[envVar] = key;
|
|
49
|
+
logger.info(`Loaded ${envVar} from auth.json`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { loadCredentials };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module idle-watchdog
|
|
3
|
+
* IdleWatchdog - BUSY/IDLE state machine with self-terminating timer.
|
|
4
|
+
*
|
|
5
|
+
* Tracks whether a sidecar session is actively processing (BUSY) or
|
|
6
|
+
* waiting for work (IDLE). Fires an onTimeout callback after the
|
|
7
|
+
* configured idle period elapses. Supports stuck-stream protection to
|
|
8
|
+
* force-transition out of BUSY if a response stream never completes.
|
|
9
|
+
*
|
|
10
|
+
* Timeout priority (highest to lowest):
|
|
11
|
+
* 1. Per-mode env var (AMICUS_IDLE_TIMEOUT_HEADLESS, etc.) in minutes
|
|
12
|
+
* (legacy SIDECAR_IDLE_TIMEOUT_* still honored via env-compat shim)
|
|
13
|
+
* 2. Blanket env var AMICUS_IDLE_TIMEOUT / SIDECAR_IDLE_TIMEOUT in minutes
|
|
14
|
+
* 3. Constructor option `timeout` in milliseconds
|
|
15
|
+
* 4. Mode default (headless=15m, interactive=60m, server=30m)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const { getCompatEnv } = require('./env-compat');
|
|
21
|
+
|
|
22
|
+
/** @type {Object.<string, number>} Default timeouts per mode in milliseconds */
|
|
23
|
+
const MODE_TIMEOUTS = {
|
|
24
|
+
headless: 15 * 60 * 1000,
|
|
25
|
+
interactive: 60 * 60 * 1000,
|
|
26
|
+
server: 30 * 60 * 1000,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** @type {Object.<string, string>} Per-mode env-compat suffixes */
|
|
30
|
+
const MODE_ENV_MAP = {
|
|
31
|
+
headless: 'IDLE_TIMEOUT_HEADLESS',
|
|
32
|
+
interactive: 'IDLE_TIMEOUT_INTERACTIVE',
|
|
33
|
+
server: 'IDLE_TIMEOUT_SERVER',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the effective timeout in milliseconds using the priority chain.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} mode - The operating mode ('headless', 'interactive', 'server')
|
|
40
|
+
* @param {number|undefined} optionTimeout - Caller-supplied timeout in ms (or undefined)
|
|
41
|
+
* @returns {number} Effective timeout in ms, or Infinity if disabled
|
|
42
|
+
*/
|
|
43
|
+
function resolveTimeout(mode, optionTimeout) {
|
|
44
|
+
const modeSuffix = MODE_ENV_MAP[mode];
|
|
45
|
+
if (modeSuffix !== undefined) {
|
|
46
|
+
const modeEnv = getCompatEnv(modeSuffix);
|
|
47
|
+
if (modeEnv !== undefined) {
|
|
48
|
+
const mins = Number(modeEnv);
|
|
49
|
+
return mins === 0 ? Infinity : mins * 60 * 1000;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const blanket = getCompatEnv('IDLE_TIMEOUT');
|
|
54
|
+
if (blanket !== undefined) {
|
|
55
|
+
const mins = Number(blanket);
|
|
56
|
+
return mins === 0 ? Infinity : mins * 60 * 1000;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (optionTimeout !== undefined) {
|
|
60
|
+
return optionTimeout === 0 ? Infinity : optionTimeout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return MODE_TIMEOUTS[mode] || MODE_TIMEOUTS.headless;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* IdleWatchdog - BUSY/IDLE state machine with configurable idle timeout.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* const wd = new IdleWatchdog({ mode: 'headless', timeout: 60000, onTimeout: () => process.exit(0) });
|
|
71
|
+
* wd.start();
|
|
72
|
+
* wd.markBusy(); // called when a stream starts
|
|
73
|
+
* wd.markIdle(); // called when a stream completes
|
|
74
|
+
* wd.touch(); // called during polling to reset the idle clock
|
|
75
|
+
* wd.cancel(); // stop all timers (e.g. on clean shutdown)
|
|
76
|
+
*/
|
|
77
|
+
class IdleWatchdog {
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} options
|
|
80
|
+
* @param {'headless'|'interactive'|'server'} [options.mode='headless'] - Operating mode
|
|
81
|
+
* @param {number} [options.timeout] - Idle timeout in ms (0 = Infinity/disabled)
|
|
82
|
+
* @param {number} [options.stuckStreamTimeout] - Max ms to remain in BUSY before force-idle
|
|
83
|
+
* @param {Function} [options.onTimeout] - Called when idle timeout fires
|
|
84
|
+
* @param {object} [options.logger] - Logger with .warn() and .info() (defaults to console)
|
|
85
|
+
*/
|
|
86
|
+
constructor(options = {}) {
|
|
87
|
+
const { mode = 'headless', timeout, stuckStreamTimeout, onTimeout, logger: log } = options;
|
|
88
|
+
|
|
89
|
+
/** @type {string} Current operating mode */
|
|
90
|
+
this.mode = mode;
|
|
91
|
+
|
|
92
|
+
/** @type {number} Effective idle timeout in ms */
|
|
93
|
+
this.timeout = resolveTimeout(mode, timeout);
|
|
94
|
+
|
|
95
|
+
/** @type {number} Max ms to stay BUSY before force-transitioning to IDLE */
|
|
96
|
+
this.stuckStreamTimeout = stuckStreamTimeout || 5 * 60 * 1000;
|
|
97
|
+
|
|
98
|
+
/** @type {Function} Callback fired on idle timeout */
|
|
99
|
+
this.onTimeout = onTimeout || (() => {});
|
|
100
|
+
|
|
101
|
+
/** @type {object} Logger instance */
|
|
102
|
+
this.logger = log || console;
|
|
103
|
+
|
|
104
|
+
/** @type {'IDLE'|'BUSY'} Current state */
|
|
105
|
+
this.state = 'IDLE';
|
|
106
|
+
|
|
107
|
+
/** @type {ReturnType<typeof setTimeout>|null} Active idle countdown timer */
|
|
108
|
+
this._timer = null;
|
|
109
|
+
|
|
110
|
+
/** @type {ReturnType<typeof setTimeout>|null} Stuck-stream detection timer */
|
|
111
|
+
this._stuckTimer = null;
|
|
112
|
+
|
|
113
|
+
/** @type {number} Epoch ms when this watchdog was created */
|
|
114
|
+
this._startedAt = Date.now();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Activate the watchdog and begin the idle countdown.
|
|
119
|
+
*
|
|
120
|
+
* @returns {IdleWatchdog} Returns `this` for chaining
|
|
121
|
+
*/
|
|
122
|
+
start() {
|
|
123
|
+
this._resetTimer();
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Transition to BUSY state, suspending the idle timer.
|
|
129
|
+
* Starts the stuck-stream protection timer.
|
|
130
|
+
* Idempotent: calling while already BUSY is a no-op for the state,
|
|
131
|
+
* but does refresh the stuck timer.
|
|
132
|
+
*/
|
|
133
|
+
markBusy() {
|
|
134
|
+
this.state = 'BUSY';
|
|
135
|
+
clearTimeout(this._timer);
|
|
136
|
+
this._timer = null;
|
|
137
|
+
this._startStuckTimer();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Transition to IDLE state and restart the idle countdown.
|
|
142
|
+
* Cancels the stuck-stream protection timer.
|
|
143
|
+
*/
|
|
144
|
+
markIdle() {
|
|
145
|
+
this.state = 'IDLE';
|
|
146
|
+
this._clearStuckTimer();
|
|
147
|
+
this._resetTimer();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Reset the idle countdown without changing state.
|
|
152
|
+
* Has no effect when in BUSY state.
|
|
153
|
+
*/
|
|
154
|
+
touch() {
|
|
155
|
+
if (this.state === 'IDLE') {
|
|
156
|
+
this._resetTimer();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Cancel all active timers. The watchdog becomes inert.
|
|
162
|
+
* Call this on clean shutdown to prevent stray callbacks.
|
|
163
|
+
*/
|
|
164
|
+
cancel() {
|
|
165
|
+
clearTimeout(this._timer);
|
|
166
|
+
this._timer = null;
|
|
167
|
+
this._clearStuckTimer();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Clear and restart the idle countdown timer.
|
|
172
|
+
*
|
|
173
|
+
* @private
|
|
174
|
+
*/
|
|
175
|
+
_resetTimer() {
|
|
176
|
+
clearTimeout(this._timer);
|
|
177
|
+
if (this.timeout === Infinity) {
|
|
178
|
+
this._timer = null;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this._timer = setTimeout(() => {
|
|
182
|
+
this.logger.info?.('Idle timeout reached', {
|
|
183
|
+
mode: this.mode,
|
|
184
|
+
uptimeMs: Date.now() - this._startedAt,
|
|
185
|
+
});
|
|
186
|
+
this.onTimeout();
|
|
187
|
+
}, this.timeout);
|
|
188
|
+
// Allow Node process to exit naturally if only this timer remains.
|
|
189
|
+
if (this._timer.unref) {
|
|
190
|
+
this._timer.unref();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Start the stuck-stream protection timer.
|
|
196
|
+
* If it fires, the watchdog force-transitions to IDLE.
|
|
197
|
+
*
|
|
198
|
+
* @private
|
|
199
|
+
*/
|
|
200
|
+
_startStuckTimer() {
|
|
201
|
+
this._clearStuckTimer();
|
|
202
|
+
if (this.stuckStreamTimeout === Infinity) { return; }
|
|
203
|
+
this._stuckTimer = setTimeout(() => {
|
|
204
|
+
this.logger.warn?.('Stuck stream detected, force-transitioning to IDLE', {
|
|
205
|
+
stuckMs: this.stuckStreamTimeout,
|
|
206
|
+
});
|
|
207
|
+
this.markIdle();
|
|
208
|
+
}, this.stuckStreamTimeout);
|
|
209
|
+
if (this._stuckTimer.unref) {
|
|
210
|
+
this._stuckTimer.unref();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Clear the stuck-stream protection timer.
|
|
216
|
+
*
|
|
217
|
+
* @private
|
|
218
|
+
*/
|
|
219
|
+
_clearStuckTimer() {
|
|
220
|
+
clearTimeout(this._stuckTimer);
|
|
221
|
+
this._stuckTimer = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { IdleWatchdog, resolveTimeout };
|