@markbrutx/promptbook-core 0.1.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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/annotations.d.ts +56 -0
- package/dist/annotations.d.ts.map +1 -0
- package/dist/annotations.js +50 -0
- package/dist/annotations.js.map +1 -0
- package/dist/bundle.d.ts +44 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +135 -0
- package/dist/bundle.js.map +1 -0
- package/dist/edge/index.js +192 -0
- package/dist/edge.d.ts +12 -0
- package/dist/edge.d.ts.map +1 -0
- package/dist/edge.js +11 -0
- package/dist/edge.js.map +1 -0
- package/dist/eval/assertions.d.ts +15 -0
- package/dist/eval/assertions.d.ts.map +1 -0
- package/dist/eval/assertions.js +131 -0
- package/dist/eval/assertions.js.map +1 -0
- package/dist/eval/evaluate.d.ts +15 -0
- package/dist/eval/evaluate.d.ts.map +1 -0
- package/dist/eval/evaluate.js +65 -0
- package/dist/eval/evaluate.js.map +1 -0
- package/dist/eval/load-fixtures.d.ts +12 -0
- package/dist/eval/load-fixtures.d.ts.map +1 -0
- package/dist/eval/load-fixtures.js +87 -0
- package/dist/eval/load-fixtures.js.map +1 -0
- package/dist/eval/types.d.ts +123 -0
- package/dist/eval/types.d.ts.map +1 -0
- package/dist/eval/types.js +2 -0
- package/dist/eval/types.js.map +1 -0
- package/dist/frontmatter.d.ts +12 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +22 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/fs.d.ts +11 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +20 -0
- package/dist/fs.js.map +1 -0
- package/dist/guards.d.ts +6 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +9 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/interpolate.d.ts +11 -0
- package/dist/interpolate.d.ts.map +1 -0
- package/dist/interpolate.js +25 -0
- package/dist/interpolate.js.map +1 -0
- package/dist/lint/lint.d.ts +11 -0
- package/dist/lint/lint.d.ts.map +1 -0
- package/dist/lint/lint.js +30 -0
- package/dist/lint/lint.js.map +1 -0
- package/dist/lint/references.d.ts +18 -0
- package/dist/lint/references.d.ts.map +1 -0
- package/dist/lint/references.js +39 -0
- package/dist/lint/references.js.map +1 -0
- package/dist/lint/rules/banned-tokens.d.ts +13 -0
- package/dist/lint/rules/banned-tokens.d.ts.map +1 -0
- package/dist/lint/rules/banned-tokens.js +38 -0
- package/dist/lint/rules/banned-tokens.js.map +1 -0
- package/dist/lint/rules/dangling-reference.d.ts +11 -0
- package/dist/lint/rules/dangling-reference.d.ts.map +1 -0
- package/dist/lint/rules/dangling-reference.js +37 -0
- package/dist/lint/rules/dangling-reference.js.map +1 -0
- package/dist/lint/rules/dead-rule.d.ts +21 -0
- package/dist/lint/rules/dead-rule.d.ts.map +1 -0
- package/dist/lint/rules/dead-rule.js +135 -0
- package/dist/lint/rules/dead-rule.js.map +1 -0
- package/dist/lint/rules/example-balance.d.ts +19 -0
- package/dist/lint/rules/example-balance.d.ts.map +1 -0
- package/dist/lint/rules/example-balance.js +57 -0
- package/dist/lint/rules/example-balance.js.map +1 -0
- package/dist/lint/rules/index.d.ts +28 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/index.js +30 -0
- package/dist/lint/rules/index.js.map +1 -0
- package/dist/lint/rules/language-directive-position.d.ts +16 -0
- package/dist/lint/rules/language-directive-position.d.ts.map +1 -0
- package/dist/lint/rules/language-directive-position.js +42 -0
- package/dist/lint/rules/language-directive-position.js.map +1 -0
- package/dist/lint/rules/token-budget.d.ts +18 -0
- package/dist/lint/rules/token-budget.d.ts.map +1 -0
- package/dist/lint/rules/token-budget.js +39 -0
- package/dist/lint/rules/token-budget.js.map +1 -0
- package/dist/lint/rules/unused-fragment.d.ts +11 -0
- package/dist/lint/rules/unused-fragment.d.ts.map +1 -0
- package/dist/lint/rules/unused-fragment.js +33 -0
- package/dist/lint/rules/unused-fragment.js.map +1 -0
- package/dist/lint/types.d.ts +50 -0
- package/dist/lint/types.d.ts.map +1 -0
- package/dist/lint/types.js +2 -0
- package/dist/lint/types.js.map +1 -0
- package/dist/load.d.ts +12 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +238 -0
- package/dist/load.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +25 -0
- package/dist/paths.js.map +1 -0
- package/dist/resolve-book.d.ts +15 -0
- package/dist/resolve-book.d.ts.map +1 -0
- package/dist/resolve-book.js +195 -0
- package/dist/resolve-book.js.map +1 -0
- package/dist/resolve.d.ts +13 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +17 -0
- package/dist/resolve.js.map +1 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +48 -0
- package/src/annotations.ts +100 -0
- package/src/bundle.ts +163 -0
- package/src/edge.ts +11 -0
- package/src/eval/assertions.ts +174 -0
- package/src/eval/evaluate.ts +84 -0
- package/src/eval/load-fixtures.ts +91 -0
- package/src/eval/types.ts +134 -0
- package/src/frontmatter.ts +28 -0
- package/src/fs.ts +21 -0
- package/src/guards.ts +11 -0
- package/src/index.ts +84 -0
- package/src/interpolate.ts +27 -0
- package/src/lint/lint.ts +32 -0
- package/src/lint/references.ts +50 -0
- package/src/lint/rules/banned-tokens.ts +46 -0
- package/src/lint/rules/dangling-reference.ts +43 -0
- package/src/lint/rules/dead-rule.ts +147 -0
- package/src/lint/rules/example-balance.ts +68 -0
- package/src/lint/rules/index.ts +47 -0
- package/src/lint/rules/language-directive-position.ts +51 -0
- package/src/lint/rules/token-budget.ts +50 -0
- package/src/lint/rules/unused-fragment.ts +38 -0
- package/src/lint/types.ts +55 -0
- package/src/load.ts +282 -0
- package/src/paths.ts +27 -0
- package/src/resolve-book.ts +237 -0
- package/src/resolve.ts +18 -0
- package/src/types.ts +191 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { iterateReferences } from "../references.js";
|
|
2
|
+
import type { LintFinding, LintRule, Severity } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export interface UnusedFragmentOptions {
|
|
5
|
+
severity?: Severity;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `unused-fragment` (book): a fragment that no composition mentions — not in a
|
|
10
|
+
* base, order, or any rule (add/replace/forbid/order/after) — is dead weight
|
|
11
|
+
* and is flagged. Findings are sorted by id for deterministic output.
|
|
12
|
+
*/
|
|
13
|
+
export function unusedFragment(options: UnusedFragmentOptions = {}): LintRule {
|
|
14
|
+
const severity = options.severity ?? "warning";
|
|
15
|
+
return {
|
|
16
|
+
id: "unused-fragment",
|
|
17
|
+
description: "Every fragment should be referenced by at least one composition or rule.",
|
|
18
|
+
scope: "book",
|
|
19
|
+
check(input) {
|
|
20
|
+
const referenced = new Set<string>();
|
|
21
|
+
for (const reference of iterateReferences(input.book)) {
|
|
22
|
+
referenced.add(reference.id);
|
|
23
|
+
}
|
|
24
|
+
const findings: LintFinding[] = [];
|
|
25
|
+
for (const id of [...input.book.fragments.keys()].sort()) {
|
|
26
|
+
if (!referenced.has(id)) {
|
|
27
|
+
findings.push({
|
|
28
|
+
ruleId: "unused-fragment",
|
|
29
|
+
severity,
|
|
30
|
+
message: `fragment "${id}" is not referenced by any composition or rule`,
|
|
31
|
+
fragmentId: id,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return findings;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for the lint engine.
|
|
3
|
+
*
|
|
4
|
+
* Lint is static and deterministic: it runs pluggable rules over an assembled
|
|
5
|
+
* `{ text, trace }` and/or the book structure. It never calls a model and adds
|
|
6
|
+
* no runtime dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import type { PromptBook, ResolveResult } from "../types.js";
|
|
9
|
+
|
|
10
|
+
/** How serious a finding is. `error` drives a non-zero CLI exit. */
|
|
11
|
+
export type Severity = "error" | "warning" | "info";
|
|
12
|
+
|
|
13
|
+
/** When a rule can run: over a resolved prompt, or over the book structure. */
|
|
14
|
+
export type LintScope = "resolved" | "book";
|
|
15
|
+
|
|
16
|
+
/** A single problem a rule reports. */
|
|
17
|
+
export interface LintFinding {
|
|
18
|
+
/** Id of the rule that produced this finding. */
|
|
19
|
+
ruleId: string;
|
|
20
|
+
severity: Severity;
|
|
21
|
+
/** Human-readable, domain-agnostic description. */
|
|
22
|
+
message: string;
|
|
23
|
+
/** Fragment the finding points at, when applicable. */
|
|
24
|
+
fragmentId?: string;
|
|
25
|
+
/** Composition rule index the finding points at, when applicable. */
|
|
26
|
+
ruleIndex?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What lint runs against. `result` is present only for resolved-scope rules. */
|
|
30
|
+
export interface LintInput {
|
|
31
|
+
book: PromptBook;
|
|
32
|
+
result?: ResolveResult;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A pluggable rule. Created by a factory so thresholds are configurable, e.g.
|
|
37
|
+
* `tokenBudget({ maxTokens })`. `check` is pure: same input, same findings.
|
|
38
|
+
*/
|
|
39
|
+
export interface LintRule {
|
|
40
|
+
/** Stable id, e.g. "token-budget". */
|
|
41
|
+
id: string;
|
|
42
|
+
/** One-line explanation of what the rule enforces. */
|
|
43
|
+
description: string;
|
|
44
|
+
/** Scope decides whether the rule needs `input.result`. */
|
|
45
|
+
scope: LintScope;
|
|
46
|
+
/** Inspect the input and return zero or more findings. */
|
|
47
|
+
check(input: LintInput): LintFinding[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The aggregated outcome of a lint run. */
|
|
51
|
+
export interface LintReport {
|
|
52
|
+
findings: LintFinding[];
|
|
53
|
+
errorCount: number;
|
|
54
|
+
warningCount: number;
|
|
55
|
+
}
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
3
|
+
import { nodeFs } from "./fs.js";
|
|
4
|
+
import { isContextValue, isMapping } from "./guards.js";
|
|
5
|
+
import { joinPath, listFiles, stripExt } from "./paths.js";
|
|
6
|
+
import type {
|
|
7
|
+
CodePrompt,
|
|
8
|
+
CodePromptSample,
|
|
9
|
+
Composition,
|
|
10
|
+
Context,
|
|
11
|
+
ContextValue,
|
|
12
|
+
Fragment,
|
|
13
|
+
FsAdapter,
|
|
14
|
+
PromptBook,
|
|
15
|
+
Rule,
|
|
16
|
+
When,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
const ACTION_KEYS = ["add", "replace", "forbid", "order"] as const;
|
|
20
|
+
|
|
21
|
+
function toStringArray(raw: unknown): string[] {
|
|
22
|
+
if (!Array.isArray(raw)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return raw.map((value) => String(value));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toScalar(value: unknown): ContextValue {
|
|
29
|
+
return isContextValue(value) ? value : String(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseWhen(raw: unknown): When {
|
|
33
|
+
if (!isMapping(raw)) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
const when: When = {};
|
|
37
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
38
|
+
when[key] = toScalar(value);
|
|
39
|
+
}
|
|
40
|
+
return when;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse a context bag, or `undefined` when absent/empty (keeps the field optional). */
|
|
44
|
+
function parseContextBag(raw: unknown): Context | undefined {
|
|
45
|
+
const bag = parseWhen(raw);
|
|
46
|
+
return Object.keys(bag).length > 0 ? bag : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Load every `.yaml`/`.yml` file in `dir` into a name-keyed map. Owns the shared
|
|
51
|
+
* scaffold: parallel reads, skip-with-warning on a non-mapping doc, and the
|
|
52
|
+
* duplicate-name warning (later wins). The caller's `build` turns one parsed doc
|
|
53
|
+
* into a named value; `noun` labels the warnings ("Composition", "Code-prompt").
|
|
54
|
+
*/
|
|
55
|
+
async function loadYamlDir<T extends { name: string; sourceFile: string }>(
|
|
56
|
+
fs: FsAdapter,
|
|
57
|
+
dir: string,
|
|
58
|
+
noun: string,
|
|
59
|
+
warnings: string[],
|
|
60
|
+
build: (doc: Record<string, unknown>, file: string, full: string) => Promise<T> | T,
|
|
61
|
+
): Promise<Map<string, T>> {
|
|
62
|
+
const map = new Map<string, T>();
|
|
63
|
+
const files = await listFiles(fs, dir, [".yaml", ".yml"]);
|
|
64
|
+
const loaded = await Promise.all(
|
|
65
|
+
files.map(async (file) => {
|
|
66
|
+
const full = joinPath(dir, file);
|
|
67
|
+
return { file, full, raw: await fs.readFile(full) };
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
for (const { file, full, raw } of loaded) {
|
|
71
|
+
const doc = parseYaml(raw) as unknown;
|
|
72
|
+
if (!isMapping(doc)) {
|
|
73
|
+
warnings.push(`${noun} file "${full}" is empty or not a mapping; skipped.`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const value = await build(doc, file, full);
|
|
77
|
+
const previous = map.get(value.name);
|
|
78
|
+
if (previous) {
|
|
79
|
+
warnings.push(
|
|
80
|
+
`Duplicate ${noun.toLowerCase()} name "${value.name}" in "${previous.sourceFile}" and "${full}"; using the latter.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
map.set(value.name, value);
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseReplaceMap(raw: unknown, index: number, file: string): Record<string, string> {
|
|
89
|
+
if (!isMapping(raw)) {
|
|
90
|
+
throw new Error(`Rule #${index} in "${file}" has a "replace" that is not a mapping.`);
|
|
91
|
+
}
|
|
92
|
+
const map: Record<string, string> = {};
|
|
93
|
+
for (const [from, to] of Object.entries(raw)) {
|
|
94
|
+
map[from] = String(to);
|
|
95
|
+
}
|
|
96
|
+
return map;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseRule(entry: unknown, index: number, file: string): Rule {
|
|
100
|
+
if (!isMapping(entry)) {
|
|
101
|
+
throw new Error(`Rule #${index} in "${file}" must be a mapping.`);
|
|
102
|
+
}
|
|
103
|
+
const obj = entry;
|
|
104
|
+
const present = ACTION_KEYS.filter((key) => obj[key] !== undefined);
|
|
105
|
+
if (present.length !== 1) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Rule #${index} in "${file}" must declare exactly one action (${ACTION_KEYS.join(
|
|
108
|
+
" / ",
|
|
109
|
+
)}); found ${present.length}.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const action = present[0] as (typeof ACTION_KEYS)[number];
|
|
113
|
+
const rule: Rule = { index, when: parseWhen(obj.when), action };
|
|
114
|
+
switch (action) {
|
|
115
|
+
case "add":
|
|
116
|
+
rule.add = toStringArray(obj.add);
|
|
117
|
+
if (typeof obj.after === "string") {
|
|
118
|
+
rule.after = obj.after;
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
case "replace":
|
|
122
|
+
rule.replace = parseReplaceMap(obj.replace, index, file);
|
|
123
|
+
break;
|
|
124
|
+
case "forbid":
|
|
125
|
+
rule.forbid = toStringArray(obj.forbid);
|
|
126
|
+
break;
|
|
127
|
+
case "order":
|
|
128
|
+
rule.order = toStringArray(obj.order);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
return rule;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseRules(raw: unknown, file: string): Rule[] {
|
|
135
|
+
if (raw === undefined || raw === null) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
if (!Array.isArray(raw)) {
|
|
139
|
+
throw new Error(`"rules" in "${file}" must be a list.`);
|
|
140
|
+
}
|
|
141
|
+
return raw.map((entry, index) => parseRule(entry, index, file));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function loadFragments(fs: FsAdapter, dir: string): Promise<Map<string, Fragment>> {
|
|
145
|
+
const fragments = new Map<string, Fragment>();
|
|
146
|
+
const sourceById = new Map<string, string>();
|
|
147
|
+
const fragmentsDir = joinPath(dir, "fragments");
|
|
148
|
+
const files = await listFiles(fs, fragmentsDir, [".md", ".markdown"]);
|
|
149
|
+
// Reads run in parallel; the sorted file order still drives processing,
|
|
150
|
+
// so duplicate detection and output stay deterministic.
|
|
151
|
+
const loaded = await Promise.all(
|
|
152
|
+
files.map(async (file) => {
|
|
153
|
+
const full = joinPath(fragmentsDir, file);
|
|
154
|
+
return { full, raw: await fs.readFile(full) };
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
for (const { full, raw } of loaded) {
|
|
158
|
+
const { data, body } = parseFrontmatter(raw);
|
|
159
|
+
const id = typeof data.id === "string" ? data.id : undefined;
|
|
160
|
+
if (!id) {
|
|
161
|
+
throw new Error(`Fragment "${full}" is missing a string "id" in its frontmatter.`);
|
|
162
|
+
}
|
|
163
|
+
const existing = sourceById.get(id);
|
|
164
|
+
if (existing) {
|
|
165
|
+
throw new Error(`Duplicate fragment id "${id}" in "${existing}" and "${full}".`);
|
|
166
|
+
}
|
|
167
|
+
sourceById.set(id, full);
|
|
168
|
+
const fragment: Fragment = { id, body: body.trim(), sourceFile: full };
|
|
169
|
+
if (typeof data.kind === "string") {
|
|
170
|
+
fragment.kind = data.kind;
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(data.tags)) {
|
|
173
|
+
fragment.tags = data.tags.map((tag) => String(tag));
|
|
174
|
+
}
|
|
175
|
+
fragments.set(id, fragment);
|
|
176
|
+
}
|
|
177
|
+
return fragments;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function loadCompositions(fs: FsAdapter, dir: string, warnings: string[]): Promise<Map<string, Composition>> {
|
|
181
|
+
return loadYamlDir(fs, joinPath(dir, "rules"), "Composition", warnings, (obj, file, full) => {
|
|
182
|
+
const name = typeof obj.name === "string" ? obj.name : stripExt(file);
|
|
183
|
+
const composition: Composition = {
|
|
184
|
+
name,
|
|
185
|
+
base: toStringArray(obj.base),
|
|
186
|
+
rules: parseRules(obj.rules, full),
|
|
187
|
+
sourceFile: full,
|
|
188
|
+
};
|
|
189
|
+
if (obj.order !== undefined) {
|
|
190
|
+
composition.order = toStringArray(obj.order);
|
|
191
|
+
}
|
|
192
|
+
return composition;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Load the captured output samples of one code-prompt manifest. A sample's text
|
|
198
|
+
* comes inline (`output`) or from a sibling file (`file`). Malformed entries
|
|
199
|
+
* and missing files become warnings, never throws — the core never runs the
|
|
200
|
+
* builder, so a broken snapshot just drops out of the menu.
|
|
201
|
+
*/
|
|
202
|
+
async function loadCodePromptSamples(
|
|
203
|
+
fs: FsAdapter,
|
|
204
|
+
codeDir: string,
|
|
205
|
+
raw: unknown,
|
|
206
|
+
manifest: string,
|
|
207
|
+
warnings: string[],
|
|
208
|
+
): Promise<CodePromptSample[]> {
|
|
209
|
+
if (raw === undefined || raw === null) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
if (!Array.isArray(raw)) {
|
|
213
|
+
warnings.push(`"samples" in "${manifest}" must be a list; ignored.`);
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
const samples: CodePromptSample[] = [];
|
|
217
|
+
for (let index = 0; index < raw.length; index++) {
|
|
218
|
+
const entry = raw[index];
|
|
219
|
+
if (!isMapping(entry)) {
|
|
220
|
+
warnings.push(`Sample #${index} in "${manifest}" must be a mapping; skipped.`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const label = typeof entry.label === "string" ? entry.label : undefined;
|
|
224
|
+
if (!label) {
|
|
225
|
+
warnings.push(`Sample #${index} in "${manifest}" is missing a string "label"; skipped.`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
let output: string | undefined;
|
|
229
|
+
if (typeof entry.output === "string") {
|
|
230
|
+
output = entry.output;
|
|
231
|
+
} else if (typeof entry.file === "string") {
|
|
232
|
+
try {
|
|
233
|
+
output = await fs.readFile(joinPath(codeDir, entry.file));
|
|
234
|
+
} catch {
|
|
235
|
+
warnings.push(`Sample "${label}" in "${manifest}" references missing file "${entry.file}"; skipped.`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
warnings.push(`Sample "${label}" in "${manifest}" must set "output" or "file"; skipped.`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const sample: CodePromptSample = { label, output };
|
|
243
|
+
const context = parseContextBag(entry.context);
|
|
244
|
+
if (context !== undefined) {
|
|
245
|
+
sample.context = context;
|
|
246
|
+
}
|
|
247
|
+
samples.push(sample);
|
|
248
|
+
}
|
|
249
|
+
return samples;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function loadCodePrompts(fs: FsAdapter, dir: string, warnings: string[]): Promise<Map<string, CodePrompt>> {
|
|
253
|
+
const codeDir = joinPath(dir, "code-prompts");
|
|
254
|
+
return loadYamlDir(fs, codeDir, "Code-prompt", warnings, async (obj, file, full) => {
|
|
255
|
+
const name = typeof obj.name === "string" ? obj.name : stripExt(file);
|
|
256
|
+
const samples = await loadCodePromptSamples(fs, codeDir, obj.samples, full, warnings);
|
|
257
|
+
const codePrompt: CodePrompt = { name, samples, sourceFile: full };
|
|
258
|
+
if (typeof obj.description === "string") {
|
|
259
|
+
codePrompt.description = obj.description;
|
|
260
|
+
}
|
|
261
|
+
return codePrompt;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Load a prompts folder (`fragments/` + `rules/` + optional `code-prompts/`)
|
|
267
|
+
* into a {@link PromptBook}.
|
|
268
|
+
*
|
|
269
|
+
* Throws only on structural errors (missing/duplicate fragment id, malformed
|
|
270
|
+
* rule). Soft issues (empty rules file, duplicate composition name, broken
|
|
271
|
+
* code-prompt manifest/sample) become warnings. Code-prompts hold snapshot text
|
|
272
|
+
* only — the builder is never executed. Reference checks happen in `resolve`.
|
|
273
|
+
*/
|
|
274
|
+
export async function loadPrompts(promptsDir: string, fs: FsAdapter = nodeFs()): Promise<PromptBook> {
|
|
275
|
+
const warnings: string[] = [];
|
|
276
|
+
const [fragments, compositions, codePrompts] = await Promise.all([
|
|
277
|
+
loadFragments(fs, promptsDir),
|
|
278
|
+
loadCompositions(fs, promptsDir, warnings),
|
|
279
|
+
loadCodePrompts(fs, promptsDir, warnings),
|
|
280
|
+
]);
|
|
281
|
+
return { fragments, compositions, codePrompts, warnings };
|
|
282
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FsAdapter } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Join a directory and child with a single `/`, tolerating a trailing slash. */
|
|
4
|
+
export function joinPath(dir: string, child: string): string {
|
|
5
|
+
return dir.endsWith("/") ? `${dir}${child}` : `${dir}/${child}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Drop a file extension, e.g. `assistant.yaml` -> `assistant`. */
|
|
9
|
+
export function stripExt(name: string): string {
|
|
10
|
+
const dot = name.lastIndexOf(".");
|
|
11
|
+
return dot === -1 ? name : name.slice(0, dot);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* List files in `dir` whose name ends with one of `exts`, sorted for
|
|
16
|
+
* deterministic processing. A missing/unreadable directory yields `[]` so
|
|
17
|
+
* callers can treat an absent folder as "no files".
|
|
18
|
+
*/
|
|
19
|
+
export async function listFiles(fs: FsAdapter, dir: string, exts: string[]): Promise<string[]> {
|
|
20
|
+
let entries: string[];
|
|
21
|
+
try {
|
|
22
|
+
entries = await fs.readDir(dir);
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
return entries.filter((name) => exts.some((ext) => name.endsWith(ext))).sort();
|
|
27
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { interpolate } from "./interpolate.js";
|
|
2
|
+
import type {
|
|
3
|
+
AddTrace,
|
|
4
|
+
Composition,
|
|
5
|
+
Context,
|
|
6
|
+
ContextValue,
|
|
7
|
+
ForbidTrace,
|
|
8
|
+
PromptBook,
|
|
9
|
+
ReplaceTrace,
|
|
10
|
+
ResolveResult,
|
|
11
|
+
RuleTrace,
|
|
12
|
+
Trace,
|
|
13
|
+
UnmatchedAxis,
|
|
14
|
+
When,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pure resolver over an already-loaded {@link PromptBook}. No filesystem, no
|
|
19
|
+
* YAML, no Node builtins — the entire dependency footprint is {@link interpolate}
|
|
20
|
+
* and type-only imports. This is the determinism boundary: given the same book
|
|
21
|
+
* and context, the returned `text` is byte-for-byte stable, and the module
|
|
22
|
+
* bundles to a self-contained artifact for edge runtimes (see `edge.ts`).
|
|
23
|
+
*
|
|
24
|
+
* Conflict strategy: rules apply in declaration order, later wins (cascade).
|
|
25
|
+
* `forbid` is the one exception — it is applied as a final filter and always
|
|
26
|
+
* wins, so a forbidden id never reaches the output even if added earlier.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveBook(book: PromptBook, prompt: string, context: Context): ResolveResult {
|
|
29
|
+
const composition = book.compositions.get(prompt);
|
|
30
|
+
if (!composition) {
|
|
31
|
+
const available = [...book.compositions.keys()].sort().join(", ") || "(none)";
|
|
32
|
+
throw new Error(`Unknown prompt "${prompt}". Available compositions: ${available}.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const warnings: string[] = [...book.warnings];
|
|
36
|
+
const ruleTraces: RuleTrace[] = [];
|
|
37
|
+
const replaced: ReplaceTrace[] = [];
|
|
38
|
+
const added: AddTrace[] = [];
|
|
39
|
+
const forbidden: ForbidTrace[] = [];
|
|
40
|
+
const forbiddenIds = new Set<string>();
|
|
41
|
+
|
|
42
|
+
const working: string[] = [...composition.base];
|
|
43
|
+
let orderOverride: string[] | undefined = composition.order ? [...composition.order] : undefined;
|
|
44
|
+
|
|
45
|
+
for (const rule of composition.rules) {
|
|
46
|
+
const match = matchWhen(rule.when, context);
|
|
47
|
+
const trace: RuleTrace = {
|
|
48
|
+
index: rule.index,
|
|
49
|
+
action: rule.action,
|
|
50
|
+
when: rule.when,
|
|
51
|
+
fired: match.fired,
|
|
52
|
+
};
|
|
53
|
+
if (!match.fired) {
|
|
54
|
+
trace.reason = match.reason;
|
|
55
|
+
ruleTraces.push(trace);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
switch (rule.action) {
|
|
60
|
+
case "replace": {
|
|
61
|
+
const effects: string[] = [];
|
|
62
|
+
for (const [from, to] of Object.entries(rule.replace ?? {})) {
|
|
63
|
+
const at = working.indexOf(from);
|
|
64
|
+
if (at === -1) {
|
|
65
|
+
warnings.push(`Rule #${rule.index} cannot replace "${from}": it is not present in "${prompt}".`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
working[at] = to;
|
|
69
|
+
replaced.push({ from, to, ruleIndex: rule.index });
|
|
70
|
+
effects.push(`${from} -> ${to}`);
|
|
71
|
+
}
|
|
72
|
+
trace.effect = `replace ${effects.join(", ")}`;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "add": {
|
|
76
|
+
const ids = rule.add ?? [];
|
|
77
|
+
const after = rule.after;
|
|
78
|
+
const at = insertionPoint(working, after, rule.index, prompt, warnings);
|
|
79
|
+
working.splice(at, 0, ...ids);
|
|
80
|
+
for (const id of ids) {
|
|
81
|
+
added.push(
|
|
82
|
+
after !== undefined ? { id, after, ruleIndex: rule.index } : { id, ruleIndex: rule.index },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
trace.effect = `add ${ids.join(", ")}${after !== undefined ? ` after ${after}` : ""}`;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "forbid": {
|
|
89
|
+
const ids = rule.forbid ?? [];
|
|
90
|
+
for (const id of ids) {
|
|
91
|
+
forbiddenIds.add(id);
|
|
92
|
+
forbidden.push({ id, ruleIndex: rule.index });
|
|
93
|
+
}
|
|
94
|
+
trace.effect = `forbid ${ids.join(", ")}`;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "order": {
|
|
98
|
+
orderOverride = [...(rule.order ?? [])];
|
|
99
|
+
trace.effect = `order ${orderOverride.join(", ")}`;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
ruleTraces.push(trace);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ordered = applyOrder(working, orderOverride);
|
|
107
|
+
const selected = ordered.filter((id) => !forbiddenIds.has(id));
|
|
108
|
+
|
|
109
|
+
const parts: string[] = [];
|
|
110
|
+
const finalOrder: string[] = [];
|
|
111
|
+
for (const id of selected) {
|
|
112
|
+
const fragment = book.fragments.get(id);
|
|
113
|
+
if (!fragment) {
|
|
114
|
+
warnings.push(`Fragment "${id}" referenced by "${prompt}" was not found.`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
parts.push(
|
|
118
|
+
interpolate(fragment.body, context, (key) => {
|
|
119
|
+
warnings.push(`Missing variable "${key}" while rendering fragment "${id}".`);
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
finalOrder.push(id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const trace: Trace = {
|
|
126
|
+
prompt,
|
|
127
|
+
context,
|
|
128
|
+
rules: ruleTraces,
|
|
129
|
+
finalOrder,
|
|
130
|
+
replaced,
|
|
131
|
+
added,
|
|
132
|
+
forbidden,
|
|
133
|
+
unmatchedAxes: computeUnmatchedAxes(composition, context),
|
|
134
|
+
warnings,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return { text: parts.join("\n\n"), trace };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface WhenMatch {
|
|
141
|
+
fired: boolean;
|
|
142
|
+
reason?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function matchWhen(when: When, context: Context): WhenMatch {
|
|
146
|
+
for (const [key, expected] of Object.entries(when)) {
|
|
147
|
+
const actual = context[key];
|
|
148
|
+
if (actual === undefined) {
|
|
149
|
+
return { fired: false, reason: `context.${key} is unset, rule expects "${expected}"` };
|
|
150
|
+
}
|
|
151
|
+
if (!valuesEqual(expected, actual)) {
|
|
152
|
+
return { fired: false, reason: `context.${key}="${actual}" does not equal expected "${expected}"` };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { fired: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function valuesEqual(a: ContextValue, b: ContextValue): boolean {
|
|
159
|
+
return String(a) === String(b);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide where an `add` inserts. With `after`, just past that id; without it,
|
|
164
|
+
* before the last fragment (so trailing format/footer fragments stay last).
|
|
165
|
+
*/
|
|
166
|
+
function insertionPoint(
|
|
167
|
+
working: string[],
|
|
168
|
+
after: string | undefined,
|
|
169
|
+
ruleIndex: number,
|
|
170
|
+
prompt: string,
|
|
171
|
+
warnings: string[],
|
|
172
|
+
): number {
|
|
173
|
+
if (after === undefined) {
|
|
174
|
+
return Math.max(working.length - 1, 0);
|
|
175
|
+
}
|
|
176
|
+
const at = working.indexOf(after);
|
|
177
|
+
if (at === -1) {
|
|
178
|
+
warnings.push(`Rule #${ruleIndex} add anchor "${after}" not found in "${prompt}"; appending at end.`);
|
|
179
|
+
return working.length;
|
|
180
|
+
}
|
|
181
|
+
return at + 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Produce the final id sequence. With an `order` override, listed ids come
|
|
186
|
+
* first (those that are present), then any remaining working ids in their
|
|
187
|
+
* existing order. Without an override, the working order is used as-is.
|
|
188
|
+
* Duplicates are collapsed to their first occurrence.
|
|
189
|
+
*/
|
|
190
|
+
function applyOrder(working: string[], orderOverride: string[] | undefined): string[] {
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
const result: string[] = [];
|
|
193
|
+
const push = (id: string) => {
|
|
194
|
+
if (!seen.has(id)) {
|
|
195
|
+
seen.add(id);
|
|
196
|
+
result.push(id);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
if (orderOverride) {
|
|
200
|
+
const present = new Set(working);
|
|
201
|
+
for (const id of orderOverride) {
|
|
202
|
+
if (present.has(id)) {
|
|
203
|
+
push(id);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const id of working) {
|
|
208
|
+
push(id);
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Find context axes that some rule referenced but matched none. This surfaces
|
|
215
|
+
* holes like `industry=zoo` where industry rules exist but none cover "zoo".
|
|
216
|
+
*/
|
|
217
|
+
function computeUnmatchedAxes(composition: Composition, context: Context): UnmatchedAxis[] {
|
|
218
|
+
const result: UnmatchedAxis[] = [];
|
|
219
|
+
for (const key of Object.keys(context)) {
|
|
220
|
+
let referenced = false;
|
|
221
|
+
let matched = false;
|
|
222
|
+
for (const rule of composition.rules) {
|
|
223
|
+
if (Object.hasOwn(rule.when, key)) {
|
|
224
|
+
referenced = true;
|
|
225
|
+
const expected = rule.when[key];
|
|
226
|
+
if (expected !== undefined && valuesEqual(expected, context[key] as ContextValue)) {
|
|
227
|
+
matched = true;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (referenced && !matched) {
|
|
233
|
+
result.push({ key, value: context[key] as ContextValue });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { loadPrompts } from "./load.js";
|
|
2
|
+
import { resolveBook } from "./resolve-book.js";
|
|
3
|
+
import type { ResolveInput, ResolveResult } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Assemble a prompt for a context and return the text plus an explain trace.
|
|
7
|
+
*
|
|
8
|
+
* Loads the prompts folder (the one IO step), then delegates to the pure
|
|
9
|
+
* {@link resolveBook}. Given the same folder contents and input, the returned
|
|
10
|
+
* `text` is byte-for-byte stable.
|
|
11
|
+
*
|
|
12
|
+
* Each call re-reads the folder. For many resolves against the same folder,
|
|
13
|
+
* call {@link loadPrompts} once and reuse {@link resolveBook}.
|
|
14
|
+
*/
|
|
15
|
+
export async function resolve(input: ResolveInput): Promise<ResolveResult> {
|
|
16
|
+
const book = await loadPrompts(input.promptsDir, input.fs);
|
|
17
|
+
return resolveBook(book, input.prompt, input.context ?? {});
|
|
18
|
+
}
|