amicus 4.9.7 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +125 -0
- package/README.md +2 -1
- package/bin/amicus.js +5 -0
- package/docs/ROADMAP.md +33 -5
- package/docs/architecture-map.md +41 -6
- package/docs/configuration.md +14 -8
- package/docs/council.md +140 -3
- package/docs/usage.md +29 -6
- package/electron/ipc-setup.js +6 -9
- package/electron/setup-ui-alias-groups.js +29 -124
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +3 -1
- package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
- package/src/cli-council-run-tools.js +168 -0
- package/src/cli-handlers-council-run.js +6 -6
- package/src/cli-handlers.js +8 -1
- package/src/cli.js +34 -1
- package/src/council/briefings-chair.js +1 -1
- package/src/council/briefings-task.js +11 -5
- package/src/council/briefings.js +25 -7
- package/src/council/report-lost-rows.js +89 -0
- package/src/council/report-md.js +3 -1
- package/src/council/report.js +3 -2
- package/src/council/run-degrade.js +22 -1
- package/src/council/run-finish.js +23 -1
- package/src/council/run-launch.js +33 -4
- package/src/council/run-retry-launch.js +9 -4
- package/src/council/run-retry.js +3 -0
- package/src/council/run-seat-tools-verify.js +296 -0
- package/src/council/run-seat-tools.js +274 -0
- package/src/council/run-server.js +41 -6
- package/src/council/run-stage1-launch.js +8 -3
- package/src/council/run.js +21 -21
- package/src/council/seat-tools.js +299 -0
- package/src/council/verdict-seats-reviewed.js +76 -6
- package/src/headless.js +136 -6
- package/src/mcp-council-pack-map.js +24 -0
- package/src/mcp-council-run.js +17 -15
- package/src/mcp-server.js +2 -2
- package/src/mcp-tools.js +15 -4
- package/src/opencode-client.js +26 -0
- package/src/pack/pack-validate.js +3 -1
- package/src/prompt-builder.js +2 -2
- package/src/sidecar/aliases-review-gate.js +65 -0
- package/src/sidecar/aliases-review-prompt.js +91 -0
- package/src/sidecar/aliases-review-render.js +116 -0
- package/src/sidecar/aliases-review.js +298 -0
- package/src/sidecar/aliases.js +279 -0
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/models.js +20 -7
- package/src/sidecar/session-utils.js +7 -34
- package/src/sidecar/setup.js +20 -18
- package/src/utils/agent-mapping.js +1 -1
- package/src/utils/alias-groups.js +128 -0
- package/src/utils/alias-proposals.js +151 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/alias-state.js +88 -0
- package/src/utils/alias-store.js +65 -0
- package/src/utils/config.js +10 -5
- package/src/utils/degrade.js +8 -0
- package/src/utils/model-id-siblings.js +106 -0
- package/src/utils/model-validator.js +1 -1
- package/src/utils/quick-picks.js +13 -32
- package/src/utils/text-sanitize.js +27 -0
package/src/sidecar/models.js
CHANGED
|
@@ -194,7 +194,7 @@ async function runCheck(args) {
|
|
|
194
194
|
// empty namespace explains stale/absent aliases downstream, and staying
|
|
195
195
|
// silent about it is the original defect.
|
|
196
196
|
for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
|
|
197
|
-
const driftLines = buildFallbackDriftReport(
|
|
197
|
+
const driftLines = buildFallbackDriftReport(catalogInfo);
|
|
198
198
|
if (stale.length === 0 && drifted.length === 0) {
|
|
199
199
|
process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
|
|
200
200
|
} else if (stale.length > 0) {
|
|
@@ -202,7 +202,11 @@ async function runCheck(args) {
|
|
|
202
202
|
process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
|
|
203
203
|
if (s.suggestions.length > 0) {
|
|
204
204
|
process.stdout.write(` candidates: ${s.suggestions.join(', ')}\n`);
|
|
205
|
-
|
|
205
|
+
// #238 D4: a user-config row is reviewable in the picker; a shipped pin
|
|
206
|
+
// that went stale can only be pinned OVER until the next release.
|
|
207
|
+
process.stdout.write(s.source === 'user-config'
|
|
208
|
+
? ' fix: amicus aliases --review\n'
|
|
209
|
+
: ` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]} (pins over the stale shipped default)\n`);
|
|
206
210
|
} else {
|
|
207
211
|
process.stdout.write(' no same-vendor candidates in catalog\n');
|
|
208
212
|
}
|
|
@@ -210,7 +214,7 @@ async function runCheck(args) {
|
|
|
210
214
|
}
|
|
211
215
|
for (const dr of drifted) {
|
|
212
216
|
process.stdout.write(`DRIFTED: ${dr.alias} -> ${dr.stored} (stored; current resolution: ${dr.current})\n`);
|
|
213
|
-
process.stdout.write(
|
|
217
|
+
process.stdout.write(' stored aliases don\'t follow catalog updates — review: amicus aliases --review\n');
|
|
214
218
|
}
|
|
215
219
|
if (driftLines.length > 0) {
|
|
216
220
|
process.stdout.write('Pinned fallback drift:\n');
|
|
@@ -233,12 +237,21 @@ async function runCheck(args) {
|
|
|
233
237
|
|
|
234
238
|
/**
|
|
235
239
|
* Non-blocking drift report: pinned family fallbacks vs live resolution.
|
|
236
|
-
*
|
|
237
|
-
*
|
|
240
|
+
* Accepts a catalogInfo (`{models, providerFailures}`) or a bare models array
|
|
241
|
+
* (older callers). Empty catalog → [] (cannot check). #238 §5: when the
|
|
242
|
+
* openrouter namespace itself was REJECTED this run, the catalog is missing the
|
|
243
|
+
* rows that make a pin look current, and a drift line computed from it would
|
|
244
|
+
* propose a downgrade — so the report is empty for that catalog. Never affects
|
|
245
|
+
* the exit code.
|
|
246
|
+
* @param {{models: Array<{id:string}>, providerFailures?: Array<{provider:string}>}|Array<{id:string}>} catalogOrInfo
|
|
238
247
|
* @returns {string[]} human-readable warning lines
|
|
239
248
|
*/
|
|
240
|
-
function buildFallbackDriftReport(
|
|
241
|
-
|
|
249
|
+
function buildFallbackDriftReport(catalogOrInfo) {
|
|
250
|
+
const info = Array.isArray(catalogOrInfo) ? { models: catalogOrInfo } : (catalogOrInfo || { models: [] });
|
|
251
|
+
const catalog = info.models || [];
|
|
252
|
+
if (catalog.length === 0) { return []; }
|
|
253
|
+
const failures = Array.isArray(info.providerFailures) ? info.providerFailures : [];
|
|
254
|
+
if (failures.some(f => f && f.provider === 'openrouter')) { return []; }
|
|
242
255
|
const lines = [];
|
|
243
256
|
for (const f of getFamilies()) {
|
|
244
257
|
const live = pickCurrent(catalog, 'openrouter/', f.vendorPath, f.idPattern);
|
|
@@ -20,8 +20,10 @@ const {
|
|
|
20
20
|
resolveExistingSessionDir
|
|
21
21
|
} = require('../session-manager');
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// HEARTBEAT_INTERVAL + createHeartbeat live in ./heartbeat (size-gate split:
|
|
24
|
+
// this file was already at the 300-line ceiling — spec 2026-09-11 §4 PR 2).
|
|
25
|
+
// Re-exported below so no caller changes.
|
|
26
|
+
const { HEARTBEAT_INTERVAL, createHeartbeat } = require('./heartbeat');
|
|
25
27
|
|
|
26
28
|
/** Session path utilities - eliminates magic strings across modules */
|
|
27
29
|
const SessionPaths = {
|
|
@@ -122,38 +124,6 @@ function outputSummary(summary) {
|
|
|
122
124
|
console.log(fenceSidecarOutput(summary));
|
|
123
125
|
}
|
|
124
126
|
|
|
125
|
-
/**
|
|
126
|
-
* Create a heartbeat that writes status to stderr periodically.
|
|
127
|
-
* When sessionDir is provided, includes message count and latest activity.
|
|
128
|
-
*
|
|
129
|
-
* @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
|
|
130
|
-
* @param {string} [sessionDir] - Session directory to read progress from
|
|
131
|
-
* @returns {{ stop: () => void }}
|
|
132
|
-
*/
|
|
133
|
-
function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
|
|
134
|
-
const startTime = Date.now();
|
|
135
|
-
const intervalId = setInterval(() => {
|
|
136
|
-
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
137
|
-
const mins = Math.floor(elapsed / 60);
|
|
138
|
-
const secs = elapsed % 60;
|
|
139
|
-
const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
|
|
140
|
-
|
|
141
|
-
if (sessionDir) {
|
|
142
|
-
const { readProgress } = require('./progress');
|
|
143
|
-
const progress = readProgress(sessionDir);
|
|
144
|
-
process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
|
|
145
|
-
} else {
|
|
146
|
-
process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
|
|
147
|
-
}
|
|
148
|
-
}, interval);
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
stop() {
|
|
152
|
-
clearInterval(intervalId);
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
127
|
/**
|
|
158
128
|
* Execute sidecar in either headless or interactive mode
|
|
159
129
|
* Consolidates the if/else pattern duplicated across start, resume, continue
|
|
@@ -228,6 +198,7 @@ async function executeMode(options) {
|
|
|
228
198
|
* @param {string} [options.client] - Client type (e.g. 'cowork', 'code-local')
|
|
229
199
|
* @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
|
|
230
200
|
* @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
|
|
201
|
+
* @param {Object<string, object>} [options.agents] - Extra agents to register (council seat agents)
|
|
231
202
|
* @param {string[]} [options.models] - Resolved executable id(s) actually launched on this
|
|
232
203
|
* server (#61 Task 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout)
|
|
233
204
|
* has no single default `config.model`, so this registers ALL of them in provider.models
|
|
@@ -257,6 +228,8 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
|
|
|
257
228
|
if (options.models) { serverOptions.models = options.models; }
|
|
258
229
|
if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
|
|
259
230
|
if (options.agentName) { serverOptions.agentName = options.agentName; }
|
|
231
|
+
// Spec 2026-09-11 §4: the council's two agents ride into buildServerOptions.
|
|
232
|
+
if (options.agents) { serverOptions.agents = options.agents; }
|
|
260
233
|
// Explicit per-call override only. Unset is the normal case and is correct:
|
|
261
234
|
// buildServerOptions resolves AMICUS_SERVER_START_TIMEOUT_MS / the platform
|
|
262
235
|
// default downstream, so forwarding `undefined` here would change nothing.
|
package/src/sidecar/setup.js
CHANGED
|
@@ -42,7 +42,7 @@ function addAlias(name, modelString) {
|
|
|
42
42
|
/**
|
|
43
43
|
* Ensure a config exists with the chosen default model. Read-modify-write:
|
|
44
44
|
* preserves every pre-existing top-level key (aliases, councils, …) and only
|
|
45
|
-
* fills in the default
|
|
45
|
+
* fills in the default — aliases are left untouched (absence follows, #238 Q9).
|
|
46
46
|
* @param {string} defaultModel - Default model alias or full model string
|
|
47
47
|
* @returns {object} The resulting config object
|
|
48
48
|
*/
|
|
@@ -51,17 +51,12 @@ function createDefaultConfig(defaultModel) {
|
|
|
51
51
|
const cfg = {
|
|
52
52
|
...existing,
|
|
53
53
|
default: existing.default || defaultModel,
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
// a measured hole — recorded as such rather than claimed as a fix.
|
|
58
|
-
aliases: { __proto__: null, ...getDefaultAliases(), ...(existing.aliases || {}) },
|
|
54
|
+
// #238 Q9: no seeding. A curated alias FOLLOWS the shipped pin by being
|
|
55
|
+
// ABSENT from config.aliases (D1); only what the user chose is written.
|
|
56
|
+
aliases: { __proto__: null, ...(existing.aliases || {}) },
|
|
59
57
|
};
|
|
60
58
|
saveConfig(cfg);
|
|
61
|
-
logger.info('Default config ensured', {
|
|
62
|
-
default: cfg.default,
|
|
63
|
-
aliasCount: Object.keys(cfg.aliases).length,
|
|
64
|
-
});
|
|
59
|
+
logger.info('Default config ensured', { default: cfg.default, aliasCount: Object.keys(cfg.aliases).length });
|
|
65
60
|
return cfg;
|
|
66
61
|
}
|
|
67
62
|
|
|
@@ -524,7 +519,8 @@ async function runReadlineSetup() {
|
|
|
524
519
|
return;
|
|
525
520
|
}
|
|
526
521
|
|
|
527
|
-
const { resolveQuickPicks,
|
|
522
|
+
const { resolveQuickPicks, toStorableRoute } = require('../utils/quick-picks');
|
|
523
|
+
const { stripGatewayPrefix } = require('../utils/curated-models');
|
|
528
524
|
const picks = resolveQuickPicks(catalog);
|
|
529
525
|
|
|
530
526
|
console.log('Choose your default model:');
|
|
@@ -545,7 +541,7 @@ async function runReadlineSetup() {
|
|
|
545
541
|
}
|
|
546
542
|
|
|
547
543
|
// Read-modify-write — never rebuild the alias table (no-clobber rule).
|
|
548
|
-
const cfg = loadConfig() || { aliases:
|
|
544
|
+
const cfg = loadConfig() || { aliases: {} };
|
|
549
545
|
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
550
546
|
if (chosen.alias) {
|
|
551
547
|
cfg.default = chosen.alias;
|
|
@@ -555,11 +551,16 @@ async function runReadlineSetup() {
|
|
|
555
551
|
// pointing at that alias name is fine (the user's explicit overall-default
|
|
556
552
|
// choice), but the alias's VALUE must stay the vendor phase's tier choice --
|
|
557
553
|
// skip the curated-flagship upgrade so it isn't discarded.
|
|
554
|
+
// #238 Q9: write the chosen default's LIVE pick only when it differs
|
|
555
|
+
// from the shipped pin, and say so — a pin the user was told about.
|
|
556
|
+
// Otherwise leave the key alone: absent = follows (D1).
|
|
558
557
|
if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
558
|
+
const live = toStorableRoute(pick, { models: catalog, providerFailures });
|
|
559
|
+
const shipped = getDefaultAliases()[chosen.alias];
|
|
560
|
+
if (live && stripGatewayPrefix(live) !== stripGatewayPrefix(shipped)) {
|
|
561
|
+
cfg.aliases[chosen.alias] = live;
|
|
562
|
+
console.log(`${chosen.alias} → ${live} (live flagship differs from the shipped ${shipped} — pinned)`);
|
|
563
|
+
}
|
|
563
564
|
}
|
|
564
565
|
|
|
565
566
|
// #138: offer the family -> model second level. `pick.vendorPath` is
|
|
@@ -608,7 +609,7 @@ async function runReadlineSetup() {
|
|
|
608
609
|
const shortlist = buildModelShortlist(pick.vendorPath, {
|
|
609
610
|
catalog,
|
|
610
611
|
providerFailures,
|
|
611
|
-
recommendedId: cfg.aliases[chosen.alias],
|
|
612
|
+
recommendedId: cfg.aliases[chosen.alias] || getDefaultAliases()[chosen.alias],
|
|
612
613
|
});
|
|
613
614
|
const specific = await promptForVendorModel(
|
|
614
615
|
askQuestion.bind(null, rl), console.log, shortlist, pick.vendorPath
|
|
@@ -626,7 +627,8 @@ async function runReadlineSetup() {
|
|
|
626
627
|
|
|
627
628
|
console.log('');
|
|
628
629
|
console.log(`Default model set to: ${cfg.default}`);
|
|
629
|
-
|
|
630
|
+
const pinned = Object.keys(cfg.aliases).length;
|
|
631
|
+
console.log(`Config saved (${pinned} pinned alias${pinned === 1 ? '' : 'es'}; the rest follow the shipped recommendations — amicus aliases).`);
|
|
630
632
|
console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
|
|
631
633
|
|
|
632
634
|
// C8: compact doctor summary, best-effort (see printDoctorFinale).
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* OpenCode Native Agents (https://opencode.ai/docs/agents/):
|
|
7
7
|
* - Chat: Reads auto, writes/bash ask permission (interactive default)
|
|
8
8
|
* - Build: Default primary agent with full tool access
|
|
9
|
-
* - Plan:
|
|
9
|
+
* - Plan: Analysis and planning without edits (reads, searches and shell allowed)
|
|
10
10
|
* - General: Full-access agent for research
|
|
11
11
|
* - Explore: Read-only agent for codebase exploration
|
|
12
12
|
*
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/alias-groups
|
|
3
|
+
* Vendor-derived alias grouping (issue 213). Moved VERBATIM out of
|
|
4
|
+
* `electron/setup-ui-alias-groups.js` (#238 PR1 fix wave F5): `src/` code
|
|
5
|
+
* (src/sidecar/aliases.js — the CLI `amicus aliases` list) must not require
|
|
6
|
+
* from `electron/`, a layering violation the whole-branch review caught.
|
|
7
|
+
* `electron/setup-ui-alias-groups.js` re-exports every symbol here so its
|
|
8
|
+
* own callers (setup-ui-aliases.js, setup-ui.js) and their tests keep
|
|
9
|
+
* working untouched.
|
|
10
|
+
*
|
|
11
|
+
* REUSE NOTE: the vendor parse is `vendorOf` from src/sidecar/fallback-chains.js
|
|
12
|
+
* — the existing primitive, imported, not re-implemented. It PARSES a vendor
|
|
13
|
+
* segment (it never emits an id that gets called), which is the same
|
|
14
|
+
* ban-exempt category as the other allowlisted `vendorOf` callers in
|
|
15
|
+
* .eslintrc.js. `groupModelsByFamily` (src/utils/model-fetcher.js) is
|
|
16
|
+
* deliberately NOT reused: it keys on `id.split('/')[0]`, so every
|
|
17
|
+
* `openrouter/...` alias would collapse into a single "OpenRouter" bucket —
|
|
18
|
+
* exactly the grouping this file exists to avoid. Its DISPLAY half
|
|
19
|
+
* (PROVIDER_FAMILY_NAMES) is reused below.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const { vendorOf } = require('../sidecar/fallback-chains');
|
|
25
|
+
const { PROVIDER_FAMILY_NAMES, listDirectProviders } = require('./provider-registry');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Display names for vendors seen in alias routes.
|
|
29
|
+
*
|
|
30
|
+
* DISPLAY ONLY — deliberately not folded into provider-registry's PROVIDERS,
|
|
31
|
+
* which is a *capability* registry (env var, direct-vs-gateway, live fetch).
|
|
32
|
+
* KNOWN_PROVIDERS / PROVIDER_ENV_MAP are derived from that list, so adding
|
|
33
|
+
* `z-ai` there would claim Amicus can hold a z-ai API key. The five real
|
|
34
|
+
* providers keep their single source of truth via PROVIDER_FAMILY_NAMES.
|
|
35
|
+
* Module-private: nothing outside this file requires it directly.
|
|
36
|
+
*/
|
|
37
|
+
const ALIAS_VENDOR_LABELS = {
|
|
38
|
+
...PROVIDER_FAMILY_NAMES,
|
|
39
|
+
// Vendors reachable through the gateway (curated + commonly pinned)
|
|
40
|
+
'qwen': 'Qwen',
|
|
41
|
+
'mistralai': 'Mistral AI',
|
|
42
|
+
'z-ai': 'Z.AI',
|
|
43
|
+
'minimax': 'MiniMax',
|
|
44
|
+
'x-ai': 'xAI',
|
|
45
|
+
'moonshotai': 'Moonshot AI',
|
|
46
|
+
'bytedance-seed': 'ByteDance Seed',
|
|
47
|
+
'thinkingmachines': 'Thinking Machines',
|
|
48
|
+
'cognitivecomputations': 'Cognitive Computations',
|
|
49
|
+
'inclusionai': 'InclusionAI',
|
|
50
|
+
'nvidia': 'NVIDIA',
|
|
51
|
+
'cohere': 'Cohere',
|
|
52
|
+
'meta-llama': 'Meta Llama',
|
|
53
|
+
'nousresearch': 'Nous Research',
|
|
54
|
+
'perplexity': 'Perplexity',
|
|
55
|
+
'microsoft': 'Microsoft',
|
|
56
|
+
'ai21': 'AI21',
|
|
57
|
+
'amazon': 'Amazon',
|
|
58
|
+
// Local providers (src/utils/local-providers.js PRESETS / VALID_FLAVORS)
|
|
59
|
+
'ollama': 'Ollama',
|
|
60
|
+
'lmstudio': 'LM Studio',
|
|
61
|
+
'vllm': 'vLLM',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `some-new-vendor` -> `Some New Vendor`, so an unmapped vendor is not a raw slug.
|
|
66
|
+
* @param {string} vendor
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function titleCaseVendor(vendor) {
|
|
70
|
+
return String(vendor).split(/[-_]/).filter(Boolean)
|
|
71
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Vendor key for an alias route. Wraps the shared `vendorOf` with the two
|
|
76
|
+
* normalisations issue 213 flagged: case, and the leading `~` of a floating
|
|
77
|
+
* OpenRouter id (`openrouter/~z-ai/glm-latest` must not form a second group
|
|
78
|
+
* next to `z-ai`).
|
|
79
|
+
* @param {string} route @returns {string} '' when there is no usable route
|
|
80
|
+
*/
|
|
81
|
+
function aliasVendorOf(route) {
|
|
82
|
+
const v = vendorOf(route).toLowerCase();
|
|
83
|
+
return v.charAt(0) === '~' ? v.slice(1) : v;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Display label for a vendor key.
|
|
88
|
+
* hasOwnProperty, not a bare lookup: vendor is derived from a user-editable
|
|
89
|
+
* route, and `__proto__`/`constructor` would otherwise return prototype junk.
|
|
90
|
+
* @param {string} vendor @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function vendorLabel(vendor) {
|
|
93
|
+
if (!vendor) { return 'Other'; }
|
|
94
|
+
const hit = Object.prototype.hasOwnProperty.call(ALIAS_VENDOR_LABELS, vendor)
|
|
95
|
+
? ALIAS_VENDOR_LABELS[vendor] : null;
|
|
96
|
+
return hit || titleCaseVendor(vendor);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Direct-route vendors render first; everything else sorts by label. @type {string[]} */
|
|
100
|
+
const PREFERRED_VENDOR_ORDER = listDirectProviders();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Bucket an alias map by route vendor.
|
|
104
|
+
* INVARIANT: every own key of `aliases` lands in exactly one returned group —
|
|
105
|
+
* there is no whitelist to miss, and the empty vendor is a real catch-all.
|
|
106
|
+
* Order within a group follows the config's own key order.
|
|
107
|
+
* @param {Object<string,string>} aliases
|
|
108
|
+
* @returns {Array<{vendor: string, label: string, keys: string[]}>}
|
|
109
|
+
*/
|
|
110
|
+
function groupAliases(aliases) {
|
|
111
|
+
const byVendor = new Map();
|
|
112
|
+
for (const key of Object.keys(aliases || {})) {
|
|
113
|
+
const vendor = aliasVendorOf(aliases[key]);
|
|
114
|
+
if (!byVendor.has(vendor)) { byVendor.set(vendor, []); }
|
|
115
|
+
byVendor.get(vendor).push(key);
|
|
116
|
+
}
|
|
117
|
+
const rank = (vendor) => {
|
|
118
|
+
if (!vendor) { return Number.MAX_SAFE_INTEGER; } // catch-all group last
|
|
119
|
+
const i = PREFERRED_VENDOR_ORDER.indexOf(vendor);
|
|
120
|
+
return i === -1 ? PREFERRED_VENDOR_ORDER.length : i;
|
|
121
|
+
};
|
|
122
|
+
return Array.from(byVendor.entries())
|
|
123
|
+
.map(([vendor, keys]) => ({ vendor, label: vendorLabel(vendor), keys }))
|
|
124
|
+
.sort((a, b) => rank(a.vendor) - rank(b.vendor) ||
|
|
125
|
+
a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { groupAliases, aliasVendorOf, vendorLabel, titleCaseVendor, PREFERRED_VENDOR_ORDER };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/alias-proposals
|
|
3
|
+
* The alias review ENGINE (#238 §2): pure, no I/O, no prompts. Turns
|
|
4
|
+
* (config.aliases, DEFAULT_ALIASES, catalogInfo, dismissed, retired, notable)
|
|
5
|
+
* into ONE proposal per alias (Q4), for the CLI picker and the Electron
|
|
6
|
+
* "Needs review" section to render and for their sinks to write.
|
|
7
|
+
*
|
|
8
|
+
* Only PINNED aliases propose (D1): a following alias resolves to the shipped
|
|
9
|
+
* pin and cannot drift. The §5 DISPLAY gate is applied here: candidates never
|
|
10
|
+
* come from a non-authoritative row or a rejected namespace, and a sibling
|
|
11
|
+
* (model-id-siblings.js) is always strictly newer. A stale pin's
|
|
12
|
+
* `replacement` candidates are ranked by similarity (not recency) and sit
|
|
13
|
+
* after `follow` for a curated alias. A replacement never repeats an id
|
|
14
|
+
* already listed as `follow`. The WRITE gate (a fresh catalog) is the
|
|
15
|
+
* renderer's, at accept time.
|
|
16
|
+
*
|
|
17
|
+
* Own keys only: a `__proto__`/`toString` alias is a custom row here as it is
|
|
18
|
+
* everywhere else in the alias tables.
|
|
19
|
+
*
|
|
20
|
+
* `retired` and `notable` are inputs `amicus aliases` does not supply yet:
|
|
21
|
+
* Phase 2 (`curated-pins.json`) ships the retirement data and Phase 4 the
|
|
22
|
+
* notable list (spec §7); the paths are built and tested here so they are
|
|
23
|
+
* not written twice, and are inert from the CLI until then (council r2, D3).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { listAliasRows } = require('./alias-state');
|
|
29
|
+
const { newestSibling } = require('./model-id-siblings');
|
|
30
|
+
const { findStaleAliases, suggestReplacements } = require('./alias-audit');
|
|
31
|
+
const { stripGatewayPrefix } = require('./curated-models');
|
|
32
|
+
|
|
33
|
+
const own = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
|
|
34
|
+
const providerOf = (id) => (typeof id === 'string' ? id.split('/')[0] : '');
|
|
35
|
+
const sameModel = (a, b) => typeof a === 'string' && typeof b === 'string' && stripGatewayPrefix(a) === stripGatewayPrefix(b);
|
|
36
|
+
|
|
37
|
+
/** §5 rules 1–2: rows a proposal may name. */
|
|
38
|
+
function candidateRows(models, failures) {
|
|
39
|
+
return models.filter(m => m && typeof m.id === 'string' && m.authoritative !== false && !failures.has(providerOf(m.id)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The §5 display gate's id set, exposed for the picker (#249 r1 R2): a typed
|
|
44
|
+
* "choose another" id that names a real catalog row must still fail if that
|
|
45
|
+
* row is a floor entry (`authoritative: false`) or sits in a rejected
|
|
46
|
+
* namespace -- the numbered menu would never have offered it as a candidate
|
|
47
|
+
* either. Reuses `candidateRows` so the two paths can never disagree.
|
|
48
|
+
* @param {{models?: Array, providerFailures?: Array}|null} catalogInfo
|
|
49
|
+
* @returns {string[]} ids of every row the §5 display gate allows as a candidate
|
|
50
|
+
*/
|
|
51
|
+
function gatedCatalogIds(catalogInfo) {
|
|
52
|
+
if (!catalogInfo || typeof catalogInfo !== 'object') { return []; }
|
|
53
|
+
const models = Array.isArray(catalogInfo.models) ? catalogInfo.models : [];
|
|
54
|
+
const rawFailures = catalogInfo.providerFailures;
|
|
55
|
+
const failures = new Set((Array.isArray(rawFailures) ? rawFailures : []).map(f => f && f.provider).filter(Boolean));
|
|
56
|
+
return candidateRows(models, failures).map(m => m.id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function proposeForRow(r, ctx) {
|
|
60
|
+
if (r.state !== 'pinned' || own(ctx.retired, r.alias)) { return null; }
|
|
61
|
+
if (ctx.failures.has(providerOf(r.id))) { return null; } // its namespace cannot be judged
|
|
62
|
+
// Staleness is judged against the RAW catalog on purpose (council r2,
|
|
63
|
+
// B1/D4): the §5 gate governs what is PROPOSED, not what is condemned --
|
|
64
|
+
// on a keyless machine the only Anthropic rows are the hardcoded floor,
|
|
65
|
+
// and judging liveness against the gated set would call every working
|
|
66
|
+
// Anthropic pin stale and propose a replacement for each. A floor row is
|
|
67
|
+
// not evidence the provider serves a model; it is not evidence it
|
|
68
|
+
// stopped, either.
|
|
69
|
+
const stale = findStaleAliases([{ alias: r.alias, model: r.id, source: 'user-config' }], ctx.models).length === 1;
|
|
70
|
+
const sibling = newestSibling(r.id, ctx.candidateIds);
|
|
71
|
+
// Order (fix round 1, Finding 3): sibling first, UNLESS it is the shipped
|
|
72
|
+
// model itself — `follow` already names that id, so listing it twice as
|
|
73
|
+
// both "newer-sibling" and "follow" would be a display duplicate. `follow`
|
|
74
|
+
// outranks similarity-ranked replacements: a human-curated shipped pin is
|
|
75
|
+
// a better answer than a same-vendor guess, and a sibling was already
|
|
76
|
+
// found (even if hidden here as identical to `follow`) means the
|
|
77
|
+
// structurally-aware comparator has already answered "what's newer" —
|
|
78
|
+
// replacements are only offered when that comparator found nothing at all.
|
|
79
|
+
const differs = r.curated && !sameModel(r.shipped, r.id);
|
|
80
|
+
// F2: a sibling identical to the shipped id is not a DISTINCT candidate (it
|
|
81
|
+
// never reaches `candidates` below), so it must not be named in `reasons`
|
|
82
|
+
// either — reasons describes what was actually offered, not every signal
|
|
83
|
+
// the engine looked at.
|
|
84
|
+
const siblingIsCandidate = !!sibling && !sameModel(sibling, r.shipped);
|
|
85
|
+
const reasons = [];
|
|
86
|
+
if (stale) { reasons.push('stale'); }
|
|
87
|
+
if (siblingIsCandidate) { reasons.push('newer-sibling'); }
|
|
88
|
+
if (differs) { reasons.push('differs-from-shipped'); }
|
|
89
|
+
if (reasons.length === 0) { return null; }
|
|
90
|
+
const candidates = [];
|
|
91
|
+
if (siblingIsCandidate) { candidates.push({ id: sibling, why: 'newer-sibling', evidence: {} }); }
|
|
92
|
+
if (differs) { candidates.push({ id: r.shipped, why: 'follow', evidence: {} }); }
|
|
93
|
+
if (stale && !sibling) {
|
|
94
|
+
// Fix round 2 (ruling: DEDUPE): a replacement never repeats an id
|
|
95
|
+
// already listed (in practice, `follow`'s) — the same id under two
|
|
96
|
+
// rationales reads as a picker bug. `suggestReplacements` still caps its
|
|
97
|
+
// OWN output at up to 3; dropping a duplicate here can leave fewer than
|
|
98
|
+
// 3, never more (no backfill).
|
|
99
|
+
const seen = new Set(candidates.map(c => c.id));
|
|
100
|
+
for (const id of suggestReplacements(r.id, ctx.candidates)) {
|
|
101
|
+
if (!seen.has(id)) { candidates.push({ id, why: 'replacement', evidence: {} }); }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const dismissKey = `${r.alias}@${candidates.length ? candidates[0].id : r.id}`;
|
|
105
|
+
if (own(ctx.dismissed, dismissKey)) { return null; }
|
|
106
|
+
return { alias: r.alias, state: 'pinned', current: r.id, shipped: r.shipped, curated: r.curated, reasons, candidates, dismissKey };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function proposeNotable(entry, ctx) {
|
|
110
|
+
if (!entry || typeof entry.id !== 'string' || typeof entry.suggestedAlias !== 'string') { return null; }
|
|
111
|
+
if (own(ctx.retired, entry.suggestedAlias) || !ctx.candidateIds.includes(entry.id)) { return null; }
|
|
112
|
+
// Fix round 1, Finding 1: a notable must not shadow an alias NAME that
|
|
113
|
+
// already exists (curated or user, pinned or following) — rule 10 checked
|
|
114
|
+
// only the model, not the name, so a notable naming an already-pinned or
|
|
115
|
+
// already-taken alias produced a second, conflicting proposal for the same
|
|
116
|
+
// name (an 'unmapped' one beside the real 'pinned'/'following' one, in the
|
|
117
|
+
// worst case sharing a dismissKey with it).
|
|
118
|
+
if (ctx.names.has(entry.suggestedAlias)) { return null; }
|
|
119
|
+
if (ctx.mapped.some(id => sameModel(id, entry.id))) { return null; }
|
|
120
|
+
const dismissKey = `${entry.suggestedAlias}@${entry.id}`;
|
|
121
|
+
if (own(ctx.dismissed, dismissKey)) { return null; }
|
|
122
|
+
return { alias: entry.suggestedAlias, state: 'unmapped', current: null, shipped: null, curated: false,
|
|
123
|
+
reasons: ['notable-unmapped'], candidates: [{ id: entry.id, why: 'notable', evidence: { note: entry.note || '' } }], dismissKey };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {{userAliases: object|null, defaults: object, catalogInfo: {models?: Array, providerFailures?: Array}|null,
|
|
128
|
+
* retired?: object, notable?: Array<{id:string, suggestedAlias:string, note?:string}>, dismissed?: object}} input
|
|
129
|
+
* @returns {Array<object>} proposals — see the module docblock for the shape
|
|
130
|
+
*/
|
|
131
|
+
function buildAliasProposals({ userAliases, defaults, catalogInfo, retired = {}, notable = [], dismissed = {} }) {
|
|
132
|
+
const models = (catalogInfo && Array.isArray(catalogInfo.models)) ? catalogInfo.models : [];
|
|
133
|
+
if (models.length === 0 || !defaults) { return []; }
|
|
134
|
+
// Fix round 1, Finding 2 (rule 11 "never throws on odd input"): a truthy
|
|
135
|
+
// non-array providerFailures (e.g. `{}`) must not reach `.map`.
|
|
136
|
+
const rawFailures = catalogInfo.providerFailures;
|
|
137
|
+
const failures = new Set((Array.isArray(rawFailures) ? rawFailures : []).map(f => f && f.provider).filter(Boolean));
|
|
138
|
+
const candidates = candidateRows(models, failures);
|
|
139
|
+
const rows = listAliasRows(userAliases, defaults);
|
|
140
|
+
const ctx = {
|
|
141
|
+
models, failures, candidates, candidateIds: candidates.map(m => m.id),
|
|
142
|
+
retired: retired || {}, dismissed: dismissed || {},
|
|
143
|
+
mapped: rows.map(r => r.id), names: new Set(rows.map(r => r.alias)),
|
|
144
|
+
};
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const r of rows) { const p = proposeForRow(r, ctx); if (p) { out.push(p); } }
|
|
147
|
+
for (const n of (Array.isArray(notable) ? notable : [])) { const p = proposeNotable(n, ctx); if (p) { out.push(p); } }
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { buildAliasProposals, gatedCatalogIds };
|
|
@@ -39,7 +39,7 @@ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
|
|
|
39
39
|
}
|
|
40
40
|
throw new Error(
|
|
41
41
|
`Alias '${alias}' is configured but has no model value. ` +
|
|
42
|
-
`Fix with: amicus setup --add-alias ${alias}=provider/model`
|
|
42
|
+
`Fix with: amicus aliases --review, or amicus setup --add-alias ${alias}=provider/model`
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/alias-state
|
|
3
|
+
* Following-vs-pinned state for model aliases (#238 D1) and the normalization
|
|
4
|
+
* that keeps config.json truthful (D6).
|
|
5
|
+
*
|
|
6
|
+
* A curated alias FOLLOWS the shipped pin when its name is ABSENT from
|
|
7
|
+
* `config.aliases` — `config.js :: getEffectiveAliases` already merges
|
|
8
|
+
* `{...DEFAULT_ALIASES, ...userAliases}`, so absence resolves to the shipped
|
|
9
|
+
* id on every consumer. A present key is a PIN. Downgrade-safe in the narrow
|
|
10
|
+
* sense: an older amicus reads a normalized config without error and
|
|
11
|
+
* honours every present key (a pin) unchanged; the aliases that FOLLOW
|
|
12
|
+
* resolve to that older binary's shipped pins — following means tracking
|
|
13
|
+
* whichever binary runs.
|
|
14
|
+
*
|
|
15
|
+
* Normalization drops any key whose value equals the shipped default, with one
|
|
16
|
+
* Notice per key. It runs inside `saveConfig` (so every write converges) and on
|
|
17
|
+
* entry to `amicus aliases`. It is idempotent and never a startup write.
|
|
18
|
+
*
|
|
19
|
+
* Own keys only, everywhere: a `toString`/`constructor` name in a user
|
|
20
|
+
* config is a plain custom alias, never a curated one; a `__proto__` key
|
|
21
|
+
* gets no row at all — `saveConfig` can never persist it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
27
|
+
|
|
28
|
+
/** @param {string} alias @param {object} defaults @returns {boolean} */
|
|
29
|
+
function isCurated(alias, defaults) {
|
|
30
|
+
return !!defaults && typeof alias === 'string' && own(defaults, alias);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} aliases user `config.aliases`
|
|
35
|
+
* @param {object} defaults the shipped map (`DEFAULT_ALIASES`)
|
|
36
|
+
* @param {(line: string) => void} [notify] one call per removed key
|
|
37
|
+
* @returns {{aliases: object, removed: Array<{alias: string, id: string}>}}
|
|
38
|
+
*/
|
|
39
|
+
function normalizeAliases(aliases, defaults, notify) {
|
|
40
|
+
// Null-prototype (not plain `{}`): `out['__proto__'] = value` on a plain
|
|
41
|
+
// object hits the inherited accessor setter and is silently LOST -- the
|
|
42
|
+
// same footgun `saveConfig`'s pre-existing stripper documents for `cleaned`.
|
|
43
|
+
// Not reachable from `saveConfig` today (that stripper rejects `__proto__`
|
|
44
|
+
// before this function ever sees it), but this module is also entered
|
|
45
|
+
// directly from `amicus aliases` on raw config, so `out` must be safe on
|
|
46
|
+
// its own. `JSON.stringify` serializes a null-prototype object's own keys
|
|
47
|
+
// exactly like a plain one, so returning it as-is is safe for saveConfig.
|
|
48
|
+
const out = { __proto__: null };
|
|
49
|
+
const removed = [];
|
|
50
|
+
if (!aliases || typeof aliases !== 'object') { return { aliases: out, removed }; }
|
|
51
|
+
for (const [alias, value] of Object.entries(aliases)) {
|
|
52
|
+
if (typeof value === 'string' && isCurated(alias, defaults) && defaults[alias] === value) {
|
|
53
|
+
removed.push({ alias, id: value });
|
|
54
|
+
if (typeof notify === 'function') {
|
|
55
|
+
notify(`Notice: alias '${alias}' matches the shipped recommendation (${value}) — now following\n`);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
out[alias] = value;
|
|
60
|
+
}
|
|
61
|
+
return { aliases: out, removed };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {object|null} userAliases
|
|
66
|
+
* @param {object} defaults
|
|
67
|
+
* @returns {Array<{alias:string,id:string,state:'following'|'pinned',curated:boolean,shipped:string|null}>}
|
|
68
|
+
*/
|
|
69
|
+
function listAliasRows(userAliases, defaults) {
|
|
70
|
+
const user = (userAliases && typeof userAliases === 'object') ? userAliases : {};
|
|
71
|
+
const rows = [];
|
|
72
|
+
for (const alias of Object.keys(defaults || {})) {
|
|
73
|
+
const pinned = own(user, alias) && typeof user[alias] === 'string';
|
|
74
|
+
rows.push({ alias, id: pinned ? user[alias] : defaults[alias], state: pinned ? 'pinned' : 'following',
|
|
75
|
+
curated: true, shipped: defaults[alias] });
|
|
76
|
+
}
|
|
77
|
+
for (const alias of Object.keys(user)) {
|
|
78
|
+
// #249 r1 R8b: '__proto__' can never be persisted (saveConfig's own
|
|
79
|
+
// stripper rejects it, config.js :: saveConfig) -- a row for it here
|
|
80
|
+
// would show state the user can never actually reach, so it gets no
|
|
81
|
+
// row and no proposal.
|
|
82
|
+
if (alias === '__proto__' || isCurated(alias, defaults) || typeof user[alias] !== 'string') { continue; }
|
|
83
|
+
rows.push({ alias, id: user[alias], state: 'pinned', curated: false, shipped: null });
|
|
84
|
+
}
|
|
85
|
+
return rows;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { normalizeAliases, listAliasRows, isCurated };
|