@ambitresearch/genie 1.3.1
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/README.md +15 -0
- package/dist/auth/bearer.d.ts +49 -0
- package/dist/auth/bearer.d.ts.map +1 -0
- package/dist/auth/bearer.js +159 -0
- package/dist/auth/bearer.js.map +1 -0
- package/dist/auth/oauth/authorize.d.ts +19 -0
- package/dist/auth/oauth/authorize.d.ts.map +1 -0
- package/dist/auth/oauth/authorize.js +111 -0
- package/dist/auth/oauth/authorize.js.map +1 -0
- package/dist/auth/oauth/config.d.ts +10 -0
- package/dist/auth/oauth/config.d.ts.map +1 -0
- package/dist/auth/oauth/config.js +16 -0
- package/dist/auth/oauth/config.js.map +1 -0
- package/dist/auth/oauth/dcr.d.ts +15 -0
- package/dist/auth/oauth/dcr.d.ts.map +1 -0
- package/dist/auth/oauth/dcr.js +38 -0
- package/dist/auth/oauth/dcr.js.map +1 -0
- package/dist/auth/oauth/jwt.d.ts +13 -0
- package/dist/auth/oauth/jwt.d.ts.map +1 -0
- package/dist/auth/oauth/jwt.js +46 -0
- package/dist/auth/oauth/jwt.js.map +1 -0
- package/dist/auth/oauth/metadata.d.ts +9 -0
- package/dist/auth/oauth/metadata.d.ts.map +1 -0
- package/dist/auth/oauth/metadata.js +19 -0
- package/dist/auth/oauth/metadata.js.map +1 -0
- package/dist/auth/oauth/router.d.ts +32 -0
- package/dist/auth/oauth/router.d.ts.map +1 -0
- package/dist/auth/oauth/router.js +159 -0
- package/dist/auth/oauth/router.js.map +1 -0
- package/dist/auth/oauth/store.d.ts +55 -0
- package/dist/auth/oauth/store.d.ts.map +1 -0
- package/dist/auth/oauth/store.js +63 -0
- package/dist/auth/oauth/store.js.map +1 -0
- package/dist/auth/oauth/token.d.ts +21 -0
- package/dist/auth/oauth/token.d.ts.map +1 -0
- package/dist/auth/oauth/token.js +77 -0
- package/dist/auth/oauth/token.js.map +1 -0
- package/dist/auth/oidc/group-policy.d.ts +43 -0
- package/dist/auth/oidc/group-policy.d.ts.map +1 -0
- package/dist/auth/oidc/group-policy.js +54 -0
- package/dist/auth/oidc/group-policy.js.map +1 -0
- package/dist/auth/oidc/verifier.d.ts +42 -0
- package/dist/auth/oidc/verifier.d.ts.map +1 -0
- package/dist/auth/oidc/verifier.js +145 -0
- package/dist/auth/oidc/verifier.js.map +1 -0
- package/dist/auth/token-cli.d.ts +9 -0
- package/dist/auth/token-cli.d.ts.map +1 -0
- package/dist/auth/token-cli.js +98 -0
- package/dist/auth/token-cli.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +149 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/redact.d.ts +20 -0
- package/dist/config/redact.d.ts.map +1 -0
- package/dist/config/redact.js +77 -0
- package/dist/config/redact.js.map +1 -0
- package/dist/config/secrets.d.ts +100 -0
- package/dist/config/secrets.d.ts.map +1 -0
- package/dist/config/secrets.js +130 -0
- package/dist/config/secrets.js.map +1 -0
- package/dist/framework/html.d.ts +103 -0
- package/dist/framework/html.d.ts.map +1 -0
- package/dist/framework/html.js +271 -0
- package/dist/framework/html.js.map +1 -0
- package/dist/framework/interface.d.ts +130 -0
- package/dist/framework/interface.d.ts.map +1 -0
- package/dist/framework/interface.js +112 -0
- package/dist/framework/interface.js.map +1 -0
- package/dist/framework/react.d.ts +133 -0
- package/dist/framework/react.d.ts.map +1 -0
- package/dist/framework/react.js +243 -0
- package/dist/framework/react.js.map +1 -0
- package/dist/framework/vue.d.ts +99 -0
- package/dist/framework/vue.d.ts.map +1 -0
- package/dist/framework/vue.js +375 -0
- package/dist/framework/vue.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/client.d.ts +81 -0
- package/dist/llm/client.d.ts.map +1 -0
- package/dist/llm/client.js +141 -0
- package/dist/llm/client.js.map +1 -0
- package/dist/llm/component-response.d.ts +111 -0
- package/dist/llm/component-response.d.ts.map +1 -0
- package/dist/llm/component-response.js +167 -0
- package/dist/llm/component-response.js.map +1 -0
- package/dist/llm/generated-files.d.ts +10 -0
- package/dist/llm/generated-files.d.ts.map +1 -0
- package/dist/llm/generated-files.js +26 -0
- package/dist/llm/generated-files.js.map +1 -0
- package/dist/llm/prompts/CHANGELOG.md +70 -0
- package/dist/llm/prompts/generate-component.system.md +99 -0
- package/dist/llm/prompts/refine-component.system.md +112 -0
- package/dist/llm/prompts.d.ts +66 -0
- package/dist/llm/prompts.d.ts.map +1 -0
- package/dist/llm/prompts.js +78 -0
- package/dist/llm/prompts.js.map +1 -0
- package/dist/llm/retry.d.ts +183 -0
- package/dist/llm/retry.d.ts.map +1 -0
- package/dist/llm/retry.js +475 -0
- package/dist/llm/retry.js.map +1 -0
- package/dist/llm/schema.d.ts +129 -0
- package/dist/llm/schema.d.ts.map +1 -0
- package/dist/llm/schema.js +147 -0
- package/dist/llm/schema.js.map +1 -0
- package/dist/llm/validate.d.ts +107 -0
- package/dist/llm/validate.d.ts.map +1 -0
- package/dist/llm/validate.js +174 -0
- package/dist/llm/validate.js.map +1 -0
- package/dist/manifest/compiler.d.ts +112 -0
- package/dist/manifest/compiler.d.ts.map +1 -0
- package/dist/manifest/compiler.js +306 -0
- package/dist/manifest/compiler.js.map +1 -0
- package/dist/manifest/ensure.d.ts +46 -0
- package/dist/manifest/ensure.d.ts.map +1 -0
- package/dist/manifest/ensure.js +49 -0
- package/dist/manifest/ensure.js.map +1 -0
- package/dist/manifest/index.d.ts +13 -0
- package/dist/manifest/index.d.ts.map +1 -0
- package/dist/manifest/index.js +13 -0
- package/dist/manifest/index.js.map +1 -0
- package/dist/middleware/plan-guard.d.ts +167 -0
- package/dist/middleware/plan-guard.d.ts.map +1 -0
- package/dist/middleware/plan-guard.js +190 -0
- package/dist/middleware/plan-guard.js.map +1 -0
- package/dist/plans/index.d.ts +71 -0
- package/dist/plans/index.d.ts.map +1 -0
- package/dist/plans/index.js +252 -0
- package/dist/plans/index.js.map +1 -0
- package/dist/schemas/component.schema.json +114 -0
- package/dist/server.d.ts +96 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +258 -0
- package/dist/server.js.map +1 -0
- package/dist/store/empty-manifest.d.ts +58 -0
- package/dist/store/empty-manifest.d.ts.map +1 -0
- package/dist/store/empty-manifest.js +59 -0
- package/dist/store/empty-manifest.js.map +1 -0
- package/dist/store/git-host.d.ts +120 -0
- package/dist/store/git-host.d.ts.map +1 -0
- package/dist/store/git-host.js +796 -0
- package/dist/store/git-host.js.map +1 -0
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +4 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/interface.d.ts +322 -0
- package/dist/store/interface.d.ts.map +1 -0
- package/dist/store/interface.js +100 -0
- package/dist/store/interface.js.map +1 -0
- package/dist/store/kit-files.d.ts +88 -0
- package/dist/store/kit-files.d.ts.map +1 -0
- package/dist/store/kit-files.js +199 -0
- package/dist/store/kit-files.js.map +1 -0
- package/dist/store/local.d.ts +70 -0
- package/dist/store/local.d.ts.map +1 -0
- package/dist/store/local.js +651 -0
- package/dist/store/local.js.map +1 -0
- package/dist/store/manifest.d.ts +44 -0
- package/dist/store/manifest.d.ts.map +1 -0
- package/dist/store/manifest.js +116 -0
- package/dist/store/manifest.js.map +1 -0
- package/dist/store/viewer-assets.d.ts +20 -0
- package/dist/store/viewer-assets.d.ts.map +1 -0
- package/dist/store/viewer-assets.js +118 -0
- package/dist/store/viewer-assets.js.map +1 -0
- package/dist/sync/anchor.d.ts +125 -0
- package/dist/sync/anchor.d.ts.map +1 -0
- package/dist/sync/anchor.js +210 -0
- package/dist/sync/anchor.js.map +1 -0
- package/dist/sync/index.d.ts +15 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +15 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/orchestrator.d.ts +160 -0
- package/dist/sync/orchestrator.d.ts.map +1 -0
- package/dist/sync/orchestrator.js +288 -0
- package/dist/sync/orchestrator.js.map +1 -0
- package/dist/tools/bind_kit.d.ts +29 -0
- package/dist/tools/bind_kit.d.ts.map +1 -0
- package/dist/tools/bind_kit.js +70 -0
- package/dist/tools/bind_kit.js.map +1 -0
- package/dist/tools/conjure.d.ts +141 -0
- package/dist/tools/conjure.d.ts.map +1 -0
- package/dist/tools/conjure.js +655 -0
- package/dist/tools/conjure.js.map +1 -0
- package/dist/tools/conjure_screen.d.ts +174 -0
- package/dist/tools/conjure_screen.d.ts.map +1 -0
- package/dist/tools/conjure_screen.js +458 -0
- package/dist/tools/conjure_screen.js.map +1 -0
- package/dist/tools/create_kit.d.ts +32 -0
- package/dist/tools/create_kit.d.ts.map +1 -0
- package/dist/tools/create_kit.js +131 -0
- package/dist/tools/create_kit.js.map +1 -0
- package/dist/tools/create_project.d.ts +267 -0
- package/dist/tools/create_project.d.ts.map +1 -0
- package/dist/tools/create_project.js +546 -0
- package/dist/tools/create_project.js.map +1 -0
- package/dist/tools/delete_files.d.ts +93 -0
- package/dist/tools/delete_files.d.ts.map +1 -0
- package/dist/tools/delete_files.js +276 -0
- package/dist/tools/delete_files.js.map +1 -0
- package/dist/tools/delete_project.d.ts +43 -0
- package/dist/tools/delete_project.d.ts.map +1 -0
- package/dist/tools/delete_project.js +118 -0
- package/dist/tools/delete_project.js.map +1 -0
- package/dist/tools/get_kit.d.ts +28 -0
- package/dist/tools/get_kit.d.ts.map +1 -0
- package/dist/tools/get_kit.js +95 -0
- package/dist/tools/get_kit.js.map +1 -0
- package/dist/tools/get_project.d.ts +37 -0
- package/dist/tools/get_project.d.ts.map +1 -0
- package/dist/tools/get_project.js +71 -0
- package/dist/tools/get_project.js.map +1 -0
- package/dist/tools/list_components.d.ts +42 -0
- package/dist/tools/list_components.d.ts.map +1 -0
- package/dist/tools/list_components.js +125 -0
- package/dist/tools/list_components.js.map +1 -0
- package/dist/tools/list_files.d.ts +32 -0
- package/dist/tools/list_files.d.ts.map +1 -0
- package/dist/tools/list_files.js +109 -0
- package/dist/tools/list_files.js.map +1 -0
- package/dist/tools/list_kits.d.ts +14 -0
- package/dist/tools/list_kits.d.ts.map +1 -0
- package/dist/tools/list_kits.js +41 -0
- package/dist/tools/list_kits.js.map +1 -0
- package/dist/tools/list_projects.d.ts +23 -0
- package/dist/tools/list_projects.d.ts.map +1 -0
- package/dist/tools/list_projects.js +66 -0
- package/dist/tools/list_projects.js.map +1 -0
- package/dist/tools/plan.d.ts +16 -0
- package/dist/tools/plan.d.ts.map +1 -0
- package/dist/tools/plan.js +146 -0
- package/dist/tools/plan.js.map +1 -0
- package/dist/tools/preview.d.ts +289 -0
- package/dist/tools/preview.d.ts.map +1 -0
- package/dist/tools/preview.js +672 -0
- package/dist/tools/preview.js.map +1 -0
- package/dist/tools/read_file.d.ts +40 -0
- package/dist/tools/read_file.d.ts.map +1 -0
- package/dist/tools/read_file.js +114 -0
- package/dist/tools/read_file.js.map +1 -0
- package/dist/tools/refine.d.ts +220 -0
- package/dist/tools/refine.d.ts.map +1 -0
- package/dist/tools/refine.js +501 -0
- package/dist/tools/refine.js.map +1 -0
- package/dist/tools/validate.d.ts +74 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +224 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/tools/write_files.d.ts +158 -0
- package/dist/tools/write_files.d.ts.map +1 -0
- package/dist/tools/write_files.js +541 -0
- package/dist/tools/write_files.js.map +1 -0
- package/dist/transport.d.ts +77 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +448 -0
- package/dist/transport.js.map +1 -0
- package/dist/ui/card-asset-broker.d.ts +42 -0
- package/dist/ui/card-asset-broker.d.ts.map +1 -0
- package/dist/ui/card-asset-broker.js +479 -0
- package/dist/ui/card-asset-broker.js.map +1 -0
- package/dist/ui/grid-resource.d.ts +252 -0
- package/dist/ui/grid-resource.d.ts.map +1 -0
- package/dist/ui/grid-resource.js +680 -0
- package/dist/ui/grid-resource.js.map +1 -0
- package/dist/ui/viewer-static/index.html +95 -0
- package/dist/ui/viewer-static/viewer.css +451 -0
- package/dist/ui/viewer-static/viewer.js +1405 -0
- package/dist/validate/full-scan.d.ts +87 -0
- package/dist/validate/full-scan.d.ts.map +1 -0
- package/dist/validate/full-scan.js +299 -0
- package/dist/validate/full-scan.js.map +1 -0
- package/dist/validate/index.d.ts +15 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +18 -0
- package/dist/validate/index.js.map +1 -0
- package/dist/validate/marker.d.ts +89 -0
- package/dist/validate/marker.d.ts.map +1 -0
- package/dist/validate/marker.js +87 -0
- package/dist/validate/marker.js.map +1 -0
- package/dist/validate/phash.d.ts +135 -0
- package/dist/validate/phash.d.ts.map +1 -0
- package/dist/validate/phash.js +307 -0
- package/dist/validate/phash.js.map +1 -0
- package/dist/validate/render.d.ts +38 -0
- package/dist/validate/render.d.ts.map +1 -0
- package/dist/validate/render.js +155 -0
- package/dist/validate/render.js.map +1 -0
- package/dist/watch/index.d.ts +12 -0
- package/dist/watch/index.d.ts.map +1 -0
- package/dist/watch/index.js +12 -0
- package/dist/watch/index.js.map +1 -0
- package/dist/watch/watcher.d.ts +65 -0
- package/dist/watch/watcher.d.ts.map +1 -0
- package/dist/watch/watcher.js +238 -0
- package/dist/watch/watcher.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: conjure (M2-03 · DRO-250) — genie's headline verb.
|
|
3
|
+
*
|
|
4
|
+
* Takes a natural-language prompt (plus an optional reference image / page) and
|
|
5
|
+
* returns a single UI component as the file set defined by `COMPONENT_SCHEMA`
|
|
6
|
+
* (M2-02). This is the real generation path: it calls the operator's configured
|
|
7
|
+
* OpenAI-compatible endpoint through the M2-01 client, demanding structured
|
|
8
|
+
* output via `response_format: { type: "json_schema", … }`, validates the reply
|
|
9
|
+
* against `COMPONENT_SCHEMA` with Ajv, and retries once with the validation
|
|
10
|
+
* error fed back on failure.
|
|
11
|
+
*
|
|
12
|
+
* `conjure` is deliberately **pure generation** (AC9): it never calls
|
|
13
|
+
* `write_files`. It hands the caller a validated component; committing it to a
|
|
14
|
+
* kit is a separate, plan-gated step the caller owns. That keeps generation
|
|
15
|
+
* free of side effects and testable without a store.
|
|
16
|
+
*
|
|
17
|
+
* Two DISTINCT retry mechanisms are in play here, at different layers — don't
|
|
18
|
+
* conflate them when reading this file:
|
|
19
|
+
* 1. The "retries once on validation failure" above is the shared
|
|
20
|
+
* `component-response.ts` harness's schema-repair loop (AC8): a
|
|
21
|
+
* structurally-valid-but-wrong-shape reply gets one more attempt with the
|
|
22
|
+
* Ajv error fed back into the prompt.
|
|
23
|
+
* 2. Each of those (up to 2) calls individually goes through M2-06's
|
|
24
|
+
* `withRetry(createChatCompletion)` (DRO-253, see `defaultChatCompletion`
|
|
25
|
+
* below) for transient network/429/5xx failures — invisible to this
|
|
26
|
+
* file's own logic and to the shared harness, handled entirely inside the
|
|
27
|
+
* `chat` seam.
|
|
28
|
+
*
|
|
29
|
+
* ── Import-time safety ────────────────────────────────────────────────────────
|
|
30
|
+
* `../llm/client.js` constructs its `llmClient` singleton eagerly at module load
|
|
31
|
+
* and throws `MissingLLMConfigError` when `GENIE_LLM_*` env vars are unset (M2-01
|
|
32
|
+
* AC1/AC2). If this module imported it *statically*, merely building the server
|
|
33
|
+
* (`createServer()`) — which CI does with no LLM endpoint configured — would
|
|
34
|
+
* throw. So the client is a **type-only** import here (erased by
|
|
35
|
+
* `verbatimModuleSyntax`), and the default runtime path reaches it via a lazy
|
|
36
|
+
* `await import(...)` inside `defaultChatCompletion`, touched only when an actual
|
|
37
|
+
* `conjure` call runs. Tests inject their own `chat` and never load the client.
|
|
38
|
+
* (`../llm/retry.js`'s `withRetry`, by contrast, has no such eager side effect —
|
|
39
|
+
* it's a plain higher-order function — so it's imported statically below and
|
|
40
|
+
* applied inside that same lazy seam.)
|
|
41
|
+
*
|
|
42
|
+
* §6 honest uncertainty (from the issue): the exact prompt shape / generation
|
|
43
|
+
* loop is unspecified R&D. The system prompt (`prompts/generate-component.system.md`,
|
|
44
|
+
* versioned by git-blob hash, AC5) is where that iteration lives; this file is the
|
|
45
|
+
* stable request/validate/retry harness around it.
|
|
46
|
+
*/
|
|
47
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
48
|
+
import ipaddr from "ipaddr.js";
|
|
49
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
50
|
+
import { z } from "zod";
|
|
51
|
+
import {} from "../llm/schema.js";
|
|
52
|
+
import { GENERATE_COMPONENT_SYSTEM_PROMPT_FILE, loadPrompt, } from "../llm/prompts.js";
|
|
53
|
+
// The shared request/validate/retry harness (M2-03/M2-04). The Ajv compile,
|
|
54
|
+
// fence-stripper, response_format envelope, retry-feedback wording, and the
|
|
55
|
+
// two-attempt loop all live here so `conjure` and `refine` share ONE copy —
|
|
56
|
+
// see llm/component-response.ts's header.
|
|
57
|
+
import { appendRetryFeedback, logStderr, runComponentGeneration, } from "../llm/component-response.js";
|
|
58
|
+
// Value import (not type-only): `retry.ts` has no import-time side effects —
|
|
59
|
+
// unlike `client.ts`, it never touches `GENIE_LLM_*` env or constructs
|
|
60
|
+
// anything eagerly at module load — so wrapping `defaultChatCompletion`
|
|
61
|
+
// below in `withRetry` is safe to do statically (Copilot review on PR #126 /
|
|
62
|
+
// DRO-253: this file's docstring claimed conjure "calls the endpoint through
|
|
63
|
+
// the M2-01 client" via `withRetry(createChatCompletion)`, but no production
|
|
64
|
+
// call site actually applied the wrapper — every future generation verb
|
|
65
|
+
// would have silently needed to remember to wrap it manually).
|
|
66
|
+
import { withRetry } from "../llm/retry.js";
|
|
67
|
+
import { normalizeGeneratedFiles, validateGeneratedBinaryContent, } from "../llm/generated-files.js";
|
|
68
|
+
// Framework adapter seam (M2-08 · DRO-255). `conjure` picks the adapter from its
|
|
69
|
+
// `framework` input (AC4) and reads the adapter's `promptDirective` — the one
|
|
70
|
+
// framework-specific bit generation carries. `interface.js` has no heavy imports
|
|
71
|
+
// at module top (esbuild/ts-morph load lazily inside the React adapter, reached
|
|
72
|
+
// only via `getAdapter`'s dynamic import), so this static import is server-build
|
|
73
|
+
// safe. The framework enum + default live in the adapter module as the single
|
|
74
|
+
// source of truth; `conjure` re-exports them under their established names.
|
|
75
|
+
import { FRAMEWORKS, DEFAULT_FRAMEWORK as ADAPTER_DEFAULT_FRAMEWORK, getAdapter, } from "../framework/interface.js";
|
|
76
|
+
import { KIT_ID_PATTERN } from "./get_kit.js";
|
|
77
|
+
export const CONJURE_TOOL_NAME = "mcp__genie__conjure";
|
|
78
|
+
/** Target framework for the generated component (AC2/AC3). Re-exported from the
|
|
79
|
+
* M2-08 adapter module so the tool contract and the adapter registry share one
|
|
80
|
+
* source of truth (`framework/interface.ts#FRAMEWORKS`). */
|
|
81
|
+
export const CONJURE_FRAMEWORKS = FRAMEWORKS;
|
|
82
|
+
/** Default framework + model routing alias (AC3). `design-default` is resolved
|
|
83
|
+
* to a concrete provider model by the configured endpoint/gateway (M2-05). */
|
|
84
|
+
export const DEFAULT_FRAMEWORK = ADAPTER_DEFAULT_FRAMEWORK;
|
|
85
|
+
export const DEFAULT_MODEL = "design-default";
|
|
86
|
+
/** `refUrl` bodies larger than this are warned about (AC7) and truncated before
|
|
87
|
+
* inlining, so a giant page can't blow the request's token budget. */
|
|
88
|
+
export const REF_URL_WARN_BYTES = 1024 * 1024; // 1 MB
|
|
89
|
+
const REF_URL_FETCH_TIMEOUT_MS = 30_000;
|
|
90
|
+
const defaultAddressLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true });
|
|
91
|
+
function parseIpLiteral(host) {
|
|
92
|
+
const unbracketed = host.startsWith("[") ? host.replace(/^\[|\]$/g, "") : host;
|
|
93
|
+
try {
|
|
94
|
+
return ipaddr.process(unbracketed);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Return a canonical address only for globally routable unicast IPs. Using a
|
|
101
|
+
* real IP parser avoids textual-prefix gaps such as IPv4-mapped loopback
|
|
102
|
+
* (`::ffff:127.0.0.1`), the full IPv6 link-local `/10`, and multicast or
|
|
103
|
+
* unspecified addresses. */
|
|
104
|
+
function asSafeAddress(host) {
|
|
105
|
+
const parsed = parseIpLiteral(host);
|
|
106
|
+
if (parsed === undefined || parsed.range() !== "unicast")
|
|
107
|
+
return undefined;
|
|
108
|
+
return {
|
|
109
|
+
address: parsed.toString(),
|
|
110
|
+
family: parsed.kind() === "ipv4" ? 4 : 6,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function isSafeRefUrl(raw) {
|
|
114
|
+
let url;
|
|
115
|
+
try {
|
|
116
|
+
url = new URL(raw);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
122
|
+
return false;
|
|
123
|
+
if (url.username !== "" || url.password !== "")
|
|
124
|
+
return false;
|
|
125
|
+
const host = url.hostname.toLowerCase();
|
|
126
|
+
// Named internal hosts.
|
|
127
|
+
if (host === "localhost" || host === "ip6-localhost" || host.endsWith(".localhost"))
|
|
128
|
+
return false;
|
|
129
|
+
if (host === "" || host === "[::1]" || host === "::1")
|
|
130
|
+
return false;
|
|
131
|
+
// Any literal must be globally routable unicast. Hostnames are resolved and
|
|
132
|
+
// subjected to the same rule immediately before the pinned connection.
|
|
133
|
+
if (parseIpLiteral(host) !== undefined && asSafeAddress(host) === undefined)
|
|
134
|
+
return false;
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve `hostname` and return only the addresses that pass the
|
|
139
|
+
* private/loopback/link-local/CGNAT check, or `undefined` if none do (all
|
|
140
|
+
* addresses unsafe, or the lookup failed). Exported so both the fetch-time
|
|
141
|
+
* guard and its regression tests share one resolution path.
|
|
142
|
+
*
|
|
143
|
+
* DNS-rebinding guard: {@link isSafeRefUrl} is a *syntactic*
|
|
144
|
+
* pre-filter on the hostname as typed — it cannot catch an attacker-controlled
|
|
145
|
+
* DNS name that resolves to a private/loopback/link-local address (the
|
|
146
|
+
* classic SSRF/DNS-rebinding bypass: `evil.example.com` passes the hostname
|
|
147
|
+
* check today and answers `127.0.0.1` at fetch time).
|
|
148
|
+
*/
|
|
149
|
+
async function resolveSafeAddress(hostname, lookupAddresses = defaultAddressLookup) {
|
|
150
|
+
const literal = parseIpLiteral(hostname);
|
|
151
|
+
if (literal !== undefined)
|
|
152
|
+
return asSafeAddress(hostname);
|
|
153
|
+
try {
|
|
154
|
+
const results = await lookupAddresses(hostname);
|
|
155
|
+
for (const { address } of results) {
|
|
156
|
+
const safe = asSafeAddress(address);
|
|
157
|
+
if (safe !== undefined)
|
|
158
|
+
return safe;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Public boolean form of {@link resolveSafeAddress} kept for the existing
|
|
168
|
+
* test surface / any external reuse (M6-03 follow-up). A hostname is "safe"
|
|
169
|
+
* if at least one resolved address is not private/loopback/link-local.
|
|
170
|
+
*/
|
|
171
|
+
export async function isSafeResolvedAddress(hostname, lookupAddresses = defaultAddressLookup) {
|
|
172
|
+
return (await resolveSafeAddress(hostname, lookupAddresses)) !== undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Fetch a single URL whose hostname has already been resolved to a
|
|
176
|
+
* known-safe `address`/`family` (M6-03 follow-up, TOCTOU fix): rather than
|
|
177
|
+
* re-validating a hostname and then letting the HTTP client perform its own,
|
|
178
|
+
* second, independent DNS resolution at connect time — the exact gap an
|
|
179
|
+
* attacker-controlled DNS name (a "rebinding" host that answers safely to
|
|
180
|
+
* the guard and privately to the real connection) can exploit — this pins
|
|
181
|
+
* the *already-validated* address into the connection via undici's
|
|
182
|
+
* `Agent({ connect: { lookup } })` override, so validation and connection
|
|
183
|
+
* are guaranteed to use the identical address. Redirects are fetched with
|
|
184
|
+
* `redirect: "manual"` and returned to the caller un-followed; the caller
|
|
185
|
+
* (`safeFetchFollowingRedirects`) re-runs full validation (schema-level
|
|
186
|
+
* `isSafeRefUrl` + resolved-address check) on every redirect target before
|
|
187
|
+
* following it, so a public URL cannot use a redirect to smuggle a request to
|
|
188
|
+
* a private target past the guard.
|
|
189
|
+
*/
|
|
190
|
+
export async function fetchWithPinnedAddress(url, address, family, timeoutMs = REF_URL_FETCH_TIMEOUT_MS) {
|
|
191
|
+
const pinnedAgent = new Agent({
|
|
192
|
+
connect: {
|
|
193
|
+
lookup: (_hostname, options, callback) => {
|
|
194
|
+
if (options.all) {
|
|
195
|
+
callback(null, [{ address, family }]);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
callback(null, address, family);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
const response = await undiciFetch(url, {
|
|
205
|
+
redirect: "manual",
|
|
206
|
+
dispatcher: pinnedAgent,
|
|
207
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
208
|
+
headers: { "accept-encoding": "identity" },
|
|
209
|
+
});
|
|
210
|
+
const contentEncoding = response.headers.get("content-encoding")?.trim().toLowerCase();
|
|
211
|
+
if (contentEncoding && contentEncoding !== "identity") {
|
|
212
|
+
await response.body?.cancel();
|
|
213
|
+
throw new Error("Encoded reference responses are not supported");
|
|
214
|
+
}
|
|
215
|
+
const isRedirect = response.status >= 300 && response.status < 400;
|
|
216
|
+
if (isRedirect || !response.ok) {
|
|
217
|
+
await response.body?.cancel();
|
|
218
|
+
return {
|
|
219
|
+
status: response.status,
|
|
220
|
+
ok: response.ok,
|
|
221
|
+
headers: response.headers,
|
|
222
|
+
bodyTruncated: false,
|
|
223
|
+
observedBodyBytes: 0,
|
|
224
|
+
text: async () => "",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const reader = response.body?.getReader();
|
|
228
|
+
const chunks = [];
|
|
229
|
+
let observedBodyBytes = 0;
|
|
230
|
+
let bodyTruncated = false;
|
|
231
|
+
if (reader) {
|
|
232
|
+
try {
|
|
233
|
+
while (observedBodyBytes <= REF_URL_WARN_BYTES) {
|
|
234
|
+
const { done, value } = await reader.read();
|
|
235
|
+
if (done)
|
|
236
|
+
break;
|
|
237
|
+
const remaining = REF_URL_WARN_BYTES + 1 - observedBodyBytes;
|
|
238
|
+
chunks.push(Buffer.from(value.subarray(0, remaining)));
|
|
239
|
+
observedBodyBytes += Math.min(value.byteLength, remaining);
|
|
240
|
+
if (value.byteLength > remaining || observedBodyBytes > REF_URL_WARN_BYTES) {
|
|
241
|
+
bodyTruncated = true;
|
|
242
|
+
await reader.cancel();
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
reader.releaseLock();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const body = truncateUtf8Buffer(Buffer.concat(chunks), REF_URL_WARN_BYTES);
|
|
252
|
+
return {
|
|
253
|
+
status: response.status,
|
|
254
|
+
ok: response.ok,
|
|
255
|
+
headers: response.headers,
|
|
256
|
+
bodyTruncated,
|
|
257
|
+
observedBodyBytes,
|
|
258
|
+
text: async () => body,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
await pinnedAgent.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const MAX_REF_URL_REDIRECTS = 5;
|
|
266
|
+
async function runBeforeDeadline(deadlineMs, operation) {
|
|
267
|
+
const remainingMs = deadlineMs - Date.now();
|
|
268
|
+
if (remainingMs <= 0)
|
|
269
|
+
throw new Error("Reference URL fetch timed out");
|
|
270
|
+
let timeout;
|
|
271
|
+
try {
|
|
272
|
+
return await Promise.race([
|
|
273
|
+
operation(remainingMs),
|
|
274
|
+
new Promise((_resolve, reject) => {
|
|
275
|
+
timeout = setTimeout(() => reject(new Error("Reference URL fetch timed out")), remainingMs);
|
|
276
|
+
}),
|
|
277
|
+
]);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
clearTimeout(timeout);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Fetch `refUrl`, following redirects manually and re-validating every hop
|
|
285
|
+
* (M6-03 follow-up — closes both blocking gaps from the security-audit
|
|
286
|
+
* re-review): each hop must (1) pass the schema-level `isSafeRefUrl` check —
|
|
287
|
+
* scheme + syntactic private-range rules — and (2) resolve to a non-private
|
|
288
|
+
* address, which is then pinned into the actual connection via
|
|
289
|
+
* {@link fetchWithPinnedAddress} so the validated and connected addresses can
|
|
290
|
+
* never diverge. A public URL that redirects to a private target (loopback,
|
|
291
|
+
* link-local, cloud metadata, or another rebinding host) is rejected at the
|
|
292
|
+
* first unsafe hop rather than silently followed by the HTTP client's own
|
|
293
|
+
* redirect handling.
|
|
294
|
+
*/
|
|
295
|
+
async function safeFetchFollowingRedirects(fetchImpl, fetchPinnedAddress, startUrl, lookupAddresses, timeoutMs) {
|
|
296
|
+
const deadlineMs = Date.now() + timeoutMs;
|
|
297
|
+
let currentUrl = startUrl;
|
|
298
|
+
for (let hop = 0; hop <= MAX_REF_URL_REDIRECTS; hop++) {
|
|
299
|
+
if (!isSafeRefUrl(currentUrl)) {
|
|
300
|
+
logStderr({ event: "conjure.ref_url.ssrf_blocked", startUrl, hop });
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
const hostname = new URL(currentUrl).hostname;
|
|
304
|
+
const resolved = await runBeforeDeadline(deadlineMs, () => resolveSafeAddress(hostname, lookupAddresses));
|
|
305
|
+
if (!resolved) {
|
|
306
|
+
logStderr({ event: "conjure.ref_url.ssrf_blocked", refUrl: currentUrl, startUrl, hop });
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
const res = await runBeforeDeadline(deadlineMs, (remainingMs) => fetchImpl
|
|
310
|
+
? fetchImpl(currentUrl)
|
|
311
|
+
: fetchPinnedAddress(currentUrl, resolved.address, resolved.family, remainingMs));
|
|
312
|
+
const status = res.status;
|
|
313
|
+
if (status >= 300 && status < 400) {
|
|
314
|
+
const location = res.headers?.get("location") ?? null;
|
|
315
|
+
if (!location) {
|
|
316
|
+
logStderr({ event: "conjure.ref_url.skip", refUrl: currentUrl, status });
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
return res;
|
|
323
|
+
}
|
|
324
|
+
logStderr({ event: "conjure.ref_url.skip", refUrl: startUrl, reason: "too_many_redirects" });
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
/** Reusable Zod schema for `refUrl`: a bounded http(s) URL that passes the SSRF
|
|
328
|
+
* guard. Shared by `conjureArgsSchema` and the MCP tool `inputSchema` so the
|
|
329
|
+
* declared tool contract and runtime validation cannot drift (Copilot review). */
|
|
330
|
+
const refUrlSchema = z
|
|
331
|
+
.string()
|
|
332
|
+
.url()
|
|
333
|
+
.max(2048)
|
|
334
|
+
.refine(isSafeRefUrl, {
|
|
335
|
+
message: "refUrl must be an http(s) URL to a public host — file:/ftp:/data: schemes and " +
|
|
336
|
+
"localhost/private/link-local addresses are rejected (SSRF guard).",
|
|
337
|
+
});
|
|
338
|
+
// ── Input schema (AC2) ────────────────────────────────────────────────────────
|
|
339
|
+
//
|
|
340
|
+
// { kitId, kit, prompt, group?, refImageDataUrl?, refUrl?, framework?, model? }.
|
|
341
|
+
// `kit` is the kit *description* the model matches against (tokens, primitives,
|
|
342
|
+
// house style) — a free-form string, generously bounded. `refImageDataUrl` must
|
|
343
|
+
// be an actual `data:` URL (AC6 attaches it as a vision input); a bare http URL
|
|
344
|
+
// belongs in `refUrl` (AC7) instead.
|
|
345
|
+
//
|
|
346
|
+
// Defined as ONE field map reused by both `conjureArgsSchema` (runtime parse)
|
|
347
|
+
// and the MCP tool `inputSchema` (declared contract), so the two can never drift
|
|
348
|
+
// (Copilot review: keep the tool-boundary schema aligned with runtime validation).
|
|
349
|
+
const conjureInputShape = {
|
|
350
|
+
kitId: z
|
|
351
|
+
.string()
|
|
352
|
+
.regex(KIT_ID_PATTERN, "kitId must be a 3-64 character slug of lowercase letters, numbers, and hyphens."),
|
|
353
|
+
kit: z.string().min(1).max(100_000),
|
|
354
|
+
prompt: z.string().min(3).max(8192),
|
|
355
|
+
group: z
|
|
356
|
+
.string()
|
|
357
|
+
.regex(/^[a-z0-9-]{1,32}$/, "group must be kebab-case: 1-32 chars of [a-z0-9-].")
|
|
358
|
+
.optional(),
|
|
359
|
+
refImageDataUrl: z
|
|
360
|
+
.string()
|
|
361
|
+
.max(8_000_000)
|
|
362
|
+
.regex(/^data:/, "refImageDataUrl must be a data: URL; use refUrl for an http(s) reference.")
|
|
363
|
+
.optional(),
|
|
364
|
+
refUrl: refUrlSchema.optional(),
|
|
365
|
+
framework: z.enum(CONJURE_FRAMEWORKS).default(DEFAULT_FRAMEWORK),
|
|
366
|
+
model: z.string().min(1).max(128).default(DEFAULT_MODEL),
|
|
367
|
+
};
|
|
368
|
+
const conjureArgsSchema = z.object(conjureInputShape).strict();
|
|
369
|
+
/** Typed failure surfaced to the tool boundary (mapped to an MCP error result).
|
|
370
|
+
* One code: an empty reply is just one way output can be invalid, so it folds
|
|
371
|
+
* into `ERR_LLM_OUTPUT_INVALID` rather than being its own code (Copilot review —
|
|
372
|
+
* keep the public error surface to codes this file actually throws). */
|
|
373
|
+
export class ConjureError extends Error {
|
|
374
|
+
code;
|
|
375
|
+
details;
|
|
376
|
+
constructor(code, message, details) {
|
|
377
|
+
super(message);
|
|
378
|
+
this.code = code;
|
|
379
|
+
this.details = details;
|
|
380
|
+
this.name = "ConjureError";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// ── Reference handling ────────────────────────────────────────────────────────
|
|
384
|
+
/**
|
|
385
|
+
* Truncate `text` so its UTF-8 encoding is at most `maxBytes` bytes, cutting on
|
|
386
|
+
* a codepoint boundary (Copilot review: `String.slice` cuts UTF-16 code units,
|
|
387
|
+
* so a non-ASCII page could still blow the byte cap). We encode, slice the byte
|
|
388
|
+
* buffer at the cap, then drop any trailing bytes that form a partial multi-byte
|
|
389
|
+
* sequence so the decoded result carries no U+FFFD replacement char and is
|
|
390
|
+
* guaranteed ≤ `maxBytes`.
|
|
391
|
+
*/
|
|
392
|
+
export function truncateUtf8(text, maxBytes) {
|
|
393
|
+
return truncateUtf8Buffer(Buffer.from(text, "utf-8"), maxBytes);
|
|
394
|
+
}
|
|
395
|
+
function truncateUtf8Buffer(buf, maxBytes) {
|
|
396
|
+
if (buf.length <= maxBytes)
|
|
397
|
+
return buf.toString("utf-8");
|
|
398
|
+
let end = maxBytes;
|
|
399
|
+
// A UTF-8 continuation byte matches 0b10xxxxxx (0x80–0xBF); back up off any
|
|
400
|
+
// partial sequence at the cut so we never split a codepoint.
|
|
401
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80)
|
|
402
|
+
end -= 1;
|
|
403
|
+
return buf.subarray(0, end).toString("utf-8");
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Fetch `refUrl` and return its body for inlining (AC7). Warns (does not throw)
|
|
407
|
+
* when the body exceeds 1 MB and truncates it, so a huge page degrades to a
|
|
408
|
+
* bounded excerpt rather than a runaway request. A network/HTTP failure is
|
|
409
|
+
* warned and skipped — generation proceeds without the reference rather than
|
|
410
|
+
* dying on a flaky link.
|
|
411
|
+
*/
|
|
412
|
+
async function fetchReference(fetchImpl, fetchPinnedAddress, refUrl, lookupAddresses, timeoutMs) {
|
|
413
|
+
try {
|
|
414
|
+
// Re-validate at fetch time against the *resolved* address, pin that exact
|
|
415
|
+
// address into the connection, and re-validate every redirect hop the same
|
|
416
|
+
// way (M6-03 follow-up — closes both the TOCTOU and redirect-bypass gaps
|
|
417
|
+
// from the security-audit re-review). See `safeFetchFollowingRedirects`.
|
|
418
|
+
const res = await safeFetchFollowingRedirects(fetchImpl, fetchPinnedAddress, refUrl, lookupAddresses, timeoutMs);
|
|
419
|
+
if (!res)
|
|
420
|
+
return undefined;
|
|
421
|
+
if (!res.ok) {
|
|
422
|
+
logStderr({ event: "conjure.ref_url.skip", refUrl, status: res.status });
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
const body = await res.text();
|
|
426
|
+
const bytes = Math.max(res.observedBodyBytes ?? 0, Buffer.byteLength(body, "utf-8"));
|
|
427
|
+
if (res.bodyTruncated || bytes > REF_URL_WARN_BYTES) {
|
|
428
|
+
logStderr({ event: "conjure.ref_url.oversize", refUrl, bytes, capBytes: REF_URL_WARN_BYTES });
|
|
429
|
+
return (truncateUtf8(body, REF_URL_WARN_BYTES) + "\n<!-- …reference truncated by genie (>1 MB) -->");
|
|
430
|
+
}
|
|
431
|
+
return body;
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
logStderr({ event: "conjure.ref_url.error", refUrl, error: String(err) });
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// ── Prompt / message assembly ─────────────────────────────────────────────────
|
|
439
|
+
/** Build the natural-language user instruction block (everything except the
|
|
440
|
+
* optional vision image, which is attached as a separate content part).
|
|
441
|
+
* `frameworkDirective` is the target-framework instruction the selected adapter
|
|
442
|
+
* owns (AC4) — it begins with `Target framework: <framework>` and adds the
|
|
443
|
+
* per-framework source-shape guidance. */
|
|
444
|
+
function buildUserText(args, frameworkDirective, referenceHtml) {
|
|
445
|
+
const lines = [
|
|
446
|
+
frameworkDirective,
|
|
447
|
+
args.group
|
|
448
|
+
? `Kit category (group): ${args.group}`
|
|
449
|
+
: `Kit category (group): (choose the best fit)`,
|
|
450
|
+
"",
|
|
451
|
+
"## UI kit",
|
|
452
|
+
args.kit,
|
|
453
|
+
"",
|
|
454
|
+
"## Component to build",
|
|
455
|
+
args.prompt,
|
|
456
|
+
];
|
|
457
|
+
if (args.refImageDataUrl) {
|
|
458
|
+
lines.push("", "## Reference image", "A reference image is attached — match its visual intent.");
|
|
459
|
+
}
|
|
460
|
+
if (referenceHtml !== undefined) {
|
|
461
|
+
lines.push("", `## Reference page (fetched from ${args.refUrl})`, referenceHtml);
|
|
462
|
+
}
|
|
463
|
+
return lines.join("\n");
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Assemble the messages for one attempt. The system prompt is always message 0
|
|
467
|
+
* (AC5). The user message carries the instruction text; if `refImageDataUrl` is
|
|
468
|
+
* set it becomes a content-parts array with a vision `image_url` part alongside
|
|
469
|
+
* the text (AC6 — the real OpenAI/Anthropic vision shape; the AC's
|
|
470
|
+
* `[{type:"image", …}]` sketch is the intent). `retryReason`, when present,
|
|
471
|
+
* appends the prior validation failure so the model can self-correct (AC8).
|
|
472
|
+
*/
|
|
473
|
+
function buildMessages(systemPrompt, args, frameworkDirective, referenceHtml, retry) {
|
|
474
|
+
let userText = buildUserText(args, frameworkDirective, referenceHtml);
|
|
475
|
+
if (retry) {
|
|
476
|
+
userText = appendRetryFeedback(userText, retry);
|
|
477
|
+
}
|
|
478
|
+
const userContent = args.refImageDataUrl
|
|
479
|
+
? [
|
|
480
|
+
{ type: "text", text: userText },
|
|
481
|
+
{ type: "image_url", image_url: { url: args.refImageDataUrl } },
|
|
482
|
+
]
|
|
483
|
+
: userText;
|
|
484
|
+
return [
|
|
485
|
+
{ role: "system", content: systemPrompt },
|
|
486
|
+
{ role: "user", content: userContent },
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
// ── Default (production) chat impl: lazy client import ─────────────────────────
|
|
490
|
+
/**
|
|
491
|
+
* Default `chat` seam: dynamically imports the M2-01 client on first call, so
|
|
492
|
+
* building the server never eagerly triggers `MissingLLMConfigError` (see module
|
|
493
|
+
* header). A real `conjure` call in a properly-configured deployment resolves
|
|
494
|
+
* this to `withRetry(createChatCompletion)` (M2-06, DRO-253) — every
|
|
495
|
+
* production LLM call this tool makes is retry/backoff-wrapped by default;
|
|
496
|
+
* callers (and tests, via `deps.chat`) never need to remember to apply the
|
|
497
|
+
* wrapper themselves.
|
|
498
|
+
*
|
|
499
|
+
* `withRetry` is called fresh on every invocation rather than wrapped once at
|
|
500
|
+
* module scope: the module-scope `createChatCompletion` binding doesn't exist
|
|
501
|
+
* until the dynamic import resolves, and re-wrapping a plain async function
|
|
502
|
+
* is cheap (no state beyond reading `GENIE_LLM_RETRY_MAX` from `process.env`
|
|
503
|
+
* per call, matching every other env read in this codebase's request path).
|
|
504
|
+
*/
|
|
505
|
+
const defaultChatCompletion = async (input) => {
|
|
506
|
+
const { createChatCompletion } = await import("../llm/client.js");
|
|
507
|
+
return withRetry(createChatCompletion)(input);
|
|
508
|
+
};
|
|
509
|
+
// ── Core ──────────────────────────────────────────────────────────────────────
|
|
510
|
+
/**
|
|
511
|
+
* Generate one component. Pure (AC9): validates + returns, never writes.
|
|
512
|
+
*
|
|
513
|
+
* Flow: resolve args → optionally fetch `refUrl` (AC7) → run the shared
|
|
514
|
+
* request/validate/retry loop (build messages +vision AC6, call the endpoint
|
|
515
|
+
* with the json_schema response_format AC4, validate against COMPONENT_SCHEMA
|
|
516
|
+
* AC8, retry ONCE on failure) → return
|
|
517
|
+
* `{ componentName, group, files, manifestEntry, usage }` (AC9) and log a
|
|
518
|
+
* per-call line (AC10). The loop itself lives in `component-response.ts`, shared
|
|
519
|
+
* with `refine` (M2-04).
|
|
520
|
+
*/
|
|
521
|
+
export async function conjure(deps, args) {
|
|
522
|
+
const parsed = conjureArgsSchema.parse(args);
|
|
523
|
+
const chat = deps.chat ?? defaultChatCompletion;
|
|
524
|
+
const fetchImpl = deps.fetchImpl;
|
|
525
|
+
const fetchPinnedAddress = deps.fetchPinnedAddress ?? fetchWithPinnedAddress;
|
|
526
|
+
const lookupAddresses = deps.lookupAddresses ?? defaultAddressLookup;
|
|
527
|
+
const refUrlTimeoutMs = deps.refUrlTimeoutMs ?? REF_URL_FETCH_TIMEOUT_MS;
|
|
528
|
+
const systemPrompt = (deps.loadSystemPrompt ?? (() => loadPrompt(GENERATE_COMPONENT_SYSTEM_PROMPT_FILE)))();
|
|
529
|
+
const startedAt = performance.now();
|
|
530
|
+
// AC4 — pick the framework adapter from the validated `framework` input. The
|
|
531
|
+
// adapter owns the framework-specific prompt directive (the one such bit
|
|
532
|
+
// `conjure` carried inline). Selection works for every framework, including
|
|
533
|
+
// the Vue/HTML stubs: their codegen is stubbed, but `promptDirective` is
|
|
534
|
+
// metadata the model reads, so pure generation still targets them.
|
|
535
|
+
const adapter = await getAdapter(parsed.framework);
|
|
536
|
+
const frameworkDirective = adapter.promptDirective;
|
|
537
|
+
const referenceHtml = parsed.refUrl
|
|
538
|
+
? await fetchReference(fetchImpl, fetchPinnedAddress, parsed.refUrl, lookupAddresses, refUrlTimeoutMs)
|
|
539
|
+
: undefined;
|
|
540
|
+
const { outcome, usage, attempts } = await runComponentGeneration({
|
|
541
|
+
chat,
|
|
542
|
+
model: parsed.model,
|
|
543
|
+
validateGeneratedComponent: validateGeneratedBinaryContent,
|
|
544
|
+
buildMessages: (retry) => buildMessages(systemPrompt.text, parsed, frameworkDirective, referenceHtml, retry),
|
|
545
|
+
});
|
|
546
|
+
const latencyMs = Math.round(performance.now() - startedAt);
|
|
547
|
+
if (!outcome.ok) {
|
|
548
|
+
// Per-call log even on failure — keeps AC10's accounting honest.
|
|
549
|
+
logStderr({
|
|
550
|
+
event: "conjure",
|
|
551
|
+
ok: false,
|
|
552
|
+
model: parsed.model,
|
|
553
|
+
promptTokens: usage.promptTokens,
|
|
554
|
+
completionTokens: usage.completionTokens,
|
|
555
|
+
latencyMs,
|
|
556
|
+
componentName: null,
|
|
557
|
+
promptVersion: systemPrompt.version,
|
|
558
|
+
attempts,
|
|
559
|
+
});
|
|
560
|
+
throw new ConjureError("ERR_LLM_OUTPUT_INVALID", `The model did not return a schema-valid component after ${attempts} attempt(s). ` +
|
|
561
|
+
`Last validation error:\n${outcome.reason}`, { attempts, reason: outcome.reason });
|
|
562
|
+
}
|
|
563
|
+
const component = outcome.component;
|
|
564
|
+
// AC10 — per-call structured log, including componentName + prompt version.
|
|
565
|
+
logStderr({
|
|
566
|
+
event: "conjure",
|
|
567
|
+
ok: true,
|
|
568
|
+
model: parsed.model,
|
|
569
|
+
promptTokens: usage.promptTokens,
|
|
570
|
+
completionTokens: usage.completionTokens,
|
|
571
|
+
latencyMs,
|
|
572
|
+
componentName: component.componentName,
|
|
573
|
+
promptVersion: systemPrompt.version,
|
|
574
|
+
attempts,
|
|
575
|
+
});
|
|
576
|
+
return {
|
|
577
|
+
componentName: component.componentName,
|
|
578
|
+
group: component.group,
|
|
579
|
+
files: normalizeGeneratedFiles(component.files),
|
|
580
|
+
manifestEntry: component.manifestEntry,
|
|
581
|
+
usage,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
// ── MCP registration ──────────────────────────────────────────────────────────
|
|
585
|
+
const conjureOutputShape = {
|
|
586
|
+
componentName: z.string(),
|
|
587
|
+
group: z.string(),
|
|
588
|
+
files: z.array(z
|
|
589
|
+
.object({
|
|
590
|
+
path: z.string(),
|
|
591
|
+
content: z.string(),
|
|
592
|
+
mimeType: z.string(),
|
|
593
|
+
encoding: z.enum(["utf-8", "base64"]),
|
|
594
|
+
})
|
|
595
|
+
.strict()),
|
|
596
|
+
manifestEntry: z
|
|
597
|
+
.object({
|
|
598
|
+
viewport: z.object({ width: z.number(), height: z.number() }).strict(),
|
|
599
|
+
subtitle: z.string().optional(),
|
|
600
|
+
tags: z.array(z.string()).optional(),
|
|
601
|
+
})
|
|
602
|
+
.strict(),
|
|
603
|
+
usage: z
|
|
604
|
+
.object({
|
|
605
|
+
promptTokens: z.number().int().min(0),
|
|
606
|
+
completionTokens: z.number().int().min(0),
|
|
607
|
+
totalTokens: z.number().int().min(0),
|
|
608
|
+
})
|
|
609
|
+
.strict(),
|
|
610
|
+
};
|
|
611
|
+
export function registerConjureTool(server, deps = {}) {
|
|
612
|
+
server.registerTool(CONJURE_TOOL_NAME, {
|
|
613
|
+
title: "Conjure component",
|
|
614
|
+
description: "Generate a single UI component against your UI kit from a natural-language prompt " +
|
|
615
|
+
"(with an optional reference image or page). Returns the COMPONENT_SCHEMA file set — " +
|
|
616
|
+
"{ componentName, group, files, manifestEntry } — validated against the schema (retried " +
|
|
617
|
+
"once on a validation failure). Pure generation: it does NOT write the files; committing " +
|
|
618
|
+
"them to a kit is the caller's separate, plan-gated step: plan the returned paths, map " +
|
|
619
|
+
"each {path, content, mimeType, encoding} to write_files " +
|
|
620
|
+
"{path, data: content, mimeType, encoding}, then preview to show the result. " +
|
|
621
|
+
"`design-default` is the valid gateway routing alias; " +
|
|
622
|
+
"override it only with a concrete model id exposed by the configured endpoint.",
|
|
623
|
+
// Reuse the exact same field map the runtime parser uses (incl. the
|
|
624
|
+
// SSRF-guarded refUrl) — no second, drift-prone copy (Copilot review).
|
|
625
|
+
inputSchema: conjureInputShape,
|
|
626
|
+
outputSchema: conjureOutputShape,
|
|
627
|
+
}, async (args) => {
|
|
628
|
+
try {
|
|
629
|
+
const result = await conjure(deps, args);
|
|
630
|
+
return {
|
|
631
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
632
|
+
structuredContent: result,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
if (error instanceof ConjureError) {
|
|
637
|
+
return {
|
|
638
|
+
isError: true,
|
|
639
|
+
content: [
|
|
640
|
+
{
|
|
641
|
+
type: "text",
|
|
642
|
+
text: JSON.stringify({
|
|
643
|
+
code: error.code,
|
|
644
|
+
message: error.message,
|
|
645
|
+
...(error.details ? { details: error.details } : {}),
|
|
646
|
+
}),
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
//# sourceMappingURL=conjure.js.map
|