@aglyn/plugins-ai 1.0.0-beta.271 → 1.0.0-beta.273

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 (132) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.d.ts +4 -3
  3. package/src/lib/activity/ai-activity-actions.js +3 -0
  4. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  5. package/src/lib/components/ai-site-build-page.component.js +18 -0
  6. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  7. package/src/lib/components/ai-site-import-job.component.d.ts +41 -0
  8. package/src/lib/components/ai-site-import-job.component.js +707 -0
  9. package/src/lib/components/ai-site-import-job.component.js.map +1 -0
  10. package/src/lib/components/ai-site-import-start.component.d.ts +64 -0
  11. package/src/lib/components/ai-site-import-start.component.js +335 -0
  12. package/src/lib/components/ai-site-import-start.component.js.map +1 -0
  13. package/src/lib/components/ai-site-ownership-proof.component.d.ts +42 -0
  14. package/src/lib/components/ai-site-ownership-proof.component.js +353 -0
  15. package/src/lib/components/ai-site-ownership-proof.component.js.map +1 -0
  16. package/src/lib/components/ai-site-start-card.component.js +329 -252
  17. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  18. package/src/lib/jobs/ai-empty-site-sweep.d.ts +12 -1
  19. package/src/lib/jobs/ai-empty-site-sweep.js +15 -3
  20. package/src/lib/jobs/ai-empty-site-sweep.js.map +1 -1
  21. package/src/lib/jobs/ai-job-drafts.d.ts +14 -0
  22. package/src/lib/jobs/ai-job-drafts.js +24 -0
  23. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  24. package/src/lib/jobs/ai-job-import-step.d.ts +136 -0
  25. package/src/lib/jobs/ai-job-import-step.js +809 -0
  26. package/src/lib/jobs/ai-job-import-step.js.map +1 -0
  27. package/src/lib/jobs/ai-job-page-language.d.ts +39 -1
  28. package/src/lib/jobs/ai-job-page-language.js +62 -4
  29. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  30. package/src/lib/jobs/ai-job-page-step.d.ts +2 -2
  31. package/src/lib/jobs/ai-job-page-step.js +26 -4
  32. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  33. package/src/lib/jobs/ai-job-seo-step.js +8 -4
  34. package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
  35. package/src/lib/jobs/ai-job-site-datasets.d.ts +39 -1
  36. package/src/lib/jobs/ai-job-site-datasets.js +111 -15
  37. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  38. package/src/lib/jobs/ai-job-site-step.d.ts +12 -3
  39. package/src/lib/jobs/ai-job-site-step.js +85 -11
  40. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  41. package/src/lib/jobs/ai-job-text-step.d.ts +15 -0
  42. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  43. package/src/lib/jobs/ai-jobs.d.ts +8 -0
  44. package/src/lib/jobs/ai-jobs.js +19 -3
  45. package/src/lib/jobs/ai-jobs.js.map +1 -1
  46. package/src/lib/jobs/ai-layout-owner-photos.d.ts +16 -1
  47. package/src/lib/jobs/ai-layout-owner-photos.js +49 -2
  48. package/src/lib/jobs/ai-layout-owner-photos.js.map +1 -1
  49. package/src/lib/jobs/ai-layout-stock-photos.d.ts +35 -0
  50. package/src/lib/jobs/ai-layout-stock-photos.js +114 -43
  51. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  52. package/src/lib/jobs/ai-site-late-photos.d.ts +143 -0
  53. package/src/lib/jobs/ai-site-late-photos.js +486 -0
  54. package/src/lib/jobs/ai-site-late-photos.js.map +1 -0
  55. package/src/lib/jobs/ai-site-publish.d.ts +18 -0
  56. package/src/lib/jobs/ai-site-publish.js +123 -5
  57. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  58. package/src/lib/jobs/ai-theme-brand-inputs.d.ts +61 -3
  59. package/src/lib/jobs/ai-theme-brand-inputs.js +117 -27
  60. package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
  61. package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.d.ts +83 -0
  62. package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.js +1553 -0
  63. package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.js.map +1 -0
  64. package/src/lib/layout-language/ai-layout-pictures.d.ts +50 -13
  65. package/src/lib/layout-language/ai-layout-pictures.js +81 -19
  66. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  67. package/src/lib/model/ai-credit-estimate.d.ts +20 -0
  68. package/src/lib/model/ai-credit-estimate.js +13 -0
  69. package/src/lib/model/ai-credit-estimate.js.map +1 -1
  70. package/src/lib/model/ai-job-activity.d.ts +2 -2
  71. package/src/lib/model/ai-job-activity.js +6 -4
  72. package/src/lib/model/ai-job-activity.js.map +1 -1
  73. package/src/lib/model/ai-job-notice.js +18 -4
  74. package/src/lib/model/ai-job-notice.js.map +1 -1
  75. package/src/lib/model/ai-jobs.types.d.ts +39 -2
  76. package/src/lib/model/ai-jobs.types.js +2 -1
  77. package/src/lib/model/ai-jobs.types.js.map +1 -1
  78. package/src/lib/model/ai-site-import-view.d.ts +130 -0
  79. package/src/lib/model/ai-site-import-view.js +182 -0
  80. package/src/lib/model/ai-site-import-view.js.map +1 -0
  81. package/src/lib/model/ai-site-import.d.ts +302 -0
  82. package/src/lib/model/ai-site-import.js +526 -0
  83. package/src/lib/model/ai-site-import.js.map +1 -0
  84. package/src/lib/model/ai-site-photos.d.ts +119 -0
  85. package/src/lib/model/ai-site-photos.js +128 -0
  86. package/src/lib/model/ai-site-photos.js.map +1 -0
  87. package/src/lib/model/ai-site-start.d.ts +22 -9
  88. package/src/lib/model/ai-site-start.js +64 -23
  89. package/src/lib/model/ai-site-start.js.map +1 -1
  90. package/src/lib/runtime/ai-doctrine-validators.d.ts +3 -1
  91. package/src/lib/runtime/ai-doctrine-validators.js +3 -1
  92. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  93. package/src/lib/runtime/seo-fields.d.ts +29 -4
  94. package/src/lib/runtime/seo-fields.js +58 -11
  95. package/src/lib/runtime/seo-fields.js.map +1 -1
  96. package/src/lib/server/ai-jobs-route.js +11 -0
  97. package/src/lib/server/ai-jobs-route.js.map +1 -1
  98. package/src/lib/server/ai-site-import-route.d.ts +89 -0
  99. package/src/lib/server/ai-site-import-route.js +356 -0
  100. package/src/lib/server/ai-site-import-route.js.map +1 -0
  101. package/src/lib/server.js +14 -0
  102. package/src/lib/server.js.map +1 -1
  103. package/src/lib/site-import/fixtures/site-import-crawl-recordings.d.ts +45 -0
  104. package/src/lib/site-import/fixtures/site-import-crawl-recordings.js +313 -0
  105. package/src/lib/site-import/fixtures/site-import-crawl-recordings.js.map +1 -0
  106. package/src/lib/site-import/fixtures/site-import-wave-recordings.d.ts +50 -0
  107. package/src/lib/site-import/fixtures/site-import-wave-recordings.js +217 -0
  108. package/src/lib/site-import/fixtures/site-import-wave-recordings.js.map +1 -0
  109. package/src/lib/site-import/site-import-crawl.d.ts +163 -0
  110. package/src/lib/site-import/site-import-crawl.js +467 -0
  111. package/src/lib/site-import/site-import-crawl.js.map +1 -0
  112. package/src/lib/site-import/site-import-flag.d.ts +23 -0
  113. package/src/lib/site-import/site-import-flag.js +23 -0
  114. package/src/lib/site-import/site-import-flag.js.map +1 -0
  115. package/src/lib/site-import/site-import-html.d.ts +94 -0
  116. package/src/lib/site-import/site-import-html.js +462 -0
  117. package/src/lib/site-import/site-import-html.js.map +1 -0
  118. package/src/lib/site-import/site-import-images.d.ts +54 -0
  119. package/src/lib/site-import/site-import-images.js +204 -0
  120. package/src/lib/site-import/site-import-images.js.map +1 -0
  121. package/src/lib/site-import/site-import-page.d.ts +48 -0
  122. package/src/lib/site-import/site-import-page.js +150 -0
  123. package/src/lib/site-import/site-import-page.js.map +1 -0
  124. package/src/lib/site-import/site-import-review.d.ts +40 -0
  125. package/src/lib/site-import/site-import-review.js +149 -0
  126. package/src/lib/site-import/site-import-review.js.map +1 -0
  127. package/src/lib/site-import/site-import-robots.d.ts +52 -0
  128. package/src/lib/site-import/site-import-robots.js +126 -0
  129. package/src/lib/site-import/site-import-robots.js.map +1 -0
  130. package/src/lib/tools/ai-seo-tool.d.ts +25 -1
  131. package/src/lib/tools/ai-seo-tool.js +61 -0
  132. package/src/lib/tools/ai-seo-tool.js.map +1 -1
@@ -17,9 +17,10 @@
17
17
  import type { SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields';
18
18
  import type { AiSeoFieldValues } from '../model/ai-seo';
19
19
  import type { AiStepKind } from '../providers/catalog';
20
- import type { AiProvider } from '../providers/contract';
20
+ import { type AiProvider } from '../providers/contract';
21
21
  import { type AiPluginSettings } from '../providers/routing';
22
22
  import { type AiValidatedGeneration } from './ai-doctrine';
23
+ import type { AiDoctrineViolation } from './ai-doctrine-validators';
23
24
  import type { AiSystemBlock } from './ai-runtime';
24
25
  /**
25
26
  * SEO fields by AI (AGL-2910): a search listing — title, description,
@@ -44,6 +45,14 @@ import type { AiSystemBlock } from './ai-runtime';
44
45
  * page's text, its current listing, the site's name and the target keywords
45
46
  * ride in the user turn, so no byte of a site sits inside the cached prefix,
46
47
  * and no site inventory is sent: a listing is written from its page.
48
+ *
49
+ * ## A length is fitted, not failed (AGL-4016)
50
+ *
51
+ * An answer is asked for once and re-asked once with the faults named. When
52
+ * the re-ask still breaks nothing but a length, the fields that ran over are
53
+ * cut to their limits (`aiFitSeoText`) and the whole check runs again, so a
54
+ * title five characters long no longer fails the listing. Any other fault, a
55
+ * numbered rule, or an answer cut off at its ceiling still ends `needs_input`.
47
56
  */
48
57
  /**
49
58
  * The output budget, from the routing table. The largest answer the tool
@@ -145,8 +154,24 @@ export declare const AI_SEO_DEFAULT_FIELDS: readonly SeoListingFieldKey[];
145
154
  * page it builds (`fields: ['title', 'description']`), and the `seo` job
146
155
  * calls it for a page's or a product's whole listing. It asks the routing
147
156
  * table's `job.seo` model through the doctrine's generation call and resolves
148
- * to its result — `ok` with the values, `needs_input` when the answer could
149
- * not be held to the editor after one re-ask, `refused` when the model
150
- * declined. It writes nothing.
157
+ * to its result — `ok` with the values (a re-ask that still ran past a
158
+ * length fitted to it, AGL-4016), `needs_input` when the answer could not be
159
+ * held to the editor after one re-ask, `refused` when the model declined. It
160
+ * writes nothing.
151
161
  */
152
162
  export declare function generateSeoFields(input: GenerateSeoFieldsInput): Promise<AiValidatedGeneration<AiSeoFieldValues>>;
163
+ /**
164
+ * A generation that ended `needs_input` over lengths alone, ended `ok` with
165
+ * those lengths fitted (AGL-4016); any other outcome as it came.
166
+ *
167
+ * The last answer is fitted only when no numbered rule was broken and the
168
+ * answer was not cut off at its ceiling (a cut-off answer is refused whatever
169
+ * its prefix reads as, AGL-3143), and it is kept only when the whole check
170
+ * then passes: a fault other than a length is still there after fitting, so
171
+ * the generation stays `needs_input` with its own reasons. Nothing is asked
172
+ * again and nothing more is spent.
173
+ */
174
+ export declare function aiFitSeoOverrun<T>(kind: string, generation: AiValidatedGeneration<T>, fit: (answer: Record<string, unknown>) => Record<string, unknown> | null, check: (answer: Record<string, unknown>) => {
175
+ value: T | null;
176
+ violations: readonly AiDoctrineViolation[];
177
+ }): AiValidatedGeneration<T>;
@@ -14,8 +14,9 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { aiStoppedAtCeiling } from "../providers/contract.js";
17
18
  import { AI_ROUTING_TABLE, aiRouteSettings } from "../providers/routing.js";
18
- import { AI_SEO_FIELDS_TOOL_NAME, aiSeoFieldsTool, checkAiSeoFields, orderedSeoListingFields } from "../tools/ai-seo-tool.js";
19
+ import { AI_SEO_FIELDS_TOOL_NAME, aiSeoFieldsTool, checkAiSeoFields, fitAiSeoFieldLengths, orderedSeoListingFields } from "../tools/ai-seo-tool.js";
19
20
  import { runValidatedGeneration } from "./ai-doctrine.js";
20
21
  /**
21
22
  * SEO fields by AI (AGL-2910): a search listing — title, description,
@@ -40,6 +41,14 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
40
41
  * page's text, its current listing, the site's name and the target keywords
41
42
  * ride in the user turn, so no byte of a site sits inside the cached prefix,
42
43
  * and no site inventory is sent: a listing is written from its page.
44
+ *
45
+ * ## A length is fitted, not failed (AGL-4016)
46
+ *
47
+ * An answer is asked for once and re-asked once with the faults named. When
48
+ * the re-ask still breaks nothing but a length, the fields that ran over are
49
+ * cut to their limits (`aiFitSeoText`) and the whole check runs again, so a
50
+ * title five characters long no longer fails the listing. Any other fault, a
51
+ * numbered rule, or an answer cut off at its ceiling still ends `needs_input`.
43
52
  */ /* ------------------------------------------------------------------------ *
44
53
  * The request
45
54
  * ------------------------------------------------------------------------ */ /**
@@ -166,9 +175,10 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
166
175
  * page it builds (`fields: ['title', 'description']`), and the `seo` job
167
176
  * calls it for a page's or a product's whole listing. It asks the routing
168
177
  * table's `job.seo` model through the doctrine's generation call and resolves
169
- * to its result — `ok` with the values, `needs_input` when the answer could
170
- * not be held to the editor after one re-ask, `refused` when the model
171
- * declined. It writes nothing.
178
+ * to its result — `ok` with the values (a re-ask that still ran past a
179
+ * length fitted to it, AGL-4016), `needs_input` when the answer could not be
180
+ * held to the editor after one re-ask, `refused` when the model declined. It
181
+ * writes nothing.
172
182
  */ export async function generateSeoFields(input) {
173
183
  var _input_fields, _input_keywords, _input_step, _input_maxTokens;
174
184
  var _input_otherTitles;
@@ -176,7 +186,13 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
176
186
  const fields = orderedSeoListingFields((_input_fields = input.fields) != null ? _input_fields : AI_SEO_DEFAULT_FIELDS).filter((key)=>key !== 'imageAlt' || hasImage);
177
187
  const keywords = (_input_keywords = input.keywords) != null ? _input_keywords : [];
178
188
  const step = (_input_step = input.step) != null ? _input_step : 'job.seo';
179
- return runValidatedGeneration('seo-fields', _extends({
189
+ const check = (answer)=>checkAiSeoFields(answer, {
190
+ fields,
191
+ hasImage,
192
+ keywords,
193
+ otherTitles: input.otherTitles
194
+ });
195
+ const generation = await runValidatedGeneration('seo-fields', _extends({
180
196
  step
181
197
  }, aiRouteSettings(step), input.settings ? {
182
198
  settings: input.settings
@@ -204,13 +220,44 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
204
220
  } : {}, input.provider ? {
205
221
  provider: input.provider
206
222
  } : {}, {
207
- check: (answer)=>checkAiSeoFields(answer, {
208
- fields,
209
- hasImage,
210
- keywords,
211
- otherTitles: input.otherTitles
212
- })
223
+ check
213
224
  }));
225
+ return aiFitSeoOverrun('seo-fields', generation, (answer)=>fitAiSeoFieldLengths(answer, fields), check);
226
+ }
227
+ /**
228
+ * A generation that ended `needs_input` over lengths alone, ended `ok` with
229
+ * those lengths fitted (AGL-4016); any other outcome as it came.
230
+ *
231
+ * The last answer is fitted only when no numbered rule was broken and the
232
+ * answer was not cut off at its ceiling (a cut-off answer is refused whatever
233
+ * its prefix reads as, AGL-3143), and it is kept only when the whole check
234
+ * then passes: a fault other than a length is still there after fitting, so
235
+ * the generation stays `needs_input` with its own reasons. Nothing is asked
236
+ * again and nothing more is spent.
237
+ */ export function aiFitSeoOverrun(kind, generation, fit, check) {
238
+ if (generation.status !== 'needs_input' || !generation.answer) return generation;
239
+ if (aiStoppedAtCeiling(generation.stopReason)) return generation;
240
+ if (generation.violations.some((violation)=>violation.rule !== null)) return generation;
241
+ const fitted = fit(generation.answer);
242
+ if (!fitted) return generation;
243
+ const checked = check(fitted);
244
+ if (checked.value === null || checked.violations.length) return generation;
245
+ console.info('ai seo answer fitted to its lengths', {
246
+ kind,
247
+ model: generation.model,
248
+ faults: generation.violations.map((violation)=>violation.code)
249
+ });
250
+ const { attempts, usage, estCostUsd, model, stopReason, effort } = generation;
251
+ return {
252
+ attempts,
253
+ usage,
254
+ estCostUsd,
255
+ model,
256
+ stopReason,
257
+ effort,
258
+ status: 'ok',
259
+ value: checked.value
260
+ };
214
261
  }
215
262
 
216
263
  //# sourceMappingURL=seo-fields.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-fields.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 { SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport type { AiSeoFieldValues } from '../model/ai-seo'\nimport type { AiStepKind } from '../providers/catalog'\nimport type { AiProvider } from '../providers/contract'\nimport { AI_ROUTING_TABLE, aiRouteSettings, type AiPluginSettings } from '../providers/routing'\nimport {\n AI_SEO_FIELDS_TOOL_NAME,\n aiSeoFieldsTool,\n checkAiSeoFields,\n orderedSeoListingFields,\n} from '../tools/ai-seo-tool'\nimport { runValidatedGeneration, type AiValidatedGeneration } from './ai-doctrine'\nimport type { AiSystemBlock } from './ai-runtime'\n\n/**\n * SEO fields by AI (AGL-2910): a search listing — title, description,\n * breadcrumb label, image description — written from what a page or a\n * product actually says, and held to the lengths its editor enforces.\n *\n * `generateSeoFields` is the one entry point. The \"Write SEO\" card on a\n * page's SEO panel and on a product's listing calls it through an `seo` job,\n * and a page generator calls it for the title and description of the page it\n * builds. It proposes; it never writes a listing anywhere.\n *\n * The tool the model answers through, and the check that holds the answer to\n * the editor, are `tools/ai-seo-tool.ts`: the tool is built from the editor's\n * own field list, so a field and its length are said once.\n *\n * ## The request\n *\n * The fast tier through the routing table (`job.seo`), never a model\n * literal, and the building doctrine's generation call\n * (`runValidatedGeneration`, AGL-3009): the doctrine's cached block, which\n * carries the acceptable-use rules, then these rules, cached after it. The\n * page's text, its current listing, the site's name and the target keywords\n * ride in the user turn, so no byte of a site sits inside the cached prefix,\n * and no site inventory is sent: a listing is written from its page.\n */\n\n/* ------------------------------------------------------------------------ *\n * The request\n * ------------------------------------------------------------------------ */\n\n/**\n * The output budget, from the routing table. The largest answer the tool\n * accepts — every field at its limit — is under 300 tokens of JSON\n * (`ai-job-seo-step.spec.ts` measures it); the rest is headroom, and the fast\n * tier thinks in none of it.\n */\nexport const AI_SEO_FIELDS_MAX_TOKENS = AI_ROUTING_TABLE['job.seo'].maxTokens\n\n/** How many other pages' titles a prompt lists for the model to avoid. */\nconst OTHER_TITLES_LISTED = 40\n\n/**\n * What one listing field's rule ADDS to the tool's own description of that\n * field (AGL-2937), stated only when the field is asked for.\n *\n * The schema the answer arrives through is built from the editor's field\n * catalog, and it already names each field, says what the value is for and\n * states its length. It rides in the same request, ahead of these blocks, so\n * a rule that restated it would be the same sentence billed twice on a door\n * whose prompt no model will cache. Each rule here is therefore only what\n * the schema does not say: how to write the field rather than what it is.\n * The image description has no rule at all, because its description already\n * carries both halves of one — what it describes, and when to answer null.\n *\n * `runtime/ai-prompt-cache.spec.ts` holds the pair together: whatever\n * `checkAiSeoFields` can refuse an answer for, the request has to state\n * somewhere, in a block or in the schema.\n */\nconst AI_SEO_FIELD_RULES: Readonly<Partial<Record<SeoListingFieldKey, string>>> = {\n title:\n 'The title says what the page is, specifically and in plain words; add the site name only where it helps.',\n description:\n 'The description is one or two sentences: no quotation marks, no emoji, no capitals for emphasis.',\n breadcrumb: 'The breadcrumb label is one to three words.',\n}\n\n/** What a listing request carries, and therefore which rules it is sent. */\nexport interface AiSeoFieldsInstructionShape {\n fields: readonly SeoListingFieldKey[]\n keywords: boolean\n otherTitles: boolean\n}\n\n/**\n * The rules for one listing request.\n *\n * Byte-identical for every request of the same SHAPE, and identical across\n * workspaces at every shape: no site's name, text or listing is in here, so\n * two tenants writing the same kind of listing send the same rules. That is\n * the invariant `runtime/ai-prompt-cache.spec.ts` holds, and it is weaker\n * than \"one block for everything\" on purpose — a rule about target keywords\n * is worth nothing to a request that carries none, and `job.seo` runs on a\n * model that caches no prompt this short, so an unread rule is simply paid\n * for at full input rate on every attempt and every re-ask.\n */\nexport function aiSeoFieldsInstructions(\n shape: AiSeoFieldsInstructionShape,\n): AiSystemBlock[] {\n const fields = orderedSeoListingFields(shape.fields)\n const rules = [\n 'Write only from the text you are given. Never invent a fact, a price, a name, a place, an offer or a claim the text does not state.',\n ...fields.map((key) => AI_SEO_FIELD_RULES[key]).filter((rule): rule is string => Boolean(rule)),\n // Every field a person reads is held to this, keywords or none, so it is\n // stated on every shape rather than inside the keyword rule it used to\n // ride in: `checkAiSeoFields` refuses a repeat in a listing that named\n // no keywords too.\n 'Never repeat the same word three times in one field.',\n ...(shape.keywords\n ? [\n 'A target keyword goes in only where the page is about it and it reads naturally: at most once in the title and once in the description. Never list keywords, and leave out a keyword the page is not about.',\n ]\n : []),\n ...(shape.otherTitles\n ? ['Do not reuse a title another page of the site already uses.']\n : []),\n 'Write in the language of the page text.',\n ]\n return [\n {\n text:\n 'You write the search listing for one page or one product of a website: what a search result shows.\\n\\n' +\n `Answer by calling ${AI_SEO_FIELDS_TOOL_NAME} exactly once. A reply in prose cannot be used.\\n\\n` +\n `Rules:\\n${rules.map((rule) => `- ${rule}`).join('\\n')}`,\n cacheBreakpoint: true,\n },\n ]\n}\n\n/** The rules for a listing of the default shape, for a door that names no other. */\nexport const AI_SEO_FIELDS_INSTRUCTIONS: AiSystemBlock[] = aiSeoFieldsInstructions({\n fields: ['title', 'description', 'breadcrumb'],\n keywords: false,\n otherTitles: false,\n})\n\n/**\n * What the site itself is, in the words of whoever set it up (AGL-2918).\n *\n * A page's own text says what the page is; it does not say who the page is\n * written for. On a site whose pages have just been generated that gap is at\n * its widest — every page reads like the same competent stranger wrote it —\n * so the words the owner used are carried into the listing rather than\n * inferred back out of the copy they produced.\n *\n * It rides in the user turn and nowhere else: it is one site's own words, so\n * putting it in a rules block would key the platform's cache entry on a\n * tenant's sentence, and the rules already say to write from what is given.\n */\nexport interface AiSeoSiteWords {\n /** What kind of site it is: \"a neighborhood dog groomer\". */\n about?: string | null\n /** Who it is for: \"local dog owners who want a regular groom booked online\". */\n audience?: string | null\n}\n\nexport interface AiSeoFieldsPromptInput {\n subject: { kind: 'screen' | 'product'; name: string; path?: string | null }\n /** The site's name, as its visitors see it. */\n brand: string\n /** What the site is and who it is for, where the caller knows (AGL-2918). */\n site?: AiSeoSiteWords | null\n /** What the page or the product says: a page's Markdown, a product's description. */\n text: string\n fields: readonly SeoListingFieldKey[]\n current?: AiSeoFieldValues\n /**\n * What is known about the share image, when the listing has one: the\n * description it carries, and the page text around the same picture where\n * the page shows it.\n */\n image?: { alt?: string | null; context?: string | null } | null\n keywords?: readonly string[]\n otherTitles?: readonly string[]\n}\n\n/** The longest a site's own words run in a prompt; they are a phrase, not copy. */\nconst SITE_WORDS_MAX_CHARS = 160\n\n/** One of the site's own words as its line reads, or nothing when it says nothing. */\nfunction siteWordsLine(label: string, value: string | null | undefined): string {\n const text = (value ?? '').replace(/\\s+/g, ' ').trim().slice(0, SITE_WORDS_MAX_CHARS).trim()\n return text ? `${label}: ${text}` : ''\n}\n\n/** The user turn: the subject, what it says now, and the page's own text. */\nexport function aiSeoFieldsPrompt(input: AiSeoFieldsPromptInput): string {\n const fields = orderedSeoListingFields(input.fields)\n const lines = [\n `Site: ${input.brand || 'untitled site'}`,\n siteWordsLine('What the site is', input.site?.about),\n siteWordsLine('Who the site is for', input.site?.audience),\n input.subject.kind === 'product'\n ? `Product: ${input.subject.name || 'untitled product'}`\n : `Page: ${input.subject.name || 'untitled page'}${input.subject.path ? ` (${input.subject.path})` : ''}`,\n `Fields to write: ${fields.join(', ')}`,\n // Only the two site lines can be empty here, and an empty one is a\n // question nobody answered rather than a blank line to send.\n ].filter(Boolean)\n const current = fields\n .map((key) => [key, input.current?.[key]?.trim()] as const)\n .filter(([, value]) => Boolean(value))\n if (current.length) {\n lines.push('Current listing:', ...current.map(([key, value]) => `- ${key}: ${value}`))\n }\n if (input.image) {\n const known = [\n input.image.alt && `its current description: ${input.image.alt}`,\n input.image.context && `the page text beside it: ${input.image.context}`,\n ]\n .filter(Boolean)\n .join('; ')\n lines.push(`Share image: ${known || 'set, and nothing is known about what it shows'}`)\n }\n if (input.keywords?.length) lines.push(`Target keywords: ${input.keywords.join(', ')}`)\n if (input.otherTitles?.length) {\n lines.push(\n 'Titles other pages already use:',\n ...input.otherTitles.slice(0, OTHER_TITLES_LISTED).map((title) => `- ${title}`),\n )\n }\n lines.push(\n '',\n input.subject.kind === 'product' ? 'Product description:' : 'Page text:',\n input.text.trim() || '(no text)',\n )\n return lines.join('\\n')\n}\n\nexport interface GenerateSeoFieldsInput extends Omit<AiSeoFieldsPromptInput, 'fields'> {\n /** The fields to write; the title, description and breadcrumb label when omitted. */\n fields?: readonly SeoListingFieldKey[]\n /** The step kind the call is routed as; `job.seo` when omitted. */\n step?: AiStepKind\n settings?: AiPluginSettings\n model?: string\n /**\n * The answer's ceiling: `AI_SEO_FIELDS_MAX_TOKENS` when omitted. A job step\n * passes the most its registered time allows on the model it runs\n * (AGL-3035).\n */\n maxTokens?: number\n signal?: AbortSignal\n provider?: AiProvider\n}\n\n/** What a listing is written with when the caller names no fields. */\nexport const AI_SEO_DEFAULT_FIELDS: readonly SeoListingFieldKey[] = ['title', 'description', 'breadcrumb']\n\n/**\n * Propose a search listing for a page or a product.\n *\n * STABLE: a page generator calls this for the title and description of a\n * page it builds (`fields: ['title', 'description']`), and the `seo` job\n * calls it for a page's or a product's whole listing. It asks the routing\n * table's `job.seo` model through the doctrine's generation call and resolves\n * to its result — `ok` with the values, `needs_input` when the answer could\n * not be held to the editor after one re-ask, `refused` when the model\n * declined. It writes nothing.\n */\nexport async function generateSeoFields(\n input: GenerateSeoFieldsInput,\n): Promise<AiValidatedGeneration<AiSeoFieldValues>> {\n const hasImage = Boolean(input.image)\n const fields = orderedSeoListingFields(input.fields ?? AI_SEO_DEFAULT_FIELDS).filter(\n (key) => key !== 'imageAlt' || hasImage,\n )\n const keywords = input.keywords ?? []\n const step = input.step ?? 'job.seo'\n return runValidatedGeneration('seo-fields', {\n step,\n // The row's thinking and effort (AGL-3781): a listing's ceiling holds the\n // answer alone.\n ...aiRouteSettings(step),\n ...(input.settings ? { settings: input.settings } : {}),\n ...(input.model ? { model: input.model } : {}),\n instructions: aiSeoFieldsInstructions({\n fields,\n keywords: keywords.length > 0,\n otherTitles: Boolean(input.otherTitles?.length),\n }),\n messages: [{ role: 'user', content: aiSeoFieldsPrompt({ ...input, fields, keywords }) }],\n tool: aiSeoFieldsTool(fields),\n maxTokens: input.maxTokens ?? AI_SEO_FIELDS_MAX_TOKENS,\n ...(input.signal ? { signal: input.signal } : {}),\n ...(input.provider ? { provider: input.provider } : {}),\n check: (answer) =>\n checkAiSeoFields(answer, {\n fields,\n hasImage,\n keywords,\n otherTitles: input.otherTitles,\n }),\n })\n}\n"],"names":["AI_ROUTING_TABLE","aiRouteSettings","AI_SEO_FIELDS_TOOL_NAME","aiSeoFieldsTool","checkAiSeoFields","orderedSeoListingFields","runValidatedGeneration","AI_SEO_FIELDS_MAX_TOKENS","maxTokens","OTHER_TITLES_LISTED","AI_SEO_FIELD_RULES","title","description","breadcrumb","aiSeoFieldsInstructions","shape","fields","rules","map","key","filter","rule","Boolean","keywords","otherTitles","text","join","cacheBreakpoint","AI_SEO_FIELDS_INSTRUCTIONS","SITE_WORDS_MAX_CHARS","siteWordsLine","label","value","replace","trim","slice","aiSeoFieldsPrompt","input","lines","brand","site","about","audience","subject","kind","name","path","current","length","push","image","known","alt","context","AI_SEO_DEFAULT_FIELDS","generateSeoFields","hasImage","step","settings","model","instructions","messages","role","content","tool","signal","provider","check","answer"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-fields.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 { SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport type { AiSeoFieldValues } from '../model/ai-seo'\nimport type { AiStepKind } from '../providers/catalog'\nimport { aiStoppedAtCeiling, type AiProvider } from '../providers/contract'\nimport { AI_ROUTING_TABLE, aiRouteSettings, type AiPluginSettings } from '../providers/routing'\nimport {\n AI_SEO_FIELDS_TOOL_NAME,\n aiSeoFieldsTool,\n checkAiSeoFields,\n fitAiSeoFieldLengths,\n orderedSeoListingFields,\n} from '../tools/ai-seo-tool'\nimport { runValidatedGeneration, type AiValidatedGeneration } from './ai-doctrine'\nimport type { AiDoctrineViolation } from './ai-doctrine-validators'\nimport type { AiSystemBlock } from './ai-runtime'\n\n/**\n * SEO fields by AI (AGL-2910): a search listing — title, description,\n * breadcrumb label, image description — written from what a page or a\n * product actually says, and held to the lengths its editor enforces.\n *\n * `generateSeoFields` is the one entry point. The \"Write SEO\" card on a\n * page's SEO panel and on a product's listing calls it through an `seo` job,\n * and a page generator calls it for the title and description of the page it\n * builds. It proposes; it never writes a listing anywhere.\n *\n * The tool the model answers through, and the check that holds the answer to\n * the editor, are `tools/ai-seo-tool.ts`: the tool is built from the editor's\n * own field list, so a field and its length are said once.\n *\n * ## The request\n *\n * The fast tier through the routing table (`job.seo`), never a model\n * literal, and the building doctrine's generation call\n * (`runValidatedGeneration`, AGL-3009): the doctrine's cached block, which\n * carries the acceptable-use rules, then these rules, cached after it. The\n * page's text, its current listing, the site's name and the target keywords\n * ride in the user turn, so no byte of a site sits inside the cached prefix,\n * and no site inventory is sent: a listing is written from its page.\n *\n * ## A length is fitted, not failed (AGL-4016)\n *\n * An answer is asked for once and re-asked once with the faults named. When\n * the re-ask still breaks nothing but a length, the fields that ran over are\n * cut to their limits (`aiFitSeoText`) and the whole check runs again, so a\n * title five characters long no longer fails the listing. Any other fault, a\n * numbered rule, or an answer cut off at its ceiling still ends `needs_input`.\n */\n\n/* ------------------------------------------------------------------------ *\n * The request\n * ------------------------------------------------------------------------ */\n\n/**\n * The output budget, from the routing table. The largest answer the tool\n * accepts — every field at its limit — is under 300 tokens of JSON\n * (`ai-job-seo-step.spec.ts` measures it); the rest is headroom, and the fast\n * tier thinks in none of it.\n */\nexport const AI_SEO_FIELDS_MAX_TOKENS = AI_ROUTING_TABLE['job.seo'].maxTokens\n\n/** How many other pages' titles a prompt lists for the model to avoid. */\nconst OTHER_TITLES_LISTED = 40\n\n/**\n * What one listing field's rule ADDS to the tool's own description of that\n * field (AGL-2937), stated only when the field is asked for.\n *\n * The schema the answer arrives through is built from the editor's field\n * catalog, and it already names each field, says what the value is for and\n * states its length. It rides in the same request, ahead of these blocks, so\n * a rule that restated it would be the same sentence billed twice on a door\n * whose prompt no model will cache. Each rule here is therefore only what\n * the schema does not say: how to write the field rather than what it is.\n * The image description has no rule at all, because its description already\n * carries both halves of one — what it describes, and when to answer null.\n *\n * `runtime/ai-prompt-cache.spec.ts` holds the pair together: whatever\n * `checkAiSeoFields` can refuse an answer for, the request has to state\n * somewhere, in a block or in the schema.\n */\nconst AI_SEO_FIELD_RULES: Readonly<Partial<Record<SeoListingFieldKey, string>>> = {\n title:\n 'The title says what the page is, specifically and in plain words; add the site name only where it helps.',\n description:\n 'The description is one or two sentences: no quotation marks, no emoji, no capitals for emphasis.',\n breadcrumb: 'The breadcrumb label is one to three words.',\n}\n\n/** What a listing request carries, and therefore which rules it is sent. */\nexport interface AiSeoFieldsInstructionShape {\n fields: readonly SeoListingFieldKey[]\n keywords: boolean\n otherTitles: boolean\n}\n\n/**\n * The rules for one listing request.\n *\n * Byte-identical for every request of the same SHAPE, and identical across\n * workspaces at every shape: no site's name, text or listing is in here, so\n * two tenants writing the same kind of listing send the same rules. That is\n * the invariant `runtime/ai-prompt-cache.spec.ts` holds, and it is weaker\n * than \"one block for everything\" on purpose — a rule about target keywords\n * is worth nothing to a request that carries none, and `job.seo` runs on a\n * model that caches no prompt this short, so an unread rule is simply paid\n * for at full input rate on every attempt and every re-ask.\n */\nexport function aiSeoFieldsInstructions(\n shape: AiSeoFieldsInstructionShape,\n): AiSystemBlock[] {\n const fields = orderedSeoListingFields(shape.fields)\n const rules = [\n 'Write only from the text you are given. Never invent a fact, a price, a name, a place, an offer or a claim the text does not state.',\n ...fields.map((key) => AI_SEO_FIELD_RULES[key]).filter((rule): rule is string => Boolean(rule)),\n // Every field a person reads is held to this, keywords or none, so it is\n // stated on every shape rather than inside the keyword rule it used to\n // ride in: `checkAiSeoFields` refuses a repeat in a listing that named\n // no keywords too.\n 'Never repeat the same word three times in one field.',\n ...(shape.keywords\n ? [\n 'A target keyword goes in only where the page is about it and it reads naturally: at most once in the title and once in the description. Never list keywords, and leave out a keyword the page is not about.',\n ]\n : []),\n ...(shape.otherTitles\n ? ['Do not reuse a title another page of the site already uses.']\n : []),\n 'Write in the language of the page text.',\n ]\n return [\n {\n text:\n 'You write the search listing for one page or one product of a website: what a search result shows.\\n\\n' +\n `Answer by calling ${AI_SEO_FIELDS_TOOL_NAME} exactly once. A reply in prose cannot be used.\\n\\n` +\n `Rules:\\n${rules.map((rule) => `- ${rule}`).join('\\n')}`,\n cacheBreakpoint: true,\n },\n ]\n}\n\n/** The rules for a listing of the default shape, for a door that names no other. */\nexport const AI_SEO_FIELDS_INSTRUCTIONS: AiSystemBlock[] = aiSeoFieldsInstructions({\n fields: ['title', 'description', 'breadcrumb'],\n keywords: false,\n otherTitles: false,\n})\n\n/**\n * What the site itself is, in the words of whoever set it up (AGL-2918).\n *\n * A page's own text says what the page is; it does not say who the page is\n * written for. On a site whose pages have just been generated that gap is at\n * its widest — every page reads like the same competent stranger wrote it —\n * so the words the owner used are carried into the listing rather than\n * inferred back out of the copy they produced.\n *\n * It rides in the user turn and nowhere else: it is one site's own words, so\n * putting it in a rules block would key the platform's cache entry on a\n * tenant's sentence, and the rules already say to write from what is given.\n */\nexport interface AiSeoSiteWords {\n /** What kind of site it is: \"a neighborhood dog groomer\". */\n about?: string | null\n /** Who it is for: \"local dog owners who want a regular groom booked online\". */\n audience?: string | null\n}\n\nexport interface AiSeoFieldsPromptInput {\n subject: { kind: 'screen' | 'product'; name: string; path?: string | null }\n /** The site's name, as its visitors see it. */\n brand: string\n /** What the site is and who it is for, where the caller knows (AGL-2918). */\n site?: AiSeoSiteWords | null\n /** What the page or the product says: a page's Markdown, a product's description. */\n text: string\n fields: readonly SeoListingFieldKey[]\n current?: AiSeoFieldValues\n /**\n * What is known about the share image, when the listing has one: the\n * description it carries, and the page text around the same picture where\n * the page shows it.\n */\n image?: { alt?: string | null; context?: string | null } | null\n keywords?: readonly string[]\n otherTitles?: readonly string[]\n}\n\n/** The longest a site's own words run in a prompt; they are a phrase, not copy. */\nconst SITE_WORDS_MAX_CHARS = 160\n\n/** One of the site's own words as its line reads, or nothing when it says nothing. */\nfunction siteWordsLine(label: string, value: string | null | undefined): string {\n const text = (value ?? '').replace(/\\s+/g, ' ').trim().slice(0, SITE_WORDS_MAX_CHARS).trim()\n return text ? `${label}: ${text}` : ''\n}\n\n/** The user turn: the subject, what it says now, and the page's own text. */\nexport function aiSeoFieldsPrompt(input: AiSeoFieldsPromptInput): string {\n const fields = orderedSeoListingFields(input.fields)\n const lines = [\n `Site: ${input.brand || 'untitled site'}`,\n siteWordsLine('What the site is', input.site?.about),\n siteWordsLine('Who the site is for', input.site?.audience),\n input.subject.kind === 'product'\n ? `Product: ${input.subject.name || 'untitled product'}`\n : `Page: ${input.subject.name || 'untitled page'}${input.subject.path ? ` (${input.subject.path})` : ''}`,\n `Fields to write: ${fields.join(', ')}`,\n // Only the two site lines can be empty here, and an empty one is a\n // question nobody answered rather than a blank line to send.\n ].filter(Boolean)\n const current = fields\n .map((key) => [key, input.current?.[key]?.trim()] as const)\n .filter(([, value]) => Boolean(value))\n if (current.length) {\n lines.push('Current listing:', ...current.map(([key, value]) => `- ${key}: ${value}`))\n }\n if (input.image) {\n const known = [\n input.image.alt && `its current description: ${input.image.alt}`,\n input.image.context && `the page text beside it: ${input.image.context}`,\n ]\n .filter(Boolean)\n .join('; ')\n lines.push(`Share image: ${known || 'set, and nothing is known about what it shows'}`)\n }\n if (input.keywords?.length) lines.push(`Target keywords: ${input.keywords.join(', ')}`)\n if (input.otherTitles?.length) {\n lines.push(\n 'Titles other pages already use:',\n ...input.otherTitles.slice(0, OTHER_TITLES_LISTED).map((title) => `- ${title}`),\n )\n }\n lines.push(\n '',\n input.subject.kind === 'product' ? 'Product description:' : 'Page text:',\n input.text.trim() || '(no text)',\n )\n return lines.join('\\n')\n}\n\nexport interface GenerateSeoFieldsInput extends Omit<AiSeoFieldsPromptInput, 'fields'> {\n /** The fields to write; the title, description and breadcrumb label when omitted. */\n fields?: readonly SeoListingFieldKey[]\n /** The step kind the call is routed as; `job.seo` when omitted. */\n step?: AiStepKind\n settings?: AiPluginSettings\n model?: string\n /**\n * The answer's ceiling: `AI_SEO_FIELDS_MAX_TOKENS` when omitted. A job step\n * passes the most its registered time allows on the model it runs\n * (AGL-3035).\n */\n maxTokens?: number\n signal?: AbortSignal\n provider?: AiProvider\n}\n\n/** What a listing is written with when the caller names no fields. */\nexport const AI_SEO_DEFAULT_FIELDS: readonly SeoListingFieldKey[] = ['title', 'description', 'breadcrumb']\n\n/**\n * Propose a search listing for a page or a product.\n *\n * STABLE: a page generator calls this for the title and description of a\n * page it builds (`fields: ['title', 'description']`), and the `seo` job\n * calls it for a page's or a product's whole listing. It asks the routing\n * table's `job.seo` model through the doctrine's generation call and resolves\n * to its result — `ok` with the values (a re-ask that still ran past a\n * length fitted to it, AGL-4016), `needs_input` when the answer could not be\n * held to the editor after one re-ask, `refused` when the model declined. It\n * writes nothing.\n */\nexport async function generateSeoFields(\n input: GenerateSeoFieldsInput,\n): Promise<AiValidatedGeneration<AiSeoFieldValues>> {\n const hasImage = Boolean(input.image)\n const fields = orderedSeoListingFields(input.fields ?? AI_SEO_DEFAULT_FIELDS).filter(\n (key) => key !== 'imageAlt' || hasImage,\n )\n const keywords = input.keywords ?? []\n const step = input.step ?? 'job.seo'\n const check = (answer: Record<string, unknown>) =>\n checkAiSeoFields(answer, { fields, hasImage, keywords, otherTitles: input.otherTitles })\n const generation = await runValidatedGeneration('seo-fields', {\n step,\n // The row's thinking and effort (AGL-3781): a listing's ceiling holds the\n // answer alone.\n ...aiRouteSettings(step),\n ...(input.settings ? { settings: input.settings } : {}),\n ...(input.model ? { model: input.model } : {}),\n instructions: aiSeoFieldsInstructions({\n fields,\n keywords: keywords.length > 0,\n otherTitles: Boolean(input.otherTitles?.length),\n }),\n messages: [{ role: 'user', content: aiSeoFieldsPrompt({ ...input, fields, keywords }) }],\n tool: aiSeoFieldsTool(fields),\n maxTokens: input.maxTokens ?? AI_SEO_FIELDS_MAX_TOKENS,\n ...(input.signal ? { signal: input.signal } : {}),\n ...(input.provider ? { provider: input.provider } : {}),\n check,\n })\n return aiFitSeoOverrun('seo-fields', generation, (answer) => fitAiSeoFieldLengths(answer, fields), check)\n}\n\n/**\n * A generation that ended `needs_input` over lengths alone, ended `ok` with\n * those lengths fitted (AGL-4016); any other outcome as it came.\n *\n * The last answer is fitted only when no numbered rule was broken and the\n * answer was not cut off at its ceiling (a cut-off answer is refused whatever\n * its prefix reads as, AGL-3143), and it is kept only when the whole check\n * then passes: a fault other than a length is still there after fitting, so\n * the generation stays `needs_input` with its own reasons. Nothing is asked\n * again and nothing more is spent.\n */\nexport function aiFitSeoOverrun<T>(\n kind: string,\n generation: AiValidatedGeneration<T>,\n fit: (answer: Record<string, unknown>) => Record<string, unknown> | null,\n check: (answer: Record<string, unknown>) => { value: T | null; violations: readonly AiDoctrineViolation[] },\n): AiValidatedGeneration<T> {\n if (generation.status !== 'needs_input' || !generation.answer) return generation\n if (aiStoppedAtCeiling(generation.stopReason)) return generation\n if (generation.violations.some((violation) => violation.rule !== null)) return generation\n const fitted = fit(generation.answer)\n if (!fitted) return generation\n const checked = check(fitted)\n if (checked.value === null || checked.violations.length) return generation\n console.info('ai seo answer fitted to its lengths', {\n kind,\n model: generation.model,\n faults: generation.violations.map((violation) => violation.code),\n })\n const { attempts, usage, estCostUsd, model, stopReason, effort } = generation\n return { attempts, usage, estCostUsd, model, stopReason, effort, status: 'ok', value: checked.value }\n}\n"],"names":["aiStoppedAtCeiling","AI_ROUTING_TABLE","aiRouteSettings","AI_SEO_FIELDS_TOOL_NAME","aiSeoFieldsTool","checkAiSeoFields","fitAiSeoFieldLengths","orderedSeoListingFields","runValidatedGeneration","AI_SEO_FIELDS_MAX_TOKENS","maxTokens","OTHER_TITLES_LISTED","AI_SEO_FIELD_RULES","title","description","breadcrumb","aiSeoFieldsInstructions","shape","fields","rules","map","key","filter","rule","Boolean","keywords","otherTitles","text","join","cacheBreakpoint","AI_SEO_FIELDS_INSTRUCTIONS","SITE_WORDS_MAX_CHARS","siteWordsLine","label","value","replace","trim","slice","aiSeoFieldsPrompt","input","lines","brand","site","about","audience","subject","kind","name","path","current","length","push","image","known","alt","context","AI_SEO_DEFAULT_FIELDS","generateSeoFields","hasImage","step","check","answer","generation","settings","model","instructions","messages","role","content","tool","signal","provider","aiFitSeoOverrun","fit","status","stopReason","violations","some","violation","fitted","checked","console","info","faults","code","attempts","usage","estCostUsd","effort"],"mappings":"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"}
@@ -33,6 +33,7 @@ import { readFreeAiCreditsLeft } from "../usage/free-ai-credits-left.js";
33
33
  import { aiCreditsConfirmation } from "../model/ai-credit-estimate.js";
34
34
  import { aiUsageMeter } from "../usage/ai-usage-meter.js";
35
35
  import { aiJobsGate } from "./ai-jobs-gate.js";
36
+ import { siteImportOfferFor } from "./ai-site-import-route.js";
36
37
  import { withoutErasedSites } from "./ai-jobs-live-sites.js";
37
38
  import { AI_JOB_ACTIVE_STATUSES } from "../model/ai-job-activity.js";
38
39
  import { reportServerError } from "@aglyn/tenant-data-admin/server/client-error-report";
@@ -398,6 +399,14 @@ export async function GET(request) {
398
399
  org: gate.org,
399
400
  now
400
401
  }) : null;
402
+ // Whether Create site offers "Bring my site" (AGL-3976), asked only by the
403
+ // guided start's own read, so the card costs no other caller anything.
404
+ const siteImport = url.searchParams.get('siteImport') === '1' ? await siteImportOfferFor({
405
+ orgId: gate.orgId,
406
+ staff: gate.staff,
407
+ org: gate.org,
408
+ member: gate.member
409
+ }) : null;
401
410
  // Whether the guided start asks for the sites the person likes (AGL-3975):
402
411
  // the workspace's own verdict, the one its site job reads them by.
403
412
  const siteInspiration = await isServerReleaseFlagOnForOrg(AI_SITE_INSPIRATION_FLAG, gate.orgId).catch(()=>false);
@@ -408,6 +417,8 @@ export async function GET(request) {
408
417
  freeTaste
409
418
  }, freeCredits ? {
410
419
  freeCredits
420
+ } : {}, siteImport ? {
421
+ siteImport
411
422
  } : {}, siteInspiration ? {
412
423
  siteInspiration: true
413
424
  } : {}), {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-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// lockdown-423: via libs/plugins/ai/src/lib/runtime/ai-gate.ts\n// The POST climbs `aiGateLadder`, whose lockdown rung is the verdict; the\n// GET climbs `ai-jobs-gate.ts`, which carries the same rung.\n\nimport { randomUUID } from 'crypto'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n AI_JOB_KINDS,\n type AiJobKind,\n type AiJobStatus,\n} from '../model/ai-jobs.types'\n// By their own entry points rather than the barrel (AGL-2903): the specs\n// stub the barrel closed-world, and the ladder, the machine and the runtime\n// are the things under test here, not things to be stubbed away.\nimport { aiGateLadder } from '../runtime/ai-gate'\nimport { aiJobAdmissionRefusal, aiJobSiteRefusal } from '../jobs/ai-job-admission'\nimport { AI_JOB_BRIEF_MAX_CHARS } from '../jobs/ai-job-text-step'\nimport { aiJobAdmittedInputs } from '../jobs/ai-job-auto-confirm'\nimport {\n AI_JOB_INLINE_BUDGET_MS,\n aiJobNextStepMinimumMs,\n aiJobSummary,\n createAiJob,\n listAiJobs,\n runAiJobStep,\n} from '../jobs/ai-jobs'\nimport { releaseAssistMessage } from '../usage/assist-usage'\nimport { readFreeAiCreditsLeft } from '../usage/free-ai-credits-left'\nimport { aiCreditsConfirmation, type AiCreditsPrompt } from '../model/ai-credit-estimate'\nimport { aiUsageMeter } from '../usage/ai-usage-meter'\nimport { aiJobsGate } from './ai-jobs-gate'\nimport { withoutErasedSites } from './ai-jobs-live-sites'\nimport { AI_JOB_ACTIVE_STATUSES } from '../model/ai-job-activity'\nimport { reportServerError } from '@aglyn/tenant-data-admin/server/client-error-report'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { AI_SITE_INSPIRATION_FLAG } from '../model/ai-site-inspiration'\n\n/**\n * A caught fault on this door, reported as well as logged (AGL-1921).\n *\n * `onRequestError` sees only an UNCAUGHT throw, and the Vercel log drain\n * forwards the status line but not a `console.error`, so the three 500s\n * this route answered on 2026-10-06 at 17:41–17:47Z reached the alert as\n * START/END/REPORT with no reason anywhere. Never throws.\n */\nasync function reportJobFault(what: string, error: unknown): Promise<void> {\n try {\n await reportServerError(\n {\n message: `${what}: ${error instanceof Error ? error.message : String(error)}`,\n stack: error instanceof Error ? error.stack : undefined,\n route: '/api/ai/jobs',\n routeType: 'route',\n method: 'POST',\n },\n { service: 'console-web' },\n )\n } catch {\n // The console line beside each call is the record of last resort.\n }\n}\n\n/**\n * AI generation jobs: create and list (AGL-2904).\n *\n * `POST` is a door that spends, so it climbs the whole ladder (AGL-2903) —\n * `aiGenerative`, `release_ai_generative`, the `ai-generate` switch, a\n * per-uid window, and a reservation — and then creates the job and runs\n * its first step INLINE under `AI_JOB_INLINE_BUDGET_MS`. A step that\n * finishes in time answers with the job `done` (or `queued` behind its\n * next step); one that does not is aborted, its reservation handed back,\n * and the job answers `queued` for the beat to resume. Either way the\n * caller gets the job document, and the events route is how it watches\n * from there.\n *\n * `POST` with `check: true` climbs the same ladder and asks the same\n * admission, then hands the reservation back and creates nothing: a 200\n * with `ok` true, or the refusal the create would have answered. \"Create site\"\n * asks it before it makes the site a guided start builds on (AGL-3859), so\n * a start that would be refused, or that asks for the Free go-ahead, leaves\n * no empty site behind. A checked job may name no site yet.\n *\n * `GET` lists the org's jobs, newest first, through the read gate: the\n * same rungs up to the lockdown verdict, no reservation. A job naming a\n * site that no longer exists is left out, so a page can come back shorter\n * than its `limit`.\n *\n * The request must NAME the org it is metered against (AGL-1934). There\n * is no fallback to \"the caller's first org\".\n */\n\nconst AI_JOBS_RATE_LIMIT = { key: 'ai-jobs', limit: 10, windowMs: 60_000 }\n\n/** Kind-specific inputs, bounded: a runner reads scalars, not a document. */\nconst MAX_INPUTS_JSON_CHARS = 8_000\n\n/** The admitted statuses for the list filter, as the union spells them. */\nconst AI_JOB_STATUSES: readonly AiJobStatus[] = [\n 'queued',\n 'running',\n 'needs_input',\n 'needs_review',\n 'done',\n 'failed',\n 'canceled',\n]\n\nexport interface CreateAiJobBody {\n orgId: string\n hostId: string | null\n kind: AiJobKind\n brief: string\n inputs: Record<string, string | number | boolean>\n /** A catalog model the creator picked (AGL-2942), or `null` for Auto. */\n model: string | null\n}\n\nconst ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/\n\n/** The longest model id a body may carry; the catalog's ids are far shorter. */\nconst MAX_MODEL_CHARS = 100\n\n/** Validate + clamp the request body; a string names what is wrong. */\nexport function parseCreateAiJobBody(payload: unknown): CreateAiJobBody | string {\n const body = (payload ?? {}) as Record<string, unknown>\n const orgId = String(body.orgId ?? '').trim()\n if (!orgId) return 'Open a workspace before using this feature'\n const kind = String(body.kind ?? '')\n if (!(AI_JOB_KINDS as readonly string[]).includes(kind)) {\n return `kind must be one of ${AI_JOB_KINDS.join(', ')}`\n }\n const brief = String(body.brief ?? '').trim()\n if (!brief) return 'Write a brief for the job'\n if (brief.length > AI_JOB_BRIEF_MAX_CHARS) {\n return `Keep the brief under ${AI_JOB_BRIEF_MAX_CHARS.toLocaleString('en-US')} characters`\n }\n const rawHost = body.hostId\n const hostId =\n rawHost === undefined || rawHost === null || rawHost === ''\n ? null\n : typeof rawHost === 'string' && ID_CHARS.test(rawHost)\n ? rawHost\n : undefined\n if (hostId === undefined) return 'hostId is not a site id'\n // A theme is fields on one site's document, so a theme job that names no\n // site has nothing to propose a change to (AGL-2938).\n if (kind === 'theme' && !hostId) return 'Open a site before changing its theme'\n const rawInputs = body.inputs\n const inputs: Record<string, string | number | boolean> = {}\n if (rawInputs !== undefined && rawInputs !== null) {\n if (typeof rawInputs !== 'object' || Array.isArray(rawInputs)) {\n return 'inputs must be an object'\n }\n for (const [key, value] of Object.entries(rawInputs as Record<string, unknown>)) {\n if (\n typeof value !== 'string' &&\n typeof value !== 'number' &&\n typeof value !== 'boolean'\n ) {\n return `inputs.${key} must be a string, number or boolean`\n }\n inputs[key] = value\n }\n if (JSON.stringify(inputs).length > MAX_INPUTS_JSON_CHARS) {\n return 'inputs are too large'\n }\n }\n const rawModel = typeof body.model === 'string' ? body.model.trim() : ''\n if (rawModel.length > MAX_MODEL_CHARS) return 'model is not a model id'\n // Validated against the plan and the allotment allowlists when each step\n // runs, not here: the allowlists are read inside the step's reservation.\n const model = rawModel && rawModel !== 'auto' ? rawModel : null\n return { orgId, hostId, kind: kind as AiJobKind, brief, inputs, model }\n}\n\nexport async function POST(request: Request): Promise<Response> {\n let payload: unknown\n try {\n payload = await request.json()\n } catch {\n payload = null\n }\n const parsed = parseCreateAiJobBody(payload)\n // The org is parsed BEFORE the ladder so a request that named none is\n // refused with the ladder's 400 rather than a shape error; every other\n // shape fault waits until the caller has proven who they are.\n const orgId = typeof parsed === 'string' ? String((payload as Record<string, unknown> | null)?.orgId ?? '') : parsed.orgId\n // The site rides along for the same reason: a collaborator's `ai.generate`\n // is decided per site (AGL-2927), so the ladder needs it before it can say\n // whether this member may generate here at all.\n const hostId = typeof parsed === 'string' ? null : parsed.hostId\n const gate = await aiGateLadder(\n { request, orgId, hostId },\n {\n feature: 'aiGenerative',\n releaseFlag: 'release_ai_generative',\n lockdownFeature: 'ai-generate',\n permission: 'ai.generate',\n rateLimit: AI_JOBS_RATE_LIMIT,\n },\n )\n if (gate instanceof Response) return gate\n if (typeof parsed === 'string') {\n // Admitted, reserved, and then found to have sent nothing runnable: the\n // reservation goes back, because no token was spent.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json({ error: parsed }, { status: 400 })\n }\n // What only this kind can say about the request (AGL-2909): a site it\n // needs, inputs it reads, an allowance its draft counts against. Asked\n // before the job exists, so a refusal spends nothing.\n let refusal: Awaited<ReturnType<typeof aiJobAdmissionRefusal>>\n // A Free start past what is left, which the person chose to build as far as\n // it goes (AGL-3722): the prompt they confirmed, kept on the job.\n let confirmedCredits: AiCreditsPrompt | null = null\n // Asked before the site exists (AGL-3859): answers, creates nothing.\n const check = (payload as Record<string, unknown> | null)?.['check'] === true\n try {\n // A site that switched AI off first (AGL-3028), then the kind's own check.\n refusal =\n (await aiJobSiteRefusal({\n firestore: gate.firestore,\n org: gate.org,\n hostId: parsed.hostId,\n })) ??\n (await aiJobAdmissionRefusal(parsed.kind, {\n firestore: gate.firestore,\n orgId: gate.orgId,\n hostId: parsed.hostId,\n siteToCome: check && !parsed.hostId,\n inputs: parsed.inputs,\n org: gate.org,\n uid: gate.uid,\n creditsConfirmed: (payload as Record<string, unknown> | null)?.['creditsConfirmed'] === true,\n onCreditsConfirmed: (prompt) => {\n confirmedCredits = prompt\n },\n }))\n } catch (error) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job admission failed', { orgId: gate.orgId, kind: parsed.kind, error })\n await reportJobFault('ai job admission failed', error)\n return Response.json({ error: 'The AI job could not be created' }, { status: 500 })\n }\n if (refusal) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n // A Free start past what is left asks before it starts (AGL-3722): the\n // prompt rides the 409, so the dialog offers its choices.\n return Response.json(\n { error: refusal.error, ...(refusal.code ? { code: refusal.code } : {}), ...(refusal.credits ? { credits: refusal.credits } : {}) },\n { status: refusal.status },\n )\n }\n if (check) {\n // Admitted, and nothing to run: the reservation goes back.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json({ ok: true }, { status: 200 })\n }\n\n const now = new Date()\n let created: Awaited<ReturnType<typeof createAiJob>>\n try {\n // The guided start's own plan confirmation (AGL-3594), kept only for a\n // site job on a site that has published nothing of the owner's yet.\n const inputs = await aiJobAdmittedInputs(gate.firestore, {\n kind: parsed.kind,\n hostId: parsed.hostId,\n inputs: parsed.inputs,\n })\n created = await createAiJob(\n gate.firestore,\n {\n orgId: gate.orgId,\n hostId: parsed.hostId,\n kind: parsed.kind,\n brief: parsed.brief,\n inputs,\n model: parsed.model,\n createdBy: gate.uid,\n createdByEmail: gate.decoded.email ?? null,\n creditsConfirmed: aiCreditsConfirmation(confirmedCredits, gate.uid, now),\n },\n now,\n )\n } catch (error) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job create failed', { orgId: gate.orgId, error })\n await reportJobFault('ai job create failed', error)\n return Response.json({ error: 'The AI job could not be created' }, { status: 500 })\n }\n const jobId = created.$id\n\n // A first step that needs more time than this request has (AGL-2907) is left\n // queued for the beat, which starts it with a budget of its own: a provider\n // call this request's timeout cut off would be billed upstream and metered\n // nowhere. Nothing ran, so the reservation goes back.\n if (aiJobNextStepMinimumMs(created) > AI_JOB_INLINE_BUDGET_MS) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json(\n {\n job: aiJobSummary(created, now),\n meter: aiUsageMeter(gate.reservation, { lastCredits: null }),\n },\n { status: 200 },\n )\n }\n\n // The first step, inline, on the reservation the ladder already holds.\n // The runner hands that reservation back itself if the provider is never\n // reached, and re-queues the step when the budget ends first.\n const run = await runAiJobStep(gate.firestore, gate.orgId, jobId, {\n owner: `route:${randomUUID()}`,\n now,\n reservation: gate.reservation,\n org: gate.org,\n signal: AbortSignal.timeout(AI_JOB_INLINE_BUDGET_MS),\n // The caller is on the request, so what the inline step produces is\n // attributed with their address; the beat's steps carry the uid alone.\n actor: { uid: gate.uid, email: gate.decoded.email ?? null },\n // The workspace's AI pause lets staff through, as the ladder above did.\n staff: gate.staff,\n })\n if (run.outcome === 'not-claimable') {\n // A job created a moment ago has a claimable first step; anything else\n // is a fault in the machine, and the reservation must not be stranded.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job first step not claimable', { orgId: gate.orgId, jobId })\n await reportJobFault('ai job first step not claimable', new Error(`job ${jobId}`))\n return Response.json({ error: 'The AI job could not be started' }, { status: 500 })\n }\n return Response.json(\n {\n job: aiJobSummary(run.job),\n // The usage strip's envelope (AGL-2942): the reservation the ladder\n // took, with what the inline step spent.\n meter: aiUsageMeter(gate.reservation, {\n lastCredits: run.job.creditsSpent > 0 ? run.job.creditsSpent : null,\n }),\n },\n { status: 200 },\n )\n}\n\nexport async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const gate = await aiJobsGate(request, url.searchParams.get('orgId') ?? '')\n if (gate instanceof Response) return gate\n const rawStatus = url.searchParams.get('status') ?? ''\n const status = (AI_JOB_STATUSES as readonly string[]).includes(rawStatus)\n ? (rawStatus as AiJobStatus)\n : undefined\n // `active` (AGL-3593): every job not yet settled, which is what the\n // console's top-bar indicator and the Assist launcher count.\n const active = rawStatus === 'active'\n const limit = Number(url.searchParams.get('limit') ?? '')\n // `hostId` (AGL-3596): one site's jobs, for the site's AI jobs page — a\n // query on the field under the (hostId, createdAt) index, never a filter\n // of a loaded window. An id that is not one is refused rather than ignored,\n // which would answer with every site's jobs.\n const rawHost = url.searchParams.get('hostId')\n if (rawHost !== null && !ID_CHARS.test(rawHost)) {\n return Response.json({ error: 'hostId is not a site id' }, { status: 400 })\n }\n const now = new Date()\n // A job naming a site that has been deleted is not listed (AGL-3596), so\n // no surface counts or shows work on a site nobody can open.\n const jobs = await withoutErasedSites(\n gate.firestore,\n await listAiJobs(gate.firestore, gate.orgId, {\n ...(status ? { status } : {}),\n ...(active ? { statuses: AI_JOB_ACTIVE_STATUSES } : {}),\n ...(rawHost ? { hostId: rawHost } : {}),\n ...(Number.isFinite(limit) && limit > 0 ? { limit } : {}),\n }),\n )\n const freeTaste = resolveEffectivePlan(gate.org as never) === 'free'\n // What the Free workspace has left this month (AGL-3660): the less of its\n // own band and its owner's allowance across their Free workspaces, which is\n // what the steps' reservations refuse at — never the 300 a month starts with.\n const freeCredits = freeTaste\n ? await readFreeAiCreditsLeft(gate.firestore, { orgId: gate.orgId, org: gate.org as object | null, now })\n : null\n // Whether the guided start asks for the sites the person likes (AGL-3975):\n // the workspace's own verdict, the one its site job reads them by.\n const siteInspiration = await isServerReleaseFlagOnForOrg(AI_SITE_INSPIRATION_FLAG, gate.orgId).catch(() => false)\n return Response.json(\n {\n jobs: jobs.map((job) => aiJobSummary(job, now)),\n // Whether the workspace spends the Free taste (AGL-3594), so the guided\n // start offers the Free page band from the request it already makes.\n freeTaste,\n ...(freeCredits ? { freeCredits } : {}),\n ...(siteInspiration ? { siteInspiration: true } : {}),\n },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n}\n\nexport const dynamic = 'force-dynamic'\nexport const maxDuration = 60\n"],"names":["randomUUID","resolveEffectivePlan","AI_JOB_KINDS","aiGateLadder","aiJobAdmissionRefusal","aiJobSiteRefusal","AI_JOB_BRIEF_MAX_CHARS","aiJobAdmittedInputs","AI_JOB_INLINE_BUDGET_MS","aiJobNextStepMinimumMs","aiJobSummary","createAiJob","listAiJobs","runAiJobStep","releaseAssistMessage","readFreeAiCreditsLeft","aiCreditsConfirmation","aiUsageMeter","aiJobsGate","withoutErasedSites","AI_JOB_ACTIVE_STATUSES","reportServerError","isServerReleaseFlagOnForOrg","AI_SITE_INSPIRATION_FLAG","reportJobFault","what","error","message","Error","String","stack","undefined","route","routeType","method","service","AI_JOBS_RATE_LIMIT","key","limit","windowMs","MAX_INPUTS_JSON_CHARS","AI_JOB_STATUSES","ID_CHARS","MAX_MODEL_CHARS","parseCreateAiJobBody","payload","body","orgId","trim","kind","includes","join","brief","length","toLocaleString","rawHost","hostId","test","rawInputs","inputs","Array","isArray","value","Object","entries","JSON","stringify","rawModel","model","POST","request","gate","json","parsed","feature","releaseFlag","lockdownFeature","permission","rateLimit","Response","firestore","reservation","catch","status","refusal","confirmedCredits","check","org","siteToCome","uid","creditsConfirmed","onCreditsConfirmed","prompt","console","code","credits","ok","now","Date","created","createdBy","createdByEmail","decoded","email","jobId","$id","job","meter","lastCredits","run","owner","signal","AbortSignal","timeout","actor","staff","outcome","creditsSpent","GET","url","URL","searchParams","get","rawStatus","active","Number","jobs","statuses","isFinite","freeTaste","freeCredits","siteInspiration","map","headers","dynamic","maxDuration"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-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// lockdown-423: via libs/plugins/ai/src/lib/runtime/ai-gate.ts\n// The POST climbs `aiGateLadder`, whose lockdown rung is the verdict; the\n// GET climbs `ai-jobs-gate.ts`, which carries the same rung.\n\nimport { randomUUID } from 'crypto'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n AI_JOB_KINDS,\n type AiJobKind,\n type AiJobStatus,\n} from '../model/ai-jobs.types'\n// By their own entry points rather than the barrel (AGL-2903): the specs\n// stub the barrel closed-world, and the ladder, the machine and the runtime\n// are the things under test here, not things to be stubbed away.\nimport { aiGateLadder } from '../runtime/ai-gate'\nimport { aiJobAdmissionRefusal, aiJobSiteRefusal } from '../jobs/ai-job-admission'\nimport { AI_JOB_BRIEF_MAX_CHARS } from '../jobs/ai-job-text-step'\nimport { aiJobAdmittedInputs } from '../jobs/ai-job-auto-confirm'\nimport {\n AI_JOB_INLINE_BUDGET_MS,\n aiJobNextStepMinimumMs,\n aiJobSummary,\n createAiJob,\n listAiJobs,\n runAiJobStep,\n} from '../jobs/ai-jobs'\nimport { releaseAssistMessage } from '../usage/assist-usage'\nimport { readFreeAiCreditsLeft } from '../usage/free-ai-credits-left'\nimport { aiCreditsConfirmation, type AiCreditsPrompt } from '../model/ai-credit-estimate'\nimport { aiUsageMeter } from '../usage/ai-usage-meter'\nimport { aiJobsGate } from './ai-jobs-gate'\nimport { siteImportOfferFor } from './ai-site-import-route'\nimport { withoutErasedSites } from './ai-jobs-live-sites'\nimport { AI_JOB_ACTIVE_STATUSES } from '../model/ai-job-activity'\nimport { reportServerError } from '@aglyn/tenant-data-admin/server/client-error-report'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { AI_SITE_INSPIRATION_FLAG } from '../model/ai-site-inspiration'\n\n/**\n * A caught fault on this door, reported as well as logged (AGL-1921).\n *\n * `onRequestError` sees only an UNCAUGHT throw, and the Vercel log drain\n * forwards the status line but not a `console.error`, so the three 500s\n * this route answered on 2026-10-06 at 17:41–17:47Z reached the alert as\n * START/END/REPORT with no reason anywhere. Never throws.\n */\nasync function reportJobFault(what: string, error: unknown): Promise<void> {\n try {\n await reportServerError(\n {\n message: `${what}: ${error instanceof Error ? error.message : String(error)}`,\n stack: error instanceof Error ? error.stack : undefined,\n route: '/api/ai/jobs',\n routeType: 'route',\n method: 'POST',\n },\n { service: 'console-web' },\n )\n } catch {\n // The console line beside each call is the record of last resort.\n }\n}\n\n/**\n * AI generation jobs: create and list (AGL-2904).\n *\n * `POST` is a door that spends, so it climbs the whole ladder (AGL-2903) —\n * `aiGenerative`, `release_ai_generative`, the `ai-generate` switch, a\n * per-uid window, and a reservation — and then creates the job and runs\n * its first step INLINE under `AI_JOB_INLINE_BUDGET_MS`. A step that\n * finishes in time answers with the job `done` (or `queued` behind its\n * next step); one that does not is aborted, its reservation handed back,\n * and the job answers `queued` for the beat to resume. Either way the\n * caller gets the job document, and the events route is how it watches\n * from there.\n *\n * `POST` with `check: true` climbs the same ladder and asks the same\n * admission, then hands the reservation back and creates nothing: a 200\n * with `ok` true, or the refusal the create would have answered. \"Create site\"\n * asks it before it makes the site a guided start builds on (AGL-3859), so\n * a start that would be refused, or that asks for the Free go-ahead, leaves\n * no empty site behind. A checked job may name no site yet.\n *\n * `GET` lists the org's jobs, newest first, through the read gate: the\n * same rungs up to the lockdown verdict, no reservation. A job naming a\n * site that no longer exists is left out, so a page can come back shorter\n * than its `limit`.\n *\n * The request must NAME the org it is metered against (AGL-1934). There\n * is no fallback to \"the caller's first org\".\n */\n\nconst AI_JOBS_RATE_LIMIT = { key: 'ai-jobs', limit: 10, windowMs: 60_000 }\n\n/** Kind-specific inputs, bounded: a runner reads scalars, not a document. */\nconst MAX_INPUTS_JSON_CHARS = 8_000\n\n/** The admitted statuses for the list filter, as the union spells them. */\nconst AI_JOB_STATUSES: readonly AiJobStatus[] = [\n 'queued',\n 'running',\n 'needs_input',\n 'needs_review',\n 'done',\n 'failed',\n 'canceled',\n]\n\nexport interface CreateAiJobBody {\n orgId: string\n hostId: string | null\n kind: AiJobKind\n brief: string\n inputs: Record<string, string | number | boolean>\n /** A catalog model the creator picked (AGL-2942), or `null` for Auto. */\n model: string | null\n}\n\nconst ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/\n\n/** The longest model id a body may carry; the catalog's ids are far shorter. */\nconst MAX_MODEL_CHARS = 100\n\n/** Validate + clamp the request body; a string names what is wrong. */\nexport function parseCreateAiJobBody(payload: unknown): CreateAiJobBody | string {\n const body = (payload ?? {}) as Record<string, unknown>\n const orgId = String(body.orgId ?? '').trim()\n if (!orgId) return 'Open a workspace before using this feature'\n const kind = String(body.kind ?? '')\n if (!(AI_JOB_KINDS as readonly string[]).includes(kind)) {\n return `kind must be one of ${AI_JOB_KINDS.join(', ')}`\n }\n const brief = String(body.brief ?? '').trim()\n if (!brief) return 'Write a brief for the job'\n if (brief.length > AI_JOB_BRIEF_MAX_CHARS) {\n return `Keep the brief under ${AI_JOB_BRIEF_MAX_CHARS.toLocaleString('en-US')} characters`\n }\n const rawHost = body.hostId\n const hostId =\n rawHost === undefined || rawHost === null || rawHost === ''\n ? null\n : typeof rawHost === 'string' && ID_CHARS.test(rawHost)\n ? rawHost\n : undefined\n if (hostId === undefined) return 'hostId is not a site id'\n // A theme is fields on one site's document, so a theme job that names no\n // site has nothing to propose a change to (AGL-2938).\n if (kind === 'theme' && !hostId) return 'Open a site before changing its theme'\n const rawInputs = body.inputs\n const inputs: Record<string, string | number | boolean> = {}\n if (rawInputs !== undefined && rawInputs !== null) {\n if (typeof rawInputs !== 'object' || Array.isArray(rawInputs)) {\n return 'inputs must be an object'\n }\n for (const [key, value] of Object.entries(rawInputs as Record<string, unknown>)) {\n if (\n typeof value !== 'string' &&\n typeof value !== 'number' &&\n typeof value !== 'boolean'\n ) {\n return `inputs.${key} must be a string, number or boolean`\n }\n inputs[key] = value\n }\n if (JSON.stringify(inputs).length > MAX_INPUTS_JSON_CHARS) {\n return 'inputs are too large'\n }\n }\n const rawModel = typeof body.model === 'string' ? body.model.trim() : ''\n if (rawModel.length > MAX_MODEL_CHARS) return 'model is not a model id'\n // Validated against the plan and the allotment allowlists when each step\n // runs, not here: the allowlists are read inside the step's reservation.\n const model = rawModel && rawModel !== 'auto' ? rawModel : null\n return { orgId, hostId, kind: kind as AiJobKind, brief, inputs, model }\n}\n\nexport async function POST(request: Request): Promise<Response> {\n let payload: unknown\n try {\n payload = await request.json()\n } catch {\n payload = null\n }\n const parsed = parseCreateAiJobBody(payload)\n // The org is parsed BEFORE the ladder so a request that named none is\n // refused with the ladder's 400 rather than a shape error; every other\n // shape fault waits until the caller has proven who they are.\n const orgId = typeof parsed === 'string' ? String((payload as Record<string, unknown> | null)?.orgId ?? '') : parsed.orgId\n // The site rides along for the same reason: a collaborator's `ai.generate`\n // is decided per site (AGL-2927), so the ladder needs it before it can say\n // whether this member may generate here at all.\n const hostId = typeof parsed === 'string' ? null : parsed.hostId\n const gate = await aiGateLadder(\n { request, orgId, hostId },\n {\n feature: 'aiGenerative',\n releaseFlag: 'release_ai_generative',\n lockdownFeature: 'ai-generate',\n permission: 'ai.generate',\n rateLimit: AI_JOBS_RATE_LIMIT,\n },\n )\n if (gate instanceof Response) return gate\n if (typeof parsed === 'string') {\n // Admitted, reserved, and then found to have sent nothing runnable: the\n // reservation goes back, because no token was spent.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json({ error: parsed }, { status: 400 })\n }\n // What only this kind can say about the request (AGL-2909): a site it\n // needs, inputs it reads, an allowance its draft counts against. Asked\n // before the job exists, so a refusal spends nothing.\n let refusal: Awaited<ReturnType<typeof aiJobAdmissionRefusal>>\n // A Free start past what is left, which the person chose to build as far as\n // it goes (AGL-3722): the prompt they confirmed, kept on the job.\n let confirmedCredits: AiCreditsPrompt | null = null\n // Asked before the site exists (AGL-3859): answers, creates nothing.\n const check = (payload as Record<string, unknown> | null)?.['check'] === true\n try {\n // A site that switched AI off first (AGL-3028), then the kind's own check.\n refusal =\n (await aiJobSiteRefusal({\n firestore: gate.firestore,\n org: gate.org,\n hostId: parsed.hostId,\n })) ??\n (await aiJobAdmissionRefusal(parsed.kind, {\n firestore: gate.firestore,\n orgId: gate.orgId,\n hostId: parsed.hostId,\n siteToCome: check && !parsed.hostId,\n inputs: parsed.inputs,\n org: gate.org,\n uid: gate.uid,\n creditsConfirmed: (payload as Record<string, unknown> | null)?.['creditsConfirmed'] === true,\n onCreditsConfirmed: (prompt) => {\n confirmedCredits = prompt\n },\n }))\n } catch (error) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job admission failed', { orgId: gate.orgId, kind: parsed.kind, error })\n await reportJobFault('ai job admission failed', error)\n return Response.json({ error: 'The AI job could not be created' }, { status: 500 })\n }\n if (refusal) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n // A Free start past what is left asks before it starts (AGL-3722): the\n // prompt rides the 409, so the dialog offers its choices.\n return Response.json(\n { error: refusal.error, ...(refusal.code ? { code: refusal.code } : {}), ...(refusal.credits ? { credits: refusal.credits } : {}) },\n { status: refusal.status },\n )\n }\n if (check) {\n // Admitted, and nothing to run: the reservation goes back.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json({ ok: true }, { status: 200 })\n }\n\n const now = new Date()\n let created: Awaited<ReturnType<typeof createAiJob>>\n try {\n // The guided start's own plan confirmation (AGL-3594), kept only for a\n // site job on a site that has published nothing of the owner's yet.\n const inputs = await aiJobAdmittedInputs(gate.firestore, {\n kind: parsed.kind,\n hostId: parsed.hostId,\n inputs: parsed.inputs,\n })\n created = await createAiJob(\n gate.firestore,\n {\n orgId: gate.orgId,\n hostId: parsed.hostId,\n kind: parsed.kind,\n brief: parsed.brief,\n inputs,\n model: parsed.model,\n createdBy: gate.uid,\n createdByEmail: gate.decoded.email ?? null,\n creditsConfirmed: aiCreditsConfirmation(confirmedCredits, gate.uid, now),\n },\n now,\n )\n } catch (error) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job create failed', { orgId: gate.orgId, error })\n await reportJobFault('ai job create failed', error)\n return Response.json({ error: 'The AI job could not be created' }, { status: 500 })\n }\n const jobId = created.$id\n\n // A first step that needs more time than this request has (AGL-2907) is left\n // queued for the beat, which starts it with a budget of its own: a provider\n // call this request's timeout cut off would be billed upstream and metered\n // nowhere. Nothing ran, so the reservation goes back.\n if (aiJobNextStepMinimumMs(created) > AI_JOB_INLINE_BUDGET_MS) {\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n return Response.json(\n {\n job: aiJobSummary(created, now),\n meter: aiUsageMeter(gate.reservation, { lastCredits: null }),\n },\n { status: 200 },\n )\n }\n\n // The first step, inline, on the reservation the ladder already holds.\n // The runner hands that reservation back itself if the provider is never\n // reached, and re-queues the step when the budget ends first.\n const run = await runAiJobStep(gate.firestore, gate.orgId, jobId, {\n owner: `route:${randomUUID()}`,\n now,\n reservation: gate.reservation,\n org: gate.org,\n signal: AbortSignal.timeout(AI_JOB_INLINE_BUDGET_MS),\n // The caller is on the request, so what the inline step produces is\n // attributed with their address; the beat's steps carry the uid alone.\n actor: { uid: gate.uid, email: gate.decoded.email ?? null },\n // The workspace's AI pause lets staff through, as the ladder above did.\n staff: gate.staff,\n })\n if (run.outcome === 'not-claimable') {\n // A job created a moment ago has a claimable first step; anything else\n // is a fault in the machine, and the reservation must not be stranded.\n await releaseAssistMessage(gate.firestore, gate.orgId, gate.reservation).catch(\n () => undefined,\n )\n console.error('ai job first step not claimable', { orgId: gate.orgId, jobId })\n await reportJobFault('ai job first step not claimable', new Error(`job ${jobId}`))\n return Response.json({ error: 'The AI job could not be started' }, { status: 500 })\n }\n return Response.json(\n {\n job: aiJobSummary(run.job),\n // The usage strip's envelope (AGL-2942): the reservation the ladder\n // took, with what the inline step spent.\n meter: aiUsageMeter(gate.reservation, {\n lastCredits: run.job.creditsSpent > 0 ? run.job.creditsSpent : null,\n }),\n },\n { status: 200 },\n )\n}\n\nexport async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const gate = await aiJobsGate(request, url.searchParams.get('orgId') ?? '')\n if (gate instanceof Response) return gate\n const rawStatus = url.searchParams.get('status') ?? ''\n const status = (AI_JOB_STATUSES as readonly string[]).includes(rawStatus)\n ? (rawStatus as AiJobStatus)\n : undefined\n // `active` (AGL-3593): every job not yet settled, which is what the\n // console's top-bar indicator and the Assist launcher count.\n const active = rawStatus === 'active'\n const limit = Number(url.searchParams.get('limit') ?? '')\n // `hostId` (AGL-3596): one site's jobs, for the site's AI jobs page — a\n // query on the field under the (hostId, createdAt) index, never a filter\n // of a loaded window. An id that is not one is refused rather than ignored,\n // which would answer with every site's jobs.\n const rawHost = url.searchParams.get('hostId')\n if (rawHost !== null && !ID_CHARS.test(rawHost)) {\n return Response.json({ error: 'hostId is not a site id' }, { status: 400 })\n }\n const now = new Date()\n // A job naming a site that has been deleted is not listed (AGL-3596), so\n // no surface counts or shows work on a site nobody can open.\n const jobs = await withoutErasedSites(\n gate.firestore,\n await listAiJobs(gate.firestore, gate.orgId, {\n ...(status ? { status } : {}),\n ...(active ? { statuses: AI_JOB_ACTIVE_STATUSES } : {}),\n ...(rawHost ? { hostId: rawHost } : {}),\n ...(Number.isFinite(limit) && limit > 0 ? { limit } : {}),\n }),\n )\n const freeTaste = resolveEffectivePlan(gate.org as never) === 'free'\n // What the Free workspace has left this month (AGL-3660): the less of its\n // own band and its owner's allowance across their Free workspaces, which is\n // what the steps' reservations refuse at — never the 300 a month starts with.\n const freeCredits = freeTaste\n ? await readFreeAiCreditsLeft(gate.firestore, { orgId: gate.orgId, org: gate.org as object | null, now })\n : null\n // Whether Create site offers \"Bring my site\" (AGL-3976), asked only by the\n // guided start's own read, so the card costs no other caller anything.\n const siteImport =\n url.searchParams.get('siteImport') === '1'\n ? await siteImportOfferFor({ orgId: gate.orgId, staff: gate.staff, org: gate.org, member: gate.member })\n : null\n // Whether the guided start asks for the sites the person likes (AGL-3975):\n // the workspace's own verdict, the one its site job reads them by.\n const siteInspiration = await isServerReleaseFlagOnForOrg(AI_SITE_INSPIRATION_FLAG, gate.orgId).catch(() => false)\n return Response.json(\n {\n jobs: jobs.map((job) => aiJobSummary(job, now)),\n // Whether the workspace spends the Free taste (AGL-3594), so the guided\n // start offers the Free page band from the request it already makes.\n freeTaste,\n ...(freeCredits ? { freeCredits } : {}),\n ...(siteImport ? { siteImport } : {}),\n ...(siteInspiration ? { siteInspiration: true } : {}),\n },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n}\n\nexport const dynamic = 'force-dynamic'\nexport const maxDuration = 60\n"],"names":["randomUUID","resolveEffectivePlan","AI_JOB_KINDS","aiGateLadder","aiJobAdmissionRefusal","aiJobSiteRefusal","AI_JOB_BRIEF_MAX_CHARS","aiJobAdmittedInputs","AI_JOB_INLINE_BUDGET_MS","aiJobNextStepMinimumMs","aiJobSummary","createAiJob","listAiJobs","runAiJobStep","releaseAssistMessage","readFreeAiCreditsLeft","aiCreditsConfirmation","aiUsageMeter","aiJobsGate","siteImportOfferFor","withoutErasedSites","AI_JOB_ACTIVE_STATUSES","reportServerError","isServerReleaseFlagOnForOrg","AI_SITE_INSPIRATION_FLAG","reportJobFault","what","error","message","Error","String","stack","undefined","route","routeType","method","service","AI_JOBS_RATE_LIMIT","key","limit","windowMs","MAX_INPUTS_JSON_CHARS","AI_JOB_STATUSES","ID_CHARS","MAX_MODEL_CHARS","parseCreateAiJobBody","payload","body","orgId","trim","kind","includes","join","brief","length","toLocaleString","rawHost","hostId","test","rawInputs","inputs","Array","isArray","value","Object","entries","JSON","stringify","rawModel","model","POST","request","gate","json","parsed","feature","releaseFlag","lockdownFeature","permission","rateLimit","Response","firestore","reservation","catch","status","refusal","confirmedCredits","check","org","siteToCome","uid","creditsConfirmed","onCreditsConfirmed","prompt","console","code","credits","ok","now","Date","created","createdBy","createdByEmail","decoded","email","jobId","$id","job","meter","lastCredits","run","owner","signal","AbortSignal","timeout","actor","staff","outcome","creditsSpent","GET","url","URL","searchParams","get","rawStatus","active","Number","jobs","statuses","isFinite","freeTaste","freeCredits","siteImport","member","siteInspiration","map","headers","dynamic","maxDuration"],"mappings":"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"}
@@ -0,0 +1,89 @@
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
+ */
17
+ /**
18
+ * The "Bring my site" doors (AGL-3974, parent AGL-3973).
19
+ *
20
+ * GET /api/ai/site-import/ownership?orgId= the org's claims
21
+ * POST /api/ai/site-import/ownership { orgId, action, domain, … }
22
+ * action `claim` start a claim (idempotent)
23
+ * action `check`, method `dns` | `meta` look for the record or tag now
24
+ * action `send-code`, email email a code to an address at the domain
25
+ * action `confirm-code`, code check the code
26
+ * POST /api/ai/site-import/crawl { orgId, domain, maxPages? }
27
+ * the site's inventory, for a domain the org has a CURRENT proof for
28
+ *
29
+ * The rungs, in order, each disclosing nothing a lower one would:
30
+ *
31
+ * 401 no bearer token, or a refused one
32
+ * 403 email unverified (an impersonation session is exempt)
33
+ * 400 the request did not name the org (AGL-1934)
34
+ * 403 not a member of that org
35
+ * 404 `release_ai_site_import` is off; a verified staff claim previews
36
+ * 403 `reason: 'role'`: the caller is not the org's owner or an admin
37
+ * 403 `reason: 'plan'`: the org is on the Free plan; import is for every
38
+ * paid plan
39
+ * 423 lockdown
40
+ *
41
+ * Nothing here spends model credits: the crawl reads and returns, and the
42
+ * import job (another lane) decides what becomes a page.
43
+ */
44
+ import type { DecodedIdToken } from 'firebase-admin/auth';
45
+ import type { AglynOrganization, AglynOrgMember } from '@aglyn/aglyn/foundation/definitions/organization.types';
46
+ import { type DomainOwnershipDeps } from '@aglyn/tenant-data-admin/server/domain-ownership';
47
+ import { type SiteImportCrawlSeams } from '../site-import/site-import-crawl';
48
+ import { SITE_IMPORT_RELEASE_FLAG } from '../site-import/site-import-flag';
49
+ export { SITE_IMPORT_RELEASE_FLAG };
50
+ /** Pages the crawl door reads in one request; the import job reads in waves. */
51
+ export declare const SITE_IMPORT_DOOR_MAX_PAGES = 25;
52
+ /** Crawls one org may run through the door in a day. */
53
+ export declare const SITE_IMPORT_CRAWLS_PER_ORG_PER_DAY = 20;
54
+ /** DNS and home-page checks one org may run in an hour. */
55
+ export declare const SITE_IMPORT_CHECKS_PER_ORG_PER_HOUR = 60;
56
+ export interface SiteImportGateContext {
57
+ uid: string;
58
+ decoded: DecodedIdToken;
59
+ staff: boolean;
60
+ orgId: string;
61
+ org: Partial<AglynOrganization>;
62
+ member: AglynOrgMember | null;
63
+ firestore: FirebaseFirestore.Firestore;
64
+ }
65
+ /** What the doors reach outside the process, replaceable in a spec. */
66
+ export interface SiteImportRouteSeams {
67
+ ownership?: DomainOwnershipDeps;
68
+ crawl?: SiteImportCrawlSeams;
69
+ }
70
+ export declare function siteImportGate(request: Request, rawOrgId: string): Promise<Response | SiteImportGateContext>;
71
+ /**
72
+ * Whether "Bring my site" is offered to this reader (AGL-3976), in the
73
+ * doors' own order, for Create site's choice card: `null` while the flag is
74
+ * off (the card is not drawn), `role` or `plan` for a refusal the workspace
75
+ * can change (the card is drawn disabled with the reason), `ready` otherwise.
76
+ * Read by the jobs list only when the card asks, so no other read pays for it.
77
+ */
78
+ export declare function siteImportOfferFor(input: {
79
+ orgId: string;
80
+ staff: boolean;
81
+ org: Partial<AglynOrganization> | null;
82
+ member: AglynOrgMember | null;
83
+ }): Promise<'ready' | 'role' | 'plan' | null>;
84
+ /** `GET /api/ai/site-import/ownership?orgId=`. */
85
+ export declare function listSiteImportOwnership(request: Request, seams?: SiteImportRouteSeams): Promise<Response>;
86
+ /** `POST /api/ai/site-import/ownership`. */
87
+ export declare function postSiteImportOwnership(request: Request, seams?: SiteImportRouteSeams): Promise<Response>;
88
+ /** `POST /api/ai/site-import/crawl`. */
89
+ export declare function postSiteImportCrawl(request: Request, seams?: SiteImportRouteSeams): Promise<Response>;