@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.
Files changed (196) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent/agent-context.d.ts +19 -0
  3. package/dist/agent/agent-context.js +67 -0
  4. package/dist/agent/agent-logger.d.ts +5 -0
  5. package/dist/agent/agent-logger.js +22 -0
  6. package/dist/agent/agent-loop-openai.d.ts +8 -0
  7. package/dist/agent/agent-loop-openai.js +172 -0
  8. package/dist/agent/agent-loop.d.ts +56 -0
  9. package/dist/agent/agent-loop.js +167 -0
  10. package/dist/agent/agent-provider.d.ts +28 -0
  11. package/dist/agent/agent-provider.js +63 -0
  12. package/dist/agent/agent-tools.d.ts +28 -0
  13. package/dist/agent/agent-tools.js +899 -0
  14. package/dist/agent/context/editing-guidelines.md +46 -0
  15. package/dist/agent/context/role.md +39 -0
  16. package/dist/agent/integration-prompt.d.ts +9 -0
  17. package/dist/agent/integration-prompt.js +154 -0
  18. package/dist/agent/sites-agent-context.d.ts +12 -0
  19. package/dist/agent/sites-agent-context.js +316 -0
  20. package/dist/agent/sites-agent-shared.d.ts +161 -0
  21. package/dist/agent/sites-agent-shared.js +1101 -0
  22. package/dist/agent/sites-agent-tools.d.ts +18 -0
  23. package/dist/agent/sites-agent-tools.js +1227 -0
  24. package/dist/chat/anthropic-cache.d.ts +20 -0
  25. package/dist/chat/anthropic-cache.js +54 -0
  26. package/dist/chat/anthropic-planner.d.ts +98 -0
  27. package/dist/chat/anthropic-planner.js +1012 -0
  28. package/dist/chat/changelog-coverage-validator.d.ts +37 -0
  29. package/dist/chat/changelog-coverage-validator.js +215 -0
  30. package/dist/chat/chat-pipeline-context.d.ts +211 -0
  31. package/dist/chat/chat-pipeline-context.js +249 -0
  32. package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
  33. package/dist/chat/chat-pipeline-deterministic.js +407 -0
  34. package/dist/chat/chat-pipeline-image.d.ts +86 -0
  35. package/dist/chat/chat-pipeline-image.js +897 -0
  36. package/dist/chat/chat-pipeline-shared.d.ts +69 -0
  37. package/dist/chat/chat-pipeline-shared.js +212 -0
  38. package/dist/chat/chat-pipeline-translation.d.ts +27 -0
  39. package/dist/chat/chat-pipeline-translation.js +417 -0
  40. package/dist/chat/chat-pipeline-ui.d.ts +14 -0
  41. package/dist/chat/chat-pipeline-ui.js +244 -0
  42. package/dist/chat/chat-pipeline.d.ts +99 -0
  43. package/dist/chat/chat-pipeline.js +3999 -0
  44. package/dist/chat/decomposer.d.ts +21 -0
  45. package/dist/chat/decomposer.js +65 -0
  46. package/dist/chat/gemini-planner.d.ts +70 -0
  47. package/dist/chat/gemini-planner.js +541 -0
  48. package/dist/chat/hallucination-validator.d.ts +36 -0
  49. package/dist/chat/hallucination-validator.js +110 -0
  50. package/dist/chat/locale-strings.d.ts +47 -0
  51. package/dist/chat/locale-strings.js +100 -0
  52. package/dist/chat/plan-json-schema.d.ts +133 -0
  53. package/dist/chat/plan-json-schema.js +112 -0
  54. package/dist/chat/planner-types.d.ts +120 -0
  55. package/dist/chat/planner-types.js +66 -0
  56. package/dist/chat/planner.d.ts +148 -0
  57. package/dist/chat/planner.js +1361 -0
  58. package/dist/chat/prompts.d.ts +67 -0
  59. package/dist/chat/prompts.js +356 -0
  60. package/dist/chat/provider-routing.d.ts +14 -0
  61. package/dist/chat/provider-routing.js +27 -0
  62. package/dist/chat/variation-pipeline.d.ts +135 -0
  63. package/dist/chat/variation-pipeline.js +837 -0
  64. package/dist/chat/vision-alt-generator.d.ts +35 -0
  65. package/dist/chat/vision-alt-generator.js +152 -0
  66. package/dist/cms/adapter.d.ts +62 -0
  67. package/dist/cms/adapter.js +1 -0
  68. package/dist/cms/bootstrap.d.ts +17 -0
  69. package/dist/cms/bootstrap.js +85 -0
  70. package/dist/cms/editor-api-adapter.d.ts +23 -0
  71. package/dist/cms/editor-api-adapter.js +71 -0
  72. package/dist/cms/index.d.ts +4 -0
  73. package/dist/cms/index.js +3 -0
  74. package/dist/cms/json-file-adapter.d.ts +11 -0
  75. package/dist/cms/json-file-adapter.js +62 -0
  76. package/dist/demo-mode.d.ts +59 -0
  77. package/dist/demo-mode.js +201 -0
  78. package/dist/errors.d.ts +67 -0
  79. package/dist/errors.js +129 -0
  80. package/dist/http/chat-stream-resumable.d.ts +108 -0
  81. package/dist/http/chat-stream-resumable.js +290 -0
  82. package/dist/http/chat-stream.d.ts +99 -0
  83. package/dist/http/chat-stream.js +92 -0
  84. package/dist/image/gdrive-client.d.ts +22 -0
  85. package/dist/image/gdrive-client.js +215 -0
  86. package/dist/image/image-helpers.d.ts +95 -0
  87. package/dist/image/image-helpers.js +488 -0
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +1 -0
  90. package/dist/jira/jira-approval.d.ts +22 -0
  91. package/dist/jira/jira-approval.js +51 -0
  92. package/dist/jira/jira-client.d.ts +44 -0
  93. package/dist/jira/jira-client.js +313 -0
  94. package/dist/jira/jira-poller.d.ts +46 -0
  95. package/dist/jira/jira-poller.js +184 -0
  96. package/dist/jira/jira-processor.d.ts +103 -0
  97. package/dist/jira/jira-processor.js +1085 -0
  98. package/dist/jira/jira-types.d.ts +117 -0
  99. package/dist/jira/jira-types.js +38 -0
  100. package/dist/logger.d.ts +12 -0
  101. package/dist/logger.js +28 -0
  102. package/dist/migration/mcp-server-stdio.d.ts +8 -0
  103. package/dist/migration/mcp-server-stdio.js +672 -0
  104. package/dist/migration/migration-prompt.d.ts +7 -0
  105. package/dist/migration/migration-prompt.js +197 -0
  106. package/dist/migration/migration-tools.d.ts +17 -0
  107. package/dist/migration/migration-tools.js +159 -0
  108. package/dist/migration/scrape-cache.d.ts +9 -0
  109. package/dist/migration/scrape-cache.js +19 -0
  110. package/dist/nlp/deterministic-planner-context.d.ts +141 -0
  111. package/dist/nlp/deterministic-planner-context.js +362 -0
  112. package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
  113. package/dist/nlp/deterministic-planner-pages.js +170 -0
  114. package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
  115. package/dist/nlp/deterministic-planner-patches.js +508 -0
  116. package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
  117. package/dist/nlp/deterministic-planner-refs.js +164 -0
  118. package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
  119. package/dist/nlp/deterministic-planner-suggestions.js +579 -0
  120. package/dist/nlp/deterministic-planner.d.ts +85 -0
  121. package/dist/nlp/deterministic-planner.js +1631 -0
  122. package/dist/nlp/intent-detection.d.ts +309 -0
  123. package/dist/nlp/intent-detection.js +730 -0
  124. package/dist/nlp/intent-helpers.d.ts +15 -0
  125. package/dist/nlp/intent-helpers.js +243 -0
  126. package/dist/nlp/intent-patterns.d.ts +40 -0
  127. package/dist/nlp/intent-patterns.js +223 -0
  128. package/dist/nlp/plan-normalizer.d.ts +41 -0
  129. package/dist/nlp/plan-normalizer.js +1537 -0
  130. package/dist/ops/destructive-action-gate.d.ts +44 -0
  131. package/dist/ops/destructive-action-gate.js +90 -0
  132. package/dist/ops/ops-engine.d.ts +151 -0
  133. package/dist/ops/ops-engine.js +1394 -0
  134. package/dist/publish/diff-engine.d.ts +18 -0
  135. package/dist/publish/diff-engine.js +305 -0
  136. package/dist/publish/publish-helpers.d.ts +87 -0
  137. package/dist/publish/publish-helpers.js +521 -0
  138. package/dist/publish/publish-target-registry.d.ts +7 -0
  139. package/dist/publish/publish-target-registry.js +61 -0
  140. package/dist/publish/publish-target.d.ts +81 -0
  141. package/dist/publish/publish-target.js +1 -0
  142. package/dist/publish/targets/deploy-hook.d.ts +13 -0
  143. package/dist/publish/targets/deploy-hook.js +123 -0
  144. package/dist/publish/targets/git.d.ts +13 -0
  145. package/dist/publish/targets/git.js +55 -0
  146. package/dist/publish/targets/site-contract.d.ts +19 -0
  147. package/dist/publish/targets/site-contract.js +124 -0
  148. package/dist/state/content-source.d.ts +17 -0
  149. package/dist/state/content-source.js +1 -0
  150. package/dist/state/in-memory-content-source.d.ts +27 -0
  151. package/dist/state/in-memory-content-source.js +51 -0
  152. package/dist/state/session-lock.d.ts +13 -0
  153. package/dist/state/session-lock.js +29 -0
  154. package/dist/state/session-state.d.ts +310 -0
  155. package/dist/state/session-state.js +1083 -0
  156. package/dist/state/sqlite-store-singleton.d.ts +31 -0
  157. package/dist/state/sqlite-store-singleton.js +170 -0
  158. package/dist/state/sqlite-store.d.ts +135 -0
  159. package/dist/state/sqlite-store.js +421 -0
  160. package/dist/telemetry/chat-telemetry.d.ts +105 -0
  161. package/dist/telemetry/chat-telemetry.js +247 -0
  162. package/dist/telemetry/eval-candidate-store.d.ts +50 -0
  163. package/dist/telemetry/eval-candidate-store.js +120 -0
  164. package/dist/telemetry/feedback-store.d.ts +34 -0
  165. package/dist/telemetry/feedback-store.js +76 -0
  166. package/dist/telemetry/jira-telemetry.d.ts +57 -0
  167. package/dist/telemetry/jira-telemetry.js +68 -0
  168. package/dist/telemetry/migration-telemetry.d.ts +35 -0
  169. package/dist/telemetry/migration-telemetry.js +40 -0
  170. package/dist/telemetry/usage.d.ts +24 -0
  171. package/dist/telemetry/usage.js +80 -0
  172. package/dist/tools/builtin-registrations.d.ts +12 -0
  173. package/dist/tools/builtin-registrations.js +33 -0
  174. package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
  175. package/dist/tools/builtins/gdrive-browse.js +68 -0
  176. package/dist/tools/builtins/image-generate.d.ts +3 -0
  177. package/dist/tools/builtins/image-generate.js +211 -0
  178. package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
  179. package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
  180. package/dist/tools/builtins/unsplash-search.d.ts +3 -0
  181. package/dist/tools/builtins/unsplash-search.js +74 -0
  182. package/dist/tools/executor.d.ts +23 -0
  183. package/dist/tools/executor.js +169 -0
  184. package/dist/tools/index.d.ts +5 -0
  185. package/dist/tools/index.js +5 -0
  186. package/dist/tools/registry.d.ts +21 -0
  187. package/dist/tools/registry.js +75 -0
  188. package/dist/tools/runtime.d.ts +27 -0
  189. package/dist/tools/runtime.js +48 -0
  190. package/dist/tools/schema-validator.d.ts +24 -0
  191. package/dist/tools/schema-validator.js +88 -0
  192. package/dist/tools/types.d.ts +86 -0
  193. package/dist/tools/types.js +1 -0
  194. package/dist/variation-images.d.ts +19 -0
  195. package/dist/variation-images.js +12 -0
  196. package/package.json +78 -0
@@ -0,0 +1,488 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import OpenAI from "openai";
4
+ import { listImages, fileNameToAlt, resolveGdriveFolderId } from "./gdrive-client.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Image generation timing — rolling average for progress estimation
7
+ // ---------------------------------------------------------------------------
8
+ const recentGenDurations = [];
9
+ const MAX_DURATION_HISTORY = 10;
10
+ const DEFAULT_ESTIMATED_MS = 35_000;
11
+ export function recordImageGenDuration(ms) {
12
+ recentGenDurations.push(ms);
13
+ if (recentGenDurations.length > MAX_DURATION_HISTORY)
14
+ recentGenDurations.shift();
15
+ }
16
+ export function estimatedImageGenMs() {
17
+ if (recentGenDurations.length === 0)
18
+ return DEFAULT_ESTIMATED_MS;
19
+ return Math.round(recentGenDurations.reduce((a, b) => a + b, 0) / recentGenDurations.length);
20
+ }
21
+ // ---------------------------------------------------------------------------
22
+ // Query normalisation & keyword extraction
23
+ // ---------------------------------------------------------------------------
24
+ export function normalizeUnsplashQuery(raw) {
25
+ return raw
26
+ .toLowerCase()
27
+ .replace(/[^\p{L}\p{N}\s-]/gu, " ")
28
+ .replace(/\s+/g, " ")
29
+ .trim()
30
+ .slice(0, 80);
31
+ }
32
+ export function extractUnsplashQuery(message) {
33
+ const cleanMessage = message.replace(/\n?\[site context\][\s\S]*?\[\/site context\]\s*$/i, "").trim();
34
+ const aboutBeforeFromUnsplash = cleanMessage.match(/\b(?:about|of|with|for)\s+([^,.!?;\n]+?)\s+(?:from|using|via)\s+unsplash\b/i);
35
+ const fromUnsplashMatch = cleanMessage.match(/\b(?:from|using|via)\s+unsplash\b[^.?!\n]*?(?:showing|of|with|for)?[^\S\n]*([^,.!?;\n]+)/i);
36
+ const unsplashMatch = cleanMessage.match(/\bunsplash\b[^.?!\n]*?(?:showing|of|with|for)\s+([^,.!?;\n]+)/i);
37
+ const replaceImageWith = cleanMessage.match(/\b(?:replace|swap|change|update|set)\s+(?:the\s+)?(?:hero\s+)?(?:image|photo|picture)\s+(?:to|with|by)\s+([^,.!?;\n]+)/i);
38
+ const replaceImageWithGerman = cleanMessage.match(/\b(?:ersetze|ersetzen|aendere|ändere|tausche|tauschen)\s+(?:das|die|den)?\s*(?:hero\s*)?(?:bild|foto|image)\s+(?:durch|mit|zu)\s+([^,.!?;\n]+)/i);
39
+ const quotedWithImageContext = cleanMessage.match(/\b(?:image|photo|picture|bild|foto)\b[^"\n]{0,60}"([^"]+)"/i)?.[1] ??
40
+ cleanMessage.match(/^"([^"]+)"$/)?.[1];
41
+ const candidate = aboutBeforeFromUnsplash?.[1] ??
42
+ fromUnsplashMatch?.[1] ??
43
+ unsplashMatch?.[1] ??
44
+ replaceImageWith?.[1] ??
45
+ replaceImageWithGerman?.[1] ??
46
+ quotedWithImageContext;
47
+ if (!candidate)
48
+ return undefined;
49
+ const cleaned = candidate
50
+ .replace(/\b(an?|the|ein(?:e|en|em|er|es)?|der|die|das|den|dem|des)\s+/gi, "")
51
+ .replace(/\b(image|photo|picture)\b/gi, "")
52
+ .replace(/\b(of|with|showing|for|mit|von|fuer|für)\b/gi, " ")
53
+ .replace(/\s+/g, " ")
54
+ .trim();
55
+ const normalized = normalizeUnsplashQuery(cleaned);
56
+ return normalized.length > 0 ? normalized : undefined;
57
+ }
58
+ /**
59
+ * Detect explicit image generation intent in a message (e.g. "generate a new image", "new photo").
60
+ * Returns true for phrases that clearly request creating/replacing an image, but NOT for
61
+ * property edits like "change the image alt text".
62
+ */
63
+ export function isExplicitImageGenRequest(message) {
64
+ const lower = message.toLowerCase();
65
+ return (/\b(?:generate|create)\b[^.!?\n]*\b(?:image|photo|picture)\b/i.test(lower) ||
66
+ /\bnew\s+(?:hero\s+)?(?:image|photo|picture)\b/i.test(lower) ||
67
+ /\breplace\b[^.!?\n]*\b(?:image|photo|picture)\b/i.test(lower));
68
+ }
69
+ /**
70
+ * Extract a detailed image description/prompt from a user message.
71
+ * Matches patterns like "generate a new image: [full description]".
72
+ * Returns the description (typically a rich prompt for AI image generation) or undefined.
73
+ */
74
+ export function extractImagePromptFromMessage(message) {
75
+ // "generate/create/make a new image: [detailed description]"
76
+ const colonMatch = message.match(/\b(?:generate|create|make|new)\b[^.!?\n]*?\b(?:image|photo|picture)\s*[:]\s*(.+)/is);
77
+ if (colonMatch?.[1]) {
78
+ const desc = colonMatch[1].trim();
79
+ if (desc.length > 20)
80
+ return desc;
81
+ }
82
+ return undefined;
83
+ }
84
+ const IMAGE_QUERY_STOPWORDS = new Set([
85
+ "a", "an", "and", "background", "backgrounds", "different", "for", "from",
86
+ "image", "images", "of", "on", "photo", "photos", "picture", "pictures",
87
+ "the", "types", "unsplash", "various", "varied", "with"
88
+ ]);
89
+ export function imageKeywordsFromQuery(raw, max = 3) {
90
+ const tokens = normalizeUnsplashQuery(raw)
91
+ .split(" ")
92
+ .map((part) => part.trim())
93
+ .filter((part) => part.length >= 3 && !IMAGE_QUERY_STOPWORDS.has(part));
94
+ const unique = Array.from(new Set(tokens));
95
+ return unique.slice(0, max);
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Hero image query builder
99
+ // ---------------------------------------------------------------------------
100
+ export function heroImageQueryFromContext(args) {
101
+ // Strip [site context]...[/site context] metadata to avoid extracting metadata as query
102
+ const cleanMessage = args.message.replace(/\n?\[site context\][\s\S]*?\[\/site context\]\s*$/i, "").trim();
103
+ const explicit = extractUnsplashQuery(cleanMessage);
104
+ if (explicit)
105
+ return explicit;
106
+ const patch = args.patchCandidate;
107
+ const patchHeading = typeof patch?.heading === "string" ? patch.heading : "";
108
+ const patchSubheading = typeof patch?.subheading === "string" ? patch.subheading : "";
109
+ const patchAlt = typeof patch?.imageAlt === "string" ? patch.imageAlt : "";
110
+ const targetProps = args.targetBlock.props;
111
+ const heading = typeof targetProps.heading === "string" ? targetProps.heading : "";
112
+ const subheading = typeof targetProps.subheading === "string" ? targetProps.subheading : "";
113
+ const alt = typeof targetProps.imageAlt === "string" ? targetProps.imageAlt : "";
114
+ const candidates = [patchAlt, patchHeading, patchSubheading, heading, subheading, alt, args.currentPage.title]
115
+ .map((entry) => normalizeUnsplashQuery(entry))
116
+ .filter(Boolean);
117
+ for (const candidate of candidates) {
118
+ const terms = imageKeywordsFromQuery(candidate, 4);
119
+ if (terms.length > 0)
120
+ return terms.join(" ");
121
+ }
122
+ const fallback = normalizeUnsplashQuery(args.currentPage.title || args.targetBlock.type || "hero image");
123
+ return fallback || "hero image";
124
+ }
125
+ export function deriveVariationImageIntent(args) {
126
+ const fromMessage = extractUnsplashQuery(args.message);
127
+ const props = args.block.props;
128
+ const headingLike = typeof props.heading === "string"
129
+ ? props.heading
130
+ : typeof props.title === "string"
131
+ ? props.title
132
+ : typeof props.subheading === "string"
133
+ ? props.subheading
134
+ : "";
135
+ const fallback = normalizeUnsplashQuery([headingLike, args.block.type].filter(Boolean).join(" "));
136
+ const baseQuery = fromMessage ?? (fallback.length > 0 ? fallback : "abstract hero background");
137
+ const lowerMessage = args.message.toLowerCase();
138
+ const varyBackgrounds = /\bdifferent\s+(?:types?\s+of\s+)?backgrounds?\b/.test(lowerMessage) ||
139
+ /\bunique\s+backgrounds?\b/.test(lowerMessage) ||
140
+ /\bvaried\s+backgrounds?\b/.test(lowerMessage) ||
141
+ /\bvarious\s+backgrounds?\b/.test(lowerMessage);
142
+ const styleTerms = [];
143
+ if (/\bclose[\s-]?up\b/.test(lowerMessage) || /\bmacro\b/.test(lowerMessage))
144
+ styleTerms.push("close up");
145
+ if (/\bstudio\b/.test(lowerMessage))
146
+ styleTerms.push("studio lighting");
147
+ if (/\bfood\b/.test(lowerMessage) || /\bproduct\b/.test(lowerMessage))
148
+ styleTerms.push("food photography");
149
+ if (/\bdark\b/.test(lowerMessage))
150
+ styleTerms.push("dark moody");
151
+ if (/\bminimal\b/.test(lowerMessage))
152
+ styleTerms.push("minimal");
153
+ if (/\boutdoor\b/.test(lowerMessage) || /\bnature\b/.test(lowerMessage))
154
+ styleTerms.push("natural light");
155
+ const backgroundTerms = Array.from(new Set(Array.from(lowerMessage.matchAll(/\b(?:background|backgrounds)\s+(?:like|such as|with|in)?\s*([a-z0-9\s-]{3,45})/gi))
156
+ .map((match) => normalizeUnsplashQuery(match[1] ?? ""))
157
+ .filter(Boolean))).slice(0, 5);
158
+ const envDefault = (process.env.VARIATION_DEFAULT_IMAGE_SOURCE?.trim().toLowerCase() ?? "unsplash");
159
+ const resolvedDefault = envDefault === "ai" || envDefault === "llm" || envDefault === "gemini" || envDefault === "openai"
160
+ ? "llm"
161
+ : "unsplash";
162
+ const provider = /\bunsplash\b/.test(lowerMessage)
163
+ ? "unsplash"
164
+ : /\b(llm|openai|gemini|nano[-\s]?banana|gpt[-\s]?image(?:[-\s]?\d)?|ai[-\s]?generated|generated backgrounds?|synthetic backgrounds?)\b/.test(lowerMessage)
165
+ ? "llm"
166
+ : resolvedDefault;
167
+ return {
168
+ baseQuery,
169
+ subjectKeywords: imageKeywordsFromQuery(baseQuery, 4),
170
+ varyBackgrounds,
171
+ styleTerms,
172
+ provider,
173
+ backgroundTerms
174
+ };
175
+ }
176
+ export function buildVariationImageQuery(intent, variationIndex) {
177
+ const base = [intent.baseQuery, ...intent.styleTerms].filter(Boolean).join(" ");
178
+ if (!intent.varyBackgrounds)
179
+ return normalizeUnsplashQuery(base);
180
+ const backgrounds = ["studio background", "wood table background", "kitchen background", "dark background", "outdoor background"];
181
+ const chosen = backgrounds[variationIndex % backgrounds.length];
182
+ return normalizeUnsplashQuery(`${base} ${chosen}`);
183
+ }
184
+ export function buildVariationImagePrompt(args) {
185
+ const fallbackBackgrounds = [
186
+ "clean neutral studio gradient",
187
+ "cozy wooden table scene",
188
+ "bright kitchen countertop",
189
+ "dark cinematic backdrop",
190
+ "outdoor natural light"
191
+ ];
192
+ const customBackgrounds = args.intent.backgroundTerms;
193
+ const backgrounds = customBackgrounds.length > 0 ? customBackgrounds : fallbackBackgrounds;
194
+ const chosenBackground = args.intent.varyBackgrounds ? backgrounds[args.variationIndex % backgrounds.length] : backgrounds[0];
195
+ const subject = args.intent.baseQuery || `${args.blockType} hero visual`;
196
+ const style = args.intent.styleTerms.length > 0 ? args.intent.styleTerms.join(", ") : "natural product photography";
197
+ const lines = [
198
+ "Use case: precise-object-edit",
199
+ `Asset type: website ${args.blockType} image`
200
+ ];
201
+ if (args.pageContext)
202
+ lines.push(`Page: ${args.pageContext}`);
203
+ if (args.sectionContext)
204
+ lines.push(`Section: ${args.sectionContext}`);
205
+ lines.push(`Primary request: create a high-quality hero image featuring ${subject}`, `Scene/background: ${chosenBackground}`, `Style/medium: ${style}`, "Composition/framing: landscape composition with clear focal subject and breathing room", "Lighting/mood: clean, editorial, realistic", "Constraints: no text, no logos, no watermark", "Avoid: clutter, over-saturation, distorted objects");
206
+ return lines.join("\n");
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // Image generation & resolution
210
+ // ---------------------------------------------------------------------------
211
+ export async function generateVariationImageWithOpenAI(args) {
212
+ if (!process.env.OPENAI_API_KEY) {
213
+ args.log?.warn({ event: "openai_image_skip", reason: "no_api_key" }, "Skipping OpenAI image generation: OPENAI_API_KEY not set");
214
+ return null;
215
+ }
216
+ const model = args.model ?? process.env.OPENAI_IMAGE_MODEL?.trim() ?? "gpt-image-1-mini";
217
+ const size = args.size ?? "1536x1024";
218
+ const background = args.background ?? "auto";
219
+ const outputFormat = args.outputFormat ?? "png";
220
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
221
+ const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(process.cwd(), "../../.data/generated-images");
222
+ const orchestratorPublicOrigin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
223
+ args.log?.info({ event: "openai_image_start", model, size, background, outputFormat, promptLength: args.prompt.length }, "Starting OpenAI image generation");
224
+ const genStartMs = Date.now();
225
+ try {
226
+ // Forward AbortSignal to the OpenAI SDK so the HTTP request is cancelled
227
+ // when the tool executor's timeout (or the upstream chat request) aborts.
228
+ // Without this, a hung OpenAI API call would wedge the deferred image
229
+ // resolution loop indefinitely and leave the preview on "Generating image…".
230
+ const result = await client.images.generate({
231
+ model,
232
+ prompt: args.prompt,
233
+ size: size,
234
+ background,
235
+ output_format: outputFormat,
236
+ }, args.signal ? { signal: args.signal } : undefined);
237
+ const durationMs = Date.now() - genStartMs;
238
+ const image = result.data?.[0];
239
+ let bytes = null;
240
+ if (typeof image?.b64_json === "string" && image.b64_json.length > 0) {
241
+ bytes = Buffer.from(image.b64_json, "base64");
242
+ }
243
+ else if (typeof image?.url === "string" && image.url.length > 0) {
244
+ const fetched = await fetch(image.url, args.signal ? { signal: args.signal } : undefined);
245
+ if (fetched.ok) {
246
+ const arrayBuffer = await fetched.arrayBuffer();
247
+ bytes = Buffer.from(arrayBuffer);
248
+ }
249
+ }
250
+ if (!bytes || bytes.byteLength === 0)
251
+ return null;
252
+ await mkdir(generatedImageDir, { recursive: true });
253
+ const ext = outputFormat === "jpeg" ? "jpg" : outputFormat;
254
+ const fileName = `var_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
255
+ await writeFile(resolve(generatedImageDir, fileName), bytes);
256
+ args.log?.info({ event: "openai_image_done", model, size, durationMs, fileSizeBytes: bytes.byteLength }, `OpenAI image generated in ${durationMs}ms (model=${model})`);
257
+ return {
258
+ url: `${orchestratorPublicOrigin}/generated-images/${fileName}`,
259
+ alt: args.altText,
260
+ query: args.prompt
261
+ };
262
+ }
263
+ catch (err) {
264
+ const failDurationMs = Date.now() - genStartMs;
265
+ args.log?.warn({ event: "openai_image_error", model, durationMs: failDurationMs, error: err instanceof Error ? err.message : String(err) }, "OpenAI image generation failed");
266
+ return null;
267
+ }
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // Shared image save utility
271
+ // ---------------------------------------------------------------------------
272
+ export async function saveGeneratedImage(bytes, prefix = "gen", ext = "png") {
273
+ const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(process.cwd(), "../../.data/generated-images");
274
+ const orchestratorPublicOrigin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
275
+ const fileName = `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
276
+ await mkdir(generatedImageDir, { recursive: true });
277
+ await writeFile(resolve(generatedImageDir, fileName), bytes);
278
+ return { fileName, url: `${orchestratorPublicOrigin}/generated-images/${fileName}` };
279
+ }
280
+ /** Extract the first inline image from a Gemini response, save to disk, return URL. */
281
+ export async function saveGeminiInlineImage(parts, prefix = "gen") {
282
+ for (const part of parts) {
283
+ if (part.inlineData?.data) {
284
+ const mimeType = part.inlineData.mimeType ?? "image/png";
285
+ const bytes = Buffer.from(part.inlineData.data, "base64");
286
+ if (bytes.byteLength === 0)
287
+ continue;
288
+ const ext = mimeType.includes("jpeg") || mimeType.includes("jpg") ? "jpg" : "png";
289
+ const { url } = await saveGeneratedImage(bytes, prefix, ext);
290
+ return { url, mimeType };
291
+ }
292
+ }
293
+ return null;
294
+ }
295
+ // ---------------------------------------------------------------------------
296
+ // Gemini client singleton + aspect ratios
297
+ // ---------------------------------------------------------------------------
298
+ export const GEMINI_ASPECT_RATIOS = {
299
+ landscape: "3:2",
300
+ square: "1:1",
301
+ portrait: "2:3"
302
+ };
303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
+ let _geminiClient = null;
305
+ export async function getGeminiClient() {
306
+ if (_geminiClient)
307
+ return _geminiClient;
308
+ const { GoogleGenAI } = await import("@google/genai");
309
+ _geminiClient = new GoogleGenAI({ apiKey: process.env.GOOGLE_GENAI_API_KEY });
310
+ return _geminiClient;
311
+ }
312
+ export function getGeminiImageModel() {
313
+ return process.env.GOOGLE_GENAI_IMAGE_MODEL?.trim() || "gemini-3.1-flash-lite-image";
314
+ }
315
+ // ---------------------------------------------------------------------------
316
+ // Gemini native image generation
317
+ // ---------------------------------------------------------------------------
318
+ export async function generateVariationImageWithGemini(args) {
319
+ if (!process.env.GOOGLE_GENAI_API_KEY?.trim()) {
320
+ args.log?.warn({ event: "gemini_image_skip", reason: "no_api_key" }, "Skipping Gemini image generation: GOOGLE_GENAI_API_KEY not set");
321
+ return null;
322
+ }
323
+ const ai = await getGeminiClient();
324
+ const model = args.model ?? getGeminiImageModel();
325
+ const geminiAspectRatio = GEMINI_ASPECT_RATIOS[args.aspectRatio ?? "landscape"] ?? "3:2";
326
+ const imageSize = args.quality === "final" ? "2K" : "1K";
327
+ args.log?.info({ event: "gemini_image_start", model, aspectRatio: geminiAspectRatio, imageSize, promptLength: args.prompt.length }, "Starting Gemini image generation");
328
+ // Gemini has no native transparency param — use prompt hint
329
+ const effectivePrompt = args.background === "transparent"
330
+ ? `${args.prompt}\n\nIMPORTANT: Generate this image on a fully transparent background with no background elements.`
331
+ : args.prompt;
332
+ const genStartMs = Date.now();
333
+ try {
334
+ // The @google/genai SDK does not expose an AbortSignal option, so we
335
+ // race the request against the signal manually. The executor enforces
336
+ // a hard timeout on top of this as a last line of defense.
337
+ const requestPromise = ai.models.generateContent({
338
+ model,
339
+ contents: effectivePrompt,
340
+ config: {
341
+ responseModalities: ["TEXT", "IMAGE"],
342
+ imageConfig: { aspectRatio: geminiAspectRatio, imageSize }
343
+ }
344
+ });
345
+ const response = args.signal
346
+ ? await Promise.race([
347
+ requestPromise,
348
+ new Promise((_resolve, reject) => {
349
+ if (args.signal.aborted) {
350
+ reject(new Error("Gemini image request aborted"));
351
+ return;
352
+ }
353
+ args.signal.addEventListener("abort", () => reject(new Error("Gemini image request aborted")), { once: true });
354
+ })
355
+ ])
356
+ : await requestPromise;
357
+ const durationMs = Date.now() - genStartMs;
358
+ const parts = response.candidates?.[0]?.content?.parts;
359
+ if (!parts)
360
+ return null;
361
+ const saved = await saveGeminiInlineImage(parts, "var");
362
+ if (!saved)
363
+ return null;
364
+ args.log?.info({ event: "gemini_image_done", model, aspectRatio: geminiAspectRatio, durationMs }, `Gemini image generated in ${durationMs}ms (model=${model})`);
365
+ return { url: saved.url, alt: args.altText, query: args.prompt };
366
+ }
367
+ catch (err) {
368
+ const failDurationMs = Date.now() - genStartMs;
369
+ args.log?.warn({ event: "gemini_image_error", model, durationMs: failDurationMs, error: err instanceof Error ? err.message : String(err) }, "Gemini image generation failed");
370
+ return null;
371
+ }
372
+ }
373
+ export async function resolveUnsplashImage(query, options, logContext) {
374
+ const safeQuery = normalizeUnsplashQuery(query);
375
+ if (!safeQuery)
376
+ return null;
377
+ const variationIndex = typeof options?.variationIndex === "number" && Number.isInteger(options.variationIndex) && options.variationIndex >= 0
378
+ ? options.variationIndex
379
+ : 0;
380
+ const page = variationIndex + 1;
381
+ const log = logContext?.logger;
382
+ const accessKey = process.env.UNSPLASH_ACCESS_KEY?.trim();
383
+ log?.info({
384
+ event: "hero_image_resolve_start",
385
+ chatRequestId: logContext?.chatRequestId,
386
+ query: safeQuery,
387
+ variationIndex,
388
+ hasUnsplashKey: Boolean(accessKey),
389
+ subjectKeywords: options?.subjectKeywords ?? []
390
+ }, "Resolving hero image candidate");
391
+ if (accessKey) {
392
+ try {
393
+ const endpoint = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(safeQuery)}&orientation=landscape&per_page=8&page=${page}&content_filter=high`;
394
+ const res = await fetch(endpoint, {
395
+ headers: {
396
+ Authorization: `Client-ID ${accessKey}`,
397
+ "Accept-Version": "v1"
398
+ }
399
+ });
400
+ function toImage(result) {
401
+ const baseUrl = typeof result?.urls?.regular === "string"
402
+ ? result.urls.regular
403
+ : typeof result?.urls?.full === "string"
404
+ ? result.urls.full
405
+ : undefined;
406
+ if (!baseUrl)
407
+ return null;
408
+ const joiner = baseUrl.includes("?") ? "&" : "?";
409
+ const url = `${baseUrl}${joiner}auto=format&fit=crop&w=1600&q=80`;
410
+ const altCandidate = typeof result?.alt_description === "string"
411
+ ? result.alt_description
412
+ : typeof result?.description === "string"
413
+ ? result.description
414
+ : "";
415
+ return {
416
+ url,
417
+ alt: altCandidate.trim() || `Unsplash photo of ${safeQuery}`,
418
+ query: safeQuery
419
+ };
420
+ }
421
+ if (res.ok) {
422
+ const payload = (await res.json());
423
+ const list = Array.isArray(payload.results) ? payload.results : [];
424
+ const subjectKeywords = Array.isArray(options?.subjectKeywords) && options?.subjectKeywords.length > 0
425
+ ? options.subjectKeywords.map((k) => k.toLowerCase())
426
+ : imageKeywordsFromQuery(safeQuery, 2);
427
+ const usedImageUrls = options?.usedImageUrls;
428
+ const matched = subjectKeywords.length > 0
429
+ ? list.filter((item) => {
430
+ const haystack = `${typeof item.alt_description === "string" ? item.alt_description : ""} ${typeof item.description === "string" ? item.description : ""}`.toLowerCase();
431
+ return subjectKeywords.some((keyword) => haystack.includes(keyword));
432
+ })
433
+ : [];
434
+ const ordered = matched.length > 0 ? [...matched, ...list.filter((item) => !matched.includes(item))] : list;
435
+ for (const item of ordered) {
436
+ const next = toImage(item);
437
+ if (!next)
438
+ continue;
439
+ if (usedImageUrls && usedImageUrls.has(next.url))
440
+ continue;
441
+ log?.info({
442
+ event: "hero_image_resolve_success",
443
+ chatRequestId: logContext?.chatRequestId,
444
+ provider: "unsplash_api",
445
+ query: safeQuery,
446
+ url: next.url,
447
+ alt: next.alt
448
+ }, "Resolved hero image from Unsplash API");
449
+ return next;
450
+ }
451
+ }
452
+ }
453
+ catch {
454
+ // Fall through to source URL fallback.
455
+ log?.warn({
456
+ event: "hero_image_resolve_unsplash_error",
457
+ chatRequestId: logContext?.chatRequestId,
458
+ query: safeQuery
459
+ }, "Unsplash API lookup failed; falling back to seeded source");
460
+ }
461
+ }
462
+ return null;
463
+ }
464
+ // ---------------------------------------------------------------------------
465
+ // Google Drive image resolution (parallel to resolveUnsplashImage)
466
+ // ---------------------------------------------------------------------------
467
+ export async function resolveGdriveImage(query, options) {
468
+ const resolvedFolderId = resolveGdriveFolderId(options?.folderId);
469
+ if (!resolvedFolderId)
470
+ return null;
471
+ const orchestratorPublicOrigin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
472
+ const log = options?.logger;
473
+ const safeQuery = normalizeUnsplashQuery(query);
474
+ const files = await listImages(resolvedFolderId, safeQuery || undefined, log, 1);
475
+ if (files.length === 0)
476
+ return null;
477
+ const first = files[0];
478
+ const url = `${orchestratorPublicOrigin}/gdrive/images/${first.id}`;
479
+ const alt = fileNameToAlt(first.name);
480
+ log?.info({
481
+ event: "gdrive_image_resolved",
482
+ chatRequestId: options?.chatRequestId,
483
+ query: safeQuery,
484
+ fileId: first.id,
485
+ fileName: first.name
486
+ }, "Resolved image from Google Drive");
487
+ return { url, alt, query: safeQuery };
488
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Approval keyword matcher for Jira comments.
3
+ *
4
+ * Used by the webhook dispatcher and poller to decide whether a reporter's
5
+ * comment signals "go ahead" (in To Do → start editing, in In Review → publish).
6
+ *
7
+ * Kept deliberately small and keyword-based. LLM-based intent detection would
8
+ * be overkill here — the set of things a reporter types to mean "yes" is small
9
+ * and we want fast, deterministic routing.
10
+ */
11
+ /**
12
+ * Lowercase approval phrases. Matched as whole tokens (word boundaries) so
13
+ * "confirmation" does not match "confirm" and "goodbye" does not match "go".
14
+ */
15
+ export declare const APPROVAL_KEYWORDS: readonly string[];
16
+ /**
17
+ * Return true when the comment text contains an approval keyword as a whole token.
18
+ *
19
+ * Only the first few hundred characters are scanned — reporters commonly lead
20
+ * with "ok, also one more thing...", so longer prose doesn't need to match.
21
+ */
22
+ export declare function isApprovalComment(text: unknown): boolean;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Approval keyword matcher for Jira comments.
3
+ *
4
+ * Used by the webhook dispatcher and poller to decide whether a reporter's
5
+ * comment signals "go ahead" (in To Do → start editing, in In Review → publish).
6
+ *
7
+ * Kept deliberately small and keyword-based. LLM-based intent detection would
8
+ * be overkill here — the set of things a reporter types to mean "yes" is small
9
+ * and we want fast, deterministic routing.
10
+ */
11
+ /**
12
+ * Lowercase approval phrases. Matched as whole tokens (word boundaries) so
13
+ * "confirmation" does not match "confirm" and "goodbye" does not match "go".
14
+ */
15
+ export const APPROVAL_KEYWORDS = [
16
+ "proceed",
17
+ "go",
18
+ "go ahead",
19
+ "yes",
20
+ "ok",
21
+ "okay",
22
+ "approved",
23
+ "approve",
24
+ "lgtm",
25
+ "ship it",
26
+ "looks good",
27
+ "confirm",
28
+ "confirmed",
29
+ "continue",
30
+ "publish",
31
+ ];
32
+ function escapeRegex(value) {
33
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
+ }
35
+ // Build once — case-insensitive, word-boundary on both sides, handles multi-word
36
+ // phrases by escaping the space too.
37
+ const APPROVAL_REGEX = new RegExp(`(?:^|\\W)(${APPROVAL_KEYWORDS.map(escapeRegex).join("|")})(?:$|\\W)`, "i");
38
+ /**
39
+ * Return true when the comment text contains an approval keyword as a whole token.
40
+ *
41
+ * Only the first few hundred characters are scanned — reporters commonly lead
42
+ * with "ok, also one more thing...", so longer prose doesn't need to match.
43
+ */
44
+ export function isApprovalComment(text) {
45
+ if (typeof text !== "string")
46
+ return false;
47
+ const trimmed = text.trim();
48
+ if (!trimmed)
49
+ return false;
50
+ return APPROVAL_REGEX.test(trimmed.slice(0, 500));
51
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * JIRA REST API client.
3
+ *
4
+ * Supports both JIRA Cloud (Basic auth: email + API token) and
5
+ * JIRA Server/Data Center (Bearer token / PAT).
6
+ *
7
+ * Uses only native fetch — no external HTTP library needed.
8
+ */
9
+ import type { JiraIssue, JiraAttachment, JiraConfig } from "./jira-types.ts";
10
+ export declare class JiraClient {
11
+ private readonly baseUrl;
12
+ private readonly authHeader;
13
+ private readonly isCloud;
14
+ constructor(config: Pick<JiraConfig, "baseUrl" | "email" | "apiToken">);
15
+ private request;
16
+ getIssue(issueKey: string): Promise<JiraIssue>;
17
+ getIssueAttachments(issueKey: string): Promise<JiraAttachment[]>;
18
+ /**
19
+ * Download an attachment binary. The `content` URL from JIRA requires the
20
+ * same auth header, so we can't just fetch it unauthenticated.
21
+ */
22
+ downloadAttachment(contentUrl: string): Promise<Buffer>;
23
+ addComment(issueKey: string, markdown: string): Promise<void>;
24
+ /**
25
+ * Transition an issue to a target status by name.
26
+ * Looks up available transitions to find the matching one.
27
+ */
28
+ transitionIssue(issueKey: string, targetStatusName: string): Promise<boolean>;
29
+ /**
30
+ * Assign an issue to a user by account ID (Cloud) or username (Server).
31
+ */
32
+ assignIssue(issueKey: string, assignee: {
33
+ accountId?: string;
34
+ name?: string;
35
+ }): Promise<void>;
36
+ searchJql(jql: string, maxResults?: number): Promise<JiraIssue[]>;
37
+ }
38
+ /**
39
+ * Convert simple markdown text to Atlassian Document Format (ADF).
40
+ * Handles paragraphs, bulletLists, tables, bold (**text**), italic (_text_),
41
+ * inline code (`text`), and links ([label](url)). Strips HTML comments.
42
+ * Good enough for status/clarification comments.
43
+ */
44
+ export declare function markdownToAdf(markdown: string): object;