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
package/electron/setup-ui.js
CHANGED
|
@@ -20,15 +20,19 @@ const { listDirectProviders } = require('../src/utils/provider-registry');
|
|
|
20
20
|
* @param {string} [options.client='code-local'] - Client type for branding
|
|
21
21
|
* @param {Array} [options.quickPicks] - Resolved quick-pick rows from resolveQuickPicks(catalog).
|
|
22
22
|
* Defaults to pinned fallbacks when not provided.
|
|
23
|
+
* @param {Object<string,object>} [options.shortlists] - issue 138: per-alias vendor
|
|
24
|
+
* shortlist from buildModelShortlist(), passed through to buildModelStepHTML
|
|
25
|
+
* for the model-level <select>. Defaults to {} (no drill-down rendered).
|
|
23
26
|
*/
|
|
24
27
|
function buildSetupHTML(options = {}) {
|
|
25
28
|
const {
|
|
26
29
|
client = 'code-local',
|
|
27
30
|
quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
|
|
31
|
+
shortlists = {},
|
|
28
32
|
} = options;
|
|
29
33
|
const brandName = getBrandName(client);
|
|
30
34
|
const keysHtml = buildKeysStepHTML(PROVIDERS);
|
|
31
|
-
const modelHtml = buildModelStepHTML(quickPicks);
|
|
35
|
+
const modelHtml = buildModelStepHTML(quickPicks, undefined, undefined, shortlists);
|
|
32
36
|
const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
|
|
33
37
|
const css = buildWizardCSS();
|
|
34
38
|
const providersJson = JSON.stringify(PROVIDERS);
|
|
@@ -82,8 +86,17 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
82
86
|
var directProviders = ${directProvidersJson};
|
|
83
87
|
var routingChoices = {};
|
|
84
88
|
var explicitRouteChoices = {};
|
|
89
|
+
// issue 138: alias -> a SPECIFIC model id the user drilled down to. Empty
|
|
90
|
+
// means "use the family flagship", i.e. today's behavior.
|
|
91
|
+
var modelChoiceIds = {};
|
|
92
|
+
var modelOpenrouterIds = {};
|
|
85
93
|
var aliasEdits = {};
|
|
86
94
|
var aliasDisplay = {};
|
|
95
|
+
// N1: the alias map as loaded from disk (init below), so buildReview can
|
|
96
|
+
// tell an actual change from a value-identical re-write. Stays {} until
|
|
97
|
+
// init resolves (a fresh/offline config has nothing saved yet, so every
|
|
98
|
+
// computed write IS new).
|
|
99
|
+
var savedAliases = {};
|
|
87
100
|
window.availableModels = null;
|
|
88
101
|
var keyValid = false, validatedKey = '';
|
|
89
102
|
var $ = function(id) { return document.getElementById(id); };
|
|
@@ -141,6 +154,13 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
141
154
|
window.customDefaultModel = cfg.default;
|
|
142
155
|
}
|
|
143
156
|
if (cfg && cfg.aliases) {
|
|
157
|
+
// N-a (council review, PR 196): a defensive copy, not the live cfg.aliases
|
|
158
|
+
// reference. Nothing in this file currently mutates cfg.aliases after
|
|
159
|
+
// this point (checked: every '.aliases[' site below is a read), so
|
|
160
|
+
// buildReview's N1 diff is not measured to be wrong today -- but the
|
|
161
|
+
// copy is one line and removes a fragile "never mutate this" invariant
|
|
162
|
+
// future edits would otherwise have to remember.
|
|
163
|
+
savedAliases = Object.assign({}, cfg.aliases); // N1: buildReview diffs against this
|
|
144
164
|
modelChoicesData.forEach(function(mc) {
|
|
145
165
|
var currentModel = cfg.aliases[mc.alias];
|
|
146
166
|
if (currentModel) {
|
|
@@ -148,6 +168,62 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
148
168
|
for (var i = 0; i < provs.length; i++) {
|
|
149
169
|
if (mc.routes[provs[i]] === currentModel) { routingChoices[mc.alias] = provs[i]; break; }
|
|
150
170
|
}
|
|
171
|
+
// F3: the shortlist <select> is server-rendered with its OWN
|
|
172
|
+
// recommendedId-derived "selected" option and never consults
|
|
173
|
+
// modelChoiceIds at init -- so a saved drill-down pick silently
|
|
174
|
+
// reverted to the family flagship on every reopen (Step 3's
|
|
175
|
+
// alias table showed the saved value while Step 2 showed a
|
|
176
|
+
// different one). Mirror the customDefaultModel restore just
|
|
177
|
+
// above: when the saved alias value names one of THIS card's
|
|
178
|
+
// shortlist rows, seed modelChoiceIds (the .model-pick change
|
|
179
|
+
// handler's own state) and push the same value into the DOM
|
|
180
|
+
// <select> so a no-op reopen-and-Finish round-trips cleanly.
|
|
181
|
+
var sel = document.querySelector('.model-pick[data-alias="' + mc.alias + '"]');
|
|
182
|
+
if (sel) {
|
|
183
|
+
var matchedByValue = false;
|
|
184
|
+
for (var j = 0; j < sel.options.length; j++) {
|
|
185
|
+
if (sel.options[j].value === currentModel) {
|
|
186
|
+
sel.value = currentModel;
|
|
187
|
+
modelChoiceIds[mc.alias] = currentModel;
|
|
188
|
+
modelOpenrouterIds[mc.alias] = sel.options[j].getAttribute('data-or') || null;
|
|
189
|
+
matchedByValue = true;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// F3-OR (council review, issue 138): an explicit-OpenRouter
|
|
194
|
+
// drilled pick is saved as "openrouter/<vendor>/<model>"
|
|
195
|
+
// (pickRouteFor's explicit-OR branch below) -- that form
|
|
196
|
+
// matches no option's bare 'value', only its 'data-or'. Every
|
|
197
|
+
// option carries 'data-or' (CONTROLLER RULING R1, issue 138 --
|
|
198
|
+
// see buildModelPickHTML's docstring in setup-ui-model.js), so
|
|
199
|
+
// search for THAT match when the bare-value search above
|
|
200
|
+
// found nothing.
|
|
201
|
+
// Restoring modelChoiceIds alone here would round-trip the
|
|
202
|
+
// dropdown selection but make Finish write the BARE id back --
|
|
203
|
+
// silently converting the user's explicit "via OpenRouter"
|
|
204
|
+
// choice into direct-first policy routing on the very next
|
|
205
|
+
// save, which is worse than the visible flagship-revert this
|
|
206
|
+
// block exists to fix. So restore the WHOLE state pickRouteFor
|
|
207
|
+
// needs to reproduce the OR form: the option's bare value (for
|
|
208
|
+
// the <select> and modelChoiceIds), its data-or
|
|
209
|
+
// (modelOpenrouterIds), and the two flags pickRouteFor's
|
|
210
|
+
// explicit-OR branch checks -- routingChoices[alias] ===
|
|
211
|
+
// 'openrouter' and explicitRouteChoices[alias] -- which are
|
|
212
|
+
// exactly the flags the route-pill click handler sets, so the
|
|
213
|
+
// rendered pill agrees with the restored state too.
|
|
214
|
+
if (!matchedByValue) {
|
|
215
|
+
for (var k = 0; k < sel.options.length; k++) {
|
|
216
|
+
if (sel.options[k].getAttribute('data-or') === currentModel) {
|
|
217
|
+
sel.value = sel.options[k].value;
|
|
218
|
+
modelChoiceIds[mc.alias] = sel.options[k].value;
|
|
219
|
+
modelOpenrouterIds[mc.alias] = currentModel;
|
|
220
|
+
routingChoices[mc.alias] = 'openrouter';
|
|
221
|
+
explicitRouteChoices[mc.alias] = true;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
151
227
|
}
|
|
152
228
|
});
|
|
153
229
|
Object.keys(cfg.aliases).forEach(function(k) {
|
|
@@ -224,6 +300,20 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
224
300
|
// the row's first route. Returns the full model id or null.
|
|
225
301
|
function pickRouteFor(mc) {
|
|
226
302
|
if (!mc) { return null; }
|
|
303
|
+
// issue 138: an explicit per-model choice overrides the family flagship.
|
|
304
|
+
var picked = modelChoiceIds[mc.alias];
|
|
305
|
+
if (picked) {
|
|
306
|
+
// Deliberately NEVER toBareIfDirect(picked) here (unlike the auto-pick
|
|
307
|
+
// canonicalization below): picked/modelOpenrouterIds come straight
|
|
308
|
+
// from the shortlist's own id/data-or, and for a DIVERGENT_VENDOR
|
|
309
|
+
// (e.g. anthropic) that id can already BE its only-callable
|
|
310
|
+
// openrouter/<vendor>/... form -- stripping the prefix would
|
|
311
|
+
// fabricate a direct id nothing serves.
|
|
312
|
+
if (routingChoices[mc.alias] === 'openrouter' && explicitRouteChoices[mc.alias]) {
|
|
313
|
+
return modelOpenrouterIds[mc.alias] || picked;
|
|
314
|
+
}
|
|
315
|
+
return picked;
|
|
316
|
+
}
|
|
227
317
|
var provs = Object.keys(mc.routes);
|
|
228
318
|
var prov = routingChoices[mc.alias];
|
|
229
319
|
if (!prov || !mc.routes[prov]) {
|
|
@@ -363,21 +453,52 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
363
453
|
kn.length > 0 ? kn.map(function(k) { return k + ' \\u2713'; }).join(', ') : 'None';
|
|
364
454
|
var r = document.querySelector('input[name="default-model"]:checked');
|
|
365
455
|
document.getElementById('review-model').textContent = window.customDefaultModel || (r ? r.value : 'Not selected');
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
456
|
+
// F4: mirror the EXACT call Finish makes (collectAliasWrites), not just
|
|
457
|
+
// the checked radio, so Step 4 can never under-report what Finish is
|
|
458
|
+
// about to write. Before this fix, buildReview re-derived "writes" from
|
|
459
|
+
// the checked radio alone -- a drilled-down pick on a card that was NOT
|
|
460
|
+
// the checked default (or any drilled pick at all, under a custom
|
|
461
|
+
// default) was invisible here and fell through to the literal 'No
|
|
462
|
+
// alias changes', even though Finish wrote it. sidecar:save-config has
|
|
463
|
+
// no confirmation step, so this review IS the only gate.
|
|
464
|
+
var aliasWritesPreview = collectAliasWrites(r ? r.value : null, !!window.customDefaultModel);
|
|
465
|
+
// N1: aliasWritesPreview is EVERY alias Finish would write, including
|
|
466
|
+
// ones whose value is already what's on disk -- after the F1 fix that
|
|
467
|
+
// is the NORMAL case on a plain reopen (init below seeds modelChoiceIds
|
|
468
|
+
// from cfg.aliases, so the drilled-down alias, the recommendedId, and
|
|
469
|
+
// the saved value are now the same string by construction). Finish
|
|
470
|
+
// still writes the full map (a value-identical write is harmless), but
|
|
471
|
+
// this review must only SHOW entries that actually differ from
|
|
472
|
+
// savedAliases -- otherwise a no-op reopen reports "N alias(es)
|
|
473
|
+
// modified" on the one screen that has no confirmation step after it.
|
|
474
|
+
// N-b (council review, PR 196): an alias that was NEVER in savedAliases
|
|
475
|
+
// reads as undefined there, while a delete-write (Step 3's delete
|
|
476
|
+
// button, for one of the five default aliases -- see aliasEdits[alias]
|
|
477
|
+
// = null in setup-ui-alias-script.js) is null. null !== undefined is
|
|
478
|
+
// true, so without this normalization a default alias that was never
|
|
479
|
+
// explicitly saved (a config written by an older/partial flow that
|
|
480
|
+
// skipped default-seeding -- addAlias() in src/sidecar/setup.js is one
|
|
481
|
+
// such path) would show "alias -> (deleted)" for deleting something
|
|
482
|
+
// that was never there. saveConfig() already drops falsy alias values
|
|
483
|
+
// (src/utils/config.js), so that write is a true no-op on disk; the
|
|
484
|
+
// review must agree. Reading savedAliases[alias] as null (not
|
|
485
|
+
// undefined) when the key is absent makes "delete of an absent alias"
|
|
486
|
+
// compare equal to "still absent" without changing any other case --
|
|
487
|
+
// every other savedAliases value here is a non-empty string.
|
|
488
|
+
var changedWrites = {};
|
|
489
|
+
Object.keys(aliasWritesPreview).forEach(function(alias) {
|
|
490
|
+
var oldVal = Object.prototype.hasOwnProperty.call(savedAliases, alias) ? savedAliases[alias] : null;
|
|
491
|
+
if (aliasWritesPreview[alias] !== oldVal) {
|
|
492
|
+
changedWrites[alias] = aliasWritesPreview[alias];
|
|
372
493
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
494
|
+
});
|
|
495
|
+
var writes = Object.keys(changedWrites).map(function(alias) {
|
|
496
|
+
var val = changedWrites[alias];
|
|
497
|
+
return alias + ' \\u2192 ' + (val === null ? '(deleted)' : val);
|
|
498
|
+
});
|
|
378
499
|
document.getElementById('review-routing').textContent =
|
|
379
500
|
writes.length > 0 ? writes.join(', ') : 'No alias changes';
|
|
380
|
-
var editCount = Object.keys(
|
|
501
|
+
var editCount = Object.keys(changedWrites).length;
|
|
381
502
|
var reviewAliases = document.getElementById('review-aliases');
|
|
382
503
|
if (reviewAliases) {
|
|
383
504
|
reviewAliases.textContent = editCount > 0 ? editCount + ' alias(es) modified' : 'No changes';
|
|
@@ -401,27 +522,97 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
401
522
|
updateWritePreviews();
|
|
402
523
|
});
|
|
403
524
|
|
|
525
|
+
// issue 138: model-level drill-down <select> change handler.
|
|
526
|
+
document.addEventListener('change', function(e) {
|
|
527
|
+
var sel = e.target && e.target.closest ? e.target.closest('.model-pick') : null;
|
|
528
|
+
if (!sel) { return; }
|
|
529
|
+
var alias = sel.getAttribute('data-alias');
|
|
530
|
+
if (!alias) { return; }
|
|
531
|
+
modelChoiceIds[alias] = sel.value;
|
|
532
|
+
var opt = sel.options[sel.selectedIndex];
|
|
533
|
+
modelOpenrouterIds[alias] = (opt && opt.getAttribute('data-or')) || null;
|
|
534
|
+
updateWritePreviews();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// issue 138 (fix round 1, Finding 1; precedence description corrected in
|
|
538
|
+
// the F2/F6 fix wave; modelChoiceIds provenance corrected in the N2 fix
|
|
539
|
+
// wave): assemble aliasWrites for Finish -- the aliasEdits (Step 3)
|
|
540
|
+
// overlay first; THEN, only for a checked quick-pick default (not a
|
|
541
|
+
// custom/searched one -- isCustomDefault skips this stage entirely), the
|
|
542
|
+
// selected alias's resolved route; then every OTHER alias whose
|
|
543
|
+
// drill-down <select> fired change -- "every OTHER" means every alias
|
|
544
|
+
// but the selected one ONLY when that earlier stage ran, so under a
|
|
545
|
+
// custom default the selected alias (if it has a drilled pick) is
|
|
546
|
+
// handled by THIS stage instead, not skipped. Before the Task-3 dropdown
|
|
547
|
+
// existed there was nothing to lose by writing only the selected alias;
|
|
548
|
+
// now a drilled-down pick on a card that is NOT the checked default
|
|
549
|
+
// would silently vanish without this. Precedence is NOT uniform across
|
|
550
|
+
// the two groups, unlike an earlier version of this comment claimed:
|
|
551
|
+
// only the selected alias clobbers its aliasEdits entry (the ONE place
|
|
552
|
+
// that's permitted -- user-locked decision #2); every OTHER drilled-down
|
|
553
|
+
// alias defers to aliasEdits and is written only when Step 3 left it
|
|
554
|
+
// untouched (see the hasOwnProperty guard below and ruling R6a, which
|
|
555
|
+
// already described the real behaviour correctly).
|
|
556
|
+
//
|
|
557
|
+
// modelChoiceIds is populated from TWO places, not one, unlike an
|
|
558
|
+
// earlier version of this comment claimed: the change handler above
|
|
559
|
+
// (a live drill-down pick), AND the init restore block (F3) -- which
|
|
560
|
+
// seeds it from cfg.aliases for every card whose SAVED value already
|
|
561
|
+
// names one of its shortlist rows, reading the id back out of that
|
|
562
|
+
// card's own server-rendered <option> elements. After the F1 fix that is
|
|
563
|
+
// the NORMAL case, not an edge case: a card the user never touched in
|
|
564
|
+
// THIS session routinely lands in modelChoiceIds anyway, because its
|
|
565
|
+
// saved value already matches its recommendedId. That is still correct
|
|
566
|
+
// to iterate here -- collectAliasWrites' job is to compute what Finish
|
|
567
|
+
// SHOULD write, and a value-identical write is harmless -- but it does
|
|
568
|
+
// mean this function can no longer be read as "only ever fires for
|
|
569
|
+
// aliases the user actually changed this session". buildReview is the
|
|
570
|
+
// layer responsible for not SHOWING those value-identical entries as
|
|
571
|
+
// changes (see its own N1 comment).
|
|
572
|
+
function collectAliasWrites(selectedAlias, isCustomDefault) {
|
|
573
|
+
var aliasWrites = {};
|
|
574
|
+
Object.keys(aliasEdits).forEach(function(k) {
|
|
575
|
+
aliasWrites[k] = aliasEdits[k];
|
|
576
|
+
});
|
|
577
|
+
function writeAliasRoute(alias) {
|
|
578
|
+
var mc = null;
|
|
579
|
+
for (var i = 0; i < modelChoicesData.length; i++) {
|
|
580
|
+
if (modelChoicesData[i].alias === alias) { mc = modelChoicesData[i]; break; }
|
|
581
|
+
}
|
|
582
|
+
if (!mc) { return; }
|
|
583
|
+
var routeId = pickRouteFor(mc);
|
|
584
|
+
if (routeId) { aliasWrites[mc.alias] = routeId; }
|
|
585
|
+
}
|
|
586
|
+
if (!isCustomDefault && selectedAlias) {
|
|
587
|
+
// Selecting a quick pick = explicit touch: upgrade that ONE alias
|
|
588
|
+
// to the resolved id via the chosen route (user-locked decision #2).
|
|
589
|
+
// This is the ONE place clobbering aliasEdits is permitted.
|
|
590
|
+
writeAliasRoute(selectedAlias);
|
|
591
|
+
}
|
|
592
|
+
// issue 138 (fix round 2, ruling R6a): every OTHER drilled-down alias is
|
|
593
|
+
// written ONLY when Step 3 left it untouched. hasOwnProperty.call, not
|
|
594
|
+
// the in operator and not a truthiness/not-undefined check:
|
|
595
|
+
// aliasEdits[alias] === null is a MEANINGFUL value here (ipc-setup.js:
|
|
596
|
+
// string = set, null = delete), so a falsy/undefined check would
|
|
597
|
+
// silently resurrect an alias the user explicitly deleted in Step 3,
|
|
598
|
+
// and the in operator or a not-undefined check can be fooled by an
|
|
599
|
+
// inherited Object.prototype property name (this codebase has been
|
|
600
|
+
// bitten by that class of bug before -- see the proto:null guard in
|
|
601
|
+
// src/sidecar/setup.js resolveChoice).
|
|
602
|
+
Object.keys(modelChoiceIds).forEach(function(alias) {
|
|
603
|
+
if (!isCustomDefault && alias === selectedAlias) { return; } // handled above, but only in that branch
|
|
604
|
+
if (Object.prototype.hasOwnProperty.call(aliasEdits, alias)) { return; }
|
|
605
|
+
writeAliasRoute(alias);
|
|
606
|
+
});
|
|
607
|
+
return aliasWrites;
|
|
608
|
+
}
|
|
609
|
+
|
|
404
610
|
finishBtn.addEventListener('click', async function() {
|
|
405
611
|
finishBtn.disabled = true; finishBtn.textContent = 'Saving...';
|
|
406
612
|
try {
|
|
407
613
|
var r = document.querySelector('input[name="default-model"]:checked');
|
|
408
614
|
var dm = window.customDefaultModel || (r ? r.value : null);
|
|
409
|
-
var aliasWrites =
|
|
410
|
-
Object.keys(aliasEdits).forEach(function(k) {
|
|
411
|
-
aliasWrites[k] = aliasEdits[k];
|
|
412
|
-
});
|
|
413
|
-
if (!window.customDefaultModel && r) {
|
|
414
|
-
// Selecting a quick pick = explicit touch: upgrade that ONE alias
|
|
415
|
-
// to the resolved id via the chosen route (user-locked decision #2).
|
|
416
|
-
var mc = null;
|
|
417
|
-
for (var i = 0; i < modelChoicesData.length; i++) {
|
|
418
|
-
if (modelChoicesData[i].alias === r.value) { mc = modelChoicesData[i]; break; }
|
|
419
|
-
}
|
|
420
|
-
if (mc) {
|
|
421
|
-
var routeId = pickRouteFor(mc);
|
|
422
|
-
if (routeId) { aliasWrites[mc.alias] = routeId; }
|
|
423
|
-
}
|
|
424
|
-
}
|
|
615
|
+
var aliasWrites = collectAliasWrites(r ? r.value : null, !!window.customDefaultModel);
|
|
425
616
|
await window.sidecarSetup.invoke('sidecar:save-config', dm, aliasWrites, (window.collectCouncilPicks && window.collectCouncilPicks()) || []);
|
|
426
617
|
var kc = Object.values(configuredKeys).filter(function(v) { return v; }).length;
|
|
427
618
|
await window.sidecarSetup.invoke('sidecar:setup-done', dm, kc);
|
|
@@ -551,6 +742,17 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
551
742
|
var idEl = el.querySelector('.write-preview-id');
|
|
552
743
|
if (idEl && routeId) { idEl.textContent = routeId; }
|
|
553
744
|
});
|
|
745
|
+
// issue 138: keep the resolved-id line in step with the route/model choice.
|
|
746
|
+
document.querySelectorAll('.model-resolved').forEach(function(el) {
|
|
747
|
+
var alias = el.getAttribute('data-alias');
|
|
748
|
+
var mc = null;
|
|
749
|
+
for (var i = 0; i < modelChoicesData.length; i++) {
|
|
750
|
+
if (modelChoicesData[i].alias === alias) { mc = modelChoicesData[i]; break; }
|
|
751
|
+
}
|
|
752
|
+
if (!mc) { return; }
|
|
753
|
+
var id = pickRouteFor(mc);
|
|
754
|
+
if (id) { el.textContent = id; }
|
|
755
|
+
});
|
|
554
756
|
}
|
|
555
757
|
|
|
556
758
|
document.addEventListener('input', function(e) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// electron/workspace-ui/live-seats.js
|
|
2
|
-
// Seats surface: dash, seatCells, SEATS_PANEL_EXCLUDED_ROLES,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// Seats surface: dash, seatCells, SEATS_PANEL_EXCLUDED_ROLES, seatsFromRunStats. Moved
|
|
3
|
+
// verbatim from live-model.js@ac7e7e12:53-285 (v4.8 PR0 size-gate split, zero behavior).
|
|
4
|
+
// `deadSeats` moved too, then PR5c split it to live-dead-seats.js :: deadSeats; it is
|
|
5
|
+
// only re-exported here. Loads BEFORE live-model.js, which re-exports on window.AmicusLive.
|
|
6
6
|
// ES5 IIFE, dual export, strict-CSP <script> loading — same shape as
|
|
7
7
|
// every renderer module. Comments write "PR 102", never the hash-number
|
|
8
8
|
// form (electron-token-drift HEX_RE trips on it — this file is scanned).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.1",
|
|
4
4
|
"mcpName": "io.github.BourbonDog/amicus",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"models:info": "node bin/amicus.js models",
|
|
64
64
|
"models:check": "node bin/amicus.js models --check",
|
|
65
65
|
"models:check:strict": "node bin/amicus.js models --check --strict",
|
|
66
|
+
"check:ci-alias-pins": "node scripts/check-ci-alias-pins.js",
|
|
66
67
|
"generate-icon": "node scripts/generate-icon.js",
|
|
67
68
|
"generate-docs": "node scripts/generate-docs.js",
|
|
68
69
|
"generate-docs:check": "node scripts/generate-docs.js --check",
|
|
@@ -14,6 +14,9 @@ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
|
|
|
14
14
|
const localProvidersCheck = require('./utils/doctor-local-providers-check');
|
|
15
15
|
// v4.6.2 PR1 (spec §4) — the 'anthropic-base-url' check body.
|
|
16
16
|
const baseUrlCheck = require('./utils/doctor-base-url-check');
|
|
17
|
+
// B3 (council review of PR 198, issue 195) — the 'aliases' check body,
|
|
18
|
+
// including its --fix repair of fabricated bare ids. Same split rationale.
|
|
19
|
+
const aliasCheck = require('./utils/doctor-alias-check');
|
|
17
20
|
|
|
18
21
|
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
|
|
19
22
|
|
|
@@ -41,6 +44,10 @@ function realDeps() {
|
|
|
41
44
|
collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
|
|
42
45
|
findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
|
|
43
46
|
findDriftedStoredAliases: (s, c) => require('./utils/alias-audit').findDriftedStoredAliases(s, c),
|
|
47
|
+
// B3: the narrow fabricated-bare-id repair class (pure detection) + the
|
|
48
|
+
// impure rewrite primitive `doctor --fix` calls when repairing one.
|
|
49
|
+
findFabricatedAliasRepairs: (s, c) => require('./utils/alias-audit').findFabricatedAliasRepairs(s, c),
|
|
50
|
+
repairAlias: (alias, newId) => aliasCheck.repairAlias(alias, newId),
|
|
44
51
|
hasOpencodeBinary: () => {
|
|
45
52
|
// Single source of truth shared with the runtime server-start guard.
|
|
46
53
|
const { ensureNodeModulesBinInPath, hasOpencodeBinary } = require('./utils/path-setup');
|
|
@@ -156,20 +163,10 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
156
163
|
: { id: 'catalog', name: 'Model catalog', status: 'warn', message: `stale (${hrs}h old)`, hint: 'amicus models --refresh' };
|
|
157
164
|
}));
|
|
158
165
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const stale = d.findStaleAliases(sources, catalog);
|
|
164
|
-
const drifted = d.findDriftedStoredAliases(sources, catalog);
|
|
165
|
-
if (stale.length === 0 && drifted.length === 0) {
|
|
166
|
-
return { id: 'aliases', name: 'Model aliases', status: 'ok', message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null };
|
|
167
|
-
}
|
|
168
|
-
const parts = [];
|
|
169
|
-
if (stale.length) { parts.push(`${stale.length} stale: ${stale.map(s => s.alias).join(', ')}`); }
|
|
170
|
-
if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map(s => s.alias).join(', ')}`); }
|
|
171
|
-
return { id: 'aliases', name: 'Model aliases', status: 'warn', message: parts.join('; '), hint: 'amicus models --check' };
|
|
172
|
-
}));
|
|
166
|
+
// B3: self-heals in place under --fix (fabricated bare ids only) — see
|
|
167
|
+
// utils/doctor-alias-check.js for the check body and utils/alias-audit.js's
|
|
168
|
+
// findFabricatedAliasRepairs for the detection rule.
|
|
169
|
+
checks.push(guard('aliases', 'Model aliases', () => aliasCheck.evaluateAliasesCheck(d)));
|
|
173
170
|
|
|
174
171
|
checks.push(guard('anthropic-base-url', 'ANTHROPIC_BASE_URL',
|
|
175
172
|
() => baseUrlCheck.evaluateAnthropicBaseUrl(d)));
|
|
@@ -38,7 +38,7 @@ function parseJudgeOutput(text, { labels, findingIds }) {
|
|
|
38
38
|
// ⚠️ v4.4.1 FINAL-REVIEW C. `JSON.parse('null')` SUCCEEDS — it returns null and
|
|
39
39
|
// throws nothing — so a body of literal `null` sailed past the catch above and
|
|
40
40
|
// `parsed.ranking` threw `TypeError: Cannot read properties of null`. parseDebateDefense
|
|
41
|
-
// (
|
|
41
|
+
// (parse-stage2.js :: parseDebateDefense) and parseRevote (parse-stage2.js :: parseRevote) below already carried this `!parsed` guard; the judge
|
|
42
42
|
// path and findings.js's validateFindings did not, which made it an asymmetry among
|
|
43
43
|
// five consumers of one extractor rather than a new rule. Guarded on BOTH derefs so
|
|
44
44
|
// a `null` body reports exactly what a keyless `{}` body already reported —
|
package/src/sidecar/setup.js
CHANGED
|
@@ -353,6 +353,73 @@ async function printDoctorFinale(deps = {}) {
|
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
/**
|
|
357
|
+
* #138 second level: after a family pick, let the user name a SPECIFIC model
|
|
358
|
+
* from that vendor. Returns the chosen catalog id, or null to keep the
|
|
359
|
+
* family default (bare Enter, an empty shortlist, or two invalid entries).
|
|
360
|
+
*
|
|
361
|
+
* The prompt deliberately avoids the substring "Pick a number":
|
|
362
|
+
* tests/sidecar/setup.test.js:392,475 branch on that literal and would
|
|
363
|
+
* answer '' here, leaving new coverage green but vacuous.
|
|
364
|
+
* @param {(q: string) => Promise<string>} ask
|
|
365
|
+
* @param {(line: string) => void} print
|
|
366
|
+
* @param {{suggested: Array<object>, rest: Array<object>, total: number}} shortlist
|
|
367
|
+
* @param {string} vendorPath
|
|
368
|
+
* @returns {Promise<string|null>}
|
|
369
|
+
*/
|
|
370
|
+
async function promptForVendorModel(ask, print, shortlist, vendorPath) {
|
|
371
|
+
if (!shortlist || shortlist.total === 0) { return null; }
|
|
372
|
+
|
|
373
|
+
let visible = shortlist.suggested;
|
|
374
|
+
const fmt = (r, i) => {
|
|
375
|
+
const price = r.pricePerMInput === null ? 'n/a' : `$${r.pricePerMInput.toFixed(2)}/M in`;
|
|
376
|
+
const ctx = r.contextLength === null || r.contextLength === undefined ? '' : ` · ctx ${r.contextLength}`;
|
|
377
|
+
return ` ${i + 1}) ${r.id}${ctx} · ${price}${r.isRecommended ? ' (recommended)' : ''}`;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const render = () => {
|
|
381
|
+
print('');
|
|
382
|
+
print(`Which ${vendorPath} model?`);
|
|
383
|
+
visible.forEach((r, i) => print(fmt(r, i)));
|
|
384
|
+
if (visible.length < shortlist.total) {
|
|
385
|
+
print(` … ${shortlist.total - visible.length} more`);
|
|
386
|
+
}
|
|
387
|
+
print('');
|
|
388
|
+
};
|
|
389
|
+
render();
|
|
390
|
+
|
|
391
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
392
|
+
const hint = visible.length < shortlist.total ? ", 'a' for all" : '';
|
|
393
|
+
const answer = (await ask(
|
|
394
|
+
`Choose 1-${visible.length}${hint}, a full model id, or Enter to keep the default: `
|
|
395
|
+
) || '').trim();
|
|
396
|
+
|
|
397
|
+
if (answer === '') { return null; }
|
|
398
|
+
if (answer.toLowerCase() === 'a' && visible.length < shortlist.total) {
|
|
399
|
+
visible = shortlist.suggested.concat(shortlist.rest);
|
|
400
|
+
render();
|
|
401
|
+
attempt--; // expanding the list is not a failed attempt
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (/^\d+$/.test(answer)) {
|
|
405
|
+
const n = Number.parseInt(answer, 10);
|
|
406
|
+
if (n >= 1 && n <= visible.length) { return visible[n - 1].id; }
|
|
407
|
+
}
|
|
408
|
+
if (answer.includes('/')) { return answer; }
|
|
409
|
+
// F1 (council review, PR 196): the final attempt used to fall through
|
|
410
|
+
// silently -- the loop just exited and the caller kept the family
|
|
411
|
+
// default with no feedback at all, so the user's last keystroke
|
|
412
|
+
// visibly did nothing. Both attempts now print, but the last one also
|
|
413
|
+
// states the consequence instead of implying a further retry.
|
|
414
|
+
if (attempt === 0) {
|
|
415
|
+
print(`Invalid choice: "${answer}".`);
|
|
416
|
+
} else {
|
|
417
|
+
print(`Invalid choice: "${answer}". Keeping the family default.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
356
423
|
/**
|
|
357
424
|
* Run the readline-based setup wizard (headless fallback)
|
|
358
425
|
*
|
|
@@ -486,6 +553,62 @@ async function runReadlineSetup() {
|
|
|
486
553
|
const fallback = getDefaultAliases()[chosen.alias];
|
|
487
554
|
if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
|
|
488
555
|
}
|
|
556
|
+
|
|
557
|
+
// #138: offer the family -> model second level. `pick.vendorPath` is
|
|
558
|
+
// the vendor whose catalog rows we drill into; a chosen id REPLACES
|
|
559
|
+
// the flagship route for this alias only. Guarded — a picker failure
|
|
560
|
+
// must never abort a setup run that has already collected keys.
|
|
561
|
+
//
|
|
562
|
+
// R4a (fix round 2, supersedes R4's noUpgrade disjunct; wording
|
|
563
|
+
// corrected in the F7 fix wave -- the previous wording claimed the
|
|
564
|
+
// governing rule was "never ask about the same vendor twice in one
|
|
565
|
+
// run", which this guard cannot implement and does not):
|
|
566
|
+
//
|
|
567
|
+
// `vendorAliasesWritten` holds PROVIDER names (runProviderDefaultPickers
|
|
568
|
+
// adds each entry of `foundKeys`, e.g. 'google'/'openai'/'anthropic'/
|
|
569
|
+
// 'deepseek'), while `chosen.alias` is a FAMILY ALIAS name ('gemini',
|
|
570
|
+
// 'gemini-pro', 'gpt', 'opus', 'deepseek'). The two prompts write
|
|
571
|
+
// DIFFERENT config keys -- the per-provider picker writes
|
|
572
|
+
// `config.aliases[provider]` (e.g. `aliases.google`), this drill-down
|
|
573
|
+
// writes `config.aliases[chosen.alias]` (e.g. `aliases.gemini`) -- so
|
|
574
|
+
// `!vendorAliasesWritten.has(chosen.alias)` only skips the drill-down
|
|
575
|
+
// when the alias STRING happens to collide with an already-written
|
|
576
|
+
// provider name. Today that's 'deepseek' alone (alias 'deepseek' ===
|
|
577
|
+
// provider 'deepseek'); every other family alias never collides, so
|
|
578
|
+
// this drill-down still fires for those even after the per-provider
|
|
579
|
+
// phase ran for that family's vendor -- correctly: the two prompts
|
|
580
|
+
// bind different keys, they are not "the same vendor twice".
|
|
581
|
+
//
|
|
582
|
+
// TRAP: re-keying this guard to `pick.vendorPath` (so it tests the
|
|
583
|
+
// actual vendor instead of the alias-name coincidence) looks like the
|
|
584
|
+
// obvious fix for the mismatch above and is NOT one -- it was
|
|
585
|
+
// measured to delete issue 138's feature entirely for every user
|
|
586
|
+
// holding a direct key, by skipping the drill-down for every family
|
|
587
|
+
// whose per-provider picker already ran this session. Leave this
|
|
588
|
+
// guard exactly as it is.
|
|
589
|
+
//
|
|
590
|
+
// `chosen.noUpgrade` does NOT imply no question was asked for this
|
|
591
|
+
// vendor: a user can type a known alias name (noUpgrade=true) for a
|
|
592
|
+
// vendor the per-provider phase already walked through this run, and
|
|
593
|
+
// that must still be skipped. Whenever noUpgrade is true AND the
|
|
594
|
+
// alias is genuinely unasked-about, `!vendorAliasesWritten.has(...)`
|
|
595
|
+
// is already true on its own, so dropping the noUpgrade disjunct
|
|
596
|
+
// loses no legitimate firing case -- only the double-ask.
|
|
597
|
+
if (pick && !vendorAliasesWritten.has(chosen.alias)) {
|
|
598
|
+
try {
|
|
599
|
+
const { buildModelShortlist } = require('../utils/model-shortlist');
|
|
600
|
+
const shortlist = buildModelShortlist(pick.vendorPath, {
|
|
601
|
+
catalog,
|
|
602
|
+
recommendedId: cfg.aliases[chosen.alias],
|
|
603
|
+
});
|
|
604
|
+
const specific = await promptForVendorModel(
|
|
605
|
+
askQuestion.bind(null, rl), console.log, shortlist, pick.vendorPath
|
|
606
|
+
);
|
|
607
|
+
if (specific) { cfg.aliases[chosen.alias] = specific; }
|
|
608
|
+
} catch (err) {
|
|
609
|
+
console.log(`Note: couldn't list ${pick.vendorPath} models (${err.message}).`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
489
612
|
} else {
|
|
490
613
|
cfg.default = chosen.modelId;
|
|
491
614
|
}
|
|
@@ -613,6 +736,7 @@ module.exports = {
|
|
|
613
736
|
createDefaultConfig,
|
|
614
737
|
deriveFreeAlias,
|
|
615
738
|
detectApiKeys,
|
|
739
|
+
promptForVendorModel,
|
|
616
740
|
runFreeCouncilBranch,
|
|
617
741
|
runInteractiveSetup,
|
|
618
742
|
runReadlineSetup,
|