@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.
- package/dist/main.js +553 -160
- 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
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
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
|
|
412
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
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 (
|
|
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
|
-
|
|
645
|
+
seen.add(frag.id);
|
|
494
646
|
}
|
|
495
|
-
fragmentIndex.set(alias, byId);
|
|
496
647
|
}
|
|
497
|
-
const
|
|
498
|
-
const
|
|
499
|
-
for (const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
577
|
-
const
|
|
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: "
|
|
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
|
-
|
|
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().
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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: "
|
|
744
|
-
description: "
|
|
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
|
|
797
|
-
* `
|
|
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
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
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
|
-
* →
|
|
937
|
-
*
|
|
938
|
-
*
|
|
1264
|
+
* → check → render 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 = {},
|
|
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.
|
|
1277
|
+
if (block.fragment_files.length === 0 && block.text_files.length === 0) return {
|
|
944
1278
|
ok: true,
|
|
945
|
-
prompt:
|
|
1279
|
+
prompt: hostText,
|
|
946
1280
|
warnings
|
|
947
1281
|
};
|
|
948
|
-
const
|
|
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
|
|
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
|
|
1016
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1307
|
-
tutor_instructions: z.string().meta({ description: "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|