@aglyn/plugins-ai 1.0.0-beta.262 → 1.0.0-beta.264

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 (64) hide show
  1. package/package.json +17 -17
  2. package/src/lib/insights/ai-figure-summaries.d.ts +1 -1
  3. package/src/lib/insights/ai-figure-summaries.js +55 -38
  4. package/src/lib/insights/ai-figure-summaries.js.map +1 -1
  5. package/src/lib/insights/ai-insight-digest.d.ts +2 -0
  6. package/src/lib/insights/ai-insight-digest.js +7 -4
  7. package/src/lib/insights/ai-insight-digest.js.map +1 -1
  8. package/src/lib/jobs/ai-job-insight-step.d.ts +1 -1
  9. package/src/lib/jobs/ai-job-insight-step.js +5 -3
  10. package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
  11. package/src/lib/jobs/ai-job-page-language.d.ts +2 -0
  12. package/src/lib/jobs/ai-job-page-language.js +23 -2
  13. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  14. package/src/lib/jobs/ai-job-page-sections.js +8 -2
  15. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  16. package/src/lib/jobs/ai-job-plan-step.d.ts +14 -1
  17. package/src/lib/jobs/ai-job-plan-step.js +44 -9
  18. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  19. package/src/lib/jobs/ai-job-site-datasets.d.ts +11 -1
  20. package/src/lib/jobs/ai-job-site-datasets.js +32 -7
  21. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  22. package/src/lib/jobs/ai-jobs-notify.d.ts +8 -1
  23. package/src/lib/jobs/ai-jobs-notify.js +44 -2
  24. package/src/lib/jobs/ai-jobs-notify.js.map +1 -1
  25. package/src/lib/jobs/ai-layout-stock-photos.d.ts +91 -11
  26. package/src/lib/jobs/ai-layout-stock-photos.js +1046 -48
  27. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  28. package/src/lib/jobs/fixtures/ai-stock-photo-juniper-records-recordings.d.ts +23 -0
  29. package/src/lib/jobs/fixtures/ai-stock-photo-juniper-records-recordings.js +163 -0
  30. package/src/lib/jobs/fixtures/ai-stock-photo-juniper-records-recordings.js.map +1 -0
  31. package/src/lib/jobs/fixtures/ai-stock-photo-learning-center-recordings.d.ts +23 -0
  32. package/src/lib/jobs/fixtures/ai-stock-photo-learning-center-recordings.js +53 -0
  33. package/src/lib/jobs/fixtures/ai-stock-photo-learning-center-recordings.js.map +1 -0
  34. package/src/lib/jobs/fixtures/ai-stock-photo-synchronicity-recordings.d.ts +21 -0
  35. package/src/lib/jobs/fixtures/ai-stock-photo-synchronicity-recordings.js +119 -0
  36. package/src/lib/jobs/fixtures/ai-stock-photo-synchronicity-recordings.js.map +1 -0
  37. package/src/lib/layout-language/ai-layout-compiler.js +24 -11
  38. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  39. package/src/lib/layout-language/ai-layout-listings.d.ts +7 -0
  40. package/src/lib/layout-language/ai-layout-listings.js +12 -0
  41. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  42. package/src/lib/layout-language/ai-layout-pictures.d.ts +27 -8
  43. package/src/lib/layout-language/ai-layout-pictures.js +85 -4
  44. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  45. package/src/lib/layout-language/ai-layout-promised-copy.d.ts +54 -0
  46. package/src/lib/layout-language/ai-layout-promised-copy.js +104 -0
  47. package/src/lib/layout-language/ai-layout-promised-copy.js.map +1 -0
  48. package/src/lib/model/ai-insight.d.ts +9 -0
  49. package/src/lib/model/ai-insight.js +12 -0
  50. package/src/lib/model/ai-insight.js.map +1 -1
  51. package/src/lib/model/ai-job-notice.d.ts +7 -1
  52. package/src/lib/model/ai-job-notice.js +10 -9
  53. package/src/lib/model/ai-job-notice.js.map +1 -1
  54. package/src/lib/model/ai-plan-said-sections.d.ts +81 -0
  55. package/src/lib/model/ai-plan-said-sections.js +168 -0
  56. package/src/lib/model/ai-plan-said-sections.js.map +1 -0
  57. package/src/lib/model/ai-site-job.d.ts +8 -2
  58. package/src/lib/model/ai-site-job.js +7 -1
  59. package/src/lib/model/ai-site-job.js.map +1 -1
  60. package/src/lib/runtime/ai-doctrine-validators.d.ts +15 -1
  61. package/src/lib/runtime/ai-doctrine-validators.js +80 -1
  62. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  63. package/src/lib/runtime/ai-palette.generated.js +11 -3
  64. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * No section that promises its own content later (AGL-3887). A guided start
18
+ * publishes what it builds, so a section whose words say it is still being
19
+ * filled — "We are building this space for the words of families we have
20
+ * worked with", the live Whitmore Lane Family Law home of 2026-10-16 — is a
21
+ * stand-in a visitor reads as an unfinished site. A page answer holding one
22
+ * is asked again; the last answer a generation takes has the section taken
23
+ * out instead (`aiLayoutPageCheck`), its first section aside.
24
+ *
25
+ * Scoped to the page speaking of itself: "this space", "this section", the
26
+ * site's own reviews, photos or portfolio promised for later. "Coming soon"
27
+ * alone is never one: an event, a menu item or a new location is genuinely
28
+ * coming soon on a dated list. A coming-soon site, whose whole point is what
29
+ * is coming, is not held to it.
30
+ */ /** The violation code a section that promises its content later is named by. */ export const AI_LAYOUT_PROMISED_COPY_CODE = 'layout-promised-copy';
31
+ /** What a site owes and may promise later: its customers' words, its pictures, its work. */ const OWED = '(?:testimonials?|reviews?|kind words|feedback|client stories|success stories|stories from (?:our )?\\w+|words from (?:our )?\\w+|photos|pictures|gallery|portfolio|case studies)';
32
+ const PROMISED_COPY = [
33
+ // The page building itself: "We are building this space", "we're putting together this section".
34
+ /\b(?:we(?:['’]re| are)|we(?:['’]ll| will) be)\s+(?:still\s+)?(?:building|growing|gathering|collecting|putting together|working on|filling|adding to)\s+(?:this|the)\s+(?:space|section|page|area|corner)\b/i,
35
+ // "This space will soon…", "This section is being built", "This page is under construction".
36
+ /\bthis\s+(?:space|section|area|corner|page)\s+will\s+soon\b/i,
37
+ /\bthis\s+(?:space|section|area|corner|page)\s+is\s+(?:being\s+(?:built|filled|prepared)|(?:currently\s+)?under\s+construction|reserved\s+for|still\s+(?:empty|growing|being))\b/i,
38
+ /\bthis\s+(?:space|section|area|corner)\s+will\s+(?:hold|feature|carry|fill with|share|be filled|be home to)\b/i,
39
+ /\b(?:page|site|section|space|website)\s+(?:is\s+)?(?:currently\s+)?under\s+construction\b/i,
40
+ // What the site owes, promised for later: "Reviews coming soon", "photos are on their way".
41
+ new RegExp(`\\b${OWED}\\b[^.!?]{0,50}\\b(?:coming soon|on (?:the|its|their) way|to come|being (?:gathered|collected|added))\\b`, 'i'),
42
+ new RegExp(`\\b(?:coming soon|check back|stay tuned)\\b[^.!?]{0,60}\\b(?:${OWED}|this (?:space|section|page))\\b`, 'i'),
43
+ new RegExp(`\\b${OWED}\\b[^.!?]{0,60}\\b(?:check back|stay tuned)\\b`, 'i')
44
+ ];
45
+ /** Whether a line promises the page's own content later, rather than saying it. */ export function aiLayoutPromisesContent(text) {
46
+ return PROMISED_COPY.some((pattern)=>pattern.test(text));
47
+ }
48
+ /** The props a node's own words are in, as a visitor reads them. */ const WORD_PROPS = [
49
+ 'children',
50
+ 'primary',
51
+ 'secondary',
52
+ 'label'
53
+ ];
54
+ /** Every line a node and everything under it says. */ function linesUnder(nodes, id) {
55
+ const lines = [];
56
+ const seen = new Set();
57
+ const stack = [
58
+ id
59
+ ];
60
+ while(stack.length){
61
+ var _node_nodes;
62
+ const next = stack.pop();
63
+ const node = nodes[next];
64
+ if (!node || seen.has(next)) continue;
65
+ seen.add(next);
66
+ for (const prop of WORD_PROPS){
67
+ var _node_props;
68
+ const value = (_node_props = node.props) == null ? void 0 : _node_props[prop];
69
+ if (typeof value === 'string' && value.trim()) lines.push(value.trim());
70
+ }
71
+ stack.push(...(_node_nodes = node.nodes) != null ? _node_nodes : []);
72
+ }
73
+ return lines;
74
+ }
75
+ /**
76
+ * The plan indices of a built page's sections that promise their content
77
+ * later, each with the line that does, in page order.
78
+ */ export function aiLayoutPromisedSections(nodes, sectionIds) {
79
+ const map = nodes;
80
+ return sectionIds.flatMap((id, index)=>{
81
+ if (!id || !map[id]) return [];
82
+ const line = linesUnder(map, id).find(aiLayoutPromisesContent);
83
+ return line ? [
84
+ {
85
+ index,
86
+ line
87
+ }
88
+ ] : [];
89
+ });
90
+ }
91
+ /** The violation that asks again for the sections that promise their content later. */ export function aiLayoutPromisedCopyViolations(screen, found) {
92
+ return found.map(({ index, line })=>{
93
+ var _ref;
94
+ var _screen_sections_index;
95
+ return {
96
+ rule: 14,
97
+ code: AI_LAYOUT_PROMISED_COPY_CODE,
98
+ message: `Section ${index + 1} ("${(_ref = (_screen_sections_index = screen.sections[index]) == null ? void 0 : _screen_sections_index.name) != null ? _ref : ''}") promises content that is not there: "${line}". This site is published exactly as written.`,
99
+ detail: `Write section ${index + 1} about what is true now, in the site's voice: what working together is like, how the work goes, what a visitor can expect. Never say a space is being built or something is coming soon, and never write a review, testimonial or quote nobody gave.`
100
+ };
101
+ });
102
+ }
103
+
104
+ //# sourceMappingURL=ai-layout-promised-copy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/layout-language/ai-layout-promised-copy.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { NodesMap } from '@aglyn/aglyn/types/nodes'\nimport type { AiDoctrineViolation } from '../runtime/ai-doctrine-validators'\n\n/**\n * No section that promises its own content later (AGL-3887). A guided start\n * publishes what it builds, so a section whose words say it is still being\n * filled — \"We are building this space for the words of families we have\n * worked with\", the live Whitmore Lane Family Law home of 2026-10-16 — is a\n * stand-in a visitor reads as an unfinished site. A page answer holding one\n * is asked again; the last answer a generation takes has the section taken\n * out instead (`aiLayoutPageCheck`), its first section aside.\n *\n * Scoped to the page speaking of itself: \"this space\", \"this section\", the\n * site's own reviews, photos or portfolio promised for later. \"Coming soon\"\n * alone is never one: an event, a menu item or a new location is genuinely\n * coming soon on a dated list. A coming-soon site, whose whole point is what\n * is coming, is not held to it.\n */\n\n/** The violation code a section that promises its content later is named by. */\nexport const AI_LAYOUT_PROMISED_COPY_CODE = 'layout-promised-copy'\n\n/** What a site owes and may promise later: its customers' words, its pictures, its work. */\nconst OWED =\n '(?:testimonials?|reviews?|kind words|feedback|client stories|success stories|stories from (?:our )?\\\\w+|words from (?:our )?\\\\w+|photos|pictures|gallery|portfolio|case studies)'\n\nconst PROMISED_COPY: readonly RegExp[] = [\n // The page building itself: \"We are building this space\", \"we're putting together this section\".\n /\\b(?:we(?:['’]re| are)|we(?:['’]ll| will) be)\\s+(?:still\\s+)?(?:building|growing|gathering|collecting|putting together|working on|filling|adding to)\\s+(?:this|the)\\s+(?:space|section|page|area|corner)\\b/i,\n // \"This space will soon…\", \"This section is being built\", \"This page is under construction\".\n /\\bthis\\s+(?:space|section|area|corner|page)\\s+will\\s+soon\\b/i,\n /\\bthis\\s+(?:space|section|area|corner|page)\\s+is\\s+(?:being\\s+(?:built|filled|prepared)|(?:currently\\s+)?under\\s+construction|reserved\\s+for|still\\s+(?:empty|growing|being))\\b/i,\n /\\bthis\\s+(?:space|section|area|corner)\\s+will\\s+(?:hold|feature|carry|fill with|share|be filled|be home to)\\b/i,\n /\\b(?:page|site|section|space|website)\\s+(?:is\\s+)?(?:currently\\s+)?under\\s+construction\\b/i,\n // What the site owes, promised for later: \"Reviews coming soon\", \"photos are on their way\".\n new RegExp(`\\\\b${OWED}\\\\b[^.!?]{0,50}\\\\b(?:coming soon|on (?:the|its|their) way|to come|being (?:gathered|collected|added))\\\\b`, 'i'),\n new RegExp(`\\\\b(?:coming soon|check back|stay tuned)\\\\b[^.!?]{0,60}\\\\b(?:${OWED}|this (?:space|section|page))\\\\b`, 'i'),\n new RegExp(`\\\\b${OWED}\\\\b[^.!?]{0,60}\\\\b(?:check back|stay tuned)\\\\b`, 'i'),\n]\n\n/** Whether a line promises the page's own content later, rather than saying it. */\nexport function aiLayoutPromisesContent(text: string): boolean {\n return PROMISED_COPY.some((pattern) => pattern.test(text))\n}\n\ninterface WordNode {\n componentId?: string\n props?: Record<string, unknown>\n nodes?: string[]\n}\n\n/** The props a node's own words are in, as a visitor reads them. */\nconst WORD_PROPS = ['children', 'primary', 'secondary', 'label'] as const\n\n/** Every line a node and everything under it says. */\nfunction linesUnder(nodes: Readonly<Record<string, WordNode>>, id: string): string[] {\n const lines: string[] = []\n const seen = new Set<string>()\n const stack = [id]\n while (stack.length) {\n const next = stack.pop() as string\n const node = nodes[next]\n if (!node || seen.has(next)) continue\n seen.add(next)\n for (const prop of WORD_PROPS) {\n const value = node.props?.[prop]\n if (typeof value === 'string' && value.trim()) lines.push(value.trim())\n }\n stack.push(...(node.nodes ?? []))\n }\n return lines\n}\n\n/**\n * The plan indices of a built page's sections that promise their content\n * later, each with the line that does, in page order.\n */\nexport function aiLayoutPromisedSections(\n nodes: NodesMap,\n sectionIds: readonly string[],\n): Array<{ index: number; line: string }> {\n const map = nodes as unknown as Readonly<Record<string, WordNode>>\n return sectionIds.flatMap((id, index) => {\n if (!id || !map[id]) return []\n const line = linesUnder(map, id).find(aiLayoutPromisesContent)\n return line ? [{ index, line }] : []\n })\n}\n\n/** The violation that asks again for the sections that promise their content later. */\nexport function aiLayoutPromisedCopyViolations(\n screen: { sections: ReadonlyArray<{ name: string }> },\n found: ReadonlyArray<{ index: number; line: string }>,\n): AiDoctrineViolation[] {\n return found.map(({ index, line }) => ({\n rule: 14,\n code: AI_LAYOUT_PROMISED_COPY_CODE,\n message: `Section ${index + 1} (\"${screen.sections[index]?.name ?? ''}\") promises content that is not there: \"${line}\". This site is published exactly as written.`,\n detail: `Write section ${index + 1} about what is true now, in the site's voice: what working together is like, how the work goes, what a visitor can expect. Never say a space is being built or something is coming soon, and never write a review, testimonial or quote nobody gave.`,\n }))\n}\n"],"names":["AI_LAYOUT_PROMISED_COPY_CODE","OWED","PROMISED_COPY","RegExp","aiLayoutPromisesContent","text","some","pattern","test","WORD_PROPS","linesUnder","nodes","id","lines","seen","Set","stack","length","node","next","pop","has","add","prop","value","props","trim","push","aiLayoutPromisedSections","sectionIds","map","flatMap","index","line","find","aiLayoutPromisedCopyViolations","screen","found","rule","code","message","sections","name","detail"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAKD;;;;;;;;;;;;;;CAcC,GAED,8EAA8E,GAC9E,OAAO,MAAMA,+BAA+B,uBAAsB;AAElE,0FAA0F,GAC1F,MAAMC,OACJ;AAEF,MAAMC,gBAAmC;IACvC,iGAAiG;IACjG;IACA,6FAA6F;IAC7F;IACA;IACA;IACA;IACA,4FAA4F;IAC5F,IAAIC,OAAO,CAAC,GAAG,EAAEF,KAAK,wGAAwG,CAAC,EAAE;IACjI,IAAIE,OAAO,CAAC,6DAA6D,EAAEF,KAAK,gCAAgC,CAAC,EAAE;IACnH,IAAIE,OAAO,CAAC,GAAG,EAAEF,KAAK,8CAA8C,CAAC,EAAE;CACxE;AAED,iFAAiF,GACjF,OAAO,SAASG,wBAAwBC,IAAY;IAClD,OAAOH,cAAcI,IAAI,CAAC,CAACC,UAAYA,QAAQC,IAAI,CAACH;AACtD;AAQA,kEAAkE,GAClE,MAAMI,aAAa;IAAC;IAAY;IAAW;IAAa;CAAQ;AAEhE,oDAAoD,GACpD,SAASC,WAAWC,KAAyC,EAAEC,EAAU;IACvE,MAAMC,QAAkB,EAAE;IAC1B,MAAMC,OAAO,IAAIC;IACjB,MAAMC,QAAQ;QAACJ;KAAG;IAClB,MAAOI,MAAMC,MAAM,CAAE;YASJC;QARf,MAAMC,OAAOH,MAAMI,GAAG;QACtB,MAAMF,OAAOP,KAAK,CAACQ,KAAK;QACxB,IAAI,CAACD,QAAQJ,KAAKO,GAAG,CAACF,OAAO;QAC7BL,KAAKQ,GAAG,CAACH;QACT,KAAK,MAAMI,QAAQd,WAAY;gBACfS;YAAd,MAAMM,SAAQN,cAAAA,KAAKO,KAAK,qBAAVP,WAAY,CAACK,KAAK;YAChC,IAAI,OAAOC,UAAU,YAAYA,MAAME,IAAI,IAAIb,MAAMc,IAAI,CAACH,MAAME,IAAI;QACtE;QACAV,MAAMW,IAAI,KAAKT,cAAAA,KAAKP,KAAK,YAAVO,cAAc,EAAE;IACjC;IACA,OAAOL;AACT;AAEA;;;CAGC,GACD,OAAO,SAASe,yBACdjB,KAAe,EACfkB,UAA6B;IAE7B,MAAMC,MAAMnB;IACZ,OAAOkB,WAAWE,OAAO,CAAC,CAACnB,IAAIoB;QAC7B,IAAI,CAACpB,MAAM,CAACkB,GAAG,CAAClB,GAAG,EAAE,OAAO,EAAE;QAC9B,MAAMqB,OAAOvB,WAAWoB,KAAKlB,IAAIsB,IAAI,CAAC9B;QACtC,OAAO6B,OAAO;YAAC;gBAAED;gBAAOC;YAAK;SAAE,GAAG,EAAE;IACtC;AACF;AAEA,qFAAqF,GACrF,OAAO,SAASE,+BACdC,MAAqD,EACrDC,KAAqD;IAErD,OAAOA,MAAMP,GAAG,CAAC,CAAC,EAAEE,KAAK,EAAEC,IAAI,EAAE;;YAGIG;eAHE;YACrCE,MAAM;YACNC,MAAMvC;YACNwC,SAAS,CAAC,QAAQ,EAAER,QAAQ,EAAE,GAAG,WAAEI,yBAAAA,OAAOK,QAAQ,CAACT,MAAM,qBAAtBI,uBAAwBM,IAAI,mBAAI,GAAG,wCAAwC,EAAET,KAAK,6CAA6C,CAAC;YACnKU,QAAQ,CAAC,cAAc,EAAEX,QAAQ,EAAE,oPAAoP,CAAC;QAC1R;;AACF"}
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type PluginFigureTable } from '@aglyn/aglyn/plugin-manager/plugin-figures';
18
+ import { weeklySummaryPeriod } from '@aglyn/aglyn/app-utils/weekly-summary';
18
19
  import type { AiJob, AiJobOutput } from './ai-jobs.types';
19
20
  /**
20
21
  * Insights (AGL-2915): what an `insight` job answers, in the shape the step
@@ -82,6 +83,14 @@ export declare const AI_INSIGHT_WINDOWS: readonly number[];
82
83
  export declare const AI_INSIGHT_DEFAULT_DAYS = 14;
83
84
  /** The window a weekly digest reads. */
84
85
  export declare const AI_INSIGHT_DIGEST_DAYS = 7;
86
+ /**
87
+ * The week a digest reads (AGL-3867): Monday to Sunday before the Monday it
88
+ * was made, the same week the free Weekly summary reports, so the two emails
89
+ * a person gets that morning quote the same figures. The sweep stamps the
90
+ * week's last day as `through`; a job made before it did reads the week
91
+ * before `now`.
92
+ */
93
+ export declare function aiInsightDigestPeriod(inputs: Record<string, unknown> | null | undefined, now: Date): ReturnType<typeof weeklySummaryPeriod>;
85
94
  /** A window an insight's inputs or a read names, held to the readers' own ranges. */
86
95
  export declare function aiInsightDays(value: unknown): number;
87
96
  /** The most insights one answer carries: a person reads them at a glance. */
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { pluginFigureCellText, pluginFigureIsoWeek } from "@aglyn/aglyn/plugin-manager/plugin-figures";
17
+ import { weeklySummaryPeriod } from "@aglyn/aglyn/app-utils/weekly-summary";
17
18
  export const AI_INSIGHT_SURFACES = [
18
19
  'analytics',
19
20
  'datasets',
@@ -119,6 +120,17 @@ export const AI_INSIGHT_SURFACES = [
119
120
  ];
120
121
  /** The window a question that names none reads: the Traffic card's default. */ export const AI_INSIGHT_DEFAULT_DAYS = 14;
121
122
  /** The window a weekly digest reads. */ export const AI_INSIGHT_DIGEST_DAYS = 7;
123
+ /**
124
+ * The week a digest reads (AGL-3867): Monday to Sunday before the Monday it
125
+ * was made, the same week the free Weekly summary reports, so the two emails
126
+ * a person gets that morning quote the same figures. The sweep stamps the
127
+ * week's last day as `through`; a job made before it did reads the week
128
+ * before `now`.
129
+ */ export function aiInsightDigestPeriod(inputs, now) {
130
+ const through = typeof (inputs == null ? void 0 : inputs['through']) === 'string' ? inputs['through'] : '';
131
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(through) ? Date.parse(`${through}T00:00:00Z`) : Number.NaN;
132
+ return weeklySummaryPeriod(Number.isFinite(day) ? new Date(day + 86400000) : now);
133
+ }
122
134
  /** A window an insight's inputs or a read names, held to the readers' own ranges. */ export function aiInsightDays(value) {
123
135
  const days = typeof value === 'number' ? value : Number(value);
124
136
  return AI_INSIGHT_WINDOWS.includes(days) ? days : AI_INSIGHT_DEFAULT_DAYS;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-insight.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 {\n pluginFigureCellText,\n pluginFigureIsoWeek,\n type PluginFigureTable,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport type { AiJob, AiJobOutput } from './ai-jobs.types'\n\n/**\n * Insights (AGL-2915): what an `insight` job answers, in the shape the step\n * writes and the console reads. Pure data — no server import — so the answer\n * card and the digest email type what they render from the module the step\n * writes it with.\n *\n * ## A number is read, never written\n *\n * The model never runs a query and never sees a record. Code reads figures —\n * traffic, orders, bookings, forms, campaigns, datasets, experiments — through\n * fixed, typed readers, each answering with one compact table that says where\n * its figures come from. The model picks the readers and phrases what their\n * tables say, and every insight it writes cites the rows its figures come from.\n * An insight whose numbers cannot be found in the rows it cites is left out\n * (`runtime/ai-insight-check.ts`), so each one a person reads can be traced to\n * a number they can open in the console.\n *\n * ## Read-only, and kept apart from the job\n *\n * An insight job writes one document besides its own record: the answer, at\n * `orgs/{orgId}/aiInsights/{jobId}`, which no rule lets a client read. The\n * job's output names it and carries no figure, because a job document is\n * readable by every member of the workspace and the figures are not: a\n * collaborator on one site may not see another site's revenue. The answer is\n * served by `GET /api/ai/insights/{jobId}` to the member who asked and, for a\n * weekly digest, to the members who reach its site.\n */\n\n/**\n * Where an insight was asked: the console page whose Assist panel — or\n * whose \"Ask AI about these numbers\" card — asked it, or the weekly digest,\n * which no person asked. A site's Marketing, Automation and Bookings pages\n * and the workspace's own pages joined at AGL-3603.\n */\nexport type AiInsightSurface =\n | 'analytics'\n | 'datasets'\n | 'crm-reports'\n | 'marketing'\n | 'automations'\n | 'bookings'\n | 'workspace'\n | 'digest'\n\nexport const AI_INSIGHT_SURFACES: readonly AiInsightSurface[] = [\n 'analytics',\n 'datasets',\n 'crm-reports',\n 'marketing',\n 'automations',\n 'bookings',\n 'workspace',\n 'digest',\n]\n\n/** The surfaces a person asks from. */\nexport const AI_INSIGHT_ASK_SURFACES: readonly AiInsightSurface[] = [\n 'analytics',\n 'datasets',\n 'crm-reports',\n 'marketing',\n 'automations',\n 'bookings',\n 'workspace',\n]\n\n/** The surface an insight job's inputs name, or `null` for one no insight job is asked from. */\nexport function aiInsightSurface(inputs: Readonly<Record<string, unknown>> | null | undefined): AiInsightSurface | null {\n const surface = inputs?.['surface']\n return typeof surface === 'string' && (AI_INSIGHT_SURFACES as readonly string[]).includes(surface)\n ? (surface as AiInsightSurface)\n : null\n}\n\n/**\n * The readers a surface may offer, by the first word of a reader's id. The\n * Analytics page — and a site's dashboard — answers about the site's traffic,\n * what it sold, took and collected, its automations' runs and its CRM\n * pipeline; a Data page about its datasets; CRM Reports about where people\n * came from, what they did and the pipeline; the Marketing page about what\n * its campaigns, overlays and tests caused and earned, beside the traffic and\n * the forms they drove; the Automation page about its runs; the Bookings page\n * about its bookings; and the workspace's own pages about its pipeline and\n * its datasets.\n */\nexport const AI_INSIGHT_SURFACE_READERS: Readonly<Record<AiInsightSurface, readonly string[]>> = {\n // `funnels` (AGL-3605): a site's funnels, step by step, beside its traffic.\n analytics: ['automations', 'bookings', 'commerce', 'crm', 'forms', 'funnels', 'marketing', 'traffic'],\n datasets: ['datasets'],\n 'crm-reports': ['bookings', 'commerce', 'crm', 'forms', 'marketing', 'traffic'],\n marketing: ['commerce', 'forms', 'marketing', 'traffic'],\n automations: ['automations'],\n bookings: ['bookings'],\n workspace: ['crm', 'datasets'],\n digest: ['bookings', 'commerce', 'forms', 'marketing', 'traffic'],\n}\n\n/** Whether a surface answers about one site; the Data page and the workspace's pages may answer about the whole workspace. */\nexport function aiInsightSurfaceNeedsSite(surface: AiInsightSurface): boolean {\n return surface !== 'datasets' && surface !== 'workspace'\n}\n\n/**\n * The readers a weekly digest reads, in the order the digest names them:\n * the traffic change, the top page, the days that stood out, the forms, the\n * campaigns, and what the site sold and took. Code reads these; no model\n * picks them.\n */\nexport const AI_INSIGHT_DIGEST_READERS: readonly string[] = [\n 'traffic.summary',\n 'traffic.pages',\n 'traffic.daily',\n 'forms.performance',\n 'marketing.campaigns',\n 'commerce.sales',\n 'bookings.services',\n]\n\n/** The windows, in days, an insight reads: the Traffic card's own ranges. */\nexport const AI_INSIGHT_WINDOWS: readonly number[] = [7, 14, 30, 90]\n\n/** The window a question that names none reads: the Traffic card's default. */\nexport const AI_INSIGHT_DEFAULT_DAYS = 14\n\n/** The window a weekly digest reads. */\nexport const AI_INSIGHT_DIGEST_DAYS = 7\n\n/** A window an insight's inputs or a read names, held to the readers' own ranges. */\nexport function aiInsightDays(value: unknown): number {\n const days = typeof value === 'number' ? value : Number(value)\n return AI_INSIGHT_WINDOWS.includes(days) ? days : AI_INSIGHT_DEFAULT_DAYS\n}\n\n/** The most insights one answer carries: a person reads them at a glance. */\nexport const AI_INSIGHT_MAX_INSIGHTS = 5\n\n/** The longest one insight may run, in characters. */\nexport const AI_INSIGHT_MAX_CHARS = 280\n\n/** The longest the sentence about what the figures could not answer may run. */\nexport const AI_INSIGHT_GAP_MAX_CHARS = 200\n\n/** The most tables one citation list names. */\nexport const AI_INSIGHT_MAX_CITES = 3\n\n/** The most rows one citation names. */\nexport const AI_INSIGHT_MAX_CITED_ROWS = 10\n\n/** The most readers one answer reads. */\nexport const AI_INSIGHT_MAX_READS = 4\n\n/** The longest question an insight job admits. */\nexport const AI_INSIGHT_QUESTION_MAX_CHARS = 1_000\n\n/**\n * A table as an insight job keeps it: the figures a reader returned, the\n * handle the model cites them by, and the reader they came from.\n */\nexport interface AiInsightTable extends PluginFigureTable {\n /** `t1`, `t2`, …, in the order the job read them. */\n ref: string\n /** The reader's id (`commerce.sales`). */\n reader: string\n /** The window the reader was asked for, in days; 0 for a reader of current counts. */\n days: number\n /** Whether the table is about one site (its path under that site) or the workspace. */\n scope: 'site' | 'org'\n}\n\n/** The rows of one table an insight's figures come from, by index. */\nexport interface AiInsightCitation {\n table: string\n rows: number[]\n}\n\nexport interface AiInsight {\n /** One or two plain sentences. */\n text: string\n cites: AiInsightCitation[]\n}\n\n/** What the model answers with. */\nexport interface AiInsightAnswer {\n insights: AiInsight[]\n /** What the figures could not answer, in one sentence without numbers; `null` when nothing. */\n gap: string | null\n}\n\n/** The answer collection under the org: server-written, and read through the insights door alone. */\nexport const AI_INSIGHTS_COLLECTION = 'aiInsights'\n\n/** An insight job's answer, as the step stores it. */\nexport interface AiInsightRecord {\n jobId: string\n orgId: string\n hostId: string | null\n surface: AiInsightSurface\n /** The member whose question it answers; for a digest, the member it was made for. */\n createdBy: string\n /** The question, verbatim; a digest's is the digest's own title. */\n question: string\n /** The window the answer was asked about, in days. */\n days: number\n /** The insights that trace to the rows they cite; every other one was left out. */\n insights: AiInsight[]\n gap: string | null\n /** Every table the job read, so each citation opens the figures it names. */\n tables: AiInsightTable[]\n /** How many insights the answer carried that were left out as not traceable. */\n left: number\n /** The ISO week a digest covers (`2026-W38`); `null` on a question. */\n week: string | null\n createdAtMs: number\n}\n\n/** The output id an answer is named by. */\nexport const AI_INSIGHT_OUTPUT_LABEL = 'Answer'\n\n/**\n * The output an insight job records: the answer's address and how many\n * insights it holds, and no figure. The count is a statement about the\n * answer rather than about the site, so every member may read it.\n */\nexport function aiInsightOutput(\n job: Pick<AiJob, '$id' | 'hostId'>,\n record: Pick<AiInsightRecord, 'insights' | 'surface'>,\n hostSubdomain: string | null,\n): AiJobOutput {\n const count = record.insights.length\n return {\n resource: 'insight',\n id: job.$id,\n versionId: null,\n hostId: job.hostId ?? null,\n hostSubdomain,\n label: record.surface === 'digest' ? 'Weekly insights' : AI_INSIGHT_OUTPUT_LABEL,\n note: count\n ? `${count} ${count === 1 ? 'insight' : 'insights'}, each traced to the figures it cites.`\n : 'The figures did not answer this question.',\n }\n}\n\n/** The answer on the wire: the stored record, with nothing a client does not render. */\nexport type AiInsightAnswerWire = AiInsightRecord\n\n/**\n * The Assist panel's insight surface for a console path, or `null` off one:\n * a site's dashboard, Analytics, Data, CRM Reports, Marketing, Automation and\n * Bookings pages; the workspace's Data page; and the workspace's sites page\n * and CRM Reports, which read the workspace's pipeline and datasets. The\n * organization's Marketing page reads site figures, so it is not one — it\n * asks about the site it is showing, through its own in-page entry.\n */\nexport function aiInsightSurfaceForPath(\n pathname: string | null | undefined,\n): { surface: Exclude<AiInsightSurface, 'digest'>; host: string | null } | null {\n const segments = String(pathname ?? '')\n .split('?')[0]\n .split('/')\n .filter(Boolean)\n // `/{org}/hosts/{host}/…` on a site, `/{org}/…` on the workspace.\n if (segments[1] === 'hosts' && segments.length >= 4) {\n const host = segments[2]\n const rest = segments.slice(3)\n if (rest[0] === 'analytics') return { surface: 'analytics', host }\n if (rest[0] === 'data') return { surface: 'datasets', host }\n if (rest[0] === 'crm' && rest[1] === 'reports') return { surface: 'crm-reports', host }\n if (rest[0] === 'marketing') return { surface: 'marketing', host }\n if (rest[0] === 'automation') return { surface: 'automations', host }\n if (rest[0] === 'bookings') return { surface: 'bookings', host }\n return null\n }\n // The site's dashboard is `/{org}/hosts/{host}`.\n if (segments[1] === 'hosts' && segments.length === 3) return { surface: 'analytics', host: segments[2] }\n if (segments.length >= 2 && segments[1] === 'data') return { surface: 'datasets', host: null }\n // The workspace's sites page, `/{org}/hosts`, which carries its dashboard row.\n if (segments.length === 2 && segments[1] === 'hosts') return { surface: 'workspace', host: null }\n if (segments[1] === 'crm' && segments[2] === 'reports') return { surface: 'workspace', host: null }\n return null\n}\n\n/** The console page a table's figures come from, or `null` when the table names none. */\nexport function aiInsightSourceHref(\n table: Pick<AiInsightTable, 'source' | 'scope'>,\n where: { orgSlug: string; host: string | null },\n): string | null {\n const path = table.source.path\n if (!path || !where.orgSlug) return null\n if (table.scope === 'org') return `/${where.orgSlug}/${path}`\n return where.host ? `/${where.orgSlug}/hosts/${where.host}/${path}` : null\n}\n\n/** How a cell reads to a person, by its column's kind; `null` is \"—\". */\nexport function aiInsightCellText(\n table: Pick<AiInsightTable, 'columns'>,\n key: string,\n value: string | number | null | undefined,\n): string {\n return pluginFigureCellText(table, key, value)\n}\n\n/** One cited row as a line a person reads: every column, labeled. */\nexport function aiInsightRowText(table: Pick<AiInsightTable, 'columns' | 'rows'>, index: number): string {\n const row = table.rows[index]\n if (!row) return ''\n return table.columns\n .map((column) => `${column.label}: ${aiInsightCellText(table, column.key, row[column.key])}`)\n .join(' · ')\n}\n\n/** The ISO 8601 week an instant falls in, in UTC (`2026-W38`). */\nexport function aiInsightIsoWeek(at: Date): string {\n return pluginFigureIsoWeek(at)\n}\n"],"names":["pluginFigureCellText","pluginFigureIsoWeek","AI_INSIGHT_SURFACES","AI_INSIGHT_ASK_SURFACES","aiInsightSurface","inputs","surface","includes","AI_INSIGHT_SURFACE_READERS","analytics","datasets","marketing","automations","bookings","workspace","digest","aiInsightSurfaceNeedsSite","AI_INSIGHT_DIGEST_READERS","AI_INSIGHT_WINDOWS","AI_INSIGHT_DEFAULT_DAYS","AI_INSIGHT_DIGEST_DAYS","aiInsightDays","value","days","Number","AI_INSIGHT_MAX_INSIGHTS","AI_INSIGHT_MAX_CHARS","AI_INSIGHT_GAP_MAX_CHARS","AI_INSIGHT_MAX_CITES","AI_INSIGHT_MAX_CITED_ROWS","AI_INSIGHT_MAX_READS","AI_INSIGHT_QUESTION_MAX_CHARS","AI_INSIGHTS_COLLECTION","AI_INSIGHT_OUTPUT_LABEL","aiInsightOutput","job","record","hostSubdomain","count","insights","length","resource","id","$id","versionId","hostId","label","note","aiInsightSurfaceForPath","pathname","segments","String","split","filter","Boolean","host","rest","slice","aiInsightSourceHref","table","where","path","source","orgSlug","scope","aiInsightCellText","key","aiInsightRowText","index","row","rows","columns","map","column","join","aiInsightIsoWeek","at"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-insight.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 {\n pluginFigureCellText,\n pluginFigureIsoWeek,\n type PluginFigureTable,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport { weeklySummaryPeriod } from '@aglyn/aglyn/app-utils/weekly-summary'\nimport type { AiJob, AiJobOutput } from './ai-jobs.types'\n\n/**\n * Insights (AGL-2915): what an `insight` job answers, in the shape the step\n * writes and the console reads. Pure data — no server import — so the answer\n * card and the digest email type what they render from the module the step\n * writes it with.\n *\n * ## A number is read, never written\n *\n * The model never runs a query and never sees a record. Code reads figures —\n * traffic, orders, bookings, forms, campaigns, datasets, experiments — through\n * fixed, typed readers, each answering with one compact table that says where\n * its figures come from. The model picks the readers and phrases what their\n * tables say, and every insight it writes cites the rows its figures come from.\n * An insight whose numbers cannot be found in the rows it cites is left out\n * (`runtime/ai-insight-check.ts`), so each one a person reads can be traced to\n * a number they can open in the console.\n *\n * ## Read-only, and kept apart from the job\n *\n * An insight job writes one document besides its own record: the answer, at\n * `orgs/{orgId}/aiInsights/{jobId}`, which no rule lets a client read. The\n * job's output names it and carries no figure, because a job document is\n * readable by every member of the workspace and the figures are not: a\n * collaborator on one site may not see another site's revenue. The answer is\n * served by `GET /api/ai/insights/{jobId}` to the member who asked and, for a\n * weekly digest, to the members who reach its site.\n */\n\n/**\n * Where an insight was asked: the console page whose Assist panel — or\n * whose \"Ask AI about these numbers\" card — asked it, or the weekly digest,\n * which no person asked. A site's Marketing, Automation and Bookings pages\n * and the workspace's own pages joined at AGL-3603.\n */\nexport type AiInsightSurface =\n | 'analytics'\n | 'datasets'\n | 'crm-reports'\n | 'marketing'\n | 'automations'\n | 'bookings'\n | 'workspace'\n | 'digest'\n\nexport const AI_INSIGHT_SURFACES: readonly AiInsightSurface[] = [\n 'analytics',\n 'datasets',\n 'crm-reports',\n 'marketing',\n 'automations',\n 'bookings',\n 'workspace',\n 'digest',\n]\n\n/** The surfaces a person asks from. */\nexport const AI_INSIGHT_ASK_SURFACES: readonly AiInsightSurface[] = [\n 'analytics',\n 'datasets',\n 'crm-reports',\n 'marketing',\n 'automations',\n 'bookings',\n 'workspace',\n]\n\n/** The surface an insight job's inputs name, or `null` for one no insight job is asked from. */\nexport function aiInsightSurface(inputs: Readonly<Record<string, unknown>> | null | undefined): AiInsightSurface | null {\n const surface = inputs?.['surface']\n return typeof surface === 'string' && (AI_INSIGHT_SURFACES as readonly string[]).includes(surface)\n ? (surface as AiInsightSurface)\n : null\n}\n\n/**\n * The readers a surface may offer, by the first word of a reader's id. The\n * Analytics page — and a site's dashboard — answers about the site's traffic,\n * what it sold, took and collected, its automations' runs and its CRM\n * pipeline; a Data page about its datasets; CRM Reports about where people\n * came from, what they did and the pipeline; the Marketing page about what\n * its campaigns, overlays and tests caused and earned, beside the traffic and\n * the forms they drove; the Automation page about its runs; the Bookings page\n * about its bookings; and the workspace's own pages about its pipeline and\n * its datasets.\n */\nexport const AI_INSIGHT_SURFACE_READERS: Readonly<Record<AiInsightSurface, readonly string[]>> = {\n // `funnels` (AGL-3605): a site's funnels, step by step, beside its traffic.\n analytics: ['automations', 'bookings', 'commerce', 'crm', 'forms', 'funnels', 'marketing', 'traffic'],\n datasets: ['datasets'],\n 'crm-reports': ['bookings', 'commerce', 'crm', 'forms', 'marketing', 'traffic'],\n marketing: ['commerce', 'forms', 'marketing', 'traffic'],\n automations: ['automations'],\n bookings: ['bookings'],\n workspace: ['crm', 'datasets'],\n digest: ['bookings', 'commerce', 'forms', 'marketing', 'traffic'],\n}\n\n/** Whether a surface answers about one site; the Data page and the workspace's pages may answer about the whole workspace. */\nexport function aiInsightSurfaceNeedsSite(surface: AiInsightSurface): boolean {\n return surface !== 'datasets' && surface !== 'workspace'\n}\n\n/**\n * The readers a weekly digest reads, in the order the digest names them:\n * the traffic change, the top page, the days that stood out, the forms, the\n * campaigns, and what the site sold and took. Code reads these; no model\n * picks them.\n */\nexport const AI_INSIGHT_DIGEST_READERS: readonly string[] = [\n 'traffic.summary',\n 'traffic.pages',\n 'traffic.daily',\n 'forms.performance',\n 'marketing.campaigns',\n 'commerce.sales',\n 'bookings.services',\n]\n\n/** The windows, in days, an insight reads: the Traffic card's own ranges. */\nexport const AI_INSIGHT_WINDOWS: readonly number[] = [7, 14, 30, 90]\n\n/** The window a question that names none reads: the Traffic card's default. */\nexport const AI_INSIGHT_DEFAULT_DAYS = 14\n\n/** The window a weekly digest reads. */\nexport const AI_INSIGHT_DIGEST_DAYS = 7\n\n/**\n * The week a digest reads (AGL-3867): Monday to Sunday before the Monday it\n * was made, the same week the free Weekly summary reports, so the two emails\n * a person gets that morning quote the same figures. The sweep stamps the\n * week's last day as `through`; a job made before it did reads the week\n * before `now`.\n */\nexport function aiInsightDigestPeriod(\n inputs: Record<string, unknown> | null | undefined,\n now: Date,\n): ReturnType<typeof weeklySummaryPeriod> {\n const through = typeof inputs?.['through'] === 'string' ? inputs['through'] : ''\n const day = /^\\d{4}-\\d{2}-\\d{2}$/.test(through) ? Date.parse(`${through}T00:00:00Z`) : Number.NaN\n return weeklySummaryPeriod(Number.isFinite(day) ? new Date(day + 86_400_000) : now)\n}\n\n/** A window an insight's inputs or a read names, held to the readers' own ranges. */\nexport function aiInsightDays(value: unknown): number {\n const days = typeof value === 'number' ? value : Number(value)\n return AI_INSIGHT_WINDOWS.includes(days) ? days : AI_INSIGHT_DEFAULT_DAYS\n}\n\n/** The most insights one answer carries: a person reads them at a glance. */\nexport const AI_INSIGHT_MAX_INSIGHTS = 5\n\n/** The longest one insight may run, in characters. */\nexport const AI_INSIGHT_MAX_CHARS = 280\n\n/** The longest the sentence about what the figures could not answer may run. */\nexport const AI_INSIGHT_GAP_MAX_CHARS = 200\n\n/** The most tables one citation list names. */\nexport const AI_INSIGHT_MAX_CITES = 3\n\n/** The most rows one citation names. */\nexport const AI_INSIGHT_MAX_CITED_ROWS = 10\n\n/** The most readers one answer reads. */\nexport const AI_INSIGHT_MAX_READS = 4\n\n/** The longest question an insight job admits. */\nexport const AI_INSIGHT_QUESTION_MAX_CHARS = 1_000\n\n/**\n * A table as an insight job keeps it: the figures a reader returned, the\n * handle the model cites them by, and the reader they came from.\n */\nexport interface AiInsightTable extends PluginFigureTable {\n /** `t1`, `t2`, …, in the order the job read them. */\n ref: string\n /** The reader's id (`commerce.sales`). */\n reader: string\n /** The window the reader was asked for, in days; 0 for a reader of current counts. */\n days: number\n /** Whether the table is about one site (its path under that site) or the workspace. */\n scope: 'site' | 'org'\n}\n\n/** The rows of one table an insight's figures come from, by index. */\nexport interface AiInsightCitation {\n table: string\n rows: number[]\n}\n\nexport interface AiInsight {\n /** One or two plain sentences. */\n text: string\n cites: AiInsightCitation[]\n}\n\n/** What the model answers with. */\nexport interface AiInsightAnswer {\n insights: AiInsight[]\n /** What the figures could not answer, in one sentence without numbers; `null` when nothing. */\n gap: string | null\n}\n\n/** The answer collection under the org: server-written, and read through the insights door alone. */\nexport const AI_INSIGHTS_COLLECTION = 'aiInsights'\n\n/** An insight job's answer, as the step stores it. */\nexport interface AiInsightRecord {\n jobId: string\n orgId: string\n hostId: string | null\n surface: AiInsightSurface\n /** The member whose question it answers; for a digest, the member it was made for. */\n createdBy: string\n /** The question, verbatim; a digest's is the digest's own title. */\n question: string\n /** The window the answer was asked about, in days. */\n days: number\n /** The insights that trace to the rows they cite; every other one was left out. */\n insights: AiInsight[]\n gap: string | null\n /** Every table the job read, so each citation opens the figures it names. */\n tables: AiInsightTable[]\n /** How many insights the answer carried that were left out as not traceable. */\n left: number\n /** The ISO week a digest covers (`2026-W38`); `null` on a question. */\n week: string | null\n createdAtMs: number\n}\n\n/** The output id an answer is named by. */\nexport const AI_INSIGHT_OUTPUT_LABEL = 'Answer'\n\n/**\n * The output an insight job records: the answer's address and how many\n * insights it holds, and no figure. The count is a statement about the\n * answer rather than about the site, so every member may read it.\n */\nexport function aiInsightOutput(\n job: Pick<AiJob, '$id' | 'hostId'>,\n record: Pick<AiInsightRecord, 'insights' | 'surface'>,\n hostSubdomain: string | null,\n): AiJobOutput {\n const count = record.insights.length\n return {\n resource: 'insight',\n id: job.$id,\n versionId: null,\n hostId: job.hostId ?? null,\n hostSubdomain,\n label: record.surface === 'digest' ? 'Weekly insights' : AI_INSIGHT_OUTPUT_LABEL,\n note: count\n ? `${count} ${count === 1 ? 'insight' : 'insights'}, each traced to the figures it cites.`\n : 'The figures did not answer this question.',\n }\n}\n\n/** The answer on the wire: the stored record, with nothing a client does not render. */\nexport type AiInsightAnswerWire = AiInsightRecord\n\n/**\n * The Assist panel's insight surface for a console path, or `null` off one:\n * a site's dashboard, Analytics, Data, CRM Reports, Marketing, Automation and\n * Bookings pages; the workspace's Data page; and the workspace's sites page\n * and CRM Reports, which read the workspace's pipeline and datasets. The\n * organization's Marketing page reads site figures, so it is not one — it\n * asks about the site it is showing, through its own in-page entry.\n */\nexport function aiInsightSurfaceForPath(\n pathname: string | null | undefined,\n): { surface: Exclude<AiInsightSurface, 'digest'>; host: string | null } | null {\n const segments = String(pathname ?? '')\n .split('?')[0]\n .split('/')\n .filter(Boolean)\n // `/{org}/hosts/{host}/…` on a site, `/{org}/…` on the workspace.\n if (segments[1] === 'hosts' && segments.length >= 4) {\n const host = segments[2]\n const rest = segments.slice(3)\n if (rest[0] === 'analytics') return { surface: 'analytics', host }\n if (rest[0] === 'data') return { surface: 'datasets', host }\n if (rest[0] === 'crm' && rest[1] === 'reports') return { surface: 'crm-reports', host }\n if (rest[0] === 'marketing') return { surface: 'marketing', host }\n if (rest[0] === 'automation') return { surface: 'automations', host }\n if (rest[0] === 'bookings') return { surface: 'bookings', host }\n return null\n }\n // The site's dashboard is `/{org}/hosts/{host}`.\n if (segments[1] === 'hosts' && segments.length === 3) return { surface: 'analytics', host: segments[2] }\n if (segments.length >= 2 && segments[1] === 'data') return { surface: 'datasets', host: null }\n // The workspace's sites page, `/{org}/hosts`, which carries its dashboard row.\n if (segments.length === 2 && segments[1] === 'hosts') return { surface: 'workspace', host: null }\n if (segments[1] === 'crm' && segments[2] === 'reports') return { surface: 'workspace', host: null }\n return null\n}\n\n/** The console page a table's figures come from, or `null` when the table names none. */\nexport function aiInsightSourceHref(\n table: Pick<AiInsightTable, 'source' | 'scope'>,\n where: { orgSlug: string; host: string | null },\n): string | null {\n const path = table.source.path\n if (!path || !where.orgSlug) return null\n if (table.scope === 'org') return `/${where.orgSlug}/${path}`\n return where.host ? `/${where.orgSlug}/hosts/${where.host}/${path}` : null\n}\n\n/** How a cell reads to a person, by its column's kind; `null` is \"—\". */\nexport function aiInsightCellText(\n table: Pick<AiInsightTable, 'columns'>,\n key: string,\n value: string | number | null | undefined,\n): string {\n return pluginFigureCellText(table, key, value)\n}\n\n/** One cited row as a line a person reads: every column, labeled. */\nexport function aiInsightRowText(table: Pick<AiInsightTable, 'columns' | 'rows'>, index: number): string {\n const row = table.rows[index]\n if (!row) return ''\n return table.columns\n .map((column) => `${column.label}: ${aiInsightCellText(table, column.key, row[column.key])}`)\n .join(' · ')\n}\n\n/** The ISO 8601 week an instant falls in, in UTC (`2026-W38`). */\nexport function aiInsightIsoWeek(at: Date): string {\n return pluginFigureIsoWeek(at)\n}\n"],"names":["pluginFigureCellText","pluginFigureIsoWeek","weeklySummaryPeriod","AI_INSIGHT_SURFACES","AI_INSIGHT_ASK_SURFACES","aiInsightSurface","inputs","surface","includes","AI_INSIGHT_SURFACE_READERS","analytics","datasets","marketing","automations","bookings","workspace","digest","aiInsightSurfaceNeedsSite","AI_INSIGHT_DIGEST_READERS","AI_INSIGHT_WINDOWS","AI_INSIGHT_DEFAULT_DAYS","AI_INSIGHT_DIGEST_DAYS","aiInsightDigestPeriod","now","through","day","test","Date","parse","Number","NaN","isFinite","aiInsightDays","value","days","AI_INSIGHT_MAX_INSIGHTS","AI_INSIGHT_MAX_CHARS","AI_INSIGHT_GAP_MAX_CHARS","AI_INSIGHT_MAX_CITES","AI_INSIGHT_MAX_CITED_ROWS","AI_INSIGHT_MAX_READS","AI_INSIGHT_QUESTION_MAX_CHARS","AI_INSIGHTS_COLLECTION","AI_INSIGHT_OUTPUT_LABEL","aiInsightOutput","job","record","hostSubdomain","count","insights","length","resource","id","$id","versionId","hostId","label","note","aiInsightSurfaceForPath","pathname","segments","String","split","filter","Boolean","host","rest","slice","aiInsightSourceHref","table","where","path","source","orgSlug","scope","aiInsightCellText","key","aiInsightRowText","index","row","rows","columns","map","column","join","aiInsightIsoWeek","at"],"mappings":"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"}
@@ -73,4 +73,10 @@ export declare function aiJobNoticeLink(job: AiJobNoticeSource, to: AiJobNoticeT
73
73
  * person who created it. Customer-safe throughout: a stopped job's sentence is
74
74
  * the job's own `error`, which the machine writes as a fixed sentence.
75
75
  */
76
- export declare function aiJobNotice(job: AiJobNoticeSource, to: AiJobNoticeTransition): AiJobNotice;
76
+ /** The site a job worked on, as its notice names it: its name, and where it is live. */
77
+ export interface AiJobNoticeSite {
78
+ name: string;
79
+ /** The site's live address, absolute; `null` when it has none yet. */
80
+ url: string | null;
81
+ }
82
+ export declare function aiJobNotice(job: AiJobNoticeSource, to: AiJobNoticeTransition, site?: AiJobNoticeSite | null): AiJobNotice;
@@ -76,11 +76,7 @@ import { aiCustomerSafeCopy, aiJobRefundCopy } from "./ai-job-failure-copy.js";
76
76
  }
77
77
  return `${base}?${AI_JOB_LINK_PARAM}=${encodeURIComponent(job.$id)}`;
78
78
  }
79
- /**
80
- * The in-app notification for one change of one job (AGL-3593), for the
81
- * person who created it. Customer-safe throughout: a stopped job's sentence is
82
- * the job's own `error`, which the machine writes as a fixed sentence.
83
- */ export function aiJobNotice(job, to) {
79
+ export function aiJobNotice(job, to, site = null) {
84
80
  const noun = aiJobKindNoun(job.kind);
85
81
  const scope = _extends({
86
82
  orgId: job.orgId
@@ -142,17 +138,22 @@ import { aiCustomerSafeCopy, aiJobRefundCopy } from "./ai-job-failure-copy.js";
142
138
  // A guided site start publishes its pages when it finishes (AGL-3596):
143
139
  // with one live, the site is, and the build page lists any that stayed
144
140
  // drafts with the reason for each.
145
- const live = job.kind === 'site' ? (_ref = (_job_sitePublish = job.sitePublish) == null ? void 0 : _job_sitePublish.published.length) != null ? _ref : 0 : 0;
146
- if (live > 0) {
141
+ const published = job.kind === 'site' ? (_ref = (_job_sitePublish = job.sitePublish) == null ? void 0 : _job_sitePublish.published.length) != null ? _ref : 0 : 0;
142
+ if (published > 0) {
147
143
  var _ref1;
148
144
  var _job_sitePublish1;
149
145
  const drafts = (_ref1 = (_job_sitePublish1 = job.sitePublish) == null ? void 0 : _job_sitePublish1.drafts.length) != null ? _ref1 : 0;
150
146
  const stayed = drafts === 1 ? 'one that stayed a draft' : `${drafts} that stayed drafts`;
147
+ // Named, with its address (AGL-3889): someone with several sites, or
148
+ // reading the email days later, cannot tell which site went live from
149
+ // "Your site is live", nor where to see it.
150
+ const live = site ? `${site.name} is live` : 'Your site is live';
151
+ const at = (site == null ? void 0 : site.url) ? ` at ${site.url}` : '';
151
152
  return _extends({
152
153
  type: 'content.aiJobDone',
153
154
  level,
154
- title: partial ? 'Your site is live, but part of it wasn’t built' : 'Your site is live',
155
- body: partial ? `${partial} ${drafts > 0 ? `What was built is published, except ${stayed}.` : 'What was built is published.'}` : drafts > 0 ? `Your pages are published, except ${stayed}. Open it to see why.` : 'Your pages are published. Open it to view your site or edit your pages.',
155
+ title: partial ? `${live}, but part of it wasn’t built` : live,
156
+ body: partial ? `${partial} ${drafts > 0 ? `What was built is published${at}, except ${stayed}.` : `What was built is published${at}.`}` : drafts > 0 ? `Your pages are published${at}, except ${stayed}. Open the build page to see why.` : at ? `Your pages are published${at}. Visit your site there, or open it in the console to edit your pages.` : 'Your pages are published. Open it to view your site or edit your pages.',
156
157
  link
157
158
  }, scope);
158
159
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-job-notice.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynNotification } from '@aglyn/aglyn/app-utils/notifications'\nimport { aiBuildOutcomeLine, aiSitePartialCopy } from './ai-build-progress'\nimport { aiJobKindNoun } from './ai-job-activity'\nimport { aiCustomerSafeCopy, aiJobRefundCopy } from './ai-job-failure-copy'\nimport type { AiJobItemLedger, AiJobKind, AiJobOutput, AiJobReviewReason, AiJobSitePublish } from './ai-jobs.types'\n\n/** The besigner segment each versioned resource lives under. */\nexport const AI_JOB_BESIGNER_SEGMENT: Partial<Record<AiJobOutput['resource'], string>> = {\n screen: 'screens',\n reusableComponent: 'components',\n layout: 'layouts',\n template: 'templates',\n // An email design is a screen: it opens in the screen besigner, as the\n // Emails page's own Edit design does.\n emailScreen: 'screens',\n}\n\n/**\n * The query parameter a console address carries to open AI jobs on one job\n * (AGL-3593): the Assist panel reads it on arrival.\n */\nexport const AI_JOB_LINK_PARAM = 'aiJob'\n\n/** Where a site's \"Building your site\" page lives, under the site (AGL-3594). */\nexport const AI_SITE_BUILD_HREF = '/ai-jobs'\n\n/**\n * What a job the meter paused says comes next (AGL-3660), on its notice and\n * its page alike: nothing it built is lost, and it carries on from where it\n * stopped — by Resume once there are credits, or by itself when they return.\n */\nexport const AI_JOB_PAUSED_NEXT_COPY =\n 'Nothing is lost: what it built so far is saved. Once you have AI credits again — from Billing, ' +\n 'or when your monthly credits reset — choose Resume, or it carries on by itself within about an hour.'\n\n/** A paused job's heading, by what it builds. */\nexport function aiJobPausedTitle(kind: AiJobKind): string {\n const noun = aiJobKindNoun(kind)\n return noun === 'AI job' ? 'Your AI job is paused' : `Your ${noun} ${noun.endsWith('s') ? 'are' : 'is'} paused`\n}\n\n/** A change the person who started a job is told about. */\nexport type AiJobNoticeTransition = 'needs-review' | 'done' | 'failed' | 'paused'\n\n/** The fields of a stored job a notice is written from. */\nexport interface AiJobNoticeSource {\n $id: string\n orgId: string\n hostId?: string | null\n kind: AiJobKind\n review?: { reason: AiJobReviewReason; message?: string | null } | null\n outputs?: readonly AiJobOutput[] | null\n error?: string | null\n /** What a guided site start put live (AGL-3596); absent on every other job. */\n sitePublish?: Pick<AiJobSitePublish, 'published' | 'drafts'> | null\n /** What the job spent, and gave back for a failure on our side (AGL-3596). */\n creditsSpent?: number\n refundedCredits?: number\n /** A build's or a guided start's items (AGL-3616), so a finished one says what was not built. */\n items?: AiJobItemLedger[] | null\n}\n\n/** A stopped job's sentence with what became of its credits, from the job's recorded give-back (AGL-3596). */\nfunction withRefund(job: AiJobNoticeSource, text: string, status: 'failed' | 'needs_review'): string {\n const refund = aiJobRefundCopy({ status, refundedCredits: job.refundedCredits, creditsSpent: job.creditsSpent })\n return refund ? `${text} ${refund}` : text\n}\n\nexport type AiJobNotice = Pick<\n AglynNotification,\n 'type' | 'title' | 'body' | 'link' | 'orgId' | 'hostId' | 'level'\n>\n\n/**\n * Where a notice's link goes, in the stored form the console rewrites when it\n * is followed (`normalizeNotificationLink`): `/{hostId}/…` for the job's site,\n * `/org…` for a job of the workspace's.\n *\n * A job that is done leads to what it built (AGL-3593): several pages of the\n * job's site open that site's Pages list, one versioned output opens in the\n * editor on the version the job wrote. Everything else — a plan to confirm, a\n * job that stopped, a done job with nothing of its own to open — opens AI\n * jobs on the job.\n */\nexport function aiJobNoticeLink(job: AiJobNoticeSource, to: AiJobNoticeTransition): string {\n const base = job.hostId ? `/${job.hostId}` : '/org'\n // A site job's every notice opens its \"Building your site\" page (AGL-3594),\n // which leads with the next step whatever the job's state.\n if (job.kind === 'site' && job.hostId) return `${base}${AI_SITE_BUILD_HREF}/${encodeURIComponent(job.$id)}`\n if (to === 'done' && job.hostId) {\n const own = (job.outputs ?? []).filter((output) => output.hostId === job.hostId)\n const screens = own.filter((output) => output.resource === 'screen')\n if (screens.length > 1) return `${base}/screens`\n const versioned = own.find(\n (output) => AI_JOB_BESIGNER_SEGMENT[output.resource] && output.versionId,\n )\n if (own.length === 1 && versioned) {\n const segment = AI_JOB_BESIGNER_SEGMENT[versioned.resource]\n return `${base}/${segment}/${versioned.id}/versions/${versioned.versionId}/besigner`\n }\n }\n return `${base}?${AI_JOB_LINK_PARAM}=${encodeURIComponent(job.$id)}`\n}\n\n/**\n * The in-app notification for one change of one job (AGL-3593), for the\n * person who created it. Customer-safe throughout: a stopped job's sentence is\n * the job's own `error`, which the machine writes as a fixed sentence.\n */\nexport function aiJobNotice(job: AiJobNoticeSource, to: AiJobNoticeTransition): AiJobNotice {\n const noun = aiJobKindNoun(job.kind)\n const scope = { orgId: job.orgId, ...(job.hostId ? { hostId: job.hostId } : {}) }\n const link = aiJobNoticeLink(job, to)\n if (to === 'needs-review') {\n if (job.review?.reason === 'plan') {\n return {\n type: 'content.aiJobNeedsYou',\n level: 'warning',\n title:\n noun === 'AI job'\n ? 'Your plan is ready: confirm it to build'\n : `Your ${noun} plan is ready: confirm it to build`,\n body: 'Nothing is built until you confirm the plan. It waits in AI jobs.',\n link,\n ...scope,\n }\n }\n return {\n type: 'content.aiJobNeedsYou',\n level: 'warning',\n title: `Your ${noun === 'AI job' ? 'AI job' : `${noun} job`} needs you`,\n // The doctrine's own words never reach a customer (AGL-3596).\n body: withRefund(\n job,\n aiCustomerSafeCopy(job.review?.message || job.error || 'It stopped for your decision. It waits in AI jobs.', { page: job.kind === 'page' }),\n 'needs_review',\n ),\n link,\n ...scope,\n }\n }\n if (to === 'paused') {\n // Paused by the meter (AGL-3660): out of credits or at a cap. Nothing is\n // lost and the job is not over, so it is a warning that names the way on.\n return {\n type: 'content.aiJobNeedsYou',\n level: 'warning',\n title: aiJobPausedTitle(job.kind),\n body: [job.error, AI_JOB_PAUSED_NEXT_COPY].filter(Boolean).join(' '),\n link,\n ...scope,\n }\n }\n if (to === 'done') {\n // A job that finished with part of it unbuilt (AGL-3596) says so in the\n // words its page uses, as a warning: \"done\" alone hid a page that failed.\n const items = job.items ?? []\n const unbuilt = items.some((row) => row.status === 'failed' || row.status === 'skipped')\n const level = unbuilt ? 'warning' : 'success'\n const partial =\n job.kind === 'site'\n ? aiSitePartialCopy({ items, status: 'done' })\n : unbuilt\n ? aiBuildOutcomeLine({ items, status: 'done' })\n : null\n // A guided site start publishes its pages when it finishes (AGL-3596):\n // with one live, the site is, and the build page lists any that stayed\n // drafts with the reason for each.\n const live = job.kind === 'site' ? (job.sitePublish?.published.length ?? 0) : 0\n if (live > 0) {\n const drafts = job.sitePublish?.drafts.length ?? 0\n const stayed = drafts === 1 ? 'one that stayed a draft' : `${drafts} that stayed drafts`\n return {\n type: 'content.aiJobDone',\n level,\n title: partial ? 'Your site is live, but part of it wasn’t built' : 'Your site is live',\n body: partial\n ? `${partial} ${drafts > 0 ? `What was built is published, except ${stayed}.` : 'What was built is published.'}`\n : drafts > 0\n ? `Your pages are published, except ${stayed}. Open it to see why.`\n : 'Your pages are published. Open it to view your site or edit your pages.',\n link,\n ...scope,\n }\n }\n const pages = (job.outputs ?? []).filter((output) => output.resource === 'screen').length\n const title =\n job.kind === 'site' || pages > 1\n ? 'Your site’s draft pages are ready'\n : job.kind === 'page'\n ? 'Your draft page is ready'\n : noun === 'AI job'\n ? 'Your AI job finished'\n : `Your ${noun} is ready`\n return {\n type: 'content.aiJobDone',\n level,\n title,\n body:\n job.kind === 'insight' || job.kind === 'text'\n ? 'Open AI jobs to read it.'\n : [partial, 'Everything it built is an unpublished draft until you publish it.'].filter(Boolean).join(' '),\n link,\n ...scope,\n }\n }\n return {\n type: 'content.aiJobFailed',\n level: 'warning',\n title: `Your ${noun === 'AI job' ? 'AI job' : `${noun} job`} stopped`,\n body: withRefund(job, aiCustomerSafeCopy(job.error || 'It stopped before it finished.', { page: job.kind === 'page' }), 'failed'),\n link,\n ...scope,\n }\n}\n"],"names":["aiBuildOutcomeLine","aiSitePartialCopy","aiJobKindNoun","aiCustomerSafeCopy","aiJobRefundCopy","AI_JOB_BESIGNER_SEGMENT","screen","reusableComponent","layout","template","emailScreen","AI_JOB_LINK_PARAM","AI_SITE_BUILD_HREF","AI_JOB_PAUSED_NEXT_COPY","aiJobPausedTitle","kind","noun","endsWith","withRefund","job","text","status","refund","refundedCredits","creditsSpent","aiJobNoticeLink","to","base","hostId","encodeURIComponent","$id","own","outputs","filter","output","screens","resource","length","versioned","find","versionId","segment","id","aiJobNotice","scope","orgId","link","review","reason","type","level","title","body","message","error","page","Boolean","join","items","unbuilt","some","row","partial","live","sitePublish","published","drafts","stayed","pages"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/model/ai-job-notice.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynNotification } from '@aglyn/aglyn/app-utils/notifications'\nimport { aiBuildOutcomeLine, aiSitePartialCopy } from './ai-build-progress'\nimport { aiJobKindNoun } from './ai-job-activity'\nimport { aiCustomerSafeCopy, aiJobRefundCopy } from './ai-job-failure-copy'\nimport type { AiJobItemLedger, AiJobKind, AiJobOutput, AiJobReviewReason, AiJobSitePublish } from './ai-jobs.types'\n\n/** The besigner segment each versioned resource lives under. */\nexport const AI_JOB_BESIGNER_SEGMENT: Partial<Record<AiJobOutput['resource'], string>> = {\n screen: 'screens',\n reusableComponent: 'components',\n layout: 'layouts',\n template: 'templates',\n // An email design is a screen: it opens in the screen besigner, as the\n // Emails page's own Edit design does.\n emailScreen: 'screens',\n}\n\n/**\n * The query parameter a console address carries to open AI jobs on one job\n * (AGL-3593): the Assist panel reads it on arrival.\n */\nexport const AI_JOB_LINK_PARAM = 'aiJob'\n\n/** Where a site's \"Building your site\" page lives, under the site (AGL-3594). */\nexport const AI_SITE_BUILD_HREF = '/ai-jobs'\n\n/**\n * What a job the meter paused says comes next (AGL-3660), on its notice and\n * its page alike: nothing it built is lost, and it carries on from where it\n * stopped — by Resume once there are credits, or by itself when they return.\n */\nexport const AI_JOB_PAUSED_NEXT_COPY =\n 'Nothing is lost: what it built so far is saved. Once you have AI credits again — from Billing, ' +\n 'or when your monthly credits reset — choose Resume, or it carries on by itself within about an hour.'\n\n/** A paused job's heading, by what it builds. */\nexport function aiJobPausedTitle(kind: AiJobKind): string {\n const noun = aiJobKindNoun(kind)\n return noun === 'AI job' ? 'Your AI job is paused' : `Your ${noun} ${noun.endsWith('s') ? 'are' : 'is'} paused`\n}\n\n/** A change the person who started a job is told about. */\nexport type AiJobNoticeTransition = 'needs-review' | 'done' | 'failed' | 'paused'\n\n/** The fields of a stored job a notice is written from. */\nexport interface AiJobNoticeSource {\n $id: string\n orgId: string\n hostId?: string | null\n kind: AiJobKind\n review?: { reason: AiJobReviewReason; message?: string | null } | null\n outputs?: readonly AiJobOutput[] | null\n error?: string | null\n /** What a guided site start put live (AGL-3596); absent on every other job. */\n sitePublish?: Pick<AiJobSitePublish, 'published' | 'drafts'> | null\n /** What the job spent, and gave back for a failure on our side (AGL-3596). */\n creditsSpent?: number\n refundedCredits?: number\n /** A build's or a guided start's items (AGL-3616), so a finished one says what was not built. */\n items?: AiJobItemLedger[] | null\n}\n\n/** A stopped job's sentence with what became of its credits, from the job's recorded give-back (AGL-3596). */\nfunction withRefund(job: AiJobNoticeSource, text: string, status: 'failed' | 'needs_review'): string {\n const refund = aiJobRefundCopy({ status, refundedCredits: job.refundedCredits, creditsSpent: job.creditsSpent })\n return refund ? `${text} ${refund}` : text\n}\n\nexport type AiJobNotice = Pick<\n AglynNotification,\n 'type' | 'title' | 'body' | 'link' | 'orgId' | 'hostId' | 'level'\n>\n\n/**\n * Where a notice's link goes, in the stored form the console rewrites when it\n * is followed (`normalizeNotificationLink`): `/{hostId}/…` for the job's site,\n * `/org…` for a job of the workspace's.\n *\n * A job that is done leads to what it built (AGL-3593): several pages of the\n * job's site open that site's Pages list, one versioned output opens in the\n * editor on the version the job wrote. Everything else — a plan to confirm, a\n * job that stopped, a done job with nothing of its own to open — opens AI\n * jobs on the job.\n */\nexport function aiJobNoticeLink(job: AiJobNoticeSource, to: AiJobNoticeTransition): string {\n const base = job.hostId ? `/${job.hostId}` : '/org'\n // A site job's every notice opens its \"Building your site\" page (AGL-3594),\n // which leads with the next step whatever the job's state.\n if (job.kind === 'site' && job.hostId) return `${base}${AI_SITE_BUILD_HREF}/${encodeURIComponent(job.$id)}`\n if (to === 'done' && job.hostId) {\n const own = (job.outputs ?? []).filter((output) => output.hostId === job.hostId)\n const screens = own.filter((output) => output.resource === 'screen')\n if (screens.length > 1) return `${base}/screens`\n const versioned = own.find(\n (output) => AI_JOB_BESIGNER_SEGMENT[output.resource] && output.versionId,\n )\n if (own.length === 1 && versioned) {\n const segment = AI_JOB_BESIGNER_SEGMENT[versioned.resource]\n return `${base}/${segment}/${versioned.id}/versions/${versioned.versionId}/besigner`\n }\n }\n return `${base}?${AI_JOB_LINK_PARAM}=${encodeURIComponent(job.$id)}`\n}\n\n/**\n * The in-app notification for one change of one job (AGL-3593), for the\n * person who created it. Customer-safe throughout: a stopped job's sentence is\n * the job's own `error`, which the machine writes as a fixed sentence.\n */\n/** The site a job worked on, as its notice names it: its name, and where it is live. */\nexport interface AiJobNoticeSite {\n name: string\n /** The site's live address, absolute; `null` when it has none yet. */\n url: string | null\n}\n\nexport function aiJobNotice(\n job: AiJobNoticeSource,\n to: AiJobNoticeTransition,\n site: AiJobNoticeSite | null = null,\n): AiJobNotice {\n const noun = aiJobKindNoun(job.kind)\n const scope = { orgId: job.orgId, ...(job.hostId ? { hostId: job.hostId } : {}) }\n const link = aiJobNoticeLink(job, to)\n if (to === 'needs-review') {\n if (job.review?.reason === 'plan') {\n return {\n type: 'content.aiJobNeedsYou',\n level: 'warning',\n title:\n noun === 'AI job'\n ? 'Your plan is ready: confirm it to build'\n : `Your ${noun} plan is ready: confirm it to build`,\n body: 'Nothing is built until you confirm the plan. It waits in AI jobs.',\n link,\n ...scope,\n }\n }\n return {\n type: 'content.aiJobNeedsYou',\n level: 'warning',\n title: `Your ${noun === 'AI job' ? 'AI job' : `${noun} job`} needs you`,\n // The doctrine's own words never reach a customer (AGL-3596).\n body: withRefund(\n job,\n aiCustomerSafeCopy(job.review?.message || job.error || 'It stopped for your decision. It waits in AI jobs.', { page: job.kind === 'page' }),\n 'needs_review',\n ),\n link,\n ...scope,\n }\n }\n if (to === 'paused') {\n // Paused by the meter (AGL-3660): out of credits or at a cap. Nothing is\n // lost and the job is not over, so it is a warning that names the way on.\n return {\n type: 'content.aiJobNeedsYou',\n level: 'warning',\n title: aiJobPausedTitle(job.kind),\n body: [job.error, AI_JOB_PAUSED_NEXT_COPY].filter(Boolean).join(' '),\n link,\n ...scope,\n }\n }\n if (to === 'done') {\n // A job that finished with part of it unbuilt (AGL-3596) says so in the\n // words its page uses, as a warning: \"done\" alone hid a page that failed.\n const items = job.items ?? []\n const unbuilt = items.some((row) => row.status === 'failed' || row.status === 'skipped')\n const level = unbuilt ? 'warning' : 'success'\n const partial =\n job.kind === 'site'\n ? aiSitePartialCopy({ items, status: 'done' })\n : unbuilt\n ? aiBuildOutcomeLine({ items, status: 'done' })\n : null\n // A guided site start publishes its pages when it finishes (AGL-3596):\n // with one live, the site is, and the build page lists any that stayed\n // drafts with the reason for each.\n const published = job.kind === 'site' ? (job.sitePublish?.published.length ?? 0) : 0\n if (published > 0) {\n const drafts = job.sitePublish?.drafts.length ?? 0\n const stayed = drafts === 1 ? 'one that stayed a draft' : `${drafts} that stayed drafts`\n // Named, with its address (AGL-3889): someone with several sites, or\n // reading the email days later, cannot tell which site went live from\n // \"Your site is live\", nor where to see it.\n const live = site ? `${site.name} is live` : 'Your site is live'\n const at = site?.url ? ` at ${site.url}` : ''\n return {\n type: 'content.aiJobDone',\n level,\n title: partial ? `${live}, but part of it wasn’t built` : live,\n body: partial\n ? `${partial} ${drafts > 0 ? `What was built is published${at}, except ${stayed}.` : `What was built is published${at}.`}`\n : drafts > 0\n ? `Your pages are published${at}, except ${stayed}. Open the build page to see why.`\n : at\n ? `Your pages are published${at}. Visit your site there, or open it in the console to edit your pages.`\n : 'Your pages are published. Open it to view your site or edit your pages.',\n link,\n ...scope,\n }\n }\n const pages = (job.outputs ?? []).filter((output) => output.resource === 'screen').length\n const title =\n job.kind === 'site' || pages > 1\n ? 'Your site’s draft pages are ready'\n : job.kind === 'page'\n ? 'Your draft page is ready'\n : noun === 'AI job'\n ? 'Your AI job finished'\n : `Your ${noun} is ready`\n return {\n type: 'content.aiJobDone',\n level,\n title,\n body:\n job.kind === 'insight' || job.kind === 'text'\n ? 'Open AI jobs to read it.'\n : [partial, 'Everything it built is an unpublished draft until you publish it.'].filter(Boolean).join(' '),\n link,\n ...scope,\n }\n }\n return {\n type: 'content.aiJobFailed',\n level: 'warning',\n title: `Your ${noun === 'AI job' ? 'AI job' : `${noun} job`} stopped`,\n body: withRefund(job, aiCustomerSafeCopy(job.error || 'It stopped before it finished.', { page: job.kind === 'page' }), 'failed'),\n link,\n ...scope,\n }\n}\n"],"names":["aiBuildOutcomeLine","aiSitePartialCopy","aiJobKindNoun","aiCustomerSafeCopy","aiJobRefundCopy","AI_JOB_BESIGNER_SEGMENT","screen","reusableComponent","layout","template","emailScreen","AI_JOB_LINK_PARAM","AI_SITE_BUILD_HREF","AI_JOB_PAUSED_NEXT_COPY","aiJobPausedTitle","kind","noun","endsWith","withRefund","job","text","status","refund","refundedCredits","creditsSpent","aiJobNoticeLink","to","base","hostId","encodeURIComponent","$id","own","outputs","filter","output","screens","resource","length","versioned","find","versionId","segment","id","aiJobNotice","site","scope","orgId","link","review","reason","type","level","title","body","message","error","page","Boolean","join","items","unbuilt","some","row","partial","published","sitePublish","drafts","stayed","live","name","at","url","pages"],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * A plan's sections of what customers say (AGL-3887).
19
+ *
20
+ * A job never writes a review, a testimonial or a quote for people who did
21
+ * not say it (rule 14, `AI_SITE_CONTEXT_RULE`). So a section planned to show
22
+ * them, on a site that has none, has nothing true to hold, and the build
23
+ * fills it with the only honest thing left: a promise of words to come. The
24
+ * live Whitmore Lane Family Law start (beta.262, 2026-10-16) planned "Client
25
+ * feedback placeholder" on its home page and shipped "We are building this
26
+ * space for the words of families we have worked with" over a stock photo.
27
+ *
28
+ * A site start's plan is settled in code before it is kept, never re-asked
29
+ * (a re-ask costs the person credits for a plan that has one answer): such a
30
+ * section becomes a band about what working together is like, as a
31
+ * photographer's start of the same day planned its "Kind words" as "What
32
+ * working together is like" (Relaxed / Prepared / Personal). Where the page
33
+ * already has that band, the section goes, if the page keeps its fewest
34
+ * sections without it.
35
+ *
36
+ * Kept as planned where the customer words are real: a store, whose
37
+ * platform shows its shoppers' reviews; a site with a dataset of them; a
38
+ * brief that asks for them or quotes them.
39
+ *
40
+ * Every plan, whatever its kind, names no section "placeholder": a section's
41
+ * name is what its build is asked to write, and a name that says it is a
42
+ * stand-in is written as one.
43
+ *
44
+ * Pure: plans in, plans out.
45
+ */
46
+ import type { AiBuildPlan } from './ai-build-plan';
47
+ import type { AiSiteInventory } from './ai-site-inventory';
48
+ /** What a section of customer words on a site that has none is planned as instead. */
49
+ export declare const AI_PLAN_SAID_SECTION_NAME = "What working with us is like";
50
+ /** The points that band makes (the photographer's three: Relaxed, Prepared, Personal). */
51
+ export declare const AI_PLAN_SAID_SECTION_ITEMS = 3;
52
+ /** Whether a planned section's name says it shows what customers say. */
53
+ export declare function aiPlanSectionSaysCustomerWords(name: string): boolean;
54
+ /** A section's name with the word "placeholder" taken out; the name unchanged where nothing is left. */
55
+ export declare function aiPlanSectionNameWithoutPlaceholder(name: string): string;
56
+ /**
57
+ * Whether the site, or the person asking, has customer words that are real
58
+ * (AGL-3887): a store's shoppers' reviews, which its platform shows; a
59
+ * dataset of them the site keeps; a brief that names them or quotes one with
60
+ * its speaker.
61
+ */
62
+ export declare function aiPlanHasCustomerWords(input: {
63
+ brief?: string | null;
64
+ inventory?: Pick<AiSiteInventory, 'datasets'> | null;
65
+ siteKind?: string | null;
66
+ }): boolean;
67
+ /** Every section's name with the word "placeholder" taken out (AGL-3887), the plan unchanged where none had it. */
68
+ export declare function aiSettlePlanPlaceholderNames<P extends Pick<AiBuildPlan, 'screens'>>(plan: P): P;
69
+ /**
70
+ * A site start's plan with no section of customer words it cannot fill
71
+ * (AGL-3887). Where `hasCustomerWords` is false, each such section past a
72
+ * page's first is planned as {@link AI_PLAN_SAID_SECTION_NAME} with its
73
+ * points; where the page already has that band, it is taken out instead
74
+ * while the page keeps its fewest sections (`homeMin` on the home page, two
75
+ * on any other), and planned as the band all the same where it cannot be.
76
+ * The plan is returned unchanged where nothing moved.
77
+ */
78
+ export declare function aiSettlePlanSaidSections<P extends Pick<AiBuildPlan, 'screens'>>(plan: P, options: {
79
+ hasCustomerWords: boolean;
80
+ homeMin: number;
81
+ }): P;