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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": { "name": "Christian Wagner" },
6
6
  "homepage": "https://bourbondog.github.io/amicus/",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.5.0] - 2026-06-29
9
+
10
+ A visual refresh plus a council-reliability fix and a config-dir consolidation.
11
+
12
+ ### Added
13
+ - **Amicus design system**: the Electron app (setup wizard, toolbar, fold overlay), the council
14
+ HTML report, and the marketing site now render from one shared token layer (`src/design/tokens.css`
15
+ + a `src/design/tokens.js` loader) — the clay/gold rail-yard brand on a neutral-black ramp, with
16
+ bundled Outfit + IBM Plex Mono fonts. Previously each surface defined its own colors independently;
17
+ the site is pixel-identical to before, now bound to the shared tokens by a drift-guard test.
18
+
19
+ ### Fixed
20
+ - **Council reliability ledger now persists.** `amicus council tally` (and the MCP
21
+ `amicus_council_tally`) computed the tally record but never wrote it, so `council-ledger.jsonl`
22
+ stayed empty and `amicus council stats` always reported "No council runs recorded yet." The tally
23
+ finalize step now auto-appends the row(s) — best-effort, so a ledger write failure never fails the
24
+ tally — and a new `--no-ledger` flag computes a record without recording it (e.g. a re-tally).
25
+
26
+ ### Changed
27
+ - **Unified config directory.** On startup Amicus now migrates a legacy `~/.config/sidecar` directory
28
+ onto the canonical `~/.config/amicus` once, non-destructively (copy; the legacy dir is kept as a
29
+ backup). This collapses the two-directory split that could let config resolution flip between them
30
+ and orphan your config, catalog, and ledger. A `CONFIG_DIR` override opts out.
31
+
32
+ ## [1.4.0] - 2026-06-28
33
+
34
+ ### Added
35
+ - **Free OpenRouter council**: a new `amicus setup` option (readline wizard + Electron Models step)
36
+ that stands up a zero-cost council of free `:free` OpenRouter models, saved as a first-class
37
+ `councils` config primitive. Run it with `amicus fanout --council free` or the `amicus_fanout` MCP
38
+ `council` param; the second-opinion skill reads `councils.free`. Free-model picks are detected
39
+ live from the catalog (the `:free` suffix is authoritative), seeded under collision-safe `free-*`
40
+ aliases, and a delisted member degrades gracefully (dropped with a warning) instead of failing the
41
+ wave. Needs only an `OPENROUTER_API_KEY`; the wizard discloses the free-tier caveats (rate limits,
42
+ variable quality, the OpenRouter data-sharing prerequisite). `config.default` is left untouched.
43
+
8
44
  ## [1.3.0] - 2026-06-24
9
45
 
10
46
  Making the mature council/fan-out engine legible: live per-leg progress, cost
package/README.md CHANGED
@@ -152,6 +152,16 @@ Then the council waits for your confirmation.
152
152
 
153
153
  The skill lives at **[`skills/second-opinion/SKILL.md`](./skills/second-opinion/SKILL.md)**; the design spec behind it is **[`skills/second-opinion/COUNCIL-DESIGN.md`](./skills/second-opinion/COUNCIL-DESIGN.md)**.
154
154
 
155
+ **Free council (zero-cost).** Want the cross-examination without the model spend? `amicus setup` offers a **Free OpenRouter council** mode — readline wizard option 2, and the Electron **Models** step. It detects the free `:free` models live from the catalog, lets you multi-pick (Enter takes a vendor-diverse default), and saves them as `councils.free` — a first-class `councils` config primitive seeded under collision-safe `free-*` aliases. Your `config.default` is left untouched, and all you need is an `OPENROUTER_API_KEY`.
156
+
157
+ Run it anywhere a council runs:
158
+
159
+ ```bash
160
+ amicus fanout --council free --prompt "Review this design"
161
+ ```
162
+
163
+ The `amicus_fanout` MCP tool takes the same `council` parameter, and the `second-opinion` skill reads `councils.free` automatically. A member that gets delisted is dropped with a warning — the council still runs as long as ≥2 survive. Free models are **rate-limited and quality-variable**, and some return 404 unless you enable data-sharing at [openrouter.ai/settings/privacy](https://openrouter.ai/settings/privacy).
164
+
155
165
  ---
156
166
 
157
167
  ## The parallel window
@@ -246,7 +256,8 @@ amicus fanout --models gemini,deepseek,gpt --prompt "Review this design" --json
246
256
 
247
257
  Fanout runs one **headless wave**: every leg gets the **same** prompt (this is the shared-prompt model the council's review stages are built on). When all legs are terminal it prints **one** JSON wave document on stdout.
248
258
 
249
- - `--models <a,b,c>` — comma-separated aliases or `provider/model` IDs (required).
259
+ - `--models <a,b,c>` — comma-separated aliases or `provider/model` IDs (required, unless `--council`).
260
+ - `--council <name>` — run a saved council (e.g. `free`) instead of `--models`; mutually exclusive with `--models`.
250
261
  - `--prompt <text>` / `--prompt-file <path>` — the shared briefing. `--prompt-file` avoids the ~32 KB Windows argument cap and is mutually exclusive with `--prompt`.
251
262
  - `--wave-id <id>` — set the wave ID explicitly (leg IDs become `<id>-1..N`).
252
263
  - `--json` — emit the wave document.
package/bin/amicus.js CHANGED
@@ -7,6 +7,17 @@
7
7
  * Routes commands to appropriate handlers.
8
8
  */
9
9
 
10
+ // One-time, non-destructive migration of the legacy ~/.config/sidecar dir onto
11
+ // the canonical ~/.config/amicus (copy; legacy kept as a backup). Runs before
12
+ // any config/credential read so both resolve to the unified dir. Best-effort.
13
+ try {
14
+ const { migrateLegacyConfigDir } = require('../src/utils/config');
15
+ const _m = migrateLegacyConfigDir();
16
+ if (_m && _m.migrated) {
17
+ process.stderr.write(`[amicus] Migrated config ${_m.from} → ${_m.to} (legacy kept as a backup).\n`);
18
+ }
19
+ } catch { /* best-effort: never block startup on migration */ }
20
+
10
21
  // Load API keys from all sources: process.env > sidecar .env > auth.json
11
22
  const { loadCredentials } = require('../src/utils/env-loader');
12
23
  loadCredentials();
package/electron/fold.js CHANGED
@@ -8,6 +8,7 @@
8
8
  const { logger } = require('../src/utils/logger');
9
9
  const { requestSummaryFromModel } = require('./summary');
10
10
  const { getSummaryTemplate } = require('../src/prompt-builder');
11
+ const { tokenCss } = require('../src/design/tokens');
11
12
 
12
13
  /**
13
14
  * Create a fold handler bound to the window state
@@ -59,11 +60,11 @@ function createFoldHandler(state) {
59
60
  if (contentView) {
60
61
  await contentView.webContents.executeJavaScript(`
61
62
  (function() {
62
- var overlay = document.getElementById('sidecar-fold-overlay');
63
+ var overlay = document.getElementById('amicus-fold-overlay');
63
64
  if (overlay) {
64
65
  while (overlay.firstChild) { overlay.removeChild(overlay.firstChild); }
65
66
  var msg = document.createElement('div');
66
- msg.style.cssText = 'color:#E8E0D8;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:15px;font-weight:500;text-align:center;max-width:320px;';
67
+ msg.style.cssText = 'color:var(--text-1);font-family:var(--font-sans),-apple-system,BlinkMacSystemFont,sans-serif;font-size:15px;font-weight:500;text-align:center;max-width:320px;';
67
68
  msg.textContent = 'Summary saved. Tell Claude you\\u2019re done with the Amicus session so it can read the results.';
68
69
  overlay.appendChild(msg);
69
70
  }
@@ -110,7 +111,7 @@ function showFoldOverlay(mainWindow, contentView) {
110
111
  var span = document.createElement('span');
111
112
  span.style.cssText = 'display:inline-flex;align-items:center;gap:6px;';
112
113
  var spinner = document.createElement('span');
113
- spinner.style.cssText = 'width:12px;height:12px;border:2px solid rgba(255,255,255,0.3);border-top-color:#fff;border-radius:50%;animation:spin 0.8s linear infinite;display:inline-block;';
114
+ spinner.style.cssText = 'width:12px;height:12px;border:2px solid rgba(255,255,255,0.3);border-top-color:var(--on-accent);border-radius:50%;animation:spin 0.8s linear infinite;display:inline-block;';
114
115
  span.appendChild(spinner);
115
116
  span.appendChild(document.createTextNode('Generating summary\\u2026'));
116
117
  btn.appendChild(span);
@@ -128,6 +129,15 @@ function showFoldOverlay(mainWindow, contentView) {
128
129
  `).catch(() => {});
129
130
  }
130
131
  if (contentView) {
132
+ // Scope token vars to the overlay container so var(--x) resolves without
133
+ // touching OpenCode's own :root (which would clobber its CSS variables).
134
+ const rawCss = tokenCss({ absoluteFontUrls: true });
135
+ // Replace only the :root selector (not occurrences inside comments) so the
136
+ // custom properties are defined on #amicus-fold-overlay and inherited by
137
+ // its descendants. @font-face blocks are left at global scope (no selector).
138
+ const scopedCss = rawCss.replace(/:root\s*\{/, '#amicus-fold-overlay {');
139
+ contentView.webContents.insertCSS(scopedCss).catch(() => {});
140
+
131
141
  contentView.webContents.executeJavaScript(`
132
142
  (function() {
133
143
  if (!document.getElementById('fold-spin-style')) {
@@ -137,20 +147,20 @@ function showFoldOverlay(mainWindow, contentView) {
137
147
  document.head.appendChild(style);
138
148
  }
139
149
  var overlay = document.createElement('div');
140
- overlay.id = 'sidecar-fold-overlay';
150
+ overlay.id = 'amicus-fold-overlay';
141
151
  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:99999;';
142
152
 
143
153
  var spinDiv = document.createElement('div');
144
- spinDiv.style.cssText = 'width:32px;height:32px;border:3px solid rgba(217,119,87,0.3);border-top-color:#D97757;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:16px;';
154
+ spinDiv.style.cssText = 'width:32px;height:32px;border:3px solid var(--accent-line);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:16px;';
145
155
  overlay.appendChild(spinDiv);
146
156
 
147
157
  var titleDiv = document.createElement('div');
148
- titleDiv.style.cssText = 'color:#E8E0D8;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:15px;font-weight:500;';
158
+ titleDiv.style.cssText = 'color:var(--text-1);font-family:var(--font-sans),-apple-system,BlinkMacSystemFont,sans-serif;font-size:15px;font-weight:500;';
149
159
  titleDiv.textContent = 'Generating summary\\u2026';
150
160
  overlay.appendChild(titleDiv);
151
161
 
152
162
  var subtitleDiv = document.createElement('div');
153
- subtitleDiv.style.cssText = 'color:#7A756F;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:12px;margin-top:6px;';
163
+ subtitleDiv.style.cssText = 'color:var(--text-3);font-family:var(--font-sans),-apple-system,BlinkMacSystemFont,sans-serif;font-size:12px;margin-top:6px;';
154
164
  subtitleDiv.textContent = 'Folding session back to Claude Code';
155
165
  overlay.appendChild(subtitleDiv);
156
166
 
@@ -97,7 +97,8 @@ function registerSetupHandlers(getMainWindow) {
97
97
 
98
98
  // Read-modify-write: never rewrite an alias the renderer didn't send.
99
99
  // aliasWrites values: string = set, null = delete. First run seeds live.
100
- ipcMain.handle('sidecar:save-config', async (_event, defaultModel, aliasWrites) => {
100
+ // councilPicks (optional): when length >= 2, seeds the free council via seedFreeCouncil.
101
+ ipcMain.handle('sidecar:save-config', async (_event, defaultModel, aliasWrites, councilPicks) => {
101
102
  try {
102
103
  const { loadConfig, saveConfig } = require('../src/utils/config');
103
104
  let cfg = loadConfig();
@@ -119,6 +120,9 @@ function registerSetupHandlers(getMainWindow) {
119
120
  }
120
121
  }
121
122
  saveConfig(cfg);
123
+ if (Array.isArray(councilPicks) && councilPicks.length >= 2) {
124
+ require('../src/sidecar/setup').seedFreeCouncil(councilPicks);
125
+ }
122
126
  return { success: true };
123
127
  } catch (err) {
124
128
  logger.error('save-config handler error', { error: err.message });
@@ -192,6 +196,19 @@ function registerSetupHandlers(getMainWindow) {
192
196
  return { models: [], fetchedAt: null };
193
197
  }
194
198
  });
199
+
200
+ // Free OpenRouter council: returns all free models from the catalog,
201
+ // marking those in the vendor-diverse suggested set with suggested:true.
202
+ ipcMain.handle('sidecar:fetch-free-models', async () => {
203
+ try {
204
+ const { getCatalog } = require('../src/utils/model-catalog');
205
+ const { listFreeModels, suggestFreeCouncil } = require('../src/utils/free-models');
206
+ const catalog = await getCatalog();
207
+ const free = listFreeModels(catalog);
208
+ const suggested = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
209
+ return free.map(r => ({ id: r.id, suggested: suggested.has(r.id) }));
210
+ } catch (_err) { return []; }
211
+ });
195
212
  }
196
213
 
197
214
  module.exports = { registerSetupHandlers };
@@ -22,7 +22,8 @@ contextBridge.exposeInMainWorld('sidecarSetup', {
22
22
  'sidecar:get-api-keys',
23
23
  'sidecar:fetch-models',
24
24
  'sidecar:get-catalog',
25
- 'sidecar:refresh-catalog'
25
+ 'sidecar:refresh-catalog',
26
+ 'sidecar:fetch-free-models'
26
27
  ];
27
28
  if (!allowedChannels.includes(channel)) {
28
29
  throw new Error(`IPC channel not allowed: ${channel}`);
@@ -51,9 +51,9 @@ function buildAliasEditorHTML(aliases) {
51
51
  const exampleModel = aliases[exampleAlias] || 'openrouter/google/gemini-3.1-flash-lite-preview';
52
52
 
53
53
  // SVG icons for the example box
54
- const terminalIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke="#D97757" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke="#D97757" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" stroke="#5A5550" stroke-width="1.5" stroke-linecap="round"/></svg>`;
55
- const arrowIcon = `<svg width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke="#D97757" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke="#D97757" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
56
- const modelIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke="#6BBF6B" stroke-width="1.5"/><path d="M8 8v3" stroke="#6BBF6B" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke="#6BBF6B" stroke-width="1" stroke-linecap="round"/></svg>`;
54
+ const terminalIcon = `<svg class="alias-icon-accent" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" class="alias-icon-faint-path" stroke-width="1.5" stroke-linecap="round"/></svg>`;
55
+ const arrowIcon = `<svg class="alias-icon-accent" width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
56
+ const modelIcon = `<svg class="alias-icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke-width="1.5"/><path d="M8 8v3" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke-width="1" stroke-linecap="round"/></svg>`;
57
57
 
58
58
  const exampleBox = `<div class="routing-example">
59
59
  <div class="example-label">How it works</div>
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Setup UI — Free OpenRouter council picker (mounted on the Models step).
3
+ * Collapsible section: a checkbox list of free models fetched via IPC. Gated
4
+ * on the OpenRouter key (recomputed on Step-2 entry by the orchestrator).
5
+ * window.collectCouncilPicks() returns the checked ids for the save payload.
6
+ */
7
+ 'use strict';
8
+
9
+ function buildCouncilSectionHTML() {
10
+ return `<div id="free-council-section" class="council-section">
11
+ <label class="council-toggle"><input type="checkbox" id="free-council-toggle">
12
+ <span>Set up a free OpenRouter council (zero-cost)</span></label>
13
+ <div id="free-council-body" style="display:none">
14
+ <div id="free-council-meta" class="search-meta"></div>
15
+ <div id="free-council-results" class="council-results"></div>
16
+ <div class="council-note">Free tier: rate-limited &amp; quality-variable; some models need
17
+ data-sharing enabled at openrouter.ai/settings/privacy.</div>
18
+ </div>
19
+ </div>`;
20
+ }
21
+
22
+ function buildCouncilScript() {
23
+ return `
24
+ (function() {
25
+ var toggle = document.getElementById('free-council-toggle');
26
+ var body = document.getElementById('free-council-body');
27
+ var results = document.getElementById('free-council-results');
28
+ var meta = document.getElementById('free-council-meta');
29
+ var loaded = false;
30
+
31
+ function hasOpenRouterKey() { return !!(window.configuredKeys && window.configuredKeys.openrouter); }
32
+
33
+ window.refreshCouncilGating = function() {
34
+ if (!toggle) { return; }
35
+ var ok = hasOpenRouterKey();
36
+ toggle.disabled = !ok;
37
+ if (meta && !ok) { meta.textContent = 'Add an OpenRouter API key (step 1) to enable a free council.'; }
38
+ else if (meta && !loaded) { meta.textContent = ''; }
39
+ };
40
+
41
+ async function loadFree() {
42
+ if (loaded) { return; }
43
+ try {
44
+ var rows = await window.sidecarSetup.invoke('sidecar:fetch-free-models');
45
+ loaded = true;
46
+ results.innerHTML = '';
47
+ (rows || []).forEach(function(r, i) {
48
+ var id = 'fc-' + i;
49
+ var row = document.createElement('label');
50
+ row.className = 'council-row';
51
+ var cb = document.createElement('input');
52
+ cb.type = 'checkbox'; cb.value = r.id; cb.id = id; cb.checked = !!r.suggested;
53
+ var span = document.createElement('span'); span.textContent = r.id;
54
+ row.appendChild(cb); row.appendChild(span);
55
+ results.appendChild(row);
56
+ });
57
+ if (meta) { meta.textContent = (rows || []).length + ' free models'; }
58
+ } catch (_e) { if (meta) { meta.textContent = 'Could not load free models.'; } }
59
+ }
60
+
61
+ if (toggle) {
62
+ toggle.addEventListener('change', function() {
63
+ body.style.display = toggle.checked ? '' : 'none';
64
+ if (toggle.checked) { loadFree(); }
65
+ });
66
+ }
67
+
68
+ window.collectCouncilPicks = function() {
69
+ if (!toggle || !toggle.checked) { return []; }
70
+ return Array.prototype.slice.call(results.querySelectorAll('input[type=checkbox]:checked'))
71
+ .map(function(cb) { return cb.value; });
72
+ };
73
+ })();
74
+ `;
75
+ }
76
+
77
+ module.exports = { buildCouncilSectionHTML, buildCouncilScript };