amicus 4.8.0 → 4.8.1
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 +106 -0
- package/README.md +3 -3
- package/docs/CITATIONS.md +13 -5
- package/docs/configuration.md +1 -1
- package/docs/usage.md +1 -1
- package/electron/ipc-setup.js +18 -2
- package/electron/main.js +46 -3
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +231 -29
- package/electron/workspace-ui/live-seats.js +4 -4
- package/package.json +2 -1
- package/src/cli-handlers-doctor.js +11 -14
- package/src/council/parse-stage2.js +1 -1
- package/src/sidecar/setup.js +124 -0
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/curated-models.js +5 -5
- package/src/utils/doctor-alias-check.js +152 -0
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-shortlist.js +100 -0
- package/src/utils/provider-default-picker.js +93 -45
- package/src/utils/provider-default-prompt.js +1 -1
- package/src/utils/quick-picks.js +2 -2
- package/src/utils/remediation-hints.js +24 -0
- package/src/workspace/run-detail.js +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.1",
|
|
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": {
|
|
6
6
|
"name": "Christian Wagner"
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,112 @@
|
|
|
3
3
|
All notable changes to Amicus are documented here. Format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
|
|
5
5
|
|
|
6
|
+
## [4.8.1] - 2026-08-25
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **Setup Step 2 offered one card per curated model *family*, with no way to choose which model
|
|
11
|
+
within it — issue #138.** `pickCurrent` kept exactly one winner per namespace, so a family with
|
|
12
|
+
several live models collapsed to a single card, and the route pill it wrote stored a **provider**
|
|
13
|
+
id, never a **model** id, so nothing downstream could tell them apart either. The family regex
|
|
14
|
+
made this worse for one vendor outright: `/^deepseek-v[\d.]+(-pro)?$/` only matches a
|
|
15
|
+
version-numbered `-v` name with an optional `-pro` suffix, so `deepseek-r1`, `deepseek-chat`, and
|
|
16
|
+
`deepseek-flash` never had a route to *any* card — structurally excluded, not just hidden behind
|
|
17
|
+
the flagship. Both wizard surfaces — the Electron Settings window and the readline setup flow —
|
|
18
|
+
now offer a vendor-scoped drill-down after the family pick, built on the existing priced picker
|
|
19
|
+
via a new pure module, `model-shortlist.js`, which splits a vendor's rows into suggested/rest so
|
|
20
|
+
the UI can show a handful of recommended models plus an "all" escape hatch. Persistence is
|
|
21
|
+
unchanged: `config.aliases[<family>]` still holds a route-encoded id and `config.default` still
|
|
22
|
+
holds the alias *name* — only the picker got smarter, not the storage shape. Two review rounds on
|
|
23
|
+
this branch closed five further blockers before it shipped: a silent setup-fallback path, a
|
|
24
|
+
misleading model-count label, a false alias-deletion report, a same-run double-ask of the same
|
|
25
|
+
vendor's drill-down, and an unescaped catalog id.
|
|
26
|
+
|
|
27
|
+
- **`chooseRowId` fabricated bare `<vendor>/<model>` ids for catalog rows that exist only on
|
|
28
|
+
OpenRouter — issue #195.** Once the user held that vendor's own API key, the fabricated id routed
|
|
29
|
+
`direct`-first and `catalogGate` rejected it as `model_not_found`. Measured against the live
|
|
30
|
+
catalog: 19 of 69 `google` rows and 51 of 175 `openai` rows. The picker unconditionally stripped
|
|
31
|
+
the `openrouter/` prefix off any non-`DIVERGENT_VENDORS` OR-only row, regardless of whether the
|
|
32
|
+
direct namespace actually carried that model. It now reuses `classifyModel` — never a
|
|
33
|
+
reimplementation of its rule — and synthesizes the bare form only when that id would *not*
|
|
34
|
+
classify `invalid` on `direct`; `deepseek`'s empty-namespace rows (14/14) still synthesize bare,
|
|
35
|
+
unchanged. The identical unconditional strip existed in two more places that would otherwise have
|
|
36
|
+
silently undone the fix at write time — `canonicalizeResolved` (preselection matching) and
|
|
37
|
+
`applyProviderDefault` (the actual persist-to-config path, reached from both the Electron IPC
|
|
38
|
+
handler and the readline flow) — both now carry the same guard. Persistence was hardened further
|
|
39
|
+
in review: a failed or empty catalog fetch at persist time used to degrade straight back to the
|
|
40
|
+
old unconditional strip (confirmed against the real 601-row catalog), so `applyProviderDefault`
|
|
41
|
+
now requires *positive* catalog evidence (`classifyModel` returning `valid`, not merely
|
|
42
|
+
non-`invalid`) before it will strip a prefix — an empty catalog is inert, never a silent fallback.
|
|
43
|
+
⚠️ **`doctor --fix` can now rewrite your config, not just diagnose it.** A new narrow repair,
|
|
44
|
+
`findFabricatedAliasRepairs`, finds aliases already persisted by v4.8.0 as a fabricated bare id —
|
|
45
|
+
one that classifies `invalid` on `direct` and has an unambiguous OpenRouter twin via
|
|
46
|
+
`pairAcrossGateways` — and rewrites them to the id the fixed picker would offer today. It is
|
|
47
|
+
gated on a **fresh** catalog fetch (declines with an explanatory hint if the same doctor run's own
|
|
48
|
+
`catalog` check reports the cache as stale, rather than repairing from data it has already told
|
|
49
|
+
you not to trust), idempotent, converts only to the one id `pairAcrossGateways` names, and every
|
|
50
|
+
repair is announced through the existing doctor-fix `heal` degrade channel, naming the alias and
|
|
51
|
+
both the old and new id. Outside that one class — a typo, a retired model, a user-invented id, or
|
|
52
|
+
a `DIVERGENT_VENDORS` alias — `doctor --fix` leaves the config alone exactly as before.
|
|
53
|
+
|
|
54
|
+
### CI
|
|
55
|
+
|
|
56
|
+
- **Council review on this repo ran with whatever alias each seat's name happened to resolve to on
|
|
57
|
+
a bare runner, not the intended bench — #193.** A CI runner has no user config, so
|
|
58
|
+
`getEffectiveAliases()` returned exactly `DEFAULT_ALIASES` and every seat bound to the shipped
|
|
59
|
+
`curated-models.js` pin: `glm` reviewed at 5.1 while 5.2 and 5.3 had already shipped, and an alias
|
|
60
|
+
missing from that table was silently dropped by `classifyCouncilMembers` with only a `run.json`
|
|
61
|
+
note to show for it. It was invisible by construction — `run.json`, `events.jsonl`, and
|
|
62
|
+
`verdict.json` all record alias *names*, so they read identically whichever model actually
|
|
63
|
+
answered; `spend-ledger.jsonl`, written to `getConfigDir()`, is the only artifact that carries a
|
|
64
|
+
resolved id. `council-review.yml` now provisions `.github/amicus-ci-aliases.json` from the
|
|
65
|
+
**base** ref (a PR can never edit the map used to review it), validates it by shape, pre-flights
|
|
66
|
+
every seat before any spend, and uploads the ledger as the receipt; every fetch failure but a 404
|
|
67
|
+
now fails closed instead of silently swapping the bench. A companion job, `alias-pin-drift.yml`,
|
|
68
|
+
compares each curated pin against its newest same-vendor sibling on the same tier/variant and
|
|
69
|
+
opens a weekly bump issue — `models --check` alone is structurally blind to this, since it
|
|
70
|
+
validates a curated *family* against its own `idPattern`, not a flat cardless pin like
|
|
71
|
+
`glm`/`qwen`/`kimi`.
|
|
72
|
+
- **The council briefing now includes the `env:` blocks a diff can't show — #194.** PR #193 was
|
|
73
|
+
reviewed twice, and both benches unanimously raised the same blocker: `$GH_REPO`, `$MODELS`, and
|
|
74
|
+
`$CHAIR` looked undefined in the `run:` step under review. They were defined — in a workflow- or
|
|
75
|
+
job-level `env:` block the PR never touched, so it never appeared in the diff. Four seats agreeing
|
|
76
|
+
was one shared blind spot, not four independent findings. The briefing now appends the workflow-
|
|
77
|
+
and job-level `env:` blocks of every changed workflow (fetched from the base ref, exactly like the
|
|
78
|
+
alias map above), labelled explicitly as context and not part of the diff; step-level `env:` is
|
|
79
|
+
excluded because it already travels with its own hunk. A literal value under a
|
|
80
|
+
`TOKEN`/`SECRET`/`PASSWORD`/`KEY`-shaped name is withheld from the briefing — a pure `${{ }}`
|
|
81
|
+
reference is kept, since it names a secret rather than exposing one.
|
|
82
|
+
- **The council-review check is no longer hardcoded green — #197.** Its `none`-policy branch set
|
|
83
|
+
`CONCLUSION="success"` before the chair's verdict was even read, so three distinct outcomes — a
|
|
84
|
+
clean review, a review with real findings, and a review that never ran at all — rendered as the
|
|
85
|
+
identical green check. `fail_on` now defaults to `rethink`: the check fails only when the chair
|
|
86
|
+
returns "Fundamental rethink," and passes on both "Fix these first" and "Ship it" (a null or
|
|
87
|
+
absent chair verdict still maps to neutral, never failure). A stricter `fail_on: fix` default was
|
|
88
|
+
tried first and reverted within hours: three live runs against PR #196 each came back "Fix these
|
|
89
|
+
first," but of the confirmed findings only one was a real, reachable defect — several were
|
|
90
|
+
accurate statements about states the system cannot produce, and two rested on premises that were
|
|
91
|
+
factually wrong. Gating on that verdict would have blocked more good work than bad. `fail_on` is
|
|
92
|
+
spelled twice in `council-review.yml` — the `workflow_call` input default, and the `pull_request`
|
|
93
|
+
path's `||` fallback, which is the load-bearing one for every label-triggered review on this repo
|
|
94
|
+
— and both spellings moved together, with a drift test re-verified against the historical
|
|
95
|
+
one-site-only mutant at the new value. `none` (report-only) and `fix` (the stricter gate) both
|
|
96
|
+
remain available for a caller that wants them.
|
|
97
|
+
|
|
98
|
+
### Internal
|
|
99
|
+
|
|
100
|
+
- **Burned down the citation allowlist and re-anchored the citations the gate cannot see —
|
|
101
|
+
citation-burndown, #192.** Twelve stale citations corrected, `CONFIG.grandfathered` emptied to
|
|
102
|
+
zero. Five of the eight previously-allowlisted entries were born stale — wrong in the commit that
|
|
103
|
+
introduced them, not rotted afterward; the no-output-backstop comment among them was a truth
|
|
104
|
+
failure rather than a numbering one, since the guard it warned was missing had been added by the
|
|
105
|
+
very commit that wrote the warning, so it is now reframed as closed history instead of
|
|
106
|
+
renumbered. Separately, several source comments cited a bare `(:129)`/`(:293)` line number with no
|
|
107
|
+
`.js` immediately before the colon — a shape `check-citations.js` cannot parse and so silently
|
|
108
|
+
never checks — and those are now `file.js :: symbol` anchors instead, both correct today and
|
|
109
|
+
visible to the gate from now on; one of them (`run.js:293`) had already rotted to the wrong line
|
|
110
|
+
and is corrected to `run.js:279` in the same pass.
|
|
111
|
+
|
|
6
112
|
## [4.8.0] - 2026-08-23
|
|
7
113
|
|
|
8
114
|
### Fixed
|
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ The council is the hero — start with the everyday way, and reach for the more
|
|
|
137
137
|
|
|
138
138
|
### Headless council (CI)
|
|
139
139
|
|
|
140
|
-
The same pipeline runs with no Claude runtime at all: `amicus council run --prompt-file briefing.md --models gemini,glm --chair deepseek --json` executes the review waves, the anonymized cross-review, the tally, and the chair verdict in one command, and writes the full run directory (`verdict.json` with the chair's parsed `overallVerdict`, `report.html`, every review and judge output). That is what powers the repo's own **Council Review GitHub Action v2** — on PRs labeled `council-review` it posts an adjudicated verdict as a check run plus a sticky comment, uploads the run directory as an evidence artifact, and
|
|
140
|
+
The same pipeline runs with no Claude runtime at all: `amicus council run --prompt-file briefing.md --models gemini,glm --chair deepseek --json` executes the review waves, the anonymized cross-review, the tally, and the chair verdict in one command, and writes the full run directory (`verdict.json` with the chair's parsed `overallVerdict`, `report.html`, every review and judge output). That is what powers the repo's own **Council Review GitHub Action v2** — on PRs labeled `council-review` it posts an adjudicated verdict as a check run plus a sticky comment, uploads the run directory as an evidence artifact, and gates merges by default via its `fail_on` input (fails only on a `Fundamental rethink` verdict; pass `fail_on: fix` to require `Ship it`, or `fail_on: none` for report-only). Reference: [docs/council.md](./docs/council.md#amicus-council-run).
|
|
141
141
|
|
|
142
142
|
<p align="center"><img src="./docs/cards/ship-gate.svg" alt="A council gating a release pipeline: exit 0 ships it, exit 1 sends it back"></p>
|
|
143
143
|
|
|
@@ -275,7 +275,7 @@ This opens a graphical wizard:
|
|
|
275
275
|
| Step | What it does |
|
|
276
276
|
|------|--------------|
|
|
277
277
|
| **1. API Keys** | Enter keys for OpenRouter, Google, OpenAI, Anthropic, and/or DeepSeek. Each is validated live against the provider's API. Written to `~/.config/amicus/.env` with `0600` permissions. |
|
|
278
|
-
| **2. Default Model** | Pick your go-to model from a searchable live picker (backed by the catalog). Used whenever you omit `--model`. |
|
|
278
|
+
| **2. Default Model** | Pick your go-to model from a searchable live picker (backed by the catalog) — each provider card also lets you drill down to a specific model instead of just the family default. Used whenever you omit `--model`. |
|
|
279
279
|
| **3. Model Routing** | Decide which provider serves each model — e.g. route Gemini through a direct Google key and everything else through OpenRouter. |
|
|
280
280
|
| **4. Review** | Confirm the configuration before saving. |
|
|
281
281
|
|
|
@@ -450,7 +450,7 @@ $ amicus status demo123 --json
|
|
|
450
450
|
"taskId": "demo123",
|
|
451
451
|
"status": "complete",
|
|
452
452
|
"elapsed": "5m 0s",
|
|
453
|
-
"version": "4.8.
|
|
453
|
+
"version": "4.8.1",
|
|
454
454
|
"model": "google/gemini-2.5-flash",
|
|
455
455
|
"phase": "terminal"
|
|
456
456
|
}
|
package/docs/CITATIONS.md
CHANGED
|
@@ -107,8 +107,16 @@ form; do not silently renumber it.
|
|
|
107
107
|
|
|
108
108
|
## The burn-down list
|
|
109
109
|
|
|
110
|
-
`CONFIG.grandfathered` in `scripts/check-citations.js`
|
|
111
|
-
already stale when
|
|
112
|
-
of shipping advisory.
|
|
113
|
-
citation, then **delete the entry** — a test asserts every entry still names
|
|
114
|
-
real citation, so the list cannot quietly accumulate dead weight.
|
|
110
|
+
`CONFIG.grandfathered` in `scripts/check-citations.js` is the escape hatch for
|
|
111
|
+
citations already stale when a gate change lands, so the gate can block from day
|
|
112
|
+
one instead of shipping advisory. **It is empty, and should stay that way.** Fix
|
|
113
|
+
the citation, then **delete the entry** — a test asserts every entry still names
|
|
114
|
+
a real citation, so the list cannot quietly accumulate dead weight.
|
|
115
|
+
|
|
116
|
+
The original eight were burned down in one pass. **Four were born stale** — wrong
|
|
117
|
+
in the commit that wrote them, not rotted by later drift: three reused a line
|
|
118
|
+
number from a split that had already landed (one from a split made in that very
|
|
119
|
+
commit), and one wrote a symbol name with `.js` appended as if it were a path.
|
|
120
|
+
The other four were ordinary rot from the PR0/PR5c splits. A gate that catches
|
|
121
|
+
only decay would never have caught the first four — which is the case for
|
|
122
|
+
`file.js :: symbol` being the default form.
|
package/docs/configuration.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Configuration Reference
|
|
2
2
|
|
|
3
|
-
`amicus setup` is the recommended way to configure Amicus. It opens a graphical wizard that validates your API keys live, lets you pick a default model from the live catalog, and saves everything to `~/.config/amicus/.env` (permissions `0600`). The environment variables below are for overrides and advanced tuning — most users only need the API keys section.
|
|
3
|
+
`amicus setup` is the recommended way to configure Amicus. It opens a graphical wizard that validates your API keys live, lets you pick a default model from the live catalog — down to a specific model per provider, not just the family default — and saves everything to `~/.config/amicus/.env` (permissions `0600`). The environment variables below are for overrides and advanced tuning — most users only need the API keys section.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
package/docs/usage.md
CHANGED
package/electron/ipc-setup.js
CHANGED
|
@@ -80,10 +80,26 @@ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ip
|
|
|
80
80
|
// Task 8: apply a per-provider default picker choice. Read-modify-write,
|
|
81
81
|
// no-clobber -- applyProviderDefault only ever writes aliases[vendor] and
|
|
82
82
|
// seeds config.default when absent (see provider-default-picker.js).
|
|
83
|
-
|
|
83
|
+
// Fetches the catalog before applying (issue 195): applyProviderDefault
|
|
84
|
+
// uses directFormIfProven (model-canonicalization.js) to decide whether to
|
|
85
|
+
// strip an OpenRouter prefix off chosenId, and needs the catalog to do it.
|
|
86
|
+
ipcMain.handle('sidecar:set-provider-default', async (_event, provider, chosenId) => {
|
|
87
|
+
let catalog = [];
|
|
88
|
+
try {
|
|
89
|
+
const { getCatalog } = require('../src/utils/model-catalog');
|
|
90
|
+
catalog = await getCatalog();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// Best-effort only -- a fetch failure leaves `catalog` empty, which
|
|
93
|
+
// directFormIfProven (F1, council review of PR 198) reads as NO
|
|
94
|
+
// evidence, never as license to strip: chosenId is persisted exactly
|
|
95
|
+
// as given, not re-derived. Applying an already-made picker choice
|
|
96
|
+
// must never abort on a catalog hiccup, and must never fabricate an
|
|
97
|
+
// id on one either -- that was the exact bug issue 195 fixed.
|
|
98
|
+
logger.error('set-provider-default catalog fetch error', { error: err.message });
|
|
99
|
+
}
|
|
84
100
|
try {
|
|
85
101
|
const { applyProviderDefault } = require('../src/utils/provider-default-picker');
|
|
86
|
-
return applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
|
|
102
|
+
return applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true, catalog });
|
|
87
103
|
} catch (err) {
|
|
88
104
|
logger.error('set-provider-default handler error', { error: err.message });
|
|
89
105
|
return { success: false, error: err.message };
|
package/electron/main.js
CHANGED
|
@@ -327,11 +327,24 @@ function createAmicusWindow() {
|
|
|
327
327
|
async function createSetupWindow() {
|
|
328
328
|
// Lazy-load setup UI to avoid loading it for sidecar mode
|
|
329
329
|
const { buildSetupHTML } = require('./setup-ui');
|
|
330
|
-
const { resolveQuickPicks } = require('../src/utils/quick-picks');
|
|
330
|
+
const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
|
|
331
331
|
let quickPicks;
|
|
332
|
+
const shortlists = {};
|
|
332
333
|
try {
|
|
333
334
|
const catalog = await require('../src/utils/model-catalog').getCatalog();
|
|
334
335
|
quickPicks = resolveQuickPicks(catalog);
|
|
336
|
+
|
|
337
|
+
// issue 138: one vendor shortlist per family card, resolved server-side from
|
|
338
|
+
// the same catalog the quick picks came from (no extra IPC round-trip).
|
|
339
|
+
const { buildModelShortlist } = require('../src/utils/model-shortlist');
|
|
340
|
+
for (const p of quickPicks) {
|
|
341
|
+
try {
|
|
342
|
+
shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
|
|
343
|
+
catalog,
|
|
344
|
+
recommendedId: toStorableRoute(p),
|
|
345
|
+
});
|
|
346
|
+
} catch (_e) { /* a shortlist failure must never block the wizard */ }
|
|
347
|
+
}
|
|
335
348
|
} catch (_err) {
|
|
336
349
|
quickPicks = undefined; // buildSetupHTML falls back to pinned
|
|
337
350
|
}
|
|
@@ -348,7 +361,7 @@ async function createSetupWindow() {
|
|
|
348
361
|
}
|
|
349
362
|
});
|
|
350
363
|
|
|
351
|
-
const html = buildSetupHTML({ client: CLIENT, quickPicks });
|
|
364
|
+
const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists });
|
|
352
365
|
mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
|
353
366
|
mainWindow.webContents.on('page-title-updated', (e) => e.preventDefault());
|
|
354
367
|
|
|
@@ -499,6 +512,36 @@ registerSetupHandlers(() => mainWindow);
|
|
|
499
512
|
function createSettingsChildWindow() {
|
|
500
513
|
const { buildSetupHTML } = require('./setup-ui');
|
|
501
514
|
|
|
515
|
+
// issue 138: mirror createSetupWindow's catalog resolution so Step 2 shows
|
|
516
|
+
// live per-model options here too, instead of falling back to the pinned
|
|
517
|
+
// `[offline list]` badge. This window must stay synchronous (opening
|
|
518
|
+
// Settings must never trigger a network fetch), so read the on-disk cache
|
|
519
|
+
// directly with readCache() rather than the async fetch-and-refresh helper
|
|
520
|
+
// createSetupWindow awaits. A missing or corrupt cache reads back as null
|
|
521
|
+
// and degrades to the same pinned fallback buildSetupHTML already applies
|
|
522
|
+
// when no quickPicks are given.
|
|
523
|
+
const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
|
|
524
|
+
const { readCache } = require('../src/utils/model-catalog');
|
|
525
|
+
let quickPicks;
|
|
526
|
+
const shortlists = {};
|
|
527
|
+
try {
|
|
528
|
+
const cacheDoc = readCache();
|
|
529
|
+
const catalog = cacheDoc ? cacheDoc.models : [];
|
|
530
|
+
quickPicks = resolveQuickPicks(catalog);
|
|
531
|
+
|
|
532
|
+
const { buildModelShortlist } = require('../src/utils/model-shortlist');
|
|
533
|
+
for (const p of quickPicks) {
|
|
534
|
+
try {
|
|
535
|
+
shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
|
|
536
|
+
catalog,
|
|
537
|
+
recommendedId: toStorableRoute(p),
|
|
538
|
+
});
|
|
539
|
+
} catch (_e) { /* a shortlist failure must never block the wizard */ }
|
|
540
|
+
}
|
|
541
|
+
} catch (_err) {
|
|
542
|
+
quickPicks = undefined; // buildSetupHTML falls back to pinned
|
|
543
|
+
}
|
|
544
|
+
|
|
502
545
|
const settingsWin = new BrowserWindow({
|
|
503
546
|
width: 560, height: 680,
|
|
504
547
|
parent: mainWindow, modal: false,
|
|
@@ -512,7 +555,7 @@ function createSettingsChildWindow() {
|
|
|
512
555
|
}
|
|
513
556
|
});
|
|
514
557
|
|
|
515
|
-
const html = buildSetupHTML({ client: CLIENT });
|
|
558
|
+
const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists });
|
|
516
559
|
settingsWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
|
517
560
|
settingsWin.webContents.on('page-title-updated', (e) => e.preventDefault());
|
|
518
561
|
}
|
|
@@ -20,6 +20,26 @@ const PROVIDER_NAMES = {
|
|
|
20
20
|
deepseek: 'DeepSeek'
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* F5: HTML-escape a value for safe interpolation into BOTH an attribute
|
|
25
|
+
* value (double-quoted) and element text content. Catalog ids are
|
|
26
|
+
* data-controlled (this repo's own convention documents them as such --
|
|
27
|
+
* see renderSearchResults in electron/setup-ui.js, which uses createElement
|
|
28
|
+
* + textContent instead of a template string for this exact row source),
|
|
29
|
+
* so every id/name reaching a template string here must be escaped rather
|
|
30
|
+
* than trusted.
|
|
31
|
+
* @param {*} value
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function escapeAttr(value) {
|
|
35
|
+
return String(value === null || value === undefined ? '' : value)
|
|
36
|
+
.replace(/&/g, '&')
|
|
37
|
+
.replace(/</g, '<')
|
|
38
|
+
.replace(/>/g, '>')
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/'/g, ''');
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
/**
|
|
24
44
|
* Check if a model has at least one route with a configured key.
|
|
25
45
|
* When no keys are configured at all (empty configuredKeys), all models are available
|
|
@@ -63,15 +83,68 @@ function buildModelSearchHTML() {
|
|
|
63
83
|
</div>`;
|
|
64
84
|
}
|
|
65
85
|
|
|
86
|
+
/**
|
|
87
|
+
* issue 138: the family -> model second level for one card. Renders EVERY model
|
|
88
|
+
* in one <select> (scrollable and type-ahead searchable, so nothing is
|
|
89
|
+
* hidden), grouped "Suggested" / "All N models". Returns '' when no
|
|
90
|
+
* shortlist was supplied (or its `total` is 0).
|
|
91
|
+
*
|
|
92
|
+
* That '' is NOT dropped by the caller — buildModelStepHTML always splices
|
|
93
|
+
* it into the card template as its own line, so a card with no shortlist
|
|
94
|
+
* gains one whitespace-only line versus the pre-issue-138 HTML string. It is NOT
|
|
95
|
+
* byte-for-byte identical to that old output. It IS behaviorally identical:
|
|
96
|
+
* no <select> is emitted, and whitespace-only text nodes are inert once
|
|
97
|
+
* parsed as HTML, so the rendered card is unchanged.
|
|
98
|
+
*
|
|
99
|
+
* CONTROLLER RULING R1 (issue 138, 2026-08-24): every <option> carries
|
|
100
|
+
* data-or="<openrouterId>" (empty string when the row has no OpenRouter
|
|
101
|
+
* form), so a later task can read the user's chosen route without
|
|
102
|
+
* re-deriving a gateway prefix from the value id.
|
|
103
|
+
* @param {string} alias
|
|
104
|
+
* @param {{recommendedId:string, suggested:Array<object>, rest:Array<object>, total:number}} [shortlist]
|
|
105
|
+
* @returns {string} HTML fragment
|
|
106
|
+
*/
|
|
107
|
+
function buildModelPickHTML(alias, shortlist) {
|
|
108
|
+
if (!shortlist || !shortlist.total) { return ''; }
|
|
109
|
+
const opt = (r) => {
|
|
110
|
+
const price = r.pricePerMInput === null ? '' : ` · $${r.pricePerMInput.toFixed(2)}/M`;
|
|
111
|
+
const sel = r.isRecommended ? ' selected' : '';
|
|
112
|
+
return `<option value="${escapeAttr(r.id)}" data-or="${escapeAttr(r.openrouterId || '')}"${sel}>${escapeAttr(r.id)}${price}</option>`;
|
|
113
|
+
};
|
|
114
|
+
// Escaping-discipline consistency pass (council review, PR 196): alias
|
|
115
|
+
// is one of the five hardcoded FAMILIES names, not catalog data, so this
|
|
116
|
+
// is not closing a live vulnerability -- it matches the escapeAttr() use
|
|
117
|
+
// a few lines up for r.id/r.openrouterId, which ARE catalog-derived, so
|
|
118
|
+
// every attribute interpolation in this function follows the same rule.
|
|
119
|
+
let html = `<select class="model-pick" data-alias="${escapeAttr(alias)}">`;
|
|
120
|
+
html += `<optgroup label="Suggested">${shortlist.suggested.map(opt).join('')}</optgroup>`;
|
|
121
|
+
if (shortlist.rest.length > 0) {
|
|
122
|
+
// council review, PR 196 (F2): this optgroup holds ONLY `rest` -- label it
|
|
123
|
+
// by rest.length, not shortlist.total, or the label overstates what's in
|
|
124
|
+
// it (e.g. "All 14 models" over 6 rows when 8 are already under
|
|
125
|
+
// "Suggested" above). Singular/plural handled explicitly so a 9-row
|
|
126
|
+
// vendor with one leftover row doesn't read "1 models".
|
|
127
|
+
const restCount = shortlist.rest.length;
|
|
128
|
+
const restLabel = restCount === 1 ? '1 more model' : `${restCount} more models`;
|
|
129
|
+
html += `<optgroup label="${restLabel}">${shortlist.rest.map(opt).join('')}</optgroup>`;
|
|
130
|
+
}
|
|
131
|
+
return html + '</select>';
|
|
132
|
+
}
|
|
133
|
+
|
|
66
134
|
/**
|
|
67
135
|
* Build the HTML fragment for Step 2 (Model Selection).
|
|
68
136
|
* @param {Array<{alias:string, label:string, blurb:string, source:string, routes:Object<string,string>}>} choices
|
|
69
137
|
* Resolved rows from resolveQuickPicks(). Each row has separate label + blurb fields.
|
|
70
138
|
* @param {string} [selectedAlias] - Pre-selected alias; defaults to first available choice.
|
|
71
139
|
* @param {Object<string,boolean>} [configuredKeys] - Provider IDs the user has keys for.
|
|
140
|
+
* @param {Object<string,object>} [shortlists] - issue 138: per-alias vendor shortlist
|
|
141
|
+
* from buildModelShortlist(), used to render the model-level <select>.
|
|
142
|
+
* Defaults to {}; omitting it (or passing {}) is behaviorally identical to
|
|
143
|
+
* today's card (no <select> for that alias) but not byte-for-byte identical
|
|
144
|
+
* to the pre-issue-138 HTML string — see buildModelPickHTML's docstring.
|
|
72
145
|
* @returns {string} HTML fragment
|
|
73
146
|
*/
|
|
74
|
-
function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
147
|
+
function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}, shortlists = {}) {
|
|
75
148
|
// Determine availability for each model
|
|
76
149
|
const availability = choices.map(c => {
|
|
77
150
|
const providers = Object.keys(c.routes);
|
|
@@ -102,6 +175,20 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
|
102
175
|
|
|
103
176
|
// Resolved id for the write-preview (prefer bestProvider route)
|
|
104
177
|
const previewId = c.routes[bestProvider] || Object.values(c.routes)[0] || '';
|
|
178
|
+
// Escaping-discipline consistency pass (council review, PR 196; extended
|
|
179
|
+
// by a second pass, N-c): NOT closing a live vulnerability -- c.alias is
|
|
180
|
+
// one of the five hardcoded FAMILIES names and previewId is filtered
|
|
181
|
+
// through resolveQuickPicks' anchored idPattern regexes (or a hardcoded
|
|
182
|
+
// fallback), neither of which can carry a payload. This applies the
|
|
183
|
+
// same escapeAttr() used a few lines up (buildModelPickHTML, for
|
|
184
|
+
// genuinely catalog-derived r.id/r.openrouterId) to every c.alias /
|
|
185
|
+
// previewId interpolation in this card template -- attributes
|
|
186
|
+
// (data-alias, the radio value) and text content alike (.model-alias,
|
|
187
|
+
// .model-resolved, .write-preview-id, the write-preview <code>s) --
|
|
188
|
+
// so a reader of this template does not have to work out which
|
|
189
|
+
// interpolations are "safe" and which are escaped; the rule is uniform.
|
|
190
|
+
const escapedAlias = escapeAttr(c.alias);
|
|
191
|
+
const escapedPreviewId = escapeAttr(previewId);
|
|
105
192
|
|
|
106
193
|
// Offline badge for fallback rows
|
|
107
194
|
const badge = c.source === 'fallback'
|
|
@@ -114,23 +201,26 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
|
114
201
|
const pills = providers.map(p => {
|
|
115
202
|
const isActive = p === bestProvider;
|
|
116
203
|
const cls = isActive ? 'route-pill active' : 'route-pill';
|
|
117
|
-
return `<button class="${cls}" data-alias="${
|
|
204
|
+
return `<button class="${cls}" data-alias="${escapedAlias}" data-provider="${p}">${PROVIDER_NAMES[p]}</button>`;
|
|
118
205
|
}).join('');
|
|
119
206
|
const toggleDisplay = showToggle ? '' : ' style="display:none"';
|
|
120
207
|
const staticDisplay = showToggle ? ' style="display:none"' : '';
|
|
121
|
-
routeHtml = `<span class="route-toggle" data-alias="${
|
|
122
|
-
routeHtml += `<span class="route-static" data-alias="${
|
|
208
|
+
routeHtml = `<span class="route-toggle" data-alias="${escapedAlias}"${toggleDisplay}>${pills}</span>`;
|
|
209
|
+
routeHtml += `<span class="route-static" data-alias="${escapedAlias}"${staticDisplay}>via ${PROVIDER_NAMES[bestProvider]}</span>`;
|
|
123
210
|
} else {
|
|
124
211
|
routeHtml = `<span class="route-static">via ${PROVIDER_NAMES[bestProvider]}</span>`;
|
|
125
212
|
}
|
|
126
213
|
|
|
214
|
+
const modelPickHtml = buildModelPickHTML(c.alias, shortlists[c.alias]);
|
|
215
|
+
|
|
127
216
|
return `<label class="${cardClass}">
|
|
128
|
-
<input type="radio" name="default-model" value="${
|
|
129
|
-
<span class="model-alias">${
|
|
217
|
+
<input type="radio" name="default-model" value="${escapedAlias}" ${checked}${disabled}>
|
|
218
|
+
<span class="model-alias">${escapedAlias}</span>
|
|
130
219
|
<span class="model-label">${c.label} — ${c.blurb}</span>${badge}
|
|
131
|
-
<span class="model-resolved">${
|
|
220
|
+
<span class="model-resolved" data-alias="${escapedAlias}">${escapedPreviewId}</span>
|
|
132
221
|
${routeHtml}
|
|
133
|
-
|
|
222
|
+
${modelPickHtml}
|
|
223
|
+
<span class="write-preview" data-alias="${escapedAlias}">will set <code>${escapedAlias}</code> → <code class="write-preview-id">${escapedPreviewId}</code></span>
|
|
134
224
|
</label>`;
|
|
135
225
|
}).join('\n ');
|
|
136
226
|
|
|
@@ -145,4 +235,4 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
|
145
235
|
</div>`;
|
|
146
236
|
}
|
|
147
237
|
|
|
148
|
-
module.exports = { buildModelSearchHTML, buildModelStepHTML, PROVIDER_NAMES };
|
|
238
|
+
module.exports = { buildModelSearchHTML, buildModelStepHTML, buildModelPickHTML, PROVIDER_NAMES, escapeAttr };
|
|
@@ -192,6 +192,28 @@ function __rawWizardCSS() {
|
|
|
192
192
|
margin-left: auto; font-size: 11px; color: var(--text-faint); font-style: italic;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/* Model-level picker (Step 2 card) — issue 138. Mirrors .alias-model-select's
|
|
196
|
+
token-driven approach (background/border/radius/color/font/outline/cursor),
|
|
197
|
+
but this control lives inside a wrapping .model-card row alongside the
|
|
198
|
+
radio and route pills, not the Step-3 alias table's own linear row —
|
|
199
|
+
so it takes flex-basis:100% (its own line, below the route pills) rather
|
|
200
|
+
than alias-model-select's flex:1, and a plain --border (not --accent,
|
|
201
|
+
which the alias editor reserves for its active-edit state) so it reads
|
|
202
|
+
as a subordinate refinement, not the card's primary affordance. The
|
|
203
|
+
option/optgroup rules are load-bearing: Windows does not inherit a
|
|
204
|
+
<select>'s background/color into its <option>/<optgroup> children. */
|
|
205
|
+
.model-pick {
|
|
206
|
+
flex-basis: 100%; margin-top: 4px; padding: 3px 6px;
|
|
207
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
208
|
+
border-radius: var(--r-3); color: var(--text-muted); font-size: 11px;
|
|
209
|
+
font-family: var(--font-mono);
|
|
210
|
+
outline: none; cursor: pointer; max-width: 280px;
|
|
211
|
+
}
|
|
212
|
+
.model-pick:hover { border-color: var(--border-strong); }
|
|
213
|
+
.model-pick:focus { border-color: var(--accent); }
|
|
214
|
+
.model-pick option { background: var(--surface); color: var(--text); }
|
|
215
|
+
.model-pick optgroup { color: var(--text-muted); font-style: normal; }
|
|
216
|
+
|
|
195
217
|
/* Routing example */
|
|
196
218
|
.routing-example {
|
|
197
219
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-8);
|