@aglyn/plugins-ai 1.0.0-beta.284 → 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
@@ -20,7 +20,7 @@ import { raiseOperatorAlert } from "@aglyn/tenant-data-admin/server/operator-ale
20
20
  import { AI_BUILD_PARTLY_FAILED, AI_JOB_FAILED } from "../operator-alerts.js";
21
21
  import { liveCustomDomain } from "@aglyn/aglyn/app-utils/host-naming";
22
22
  import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
23
- import { aiJobNotice } from "../model/ai-job-notice.js";
23
+ import { aiJobNotice, aiJobPlanReminderNotice } from "../model/ai-job-notice.js";
24
24
  import { aiBusinessProfilePrefiller } from "./ai-business-profile-prefill.js";
25
25
  import { aiJobAutoConfirms } from "./ai-job-auto-confirm.js";
26
26
  import { registerAiJobTransitionListener } from "./ai-jobs.js";
@@ -78,9 +78,9 @@ import { registerAiJobTransitionListener } from "./ai-jobs.js";
78
78
  * A job's site as its notice names it: the name its managers gave it, else
79
79
  * its address, and where it is live — its custom domain once that serves,
80
80
  * else its platform subdomain. `null` for a site that is gone.
81
- */ export async function readJobSite(hostId) {
81
+ */ export async function readJobSite(hostId, firestore = firebaseAdmin.app().firestore()) {
82
82
  var _host_data;
83
- const host = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).get();
83
+ const host = await firestore.collection('hosts').doc(hostId).get();
84
84
  if (!host.exists) return null;
85
85
  const data = (_host_data = host.data()) != null ? _host_data : {};
86
86
  const text = (value)=>typeof value === 'string' ? value.trim() : '';
@@ -91,6 +91,139 @@ import { registerAiJobTransitionListener } from "./ai-jobs.js";
91
91
  url: domain ? `https://${domain}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : null
92
92
  };
93
93
  }
94
+ // ── The one plan-ready reminder (AGL-4076) ───────────────────────────────
95
+ /**
96
+ * How long a plan waits for its confirmation before its one reminder: a day
97
+ * after the job last changed, which for a plan nobody touched is the moment
98
+ * it was ready.
99
+ */ export const AI_PLAN_REMINDER_AFTER_MS = 24 * 60 * 60000;
100
+ /**
101
+ * How long after it is due a reminder may still go out. A plan that has
102
+ * waited longer than a day and this is never reminded: the person was told
103
+ * when it was ready, and a reminder about a plan left for a week reads as
104
+ * noise, so the plans already waiting when this shipped are not mailed all
105
+ * at once. It is also the beats a lockdown of `ai-generate` may hold the
106
+ * reminders for before one is missed rather than sent late.
107
+ */ export const AI_PLAN_REMINDER_WINDOW_MS = 6 * 60 * 60000;
108
+ /** The waiting jobs one beat reads, oldest first. */ export const AI_PLAN_REMINDER_SCAN = 25;
109
+ /** The reminders one beat sends; the rest are the next minute's. */ export const AI_PLAN_REMINDER_MAX = 5;
110
+ /** Wall clock one beat gives the reminders before it runs any job's step. */ export const AI_PLAN_REMINDER_BUDGET_MS = 10000;
111
+ /** The org's jobs, as `AI_JOBS_COLLECTION` in `ai-jobs.ts` names them. */ const JOBS_COLLECTION = 'aiJobs';
112
+ function instantMs(value) {
113
+ if (value === null || value === undefined) return null;
114
+ if (value instanceof Date) return value.getTime();
115
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
116
+ if (typeof value === 'string') {
117
+ const parsed = Date.parse(value);
118
+ return Number.isFinite(parsed) ? parsed : null;
119
+ }
120
+ return typeof value.toMillis === 'function' ? value.toMillis() : null;
121
+ }
122
+ /**
123
+ * Whether a job is owed its plan reminder now (AGL-4076): its plan waits for
124
+ * the person who started it (`needs_review`, reason `plan`), has waited
125
+ * between a day and a day and {@link AI_PLAN_REMINDER_WINDOW_MS}, and the job
126
+ * was never reminded. Never a plan that is confirmed for its person (a guided
127
+ * start's), a job someone asked to cancel, or one whose site was removed: the
128
+ * same jobs the first notice skips, and the ones that moved on.
129
+ */ export function aiPlanReminderDue(job, nowMs) {
130
+ var _instantMs;
131
+ var _job_review;
132
+ if (job.status !== 'needs_review' || ((_job_review = job.review) == null ? void 0 : _job_review.reason) !== 'plan' || !job.createdBy) return false;
133
+ if (job.remindedAt || job.cancelRequested || job.siteRemoved || aiJobAutoConfirms(job)) return false;
134
+ const waitedMs = nowMs - ((_instantMs = instantMs(job.updatedAt)) != null ? _instantMs : nowMs);
135
+ return waitedMs >= AI_PLAN_REMINDER_AFTER_MS && waitedMs < AI_PLAN_REMINDER_AFTER_MS + AI_PLAN_REMINDER_WINDOW_MS;
136
+ }
137
+ /**
138
+ * One beat's plan reminders (AGL-4076). A plan that waits for its
139
+ * confirmation is told about once when it is ready (`aiJobTransitionNotifier`)
140
+ * and once more a day later, here, and never again: 13 plans in the 10/10 to
141
+ * 10/28 window waited 4 to 428 hours after their single notice.
142
+ *
143
+ * The waiting jobs are read by the collection-group index the sweep already
144
+ * uses, on (status, updatedAt), inside the window a reminder may go out in,
145
+ * oldest first; every condition is then read again on the job, in the
146
+ * transaction that marks `remindedAt`, so two beats never both send one and a
147
+ * job confirmed, cancelled or deleted in the meantime gets none. The mark is
148
+ * written before the notice: a beat that dies between the two loses a
149
+ * reminder rather than sending a second. `notifyUsers` holds the notice to the
150
+ * person's own settings for AI job needs you, by channel, workspace and site,
151
+ * as it holds the first one; a job whose site is gone is not reminded.
152
+ *
153
+ * The mark leaves `updatedAt` alone, so the job's age in AI jobs stays the
154
+ * age of its plan.
155
+ */ export async function sweepAiPlanReminders(options) {
156
+ var _options_now, _options_budgetMs, _options_notify, _options_readSite;
157
+ const now = (_options_now = options.now) != null ? _options_now : Date.now;
158
+ const startedAt = now();
159
+ const budgetMs = (_options_budgetMs = options.budgetMs) != null ? _options_budgetMs : AI_PLAN_REMINDER_BUDGET_MS;
160
+ const notify = (_options_notify = options.notify) != null ? _options_notify : notifyUsers;
161
+ const readSite = (_options_readSite = options.readSite) != null ? _options_readSite : (hostId)=>readJobSite(hostId, options.firestore);
162
+ const dueBefore = startedAt - AI_PLAN_REMINDER_AFTER_MS;
163
+ const waiting = await options.firestore.collectionGroup(JOBS_COLLECTION).where('status', '==', 'needs_review').where('updatedAt', '<=', new Date(dueBefore)).where('updatedAt', '>', new Date(dueBefore - AI_PLAN_REMINDER_WINDOW_MS)).orderBy('updatedAt', 'asc').limit(AI_PLAN_REMINDER_SCAN).get();
164
+ const result = {
165
+ scanned: waiting.size,
166
+ reminded: 0
167
+ };
168
+ for (const doc of waiting.docs){
169
+ if (result.reminded >= AI_PLAN_REMINDER_MAX || now() - startedAt >= budgetMs) break;
170
+ const listed = _extends({}, doc.data(), {
171
+ $id: doc.id
172
+ });
173
+ if (!aiPlanReminderDue(listed, now())) continue;
174
+ try {
175
+ // A job's site, read before the mark, so a job whose site is gone is never marked or told.
176
+ const site = listed.hostId ? await readSite(listed.hostId) : null;
177
+ if (listed.hostId && !site) continue;
178
+ const job = await options.firestore.runTransaction(async (tx)=>{
179
+ const fresh = await tx.get(doc.ref);
180
+ if (!fresh.exists) return null;
181
+ const current = _extends({}, fresh.data(), {
182
+ $id: fresh.id
183
+ });
184
+ if (!aiPlanReminderDue(current, now())) return null;
185
+ tx.update(doc.ref, {
186
+ remindedAt: new Date(now())
187
+ });
188
+ return current;
189
+ });
190
+ if (!job) continue;
191
+ const notice = aiJobPlanReminderNotice(job, site);
192
+ if (site) {
193
+ await notify([
194
+ job.createdBy
195
+ ], notice, {
196
+ emailDetails: [
197
+ {
198
+ label: 'Site',
199
+ value: site.name
200
+ },
201
+ ...site.url ? [
202
+ {
203
+ label: 'Address',
204
+ value: site.url
205
+ }
206
+ ] : []
207
+ ]
208
+ });
209
+ } else {
210
+ await notify([
211
+ job.createdBy
212
+ ], notice);
213
+ }
214
+ result.reminded += 1;
215
+ } catch (error) {
216
+ // One job's fault is isolated; an unmarked job is read again next beat, inside its window.
217
+ console.error('ai plan reminder failed', {
218
+ orgId: listed.orgId,
219
+ jobId: listed.$id,
220
+ error
221
+ });
222
+ }
223
+ }
224
+ if (result.reminded) console.info(`ai jobs: ${result.reminded} plan reminder(s) sent`);
225
+ return result;
226
+ }
94
227
  /** The longest runner error an alert quotes; the log has the rest. */ const ALERT_ERROR_MAX = 300;
95
228
  /** At most this many failed parts are named in a partial-build alert. */ const ALERT_ITEMS_MAX = 5;
96
229
  const capped = (text)=>text.length > ALERT_ERROR_MAX ? `${text.slice(0, ALERT_ERROR_MAX)}…` : text;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-notify.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport { raiseOperatorAlert } from '@aglyn/tenant-data-admin/server/operator-alerts'\nimport { AI_BUILD_PARTLY_FAILED, AI_JOB_FAILED } from '../operator-alerts'\nimport { liveCustomDomain } from '@aglyn/aglyn/app-utils/host-naming'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport { aiJobNotice, type AiJobNoticeSite } from '../model/ai-job-notice'\nimport { aiBusinessProfilePrefiller } from './ai-business-profile-prefill'\nimport { aiJobAutoConfirms } from './ai-job-auto-confirm'\nimport { registerAiJobTransitionListener, type AiJobTransitionListener } from './ai-jobs'\n\n/**\n * The person who started an AI job is told when it needs them, when it\n * finishes and when it stops (AGL-3593), through the console's notifications\n * — the bell, and the generic notification email for a person who switched\n * that channel on for the category. No other channel: there is no push.\n *\n * The machine calls this where the job's state changes, once per change\n * (`registerAiJobTransitionListener`): the request that ran a step inline,\n * or the beat that ran it with nobody watching — which is exactly when a\n * person needs telling. The recipient is the job's creator, whose job it is\n * and who alone confirms its plan; a teammate is not told about someone\n * else's draft.\n *\n * `notifyUsers` never throws, and honors the person's per-category,\n * per-workspace and per-site preferences.\n */\nexport function aiJobTransitionNotifier(\n notify: typeof notifyUsers = notifyUsers,\n readSite: (hostId: string) => Promise<AiJobNoticeSite | null> = readJobSite,\n): AiJobTransitionListener {\n return async ({ job, to }) => {\n if (!job.createdBy) return\n // A guided site start's plan is confirmed for it the moment it is kept\n // (AGL-3594): there is nothing for the person to confirm.\n if (to === 'needs-review' && job.review?.reason === 'plan' && aiJobAutoConfirms(job)) return\n // The site a job worked on, by name and address (AGL-3889): the email is\n // read away from the console, often by someone with several sites.\n const site = job.hostId ? await readSite(job.hostId).catch(() => null) : null\n const notice = aiJobNotice(job, to, site)\n if (!site) {\n await notify([job.createdBy], notice)\n return\n }\n await notify([job.createdBy], notice, {\n emailDetails: [\n { label: 'Site', value: site.name },\n ...(site.url ? [{ label: 'Address', value: site.url }] : []),\n ],\n })\n }\n}\n\n/**\n * A job's site as its notice names it: the name its managers gave it, else\n * its address, and where it is live — its custom domain once that serves,\n * else its platform subdomain. `null` for a site that is gone.\n */\nexport async function readJobSite(hostId: string): Promise<AiJobNoticeSite | null> {\n const host = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).get()\n if (!host.exists) return null\n const data = (host.data() ?? {}) as Record<string, unknown>\n const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')\n const subdomain = text(data['subdomain'])\n const domain = liveCustomDomain(data as never)\n return {\n name: text(data['displayName']) || text(data['name']) || subdomain || hostId,\n url: domain ? `https://${domain}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : null,\n }\n}\n\n/** The longest runner error an alert quotes; the log has the rest. */\nconst ALERT_ERROR_MAX = 300\n\n/** At most this many failed parts are named in a partial-build alert. */\nconst ALERT_ITEMS_MAX = 5\n\nconst capped = (text: string): string =>\n text.length > ALERT_ERROR_MAX ? `${text.slice(0, ALERT_ERROR_MAX)}…` : text\n\n/**\n * Staff are told when a job fails on our side (`ai.jobFailed`), and when a\n * build finishes `done` with parts that failed on our side\n * (`ai.buildPartlyFailed`) — each deduped per job kind, so a broken provider\n * or a broken step is heard about the hour it starts. A failure that is the\n * customer's — the model declined the brief or a part of it, the site\n * switched AI off — raises nothing. `raiseOperatorAlert` never throws.\n */\nexport function aiJobFailureAlerter(\n raise: typeof raiseOperatorAlert = raiseOperatorAlert,\n): AiJobTransitionListener {\n return async ({ job, to, failure }) => {\n if (to === 'done') {\n const items = job.items ?? []\n const ours = items.filter((row) => row.status === 'failed' && row.failure?.ours)\n if (!ours.length) return\n const named = ours\n .slice(0, ALERT_ITEMS_MAX)\n .map((row) => `${row.label} (${row.failure?.reason}${row.failure?.detail ? `: ${row.failure.detail}` : ''})`)\n .join('; ')\n await raise(AI_BUILD_PARTLY_FAILED, {\n dedupeKey: job.kind,\n orgId: job.orgId,\n ...(job.hostId ? { hostId: job.hostId } : {}),\n context: {\n kind: job.kind,\n jobId: job.$id,\n orgId: job.orgId,\n failed: ours.length,\n total: items.length,\n items: capped(ours.length > ALERT_ITEMS_MAX ? `${named}; and ${ours.length - ALERT_ITEMS_MAX} more` : named),\n },\n })\n return\n }\n if (to !== 'failed' || !failure?.ours) return\n const error = failure.error ?? job.error ?? 'no error recorded'\n await raise(AI_JOB_FAILED, {\n dedupeKey: job.kind,\n orgId: job.orgId,\n ...(job.hostId ? { hostId: job.hostId } : {}),\n context: {\n kind: job.kind,\n jobId: job.$id,\n orgId: job.orgId,\n step: failure.stepIndex === null ? 'n/a' : job.steps[failure.stepIndex]?.name ?? failure.stepIndex,\n error: capped(error),\n },\n })\n }\n}\n\n/**\n * Registers what the jobs machine tells about each change: the notifier, the\n * staff alert for a failure on our side, and the business profile's prefill\n * from a site job (AGL-3661). One listener, each in turn; the alert and the\n * prefill swallow their own failures, so neither costs the person their notice.\n */\nexport function registerAiJobsNotify(): void {\n const notify = aiJobTransitionNotifier()\n const alert = aiJobFailureAlerter()\n const prefill = aiBusinessProfilePrefiller(() => firebaseAdmin.app().firestore())\n registerAiJobTransitionListener(async (change) => {\n await notify(change)\n await alert(change)\n await prefill(change)\n })\n}\n"],"names":["firebaseAdmin","notifyUsers","raiseOperatorAlert","AI_BUILD_PARTLY_FAILED","AI_JOB_FAILED","liveCustomDomain","TENANT_APEX","aiJobNotice","aiBusinessProfilePrefiller","aiJobAutoConfirms","registerAiJobTransitionListener","aiJobTransitionNotifier","notify","readSite","readJobSite","job","to","createdBy","review","reason","site","hostId","catch","notice","emailDetails","label","value","name","url","host","app","firestore","collection","doc","get","exists","data","text","trim","subdomain","domain","ALERT_ERROR_MAX","ALERT_ITEMS_MAX","capped","length","slice","aiJobFailureAlerter","raise","failure","items","ours","filter","row","status","named","map","detail","join","dedupeKey","kind","orgId","context","jobId","$id","failed","total","error","step","stepIndex","steps","registerAiJobsNotify","alert","prefill","change"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-jobs-notify.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport { raiseOperatorAlert } from '@aglyn/tenant-data-admin/server/operator-alerts'\nimport { AI_BUILD_PARTLY_FAILED, AI_JOB_FAILED } from '../operator-alerts'\nimport { liveCustomDomain } from '@aglyn/aglyn/app-utils/host-naming'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport { aiJobNotice, aiJobPlanReminderNotice, type AiJobNoticeSite } from '../model/ai-job-notice'\nimport type { AiJob } from '../model/ai-jobs.types'\nimport { aiBusinessProfilePrefiller } from './ai-business-profile-prefill'\nimport { aiJobAutoConfirms } from './ai-job-auto-confirm'\nimport { registerAiJobTransitionListener, type AiJobTransitionListener } from './ai-jobs'\n\n/**\n * The person who started an AI job is told when it needs them, when it\n * finishes and when it stops (AGL-3593), through the console's notifications\n * — the bell, and the generic notification email for a person who switched\n * that channel on for the category. No other channel: there is no push.\n *\n * The machine calls this where the job's state changes, once per change\n * (`registerAiJobTransitionListener`): the request that ran a step inline,\n * or the beat that ran it with nobody watching — which is exactly when a\n * person needs telling. The recipient is the job's creator, whose job it is\n * and who alone confirms its plan; a teammate is not told about someone\n * else's draft.\n *\n * `notifyUsers` never throws, and honors the person's per-category,\n * per-workspace and per-site preferences.\n */\nexport function aiJobTransitionNotifier(\n notify: typeof notifyUsers = notifyUsers,\n readSite: (hostId: string) => Promise<AiJobNoticeSite | null> = readJobSite,\n): AiJobTransitionListener {\n return async ({ job, to }) => {\n if (!job.createdBy) return\n // A guided site start's plan is confirmed for it the moment it is kept\n // (AGL-3594): there is nothing for the person to confirm.\n if (to === 'needs-review' && job.review?.reason === 'plan' && aiJobAutoConfirms(job)) return\n // The site a job worked on, by name and address (AGL-3889): the email is\n // read away from the console, often by someone with several sites.\n const site = job.hostId ? await readSite(job.hostId).catch(() => null) : null\n const notice = aiJobNotice(job, to, site)\n if (!site) {\n await notify([job.createdBy], notice)\n return\n }\n await notify([job.createdBy], notice, {\n emailDetails: [\n { label: 'Site', value: site.name },\n ...(site.url ? [{ label: 'Address', value: site.url }] : []),\n ],\n })\n }\n}\n\n/**\n * A job's site as its notice names it: the name its managers gave it, else\n * its address, and where it is live — its custom domain once that serves,\n * else its platform subdomain. `null` for a site that is gone.\n */\nexport async function readJobSite(\n hostId: string,\n firestore: FirebaseFirestore.Firestore = firebaseAdmin.app().firestore(),\n): Promise<AiJobNoticeSite | null> {\n const host = await firestore.collection('hosts').doc(hostId).get()\n if (!host.exists) return null\n const data = (host.data() ?? {}) as Record<string, unknown>\n const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')\n const subdomain = text(data['subdomain'])\n const domain = liveCustomDomain(data as never)\n return {\n name: text(data['displayName']) || text(data['name']) || subdomain || hostId,\n url: domain ? `https://${domain}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : null,\n }\n}\n\n// ── The one plan-ready reminder (AGL-4076) ───────────────────────────────\n\n/**\n * How long a plan waits for its confirmation before its one reminder: a day\n * after the job last changed, which for a plan nobody touched is the moment\n * it was ready.\n */\nexport const AI_PLAN_REMINDER_AFTER_MS = 24 * 60 * 60_000\n\n/**\n * How long after it is due a reminder may still go out. A plan that has\n * waited longer than a day and this is never reminded: the person was told\n * when it was ready, and a reminder about a plan left for a week reads as\n * noise, so the plans already waiting when this shipped are not mailed all\n * at once. It is also the beats a lockdown of `ai-generate` may hold the\n * reminders for before one is missed rather than sent late.\n */\nexport const AI_PLAN_REMINDER_WINDOW_MS = 6 * 60 * 60_000\n\n/** The waiting jobs one beat reads, oldest first. */\nexport const AI_PLAN_REMINDER_SCAN = 25\n\n/** The reminders one beat sends; the rest are the next minute's. */\nexport const AI_PLAN_REMINDER_MAX = 5\n\n/** Wall clock one beat gives the reminders before it runs any job's step. */\nexport const AI_PLAN_REMINDER_BUDGET_MS = 10_000\n\n/** The org's jobs, as `AI_JOBS_COLLECTION` in `ai-jobs.ts` names them. */\nconst JOBS_COLLECTION = 'aiJobs'\n\ntype Instant = Date | { toMillis(): number } | number | string | null | undefined\n\nfunction instantMs(value: Instant): number | null {\n if (value === null || value === undefined) return null\n if (value instanceof Date) return value.getTime()\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n if (typeof value === 'string') {\n const parsed = Date.parse(value)\n return Number.isFinite(parsed) ? parsed : null\n }\n return typeof value.toMillis === 'function' ? value.toMillis() : null\n}\n\n/**\n * Whether a job is owed its plan reminder now (AGL-4076): its plan waits for\n * the person who started it (`needs_review`, reason `plan`), has waited\n * between a day and a day and {@link AI_PLAN_REMINDER_WINDOW_MS}, and the job\n * was never reminded. Never a plan that is confirmed for its person (a guided\n * start's), a job someone asked to cancel, or one whose site was removed: the\n * same jobs the first notice skips, and the ones that moved on.\n */\nexport function aiPlanReminderDue(\n job: Pick<AiJob, 'kind' | 'inputs' | 'status' | 'review' | 'createdBy' | 'updatedAt' | 'remindedAt' | 'cancelRequested' | 'siteRemoved'>,\n nowMs: number,\n): boolean {\n if (job.status !== 'needs_review' || job.review?.reason !== 'plan' || !job.createdBy) return false\n if (job.remindedAt || job.cancelRequested || job.siteRemoved || aiJobAutoConfirms(job)) return false\n const waitedMs = nowMs - (instantMs(job.updatedAt as Instant) ?? nowMs)\n return waitedMs >= AI_PLAN_REMINDER_AFTER_MS && waitedMs < AI_PLAN_REMINDER_AFTER_MS + AI_PLAN_REMINDER_WINDOW_MS\n}\n\nexport interface AiPlanReminderSweepResult {\n /** Jobs read that were waiting in the window, reminded or not. */\n scanned: number\n /** Reminders sent. */\n reminded: number\n}\n\nexport interface SweepAiPlanRemindersOptions {\n firestore: FirebaseFirestore.Firestore\n now?: () => number\n budgetMs?: number\n notify?: typeof notifyUsers\n /** The job's site; `readJobSite` unless a spec replaces it. */\n readSite?: (hostId: string) => Promise<AiJobNoticeSite | null>\n}\n\n/**\n * One beat's plan reminders (AGL-4076). A plan that waits for its\n * confirmation is told about once when it is ready (`aiJobTransitionNotifier`)\n * and once more a day later, here, and never again: 13 plans in the 10/10 to\n * 10/28 window waited 4 to 428 hours after their single notice.\n *\n * The waiting jobs are read by the collection-group index the sweep already\n * uses, on (status, updatedAt), inside the window a reminder may go out in,\n * oldest first; every condition is then read again on the job, in the\n * transaction that marks `remindedAt`, so two beats never both send one and a\n * job confirmed, cancelled or deleted in the meantime gets none. The mark is\n * written before the notice: a beat that dies between the two loses a\n * reminder rather than sending a second. `notifyUsers` holds the notice to the\n * person's own settings for AI job needs you, by channel, workspace and site,\n * as it holds the first one; a job whose site is gone is not reminded.\n *\n * The mark leaves `updatedAt` alone, so the job's age in AI jobs stays the\n * age of its plan.\n */\nexport async function sweepAiPlanReminders(options: SweepAiPlanRemindersOptions): Promise<AiPlanReminderSweepResult> {\n const now = options.now ?? Date.now\n const startedAt = now()\n const budgetMs = options.budgetMs ?? AI_PLAN_REMINDER_BUDGET_MS\n const notify = options.notify ?? notifyUsers\n const readSite = options.readSite ?? ((hostId: string) => readJobSite(hostId, options.firestore))\n const dueBefore = startedAt - AI_PLAN_REMINDER_AFTER_MS\n const waiting = await options.firestore\n .collectionGroup(JOBS_COLLECTION)\n .where('status', '==', 'needs_review')\n .where('updatedAt', '<=', new Date(dueBefore))\n .where('updatedAt', '>', new Date(dueBefore - AI_PLAN_REMINDER_WINDOW_MS))\n .orderBy('updatedAt', 'asc')\n .limit(AI_PLAN_REMINDER_SCAN)\n .get()\n const result: AiPlanReminderSweepResult = { scanned: waiting.size, reminded: 0 }\n for (const doc of waiting.docs) {\n if (result.reminded >= AI_PLAN_REMINDER_MAX || now() - startedAt >= budgetMs) break\n const listed = { ...(doc.data() as Omit<AiJob, '$id'>), $id: doc.id } as AiJob\n if (!aiPlanReminderDue(listed, now())) continue\n try {\n // A job's site, read before the mark, so a job whose site is gone is never marked or told.\n const site = listed.hostId ? await readSite(listed.hostId) : null\n if (listed.hostId && !site) continue\n const job = await options.firestore.runTransaction(async (tx) => {\n const fresh = await tx.get(doc.ref)\n if (!fresh.exists) return null\n const current = { ...(fresh.data() as Omit<AiJob, '$id'>), $id: fresh.id } as AiJob\n if (!aiPlanReminderDue(current, now())) return null\n tx.update(doc.ref, { remindedAt: new Date(now()) })\n return current\n })\n if (!job) continue\n const notice = aiJobPlanReminderNotice(job, site)\n if (site) {\n await notify([job.createdBy], notice, {\n emailDetails: [\n { label: 'Site', value: site.name },\n ...(site.url ? [{ label: 'Address', value: site.url }] : []),\n ],\n })\n } else {\n await notify([job.createdBy], notice)\n }\n result.reminded += 1\n } catch (error) {\n // One job's fault is isolated; an unmarked job is read again next beat, inside its window.\n console.error('ai plan reminder failed', { orgId: listed.orgId, jobId: listed.$id, error })\n }\n }\n if (result.reminded) console.info(`ai jobs: ${result.reminded} plan reminder(s) sent`)\n return result\n}\n\n/** The longest runner error an alert quotes; the log has the rest. */\nconst ALERT_ERROR_MAX = 300\n\n/** At most this many failed parts are named in a partial-build alert. */\nconst ALERT_ITEMS_MAX = 5\n\nconst capped = (text: string): string =>\n text.length > ALERT_ERROR_MAX ? `${text.slice(0, ALERT_ERROR_MAX)}…` : text\n\n/**\n * Staff are told when a job fails on our side (`ai.jobFailed`), and when a\n * build finishes `done` with parts that failed on our side\n * (`ai.buildPartlyFailed`) — each deduped per job kind, so a broken provider\n * or a broken step is heard about the hour it starts. A failure that is the\n * customer's — the model declined the brief or a part of it, the site\n * switched AI off — raises nothing. `raiseOperatorAlert` never throws.\n */\nexport function aiJobFailureAlerter(\n raise: typeof raiseOperatorAlert = raiseOperatorAlert,\n): AiJobTransitionListener {\n return async ({ job, to, failure }) => {\n if (to === 'done') {\n const items = job.items ?? []\n const ours = items.filter((row) => row.status === 'failed' && row.failure?.ours)\n if (!ours.length) return\n const named = ours\n .slice(0, ALERT_ITEMS_MAX)\n .map((row) => `${row.label} (${row.failure?.reason}${row.failure?.detail ? `: ${row.failure.detail}` : ''})`)\n .join('; ')\n await raise(AI_BUILD_PARTLY_FAILED, {\n dedupeKey: job.kind,\n orgId: job.orgId,\n ...(job.hostId ? { hostId: job.hostId } : {}),\n context: {\n kind: job.kind,\n jobId: job.$id,\n orgId: job.orgId,\n failed: ours.length,\n total: items.length,\n items: capped(ours.length > ALERT_ITEMS_MAX ? `${named}; and ${ours.length - ALERT_ITEMS_MAX} more` : named),\n },\n })\n return\n }\n if (to !== 'failed' || !failure?.ours) return\n const error = failure.error ?? job.error ?? 'no error recorded'\n await raise(AI_JOB_FAILED, {\n dedupeKey: job.kind,\n orgId: job.orgId,\n ...(job.hostId ? { hostId: job.hostId } : {}),\n context: {\n kind: job.kind,\n jobId: job.$id,\n orgId: job.orgId,\n step: failure.stepIndex === null ? 'n/a' : job.steps[failure.stepIndex]?.name ?? failure.stepIndex,\n error: capped(error),\n },\n })\n }\n}\n\n/**\n * Registers what the jobs machine tells about each change: the notifier, the\n * staff alert for a failure on our side, and the business profile's prefill\n * from a site job (AGL-3661). One listener, each in turn; the alert and the\n * prefill swallow their own failures, so neither costs the person their notice.\n */\nexport function registerAiJobsNotify(): void {\n const notify = aiJobTransitionNotifier()\n const alert = aiJobFailureAlerter()\n const prefill = aiBusinessProfilePrefiller(() => firebaseAdmin.app().firestore())\n registerAiJobTransitionListener(async (change) => {\n await notify(change)\n await alert(change)\n await prefill(change)\n })\n}\n"],"names":["firebaseAdmin","notifyUsers","raiseOperatorAlert","AI_BUILD_PARTLY_FAILED","AI_JOB_FAILED","liveCustomDomain","TENANT_APEX","aiJobNotice","aiJobPlanReminderNotice","aiBusinessProfilePrefiller","aiJobAutoConfirms","registerAiJobTransitionListener","aiJobTransitionNotifier","notify","readSite","readJobSite","job","to","createdBy","review","reason","site","hostId","catch","notice","emailDetails","label","value","name","url","firestore","app","host","collection","doc","get","exists","data","text","trim","subdomain","domain","AI_PLAN_REMINDER_AFTER_MS","AI_PLAN_REMINDER_WINDOW_MS","AI_PLAN_REMINDER_SCAN","AI_PLAN_REMINDER_MAX","AI_PLAN_REMINDER_BUDGET_MS","JOBS_COLLECTION","instantMs","undefined","Date","getTime","Number","isFinite","parsed","parse","toMillis","aiPlanReminderDue","nowMs","status","remindedAt","cancelRequested","siteRemoved","waitedMs","updatedAt","sweepAiPlanReminders","options","now","startedAt","budgetMs","dueBefore","waiting","collectionGroup","where","orderBy","limit","result","scanned","size","reminded","docs","listed","$id","id","runTransaction","tx","fresh","ref","current","update","error","console","orgId","jobId","info","ALERT_ERROR_MAX","ALERT_ITEMS_MAX","capped","length","slice","aiJobFailureAlerter","raise","failure","items","ours","filter","row","named","map","detail","join","dedupeKey","kind","context","failed","total","step","stepIndex","steps","registerAiJobsNotify","alert","prefill","change"],"mappings":"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"}
@@ -138,6 +138,20 @@ import type { AiBuildPlan } from '../model/ai-build-plan';
138
138
  * where this module lists it ("gym" of "Synchronicity Gym"); and a
139
139
  * training word's other senses (a railway's train, an elevator's lift, a
140
140
  * newspaper's press, a boat's row) never fill a gym's picture.
141
+ * 10. **The trade, never a word of it** (the 2026-10-25 booth kit, AGL-4065:
142
+ * six trades whose photos were race cars, cyclists, horses, a Honda
143
+ * scooter, a canoe for "Parts for the ranch", hotel buffets for a taco
144
+ * trailer and an office for a landscaper). A business named by two
145
+ * things ("Motorcycle and dirt bike repair shop") is both; the craft
146
+ * named first leads, never one named only in what a customer may ask
147
+ * ("takes catering requests"); a hero's first tag and a broad search's
148
+ * hit name the business's own world ({@link AiStockSiteTerms.lead}),
149
+ * never any word of its type ("race" of "race car"); a two-word world
150
+ * ("dirt bike") is named whole; a maker's name never shows; a sentence to
151
+ * the reader, a job or an order is never a subject, and a part or gear is
152
+ * searched only with its world; a record's cover is searched in its world
153
+ * first; and a listed world widens only where the library knew nothing of
154
+ * it, never an outdoor or a trade world to an office.
141
155
  *
142
156
  * A slot the library could not fill because it never answered — down, or
143
157
  * out of time before its first answer — or because the site's library
@@ -207,6 +221,9 @@ export declare function aiStockStem(word: string): string;
207
221
  * of an English noun phrase is at its end). "ceramic artist" from "a
208
222
  * portfolio for a ceramic artist who makes stoneware bowls"; "candle shop"
209
223
  * from "a small-batch candle shop selling hand-poured soy candles online".
224
+ * A business named by two things joined by "and" is its second noun phrase
225
+ * whole: "dirt bike repair shop" of "Motorcycle and dirt bike repair shop"
226
+ * (AGL-4065).
210
227
  */
211
228
  export declare function aiStockBusinessWords(businessType: string): string;
212
229
  /**
@@ -229,6 +246,19 @@ export interface AiStockSiteTerms {
229
246
  * held to it.
230
247
  */
231
248
  domain: string[];
249
+ /**
250
+ * The business's own world (AGL-4065): the listed crafts its own clause
251
+ * names, their names and kin, and its own words where no craft covers
252
+ * them, as stems and stem pairs ("dirt bike"). What a hero's first tag and a
253
+ * broad search's hit name. Its whole domain where no craft is listed.
254
+ */
255
+ lead?: string[];
256
+ /**
257
+ * The world a trade serves, as its type names it, where the craft is the
258
+ * trade ("dirt bike" for a parts shop's craft "part", AGL-4065): what its
259
+ * pictures are qualified by in place of the craft.
260
+ */
261
+ leadWord?: string;
232
262
  /** The searches for the site's world itself, broadest last. */
233
263
  broad: string[];
234
264
  /** The searches for its maker at work, for a story of the craft (AGL-3660); empty for a craft not listed. */
@@ -266,13 +296,30 @@ export interface AiStockSiteTerms {
266
296
  */
267
297
  work?: AiStockScene[];
268
298
  }
269
- /** The site's terms, read from its business type. */
299
+ /**
300
+ * The site's terms, read from its business type.
301
+ *
302
+ * Two worlds are read (AGL-4065). Its DOMAIN is everything its words name
303
+ * of its world, what a picture of a thing is held to. Its LEAD is the
304
+ * business's own world: the listed crafts its own clause names (and the
305
+ * craft's), their names and kin, and the clause's own words where no craft
306
+ * covers them, never a word that names a world only beside another ("race",
307
+ * "team") nor what a customer is asked to do. A hero's first tag and a broad
308
+ * search's hit name the lead: the Rut Runners heroes, held to every word of
309
+ * "Amateur motocross race team: rider profiles, race schedule…", took race
310
+ * cars because "race car" named "race". Of the crafts its words list, the one
311
+ * named first leads (a taco trailer that "takes catering requests" is a taco
312
+ * trailer), and a craft named only in what a customer may ask for gives the
313
+ * site no searches of its own.
314
+ */
270
315
  export declare function aiStockSiteTerms(businessType: string): AiStockSiteTerms;
271
316
  /**
272
317
  * A picture's subject in a few words (AGL-3660): the noun phrase its alt
273
318
  * text opens with — its first clause holding more than filler, cut where a
274
319
  * preposition starts the setting, filler dropped, its last three words (the
275
- * head noun is the last) — else its section's name.
320
+ * head noun is the last) — else its section's name. A word or two joined by
321
+ * "and" to a longer noun phrase only modify its head: the subject of "Potato
322
+ * and egg breakfast taco" is "egg breakfast taco", never "potato" (AGL-4065).
276
323
  */
277
324
  export declare function aiStockSubjectWords(alt: string, sectionName: string): string;
278
325
  /**
@@ -490,7 +537,7 @@ export declare function aiStockProductSearches(slot: Pick<AiLayoutPictureSlot, '
490
537
  * asks first for the role its words or its list's name (`sectionName`) say,
491
538
  * in the site's world: "teacher classroom" for a "Math instructor" (AGL-3856).
492
539
  */
493
- export declare function aiStockSearchesFor(slot: Pick<AiLayoutPictureSlot, 'role' | 'alt' | 'aspect' | 'people' | 'record' | 'product'>, terms: Pick<AiStockSiteTerms, 'business' | 'craft' | 'domain' | 'broad'> & Partial<Pick<AiStockSiteTerms, 'making' | 'core' | 'scenes' | 'setting' | 'maker' | 'work'>>, subject: string, part?: AiStockSlotPart, sectionName?: string): AiStockSearch[];
540
+ export declare function aiStockSearchesFor(slot: Pick<AiLayoutPictureSlot, 'role' | 'alt' | 'aspect' | 'people' | 'record' | 'product'>, terms: Pick<AiStockSiteTerms, 'business' | 'craft' | 'domain' | 'broad'> & Partial<Pick<AiStockSiteTerms, 'making' | 'core' | 'scenes' | 'setting' | 'maker' | 'work' | 'leadWord'>>, subject: string, part?: AiStockSlotPart, sectionName?: string): AiStockSearch[];
494
541
  /**
495
542
  * Whether a picture of a craft's site whose pictures are people at a moment
496
543
  * shows that work wherever a page would show the maker (AGL-3942): an about
@@ -513,6 +560,12 @@ export interface AiStockJudgement {
513
560
  subject?: string;
514
561
  /** The site's domain stems ({@link AiStockSiteTerms.domain}). */
515
562
  domain?: readonly string[];
563
+ /**
564
+ * The business's own world ({@link AiStockSiteTerms.lead}, AGL-4065): what a
565
+ * hero's first tag and a hit of a search that is not of a thing name. The
566
+ * domain where absent.
567
+ */
568
+ world?: readonly string[];
516
569
  /**
517
570
  * Whether a hit must name the subject's object. A broad search, a hero
518
571
  * and an about picture need only name the domain.
@@ -841,10 +894,12 @@ export declare function aiStockWorldIsUnlisted(terms: Pick<AiStockSiteTerms, 'bu
841
894
  * "freelencing", which no library tags, so its cover hero searched it, found
842
895
  * nothing, and shipped a flat dark band with no photo.
843
896
  */
844
- export declare function aiStockWiderTerms(terms: Pick<AiStockSiteTerms, 'business' | 'craft' | 'broad' | 'domain'>, kind: string): Array<{
897
+ export declare function aiStockWiderTerms(terms: Pick<AiStockSiteTerms, 'business' | 'craft' | 'broad' | 'domain'> & Partial<Pick<AiStockSiteTerms, 'lead'>>, kind: string): Array<{
845
898
  rung: 'kind' | 'starter' | 'generic';
846
899
  terms: AiStockSiteTerms;
847
900
  }>;
901
+ /** Whether a site's own world is outdoors, on a vehicle, with tools or over food ({@link OUTDOOR_AND_TRADE_STEMS}). */
902
+ export declare function aiStockWorldIsOutdoorOrTrade(terms: Pick<AiStockSiteTerms, 'business' | 'craft' | 'domain'> & Partial<Pick<AiStockSiteTerms, 'lead'>>): boolean;
848
903
  /**
849
904
  * The first word of a text that names a craft {@link CRAFT_KIN} lists, as
850
905
  * its table writes it: "gym" of "Synchronicity Gym", "law" of "Hartwell