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/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).'
@@ -40,19 +40,23 @@ function addAlias(name, modelString) {
40
40
  }
41
41
 
42
42
  /**
43
- * Create a new config with all default aliases and the chosen default model
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 created config object
47
+ * @returns {object} The resulting config object
46
48
  */
47
49
  function createDefaultConfig(defaultModel) {
50
+ const existing = loadConfig() || {};
48
51
  const cfg = {
49
- default: defaultModel,
50
- aliases: getDefaultAliases()
52
+ ...existing,
53
+ default: existing.default || defaultModel,
54
+ aliases: { ...getDefaultAliases(), ...(existing.aliases || {}) },
51
55
  };
52
56
  saveConfig(cfg);
53
- logger.info('Default config created', {
54
- default: defaultModel,
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. Default model selection from live quick-picks (read-modify-write, no clobber)
161
- * 3. Config file save
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
  };
@@ -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 };