@novedu/cli 0.8.0 → 0.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.
Files changed (2) hide show
  1. package/dist/main.js +401 -188
  2. package/package.json +3 -3
package/dist/main.js CHANGED
@@ -358,25 +358,34 @@ Purely local — already-issued access tokens stay valid until they expire
358
358
  });
359
359
  }
360
360
  //#endregion
361
- //#region ../lib/tutors/assemble.ts
361
+ //#region ../lib/prompt-fragments/assemble.ts
362
362
  const COMPILE_OPTIONS = {
363
363
  strict: true,
364
364
  noEscape: true
365
365
  };
366
366
  /**
367
- * Render each fragment in priority order and append the tutor-specific
368
- * instructions last (they carry no priority, so "after everything" is the only
369
- * deterministic position). May throw if a template references a missing variable.
367
+ * Render each fragment in priority order and, when provided, append the caller's
368
+ * trailing instructions last (they carry no priority, so "after everything" is the
369
+ * only deterministic position the exact role `tutor_instructions` plays for a
370
+ * tutor, and the activity frame / `instructions` play for quiz / writing / coding).
371
+ * May throw if a template references a missing variable.
372
+ *
373
+ * `trailingInstructions` is optional so a consumer can assemble a fragment-only
374
+ * PREAMBLE (quiz / writing / coding) and concatenate its own frame afterwards. An
375
+ * empty plan with no trailing text renders to the empty string (so an activity that
376
+ * declares no fragments gets no stray whitespace); every non-empty result ends in a
377
+ * single trailing newline, byte-identical to the historic tutor output.
370
378
  */
371
- function assembleSystemPrompt(plan, tutor) {
379
+ function assembleSystemPrompt(plan, trailingInstructions) {
372
380
  const parts = plan.map((fragment) => {
373
381
  return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
374
382
  });
375
- parts.push(tutor.prompt.tutor_instructions.trimEnd());
383
+ if (trailingInstructions !== void 0) parts.push(trailingInstructions.trimEnd());
384
+ if (parts.length === 0) return "";
376
385
  return `${parts.join("\n\n")}\n`;
377
386
  }
378
387
  //#endregion
379
- //#region ../lib/tutors/errors.ts
388
+ //#region ../lib/prompt-fragments/errors.ts
380
389
  /** Small helper to build an error object tersely at call sites. */
381
390
  function error(code, message, extra = {}) {
382
391
  return {
@@ -413,7 +422,7 @@ function formatZodIssues(zodIssues) {
413
422
  return out;
414
423
  }
415
424
  //#endregion
416
- //#region ../lib/tutors/consistency.ts
425
+ //#region ../lib/prompt-fragments/consistency.ts
417
426
  /** Compare a supplied value against its declared property type. Returns null when it matches. */
418
427
  function typeMismatch(prop, value) {
419
428
  const actual = Array.isArray(value) ? "array" : typeof value;
@@ -432,11 +441,11 @@ function typeMismatch(prop, value) {
432
441
  };
433
442
  }
434
443
  }
435
- function checkConsistency(tutor, fragmentFilesByAlias) {
444
+ function checkConsistency(block, fragmentFilesByAlias) {
436
445
  const errors = [];
437
446
  const warnings = [];
438
447
  const aliasCounts = /* @__PURE__ */ new Map();
439
- for (const ref of tutor.prompt.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
448
+ for (const ref of block.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
440
449
  for (const [alias, count] of aliasCounts) if (count > 1) errors.push(error("DUPLICATE_FRAGMENT_FILE_ALIAS", `Fragment-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
441
450
  const fragmentIndex = /* @__PURE__ */ new Map();
442
451
  for (const [alias, file] of fragmentFilesByAlias) {
@@ -455,7 +464,7 @@ function checkConsistency(tutor, fragmentFilesByAlias) {
455
464
  }
456
465
  const resolved = [];
457
466
  const seenRefs = /* @__PURE__ */ new Set();
458
- for (const ref of tutor.prompt.fragments) {
467
+ for (const ref of block.fragments) {
459
468
  const refKey = `${ref.file}::${ref.id}`;
460
469
  if (seenRefs.has(refKey)) warnings.push(warning("DUPLICATE_FRAGMENT_REFERENCE", `Fragment "${ref.id}" from "${ref.file}" is referenced more than once`, {
461
470
  fileAlias: ref.file,
@@ -547,7 +556,7 @@ function checkConsistency(tutor, fragmentFilesByAlias) {
547
556
  };
548
557
  }
549
558
  //#endregion
550
- //#region ../lib/tutors/fetcher.ts
559
+ //#region ../lib/prompt-fragments/fetcher.ts
551
560
  const DEFAULT_TIMEOUT_MS = 1e4;
552
561
  /** Production fetcher: global `fetch` with an abort-based timeout so a slow host can't hang the request. */
553
562
  const defaultFetcher = async (url) => {
@@ -563,7 +572,7 @@ const defaultFetcher = async (url) => {
563
572
  }
564
573
  };
565
574
  //#endregion
566
- //#region ../lib/tutors/parse.ts
575
+ //#region ../lib/prompt-fragments/parse.ts
567
576
  /** Parse a YAML document, mapping syntax errors to a structured `YAML_PARSE_ERROR`. */
568
577
  function parseYaml(text, url) {
569
578
  try {
@@ -593,105 +602,117 @@ function validate(value, schema, code, url) {
593
602
  })
594
603
  };
595
604
  }
596
- const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH");
597
605
  //#endregion
598
- //#region ../lib/tutors/schemas.ts
606
+ //#region ../lib/prompt-fragments/schemas.ts
599
607
  /**
600
608
  * A fragment-file reference: either an absolute http(s) URL or a relative path that
601
- * `load.ts` resolves against the tutor YAML's own URL. We reject any *other* absolute
609
+ * `load.ts` resolves against the activity YAML's own URL. We reject any *other* absolute
602
610
  * scheme (`ftp:`, `mailto:`, …) so a typo can't smuggle in a non-http(s) target — the
603
611
  * refine reads as "if it carries a URI scheme at all, that scheme must be http(s)".
604
612
  * Strings without a scheme (relative paths) pass through and are resolved at load time.
613
+ *
614
+ * The `.refine()` is the RUNTIME enforcement. `z.toJSONSchema()` cannot derive a pattern
615
+ * from a refinement, so the equivalent editor constraint is re-expressed via `.meta({ pattern })`
616
+ * — the two MUST be kept consistent (both mean "http(s) URL or a scheme-less relative path").
605
617
  */
606
- const FragmentUrlRef = z.string().min(1).refine((u) => !/^[a-z][a-z0-9+.-]*:/i.test(u) || /^https?:\/\//i.test(u), { message: "Must be an http(s) URL or a relative path" });
618
+ const FragmentUrlRef = z.string().min(1).refine((u) => !/^[a-z][a-z0-9+.-]*:/i.test(u) || /^https?:\/\//i.test(u), { message: "Must be an http(s) URL or a relative path" }).meta({
619
+ pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
620
+ description: "HTTP(S) URL or relative path to the fragment library."
621
+ });
607
622
  /**
608
623
  * A declared property is a string, a boolean, or an array of strings. Each may carry an
609
624
  * optional `default`, typed to match its `type` (a string default on a boolean property
610
- * is a schema error). When the tutor omits the variable, the default is used; supplying
625
+ * is a schema error). When the activity omits the variable, the default is used; supplying
611
626
  * a value overrides it. See `consistency.ts` for where defaults are injected.
612
627
  */
628
+ const StringPropertySchema = z.strictObject({
629
+ type: z.literal("string").meta({ description: "Declares a string variable." }),
630
+ default: z.string().optional().meta({ description: "Default string used when the activity omits this variable." })
631
+ }).meta({
632
+ id: "stringProperty",
633
+ description: "A string variable, with an optional default."
634
+ });
635
+ const BooleanPropertySchema = z.strictObject({
636
+ type: z.literal("boolean").meta({ description: "Declares a boolean variable." }),
637
+ default: z.boolean().optional().meta({ description: "Default boolean used when the activity omits this variable." })
638
+ }).meta({
639
+ id: "booleanProperty",
640
+ description: "A boolean variable, with an optional default."
641
+ });
642
+ const StringArrayPropertySchema = z.strictObject({
643
+ type: z.literal("array").meta({ description: "Declares an array-of-strings variable." }),
644
+ items: z.strictObject({ type: z.literal("string").meta({ description: "Array elements are strings." }) }).meta({ description: "The element type of the array (always string)." }),
645
+ default: z.array(z.string()).optional().meta({ description: "Default string array used when the activity omits this variable." })
646
+ }).meta({
647
+ id: "stringArrayProperty",
648
+ description: "An array-of-strings variable, with an optional default."
649
+ });
613
650
  const PropertySchema = z.discriminatedUnion("type", [
614
- z.strictObject({
615
- type: z.literal("string"),
616
- default: z.string().optional()
617
- }),
618
- z.strictObject({
619
- type: z.literal("boolean"),
620
- default: z.boolean().optional()
621
- }),
622
- z.strictObject({
623
- type: z.literal("array"),
624
- items: z.strictObject({ type: z.literal("string") }),
625
- default: z.array(z.string()).optional()
626
- })
627
- ]);
651
+ StringPropertySchema,
652
+ BooleanPropertySchema,
653
+ StringArrayPropertySchema
654
+ ]).meta({
655
+ id: "inputProperty",
656
+ description: "A declared variable: a string, a boolean, or an array of strings, each with an optional matching default."
657
+ });
628
658
  const InputSchema = z.strictObject({
629
- type: z.literal("object"),
630
- required: z.array(z.string()).default([]),
631
- properties: z.record(z.string(), PropertySchema).default({})
659
+ type: z.literal("object").meta({ description: "Always \"object\"." }),
660
+ required: z.array(z.string()).default([]).meta({ description: "Names of the variables that must be supplied." }),
661
+ properties: z.record(z.string(), PropertySchema).default({}).meta({ description: "The declared variables, keyed by variable name." })
662
+ }).meta({
663
+ id: "inputSchema",
664
+ description: "A constrained mini JSON-schema declaring the variables a fragment accepts."
632
665
  });
633
666
  const ClassificationSchema = z.strictObject({
634
- type: z.string(),
635
- override_allowed: z.boolean().optional()
667
+ type: z.string().meta({ description: "Classification label for this fragment." }),
668
+ override_allowed: z.boolean().optional().meta({ description: "Whether an activity may override this classification." })
669
+ }).meta({
670
+ id: "classification",
671
+ description: "Optional classification metadata for a fragment."
636
672
  });
637
673
  const FragmentSchema = z.strictObject({
638
- id: z.string(),
639
- version: z.number(),
640
- priority: z.number(),
674
+ id: z.string().meta({ description: "Unique fragment id within this library." }),
675
+ version: z.number().meta({ description: "Fragment version number." }),
676
+ priority: z.number().meta({ description: "Assembly order. Lower priorities appear earlier." }),
641
677
  input_schema: InputSchema.optional(),
642
678
  classification: ClassificationSchema.optional(),
643
- content: z.string()
679
+ content: z.string().meta({ description: "Prompt text as a Handlebars template." })
680
+ }).meta({
681
+ id: "fragment",
682
+ description: "One reusable, parameterized prompt fragment."
644
683
  });
645
684
  const FragmentFileSchema = z.strictObject({
646
- id: z.string(),
647
- fragments: z.array(FragmentSchema).min(1)
685
+ id: z.string().meta({ description: "Machine-readable id of this fragment library." }),
686
+ fragments: z.array(FragmentSchema).min(1).meta({ description: "The reusable fragments this library provides (at least one)." })
648
687
  });
649
688
  /** A supplied variable value mirrors what `input_schema` can declare. */
650
689
  const VariableValueSchema = z.union([
651
690
  z.string(),
652
691
  z.boolean(),
653
692
  z.array(z.string())
654
- ]);
693
+ ]).meta({
694
+ id: "variableValue",
695
+ description: "A literal variable value: a string, a boolean, or an array of strings."
696
+ });
655
697
  const FragmentFileRefSchema = z.strictObject({
656
- id: z.string(),
698
+ id: z.string().meta({ description: "Local alias for this library, referenced by each fragment's `file`." }),
657
699
  url: FragmentUrlRef
700
+ }).meta({
701
+ id: "fragmentFileRef",
702
+ description: "A reference to a fragment library by alias + URL."
658
703
  });
659
704
  const FragmentRefSchema = z.strictObject({
660
- file: z.string(),
661
- id: z.string(),
662
- variables: z.record(z.string(), VariableValueSchema).optional(),
663
- bind: z.record(z.string(), z.string()).optional(),
664
- required: z.boolean().optional()
665
- });
666
- /**
667
- * An example question offered to students on the welcome screen: the `title` is
668
- * the clickable label, the `question` is the full text placed into the chat
669
- * input on click. Tutors may define any number; the UI samples at most 5.
670
- */
671
- const ExampleQuestionSchema = z.strictObject({
672
- title: z.string().min(1),
673
- question: z.string().min(1)
674
- });
675
- const TutorSchema = z.strictObject({
676
- id: z.string(),
677
- name: z.string(),
678
- title: z.string().optional(),
679
- description: z.string(),
680
- exampleQuestions: z.array(ExampleQuestionSchema).optional(),
681
- anonymous: z.boolean().optional(),
682
- llm: z.strictObject({
683
- model: z.string(),
684
- provider: providerSchema,
685
- imageInput: z.boolean().optional()
686
- }),
687
- prompt: z.strictObject({
688
- fragment_files: z.array(FragmentFileRefSchema).default([]),
689
- fragments: z.array(FragmentRefSchema).default([]),
690
- tutor_instructions: z.string()
691
- })
705
+ file: z.string().meta({ description: "The alias of the fragment library this fragment is drawn from." }),
706
+ id: z.string().meta({ description: "Fragment id inside the referenced library." }),
707
+ variables: z.record(z.string(), VariableValueSchema).optional().meta({ description: "Literal values passed into the fragment template." }),
708
+ bind: z.record(z.string(), z.string()).optional().meta({ description: "Accepted for compatibility but ignored by the current assembler." }),
709
+ required: z.boolean().optional().meta({ description: "Marker for important fragments. Accepted but not currently enforced." })
710
+ }).meta({
711
+ id: "fragmentRef",
712
+ description: "Selects one fragment from a referenced library."
692
713
  });
693
714
  //#endregion
694
- //#region ../lib/tutors/fragment.ts
715
+ //#region ../lib/prompt-fragments/fragment.ts
695
716
  /**
696
717
  * A placeholder value for a declared input, shaped to its type so the template
697
718
  * actually exercises it: a string renders, a boolean drives `{{#if}}`, an array
@@ -794,17 +815,17 @@ function resolveRelativeUrl(ref, baseUrl) {
794
815
  return new URL(ref, baseUrl).href;
795
816
  }
796
817
  //#endregion
797
- //#region ../lib/tutors/load.ts
818
+ //#region ../lib/prompt-fragments/load.ts
798
819
  /**
799
820
  * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
800
- * as-is; anything else is treated as relative to the tutor URL — standard URL resolution
801
- * drops the tutor's filename and appends the relative path (so `my-fragments.yaml`
821
+ * as-is; anything else is treated as relative to the activity URL — standard URL resolution
822
+ * drops the activity's filename and appends the relative path (so `my-fragments.yaml`
802
823
  * next to `.../tutors/my-tutor.yaml` becomes `.../tutors/my-fragments.yaml`,
803
824
  * and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
804
825
  * already guarantees the only inputs here are http(s) URLs or relative paths.
805
826
  */
806
- function resolveFragmentUrl(ref, tutorUrl) {
807
- return resolveRelativeUrl(ref, tutorUrl);
827
+ function resolveFragmentUrl(ref, baseUrl) {
828
+ return resolveRelativeUrl(ref, baseUrl);
808
829
  }
809
830
  async function fetchText(url, fetchImpl) {
810
831
  try {
@@ -829,23 +850,33 @@ async function fetchText(url, fetchImpl) {
829
850
  }
830
851
  const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
831
852
  /**
832
- * The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
833
- * fetch the document, and parse it as YAML returning the parsed-but-not-yet-schema-
834
- * validated value or the first structured error. Reused by the tutor builder, the
835
- * standalone fragment checker, and the quiz/writing validators so they all gate
836
- * schemes identically.
853
+ * The SSRF scheme gate shared by the top-level activity load (`loadYaml`) and every
854
+ * fragment-file fetch (`assembleFragmentPrompt`): a URL is allowed only if its scheme
855
+ * is in `allowedSchemes` (default http(s); the CLI adds `file:` for on-disk validation).
856
+ * Returns the structured error to surface, or `null` when the scheme is allowed.
837
857
  */
838
- async function loadYaml(url, fetchImpl, opts = {}) {
839
- const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
858
+ function schemeGate(url, allowedSchemes) {
840
859
  let scheme;
841
860
  try {
842
861
  scheme = new URL(url).protocol;
843
862
  } catch {
844
863
  scheme = "";
845
864
  }
846
- if (!allowedSchemes.includes(scheme)) return {
865
+ if (allowedSchemes.includes(scheme)) return null;
866
+ return error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url });
867
+ }
868
+ /**
869
+ * The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
870
+ * fetch the document, and parse it as YAML — returning the parsed-but-not-yet-schema-
871
+ * validated value or the first structured error. Reused by the activity builders, the
872
+ * standalone fragment checker, and the quiz/writing/coding validators so they all gate
873
+ * schemes identically.
874
+ */
875
+ async function loadYaml(url, fetchImpl, opts = {}) {
876
+ const schemeError = schemeGate(url, opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES);
877
+ if (schemeError) return {
847
878
  ok: false,
848
- error: error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })
879
+ error: schemeError
849
880
  };
850
881
  const fetched = await fetchText(url, fetchImpl);
851
882
  if (!fetched.ok) return {
@@ -862,25 +893,30 @@ async function loadYaml(url, fetchImpl, opts = {}) {
862
893
  value: parsed.value
863
894
  };
864
895
  }
865
- async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
896
+ /**
897
+ * Resolve a document-level fragment block to a finished prompt string: fetch every
898
+ * declared fragment file in parallel (relative refs resolved against `baseUrl`),
899
+ * schema-validate each, (optionally) run the thorough whole-library check, check
900
+ * consistency, and assemble the priority-ordered plan followed by the optional
901
+ * `trailingInstructions`.
902
+ *
903
+ * The single seam every activity kind shares — the sole owner of the fetch → validate
904
+ * → consistency → assemble pipeline. Consumers concatenate their own frame only when
905
+ * they pass no `trailingInstructions` (a fragment-only preamble); tutors pass their
906
+ * `tutor_instructions` and get a complete prompt.
907
+ */
908
+ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trailingInstructions) {
866
909
  const warnings = [];
867
- const tutorYaml = await loadYaml(url, fetchImpl, opts);
868
- if (!tutorYaml.ok) return {
869
- ok: false,
870
- errors: [tutorYaml.error],
871
- warnings
872
- };
873
- const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
874
- if (!tutorValid.ok) return {
875
- ok: false,
876
- errors: [tutorValid.error],
910
+ const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
911
+ if (block.fragment_files.length === 0 && block.fragments.length === 0) return {
912
+ ok: true,
913
+ prompt: assembleSystemPrompt([], trailingInstructions),
877
914
  warnings
878
915
  };
879
- const tutor = tutorValid.data;
880
- const settled = await Promise.all(tutor.prompt.fragment_files.map(async (ref) => {
916
+ const settled = await Promise.all(block.fragment_files.map(async (ref) => {
881
917
  let fragmentUrl;
882
918
  try {
883
- fragmentUrl = resolveFragmentUrl(ref.url, url);
919
+ fragmentUrl = resolveFragmentUrl(ref.url, baseUrl);
884
920
  } catch {
885
921
  return {
886
922
  alias: ref.id,
@@ -890,6 +926,14 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
890
926
  })
891
927
  };
892
928
  }
929
+ const schemeError = schemeGate(fragmentUrl, allowedSchemes);
930
+ if (schemeError) return {
931
+ alias: ref.id,
932
+ error: {
933
+ ...schemeError,
934
+ fileAlias: ref.id
935
+ }
936
+ };
893
937
  const fetched = await fetchText(fragmentUrl, fetchImpl);
894
938
  if (!fetched.ok) return {
895
939
  alias: ref.id,
@@ -936,7 +980,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
936
980
  libraryErrors.push(...checked.errors);
937
981
  warnings.push(...checked.warnings);
938
982
  }
939
- const consistency = checkConsistency(tutor, fragmentFilesByAlias);
983
+ const consistency = checkConsistency(block, fragmentFilesByAlias);
940
984
  warnings.push(...consistency.warnings);
941
985
  const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
942
986
  if (preAssemblyErrors.length > 0) return {
@@ -947,14 +991,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
947
991
  try {
948
992
  return {
949
993
  ok: true,
950
- prompt: assembleSystemPrompt(consistency.plan, tutor),
951
- model: tutor.llm.model,
952
- provider: tutor.llm.provider,
953
- imageInput: tutor.llm.imageInput ?? true,
954
- anonymous: tutor.anonymous ?? true,
955
- title: tutor.title,
956
- description: tutor.description,
957
- exampleQuestions: tutor.exampleQuestions ?? [],
994
+ prompt: assembleSystemPrompt(consistency.plan, trailingInstructions),
958
995
  warnings
959
996
  };
960
997
  } catch (e) {
@@ -968,9 +1005,9 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
968
1005
  /**
969
1006
  * Validate a fragment FILE on its own (the `--kind fragment` / "Fragment library"
970
1007
  * path): scheme-gate + fetch + parse, then the pure `checkFragmentFileValue`. A
971
- * fragment library is self-contained, so — unlike a tutor — there are no further
1008
+ * fragment library is self-contained, so — unlike an activity — there are no further
972
1009
  * files to fetch. The caller already knows it asked for a fragment, so this returns
973
- * a `FragmentCheckResult` directly (no tutor `BuildResult`, no kind discriminator).
1010
+ * a `FragmentCheckResult` directly (no activity `BuildResult`, no kind discriminator).
974
1011
  */
975
1012
  async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
976
1013
  const yaml = await loadYaml(url, fetchImpl, opts);
@@ -981,33 +1018,32 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
981
1018
  };
982
1019
  return checkFragmentFileValue(yaml.value, url);
983
1020
  }
1021
+ const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH").meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
984
1022
  //#endregion
985
1023
  //#region ../lib/coding-schema.ts
986
1024
  const CodingYamlSchema = z.strictObject({
987
- id: z.string().min(1),
988
- name: z.string().optional(),
989
- title: z.string().optional(),
1025
+ id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. beginner-typescript." }),
1026
+ name: z.string().optional().meta({ description: "Optional human-readable label (not shown to the student)." }),
1027
+ title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
990
1028
  llm: z.strictObject({
991
- model: z.string().min(1),
1029
+ model: z.string().min(1).meta({ description: "The model that answers. SERVER-ONLY and PINNED: the proxy always uses this model and ignores whatever model the coding agent sends." }),
992
1030
  provider: providerSchema
1031
+ }).meta({
1032
+ id: "llm",
1033
+ description: "The pinned model and provider that answer coding requests."
993
1034
  }),
994
- instructions: z.string().min(1)
1035
+ fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
1036
+ fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended AHEAD of instructions." }),
1037
+ instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned." })
995
1038
  });
996
1039
  //#endregion
997
1040
  //#region ../lib/coding-validate.ts
998
1041
  /**
999
- * Validate an already-parsed coding value against its schema, then extract metadata.
1000
- * Pure (the parsed value is passed in); `loadAndCheckCoding` wraps it with fetch +
1001
- * YAML parse.
1042
+ * Extract metadata from an already-schema-validated coding value. Split from
1043
+ * `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
1044
+ * ran (no second parse of the same document against the same schema).
1002
1045
  */
1003
- function checkCodingValue(parsed, url) {
1004
- const valid = validate(parsed, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
1005
- if (!valid.ok) return {
1006
- ok: false,
1007
- errors: [valid.error],
1008
- warnings: []
1009
- };
1010
- const coding = valid.data;
1046
+ function checkCodingParsed(coding) {
1011
1047
  return {
1012
1048
  ok: true,
1013
1049
  codingId: coding.id,
@@ -1029,16 +1065,46 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
1029
1065
  errors: [yaml.error],
1030
1066
  warnings: []
1031
1067
  };
1032
- return checkCodingValue(yaml.value, url);
1068
+ const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
1069
+ if (!valid.ok) return {
1070
+ ok: false,
1071
+ errors: [valid.error],
1072
+ warnings: []
1073
+ };
1074
+ const checked = checkCodingParsed(valid.data);
1075
+ if (!checked.ok) return checked;
1076
+ const assembled = await assembleFragmentPrompt({
1077
+ fragment_files: valid.data.fragment_files,
1078
+ fragments: valid.data.fragments
1079
+ }, url, fetchImpl, {
1080
+ allowedSchemes: opts.allowedSchemes,
1081
+ validateLibraries: opts.validateLibraries ?? true
1082
+ });
1083
+ const warnings = [...checked.warnings, ...assembled.warnings];
1084
+ if (!assembled.ok) return {
1085
+ ok: false,
1086
+ errors: assembled.errors,
1087
+ warnings
1088
+ };
1089
+ return {
1090
+ ...checked,
1091
+ warnings
1092
+ };
1033
1093
  }
1034
1094
  //#endregion
1035
1095
  //#region ../lib/quiz-schema.ts
1036
1096
  /** An optional content image attached to a question (carries no secret). */
1037
1097
  const ImageRefSchema = z.strictObject({
1038
- hosted: z.boolean().optional(),
1039
- src: z.string().min(1),
1040
- alt: z.string().optional(),
1041
- credit: z.string().optional()
1098
+ hosted: z.boolean().optional().meta({
1099
+ default: false,
1100
+ description: "When true, src is an app-hosted image NAME resolved server-side; otherwise src is an absolute URL or a path relative to the quiz's own URL."
1101
+ }),
1102
+ src: z.string().min(1).meta({ description: "The hosted image name (when hosted) or the image URL / relative path." }),
1103
+ alt: z.string().optional().meta({ description: "Accessible description shown if the image cannot be loaded." }),
1104
+ credit: z.string().optional().meta({ description: "Optional attribution (\"Content Credentials\") shown small below the image." })
1105
+ }).meta({
1106
+ id: "image",
1107
+ description: "An optional content image attached to a question."
1042
1108
  });
1043
1109
  /**
1044
1110
  * One question. `id` keys the per-question stats (must be unique — see
@@ -1046,25 +1112,47 @@ const ImageRefSchema = z.strictObject({
1046
1112
  * `evaluation` is the server-only grading prompt.
1047
1113
  */
1048
1114
  const QuizQuestionSchema = z.strictObject({
1049
- id: z.string().min(1),
1050
- title: z.string().optional(),
1051
- question: z.string().min(1),
1052
- evaluation: z.string().min(1),
1053
- image: ImageRefSchema.optional()
1115
+ id: z.string().min(1).meta({ description: "Stable question id, unique within the quiz (the per-question stats key)." }),
1116
+ title: z.string().optional().meta({ description: "Optional short label for the stats table and progress display." }),
1117
+ question: z.string().min(1).meta({ description: "The Markdown shown to the student." }),
1118
+ evaluation: z.string().min(1).meta({ description: "The grading prompt. SERVER-ONLY: never sent to the browser, so it may embed the expected answer and the grading rubric." }),
1119
+ image: ImageRefSchema.optional(),
1120
+ imageInput: z.boolean().optional().meta({ description: "Overrides the quiz-level llm.imageInput for this question only (photo answers on/off)." })
1121
+ }).meta({
1122
+ id: "question",
1123
+ description: "One open-ended, LLM-graded quiz question."
1054
1124
  });
1055
1125
  const QuizYamlSchema = z.strictObject({
1056
- id: z.string().min(1),
1057
- name: z.string().optional(),
1058
- title: z.string().optional(),
1059
- description: z.string().optional(),
1060
- anonymous: z.boolean().optional(),
1061
- shuffle: z.boolean().optional(),
1126
+ id: z.string().min(1).meta({ description: "Short machine-readable quiz id, e.g. countries-basics. Used as the per-quiz identity." }),
1127
+ name: z.string().optional().meta({ description: "Optional human-readable quiz title (used as a label)." }),
1128
+ title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
1129
+ description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
1130
+ anonymous: z.boolean().optional().meta({
1131
+ default: true,
1132
+ description: "Quizzes are anonymous by default: answers are recorded for aggregate stats but not linked to a student. Set to false to attribute each attempt to the signed-in student."
1133
+ }),
1134
+ shuffle: z.boolean().optional().meta({
1135
+ default: true,
1136
+ description: "Present questions in a random order per attempt. Set to false to keep the authored order."
1137
+ }),
1062
1138
  llm: z.strictObject({
1063
- model: z.string().min(1),
1064
- provider: providerSchema
1139
+ model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
1140
+ provider: providerSchema,
1141
+ imageInput: z.boolean().optional().meta({
1142
+ default: false,
1143
+ description: "Default for all questions: students may attach photos (up to 3, 5 MB each) to their answers. The model must be vision-capable. A per-question imageInput overrides it."
1144
+ })
1145
+ }).meta({
1146
+ id: "llm",
1147
+ description: "The single model + provider that grades and discusses answers."
1148
+ }),
1149
+ discussion: z.strictObject({ instructions: z.string().min(1).meta({ description: "Optional guidance appended to the per-question follow-up discussion chat's system prompt." }) }).optional().meta({
1150
+ id: "discussion",
1151
+ description: "Optional guidance for the per-question follow-up discussion chat."
1065
1152
  }),
1066
- discussion: z.strictObject({ instructions: z.string().min(1) }).optional(),
1067
- questions: z.array(QuizQuestionSchema).min(1)
1153
+ fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
1154
+ fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended to BOTH the grader prompt and the discussion chat's system prompt." }),
1155
+ questions: z.array(QuizQuestionSchema).min(1).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt." })
1068
1156
  });
1069
1157
  //#endregion
1070
1158
  //#region ../lib/quiz-validate.ts
@@ -1084,18 +1172,11 @@ function findDuplicateQuestionIds(quiz) {
1084
1172
  return errors;
1085
1173
  }
1086
1174
  /**
1087
- * Validate an already-parsed quiz value: schema → unique question ids → metadata.
1088
- * Pure (the parsed value is passed in); `loadAndCheckQuiz` wraps it with fetch +
1089
- * YAML parse.
1175
+ * Check an already-schema-validated quiz: unique question ids → metadata. Split from
1176
+ * `checkQuizValue` so `loadAndCheckQuiz` can reuse the single `validate` it already ran
1177
+ * (no second parse of the same document against the same schema).
1090
1178
  */
1091
- function checkQuizValue(parsed, url) {
1092
- const valid = validate(parsed, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
1093
- if (!valid.ok) return {
1094
- ok: false,
1095
- errors: [valid.error],
1096
- warnings: []
1097
- };
1098
- const quiz = valid.data;
1179
+ function checkQuizParsed(quiz) {
1099
1180
  const errors = findDuplicateQuestionIds(quiz);
1100
1181
  if (errors.length > 0) return {
1101
1182
  ok: false,
@@ -1114,9 +1195,11 @@ function checkQuizValue(parsed, url) {
1114
1195
  };
1115
1196
  }
1116
1197
  /**
1117
- * Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
1118
- * pure `checkQuizValue`. The web app passes the default http(s)-only schemes; the
1119
- * CLI adds `file:` so a local quiz YAML on disk validates too.
1198
+ * Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), the pure
1199
+ * `checkQuizValue`, then the document-level fragment block's authoring gate fetch
1200
+ * every referenced library, run the THOROUGH whole-library check, consistency, and an
1201
+ * assembly dry-run (the strict-Handlebars backstop). The web app passes the default
1202
+ * http(s)-only schemes; the CLI adds `file:` so a local quiz YAML on disk validates too.
1120
1203
  */
1121
1204
  async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1122
1205
  const yaml = await loadYaml(url, fetchImpl, opts);
@@ -1125,40 +1208,146 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1125
1208
  errors: [yaml.error],
1126
1209
  warnings: []
1127
1210
  };
1128
- return checkQuizValue(yaml.value, url);
1211
+ const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
1212
+ if (!valid.ok) return {
1213
+ ok: false,
1214
+ errors: [valid.error],
1215
+ warnings: []
1216
+ };
1217
+ const checked = checkQuizParsed(valid.data);
1218
+ if (!checked.ok) return checked;
1219
+ const assembled = await assembleFragmentPrompt({
1220
+ fragment_files: valid.data.fragment_files,
1221
+ fragments: valid.data.fragments
1222
+ }, url, fetchImpl, {
1223
+ allowedSchemes: opts.allowedSchemes,
1224
+ validateLibraries: opts.validateLibraries ?? true
1225
+ });
1226
+ const warnings = [...checked.warnings, ...assembled.warnings];
1227
+ if (!assembled.ok) return {
1228
+ ok: false,
1229
+ errors: assembled.errors,
1230
+ warnings
1231
+ };
1232
+ return {
1233
+ ...checked,
1234
+ warnings
1235
+ };
1236
+ }
1237
+ //#endregion
1238
+ //#region ../lib/tutors/schemas.ts
1239
+ /**
1240
+ * An example question offered to students on the welcome screen: the `title` is
1241
+ * the clickable label, the `question` is the full text placed into the chat
1242
+ * input on click. Tutors may define any number; the UI samples at most 5.
1243
+ */
1244
+ const ExampleQuestionSchema = z.strictObject({
1245
+ title: z.string().min(1).meta({ description: "Short clickable label shown on the welcome screen." }),
1246
+ question: z.string().min(1).meta({ description: "Full question text. Shown as a tooltip and placed into the chat input on click." })
1247
+ }).meta({
1248
+ id: "exampleQuestion",
1249
+ description: "An example question shown on the welcome screen."
1250
+ });
1251
+ const TutorSchema = z.strictObject({
1252
+ id: z.string().meta({ description: "Short machine-readable tutor id, e.g. fractions-de." }),
1253
+ name: z.string().meta({ description: "Human-readable tutor title." }),
1254
+ title: z.string().optional().meta({ description: "Optional greeting shown to students on the empty chat instead of the default welcome message." }),
1255
+ description: z.string().meta({ description: "Short description of what this tutor does. Shown to students below the welcome greeting." }),
1256
+ exampleQuestions: z.array(ExampleQuestionSchema).optional().meta({ description: "Optional example questions shown to students below the description on the empty chat. Clicking one puts the question text into the chat input. At most 5 are shown; with more, a random 5 are picked per page load." }),
1257
+ anonymous: z.boolean().optional().meta({
1258
+ default: true,
1259
+ description: "Chats are anonymous by default: no link between the signed-in student and their chat is stored. Set to false to record which student each chat belongs to."
1260
+ }),
1261
+ llm: z.strictObject({
1262
+ model: z.string().meta({ description: "Model used for this tutor." }),
1263
+ provider: providerSchema,
1264
+ imageInput: z.boolean().optional().meta({
1265
+ default: true,
1266
+ description: "Image uploads are enabled by default. Set to false to hide the upload UI for text-only tutors or non-vision-capable models."
1267
+ })
1268
+ }).meta({
1269
+ id: "llm",
1270
+ description: "The model and provider that back this tutor."
1271
+ }),
1272
+ prompt: z.strictObject({
1273
+ fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
1274
+ fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files." }),
1275
+ tutor_instructions: z.string().meta({ description: "Final tutor-specific system-prompt instructions. For single-file tutors, this can be the whole prompt." })
1276
+ }).meta({
1277
+ id: "prompt",
1278
+ description: "The assembled system prompt: fragments plus tutor instructions."
1279
+ })
1280
+ });
1281
+ //#endregion
1282
+ //#region ../lib/tutors/load.ts
1283
+ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
1284
+ const warnings = [];
1285
+ const tutorYaml = await loadYaml(url, fetchImpl, opts);
1286
+ if (!tutorYaml.ok) return {
1287
+ ok: false,
1288
+ errors: [tutorYaml.error],
1289
+ warnings
1290
+ };
1291
+ const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
1292
+ if (!tutorValid.ok) return {
1293
+ ok: false,
1294
+ errors: [tutorValid.error],
1295
+ warnings
1296
+ };
1297
+ const tutor = tutorValid.data;
1298
+ const assembled = await assembleFragmentPrompt(tutor.prompt, url, fetchImpl, opts, tutor.prompt.tutor_instructions);
1299
+ warnings.push(...assembled.warnings);
1300
+ if (!assembled.ok) return {
1301
+ ok: false,
1302
+ errors: assembled.errors,
1303
+ warnings
1304
+ };
1305
+ return {
1306
+ ok: true,
1307
+ prompt: assembled.prompt,
1308
+ model: tutor.llm.model,
1309
+ provider: tutor.llm.provider,
1310
+ imageInput: tutor.llm.imageInput ?? true,
1311
+ anonymous: tutor.anonymous ?? true,
1312
+ title: tutor.title,
1313
+ description: tutor.description,
1314
+ exampleQuestions: tutor.exampleQuestions ?? [],
1315
+ warnings
1316
+ };
1129
1317
  }
1130
1318
  //#endregion
1131
1319
  //#region ../lib/writing-schema.ts
1132
1320
  const WritingYamlSchema = z.strictObject({
1133
- id: z.string().min(1),
1134
- name: z.string().optional(),
1135
- title: z.string().optional(),
1136
- description: z.string().optional(),
1137
- anonymous: z.boolean().optional(),
1321
+ id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. human-animal-short-story." }),
1322
+ name: z.string().optional().meta({ description: "Optional human-readable title (used as a label)." }),
1323
+ title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
1324
+ description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
1325
+ anonymous: z.boolean().optional().meta({
1326
+ default: false,
1327
+ description: "Writing DIVERGES: it defaults to false (attributed), because review and the Save feature need to know whose text it is. Set to true for ephemeral, unattributed writing — which also disables saving."
1328
+ }),
1138
1329
  llm: z.strictObject({
1139
- model: z.string().min(1),
1330
+ model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
1140
1331
  provider: providerSchema
1332
+ }).meta({
1333
+ id: "llm",
1334
+ description: "The model and provider that back the writing coach."
1141
1335
  }),
1142
- instructions: z.string().min(1),
1143
- placeholder: z.string().optional()
1336
+ fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
1337
+ fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended AHEAD of instructions." }),
1338
+ instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy." }),
1339
+ placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
1144
1340
  });
1145
1341
  //#endregion
1146
1342
  //#region ../lib/writing-validate.ts
1147
1343
  /** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
1148
1344
  const DEFAULT_ANONYMOUS = false;
1149
1345
  /**
1150
- * Validate an already-parsed writing value against its schema, then extract metadata.
1151
- * Pure (the parsed value is passed in); `loadAndCheckWriting` wraps it with fetch +
1152
- * YAML parse.
1346
+ * Extract metadata from an already-schema-validated writing value. Split from
1347
+ * `checkWritingValue` so `loadAndCheckWriting` can reuse the single `validate` it already
1348
+ * ran (no second parse of the same document against the same schema).
1153
1349
  */
1154
- function checkWritingValue(parsed, url) {
1155
- const valid = validate(parsed, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
1156
- if (!valid.ok) return {
1157
- ok: false,
1158
- errors: [valid.error],
1159
- warnings: []
1160
- };
1161
- const writing = valid.data;
1350
+ function checkWritingParsed(writing) {
1162
1351
  return {
1163
1352
  ok: true,
1164
1353
  writingId: writing.id,
@@ -1181,7 +1370,31 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
1181
1370
  errors: [yaml.error],
1182
1371
  warnings: []
1183
1372
  };
1184
- return checkWritingValue(yaml.value, url);
1373
+ const valid = validate(yaml.value, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
1374
+ if (!valid.ok) return {
1375
+ ok: false,
1376
+ errors: [valid.error],
1377
+ warnings: []
1378
+ };
1379
+ const checked = checkWritingParsed(valid.data);
1380
+ if (!checked.ok) return checked;
1381
+ const assembled = await assembleFragmentPrompt({
1382
+ fragment_files: valid.data.fragment_files,
1383
+ fragments: valid.data.fragments
1384
+ }, url, fetchImpl, {
1385
+ allowedSchemes: opts.allowedSchemes,
1386
+ validateLibraries: opts.validateLibraries ?? true
1387
+ });
1388
+ const warnings = [...checked.warnings, ...assembled.warnings];
1389
+ if (!assembled.ok) return {
1390
+ ok: false,
1391
+ errors: assembled.errors,
1392
+ warnings
1393
+ };
1394
+ return {
1395
+ ...checked,
1396
+ warnings
1397
+ };
1185
1398
  }
1186
1399
  //#endregion
1187
1400
  //#region src/file-fetcher.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions; signs in with Entra ID and manages codes and app-hosted files over the app's API.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -25,13 +25,13 @@
25
25
  "prepublishOnly": "npm run build"
26
26
  },
27
27
  "dependencies": {
28
- "@azure/msal-node": "^5.3.1",
28
+ "@azure/msal-node": "^5.4.0",
29
29
  "commander": "^15.0.0",
30
30
  "handlebars": "^4.7.9",
31
31
  "yaml": "^2.9.0",
32
32
  "zod": "^4.4.3"
33
33
  },
34
34
  "devDependencies": {
35
- "tsdown": "^0.22.3"
35
+ "tsdown": "^0.22.4"
36
36
  }
37
37
  }