@novedu/cli 0.12.1 → 0.14.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 +553 -160
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -396,25 +396,13 @@ const COMPILE_OPTIONS = {
396
396
  noEscape: true
397
397
  };
398
398
  /**
399
- * Render each fragment in priority order and, when provided, append the caller's
400
- * trailing instructions last (they carry no priority, so "after everything" is the
401
- * only deterministic position the exact role `tutor_instructions` plays for a
402
- * tutor, and the activity frame / `instructions` play for quiz / writing / coding).
403
- * May throw if a template references a missing variable.
404
- *
405
- * `trailingInstructions` is optional so a consumer can assemble a fragment-only
406
- * PREAMBLE (quiz / writing / coding) and concatenate its own frame afterwards. An
407
- * empty plan with no trailing text renders to the empty string (so an activity that
408
- * declares no fragments gets no stray whitespace); every non-empty result ends in a
409
- * single trailing newline, byte-identical to the historic tutor output.
399
+ * Compile and render a single fragment's `content` with the merged variables. Shared
400
+ * by the host-template `fragment` helper (real render) so the produced text is
401
+ * byte-identical to what the standalone fragment check exercises. May throw if the
402
+ * template references a variable not in `variables` (strict mode).
410
403
  */
411
- function assembleSystemPrompt(plan, trailingInstructions) {
412
- const parts = plan.map((fragment) => {
413
- return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
414
- });
415
- if (trailingInstructions !== void 0) parts.push(trailingInstructions.trimEnd());
416
- if (parts.length === 0) return "";
417
- return `${parts.join("\n\n")}\n`;
404
+ function renderFragmentContent(content, variables) {
405
+ return Handlebars.compile(content, COMPILE_OPTIONS)(variables);
418
406
  }
419
407
  //#endregion
420
408
  //#region ../lib/prompt-fragments/errors.ts
@@ -454,6 +442,41 @@ function formatZodIssues(zodIssues) {
454
442
  return out;
455
443
  }
456
444
  //#endregion
445
+ //#region ../lib/prompt-fragments/text-files.ts
446
+ /**
447
+ * Split a body into logical lines for range slicing. `body.split("\n")` yields a
448
+ * trailing empty element for a body that ends in a newline (`"a\n"` → `["a", ""]`); we
449
+ * drop exactly ONE such trailing element so a conventional newline-terminated file
450
+ * reports its real line count (`"a\n"` is one line, not two). An empty body is zero
451
+ * lines. NB: this is ONLY for line-range math — a no-range `{{file}}` renders the body
452
+ * byte-for-byte (see `load.ts`), trailing newline and all.
453
+ */
454
+ function toLines(body) {
455
+ if (body === "") return [];
456
+ const lines = body.split("\n");
457
+ if (lines[lines.length - 1] === "") lines.pop();
458
+ return lines;
459
+ }
460
+ /** The number of logical lines in `body` (a newline-terminated final line is not double-counted). */
461
+ function countLines(body) {
462
+ return toLines(body).length;
463
+ }
464
+ /**
465
+ * Slice `body` to the 1-based inclusive line range `[from, to]` and re-join with `"\n"`,
466
+ * appending nothing. `from` alone means "line `from` to end of file"; `to` alone means
467
+ * "line 1 to `to`". `to` beyond EOF CLAMPS to end-of-file (native `Array.slice`
468
+ * behaviour) — the runtime's graceful-degradation contract when a source file was
469
+ * shortened after validation. The `from` lower bound is enforced by the caller
470
+ * (`checkPlacements` errors on `from` beyond EOF), because an empty splice would
471
+ * silently drop material.
472
+ */
473
+ function sliceLines(body, from, to) {
474
+ const lines = toLines(body);
475
+ const start = from !== void 0 ? from - 1 : 0;
476
+ const end = to !== void 0 ? to : lines.length;
477
+ return lines.slice(start, end).join("\n");
478
+ }
479
+ //#endregion
457
480
  //#region ../lib/prompt-fragments/consistency.ts
458
481
  /** Compare a supplied value against its declared property type. Returns null when it matches. */
459
482
  function typeMismatch(prop, value) {
@@ -473,120 +496,197 @@ function typeMismatch(prop, value) {
473
496
  };
474
497
  }
475
498
  }
476
- function checkConsistency(block, fragmentFilesByAlias) {
499
+ /** Split an inline reference at the FIRST dot: aliases cannot contain dots, ids may. */
500
+ function splitFragmentRef(ref) {
501
+ const dot = ref.indexOf(".");
502
+ if (dot === -1) return {
503
+ alias: ref,
504
+ fragmentId: ""
505
+ };
506
+ return {
507
+ alias: ref.slice(0, dot),
508
+ fragmentId: ref.slice(dot + 1)
509
+ };
510
+ }
511
+ /**
512
+ * Resolve one `"alias.id"` reference against the fetched libraries and validate its
513
+ * inline args against the fragment's `input_schema` — required present, types correct,
514
+ * undeclared flagged, optional defaults filled in (a supplied value always wins). The
515
+ * same required/type/undeclared/defaults machinery that ran per document-level ref
516
+ * before, now per placement.
517
+ */
518
+ function resolveAndMerge(ref, args, filesByAlias) {
519
+ const errors = [];
520
+ const warnings = [];
521
+ const { alias, fragmentId } = splitFragmentRef(ref);
522
+ const file = filesByAlias.get(alias);
523
+ if (!file) {
524
+ errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment marker "${ref}" uses unknown file alias "${alias}"`, {
525
+ fileAlias: alias,
526
+ fragmentId
527
+ }));
528
+ return {
529
+ errors,
530
+ warnings,
531
+ variables: args,
532
+ content: null
533
+ };
534
+ }
535
+ const fragment = file.fragments.find((f) => f.id === fragmentId);
536
+ if (!fragment) {
537
+ errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${fragmentId}" not found in file "${alias}"`, {
538
+ fileAlias: alias,
539
+ fragmentId
540
+ }));
541
+ return {
542
+ errors,
543
+ warnings,
544
+ variables: args,
545
+ content: null
546
+ };
547
+ }
548
+ return {
549
+ errors,
550
+ warnings,
551
+ variables: mergeVariables(alias, fragment, args, errors, warnings),
552
+ content: fragment.content
553
+ };
554
+ }
555
+ /** Validate `args` against `fragment.input_schema` and merge in optional defaults. */
556
+ function mergeVariables(alias, fragment, args, errors, warnings) {
557
+ const merged = { ...args };
558
+ const schema = fragment.input_schema;
559
+ const id = fragment.id;
560
+ if (!schema) {
561
+ for (const name of Object.keys(args)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}", which declares no input schema`, {
562
+ fileAlias: alias,
563
+ fragmentId: id,
564
+ variable: name
565
+ }));
566
+ return merged;
567
+ }
568
+ for (const name of schema.required) if (!(name in args)) errors.push(error("MISSING_REQUIRED_VARIABLE", `Fragment "${id}" requires variable "${name}", which is not supplied`, {
569
+ fileAlias: alias,
570
+ fragmentId: id,
571
+ variable: name
572
+ }));
573
+ for (const [name, value] of Object.entries(args)) {
574
+ const prop = schema.properties[name];
575
+ if (!prop) {
576
+ warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}" is not declared in its input schema`, {
577
+ fileAlias: alias,
578
+ fragmentId: id,
579
+ variable: name
580
+ }));
581
+ continue;
582
+ }
583
+ const mismatch = typeMismatch(prop, value);
584
+ if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
585
+ fileAlias: alias,
586
+ fragmentId: id,
587
+ variable: name,
588
+ expectedType: mismatch.expected,
589
+ actualType: mismatch.actual
590
+ }));
591
+ }
592
+ const requiredSet = new Set(schema.required);
593
+ for (const [name, prop] of Object.entries(schema.properties)) {
594
+ if (prop.default === void 0) continue;
595
+ if (requiredSet.has(name)) {
596
+ warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${id}" is required, so its default is never used`, {
597
+ fileAlias: alias,
598
+ fragmentId: id,
599
+ variable: name
600
+ }));
601
+ continue;
602
+ }
603
+ if (!(name in merged)) merged[name] = prop.default;
604
+ }
605
+ return merged;
606
+ }
607
+ /**
608
+ * Cross-check every inline placement against the declared libraries AND text files:
609
+ * duplicate aliases (fragment libraries, text files, and collisions ACROSS the two —
610
+ * one shared alias namespace), duplicate fragment ids within a file, each `{{fragment}}`
611
+ * placement's `alias.id` resolution + variable validation (via `resolveAndMerge`), each
612
+ * `{{file}}` placement's alias resolution + line-range bounds, and a warning for any
613
+ * declared library / text file no placement ever uses. Errors block the build; the rest
614
+ * are warnings. Order-independent — placements carry their own textual position.
615
+ *
616
+ * `textFilesByAlias` maps a declared text-file alias to its FETCHED body (needed for the
617
+ * range bounds check); `strict` is the authoring-strictness flag — when set (authoring
618
+ * validators / CLI), a `to` beyond EOF is an error too, whereas at runtime (`strict:
619
+ * false`) the render layer clamps `to` to EOF instead. A `from` beyond EOF is ALWAYS an
620
+ * error (an empty splice would silently drop material). The new params default so the
621
+ * legacy fragment-only call sites (and their tests) need no change; the one real caller
622
+ * (`load.ts`) always passes the text side explicitly.
623
+ */
624
+ function checkPlacements(placements, filesByAlias, fileRefs, textFilesByAlias = /* @__PURE__ */ new Map(), textFileRefs = [], strict = false) {
477
625
  const errors = [];
478
626
  const warnings = [];
479
627
  const aliasCounts = /* @__PURE__ */ new Map();
480
- for (const ref of block.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
628
+ for (const ref of fileRefs) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
481
629
  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 }));
482
- const fragmentIndex = /* @__PURE__ */ new Map();
483
- for (const [alias, file] of fragmentFilesByAlias) {
484
- const byId = /* @__PURE__ */ new Map();
630
+ const fragmentAliases = new Set(fileRefs.map((r) => r.id));
631
+ const textAliasCounts = /* @__PURE__ */ new Map();
632
+ for (const ref of textFileRefs) textAliasCounts.set(ref.id, (textAliasCounts.get(ref.id) ?? 0) + 1);
633
+ for (const [alias, count] of textAliasCounts) if (count > 1) errors.push(error("DUPLICATE_TEXT_FILE_ALIAS", `Text-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
634
+ else if (fragmentAliases.has(alias)) errors.push(error("DUPLICATE_TEXT_FILE_ALIAS", `Alias "${alias}" is declared as both a fragment library and a text file`, { fileAlias: alias }));
635
+ for (const [alias, file] of filesByAlias) {
636
+ const seen = /* @__PURE__ */ new Set();
485
637
  for (const frag of file.fragments) {
486
- if (byId.has(frag.id)) {
638
+ if (seen.has(frag.id)) {
487
639
  errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
488
640
  fileAlias: alias,
489
641
  fragmentId: frag.id
490
642
  }));
491
643
  continue;
492
644
  }
493
- byId.set(frag.id, frag);
645
+ seen.add(frag.id);
494
646
  }
495
- fragmentIndex.set(alias, byId);
496
647
  }
497
- const resolved = [];
498
- const seenRefs = /* @__PURE__ */ new Set();
499
- for (const ref of block.fragments) {
500
- const refKey = `${ref.file}::${ref.id}`;
501
- if (seenRefs.has(refKey)) warnings.push(warning("DUPLICATE_FRAGMENT_REFERENCE", `Fragment "${ref.id}" from "${ref.file}" is referenced more than once`, {
502
- fileAlias: ref.file,
503
- fragmentId: ref.id
504
- }));
505
- seenRefs.add(refKey);
506
- const byId = fragmentIndex.get(ref.file);
507
- if (!byId) {
508
- errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment reference uses unknown file alias "${ref.file}"`, {
509
- fileAlias: ref.file,
510
- fragmentId: ref.id
511
- }));
512
- continue;
513
- }
514
- const fragment = byId.get(ref.id);
515
- if (!fragment) {
516
- errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${ref.id}" not found in file "${ref.file}"`, {
517
- fileAlias: ref.file,
518
- fragmentId: ref.id
519
- }));
648
+ const usedAliases = /* @__PURE__ */ new Set();
649
+ const usedTextAliases = /* @__PURE__ */ new Set();
650
+ for (const placement of placements) {
651
+ if (placement.kind === "file") {
652
+ checkFilePlacement(placement, textFilesByAlias, strict, errors);
653
+ usedTextAliases.add(placement.ref);
520
654
  continue;
521
655
  }
522
- const variables = ref.variables ?? {};
523
- const schema = fragment.input_schema;
524
- const merged = { ...variables };
525
- if (schema) {
526
- for (const name of schema.required) if (!(name in variables)) errors.push(error("MISSING_REQUIRED_VARIABLE", `Fragment "${ref.id}" requires variable "${name}", which is not supplied`, {
527
- fileAlias: ref.file,
528
- fragmentId: ref.id,
529
- variable: name
530
- }));
531
- for (const [name, value] of Object.entries(variables)) {
532
- const prop = schema.properties[name];
533
- if (!prop) {
534
- warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}" is not declared in its input schema`, {
535
- fileAlias: ref.file,
536
- fragmentId: ref.id,
537
- variable: name
538
- }));
539
- continue;
540
- }
541
- const mismatch = typeMismatch(prop, value);
542
- if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${ref.id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
543
- fileAlias: ref.file,
544
- fragmentId: ref.id,
545
- variable: name,
546
- expectedType: mismatch.expected,
547
- actualType: mismatch.actual
548
- }));
549
- }
550
- const requiredSet = new Set(schema.required);
551
- for (const [name, prop] of Object.entries(schema.properties)) {
552
- if (prop.default === void 0) continue;
553
- if (requiredSet.has(name)) {
554
- warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${ref.id}" is required, so its default is never used`, {
555
- fileAlias: ref.file,
556
- fragmentId: ref.id,
557
- variable: name
558
- }));
559
- continue;
560
- }
561
- if (!(name in merged)) merged[name] = prop.default;
562
- }
563
- } else for (const name of Object.keys(variables)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}", which declares no input schema`, {
564
- fileAlias: ref.file,
565
- fragmentId: ref.id,
566
- variable: name
567
- }));
568
- resolved.push({
569
- fileAlias: ref.file,
570
- fragmentId: ref.id,
571
- priority: fragment.priority,
572
- content: fragment.content,
573
- variables: merged
574
- });
656
+ usedAliases.add(splitFragmentRef(placement.ref).alias);
657
+ const resolved = resolveAndMerge(placement.ref, placement.args, filesByAlias);
658
+ errors.push(...resolved.errors);
659
+ warnings.push(...resolved.warnings);
575
660
  }
576
- const plan = [...resolved].sort((a, b) => a.priority - b.priority);
577
- const priorityOwners = /* @__PURE__ */ new Map();
578
- for (const r of resolved) {
579
- const owners = priorityOwners.get(r.priority) ?? [];
580
- owners.push(r.fragmentId);
581
- priorityOwners.set(r.priority, owners);
582
- }
583
- for (const [priority, owners] of priorityOwners) if (owners.length > 1) errors.push(error("DUPLICATE_PRIORITY", `Priority ${priority} is shared by fragments: ${owners.join(", ")} — ordering is ambiguous`));
661
+ for (const ref of fileRefs) if (!usedAliases.has(ref.id)) warnings.push(warning("UNUSED_FRAGMENT_FILE", `Fragment library "${ref.id}" is declared but no {{fragment}} marker uses it`, { fileAlias: ref.id }));
662
+ for (const ref of textFileRefs) if (!usedTextAliases.has(ref.id)) warnings.push(warning("UNUSED_TEXT_FILE", `Text file "${ref.id}" is declared but no {{file}} marker embeds it`, { fileAlias: ref.id }));
584
663
  return {
585
664
  errors,
586
- warnings,
587
- plan
665
+ warnings
588
666
  };
589
667
  }
668
+ /**
669
+ * Validate one `{{file}}` placement: the alias must be a declared text file, and its
670
+ * line range must fit. `from` beyond EOF is ALWAYS an error (an empty splice would
671
+ * silently drop material); `to` beyond EOF is an error ONLY under `strict` (authoring) —
672
+ * at runtime the render layer clamps `to` to EOF so a shortened source degrades gracefully.
673
+ */
674
+ function checkFilePlacement(placement, textFilesByAlias, strict, errors) {
675
+ const body = textFilesByAlias.get(placement.ref);
676
+ if (body === void 0) {
677
+ errors.push(error("UNKNOWN_TEXT_FILE_ALIAS", `File marker "${placement.ref}" uses unknown text-file alias`, { fileAlias: placement.ref }));
678
+ return;
679
+ }
680
+ const lineCount = countLines(body);
681
+ if (placement.from !== void 0 && placement.from > lineCount) errors.push(error("TEXT_FILE_RANGE_OUT_OF_BOUNDS", `File "${placement.ref}" has ${lineCount} line(s), but from=${placement.from} is past the end`, {
682
+ fileAlias: placement.ref,
683
+ line: placement.line
684
+ }));
685
+ if (strict && placement.to !== void 0 && placement.to > lineCount) errors.push(error("TEXT_FILE_RANGE_OUT_OF_BOUNDS", `File "${placement.ref}" has ${lineCount} line(s), but to=${placement.to} is past the end`, {
686
+ fileAlias: placement.ref,
687
+ line: placement.line
688
+ }));
689
+ }
590
690
  //#endregion
591
691
  //#region ../lib/prompt-fragments/fetcher.ts
592
692
  const DEFAULT_TIMEOUT_MS = 1e4;
@@ -704,8 +804,7 @@ const ClassificationSchema = z.strictObject({
704
804
  });
705
805
  const FragmentSchema = z.strictObject({
706
806
  id: z.string().meta({ description: "Unique fragment id within this library." }),
707
- version: z.number().meta({ description: "Fragment version number." }),
708
- priority: z.number().meta({ description: "Assembly order. Lower priorities appear earlier." }),
807
+ version: z.number().optional().meta({ description: "Optional fragment version number." }),
709
808
  input_schema: InputSchema.optional(),
710
809
  classification: ClassificationSchema.optional(),
711
810
  content: z.string().meta({ description: "Prompt text as a Handlebars template." })
@@ -717,8 +816,7 @@ const FragmentFileSchema = z.strictObject({
717
816
  id: z.string().meta({ description: "Machine-readable id of this fragment library." }),
718
817
  fragments: z.array(FragmentSchema).min(1).meta({ description: "The reusable fragments this library provides (at least one)." })
719
818
  });
720
- /** A supplied variable value mirrors what `input_schema` can declare. */
721
- const VariableValueSchema = z.union([
819
+ z.union([
722
820
  z.string(),
723
821
  z.boolean(),
724
822
  z.array(z.string())
@@ -727,21 +825,36 @@ const VariableValueSchema = z.union([
727
825
  description: "A literal variable value: a string, a boolean, or an array of strings."
728
826
  });
729
827
  const FragmentFileRefSchema = z.strictObject({
730
- id: z.string().meta({ description: "Local alias for this library, referenced by each fragment's `file`." }),
828
+ id: z.string().regex(/^[^.]+$/, { message: "Alias must not contain a dot" }).meta({
829
+ pattern: "^[^.]+$",
830
+ description: "Local alias for this library, used before the dot in `{{fragment \"alias.id\"}}`. Must not contain a dot."
831
+ }),
731
832
  url: FragmentUrlRef
732
833
  }).meta({
733
834
  id: "fragmentFileRef",
734
835
  description: "A reference to a fragment library by alias + URL."
735
836
  });
736
- const FragmentRefSchema = z.strictObject({
737
- file: z.string().meta({ description: "The alias of the fragment library this fragment is drawn from." }),
738
- id: z.string().meta({ description: "Fragment id inside the referenced library." }),
739
- variables: z.record(z.string(), VariableValueSchema).optional().meta({ description: "Literal values passed into the fragment template." }),
740
- bind: z.record(z.string(), z.string()).optional().meta({ description: "Accepted for compatibility but ignored by the current assembler." }),
741
- required: z.boolean().optional().meta({ description: "Marker for important fragments. Accepted but not currently enforced." })
837
+ /**
838
+ * A plain-text file reference: an `id` alias + a `url`, mirroring `FragmentFileRefSchema`
839
+ * exactly (same no-dot alias regex, same http(s)-or-relative `FragmentUrlRef`). Unlike a
840
+ * fragment library, a text file is arbitrary content (markdown course material, a
841
+ * sample-solution source file) fetched over HTTP(S) and spliced VERBATIM into the host
842
+ * text by an inline `{{file "alias"}}` marker there is nothing to select inside it, so
843
+ * the alias carries no dot. Text-file and fragment-file aliases share ONE namespace (an
844
+ * `id` may not collide across the two lists), so every marker alias resolves unambiguously.
845
+ */
846
+ const TextFileRefSchema = z.strictObject({
847
+ id: z.string().regex(/^[^.]+$/, { message: "Alias must not contain a dot" }).meta({
848
+ pattern: "^[^.]+$",
849
+ description: "Local alias for this text file, used in `{{file \"alias\"}}`. Must not contain a dot."
850
+ }),
851
+ url: FragmentUrlRef.meta({
852
+ pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
853
+ description: "HTTP(S) URL or relative path to the plain-text file."
854
+ })
742
855
  }).meta({
743
- id: "fragmentRef",
744
- description: "Selects one fragment from a referenced library."
856
+ id: "textFileRef",
857
+ description: "A reference to a plain-text file (markdown / source) by alias + URL."
745
858
  });
746
859
  //#endregion
747
860
  //#region ../lib/prompt-fragments/fragment.ts
@@ -793,8 +906,8 @@ function checkFragmentTemplates(file, opts = {}) {
793
906
  }
794
907
  /**
795
908
  * Fragment ids declared more than once within a single file. The standalone
796
- * validator runs this directly; the tutor path gets the same check from
797
- * `checkConsistency` (so the whole-library pass must NOT repeat it).
909
+ * validator runs this directly; the activity path gets the same check from
910
+ * `checkPlacements` (so the whole-library pass must NOT repeat it).
798
911
  */
799
912
  function findDuplicateFragmentIds(file) {
800
913
  const errors = [];
@@ -835,6 +948,218 @@ function checkFragmentFileValue(parsed, url) {
835
948
  };
836
949
  }
837
950
  //#endregion
951
+ //#region ../lib/prompt-fragments/host-template.ts
952
+ const FRAGMENT_HELPER = "fragment";
953
+ const ARRAY_HELPER = "array";
954
+ const FILE_HELPER = "file";
955
+ const isFragmentNode = (node) => node.path?.original === FRAGMENT_HELPER;
956
+ const isFileNode = (node) => node.path?.original === FILE_HELPER;
957
+ /** A structural `{{fragment}}` error stamped with the node's 1-based position. */
958
+ function markerInvalid(loc, detail) {
959
+ return error("FRAGMENT_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
960
+ line: loc.start.line,
961
+ column: loc.start.column + 1
962
+ });
963
+ }
964
+ /**
965
+ * A structural `{{file}}` error stamped with the node's 1-based position. Every
966
+ * `{{file}}` violation collapses to this ONE code (there is deliberately no separate
967
+ * not-literal variant, unlike `{{fragment}}`): a file marker either parses into a valid
968
+ * `alias`/`from`/`to` shape or fails closed here.
969
+ */
970
+ function fileMarkerInvalid(loc, detail) {
971
+ return error("TEXT_FILE_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
972
+ line: loc.start.line,
973
+ column: loc.start.column + 1
974
+ });
975
+ }
976
+ /**
977
+ * Read a hash-pair value into a supported literal. The supported set IS the contract:
978
+ * a string, a boolean, or an `(array "…" …)` of string literals. Anything else — a
979
+ * number, a path reference, a nested `(fragment …)`, an array with a non-string
980
+ * element — is a hard error, because at RENDER time the real Handlebars runtime hands
981
+ * the helper the raw value regardless, so silently dropping it here would let the
982
+ * placement validate against different args than it renders with (fail-open drift).
983
+ */
984
+ function readHashValue(key, node, loc) {
985
+ switch (node.type) {
986
+ case "StringLiteral": return { value: node.value };
987
+ case "BooleanLiteral": return { value: node.value };
988
+ case "SubExpression": {
989
+ const sub = node;
990
+ if (sub.path.original !== ARRAY_HELPER) return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
991
+ if (!sub.params.every((p) => p.type === "StringLiteral")) return { error: markerInvalid(loc, `Every element of (array …) for "${key}" must be a string`) };
992
+ return { value: sub.params.map((p) => p.value) };
993
+ }
994
+ default: return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
995
+ }
996
+ }
997
+ /** Extract one inline `{{fragment "alias.id" …}}` mustache's ref + validated args. */
998
+ function extractInlineMarker(node, out) {
999
+ const first = node.params?.[0];
1000
+ if (first?.type !== "StringLiteral") {
1001
+ out.errors.push(error("FRAGMENT_REF_NOT_LITERAL", `A {{fragment}} marker at line ${node.loc.start.line} needs a quoted "alias.id" reference`, {
1002
+ line: node.loc.start.line,
1003
+ column: node.loc.start.column + 1
1004
+ }));
1005
+ return;
1006
+ }
1007
+ if ((node.params?.length ?? 0) > 1) {
1008
+ out.errors.push(markerInvalid(node.loc, "A {{fragment}} marker takes exactly one \"alias.id\" reference"));
1009
+ return;
1010
+ }
1011
+ const args = {};
1012
+ for (const pair of node.hash?.pairs ?? []) {
1013
+ const read = readHashValue(pair.key, pair.value, node.loc);
1014
+ if ("error" in read) {
1015
+ out.errors.push(read.error);
1016
+ return;
1017
+ }
1018
+ args[pair.key] = read.value;
1019
+ }
1020
+ out.placements.push({
1021
+ kind: "fragment",
1022
+ ref: first.value,
1023
+ args,
1024
+ line: node.loc.start.line,
1025
+ column: node.loc.start.column + 1
1026
+ });
1027
+ }
1028
+ /**
1029
+ * Extract one inline `{{file "alias" from=N to=M}}` mustache's alias + optional range.
1030
+ * Structural contract (all TEXT_FILE_MARKER_INVALID, recording no placement on failure):
1031
+ * the first positional must be a quoted string literal, there is exactly one positional,
1032
+ * hash keys are restricted to `from` / `to`, each value must be an integer NumberLiteral
1033
+ * >= 1, and when both are present `from <= to`. A `from`/`to` beyond the file's actual
1034
+ * line count is NOT checked here (that needs the fetched body — see `checkPlacements`).
1035
+ */
1036
+ function extractFileMarker(node, out) {
1037
+ const first = node.params?.[0];
1038
+ if (first?.type !== "StringLiteral") {
1039
+ out.errors.push(fileMarkerInvalid(node.loc, "A {{file}} marker needs a quoted \"alias\" reference"));
1040
+ return;
1041
+ }
1042
+ if ((node.params?.length ?? 0) > 1) {
1043
+ out.errors.push(fileMarkerInvalid(node.loc, "A {{file}} marker takes exactly one \"alias\" reference"));
1044
+ return;
1045
+ }
1046
+ let from;
1047
+ let to;
1048
+ for (const pair of node.hash?.pairs ?? []) {
1049
+ if (pair.key !== "from" && pair.key !== "to") {
1050
+ out.errors.push(fileMarkerInvalid(node.loc, `A {{file}} marker accepts only from= / to=, not "${pair.key}"`));
1051
+ return;
1052
+ }
1053
+ if (pair.value.type !== "NumberLiteral" || !Number.isInteger(pair.value.value) || pair.value.value < 1) {
1054
+ out.errors.push(fileMarkerInvalid(node.loc, `"${pair.key}" must be an integer line number >= 1`));
1055
+ return;
1056
+ }
1057
+ if (pair.key === "from") from = pair.value.value;
1058
+ else to = pair.value.value;
1059
+ }
1060
+ if (from !== void 0 && to !== void 0 && from > to) {
1061
+ out.errors.push(fileMarkerInvalid(node.loc, `"from" (${from}) must not be greater than "to" (${to})`));
1062
+ return;
1063
+ }
1064
+ out.placements.push({
1065
+ kind: "file",
1066
+ ref: first.value,
1067
+ args: {},
1068
+ ...from !== void 0 ? { from } : {},
1069
+ ...to !== void 0 ? { to } : {},
1070
+ line: node.loc.start.line,
1071
+ column: node.loc.start.column + 1
1072
+ });
1073
+ }
1074
+ /** Reject a `fragment` / `file` helper used anywhere but a simple inline mustache. */
1075
+ function visitExpression(node, out) {
1076
+ if (node.type !== "SubExpression") return;
1077
+ const sub = node;
1078
+ if (isFragmentNode(sub)) out.errors.push(markerInvalid(sub.loc, "{{fragment}} must be a standalone inline marker, not a subexpression"));
1079
+ else if (isFileNode(sub)) out.errors.push(fileMarkerInvalid(sub.loc, "{{file}} must be a standalone inline marker, not a subexpression"));
1080
+ for (const p of sub.params ?? []) visitExpression(p, out);
1081
+ for (const pair of sub.hash?.pairs ?? []) visitExpression(pair.value, out);
1082
+ }
1083
+ /** Collect every `{{fragment}}` / `{{file}}` marker in a program body (recursing into blocks). */
1084
+ function collectPlacements(program, out) {
1085
+ for (const node of program.body) {
1086
+ if (isFragmentNode(node)) if (node.type === "MustacheStatement") extractInlineMarker(node, out);
1087
+ else out.errors.push(markerInvalid(node.loc, "{{fragment}} must be a simple inline marker, not a block {{#fragment}}…{{/fragment}}"));
1088
+ else if (isFileNode(node)) if (node.type === "MustacheStatement") extractFileMarker(node, out);
1089
+ else out.errors.push(fileMarkerInvalid(node.loc, "{{file}} must be a simple inline marker, not a block {{#file}}…{{/file}}"));
1090
+ else {
1091
+ for (const p of node.params ?? []) visitExpression(p, out);
1092
+ for (const pair of node.hash?.pairs ?? []) visitExpression(pair.value, out);
1093
+ }
1094
+ if (node.program) collectPlacements(node.program, out);
1095
+ if (node.inverse) collectPlacements(node.inverse, out);
1096
+ }
1097
+ }
1098
+ /**
1099
+ * Parse the host text and extract every inline `{{fragment}}` placement in textual
1100
+ * order, WITHOUT rendering. A whole-template syntax error (a malformed marker, an
1101
+ * unescaped literal `{{`) becomes a single `HOST_TEMPLATE_PARSE_ERROR`; its position
1102
+ * is regexed out of Handlebars' message (`Parse error on line N:` — the parser
1103
+ * carries no structured position fields). A well-formed `{{fragment}}` marker whose
1104
+ * reference is not a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL`;
1105
+ * a structurally wrong `{{file}}` marker becomes a per-marker `TEXT_FILE_MARKER_INVALID`.
1106
+ */
1107
+ function parseHostPlacements(text) {
1108
+ const result = {
1109
+ placements: [],
1110
+ errors: []
1111
+ };
1112
+ let program;
1113
+ try {
1114
+ program = Handlebars.parse(text);
1115
+ } catch (e) {
1116
+ const message = e instanceof Error ? e.message : String(e);
1117
+ const lineMatch = message.match(/line (\d+)/i);
1118
+ result.errors.push(error("HOST_TEMPLATE_PARSE_ERROR", `Host text is not a valid template: ${message}`, { ...lineMatch ? { line: Number(lineMatch[1]) } : {} }));
1119
+ return result;
1120
+ }
1121
+ collectPlacements(program, result);
1122
+ return result;
1123
+ }
1124
+ /** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` + `file` helpers. */
1125
+ function createHostInstance(resolver, fileResolver) {
1126
+ const hb = Handlebars.create();
1127
+ hb.registerHelper(FRAGMENT_HELPER, (...args) => {
1128
+ const ref = args.length >= 2 ? args[0] : void 0;
1129
+ if (typeof ref !== "string") throw new Error("a {{fragment}} marker needs a quoted \"alias.id\" reference");
1130
+ const options = args[args.length - 1];
1131
+ return resolver(ref, options?.hash ?? {});
1132
+ });
1133
+ hb.registerHelper(ARRAY_HELPER, (...args) => {
1134
+ return args.slice(0, -1);
1135
+ });
1136
+ hb.registerHelper(FILE_HELPER, (...args) => {
1137
+ const alias = args.length >= 2 ? args[0] : void 0;
1138
+ if (typeof alias !== "string") throw new Error("a {{file}} marker needs a quoted \"alias\" reference");
1139
+ const options = args[args.length - 1];
1140
+ return fileResolver(alias, options?.hash?.from, options?.hash?.to);
1141
+ });
1142
+ return hb;
1143
+ }
1144
+ /**
1145
+ * Compile + render the host text with the isolated instance under
1146
+ * `{ strict: true, noEscape: true }` — `strict` so any stray `{{…}}` that is not a
1147
+ * `fragment` / `array` / `file` marker fails closed instead of rendering empty; `noEscape`
1148
+ * so the prompt text (ASCII diagrams, quotes) passes through verbatim. Each `{{fragment}}`
1149
+ * placement is replaced by its `resolver` result, each `{{file}}` by its `fileResolver`
1150
+ * result. May throw — the caller wraps it as ASSEMBLY_ERROR. `fileResolver` defaults to a
1151
+ * thrower: a template with no `{{file}}` markers never invokes it, but one that does
1152
+ * without a resolver fails closed rather than rendering wrong.
1153
+ */
1154
+ function renderHostTemplate(text, resolver, fileResolver = () => {
1155
+ throw new Error("a {{file}} marker was rendered without a file resolver");
1156
+ }) {
1157
+ return createHostInstance(resolver, fileResolver).compile(text, {
1158
+ strict: true,
1159
+ noEscape: true
1160
+ })({});
1161
+ }
1162
+ //#endregion
838
1163
  //#region ../lib/relative-url.ts
839
1164
  /**
840
1165
  * Resolve a reference to an absolute URL. An absolute http(s) ref is used as-is;
@@ -848,6 +1173,8 @@ function resolveRelativeUrl(ref, baseUrl) {
848
1173
  }
849
1174
  //#endregion
850
1175
  //#region ../lib/prompt-fragments/load.ts
1176
+ /** The 200 KB (UTF-8 bytes) per-file cap on an embedded `text_files:` body — fail closed above it. */
1177
+ const MAX_TEXT_FILE_BYTES = 200 * 1024;
851
1178
  /**
852
1179
  * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
853
1180
  * as-is; anything else is treated as relative to the activity URL — standard URL resolution
@@ -926,26 +1253,33 @@ async function loadYaml(url, fetchImpl, opts = {}) {
926
1253
  };
927
1254
  }
928
1255
  /**
929
- * Resolve a document-level fragment block to a finished prompt string: fetch every
930
- * declared fragment file in parallel (relative refs resolved against `baseUrl`),
931
- * schema-validate each, (optionally) run the thorough whole-library check, check
932
- * consistency, and assemble the priority-ordered plan followed by the optional
933
- * `trailingInstructions`.
1256
+ * Render an activity's host text (`tutor_instructions` / `instructions`) into a
1257
+ * finished prompt string, inserting each inline `{{fragment "alias.id" …}}` marker in
1258
+ * place. Fetches every declared fragment library in parallel (relative refs resolved
1259
+ * against `baseUrl`), schema-validates each, (optionally) runs the thorough
1260
+ * whole-library check, extracts + checks the placements, then compiles + renders the
1261
+ * host template under strict Handlebars.
934
1262
  *
935
1263
  * The single seam every activity kind shares — the sole owner of the fetch → validate
936
- * → consistencyassemble pipeline. Consumers concatenate their own frame only when
937
- * they pass no `trailingInstructions` (a fragment-only preamble); tutors pass their
938
- * `tutor_instructions` and get a complete prompt.
1264
+ * → checkrender pipeline. `hostText` IS the template: fragments appear only where
1265
+ * the author placed a marker; there is no ordering concept and no prepend fallback.
1266
+ *
1267
+ * TEMPLATE-SEMANTICS OPT-IN: an activity that declares NEITHER `fragment_files:` NOR
1268
+ * `text_files:` is NEVER compiled — its host text returns byte-verbatim (protecting plain
1269
+ * activities and the authoring tutors whose prose contains sample markers as teaching
1270
+ * content). Text files are fetched in parallel with the fragment libraries; each is kept
1271
+ * as a RAW string (no YAML parse) and spliced verbatim at render — never re-compiled as
1272
+ * Handlebars, so a literal `{{` in course material can never execute.
939
1273
  */
940
- async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trailingInstructions) {
1274
+ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, hostText = "") {
941
1275
  const warnings = [];
942
1276
  const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
943
- if (block.fragment_files.length === 0 && block.fragments.length === 0) return {
1277
+ if (block.fragment_files.length === 0 && block.text_files.length === 0) return {
944
1278
  ok: true,
945
- prompt: assembleSystemPrompt([], trailingInstructions),
1279
+ prompt: hostText,
946
1280
  warnings
947
1281
  };
948
- const settled = await Promise.all(block.fragment_files.map(async (ref) => {
1282
+ const fragmentSettledPromise = Promise.all(block.fragment_files.map(async (ref) => {
949
1283
  let fragmentUrl;
950
1284
  try {
951
1285
  fragmentUrl = resolveFragmentUrl(ref.url, baseUrl);
@@ -990,14 +1324,57 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trai
990
1324
  url: fragmentUrl
991
1325
  };
992
1326
  }));
1327
+ const textSettledPromise = Promise.all(block.text_files.map(async (ref) => {
1328
+ let fileUrl;
1329
+ try {
1330
+ fileUrl = resolveFragmentUrl(ref.url, baseUrl);
1331
+ } catch {
1332
+ return {
1333
+ alias: ref.id,
1334
+ error: error("INVALID_URL", `Invalid text-file URL: ${ref.url}`, {
1335
+ url: ref.url,
1336
+ fileAlias: ref.id
1337
+ })
1338
+ };
1339
+ }
1340
+ const schemeError = schemeGate(fileUrl, allowedSchemes);
1341
+ if (schemeError) return {
1342
+ alias: ref.id,
1343
+ error: {
1344
+ ...schemeError,
1345
+ fileAlias: ref.id
1346
+ }
1347
+ };
1348
+ const fetched = await fetchText(fileUrl, fetchImpl);
1349
+ if (!fetched.ok) return {
1350
+ alias: ref.id,
1351
+ error: fetched.error
1352
+ };
1353
+ const byteLength = new TextEncoder().encode(fetched.text).length;
1354
+ if (byteLength > MAX_TEXT_FILE_BYTES) return {
1355
+ alias: ref.id,
1356
+ error: error("TEXT_FILE_TOO_LARGE", `Text file "${ref.id}" is ${byteLength} bytes, over the ${MAX_TEXT_FILE_BYTES}-byte limit`, {
1357
+ url: fileUrl,
1358
+ fileAlias: ref.id
1359
+ })
1360
+ };
1361
+ return {
1362
+ alias: ref.id,
1363
+ body: fetched.text
1364
+ };
1365
+ }));
1366
+ const [fragmentSettled, textSettled] = await Promise.all([fragmentSettledPromise, textSettledPromise]);
993
1367
  const fragmentFilesByAlias = /* @__PURE__ */ new Map();
994
1368
  const fragmentUrlByAlias = /* @__PURE__ */ new Map();
1369
+ const textFilesByAlias = /* @__PURE__ */ new Map();
995
1370
  const fileErrors = [];
996
- for (const result of settled) if ("error" in result) fileErrors.push(result.error);
1371
+ for (const result of fragmentSettled) if ("error" in result) fileErrors.push(result.error);
997
1372
  else {
998
1373
  fragmentFilesByAlias.set(result.alias, result.file);
999
1374
  fragmentUrlByAlias.set(result.alias, result.url);
1000
1375
  }
1376
+ for (const result of textSettled) if ("error" in result) fileErrors.push(result.error);
1377
+ else textFilesByAlias.set(result.alias, result.body);
1001
1378
  if (fileErrors.length > 0) return {
1002
1379
  ok: false,
1003
1380
  errors: fileErrors,
@@ -1012,18 +1389,33 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trai
1012
1389
  libraryErrors.push(...checked.errors);
1013
1390
  warnings.push(...checked.warnings);
1014
1391
  }
1015
- const consistency = checkConsistency(block, fragmentFilesByAlias);
1016
- warnings.push(...consistency.warnings);
1017
- const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
1018
- if (preAssemblyErrors.length > 0) return {
1392
+ const parsed = parseHostPlacements(hostText);
1393
+ if (parsed.errors.length > 0) return {
1019
1394
  ok: false,
1020
- errors: preAssemblyErrors,
1395
+ errors: [...libraryErrors, ...parsed.errors],
1396
+ warnings
1397
+ };
1398
+ const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files, textFilesByAlias, block.text_files, opts.validateLibraries ?? false);
1399
+ warnings.push(...placementCheck.warnings);
1400
+ const preRenderErrors = [...libraryErrors, ...placementCheck.errors];
1401
+ if (preRenderErrors.length > 0) return {
1402
+ ok: false,
1403
+ errors: preRenderErrors,
1021
1404
  warnings
1022
1405
  };
1023
1406
  try {
1024
1407
  return {
1025
1408
  ok: true,
1026
- prompt: assembleSystemPrompt(consistency.plan, trailingInstructions),
1409
+ prompt: renderHostTemplate(hostText, (ref, args) => {
1410
+ const resolved = resolveAndMerge(ref, args, fragmentFilesByAlias);
1411
+ if (resolved.content === null || resolved.errors.length > 0) throw new Error(`Fragment "${ref}" could not be resolved`);
1412
+ return renderFragmentContent(resolved.content, resolved.variables);
1413
+ }, (alias, from, to) => {
1414
+ const body = textFilesByAlias.get(alias);
1415
+ if (body === void 0) throw new Error(`Text file "${alias}" was not prefetched`);
1416
+ if (from === void 0 && to === void 0) return body;
1417
+ return sliceLines(body, from, to);
1418
+ }),
1027
1419
  warnings
1028
1420
  };
1029
1421
  } catch (e) {
@@ -1065,8 +1457,8 @@ const CodingYamlSchema = z.strictObject({
1065
1457
  description: "The pinned model and provider that answer coding requests."
1066
1458
  }),
1067
1459
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
1068
- fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended AHEAD of instructions." }),
1069
- 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." })
1460
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source, e.g. a sample solution) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
1461
+ 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. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" }} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." })
1070
1462
  });
1071
1463
  //#endregion
1072
1464
  //#region ../lib/coding-validate.ts
@@ -1107,11 +1499,11 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
1107
1499
  if (!checked.ok) return checked;
1108
1500
  const assembled = await assembleFragmentPrompt({
1109
1501
  fragment_files: valid.data.fragment_files,
1110
- fragments: valid.data.fragments
1502
+ text_files: valid.data.text_files
1111
1503
  }, url, fetchImpl, {
1112
1504
  allowedSchemes: opts.allowedSchemes,
1113
1505
  validateLibraries: opts.validateLibraries ?? true
1114
- });
1506
+ }, valid.data.instructions);
1115
1507
  const warnings = [...checked.warnings, ...assembled.warnings];
1116
1508
  if (!assembled.ok) return {
1117
1509
  ok: false,
@@ -1183,7 +1575,8 @@ const QuizYamlSchema = z.strictObject({
1183
1575
  description: "Optional guidance for the per-question follow-up discussion chat."
1184
1576
  }),
1185
1577
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
1186
- 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." }),
1578
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
1579
+ instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files or text_files are declared, place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
1187
1580
  questions: z.array(QuizQuestionSchema).min(1).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt." })
1188
1581
  });
1189
1582
  //#endregion
@@ -1250,11 +1643,11 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1250
1643
  if (!checked.ok) return checked;
1251
1644
  const assembled = await assembleFragmentPrompt({
1252
1645
  fragment_files: valid.data.fragment_files,
1253
- fragments: valid.data.fragments
1646
+ text_files: valid.data.text_files
1254
1647
  }, url, fetchImpl, {
1255
1648
  allowedSchemes: opts.allowedSchemes,
1256
1649
  validateLibraries: opts.validateLibraries ?? true
1257
- });
1650
+ }, valid.data.instructions ?? "");
1258
1651
  const warnings = [...checked.warnings, ...assembled.warnings];
1259
1652
  if (!assembled.ok) return {
1260
1653
  ok: false,
@@ -1303,11 +1696,11 @@ const TutorSchema = z.strictObject({
1303
1696
  }),
1304
1697
  prompt: z.strictObject({
1305
1698
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
1306
- fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files." }),
1307
- tutor_instructions: z.string().meta({ description: "Final tutor-specific system-prompt instructions. For single-file tutors, this can be the whole prompt." })
1699
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim via {{file \"alias\"}} markers." }),
1700
+ tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files or text_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
1308
1701
  }).meta({
1309
1702
  id: "prompt",
1310
- description: "The assembled system prompt: fragments plus tutor instructions."
1703
+ description: "The tutor system prompt: a host template with inline fragment markers."
1311
1704
  })
1312
1705
  });
1313
1706
  //#endregion
@@ -1366,8 +1759,8 @@ const WritingYamlSchema = z.strictObject({
1366
1759
  description: "The model and provider that back the writing coach."
1367
1760
  }),
1368
1761
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
1369
- fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended AHEAD of instructions." }),
1370
- 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." }),
1762
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
1763
+ 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. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" }} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
1371
1764
  placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
1372
1765
  });
1373
1766
  //#endregion
@@ -1412,11 +1805,11 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
1412
1805
  if (!checked.ok) return checked;
1413
1806
  const assembled = await assembleFragmentPrompt({
1414
1807
  fragment_files: valid.data.fragment_files,
1415
- fragments: valid.data.fragments
1808
+ text_files: valid.data.text_files
1416
1809
  }, url, fetchImpl, {
1417
1810
  allowedSchemes: opts.allowedSchemes,
1418
1811
  validateLibraries: opts.validateLibraries ?? true
1419
- });
1812
+ }, valid.data.instructions);
1420
1813
  const warnings = [...checked.warnings, ...assembled.warnings];
1421
1814
  if (!assembled.ok) return {
1422
1815
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.12.1",
3
+ "version": "0.14.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": {