motionspec 1.0.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 +102 -0
- package/bin/catalog-lock.js +46 -0
- package/bin/license-check.js +32 -0
- package/bin/motion.js +136 -0
- package/catalog.lock.json +243 -0
- package/package.json +68 -0
- package/primitives/counterUp.json +54 -0
- package/primitives/cssTransition.json +38 -0
- package/primitives/marquee.json +44 -0
- package/primitives/parallaxLayer.json +35 -0
- package/primitives/pinnedSection.json +30 -0
- package/primitives/scaleOnScroll.json +55 -0
- package/primitives/scrollReveal.json +43 -0
- package/primitives/staggerReveal.json +43 -0
- package/schema/motionspec.schema.json +56 -0
- package/src/compiler/catalog-semver.js +133 -0
- package/src/compiler/catalog.js +94 -0
- package/src/compiler/compile.js +149 -0
- package/src/compiler/validate.js +264 -0
- package/src/demo/build-demo.js +150 -0
- package/src/discover/discover.js +92 -0
- package/src/mcp/register-tools.js +163 -0
- package/src/mcp/server.mjs +46 -0
- package/src/router/cache.js +81 -0
- package/src/router/clients.js +98 -0
- package/src/router/prompt.js +99 -0
- package/src/router/route.js +108 -0
- package/src/router/telemetry-sink.js +53 -0
- package/src/router/telemetry.js +68 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* register-tools — runtime-agnostische Registrierung der MotionSpec-MCP-Tools
|
|
4
|
+
* (Phase C / C2). Registriert die vier motion_-Tools auf einem uebergebenen
|
|
5
|
+
* McpServer. KEINE stdio-/process-/createRequire-Annahmen: der Aufrufer
|
|
6
|
+
* injiziert den Katalog-Zustand (getCatalog/getCatVer); Transport, Katalog-
|
|
7
|
+
* Reload und Logging gehoeren dem jeweiligen Entrypoint (stdio: server.mjs,
|
|
8
|
+
* Worker: fetch-Handler). So teilen sich stdio und Worker exakt dieselbe
|
|
9
|
+
* Tool-Logik — eine Quelle der Wahrheit (ADR-0001 Trust Boundary).
|
|
10
|
+
*/
|
|
11
|
+
const { z } = require("zod");
|
|
12
|
+
const { validateSpec } = require("../compiler/validate.js");
|
|
13
|
+
const { compileSpec } = require("../compiler/compile.js");
|
|
14
|
+
const telemetry = require("../router/telemetry.js");
|
|
15
|
+
|
|
16
|
+
/* Phase B security: cap MCP input size BEFORE any work. A spec is small by
|
|
17
|
+
* nature (a few motions); anything larger is abuse/DoS. 64 KB is generous. */
|
|
18
|
+
const MAX_SPEC_BYTES = 64 * 1024;
|
|
19
|
+
function oversizeError(spec) {
|
|
20
|
+
let bytes = Infinity;
|
|
21
|
+
try { bytes = Buffer.byteLength(JSON.stringify(spec) || "", "utf8"); } catch { /* circular/garbage */ }
|
|
22
|
+
if (bytes > MAX_SPEC_BYTES) {
|
|
23
|
+
return { code: "MS-INPUT-TOO-LARGE", message: "spec exceeds " + MAX_SPEC_BYTES + " bytes (" + bytes + "). Reject without processing." };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const AUTHORING_RULES = [
|
|
29
|
+
'Write a MotionSpec JSON object (specVersion "1.0").',
|
|
30
|
+
'1. "primitive" MUST be a catalog name — nothing else exists. Never invent one.',
|
|
31
|
+
"2. Params only from the primitive's paramSchema; respect min/max. Omit a param to use its default.",
|
|
32
|
+
'3. "target" is a plain CSS selector without quotes/special characters (e.g. .hero h1, #cta).',
|
|
33
|
+
'4. "id" matches [A-Za-z0-9_-]{1,64}, descriptive, unique per motion.',
|
|
34
|
+
'5. meta.target is "vanilla-gsap". Set globals.respectReducedMotion: true.',
|
|
35
|
+
"6. If no catalog primitive covers the request, do NOT improvise — tell the user which primitive is missing (this is an escalation signal).",
|
|
36
|
+
].join("\n");
|
|
37
|
+
|
|
38
|
+
function catalogSummaryOf(catalog) {
|
|
39
|
+
return Object.keys(catalog).sort().map((name) => {
|
|
40
|
+
const p = catalog[name];
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
version: p.version,
|
|
44
|
+
purpose: p.purpose,
|
|
45
|
+
engine: p.engine,
|
|
46
|
+
cost: (p.performance && p.performance.cost) || 0,
|
|
47
|
+
paramSchema: p.paramSchema || {},
|
|
48
|
+
triggerDefaults: p.triggerDefaults || {},
|
|
49
|
+
reducedMotionFallback: p.a11y && p.a11y.reducedMotionFallback,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Registriert die vier Tools. deps.getCatalog()/getCatVer() liefern stets den
|
|
55
|
+
* AKTUELLEN Katalog (stdio kann via SIGHUP neu laden; der Worker liefert den
|
|
56
|
+
* gebundelten Katalog statisch). */
|
|
57
|
+
function registerMotionspecTools(server, deps) {
|
|
58
|
+
const getCatalog = deps.getCatalog;
|
|
59
|
+
const getCatVer = deps.getCatVer;
|
|
60
|
+
|
|
61
|
+
server.registerTool(
|
|
62
|
+
"motion_catalog",
|
|
63
|
+
{
|
|
64
|
+
title: "MotionSpec catalog & authoring rules",
|
|
65
|
+
description:
|
|
66
|
+
"Returns the catalog of verified motion primitives (names, purpose, parameter schemas, defaults) plus the authoring rules for writing a MotionSpec. Call this FIRST, then write the spec yourself and pass it to motion_compile.",
|
|
67
|
+
inputSchema: {},
|
|
68
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
69
|
+
},
|
|
70
|
+
async () => {
|
|
71
|
+
const out = {
|
|
72
|
+
catalogVersion: getCatVer(),
|
|
73
|
+
specVersion: "1.0",
|
|
74
|
+
authoringRules: AUTHORING_RULES,
|
|
75
|
+
primitives: catalogSummaryOf(getCatalog()),
|
|
76
|
+
exampleSpec: {
|
|
77
|
+
specVersion: "1.0",
|
|
78
|
+
meta: { project: "example", target: "vanilla-gsap", createdWith: "mcp-host" },
|
|
79
|
+
globals: { respectReducedMotion: true },
|
|
80
|
+
motions: [
|
|
81
|
+
{
|
|
82
|
+
id: "hero-headline",
|
|
83
|
+
primitive: "scrollReveal",
|
|
84
|
+
target: ".hero h1",
|
|
85
|
+
params: { from: { opacity: 0, y: 48 }, duration: 0.8 },
|
|
86
|
+
trigger: { start: "top 80%", once: true },
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out };
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
server.registerTool(
|
|
96
|
+
"motion_validate",
|
|
97
|
+
{
|
|
98
|
+
title: "Validate a MotionSpec (trust boundary)",
|
|
99
|
+
description:
|
|
100
|
+
"Checks a MotionSpec against the schema, the primitive allow-list, parameter bounds and injection rules. Fail-closed: returns ok=false with precise errors. Use to pre-check a spec before compiling.",
|
|
101
|
+
inputSchema: { spec: z.record(z.string(), z.any()).describe("The MotionSpec JSON object") },
|
|
102
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
103
|
+
},
|
|
104
|
+
async ({ spec }) => {
|
|
105
|
+
const catVer = getCatVer();
|
|
106
|
+
const big = oversizeError(spec);
|
|
107
|
+
if (big) {
|
|
108
|
+
const out = { ok: false, errors: ["[" + big.code + "] " + big.message], deprecations: [], catalogVersion: catVer };
|
|
109
|
+
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out, isError: true };
|
|
110
|
+
}
|
|
111
|
+
const v = validateSpec(spec, getCatalog());
|
|
112
|
+
telemetry.log({ outcome: v.ok ? "mcp-validate-ok" : "mcp-validate-fail", model: "mcp-host", attempts: 1, errors: v.ok ? undefined : v.errors });
|
|
113
|
+
const out = { ok: v.ok, errors: v.errors || [], deprecations: v.deprecations || [], catalogVersion: catVer };
|
|
114
|
+
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out };
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
server.registerTool(
|
|
119
|
+
"motion_compile",
|
|
120
|
+
{
|
|
121
|
+
title: "Compile a MotionSpec to GSAP/CSS",
|
|
122
|
+
description:
|
|
123
|
+
"Validates (fail-closed) and deterministically compiles a MotionSpec into production-ready vanilla-GSAP JavaScript and CSS, with enforced prefers-reduced-motion fallbacks and a performance-budget report. Same spec always yields identical code. Returns {ok, js, css, report} or {ok:false, errors}.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
spec: z.record(z.string(), z.any()).describe("The MotionSpec JSON object"),
|
|
126
|
+
specName: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/).optional().describe("Optional name used in the artifact header"),
|
|
127
|
+
},
|
|
128
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
129
|
+
},
|
|
130
|
+
async ({ spec, specName }) => {
|
|
131
|
+
const catVer = getCatVer();
|
|
132
|
+
const big = oversizeError(spec);
|
|
133
|
+
if (big) {
|
|
134
|
+
const out = { ok: false, errors: ["[" + big.code + "] " + big.message], catalogVersion: catVer };
|
|
135
|
+
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out, isError: true };
|
|
136
|
+
}
|
|
137
|
+
const res = compileSpec(spec, getCatalog(), { specName: specName || "mcp-spec" });
|
|
138
|
+
telemetry.log({ outcome: res.ok ? "mcp-compile-ok" : "mcp-compile-fail", model: "mcp-host", attempts: 1, errors: res.ok ? undefined : res.errors });
|
|
139
|
+
const out = res.ok
|
|
140
|
+
? { ok: true, js: res.js, css: res.css, report: res.report, catalogVersion: catVer }
|
|
141
|
+
: { ok: false, errors: res.errors, hint: "Fix the listed errors. Call motion_catalog to re-check allowed primitives and parameter bounds.", catalogVersion: catVer };
|
|
142
|
+
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out, isError: !res.ok };
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
server.registerTool(
|
|
147
|
+
"motion_stats",
|
|
148
|
+
{
|
|
149
|
+
title: "MotionSpec usage telemetry",
|
|
150
|
+
description:
|
|
151
|
+
"Summary of routing/compile telemetry (counts per outcome). Escalation clusters indicate which new primitive the catalog needs next.",
|
|
152
|
+
inputSchema: {},
|
|
153
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
154
|
+
},
|
|
155
|
+
async () => {
|
|
156
|
+
const s = await telemetry.summary();
|
|
157
|
+
const out = { total: s.total, byOutcome: s.byOutcome, escalations: s.escalations, note: "escalations = Katalog-Wachstums-Signal (validate/cache-Rauschen ausgeblendet)" };
|
|
158
|
+
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out };
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { registerMotionspecTools, AUTHORING_RULES, MAX_SPEC_BYTES };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* motionspec-mcp-server — stdio-Entrypoint fuer den MotionSpec-Werkzeugkasten.
|
|
4
|
+
*
|
|
5
|
+
* Architektur: Der Host-LLM (Claude, Cursor, ...) ist hier selbst der Spec-
|
|
6
|
+
* Autor (Stufe A). Der Server liefert die Leitplanken (Katalog + Regeln) und
|
|
7
|
+
* erzwingt die Trust Boundary: motion_compile/motion_validate weisen jede
|
|
8
|
+
* ungueltige Spec fail-closed ab.
|
|
9
|
+
*
|
|
10
|
+
* Phase C / C2: Die Tool-Logik liegt in register-tools.js (runtime-agnostisch)
|
|
11
|
+
* und wird vom Worker-Entrypoint exakt gleich genutzt. Diese Datei besorgt nur
|
|
12
|
+
* das, was stdio-/Node-spezifisch ist: Katalog von Platte laden, SIGHUP-Reload,
|
|
13
|
+
* stdio-Transport.
|
|
14
|
+
*
|
|
15
|
+
* Start: node src/mcp/server.mjs (stdio)
|
|
16
|
+
* Claude Code: claude mcp add motionspec -- node <repo>/src/mcp/server.mjs
|
|
17
|
+
*/
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { loadCatalog, catalogVersion } = require("../compiler/catalog.js");
|
|
24
|
+
const { registerMotionspecTools } = require("./register-tools.js");
|
|
25
|
+
const pkg = require("../../package.json");
|
|
26
|
+
|
|
27
|
+
/* Audit-Befund #10 (2026-06-12): Katalog ist neu ladbar. SIGHUP laedt die
|
|
28
|
+
* Primitive neu, ohne den Server-Prozess zu beenden. */
|
|
29
|
+
let catalog = loadCatalog();
|
|
30
|
+
let catVer = catalogVersion(catalog);
|
|
31
|
+
process.on("SIGHUP", () => {
|
|
32
|
+
try {
|
|
33
|
+
catalog = loadCatalog();
|
|
34
|
+
catVer = catalogVersion(catalog);
|
|
35
|
+
console.error("[motionspec-mcp] Katalog neu geladen — " + catVer + " (" + Object.keys(catalog).length + " primitives)");
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error("[motionspec-mcp] Katalog-Reload abgelehnt (alter Stand bleibt aktiv): " + e.message);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const server = new McpServer({ name: "motionspec-mcp-server", version: pkg.version });
|
|
42
|
+
registerMotionspecTools(server, { getCatalog: () => catalog, getCatVer: () => catVer });
|
|
43
|
+
|
|
44
|
+
const transport = new StdioServerTransport();
|
|
45
|
+
await server.connect(transport);
|
|
46
|
+
console.error("[motionspec-mcp] ready — catalog " + catVer + " (" + Object.keys(catalog).length + " primitives)");
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Anfrage->Spec-Cache. Schluessel: sha256(anfrage + katalogVersion + ziel).
|
|
4
|
+
* Wiederkehrende Anfragen kosten beim zweiten Mal nichts — weder Tokens
|
|
5
|
+
* noch Wartezeit. Katalog-Aenderung invalidiert automatisch (Versions-Hash).
|
|
6
|
+
* Ablage: .cache/<key>.json im Repo (gitignored).
|
|
7
|
+
*
|
|
8
|
+
* Audit-Befund #11 (2026-06-12): Hygiene. TTL + LRU-Cap + sweep().
|
|
9
|
+
* Eintraege tragen { spec, savedAt }; abgelaufene/ueberzaehlige werden
|
|
10
|
+
* entfernt. (Cache-Treffer werden zusaetzlich in route.js re-validiert —
|
|
11
|
+
* Befund #2 —, daher ist der Cache nie eine Vertrauensgrenze.)
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const crypto = require("crypto");
|
|
16
|
+
|
|
17
|
+
const CACHE_DIR = path.join(__dirname, "..", "..", ".cache");
|
|
18
|
+
const TTL_MS = 30 * 24 * 60 * 60 * 1000; /* 30 Tage */
|
|
19
|
+
const MAX_ENTRIES = 500;
|
|
20
|
+
|
|
21
|
+
function key(request, catalogVer, target) {
|
|
22
|
+
return crypto
|
|
23
|
+
.createHash("sha256")
|
|
24
|
+
.update(JSON.stringify({ r: request.trim().toLowerCase(), v: catalogVer, t: target }))
|
|
25
|
+
.digest("hex")
|
|
26
|
+
.slice(0, 32);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function get(k) {
|
|
30
|
+
const f = path.join(CACHE_DIR, k + ".json");
|
|
31
|
+
if (!fs.existsSync(f)) return null;
|
|
32
|
+
try {
|
|
33
|
+
const entry = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
34
|
+
if (entry && entry.savedAt && Date.now() - entry.savedAt > TTL_MS) {
|
|
35
|
+
try { fs.unlinkSync(f); } catch { /* ignore */ }
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return entry && entry.spec ? entry.spec : entry; /* Rueckwaerts-kompatibel */
|
|
39
|
+
} catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function set(k, spec) {
|
|
43
|
+
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(CACHE_DIR, k + ".json"), JSON.stringify({ spec, savedAt: Date.now() }, null, 2));
|
|
45
|
+
evict();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* LRU per mtime: aelteste Eintraege entfernen, bis MAX_ENTRIES erreicht. */
|
|
49
|
+
function evict() {
|
|
50
|
+
let files;
|
|
51
|
+
try { files = fs.readdirSync(CACHE_DIR).filter((f) => f.endsWith(".json")); }
|
|
52
|
+
catch { return; }
|
|
53
|
+
if (files.length <= MAX_ENTRIES) return;
|
|
54
|
+
const withTime = files.map((f) => {
|
|
55
|
+
const full = path.join(CACHE_DIR, f);
|
|
56
|
+
let t = 0;
|
|
57
|
+
try { t = fs.statSync(full).mtimeMs; } catch { /* ignore */ }
|
|
58
|
+
return { full, t };
|
|
59
|
+
}).sort((a, b) => a.t - b.t);
|
|
60
|
+
for (const e of withTime.slice(0, withTime.length - MAX_ENTRIES)) {
|
|
61
|
+
try { fs.unlinkSync(e.full); } catch { /* ignore */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Startup-Sweep: abgelaufene Eintraege entfernen. */
|
|
66
|
+
function sweep() {
|
|
67
|
+
let files;
|
|
68
|
+
try { files = fs.readdirSync(CACHE_DIR).filter((f) => f.endsWith(".json")); }
|
|
69
|
+
catch { return 0; }
|
|
70
|
+
let removed = 0;
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
const full = path.join(CACHE_DIR, f);
|
|
73
|
+
try {
|
|
74
|
+
const entry = JSON.parse(fs.readFileSync(full, "utf8"));
|
|
75
|
+
if (entry && entry.savedAt && Date.now() - entry.savedAt > TTL_MS) { fs.unlinkSync(full); removed++; }
|
|
76
|
+
} catch { /* ignore */ }
|
|
77
|
+
}
|
|
78
|
+
return removed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { key, get, set, sweep, evict, CACHE_DIR, TTL_MS, MAX_ENTRIES };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Modell-Clients fuer Stufe A.
|
|
4
|
+
*
|
|
5
|
+
* openAICompatClient — jeder OpenAI-kompatible Endpunkt (OpenRouter,
|
|
6
|
+
* Anthropic-Gateway, lokales vLLM ...). Konfig via Optionen oder Env:
|
|
7
|
+
* MOTION_BASE_URL (default: https://openrouter.ai/api/v1)
|
|
8
|
+
* MOTION_MODEL (default: anthropic/claude-haiku-4.5)
|
|
9
|
+
* MOTION_API_KEY bzw. OPENROUTER_API_KEY
|
|
10
|
+
*
|
|
11
|
+
* mockClient — deterministischer Offline-Client fuer Tests & Demos.
|
|
12
|
+
* Simuliert ein kleines Modell per Keyword-Mapping; kann auf Wunsch
|
|
13
|
+
* beim ersten Versuch einen typischen Modellfehler machen (fuer
|
|
14
|
+
* Repair-Loop-Tests).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function openAICompatClient(opts) {
|
|
18
|
+
const o = opts || {};
|
|
19
|
+
const baseURL = o.baseURL || process.env.MOTION_BASE_URL || "https://openrouter.ai/api/v1";
|
|
20
|
+
const apiKey = o.apiKey || process.env.MOTION_API_KEY || process.env.OPENROUTER_API_KEY;
|
|
21
|
+
const model = o.model || process.env.MOTION_MODEL || "anthropic/claude-haiku-4.5";
|
|
22
|
+
if (!apiKey) throw new Error("Kein API-Key (MOTION_API_KEY / OPENROUTER_API_KEY).");
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
name: model,
|
|
26
|
+
async complete(system, user) {
|
|
27
|
+
const res = await fetch(baseURL.replace(/\/$/, "") + "/chat/completions", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "content-type": "application/json", authorization: "Bearer " + apiKey },
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
model,
|
|
32
|
+
temperature: 0,
|
|
33
|
+
response_format: { type: "json_object" },
|
|
34
|
+
messages: [
|
|
35
|
+
{ role: "system", content: system },
|
|
36
|
+
{ role: "user", content: user },
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) throw new Error("Modell-API " + res.status + ": " + (await res.text()).slice(0, 300));
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
return (data.choices && data.choices[0] && data.choices[0].message.content) || "";
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* ---------------------------------------------------------------- */
|
|
48
|
+
|
|
49
|
+
const KEYWORDS = [
|
|
50
|
+
{ re: /(stagger|nacheinander|gestaffelt|one after|cards?|karten)/i, primitive: "staggerReveal", target: ".features .card", params: { from: { opacity: 0, y: 32 } } },
|
|
51
|
+
{ re: /(parallax|tiefe|depth|layers?|ebenen)/i, primitive: "parallaxLayer", target: ".hero .bg", params: { yPercent: -25 } },
|
|
52
|
+
{ re: /(pin|fixier|sticky|fest)/i, primitive: "pinnedSection", target: ".showcase", params: { distance: "+=100%" } },
|
|
53
|
+
{ re: /(hover|button|maus|cursor)/i, primitive: "cssTransition", target: ".cta-button", params: { hoverValue: "translateY(-4px)" } },
|
|
54
|
+
{ re: /(reveal|einblend|fade|erscheinen|appear|headline|ueberschrift|überschrift|scroll)/i, primitive: "scrollReveal", target: ".hero h1", params: { from: { opacity: 0, y: 48 } } },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function mockClient(opts) {
|
|
58
|
+
const o = opts || {};
|
|
59
|
+
let calls = 0;
|
|
60
|
+
return {
|
|
61
|
+
name: "mock-small-model",
|
|
62
|
+
async complete(system, user) {
|
|
63
|
+
calls++;
|
|
64
|
+
/* Optional: erster Versuch liefert einen typischen Modellfehler */
|
|
65
|
+
if (o.failFirst && calls === 1) {
|
|
66
|
+
return JSON.stringify({
|
|
67
|
+
specVersion: "1.0",
|
|
68
|
+
meta: { target: "vanilla-gsap" },
|
|
69
|
+
motions: [{ id: "bad motion!", primitive: "magicSparkle", target: ".hero" }],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const request = user.includes("Anfrage:") ? user : user;
|
|
73
|
+
const motions = [];
|
|
74
|
+
const used = new Set();
|
|
75
|
+
for (const k of KEYWORDS) {
|
|
76
|
+
if (k.re.test(request) && !used.has(k.primitive)) {
|
|
77
|
+
used.add(k.primitive);
|
|
78
|
+
motions.push({
|
|
79
|
+
id: k.primitive.toLowerCase() + "-" + motions.length,
|
|
80
|
+
primitive: k.primitive,
|
|
81
|
+
target: k.target,
|
|
82
|
+
params: k.params,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (motions.length === 0)
|
|
87
|
+
return JSON.stringify({ escalate: true, reason: "Kein Katalog-Primitiv passt zur Anfrage." });
|
|
88
|
+
return JSON.stringify({
|
|
89
|
+
specVersion: "1.0",
|
|
90
|
+
meta: { project: "request", target: "vanilla-gsap", createdWith: "mock-small-model" },
|
|
91
|
+
globals: { respectReducedMotion: true },
|
|
92
|
+
motions,
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { openAICompatClient, mockClient };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Stufe A — Prompt-Builder
|
|
4
|
+
* Das kleine Modell bekommt: Aufgabe, Schema-Regeln, Katalog (Namen +
|
|
5
|
+
* paramSchemas als Leitplanke) und Few-Shot-Beispiele. Es darf NUR JSON
|
|
6
|
+
* ausgeben. Alles weitere erzwingt die Trust Boundary.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const FEW_SHOT = [
|
|
10
|
+
{
|
|
11
|
+
request: "Die Hero-Überschrift soll beim Scrollen sanft von unten einblenden.",
|
|
12
|
+
spec: {
|
|
13
|
+
specVersion: "1.0",
|
|
14
|
+
meta: { project: "request", target: "vanilla-gsap", createdWith: "router" },
|
|
15
|
+
globals: { respectReducedMotion: true },
|
|
16
|
+
motions: [
|
|
17
|
+
{
|
|
18
|
+
id: "hero-headline",
|
|
19
|
+
primitive: "scrollReveal",
|
|
20
|
+
target: ".hero h1",
|
|
21
|
+
params: { from: { opacity: 0, y: 48 }, duration: 0.8 },
|
|
22
|
+
trigger: { start: "top 80%", once: true },
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
request: "Three feature cards should appear one after another, and the section header pins while you scroll through.",
|
|
29
|
+
spec: {
|
|
30
|
+
specVersion: "1.0",
|
|
31
|
+
meta: { project: "request", target: "vanilla-gsap", createdWith: "router" },
|
|
32
|
+
globals: { respectReducedMotion: true },
|
|
33
|
+
motions: [
|
|
34
|
+
{
|
|
35
|
+
id: "feature-cards",
|
|
36
|
+
primitive: "staggerReveal",
|
|
37
|
+
target: ".features .card",
|
|
38
|
+
params: { from: { opacity: 0, y: 32 }, stagger: 0.12 },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "features-pin",
|
|
42
|
+
primitive: "pinnedSection",
|
|
43
|
+
target: ".features",
|
|
44
|
+
params: { distance: "+=100%" },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function catalogSummary(catalog) {
|
|
52
|
+
return Object.keys(catalog).sort().map((name) => {
|
|
53
|
+
const p = catalog[name];
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
purpose: p.purpose,
|
|
57
|
+
params: p.paramSchema || {},
|
|
58
|
+
triggerDefaults: p.triggerDefaults || {},
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildSystemPrompt(catalog) {
|
|
64
|
+
return [
|
|
65
|
+
"Du bist ein Uebersetzer von natuerlichsprachlichen Bewegungs-Anfragen in MotionSpec-JSON (specVersion 1.0).",
|
|
66
|
+
"Du gibst AUSSCHLIESSLICH ein einzelnes JSON-Objekt aus. Kein Markdown, kein Text davor oder danach.",
|
|
67
|
+
"",
|
|
68
|
+
"HARTE REGELN:",
|
|
69
|
+
'1. "primitive" MUSS einer dieser Katalog-Namen sein: ' + Object.keys(catalog).sort().join(", ") + ". Nichts anderes existiert. Erfinde nie ein Primitiv.",
|
|
70
|
+
"2. Parameter nur aus dem paramSchema des jeweiligen Primitivs; halte min/max ein. Lass Parameter weg, wenn der Default passt.",
|
|
71
|
+
'3. "target" ist ein einfacher CSS-Selektor ohne Quotes/Sonderzeichen (z.B. .hero h1, #cta, .features .card).',
|
|
72
|
+
'4. "id" nur [A-Za-z0-9_-], beschreibend, eindeutig.',
|
|
73
|
+
'5. meta.target ist immer "vanilla-gsap". globals.respectReducedMotion ist immer true.',
|
|
74
|
+
"6. Wenn die Anfrage etwas verlangt, das KEIN Katalog-Primitiv abdeckt, gib stattdessen aus:",
|
|
75
|
+
' {"escalate": true, "reason": "<kurze Begruendung, welches Primitiv fehlt>"}',
|
|
76
|
+
"",
|
|
77
|
+
"KATALOG (Leitplanke):",
|
|
78
|
+
JSON.stringify(catalogSummary(catalog), null, 1),
|
|
79
|
+
"",
|
|
80
|
+
"BEISPIELE:",
|
|
81
|
+
...FEW_SHOT.map(
|
|
82
|
+
(ex) => "Anfrage: " + ex.request + "\nAusgabe: " + JSON.stringify(ex.spec)
|
|
83
|
+
),
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildRepairPrompt(request, badOutput, errors) {
|
|
88
|
+
return [
|
|
89
|
+
"Deine vorige Ausgabe fuer die Anfrage hat die Schema-Validierung NICHT bestanden.",
|
|
90
|
+
"Anfrage: " + request,
|
|
91
|
+
"Deine Ausgabe war:",
|
|
92
|
+
badOutput,
|
|
93
|
+
"Validierungsfehler:",
|
|
94
|
+
...errors.map((e) => "- " + e),
|
|
95
|
+
"Gib jetzt die korrigierte, vollstaendige MotionSpec als einzelnes JSON-Objekt aus. Nur JSON.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { buildSystemPrompt, buildRepairPrompt, catalogSummary, FEW_SHOT };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Modell-Routing (Stufe A) — Anfrage zu Spec. Konzept Kapitel 8.
|
|
4
|
+
*
|
|
5
|
+
* Anfrage -> Cache?
|
|
6
|
+
* -> kleines Modell -> JSON extrahieren -> TRUST BOUNDARY
|
|
7
|
+
* -> bei Fehler: genau EIN Reparatur-Versuch (Fehler als Kontext)
|
|
8
|
+
* -> bei erneutem Fehler oder Selbst-Eskalation des Modells:
|
|
9
|
+
* { escalate: true } — Signal fuer +1 / Katalog-Ausbau.
|
|
10
|
+
*
|
|
11
|
+
* Jede Anfrage erzeugt einen Telemetrie-Messpunkt (Konzept §3.3).
|
|
12
|
+
*/
|
|
13
|
+
const { loadCatalog, catalogVersion } = require("../compiler/catalog.js");
|
|
14
|
+
const { validateSpec } = require("../compiler/validate.js");
|
|
15
|
+
const { buildSystemPrompt, buildRepairPrompt } = require("./prompt.js");
|
|
16
|
+
const cache = require("./cache.js");
|
|
17
|
+
const telemetry = require("./telemetry.js");
|
|
18
|
+
|
|
19
|
+
/* Audit #11: Startup-Sweep einmal pro Prozess — abgelaufene Cache-Eintraege
|
|
20
|
+
* werden beim ersten Routing entfernt (sweep() war zuvor nie aufgerufen). */
|
|
21
|
+
let swept = false;
|
|
22
|
+
function sweepOnce() {
|
|
23
|
+
if (swept) return;
|
|
24
|
+
swept = true;
|
|
25
|
+
try { cache.sweep(); } catch { /* Cache-Hygiene darf Routing nie blockieren */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractJson(text) {
|
|
29
|
+
if (typeof text !== "string") return null;
|
|
30
|
+
const t = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
31
|
+
const start = t.indexOf("{");
|
|
32
|
+
const end = t.lastIndexOf("}");
|
|
33
|
+
if (start === -1 || end <= start) return null;
|
|
34
|
+
try { return JSON.parse(t.slice(start, end + 1)); }
|
|
35
|
+
catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* route(request, { client, catalog, target, noCache }) ->
|
|
40
|
+
* { ok: true, spec, source: "cache"|"model"|"model-repaired", attempts, ms }
|
|
41
|
+
* | { ok: false, escalate: true, reason, errors?, attempts, ms }
|
|
42
|
+
*/
|
|
43
|
+
async function route(request, opts) {
|
|
44
|
+
const o = opts || {};
|
|
45
|
+
const t0 = Date.now();
|
|
46
|
+
const catalog = o.catalog || loadCatalog();
|
|
47
|
+
const catVer = catalogVersion(catalog);
|
|
48
|
+
const target = o.target || "vanilla-gsap";
|
|
49
|
+
const client = o.client;
|
|
50
|
+
if (!client) throw new Error("route(): opts.client fehlt (mockClient oder openAICompatClient).");
|
|
51
|
+
sweepOnce();
|
|
52
|
+
|
|
53
|
+
/* ---- 1. Cache ---- */
|
|
54
|
+
const k = cache.key(request, catVer, target);
|
|
55
|
+
if (!o.noCache) {
|
|
56
|
+
const hit = cache.get(k);
|
|
57
|
+
if (hit) {
|
|
58
|
+
/* Audit-Befund #2 (2026-06-12): Cache-Treffer durchlaufen die Trust
|
|
59
|
+
* Boundary ERNEUT — fail-closed gilt auch fuer manipulierte/.stale
|
|
60
|
+
* Cache-Dateien. Ungueltige Treffer werden verworfen. */
|
|
61
|
+
const vc = validateSpec(hit, catalog);
|
|
62
|
+
if (vc.ok) {
|
|
63
|
+
telemetry.log({ outcome: "cache-hit", key: k, model: null, attempts: 0, ms: Date.now() - t0 });
|
|
64
|
+
return { ok: true, spec: hit, source: "cache", attempts: 0, ms: Date.now() - t0 };
|
|
65
|
+
}
|
|
66
|
+
telemetry.log({ outcome: "cache-invalidated", key: k, model: null, attempts: 0, ms: Date.now() - t0 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ---- 2. Kleines Modell ---- */
|
|
71
|
+
const system = buildSystemPrompt(catalog);
|
|
72
|
+
let raw, parsed, errors = [];
|
|
73
|
+
|
|
74
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
75
|
+
const user = attempt === 1 ? "Anfrage: " + request : buildRepairPrompt(request, raw, errors);
|
|
76
|
+
try { raw = await client.complete(system, user); }
|
|
77
|
+
catch (e) {
|
|
78
|
+
/* Audit-Befund #8: Transportfehler sind Eskalation, keine Exception. */
|
|
79
|
+
telemetry.log({ outcome: "escalate-transport", key: k, model: client.name, attempts: attempt, ms: Date.now() - t0, error: String(e.message).slice(0, 200) });
|
|
80
|
+
return { ok: false, escalate: true, reason: "Modell-Transportfehler: " + String(e.message).slice(0, 200), attempts: attempt, ms: Date.now() - t0 };
|
|
81
|
+
}
|
|
82
|
+
parsed = extractJson(raw);
|
|
83
|
+
|
|
84
|
+
if (!parsed) { errors = ["Ausgabe ist kein parsebares JSON."]; continue; }
|
|
85
|
+
|
|
86
|
+
/* Selbst-Eskalation des Modells: Anfrage liegt ausserhalb des Katalogs */
|
|
87
|
+
if (parsed.escalate === true) {
|
|
88
|
+
telemetry.log({ outcome: "escalate-no-primitive", key: k, model: client.name, attempts: attempt, ms: Date.now() - t0, reason: parsed.reason || null, request: String(request).slice(0, 500) });
|
|
89
|
+
return { ok: false, escalate: true, reason: parsed.reason || "Kein passendes Primitiv im Katalog.", attempts: attempt, ms: Date.now() - t0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ---- 3. TRUST BOUNDARY ---- */
|
|
93
|
+
const v = validateSpec(parsed, catalog);
|
|
94
|
+
if (v.ok) {
|
|
95
|
+
cache.set(k, parsed);
|
|
96
|
+
const source = attempt === 1 ? "model" : "model-repaired";
|
|
97
|
+
telemetry.log({ outcome: source, key: k, model: client.name, attempts: attempt, ms: Date.now() - t0 });
|
|
98
|
+
return { ok: true, spec: parsed, source, attempts: attempt, ms: Date.now() - t0 };
|
|
99
|
+
}
|
|
100
|
+
errors = v.errors;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ---- 4. Eskalation nach gescheitertem Repair ---- */
|
|
104
|
+
telemetry.log({ outcome: "escalate-invalid", key: k, model: client.name, attempts: 2, ms: Date.now() - t0, errors, request: String(request).slice(0, 500) });
|
|
105
|
+
return { ok: false, escalate: true, reason: "Spec auch nach Reparatur-Versuch ungueltig.", errors, attempts: 2, ms: Date.now() - t0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { route, extractJson };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Telemetrie-Sink — Speicher-Abstraktion fuer die Telemetrie (Phase C, C1).
|
|
4
|
+
* Trennt das WIE-gespeichert-wird vom WAS-gemessen-wird: telemetry.js macht
|
|
5
|
+
* Clamping + Aggregation, der Sink nur I/O. Dadurch laeuft dieselbe
|
|
6
|
+
* Telemetrie-Logik lokal (FileSink, JSONL) wie spaeter auf einem Cloudflare
|
|
7
|
+
* Worker (AnalyticsEngineSink) — eine Quelle, zwei Backends.
|
|
8
|
+
* Sink-Vertrag: append(record) -> void ; readAll() -> record[].
|
|
9
|
+
*/
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
class FileSink {
|
|
14
|
+
constructor(file) {
|
|
15
|
+
this.file = file;
|
|
16
|
+
this._dirReady = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_ensureDir() {
|
|
20
|
+
if (this._dirReady) return;
|
|
21
|
+
const dir = path.dirname(this.file);
|
|
22
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
this._dirReady = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Append-only JSONL — ein Record pro Zeile. */
|
|
27
|
+
append(record) {
|
|
28
|
+
this._ensureDir();
|
|
29
|
+
fs.appendFileSync(this.file, JSON.stringify(record) + "\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Alle Records; fehlende Datei -> []; kaputte Zeilen werden uebersprungen. */
|
|
33
|
+
readAll() {
|
|
34
|
+
if (!fs.existsSync(this.file)) return [];
|
|
35
|
+
const lines = fs.readFileSync(this.file, "utf8").trim().split("\n").filter(Boolean);
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const l of lines) {
|
|
38
|
+
try { out.push(JSON.parse(l)); } catch { /* kaputte Zeile ignorieren */ }
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* MemorySink — fluechtiger In-Memory-Sink ohne fs (Phase C / C3). Der Worker
|
|
45
|
+
* nutzt ihn als Default, damit telemetry.log auf workerd nicht in die (read-only)
|
|
46
|
+
* fs greift; C4 ersetzt ihn durch den AnalyticsEngineSink (durable). */
|
|
47
|
+
class MemorySink {
|
|
48
|
+
constructor() { this._records = []; }
|
|
49
|
+
append(record) { this._records.push(record); }
|
|
50
|
+
readAll() { return this._records.slice(); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { FileSink, MemorySink };
|