amicus 1.3.0 → 1.5.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +12 -1
- package/bin/amicus.js +11 -0
- package/electron/fold.js +17 -7
- package/electron/ipc-setup.js +18 -1
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-aliases.js +3 -3
- package/electron/setup-ui-council.js +77 -0
- package/electron/setup-ui-styles.js +203 -143
- package/electron/setup-ui.js +11 -5
- package/electron/toolbar.js +29 -25
- package/package.json +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +6 -0
- package/skills/second-opinion/SKILL.md +17 -1
- package/src/cli-handlers-council.js +11 -4
- package/src/cli-handlers-run.js +24 -2
- package/src/cli.js +2 -0
- package/src/council/report-html.js +39 -9
- package/src/design/fonts/IBMPlexMono-400.ttf +0 -0
- package/src/design/fonts/IBMPlexMono-500.ttf +0 -0
- package/src/design/fonts/IBMPlexMono-600.ttf +0 -0
- package/src/design/fonts/Outfit-300.ttf +0 -0
- package/src/design/fonts/Outfit-400.ttf +0 -0
- package/src/design/fonts/Outfit-500.ttf +0 -0
- package/src/design/fonts/Outfit-600.ttf +0 -0
- package/src/design/fonts/Outfit-700.ttf +0 -0
- package/src/design/fonts/Outfit-800.ttf +0 -0
- package/src/design/tokens.css +134 -0
- package/src/design/tokens.js +51 -0
- package/src/mcp-server.js +38 -5
- package/src/mcp-tools.js +5 -2
- package/src/sidecar/setup.js +129 -9
- package/src/utils/config.js +83 -0
- package/src/utils/free-models.js +50 -0
package/src/mcp-tools.js
CHANGED
|
@@ -255,8 +255,11 @@ function getTools() {
|
|
|
255
255
|
'document (per-leg summaries inside). Each leg is also an ordinary ' +
|
|
256
256
|
'session readable by taskId.',
|
|
257
257
|
inputSchema: {
|
|
258
|
-
models: z.array(safeModel).min(1).max(10).describe(
|
|
259
|
-
`1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed.`
|
|
258
|
+
models: z.array(safeModel).min(1).max(10).optional().describe(
|
|
259
|
+
`1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed. Omit when using 'council'.`
|
|
260
|
+
),
|
|
261
|
+
council: z.string().optional().describe(
|
|
262
|
+
"Run a saved council by name (e.g. 'free') instead of 'models'. Expands to the council's members. Mutually exclusive with 'models'."
|
|
260
263
|
),
|
|
261
264
|
prompt: z.string().describe(
|
|
262
265
|
'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
|
package/src/sidecar/setup.js
CHANGED
|
@@ -40,19 +40,23 @@ function addAlias(name, modelString) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* Ensure a config exists with the chosen default model. Read-modify-write:
|
|
44
|
+
* preserves every pre-existing top-level key (aliases, councils, …) and only
|
|
45
|
+
* fills in the default + any missing default aliases. Never clobbers.
|
|
44
46
|
* @param {string} defaultModel - Default model alias or full model string
|
|
45
|
-
* @returns {object} The
|
|
47
|
+
* @returns {object} The resulting config object
|
|
46
48
|
*/
|
|
47
49
|
function createDefaultConfig(defaultModel) {
|
|
50
|
+
const existing = loadConfig() || {};
|
|
48
51
|
const cfg = {
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
...existing,
|
|
53
|
+
default: existing.default || defaultModel,
|
|
54
|
+
aliases: { ...getDefaultAliases(), ...(existing.aliases || {}) },
|
|
51
55
|
};
|
|
52
56
|
saveConfig(cfg);
|
|
53
|
-
logger.info('Default config
|
|
54
|
-
default:
|
|
55
|
-
aliasCount: Object.keys(cfg.aliases).length
|
|
57
|
+
logger.info('Default config ensured', {
|
|
58
|
+
default: cfg.default,
|
|
59
|
+
aliasCount: Object.keys(cfg.aliases).length,
|
|
56
60
|
});
|
|
57
61
|
return cfg;
|
|
58
62
|
}
|
|
@@ -152,13 +156,70 @@ async function seedCatalog(print) {
|
|
|
152
156
|
log('Model catalog unavailable (offline?) — it will refresh on first start.');
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Free OpenRouter council branch of the readline wizard. Requires
|
|
161
|
+
* OPENROUTER_API_KEY; lists free catalog models, lets the user multi-pick
|
|
162
|
+
* (Enter = the vendor-diverse default), seeds aliases + councils.free, and
|
|
163
|
+
* never touches config.default.
|
|
164
|
+
* @param {readline.Interface} rl
|
|
165
|
+
*/
|
|
166
|
+
async function runFreeCouncilBranch(rl) {
|
|
167
|
+
const keys = detectApiKeys();
|
|
168
|
+
if (!keys.openrouter) {
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log('A free council needs OPENROUTER_API_KEY (free models route only through OpenRouter).');
|
|
171
|
+
console.log('Set OPENROUTER_API_KEY and re-run: amicus setup. No changes made.');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const { getCatalog } = require('../utils/model-catalog');
|
|
175
|
+
const { listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS } = require('../utils/free-models');
|
|
176
|
+
let catalog = [];
|
|
177
|
+
try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
|
|
178
|
+
let free = listFreeModels(catalog);
|
|
179
|
+
if (free.length === 0) {
|
|
180
|
+
console.log('Live free-model list unavailable (offline?) — using a small pinned set.');
|
|
181
|
+
free = PINNED_FREE_MODELS.map(id => ({ id }));
|
|
182
|
+
}
|
|
183
|
+
const defaults = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
|
|
184
|
+
console.log('');
|
|
185
|
+
console.log('Free OpenRouter models (★ = default council):');
|
|
186
|
+
free.forEach((r, i) => {
|
|
187
|
+
const star = defaults.has(r.id) ? '★' : ' ';
|
|
188
|
+
console.log(` ${star} ${i + 1}) ${r.id}`);
|
|
189
|
+
});
|
|
190
|
+
console.log('');
|
|
191
|
+
const answer = await askQuestion(rl,
|
|
192
|
+
'Pick members (comma-separated numbers, or Enter for the ★ default): ');
|
|
193
|
+
let pickIds;
|
|
194
|
+
if (!answer) {
|
|
195
|
+
pickIds = free.filter(r => defaults.has(r.id)).map(r => r.id);
|
|
196
|
+
} else {
|
|
197
|
+
pickIds = answer.split(',').map(s => parseInt(s.trim(), 10))
|
|
198
|
+
.filter(n => n >= 1 && n <= free.length).map(n => free[n - 1].id);
|
|
199
|
+
}
|
|
200
|
+
if (pickIds.length < 2) {
|
|
201
|
+
console.log('A council needs at least 2 models. No changes made.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const { council } = seedFreeCouncil(pickIds);
|
|
205
|
+
await seedCatalog();
|
|
206
|
+
console.log('');
|
|
207
|
+
console.log(`Free council saved: councils.free = [${council.join(', ')}]`);
|
|
208
|
+
console.log('Run it: amicus fanout --council free --prompt "..."');
|
|
209
|
+
console.log('config.default left unchanged.');
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log('Heads up (free tier): rate-limited & quality-variable; some models 404');
|
|
212
|
+
console.log('unless you enable data-sharing at openrouter.ai/settings/privacy.');
|
|
213
|
+
}
|
|
214
|
+
|
|
155
215
|
/**
|
|
156
216
|
* Run the readline-based setup wizard (headless fallback)
|
|
157
217
|
*
|
|
158
218
|
* Guides the user through:
|
|
159
219
|
* 1. API key detection
|
|
160
|
-
* 2.
|
|
161
|
-
* 3.
|
|
220
|
+
* 2. Mode selection (standard or free council)
|
|
221
|
+
* 3. Default model selection from live quick-picks (read-modify-write, no clobber)
|
|
222
|
+
* 4. Config file save
|
|
162
223
|
*/
|
|
163
224
|
async function runReadlineSetup() {
|
|
164
225
|
const rl = readline.createInterface({
|
|
@@ -185,6 +246,13 @@ async function runReadlineSetup() {
|
|
|
185
246
|
}
|
|
186
247
|
console.log('');
|
|
187
248
|
|
|
249
|
+
const mode = await askQuestion(rl,
|
|
250
|
+
'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
|
|
251
|
+
if (mode === '2') {
|
|
252
|
+
await runFreeCouncilBranch(rl);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
188
256
|
const { getCatalog } = require('../utils/model-catalog');
|
|
189
257
|
const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
|
|
190
258
|
let catalog = [];
|
|
@@ -282,12 +350,64 @@ async function runInteractiveSetup() {
|
|
|
282
350
|
|
|
283
351
|
/* eslint-enable no-console */
|
|
284
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Collision-safe alias name from a free model id. Strips the openrouter/
|
|
355
|
+
* prefix and trailing :free, sanitizes '/'/':' to '-', prefixes 'free-',
|
|
356
|
+
* and disambiguates against `taken` with a numeric suffix.
|
|
357
|
+
* @param {string} id e.g. openrouter/deepseek/deepseek-r1:free
|
|
358
|
+
* @param {Set<string>} taken alias names already in use
|
|
359
|
+
* @returns {string} e.g. free-deepseek-deepseek-r1
|
|
360
|
+
*/
|
|
361
|
+
function deriveFreeAlias(id, taken) {
|
|
362
|
+
const base = 'free-' + id
|
|
363
|
+
.replace(/^openrouter\//, '')
|
|
364
|
+
.replace(/:free$/, '')
|
|
365
|
+
.replace(/[/:]/g, '-')
|
|
366
|
+
.replace(/-+/g, '-');
|
|
367
|
+
let name = base;
|
|
368
|
+
let n = 2;
|
|
369
|
+
while (taken.has(name)) { name = `${base}-${n++}`; }
|
|
370
|
+
taken.add(name);
|
|
371
|
+
return name;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Seed free-model aliases + councils.free from chosen catalog ids.
|
|
376
|
+
* Single atomic read-modify-write. Reuses an existing alias that already
|
|
377
|
+
* maps to the same id (idempotent re-runs); never touches config.default.
|
|
378
|
+
* @param {string[]} pickIds full openrouter/.../...:free ids
|
|
379
|
+
* @returns {{added: Array<{alias:string, model:string}>, council: string[]}}
|
|
380
|
+
*/
|
|
381
|
+
function seedFreeCouncil(pickIds) {
|
|
382
|
+
const cfg = loadConfig() || { aliases: {} };
|
|
383
|
+
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
384
|
+
const taken = new Set(Object.keys(cfg.aliases));
|
|
385
|
+
const council = [];
|
|
386
|
+
const added = [];
|
|
387
|
+
for (const id of pickIds) {
|
|
388
|
+
const existing = Object.entries(cfg.aliases).find(([, m]) => m === id);
|
|
389
|
+
if (existing) { if (!council.includes(existing[0])) { council.push(existing[0]); } continue; }
|
|
390
|
+
const alias = deriveFreeAlias(id, taken);
|
|
391
|
+
cfg.aliases[alias] = id;
|
|
392
|
+
added.push({ alias, model: id });
|
|
393
|
+
council.push(alias);
|
|
394
|
+
}
|
|
395
|
+
if (!cfg.councils) { cfg.councils = {}; }
|
|
396
|
+
cfg.councils.free = Array.from(new Set(council));
|
|
397
|
+
saveConfig(cfg);
|
|
398
|
+
logger.info('Free council seeded', { count: cfg.councils.free.length });
|
|
399
|
+
return { added, council: cfg.councils.free };
|
|
400
|
+
}
|
|
401
|
+
|
|
285
402
|
module.exports = {
|
|
286
403
|
addAlias,
|
|
287
404
|
createDefaultConfig,
|
|
405
|
+
deriveFreeAlias,
|
|
288
406
|
detectApiKeys,
|
|
407
|
+
runFreeCouncilBranch,
|
|
289
408
|
runInteractiveSetup,
|
|
290
409
|
runReadlineSetup,
|
|
291
410
|
runApiKeySetup,
|
|
292
411
|
seedCatalog,
|
|
412
|
+
seedFreeCouncil,
|
|
293
413
|
};
|
package/src/utils/config.js
CHANGED
|
@@ -39,6 +39,34 @@ function getConfigDir() {
|
|
|
39
39
|
return amicusDir;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* One-time, non-destructive migration of the legacy ~/.config/sidecar config
|
|
44
|
+
* directory onto the canonical ~/.config/amicus. Copies (does not move), so the
|
|
45
|
+
* legacy dir is left intact as a backup. This collapses the two-dir split that
|
|
46
|
+
* let getConfigDir() flip between them and orphan data: once ~/.config/amicus
|
|
47
|
+
* exists it always wins. No-op when amicus already exists, when there is no
|
|
48
|
+
* legacy dir, or when a CONFIG_DIR override is set. Best-effort — returns a
|
|
49
|
+
* result object and never throws. Call once at startup, before any config read.
|
|
50
|
+
*
|
|
51
|
+
* @param {{home?: string}} [opts]
|
|
52
|
+
* @returns {{migrated: boolean, from?: string, to?: string, reason?: string, error?: string}}
|
|
53
|
+
*/
|
|
54
|
+
function migrateLegacyConfigDir(opts = {}) {
|
|
55
|
+
if (getCompatEnv('CONFIG_DIR')) { return { migrated: false, reason: 'override-set' }; }
|
|
56
|
+
const home = opts.home || process.env.HOME || process.env.USERPROFILE;
|
|
57
|
+
if (!home) { return { migrated: false, reason: 'no-home' }; }
|
|
58
|
+
const amicusDir = path.join(home, '.config', 'amicus');
|
|
59
|
+
const legacyDir = path.join(home, '.config', 'sidecar');
|
|
60
|
+
try {
|
|
61
|
+
if (fs.existsSync(amicusDir)) { return { migrated: false, reason: 'amicus-exists' }; }
|
|
62
|
+
if (!fs.existsSync(legacyDir)) { return { migrated: false, reason: 'no-legacy' }; }
|
|
63
|
+
fs.cpSync(legacyDir, amicusDir, { recursive: true });
|
|
64
|
+
return { migrated: true, from: legacyDir, to: amicusDir };
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { migrated: false, reason: 'error', error: err.message };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
/** @returns {string} Full path to config.json */
|
|
43
71
|
function getConfigPath() {
|
|
44
72
|
return path.join(getConfigDir(), 'config.json');
|
|
@@ -273,8 +301,60 @@ function detectFallback(alias, resolvedModel) {
|
|
|
273
301
|
return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
|
|
274
302
|
}
|
|
275
303
|
|
|
304
|
+
/** @returns {Object<string,string[]>} the councils map (empty if none) */
|
|
305
|
+
function getCouncils() {
|
|
306
|
+
const config = loadConfig();
|
|
307
|
+
return (config && config.councils) || {};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** @param {string} name @returns {string[]|null} council members, or null if absent */
|
|
311
|
+
function getCouncil(name) {
|
|
312
|
+
return getCouncils()[name] || null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Expand a saved council into a runnable members list, degrading gracefully.
|
|
317
|
+
* Each member is resolved to its full model id (alias → id via effective
|
|
318
|
+
* aliases; a member containing '/' is taken as-is) and that id checked against
|
|
319
|
+
* the cached catalog. Unresolvable aliases and delisted ids are dropped with a
|
|
320
|
+
* warning rather than fail-fast-aborting the whole wave. The catalog check is
|
|
321
|
+
* skipped when the catalog is empty (offline). Returns members RAW (alias or
|
|
322
|
+
* id) — leg-time validation resolves them again.
|
|
323
|
+
* @param {string} name
|
|
324
|
+
* @param {Array<{id:string}>} [catalog]
|
|
325
|
+
* @returns {{models:string[], dropped:string[]} | {error:string}}
|
|
326
|
+
*/
|
|
327
|
+
function resolveCouncilMembers(name, catalog = []) {
|
|
328
|
+
const members = getCouncil(name);
|
|
329
|
+
if (!members) {
|
|
330
|
+
return { error: `Unknown council '${name}'. Run 'amicus setup' to create one.` };
|
|
331
|
+
}
|
|
332
|
+
if (!Array.isArray(members) || members.length === 0) {
|
|
333
|
+
return { error: `Council '${name}' is empty. Run 'amicus setup' to populate it.` };
|
|
334
|
+
}
|
|
335
|
+
const aliases = getEffectiveAliases();
|
|
336
|
+
const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
|
|
337
|
+
const models = [];
|
|
338
|
+
const dropped = [];
|
|
339
|
+
for (const member of members) {
|
|
340
|
+
const id = member.includes('/') ? member : aliases[member];
|
|
341
|
+
if (!id) { dropped.push(member); continue; } // alias no longer resolves
|
|
342
|
+
if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
|
|
343
|
+
models.push(member);
|
|
344
|
+
}
|
|
345
|
+
if (models.length < 2) {
|
|
346
|
+
return {
|
|
347
|
+
error: `Council '${name}' has fewer than 2 usable members` +
|
|
348
|
+
(dropped.length ? ` (dropped: ${dropped.join(', ')})` : '') +
|
|
349
|
+
'. Run \'amicus setup\' to refresh it.',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return { models, dropped };
|
|
353
|
+
}
|
|
354
|
+
|
|
276
355
|
module.exports = {
|
|
277
356
|
getConfigDir,
|
|
357
|
+
migrateLegacyConfigDir,
|
|
278
358
|
getConfigPath,
|
|
279
359
|
loadConfig,
|
|
280
360
|
saveConfig,
|
|
@@ -288,4 +368,7 @@ module.exports = {
|
|
|
288
368
|
formatAliasNames,
|
|
289
369
|
tryResolveModel,
|
|
290
370
|
buildProviderModels,
|
|
371
|
+
getCouncils,
|
|
372
|
+
getCouncil,
|
|
373
|
+
resolveCouncilMembers,
|
|
291
374
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free OpenRouter model detection (Unit A).
|
|
3
|
+
*
|
|
4
|
+
* A free model is an openrouter/* catalog id whose slug ends in ':free'.
|
|
5
|
+
* The ':free' suffix is OpenRouter's authoritative free-tier marker. A
|
|
6
|
+
* zero prompt/completion price is deliberately NOT used: the catalog
|
|
7
|
+
* normalizer keeps only {prompt, completion} and discards request/image
|
|
8
|
+
* pricing, so a per-request-charged model with prompt:'0' would be
|
|
9
|
+
* mislabeled. Pure + network-free.
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
/** Offline last-resort free ids (used only when the live catalog is empty). */
|
|
14
|
+
const PINNED_FREE_MODELS = [
|
|
15
|
+
'openrouter/deepseek/deepseek-r1:free',
|
|
16
|
+
'openrouter/google/gemini-2.0-flash-exp:free',
|
|
17
|
+
'openrouter/qwen/qwen3-coder:free',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** @param {{id?:string}} row @returns {boolean} */
|
|
21
|
+
function isFreeModel(row) {
|
|
22
|
+
const id = row && typeof row.id === 'string' ? row.id : '';
|
|
23
|
+
return id.startsWith('openrouter/') && id.endsWith(':free');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {Array} catalog @returns {Array} free rows, sorted by vendor then id */
|
|
27
|
+
function listFreeModels(catalog) {
|
|
28
|
+
const rows = (Array.isArray(catalog) ? catalog : []).filter(isFreeModel);
|
|
29
|
+
return rows.sort((a, b) => {
|
|
30
|
+
const va = a.id.split('/')[1] || '';
|
|
31
|
+
const vb = b.id.split('/')[1] || '';
|
|
32
|
+
return va === vb ? a.id.localeCompare(b.id) : va.localeCompare(vb);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {Array} catalog @param {number} n @returns {Array} ≤n free rows, one per vendor */
|
|
37
|
+
function suggestFreeCouncil(catalog, n = 3) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const seenVendors = new Set();
|
|
40
|
+
for (const row of listFreeModels(catalog)) {
|
|
41
|
+
const vendor = row.id.split('/')[1] || '';
|
|
42
|
+
if (seenVendors.has(vendor)) { continue; }
|
|
43
|
+
seenVendors.add(vendor);
|
|
44
|
+
out.push(row);
|
|
45
|
+
if (out.length >= n) { break; }
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { isFreeModel, listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS };
|