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

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 (210) hide show
  1. package/package.json +17 -17
  2. package/src/lib/assets/site-styles/nonprofit.webp +0 -0
  3. package/src/lib/assets/site-styles/professional.webp +0 -0
  4. package/src/lib/assets/site-styles/store.webp +0 -0
  5. package/src/lib/assets/site-styles/trades.webp +0 -0
  6. package/src/lib/components/ai-theme-proposal-card.component.js +12 -3
  7. package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
  8. package/src/lib/declarations.js +5 -2
  9. package/src/lib/declarations.js.map +1 -1
  10. package/src/lib/declarations.server.js +12 -0
  11. package/src/lib/declarations.server.js.map +1 -1
  12. package/src/lib/jobs/ai-build-capabilities.d.ts +3 -1
  13. package/src/lib/jobs/ai-build-capabilities.js +27 -2
  14. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  15. package/src/lib/jobs/ai-image-reads.d.ts +59 -0
  16. package/src/lib/jobs/ai-image-reads.js +138 -0
  17. package/src/lib/jobs/ai-image-reads.js.map +1 -0
  18. package/src/lib/jobs/ai-job-build-step.d.ts +29 -0
  19. package/src/lib/jobs/ai-job-build-step.js +83 -12
  20. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  21. package/src/lib/jobs/ai-job-duplicate-step.d.ts +2 -1
  22. package/src/lib/jobs/ai-job-duplicate-step.js +6 -6
  23. package/src/lib/jobs/ai-job-duplicate-step.js.map +1 -1
  24. package/src/lib/jobs/ai-job-import-step.d.ts +4 -2
  25. package/src/lib/jobs/ai-job-import-step.js +15 -11
  26. package/src/lib/jobs/ai-job-import-step.js.map +1 -1
  27. package/src/lib/jobs/ai-job-insight-step.d.ts +10 -0
  28. package/src/lib/jobs/ai-job-insight-step.js +23 -5
  29. package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
  30. package/src/lib/jobs/ai-job-layout-language.js +2 -1
  31. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  32. package/src/lib/jobs/ai-job-media-step.d.ts +2 -1
  33. package/src/lib/jobs/ai-job-media-step.js +6 -8
  34. package/src/lib/jobs/ai-job-media-step.js.map +1 -1
  35. package/src/lib/jobs/ai-job-navigation-step.d.ts +2 -1
  36. package/src/lib/jobs/ai-job-navigation-step.js +7 -9
  37. package/src/lib/jobs/ai-job-navigation-step.js.map +1 -1
  38. package/src/lib/jobs/ai-job-page-language.js +7 -1
  39. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  40. package/src/lib/jobs/ai-job-page-sections.js +16 -3
  41. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  42. package/src/lib/jobs/ai-job-page-step.js +9 -1
  43. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  44. package/src/lib/jobs/ai-job-plan-step.d.ts +30 -8
  45. package/src/lib/jobs/ai-job-plan-step.js +78 -13
  46. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  47. package/src/lib/jobs/ai-job-privacy-step.d.ts +4 -2
  48. package/src/lib/jobs/ai-job-privacy-step.js +14 -15
  49. package/src/lib/jobs/ai-job-privacy-step.js.map +1 -1
  50. package/src/lib/jobs/ai-job-products-step.js +5 -3
  51. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  52. package/src/lib/jobs/ai-job-settings-step.d.ts +2 -1
  53. package/src/lib/jobs/ai-job-settings-step.js +7 -8
  54. package/src/lib/jobs/ai-job-settings-step.js.map +1 -1
  55. package/src/lib/jobs/ai-job-site-content.d.ts +34 -1
  56. package/src/lib/jobs/ai-job-site-content.js +67 -8
  57. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  58. package/src/lib/jobs/ai-job-site-datasets.d.ts +53 -3
  59. package/src/lib/jobs/ai-job-site-datasets.js +298 -30
  60. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  61. package/src/lib/jobs/ai-job-site-step.js +6 -3
  62. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  63. package/src/lib/jobs/ai-job-site-store-pages.js +3 -3
  64. package/src/lib/jobs/ai-job-site-store-pages.js.map +1 -1
  65. package/src/lib/jobs/ai-job-tracking-step.d.ts +2 -1
  66. package/src/lib/jobs/ai-job-tracking-step.js +7 -8
  67. package/src/lib/jobs/ai-job-tracking-step.js.map +1 -1
  68. package/src/lib/jobs/ai-jobs-beat.d.ts +11 -7
  69. package/src/lib/jobs/ai-jobs-beat.js +18 -8
  70. package/src/lib/jobs/ai-jobs-beat.js.map +1 -1
  71. package/src/lib/jobs/ai-jobs-notify.d.ts +66 -1
  72. package/src/lib/jobs/ai-jobs-notify.js +136 -3
  73. package/src/lib/jobs/ai-jobs-notify.js.map +1 -1
  74. package/src/lib/jobs/ai-layout-stock-photos.d.ts +59 -4
  75. package/src/lib/jobs/ai-layout-stock-photos.js +897 -81
  76. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  77. package/src/lib/jobs/ai-product-image.d.ts +14 -0
  78. package/src/lib/jobs/ai-product-image.js +73 -21
  79. package/src/lib/jobs/ai-product-image.js.map +1 -1
  80. package/src/lib/jobs/ai-theme-brand-inputs.d.ts +9 -1
  81. package/src/lib/jobs/ai-theme-brand-inputs.js +28 -2
  82. package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
  83. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.d.ts +175 -0
  84. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.js +601 -0
  85. package/src/lib/jobs/fixtures/ai-site-agl4065-datasets.js.map +1 -0
  86. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.d.ts +46 -0
  87. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.js +2862 -0
  88. package/src/lib/jobs/fixtures/ai-site-agl4065-pages.js.map +1 -0
  89. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.d.ts +74 -0
  90. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.js +888 -0
  91. package/src/lib/jobs/fixtures/ai-site-agl4065-plans.js.map +1 -0
  92. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.d.ts +29 -0
  93. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.js +81 -0
  94. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-judge.js.map +1 -0
  95. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.d.ts +87 -0
  96. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.js +225 -0
  97. package/src/lib/jobs/fixtures/ai-stock-photo-agl4065-recordings.js.map +1 -0
  98. package/src/lib/layout-language/ai-layout-compiler.d.ts +11 -0
  99. package/src/lib/layout-language/ai-layout-compiler.js +194 -18
  100. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  101. package/src/lib/layout-language/ai-layout-copy.d.ts +37 -0
  102. package/src/lib/layout-language/ai-layout-copy.js +383 -3
  103. package/src/lib/layout-language/ai-layout-copy.js.map +1 -1
  104. package/src/lib/layout-language/ai-layout-design.d.ts +19 -0
  105. package/src/lib/layout-language/ai-layout-design.js +31 -11
  106. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  107. package/src/lib/layout-language/ai-layout-listings.d.ts +7 -1
  108. package/src/lib/layout-language/ai-layout-listings.js +9 -1
  109. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  110. package/src/lib/layout-language/ai-layout-pictures.d.ts +30 -0
  111. package/src/lib/layout-language/ai-layout-pictures.js +131 -3
  112. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  113. package/src/lib/layout-language/ai-layout-storefront.d.ts +18 -4
  114. package/src/lib/layout-language/ai-layout-storefront.js +100 -20
  115. package/src/lib/layout-language/ai-layout-storefront.js.map +1 -1
  116. package/src/lib/model/ai-build-job.d.ts +7 -0
  117. package/src/lib/model/ai-build-job.js +36 -7
  118. package/src/lib/model/ai-build-job.js.map +1 -1
  119. package/src/lib/model/ai-build-plan.d.ts +28 -4
  120. package/src/lib/model/ai-build-plan.js +49 -9
  121. package/src/lib/model/ai-build-plan.js.map +1 -1
  122. package/src/lib/model/ai-build-progress.js +3 -1
  123. package/src/lib/model/ai-build-progress.js.map +1 -1
  124. package/src/lib/model/ai-job-notice.d.ts +8 -0
  125. package/src/lib/model/ai-job-notice.js +33 -0
  126. package/src/lib/model/ai-job-notice.js.map +1 -1
  127. package/src/lib/model/ai-job-plan-lines.d.ts +2 -1
  128. package/src/lib/model/ai-job-plan-lines.js +6 -1
  129. package/src/lib/model/ai-job-plan-lines.js.map +1 -1
  130. package/src/lib/model/ai-jobs.types.d.ts +8 -0
  131. package/src/lib/model/ai-jobs.types.js.map +1 -1
  132. package/src/lib/model/ai-permissions.d.ts +13 -0
  133. package/src/lib/model/ai-permissions.js +29 -1
  134. package/src/lib/model/ai-permissions.js.map +1 -1
  135. package/src/lib/model/ai-products.d.ts +18 -0
  136. package/src/lib/model/ai-products.js +32 -0
  137. package/src/lib/model/ai-products.js.map +1 -1
  138. package/src/lib/model/ai-site-inspiration.d.ts +3 -2
  139. package/src/lib/model/ai-site-inspiration.js +3 -2
  140. package/src/lib/model/ai-site-inspiration.js.map +1 -1
  141. package/src/lib/plugin.js +4 -13
  142. package/src/lib/plugin.js.map +1 -1
  143. package/src/lib/providers/anthropic.js +9 -2
  144. package/src/lib/providers/anthropic.js.map +1 -1
  145. package/src/lib/providers/catalog.js +14 -5
  146. package/src/lib/providers/catalog.js.map +1 -1
  147. package/src/lib/providers/contract.d.ts +24 -1
  148. package/src/lib/providers/contract.js.map +1 -1
  149. package/src/lib/providers/routing.js +5 -2
  150. package/src/lib/providers/routing.js.map +1 -1
  151. package/src/lib/runtime/ai-dataset-generation.d.ts +99 -1
  152. package/src/lib/runtime/ai-dataset-generation.js +251 -3
  153. package/src/lib/runtime/ai-dataset-generation.js.map +1 -1
  154. package/src/lib/runtime/ai-dev-replay.d.ts +1 -0
  155. package/src/lib/runtime/ai-dev-replay.js +4 -2
  156. package/src/lib/runtime/ai-dev-replay.js.map +1 -1
  157. package/src/lib/runtime/ai-doctrine-validators.d.ts +47 -3
  158. package/src/lib/runtime/ai-doctrine-validators.js +343 -27
  159. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  160. package/src/lib/runtime/ai-doctrine.js +8 -2
  161. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  162. package/src/lib/runtime/ai-icon-library.d.ts +27 -1
  163. package/src/lib/runtime/ai-icon-library.js +30 -3
  164. package/src/lib/runtime/ai-icon-library.js.map +1 -1
  165. package/src/lib/runtime/ai-products-generation.d.ts +11 -1
  166. package/src/lib/runtime/ai-products-generation.js +51 -4
  167. package/src/lib/runtime/ai-products-generation.js.map +1 -1
  168. package/src/lib/runtime/assist-answer-cache.d.ts +2 -0
  169. package/src/lib/runtime/assist-answer-cache.js +5 -2
  170. package/src/lib/runtime/assist-answer-cache.js.map +1 -1
  171. package/src/lib/server/ai-allotments.js +7 -2
  172. package/src/lib/server/ai-allotments.js.map +1 -1
  173. package/src/lib/server/ai-experiment-versions.js +5 -5
  174. package/src/lib/server/ai-experiment-versions.js.map +1 -1
  175. package/src/lib/server/ai-host-permissions-roster.d.ts +25 -0
  176. package/src/lib/server/ai-host-permissions-roster.js +33 -0
  177. package/src/lib/server/ai-host-permissions-roster.js.map +1 -0
  178. package/src/lib/server/ai-host-permissions.js +8 -6
  179. package/src/lib/server/ai-host-permissions.js.map +1 -1
  180. package/src/lib/server/ai-image-reads-route.d.ts +17 -0
  181. package/src/lib/server/ai-image-reads-route.js +106 -0
  182. package/src/lib/server/ai-image-reads-route.js.map +1 -0
  183. package/src/lib/server/ai-seo-apply.js +5 -5
  184. package/src/lib/server/ai-seo-apply.js.map +1 -1
  185. package/src/lib/server/ai-site-import-route.js +7 -4
  186. package/src/lib/server/ai-site-import-route.js.map +1 -1
  187. package/src/lib/server/assist-build.js +1 -0
  188. package/src/lib/server/assist-build.js.map +1 -1
  189. package/src/lib/server/assist-chat.d.ts +11 -7
  190. package/src/lib/server/assist-chat.js +185 -48
  191. package/src/lib/server/assist-chat.js.map +1 -1
  192. package/src/lib/server/assist-docs-query.d.ts +51 -0
  193. package/src/lib/server/assist-docs-query.js +57 -0
  194. package/src/lib/server/assist-docs-query.js.map +1 -0
  195. package/src/lib/server/assist-jobs-context.d.ts +102 -0
  196. package/src/lib/server/assist-jobs-context.js +227 -0
  197. package/src/lib/server/assist-jobs-context.js.map +1 -0
  198. package/src/lib/server/assist-view-context.d.ts +8 -4
  199. package/src/lib/server/assist-view-context.js +12 -7
  200. package/src/lib/server/assist-view-context.js.map +1 -1
  201. package/src/lib/server.js +12 -0
  202. package/src/lib/server.js.map +1 -1
  203. package/src/lib/tools/ai-dataset-tool.d.ts +11 -1
  204. package/src/lib/tools/ai-dataset-tool.js +24 -7
  205. package/src/lib/tools/ai-dataset-tool.js.map +1 -1
  206. package/src/lib/tools/ai-duplicate-resource-tool.js +5 -6
  207. package/src/lib/tools/ai-duplicate-resource-tool.js.map +1 -1
  208. package/src/lib/components/ai-collaborator-permissions-column.component.d.ts +0 -32
  209. package/src/lib/components/ai-collaborator-permissions-column.component.js +0 -132
  210. package/src/lib/components/ai-collaborator-permissions-column.component.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/ai-dev-replay.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 { createHash } from 'node:crypto'\nimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { aiProviderRatesForModel, estimateAiProviderCostUsd } from '../providers/catalog'\nimport { aiCacheWriteHourSurchargeUsd } from '../providers/cache-write-hour'\nimport type {\n AiProvider,\n AiProviderRequest,\n AiResult,\n AiStreamEvent,\n AiSystemBlock,\n AiTool,\n} from '../providers/contract'\nimport { aiLiveBatchStats } from './ai-live-batch'\nimport { AI_REPLAY_DIR_ENV, aiDevReplayMode, type AiDevEnv as Env, type AiDevReplayMode } from './ai-dev-env'\n\n/** The key format's version: bumped when what a key covers changes, so no old answer is read under a new meaning. */\nconst KEY_VERSION = 1\n\n/**\n * THE DEVELOPMENT REPLAY CACHE (AGL-3660).\n *\n * Almost every dollar the provider bills in a month is a development run: a\n * live eval, the layout screenshots fed by one, a local guided start. Most of\n * the requests in a second run of the same eval are byte-identical to the\n * first — the change under test was to a compiler, a check or one prompt, not\n * to all of them — and each identical request bought the same sample twice.\n *\n * So, outside production only, the runtime keys each request by a hash of\n * everything that decides the answer — the provider, the model, the system\n * blocks, the tools, the messages, the ceiling, thinking and effort — and keeps\n * the provider's answer under `.cache/ai-replay/` (gitignored). The next\n * identical request is answered from disk for nothing; a request whose prompt\n * changed by one byte has a new key and goes to the provider. A replayed answer\n * is a real answer the same model gave to the same bytes, so an eval that\n * passes on replays has still seen every CHANGED prompt answered live: the\n * replay never stands in for a prompt the provider has not answered.\n *\n * `AGLYN_AI_REPLAY`:\n * - `1` / `on`: replay what is recorded, record what is not.\n * - `refresh`: every request live, its answer recorded over the old one.\n * - `off` / `0`: nothing read, nothing written.\n * - unset: on under the live-eval launcher (`AI_EVAL_LIVE_LAUNCHER`), off\n * everywhere else, so a dev server or a spec that never asked for it\n * behaves exactly as before.\n *\n * It never runs in a deployed server. `NODE_ENV=production`, `VERCEL_ENV`\n * of `production` or `preview`, or a Cloud Run service (`K_SERVICE`) turn it\n * off whatever the variable says, and the first request says so in the log:\n * a recorded answer handed to a customer would be another customer's page.\n *\n * Every live eval's table carries `aiDevReplayStats()`, so a run that was\n * all replays reads as one: `live: 0` is not a live pass.\n */\n\n// ── The key ──────────────────────────────────────────────────────────────\n\n/** JSON with every object's keys in sorted order: the same value, the same bytes, whatever built it. */\nexport function aiCanonicalJson(value: unknown): string {\n return JSON.stringify(sortKeys(value))\n}\n\nfunction sortKeys(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(sortKeys)\n if (value && typeof value === 'object') {\n return Object.fromEntries(\n Object.keys(value as Record<string, unknown>)\n .sort()\n .filter((key) => (value as Record<string, unknown>)[key] !== undefined)\n .map((key) => [key, sortKeys((value as Record<string, unknown>)[key])]),\n )\n }\n return value\n}\n\n/** What a request's answer depends on. The key, the signal and the cache lifetime are not part of it. */\nexport interface AiReplayRequest {\n model: string\n system: readonly AiSystemBlock[]\n messages: AiProviderRequest['messages']\n tools?: readonly AiTool[]\n maxTokens: number\n thinking?: AiProviderRequest['thinking']\n effort?: AiProviderRequest['effort']\n}\n\n/** The request's replay key: a SHA-256 of everything that decides the answer. */\nexport function aiDevReplayKey(providerId: string, stream: boolean, request: AiReplayRequest): string {\n return createHash('sha256')\n .update(\n aiCanonicalJson({\n v: KEY_VERSION,\n provider: providerId,\n stream,\n model: request.model,\n // A breakpoint changes no answer, so the key reads the text alone.\n system: request.system.map((block) => block.text),\n tools: request.tools ?? [],\n messages: request.messages,\n maxTokens: request.maxTokens,\n thinking: request.thinking ?? null,\n effort: request.effort ?? null,\n }),\n )\n .digest('hex')\n}\n\n/**\n * The prefix a provider caches: the model, the tools and every system block\n * through the last breakpoint. Two requests with the same prefix key read one\n * cache entry; `null` when the request marks no breakpoint.\n */\nexport function aiCachedPrefixKey(request: Pick<AiReplayRequest, 'model' | 'system' | 'tools'>): string | null {\n const last = request.system.map((block) => Boolean(block.cacheBreakpoint)).lastIndexOf(true)\n if (last < 0) return null\n return createHash('sha256')\n .update(\n aiCanonicalJson({\n model: request.model,\n tools: request.tools ?? [],\n system: request.system.slice(0, last + 1).map((block) => block.text),\n }),\n )\n .digest('hex')\n}\n\n// ── The store ────────────────────────────────────────────────────────────\n\n/** The repository root: the nearest directory up from here holding `nx.json`, else the working directory. */\nfunction repositoryRoot(start: string = process.cwd()): string {\n let dir = start\n for (;;) {\n if (existsSync(join(dir, 'nx.json'))) return dir\n const parent = dirname(dir)\n if (parent === dir) return start\n dir = parent\n }\n}\n\n/** Where recorded answers are kept. */\nexport function aiDevReplayDir(env: Env = process.env): string {\n return env[AI_REPLAY_DIR_ENV] || join(repositoryRoot(), '.cache', 'ai-replay')\n}\n\ntype Recording =\n | { v: number; key: string; model: string; recordedAt: string; kind: 'result'; result: AiResult }\n | { v: number; key: string; model: string; recordedAt: string; kind: 'stream'; events: AiStreamEvent[] }\n\nfunction pathFor(dir: string, key: string): string {\n return join(dir, key.slice(0, 2), `${key}.json`)\n}\n\nfunction readRecording(dir: string, key: string): Recording | null {\n const path = pathFor(dir, key)\n if (!existsSync(path)) return null\n try {\n const parsed = JSON.parse(readFileSync(path, 'utf8')) as Recording\n return parsed?.v === KEY_VERSION && parsed.key === key ? parsed : null\n } catch {\n return null\n }\n}\n\nfunction writeRecording(dir: string, recording: Recording): void {\n const path = pathFor(dir, recording.key)\n mkdirSync(dirname(path), { recursive: true })\n // Written aside and renamed, so a run killed mid-write leaves no half file to replay.\n const temp = `${path}.${process.pid}.tmp`\n writeFileSync(temp, JSON.stringify(recording))\n renameSync(temp, path)\n}\n\n// ── What a run did ───────────────────────────────────────────────────────\n\n/** How many of this process's requests were sent, and how many were answered from disk. */\nexport interface AiDevReplayStats {\n mode: AiDevReplayMode\n /** Requests sent to the provider. */\n live: number\n /** Requests answered from a recording. */\n replayed: number\n /** What the live requests cost at the provider's list rates. */\n liveProviderUsd: number\n /** What the replayed ones would have cost, had they been sent. */\n savedProviderUsd: number\n}\n\nconst stats: AiDevReplayStats = {\n mode: 'off',\n live: 0,\n replayed: 0,\n liveProviderUsd: 0,\n savedProviderUsd: 0,\n}\n\n/** This process's count so far: what a live eval prints beside its table. */\nexport function aiDevReplayStats(env: Env = process.env): AiDevReplayStats {\n return {\n ...stats,\n mode: aiDevReplayMode(env),\n liveProviderUsd: round(stats.liveProviderUsd),\n savedProviderUsd: round(stats.savedProviderUsd),\n }\n}\n\n/** Starts the count again; a spec that runs two rounds reports each. */\nexport function resetAiDevReplayStats(): void {\n stats.live = 0\n stats.replayed = 0\n stats.liveProviderUsd = 0\n stats.savedProviderUsd = 0\n}\n\nfunction round(usd: number): number {\n return Math.round(usd * 1_000_000) / 1_000_000\n}\n\nfunction usageCost(usage: AiResult['usage'], model: string): number {\n // A live run writes hour-long entries, billed above the rate table's write rate (AGL-3830).\n return round(estimateAiProviderCostUsd(usage, model) + aiCacheWriteHourSurchargeUsd(usage, aiProviderRatesForModel(model)))\n}\n\n/** Counts one request a live path sent, at what the provider billed for it; `discount` is a batch's half. */\nexport function aiDevCountLive(usage: AiResult['usage'], model: string, discount = 1): void {\n stats.live += 1\n stats.liveProviderUsd += usageCost(usage, model) * discount\n}\n\n// ── The seam ─────────────────────────────────────────────────────────────\n\nasync function* replayEvents(events: readonly AiStreamEvent[]): AsyncGenerator<AiStreamEvent> {\n for (const event of events) yield event\n}\n\n/** A live stream passed through as it arrives, and recorded once it is done. */\nasync function* recordEvents(\n source: AsyncIterable<AiStreamEvent>,\n onDone: (events: AiStreamEvent[]) => void,\n): AsyncGenerator<AiStreamEvent> {\n const events: AiStreamEvent[] = []\n let completed = false\n for await (const event of source) {\n events.push(event)\n if (event.type === 'done') completed = true\n yield event\n }\n // A stream cut short, or one that carried a provider error, is not an\n // answer worth keeping: the next run asks again.\n if (completed && !events.some((event) => event.type === 'error')) onDone(events)\n}\n\n/**\n * One request through the replay cache: answered from disk when recorded and\n * the mode replays, otherwise sent through `send` and recorded. `send` is the\n * live path — the provider, or the batch queue — and counts its own spend.\n */\nexport async function aiDevReplayComplete(\n provider: Pick<AiProvider, 'id'>,\n request: AiReplayRequest,\n mode: Exclude<AiDevReplayMode, 'off'>,\n send: () => Promise<AiResult>,\n env: Env = process.env,\n): Promise<AiResult> {\n const dir = aiDevReplayDir(env)\n const key = aiDevReplayKey(provider.id, false, request)\n const recorded = mode === 'replay' ? readRecording(dir, key) : null\n if (recorded?.kind === 'result') {\n stats.replayed += 1\n stats.savedProviderUsd += usageCost(recorded.result.usage, request.model)\n return recorded.result\n }\n const result = await send()\n writeRecording(dir, {\n v: KEY_VERSION,\n key,\n model: request.model,\n recordedAt: new Date().toISOString(),\n kind: 'result',\n result,\n })\n return result\n}\n\n/** The streaming half of {@link aiDevReplayComplete}. */\nexport async function aiDevReplayStream(\n provider: Pick<AiProvider, 'id'>,\n request: AiReplayRequest,\n mode: Exclude<AiDevReplayMode, 'off'>,\n send: () => Promise<AsyncIterable<AiStreamEvent>>,\n env: Env = process.env,\n): Promise<AsyncIterable<AiStreamEvent>> {\n const dir = aiDevReplayDir(env)\n const key = aiDevReplayKey(provider.id, true, request)\n const recorded = mode === 'replay' ? readRecording(dir, key) : null\n if (recorded?.kind === 'stream') {\n stats.replayed += 1\n const done = recorded.events.find((event) => event.type === 'done')\n if (done?.type === 'done') stats.savedProviderUsd += usageCost(done.usage, request.model)\n return replayEvents(recorded.events)\n }\n const live = await send()\n return recordEvents(live, (events) => {\n const done = events.find((event) => event.type === 'done')\n if (done?.type === 'done') aiDevCountLive(done.usage, request.model)\n writeRecording(dir, {\n v: KEY_VERSION,\n key,\n model: request.model,\n recordedAt: new Date().toISOString(),\n kind: 'stream',\n events,\n })\n })\n}\n\n// ── Warming a shared prefix before a fan-out ─────────────────────────────\n\n/**\n * A cache entry is readable only once the request that writes it has started\n * answering, so N requests sent at once over one prefix all pay to write it\n * and none reads it — which is exactly what a live eval's `Promise.all` over\n * its briefs did. In a development run the first request over a prefix goes\n * alone; the others wait for it and then read what it wrote. A prefix\n * answered within the cache's lifetime is not held again.\n *\n * Development only, like everything here: production's beat runs one step at\n * a time, and holding a customer's request behind another's would trade\n * their seconds for our cents.\n */\nconst warming = new Map<string, Promise<unknown>>()\nconst warmedAt = new Map<string, number>()\n\nexport async function aiDevWarmPrefixFirst<T>(\n prefixKey: string | null,\n ttlMs: number,\n send: () => Promise<T>,\n now: () => number = Date.now,\n): Promise<T> {\n if (!prefixKey) return send()\n const warmed = warmedAt.get(prefixKey)\n if (warmed !== undefined && now() - warmed < ttlMs) return send()\n const pending = warming.get(prefixKey)\n if (pending) {\n await pending.catch(() => undefined)\n return send()\n }\n const first = send()\n warming.set(prefixKey, first)\n try {\n const result = await first\n warmedAt.set(prefixKey, now())\n return result\n } finally {\n warming.delete(prefixKey)\n }\n}\n\n/** Forgets every warmed prefix; for specs. */\nexport function resetAiDevWarmPrefixes(): void {\n warming.clear()\n warmedAt.clear()\n}\n\n// ── What a live eval prints ──────────────────────────────────────────────\n\n/**\n * The line every live eval's table carries (AGL-3660): how many of its\n * requests went to the provider and how many were answered from disk, what\n * the live ones cost at list, and how many batches carried them. A table\n * whose `live` is 0 proved nothing new — every prompt in it had been answered\n * before — and is not a live pass; `AGLYN_AI_REPLAY=refresh` asks again.\n * `batchedRequests` are the ones a batch answered, at half price;\n * `batchFallbacks` are the ones a batch left unanswered past its wait and\n * that were asked on their own, at full price (AGL-3781). Both count in `live`.\n */\nexport function aiLiveRunLedger(env: Env = process.env): AiDevReplayStats & {\n batches: number\n batchedRequests: number\n batchFallbacks: number\n allReplayed: boolean\n} {\n const replay = aiDevReplayStats(env)\n const batch = aiLiveBatchStats()\n return {\n ...replay,\n batches: batch.batches,\n batchedRequests: batch.requests,\n batchFallbacks: batch.fallbacks,\n allReplayed: replay.live === 0 && replay.replayed > 0,\n }\n}\n"],"names":["createHash","existsSync","mkdirSync","readFileSync","renameSync","writeFileSync","dirname","join","aiProviderRatesForModel","estimateAiProviderCostUsd","aiCacheWriteHourSurchargeUsd","aiLiveBatchStats","AI_REPLAY_DIR_ENV","aiDevReplayMode","KEY_VERSION","aiCanonicalJson","value","JSON","stringify","sortKeys","Array","isArray","map","Object","fromEntries","keys","sort","filter","key","undefined","aiDevReplayKey","providerId","stream","request","update","v","provider","model","system","block","text","tools","messages","maxTokens","thinking","effort","digest","aiCachedPrefixKey","last","Boolean","cacheBreakpoint","lastIndexOf","slice","repositoryRoot","start","process","cwd","dir","parent","aiDevReplayDir","env","pathFor","readRecording","path","parsed","parse","writeRecording","recording","recursive","temp","pid","stats","mode","live","replayed","liveProviderUsd","savedProviderUsd","aiDevReplayStats","round","resetAiDevReplayStats","usd","Math","usageCost","usage","aiDevCountLive","discount","replayEvents","events","event","recordEvents","source","onDone","completed","push","type","some","aiDevReplayComplete","send","id","recorded","kind","result","recordedAt","Date","toISOString","aiDevReplayStream","done","find","warming","Map","warmedAt","aiDevWarmPrefixFirst","prefixKey","ttlMs","now","warmed","get","pending","catch","first","set","delete","resetAiDevWarmPrefixes","clear","aiLiveRunLedger","replay","batch","batches","batchedRequests","requests","batchFallbacks","fallbacks","allReplayed"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/ai-dev-replay.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 { createHash } from 'node:crypto'\nimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { aiProviderRatesForModel, estimateAiProviderCostUsd } from '../providers/catalog'\nimport { aiCacheWriteHourSurchargeUsd } from '../providers/cache-write-hour'\nimport type {\n AiProvider,\n AiProviderRequest,\n AiResult,\n AiStreamEvent,\n AiSystemBlock,\n AiTool,\n} from '../providers/contract'\nimport { aiLiveBatchStats } from './ai-live-batch'\nimport { AI_REPLAY_DIR_ENV, aiDevReplayMode, type AiDevEnv as Env, type AiDevReplayMode } from './ai-dev-env'\n\n/** The key format's version: bumped when what a key covers changes, so no old answer is read under a new meaning. */\nconst KEY_VERSION = 1\n\n/**\n * THE DEVELOPMENT REPLAY CACHE (AGL-3660).\n *\n * Almost every dollar the provider bills in a month is a development run: a\n * live eval, the layout screenshots fed by one, a local guided start. Most of\n * the requests in a second run of the same eval are byte-identical to the\n * first — the change under test was to a compiler, a check or one prompt, not\n * to all of them — and each identical request bought the same sample twice.\n *\n * So, outside production only, the runtime keys each request by a hash of\n * everything that decides the answer — the provider, the model, the system\n * blocks, the tools, the messages, the ceiling, thinking and effort — and keeps\n * the provider's answer under `.cache/ai-replay/` (gitignored). The next\n * identical request is answered from disk for nothing; a request whose prompt\n * changed by one byte has a new key and goes to the provider. A replayed answer\n * is a real answer the same model gave to the same bytes, so an eval that\n * passes on replays has still seen every CHANGED prompt answered live: the\n * replay never stands in for a prompt the provider has not answered.\n *\n * `AGLYN_AI_REPLAY`:\n * - `1` / `on`: replay what is recorded, record what is not.\n * - `refresh`: every request live, its answer recorded over the old one.\n * - `off` / `0`: nothing read, nothing written.\n * - unset: on under the live-eval launcher (`AI_EVAL_LIVE_LAUNCHER`), off\n * everywhere else, so a dev server or a spec that never asked for it\n * behaves exactly as before.\n *\n * It never runs in a deployed server. `NODE_ENV=production`, `VERCEL_ENV`\n * of `production` or `preview`, or a Cloud Run service (`K_SERVICE`) turn it\n * off whatever the variable says, and the first request says so in the log:\n * a recorded answer handed to a customer would be another customer's page.\n *\n * Every live eval's table carries `aiDevReplayStats()`, so a run that was\n * all replays reads as one: `live: 0` is not a live pass.\n */\n\n// ── The key ──────────────────────────────────────────────────────────────\n\n/** JSON with every object's keys in sorted order: the same value, the same bytes, whatever built it. */\nexport function aiCanonicalJson(value: unknown): string {\n return JSON.stringify(sortKeys(value))\n}\n\nfunction sortKeys(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(sortKeys)\n if (value && typeof value === 'object') {\n return Object.fromEntries(\n Object.keys(value as Record<string, unknown>)\n .sort()\n .filter((key) => (value as Record<string, unknown>)[key] !== undefined)\n .map((key) => [key, sortKeys((value as Record<string, unknown>)[key])]),\n )\n }\n return value\n}\n\n/** What a request's answer depends on. The key, the signal and the cache lifetime are not part of it. */\nexport interface AiReplayRequest {\n model: string\n system: readonly AiSystemBlock[]\n messages: AiProviderRequest['messages']\n tools?: readonly AiTool[]\n maxTokens: number\n thinking?: AiProviderRequest['thinking']\n effort?: AiProviderRequest['effort']\n toolChoice?: AiProviderRequest['toolChoice']\n}\n\n/** The request's replay key: a SHA-256 of everything that decides the answer. */\nexport function aiDevReplayKey(providerId: string, stream: boolean, request: AiReplayRequest): string {\n return createHash('sha256')\n .update(\n aiCanonicalJson({\n v: KEY_VERSION,\n provider: providerId,\n stream,\n model: request.model,\n // A breakpoint changes no answer, so the key reads the text alone.\n system: request.system.map((block) => block.text),\n tools: request.tools ?? [],\n messages: request.messages,\n maxTokens: request.maxTokens,\n thinking: request.thinking ?? null,\n effort: request.effort ?? null,\n // A named tool can change the answer (AGL-4076); keyed only when named, so no older key moves.\n ...(request.toolChoice ? { toolChoice: request.toolChoice } : {}),\n }),\n )\n .digest('hex')\n}\n\n/**\n * The prefix a provider caches: the model, the tools and every system block\n * through the last breakpoint. Two requests with the same prefix key read one\n * cache entry; `null` when the request marks no breakpoint.\n */\nexport function aiCachedPrefixKey(request: Pick<AiReplayRequest, 'model' | 'system' | 'tools'>): string | null {\n const last = request.system.map((block) => Boolean(block.cacheBreakpoint)).lastIndexOf(true)\n if (last < 0) return null\n return createHash('sha256')\n .update(\n aiCanonicalJson({\n model: request.model,\n tools: request.tools ?? [],\n system: request.system.slice(0, last + 1).map((block) => block.text),\n }),\n )\n .digest('hex')\n}\n\n// ── The store ────────────────────────────────────────────────────────────\n\n/** The repository root: the nearest directory up from here holding `nx.json`, else the working directory. */\nfunction repositoryRoot(start: string = process.cwd()): string {\n let dir = start\n for (;;) {\n if (existsSync(join(dir, 'nx.json'))) return dir\n const parent = dirname(dir)\n if (parent === dir) return start\n dir = parent\n }\n}\n\n/** Where recorded answers are kept. */\nexport function aiDevReplayDir(env: Env = process.env): string {\n return env[AI_REPLAY_DIR_ENV] || join(repositoryRoot(), '.cache', 'ai-replay')\n}\n\ntype Recording =\n | { v: number; key: string; model: string; recordedAt: string; kind: 'result'; result: AiResult }\n | { v: number; key: string; model: string; recordedAt: string; kind: 'stream'; events: AiStreamEvent[] }\n\nfunction pathFor(dir: string, key: string): string {\n return join(dir, key.slice(0, 2), `${key}.json`)\n}\n\nfunction readRecording(dir: string, key: string): Recording | null {\n const path = pathFor(dir, key)\n if (!existsSync(path)) return null\n try {\n const parsed = JSON.parse(readFileSync(path, 'utf8')) as Recording\n return parsed?.v === KEY_VERSION && parsed.key === key ? parsed : null\n } catch {\n return null\n }\n}\n\nfunction writeRecording(dir: string, recording: Recording): void {\n const path = pathFor(dir, recording.key)\n mkdirSync(dirname(path), { recursive: true })\n // Written aside and renamed, so a run killed mid-write leaves no half file to replay.\n const temp = `${path}.${process.pid}.tmp`\n writeFileSync(temp, JSON.stringify(recording))\n renameSync(temp, path)\n}\n\n// ── What a run did ───────────────────────────────────────────────────────\n\n/** How many of this process's requests were sent, and how many were answered from disk. */\nexport interface AiDevReplayStats {\n mode: AiDevReplayMode\n /** Requests sent to the provider. */\n live: number\n /** Requests answered from a recording. */\n replayed: number\n /** What the live requests cost at the provider's list rates. */\n liveProviderUsd: number\n /** What the replayed ones would have cost, had they been sent. */\n savedProviderUsd: number\n}\n\nconst stats: AiDevReplayStats = {\n mode: 'off',\n live: 0,\n replayed: 0,\n liveProviderUsd: 0,\n savedProviderUsd: 0,\n}\n\n/** This process's count so far: what a live eval prints beside its table. */\nexport function aiDevReplayStats(env: Env = process.env): AiDevReplayStats {\n return {\n ...stats,\n mode: aiDevReplayMode(env),\n liveProviderUsd: round(stats.liveProviderUsd),\n savedProviderUsd: round(stats.savedProviderUsd),\n }\n}\n\n/** Starts the count again; a spec that runs two rounds reports each. */\nexport function resetAiDevReplayStats(): void {\n stats.live = 0\n stats.replayed = 0\n stats.liveProviderUsd = 0\n stats.savedProviderUsd = 0\n}\n\nfunction round(usd: number): number {\n return Math.round(usd * 1_000_000) / 1_000_000\n}\n\nfunction usageCost(usage: AiResult['usage'], model: string): number {\n // A live run writes hour-long entries, billed above the rate table's write rate (AGL-3830).\n return round(estimateAiProviderCostUsd(usage, model) + aiCacheWriteHourSurchargeUsd(usage, aiProviderRatesForModel(model)))\n}\n\n/** Counts one request a live path sent, at what the provider billed for it; `discount` is a batch's half. */\nexport function aiDevCountLive(usage: AiResult['usage'], model: string, discount = 1): void {\n stats.live += 1\n stats.liveProviderUsd += usageCost(usage, model) * discount\n}\n\n// ── The seam ─────────────────────────────────────────────────────────────\n\nasync function* replayEvents(events: readonly AiStreamEvent[]): AsyncGenerator<AiStreamEvent> {\n for (const event of events) yield event\n}\n\n/** A live stream passed through as it arrives, and recorded once it is done. */\nasync function* recordEvents(\n source: AsyncIterable<AiStreamEvent>,\n onDone: (events: AiStreamEvent[]) => void,\n): AsyncGenerator<AiStreamEvent> {\n const events: AiStreamEvent[] = []\n let completed = false\n for await (const event of source) {\n events.push(event)\n if (event.type === 'done') completed = true\n yield event\n }\n // A stream cut short, or one that carried a provider error, is not an\n // answer worth keeping: the next run asks again.\n if (completed && !events.some((event) => event.type === 'error')) onDone(events)\n}\n\n/**\n * One request through the replay cache: answered from disk when recorded and\n * the mode replays, otherwise sent through `send` and recorded. `send` is the\n * live path — the provider, or the batch queue — and counts its own spend.\n */\nexport async function aiDevReplayComplete(\n provider: Pick<AiProvider, 'id'>,\n request: AiReplayRequest,\n mode: Exclude<AiDevReplayMode, 'off'>,\n send: () => Promise<AiResult>,\n env: Env = process.env,\n): Promise<AiResult> {\n const dir = aiDevReplayDir(env)\n const key = aiDevReplayKey(provider.id, false, request)\n const recorded = mode === 'replay' ? readRecording(dir, key) : null\n if (recorded?.kind === 'result') {\n stats.replayed += 1\n stats.savedProviderUsd += usageCost(recorded.result.usage, request.model)\n return recorded.result\n }\n const result = await send()\n writeRecording(dir, {\n v: KEY_VERSION,\n key,\n model: request.model,\n recordedAt: new Date().toISOString(),\n kind: 'result',\n result,\n })\n return result\n}\n\n/** The streaming half of {@link aiDevReplayComplete}. */\nexport async function aiDevReplayStream(\n provider: Pick<AiProvider, 'id'>,\n request: AiReplayRequest,\n mode: Exclude<AiDevReplayMode, 'off'>,\n send: () => Promise<AsyncIterable<AiStreamEvent>>,\n env: Env = process.env,\n): Promise<AsyncIterable<AiStreamEvent>> {\n const dir = aiDevReplayDir(env)\n const key = aiDevReplayKey(provider.id, true, request)\n const recorded = mode === 'replay' ? readRecording(dir, key) : null\n if (recorded?.kind === 'stream') {\n stats.replayed += 1\n const done = recorded.events.find((event) => event.type === 'done')\n if (done?.type === 'done') stats.savedProviderUsd += usageCost(done.usage, request.model)\n return replayEvents(recorded.events)\n }\n const live = await send()\n return recordEvents(live, (events) => {\n const done = events.find((event) => event.type === 'done')\n if (done?.type === 'done') aiDevCountLive(done.usage, request.model)\n writeRecording(dir, {\n v: KEY_VERSION,\n key,\n model: request.model,\n recordedAt: new Date().toISOString(),\n kind: 'stream',\n events,\n })\n })\n}\n\n// ── Warming a shared prefix before a fan-out ─────────────────────────────\n\n/**\n * A cache entry is readable only once the request that writes it has started\n * answering, so N requests sent at once over one prefix all pay to write it\n * and none reads it — which is exactly what a live eval's `Promise.all` over\n * its briefs did. In a development run the first request over a prefix goes\n * alone; the others wait for it and then read what it wrote. A prefix\n * answered within the cache's lifetime is not held again.\n *\n * Development only, like everything here: production's beat runs one step at\n * a time, and holding a customer's request behind another's would trade\n * their seconds for our cents.\n */\nconst warming = new Map<string, Promise<unknown>>()\nconst warmedAt = new Map<string, number>()\n\nexport async function aiDevWarmPrefixFirst<T>(\n prefixKey: string | null,\n ttlMs: number,\n send: () => Promise<T>,\n now: () => number = Date.now,\n): Promise<T> {\n if (!prefixKey) return send()\n const warmed = warmedAt.get(prefixKey)\n if (warmed !== undefined && now() - warmed < ttlMs) return send()\n const pending = warming.get(prefixKey)\n if (pending) {\n await pending.catch(() => undefined)\n return send()\n }\n const first = send()\n warming.set(prefixKey, first)\n try {\n const result = await first\n warmedAt.set(prefixKey, now())\n return result\n } finally {\n warming.delete(prefixKey)\n }\n}\n\n/** Forgets every warmed prefix; for specs. */\nexport function resetAiDevWarmPrefixes(): void {\n warming.clear()\n warmedAt.clear()\n}\n\n// ── What a live eval prints ──────────────────────────────────────────────\n\n/**\n * The line every live eval's table carries (AGL-3660): how many of its\n * requests went to the provider and how many were answered from disk, what\n * the live ones cost at list, and how many batches carried them. A table\n * whose `live` is 0 proved nothing new — every prompt in it had been answered\n * before — and is not a live pass; `AGLYN_AI_REPLAY=refresh` asks again.\n * `batchedRequests` are the ones a batch answered, at half price;\n * `batchFallbacks` are the ones a batch left unanswered past its wait and\n * that were asked on their own, at full price (AGL-3781). Both count in `live`.\n */\nexport function aiLiveRunLedger(env: Env = process.env): AiDevReplayStats & {\n batches: number\n batchedRequests: number\n batchFallbacks: number\n allReplayed: boolean\n} {\n const replay = aiDevReplayStats(env)\n const batch = aiLiveBatchStats()\n return {\n ...replay,\n batches: batch.batches,\n batchedRequests: batch.requests,\n batchFallbacks: batch.fallbacks,\n allReplayed: replay.live === 0 && replay.replayed > 0,\n }\n}\n"],"names":["createHash","existsSync","mkdirSync","readFileSync","renameSync","writeFileSync","dirname","join","aiProviderRatesForModel","estimateAiProviderCostUsd","aiCacheWriteHourSurchargeUsd","aiLiveBatchStats","AI_REPLAY_DIR_ENV","aiDevReplayMode","KEY_VERSION","aiCanonicalJson","value","JSON","stringify","sortKeys","Array","isArray","map","Object","fromEntries","keys","sort","filter","key","undefined","aiDevReplayKey","providerId","stream","request","update","v","provider","model","system","block","text","tools","messages","maxTokens","thinking","effort","toolChoice","digest","aiCachedPrefixKey","last","Boolean","cacheBreakpoint","lastIndexOf","slice","repositoryRoot","start","process","cwd","dir","parent","aiDevReplayDir","env","pathFor","readRecording","path","parsed","parse","writeRecording","recording","recursive","temp","pid","stats","mode","live","replayed","liveProviderUsd","savedProviderUsd","aiDevReplayStats","round","resetAiDevReplayStats","usd","Math","usageCost","usage","aiDevCountLive","discount","replayEvents","events","event","recordEvents","source","onDone","completed","push","type","some","aiDevReplayComplete","send","id","recorded","kind","result","recordedAt","Date","toISOString","aiDevReplayStream","done","find","warming","Map","warmedAt","aiDevWarmPrefixFirst","prefixKey","ttlMs","now","warmed","get","pending","catch","first","set","delete","resetAiDevWarmPrefixes","clear","aiLiveRunLedger","replay","batch","batches","batchedRequests","requests","batchFallbacks","fallbacks","allReplayed"],"mappings":"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"}
@@ -573,6 +573,39 @@ export declare function detectUnrelatedScreenLinks(tree: AiDoctrineTree, outputK
573
573
  * check was given it, each by an id of `tree`.
574
574
  */
575
575
  export declare function detectLinksWithoutDestination(tree: AiDoctrineTree, outputKind: AiOutputKind, context?: Pick<AiDoctrineTreeContext, 'pageSections' | 'scrollTargetIds'>, written?: (id: string) => unknown, stored?: (id: string) => unknown): AiDoctrineViolation[];
576
+ /**
577
+ * What a link's destination is read against when it is settled (AGL-4076):
578
+ * what `detectLinksWithoutDestination` reads it against, and the page being
579
+ * checked by every id it goes by, which a link is never sent to.
580
+ */
581
+ export type AiLinkSettleContext = Pick<AiDoctrineTreeContext, 'pageSections' | 'scrollTargetIds' | 'writtenNode' | 'formScreenIds' | 'screenIds'> & {
582
+ ownScreenIds?: readonly string[];
583
+ };
584
+ /**
585
+ * RULE 10'S LINK THAT GOES NOWHERE, SETTLED IN CODE (AGL-4076).
586
+ *
587
+ * A Button or a Page Link with no destination (`link-without-destination`),
588
+ * or one sent to an anchor no section of its page has (`link-fragment`),
589
+ * stopped 5 of the 12 findings behind the 10/10 to 10/28 doctrine stops: the
590
+ * re-ask is spent, the job stops, and Try again asks the same. Each has one
591
+ * answer the rule itself gives, so it is applied before the check reads the
592
+ * tree, as `aiSettleUnrelatedScreenLinks` settles a link sent home:
593
+ *
594
+ * - a button drawn inside a Form is taken out, since the Form draws its own
595
+ * send button; its words become the Form's `submitLabel` when the Form has
596
+ * none of its own;
597
+ * - a link whose words ask a visitor to get in touch is sent to the page that
598
+ * places the site's form, when the site has one that is not this page;
599
+ * - any other is left out: no page can be chosen for it from its words alone.
600
+ *
601
+ * A frame the link was the last thing in is taken out with it rather than
602
+ * left empty, up to the page's own sections, which always stay. A link to a
603
+ * section of its page, to a page the site has, or to any address is never
604
+ * touched, and nor is one whose `scrollTo` names no section, which the check
605
+ * re-asks as before. The root is never taken out. A tree with nothing to
606
+ * settle comes back as the same object; a node that is changed is copied.
607
+ */
608
+ export declare function aiSettleLinksWithoutDestination<T extends AiDoctrineTree>(tree: T, outputKind: AiOutputKind, context?: AiLinkSettleContext): T;
576
609
  /**
577
610
  * Rule 13. The AI writes drafts. An answer that asks for anything to be
578
611
  * published, or to go live, is refused whatever else it got right; nothing
@@ -836,19 +869,30 @@ export declare function aiSettleCutHeadings(input: unknown): unknown;
836
869
  * - `page-text-on-band` (5): words on a band in a family's main or dark shade
837
870
  * whose `sx` color is a page text color (`text.secondary`) are given the
838
871
  * family's contrast text, and a List Item Text's secondary words there take
839
- * the row's own color (`secondaryColor: inherit`).
872
+ * the row's own color (`secondaryColor: inherit`);
873
+ * - `duplicated-inline-style` (16), where the style can be carried once
874
+ * without changing what is drawn (AGL-4076): when the elements repeating it
875
+ * are every element of one frame, its text alignment and its text color
876
+ * move to that frame, which they inherit from, and a Typography's text
877
+ * alignment becomes its `align`. Only where that leaves fewer repeats; a
878
+ * repeated padding, size or border has no single place to go and is still
879
+ * the re-ask's;
880
+ * - `link-without-destination` and `link-fragment` (10), given `links`, the
881
+ * context the check reads links in (`aiSettleLinksWithoutDestination`).
840
882
  *
841
883
  * A tree with nothing to settle comes back as the same object, so settling is
842
884
  * idempotent; a node that is changed is copied, never edited in place.
843
885
  */
844
- export declare function aiSettleMechanicalRules<T extends AiDoctrineTree>(tree: T, outputKind: AiOutputKind): T;
886
+ export declare function aiSettleMechanicalRules<T extends AiDoctrineTree>(tree: T, outputKind: AiOutputKind, links?: AiLinkSettleContext): T;
845
887
  /**
846
888
  * `aiSettleMechanicalRules` on a tree as the model wrote it, before the
847
889
  * palette validator reads it (AGL-3660), as `aiSettleWrittenGridItems` settles
848
890
  * its items. Anything that is no flat node map comes back as it was, and a
849
891
  * node whose shape is not a palette node's is read as nothing and left as written.
892
+ * Given `links`, a link that goes nowhere is settled too (AGL-4076), and a
893
+ * node that takes out is gone from the answer.
850
894
  */
851
- export declare function aiSettleWrittenMechanicalRules(input: unknown, outputKind: AiOutputKind): unknown;
895
+ export declare function aiSettleWrittenMechanicalRules(input: unknown, outputKind: AiOutputKind, links?: AiLinkSettleContext): unknown;
852
896
  export interface AiDoctrineTreeReport {
853
897
  ok: boolean;
854
898
  /** The palette validator's result: the tree to store, its repairs and its id map. */
@@ -30,7 +30,7 @@ import { aiFreeSiteSectionsWithin, aiSiteHomeMinSections, aiSitePlanIsHome, aiSi
30
30
  import { aiLayoutIsShopPage, aiLayoutShopGridSection } from "../layout-language/ai-layout-listings.js";
31
31
  import { aiStorefrontSays } from "../layout-language/ai-layout-storefront.js";
32
32
  import { AI_INSTANCE_REF_PROP, validateAiNodeTree } from "./ai-node-tree.js";
33
- import { AI_PAGE_SCROLL_TO_PROP, aiPageLinkTarget, aiStoredScrollTargets } from "./ai-page-links.js";
33
+ import { AI_PAGE_SCROLL_TO_PROP, aiPageLinkTarget, aiStoredScrollTargets, isAiFragmentHref } from "./ai-page-links.js";
34
34
  import { AI_EMAIL_CLIP_BYTES, AI_OUTPUT_BUDGETS, AI_OUTPUT_SURFACE, AI_TEXT_LIMITS, isHeadlineVariant } from "./ai-palette.js";
35
35
  import { AI_PALETTE, AI_SX_TOKENS } from "./ai-palette.generated.js";
36
36
  import { aiIsOnlyPlaceholders, aiPlaceholderTokens } from "./ai-placeholder-tokens.js";
@@ -2151,6 +2151,162 @@ const GRID = 'muiGrid';
2151
2151
  }
2152
2152
  return violations;
2153
2153
  }
2154
+ /** The longest send label a Form takes, as its palette entry bounds `submitLabel`. */ const FORM_SUBMIT_LABEL_MAX = 40;
2155
+ /** The frames a link may be the last thing in, taken out with it rather than left empty. */ const LINK_FRAMES = new Set([
2156
+ 'muiBox',
2157
+ 'muiStack',
2158
+ 'muiContainer',
2159
+ 'muiGrid',
2160
+ 'section',
2161
+ 'div',
2162
+ 'muiCardActions'
2163
+ ]);
2164
+ /**
2165
+ * The links `detectLinksWithoutDestination` would refuse as going nowhere
2166
+ * (`link-without-destination`) or to an anchor no section has
2167
+ * (`link-fragment`), read conservatively: a link whose `screenId` names a page
2168
+ * the site has, whose `href` is anything but a fragment, that carries a Scroll
2169
+ * to element interaction to a section of its page, or whose `scrollTo` the
2170
+ * check reads on its own terms (`scroll-target-unknown`), is not one. A tree
2171
+ * as written is read as the check will read it once the palette validator has
2172
+ * dropped the fragment.
2173
+ */ function linksWithoutDestination(tree, outputKind, context) {
2174
+ var _context_pageSections;
2175
+ if (outputKind === 'email' || outputKind === 'form') return [];
2176
+ const sections = (_context_pageSections = context.pageSections) != null ? _context_pageSections : [];
2177
+ const targets = context.scrollTargetIds ? new Set(context.scrollTargetIds) : null;
2178
+ const known = context.screenIds ? new Set(context.screenIds) : null;
2179
+ const written = (id)=>context.writtenNode ? context.writtenNode(id) : tree.nodes[id];
2180
+ const found = [];
2181
+ for (const visit of walkTree(tree)){
2182
+ var _reverse_find;
2183
+ var _node_props, _node_props1, _node_props2;
2184
+ const { node } = visit;
2185
+ if (!LINK_COMPONENTS.has(node.componentId)) continue;
2186
+ const screenId = (_node_props = node.props) == null ? void 0 : _node_props['screenId'];
2187
+ if (isFilled(screenId) && (!known || known.has(String(screenId).trim()))) continue;
2188
+ const href = (_node_props1 = node.props) == null ? void 0 : _node_props1['href'];
2189
+ if (isFilled(href) && !isAiFragmentHref(href)) continue;
2190
+ const scrolls = aiStoredScrollTargets(node);
2191
+ if (scrolls.length && (!targets || scrolls.some((target)=>targets.has(target)))) continue;
2192
+ const target = aiPageLinkTarget(written(visit.id), sections);
2193
+ if ((target == null ? void 0 : target.kind) === 'section' || (target == null ? void 0 : target.kind) === 'unknown' && target.via === 'scrollTo') continue;
2194
+ const label = typeof ((_node_props2 = node.props) == null ? void 0 : _node_props2['children']) === 'string' ? node.props['children'].trim() : '';
2195
+ const form = (_reverse_find = [
2196
+ ...visit.ancestors
2197
+ ].reverse().find((id)=>{
2198
+ var _tree_nodes_id;
2199
+ return ((_tree_nodes_id = tree.nodes[id]) == null ? void 0 : _tree_nodes_id.componentId) === 'form';
2200
+ })) != null ? _reverse_find : null;
2201
+ found.push({
2202
+ id: visit.id,
2203
+ label,
2204
+ form
2205
+ });
2206
+ }
2207
+ return found;
2208
+ }
2209
+ /**
2210
+ * RULE 10'S LINK THAT GOES NOWHERE, SETTLED IN CODE (AGL-4076).
2211
+ *
2212
+ * A Button or a Page Link with no destination (`link-without-destination`),
2213
+ * or one sent to an anchor no section of its page has (`link-fragment`),
2214
+ * stopped 5 of the 12 findings behind the 10/10 to 10/28 doctrine stops: the
2215
+ * re-ask is spent, the job stops, and Try again asks the same. Each has one
2216
+ * answer the rule itself gives, so it is applied before the check reads the
2217
+ * tree, as `aiSettleUnrelatedScreenLinks` settles a link sent home:
2218
+ *
2219
+ * - a button drawn inside a Form is taken out, since the Form draws its own
2220
+ * send button; its words become the Form's `submitLabel` when the Form has
2221
+ * none of its own;
2222
+ * - a link whose words ask a visitor to get in touch is sent to the page that
2223
+ * places the site's form, when the site has one that is not this page;
2224
+ * - any other is left out: no page can be chosen for it from its words alone.
2225
+ *
2226
+ * A frame the link was the last thing in is taken out with it rather than
2227
+ * left empty, up to the page's own sections, which always stay. A link to a
2228
+ * section of its page, to a page the site has, or to any address is never
2229
+ * touched, and nor is one whose `scrollTo` names no section, which the check
2230
+ * re-asks as before. The root is never taken out. A tree with nothing to
2231
+ * settle comes back as the same object; a node that is changed is copied.
2232
+ */ export function aiSettleLinksWithoutDestination(tree, outputKind, context = {}) {
2233
+ var _context_ownScreenIds, _find, _context_formScreenIds, _tree_nodes_tree_rootId_nodes, _context_scrollTargetIds;
2234
+ if (!tree.nodes[tree.rootId]) return tree;
2235
+ const found = linksWithoutDestination(tree, outputKind, context);
2236
+ if (!found.length) return tree;
2237
+ const own = new Set((_context_ownScreenIds = context.ownScreenIds) != null ? _context_ownScreenIds : []);
2238
+ const formScreen = (_find = ((_context_formScreenIds = context.formScreenIds) != null ? _context_formScreenIds : []).find((id)=>!own.has(id))) != null ? _find : null;
2239
+ // A page's sections, and anything a link scrolls to, always stay.
2240
+ const kept = new Set([
2241
+ tree.rootId,
2242
+ ...(_tree_nodes_tree_rootId_nodes = tree.nodes[tree.rootId].nodes) != null ? _tree_nodes_tree_rootId_nodes : [],
2243
+ ...(_context_scrollTargetIds = context.scrollTargetIds) != null ? _context_scrollTargetIds : []
2244
+ ]);
2245
+ const nodes = _extends({}, tree.nodes);
2246
+ const parents = new Map();
2247
+ for (const visit of walkTree(tree)){
2248
+ const parent = visit.ancestors[visit.ancestors.length - 1];
2249
+ if (parent !== undefined) parents.set(visit.id, parent);
2250
+ }
2251
+ let changed = false;
2252
+ const takeOut = (id)=>{
2253
+ let next = id;
2254
+ while(next !== undefined && nodes[next] && !kept.has(next)){
2255
+ var _nodes_parent_nodes;
2256
+ const parent = parents.get(next);
2257
+ if (parent === undefined || !nodes[parent]) return;
2258
+ const children = ((_nodes_parent_nodes = nodes[parent].nodes) != null ? _nodes_parent_nodes : []).filter((child)=>child !== next);
2259
+ nodes[parent] = _extends({}, nodes[parent], {
2260
+ nodes: children
2261
+ });
2262
+ changed = true;
2263
+ const stack = [
2264
+ next
2265
+ ];
2266
+ while(stack.length){
2267
+ var _ref;
2268
+ var _nodes_gone;
2269
+ const gone = stack.pop();
2270
+ stack.push(...(_ref = (_nodes_gone = nodes[gone]) == null ? void 0 : _nodes_gone.nodes) != null ? _ref : []);
2271
+ delete nodes[gone];
2272
+ }
2273
+ next = !children.some((child)=>nodes[child]) && LINK_FRAMES.has(nodes[parent].componentId) ? parent : undefined;
2274
+ }
2275
+ };
2276
+ for (const { id, label, form } of found){
2277
+ if (!nodes[id]) continue;
2278
+ if (form && nodes[form]) {
2279
+ var _nodes_form_props;
2280
+ if (label && label.length <= FORM_SUBMIT_LABEL_MAX && !isFilled((_nodes_form_props = nodes[form].props) == null ? void 0 : _nodes_form_props['submitLabel'])) {
2281
+ var _nodes_form_props1;
2282
+ nodes[form] = _extends({}, nodes[form], {
2283
+ props: _extends({}, (_nodes_form_props1 = nodes[form].props) != null ? _nodes_form_props1 : {}, {
2284
+ submitLabel: label
2285
+ })
2286
+ });
2287
+ changed = true;
2288
+ }
2289
+ takeOut(id);
2290
+ continue;
2291
+ }
2292
+ if (formScreen && label && aiAsksToGetInTouch(label)) {
2293
+ var _nodes_id_props;
2294
+ const props = _extends({}, (_nodes_id_props = nodes[id].props) != null ? _nodes_id_props : {}, {
2295
+ screenId: formScreen
2296
+ });
2297
+ delete props['href'];
2298
+ nodes[id] = _extends({}, nodes[id], {
2299
+ props
2300
+ });
2301
+ changed = true;
2302
+ continue;
2303
+ }
2304
+ takeOut(id);
2305
+ }
2306
+ return changed ? _extends({}, tree, {
2307
+ nodes
2308
+ }) : tree;
2309
+ }
2154
2310
  /** Answer fields that say "publish", wherever the answer put them. */ const PUBLISH_KEYS = /^(?:publish|published|publishNow|publishAt|goLive|isLive|live|makeLive)$/i;
2155
2311
  /**
2156
2312
  * Rule 13. The AI writes drafts. An answer that asks for anything to be
@@ -2613,6 +2769,29 @@ const EMBED_COMPONENTS = new Set([
2613
2769
  ]);
2614
2770
  const DUPLICATE_SX_MIN_KEYS = 2;
2615
2771
  const DUPLICATE_SX_MIN_NODES = 4;
2772
+ /**
2773
+ * Rule 16's repeated style: the elements whose `sx` is one style of at least
2774
+ * `DUPLICATE_SX_MIN_KEYS` keys, grouped by that style, each group of at least
2775
+ * `DUPLICATE_SX_MIN_NODES` elements, in document order.
2776
+ */ function duplicatedSxGroups(tree) {
2777
+ const seen = new Map();
2778
+ for (const { id, node } of walkTree(tree)){
2779
+ var _node_sx, _seen_get;
2780
+ const sx = (_node_sx = node.sx) != null ? _node_sx : {};
2781
+ if (Object.keys(sx).length < DUPLICATE_SX_MIN_KEYS) continue;
2782
+ const key = JSON.stringify(Object.keys(sx).sort().map((name)=>[
2783
+ name,
2784
+ sx[name]
2785
+ ]));
2786
+ seen.set(key, [
2787
+ ...(_seen_get = seen.get(key)) != null ? _seen_get : [],
2788
+ id
2789
+ ]);
2790
+ }
2791
+ return [
2792
+ ...seen.values()
2793
+ ].filter((ids)=>ids.length >= DUPLICATE_SX_MIN_NODES);
2794
+ }
2616
2795
  /**
2617
2796
  * Whether a third-party player is one the confirmed plan lists (AGL-3433). A
2618
2797
  * Video embed is admitted where the plan lists a player, playing the video
@@ -2648,11 +2827,10 @@ const DUPLICATE_SX_MIN_NODES = 4;
2648
2827
  const video = [];
2649
2828
  const embeds = [];
2650
2829
  const embedLinks = [];
2651
- const sxSeen = new Map();
2652
2830
  let images = 0;
2653
2831
  for (const { id, node } of visits){
2654
- var _node_nodes, _node_props, _node_sx, _node_sx1;
2655
- var _node_props1, _node_sx2, _node_props2, _node_props3, _node_props4, _node_props5;
2832
+ var _node_nodes, _node_props, _node_sx;
2833
+ var _node_props1, _node_sx1, _node_props2, _node_props3, _node_props4, _node_props5;
2656
2834
  const entry = AI_PALETTE[node.componentId];
2657
2835
  const children = ((_node_nodes = node.nodes) != null ? _node_nodes : []).filter((child)=>tree.nodes[child]);
2658
2836
  const ownProps = Object.entries((_node_props = node.props) != null ? _node_props : {}).filter(([name, value])=>!(name === 'component' && value === 'div'));
@@ -2663,7 +2841,7 @@ const DUPLICATE_SX_MIN_NODES = 4;
2663
2841
  if ((entry == null ? void 0 : entry.acceptsChildren) && !children.length && !boundForm && !OWNS_ITS_EMPTY_STATE.has(node.componentId)) {
2664
2842
  empty.push(id);
2665
2843
  }
2666
- if (outputKind !== 'email' && ((_node_sx2 = node.sx) == null ? void 0 : _node_sx2['fontFamily']) !== undefined) fonts.push(id);
2844
+ if (outputKind !== 'email' && ((_node_sx1 = node.sx) == null ? void 0 : _node_sx1['fontFamily']) !== undefined) fonts.push(id);
2667
2845
  if (node.componentId === 'image') {
2668
2846
  var _node_props6;
2669
2847
  images += 1;
@@ -2678,22 +2856,8 @@ const DUPLICATE_SX_MIN_NODES = 4;
2678
2856
  if (verdict === 'unplanned') embeds.push(id);
2679
2857
  if (verdict === 'unplanned-link') embedLinks.push(id);
2680
2858
  }
2681
- const sx = (_node_sx1 = node.sx) != null ? _node_sx1 : {};
2682
- if (Object.keys(sx).length >= DUPLICATE_SX_MIN_KEYS) {
2683
- var _sxSeen_get;
2684
- const key = JSON.stringify(Object.keys(sx).sort().map((name)=>[
2685
- name,
2686
- sx[name]
2687
- ]));
2688
- sxSeen.set(key, [
2689
- ...(_sxSeen_get = sxSeen.get(key)) != null ? _sxSeen_get : [],
2690
- id
2691
- ]);
2692
- }
2693
2859
  }
2694
- const duplicatedSx = [
2695
- ...sxSeen.values()
2696
- ].filter((ids)=>ids.length >= DUPLICATE_SX_MIN_NODES).flat();
2860
+ const duplicatedSx = duplicatedSxGroups(tree).flat();
2697
2861
  const violations = [];
2698
2862
  const add = (code, ids, message)=>{
2699
2863
  if (ids.length) violations.push({
@@ -3416,6 +3580,52 @@ const BUDGET_METRICS = [
3416
3580
  }) : input;
3417
3581
  }
3418
3582
  // ── Mechanical rules, settled in code (AGL-3660) ─────────────────────────
3583
+ /** The inherited styles a repeated style may be carried once in, by the frame that holds every element repeating it (AGL-4076). */ const HOISTED_SX_KEYS = [
3584
+ 'textAlign',
3585
+ 'color'
3586
+ ];
3587
+ /**
3588
+ * The elements that take each of those styles from their parent when they set
3589
+ * none themselves, which are also the frames that may carry one for the
3590
+ * elements they hold: none of them draws anything of its own in it.
3591
+ */ const INHERITS_SX = {
3592
+ // A Typography aligns as `inherit` unless its `align` says otherwise; a Card and a Paper set no alignment.
3593
+ textAlign: new Set([
3594
+ 'muiTypography',
3595
+ 'muiBox',
3596
+ 'muiStack',
3597
+ 'muiGrid',
3598
+ 'muiContainer',
3599
+ 'section',
3600
+ 'div',
3601
+ 'muiCardContent',
3602
+ 'muiCard',
3603
+ 'muiPaper'
3604
+ ]),
3605
+ // A Card and a Paper paint their own text color, and a Button, a link and a list row theirs.
3606
+ color: new Set([
3607
+ 'muiTypography',
3608
+ 'muiBox',
3609
+ 'muiStack',
3610
+ 'muiGrid',
3611
+ 'muiContainer',
3612
+ 'section',
3613
+ 'div',
3614
+ 'muiCardContent'
3615
+ ])
3616
+ };
3617
+ /** A Typography's `align` values, which draw exactly what the same `sx` text alignment does. */ const TYPOGRAPHY_ALIGNS = new Set([
3618
+ 'inherit',
3619
+ 'left',
3620
+ 'center',
3621
+ 'right',
3622
+ 'justify'
3623
+ ]);
3624
+ /** An `sx` without one key. */ function sxWithout(sx, key) {
3625
+ const rest = _extends({}, sx != null ? sx : {});
3626
+ delete rest[key];
3627
+ return rest;
3628
+ }
3419
3629
  /**
3420
3630
  * THE RULES A CHECK WOULD REFUSE FOR AN ATTRIBUTE, SETTLED IN CODE (AGL-3660).
3421
3631
  *
@@ -3451,11 +3661,20 @@ const BUDGET_METRICS = [
3451
3661
  * - `page-text-on-band` (5): words on a band in a family's main or dark shade
3452
3662
  * whose `sx` color is a page text color (`text.secondary`) are given the
3453
3663
  * family's contrast text, and a List Item Text's secondary words there take
3454
- * the row's own color (`secondaryColor: inherit`).
3664
+ * the row's own color (`secondaryColor: inherit`);
3665
+ * - `duplicated-inline-style` (16), where the style can be carried once
3666
+ * without changing what is drawn (AGL-4076): when the elements repeating it
3667
+ * are every element of one frame, its text alignment and its text color
3668
+ * move to that frame, which they inherit from, and a Typography's text
3669
+ * alignment becomes its `align`. Only where that leaves fewer repeats; a
3670
+ * repeated padding, size or border has no single place to go and is still
3671
+ * the re-ask's;
3672
+ * - `link-without-destination` and `link-fragment` (10), given `links`, the
3673
+ * context the check reads links in (`aiSettleLinksWithoutDestination`).
3455
3674
  *
3456
3675
  * A tree with nothing to settle comes back as the same object, so settling is
3457
3676
  * idempotent; a node that is changed is copied, never edited in place.
3458
- */ export function aiSettleMechanicalRules(tree, outputKind) {
3677
+ */ export function aiSettleMechanicalRules(tree, outputKind, links) {
3459
3678
  if (!tree.nodes[tree.rootId]) return tree;
3460
3679
  const nodes = _extends({}, tree.nodes);
3461
3680
  const copied = new Set();
@@ -3590,25 +3809,122 @@ const BUDGET_METRICS = [
3590
3809
  });
3591
3810
  }
3592
3811
  }
3593
- return copied.size ? _extends({}, tree, {
3812
+ // Rule 16: a style a group of elements repeats, carried once where it draws the same (AGL-4076).
3813
+ if (framed) {
3814
+ const parentOf = new Map();
3815
+ for (const visit of walkTree({
3816
+ rootId: tree.rootId,
3817
+ nodes
3818
+ })){
3819
+ const parent = visit.ancestors[visit.ancestors.length - 1];
3820
+ if (parent !== undefined) parentOf.set(visit.id, parent);
3821
+ }
3822
+ const heavy = ()=>detectHeavyDocument({
3823
+ rootId: tree.rootId,
3824
+ nodes
3825
+ }, outputKind).reduce((sum, violation)=>{
3826
+ var _ref;
3827
+ var _violation_nodeIds;
3828
+ return sum + ((_ref = (_violation_nodeIds = violation.nodeIds) == null ? void 0 : _violation_nodeIds.length) != null ? _ref : 0);
3829
+ }, 0);
3830
+ for (const group of duplicatedSxGroups({
3831
+ rootId: tree.rootId,
3832
+ nodes
3833
+ })){
3834
+ var _ref, _ref1;
3835
+ var _nodes_group_, _nodes_parent;
3836
+ const style = (_ref = (_nodes_group_ = nodes[group[0]]) == null ? void 0 : _nodes_group_.sx) != null ? _ref : {};
3837
+ const same = JSON.stringify(Object.keys(style).sort().map((name)=>[
3838
+ name,
3839
+ style[name]
3840
+ ]));
3841
+ const alike = group.every((id)=>{
3842
+ var _ref;
3843
+ var _nodes_id;
3844
+ const sx = (_ref = (_nodes_id = nodes[id]) == null ? void 0 : _nodes_id.sx) != null ? _ref : {};
3845
+ return JSON.stringify(Object.keys(sx).sort().map((name)=>[
3846
+ name,
3847
+ sx[name]
3848
+ ])) === same;
3849
+ });
3850
+ if (!alike) continue;
3851
+ const before = heavy();
3852
+ const replaced = new Map();
3853
+ const wasCopied = new Set(copied);
3854
+ const put = (id, node)=>{
3855
+ if (!replaced.has(id)) replaced.set(id, nodes[id]);
3856
+ nodes[id] = node;
3857
+ copied.add(id);
3858
+ };
3859
+ const parent = parentOf.get(group[0]);
3860
+ const held = parent !== undefined ? ((_ref1 = (_nodes_parent = nodes[parent]) == null ? void 0 : _nodes_parent.nodes) != null ? _ref1 : []).filter((child)=>nodes[child]) : [];
3861
+ const whole = parent !== undefined && parent !== tree.rootId && held.length === group.length && group.every((id)=>parentOf.get(id) === parent);
3862
+ for (const key of whole ? HOISTED_SX_KEYS : []){
3863
+ var _holder_sx, _holder_sx1;
3864
+ var _holder_sx2;
3865
+ const holder = nodes[parent];
3866
+ if (style[key] === undefined || !INHERITS_SX[key].has(holder.componentId)) continue;
3867
+ if (!group.every((id)=>INHERITS_SX[key].has(nodes[id].componentId))) continue;
3868
+ const own = (_holder_sx2 = holder.sx) == null ? void 0 : _holder_sx2[key];
3869
+ if (own !== undefined && JSON.stringify(own) !== JSON.stringify(style[key])) continue;
3870
+ // A border drawn in the frame's own text color would take the new one.
3871
+ if (key === 'color' && Object.keys((_holder_sx = holder.sx) != null ? _holder_sx : {}).some((name)=>/^(?:border|outline)/.test(name))) continue;
3872
+ put(parent, _extends({}, holder, {
3873
+ sx: _extends({}, (_holder_sx1 = holder.sx) != null ? _holder_sx1 : {}, {
3874
+ [key]: style[key]
3875
+ })
3876
+ }));
3877
+ for (const id of group)put(id, _extends({}, nodes[id], {
3878
+ sx: sxWithout(nodes[id].sx, key)
3879
+ }));
3880
+ }
3881
+ for (const id of group){
3882
+ var _node_props7;
3883
+ var _node_sx4, _node_props8;
3884
+ const node = nodes[id];
3885
+ const align = (_node_sx4 = node.sx) == null ? void 0 : _node_sx4['textAlign'];
3886
+ if (node.componentId !== 'muiTypography' || typeof align !== 'string' || !TYPOGRAPHY_ALIGNS.has(align)) continue;
3887
+ if (((_node_props8 = node.props) == null ? void 0 : _node_props8['align']) !== undefined && node.props['align'] !== 'inherit') continue;
3888
+ put(id, _extends({}, node, {
3889
+ props: _extends({}, (_node_props7 = node.props) != null ? _node_props7 : {}, {
3890
+ align
3891
+ }),
3892
+ sx: sxWithout(node.sx, 'textAlign')
3893
+ }));
3894
+ }
3895
+ // Kept only where it leaves the page lighter by the rule's own measure.
3896
+ if (replaced.size && heavy() >= before) {
3897
+ for (const [id, node] of replaced)nodes[id] = node;
3898
+ copied.clear();
3899
+ for (const id of wasCopied)copied.add(id);
3900
+ }
3901
+ }
3902
+ }
3903
+ const settled = copied.size ? _extends({}, tree, {
3594
3904
  nodes
3595
3905
  }) : tree;
3906
+ return links ? aiSettleLinksWithoutDestination(settled, outputKind, links) : settled;
3596
3907
  }
3597
3908
  /**
3598
3909
  * `aiSettleMechanicalRules` on a tree as the model wrote it, before the
3599
3910
  * palette validator reads it (AGL-3660), as `aiSettleWrittenGridItems` settles
3600
3911
  * its items. Anything that is no flat node map comes back as it was, and a
3601
3912
  * node whose shape is not a palette node's is read as nothing and left as written.
3602
- */ export function aiSettleWrittenMechanicalRules(input, outputKind) {
3913
+ * Given `links`, a link that goes nowhere is settled too (AGL-4076), and a
3914
+ * node that takes out is gone from the answer.
3915
+ */ export function aiSettleWrittenMechanicalRules(input, outputKind, links) {
3603
3916
  if (!isRecord(input) || typeof input['rootId'] !== 'string' || !isRecord(input['nodes'])) return input;
3604
3917
  const readable = Object.entries(input['nodes']).filter(([, node])=>isRecord(node) && typeof node['componentId'] === 'string' && (node['props'] === undefined || isRecord(node['props'])) && (node['sx'] === undefined || isRecord(node['sx'])) && (node['nodes'] === undefined || Array.isArray(node['nodes']) && node['nodes'].every((child)=>typeof child === 'string')));
3605
3918
  const tree = {
3606
3919
  rootId: input['rootId'],
3607
3920
  nodes: Object.fromEntries(readable)
3608
3921
  };
3609
- const settled = aiSettleMechanicalRules(tree, outputKind);
3610
- return settled === tree ? input : _extends({}, input, {
3611
- nodes: _extends({}, input['nodes'], settled.nodes)
3922
+ const settled = aiSettleMechanicalRules(tree, outputKind, links);
3923
+ if (settled === tree) return input;
3924
+ const nodes = _extends({}, input['nodes'], settled.nodes);
3925
+ for (const id of Object.keys(tree.nodes))if (!settled.nodes[id]) delete nodes[id];
3926
+ return _extends({}, input, {
3927
+ nodes
3612
3928
  });
3613
3929
  }
3614
3930
  /**