amicus 4.8.0 → 4.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,21 @@
1
1
  /**
2
- * Alias Audit (F5) — report + suggest, never auto-repair.
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, never auto-repair (this module's charter).
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
- module.exports = { collectAliasSources, findStaleAliases, findDriftedStoredAliases, suggestReplacements };
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
+ };
@@ -112,11 +112,11 @@ const CARDLESS = [
112
112
  { alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
113
113
  { alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
114
114
  // inkling added 2026-08-14: the council-review workflow's default bench
115
- // names it, and a workflow can only use aliases this table ships a CI
116
- // runner has no user config, so a locally-defined alias resolves to
117
- // nothing there. Pinned to the full model, not `inkling-small`: the bench
118
- // seat wants the flagship's judgment. `:batch` is deliberately not pinned
119
- // (deferred completion is wrong for an interactive council leg).
115
+ // names it, and this table is the FLOOR a runner falls back to when no
116
+ // alias map is provisioned (workflow_call callers, forks) there, a
117
+ // locally defined alias still resolves to nothing. Pinned to the full
118
+ // model, not `inkling-small`: the bench seat wants the flagship's
119
+ // judgment. `:batch` is not pinned (wrong for an interactive council leg).
120
120
  { alias: 'inkling', routes: { openrouter: 'openrouter/thinkingmachines/inkling' } },
121
121
  ];
122
122
 
@@ -0,0 +1,152 @@
1
+ // src/utils/doctor-alias-check.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module utils/doctor-alias-check
6
+ * The `aliases` doctor check ("Model aliases"), split out of
7
+ * src/cli-handlers-doctor.js to keep that file under the 300-line gate
8
+ * (mirrors doctor-engine-check.js / doctor-electron-mcp-check.js /
9
+ * doctor-base-url-check.js / doctor-local-providers-check.js -- same reason,
10
+ * a different check).
11
+ *
12
+ * B3 (council review of PR 198, issue 195): `doctor --fix` repairs exactly
13
+ * one narrow class of stored alias -- see alias-audit.js's
14
+ * `findFabricatedAliasRepairs` for the detection rule (classifies `invalid`
15
+ * on the `direct` gateway AND has an unambiguous OpenRouter twin) and why it
16
+ * cannot false-positive a typo, a retired model, or a user-invented id.
17
+ * Repair = rewrite `config.aliases[alias]` to that catalog-confirmed
18
+ * OpenRouter id -- read-modify-write, no-clobber (mirrors
19
+ * `applyProviderDefault`, provider-default-picker.js). Every OTHER
20
+ * stale/drifted alias is left untouched and stays a warning, hinting at
21
+ * `amicus models --check` same as before this PR.
22
+ *
23
+ * A3 (council review of PR 198): the repair ACTION additionally requires the
24
+ * cached catalog to be FRESH (same `MAX_CATALOG_AGE_MS` window as doctor's
25
+ * own `catalog` check, cli-handlers-doctor.js). `readCache()` here reads the
26
+ * exact same cache doctor's `catalog` check may independently report as
27
+ * `stale (Nh old)` -- without this gate, `--fix` would rewrite a user's
28
+ * config from data the SAME run just called untrustworthy. A stale catalog
29
+ * can be missing rows that would make a "fabricated" id look repairable when
30
+ * it is merely unfetched, so a stale catalog declines the repair (explaining
31
+ * why via `repairFabricatedAliasStaleCatalog`) rather than writing on
32
+ * unverified evidence; detection/reporting is unaffected either way.
33
+ */
34
+
35
+ const HINTS = require('./remediation-hints');
36
+
37
+ // Mirrors cli-handlers-doctor.js's own MAX_CATALOG_AGE_MS (which itself
38
+ // mirrors model-catalog.js's DEFAULT_MAX_AGE_MS) -- duplicated rather than
39
+ // imported to avoid a require cycle (cli-handlers-doctor.js requires this
40
+ // module at load time, before its own module.exports exists).
41
+ const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
42
+
43
+ /**
44
+ * @param {{fetchedAt?: number}|null} cache
45
+ * @returns {boolean} true when `cache` exists, has a numeric `fetchedAt`, and
46
+ * is no older than `MAX_CATALOG_AGE_MS` -- the same test doctor's `catalog`
47
+ * check applies to decide `ok` vs `stale (Nh old)`.
48
+ */
49
+ function isCatalogFresh(cache) {
50
+ if (!cache || typeof cache.fetchedAt !== 'number') { return false; }
51
+ return (Date.now() - cache.fetchedAt) <= MAX_CATALOG_AGE_MS;
52
+ }
53
+
54
+ /**
55
+ * Rewrite one alias's stored value in place. Read-modify-write / no-clobber
56
+ * -- preserves `config.default` and every other alias/key (same contract as
57
+ * `applyProviderDefault`, provider-default-picker.js).
58
+ * @param {string} alias
59
+ * @param {string} newId verbatim catalog id (an OpenRouter-namespace id from
60
+ * `pairAcrossGateways` -- never hand-derived by string concatenation)
61
+ */
62
+ function repairAlias(alias, newId) {
63
+ const { loadConfig, saveConfig } = require('./config');
64
+ const config = loadConfig() || {};
65
+ if (!config.aliases || typeof config.aliases !== 'object') { config.aliases = {}; }
66
+ config.aliases[alias] = newId;
67
+ saveConfig(config);
68
+ }
69
+
70
+ /** One pass: sources + both existing audits + the repairable set, over the same catalog. */
71
+ function computeState(d, catalog) {
72
+ const sources = d.collectAliasSources();
73
+ return {
74
+ sources,
75
+ stale: d.findStaleAliases(sources, catalog),
76
+ drifted: d.findDriftedStoredAliases(sources, catalog),
77
+ repairable: d.findFabricatedAliasRepairs(sources, catalog),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * @param {{readCache: () => ({models?: Array}|null), collectAliasSources: () => Array,
83
+ * findStaleAliases: (s:Array, c:Array) => Array, findDriftedStoredAliases: (s:Array, c:Array) => Array,
84
+ * findFabricatedAliasRepairs: (s:Array, c:Array) => Array<{alias:string,oldId:string,newId:string}>,
85
+ * fix?: boolean, repairAlias?: (alias:string, newId:string) => void}} d
86
+ * @returns {{id,name,status,message,hint,fixed?,fixDetail?}}
87
+ */
88
+ function evaluateAliasesCheck(d) {
89
+ const id = 'aliases';
90
+ const name = 'Model aliases';
91
+ const cache = d.readCache();
92
+ const catalog = (cache && cache.models) || [];
93
+ const catalogFresh = isCatalogFresh(cache);
94
+
95
+ let state = computeState(d, catalog);
96
+ let fixFields = {};
97
+
98
+ // Only under --fix, only when there is something in the narrow,
99
+ // mechanically-unambiguous class to repair (rule 1), and only on a FRESH
100
+ // catalog (A3) -- a failed individual rewrite is best-effort -- it simply
101
+ // stays a warning, same as one findStaleAliases could never resolve.
102
+ if (d.fix && state.repairable.length > 0 && catalogFresh) {
103
+ const repaired = [];
104
+ for (const r of state.repairable) {
105
+ try { d.repairAlias(r.alias, r.newId); repaired.push(r); }
106
+ catch { /* best-effort -- an unrepaired alias just stays a warning below */ }
107
+ }
108
+ if (repaired.length > 0) {
109
+ // Rule 6: announce every repair, naming the alias and both ids -- this
110
+ // fixDetail flows into the 'heal' degrade's `why` field (doctor-degrade.js).
111
+ const detail = repaired.map((r) => `'${r.alias}' (${r.oldId} -> ${r.newId})`).join('; ');
112
+ fixFields = {
113
+ fixed: true,
114
+ fixDetail: `rewrote ${repaired.length} fabricated alias(es) to its catalog-confirmed OpenRouter id: ${detail}`,
115
+ };
116
+ // Rule 5 (idempotency): recompute from a fresh config read so both this
117
+ // run's message and a second --fix run see the post-repair reality, not
118
+ // the pre-repair snapshot -- a repaired alias must not still count as
119
+ // stale/repairable below.
120
+ state = computeState(d, catalog);
121
+ }
122
+ }
123
+
124
+ const { stale, drifted, repairable } = state;
125
+ if (stale.length === 0 && drifted.length === 0) {
126
+ return {
127
+ id, name, status: 'ok',
128
+ message: catalog.length ? 'all resolve' : 'catalog empty — not checked', hint: null,
129
+ ...fixFields,
130
+ };
131
+ }
132
+ const parts = [];
133
+ if (stale.length) { parts.push(`${stale.length} stale: ${stale.map((s) => s.alias).join(', ')}`); }
134
+ if (drifted.length) { parts.push(`${drifted.length} drifted: ${drifted.map((s) => s.alias).join(', ')}`); }
135
+ // Rule 1: without --fix, report the repairable count and the hint, change
136
+ // nothing. A3: when the catalog is stale, say so explicitly rather than
137
+ // offering a fix that will silently decline to write.
138
+ if (repairable.length) {
139
+ parts.push(catalogFresh
140
+ ? `${repairable.length} fixable via doctor --fix`
141
+ : `${repairable.length} fixable via doctor --fix once the catalog is refreshed (catalog is stale)`);
142
+ }
143
+ return {
144
+ id, name, status: 'warn', message: parts.join('; '),
145
+ hint: repairable.length === 0
146
+ ? 'amicus models --check'
147
+ : (catalogFresh ? HINTS.repairFabricatedAlias : HINTS.repairFabricatedAliasStaleCatalog),
148
+ ...fixFields,
149
+ };
150
+ }
151
+
152
+ module.exports = { evaluateAliasesCheck, repairAlias };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Direct-first id canonicalization, guarded by `classifyModel`
3
+ * (model-classification.js). Extracted out of `provider-default-picker.js`
4
+ * (issue 195 follow-up, F1/B4 -- council review of PR 198) purely to keep
5
+ * that file under the 300-line size gate; the two function bodies below are
6
+ * an unmodified move, not a rewrite.
7
+ *
8
+ * Two call sites, same predicate (`classifyModel`), opposite defaults --
9
+ * named separately on purpose so neither behavior can be reached by
10
+ * accident (e.g. forgetting to pass an option):
11
+ *
12
+ * - `directFormIfSafe` -- LIST-BUILDING (`chooseRowId`/`canonicalizeResolved`
13
+ * in provider-default-picker.js, building the picker's row list). With no
14
+ * catalog evidence either way, a bare policy-routed id is a reasonable
15
+ * GUESS to offer: optimistic, strips the `openrouter/` prefix unless
16
+ * `classifyModel` can prove the bare form `invalid`.
17
+ * - `directFormIfProven` -- PERSISTENCE (`applyProviderDefault`, writing
18
+ * `config.aliases[vendor]`). By the time an id is persisted, the caller
19
+ * has already handed in whatever prefix it decided on -- that prefix IS
20
+ * the user's choice, carrying information an empty or absent catalog does
21
+ * not contradict. Strips only on POSITIVE evidence (`classifyModel`
22
+ * returns `valid`, i.e. the bare id is an actual catalog row); anything
23
+ * else -- `unknown` (including a failed/absent catalog fetch, which is
24
+ * exactly the case that used to silently re-fabricate the id issue 195
25
+ * fixed) or `invalid` -- preserves the id exactly as given.
26
+ *
27
+ * Both gate `DIVERGENT_VENDORS` FIRST and internally, always returning the
28
+ * input unchanged for one of those vendors (dot vs. dash direct ids, e.g.
29
+ * anthropic) -- a caller cannot reach the optimistic OR the proven strip for
30
+ * a divergent vendor by forgetting to check the set itself.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
36
+ const { classifyModel } = require('./model-classification');
37
+
38
+ /**
39
+ * @param {string} vendor
40
+ * @param {string} orId an `openrouter/<vendor>/<rest>` id (or already-bare)
41
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
42
+ * @returns {string} the bare direct id when not proven invalid, else `orId` unchanged
43
+ */
44
+ function directFormIfSafe(vendor, orId, catalogInfo) {
45
+ if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
46
+ const bare = toCanonicalDefault(orId);
47
+ if (bare === orId) { return orId; } // gateway-only vendor -- no direct integration at all
48
+ return classifyModel(bare, 'direct', catalogInfo) === 'invalid' ? orId : bare;
49
+ }
50
+
51
+ /**
52
+ * @param {string} vendor
53
+ * @param {string} orId an `openrouter/<vendor>/<rest>` id (or already-bare)
54
+ * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
55
+ * @returns {string} the bare direct id only when PROVEN valid, else `orId` unchanged
56
+ */
57
+ function directFormIfProven(vendor, orId, catalogInfo) {
58
+ if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
59
+ const bare = toCanonicalDefault(orId);
60
+ if (bare === orId) { return orId; }
61
+ return classifyModel(bare, 'direct', catalogInfo) === 'valid' ? bare : orId;
62
+ }
63
+
64
+ module.exports = { directFormIfSafe, directFormIfProven };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Vendor model shortlist (#138) -- the family -> model second level.
3
+ *
4
+ * Turns a vendor's deduped, priced picker rows into a
5
+ * `{suggested, rest}` split so a surface can show a short list without
6
+ * hiding anything: the GUI renders both groups in one <select>, readline
7
+ * prints `suggested` and offers `a` to print `rest` too.
8
+ *
9
+ * PURE and transport-agnostic, exactly like `provider-default-picker.js`:
10
+ * the catalog is always injected, and nothing here formats for a renderer.
11
+ *
12
+ * Why the caller supplies `recommendedId`: the picker's own preselect is
13
+ * COST-TIER driven (`computePreselectedId`), which disagrees with the
14
+ * wizard card's family flagship -- for DeepSeek, `v3.2` vs `v4-pro`.
15
+ * Owner ruling (#138, 2026-08-24) is that the flagship wins, so that
16
+ * opening the drill-down and accepting is a guaranteed no-op. The tier
17
+ * preselect remains the fallback when the flagship matches no row.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const { buildProviderDefaultChoices } = require('./provider-default-picker');
23
+ const { pairAcrossGateways } = require('./gateway-route-catalog');
24
+
25
+ /** Rows shown before the "all models" group. */
26
+ const SHORTLIST_LIMIT = 8;
27
+
28
+ /**
29
+ * Both gateway spellings of a row id, so a surface can honour an explicit
30
+ * "via OpenRouter" choice for a drilled-down model. NEVER derives one form
31
+ * from the other -- `pairAcrossGateways` reads real catalog rows, which is
32
+ * the only safe move for DIVERGENT_VENDORS (anthropic's direct and
33
+ * OpenRouter ids are different strings, not differently prefixed).
34
+ * @param {string} vendor
35
+ * @param {string} id verbatim picker row id
36
+ * @param {Array<{id:string}>} catalog
37
+ * @returns {{directId: (string|null), openrouterId: (string|null)}}
38
+ */
39
+ function routeFormsFor(vendor, id, catalog) {
40
+ const token = id.replace(/^openrouter\//, '').replace(`${vendor}/`, '');
41
+ const paired = pairAcrossGateways(vendor, token, { models: catalog });
42
+ return {
43
+ directId: paired.direct || null,
44
+ openrouterId: paired.openrouter || null,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Recommended-first, then price-ascending, nulls last -- the same order
50
+ * `provider-default-picker.js`'s `compareRows` already establishes, so the
51
+ * drill-down reads like the picker the user may already have seen.
52
+ */
53
+ function compareShortlistRows(a, b) {
54
+ if (a.isRecommended !== b.isRecommended) { return a.isRecommended ? -1 : 1; }
55
+ if (a.pricePerMInput === null && b.pricePerMInput === null) { return 0; }
56
+ if (a.pricePerMInput === null) { return 1; }
57
+ if (b.pricePerMInput === null) { return -1; }
58
+ return a.pricePerMInput - b.pricePerMInput;
59
+ }
60
+
61
+ /**
62
+ * @param {string} vendor e.g. 'deepseek'
63
+ * @param {{catalog?: Array<object>, recommendedId?: string, limit?: number}} [options]
64
+ * @returns {{recommendedId: (string|null), suggested: Array<object>,
65
+ * rest: Array<object>, total: number}}
66
+ */
67
+ function buildModelShortlist(vendor, options = {}) {
68
+ const catalog = Array.isArray(options.catalog) ? options.catalog : [];
69
+ const limit = Number.isInteger(options.limit) && options.limit > 0
70
+ ? options.limit : SHORTLIST_LIMIT;
71
+
72
+ const { preselectedId, rows } = buildProviderDefaultChoices(vendor, { catalog });
73
+ if (!rows || rows.length === 0) {
74
+ return { recommendedId: null, suggested: [], rest: [], total: 0 };
75
+ }
76
+
77
+ // Owner ruling: the family flagship wins when it names a real row;
78
+ // otherwise keep the picker's tier preselect rather than inventing one.
79
+ const wanted = options.recommendedId;
80
+ const recommendedId = (wanted && rows.some(r => r.id === wanted)) ? wanted : preselectedId;
81
+
82
+ const annotated = rows.map(r => Object.assign({
83
+ id: r.id,
84
+ name: r.name,
85
+ contextLength: r.contextLength,
86
+ pricePerMInput: r.pricePerMInput,
87
+ isRecommended: r.id === recommendedId,
88
+ }, routeFormsFor(vendor, r.id, catalog)));
89
+
90
+ annotated.sort(compareShortlistRows);
91
+
92
+ return {
93
+ recommendedId,
94
+ suggested: annotated.slice(0, limit),
95
+ rest: annotated.slice(limit),
96
+ total: annotated.length,
97
+ };
98
+ }
99
+
100
+ module.exports = { buildModelShortlist, compareShortlistRows, SHORTLIST_LIMIT };