@aglyn/plugins-ai 1.0.0-beta.285 → 1.0.0-beta.288

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 (228) hide show
  1. package/package.json +17 -17
  2. package/src/lib/assets/site-styles/nonprofit.webp +0 -0
  3. package/src/lib/assets/site-styles/professional.webp +0 -0
  4. package/src/lib/assets/site-styles/store.webp +0 -0
  5. package/src/lib/assets/site-styles/trades.webp +0 -0
  6. package/src/lib/components/ai-theme-proposal-card.component.js +12 -3
  7. package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
  8. package/src/lib/declarations.js +5 -2
  9. package/src/lib/declarations.js.map +1 -1
  10. package/src/lib/declarations.server.js +12 -0
  11. package/src/lib/declarations.server.js.map +1 -1
  12. package/src/lib/jobs/ai-build-capabilities.d.ts +3 -1
  13. package/src/lib/jobs/ai-build-capabilities.js +27 -2
  14. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  15. package/src/lib/jobs/ai-business-profile-prefill.js +6 -3
  16. package/src/lib/jobs/ai-business-profile-prefill.js.map +1 -1
  17. package/src/lib/jobs/ai-image-reads.d.ts +59 -0
  18. package/src/lib/jobs/ai-image-reads.js +138 -0
  19. package/src/lib/jobs/ai-image-reads.js.map +1 -0
  20. package/src/lib/jobs/ai-job-build-step.d.ts +29 -0
  21. package/src/lib/jobs/ai-job-build-step.js +83 -12
  22. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  23. package/src/lib/jobs/ai-job-duplicate-step.d.ts +2 -1
  24. package/src/lib/jobs/ai-job-duplicate-step.js +6 -6
  25. package/src/lib/jobs/ai-job-duplicate-step.js.map +1 -1
  26. package/src/lib/jobs/ai-job-import-step.d.ts +4 -2
  27. package/src/lib/jobs/ai-job-import-step.js +15 -11
  28. package/src/lib/jobs/ai-job-import-step.js.map +1 -1
  29. package/src/lib/jobs/ai-job-insight-step.d.ts +10 -0
  30. package/src/lib/jobs/ai-job-insight-step.js +23 -5
  31. package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
  32. package/src/lib/jobs/ai-job-layout-language.js +2 -1
  33. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  34. package/src/lib/jobs/ai-job-media-step.d.ts +2 -1
  35. package/src/lib/jobs/ai-job-media-step.js +6 -8
  36. package/src/lib/jobs/ai-job-media-step.js.map +1 -1
  37. package/src/lib/jobs/ai-job-navigation-step.d.ts +2 -1
  38. package/src/lib/jobs/ai-job-navigation-step.js +7 -9
  39. package/src/lib/jobs/ai-job-navigation-step.js.map +1 -1
  40. package/src/lib/jobs/ai-job-page-language.js +7 -1
  41. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  42. package/src/lib/jobs/ai-job-page-sections.js +16 -3
  43. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  44. package/src/lib/jobs/ai-job-page-step.js +9 -1
  45. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  46. package/src/lib/jobs/ai-job-plan-step.d.ts +30 -8
  47. package/src/lib/jobs/ai-job-plan-step.js +78 -13
  48. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  49. package/src/lib/jobs/ai-job-privacy-step.d.ts +4 -2
  50. package/src/lib/jobs/ai-job-privacy-step.js +14 -15
  51. package/src/lib/jobs/ai-job-privacy-step.js.map +1 -1
  52. package/src/lib/jobs/ai-job-products-step.js +5 -3
  53. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-settings-step.d.ts +2 -1
  55. package/src/lib/jobs/ai-job-settings-step.js +7 -8
  56. package/src/lib/jobs/ai-job-settings-step.js.map +1 -1
  57. package/src/lib/jobs/ai-job-site-content.d.ts +34 -1
  58. package/src/lib/jobs/ai-job-site-content.js +67 -8
  59. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  60. package/src/lib/jobs/ai-job-site-datasets.d.ts +53 -3
  61. package/src/lib/jobs/ai-job-site-datasets.js +300 -30
  62. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  63. package/src/lib/jobs/ai-job-site-step.js +6 -3
  64. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  65. package/src/lib/jobs/ai-job-site-store-pages.js +3 -3
  66. package/src/lib/jobs/ai-job-site-store-pages.js.map +1 -1
  67. package/src/lib/jobs/ai-job-text-step.js +2 -1
  68. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  69. package/src/lib/jobs/ai-job-tracking-step.d.ts +2 -1
  70. package/src/lib/jobs/ai-job-tracking-step.js +7 -8
  71. package/src/lib/jobs/ai-job-tracking-step.js.map +1 -1
  72. package/src/lib/jobs/ai-jobs-beat.d.ts +11 -7
  73. package/src/lib/jobs/ai-jobs-beat.js +18 -8
  74. package/src/lib/jobs/ai-jobs-beat.js.map +1 -1
  75. package/src/lib/jobs/ai-jobs-notify.d.ts +66 -1
  76. package/src/lib/jobs/ai-jobs-notify.js +136 -3
  77. package/src/lib/jobs/ai-jobs-notify.js.map +1 -1
  78. package/src/lib/jobs/ai-layout-stock-photos.d.ts +155 -7
  79. package/src/lib/jobs/ai-layout-stock-photos.js +1517 -220
  80. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  81. package/src/lib/jobs/ai-product-image.d.ts +14 -0
  82. package/src/lib/jobs/ai-product-image.js +73 -21
  83. package/src/lib/jobs/ai-product-image.js.map +1 -1
  84. package/src/lib/jobs/ai-theme-brand-inputs.d.ts +9 -1
  85. package/src/lib/jobs/ai-theme-brand-inputs.js +28 -2
  86. package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
  87. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.d.ts +175 -0
  88. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.js +601 -0
  89. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.js.map +1 -0
  90. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.d.ts +46 -0
  91. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.js +2862 -0
  92. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.js.map +1 -0
  93. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.d.ts +74 -0
  94. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.js +888 -0
  95. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.js.map +1 -0
  96. package/src/lib/jobs/fixtures/ai-site-agl4092-datasets.d.ts +120 -0
  97. package/src/lib/jobs/fixtures/ai-site-agl4092-datasets.js +410 -0
  98. package/src/lib/jobs/fixtures/ai-site-agl4092-datasets.js.map +1 -0
  99. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.d.ts +29 -0
  100. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.js +81 -0
  101. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.js.map +1 -0
  102. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.d.ts +87 -0
  103. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.js +225 -0
  104. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.js.map +1 -0
  105. package/src/lib/jobs/fixtures/ai-stock-photo-agl4092-recordings.d.ts +26 -0
  106. package/src/lib/jobs/fixtures/ai-stock-photo-agl4092-recordings.js +714 -0
  107. package/src/lib/jobs/fixtures/ai-stock-photo-agl4092-recordings.js.map +1 -0
  108. package/src/lib/layout-language/ai-layout-compiler.d.ts +11 -0
  109. package/src/lib/layout-language/ai-layout-compiler.js +194 -18
  110. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  111. package/src/lib/layout-language/ai-layout-copy.d.ts +37 -0
  112. package/src/lib/layout-language/ai-layout-copy.js +383 -3
  113. package/src/lib/layout-language/ai-layout-copy.js.map +1 -1
  114. package/src/lib/layout-language/ai-layout-design.d.ts +19 -0
  115. package/src/lib/layout-language/ai-layout-design.js +31 -11
  116. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  117. package/src/lib/layout-language/ai-layout-listings.d.ts +7 -1
  118. package/src/lib/layout-language/ai-layout-listings.js +9 -1
  119. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  120. package/src/lib/layout-language/ai-layout-pictures.d.ts +39 -1
  121. package/src/lib/layout-language/ai-layout-pictures.js +141 -4
  122. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  123. package/src/lib/layout-language/ai-layout-storefront.d.ts +18 -4
  124. package/src/lib/layout-language/ai-layout-storefront.js +100 -20
  125. package/src/lib/layout-language/ai-layout-storefront.js.map +1 -1
  126. package/src/lib/model/ai-build-job.d.ts +7 -0
  127. package/src/lib/model/ai-build-job.js +36 -7
  128. package/src/lib/model/ai-build-job.js.map +1 -1
  129. package/src/lib/model/ai-build-plan.d.ts +28 -4
  130. package/src/lib/model/ai-build-plan.js +49 -9
  131. package/src/lib/model/ai-build-plan.js.map +1 -1
  132. package/src/lib/model/ai-build-progress.js +3 -1
  133. package/src/lib/model/ai-build-progress.js.map +1 -1
  134. package/src/lib/model/ai-job-notice.d.ts +8 -0
  135. package/src/lib/model/ai-job-notice.js +33 -0
  136. package/src/lib/model/ai-job-notice.js.map +1 -1
  137. package/src/lib/model/ai-job-plan-lines.d.ts +2 -1
  138. package/src/lib/model/ai-job-plan-lines.js +6 -1
  139. package/src/lib/model/ai-job-plan-lines.js.map +1 -1
  140. package/src/lib/model/ai-jobs.types.d.ts +8 -0
  141. package/src/lib/model/ai-jobs.types.js.map +1 -1
  142. package/src/lib/model/ai-permissions.d.ts +13 -0
  143. package/src/lib/model/ai-permissions.js +29 -1
  144. package/src/lib/model/ai-permissions.js.map +1 -1
  145. package/src/lib/model/ai-plan-said-sections.d.ts +12 -3
  146. package/src/lib/model/ai-plan-said-sections.js +22 -5
  147. package/src/lib/model/ai-plan-said-sections.js.map +1 -1
  148. package/src/lib/model/ai-products.d.ts +18 -0
  149. package/src/lib/model/ai-products.js +32 -0
  150. package/src/lib/model/ai-products.js.map +1 -1
  151. package/src/lib/model/ai-site-context.d.ts +6 -2
  152. package/src/lib/model/ai-site-context.js +8 -2
  153. package/src/lib/model/ai-site-context.js.map +1 -1
  154. package/src/lib/model/ai-site-inspiration.d.ts +3 -2
  155. package/src/lib/model/ai-site-inspiration.js +3 -2
  156. package/src/lib/model/ai-site-inspiration.js.map +1 -1
  157. package/src/lib/plugin.js +4 -13
  158. package/src/lib/plugin.js.map +1 -1
  159. package/src/lib/providers/anthropic.js +9 -2
  160. package/src/lib/providers/anthropic.js.map +1 -1
  161. package/src/lib/providers/catalog.js +14 -5
  162. package/src/lib/providers/catalog.js.map +1 -1
  163. package/src/lib/providers/contract.d.ts +24 -1
  164. package/src/lib/providers/contract.js.map +1 -1
  165. package/src/lib/providers/routing.js +5 -2
  166. package/src/lib/providers/routing.js.map +1 -1
  167. package/src/lib/runtime/ai-dataset-generation.d.ts +120 -1
  168. package/src/lib/runtime/ai-dataset-generation.js +352 -3
  169. package/src/lib/runtime/ai-dataset-generation.js.map +1 -1
  170. package/src/lib/runtime/ai-dev-replay.d.ts +1 -0
  171. package/src/lib/runtime/ai-dev-replay.js +4 -2
  172. package/src/lib/runtime/ai-dev-replay.js.map +1 -1
  173. package/src/lib/runtime/ai-doctrine-validators.d.ts +47 -3
  174. package/src/lib/runtime/ai-doctrine-validators.js +345 -28
  175. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  176. package/src/lib/runtime/ai-doctrine.d.ts +5 -4
  177. package/src/lib/runtime/ai-doctrine.js +10 -3
  178. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  179. package/src/lib/runtime/ai-icon-library.d.ts +27 -1
  180. package/src/lib/runtime/ai-icon-library.js +30 -3
  181. package/src/lib/runtime/ai-icon-library.js.map +1 -1
  182. package/src/lib/runtime/ai-products-generation.d.ts +11 -1
  183. package/src/lib/runtime/ai-products-generation.js +51 -4
  184. package/src/lib/runtime/ai-products-generation.js.map +1 -1
  185. package/src/lib/runtime/assist-answer-cache.d.ts +2 -0
  186. package/src/lib/runtime/assist-answer-cache.js +5 -2
  187. package/src/lib/runtime/assist-answer-cache.js.map +1 -1
  188. package/src/lib/server/ai-allotments.js +7 -2
  189. package/src/lib/server/ai-allotments.js.map +1 -1
  190. package/src/lib/server/ai-experiment-versions.js +5 -5
  191. package/src/lib/server/ai-experiment-versions.js.map +1 -1
  192. package/src/lib/server/ai-host-permissions-roster.d.ts +25 -0
  193. package/src/lib/server/ai-host-permissions-roster.js +33 -0
  194. package/src/lib/server/ai-host-permissions-roster.js.map +1 -0
  195. package/src/lib/server/ai-host-permissions.js +8 -6
  196. package/src/lib/server/ai-host-permissions.js.map +1 -1
  197. package/src/lib/server/ai-image-reads-route.d.ts +17 -0
  198. package/src/lib/server/ai-image-reads-route.js +106 -0
  199. package/src/lib/server/ai-image-reads-route.js.map +1 -0
  200. package/src/lib/server/ai-seo-apply.js +5 -5
  201. package/src/lib/server/ai-seo-apply.js.map +1 -1
  202. package/src/lib/server/ai-site-import-route.js +7 -4
  203. package/src/lib/server/ai-site-import-route.js.map +1 -1
  204. package/src/lib/server/assist-build.d.ts +15 -1
  205. package/src/lib/server/assist-build.js +30 -3
  206. package/src/lib/server/assist-build.js.map +1 -1
  207. package/src/lib/server/assist-chat.d.ts +11 -7
  208. package/src/lib/server/assist-chat.js +185 -48
  209. package/src/lib/server/assist-chat.js.map +1 -1
  210. package/src/lib/server/assist-docs-query.d.ts +51 -0
  211. package/src/lib/server/assist-docs-query.js +57 -0
  212. package/src/lib/server/assist-docs-query.js.map +1 -0
  213. package/src/lib/server/assist-jobs-context.d.ts +102 -0
  214. package/src/lib/server/assist-jobs-context.js +227 -0
  215. package/src/lib/server/assist-jobs-context.js.map +1 -0
  216. package/src/lib/server/assist-view-context.d.ts +8 -4
  217. package/src/lib/server/assist-view-context.js +12 -7
  218. package/src/lib/server/assist-view-context.js.map +1 -1
  219. package/src/lib/server.js +12 -0
  220. package/src/lib/server.js.map +1 -1
  221. package/src/lib/tools/ai-dataset-tool.d.ts +11 -1
  222. package/src/lib/tools/ai-dataset-tool.js +24 -7
  223. package/src/lib/tools/ai-dataset-tool.js.map +1 -1
  224. package/src/lib/tools/ai-duplicate-resource-tool.js +5 -6
  225. package/src/lib/tools/ai-duplicate-resource-tool.js.map +1 -1
  226. package/src/lib/components/ai-collaborator-permissions-column.component.d.ts +0 -32
  227. package/src/lib/components/ai-collaborator-permissions-column.component.js +0 -132
  228. package/src/lib/components/ai-collaborator-permissions-column.component.js.map +0 -1
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
17
+ import { AI_IMAGE_READS_SIGNATURE, verifyAiImageReadsRequest } from "../jobs/ai-image-reads.js";
18
+ import { aiMediaAssetLocation } from "../jobs/ai-media-asset.js";
19
+ import { readAiProductImageAt } from "../jobs/ai-product-image.js";
20
+ import { readAiThemeLogoColorsAt } from "../jobs/ai-theme-brand-inputs.js";
21
+ /*==========================================
22
+ * `POST /api/plugin-images/ai/image-reads` (AGL-4048): a job step's image
23
+ * reads, in the one function that ships `sharp`'s native library (the
24
+ * console's plugins' image function; see `jobs/ai-image-reads.ts` for why).
25
+ *
26
+ * {op: 'product-photo', ref, hostId, orgId} `{read}`: the product photo
27
+ * as the products step sends
28
+ * it, or why it was not read
29
+ * {op: 'logo-colors', ref, hostId, orgId} `{colors}`: the site logo's
30
+ * dominant colors, or null
31
+ *
32
+ * The asset is located again here from its reference, the site and the org,
33
+ * under the rule the step used (`aiMediaAssetLocation`): one that is not the
34
+ * site's own library's or its org's is not read. The read is the media
35
+ * library's own (`readAiMediaAssetBytes`), so nothing is fetched from a URL.
36
+ *
37
+ * A machine's door: no session, a signature over the moment and the body
38
+ * (401 without one). A read that fails is a 500, and the step treats it as
39
+ * the unreadable photo or logo it would have been in its own process.
40
+ *=========================================*/ const DOCUMENT_ID = /^[A-Za-z0-9_-]{1,128}$/;
41
+ function json(body, status = 200) {
42
+ return Response.json(body, {
43
+ status,
44
+ headers: {
45
+ 'Cache-Control': 'no-store'
46
+ }
47
+ });
48
+ }
49
+ export async function aiImageReadsRoute(request) {
50
+ var _body_hostId, _body_orgId;
51
+ if (request.method !== 'POST') return json({
52
+ error: 'Method not allowed'
53
+ }, 405);
54
+ const raw = await request.text();
55
+ if (!verifyAiImageReadsRequest(request.headers.get(AI_IMAGE_READS_SIGNATURE), raw)) {
56
+ return json({
57
+ error: 'Unauthenticated'
58
+ }, 401);
59
+ }
60
+ let body;
61
+ try {
62
+ body = JSON.parse(raw);
63
+ } catch (unused) {
64
+ return json({
65
+ error: 'Bad request'
66
+ }, 400);
67
+ }
68
+ if (!body || typeof body !== 'object') return json({
69
+ error: 'Bad request'
70
+ }, 400);
71
+ const op = body['op'];
72
+ if (op !== 'product-photo' && op !== 'logo-colors') return json({
73
+ error: 'Unknown op'
74
+ }, 400);
75
+ const hostId = String((_body_hostId = body['hostId']) != null ? _body_hostId : '');
76
+ const orgId = String((_body_orgId = body['orgId']) != null ? _body_orgId : '');
77
+ if (!DOCUMENT_ID.test(hostId) || orgId !== '' && !DOCUMENT_ID.test(orgId)) {
78
+ return json({
79
+ error: 'Bad request'
80
+ }, 400);
81
+ }
82
+ const location = aiMediaAssetLocation(body['ref'], hostId, orgId);
83
+ try {
84
+ const firestore = firebaseAdmin.app().firestore();
85
+ if (op === 'product-photo') {
86
+ return json({
87
+ read: location ? await readAiProductImageAt(firestore, location, hostId) : {
88
+ status: 'not-in-library'
89
+ }
90
+ });
91
+ }
92
+ return json({
93
+ colors: location ? await readAiThemeLogoColorsAt(firestore, location, hostId) : null
94
+ });
95
+ } catch (error) {
96
+ console.error('[ai-image-reads]', op, {
97
+ hostId,
98
+ error
99
+ });
100
+ return json({
101
+ error: 'The image could not be read.'
102
+ }, 500);
103
+ }
104
+ }
105
+
106
+ //# sourceMappingURL=ai-image-reads-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-image-reads-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { AI_IMAGE_READS_SIGNATURE, verifyAiImageReadsRequest } from '../jobs/ai-image-reads'\nimport { aiMediaAssetLocation } from '../jobs/ai-media-asset'\nimport { readAiProductImageAt } from '../jobs/ai-product-image'\nimport { readAiThemeLogoColorsAt } from '../jobs/ai-theme-brand-inputs'\n\n/*==========================================\n * `POST /api/plugin-images/ai/image-reads` (AGL-4048): a job step's image\n * reads, in the one function that ships `sharp`'s native library (the\n * console's plugins' image function; see `jobs/ai-image-reads.ts` for why).\n *\n * {op: 'product-photo', ref, hostId, orgId} `{read}`: the product photo\n * as the products step sends\n * it, or why it was not read\n * {op: 'logo-colors', ref, hostId, orgId} `{colors}`: the site logo's\n * dominant colors, or null\n *\n * The asset is located again here from its reference, the site and the org,\n * under the rule the step used (`aiMediaAssetLocation`): one that is not the\n * site's own library's or its org's is not read. The read is the media\n * library's own (`readAiMediaAssetBytes`), so nothing is fetched from a URL.\n *\n * A machine's door: no session, a signature over the moment and the body\n * (401 without one). A read that fails is a 500, and the step treats it as\n * the unreadable photo or logo it would have been in its own process.\n *=========================================*/\n\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,128}$/\n\nfunction json(body: unknown, status = 200): Response {\n return Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } })\n}\n\nexport async function aiImageReadsRoute(request: Request): Promise<Response> {\n if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405)\n const raw = await request.text()\n if (!verifyAiImageReadsRequest(request.headers.get(AI_IMAGE_READS_SIGNATURE), raw)) {\n return json({ error: 'Unauthenticated' }, 401)\n }\n let body: Record<string, unknown>\n try {\n body = JSON.parse(raw) as Record<string, unknown>\n } catch {\n return json({ error: 'Bad request' }, 400)\n }\n if (!body || typeof body !== 'object') return json({ error: 'Bad request' }, 400)\n const op = body['op']\n if (op !== 'product-photo' && op !== 'logo-colors') return json({ error: 'Unknown op' }, 400)\n const hostId = String(body['hostId'] ?? '')\n const orgId = String(body['orgId'] ?? '')\n if (!DOCUMENT_ID.test(hostId) || (orgId !== '' && !DOCUMENT_ID.test(orgId))) {\n return json({ error: 'Bad request' }, 400)\n }\n const location = aiMediaAssetLocation(body['ref'], hostId, orgId)\n try {\n const firestore = firebaseAdmin.app().firestore()\n if (op === 'product-photo') {\n return json({\n read: location ? await readAiProductImageAt(firestore, location, hostId) : { status: 'not-in-library' },\n })\n }\n return json({ colors: location ? await readAiThemeLogoColorsAt(firestore, location, hostId) : null })\n } catch (error) {\n console.error('[ai-image-reads]', op, { hostId, error })\n return json({ error: 'The image could not be read.' }, 500)\n }\n}\n"],"names":["firebaseAdmin","AI_IMAGE_READS_SIGNATURE","verifyAiImageReadsRequest","aiMediaAssetLocation","readAiProductImageAt","readAiThemeLogoColorsAt","DOCUMENT_ID","json","body","status","Response","headers","aiImageReadsRoute","request","method","error","raw","text","get","JSON","parse","op","hostId","String","orgId","test","location","firestore","app","read","colors","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,wBAAwB,EAAEC,yBAAyB,QAAQ,4BAAwB;AAC5F,SAASC,oBAAoB,QAAQ,4BAAwB;AAC7D,SAASC,oBAAoB,QAAQ,8BAA0B;AAC/D,SAASC,uBAAuB,QAAQ,mCAA+B;AAEvE;;;;;;;;;;;;;;;;;;;2CAmB2C,GAE3C,MAAMC,cAAc;AAEpB,SAASC,KAAKC,IAAa,EAAEC,SAAS,GAAG;IACvC,OAAOC,SAASH,IAAI,CAACC,MAAM;QAAEC;QAAQE,SAAS;YAAE,iBAAiB;QAAW;IAAE;AAChF;AAEA,OAAO,eAAeC,kBAAkBC,OAAgB;QAehCL,cACDA;IAfrB,IAAIK,QAAQC,MAAM,KAAK,QAAQ,OAAOP,KAAK;QAAEQ,OAAO;IAAqB,GAAG;IAC5E,MAAMC,MAAM,MAAMH,QAAQI,IAAI;IAC9B,IAAI,CAACf,0BAA0BW,QAAQF,OAAO,CAACO,GAAG,CAACjB,2BAA2Be,MAAM;QAClF,OAAOT,KAAK;YAAEQ,OAAO;QAAkB,GAAG;IAC5C;IACA,IAAIP;IACJ,IAAI;QACFA,OAAOW,KAAKC,KAAK,CAACJ;IACpB,EAAE,eAAM;QACN,OAAOT,KAAK;YAAEQ,OAAO;QAAc,GAAG;IACxC;IACA,IAAI,CAACP,QAAQ,OAAOA,SAAS,UAAU,OAAOD,KAAK;QAAEQ,OAAO;IAAc,GAAG;IAC7E,MAAMM,KAAKb,IAAI,CAAC,KAAK;IACrB,IAAIa,OAAO,mBAAmBA,OAAO,eAAe,OAAOd,KAAK;QAAEQ,OAAO;IAAa,GAAG;IACzF,MAAMO,SAASC,QAAOf,eAAAA,IAAI,CAAC,SAAS,YAAdA,eAAkB;IACxC,MAAMgB,QAAQD,QAAOf,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;IACtC,IAAI,CAACF,YAAYmB,IAAI,CAACH,WAAYE,UAAU,MAAM,CAAClB,YAAYmB,IAAI,CAACD,QAAS;QAC3E,OAAOjB,KAAK;YAAEQ,OAAO;QAAc,GAAG;IACxC;IACA,MAAMW,WAAWvB,qBAAqBK,IAAI,CAAC,MAAM,EAAEc,QAAQE;IAC3D,IAAI;QACF,MAAMG,YAAY3B,cAAc4B,GAAG,GAAGD,SAAS;QAC/C,IAAIN,OAAO,iBAAiB;YAC1B,OAAOd,KAAK;gBACVsB,MAAMH,WAAW,MAAMtB,qBAAqBuB,WAAWD,UAAUJ,UAAU;oBAAEb,QAAQ;gBAAiB;YACxG;QACF;QACA,OAAOF,KAAK;YAAEuB,QAAQJ,WAAW,MAAMrB,wBAAwBsB,WAAWD,UAAUJ,UAAU;QAAK;IACrG,EAAE,OAAOP,OAAO;QACdgB,QAAQhB,KAAK,CAAC,oBAAoBM,IAAI;YAAEC;YAAQP;QAAM;QACtD,OAAOR,KAAK;YAAEQ,OAAO;QAA+B,GAAG;IACzD;AACF"}
@@ -17,7 +17,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  */ // lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts
18
18
  // The gate carries the org verdict; the handler adds the site's own below,
19
19
  // with the host document in hand.
20
- import { checkEntitlement, createResourceUid, encodeStoredNodes, hostRoleCanWrite } from "@aglyn/aglyn/server";
20
+ import { holdsSiteKey, siteKeyRefusal } from "@aglyn/aglyn/app-utils/host-permissions";
21
+ import { checkEntitlement, createResourceUid, encodeStoredNodes } from "@aglyn/aglyn/server";
21
22
  import { pageContentRootId } from "@aglyn/aglyn/app-utils/page-markdown";
22
23
  import { decodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
23
24
  import { lockdownRefusal } from "@aglyn/tenant-data-admin/server/lockdown";
@@ -168,7 +169,7 @@ const str = (value)=>typeof value === 'string' ? value.trim() : '';
168
169
  };
169
170
  }
170
171
  export async function POST(request) {
171
- var _body_orgId, _host_memberRoles, _job_outputs, _decoded_email;
172
+ var _body_orgId, _job_outputs, _decoded_email;
172
173
  let body;
173
174
  try {
174
175
  var _ref;
@@ -197,10 +198,9 @@ export async function POST(request) {
197
198
  status: 404
198
199
  });
199
200
  }
200
- const role = ((_host_memberRoles = host['memberRoles']) != null ? _host_memberRoles : {})[uid];
201
- if (!staff && !hostRoleCanWrite(role)) {
201
+ if (!staff && !holdsSiteKey(host, uid, 'site.edit')) {
202
202
  return Response.json({
203
- error: 'Editing requires the editor role'
203
+ error: siteKeyRefusal('site.edit')
204
204
  }, {
205
205
  status: 403
206
206
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-seo-apply.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts\n// The gate carries the org verdict; the handler adds the site's own below,\n// with the host document in hand.\n\nimport {\n checkEntitlement,\n createResourceUid,\n encodeStoredNodes,\n hostRoleCanWrite,\n} from '@aglyn/aglyn/server'\nimport { pageContentRootId } from '@aglyn/aglyn/app-utils/page-markdown'\nimport { decodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { logAiSeoApplied } from '../activity/ai-activity'\nimport {\n aiJobSummary,\n getAiJob,\n recordAiJobApplied,\n writeAiJobAudit,\n} from '../jobs/ai-jobs'\nimport type { AiJob, AiJobApplied } from '../model/ai-jobs.types'\nimport { aiSeoAuditView, aiSeoJobTarget, type AiSeoAuditView } from '../model/ai-seo'\nimport { applyAiSeoContentFixes } from '../runtime/seo-content-fixes'\nimport { aiJobsGate } from './ai-jobs-gate'\n\n/**\n * Apply a site audit (AGL-2910): `POST /api/ai/seo/apply { orgId, hostId, jobId }`.\n *\n * \"Apply all\" does two things and neither changes what the published site\n * serves.\n *\n * - **Content fixes open a NEW version per page.** The page's published\n * version is copied, the fixes are applied to the copy — an image\n * description, the page's one main heading — and the copy is stored as a\n * new unpublished version. The published version and the screen's\n * `versionId` are never touched: a person opens the draft, looks, and\n * publishes it through the door that already exists.\n * - **Listing values are STAGED, not written.** A page's search title and\n * description are served straight from the screen document, so writing\n * them would publish them. The apply records which pages have values\n * waiting; the page's SEO card offers them, and its Save SEO is the write.\n *\n * The site-wide proposals — structured data and the agent guidance — are the\n * site SEO form's to save, and the card puts them there as unsaved edits; this\n * door never writes the host document.\n *\n * Idempotent per job: a page this job already opened a version for keeps\n * that version, so a second press opens nothing new.\n *\n * ## The rungs\n *\n * The jobs read gate (membership, the release flag, `aiGenerative`, the org\n * lockdown), then the site: it must be the job's and the org's, the caller\n * must hold a role that writes the site's content — the role creating a\n * version asks for — and the site's own lockdown verdict must admit a write.\n * Nothing here spends a token, so there is no reservation.\n */\n\nconst ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/\n\n/** The version history sentence the versions route gives, for a plan without it. */\nexport const AI_SEO_APPLY_VERSIONING_COPY =\n 'Version history requires a Pro plan — see Billing to upgrade'\n\nexport interface AiSeoApplyInput {\n orgId: string\n hostId: string\n job: AiJob\n view: AiSeoAuditView\n uid: string\n /** Whether the org's plan keeps more than one version of a page. */\n versioning: boolean\n now?: Date\n}\n\nexport interface AiSeoApplyResult {\n job: AiJob\n /** The version each page's content fixes went into; `fixes` is 0 for one opened by an earlier apply. */\n versions: Array<{ screenId: string; versionId: string; name: string; fixes: number }>\n /** Pages whose listing values wait in their SEO card. */\n staged: string[]\n skipped: Array<{ screenId: string; reason: string }>\n}\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/**\n * Open the versions and record the apply. Exported for the spec, which drives\n * it against a Firestore double and proves what it leaves alone.\n */\nexport async function applyAiSeoAudit(\n firestore: FirebaseFirestore.Firestore,\n input: AiSeoApplyInput,\n): Promise<AiSeoApplyResult> {\n const now = input.now ?? new Date()\n const hostRef = firestore.collection('hosts').doc(input.hostId)\n const opened = { ...(input.job.applied?.versions ?? {}) }\n const staged = new Set(input.job.applied?.staged ?? [])\n const names = new Map(input.view.report.pages.map((page) => [page.screenId, page.name]))\n const versions: AiSeoApplyResult['versions'] = []\n const skipped: AiSeoApplyResult['skipped'] = []\n\n for (const fix of Object.values(input.view.fixes)) {\n const screenId = fix.screenId\n const name = names.get(screenId) ?? screenId\n if (Object.keys(fix.values).length) staged.add(screenId)\n if (!fix.content.length) continue\n const screenRef = hostRef.collection('screens').doc(screenId)\n\n const earlier = opened[screenId]\n if (earlier && (await screenRef.collection('versions').doc(earlier).get()).exists) {\n versions.push({ screenId, versionId: earlier, name, fixes: 0 })\n continue\n }\n if (!input.versioning) {\n skipped.push({ screenId, reason: AI_SEO_APPLY_VERSIONING_COPY })\n continue\n }\n const screenSnapshot = await screenRef.get()\n if (!screenSnapshot.exists || screenSnapshot.get('deletedAt')) {\n skipped.push({ screenId, reason: 'The page no longer exists.' })\n continue\n }\n const publishedId = str(screenSnapshot.get('versionId'))\n const source = publishedId ? await screenRef.collection('versions').doc(publishedId).get() : null\n const sourceData = source?.exists ? (source.data() as Record<string, unknown>) : null\n const nodes = sourceData ? decodeStoredNodes<Record<string, unknown>>(sourceData['nodes']) : null\n if (!sourceData || !nodes) {\n skipped.push({ screenId, reason: 'The page has no published version to start from.' })\n continue\n }\n const rootId = typeof sourceData['rootId'] === 'string' ? sourceData['rootId'] : undefined\n const result = applyAiSeoContentFixes(\n nodes,\n fix.content,\n pageContentRootId(nodes as never, rootId),\n )\n if (!result.applied.length) {\n skipped.push({ screenId, reason: result.skipped[0]?.reason ?? 'Nothing on the page needed changing.' })\n continue\n }\n const versionId = createResourceUid()\n // Compressed at rest, as every write of a version's nodes is.\n const packed = encodeStoredNodes(result.nodes)\n await screenRef\n .collection('versions')\n .doc(versionId)\n .create({\n ...sourceData,\n nodes: packed ? Buffer.from(packed) : result.nodes,\n displayName: 'SEO fixes',\n createdAt: now,\n updatedAt: now,\n createdBy: input.uid,\n })\n opened[screenId] = versionId\n versions.push({ screenId, versionId, name, fixes: result.applied.length })\n }\n\n const applied: AiJobApplied = {\n // The Admin SDK stores a `Date` as the timestamp the type describes.\n at: now as unknown as AiJobApplied['at'],\n by: input.uid,\n versions: opened,\n staged: [...staged],\n }\n const job = await recordAiJobApplied(firestore, input.orgId, input.job.$id, applied, now)\n return { job, versions, staged: [...staged], skipped }\n}\n\nexport async function POST(request: Request): Promise<Response> {\n let body: Record<string, unknown>\n try {\n body = ((await request.json()) ?? {}) as Record<string, unknown>\n } catch {\n body = {}\n }\n const gate = await aiJobsGate(request, String(body['orgId'] ?? ''))\n if (gate instanceof Response) return gate\n const hostId = str(body['hostId'])\n const jobId = str(body['jobId'])\n if (!ID_CHARS.test(hostId) || !ID_CHARS.test(jobId)) {\n return Response.json({ error: 'Name the site and the audit to apply' }, { status: 400 })\n }\n const { firestore, orgId, uid, staff, org, decoded } = gate\n\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n const host = hostSnapshot.exists ? (hostSnapshot.data() as Record<string, unknown>) : null\n if (!host || host['orgId'] !== orgId) {\n return Response.json({ error: 'Unknown site' }, { status: 404 })\n }\n const role = ((host['memberRoles'] ?? {}) as Record<string, unknown>)[uid]\n if (!staff && !hostRoleCanWrite(role)) {\n return Response.json({ error: 'Editing requires the editor role' }, { status: 403 })\n }\n const locked = await lockdownRefusal({\n request,\n staff,\n uid,\n org: org as Record<string, unknown>,\n host: host as never,\n })\n if (locked) return locked\n\n const job = await getAiJob(firestore, orgId, jobId)\n if (!job || job.kind !== 'seo' || job.hostId !== hostId || aiSeoJobTarget(job.inputs) !== 'site') {\n return Response.json({ error: 'Unknown audit' }, { status: 404 })\n }\n if (job.status !== 'done') {\n return Response.json({ error: 'The audit is still running — apply it once it has finished' }, { status: 409 })\n }\n const view = aiSeoAuditView(job.outputs ?? [])\n if (!view) {\n return Response.json({ error: 'This audit has nothing to apply' }, { status: 409 })\n }\n\n const result = await applyAiSeoAudit(firestore, {\n orgId,\n hostId,\n job,\n view,\n uid,\n versioning: checkEntitlement(org, 'versioning'),\n })\n const opened = result.versions.filter((entry) => entry.fixes > 0)\n await writeAiJobAudit(firestore, {\n action: 'ai.job.apply',\n actorUid: uid,\n actorEmail: decoded.email ?? null,\n orgId,\n jobId,\n after: {\n hostId,\n versions: Object.fromEntries(opened.map((entry) => [entry.screenId, entry.versionId])),\n staged: result.staged,\n },\n })\n if (opened.length || result.staged.length) {\n await logAiSeoApplied(orgId, { uid, email: decoded.email ?? null }, {\n jobId,\n hostId,\n hostName: str(host['displayName']) || null,\n versions: opened.length,\n staged: result.staged.length,\n }).catch((error) => console.error('ai seo apply activity failed', { orgId, jobId, error }))\n }\n return Response.json(\n {\n job: aiJobSummary(result.job),\n versions: result.versions,\n staged: result.staged,\n skipped: result.skipped,\n },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n}\n\nexport const dynamic = 'force-dynamic'\n"],"names":["checkEntitlement","createResourceUid","encodeStoredNodes","hostRoleCanWrite","pageContentRootId","decodeStoredNodes","lockdownRefusal","logAiSeoApplied","aiJobSummary","getAiJob","recordAiJobApplied","writeAiJobAudit","aiSeoAuditView","aiSeoJobTarget","applyAiSeoContentFixes","aiJobsGate","ID_CHARS","AI_SEO_APPLY_VERSIONING_COPY","str","value","trim","applyAiSeoAudit","firestore","input","now","Date","hostRef","collection","doc","hostId","opened","job","applied","versions","staged","Set","names","Map","view","report","pages","map","page","screenId","name","skipped","fix","Object","values","fixes","get","keys","length","add","content","screenRef","earlier","exists","push","versionId","versioning","reason","screenSnapshot","publishedId","source","sourceData","data","nodes","rootId","undefined","result","packed","create","Buffer","from","displayName","createdAt","updatedAt","createdBy","uid","at","by","orgId","$id","POST","request","body","host","decoded","json","gate","String","Response","jobId","test","error","status","staff","org","hostSnapshot","role","locked","kind","inputs","outputs","filter","entry","action","actorUid","actorEmail","email","after","fromEntries","hostName","catch","console","headers","dynamic"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-seo-apply.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts\n// The gate carries the org verdict; the handler adds the site's own below,\n// with the host document in hand.\n\nimport { holdsSiteKey, siteKeyRefusal } from '@aglyn/aglyn/app-utils/host-permissions'\nimport {\n checkEntitlement,\n createResourceUid,\n encodeStoredNodes,\n} from '@aglyn/aglyn/server'\nimport { pageContentRootId } from '@aglyn/aglyn/app-utils/page-markdown'\nimport { decodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { logAiSeoApplied } from '../activity/ai-activity'\nimport {\n aiJobSummary,\n getAiJob,\n recordAiJobApplied,\n writeAiJobAudit,\n} from '../jobs/ai-jobs'\nimport type { AiJob, AiJobApplied } from '../model/ai-jobs.types'\nimport { aiSeoAuditView, aiSeoJobTarget, type AiSeoAuditView } from '../model/ai-seo'\nimport { applyAiSeoContentFixes } from '../runtime/seo-content-fixes'\nimport { aiJobsGate } from './ai-jobs-gate'\n\n/**\n * Apply a site audit (AGL-2910): `POST /api/ai/seo/apply { orgId, hostId, jobId }`.\n *\n * \"Apply all\" does two things and neither changes what the published site\n * serves.\n *\n * - **Content fixes open a NEW version per page.** The page's published\n * version is copied, the fixes are applied to the copy — an image\n * description, the page's one main heading — and the copy is stored as a\n * new unpublished version. The published version and the screen's\n * `versionId` are never touched: a person opens the draft, looks, and\n * publishes it through the door that already exists.\n * - **Listing values are STAGED, not written.** A page's search title and\n * description are served straight from the screen document, so writing\n * them would publish them. The apply records which pages have values\n * waiting; the page's SEO card offers them, and its Save SEO is the write.\n *\n * The site-wide proposals — structured data and the agent guidance — are the\n * site SEO form's to save, and the card puts them there as unsaved edits; this\n * door never writes the host document.\n *\n * Idempotent per job: a page this job already opened a version for keeps\n * that version, so a second press opens nothing new.\n *\n * ## The rungs\n *\n * The jobs read gate (membership, the release flag, `aiGenerative`, the org\n * lockdown), then the site: it must be the job's and the org's, the caller\n * must hold a role that writes the site's content — the role creating a\n * version asks for — and the site's own lockdown verdict must admit a write.\n * Nothing here spends a token, so there is no reservation.\n */\n\nconst ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/\n\n/** The version history sentence the versions route gives, for a plan without it. */\nexport const AI_SEO_APPLY_VERSIONING_COPY =\n 'Version history requires a Pro plan — see Billing to upgrade'\n\nexport interface AiSeoApplyInput {\n orgId: string\n hostId: string\n job: AiJob\n view: AiSeoAuditView\n uid: string\n /** Whether the org's plan keeps more than one version of a page. */\n versioning: boolean\n now?: Date\n}\n\nexport interface AiSeoApplyResult {\n job: AiJob\n /** The version each page's content fixes went into; `fixes` is 0 for one opened by an earlier apply. */\n versions: Array<{ screenId: string; versionId: string; name: string; fixes: number }>\n /** Pages whose listing values wait in their SEO card. */\n staged: string[]\n skipped: Array<{ screenId: string; reason: string }>\n}\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/**\n * Open the versions and record the apply. Exported for the spec, which drives\n * it against a Firestore double and proves what it leaves alone.\n */\nexport async function applyAiSeoAudit(\n firestore: FirebaseFirestore.Firestore,\n input: AiSeoApplyInput,\n): Promise<AiSeoApplyResult> {\n const now = input.now ?? new Date()\n const hostRef = firestore.collection('hosts').doc(input.hostId)\n const opened = { ...(input.job.applied?.versions ?? {}) }\n const staged = new Set(input.job.applied?.staged ?? [])\n const names = new Map(input.view.report.pages.map((page) => [page.screenId, page.name]))\n const versions: AiSeoApplyResult['versions'] = []\n const skipped: AiSeoApplyResult['skipped'] = []\n\n for (const fix of Object.values(input.view.fixes)) {\n const screenId = fix.screenId\n const name = names.get(screenId) ?? screenId\n if (Object.keys(fix.values).length) staged.add(screenId)\n if (!fix.content.length) continue\n const screenRef = hostRef.collection('screens').doc(screenId)\n\n const earlier = opened[screenId]\n if (earlier && (await screenRef.collection('versions').doc(earlier).get()).exists) {\n versions.push({ screenId, versionId: earlier, name, fixes: 0 })\n continue\n }\n if (!input.versioning) {\n skipped.push({ screenId, reason: AI_SEO_APPLY_VERSIONING_COPY })\n continue\n }\n const screenSnapshot = await screenRef.get()\n if (!screenSnapshot.exists || screenSnapshot.get('deletedAt')) {\n skipped.push({ screenId, reason: 'The page no longer exists.' })\n continue\n }\n const publishedId = str(screenSnapshot.get('versionId'))\n const source = publishedId ? await screenRef.collection('versions').doc(publishedId).get() : null\n const sourceData = source?.exists ? (source.data() as Record<string, unknown>) : null\n const nodes = sourceData ? decodeStoredNodes<Record<string, unknown>>(sourceData['nodes']) : null\n if (!sourceData || !nodes) {\n skipped.push({ screenId, reason: 'The page has no published version to start from.' })\n continue\n }\n const rootId = typeof sourceData['rootId'] === 'string' ? sourceData['rootId'] : undefined\n const result = applyAiSeoContentFixes(\n nodes,\n fix.content,\n pageContentRootId(nodes as never, rootId),\n )\n if (!result.applied.length) {\n skipped.push({ screenId, reason: result.skipped[0]?.reason ?? 'Nothing on the page needed changing.' })\n continue\n }\n const versionId = createResourceUid()\n // Compressed at rest, as every write of a version's nodes is.\n const packed = encodeStoredNodes(result.nodes)\n await screenRef\n .collection('versions')\n .doc(versionId)\n .create({\n ...sourceData,\n nodes: packed ? Buffer.from(packed) : result.nodes,\n displayName: 'SEO fixes',\n createdAt: now,\n updatedAt: now,\n createdBy: input.uid,\n })\n opened[screenId] = versionId\n versions.push({ screenId, versionId, name, fixes: result.applied.length })\n }\n\n const applied: AiJobApplied = {\n // The Admin SDK stores a `Date` as the timestamp the type describes.\n at: now as unknown as AiJobApplied['at'],\n by: input.uid,\n versions: opened,\n staged: [...staged],\n }\n const job = await recordAiJobApplied(firestore, input.orgId, input.job.$id, applied, now)\n return { job, versions, staged: [...staged], skipped }\n}\n\nexport async function POST(request: Request): Promise<Response> {\n let body: Record<string, unknown>\n try {\n body = ((await request.json()) ?? {}) as Record<string, unknown>\n } catch {\n body = {}\n }\n const gate = await aiJobsGate(request, String(body['orgId'] ?? ''))\n if (gate instanceof Response) return gate\n const hostId = str(body['hostId'])\n const jobId = str(body['jobId'])\n if (!ID_CHARS.test(hostId) || !ID_CHARS.test(jobId)) {\n return Response.json({ error: 'Name the site and the audit to apply' }, { status: 400 })\n }\n const { firestore, orgId, uid, staff, org, decoded } = gate\n\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n const host = hostSnapshot.exists ? (hostSnapshot.data() as Record<string, unknown>) : null\n if (!host || host['orgId'] !== orgId) {\n return Response.json({ error: 'Unknown site' }, { status: 404 })\n }\n if (!staff && !holdsSiteKey(host, uid, 'site.edit')) {\n return Response.json({ error: siteKeyRefusal('site.edit') }, { status: 403 })\n }\n const locked = await lockdownRefusal({\n request,\n staff,\n uid,\n org: org as Record<string, unknown>,\n host: host as never,\n })\n if (locked) return locked\n\n const job = await getAiJob(firestore, orgId, jobId)\n if (!job || job.kind !== 'seo' || job.hostId !== hostId || aiSeoJobTarget(job.inputs) !== 'site') {\n return Response.json({ error: 'Unknown audit' }, { status: 404 })\n }\n if (job.status !== 'done') {\n return Response.json({ error: 'The audit is still running — apply it once it has finished' }, { status: 409 })\n }\n const view = aiSeoAuditView(job.outputs ?? [])\n if (!view) {\n return Response.json({ error: 'This audit has nothing to apply' }, { status: 409 })\n }\n\n const result = await applyAiSeoAudit(firestore, {\n orgId,\n hostId,\n job,\n view,\n uid,\n versioning: checkEntitlement(org, 'versioning'),\n })\n const opened = result.versions.filter((entry) => entry.fixes > 0)\n await writeAiJobAudit(firestore, {\n action: 'ai.job.apply',\n actorUid: uid,\n actorEmail: decoded.email ?? null,\n orgId,\n jobId,\n after: {\n hostId,\n versions: Object.fromEntries(opened.map((entry) => [entry.screenId, entry.versionId])),\n staged: result.staged,\n },\n })\n if (opened.length || result.staged.length) {\n await logAiSeoApplied(orgId, { uid, email: decoded.email ?? null }, {\n jobId,\n hostId,\n hostName: str(host['displayName']) || null,\n versions: opened.length,\n staged: result.staged.length,\n }).catch((error) => console.error('ai seo apply activity failed', { orgId, jobId, error }))\n }\n return Response.json(\n {\n job: aiJobSummary(result.job),\n versions: result.versions,\n staged: result.staged,\n skipped: result.skipped,\n },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n}\n\nexport const dynamic = 'force-dynamic'\n"],"names":["holdsSiteKey","siteKeyRefusal","checkEntitlement","createResourceUid","encodeStoredNodes","pageContentRootId","decodeStoredNodes","lockdownRefusal","logAiSeoApplied","aiJobSummary","getAiJob","recordAiJobApplied","writeAiJobAudit","aiSeoAuditView","aiSeoJobTarget","applyAiSeoContentFixes","aiJobsGate","ID_CHARS","AI_SEO_APPLY_VERSIONING_COPY","str","value","trim","applyAiSeoAudit","firestore","input","now","Date","hostRef","collection","doc","hostId","opened","job","applied","versions","staged","Set","names","Map","view","report","pages","map","page","screenId","name","skipped","fix","Object","values","fixes","get","keys","length","add","content","screenRef","earlier","exists","push","versionId","versioning","reason","screenSnapshot","publishedId","source","sourceData","data","nodes","rootId","undefined","result","packed","create","Buffer","from","displayName","createdAt","updatedAt","createdBy","uid","at","by","orgId","$id","POST","request","body","decoded","json","gate","String","Response","jobId","test","error","status","staff","org","hostSnapshot","host","locked","kind","inputs","outputs","filter","entry","action","actorUid","actorEmail","email","after","fromEntries","hostName","catch","console","headers","dynamic"],"mappings":"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"}
@@ -46,7 +46,8 @@ import { checkDomainOwnership, confirmDomainOwnershipCode, DOMAIN_OWNERSHIP_META
46
46
  import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin/server/firebase-admin";
47
47
  import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
48
48
  import { lockdownRefusal } from "@aglyn/tenant-data-admin/server/lockdown";
49
- import { getOrgForUser } from "@aglyn/tenant-data-admin/server/organizations";
49
+ import { getOrgForUser, memberHasOrgPermission } from "@aglyn/tenant-data-admin/server/organizations";
50
+ import { permissionRefusalText } from "@aglyn/aglyn/app-utils/org-permissions";
50
51
  import { consumeRateLimit } from "@aglyn/tenant-data-admin/server/rate-limit-store";
51
52
  import { isServerReleaseFlagOnForOrg } from "@aglyn/tenant-data-admin/server/release-flags";
52
53
  import { FieldValue } from "firebase-admin/firestore";
@@ -105,9 +106,11 @@ export async function siteImportGate(request, rawOrgId) {
105
106
  });
106
107
  }
107
108
  const member = (_resolved_member = resolved.member) != null ? _resolved_member : null;
108
- if (!member || member.role !== 'owner' && member.role !== 'admin') {
109
+ // "Bring a site in" (`ai.import`, AGL-4047), whose default is the owner and
110
+ // admin this asked for.
111
+ if (!member || !await memberHasOrgPermission(orgId, member, 'ai.import')) {
109
112
  return Response.json({
110
- error: 'Only the workspace owner or an admin can bring a site in.',
113
+ error: permissionRefusalText('ai.import'),
111
114
  reason: 'role'
112
115
  }, {
113
116
  status: 403
@@ -152,7 +155,7 @@ export async function siteImportGate(request, rawOrgId) {
152
155
  } catch (unused) {
153
156
  return null;
154
157
  }
155
- if (!input.member || input.member.role !== 'owner' && input.member.role !== 'admin') return 'role';
158
+ if (!input.member || !await memberHasOrgPermission(input.orgId, input.member, 'ai.import')) return 'role';
156
159
  if (resolveEffectivePlan((_input_org = input.org) != null ? _input_org : {}) === 'free') return 'plan';
157
160
  return 'ready';
158
161
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-site-import-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The \"Bring my site\" doors (AGL-3974, parent AGL-3973).\n *\n * GET /api/ai/site-import/ownership?orgId= the org's claims\n * POST /api/ai/site-import/ownership { orgId, action, domain, … }\n * action `claim` start a claim (idempotent)\n * action `check`, method `dns` | `meta` look for the record or tag now\n * action `send-code`, email email a code to an address at the domain\n * action `confirm-code`, code check the code\n * POST /api/ai/site-import/crawl { orgId, domain, maxPages? }\n * the site's inventory, for a domain the org has a CURRENT proof for\n *\n * The rungs, in order, each disclosing nothing a lower one would:\n *\n * 401 no bearer token, or a refused one\n * 403 email unverified (an impersonation session is exempt)\n * 400 the request did not name the org (AGL-1934)\n * 403 not a member of that org\n * 404 `release_ai_site_import` is off; a verified staff claim previews\n * 403 `reason: 'role'`: the caller is not the org's owner or an admin\n * 403 `reason: 'plan'`: the org is on the Free plan; import is for every\n * paid plan\n * 423 lockdown\n *\n * Nothing here spends model credits: the crawl reads and returns, and the\n * import job (another lane) decides what becomes a page.\n */\n\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { resolveEffectivePlan, resolveOrgEntitlements } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport type { AglynOrganization, AglynOrgMember } from '@aglyn/aglyn/foundation/definitions/organization.types'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\nimport {\n checkDomainOwnership,\n confirmDomainOwnershipCode,\n DOMAIN_OWNERSHIP_META_NAME,\n isDomainOwnershipProven,\n issueDomainOwnershipClaim,\n listDomainOwnershipClaims,\n normalizeOwnershipDomain,\n sendDomainOwnershipCode,\n type DomainOwnershipDeps,\n} from '@aglyn/tenant-data-admin/server/domain-ownership'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { getOrgForUser } from '@aglyn/tenant-data-admin/server/organizations'\nimport { consumeRateLimit } from '@aglyn/tenant-data-admin/server/rate-limit-store'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n crawlSiteForImport,\n readSiteVerificationMeta,\n type SiteImportCrawlSeams,\n} from '../site-import/site-import-crawl'\nimport { SITE_IMPORT_RELEASE_FLAG } from '../site-import/site-import-flag'\n\nexport { SITE_IMPORT_RELEASE_FLAG }\n\n/** Pages the crawl door reads in one request; the import job reads in waves. */\nexport const SITE_IMPORT_DOOR_MAX_PAGES = 25\n\n/** Crawls one org may run through the door in a day. */\nexport const SITE_IMPORT_CRAWLS_PER_ORG_PER_DAY = 20\n\n/** DNS and home-page checks one org may run in an hour. */\nexport const SITE_IMPORT_CHECKS_PER_ORG_PER_HOUR = 60\n\n/** The door's own clock for a crawl, inside the route's `maxDuration`. */\nconst SITE_IMPORT_DOOR_TIME_MS = 45_000\n\nexport interface SiteImportGateContext {\n uid: string\n decoded: DecodedIdToken\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n member: AglynOrgMember | null\n firestore: FirebaseFirestore.Firestore\n}\n\n/** What the doors reach outside the process, replaceable in a spec. */\nexport interface SiteImportRouteSeams {\n ownership?: DomainOwnershipDeps\n crawl?: SiteImportCrawlSeams\n}\n\nfunction bearerToken(request: Request): string | undefined {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined\n}\n\nexport async function siteImportGate(\n request: Request,\n rawOrgId: string,\n): Promise<Response | SiteImportGateContext> {\n const idToken = bearerToken(request)\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const app = firebaseAdmin.app()\n let decoded: DecodedIdToken\n try {\n decoded = await app.auth().verifyIdToken(idToken)\n } catch (error) {\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n throw error\n }\n if (!decoded.email_verified && !isImpersonationSession(decoded)) return emailUnverifiedResponse()\n const staff = decoded['staff'] === true\n const orgId = String(rawOrgId ?? '').trim()\n if (!orgId) {\n return Response.json({ error: 'Open a workspace before using this feature' }, { status: 400 })\n }\n const resolved = await getOrgForUser(decoded.uid, orgId)\n if (!resolved || resolved.orgId !== orgId) {\n return Response.json({ error: 'You are not a member of that organization' }, { status: 403 })\n }\n if (!staff && !(await isServerReleaseFlagOnForOrg(SITE_IMPORT_RELEASE_FLAG, orgId))) {\n return Response.json({ error: 'Not found' }, { status: 404 })\n }\n const member = resolved.member ?? null\n if (!member || (member.role !== 'owner' && member.role !== 'admin')) {\n return Response.json(\n { error: 'Only the workspace owner or an admin can bring a site in.', reason: 'role' },\n { status: 403 },\n )\n }\n const org = resolved.org ?? {}\n if (resolveEffectivePlan(org) === 'free') {\n return Response.json(\n { error: 'Bringing an existing site in is included on every paid plan.', reason: 'plan' },\n { status: 403 },\n )\n }\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org: org as Record<string, unknown>,\n })\n if (locked) return locked\n return { uid: decoded.uid, decoded, staff, orgId, org, member, firestore: app.firestore() }\n}\n\n/**\n * Whether \"Bring my site\" is offered to this reader (AGL-3976), in the\n * doors' own order, for Create site's choice card: `null` while the flag is\n * off (the card is not drawn), `role` or `plan` for a refusal the workspace\n * can change (the card is drawn disabled with the reason), `ready` otherwise.\n * Read by the jobs list only when the card asks, so no other read pays for it.\n */\nexport async function siteImportOfferFor(input: {\n orgId: string\n staff: boolean\n org: Partial<AglynOrganization> | null\n member: AglynOrgMember | null\n}): Promise<'ready' | 'role' | 'plan' | null> {\n try {\n if (!input.staff && !(await isServerReleaseFlagOnForOrg(SITE_IMPORT_RELEASE_FLAG, input.orgId))) return null\n } catch {\n return null\n }\n if (!input.member || (input.member.role !== 'owner' && input.member.role !== 'admin')) return 'role'\n if (resolveEffectivePlan(input.org ?? {}) === 'free') return 'plan'\n return 'ready'\n}\n\nasync function audit(\n gate: SiteImportGateContext,\n action: string,\n domain: string,\n after: Record<string, unknown>,\n): Promise<void> {\n try {\n await addAdminAudit(gate.firestore, {\n actorUid: gate.uid,\n actorEmail: gate.decoded.email ?? null,\n action,\n target: `orgs/${gate.orgId}/domainOwnershipClaims/${domain}`,\n after: { ...after, ...(gate.staff ? { staff: true } : {}) },\n at: FieldValue.serverTimestamp(),\n })\n } catch (error) {\n console.error('site import audit write failed', { orgId: gate.orgId, action, error })\n }\n}\n\nfunction ownershipDeps(seams: SiteImportRouteSeams): DomainOwnershipDeps {\n return {\n readVerificationMeta: (domain) =>\n readSiteVerificationMeta(domain, DOMAIN_OWNERSHIP_META_NAME, seams.crawl),\n ...seams.ownership,\n }\n}\n\nconst tooMany = (error: string) => Response.json({ error, reason: 'rate' }, { status: 429 })\n\n/** `GET /api/ai/site-import/ownership?orgId=`. */\nexport async function listSiteImportOwnership(\n request: Request,\n seams: SiteImportRouteSeams = {},\n): Promise<Response> {\n const orgId = new URL(request.url).searchParams.get('orgId') ?? ''\n const gate = await siteImportGate(request, orgId)\n if (gate instanceof Response) return gate\n const claims = await listDomainOwnershipClaims(gate.orgId, ownershipDeps(seams))\n return Response.json({ claims })\n}\n\n/** `POST /api/ai/site-import/ownership`. */\nexport async function postSiteImportOwnership(\n request: Request,\n seams: SiteImportRouteSeams = {},\n): Promise<Response> {\n const body = (await request.json().catch(() => null)) as Record<string, unknown> | null\n if (!body || typeof body !== 'object') {\n return Response.json({ error: 'Send a JSON body' }, { status: 400 })\n }\n const gate = await siteImportGate(request, String(body['orgId'] ?? ''))\n if (gate instanceof Response) return gate\n const deps = ownershipDeps(seams)\n const action = String(body['action'] ?? '')\n const domain = String(body['domain'] ?? '')\n\n if (action === 'claim') {\n const { claim, error } = await issueDomainOwnershipClaim(gate.orgId, domain, gate.uid, deps)\n if (!claim) return Response.json({ error }, { status: 400 })\n await audit(gate, 'site-import.ownership.claim', claim.domain, { status: claim.status })\n return Response.json({ claim })\n }\n\n if (action === 'check') {\n const method = body['method']\n if (method !== 'dns' && method !== 'meta') {\n return Response.json({ error: 'method must be dns or meta' }, { status: 400 })\n }\n const window = await consumeRateLimit(`site-import-ownership-check:${gate.orgId}`, {\n limit: SITE_IMPORT_CHECKS_PER_ORG_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!window.allowed) return tooMany('Too many checks. Try again in an hour.')\n const { result, error } = await checkDomainOwnership(gate.orgId, domain, method, deps)\n if (!result) return Response.json({ error }, { status: 400 })\n if (result.status === 'proven') {\n await audit(gate, 'site-import.ownership.proven', result.claim.domain, {\n method,\n detail: result.claim.provenDetail,\n })\n }\n return Response.json({ result })\n }\n\n if (action === 'send-code') {\n const sent = await sendDomainOwnershipCode(\n gate.orgId,\n domain,\n String(body['email'] ?? ''),\n { org: gate.org as Record<string, unknown> },\n deps,\n )\n if (!sent.claim) return Response.json({ error: sent.error }, { status: sent.status })\n await audit(gate, 'site-import.ownership.code-sent', sent.claim.domain, {\n address: sent.claim.email.pendingAddress,\n })\n return Response.json({ claim: sent.claim })\n }\n\n if (action === 'confirm-code') {\n const { result, error } = await confirmDomainOwnershipCode(gate.orgId, domain, String(body['code'] ?? ''), deps)\n if (!result) return Response.json({ error }, { status: 400 })\n if (result.status === 'proven') {\n await audit(gate, 'site-import.ownership.proven', result.claim.domain, {\n method: 'email',\n detail: result.claim.provenDetail,\n })\n }\n return Response.json({ result })\n }\n\n return Response.json(\n { error: 'action must be claim, check, send-code or confirm-code' },\n { status: 400 },\n )\n}\n\n/** `POST /api/ai/site-import/crawl`. */\nexport async function postSiteImportCrawl(\n request: Request,\n seams: SiteImportRouteSeams = {},\n): Promise<Response> {\n const body = (await request.json().catch(() => null)) as Record<string, unknown> | null\n if (!body || typeof body !== 'object') {\n return Response.json({ error: 'Send a JSON body' }, { status: 400 })\n }\n const gate = await siteImportGate(request, String(body['orgId'] ?? ''))\n if (gate instanceof Response) return gate\n const { domain, error } = normalizeOwnershipDomain(String(body['domain'] ?? ''))\n if (!domain) return Response.json({ error }, { status: 400 })\n // Ownership BEFORE any page is read: a crawl of a site the org has not\n // proved is exactly the clone this whole feature is built to refuse.\n if (!(await isDomainOwnershipProven(gate.orgId, domain, ownershipDeps(seams)))) {\n return Response.json(\n { error: 'Verify that you own this site first.', reason: 'ownership' },\n { status: 403 },\n )\n }\n const window = await consumeRateLimit(`site-import-crawl:${gate.orgId}`, {\n limit: SITE_IMPORT_CRAWLS_PER_ORG_PER_DAY,\n windowMs: 24 * 60 * 60 * 1000,\n })\n if (!window.allowed) return tooMany('This workspace has read sites enough times today. Try again tomorrow.')\n const planPages = resolveOrgEntitlements(gate.org).screensPerHost\n const asked = Number(body['maxPages'])\n const maxPages = Math.max(\n 1,\n Math.min(\n SITE_IMPORT_DOOR_MAX_PAGES,\n Number.isFinite(planPages) && planPages > 0 ? planPages : SITE_IMPORT_DOOR_MAX_PAGES,\n Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : SITE_IMPORT_DOOR_MAX_PAGES,\n ),\n )\n const inventory = await crawlSiteForImport(\n { domain, maxPages, timeBudgetMs: SITE_IMPORT_DOOR_TIME_MS, signal: request.signal },\n seams.crawl,\n )\n await audit(gate, 'site-import.crawl', domain, {\n pages: inventory.pages.length,\n skipped: inventory.skipped.length + inventory.skippedOverflow,\n loginPagesRefused: inventory.skipped.filter((entry) => entry.reason === 'login-form').length,\n stoppedBy: inventory.stoppedBy,\n refused: inventory.refused,\n bytesRead: inventory.bytesRead,\n })\n return Response.json({ inventory })\n}\n"],"names":["resolveEffectivePlan","resolveOrgEntitlements","addAdminAudit","checkDomainOwnership","confirmDomainOwnershipCode","DOMAIN_OWNERSHIP_META_NAME","isDomainOwnershipProven","issueDomainOwnershipClaim","listDomainOwnershipClaims","normalizeOwnershipDomain","sendDomainOwnershipCode","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","invalidIdTokenResponse","lockdownRefusal","getOrgForUser","consumeRateLimit","isServerReleaseFlagOnForOrg","FieldValue","crawlSiteForImport","readSiteVerificationMeta","SITE_IMPORT_RELEASE_FLAG","SITE_IMPORT_DOOR_MAX_PAGES","SITE_IMPORT_CRAWLS_PER_ORG_PER_DAY","SITE_IMPORT_CHECKS_PER_ORG_PER_HOUR","SITE_IMPORT_DOOR_TIME_MS","bearerToken","request","authorization","headers","get","startsWith","slice","length","undefined","siteImportGate","rawOrgId","resolved","idToken","Response","json","error","status","app","decoded","auth","verifyIdToken","unauthenticated","email_verified","staff","orgId","String","trim","uid","member","role","reason","org","locked","firestore","siteImportOfferFor","input","audit","gate","action","domain","after","actorUid","actorEmail","email","target","at","serverTimestamp","console","ownershipDeps","seams","readVerificationMeta","crawl","ownership","tooMany","listSiteImportOwnership","URL","url","searchParams","claims","postSiteImportOwnership","body","catch","deps","claim","method","window","limit","windowMs","allowed","result","detail","provenDetail","sent","address","pendingAddress","postSiteImportCrawl","planPages","screensPerHost","asked","Number","maxPages","Math","max","min","isFinite","floor","inventory","timeBudgetMs","signal","pages","skipped","skippedOverflow","loginPagesRefused","filter","entry","stoppedBy","refused","bytesRead"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-site-import-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The \"Bring my site\" doors (AGL-3974, parent AGL-3973).\n *\n * GET /api/ai/site-import/ownership?orgId= the org's claims\n * POST /api/ai/site-import/ownership { orgId, action, domain, … }\n * action `claim` start a claim (idempotent)\n * action `check`, method `dns` | `meta` look for the record or tag now\n * action `send-code`, email email a code to an address at the domain\n * action `confirm-code`, code check the code\n * POST /api/ai/site-import/crawl { orgId, domain, maxPages? }\n * the site's inventory, for a domain the org has a CURRENT proof for\n *\n * The rungs, in order, each disclosing nothing a lower one would:\n *\n * 401 no bearer token, or a refused one\n * 403 email unverified (an impersonation session is exempt)\n * 400 the request did not name the org (AGL-1934)\n * 403 not a member of that org\n * 404 `release_ai_site_import` is off; a verified staff claim previews\n * 403 `reason: 'role'`: the caller is not the org's owner or an admin\n * 403 `reason: 'plan'`: the org is on the Free plan; import is for every\n * paid plan\n * 423 lockdown\n *\n * Nothing here spends model credits: the crawl reads and returns, and the\n * import job (another lane) decides what becomes a page.\n */\n\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { resolveEffectivePlan, resolveOrgEntitlements } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport type { AglynOrganization, AglynOrgMember } from '@aglyn/aglyn/foundation/definitions/organization.types'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\nimport {\n checkDomainOwnership,\n confirmDomainOwnershipCode,\n DOMAIN_OWNERSHIP_META_NAME,\n isDomainOwnershipProven,\n issueDomainOwnershipClaim,\n listDomainOwnershipClaims,\n normalizeOwnershipDomain,\n sendDomainOwnershipCode,\n type DomainOwnershipDeps,\n} from '@aglyn/tenant-data-admin/server/domain-ownership'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { getOrgForUser, memberHasOrgPermission } from '@aglyn/tenant-data-admin/server/organizations'\nimport { permissionRefusalText } from '@aglyn/aglyn/app-utils/org-permissions'\nimport { consumeRateLimit } from '@aglyn/tenant-data-admin/server/rate-limit-store'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n crawlSiteForImport,\n readSiteVerificationMeta,\n type SiteImportCrawlSeams,\n} from '../site-import/site-import-crawl'\nimport { SITE_IMPORT_RELEASE_FLAG } from '../site-import/site-import-flag'\n\nexport { SITE_IMPORT_RELEASE_FLAG }\n\n/** Pages the crawl door reads in one request; the import job reads in waves. */\nexport const SITE_IMPORT_DOOR_MAX_PAGES = 25\n\n/** Crawls one org may run through the door in a day. */\nexport const SITE_IMPORT_CRAWLS_PER_ORG_PER_DAY = 20\n\n/** DNS and home-page checks one org may run in an hour. */\nexport const SITE_IMPORT_CHECKS_PER_ORG_PER_HOUR = 60\n\n/** The door's own clock for a crawl, inside the route's `maxDuration`. */\nconst SITE_IMPORT_DOOR_TIME_MS = 45_000\n\nexport interface SiteImportGateContext {\n uid: string\n decoded: DecodedIdToken\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n member: AglynOrgMember | null\n firestore: FirebaseFirestore.Firestore\n}\n\n/** What the doors reach outside the process, replaceable in a spec. */\nexport interface SiteImportRouteSeams {\n ownership?: DomainOwnershipDeps\n crawl?: SiteImportCrawlSeams\n}\n\nfunction bearerToken(request: Request): string | undefined {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined\n}\n\nexport async function siteImportGate(\n request: Request,\n rawOrgId: string,\n): Promise<Response | SiteImportGateContext> {\n const idToken = bearerToken(request)\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const app = firebaseAdmin.app()\n let decoded: DecodedIdToken\n try {\n decoded = await app.auth().verifyIdToken(idToken)\n } catch (error) {\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n throw error\n }\n if (!decoded.email_verified && !isImpersonationSession(decoded)) return emailUnverifiedResponse()\n const staff = decoded['staff'] === true\n const orgId = String(rawOrgId ?? '').trim()\n if (!orgId) {\n return Response.json({ error: 'Open a workspace before using this feature' }, { status: 400 })\n }\n const resolved = await getOrgForUser(decoded.uid, orgId)\n if (!resolved || resolved.orgId !== orgId) {\n return Response.json({ error: 'You are not a member of that organization' }, { status: 403 })\n }\n if (!staff && !(await isServerReleaseFlagOnForOrg(SITE_IMPORT_RELEASE_FLAG, orgId))) {\n return Response.json({ error: 'Not found' }, { status: 404 })\n }\n const member = resolved.member ?? null\n // \"Bring a site in\" (`ai.import`, AGL-4047), whose default is the owner and\n // admin this asked for.\n if (!member || !(await memberHasOrgPermission(orgId, member, 'ai.import'))) {\n return Response.json(\n { error: permissionRefusalText('ai.import'), reason: 'role' },\n { status: 403 },\n )\n }\n const org = resolved.org ?? {}\n if (resolveEffectivePlan(org) === 'free') {\n return Response.json(\n { error: 'Bringing an existing site in is included on every paid plan.', reason: 'plan' },\n { status: 403 },\n )\n }\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org: org as Record<string, unknown>,\n })\n if (locked) return locked\n return { uid: decoded.uid, decoded, staff, orgId, org, member, firestore: app.firestore() }\n}\n\n/**\n * Whether \"Bring my site\" is offered to this reader (AGL-3976), in the\n * doors' own order, for Create site's choice card: `null` while the flag is\n * off (the card is not drawn), `role` or `plan` for a refusal the workspace\n * can change (the card is drawn disabled with the reason), `ready` otherwise.\n * Read by the jobs list only when the card asks, so no other read pays for it.\n */\nexport async function siteImportOfferFor(input: {\n orgId: string\n staff: boolean\n org: Partial<AglynOrganization> | null\n member: AglynOrgMember | null\n}): Promise<'ready' | 'role' | 'plan' | null> {\n try {\n if (!input.staff && !(await isServerReleaseFlagOnForOrg(SITE_IMPORT_RELEASE_FLAG, input.orgId))) return null\n } catch {\n return null\n }\n if (!input.member || !(await memberHasOrgPermission(input.orgId, input.member, 'ai.import'))) return 'role'\n if (resolveEffectivePlan(input.org ?? {}) === 'free') return 'plan'\n return 'ready'\n}\n\nasync function audit(\n gate: SiteImportGateContext,\n action: string,\n domain: string,\n after: Record<string, unknown>,\n): Promise<void> {\n try {\n await addAdminAudit(gate.firestore, {\n actorUid: gate.uid,\n actorEmail: gate.decoded.email ?? null,\n action,\n target: `orgs/${gate.orgId}/domainOwnershipClaims/${domain}`,\n after: { ...after, ...(gate.staff ? { staff: true } : {}) },\n at: FieldValue.serverTimestamp(),\n })\n } catch (error) {\n console.error('site import audit write failed', { orgId: gate.orgId, action, error })\n }\n}\n\nfunction ownershipDeps(seams: SiteImportRouteSeams): DomainOwnershipDeps {\n return {\n readVerificationMeta: (domain) =>\n readSiteVerificationMeta(domain, DOMAIN_OWNERSHIP_META_NAME, seams.crawl),\n ...seams.ownership,\n }\n}\n\nconst tooMany = (error: string) => Response.json({ error, reason: 'rate' }, { status: 429 })\n\n/** `GET /api/ai/site-import/ownership?orgId=`. */\nexport async function listSiteImportOwnership(\n request: Request,\n seams: SiteImportRouteSeams = {},\n): Promise<Response> {\n const orgId = new URL(request.url).searchParams.get('orgId') ?? ''\n const gate = await siteImportGate(request, orgId)\n if (gate instanceof Response) return gate\n const claims = await listDomainOwnershipClaims(gate.orgId, ownershipDeps(seams))\n return Response.json({ claims })\n}\n\n/** `POST /api/ai/site-import/ownership`. */\nexport async function postSiteImportOwnership(\n request: Request,\n seams: SiteImportRouteSeams = {},\n): Promise<Response> {\n const body = (await request.json().catch(() => null)) as Record<string, unknown> | null\n if (!body || typeof body !== 'object') {\n return Response.json({ error: 'Send a JSON body' }, { status: 400 })\n }\n const gate = await siteImportGate(request, String(body['orgId'] ?? ''))\n if (gate instanceof Response) return gate\n const deps = ownershipDeps(seams)\n const action = String(body['action'] ?? '')\n const domain = String(body['domain'] ?? '')\n\n if (action === 'claim') {\n const { claim, error } = await issueDomainOwnershipClaim(gate.orgId, domain, gate.uid, deps)\n if (!claim) return Response.json({ error }, { status: 400 })\n await audit(gate, 'site-import.ownership.claim', claim.domain, { status: claim.status })\n return Response.json({ claim })\n }\n\n if (action === 'check') {\n const method = body['method']\n if (method !== 'dns' && method !== 'meta') {\n return Response.json({ error: 'method must be dns or meta' }, { status: 400 })\n }\n const window = await consumeRateLimit(`site-import-ownership-check:${gate.orgId}`, {\n limit: SITE_IMPORT_CHECKS_PER_ORG_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!window.allowed) return tooMany('Too many checks. Try again in an hour.')\n const { result, error } = await checkDomainOwnership(gate.orgId, domain, method, deps)\n if (!result) return Response.json({ error }, { status: 400 })\n if (result.status === 'proven') {\n await audit(gate, 'site-import.ownership.proven', result.claim.domain, {\n method,\n detail: result.claim.provenDetail,\n })\n }\n return Response.json({ result })\n }\n\n if (action === 'send-code') {\n const sent = await sendDomainOwnershipCode(\n gate.orgId,\n domain,\n String(body['email'] ?? ''),\n { org: gate.org as Record<string, unknown> },\n deps,\n )\n if (!sent.claim) return Response.json({ error: sent.error }, { status: sent.status })\n await audit(gate, 'site-import.ownership.code-sent', sent.claim.domain, {\n address: sent.claim.email.pendingAddress,\n })\n return Response.json({ claim: sent.claim })\n }\n\n if (action === 'confirm-code') {\n const { result, error } = await confirmDomainOwnershipCode(gate.orgId, domain, String(body['code'] ?? ''), deps)\n if (!result) return Response.json({ error }, { status: 400 })\n if (result.status === 'proven') {\n await audit(gate, 'site-import.ownership.proven', result.claim.domain, {\n method: 'email',\n detail: result.claim.provenDetail,\n })\n }\n return Response.json({ result })\n }\n\n return Response.json(\n { error: 'action must be claim, check, send-code or confirm-code' },\n { status: 400 },\n )\n}\n\n/** `POST /api/ai/site-import/crawl`. */\nexport async function postSiteImportCrawl(\n request: Request,\n seams: SiteImportRouteSeams = {},\n): Promise<Response> {\n const body = (await request.json().catch(() => null)) as Record<string, unknown> | null\n if (!body || typeof body !== 'object') {\n return Response.json({ error: 'Send a JSON body' }, { status: 400 })\n }\n const gate = await siteImportGate(request, String(body['orgId'] ?? ''))\n if (gate instanceof Response) return gate\n const { domain, error } = normalizeOwnershipDomain(String(body['domain'] ?? ''))\n if (!domain) return Response.json({ error }, { status: 400 })\n // Ownership BEFORE any page is read: a crawl of a site the org has not\n // proved is exactly the clone this whole feature is built to refuse.\n if (!(await isDomainOwnershipProven(gate.orgId, domain, ownershipDeps(seams)))) {\n return Response.json(\n { error: 'Verify that you own this site first.', reason: 'ownership' },\n { status: 403 },\n )\n }\n const window = await consumeRateLimit(`site-import-crawl:${gate.orgId}`, {\n limit: SITE_IMPORT_CRAWLS_PER_ORG_PER_DAY,\n windowMs: 24 * 60 * 60 * 1000,\n })\n if (!window.allowed) return tooMany('This workspace has read sites enough times today. Try again tomorrow.')\n const planPages = resolveOrgEntitlements(gate.org).screensPerHost\n const asked = Number(body['maxPages'])\n const maxPages = Math.max(\n 1,\n Math.min(\n SITE_IMPORT_DOOR_MAX_PAGES,\n Number.isFinite(planPages) && planPages > 0 ? planPages : SITE_IMPORT_DOOR_MAX_PAGES,\n Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : SITE_IMPORT_DOOR_MAX_PAGES,\n ),\n )\n const inventory = await crawlSiteForImport(\n { domain, maxPages, timeBudgetMs: SITE_IMPORT_DOOR_TIME_MS, signal: request.signal },\n seams.crawl,\n )\n await audit(gate, 'site-import.crawl', domain, {\n pages: inventory.pages.length,\n skipped: inventory.skipped.length + inventory.skippedOverflow,\n loginPagesRefused: inventory.skipped.filter((entry) => entry.reason === 'login-form').length,\n stoppedBy: inventory.stoppedBy,\n refused: inventory.refused,\n bytesRead: inventory.bytesRead,\n })\n return Response.json({ inventory })\n}\n"],"names":["resolveEffectivePlan","resolveOrgEntitlements","addAdminAudit","checkDomainOwnership","confirmDomainOwnershipCode","DOMAIN_OWNERSHIP_META_NAME","isDomainOwnershipProven","issueDomainOwnershipClaim","listDomainOwnershipClaims","normalizeOwnershipDomain","sendDomainOwnershipCode","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","invalidIdTokenResponse","lockdownRefusal","getOrgForUser","memberHasOrgPermission","permissionRefusalText","consumeRateLimit","isServerReleaseFlagOnForOrg","FieldValue","crawlSiteForImport","readSiteVerificationMeta","SITE_IMPORT_RELEASE_FLAG","SITE_IMPORT_DOOR_MAX_PAGES","SITE_IMPORT_CRAWLS_PER_ORG_PER_DAY","SITE_IMPORT_CHECKS_PER_ORG_PER_HOUR","SITE_IMPORT_DOOR_TIME_MS","bearerToken","request","authorization","headers","get","startsWith","slice","length","undefined","siteImportGate","rawOrgId","resolved","idToken","Response","json","error","status","app","decoded","auth","verifyIdToken","unauthenticated","email_verified","staff","orgId","String","trim","uid","member","reason","org","locked","firestore","siteImportOfferFor","input","audit","gate","action","domain","after","actorUid","actorEmail","email","target","at","serverTimestamp","console","ownershipDeps","seams","readVerificationMeta","crawl","ownership","tooMany","listSiteImportOwnership","URL","url","searchParams","claims","postSiteImportOwnership","body","catch","deps","claim","method","window","limit","windowMs","allowed","result","detail","provenDetail","sent","address","pendingAddress","postSiteImportCrawl","planPages","screensPerHost","asked","Number","maxPages","Math","max","min","isFinite","floor","inventory","timeBudgetMs","signal","pages","skipped","skippedOverflow","loginPagesRefused","filter","entry","stoppedBy","refused","bytesRead"],"mappings":"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"}
@@ -27,11 +27,25 @@ export declare function assistBuildTool(): AiTool;
27
27
  export declare function assistBuildBlock(): string;
28
28
  /** What a build can make on this site, in each owner's own words. Per site: volatile. */
29
29
  export declare function assistBuildIntentsBlock(intents: readonly string[]): string;
30
+ /**
31
+ * Where a build's facts come from, said once at the end of every brief the
32
+ * chat proposes (AGL-4092): never a placeholder for one it lacks.
33
+ */
34
+ export declare const ASSIST_BUILD_FACTS_LINE = "Facts: use the site\u2019s business profile and contact details on file; where a fact is missing, leave it out \u2014 no placeholders, no brackets.";
35
+ /**
36
+ * The brief with every clause that asks for placeholders, brackets or "to be
37
+ * added" text taken out (AGL-4092): "Do not invent names; use bracketed
38
+ * placeholders where details are missing." keeps "Do not invent names." A
39
+ * sentence nothing is left of goes. Pure.
40
+ */
41
+ export declare function assistBuildBriefWithoutGaps(brief: string): string;
30
42
  /**
31
43
  * The tool call held to what a build job accepts, or `null` when it is
32
44
  * unusable. The site is the request's own, never the model's; the brief is
33
45
  * clamped to the job door's limit, with a line naming the console page it
34
- * was asked from so the plan step can read "this page" against it.
46
+ * was asked from so the plan step can read "this page" against it. Before
47
+ * it, the brief asks for no placeholder and says where facts come from
48
+ * (AGL-4092).
35
49
  */
36
50
  export declare function resolveAssistBuild(input: Record<string, unknown> | null, scope: {
37
51
  hostId: string;
@@ -64,8 +64,11 @@ import { aiJobStepRunnerFor } from "../jobs/ai-jobs.js";
64
64
  '- brief: everything they asked for, in their words, with each page, form and other part named and what each is for. Include what the conversation already settled. Do not invent parts they did not ask for beyond what their request plainly needs (a page that shows a form needs that form).',
65
65
  '- publish: true only when they asked for the new pages to go live or be published; otherwise false. New work is an unpublished draft by default.',
66
66
  '- summary: one short sentence, such as "Plan three pages, a contact form and a booking service".',
67
+ // AGL-4092: a brief the chat wrote asked the build to "use bracketed placeholders where details are missing".
68
+ '- Never ask the build for placeholders, brackets or "to be added" text. Where the user gave no fact, say nothing of it: the build reads the site’s business profile and contact details itself.',
67
69
  '- Nothing is built when you call the tool: the user is shown a plan with its price and confirms it. Never say anything is made, and never quote a price.',
68
70
  '- Only offer what the "A build here can make" block lists. If they ask for something it does not list, say plainly it cannot be built here from the chat and build the rest.',
71
+ '- A change to the site’s theme or look (its colors, fonts or corners) is offered only where the "A build here can make" block lists it, which it does on paid plans. When it is asked for and not listed, say a build cannot change the theme on this workspace’s plan and that they can change it in Setup → Theme, where the Theme assistant can propose one; then build the rest.',
69
72
  '- A question, or a change to the canvas the user has open, is not a build: answer it, or use the canvas edit tool where it is offered.'
70
73
  ].join('\n');
71
74
  }
@@ -75,17 +78,41 @@ import { aiJobStepRunnerFor } from "../jobs/ai-jobs.js";
75
78
  ...intents.map((intent)=>`- ${intent}`)
76
79
  ].join('\n');
77
80
  }
81
+ /**
82
+ * Where a build's facts come from, said once at the end of every brief the
83
+ * chat proposes (AGL-4092): never a placeholder for one it lacks.
84
+ */ export const ASSIST_BUILD_FACTS_LINE = 'Facts: use the site’s business profile and contact details on file; where a fact is missing, leave it out — no placeholders, no brackets.';
85
+ /**
86
+ * A clause of a brief that asks for a gap instead of a fact: "use bracketed
87
+ * placeholders where details are missing", "mark it in square brackets",
88
+ * "show TBD". A shop's "shelf brackets" asks for none.
89
+ */ const ASKS_FOR_GAPS = /\b(?:placeholders?|bracketed|in (?:square )?brackets|to be (?:added|filled|confirmed)|tbd)\b/i;
90
+ /**
91
+ * The brief with every clause that asks for placeholders, brackets or "to be
92
+ * added" text taken out (AGL-4092): "Do not invent names; use bracketed
93
+ * placeholders where details are missing." keeps "Do not invent names." A
94
+ * sentence nothing is left of goes. Pure.
95
+ */ export function assistBuildBriefWithoutGaps(brief) {
96
+ return brief.split('\n').map((line)=>line.split(/(?<=[.!?])\s+/).map((sentence)=>{
97
+ if (!ASKS_FOR_GAPS.test(sentence)) return sentence;
98
+ const kept = sentence.replace(/[.!?]$/, '').split(/\s*;\s*/).filter((clause)=>!ASKS_FOR_GAPS.test(clause)).join('; ').trim();
99
+ return kept ? `${kept}.` : '';
100
+ }).filter(Boolean).join(' ')).filter((line)=>line.trim()).join('\n').trim();
101
+ }
78
102
  /**
79
103
  * The tool call held to what a build job accepts, or `null` when it is
80
104
  * unusable. The site is the request's own, never the model's; the brief is
81
105
  * clamped to the job door's limit, with a line naming the console page it
82
- * was asked from so the plan step can read "this page" against it.
106
+ * was asked from so the plan step can read "this page" against it. Before
107
+ * it, the brief asks for no placeholder and says where facts come from
108
+ * (AGL-4092).
83
109
  */ export function resolveAssistBuild(input, scope) {
84
110
  if (!input) return null;
85
- const asked = typeof input['brief'] === 'string' ? input['brief'].trim() : '';
111
+ const asked = typeof input['brief'] === 'string' ? assistBuildBriefWithoutGaps(input['brief'].trim()) : '';
86
112
  if (!asked) return null;
87
113
  const where = scope.screen ? `\nAsked from the console page: ${scope.screen}` : '';
88
- const brief = `${asked.slice(0, AI_JOB_BRIEF_MAX_CHARS - where.length)}${where}`.trim();
114
+ const facts = `\n${ASSIST_BUILD_FACTS_LINE}`;
115
+ const brief = `${asked.slice(0, AI_JOB_BRIEF_MAX_CHARS - facts.length - where.length).trim()}${facts}${where}`.trim();
89
116
  const summary = typeof input['summary'] === 'string' && input['summary'].trim() ? input['summary'].trim().slice(0, ASSIST_BUILD_SUMMARY_MAX_CHARS) : 'Plan what you asked for';
90
117
  return {
91
118
  id: ASSIST_BUILD_ACTION_ID,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/assist-build.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport {\n ASSIST_BUILD_ACTION_ID,\n ASSIST_BUILD_SUMMARY_MAX_CHARS,\n ASSIST_BUILD_TOOL_NAME,\n type AssistBuildProposal,\n} from '../model/assist-build'\nimport type { AiTool } from '../providers/contract'\nimport { aiBuildIntents, aiBuildOps } from '../jobs/ai-build-capabilities'\nimport { aiBuildFreeTaste } from '../jobs/ai-job-build-step'\nimport { AI_JOB_BRIEF_MAX_CHARS } from '../jobs/ai-job-text-step'\nimport { aiJobStepRunnerFor } from '../jobs/ai-jobs'\n\n/**\n * The chat's build rung (AGL-3616): on a site, a request that names things\n * to make — \"a few new pages, a booking form and a contact form, then an\n * about page\" — is proposed as ONE build through a strict tool. The tool\n * carries the request, not a plan: planning is the build job's own plan\n * step, against the site's real inventory, with its re-ask and its limits.\n * The panel starts that job and shows its plan card with one price; nothing\n * is built until the person confirms it, and what is built is a draft unless\n * they asked to publish and tick the card's box.\n */\n\nconst STRING = { type: 'string' } as const\n\n/** The strict tool the model proposes a build through. */\nexport function assistBuildTool(): AiTool {\n return {\n name: ASSIST_BUILD_TOOL_NAME,\n description:\n 'Propose building what the user asked for on this site: pages, a header ' +\n 'and footer, forms and the other things listed. The user sees a plan with ' +\n 'its price and confirms it before anything is made. Call at most once per ' +\n 'message, with the whole request.',\n inputSchema: {\n type: 'object',\n properties: {\n summary: STRING,\n brief: STRING,\n publish: { type: 'boolean' },\n },\n required: ['summary', 'brief', 'publish'],\n additionalProperties: false,\n },\n strict: true,\n }\n}\n\n/**\n * The build protocol — the same for every site, so it sits inside the cached\n * prefix. What a build can make on THIS site is per site and rides\n * `assistBuildIntentsBlock`, after every breakpoint.\n */\nexport function assistBuildBlock(): string {\n return [\n 'Building on this site:',\n `- When the user asks you to make or set up things on their site — pages, a header and footer, forms, or anything listed in the \"A build here can make\" block — write one or two sentences saying what you will plan, then call the ${ASSIST_BUILD_TOOL_NAME} tool once with the whole request. Write no JSON in your message.`,\n '- brief: everything they asked for, in their words, with each page, form and other part named and what each is for. Include what the conversation already settled. Do not invent parts they did not ask for beyond what their request plainly needs (a page that shows a form needs that form).',\n '- publish: true only when they asked for the new pages to go live or be published; otherwise false. New work is an unpublished draft by default.',\n '- summary: one short sentence, such as \"Plan three pages, a contact form and a booking service\".',\n '- Nothing is built when you call the tool: the user is shown a plan with its price and confirms it. Never say anything is made, and never quote a price.',\n '- Only offer what the \"A build here can make\" block lists. If they ask for something it does not list, say plainly it cannot be built here from the chat and build the rest.',\n '- A question, or a change to the canvas the user has open, is not a build: answer it, or use the canvas edit tool where it is offered.',\n ].join('\\n')\n}\n\n/** What a build can make on this site, in each owner's own words. Per site: volatile. */\nexport function assistBuildIntentsBlock(intents: readonly string[]): string {\n return ['A build here can make:', ...intents.map((intent) => `- ${intent}`)].join('\\n')\n}\n\n/**\n * The tool call held to what a build job accepts, or `null` when it is\n * unusable. The site is the request's own, never the model's; the brief is\n * clamped to the job door's limit, with a line naming the console page it\n * was asked from so the plan step can read \"this page\" against it.\n */\nexport function resolveAssistBuild(\n input: Record<string, unknown> | null,\n scope: { hostId: string; screen: string | null },\n): AssistBuildProposal | null {\n if (!input) return null\n const asked = typeof input['brief'] === 'string' ? input['brief'].trim() : ''\n if (!asked) return null\n const where = scope.screen ? `\\nAsked from the console page: ${scope.screen}` : ''\n const brief = `${asked.slice(0, AI_JOB_BRIEF_MAX_CHARS - where.length)}${where}`.trim()\n const summary =\n typeof input['summary'] === 'string' && input['summary'].trim()\n ? input['summary'].trim().slice(0, ASSIST_BUILD_SUMMARY_MAX_CHARS)\n : 'Plan what you asked for'\n return {\n id: ASSIST_BUILD_ACTION_ID,\n hostId: scope.hostId,\n brief,\n publish: input['publish'] === true,\n summary,\n }\n}\n\nexport interface AssistBuildIntentsDeps {\n ops?: typeof aiBuildOps\n hasBuildRunner?: () => boolean\n}\n\n/**\n * What a build may make on this site, or `null` where none can be built: a\n * deployment that has not loaded the build kind, or a site where no\n * operation passes its owner's gates. The same operations the build's plan\n * step offers, so the chat never offers what the plan cannot make.\n */\nexport async function assistBuildIntents(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; hostId: string; org: Record<string, unknown> },\n deps: AssistBuildIntentsDeps = {},\n): Promise<string[] | null> {\n const hasBuildRunner = deps.hasBuildRunner ?? (() => aiJobStepRunnerFor('build') !== null)\n if (!hasBuildRunner()) return null\n const host = ((await firestore.collection('hosts').doc(input.hostId).get()).data() ?? null) as Record<\n string,\n unknown\n > | null\n if (!host) return null\n const org = input.org as Partial<AglynOrgBilling>\n const ops = await (deps.ops ?? aiBuildOps)({\n orgId: input.orgId,\n hostId: input.hostId,\n org,\n host,\n freeTaste: aiBuildFreeTaste(org),\n })\n const intents = aiBuildIntents(ops)\n return intents.length ? intents : null\n}\n"],"names":["ASSIST_BUILD_ACTION_ID","ASSIST_BUILD_SUMMARY_MAX_CHARS","ASSIST_BUILD_TOOL_NAME","aiBuildIntents","aiBuildOps","aiBuildFreeTaste","AI_JOB_BRIEF_MAX_CHARS","aiJobStepRunnerFor","STRING","type","assistBuildTool","name","description","inputSchema","properties","summary","brief","publish","required","additionalProperties","strict","assistBuildBlock","join","assistBuildIntentsBlock","intents","map","intent","resolveAssistBuild","input","scope","asked","trim","where","screen","slice","length","id","hostId","assistBuildIntents","firestore","deps","hasBuildRunner","host","collection","doc","get","data","org","ops","orgId","freeTaste"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/assist-build.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport {\n ASSIST_BUILD_ACTION_ID,\n ASSIST_BUILD_SUMMARY_MAX_CHARS,\n ASSIST_BUILD_TOOL_NAME,\n type AssistBuildProposal,\n} from '../model/assist-build'\nimport type { AiTool } from '../providers/contract'\nimport { aiBuildIntents, aiBuildOps } from '../jobs/ai-build-capabilities'\nimport { aiBuildFreeTaste } from '../jobs/ai-job-build-step'\nimport { AI_JOB_BRIEF_MAX_CHARS } from '../jobs/ai-job-text-step'\nimport { aiJobStepRunnerFor } from '../jobs/ai-jobs'\n\n/**\n * The chat's build rung (AGL-3616): on a site, a request that names things\n * to make — \"a few new pages, a booking form and a contact form, then an\n * about page\" — is proposed as ONE build through a strict tool. The tool\n * carries the request, not a plan: planning is the build job's own plan\n * step, against the site's real inventory, with its re-ask and its limits.\n * The panel starts that job and shows its plan card with one price; nothing\n * is built until the person confirms it, and what is built is a draft unless\n * they asked to publish and tick the card's box.\n */\n\nconst STRING = { type: 'string' } as const\n\n/** The strict tool the model proposes a build through. */\nexport function assistBuildTool(): AiTool {\n return {\n name: ASSIST_BUILD_TOOL_NAME,\n description:\n 'Propose building what the user asked for on this site: pages, a header ' +\n 'and footer, forms and the other things listed. The user sees a plan with ' +\n 'its price and confirms it before anything is made. Call at most once per ' +\n 'message, with the whole request.',\n inputSchema: {\n type: 'object',\n properties: {\n summary: STRING,\n brief: STRING,\n publish: { type: 'boolean' },\n },\n required: ['summary', 'brief', 'publish'],\n additionalProperties: false,\n },\n strict: true,\n }\n}\n\n/**\n * The build protocol — the same for every site, so it sits inside the cached\n * prefix. What a build can make on THIS site is per site and rides\n * `assistBuildIntentsBlock`, after every breakpoint.\n */\nexport function assistBuildBlock(): string {\n return [\n 'Building on this site:',\n `- When the user asks you to make or set up things on their site — pages, a header and footer, forms, or anything listed in the \"A build here can make\" block — write one or two sentences saying what you will plan, then call the ${ASSIST_BUILD_TOOL_NAME} tool once with the whole request. Write no JSON in your message.`,\n '- brief: everything they asked for, in their words, with each page, form and other part named and what each is for. Include what the conversation already settled. Do not invent parts they did not ask for beyond what their request plainly needs (a page that shows a form needs that form).',\n '- publish: true only when they asked for the new pages to go live or be published; otherwise false. New work is an unpublished draft by default.',\n '- summary: one short sentence, such as \"Plan three pages, a contact form and a booking service\".',\n // AGL-4092: a brief the chat wrote asked the build to \"use bracketed placeholders where details are missing\".\n '- Never ask the build for placeholders, brackets or \"to be added\" text. Where the user gave no fact, say nothing of it: the build reads the site’s business profile and contact details itself.',\n '- Nothing is built when you call the tool: the user is shown a plan with its price and confirms it. Never say anything is made, and never quote a price.',\n '- Only offer what the \"A build here can make\" block lists. If they ask for something it does not list, say plainly it cannot be built here from the chat and build the rest.',\n '- A change to the site’s theme or look (its colors, fonts or corners) is offered only where the \"A build here can make\" block lists it, which it does on paid plans. When it is asked for and not listed, say a build cannot change the theme on this workspace’s plan and that they can change it in Setup → Theme, where the Theme assistant can propose one; then build the rest.',\n '- A question, or a change to the canvas the user has open, is not a build: answer it, or use the canvas edit tool where it is offered.',\n ].join('\\n')\n}\n\n/** What a build can make on this site, in each owner's own words. Per site: volatile. */\nexport function assistBuildIntentsBlock(intents: readonly string[]): string {\n return ['A build here can make:', ...intents.map((intent) => `- ${intent}`)].join('\\n')\n}\n\n/**\n * Where a build's facts come from, said once at the end of every brief the\n * chat proposes (AGL-4092): never a placeholder for one it lacks.\n */\nexport const ASSIST_BUILD_FACTS_LINE =\n 'Facts: use the site’s business profile and contact details on file; where a fact is missing, leave it out — no placeholders, no brackets.'\n\n/**\n * A clause of a brief that asks for a gap instead of a fact: \"use bracketed\n * placeholders where details are missing\", \"mark it in square brackets\",\n * \"show TBD\". A shop's \"shelf brackets\" asks for none.\n */\nconst ASKS_FOR_GAPS = /\\b(?:placeholders?|bracketed|in (?:square )?brackets|to be (?:added|filled|confirmed)|tbd)\\b/i\n\n/**\n * The brief with every clause that asks for placeholders, brackets or \"to be\n * added\" text taken out (AGL-4092): \"Do not invent names; use bracketed\n * placeholders where details are missing.\" keeps \"Do not invent names.\" A\n * sentence nothing is left of goes. Pure.\n */\nexport function assistBuildBriefWithoutGaps(brief: string): string {\n return brief\n .split('\\n')\n .map((line) =>\n line\n .split(/(?<=[.!?])\\s+/)\n .map((sentence) => {\n if (!ASKS_FOR_GAPS.test(sentence)) return sentence\n const kept = sentence\n .replace(/[.!?]$/, '')\n .split(/\\s*;\\s*/)\n .filter((clause) => !ASKS_FOR_GAPS.test(clause))\n .join('; ')\n .trim()\n return kept ? `${kept}.` : ''\n })\n .filter(Boolean)\n .join(' '),\n )\n .filter((line) => line.trim())\n .join('\\n')\n .trim()\n}\n\n/**\n * The tool call held to what a build job accepts, or `null` when it is\n * unusable. The site is the request's own, never the model's; the brief is\n * clamped to the job door's limit, with a line naming the console page it\n * was asked from so the plan step can read \"this page\" against it. Before\n * it, the brief asks for no placeholder and says where facts come from\n * (AGL-4092).\n */\nexport function resolveAssistBuild(\n input: Record<string, unknown> | null,\n scope: { hostId: string; screen: string | null },\n): AssistBuildProposal | null {\n if (!input) return null\n const asked = typeof input['brief'] === 'string' ? assistBuildBriefWithoutGaps(input['brief'].trim()) : ''\n if (!asked) return null\n const where = scope.screen ? `\\nAsked from the console page: ${scope.screen}` : ''\n const facts = `\\n${ASSIST_BUILD_FACTS_LINE}`\n const brief = `${asked.slice(0, AI_JOB_BRIEF_MAX_CHARS - facts.length - where.length).trim()}${facts}${where}`.trim()\n const summary =\n typeof input['summary'] === 'string' && input['summary'].trim()\n ? input['summary'].trim().slice(0, ASSIST_BUILD_SUMMARY_MAX_CHARS)\n : 'Plan what you asked for'\n return {\n id: ASSIST_BUILD_ACTION_ID,\n hostId: scope.hostId,\n brief,\n publish: input['publish'] === true,\n summary,\n }\n}\n\nexport interface AssistBuildIntentsDeps {\n ops?: typeof aiBuildOps\n hasBuildRunner?: () => boolean\n}\n\n/**\n * What a build may make on this site, or `null` where none can be built: a\n * deployment that has not loaded the build kind, or a site where no\n * operation passes its owner's gates. The same operations the build's plan\n * step offers, so the chat never offers what the plan cannot make.\n */\nexport async function assistBuildIntents(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; hostId: string; org: Record<string, unknown> },\n deps: AssistBuildIntentsDeps = {},\n): Promise<string[] | null> {\n const hasBuildRunner = deps.hasBuildRunner ?? (() => aiJobStepRunnerFor('build') !== null)\n if (!hasBuildRunner()) return null\n const host = ((await firestore.collection('hosts').doc(input.hostId).get()).data() ?? null) as Record<\n string,\n unknown\n > | null\n if (!host) return null\n const org = input.org as Partial<AglynOrgBilling>\n const ops = await (deps.ops ?? aiBuildOps)({\n orgId: input.orgId,\n hostId: input.hostId,\n org,\n host,\n freeTaste: aiBuildFreeTaste(org),\n })\n const intents = aiBuildIntents(ops)\n return intents.length ? intents : null\n}\n"],"names":["ASSIST_BUILD_ACTION_ID","ASSIST_BUILD_SUMMARY_MAX_CHARS","ASSIST_BUILD_TOOL_NAME","aiBuildIntents","aiBuildOps","aiBuildFreeTaste","AI_JOB_BRIEF_MAX_CHARS","aiJobStepRunnerFor","STRING","type","assistBuildTool","name","description","inputSchema","properties","summary","brief","publish","required","additionalProperties","strict","assistBuildBlock","join","assistBuildIntentsBlock","intents","map","intent","ASSIST_BUILD_FACTS_LINE","ASKS_FOR_GAPS","assistBuildBriefWithoutGaps","split","line","sentence","test","kept","replace","filter","clause","trim","Boolean","resolveAssistBuild","input","scope","asked","where","screen","facts","slice","length","id","hostId","assistBuildIntents","firestore","deps","hasBuildRunner","host","collection","doc","get","data","org","ops","orgId","freeTaste"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SACEA,sBAAsB,EACtBC,8BAA8B,EAC9BC,sBAAsB,QAEjB,2BAAuB;AAE9B,SAASC,cAAc,EAAEC,UAAU,QAAQ,mCAA+B;AAC1E,SAASC,gBAAgB,QAAQ,+BAA2B;AAC5D,SAASC,sBAAsB,QAAQ,8BAA0B;AACjE,SAASC,kBAAkB,QAAQ,qBAAiB;AAEpD;;;;;;;;;CASC,GAED,MAAMC,SAAS;IAAEC,MAAM;AAAS;AAEhC,wDAAwD,GACxD,OAAO,SAASC;IACd,OAAO;QACLC,MAAMT;QACNU,aACE,4EACA,8EACA,8EACA;QACFC,aAAa;YACXJ,MAAM;YACNK,YAAY;gBACVC,SAASP;gBACTQ,OAAOR;gBACPS,SAAS;oBAAER,MAAM;gBAAU;YAC7B;YACAS,UAAU;gBAAC;gBAAW;gBAAS;aAAU;YACzCC,sBAAsB;QACxB;QACAC,QAAQ;IACV;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASC;IACd,OAAO;QACL;QACA,CAAC,mOAAmO,EAAEnB,uBAAuB,iEAAiE,CAAC;QAC/T;QACA;QACA;QACA,8GAA8G;QAC9G;QACA;QACA;QACA;QACA;KACD,CAACoB,IAAI,CAAC;AACT;AAEA,uFAAuF,GACvF,OAAO,SAASC,wBAAwBC,OAA0B;IAChE,OAAO;QAAC;WAA6BA,QAAQC,GAAG,CAAC,CAACC,SAAW,CAAC,EAAE,EAAEA,QAAQ;KAAE,CAACJ,IAAI,CAAC;AACpF;AAEA;;;CAGC,GACD,OAAO,MAAMK,0BACX,4IAA2I;AAE7I;;;;CAIC,GACD,MAAMC,gBAAgB;AAEtB;;;;;CAKC,GACD,OAAO,SAASC,4BAA4Bb,KAAa;IACvD,OAAOA,MACJc,KAAK,CAAC,MACNL,GAAG,CAAC,CAACM,OACJA,KACGD,KAAK,CAAC,iBACNL,GAAG,CAAC,CAACO;YACJ,IAAI,CAACJ,cAAcK,IAAI,CAACD,WAAW,OAAOA;YAC1C,MAAME,OAAOF,SACVG,OAAO,CAAC,UAAU,IAClBL,KAAK,CAAC,WACNM,MAAM,CAAC,CAACC,SAAW,CAACT,cAAcK,IAAI,CAACI,SACvCf,IAAI,CAAC,MACLgB,IAAI;YACP,OAAOJ,OAAO,GAAGA,KAAK,CAAC,CAAC,GAAG;QAC7B,GACCE,MAAM,CAACG,SACPjB,IAAI,CAAC,MAETc,MAAM,CAAC,CAACL,OAASA,KAAKO,IAAI,IAC1BhB,IAAI,CAAC,MACLgB,IAAI;AACT;AAEA;;;;;;;CAOC,GACD,OAAO,SAASE,mBACdC,KAAqC,EACrCC,KAAgD;IAEhD,IAAI,CAACD,OAAO,OAAO;IACnB,MAAME,QAAQ,OAAOF,KAAK,CAAC,QAAQ,KAAK,WAAWZ,4BAA4BY,KAAK,CAAC,QAAQ,CAACH,IAAI,MAAM;IACxG,IAAI,CAACK,OAAO,OAAO;IACnB,MAAMC,QAAQF,MAAMG,MAAM,GAAG,CAAC,+BAA+B,EAAEH,MAAMG,MAAM,EAAE,GAAG;IAChF,MAAMC,QAAQ,CAAC,EAAE,EAAEnB,yBAAyB;IAC5C,MAAMX,QAAQ,GAAG2B,MAAMI,KAAK,CAAC,GAAGzC,yBAAyBwC,MAAME,MAAM,GAAGJ,MAAMI,MAAM,EAAEV,IAAI,KAAKQ,QAAQF,OAAO,CAACN,IAAI;IACnH,MAAMvB,UACJ,OAAO0B,KAAK,CAAC,UAAU,KAAK,YAAYA,KAAK,CAAC,UAAU,CAACH,IAAI,KACzDG,KAAK,CAAC,UAAU,CAACH,IAAI,GAAGS,KAAK,CAAC,GAAG9C,kCACjC;IACN,OAAO;QACLgD,IAAIjD;QACJkD,QAAQR,MAAMQ,MAAM;QACpBlC;QACAC,SAASwB,KAAK,CAAC,UAAU,KAAK;QAC9B1B;IACF;AACF;AAOA;;;;;CAKC,GACD,OAAO,eAAeoC,mBACpBC,SAAsC,EACtCX,KAAsE,EACtEY,OAA+B,CAAC,CAAC;QAEVA,sBAET,OAMKA;IARnB,MAAMC,kBAAiBD,uBAAAA,KAAKC,cAAc,YAAnBD,uBAAwB,IAAM9C,mBAAmB,aAAa;IACrF,IAAI,CAAC+C,kBAAkB,OAAO;IAC9B,MAAMC,QAAQ,QAAA,AAAC,CAAA,MAAMH,UAAUI,UAAU,CAAC,SAASC,GAAG,CAAChB,MAAMS,MAAM,EAAEQ,GAAG,EAAC,EAAGC,IAAI,cAAlE,QAAwE;IAItF,IAAI,CAACJ,MAAM,OAAO;IAClB,MAAMK,MAAMnB,MAAMmB,GAAG;IACrB,MAAMC,MAAM,MAAM,EAACR,YAAAA,KAAKQ,GAAG,YAARR,YAAYjD,YAAY;QACzC0D,OAAOrB,MAAMqB,KAAK;QAClBZ,QAAQT,MAAMS,MAAM;QACpBU;QACAL;QACAQ,WAAW1D,iBAAiBuD;IAC9B;IACA,MAAMpC,UAAUrB,eAAe0D;IAC/B,OAAOrC,QAAQwB,MAAM,GAAGxB,UAAU;AACpC"}
@@ -79,7 +79,9 @@ import { type AssistBuildDraft } from '../model/assist-follow-up';
79
79
  * answers PLUS page-context awareness (level 2 — the current route/host is
80
80
  * injected so the assistant can walk the user through the view they are
81
81
  * on). Free orgs get level 1 only: docs-grounded answers and deep links;
82
- * any client-sent page context is deliberately dropped.
82
+ * any client-sent page context is deliberately dropped. Every tier is told
83
+ * its plan by name and, where AI jobs exist, the workspace's open ones
84
+ * (AGL-4075); a job on the site the page named links its page there.
83
85
  *
84
86
  * Streaming: the route re-emits the provider's stream as simplified
85
87
  * `data: {type:'delta',text}` events, then one `{type:'done', exchangeId,
@@ -96,18 +98,20 @@ import { type AssistBuildDraft } from '../model/assist-follow-up';
96
98
  * thinking plus answer together, so leaving it off the request would spend
97
99
  * output-priced thinking tokens on "how do I publish a screen", stall the
98
100
  * stream behind a silent think (display defaults to `omitted`, i.e. empty
99
- * thinking blocks), and truncate the answer inside the same 1024-token
100
- * ceiling. `effort: low` is the documented posture for a scoped chat
101
- * workload; both are asserted in the spec so a silent default change fails.
101
+ * thinking blocks), and truncate the answer inside the same 2,048-token
102
+ * ceiling (1,024 until AGL-4075). `effort: low` is the documented posture for
103
+ * a scoped chat workload; both are asserted in the spec so a silent default
104
+ * change fails.
102
105
  *
103
- * Prompt caching: the system array is ordered stable → volatile across FIVE
106
+ * Prompt caching: the system array is ordered stable → volatile across SIX
104
107
  * blocks, with breakpoints after the first two.
105
108
  *
106
109
  * 1. static prompt [breakpoint] identical everywhere
107
110
  * 2. screen description [breakpoint] identical per ROUTE, any tenant
108
111
  * 3. product brand the org's name for the product
109
- * 4. request facts workspace, plan, host, path
110
- * 5. docs retrieval follows the question
112
+ * 4. workspace its plan and open AI jobs (AGL-4075)
113
+ * 5. request facts workspace, host, path (paid)
114
+ * 6. docs retrieval follows the question
111
115
  *
112
116
  * On the edit and build rungs a SITE block (AGL-3661) follows the stable
113
117
  * blocks: the business profile, status and remembered preferences of the