amicus 4.8.0 → 4.9.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 +355 -0
- package/README.md +10 -5
- package/docs/CITATIONS.md +13 -5
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +55 -5
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +39 -2
- package/electron/main.js +46 -3
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +244 -32
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/live-seats.js +4 -4
- package/electron/workspace-ui/workspace-banners.js +30 -7
- package/electron/workspace-ui/workspace-matrix.js +23 -3
- package/electron/workspace-ui/workspace-seats.js +95 -79
- package/package.json +2 -1
- package/schemas/council-run.schema.json +2 -2
- package/schemas/council-tally.schema.json +17 -1
- package/schemas/council-verdict.schema.json +12 -4
- package/schemas/run.schema.json +6 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +88 -9
- package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
- package/skills/second-opinion/SKILL.md +151 -36
- package/src/cli-council-run-bench.js +98 -6
- package/src/cli-handlers-council-run.js +18 -6
- package/src/cli-handlers-council.js +57 -7
- package/src/cli-handlers-doctor.js +12 -15
- package/src/cli.js +3 -1
- package/src/council/anonymize.js +2 -1
- package/src/council/briefings-chair-task.js +161 -0
- package/src/council/briefings-chair.js +33 -8
- package/src/council/briefings-debate.js +79 -13
- package/src/council/briefings-stage2-task.js +236 -0
- package/src/council/briefings-stage2.js +103 -26
- package/src/council/briefings-task.js +167 -0
- package/src/council/briefings.js +41 -4
- package/src/council/chair-fallback.js +95 -0
- package/src/council/debate.js +38 -21
- package/src/council/findings.js +3 -2
- package/src/council/ledger.js +2 -2
- package/src/council/parse-stage2.js +64 -16
- package/src/council/report-cost.js +61 -0
- package/src/council/report-html.js +26 -4
- package/src/council/report-md.js +30 -2
- package/src/council/report.js +40 -37
- package/src/council/run-assemble.js +21 -6
- package/src/council/run-chair.js +44 -95
- package/src/council/run-debate-revote.js +81 -49
- package/src/council/run-debate.js +51 -34
- package/src/council/run-finish.js +5 -3
- package/src/council/run-retry-keys.js +4 -4
- package/src/council/run-retry-launch.js +4 -4
- package/src/council/run-retry-notes.js +72 -15
- package/src/council/run-stage1-launch.js +4 -4
- package/src/council/run-stage1-rows.js +9 -6
- package/src/council/run-stage2.js +81 -47
- package/src/council/run-stages.js +9 -21
- package/src/council/run-stats-entry.js +46 -1
- package/src/council/run.js +28 -13
- package/src/council/seats.js +2 -2
- package/src/council/stage1-bind.js +3 -2
- package/src/council/verdict-seat-loss.js +124 -0
- package/src/council/verdict.js +108 -99
- package/src/headless.js +256 -49
- package/src/mcp-council-bench.js +64 -3
- package/src/mcp-council-run.js +10 -3
- package/src/mcp-server.js +52 -12
- package/src/mcp-tools.js +41 -5
- package/src/observe/council-legs.js +2 -2
- package/src/opencode-client.js +19 -1
- package/src/pack/pack-forward.js +15 -12
- package/src/pack/pack-resolve.js +1 -1
- package/src/prompt-builder.js +17 -1
- package/src/sidecar/fanout-leg.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models.js +8 -1
- package/src/sidecar/read.js +34 -10
- package/src/sidecar/setup.js +124 -0
- package/src/template/render.js +16 -7
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/alias-shadow-writer.js +220 -0
- package/src/utils/alias-shadow.js +294 -0
- package/src/utils/config.js +1 -1
- package/src/utils/curated-models.js +16 -8
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +149 -0
- package/src/utils/engine-log-parse.js +289 -0
- package/src/utils/engine-log-tail.js +114 -0
- package/src/utils/engine-log.js +250 -0
- package/src/utils/engine-skew-records.js +146 -0
- package/src/utils/engine-skew.js +300 -0
- package/src/utils/gateway-router.js +10 -2
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-catalog.js +1 -1
- 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/utils/result-schema.js +10 -0
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +35 -9
- package/src/workspace/seat-space.js +10 -6
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,
|
package/src/template/render.js
CHANGED
|
@@ -14,12 +14,25 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const VAR_RE = /\{\{\s*([A-Za-z_][\w.]*)\s*\}\}/g;
|
|
17
|
+
// Single source: validation and rendering both derive from this set — adding an
|
|
18
|
+
// entry here is sufficient for both. A simple variable {{foo_bar}} reads
|
|
19
|
+
// data.fooBar (snake_case slot, camelCase data key).
|
|
17
20
|
const KNOWN_VARIABLES = ['prompt', 'artifact', 'artifact_path', 'date', 'project', 'var.<key>'];
|
|
18
21
|
|
|
19
22
|
function knownList() {
|
|
20
23
|
return KNOWN_VARIABLES.map((v) => `{{${v}}}`).join(', ');
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
function isSimpleVariable(name) {
|
|
27
|
+
// Reads KNOWN_VARIABLES live, not a load-time copy, so extending the
|
|
28
|
+
// exported set is sufficient (VAR_RE can never match the 'var.<key>' entry).
|
|
29
|
+
return name !== 'var.<key>' && KNOWN_VARIABLES.includes(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function dataKeyFor(name) {
|
|
33
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
/**
|
|
24
37
|
* Render a template with strict typo-safety rules:
|
|
25
38
|
* unknown variable -> error; slot present without its data -> error; data
|
|
@@ -46,7 +59,7 @@ function renderTemplate(text, data) {
|
|
|
46
59
|
}
|
|
47
60
|
continue;
|
|
48
61
|
}
|
|
49
|
-
if (!
|
|
62
|
+
if (!isSimpleVariable(name)) {
|
|
50
63
|
return { error: `Error: Unknown template variable {{${name}}}. Known: ${knownList()}` };
|
|
51
64
|
}
|
|
52
65
|
}
|
|
@@ -76,12 +89,8 @@ function renderTemplate(text, data) {
|
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
const rendered = String(text).replace(VAR_RE, (_, name) => {
|
|
79
|
-
if (name
|
|
80
|
-
|
|
81
|
-
if (name === 'artifact_path') { return data.artifactPath; }
|
|
82
|
-
if (name === 'date') { return data.date; }
|
|
83
|
-
if (name === 'project') { return data.project; }
|
|
84
|
-
return vars[name.slice(4)];
|
|
92
|
+
if (name.startsWith('var.')) { return vars[name.slice(4)]; }
|
|
93
|
+
return data[dataKeyFor(name)];
|
|
85
94
|
});
|
|
86
95
|
|
|
87
96
|
return { text: rendered, notices };
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Alias Audit (F5) — report + suggest
|
|
2
|
+
* Alias Audit (F5) — report + suggest for most classes; doctor --fix auto-repairs one narrow, mechanically-unambiguous class (B3).
|
|
3
3
|
*
|
|
4
4
|
* Finds aliases/routes pointing at models absent from the catalog and
|
|
5
5
|
* suggests current same-vendor replacements. Pure functions over inputs;
|
|
6
6
|
* collectAliasSources() does the gathering. Consumed by `amicus models
|
|
7
7
|
* --check` and the npm wrapper scripts.
|
|
8
|
+
*
|
|
9
|
+
* `findFabricatedAliasRepairs()` (B3, council review of PR 198) is the one
|
|
10
|
+
* exception to "never auto-repair": it detects the single class `doctor
|
|
11
|
+
* --fix` can safely rewrite unattended -- a bare id `classifyModel` proves
|
|
12
|
+
* `invalid` on the `direct` gateway AND for which the catalog holds an
|
|
13
|
+
* unambiguous OpenRouter twin (`pairAcrossGateways`, never string
|
|
14
|
+
* concatenation) -- still pure detection here; the actual write lives in
|
|
15
|
+
* `doctor-alias-check.js`'s `repairAlias()`. Every OTHER class this module
|
|
16
|
+
* finds (typo, retired model, ambiguous twin, drifted-but-live) stays
|
|
17
|
+
* report-and-suggest only, same as before -- this module does not become a
|
|
18
|
+
* general auto-repair tool.
|
|
8
19
|
*/
|
|
9
20
|
|
|
10
21
|
'use strict';
|
|
@@ -132,7 +143,10 @@ function suggestReplacements(staleModel, catalog, n = 3) {
|
|
|
132
143
|
* fresh `amicus setup` would seed today — the v4.6.1 release-gate class
|
|
133
144
|
* (stored `gemini` -> 3.1-flash-lite-preview: still catalog-listed so
|
|
134
145
|
* findStaleAliases passes it, no longer what the family resolves to).
|
|
135
|
-
* Report + suggest
|
|
146
|
+
* Report + suggest only -- unlike `findFabricatedAliasRepairs` below (B3's
|
|
147
|
+
* narrow, mechanically-unambiguous exception), THIS function never
|
|
148
|
+
* auto-repairs: a drifted-but-live target has no single unambiguous
|
|
149
|
+
* "correct" answer to converge on the way a fabricated id does.
|
|
136
150
|
*
|
|
137
151
|
* Only user-config rows are checked (defaults/curated follow the catalog by
|
|
138
152
|
* construction), only for aliases that are quick-pick families (a custom
|
|
@@ -178,4 +192,68 @@ function findDriftedStoredAliases(sources, catalog) {
|
|
|
178
192
|
.map(({ alias, model }) => ({ alias, stored: model, current: current.get(alias).display }));
|
|
179
193
|
}
|
|
180
194
|
|
|
181
|
-
|
|
195
|
+
/**
|
|
196
|
+
* B3 (council review of PR 198, issue 195): the narrow, mechanically-
|
|
197
|
+
* unambiguous class of stored alias `doctor --fix` may repair -- a bare
|
|
198
|
+
* `<vendor>/<model>` id in a 'user-config' row (what's actually persisted to
|
|
199
|
+
* `config.aliases`, the only source a repair can rewrite) that
|
|
200
|
+
* `classifyModel` proves `invalid` on the `direct` gateway AND for which the
|
|
201
|
+
* catalog contains an unambiguous OpenRouter twin (`pairAcrossGateways` --
|
|
202
|
+
* never string-concatenation). This is exactly the class the pre-fix
|
|
203
|
+
* v4.8.0 `chooseRowId`/`applyProviderDefault` could persist (an unconditional
|
|
204
|
+
* `openrouter/` strip with no catalog evidence); PR 198's
|
|
205
|
+
* `directFormIfProven` (model-canonicalization.js) now refuses to write it
|
|
206
|
+
* going forward, so this function finds the ones already ON DISK and
|
|
207
|
+
* converges them onto the same OpenRouter-prefixed answer the picker would
|
|
208
|
+
* offer today, rather than inventing a third one.
|
|
209
|
+
*
|
|
210
|
+
* Deliberately narrower than findStaleAliases: a typo'd, retired, or
|
|
211
|
+
* user-invented id also classifies `invalid`, but `pairAcrossGateways` can
|
|
212
|
+
* only find a twin when the extracted vendor + version token names a REAL
|
|
213
|
+
* model that still exists, under that exact normalized name, in the
|
|
214
|
+
* catalog's OpenRouter namespace -- a typo or a genuinely dead model can
|
|
215
|
+
* never satisfy that. Ambiguous (>1 match) or absent twins are left
|
|
216
|
+
* untouched -- still reported as stale by findStaleAliases, never repaired.
|
|
217
|
+
*
|
|
218
|
+
* DIVERGENT_VENDORS (e.g. anthropic) are excluded on purpose:
|
|
219
|
+
* model-canonicalization.js gates that set FIRST and unconditionally, so its
|
|
220
|
+
* predicates never strip a divergent vendor's prefix in the first place --
|
|
221
|
+
* a bare divergent-vendor alias could not have been produced by the bug
|
|
222
|
+
* this repairs, so treating one as fabricated here would be inventing a new
|
|
223
|
+
* class, not converging on the picker's own answer.
|
|
224
|
+
*
|
|
225
|
+
* Empty/absent catalog -> [] : classifyModel can only return 'unknown' with
|
|
226
|
+
* no catalog rows, and 'unknown' never authorises a repair -- no positive
|
|
227
|
+
* evidence, no write.
|
|
228
|
+
* @param {Array<{alias:string,model:string,source:string}>} sources
|
|
229
|
+
* @param {Array<{id:string}>} catalog
|
|
230
|
+
* @returns {Array<{alias:string,oldId:string,newId:string}>}
|
|
231
|
+
*/
|
|
232
|
+
function findFabricatedAliasRepairs(sources, catalog) {
|
|
233
|
+
if (!catalog || catalog.length === 0) { return []; }
|
|
234
|
+
const { classifyModel } = require('./model-classification');
|
|
235
|
+
const { pairAcrossGateways } = require('./gateway-route-catalog');
|
|
236
|
+
const { DIVERGENT_VENDORS } = require('./curated-models');
|
|
237
|
+
const catalogInfo = { models: catalog };
|
|
238
|
+
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const { alias, model, source } of sources) {
|
|
241
|
+
if (source !== 'user-config') { continue; }
|
|
242
|
+
if (typeof model !== 'string' || model.startsWith('openrouter/')) { continue; } // not bare
|
|
243
|
+
const parts = model.split('/');
|
|
244
|
+
if (parts.length < 2) { continue; } // not a <vendor>/<model> shape
|
|
245
|
+
const vendor = parts[0];
|
|
246
|
+
if (DIVERGENT_VENDORS.has(vendor)) { continue; }
|
|
247
|
+
const versionToken = parts.slice(1).join('/');
|
|
248
|
+
if (classifyModel(model, 'direct', catalogInfo) !== 'invalid') { continue; }
|
|
249
|
+
const paired = pairAcrossGateways(vendor, versionToken, catalogInfo);
|
|
250
|
+
if (!paired.openrouter) { continue; } // ambiguous or absent twin -- leave as a warning
|
|
251
|
+
out.push({ alias, oldId: model, newId: paired.openrouter });
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements,
|
|
258
|
+
findFabricatedAliasRepairs,
|
|
259
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/alias-shadow-writer
|
|
3
|
+
* The alias-shadow notice's WRITE half: say it without ever sinking the run.
|
|
4
|
+
*
|
|
5
|
+
* EXTRACTED from src/utils/alias-shadow.js (PR #207 round 4). That file already
|
|
6
|
+
* separated the CHECK (read two alias tables, compare canonical forms) from the
|
|
7
|
+
* WRITE (get one advisory line onto a stream that may be a closed pipe), and
|
|
8
|
+
* rounds 2 and 3 put two independent hardenings on the write half alone —
|
|
9
|
+
* `safeWrite`'s synchronous guard and `armStream`'s asynchronous one. Round 4's
|
|
10
|
+
* B1 landed a third. The seam was already there; the file was at 286/300, and
|
|
11
|
+
* shaving the measurement prose that makes these guards auditable would have
|
|
12
|
+
* been the wrong economy, so the write half moved out whole.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here was ever part of `alias-shadow.js`'s `module.exports` — these are
|
|
15
|
+
* internal helpers, not a public API — so there is no re-export shim and no
|
|
16
|
+
* import path anywhere in the tree that changes.
|
|
17
|
+
*
|
|
18
|
+
* Named mutants live with their red sets in tests/alias-shadow.test.js:
|
|
19
|
+
* "WRITERFATAL" (drop `safeWrite`'s try/catch), "STREAMFATAL" (drop
|
|
20
|
+
* `armStream`'s attach-once 'error' handler) and "STREAMDEAF" (round 5 — put
|
|
21
|
+
* the PURE no-op handler back, so the arming goes deaf to every class again
|
|
22
|
+
* instead of only the benign one).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Write a notice without ever letting the writer sink the run (round 2, B1).
|
|
29
|
+
*
|
|
30
|
+
* The 'never throws' contract used to cover only the CHECK: the guard wrapped
|
|
31
|
+
* `findAliasShadows`, so a writer that threw — a caller-supplied collector that
|
|
32
|
+
* rejects, a stream whose write throws — escaped and killed the launch this
|
|
33
|
+
* diagnosis exists to protect. Worse, on the failure branch it escaped a second
|
|
34
|
+
* time, because that branch announced itself through the SAME broken writer. A
|
|
35
|
+
* notice must never be fatal to what it is describing, so the write is swallowed
|
|
36
|
+
* here and nowhere else.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ This covers the SYNCHRONOUS half only. A piped stderr fails on a later turn
|
|
39
|
+
* and never throws from `write()` at all — see `armStream` below, which is the
|
|
40
|
+
* other half of the same contract.
|
|
41
|
+
* @param {(line: string) => void} out
|
|
42
|
+
* @param {string} line
|
|
43
|
+
*/
|
|
44
|
+
function safeWrite(out, line) {
|
|
45
|
+
try { out(line); } catch { /* a diagnosis must never sink the run it diagnoses */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The armed marker, keyed ON THE STREAM (PR #207 round 4, B1).
|
|
50
|
+
*
|
|
51
|
+
* It used to be a module-scoped `WeakSet`, which made "attach once" true only
|
|
52
|
+
* once per MODULE INSTANCE. `jest.resetModules()` builds a fresh registry, the
|
|
53
|
+
* fresh copy of this module starts with an empty set, and the SAME
|
|
54
|
+
* `process.stderr` collects a second listener — measured at SEVEN across
|
|
55
|
+
* tests/alias-shadow.test.js alone, against Node's 10-listener
|
|
56
|
+
* MaxListenersExceededWarning. That warning is emitted ASYNCHRONOUSLY onto
|
|
57
|
+
* `process.stderr.write`, which is the method several of this feature's absence
|
|
58
|
+
* controls replace and exact-match on, so the accumulation was a
|
|
59
|
+
* nondeterministic flake waiting for two more fixtures. A marker that lives on
|
|
60
|
+
* the stream is immune: it is the same object either way.
|
|
61
|
+
*
|
|
62
|
+
* `Symbol.for`, not `Symbol()`: the well-known-symbol registry is per REALM, so
|
|
63
|
+
* every instance of this module — reset registry, second resolved path, npx
|
|
64
|
+
* copy loaded beside a global one — computes the SAME key and therefore agrees
|
|
65
|
+
* about what is already armed. A module-local `Symbol()` would be a fresh key
|
|
66
|
+
* per instance and would re-arm exactly like the WeakSet did.
|
|
67
|
+
*
|
|
68
|
+
* It also retains nothing: a swapped-out stream (tests do this) is dropped with
|
|
69
|
+
* its own marker attached, where the WeakSet had to be weak on purpose.
|
|
70
|
+
*/
|
|
71
|
+
const ARMED = Symbol.for('amicus.alias-shadow.armed');
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The self-report marker (PR #207 round 5, A3+B2+D1+C2). Same `Symbol.for`
|
|
75
|
+
* registry discipline, and the same reason, as `ARMED` above: one report per
|
|
76
|
+
* STREAM, agreed on by every instance of this module.
|
|
77
|
+
*/
|
|
78
|
+
const REPORTED = Symbol.for('amicus.alias-shadow.reported');
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stream failures that mean "the reader went away", and nothing more.
|
|
82
|
+
*
|
|
83
|
+
* This is the class the arming exists for: `amicus … | head`, a closed MCP
|
|
84
|
+
* client, a terminal that went away mid-write. There is nobody left to tell, so
|
|
85
|
+
* telling is pointless — these are swallowed in silence. Everything else is a
|
|
86
|
+
* real fault and gets said out loud (see `armStream`).
|
|
87
|
+
*
|
|
88
|
+
* `EOF` is here beside `EPIPE` because that is what a severed pipe reports on
|
|
89
|
+
* Windows, where this is developed; `ERR_STREAM_DESTROYED` and
|
|
90
|
+
* `ERR_STREAM_WRITE_AFTER_END` are Node's own spellings of the same "the sink is
|
|
91
|
+
* gone" fact for a destroyed/ended stream.
|
|
92
|
+
*/
|
|
93
|
+
const BENIGN_STREAM_ERRORS = new Set([
|
|
94
|
+
'EPIPE', 'EOF', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Is this the reader-went-away class? A code-less error is NOT assumed benign —
|
|
99
|
+
* nothing says it is, and the honest default for an unrecognised fault is to
|
|
100
|
+
* report it.
|
|
101
|
+
* @param {*} err
|
|
102
|
+
*/
|
|
103
|
+
function isBenignStreamError(err) {
|
|
104
|
+
return !!err && BENIGN_STREAM_ERRORS.has(err.code);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Make a stream's write failures non-fatal, once — WITHOUT going deaf (round 3
|
|
109
|
+
* A1; round 5 A3+B2+D1, and the contested C2, same mechanism).
|
|
110
|
+
*
|
|
111
|
+
* MEASURED, node v24.18.0 on Windows, against a REAL closed pipe (parent spawns
|
|
112
|
+
* a child with `stdio: ['ignore','ignore','pipe']` and destroys the read end;
|
|
113
|
+
* the child then writes to `process.stderr`):
|
|
114
|
+
*
|
|
115
|
+
* · `write(line)` returns FALSE and throws NOTHING. `safeWrite`'s try/catch
|
|
116
|
+
* sees nothing at all. The EPIPE arrives on a LATER turn, as an 'error'
|
|
117
|
+
* event; with no listener, EventEmitter throws it, and that throw is an
|
|
118
|
+
* uncaughtException that ends the process (measured: exit code 7).
|
|
119
|
+
* · Passing a write CALLBACK does NOT fix it. The callback received the EPIPE
|
|
120
|
+
* AND the 'error' event still fired unhandled — same exit 7. This is why
|
|
121
|
+
* there is no callback here: it would observe the failure without disarming
|
|
122
|
+
* it, and read like a guard while being none.
|
|
123
|
+
* · Attaching for the duration of the write and detaching after is not merely
|
|
124
|
+
* racy, it is always WRONG: delivery is always on a later turn, so the
|
|
125
|
+
* detach always wins (measured: exit 7 again, with the handler's own log
|
|
126
|
+
* line showing it was removed before the error landed).
|
|
127
|
+
* · A persistent listener absorbs it, and a SECOND write raises a SECOND
|
|
128
|
+
* 'error' — so this must be `on`, never `once`.
|
|
129
|
+
*
|
|
130
|
+
* Hence: attach once, per stream object, and leave it.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ THE SCOPE IS THE WHOLE PROCESS, PERMANENTLY, and the round-3 text undersold
|
|
133
|
+
* it. From the first notice onward this handler receives EVERY 'error' raised on
|
|
134
|
+
* `process.stderr` by ANY producer in the process — not only the EPIPE from this
|
|
135
|
+
* feature's own write, and not only for the duration of the notice. It is
|
|
136
|
+
* sharpest in the MCP server, which imports this in-tree and then outlives every
|
|
137
|
+
* individual run. A pure no-op there would silently discard unrelated stderr
|
|
138
|
+
* faults for the life of the server, which is precisely the correct-but-SILENT
|
|
139
|
+
* degrade the product principle forbids. So the handler DISCRIMINATES: the
|
|
140
|
+
* benign reader-went-away class above is swallowed, and anything else is
|
|
141
|
+
* reported through the house logger.
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ WHY THE REPORT IS BOUNDED TO ONE. MEASURED: `utils/logger.js` writes with
|
|
144
|
+
* `console.error`, i.e. onto `process.stderr` — the same stream that just
|
|
145
|
+
* failed (its own header: "Outputs JSON-formatted logs to stderr"). An unbounded
|
|
146
|
+
* report could therefore provoke the next 'error', handle it, report again, and
|
|
147
|
+
* spin the event loop forever — round 3's B1 defect ("the announcement becomes
|
|
148
|
+
* the next escape") in its asynchronous form. One report per stream, wrapped in
|
|
149
|
+
* its own try/catch, is what makes speaking up safe. The report is best-effort
|
|
150
|
+
* BY CONSTRUCTION and is documented as such rather than promised.
|
|
151
|
+
*
|
|
152
|
+
* Checked rather than assumed: nothing in src/, bin/ or scripts/ attaches to or
|
|
153
|
+
* depends on `process.stderr`'s 'error' event (the `.on('error')` hits in the
|
|
154
|
+
* tree are all on CHILD process streams), and adding a listener removes nobody
|
|
155
|
+
* else's — any handler another module attaches still runs alongside this one.
|
|
156
|
+
* `electron/main.js` installs a listener on the same stream for the same
|
|
157
|
+
* round-3 reason, but its handler is silent for EVERY code; this one
|
|
158
|
+
* deliberately departs from that precedent for the non-benign class, which is
|
|
159
|
+
* exactly why the bound above had to exist first.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ Scoped to the module's OWN default writer. An INJECTED writer (every test
|
|
162
|
+
* collector, and the MCP notices array) never reaches here, so nothing is armed
|
|
163
|
+
* on its behalf.
|
|
164
|
+
* @param {NodeJS.WritableStream} stream
|
|
165
|
+
* @param {(msg: string, ctx: object) => void} [log] the diagnostic seam —
|
|
166
|
+
* injected by tests; defaults to the house logger's `error`.
|
|
167
|
+
*/
|
|
168
|
+
function armStream(stream, log) {
|
|
169
|
+
if (!stream || typeof stream.on !== 'function' || stream[ARMED]) { return; }
|
|
170
|
+
try {
|
|
171
|
+
// `configurable` so a test that borrows a real stream can still take it
|
|
172
|
+
// back; symbol-keyed, so it is invisible to `Object.keys`, `JSON.stringify`
|
|
173
|
+
// and anything else that walks the stream object we are borrowing.
|
|
174
|
+
Object.defineProperty(stream, ARMED, { value: true, configurable: true });
|
|
175
|
+
} catch {
|
|
176
|
+
// A stream nobody can MARK cannot be armed exactly once, and arming it
|
|
177
|
+
// without a mark would trade round 3's unhandled-'error' hazard for an
|
|
178
|
+
// unbounded-listener one on that same stream. Node never freezes
|
|
179
|
+
// `process.stderr`, so this is a guard, not a path.
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
stream.on('error', (err) => {
|
|
183
|
+
// The reader went away. Nobody to tell; a diagnosis must never sink the run
|
|
184
|
+
// it diagnoses, and here it must not natter at a dead pipe either.
|
|
185
|
+
if (isBenignStreamError(err)) { return; }
|
|
186
|
+
if (stream[REPORTED]) { return; }
|
|
187
|
+
try {
|
|
188
|
+
Object.defineProperty(stream, REPORTED, { value: true, configurable: true });
|
|
189
|
+
} catch {
|
|
190
|
+
// Unreachable in practice: `ARMED` was defined on this same object moments
|
|
191
|
+
// ago, so the object takes symbol properties. Bounded-over-loud if it ever
|
|
192
|
+
// is reached — an unbounded report is the one failure mode worse than a
|
|
193
|
+
// missed one.
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const report = log || require('./logger').logger.error;
|
|
198
|
+
// The message rides in the CONTEXT, not the format string: the logger
|
|
199
|
+
// JSON.stringifies the entry, and JSON.stringify escapes every C0 byte
|
|
200
|
+
// (MEASURED: ESC serializes as the six characters \u001b), so its text
|
|
201
|
+
// cannot repaint a terminal from here the way `formatAliasShadow`'s
|
|
202
|
+
// config fragments could.
|
|
203
|
+
report('alias-shadow notice: unexpected error on the notice stream', {
|
|
204
|
+
code: (err && err.code) || null,
|
|
205
|
+
error: err && err.message ? String(err.message) : String(err),
|
|
206
|
+
});
|
|
207
|
+
} catch { /* a diagnosis must never sink the run it diagnoses */ }
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The default writer: stderr, armed against its own asynchronous failure. */
|
|
212
|
+
function writeNoticeToStderr(line) {
|
|
213
|
+
const stream = process.stderr;
|
|
214
|
+
armStream(stream);
|
|
215
|
+
stream.write(line);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// `ARMED` is deliberately NOT exported: it is this module's private marker, and
|
|
219
|
+
// a second holder of the key is a second thing that could clear it.
|
|
220
|
+
module.exports = { safeWrite, armStream, writeNoticeToStderr };
|