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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/alias-store
|
|
3
|
+
* The write sinks the alias review flow needs beyond `setup.js :: addAlias`
|
|
4
|
+
* (#238 §2). Every write is read-modify-write through `saveConfig` (which
|
|
5
|
+
* normalizes, D6) and preserves every other key — the no-clobber contract of
|
|
6
|
+
* `provider-default-picker.js :: applyProviderDefault`.
|
|
7
|
+
*
|
|
8
|
+
* `removeAlias` is BOTH "unpin" (a curated name resurrects from the defaults)
|
|
9
|
+
* and "delete" (a user-invented name is gone) — the meaning is decided by
|
|
10
|
+
* whether the name is curated, not by this module (D1).
|
|
11
|
+
*
|
|
12
|
+
* Dismissals are keyed `alias@proposedId` (Q5): permanent for that pair, and a
|
|
13
|
+
* newer proposed id for the same alias is a new key that asks again.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} alias
|
|
22
|
+
* @returns {boolean} true when a key was removed and the config saved
|
|
23
|
+
*/
|
|
24
|
+
function removeAlias(alias) {
|
|
25
|
+
if (typeof alias === 'string') { alias = alias.trim(); }
|
|
26
|
+
if (!alias || typeof alias !== 'string' || alias === 'null') {
|
|
27
|
+
throw new Error(`Invalid alias name: '${alias}'. Alias name must be a non-empty string.`);
|
|
28
|
+
}
|
|
29
|
+
const { loadConfig, saveConfig } = require('./config');
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
if (!config || !config.aliases || typeof config.aliases !== 'object' || !own(config.aliases, alias)) { return false; }
|
|
32
|
+
delete config.aliases[alias];
|
|
33
|
+
saveConfig(config);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @returns {Object<string,string>} null-prototype copy of aliasReview.dismissed */
|
|
38
|
+
function readDismissals() {
|
|
39
|
+
const { loadConfig } = require('./config');
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
const out = { __proto__: null };
|
|
42
|
+
const d = config && config.aliasReview && typeof config.aliasReview === 'object' ? config.aliasReview.dismissed : null;
|
|
43
|
+
if (d && typeof d === 'object') {
|
|
44
|
+
for (const [k, v] of Object.entries(d)) { if (typeof v === 'string') { out[k] = v; } }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} dismissKey `alias@proposedId`
|
|
51
|
+
* @param {Date} [now]
|
|
52
|
+
*/
|
|
53
|
+
function recordDismissal(dismissKey, now = new Date()) {
|
|
54
|
+
if (typeof dismissKey !== 'string' || !dismissKey || !dismissKey.includes('@')) {
|
|
55
|
+
throw new Error(`Invalid dismissKey '${dismissKey}': expected alias@proposedId`);
|
|
56
|
+
}
|
|
57
|
+
const { loadConfig, saveConfig } = require('./config');
|
|
58
|
+
const config = loadConfig() || {};
|
|
59
|
+
if (!config.aliasReview || typeof config.aliasReview !== 'object') { config.aliasReview = {}; }
|
|
60
|
+
if (!config.aliasReview.dismissed || typeof config.aliasReview.dismissed !== 'object') { config.aliasReview.dismissed = {}; }
|
|
61
|
+
config.aliasReview.dismissed[dismissKey] = now.toISOString();
|
|
62
|
+
saveConfig(config);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { removeAlias, readDismissals, recordDismissal };
|
package/src/utils/config.js
CHANGED
|
@@ -77,7 +77,10 @@ function saveConfig(configData) {
|
|
|
77
77
|
}
|
|
78
78
|
cleaned[key] = value;
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
// #238 D6: a key equal to the shipped default is the same as absence —
|
|
81
|
+
// drop it so the alias FOLLOWS the next pin bump (one Notice per key).
|
|
82
|
+
const { normalizeAliases } = require('./alias-state');
|
|
83
|
+
configData.aliases = normalizeAliases(cleaned, DEFAULT_ALIASES, (line) => process.stderr.write(line)).aliases;
|
|
81
84
|
}
|
|
82
85
|
const configDir = getConfigDir();
|
|
83
86
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
@@ -187,9 +190,11 @@ function computeConfigHash() {
|
|
|
187
190
|
/** @returns {string} Markdown alias table with (default) marker, or empty string */
|
|
188
191
|
function buildAliasTable() {
|
|
189
192
|
const config = loadConfig();
|
|
190
|
-
if (!config
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
if (!config) { return ''; }
|
|
194
|
+
// #238 D6: EFFECTIVE aliases — a following alias is absent from config.aliases
|
|
195
|
+
// but is still an alias the project's CLAUDE.md block must list.
|
|
196
|
+
const aliases = getEffectiveAliases();
|
|
197
|
+
if (Object.keys(aliases).length === 0) { return ''; }
|
|
193
198
|
|
|
194
199
|
const defaultAlias = config.default || null;
|
|
195
200
|
const lines = [];
|
|
@@ -197,7 +202,7 @@ function buildAliasTable() {
|
|
|
197
202
|
lines.push('| Alias | Model |');
|
|
198
203
|
lines.push('|-------|-------|');
|
|
199
204
|
|
|
200
|
-
for (const [alias, model] of Object.entries(
|
|
205
|
+
for (const [alias, model] of Object.entries(aliases)) {
|
|
201
206
|
const marker = (alias === defaultAlias) ? ' (default)' : '';
|
|
202
207
|
lines.push(`| ${alias}${marker} | ${model} |`);
|
|
203
208
|
}
|
package/src/utils/degrade.js
CHANGED
|
@@ -39,6 +39,14 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
39
39
|
// lost reviewer by consumers that only ever meant seats (verdict-seat-loss.js
|
|
40
40
|
// already gates the Stage-2 notes out of `seat-unbound` for the same reason).
|
|
41
41
|
'stage2-judge',
|
|
42
|
+
// #242 / spec §5 (v4.9.8): render-time rows the report derives from runStats
|
|
43
|
+
// (council/report-lost-rows.js) — never emitted by the sink, so neither can
|
|
44
|
+
// flip `degraded` or the exit code. `unverified-repair`: a seat's findings
|
|
45
|
+
// came from a repair of a response with no findings block
|
|
46
|
+
// (runStats[].findingsUnverified); `repair-refused`: the repair broke its
|
|
47
|
+
// count contract (runStats[].repairRefused). Registered here because the
|
|
48
|
+
// degrade-contract drift pin reads every `channel:` literal in src/.
|
|
49
|
+
'unverified-repair', 'repair-refused',
|
|
42
50
|
'internal',
|
|
43
51
|
// doctor channels
|
|
44
52
|
'doctor-check-failed', 'doctor-fix',
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/model-id-siblings
|
|
3
|
+
* The tier-safe sibling comparator (#238 D7). A sibling shares a pin's vendor
|
|
4
|
+
* path, its pre-version prefix and its post-version suffix, and differs only in
|
|
5
|
+
* the version number — so `gpt-5.6-terra` is never compared against
|
|
6
|
+
* `gpt-5.6-sol`, and `kimi-k3` never against `kimi-k2.7-code`. Lifted verbatim
|
|
7
|
+
* from scripts/check-ci-alias-pins.js (which still consumes it) so the alias
|
|
8
|
+
* review engine (alias-proposals.js) asks the user's pinned ids the SAME sibling
|
|
9
|
+
* question the CI drift gate asks the CI alias map. `amicus models --check`
|
|
10
|
+
* asks a DIFFERENT question of the same shipped pins — still-listed vs.
|
|
11
|
+
* gone-from-the-catalog — through `alias-audit.js :: findStaleAliases`, not
|
|
12
|
+
* this module (#249 r2 review F2: this line previously implied otherwise).
|
|
13
|
+
*
|
|
14
|
+
* Known limits, both under-report by design (a suppressed sibling, never a
|
|
15
|
+
* manufactured one) — INVARIANT: neither rule can ever turn two DIFFERENT
|
|
16
|
+
* models into siblings, only fail to notice two of the SAME family are:
|
|
17
|
+
* 1. A dash-versioned id — Anthropic's `claude-opus-4-5`, say — parses its
|
|
18
|
+
* trailing `-5` as part of the suffix rather than the version (the
|
|
19
|
+
* version group only extends through a DOTTED numeric run), so two
|
|
20
|
+
* dash-versioned releases are never compared as siblings at all.
|
|
21
|
+
* 2. A numeric run glued to a following ASCII letter is a size/variant
|
|
22
|
+
* token, never a version (#249 r2 B2) — `parsePin` returns `null` for
|
|
23
|
+
* both `gpt-oss-20b` and `gpt-oss-120b` (#249 r2 review F6: a null
|
|
24
|
+
* parse has no prefix at all, unlike limit 1's dash-versioned case
|
|
25
|
+
* above), since `20`/`120` are each immediately followed by `b` and no
|
|
26
|
+
* other numeric-dotted run exists to fall back to. MEASURED against a
|
|
27
|
+
* 638-id live catalog cache: 129 ids carry a glued run (`24b`, `70b`,
|
|
28
|
+
* `a3b`, `8x22b`, `4o`, …) — without this rule `gpt-oss-120b` reads as
|
|
29
|
+
* a "newer same-tier sibling" of `gpt-oss-20b`, a different model
|
|
30
|
+
* wearing a bigger size, not a newer version. A run preceded by a
|
|
31
|
+
* letter is unaffected (`kimi-k3`, `deepseek-v4-pro`, `qwen3.8-max`
|
|
32
|
+
* keep parsing exactly as before) — only the character AFTER the run
|
|
33
|
+
* is examined.
|
|
34
|
+
* `scripts/check-ci-alias-pins.js`'s CI drift gate consumes `parsePin`
|
|
35
|
+
* unchanged, so it inherits rule 2 automatically.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a model id into the parts a sibling comparison needs.
|
|
42
|
+
* `openrouter/z-ai/glm-5.3` -> vendor `openrouter/z-ai`, prefix `glm-`,
|
|
43
|
+
* version [5,3], suffix ``. Returns null when the tail carries no numeric
|
|
44
|
+
* version (nothing to compare) or the id is an OpenRouter floating pointer,
|
|
45
|
+
* whose `~` forms name no concrete release.
|
|
46
|
+
* @param {string} id
|
|
47
|
+
* @returns {{vendor:string, prefix:string, version:number[], suffix:string}|null}
|
|
48
|
+
*/
|
|
49
|
+
function parsePin(id) {
|
|
50
|
+
if (typeof id !== 'string' || id.includes('~')) { return null; }
|
|
51
|
+
const cut = id.lastIndexOf('/');
|
|
52
|
+
if (cut === -1) { return null; }
|
|
53
|
+
const vendor = id.slice(0, cut);
|
|
54
|
+
const tail = id.slice(cut + 1);
|
|
55
|
+
// Scan every numeric-dotted run left to right; the first one NOT glued to
|
|
56
|
+
// a following ASCII letter is the version (#249 r2 B2 -- see the module
|
|
57
|
+
// docblock's known limit 2). A glued run is a size/variant token (`20b`,
|
|
58
|
+
// `8x22b`) and is skipped, falling through to whatever comes after it --
|
|
59
|
+
// which, once a version is found, is everything the plain suffix already
|
|
60
|
+
// captured, glued runs included.
|
|
61
|
+
for (const m of tail.matchAll(/\d+(?:\.\d+)*/g)) {
|
|
62
|
+
const end = m.index + m[0].length;
|
|
63
|
+
if (/[A-Za-z]/.test(tail[end] || '')) { continue; }
|
|
64
|
+
return { vendor, prefix: tail.slice(0, m.index), version: m[0].split('.').map(Number), suffix: tail.slice(end) };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @returns {number} >0 when a is newer than b, <0 when older, 0 when equal */
|
|
70
|
+
function compareVersions(a, b) {
|
|
71
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
72
|
+
const diff = (a[i] || 0) - (b[i] || 0);
|
|
73
|
+
if (diff !== 0) { return diff; }
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} pinned a fully-qualified model id
|
|
80
|
+
* @param {string[]} catalogIds every id in the live catalog
|
|
81
|
+
* @returns {string|null} the newest strictly-newer sibling, or null
|
|
82
|
+
*/
|
|
83
|
+
function newestSibling(pinned, catalogIds) {
|
|
84
|
+
const pin = parsePin(pinned);
|
|
85
|
+
if (!pin) { return null; }
|
|
86
|
+
let best = null;
|
|
87
|
+
let bestVersion = pin.version;
|
|
88
|
+
for (const id of catalogIds) {
|
|
89
|
+
// No blanket `:` skip. `:free` / `:batch` land in `suffix`, so the
|
|
90
|
+
// suffix equality below ALREADY refuses to bump a plain pin to a billing
|
|
91
|
+
// variant — while a blanket skip additionally blinded the checker to a
|
|
92
|
+
// pin that is ITSELF a variant (a `:free` pin could never find a `:free`
|
|
93
|
+
// sibling, and went quietly unwatched forever). Council finding D3.
|
|
94
|
+
const other = parsePin(id);
|
|
95
|
+
if (!other) { continue; }
|
|
96
|
+
if (other.vendor !== pin.vendor) { continue; }
|
|
97
|
+
if (other.prefix !== pin.prefix || other.suffix !== pin.suffix) { continue; }
|
|
98
|
+
if (compareVersions(other.version, bestVersion) > 0) {
|
|
99
|
+
best = id;
|
|
100
|
+
bestVersion = other.version;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { parsePin, compareVersions, newestSibling };
|
|
@@ -159,7 +159,7 @@ async function validateAgainstCatalog(resolvedModel, alias) {
|
|
|
159
159
|
throw new Error(
|
|
160
160
|
`Model '${resolvedModel}' not found in the OpenRouter catalog.\n` +
|
|
161
161
|
(list ? `Did you mean:\n${list}\n` : '') +
|
|
162
|
-
`Fix: amicus setup --add-alias ${alias || '<alias>'}=${relevant[0] ? relevant[0].id : 'openrouter/provider/model'}\n` +
|
|
162
|
+
`Fix: amicus aliases --review (or pin directly: amicus setup --add-alias ${alias || '<alias>'}=${relevant[0] ? relevant[0].id : 'openrouter/provider/model'})\n` +
|
|
163
163
|
'Run \'amicus models --refresh\' to update the catalog, or pass --no-validate-model to skip.'
|
|
164
164
|
);
|
|
165
165
|
}
|
package/src/utils/quick-picks.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
|
-
const { getFamilies,
|
|
13
|
+
const { getFamilies, DIVERGENT_VENDORS } = require('./curated-models');
|
|
14
14
|
const { directFormIfSafe } = require('./model-canonicalization');
|
|
15
15
|
|
|
16
16
|
const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
|
|
@@ -37,7 +37,11 @@ function compareIdsDesc(a, b) {
|
|
|
37
37
|
function pickCurrent(catalog, nsPrefix, vendorPath, idPattern) {
|
|
38
38
|
const prefix = `${nsPrefix}${vendorPath}/`;
|
|
39
39
|
const ids = (Array.isArray(catalog) ? catalog : [])
|
|
40
|
-
|
|
40
|
+
// #238 §5: a non-authoritative row (the hardcoded Anthropic floor, a
|
|
41
|
+
// floor-fallback) is not evidence of what the provider serves — the same
|
|
42
|
+
// guard gateway-route-audit.js :: isAuthoritative applies per gateway.
|
|
43
|
+
.filter(m => m && m.authoritative !== false)
|
|
44
|
+
.map(m => m.id)
|
|
41
45
|
.filter(id => typeof id === 'string' && id.startsWith(prefix))
|
|
42
46
|
.filter(id => idPattern.test(id.slice(prefix.length)));
|
|
43
47
|
if (ids.length === 0) { return null; }
|
|
@@ -92,38 +96,15 @@ function toStorableRoute(pick, catalogInfo) {
|
|
|
92
96
|
}
|
|
93
97
|
const route = routes.openrouter || Object.values(routes)[0];
|
|
94
98
|
if (!route) { return undefined; }
|
|
95
|
-
// issue 214 remedy 1: this value is PERSISTED
|
|
96
|
-
// config.aliases
|
|
97
|
-
// not be a blind prefix strip.
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// path and left open here.
|
|
99
|
+
// issue 214 remedy 1: this value is PERSISTED -- sidecar/setup.js writes it
|
|
100
|
+
// into config.aliases for the chosen default when it differs from the
|
|
101
|
+
// shipped pin (#238 Q9) -- so it must not be a blind prefix strip.
|
|
102
|
+
// directFormIfSafe keeps the optimism for a namespace that was never
|
|
103
|
+
// fetched while refusing for one the catalog disproves OR whose fetch was
|
|
104
|
+
// rejected -- the gap #208 closed on the picker path and left open here.
|
|
101
105
|
return directFormIfSafe(pick.vendorPath, route, catalogInfo || { models: [] });
|
|
102
106
|
}
|
|
103
107
|
|
|
104
|
-
/**
|
|
105
|
-
* Seed map for fresh configs: static defaults overlaid with live family
|
|
106
|
-
* routes (cardless aliases stay pinned). See `toStorableRoute` for why the
|
|
107
|
-
* overlaid value is not a raw prefix strip.
|
|
108
|
-
* @returns {Object<string,string>}
|
|
109
|
-
*/
|
|
110
|
-
function toLiveSeedAliases(catalogOrInfo) {
|
|
111
|
-
// Accepts the bare models array (historical callers) or a full catalogInfo.
|
|
112
|
-
// issue 214: the evidence was always handed in and then discarded.
|
|
113
|
-
const info = Array.isArray(catalogOrInfo)
|
|
114
|
-
? { models: catalogOrInfo }
|
|
115
|
-
: (catalogOrInfo || { models: [] });
|
|
116
|
-
const seeds = toDefaultAliases();
|
|
117
|
-
for (const r of resolveQuickPicks(info.models || [])) {
|
|
118
|
-
if (r.source === 'live' && r.routes.openrouter) {
|
|
119
|
-
const stored = toStorableRoute(r, info);
|
|
120
|
-
if (stored) { seeds[r.alias] = stored; }
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return seeds;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
108
|
/**
|
|
128
109
|
* Per-provider SAFE storable form for a resolved quick pick (issue 214).
|
|
129
110
|
*
|
|
@@ -147,4 +128,4 @@ function canonicalRoutesFor(pick, catalogInfo) {
|
|
|
147
128
|
return out;
|
|
148
129
|
}
|
|
149
130
|
|
|
150
|
-
module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks,
|
|
131
|
+
module.exports = { compareIdsDesc, canonicalRoutesFor, pickCurrent, resolveQuickPicks, toStorableRoute };
|
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
* that has landed here (ANSI in round 2, bidi in round 3) was a class the
|
|
21
21
|
* previous pass could not see.
|
|
22
22
|
*
|
|
23
|
+
* `safeFragment` (#249 r2 C4) is the same discipline for a shorter kind of
|
|
24
|
+
* text: not a sentence-length excerpt but ONE quoted config/catalog/typed
|
|
25
|
+
* value — an alias name, a model id, a dismiss key. MEASURED: the longest id
|
|
26
|
+
* in a 638-row live catalog cache is 67 characters
|
|
27
|
+
* (`openrouter/cognitivecomputations/dolphin-mistral-24b-venice-edition`);
|
|
28
|
+
* past 96 it is a payload, not an id. `utils/alias-shadow.js` keeps its OWN
|
|
29
|
+
* 64-char local cap (measured against the curated table alone, which never
|
|
30
|
+
* exceeds 39 characters) through this same `collapseExcerpt` function — one
|
|
31
|
+
* sanitizer, two caps sized to what each caller actually holds, no second
|
|
32
|
+
* dialect.
|
|
33
|
+
*
|
|
23
34
|
* PURE: no I/O, no throwing paths, no state.
|
|
24
35
|
*/
|
|
25
36
|
|
|
@@ -75,7 +86,23 @@ function collapseExcerpt(text, maxChars = MAX_EXCERPT_CHARS) {
|
|
|
75
86
|
return `${oneLine.slice(0, maxChars - 1)}…`;
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
/** One quoted fragment's cap — an id/name/key, not a sentence. See the module docblock. */
|
|
90
|
+
const MAX_FRAGMENT_CHARS = 96;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One third-party fragment (an alias name, a model id, a dismiss key, a
|
|
94
|
+
* catalog note), safe to interpolate into a terminal line. Same pass as
|
|
95
|
+
* `collapseExcerpt`, just capped for a short value instead of a sentence.
|
|
96
|
+
* @param {*} value
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
function safeFragment(value) {
|
|
100
|
+
return collapseExcerpt(value, MAX_FRAGMENT_CHARS);
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
module.exports = {
|
|
79
104
|
collapseExcerpt,
|
|
80
105
|
MAX_EXCERPT_CHARS,
|
|
106
|
+
safeFragment,
|
|
107
|
+
MAX_FRAGMENT_CHARS,
|
|
81
108
|
};
|