@mmmbuto/nexuscrew 0.9.76 → 0.9.78

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.
@@ -41,7 +41,7 @@ const {
41
41
  CATALOG, CLIENT_LABELS, CLIENT_ORDER, engineClient, ordinaEngine, isStandardClient,
42
42
  credential: resolveProfileCredential, extraModelsFrom, knownMcpServerNames, profileFor,
43
43
  normalizeManagedSpec, codexSurfaceNames,
44
- engineModelDiscoveryPlan, ollamaCloudCatalogModels, declaredModelsFor, ALIBABA_PI_MODELS,
44
+ engineModelDiscoveryPlan, modelListingCredential, ollamaCloudCatalogModels, declaredModelsFor, ALIBABA_PI_MODELS,
45
45
  } = require('./managed.js');
46
46
  const { probeModel, probeCustomEndpoint } = require('./model-probe.js');
47
47
  const { createModelDiscovery, DISCOVERY_PROPOSAL_CAP } = require('./model-discovery.js');
@@ -54,6 +54,7 @@ const { createLeaseManager } = require('./cell-lease-server.js');
54
54
  const { createIdentityAuthority } = require('./identity-authority.js');
55
55
  const { MINIMAL_ENV_KEYS, termuxRuntimePaths } = require('../runtime/env.js');
56
56
  const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
57
+ const { requestVlNewSession, vlNewSessionStatus, vlLegacyInventory } = require('./vl-session.js');
57
58
 
58
59
  // Toolkit stateless + runtime estratti (behavior-preserving). I simboli di
59
60
  // launch.js sono re-esportati in module.exports per i test che li importano
@@ -965,7 +966,15 @@ async function createBuiltinFleet(cfg = {}) {
965
966
  // I campi non-identita' che `parseModel` ammette per una dichiarazione:
966
967
  // id ed engine SONO l'identita' (il chiaro per cui esiste edit-model e' non
967
968
  // doverla ritoccare), tutto il resto e' patchabile con la stessa validazione.
968
- const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning']);
969
+ // `effortLevels` e' fra questi: i livelli dichiarati sono parte della
970
+ // dichiarazione del modello, e dichiararli una volta sola non basta — il
971
+ // cliente vl accetta solo i livelli dichiarati, quindi cambiarli e' una
972
+ // correzione legittima della dichiarazione, non un campo exotic.
973
+ const EDIT_MODEL_FIELDS = new Set(['label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning', 'effortLevels']);
974
+ // Stessa forma del parser (definitions.js, `parseModel`): i livelli sono
975
+ // token corti in minuscolo, e finiscono in un valore d'ambiente, non in argv.
976
+ const EFFORT_LEVELS_MAX = 8;
977
+ const EFFORT_LEVEL_RE = /^[a-z0-9][a-z0-9-]{0,15}$/;
969
978
 
970
979
  async function editModel(id, engine, patch) {
971
980
  if (readonly()) throw httpError(403, 'READONLY: edit-model bloccato');
@@ -997,6 +1006,24 @@ async function createBuiltinFleet(cfg = {}) {
997
1006
  throw httpError(400, `${key} non valido: booleano`);
998
1007
  }
999
1008
  }
1009
+ // `effortLevels`: la dichiarazione dei livelli accettati per questo
1010
+ // modello. `null` = togli la dichiarazione (i livelli tornano a non essere
1011
+ // richiesti); un array = i livelli nuovi. La validazione e' IDENTICA a
1012
+ // quella del parser: un valore che il file rifiuta non entra, e un
1013
+ // livello inventato non viene battezzato "low".
1014
+ if (patch.effortLevels !== undefined && patch.effortLevels !== null) {
1015
+ const levels = patch.effortLevels;
1016
+ if (!Array.isArray(levels) || levels.length < 1 || levels.length > EFFORT_LEVELS_MAX) {
1017
+ throw httpError(400, `effortLevels non valido: array da 1 a ${EFFORT_LEVELS_MAX} livelli`);
1018
+ }
1019
+ const visti = new Set();
1020
+ for (const level of levels) {
1021
+ if (typeof level !== 'string' || !EFFORT_LEVEL_RE.test(level) || visti.has(level)) {
1022
+ throw httpError(400, `effortLevels non valido: livello non conforme o duplicato: ${JSON.stringify(level)}`);
1023
+ }
1024
+ visti.add(level);
1025
+ }
1026
+ }
1000
1027
  const defs = reloadDefs();
1001
1028
  if (!(defs.models || []).some((m) => m.id === wantedId && m.engine === wantedEngine)) {
1002
1029
  throw httpError(404, `modello non dichiarato: ${wantedId} su ${wantedEngine}`);
@@ -1008,7 +1035,13 @@ async function createBuiltinFleet(cfg = {}) {
1008
1035
  // e' la stessa riga del file, l'engine non ha nulla da ricaricare.
1009
1036
  await mutate(defs, (d) => {
1010
1037
  const target = d.models.find((m) => m.id === wantedId && m.engine === wantedEngine);
1011
- Object.assign(target, patch);
1038
+ for (const [key, value] of Object.entries(patch)) {
1039
+ // `null` su `effortLevels` TOGLIE la dichiarazione: e' l'unico modo per
1040
+ // tornare a un modello senza livelli dichiarati (il parser rifiuta un
1041
+ // array vuoto, quindi l'assenza si scrive togliendo il campo).
1042
+ if (key === 'effortLevels' && value === null) { delete target.effortLevels; continue; }
1043
+ target[key] = value;
1044
+ }
1012
1045
  });
1013
1046
  return { ok: true, id: wantedId, engine: wantedEngine, edited: keys };
1014
1047
  }
@@ -1580,6 +1613,15 @@ async function createBuiltinFleet(cfg = {}) {
1580
1613
  baseUrl: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_COMMAND_LEN },
1581
1614
  envKey: { type: 'string', requiredFor: 'provider=custom', pattern: '^[A-Za-z_][A-Za-z0-9_]{0,63}$' },
1582
1615
  providerId: { type: 'string', requiredFor: 'provider=custom', pattern: '^[a-z][a-z0-9_-]{0,31}$' },
1616
+ // Campi vl (client vl): percorsi assoluti sul nodo esecutore, profilo
1617
+ // del catalogo dichiarato, livello effort dichiarato per il modello.
1618
+ // La validazione semantica (esistenza, eseguibile, profilo nel
1619
+ // catalogo, effort nei livelli dichiarati) vive in normalizeManagedSpec
1620
+ // e ai gate di lancio: lo schema dice la FORMA, non la verita' del nodo.
1621
+ vlBinary: { type: 'string', required: false, absolute: true, max: 4096 },
1622
+ vlConfigFile: { type: 'string', required: false, absolute: true, max: 4096 },
1623
+ vlProfile: { type: 'string', required: false, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' },
1624
+ effort: { type: 'string', required: false, pattern: '^[a-z0-9][a-z0-9-]{0,15}$' },
1583
1625
  },
1584
1626
  command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
1585
1627
  args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
@@ -1823,6 +1865,23 @@ async function createBuiltinFleet(cfg = {}) {
1823
1865
  ...(cause ? { cause } : {}),
1824
1866
  };
1825
1867
  }
1868
+ // Backup proposals for fields no layer could fill: the missing-data dialog
1869
+ // offers what the internal catalog knows, and the operator's confirmation
1870
+ // is what turns a proposal into a user value.
1871
+ function withBackupDefaults(records, profile) {
1872
+ const internalIndex = internalModelIndex(profile);
1873
+ return (Array.isArray(records) ? records : []).map((m) => {
1874
+ const backupValues = {};
1875
+ for (const field of ['contextWindow', 'maxTokens']) {
1876
+ if (m[field] === undefined) {
1877
+ const known = internalValueFor(internalIndex, m.id);
1878
+ if (known && known[field] !== undefined) backupValues[field] = known[field];
1879
+ }
1880
+ }
1881
+ return Object.keys(backupValues).length ? { ...m, backupValues } : m;
1882
+ });
1883
+ }
1884
+
1826
1885
  async function discoverEngineModels(engineId, opts = {}) {
1827
1886
  const id = String(engineId || '');
1828
1887
  if (!id || id.length > 64 || !/^[A-Za-z0-9._-]+$/.test(id)) {
@@ -1843,16 +1902,10 @@ async function createBuiltinFleet(cfg = {}) {
1843
1902
  spec = profile ? { client: profile.client, provider: profile.provider, ...(profile.credentialProfile ? { credentialProfile: profile.credentialProfile } : {}) } : null;
1844
1903
  }
1845
1904
  if (!profile) { const e = new Error(`engine sconosciuto: ${id}`); e.status = 404; throw e; }
1846
- const plan = engineModelDiscoveryPlan(profile, spec, cfg, home, id);
1847
- if (!plan) {
1848
- // No HTTP listing to interrogate (login/native flows, local providers
1849
- // without a base URL): an honest empty answer, never an error, so the
1850
- // editor stays usable. The UI hides the section on `supported: false`.
1851
- return { engine: id, supported: false, source: 'unsupported', models: [] };
1852
- }
1853
1905
  // User layer: models declared in fleet.json for this engine (or its
1854
1906
  // profile) — the values the operator chose, always on top and never
1855
- // rewritten by discovery.
1907
+ // rewritten by discovery. Built BEFORE the plan: an engine without a
1908
+ // listing still has ids worth enriching.
1856
1909
  const extraModels = extraModelsFrom(defs);
1857
1910
  const userModels = [];
1858
1911
  const seenUser = new Set();
@@ -1876,33 +1929,57 @@ async function createBuiltinFleet(cfg = {}) {
1876
1929
  }
1877
1930
  const backupModels = backupModelsFor(profile);
1878
1931
  const wanted = [...new Set([...userModels, ...backupModels].map((m) => m.id))].slice(0, 32);
1932
+ // The plan is the HTTP listing, when the profile has one. When it does
1933
+ // not, the same composition still runs with the API layer explicitly
1934
+ // unavailable: known ids are enriched from the public snapshot, with no
1935
+ // invented collector, request or auth attempt.
1936
+ const plan = engineModelDiscoveryPlan(profile, spec, cfg, home, id);
1937
+ // The active model is configuration the operator already chose. It counts
1938
+ // as a known id only when it is the ONLY one: with a declared model or a
1939
+ // profile backup the proposal already has its ids, and adding it would
1940
+ // duplicate a row or change the order.
1941
+ const activeModel = spec && typeof spec.model === 'string' ? spec.model.trim() : '';
1942
+ const knownIds = !userModels.length && !backupModels.length && activeModel ? [activeModel] : [];
1879
1943
  const result = await engineModelDiscovery.discoverEngine({
1880
- ...plan,
1944
+ ...(plan || {}),
1945
+ engine: id,
1881
1946
  home,
1882
1947
  userModels, backupModels,
1948
+ knownIds,
1883
1949
  ...(wanted.length ? { detailIds: wanted } : {}),
1884
- publicCatalog: { provider: profile.provider, baseUrl: plan.baseUrl, ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) },
1950
+ publicCatalog: {
1951
+ provider: profile.provider,
1952
+ ...(plan && plan.baseUrl ? { baseUrl: plan.baseUrl } : {}),
1953
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
1954
+ },
1885
1955
  ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
1886
1956
  ...(opts.refresh === true ? { noCache: true } : {}),
1957
+ ...(plan ? {} : { apiAvailable: false }),
1887
1958
  });
1888
1959
  // Curated response: `plan` (baseUrl, apiKey) stays out; apiModels is
1889
- // already merged (with provenance) into `models`.
1890
- // `backupValues` rides along only for fields NO layer could fill: the
1891
- // internal catalog's proposal for the missing-data dialog. A field any
1892
- // layer answered carries no proposal — the dialog asks for what nobody
1893
- // knows, and the operator's confirmation is what turns a proposal into a
1894
- // user value.
1895
- const internalIndex = internalModelIndex(profile);
1896
- const models = result.models.map((m) => {
1897
- const backupValues = {};
1898
- for (const field of ['contextWindow', 'maxTokens']) {
1899
- if (m[field] === undefined) {
1900
- const known = internalValueFor(internalIndex, m.id);
1901
- if (known && known[field] !== undefined) backupValues[field] = known[field];
1902
- }
1903
- }
1904
- return Object.keys(backupValues).length ? { ...m, backupValues } : m;
1905
- });
1960
+ // already merged (with provenance) into `models`. `backupValues` rides
1961
+ // along only for fields NO layer could fill.
1962
+ const models = withBackupDefaults(result.models, profile);
1963
+ if (!plan) {
1964
+ const cred = modelListingCredential(profile, spec, cfg, home);
1965
+ return {
1966
+ engine: id,
1967
+ supported: false,
1968
+ source: 'unsupported',
1969
+ status: result.status,
1970
+ stale: false,
1971
+ error: '',
1972
+ auth: {
1973
+ credentialState: cred.credentialState,
1974
+ ...(cred.envKey ? { envKey: cred.envKey } : {}),
1975
+ attempted: false,
1976
+ credentialSent: false,
1977
+ },
1978
+ ...(result.publicCatalog ? { publicCatalog: result.publicCatalog } : {}),
1979
+ models,
1980
+ cap: DISCOVERY_PROPOSAL_CAP,
1981
+ };
1982
+ }
1906
1983
  return {
1907
1984
  engine: id,
1908
1985
  supported: true,
@@ -1920,6 +1997,67 @@ async function createBuiltinFleet(cfg = {}) {
1920
1997
 
1921
1998
  async function close() { await launchBroker.close(); if (leaseManager) leaseManager.close(); }
1922
1999
 
2000
+ // --- Sessione nuova esplicita (contratto vl) ------------------------------
2001
+ // Azione SEPARATA dal ciclo di lancio: crea la richiesta persistente che il
2002
+ // prossimo up() della cella consumera' UNA volta (rename atomico, poi --new
2003
+ // alla sola generazione 0). Mai un flag dell'engine, mai un avvio implicito:
2004
+ // l'operatore chiede, il ciclo di lancio applica e consuma. Autorizzata sul
2005
+ // nodo owner perche' il marcatore e lo spawn sono dello stesso nodo.
2006
+ async function vlNewSession(cellId) {
2007
+ if (readonly()) throw httpError(403, 'READONLY: vl-new-session bloccato');
2008
+ const id = String(cellId || '');
2009
+ if (!/^[A-Za-z0-9._-]{1,32}$/.test(id) || id === '.' || id === '..') {
2010
+ throw httpError(400, 'id cell non valido');
2011
+ }
2012
+ const defs = reloadDefs();
2013
+ const cell = findCell(defs, id);
2014
+ if (!cell) throw httpError(400, `cella sconosciuta: ${id}`);
2015
+ const engine = findEngine(defs, cell.engine);
2016
+ if (!engine || !engine.managed || engine.managed.client !== 'vl') {
2017
+ throw httpError(400, `la cella ${id} non usa un engine vl: la sessione nuova esplicita esiste solo per vl`);
2018
+ }
2019
+ try {
2020
+ const requested = requestVlNewSession({ home, cellId: id, now: Date.now() });
2021
+ // Lo stato COMPLETO dopo la richiesta: se sotto c'e' un consumo precedente
2022
+ // non verificato, l'operatore lo vede qui (esito incerto riferito, mai
2023
+ // ripetuto in automatico).
2024
+ return { ok: true, cell: id, ...requested, status: vlNewSessionStatus({ home, cellId: id }) };
2025
+ } catch (e) {
2026
+ throw httpError(500, `richiesta sessione nuova non registrabile: ${e.message}`);
2027
+ }
2028
+ }
2029
+
2030
+ // Vista READ-ONLY dello stato della richiesta di sessione nuova, per una
2031
+ // cella sola. Il contratto e' una domanda su UNA cella: chi non puo'
2032
+ // agire su quella cella non ne legge lo stato (il percorso di scope lo
2033
+ // dichiara come bersaglio, quindi la risposta non porta niente di altre
2034
+ // celle). Niente mutazioni: e' una lettura, e non e' una richiesta nuova.
2035
+ function vlNewSessionStateView(cellId) {
2036
+ const id = String(cellId || '');
2037
+ if (!/^[A-Za-z0-9._-]{1,32}$/.test(id) || id === '.' || id === '..') {
2038
+ throw httpError(400, 'id cell non valido');
2039
+ }
2040
+ const defs = reloadDefs();
2041
+ const cell = findCell(defs, id);
2042
+ if (!cell) throw httpError(400, `cella sconosciuta: ${id}`);
2043
+ const engine = findEngine(defs, cell.engine);
2044
+ if (!engine || !engine.managed || engine.managed.client !== 'vl') {
2045
+ throw httpError(400, `la cella ${id} non usa un engine vl: lo stato della sessione nuova esiste solo per vl`);
2046
+ }
2047
+ // 'pending' = il prossimo lancio ruota; 'consumed' = un lancio ha
2048
+ // consumato la richiesta e l'esito non e' tracciato; 'none' = mai
2049
+ // richiesto. Nomi e timestamp: nessun contenuto di sessione.
2050
+ return { cell: id, ...vlNewSessionStatus({ home, cellId: id }) };
2051
+ }
2052
+
2053
+ // Inventario read-only delle cartelle di stato vl LEGACY (pre-contratto):
2054
+ // elenca quello che esiste, non sposta e non cancella nulla. La migrazione,
2055
+ // se mai ci sara', e' una procedura esplicita separata con backup e scelta
2056
+ // dell'operatore.
2057
+ function vlLegacyInventoryView() {
2058
+ return vlLegacyInventory(home);
2059
+ }
2060
+
1923
2061
  return {
1924
2062
  available: true,
1925
2063
  provider: 'builtin',
@@ -1929,6 +2067,7 @@ async function createBuiltinFleet(cfg = {}) {
1929
2067
  schema, definitions, capabilities,
1930
2068
  credentialStatus, setLocalCredential, removeLocalCredential, testModel, discoverEngineModels,
1931
2069
  defineModel, editModel, removeModel, close,
2070
+ vlNewSession, vlNewSessionState: vlNewSessionStateView, vlLegacyInventory: vlLegacyInventoryView,
1932
2071
  // Fetta 2b: il lease Live esposto alle route /api/lease, che il bridge
1933
2072
  // MCP delle celle consuma per register/refresh/recovery. Assente se il
1934
2073
  // lease e' disattivato: le route rispondono 501, non 500.
@@ -40,6 +40,10 @@ const CODES = [
40
40
  'MCP_CELL_FILE_UNWRITABLE',
41
41
  'CAPABILITY_PROFILE_UNWRITABLE',
42
42
  'CAPABILITY_UNKNOWN_NAME',
43
+ // preflight vl: pausa minima fra due lanci della stessa cella non trascorsa /
44
+ // marcatore «sessione nuova» non consumabile in sicurezza (albero ostile)
45
+ 'VL_LAUNCH_COOLDOWN',
46
+ 'VL_NEW_SESSION_UNSAFE',
43
47
  // launch-broker
44
48
  'LAUNCH_BROKER_UNSAFE',
45
49
  'LAUNCH_BROKER_PAYLOAD',
@@ -147,7 +147,7 @@ function validActivity(value) {
147
147
 
148
148
  function validPayload(payload) {
149
149
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
150
- if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel', 'activity'].includes(key))) return false;
150
+ if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel', 'activity', 'vlNewSession'].includes(key))) return false;
151
151
  if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
152
152
  if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
153
153
  // Il launcher decide se il canale identita' esiste (managed.js) e lo dichiara
@@ -155,6 +155,10 @@ function validPayload(payload) {
155
155
  // va quindi ammessa qui, e SOLO come booleano: un valore di altro tipo e' un
156
156
  // payload malformato, non un "canale assente" (assente = caller precedente).
157
157
  if (payload.identityChannel !== undefined && typeof payload.identityChannel !== 'boolean') return false;
158
+ // Stessa regola per la sessione nuova vl: la decisione arriva GIÀ PRESA dal
159
+ // launcher (che ha consumato il marcatore prima dello spawn), quindi qui è un
160
+ // booleano o niente — mai un argv da reinterpretare.
161
+ if (payload.vlNewSession !== undefined && typeof payload.vlNewSession !== 'boolean') return false;
158
162
  return payload.args.every((v) => typeof v === 'string')
159
163
  && Object.entries(payload.env).every(([k, v]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && typeof v === 'string')
160
164
  && validSupervise(payload.supervise)
@@ -767,7 +771,16 @@ async function main(argv = process.argv.slice(2), seams = {}) {
767
771
  const startedAt = now();
768
772
  const childState = { exited: false };
769
773
  const promptCtl = startGenerationPrompt(payload.restartPrompt, generation, childState, seams);
770
- current = spawnImpl(payload.command, payload.args, {
774
+ // Sessione nuova vl (payload.vlNewSession): `--new` va SOLO alla
775
+ // generazione 0 — il primo spawn dopo che il launcher ha consumato la
776
+ // richiesta esplicita. I restart supervisionati (generazione 1+) usano
777
+ // argv pulito: una rotazione ripetuta butterebbe via la sessione appena
778
+ // creata, ed è esattamente ciò che il contratto vieta. Il payload resta
779
+ // immutato: la decisione è del launcher, l'applicazione è per generazione.
780
+ const generationArgs = generation === 0 && payload.vlNewSession === true
781
+ ? [...payload.args, '--new']
782
+ : payload.args;
783
+ current = spawnImpl(payload.command, generationArgs, {
771
784
  env: childEnv,
772
785
  // Senza canale non si creano nemmeno le pipe: fd 3 e 4 chiusi nel
773
786
  // child (non pipe aperte e inutilizzate).
@@ -196,7 +196,7 @@ const MODEL_ID_MAX = 128;
196
196
 
197
197
  function parseModel(m) {
198
198
  if (!m || typeof m !== 'object' || Array.isArray(m)) return null;
199
- const allowed = new Set(['id', 'engine', 'label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning']);
199
+ const allowed = new Set(['id', 'engine', 'label', 'contextWindow', 'maxTokens', 'tools', 'vision', 'reasoning', 'effortLevels']);
200
200
  if (Object.keys(m).some((k) => !allowed.has(k))) return null;
201
201
  const id = typeof m.id === 'string' ? m.id.trim() : '';
202
202
  // Stessa forma accettata dal gate dei modelli: nessun byte di controllo, un
@@ -230,6 +230,20 @@ function parseModel(m) {
230
230
  if (typeof m[key] !== 'boolean') return null;
231
231
  out[key] = m[key];
232
232
  }
233
+ // `effortLevels`: the effort levels DECLARED for this model (client vl, env
234
+ // VL_EFFORT). The runtime accepts only declared levels — no universal
235
+ // low/medium/high equivalence is assumed — so an absent declaration means
236
+ // "no effort can be requested for this model", never "any level goes".
237
+ // Short lowercase tokens only: they land in an env value, not in argv.
238
+ if (m.effortLevels !== undefined) {
239
+ if (!Array.isArray(m.effortLevels) || !m.effortLevels.length || m.effortLevels.length > 8) return null;
240
+ const seen = new Set();
241
+ for (const level of m.effortLevels) {
242
+ if (typeof level !== 'string' || !/^[a-z0-9][a-z0-9-]{0,15}$/.test(level) || seen.has(level)) return null;
243
+ seen.add(level);
244
+ }
245
+ out.effortLevels = [...m.effortLevels];
246
+ }
233
247
  return out;
234
248
  }
235
249