@0xgeorgemathew/zcode 0.4.4 → 0.5.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/lib/executor.js CHANGED
@@ -19,9 +19,11 @@ const { resolveRuntime, makeClient } = require('./bootstrap.js');
19
19
  const { buildEngineHome, rootDir, liveDir } = require('./isolation.js');
20
20
  const { openSession, runTurn, TurnError } = require('./appserver.js');
21
21
  const { runChecks } = require('./checks.js');
22
+ const { reservePort } = require('./ports.js');
22
23
  const {
23
24
  workspaceRoot,
24
25
  isOwned,
26
+ isZcodePath,
25
27
  acquireLease,
26
28
  liveLeases,
27
29
  snapshot,
@@ -34,6 +36,9 @@ const {
34
36
  HANDBACK_REQUEST,
35
37
  parseHandback,
36
38
  deriveStatus,
39
+ unmetVisual,
40
+ visualGaps,
41
+ buildVisualReportRequest,
37
42
  } = require('./contract.js');
38
43
 
39
44
  const RESULT_SCHEMA = 'zcode.result/1';
@@ -52,7 +57,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
52
57
  // Settings a session keeps across invocations (stored as invocation.json).
53
58
  const SETTING_KEYS = [
54
59
  'kind', 'label', 'cwd', 'model', 'effort', 'timeoutMs', 'maxRepairs', 'checkTimeoutMs',
55
- 'checks', 'owns', 'ownsDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit', 'noBrowser',
60
+ 'checks', 'preflight', 'owns', 'ownsDisplay', 'touch', 'touchDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit', 'noBrowser',
56
61
  'skillsDirs', 'noSkills', 'profile', 'includeReasoning', 'allowOverlap', 'stream',
57
62
  ];
58
63
 
@@ -159,6 +164,8 @@ function prepareSession(inv, env = process.env) {
159
164
  workspaceRoot: workspaceRoot(inv.cwd),
160
165
  owns: inv.ownsDisplay || [],
161
166
  ownsRoot: inv.owns || [],
167
+ touch: inv.touchDisplay || [],
168
+ preflight: Boolean(inv.preflight),
162
169
  checks: inv.checks || [],
163
170
  model: inv.model,
164
171
  effort: inv.effort,
@@ -210,7 +217,13 @@ async function execute(inv, opts = {}) {
210
217
  if (releaseSlot) releaseSlot();
211
218
  releaseOwner();
212
219
  };
220
+ // Resuming a preflight session proceeds to the real task: answers + the
221
+ // full protocol, checks and visual verification from here on.
222
+ const leavingPreflight = resuming && Boolean(inv.preflight);
223
+ if (leavingPreflight) inv.preflight = false;
224
+ const preflight = Boolean(inv.preflight);
213
225
  if (resuming) writeSettings(id, inv, env);
226
+ if (leavingPreflight) store.updateSession(id, { preflight: false }, env);
214
227
  const isFirstInvocation = !record.engineSessionId;
215
228
  const update = (patch) => store.updateSession(id, patch, env);
216
229
 
@@ -285,14 +298,19 @@ async function execute(inv, opts = {}) {
285
298
  }
286
299
 
287
300
  // --- lease ------------------------------------------------------------------
288
- const lease = acquireLease({ id, root, owns: inv.owns || [], allowOverlap: inv.allowOverlap, env });
301
+ // A preflight is read-only: it reserves nothing and conflicts with nobody.
302
+ const lease = preflight
303
+ ? { ok: true, conflicts: [], others: [], release() {} }
304
+ : acquireLease({ id, root, owns: inv.owns || [], touch: inv.touch || [], allowOverlap: inv.allowOverlap, env });
289
305
  if (!lease.ok) {
290
306
  const names = lease.conflicts
291
307
  .map((c) => `${c.id} (owns ${c.owns && c.owns.length ? c.owns.join(', ') : 'the whole workspace'})`)
292
308
  .join('; ');
293
- const message =
294
- `workspace conflict in ${root}: overlapping ownership with running session(s) ${names}. ` +
295
- 'Declare disjoint --owns paths, use a separate worktree, wait for them, or pass --allow-overlap.';
309
+ const message = lease.touch
310
+ ? `workspace conflict in ${root}: --touch ${(inv.touchDisplay || []).join(', ')} is owned by running session(s) ${names}. ` +
311
+ 'Wait for them, touch something else, or pass --allow-overlap.'
312
+ : `workspace conflict in ${root}: overlapping ownership with running session(s) ${names}. ` +
313
+ 'Declare disjoint --owns paths, use a separate worktree, wait for them, or pass --allow-overlap.';
296
314
  if (inv.adopt) {
297
315
  // A detached worker: the caller is polling this id, so leave a result.
298
316
  store.writeResult(id, {
@@ -323,6 +341,9 @@ async function execute(inv, opts = {}) {
323
341
  let subagentNote = null;
324
342
  let built = null;
325
343
  let before = null;
344
+ let releasePort = null;
345
+ let sessionEnv = null;
346
+ const visualDeclared = (inv.contract && inv.contract.visual) || [];
326
347
  const concurrent = new Map(lease.others.map((o) => [o.id, o.owns || []]));
327
348
  const checkTouched = new Set(); // files written by zcode's own check runs
328
349
  const invocationStart = new Date(started).toISOString();
@@ -331,6 +352,23 @@ async function execute(inv, opts = {}) {
331
352
  if (signal && signal.aborted) throw new TurnError('cancelled', { kind: 'cancelled' });
332
353
  if (!releaseSlot) throw new TurnError('run exceeded --timeout-ms while queued', { kind: 'timeout' });
333
354
 
355
+ // Per-session environment for the executor AND the checks: a port no
356
+ // other live session holds, plus output and scratch dirs of its own.
357
+ const reserved = await reservePort({ env, preferred: record.port });
358
+ if (!reserved) throw new ExecError('no free port for ZCODE_PORT in 41000-48999', { exitCode: 5, kind: 'no_port' });
359
+ releasePort = reserved.release;
360
+ const outDir = path.join(record.cwd, '.zcode', 'out', id);
361
+ const tmpDir = path.join(dir, 'tmp');
362
+ fs.mkdirSync(outDir, { recursive: true });
363
+ fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
364
+ sessionEnv = {
365
+ ZCODE_SESSION_ID: id,
366
+ ZCODE_PORT: String(reserved.port),
367
+ ZCODE_OUT: outDir,
368
+ ZCODE_TMP: tmpDir,
369
+ };
370
+ update({ port: reserved.port, out: outDir, tmp: tmpDir });
371
+
334
372
  built = buildEngineHome({
335
373
  home,
336
374
  root: rootDir(env),
@@ -348,7 +386,7 @@ async function execute(inv, opts = {}) {
348
386
  // nothing has been sent to the model yet).
349
387
  setPhase('starting');
350
388
  for (let attempt = 1; ; attempt++) {
351
- client = makeClient(runtime, { home, cwd: record.cwd });
389
+ client = makeClient(runtime, { home, cwd: record.cwd, sessionEnv });
352
390
  client.start();
353
391
  update({ enginePid: client.child.pid });
354
392
  try {
@@ -373,10 +411,21 @@ async function execute(inv, opts = {}) {
373
411
  // First prompt for this invocation.
374
412
  let prompt;
375
413
  if (inv.kind === 'task') {
376
- prompt = isFirstInvocation
377
- ? buildTaskPrompt(inv.contract, { ownsDisplay: inv.ownsDisplay || [] }) +
414
+ const taskPrompt = () => buildTaskPrompt(inv.contract, {
415
+ ownsDisplay: inv.ownsDisplay || [],
416
+ session: { id, port: sessionEnv.ZCODE_PORT, out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP },
417
+ preflight,
418
+ });
419
+ prompt = leavingPreflight
420
+ ? `${taskPrompt()}\n\n## Answers to your preflight questions\nThe preflight is over: implement the task now (edits are allowed). The orchestrator's answers are binding:\n${(inv.resume.message || '').trim() || '(none: take the most conservative choice for anything open and list it in handback `decisions`)'}`
421
+ : isFirstInvocation
422
+ ? taskPrompt() +
378
423
  (resuming && inv.resume.message ? `\n\n## Message from the orchestrator\n${inv.resume.message}` : '')
379
- : buildResumePrompt(inv.resume ? inv.resume.message : '', { kind: 'task' });
424
+ : buildResumePrompt(inv.resume ? inv.resume.message : '', {
425
+ kind: 'task',
426
+ // The port is the one thing that may have changed since the first prompt.
427
+ portChange: record.port && String(record.port) !== sessionEnv.ZCODE_PORT ? sessionEnv.ZCODE_PORT : null,
428
+ });
380
429
  } else if (inv.kind === 'agent') {
381
430
  prompt =
382
431
  `You MUST use the Agent tool (profileId: "${inv.profile}") to accomplish the ` +
@@ -387,6 +436,7 @@ async function execute(inv, opts = {}) {
387
436
  }
388
437
 
389
438
  let askedForHandback = false;
439
+ let askedForVisual = false;
390
440
  let phase = 'executing';
391
441
  for (;;) {
392
442
  setPhase(phase, phase === 'repairing' ? { attempt: repairs, of: inv.maxRepairs } : {});
@@ -412,28 +462,44 @@ async function execute(inv, opts = {}) {
412
462
  phase = 'reporting';
413
463
  continue;
414
464
  }
465
+ if (preflight) break; // read-only: zcode runs nothing
415
466
  if (parsed.handback && parsed.handback.status === 'blocked') break;
416
- if (!inv.checks.length) break;
417
-
418
- setPhase('checking', { count: inv.checks.length });
419
- const preChecks = before ? snapshot(root) : null;
420
- checks = await runChecks(inv.checks, {
421
- cwd: record.cwd,
422
- timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
423
- signal,
424
- onEvent: relay,
425
- });
426
- if (preChecks) {
427
- for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
428
- }
429
- if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
430
- throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
467
+ if (!inv.checks.length && !visualDeclared.length) break;
468
+
469
+ if (inv.checks.length) {
470
+ setPhase('checking', { count: inv.checks.length });
471
+ const preChecks = before ? snapshot(root) : null;
472
+ checks = await runChecks(inv.checks, {
473
+ cwd: record.cwd,
474
+ env: { ...env, ...sessionEnv },
475
+ timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
476
+ signal,
477
+ onEvent: relay,
478
+ });
479
+ if (preChecks) {
480
+ for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
481
+ }
482
+ if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
483
+ throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
484
+ }
431
485
  }
432
486
  const failures = checks.filter((c) => !c.ok);
433
- if (!failures.length || repairs >= inv.maxRepairs) break;
487
+ // Unmet visual criteria share the repair budget with failing checks.
488
+ const gaps = visualGaps(parsed.handback, visualDeclared);
489
+ // Criteria merely left out of the handback get one cheap report-only
490
+ // follow-up that does not consume the repair budget.
491
+ if (!failures.length && gaps.missing.length && !gaps.failed.length && !askedForVisual
492
+ && !(signal && signal.aborted) && Date.now() < deadline) {
493
+ askedForVisual = true;
494
+ prompt = buildVisualReportRequest(gaps.missing);
495
+ phase = 'reporting';
496
+ continue;
497
+ }
498
+ const visualUnmet = unmetVisual(parsed.handback, visualDeclared);
499
+ if ((!failures.length && !visualUnmet.length) || repairs >= inv.maxRepairs) break;
434
500
  if ((signal && signal.aborted) || Date.now() >= deadline) break;
435
501
  repairs++;
436
- prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs });
502
+ prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs, visual: visualUnmet });
437
503
  phase = 'repairing';
438
504
  }
439
505
 
@@ -451,6 +517,7 @@ async function execute(inv, opts = {}) {
451
517
  error = err;
452
518
  } finally {
453
519
  if (client) client.kill();
520
+ if (releasePort) releasePort();
454
521
  lease.release();
455
522
  releaseClaims();
456
523
  }
@@ -475,21 +542,38 @@ async function execute(inv, opts = {}) {
475
542
  const after = before ? snapshot(root) : null;
476
543
  const diff = before ? diffSnapshots(before, after) : null;
477
544
  const owns = inv.owns || [];
545
+ const touch = inv.touch || [];
478
546
  const files = (diff || [])
479
- .filter((f) => !f.path.startsWith('.zcode/'))
547
+ .filter((f) => !isZcodePath(f.path))
480
548
  .map((f) => {
481
549
  const owned = isOwned(f.path, owns);
482
- const by = owned
550
+ // --touch: edits allowed but reported, never a scope violation.
551
+ const touchedByDesign = !owned && touch.length > 0 && isOwned(f.path, touch);
552
+ const by = owned || touchedByDesign
483
553
  ? null
484
554
  : [...concurrent.entries()].find(([, o]) => o.length && isOwned(f.path, o));
485
- const fromChecks = !owned && !by && checkTouched.has(f.path);
486
- return { ...f, owned, ...(by ? { concurrentSession: by[0] } : {}), ...(fromChecks ? { fromChecks: true } : {}) };
555
+ const fromChecks = !owned && !touchedByDesign && !by && checkTouched.has(f.path);
556
+ return {
557
+ ...f,
558
+ owned,
559
+ ...(touchedByDesign ? { touched: true } : {}),
560
+ ...(by ? { concurrentSession: by[0] } : {}),
561
+ ...(fromChecks ? { fromChecks: true } : {}),
562
+ };
487
563
  });
564
+ const touched = files.filter((f) => f.touched).map((f) => f.path);
488
565
  const scopeViolations = files
489
- .filter((f) => !f.owned && !f.concurrentSession && !f.fromChecks)
566
+ .filter((f) => !f.owned && !f.touched && !f.concurrentSession && !f.fromChecks)
490
567
  .map((f) => f.path);
491
568
 
492
569
  const handback = parsed.handback;
570
+ // Preflight is read-only: anything it changed (that no concurrent session
571
+ // owns) is a protocol breach.
572
+ const preflightWrote = preflight
573
+ ? files
574
+ .filter((f) => ![...concurrent.values()].some((o) => o.length && isOwned(f.path, o)))
575
+ .map((f) => f.path)
576
+ : [];
493
577
  let { status, issues } = deriveStatus({
494
578
  kind: inv.kind,
495
579
  error: error && !cancelled && error.kind !== 'user_input' ? error : null,
@@ -498,12 +582,16 @@ async function execute(inv, opts = {}) {
498
582
  handback,
499
583
  handbackError: parsed.error,
500
584
  checks,
501
- scopeViolations,
585
+ scopeViolations: preflight ? [] : scopeViolations,
586
+ preflight,
587
+ preflightWrote,
588
+ visualDeclared,
502
589
  });
503
590
 
504
591
  let errorOut = null;
505
592
  if (status === 'failed') {
506
593
  if (error) errorOut = { kind: error.kind || 'error', message: scrub(error.message) };
594
+ else if (preflightWrote.length) errorOut = { kind: 'preflight_wrote', message: issues[0] };
507
595
  else if (checks.some((c) => !c.ok)) errorOut = { kind: 'checks_failed', message: issues[0] };
508
596
  } else if (status === 'cancelled') {
509
597
  errorOut = { kind: 'cancelled', message: 'cancelled' };
@@ -521,10 +609,19 @@ async function execute(inv, opts = {}) {
521
609
  }
522
610
 
523
611
  const notVerified = [];
524
- if (inv.kind === 'task') {
612
+ if (inv.kind === 'task' && preflight) {
613
+ notVerified.push('preflight: the questions come from reading only; nothing was built, run or checked');
614
+ } else if (inv.kind === 'task') {
525
615
  if (!inv.checks.length) notVerified.push('no acceptance checks were declared; nothing was executed by zcode to verify the work');
526
616
  if (inv.acceptanceDeclared) notVerified.push('prose acceptance criteria are confirmed only by the executor\'s own report');
527
617
  if (subagents && subagents.length) notVerified.push('subagent "success" means the subagent returned; only the integrated result is verified');
618
+ if (handback && handback.units.length) {
619
+ notVerified.push(`unit results are executor claims: ${handback.units.map((u) => `${u.name} ${u.status || '?'}`).join(', ')}; only the integrated result is verified`);
620
+ }
621
+ if (visualDeclared.length) {
622
+ const reported = handback ? handback.visual : [];
623
+ notVerified.push(`visual criteria are the executor's judgement from its own screenshots (${reported.filter((v) => v.met === true).length} of ${reported.length} reported met); zcode ran no pixel check`);
624
+ }
528
625
  }
529
626
  if (before && !diff) notVerified.push('changed files could not be tracked in this workspace');
530
627
  if (subagentNote) issues.push(subagentNote);
@@ -542,7 +639,9 @@ async function execute(inv, opts = {}) {
542
639
  id,
543
640
  kind: inv.kind,
544
641
  label: inv.label || null,
545
- summary: handback && handback.summary ? handback.summary : '',
642
+ summary: preflight && status === 'completed'
643
+ ? 'no open questions'
644
+ : handback && handback.summary ? handback.summary : '',
546
645
  response: inv.kind === 'task' ? parsed.report : response,
547
646
  issues,
548
647
  questions,
@@ -553,6 +652,14 @@ async function execute(inv, opts = {}) {
553
652
  files,
554
653
  },
555
654
  scopeViolations,
655
+ touched,
656
+ ...(preflight ? { preflight: true } : {}),
657
+ decisions: handback ? handback.decisions : [],
658
+ visual: handback ? handback.visual : [],
659
+ units: handback ? handback.units : [],
660
+ sessionEnv: sessionEnv
661
+ ? { port: Number(sessionEnv.ZCODE_PORT), out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP }
662
+ : null,
556
663
  checks: checks.map((c) => ({
557
664
  command: c.command,
558
665
  ok: c.ok,
package/lib/isolation.js CHANGED
@@ -360,7 +360,9 @@ function migrateRoot(env = process.env) {
360
360
 
361
361
  // Env for the engine child process (§6.3): HOME=scratch, pinned builtin
362
362
  // config, and the uncontrolled overrides deleted. cwd is set by the caller.
363
- function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
363
+ // `sessionEnv` (ZCODE_SESSION_ID, ZCODE_PORT, …) is applied last so nothing
364
+ // inherited can shadow it; the engine's tools inherit it.
365
+ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env, sessionEnv = {} }) {
364
366
  const childEnv = { ...parentEnv };
365
367
  const realHome = parentEnv.HOME || os.homedir();
366
368
  // The scratch HOME hides the user's tool caches. Point Playwright and npm
@@ -385,6 +387,7 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
385
387
  ]) {
386
388
  delete childEnv[name];
387
389
  }
390
+ Object.assign(childEnv, sessionEnv);
388
391
  return childEnv;
389
392
  }
390
393
 
package/lib/models.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // every engine request itself, so nothing outside this allowlist can reach
4
4
  // the wire.
5
5
 
6
+ const fs = require('node:fs');
7
+
6
8
  const DEFAULT_MODEL = 'GLM-5.3-Flash';
7
9
 
8
10
  // Case-insensitive aliases → official ids (exact ids pass through verbatim;
@@ -43,7 +45,89 @@ function unknownModelMessage(input) {
43
45
  );
44
46
  }
45
47
 
48
+ // --- supported effort levels per model ---------------------------------------
49
+ //
50
+ // The engine rejects an unsupported reasoning level only after it has been
51
+ // spawned and a session opened (GLM-5.3 refused `medium` that way in a real
52
+ // run). The levels live in the engine's builtin provider config
53
+ // (config/provider/zcode-builtin.json, the path `zcode doctor` reports):
54
+ // modelConfigRules.modelRules[] = {modelMatch: <regex>, config:
55
+ // {optionSpecs: {reasoningLevel: {values: […]}}}}. Later rules refine
56
+ // earlier ones (a generic `glm-5` rule lists disabled/enabled; the
57
+ // `glm-5.3(-flash)?` rule after it lists low/high/max), so the LAST matching
58
+ // rule that lists values wins. Unreadable or unmatched → the table below
59
+ // (observed in zcode-builtin.json shipped with ZCode 0.16.x).
60
+ const FALLBACK_LEVELS = {
61
+ 'GLM-5.3': ['low', 'high', 'max'],
62
+ 'GLM-5.3-Flash': ['low', 'high', 'max'],
63
+ };
64
+ const OFFICIAL_IDS = Object.keys(FALLBACK_LEVELS);
65
+
66
+ const levelCache = new Map(); // path:mtime:size → parsed rules
67
+
68
+ function readRules(file) {
69
+ try {
70
+ const st = fs.statSync(file);
71
+ const key = `${file}:${st.mtimeMs}:${st.size}`;
72
+ if (!levelCache.has(key)) {
73
+ const json = JSON.parse(fs.readFileSync(file, 'utf8'));
74
+ const rules = json && json.config && json.config.modelConfigRules && json.config.modelConfigRules.modelRules;
75
+ levelCache.set(key, Array.isArray(rules) ? rules : []);
76
+ }
77
+ return levelCache.get(key);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function builtinConfigPath(env) {
84
+ try {
85
+ const { resolveEngine, resolveBuiltinProviderConfig } = require('./engine.js');
86
+ return resolveBuiltinProviderConfig(resolveEngine(env), env);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // Efforts (a subset of EFFORT_LEVELS, in enum order) the model accepts.
93
+ function supportedEfforts(model, { env = process.env, builtinConfig } = {}) {
94
+ const file = builtinConfig || builtinConfigPath(env);
95
+ let values = null;
96
+ if (file) {
97
+ for (const rule of readRules(file)) {
98
+ let match = false;
99
+ try {
100
+ match = new RegExp(`^(?:${rule.modelMatch})$`, 'i').test(model);
101
+ } catch {
102
+ /* a malformed pattern matches nothing */
103
+ }
104
+ const v = match && rule.config && rule.config.optionSpecs && rule.config.optionSpecs.reasoningLevel
105
+ && rule.config.optionSpecs.reasoningLevel.values;
106
+ if (Array.isArray(v)) values = v;
107
+ }
108
+ }
109
+ const levels = values ? EFFORT_LEVELS.filter((l) => values.includes(l)) : [];
110
+ return levels.length ? levels : FALLBACK_LEVELS[model] || [];
111
+ }
112
+
113
+ // {modelId: [levels]} for every supported model (doctor).
114
+ function modelLevels(opts) {
115
+ return Object.fromEntries(OFFICIAL_IDS.map((id) => [id, supportedEfforts(id, opts)]));
116
+ }
117
+
118
+ // null when `effort` is usable with `model`, else the one-line refusal.
119
+ // `source` names where the value came from ("--effort", "contract effort").
120
+ function effortProblem(model, effort, { source = '--effort', ...opts } = {}) {
121
+ const levels = supportedEfforts(model, opts);
122
+ if (levels.includes(effort)) return null;
123
+ return `${source} ${effort} is not supported by ${model} (supported: ${levels.join(', ')}).`;
124
+ }
125
+
46
126
  module.exports = {
127
+ supportedEfforts,
128
+ modelLevels,
129
+ effortProblem,
130
+ FALLBACK_LEVELS,
47
131
  DEFAULT_MODEL,
48
132
  normalizeModel,
49
133
  unknownModelMessage,