@avocadostudio-ai/orchestrator-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 +201 -0
- package/dist/agent/agent-context.d.ts +19 -0
- package/dist/agent/agent-context.js +67 -0
- package/dist/agent/agent-logger.d.ts +5 -0
- package/dist/agent/agent-logger.js +22 -0
- package/dist/agent/agent-loop-openai.d.ts +8 -0
- package/dist/agent/agent-loop-openai.js +172 -0
- package/dist/agent/agent-loop.d.ts +56 -0
- package/dist/agent/agent-loop.js +167 -0
- package/dist/agent/agent-provider.d.ts +28 -0
- package/dist/agent/agent-provider.js +63 -0
- package/dist/agent/agent-tools.d.ts +28 -0
- package/dist/agent/agent-tools.js +899 -0
- package/dist/agent/context/editing-guidelines.md +46 -0
- package/dist/agent/context/role.md +39 -0
- package/dist/agent/integration-prompt.d.ts +9 -0
- package/dist/agent/integration-prompt.js +154 -0
- package/dist/agent/sites-agent-context.d.ts +12 -0
- package/dist/agent/sites-agent-context.js +316 -0
- package/dist/agent/sites-agent-shared.d.ts +161 -0
- package/dist/agent/sites-agent-shared.js +1101 -0
- package/dist/agent/sites-agent-tools.d.ts +18 -0
- package/dist/agent/sites-agent-tools.js +1227 -0
- package/dist/chat/anthropic-cache.d.ts +20 -0
- package/dist/chat/anthropic-cache.js +54 -0
- package/dist/chat/anthropic-planner.d.ts +98 -0
- package/dist/chat/anthropic-planner.js +1012 -0
- package/dist/chat/changelog-coverage-validator.d.ts +37 -0
- package/dist/chat/changelog-coverage-validator.js +215 -0
- package/dist/chat/chat-pipeline-context.d.ts +211 -0
- package/dist/chat/chat-pipeline-context.js +249 -0
- package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
- package/dist/chat/chat-pipeline-deterministic.js +407 -0
- package/dist/chat/chat-pipeline-image.d.ts +86 -0
- package/dist/chat/chat-pipeline-image.js +897 -0
- package/dist/chat/chat-pipeline-shared.d.ts +69 -0
- package/dist/chat/chat-pipeline-shared.js +212 -0
- package/dist/chat/chat-pipeline-translation.d.ts +27 -0
- package/dist/chat/chat-pipeline-translation.js +417 -0
- package/dist/chat/chat-pipeline-ui.d.ts +14 -0
- package/dist/chat/chat-pipeline-ui.js +244 -0
- package/dist/chat/chat-pipeline.d.ts +99 -0
- package/dist/chat/chat-pipeline.js +3999 -0
- package/dist/chat/decomposer.d.ts +21 -0
- package/dist/chat/decomposer.js +65 -0
- package/dist/chat/gemini-planner.d.ts +70 -0
- package/dist/chat/gemini-planner.js +541 -0
- package/dist/chat/hallucination-validator.d.ts +36 -0
- package/dist/chat/hallucination-validator.js +110 -0
- package/dist/chat/locale-strings.d.ts +47 -0
- package/dist/chat/locale-strings.js +100 -0
- package/dist/chat/plan-json-schema.d.ts +133 -0
- package/dist/chat/plan-json-schema.js +112 -0
- package/dist/chat/planner-types.d.ts +120 -0
- package/dist/chat/planner-types.js +66 -0
- package/dist/chat/planner.d.ts +148 -0
- package/dist/chat/planner.js +1361 -0
- package/dist/chat/prompts.d.ts +67 -0
- package/dist/chat/prompts.js +356 -0
- package/dist/chat/provider-routing.d.ts +14 -0
- package/dist/chat/provider-routing.js +27 -0
- package/dist/chat/variation-pipeline.d.ts +135 -0
- package/dist/chat/variation-pipeline.js +837 -0
- package/dist/chat/vision-alt-generator.d.ts +35 -0
- package/dist/chat/vision-alt-generator.js +152 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +17 -0
- package/dist/cms/bootstrap.js +85 -0
- package/dist/cms/editor-api-adapter.d.ts +23 -0
- package/dist/cms/editor-api-adapter.js +71 -0
- package/dist/cms/index.d.ts +4 -0
- package/dist/cms/index.js +3 -0
- package/dist/cms/json-file-adapter.d.ts +11 -0
- package/dist/cms/json-file-adapter.js +62 -0
- package/dist/demo-mode.d.ts +59 -0
- package/dist/demo-mode.js +201 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +129 -0
- package/dist/http/chat-stream-resumable.d.ts +108 -0
- package/dist/http/chat-stream-resumable.js +290 -0
- package/dist/http/chat-stream.d.ts +99 -0
- package/dist/http/chat-stream.js +92 -0
- package/dist/image/gdrive-client.d.ts +22 -0
- package/dist/image/gdrive-client.js +215 -0
- package/dist/image/image-helpers.d.ts +95 -0
- package/dist/image/image-helpers.js +488 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jira/jira-approval.d.ts +22 -0
- package/dist/jira/jira-approval.js +51 -0
- package/dist/jira/jira-client.d.ts +44 -0
- package/dist/jira/jira-client.js +313 -0
- package/dist/jira/jira-poller.d.ts +46 -0
- package/dist/jira/jira-poller.js +184 -0
- package/dist/jira/jira-processor.d.ts +103 -0
- package/dist/jira/jira-processor.js +1085 -0
- package/dist/jira/jira-types.d.ts +117 -0
- package/dist/jira/jira-types.js +38 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +28 -0
- package/dist/migration/mcp-server-stdio.d.ts +8 -0
- package/dist/migration/mcp-server-stdio.js +672 -0
- package/dist/migration/migration-prompt.d.ts +7 -0
- package/dist/migration/migration-prompt.js +197 -0
- package/dist/migration/migration-tools.d.ts +17 -0
- package/dist/migration/migration-tools.js +159 -0
- package/dist/migration/scrape-cache.d.ts +9 -0
- package/dist/migration/scrape-cache.js +19 -0
- package/dist/nlp/deterministic-planner-context.d.ts +141 -0
- package/dist/nlp/deterministic-planner-context.js +362 -0
- package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
- package/dist/nlp/deterministic-planner-pages.js +170 -0
- package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
- package/dist/nlp/deterministic-planner-patches.js +508 -0
- package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
- package/dist/nlp/deterministic-planner-refs.js +164 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
- package/dist/nlp/deterministic-planner-suggestions.js +579 -0
- package/dist/nlp/deterministic-planner.d.ts +85 -0
- package/dist/nlp/deterministic-planner.js +1631 -0
- package/dist/nlp/intent-detection.d.ts +309 -0
- package/dist/nlp/intent-detection.js +730 -0
- package/dist/nlp/intent-helpers.d.ts +15 -0
- package/dist/nlp/intent-helpers.js +243 -0
- package/dist/nlp/intent-patterns.d.ts +40 -0
- package/dist/nlp/intent-patterns.js +223 -0
- package/dist/nlp/plan-normalizer.d.ts +41 -0
- package/dist/nlp/plan-normalizer.js +1537 -0
- package/dist/ops/destructive-action-gate.d.ts +44 -0
- package/dist/ops/destructive-action-gate.js +90 -0
- package/dist/ops/ops-engine.d.ts +151 -0
- package/dist/ops/ops-engine.js +1394 -0
- package/dist/publish/diff-engine.d.ts +18 -0
- package/dist/publish/diff-engine.js +305 -0
- package/dist/publish/publish-helpers.d.ts +87 -0
- package/dist/publish/publish-helpers.js +521 -0
- package/dist/publish/publish-target-registry.d.ts +7 -0
- package/dist/publish/publish-target-registry.js +61 -0
- package/dist/publish/publish-target.d.ts +81 -0
- package/dist/publish/publish-target.js +1 -0
- package/dist/publish/targets/deploy-hook.d.ts +13 -0
- package/dist/publish/targets/deploy-hook.js +123 -0
- package/dist/publish/targets/git.d.ts +13 -0
- package/dist/publish/targets/git.js +55 -0
- package/dist/publish/targets/site-contract.d.ts +19 -0
- package/dist/publish/targets/site-contract.js +124 -0
- package/dist/state/content-source.d.ts +17 -0
- package/dist/state/content-source.js +1 -0
- package/dist/state/in-memory-content-source.d.ts +27 -0
- package/dist/state/in-memory-content-source.js +51 -0
- package/dist/state/session-lock.d.ts +13 -0
- package/dist/state/session-lock.js +29 -0
- package/dist/state/session-state.d.ts +310 -0
- package/dist/state/session-state.js +1083 -0
- package/dist/state/sqlite-store-singleton.d.ts +31 -0
- package/dist/state/sqlite-store-singleton.js +170 -0
- package/dist/state/sqlite-store.d.ts +135 -0
- package/dist/state/sqlite-store.js +421 -0
- package/dist/telemetry/chat-telemetry.d.ts +105 -0
- package/dist/telemetry/chat-telemetry.js +247 -0
- package/dist/telemetry/eval-candidate-store.d.ts +50 -0
- package/dist/telemetry/eval-candidate-store.js +120 -0
- package/dist/telemetry/feedback-store.d.ts +34 -0
- package/dist/telemetry/feedback-store.js +76 -0
- package/dist/telemetry/jira-telemetry.d.ts +57 -0
- package/dist/telemetry/jira-telemetry.js +68 -0
- package/dist/telemetry/migration-telemetry.d.ts +35 -0
- package/dist/telemetry/migration-telemetry.js +40 -0
- package/dist/telemetry/usage.d.ts +24 -0
- package/dist/telemetry/usage.js +80 -0
- package/dist/tools/builtin-registrations.d.ts +12 -0
- package/dist/tools/builtin-registrations.js +33 -0
- package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
- package/dist/tools/builtins/gdrive-browse.js +68 -0
- package/dist/tools/builtins/image-generate.d.ts +3 -0
- package/dist/tools/builtins/image-generate.js +211 -0
- package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
- package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
- package/dist/tools/builtins/unsplash-search.d.ts +3 -0
- package/dist/tools/builtins/unsplash-search.js +74 -0
- package/dist/tools/executor.d.ts +23 -0
- package/dist/tools/executor.js +169 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/registry.d.ts +21 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/runtime.d.ts +27 -0
- package/dist/tools/runtime.js +48 -0
- package/dist/tools/schema-validator.d.ts +24 -0
- package/dist/tools/schema-validator.js +88 -0
- package/dist/tools/types.d.ts +86 -0
- package/dist/tools/types.js +1 -0
- package/dist/variation-images.d.ts +19 -0
- package/dist/variation-images.js +12 -0
- package/package.json +78 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface VisionAltLogger {
|
|
2
|
+
warn?: (obj: object, msg: string) => void;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* For inline "Generate alt text" / "Improve accessibility" / "Make more descriptive"
|
|
6
|
+
* wand actions on imageAlt fields. Bypasses the heavy planner (which routinely returns
|
|
7
|
+
* needs_clarification when the existing alt text looks like a user instruction or is
|
|
8
|
+
* empty) and asks the vision model directly for one clean alt-text string.
|
|
9
|
+
*
|
|
10
|
+
* Provider preference order: anthropic → openai. Falls back to the other if the
|
|
11
|
+
* preferred provider's API key is missing or the call fails. Returns null only
|
|
12
|
+
* when both providers fail or no API key is set anywhere.
|
|
13
|
+
*/
|
|
14
|
+
export declare function generateAltTextFromVision(opts: {
|
|
15
|
+
imageUrl: string;
|
|
16
|
+
preferredProvider: "anthropic" | "openai" | "gemini";
|
|
17
|
+
log?: VisionAltLogger;
|
|
18
|
+
}): Promise<string | null>;
|
|
19
|
+
/**
|
|
20
|
+
* Parse an editablePath into an op shape. Supports two cases:
|
|
21
|
+
* - "imageAlt" → update_props { imageAlt: alt }
|
|
22
|
+
* - "cards[0].imageAlt" → update_item cards index 0 { imageAlt: alt }
|
|
23
|
+
*
|
|
24
|
+
* Returns null when the path doesn't match either shape — callers should fall
|
|
25
|
+
* back to the planner.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseAltPathForOp(path: string): {
|
|
28
|
+
kind: "props";
|
|
29
|
+
key: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "item";
|
|
32
|
+
listKey: string;
|
|
33
|
+
index: number;
|
|
34
|
+
itemKey: string;
|
|
35
|
+
} | null;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import OpenAI from "openai";
|
|
3
|
+
import { looksLikeUserInstruction } from "./chat-pipeline-ui.js";
|
|
4
|
+
const MIME_BY_EXT = {
|
|
5
|
+
".png": "image/png",
|
|
6
|
+
".jpg": "image/jpeg",
|
|
7
|
+
".jpeg": "image/jpeg",
|
|
8
|
+
".gif": "image/gif",
|
|
9
|
+
".webp": "image/webp"
|
|
10
|
+
};
|
|
11
|
+
function resolveImageFetchUrl(rawUrl) {
|
|
12
|
+
if (/^https?:\/\//i.test(rawUrl))
|
|
13
|
+
return rawUrl;
|
|
14
|
+
if (rawUrl.startsWith("/")) {
|
|
15
|
+
const origin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
|
|
16
|
+
return `${origin}${rawUrl}`;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
async function fetchImageBytes(url) {
|
|
21
|
+
const resolved = resolveImageFetchUrl(url);
|
|
22
|
+
if (!resolved)
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(resolved);
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
return null;
|
|
28
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
29
|
+
const contentType = res.headers.get("content-type");
|
|
30
|
+
let mediaType = contentType?.split(";")[0]?.trim() ?? "";
|
|
31
|
+
if (!mediaType || mediaType === "application/octet-stream") {
|
|
32
|
+
const ext = resolved.match(/(\.[a-z]+)(?:\?|$)/i)?.[1]?.toLowerCase() ?? "";
|
|
33
|
+
mediaType = MIME_BY_EXT[ext] ?? "image/jpeg";
|
|
34
|
+
}
|
|
35
|
+
return { base64: buffer.toString("base64"), mediaType };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const ALT_PROMPT = "Write a 1-2 sentence noun-phrase description of this image suitable as accessibility alt text. " +
|
|
42
|
+
"Be specific about the main subject, action, and setting. " +
|
|
43
|
+
"Do not start with 'image of', 'photo of', or 'a/an '. " +
|
|
44
|
+
"Do not mention 'AI-generated', watermarks, or metadata. " +
|
|
45
|
+
"Reply with only the alt text — no quotes, no preamble, no labels.";
|
|
46
|
+
/**
|
|
47
|
+
* For inline "Generate alt text" / "Improve accessibility" / "Make more descriptive"
|
|
48
|
+
* wand actions on imageAlt fields. Bypasses the heavy planner (which routinely returns
|
|
49
|
+
* needs_clarification when the existing alt text looks like a user instruction or is
|
|
50
|
+
* empty) and asks the vision model directly for one clean alt-text string.
|
|
51
|
+
*
|
|
52
|
+
* Provider preference order: anthropic → openai. Falls back to the other if the
|
|
53
|
+
* preferred provider's API key is missing or the call fails. Returns null only
|
|
54
|
+
* when both providers fail or no API key is set anywhere.
|
|
55
|
+
*/
|
|
56
|
+
export async function generateAltTextFromVision(opts) {
|
|
57
|
+
const img = await fetchImageBytes(opts.imageUrl);
|
|
58
|
+
if (!img) {
|
|
59
|
+
opts.log?.warn?.({ imageUrl: opts.imageUrl }, "vision alt-text: failed to fetch image bytes");
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const order = opts.preferredProvider === "openai" ? ["openai", "anthropic"] : ["anthropic", "openai"];
|
|
63
|
+
for (const provider of order) {
|
|
64
|
+
const text = await tryProvider(provider, img, opts.log);
|
|
65
|
+
if (text) {
|
|
66
|
+
const sanitized = sanitizeAltText(text);
|
|
67
|
+
if (sanitized && !looksLikeUserInstruction(sanitized))
|
|
68
|
+
return sanitized;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
async function tryProvider(provider, img, log) {
|
|
74
|
+
try {
|
|
75
|
+
if (provider === "anthropic") {
|
|
76
|
+
if (!process.env.ANTHROPIC_API_KEY)
|
|
77
|
+
return null;
|
|
78
|
+
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
79
|
+
const model = process.env.ANTHROPIC_VISION_ALT_MODEL?.trim() || "claude-haiku-4-5-20251001";
|
|
80
|
+
const result = await client.messages.create({
|
|
81
|
+
model,
|
|
82
|
+
max_tokens: 250,
|
|
83
|
+
messages: [
|
|
84
|
+
{
|
|
85
|
+
role: "user",
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "image",
|
|
89
|
+
source: {
|
|
90
|
+
type: "base64",
|
|
91
|
+
media_type: img.mediaType,
|
|
92
|
+
data: img.base64
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{ type: "text", text: ALT_PROMPT }
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
});
|
|
100
|
+
const textBlock = result.content.find((c) => c.type === "text");
|
|
101
|
+
return textBlock && textBlock.type === "text" ? textBlock.text : null;
|
|
102
|
+
}
|
|
103
|
+
if (!process.env.OPENAI_API_KEY)
|
|
104
|
+
return null;
|
|
105
|
+
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
106
|
+
const model = process.env.OPENAI_VISION_ALT_MODEL?.trim() || process.env.OPENAI_VISION_MODEL?.trim() || "gpt-4o-mini";
|
|
107
|
+
const dataUrl = `data:${img.mediaType};base64,${img.base64}`;
|
|
108
|
+
const completion = await client.chat.completions.create({
|
|
109
|
+
model,
|
|
110
|
+
max_tokens: 250,
|
|
111
|
+
messages: [
|
|
112
|
+
{
|
|
113
|
+
role: "user",
|
|
114
|
+
content: [
|
|
115
|
+
{ type: "text", text: ALT_PROMPT },
|
|
116
|
+
{ type: "image_url", image_url: { url: dataUrl } }
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
});
|
|
121
|
+
return completion.choices[0]?.message?.content ?? null;
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
log?.warn?.({ provider, err: err instanceof Error ? err.message : String(err) }, "vision alt-text direct call failed");
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function sanitizeAltText(raw) {
|
|
129
|
+
return raw
|
|
130
|
+
.trim()
|
|
131
|
+
.replace(/^["'`“‘]+|["'`”’]+$/g, "")
|
|
132
|
+
.replace(/^(alt(\s+text)?\s*[:\-—]\s*)/i, "")
|
|
133
|
+
.trim();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Parse an editablePath into an op shape. Supports two cases:
|
|
137
|
+
* - "imageAlt" → update_props { imageAlt: alt }
|
|
138
|
+
* - "cards[0].imageAlt" → update_item cards index 0 { imageAlt: alt }
|
|
139
|
+
*
|
|
140
|
+
* Returns null when the path doesn't match either shape — callers should fall
|
|
141
|
+
* back to the planner.
|
|
142
|
+
*/
|
|
143
|
+
export function parseAltPathForOp(path) {
|
|
144
|
+
const nested = path.match(/^(\w+)\[(\d+)\]\.(\w+)$/);
|
|
145
|
+
if (nested) {
|
|
146
|
+
const [, listKey, indexStr, itemKey] = nested;
|
|
147
|
+
return { kind: "item", listKey, index: Number(indexStr), itemKey };
|
|
148
|
+
}
|
|
149
|
+
if (/^\w+$/.test(path))
|
|
150
|
+
return { kind: "props", key: path };
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
|
+
/**
|
|
3
|
+
* A single inline image asset attached to a publish call. Used for
|
|
4
|
+
* chat-generated images that live as base64 blobs (no public URL yet) — the
|
|
5
|
+
* adapter is responsible for uploading these to its content store and
|
|
6
|
+
* swapping the original blob URLs for the resulting public URLs.
|
|
7
|
+
*/
|
|
8
|
+
export interface CmsInlineAsset {
|
|
9
|
+
/** base64-encoded image bytes */
|
|
10
|
+
data: string;
|
|
11
|
+
/** MIME type, e.g. "image/png" */
|
|
12
|
+
mimeType: string;
|
|
13
|
+
/** Original filename */
|
|
14
|
+
fileName: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Extra context passed to `onPublish` alongside the page list. Optional —
|
|
18
|
+
* adapters that don't care about inline assets can ignore it.
|
|
19
|
+
*/
|
|
20
|
+
export interface CmsPublishContext {
|
|
21
|
+
/**
|
|
22
|
+
* Inline assets keyed by their placeholder URL inside the PageDoc[]
|
|
23
|
+
* (typically a `blob:` or `localhost` URL the adapter wouldn't be able to
|
|
24
|
+
* fetch). The adapter should upload each one, replace the placeholder URL
|
|
25
|
+
* in block props, and emit the resulting public URL.
|
|
26
|
+
*/
|
|
27
|
+
assets?: Record<string, CmsInlineAsset>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Result of an `onPublish` call. `void` means "success, nothing to report";
|
|
31
|
+
* an explicit `{ ok: false, error }` lets the adapter surface a per-publish
|
|
32
|
+
* error message that the orchestrator returns to the client.
|
|
33
|
+
*/
|
|
34
|
+
export type CmsPublishResult = void | {
|
|
35
|
+
ok: true;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
error?: string;
|
|
39
|
+
};
|
|
40
|
+
export interface CmsAdapter {
|
|
41
|
+
/**
|
|
42
|
+
* Stable identifier for telemetry and per-adapter bootstrap caches.
|
|
43
|
+
* Keep it short and DNS-safe: `json-file`, `editor-api`, `sanity`, etc.
|
|
44
|
+
*/
|
|
45
|
+
readonly id: string;
|
|
46
|
+
/**
|
|
47
|
+
* Return every page the orchestrator should know about for this site.
|
|
48
|
+
* Called lazily on the first chat for a fresh session. Failures are
|
|
49
|
+
* logged but non-fatal — the session simply starts empty.
|
|
50
|
+
*/
|
|
51
|
+
getPages(): Promise<PageDoc[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Optional publish hook. If present, called when the editor publishes
|
|
54
|
+
* a session's draft. If absent, publish remains a no-op against the
|
|
55
|
+
* external store — SQLite still records the publish locally.
|
|
56
|
+
*
|
|
57
|
+
* Second arg carries the session's current SiteConfig (navLabels, theme
|
|
58
|
+
* overrides, etc.); third arg carries any inline image blobs the editor
|
|
59
|
+
* collected during the session.
|
|
60
|
+
*/
|
|
61
|
+
onPublish?(pages: PageDoc[], config: SiteConfig, context?: CmsPublishContext): Promise<CmsPublishResult>;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CmsAdapter } from "./adapter.ts";
|
|
2
|
+
import type { Logger } from "../logger.ts";
|
|
3
|
+
export interface CmsBootstrapCacheOptions {
|
|
4
|
+
/** Soft cap on remembered "already-attempted" sessions. Default 5000. */
|
|
5
|
+
maxAttempted?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface CmsBootstrapCache {
|
|
8
|
+
ensure(session: string, adapter: CmsAdapter | null | undefined, log: Logger): Promise<void>;
|
|
9
|
+
/** Test helper — drop all remembered state. */
|
|
10
|
+
reset(): void;
|
|
11
|
+
/** Inspection — number of remembered attempts (post-eviction). */
|
|
12
|
+
readonly attemptedSize: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function createCmsBootstrapCache(opts?: CmsBootstrapCacheOptions): CmsBootstrapCache;
|
|
15
|
+
export declare function ensureSessionBootstrapped(session: string, adapter: CmsAdapter | null | undefined, log: Logger): Promise<void>;
|
|
16
|
+
/** Test helper — clears the default process cache. Runtime-scoped caches own their own `reset()`. */
|
|
17
|
+
export declare function _resetCmsBootstrapCache(): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getSessionDraft, ensureHeroImageProps, bumpVersion, schedulePersistState } from "../state/session-state.js";
|
|
2
|
+
const DEFAULT_MAX_ATTEMPTED = 5000;
|
|
3
|
+
export function createCmsBootstrapCache(opts = {}) {
|
|
4
|
+
const inFlight = new Map();
|
|
5
|
+
// Set is insertion-ordered, so FIFO eviction is just "delete the first key".
|
|
6
|
+
const attempted = new Set();
|
|
7
|
+
const maxAttempted = Math.max(1, opts.maxAttempted ?? DEFAULT_MAX_ATTEMPTED);
|
|
8
|
+
function markAttempted(session) {
|
|
9
|
+
attempted.add(session);
|
|
10
|
+
while (attempted.size > maxAttempted) {
|
|
11
|
+
const oldest = attempted.values().next().value;
|
|
12
|
+
if (oldest === undefined)
|
|
13
|
+
break;
|
|
14
|
+
attempted.delete(oldest);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function ensure(session, adapter, log) {
|
|
18
|
+
if (!adapter)
|
|
19
|
+
return;
|
|
20
|
+
if (attempted.has(session))
|
|
21
|
+
return;
|
|
22
|
+
const existing = inFlight.get(session);
|
|
23
|
+
if (existing)
|
|
24
|
+
return existing;
|
|
25
|
+
const promise = (async () => {
|
|
26
|
+
const draft = getSessionDraft(session);
|
|
27
|
+
if (draft.size > 0)
|
|
28
|
+
return; // finally-block records the attempt
|
|
29
|
+
try {
|
|
30
|
+
const pages = await adapter.getPages();
|
|
31
|
+
if (pages.length === 0) {
|
|
32
|
+
log.info({ session, adapter: adapter.id }, "cms-bootstrap: adapter returned no pages");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const page of pages) {
|
|
36
|
+
const copy = structuredClone(page);
|
|
37
|
+
ensureHeroImageProps(copy);
|
|
38
|
+
draft.set(copy.slug, copy);
|
|
39
|
+
}
|
|
40
|
+
bumpVersion(session);
|
|
41
|
+
schedulePersistState(log);
|
|
42
|
+
log.info({ session, adapter: adapter.id, count: pages.length }, "cms-bootstrap: seeded session draft");
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
log.warn({
|
|
46
|
+
session,
|
|
47
|
+
adapter: adapter.id,
|
|
48
|
+
err: err instanceof Error ? err.stack ?? err.message : String(err)
|
|
49
|
+
}, "cms-bootstrap: adapter.getPages() failed");
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
markAttempted(session);
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
inFlight.set(session, promise);
|
|
56
|
+
try {
|
|
57
|
+
await promise;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
inFlight.delete(session);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
ensure,
|
|
65
|
+
reset() {
|
|
66
|
+
inFlight.clear();
|
|
67
|
+
attempted.clear();
|
|
68
|
+
},
|
|
69
|
+
get attemptedSize() {
|
|
70
|
+
return attempted.size;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Process-default cache, retained so call sites that don't construct their
|
|
75
|
+
// own runtime (e.g. the orchestrator-core test suite) can still call
|
|
76
|
+
// `ensureSessionBootstrapped` directly. Library-mode runtimes built by
|
|
77
|
+
// createOrchestrator() own their own cache via createCmsBootstrapCache.
|
|
78
|
+
const defaultCache = createCmsBootstrapCache();
|
|
79
|
+
export async function ensureSessionBootstrapped(session, adapter, log) {
|
|
80
|
+
return defaultCache.ensure(session, adapter, log);
|
|
81
|
+
}
|
|
82
|
+
/** Test helper — clears the default process cache. Runtime-scoped caches own their own `reset()`. */
|
|
83
|
+
export function _resetCmsBootstrapCache() {
|
|
84
|
+
defaultCache.reset();
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CmsAdapter } from "./adapter.ts";
|
|
2
|
+
import type { Logger } from "../logger.ts";
|
|
3
|
+
export interface EditorApiAdapterOptions {
|
|
4
|
+
/** Site origin, e.g. `https://example.com`. Defaults to env AUTO_BOOTSTRAP_SITE_ORIGIN. */
|
|
5
|
+
origin?: string;
|
|
6
|
+
/** Path under the origin. Default `/api/editor/pages`. */
|
|
7
|
+
path?: string;
|
|
8
|
+
/** Optional siteId query param. */
|
|
9
|
+
siteId?: string;
|
|
10
|
+
/** Request timeout in ms. Default 8000. */
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Extra request headers (e.g. `{ authorization: "Bearer …", cookie: "…" }`)
|
|
14
|
+
* for protected `/api/editor/pages` endpoints. Merged on top of the
|
|
15
|
+
* default `accept: application/json`. Plain object form keeps this
|
|
16
|
+
* adapter usable from Node-only tsconfig setups that don't pull in DOM
|
|
17
|
+
* lib types.
|
|
18
|
+
*/
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
/** Logger for dropped-page warnings. Defaults to consoleLogger(). */
|
|
21
|
+
logger?: Logger;
|
|
22
|
+
}
|
|
23
|
+
export declare function editorApiAdapter(opts?: EditorApiAdapterOptions): CmsAdapter;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// editorApiAdapter — fetch PageDoc[] from a site's /api/editor/pages endpoint.
|
|
2
|
+
//
|
|
3
|
+
// This is the "Avocado is mounted inside the site itself" pattern: when the
|
|
4
|
+
// orchestrator runs in library mode under the site's Next.js app, it can call
|
|
5
|
+
// the site's own page-listing API to discover content. The legacy behavior
|
|
6
|
+
// of apps/orchestrator's autoBootstrapIfEmpty is exactly this, modulo the
|
|
7
|
+
// fact that the orchestrator now lives in-process so the loopback is purely
|
|
8
|
+
// for sites that already exposed /api/editor/pages.
|
|
9
|
+
//
|
|
10
|
+
// Pass `origin` if the orchestrator runs separately from the site; omit
|
|
11
|
+
// `origin` and pass `loopbackPath` (default `/api/editor/pages`) for the
|
|
12
|
+
// same-process case where the route is a sibling.
|
|
13
|
+
import { pageDocSchemaLenient } from "@avocadostudio-ai/shared";
|
|
14
|
+
import { consoleLogger } from "../logger.js";
|
|
15
|
+
export function editorApiAdapter(opts = {}) {
|
|
16
|
+
const path = opts.path ?? "/api/editor/pages";
|
|
17
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
18
|
+
const log = opts.logger ?? consoleLogger();
|
|
19
|
+
return {
|
|
20
|
+
id: "editor-api",
|
|
21
|
+
async getPages() {
|
|
22
|
+
const origin = opts.origin ?? process.env.AUTO_BOOTSTRAP_SITE_ORIGIN?.trim();
|
|
23
|
+
if (!origin)
|
|
24
|
+
throw new Error("editorApiAdapter: no origin configured (pass origin or set AUTO_BOOTSTRAP_SITE_ORIGIN)");
|
|
25
|
+
const url = new URL(path, origin);
|
|
26
|
+
if (opts.siteId)
|
|
27
|
+
url.searchParams.set("siteId", opts.siteId);
|
|
28
|
+
const headers = { accept: "application/json" };
|
|
29
|
+
if (opts.headers) {
|
|
30
|
+
for (const [key, value] of Object.entries(opts.headers)) {
|
|
31
|
+
headers[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const res = await fetch(url.toString(), {
|
|
35
|
+
headers,
|
|
36
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok)
|
|
39
|
+
throw new Error(`editorApiAdapter: ${url} returned ${res.status}`);
|
|
40
|
+
const body = (await res.json());
|
|
41
|
+
const rawPages = Array.isArray(body)
|
|
42
|
+
? body
|
|
43
|
+
: body && typeof body === "object" && Array.isArray(body.pages)
|
|
44
|
+
? body.pages
|
|
45
|
+
: [];
|
|
46
|
+
const pages = [];
|
|
47
|
+
for (let i = 0; i < rawPages.length; i++) {
|
|
48
|
+
const candidate = rawPages[i];
|
|
49
|
+
const result = pageDocSchemaLenient.safeParse(candidate);
|
|
50
|
+
if (result.success) {
|
|
51
|
+
pages.push(result.data);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const firstIssue = result.error.issues[0];
|
|
55
|
+
const slugHint = candidate && typeof candidate === "object" && "slug" in candidate
|
|
56
|
+
? String(candidate.slug ?? "")
|
|
57
|
+
: "";
|
|
58
|
+
log.warn({
|
|
59
|
+
adapter: "editor-api",
|
|
60
|
+
url: url.toString(),
|
|
61
|
+
index: i,
|
|
62
|
+
slug: slugHint,
|
|
63
|
+
issuePath: firstIssue?.path,
|
|
64
|
+
issueMessage: firstIssue?.message
|
|
65
|
+
}, "editorApiAdapter: dropped page that failed lenient PageDoc parse");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return pages;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { CmsAdapter, CmsInlineAsset, CmsPublishContext, CmsPublishResult } from "./adapter.ts";
|
|
2
|
+
export { jsonFileAdapter, type JsonFileAdapterOptions } from "./json-file-adapter.ts";
|
|
3
|
+
export { editorApiAdapter, type EditorApiAdapterOptions } from "./editor-api-adapter.ts";
|
|
4
|
+
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions } from "./bootstrap.ts";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CmsAdapter } from "./adapter.ts";
|
|
2
|
+
import type { Logger } from "../logger.ts";
|
|
3
|
+
export interface JsonFileAdapterOptions {
|
|
4
|
+
/** Path to the JSON file. Relative paths resolve against `process.cwd()`. */
|
|
5
|
+
path: string;
|
|
6
|
+
/** If true, onPublish() will overwrite the file. Default: false. */
|
|
7
|
+
writeOnPublish?: boolean;
|
|
8
|
+
/** Logger for dropped-page warnings. Defaults to consoleLogger(). */
|
|
9
|
+
logger?: Logger;
|
|
10
|
+
}
|
|
11
|
+
export declare function jsonFileAdapter(opts: JsonFileAdapterOptions): CmsAdapter;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// jsonFileAdapter — read PageDoc[] from a JSON file on disk.
|
|
2
|
+
//
|
|
3
|
+
// Smallest possible adapter. Suitable for sites that keep their content as
|
|
4
|
+
// a checked-in JSON array (the shape the orchestrator + blocks already
|
|
5
|
+
// understand). villa-puravida-web's lib/published-content.json is the
|
|
6
|
+
// canonical example.
|
|
7
|
+
//
|
|
8
|
+
// If `writeOnPublish` is true, onPublish() will overwrite the file with the
|
|
9
|
+
// new PageDoc[]. Use sparingly — in production deployments the file is
|
|
10
|
+
// typically read-only at runtime and republished via CI / git commit.
|
|
11
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { isAbsolute, resolve } from "node:path";
|
|
13
|
+
import { pageDocSchemaLenient } from "@avocadostudio-ai/shared";
|
|
14
|
+
import { consoleLogger } from "../logger.js";
|
|
15
|
+
export function jsonFileAdapter(opts) {
|
|
16
|
+
const resolved = isAbsolute(opts.path) ? opts.path : resolve(process.cwd(), opts.path);
|
|
17
|
+
const log = opts.logger ?? consoleLogger();
|
|
18
|
+
const adapter = {
|
|
19
|
+
id: "json-file",
|
|
20
|
+
async getPages() {
|
|
21
|
+
const raw = await readFile(resolved, "utf-8");
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
const candidates = Array.isArray(parsed)
|
|
24
|
+
? parsed
|
|
25
|
+
: parsed && typeof parsed === "object" && Array.isArray(parsed.pages)
|
|
26
|
+
? parsed.pages
|
|
27
|
+
: [];
|
|
28
|
+
const pages = [];
|
|
29
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
30
|
+
const candidate = candidates[i];
|
|
31
|
+
const result = pageDocSchemaLenient.safeParse(candidate);
|
|
32
|
+
if (result.success) {
|
|
33
|
+
pages.push(result.data);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const firstIssue = result.error.issues[0];
|
|
37
|
+
const slugHint = candidate && typeof candidate === "object" && "slug" in candidate
|
|
38
|
+
? String(candidate.slug ?? "")
|
|
39
|
+
: "";
|
|
40
|
+
log.warn({
|
|
41
|
+
adapter: "json-file",
|
|
42
|
+
path: resolved,
|
|
43
|
+
index: i,
|
|
44
|
+
slug: slugHint,
|
|
45
|
+
issuePath: firstIssue?.path,
|
|
46
|
+
issueMessage: firstIssue?.message
|
|
47
|
+
}, "jsonFileAdapter: dropped page that failed lenient PageDoc parse");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return pages;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
if (opts.writeOnPublish === true) {
|
|
54
|
+
// SiteConfig + inline assets aren't relevant to a flat JSON dump — the
|
|
55
|
+
// file shape is just `PageDoc[]`. Adapters that map into a richer store
|
|
56
|
+
// (Contentful, Sanity, Strapi) consume those extra args.
|
|
57
|
+
adapter.onPublish = async (pages) => {
|
|
58
|
+
await writeFile(resolved, JSON.stringify(pages, null, 2) + "\n", "utf-8");
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return adapter;
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Operation, PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
/** Is demo mode enabled for this orchestrator process? */
|
|
3
|
+
export declare function isDemoModeEnabled(): boolean;
|
|
4
|
+
/** Operation types allowed in demo mode. Defaults to ["update_props"]. */
|
|
5
|
+
export declare function getDemoAllowedOpTypes(): string[];
|
|
6
|
+
/** Block types on which update_props is allowed. Defaults to ["Hero"]. */
|
|
7
|
+
export declare function getDemoAllowedBlockTypes(): string[];
|
|
8
|
+
/** Max requests per IP per hour. Defaults to 20. */
|
|
9
|
+
export declare function getDemoRateLimitPerHour(): number;
|
|
10
|
+
/** Should image generation be disabled in demo mode? Defaults to true. */
|
|
11
|
+
export declare function isDemoImageGenDisabled(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic ephemeral session key for a demo visitor.
|
|
14
|
+
*
|
|
15
|
+
* We hash the IP to avoid leaking it, and keep the key short enough to read
|
|
16
|
+
* in logs. The key does NOT contain "::" so it routes through the default
|
|
17
|
+
* `getSessionDraft` path which auto-seeds from `demoPublishedPages()`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function demoSessionKeyForIp(ip: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Extract a client IP from a Fastify request. Render puts the real client IP
|
|
22
|
+
* in `x-forwarded-for` (first entry). Falls back to request.ip.
|
|
23
|
+
*/
|
|
24
|
+
export declare function extractClientIp(request: {
|
|
25
|
+
headers: Record<string, string | string[] | undefined>;
|
|
26
|
+
ip?: string;
|
|
27
|
+
}): string;
|
|
28
|
+
export type DemoEnforcement = {
|
|
29
|
+
allowed: Operation[];
|
|
30
|
+
rejected: Array<{
|
|
31
|
+
op: Operation;
|
|
32
|
+
reason: string;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Validate a list of operations against demo-mode rules. Returns the split
|
|
37
|
+
* between allowed and rejected ops; caller decides whether to proceed with
|
|
38
|
+
* the allowed subset or to throw.
|
|
39
|
+
*/
|
|
40
|
+
export declare function splitDemoOps(ops: Operation[], staged: Map<string, PageDoc>): DemoEnforcement;
|
|
41
|
+
/**
|
|
42
|
+
* Throws an `OperationError` if any of the given ops violates demo-mode
|
|
43
|
+
* rules. Used as the final gate inside `applyOpsAtomically`, so both chat
|
|
44
|
+
* and direct `/ops` paths are covered.
|
|
45
|
+
*/
|
|
46
|
+
export declare function enforceDemoOps(ops: Operation[], staged: Map<string, PageDoc>): void;
|
|
47
|
+
/**
|
|
48
|
+
* Consume one token for the given IP. Returns `{ ok: true }` if allowed,
|
|
49
|
+
* or `{ ok: false, retryAfterSeconds }` if the quota for the current
|
|
50
|
+
* hourly window is exhausted.
|
|
51
|
+
*/
|
|
52
|
+
export declare function consumeDemoRateToken(ip: string): {
|
|
53
|
+
ok: true;
|
|
54
|
+
} | {
|
|
55
|
+
ok: false;
|
|
56
|
+
retryAfterSeconds: number;
|
|
57
|
+
};
|
|
58
|
+
/** Reset the rate limiter (test hook). */
|
|
59
|
+
export declare function _resetDemoRateLimiterForTests(): void;
|