@novedu/cli 0.12.1 → 0.13.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 +321 -171
  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
@@ -473,118 +461,151 @@ function typeMismatch(prop, value) {
473
461
  };
474
462
  }
475
463
  }
476
- function checkConsistency(block, fragmentFilesByAlias) {
464
+ /** Split an inline reference at the FIRST dot: aliases cannot contain dots, ids may. */
465
+ function splitFragmentRef(ref) {
466
+ const dot = ref.indexOf(".");
467
+ if (dot === -1) return {
468
+ alias: ref,
469
+ fragmentId: ""
470
+ };
471
+ return {
472
+ alias: ref.slice(0, dot),
473
+ fragmentId: ref.slice(dot + 1)
474
+ };
475
+ }
476
+ /**
477
+ * Resolve one `"alias.id"` reference against the fetched libraries and validate its
478
+ * inline args against the fragment's `input_schema` — required present, types correct,
479
+ * undeclared flagged, optional defaults filled in (a supplied value always wins). The
480
+ * same required/type/undeclared/defaults machinery that ran per document-level ref
481
+ * before, now per placement.
482
+ */
483
+ function resolveAndMerge(ref, args, filesByAlias) {
477
484
  const errors = [];
478
485
  const warnings = [];
479
- const aliasCounts = /* @__PURE__ */ new Map();
480
- for (const ref of block.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
481
- 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();
485
- for (const frag of file.fragments) {
486
- if (byId.has(frag.id)) {
487
- errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
488
- fileAlias: alias,
489
- fragmentId: frag.id
490
- }));
491
- continue;
492
- }
493
- byId.set(frag.id, frag);
494
- }
495
- fragmentIndex.set(alias, byId);
486
+ const { alias, fragmentId } = splitFragmentRef(ref);
487
+ const file = filesByAlias.get(alias);
488
+ if (!file) {
489
+ errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment marker "${ref}" uses unknown file alias "${alias}"`, {
490
+ fileAlias: alias,
491
+ fragmentId
492
+ }));
493
+ return {
494
+ errors,
495
+ warnings,
496
+ variables: args,
497
+ content: null
498
+ };
496
499
  }
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
500
+ const fragment = file.fragments.find((f) => f.id === fragmentId);
501
+ if (!fragment) {
502
+ errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${fragmentId}" not found in file "${alias}"`, {
503
+ fileAlias: alias,
504
+ fragmentId
505
+ }));
506
+ return {
507
+ errors,
508
+ warnings,
509
+ variables: args,
510
+ content: null
511
+ };
512
+ }
513
+ return {
514
+ errors,
515
+ warnings,
516
+ variables: mergeVariables(alias, fragment, args, errors, warnings),
517
+ content: fragment.content
518
+ };
519
+ }
520
+ /** Validate `args` against `fragment.input_schema` and merge in optional defaults. */
521
+ function mergeVariables(alias, fragment, args, errors, warnings) {
522
+ const merged = { ...args };
523
+ const schema = fragment.input_schema;
524
+ const id = fragment.id;
525
+ if (!schema) {
526
+ for (const name of Object.keys(args)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}", which declares no input schema`, {
527
+ fileAlias: alias,
528
+ fragmentId: id,
529
+ variable: name
504
530
  }));
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
531
+ return merged;
532
+ }
533
+ 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`, {
534
+ fileAlias: alias,
535
+ fragmentId: id,
536
+ variable: name
537
+ }));
538
+ for (const [name, value] of Object.entries(args)) {
539
+ const prop = schema.properties[name];
540
+ if (!prop) {
541
+ warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}" is not declared in its input schema`, {
542
+ fileAlias: alias,
543
+ fragmentId: id,
544
+ variable: name
511
545
  }));
512
546
  continue;
513
547
  }
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
548
+ const mismatch = typeMismatch(prop, value);
549
+ if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
550
+ fileAlias: alias,
551
+ fragmentId: id,
552
+ variable: name,
553
+ expectedType: mismatch.expected,
554
+ actualType: mismatch.actual
555
+ }));
556
+ }
557
+ const requiredSet = new Set(schema.required);
558
+ for (const [name, prop] of Object.entries(schema.properties)) {
559
+ if (prop.default === void 0) continue;
560
+ if (requiredSet.has(name)) {
561
+ warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${id}" is required, so its default is never used`, {
562
+ fileAlias: alias,
563
+ fragmentId: id,
564
+ variable: name
519
565
  }));
520
566
  continue;
521
567
  }
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
568
+ if (!(name in merged)) merged[name] = prop.default;
569
+ }
570
+ return merged;
571
+ }
572
+ /**
573
+ * Cross-check every inline placement against the declared libraries: duplicate
574
+ * aliases, duplicate fragment ids within a file, each placement's `alias.id`
575
+ * resolution + variable validation (via `resolveAndMerge`), and a warning for any
576
+ * declared library no placement ever uses. Errors block the build; the rest are
577
+ * warnings. Order-independent placements carry their own textual position.
578
+ */
579
+ function checkPlacements(placements, filesByAlias, fileRefs) {
580
+ const errors = [];
581
+ const warnings = [];
582
+ const aliasCounts = /* @__PURE__ */ new Map();
583
+ for (const ref of fileRefs) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
584
+ 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 }));
585
+ for (const [alias, file] of filesByAlias) {
586
+ const seen = /* @__PURE__ */ new Set();
587
+ for (const frag of file.fragments) {
588
+ if (seen.has(frag.id)) {
589
+ errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
590
+ fileAlias: alias,
591
+ fragmentId: frag.id
548
592
  }));
593
+ continue;
549
594
  }
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
- });
595
+ seen.add(frag.id);
596
+ }
575
597
  }
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);
598
+ const usedAliases = /* @__PURE__ */ new Set();
599
+ for (const placement of placements) {
600
+ usedAliases.add(splitFragmentRef(placement.ref).alias);
601
+ const resolved = resolveAndMerge(placement.ref, placement.args, filesByAlias);
602
+ errors.push(...resolved.errors);
603
+ warnings.push(...resolved.warnings);
582
604
  }
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`));
605
+ 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 }));
584
606
  return {
585
607
  errors,
586
- warnings,
587
- plan
608
+ warnings
588
609
  };
589
610
  }
590
611
  //#endregion
@@ -704,8 +725,7 @@ const ClassificationSchema = z.strictObject({
704
725
  });
705
726
  const FragmentSchema = z.strictObject({
706
727
  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." }),
728
+ version: z.number().optional().meta({ description: "Optional fragment version number." }),
709
729
  input_schema: InputSchema.optional(),
710
730
  classification: ClassificationSchema.optional(),
711
731
  content: z.string().meta({ description: "Prompt text as a Handlebars template." })
@@ -717,8 +737,7 @@ const FragmentFileSchema = z.strictObject({
717
737
  id: z.string().meta({ description: "Machine-readable id of this fragment library." }),
718
738
  fragments: z.array(FragmentSchema).min(1).meta({ description: "The reusable fragments this library provides (at least one)." })
719
739
  });
720
- /** A supplied variable value mirrors what `input_schema` can declare. */
721
- const VariableValueSchema = z.union([
740
+ z.union([
722
741
  z.string(),
723
742
  z.boolean(),
724
743
  z.array(z.string())
@@ -727,22 +746,15 @@ const VariableValueSchema = z.union([
727
746
  description: "A literal variable value: a string, a boolean, or an array of strings."
728
747
  });
729
748
  const FragmentFileRefSchema = z.strictObject({
730
- id: z.string().meta({ description: "Local alias for this library, referenced by each fragment's `file`." }),
749
+ id: z.string().regex(/^[^.]+$/, { message: "Alias must not contain a dot" }).meta({
750
+ pattern: "^[^.]+$",
751
+ description: "Local alias for this library, used before the dot in `{{fragment \"alias.id\"}}`. Must not contain a dot."
752
+ }),
731
753
  url: FragmentUrlRef
732
754
  }).meta({
733
755
  id: "fragmentFileRef",
734
756
  description: "A reference to a fragment library by alias + URL."
735
757
  });
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." })
742
- }).meta({
743
- id: "fragmentRef",
744
- description: "Selects one fragment from a referenced library."
745
- });
746
758
  //#endregion
747
759
  //#region ../lib/prompt-fragments/fragment.ts
748
760
  /**
@@ -793,8 +805,8 @@ function checkFragmentTemplates(file, opts = {}) {
793
805
  }
794
806
  /**
795
807
  * 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).
808
+ * validator runs this directly; the activity path gets the same check from
809
+ * `checkPlacements` (so the whole-library pass must NOT repeat it).
798
810
  */
799
811
  function findDuplicateFragmentIds(file) {
800
812
  const errors = [];
@@ -835,6 +847,142 @@ function checkFragmentFileValue(parsed, url) {
835
847
  };
836
848
  }
837
849
  //#endregion
850
+ //#region ../lib/prompt-fragments/host-template.ts
851
+ const FRAGMENT_HELPER = "fragment";
852
+ const ARRAY_HELPER = "array";
853
+ const isFragmentNode = (node) => node.path?.original === FRAGMENT_HELPER;
854
+ /** A structural error stamped with the node's 1-based position. */
855
+ function markerInvalid(loc, detail) {
856
+ return error("FRAGMENT_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
857
+ line: loc.start.line,
858
+ column: loc.start.column + 1
859
+ });
860
+ }
861
+ /**
862
+ * Read a hash-pair value into a supported literal. The supported set IS the contract:
863
+ * a string, a boolean, or an `(array "…" …)` of string literals. Anything else — a
864
+ * number, a path reference, a nested `(fragment …)`, an array with a non-string
865
+ * element — is a hard error, because at RENDER time the real Handlebars runtime hands
866
+ * the helper the raw value regardless, so silently dropping it here would let the
867
+ * placement validate against different args than it renders with (fail-open drift).
868
+ */
869
+ function readHashValue(key, node, loc) {
870
+ switch (node.type) {
871
+ case "StringLiteral": return { value: node.value };
872
+ case "BooleanLiteral": return { value: node.value };
873
+ case "SubExpression": {
874
+ const sub = node;
875
+ if (sub.path.original !== ARRAY_HELPER) return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
876
+ if (!sub.params.every((p) => p.type === "StringLiteral")) return { error: markerInvalid(loc, `Every element of (array …) for "${key}" must be a string`) };
877
+ return { value: sub.params.map((p) => p.value) };
878
+ }
879
+ default: return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
880
+ }
881
+ }
882
+ /** Extract one inline `{{fragment "alias.id" …}}` mustache's ref + validated args. */
883
+ function extractInlineMarker(node, out) {
884
+ const first = node.params?.[0];
885
+ if (first?.type !== "StringLiteral") {
886
+ out.errors.push(error("FRAGMENT_REF_NOT_LITERAL", `A {{fragment}} marker at line ${node.loc.start.line} needs a quoted "alias.id" reference`, {
887
+ line: node.loc.start.line,
888
+ column: node.loc.start.column + 1
889
+ }));
890
+ return;
891
+ }
892
+ if ((node.params?.length ?? 0) > 1) {
893
+ out.errors.push(markerInvalid(node.loc, "A {{fragment}} marker takes exactly one \"alias.id\" reference"));
894
+ return;
895
+ }
896
+ const args = {};
897
+ for (const pair of node.hash?.pairs ?? []) {
898
+ const read = readHashValue(pair.key, pair.value, node.loc);
899
+ if ("error" in read) {
900
+ out.errors.push(read.error);
901
+ return;
902
+ }
903
+ args[pair.key] = read.value;
904
+ }
905
+ out.placements.push({
906
+ ref: first.value,
907
+ args,
908
+ line: node.loc.start.line,
909
+ column: node.loc.start.column + 1
910
+ });
911
+ }
912
+ /** Reject a `fragment` helper used anywhere but a simple inline mustache. */
913
+ function visitExpression(node, out) {
914
+ if (node.type !== "SubExpression") return;
915
+ const sub = node;
916
+ if (isFragmentNode(sub)) out.errors.push(markerInvalid(sub.loc, "{{fragment}} must be a standalone inline marker, not a subexpression"));
917
+ for (const p of sub.params ?? []) visitExpression(p, out);
918
+ for (const pair of sub.hash?.pairs ?? []) visitExpression(pair.value, out);
919
+ }
920
+ /** Collect every `{{fragment}}` marker in a program body (recursing into blocks). */
921
+ function collectPlacements(program, out) {
922
+ for (const node of program.body) {
923
+ if (isFragmentNode(node)) if (node.type === "MustacheStatement") extractInlineMarker(node, out);
924
+ else out.errors.push(markerInvalid(node.loc, "{{fragment}} must be a simple inline marker, not a block {{#fragment}}…{{/fragment}}"));
925
+ else {
926
+ for (const p of node.params ?? []) visitExpression(p, out);
927
+ for (const pair of node.hash?.pairs ?? []) visitExpression(pair.value, out);
928
+ }
929
+ if (node.program) collectPlacements(node.program, out);
930
+ if (node.inverse) collectPlacements(node.inverse, out);
931
+ }
932
+ }
933
+ /**
934
+ * Parse the host text and extract every inline `{{fragment}}` placement in textual
935
+ * order, WITHOUT rendering. A whole-template syntax error (a malformed marker, an
936
+ * unescaped literal `{{`) becomes a single `HOST_TEMPLATE_PARSE_ERROR`; its position
937
+ * is regexed out of Handlebars' message (`Parse error on line N:` — the parser
938
+ * carries no structured position fields). A well-formed marker whose reference is not
939
+ * a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL`.
940
+ */
941
+ function parseHostPlacements(text) {
942
+ const result = {
943
+ placements: [],
944
+ errors: []
945
+ };
946
+ let program;
947
+ try {
948
+ program = Handlebars.parse(text);
949
+ } catch (e) {
950
+ const message = e instanceof Error ? e.message : String(e);
951
+ const lineMatch = message.match(/line (\d+)/i);
952
+ result.errors.push(error("HOST_TEMPLATE_PARSE_ERROR", `Host text is not a valid template: ${message}`, { ...lineMatch ? { line: Number(lineMatch[1]) } : {} }));
953
+ return result;
954
+ }
955
+ collectPlacements(program, result);
956
+ return result;
957
+ }
958
+ /** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` helpers. */
959
+ function createHostInstance(resolver) {
960
+ const hb = Handlebars.create();
961
+ hb.registerHelper(FRAGMENT_HELPER, (...args) => {
962
+ const ref = args.length >= 2 ? args[0] : void 0;
963
+ if (typeof ref !== "string") throw new Error("a {{fragment}} marker needs a quoted \"alias.id\" reference");
964
+ const options = args[args.length - 1];
965
+ return resolver(ref, options?.hash ?? {});
966
+ });
967
+ hb.registerHelper(ARRAY_HELPER, (...args) => {
968
+ return args.slice(0, -1);
969
+ });
970
+ return hb;
971
+ }
972
+ /**
973
+ * Compile + render the host text with the isolated instance under
974
+ * `{ strict: true, noEscape: true }` — `strict` so any stray `{{…}}` that is not a
975
+ * `fragment` / `array` marker fails closed instead of rendering empty; `noEscape` so
976
+ * the prompt text (ASCII diagrams, quotes) passes through verbatim. Each placement is
977
+ * replaced by its `resolver` result. May throw — the caller wraps it as ASSEMBLY_ERROR.
978
+ */
979
+ function renderHostTemplate(text, resolver) {
980
+ return createHostInstance(resolver).compile(text, {
981
+ strict: true,
982
+ noEscape: true
983
+ })({});
984
+ }
985
+ //#endregion
838
986
  //#region ../lib/relative-url.ts
839
987
  /**
840
988
  * Resolve a reference to an absolute URL. An absolute http(s) ref is used as-is;
@@ -926,23 +1074,27 @@ async function loadYaml(url, fetchImpl, opts = {}) {
926
1074
  };
927
1075
  }
928
1076
  /**
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`.
1077
+ * Render an activity's host text (`tutor_instructions` / `instructions`) into a
1078
+ * finished prompt string, inserting each inline `{{fragment "alias.id" …}}` marker in
1079
+ * place. Fetches every declared fragment library in parallel (relative refs resolved
1080
+ * against `baseUrl`), schema-validates each, (optionally) runs the thorough
1081
+ * whole-library check, extracts + checks the placements, then compiles + renders the
1082
+ * host template under strict Handlebars.
934
1083
  *
935
1084
  * 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.
1085
+ * → checkrender pipeline. `hostText` IS the template: fragments appear only where
1086
+ * the author placed a marker; there is no ordering concept and no prepend fallback.
1087
+ *
1088
+ * TEMPLATE-SEMANTICS OPT-IN: an activity that declares no `fragment_files:` is NEVER
1089
+ * compiled — its host text returns byte-verbatim (protecting plain activities and the
1090
+ * authoring tutors whose prose contains sample markers as teaching content).
939
1091
  */
940
- async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trailingInstructions) {
1092
+ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, hostText = "") {
941
1093
  const warnings = [];
942
1094
  const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
943
- if (block.fragment_files.length === 0 && block.fragments.length === 0) return {
1095
+ if (block.fragment_files.length === 0) return {
944
1096
  ok: true,
945
- prompt: assembleSystemPrompt([], trailingInstructions),
1097
+ prompt: hostText,
946
1098
  warnings
947
1099
  };
948
1100
  const settled = await Promise.all(block.fragment_files.map(async (ref) => {
@@ -1012,18 +1164,28 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trai
1012
1164
  libraryErrors.push(...checked.errors);
1013
1165
  warnings.push(...checked.warnings);
1014
1166
  }
1015
- const consistency = checkConsistency(block, fragmentFilesByAlias);
1016
- warnings.push(...consistency.warnings);
1017
- const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
1018
- if (preAssemblyErrors.length > 0) return {
1167
+ const parsed = parseHostPlacements(hostText);
1168
+ if (parsed.errors.length > 0) return {
1169
+ ok: false,
1170
+ errors: [...libraryErrors, ...parsed.errors],
1171
+ warnings
1172
+ };
1173
+ const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files);
1174
+ warnings.push(...placementCheck.warnings);
1175
+ const preRenderErrors = [...libraryErrors, ...placementCheck.errors];
1176
+ if (preRenderErrors.length > 0) return {
1019
1177
  ok: false,
1020
- errors: preAssemblyErrors,
1178
+ errors: preRenderErrors,
1021
1179
  warnings
1022
1180
  };
1023
1181
  try {
1024
1182
  return {
1025
1183
  ok: true,
1026
- prompt: assembleSystemPrompt(consistency.plan, trailingInstructions),
1184
+ prompt: renderHostTemplate(hostText, (ref, args) => {
1185
+ const resolved = resolveAndMerge(ref, args, fragmentFilesByAlias);
1186
+ if (resolved.content === null || resolved.errors.length > 0) throw new Error(`Fragment "${ref}" could not be resolved`);
1187
+ return renderFragmentContent(resolved.content, resolved.variables);
1188
+ }),
1027
1189
  warnings
1028
1190
  };
1029
1191
  } catch (e) {
@@ -1065,8 +1227,7 @@ const CodingYamlSchema = z.strictObject({
1065
1227
  description: "The pinned model and provider that answer coding requests."
1066
1228
  }),
1067
1229
  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." })
1230
+ 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 are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" }} markers (escape a literal {{ as \\{{)." })
1070
1231
  });
1071
1232
  //#endregion
1072
1233
  //#region ../lib/coding-validate.ts
@@ -1105,13 +1266,10 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
1105
1266
  };
1106
1267
  const checked = checkCodingParsed(valid.data);
1107
1268
  if (!checked.ok) return checked;
1108
- const assembled = await assembleFragmentPrompt({
1109
- fragment_files: valid.data.fragment_files,
1110
- fragments: valid.data.fragments
1111
- }, url, fetchImpl, {
1269
+ const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1112
1270
  allowedSchemes: opts.allowedSchemes,
1113
1271
  validateLibraries: opts.validateLibraries ?? true
1114
- });
1272
+ }, valid.data.instructions);
1115
1273
  const warnings = [...checked.warnings, ...assembled.warnings];
1116
1274
  if (!assembled.ok) return {
1117
1275
  ok: false,
@@ -1183,7 +1341,7 @@ const QuizYamlSchema = z.strictObject({
1183
1341
  description: "Optional guidance for the per-question follow-up discussion chat."
1184
1342
  }),
1185
1343
  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." }),
1344
+ instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files are declared, place fragments inline here with {{fragment \"alias.id\" }} markers (escape a literal {{ as \\{{)." }),
1187
1345
  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
1346
  });
1189
1347
  //#endregion
@@ -1248,13 +1406,10 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1248
1406
  };
1249
1407
  const checked = checkQuizParsed(valid.data);
1250
1408
  if (!checked.ok) return checked;
1251
- const assembled = await assembleFragmentPrompt({
1252
- fragment_files: valid.data.fragment_files,
1253
- fragments: valid.data.fragments
1254
- }, url, fetchImpl, {
1409
+ const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1255
1410
  allowedSchemes: opts.allowedSchemes,
1256
1411
  validateLibraries: opts.validateLibraries ?? true
1257
- });
1412
+ }, valid.data.instructions ?? "");
1258
1413
  const warnings = [...checked.warnings, ...assembled.warnings];
1259
1414
  if (!assembled.ok) return {
1260
1415
  ok: false,
@@ -1303,11 +1458,10 @@ const TutorSchema = z.strictObject({
1303
1458
  }),
1304
1459
  prompt: z.strictObject({
1305
1460
  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." })
1461
+ tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers (escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
1308
1462
  }).meta({
1309
1463
  id: "prompt",
1310
- description: "The assembled system prompt: fragments plus tutor instructions."
1464
+ description: "The tutor system prompt: a host template with inline fragment markers."
1311
1465
  })
1312
1466
  });
1313
1467
  //#endregion
@@ -1366,8 +1520,7 @@ const WritingYamlSchema = z.strictObject({
1366
1520
  description: "The model and provider that back the writing coach."
1367
1521
  }),
1368
1522
  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." }),
1523
+ 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 are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" }} markers (escape a literal {{ as \\{{)." }),
1371
1524
  placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
1372
1525
  });
1373
1526
  //#endregion
@@ -1410,13 +1563,10 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
1410
1563
  };
1411
1564
  const checked = checkWritingParsed(valid.data);
1412
1565
  if (!checked.ok) return checked;
1413
- const assembled = await assembleFragmentPrompt({
1414
- fragment_files: valid.data.fragment_files,
1415
- fragments: valid.data.fragments
1416
- }, url, fetchImpl, {
1566
+ const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
1417
1567
  allowedSchemes: opts.allowedSchemes,
1418
1568
  validateLibraries: opts.validateLibraries ?? true
1419
- });
1569
+ }, valid.data.instructions);
1420
1570
  const warnings = [...checked.warnings, ...assembled.warnings];
1421
1571
  if (!assembled.ok) return {
1422
1572
  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.13.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": {