@aglyn/plugins-ai 1.0.0-beta.254 → 1.0.0-beta.256

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 (71) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/assist-signals-page.component.js +5 -6
  3. package/src/lib/components/assist-signals-page.component.js.map +1 -1
  4. package/src/lib/components/staff-org-ai-card.component.d.ts +0 -10
  5. package/src/lib/components/staff-org-ai-card.component.js +10 -79
  6. package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
  7. package/src/lib/components/staff-org-ai-conversations.component.js +8 -41
  8. package/src/lib/components/staff-org-ai-conversations.component.js.map +1 -1
  9. package/src/lib/components/staff-user-ai-usage-card.component.js +2 -1
  10. package/src/lib/components/staff-user-ai-usage-card.component.js.map +1 -1
  11. package/src/lib/jobs/ai-job-page-language.d.ts +14 -1
  12. package/src/lib/jobs/ai-job-page-language.js +18 -3
  13. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  14. package/src/lib/jobs/ai-job-page-step.js +17 -3
  15. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  16. package/src/lib/jobs/ai-job-plan-step.js +4 -0
  17. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  18. package/src/lib/jobs/ai-layout-stock-photos.d.ts +30 -0
  19. package/src/lib/jobs/ai-layout-stock-photos.js +76 -8
  20. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  21. package/src/lib/jobs/fixtures/ai-dental-site-plan-recording.d.ts +95 -0
  22. package/src/lib/jobs/fixtures/ai-dental-site-plan-recording.js +137 -0
  23. package/src/lib/jobs/fixtures/ai-dental-site-plan-recording.js.map +1 -0
  24. package/src/lib/layout-language/ai-layout-labels.d.ts +2 -0
  25. package/src/lib/layout-language/ai-layout-labels.js +12 -6
  26. package/src/lib/layout-language/ai-layout-labels.js.map +1 -1
  27. package/src/lib/layout-language/ai-layout-pictures.d.ts +12 -3
  28. package/src/lib/layout-language/ai-layout-pictures.js +6 -3
  29. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  30. package/src/lib/model/ai-products-bulk.d.ts +48 -0
  31. package/src/lib/model/ai-products-bulk.js +56 -0
  32. package/src/lib/model/ai-products-bulk.js.map +1 -0
  33. package/src/lib/model/ai-products.d.ts +2 -4
  34. package/src/lib/model/ai-products.js +4 -9
  35. package/src/lib/model/ai-products.js.map +1 -1
  36. package/src/lib/providers/anthropic.js +26 -5
  37. package/src/lib/providers/anthropic.js.map +1 -1
  38. package/src/lib/providers/contract.d.ts +10 -0
  39. package/src/lib/providers/contract.js.map +1 -1
  40. package/src/lib/runtime/ai-doctrine-validators.d.ts +20 -0
  41. package/src/lib/runtime/ai-doctrine-validators.js +50 -0
  42. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  43. package/src/lib/runtime/ai-doctrine.d.ts +11 -0
  44. package/src/lib/runtime/ai-doctrine.js +9 -4
  45. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  46. package/src/lib/runtime/ai-runtime.d.ts +6 -0
  47. package/src/lib/runtime/ai-runtime.js +10 -0
  48. package/src/lib/runtime/ai-runtime.js.map +1 -1
  49. package/src/lib/server/ai-admin-conversations.d.ts +2 -0
  50. package/src/lib/server/ai-admin-conversations.js +40 -7
  51. package/src/lib/server/ai-admin-conversations.js.map +1 -1
  52. package/src/lib/server/ai-admin-org.js +28 -2
  53. package/src/lib/server/ai-admin-org.js.map +1 -1
  54. package/src/lib/server/ai-admin-orgs-spend.js +19 -1
  55. package/src/lib/server/ai-admin-orgs-spend.js.map +1 -1
  56. package/src/lib/server/ai-admin-signals.js +11 -3
  57. package/src/lib/server/ai-admin-signals.js.map +1 -1
  58. package/src/lib/server/ai-admin-user.js +11 -1
  59. package/src/lib/server/ai-admin-user.js.map +1 -1
  60. package/src/lib/usage/ai-usage-wire.d.ts +5 -2
  61. package/src/lib/usage/ai-usage-wire.js +5 -2
  62. package/src/lib/usage/ai-usage-wire.js.map +1 -1
  63. package/src/lib/usage/assist-signals-view.d.ts +123 -0
  64. package/src/lib/usage/assist-signals-view.js +151 -0
  65. package/src/lib/usage/assist-signals-view.js.map +1 -0
  66. package/src/lib/usage/staff-org-ai-conversations-view.d.ts +51 -0
  67. package/src/lib/usage/staff-org-ai-conversations-view.js +127 -0
  68. package/src/lib/usage/staff-org-ai-conversations-view.js.map +1 -0
  69. package/src/lib/usage/staff-org-ai-view.d.ts +181 -0
  70. package/src/lib/usage/staff-org-ai-view.js +263 -0
  71. package/src/lib/usage/staff-org-ai-view.js.map +1 -0
@@ -18,7 +18,7 @@ import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession } from "
18
18
  import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
19
19
  import { ASSIST_PROVIDER_COST_FIELD, assistProviderCostUsd } from "../usage/assist-credits.js";
20
20
  import { assistUsageMonth } from "../usage/assist-usage.js";
21
- import { parseStaffOrgIds } from "../usage/staff-orgs-ai-spend.js";
21
+ import { aiSpendCell, aiSpendSortValue, parseStaffOrgIds } from "../usage/staff-orgs-ai-spend.js";
22
22
  /**
23
23
  * THE STAFF ORGANIZATIONS LIST'S AI SPEND (AGL-2984).
24
24
  *
@@ -67,6 +67,7 @@ import { parseStaffOrgIds } from "../usage/staff-orgs-ai-spend.js";
67
67
  });
68
68
  }
69
69
  try {
70
+ var _query_view;
70
71
  const app = firebaseAdmin.app();
71
72
  const decoded = await app.auth().verifyIdToken(idToken);
72
73
  if (!decoded.email_verified && !isImpersonationSession(decoded)) {
@@ -94,6 +95,23 @@ import { parseStaffOrgIds } from "../usage/staff-orgs-ai-spend.js";
94
95
  spendOf(snapshots == null ? void 0 : snapshots[index])
95
96
  ]))
96
97
  };
98
+ // The column as it prints, for a client that draws no table (the staff apps): the cell each org shows, and the
99
+ // number the header sorts by, with the unmeasured as null so they sort last in both directions.
100
+ if (String((_query_view = query['view']) != null ? _query_view : '') === 'prepared') {
101
+ return Response.json({
102
+ month,
103
+ cells: Object.fromEntries(Object.entries(body.spendUsd).map(([id, spend])=>[
104
+ id,
105
+ aiSpendCell(spend)
106
+ ])),
107
+ sort: Object.fromEntries(Object.entries(body.spendUsd).map(([id, spend])=>[
108
+ id,
109
+ aiSpendSortValue(spend)
110
+ ]))
111
+ }, {
112
+ status: 200
113
+ });
114
+ }
97
115
  return Response.json(body, {
98
116
  status: 200
99
117
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-orgs-spend.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 { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n ASSIST_PROVIDER_COST_FIELD,\n assistProviderCostUsd,\n} from '../usage/assist-credits'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport {\n parseStaffOrgIds,\n type StaffOrgsAiSpendWire,\n} from '../usage/staff-orgs-ai-spend'\n\n/**\n * THE STAFF ORGANIZATIONS LIST'S AI SPEND (AGL-2984).\n *\n * `GET /api/ai/admin/orgs-spend?orgIds=a,b,…` answers this month's provider\n * spend for each org the list's page shows — the figures its AI spend column\n * draws and sorts by. Staff-gated exactly as `/api/ai/admin/org` is, and\n * read-only.\n *\n * ## The read\n *\n * ONE `getAll` over each named org's usage document for the current UTC\n * month, the key the usage writer files spend under — a single bounded\n * round trip for the page, never a read per row, and at most\n * `STAFF_ORGS_AI_SPEND_MAX_IDS` ids per request.\n *\n * An org with no document for the month answers `null`, never `0`, and a\n * read that FAILS answers `null` for every id: the list renders with the\n * column blank rather than failing because this read did.\n *\n * No `adminAudit` row: the answer is one dollar figure per workspace, with\n * nothing in it about a person.\n */\nasync function handler(request: Request): Promise<Response> {\n const {\n method,\n query,\n headers: rawHeaders,\n } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken)\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const asked = parseStaffOrgIds(query['orgIds'])\n if (asked.error) {\n return Response.json({ error: asked.error }, { status: 400 })\n }\n\n try {\n const app = firebaseAdmin.app()\n const decoded = await app.auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const month = assistUsageMonth()\n const db = app.firestore()\n const orgs = db.collection('orgs')\n const snapshots = await db\n .getAll(\n ...asked.orgIds.map((orgId) =>\n orgs.doc(orgId).collection('assistUsage').doc(month),\n ),\n )\n .catch((error: unknown) => {\n console.error('[ai/admin/orgs-spend] read failed', error)\n return null\n })\n const body: StaffOrgsAiSpendWire = {\n month,\n // `fromEntries`, so an id is always an own key of the answer.\n spendUsd: Object.fromEntries(\n asked.orgIds.map((orgId, index) => [\n orgId,\n spendOf(snapshots?.[index]),\n ]),\n ),\n }\n return Response.json(body, { status: 200 })\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/orgs-spend]', error)\n return Response.json({ error: 'AI spend lookup failed' }, { status: 500 })\n }\n}\n\n/**\n * A month document's PROVIDER spend, or `null` when there is no document to\n * read.\n *\n * The column this feeds is named for our money and is sorted on to find the\n * workspace spending it, so it is the provider figure and not what the\n * workspace drew (AGL-3015). A month closed before the two were split\n * answers with what it drew, which over-states the bill rather than hiding\n * it — the direction that still surfaces the workspace worth looking at.\n */\nfunction spendOf(\n snapshot: FirebaseFirestore.DocumentSnapshot | undefined,\n): number | null {\n if (!snapshot?.exists) return null\n const cost = assistProviderCostUsd(\n snapshot.get('estCostUsd'),\n snapshot.get(ASSIST_PROVIDER_COST_FIELD),\n )\n return Number.isFinite(cost) ? cost : null\n}\n\nexport { handler as GET }\n"],"names":["pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","invalidIdTokenResponse","ASSIST_PROVIDER_COST_FIELD","assistProviderCostUsd","assistUsageMonth","parseStaffOrgIds","handler","request","headers","method","query","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","asked","app","decoded","auth","verifyIdToken","email_verified","month","db","firestore","orgs","collection","snapshots","getAll","orgIds","map","orgId","doc","catch","console","body","spendUsd","Object","fromEntries","index","spendOf","unauthenticated","snapshot","exists","cost","get","Number","isFinite","GET"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-orgs-spend.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 { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n ASSIST_PROVIDER_COST_FIELD,\n assistProviderCostUsd,\n} from '../usage/assist-credits'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport {\n aiSpendCell,\n aiSpendSortValue,\n parseStaffOrgIds,\n type StaffOrgsAiSpendWire,\n} from '../usage/staff-orgs-ai-spend'\n\n/**\n * THE STAFF ORGANIZATIONS LIST'S AI SPEND (AGL-2984).\n *\n * `GET /api/ai/admin/orgs-spend?orgIds=a,b,…` answers this month's provider\n * spend for each org the list's page shows — the figures its AI spend column\n * draws and sorts by. Staff-gated exactly as `/api/ai/admin/org` is, and\n * read-only.\n *\n * ## The read\n *\n * ONE `getAll` over each named org's usage document for the current UTC\n * month, the key the usage writer files spend under — a single bounded\n * round trip for the page, never a read per row, and at most\n * `STAFF_ORGS_AI_SPEND_MAX_IDS` ids per request.\n *\n * An org with no document for the month answers `null`, never `0`, and a\n * read that FAILS answers `null` for every id: the list renders with the\n * column blank rather than failing because this read did.\n *\n * No `adminAudit` row: the answer is one dollar figure per workspace, with\n * nothing in it about a person.\n */\nasync function handler(request: Request): Promise<Response> {\n const {\n method,\n query,\n headers: rawHeaders,\n } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken)\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const asked = parseStaffOrgIds(query['orgIds'])\n if (asked.error) {\n return Response.json({ error: asked.error }, { status: 400 })\n }\n\n try {\n const app = firebaseAdmin.app()\n const decoded = await app.auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const month = assistUsageMonth()\n const db = app.firestore()\n const orgs = db.collection('orgs')\n const snapshots = await db\n .getAll(\n ...asked.orgIds.map((orgId) =>\n orgs.doc(orgId).collection('assistUsage').doc(month),\n ),\n )\n .catch((error: unknown) => {\n console.error('[ai/admin/orgs-spend] read failed', error)\n return null\n })\n const body: StaffOrgsAiSpendWire = {\n month,\n // `fromEntries`, so an id is always an own key of the answer.\n spendUsd: Object.fromEntries(\n asked.orgIds.map((orgId, index) => [\n orgId,\n spendOf(snapshots?.[index]),\n ]),\n ),\n }\n // The column as it prints, for a client that draws no table (the staff apps): the cell each org shows, and the\n // number the header sorts by, with the unmeasured as null so they sort last in both directions.\n if (String(query['view'] ?? '') === 'prepared') {\n return Response.json(\n {\n month,\n cells: Object.fromEntries(Object.entries(body.spendUsd).map(([id, spend]) => [id, aiSpendCell(spend)])),\n sort: Object.fromEntries(Object.entries(body.spendUsd).map(([id, spend]) => [id, aiSpendSortValue(spend)])),\n },\n { status: 200 },\n )\n }\n return Response.json(body, { status: 200 })\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/orgs-spend]', error)\n return Response.json({ error: 'AI spend lookup failed' }, { status: 500 })\n }\n}\n\n/**\n * A month document's PROVIDER spend, or `null` when there is no document to\n * read.\n *\n * The column this feeds is named for our money and is sorted on to find the\n * workspace spending it, so it is the provider figure and not what the\n * workspace drew (AGL-3015). A month closed before the two were split\n * answers with what it drew, which over-states the bill rather than hiding\n * it — the direction that still surfaces the workspace worth looking at.\n */\nfunction spendOf(\n snapshot: FirebaseFirestore.DocumentSnapshot | undefined,\n): number | null {\n if (!snapshot?.exists) return null\n const cost = assistProviderCostUsd(\n snapshot.get('estCostUsd'),\n snapshot.get(ASSIST_PROVIDER_COST_FIELD),\n )\n return Number.isFinite(cost) ? cost : null\n}\n\nexport { handler as GET }\n"],"names":["pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","invalidIdTokenResponse","ASSIST_PROVIDER_COST_FIELD","assistProviderCostUsd","assistUsageMonth","aiSpendCell","aiSpendSortValue","parseStaffOrgIds","handler","request","headers","method","query","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","asked","app","decoded","auth","verifyIdToken","email_verified","month","db","firestore","orgs","collection","snapshots","getAll","orgIds","map","orgId","doc","catch","console","body","spendUsd","Object","fromEntries","index","spendOf","String","cells","entries","id","spend","sort","unauthenticated","snapshot","exists","cost","get","Number","isFinite","GET"],"mappings":"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"}
@@ -24,6 +24,7 @@ import { assistUsageDay, assistUsageMonth } from "../usage/assist-usage.js";
24
24
  import { ASSIST_RETURNED_USD_FIELD, assistSpendAfterReturnsUsd } from "../usage/assist-credit-returns.js";
25
25
  import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
26
26
  import { assistSignalRow, mineAssistSignals, rankAssistSpend } from "../usage/assist-signal-mining.js";
27
+ import { assistSignalsView } from "../usage/assist-signals-view.js";
27
28
  /**
28
29
  * The Assist docs-gap and cost mining view (AGL-1860, AGL-2252), served at
29
30
  * `/api/ai/admin/signals` for the plugin's Assist signal staff page.
@@ -170,7 +171,7 @@ async function handler(request) {
170
171
  status: 401
171
172
  });
172
173
  try {
173
- var _query_limit;
174
+ var _query_limit, _query_view;
174
175
  const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
175
176
  if (!decoded.email_verified && !isImpersonationSession(decoded)) {
176
177
  return emailUnverifiedResponse();
@@ -301,7 +302,7 @@ async function handler(request) {
301
302
  console.error('[ai/admin/signals] free spend readout failed', error);
302
303
  return undefined;
303
304
  });
304
- return Response.json(_extends({}, report, {
305
+ const answer = _extends({}, report, {
305
306
  freeSpend,
306
307
  orgs: report.orgs.map((row)=>_extends({}, row, {
307
308
  orgLabel: orgLabel(row.orgId)
@@ -311,7 +312,14 @@ async function handler(request) {
311
312
  })),
312
313
  spend,
313
314
  ceiling: SCAN_CEILING
314
- }), {
315
+ });
316
+ // The board as the page prints it, for a client that draws no tables (the staff apps).
317
+ if (String((_query_view = query.view) != null ? _query_view : '') === 'prepared') {
318
+ return Response.json(assistSignalsView(answer), {
319
+ status: 200
320
+ });
321
+ }
322
+ return Response.json(answer, {
315
323
  status: 200
316
324
  });
317
325
  } catch (error) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-signals.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 ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport {\n ASSIST_PROVIDER_COST_FIELD,\n assistCreditsFromUsd,\n assistProviderCostUsd,\n} from '../usage/assist-credits'\nimport {\n hasAiAddon,\n resolveEffectivePlan,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { readPlatformFreeSpend } from '../usage/assist-free-taste'\nimport { assistRefusalCounts } from '../usage/assist-refusals'\nimport { assistUsageDay, assistUsageMonth } from '../usage/assist-usage'\nimport {\n ASSIST_RETURNED_USD_FIELD,\n assistSpendAfterReturnsUsd,\n} from '../usage/assist-credit-returns'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n assistSignalRow,\n mineAssistSignals,\n rankAssistSpend,\n type AssistSpendRow,\n} from '../usage/assist-signal-mining'\n\n/**\n * The Assist docs-gap and cost mining view (AGL-1860, AGL-2252), served at\n * `/api/ai/admin/signals` for the plugin's Assist signal staff page.\n * Staff-gated, read-only, and the only reader `assistSignals` has.\n *\n * ## Why a route and not a client query\n *\n * `assistSignals` is absent from `firebase-firestore.rules` deliberately: the\n * org block matches its subcollections BY NAME and carries no wildcard, so an\n * unlisted name is default-deny for every client. That is the property that\n * lets the collection hold cross-tenant analytics at all. Reading it therefore\n * goes through the Admin SDK behind the staff claim, exactly as every staff\n * route does.\n *\n * ## The collection group, and the index it does not need\n *\n * One `collectionGroup('assistSignals')` read gathers every org's signals in a\n * single query; the owning org is `ref.parent.parent.id`, since the document\n * carries no `orgId` field (it carries no identifiers at all — see AGL-1972).\n * The query has no `where` and no `orderBy`, so it needs no composite index,\n * and adding one later would mean declaring a `COLLECTION_GROUP`-scoped\n * override and deploying it by hand. `/api/admin/overview` reads org billing\n * the same way and for the same reason.\n *\n * ## Truncation is reported, never hidden\n *\n * The read is bounded — an unbounded fleet-wide get is how a staff page\n * becomes an outage — but a ranking cut short looks exactly like a complete\n * one, so the ceiling is fetched PLUS ONE and the extra row sets `truncated`.\n * AGL-2220 is the standing example of the alternative: a sweep that stopped at\n * 500 orgs and reported nothing about the rest, for as long as nobody counted.\n */\n\n/**\n * Rows read in one pass. Each signal is a handful of scalars, so this is a\n * few megabytes at the ceiling — large enough to be worth ranking, small\n * enough that the honest answer to \"is it all of them\" is usually yes.\n */\nconst SCAN_CEILING = 20000\n\n/**\n * How much of an answer the prose panel shows.\n *\n * Enough to judge whether the answer was wrong, short enough that twenty-five\n * of them are still a page somebody reads.\n */\nconst ANSWER_PREVIEW_CHARS = 600\n\n/**\n * Month documents read in one pass for the spend leaderboard (AGL-2930).\n *\n * `assistUsage` holds one document per org per month, so this is orgs times\n * months — a fraction of the signal count, and the same PLUS-ONE truncation\n * probe the signals read uses. Read as a collection group with NO `where`\n * and NO `orderBy`, for the reason the module note gives: a filter on\n * `month` would need a COLLECTION_GROUP-scoped index deployed by hand, and\n * filtering the month in memory costs nothing at this size.\n */\nconst SPEND_SCAN_CEILING = 20000\n\nexport interface AssistSpendLeaderboard {\n month: string\n rows: AssistSpendRow[]\n /** How many workspaces had a document for the month — the ranked count. */\n ranked: number\n /** True when the scan hit its ceiling, so the ranking is over a sample. */\n truncated: boolean\n /**\n * True when the month documents could not be read at all. Reported rather\n * than rendered as an empty table: \"no workspace spent anything\" and \"the\n * read failed\" are opposite findings, and the rest of the board — read\n * from a different collection — is still good.\n */\n failed: boolean\n}\n\n/**\n * The month's per-org spend, refusals and plan, dearest first.\n *\n * Plan and add-on come from the org and billing documents of the ranked\n * rows only — bounded by `limit`, never by the fleet — merged org-doc-first\n * exactly as every other staff reader merges them.\n */\nasync function readAssistSpendLeaderboard(\n firestore: FirebaseFirestore.Firestore,\n limit: number,\n month: string,\n): Promise<AssistSpendLeaderboard> {\n try {\n return await rankAssistSpendForMonth(firestore, limit, month)\n } catch (error) {\n console.error('[ai/admin/signals] spend leaderboard read failed', error)\n return { month, rows: [], ranked: 0, truncated: false, failed: true }\n }\n}\n\nasync function rankAssistSpendForMonth(\n firestore: FirebaseFirestore.Firestore,\n limit: number,\n month: string,\n): Promise<AssistSpendLeaderboard> {\n const snapshot = await firestore\n .collectionGroup('assistUsage')\n .limit(SPEND_SCAN_CEILING + 1)\n .get()\n const truncated = snapshot.size > SPEND_SCAN_CEILING\n const docs = truncated ? snapshot.docs.slice(0, SPEND_SCAN_CEILING) : snapshot.docs\n const unranked: AssistSpendRow[] = []\n for (const doc of docs) {\n // The id IS the month; the field is the same value and is what an\n // older document may lack.\n if (doc.id !== month && doc.get('month') !== month) continue\n const orgId = doc.ref.parent.parent?.id\n if (!orgId) continue\n // Two figures off one document (AGL-3015): the credits a workspace drew\n // come from what it was BILLED, and the dollars staff rank workspaces by\n // come from what it COST US. Ranking on the billed figure would sort the\n // fleet by our own markup wherever workspaces differ in model mix.\n const billed = Number(doc.get('estCostUsd') ?? 0)\n const billedUsd = Number.isFinite(billed) && billed > 0 ? billed : 0\n unranked.push({\n orgId,\n plan: 'free',\n aiAddon: false,\n // Net of credits given back (AGL-3595); the provider dollars are not.\n credits: assistCreditsFromUsd(\n assistSpendAfterReturnsUsd(billedUsd, doc.get(ASSIST_RETURNED_USD_FIELD)),\n ),\n providerCostUsd: assistProviderCostUsd(\n billedUsd,\n doc.get(ASSIST_PROVIDER_COST_FIELD),\n ),\n refusals: assistRefusalCounts(doc.get('refusals')),\n })\n }\n const { rows, ranked } = rankAssistSpend(unranked, limit)\n if (rows.length) {\n const orgRefs = rows.map((row) => firestore.collection('orgs').doc(row.orgId))\n const [orgSnaps, billingSnaps] = await Promise.all([\n firestore.getAll(...orgRefs),\n firestore.getAll(\n ...orgRefs.map((ref) =>\n ref.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID),\n ),\n ),\n ])\n rows.forEach((row, index) => {\n const orgData = orgSnaps[index]?.exists ? (orgSnaps[index].data() ?? {}) : {}\n const org = {\n ...orgData,\n ...(billingSnaps[index]?.exists ? billingSnaps[index].data() : {}),\n }\n row.plan = resolveEffectivePlan(org as never)\n row.aiAddon = hasAiAddon(org as never)\n const label = (orgData['name'] ?? orgData['slug'] ?? '') as string\n row.orgLabel = label || null\n })\n }\n return { month, rows, ranked, truncated, failed: false }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const requested = Number(query.limit ?? 25)\n const limit =\n Number.isFinite(requested) && requested > 0\n ? Math.min(Math.floor(requested), 100)\n : 25\n\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collectionGroup('assistSignals')\n // Ceiling PLUS ONE: the extra document is what makes truncation\n // detectable rather than assumed.\n .limit(SCAN_CEILING + 1)\n .get()\n\n const truncated = snapshot.size > SCAN_CEILING\n const docs = truncated ? snapshot.docs.slice(0, SCAN_CEILING) : snapshot.docs\n const rows = docs.map((doc) =>\n assistSignalRow(\n doc.ref.parent.parent?.id ?? '(unknown)',\n // The signal id IS the exchange id (AGL-2314) — one id, two halves,\n // minted together by `recordAssistExchange` precisely so the join is\n // possible. Until now nothing carried it this far.\n doc.id,\n doc.data(),\n ),\n )\n\n const report = mineAssistSignals(rows, { truncated, limit })\n\n // This month's spend, per workspace (AGL-2930) — the board's only\n // month-scoped panel, read off the month documents rather than mined\n // from the all-time signals above.\n const spend = await readAssistSpendLeaderboard(\n firebaseAdmin.app().firestore(),\n limit,\n assistUsageMonth(),\n )\n\n /*==========================================\n * THE VERBATIM HALF (AGL-2314).\n *\n * `assistExchanges` stores the question a customer typed and the answer\n * we gave, for 180 days, and `assist-usage.ts` calls it \"the data loop's\n * corpus\". NOTHING read it — one write, no reader, no collection group.\n * So we retained customers' words, and committed publicly in the privacy\n * policy to retaining them, for zero product value.\n *\n * Read here rather than in the miner because the miner is pure: it does\n * the arithmetic, the route does the reading.\n *\n * ## Only the turns that FAILED\n *\n * The shortlist is thumbs-down and ungrounded turns only, capped by the\n * same `limit` as every other panel. That is a privacy decision as much\n * as a cost one: the counts already answer \"how often\", and the words are\n * fetched only where the counts CANNOT say what went wrong. Pulling every\n * exchange would be surveillance with a dashboard on it.\n *\n * `uid` is deliberately not projected. The corpus question is what people\n * asked, never who asked it — and the exchange is the only document that\n * still carries an identifier at all.\n *\n * ## An expired exchange is an ANSWER\n *\n * The 180-day TTL means a signal can outlive its prose, and it is meant\n * to. A missing document therefore reports `expired: true` rather than\n * being dropped: a shortlist silently shorter than the failure count it\n * came from would read as \"these are all the failures\", which is the\n * AGL-2220 shape.\n *=========================================*/\n const firestore = firebaseAdmin.app().firestore()\n const exchanges = report.proseCandidates.length\n ? await firestore.getAll(\n ...report.proseCandidates.map((candidate) =>\n firestore\n .collection('orgs')\n .doc(candidate.orgId)\n .collection('assistExchanges')\n .doc(candidate.exchangeId),\n ),\n )\n : []\n const prose = report.proseCandidates.map((candidate, index) => {\n const snapshot = exchanges[index]\n const data = (snapshot?.exists ? snapshot.data() : null) ?? {}\n return {\n ...candidate,\n expired: !snapshot?.exists,\n question: typeof data['question'] === 'string' ? data['question'] : null,\n // Trimmed, not omitted: judging whether an answer was wrong needs to\n // see it, and a whole answer per row would make the panel unreadable.\n answer:\n typeof data['answer'] === 'string'\n ? data['answer'].slice(0, ANSWER_PREVIEW_CHARS)\n : null,\n answerTruncated:\n typeof data['answer'] === 'string' &&\n data['answer'].length > ANSWER_PREVIEW_CHARS,\n }\n })\n\n /*==========================================\n * THE WORKSPACE, BY NAME.\n *\n * A staff reader recognizes a customer by name, never by document id,\n * and the cost board's own column header says \"Workspace\" over a column\n * of generated ids. `/api/admin/overview` fixed the same shape by\n * building a label map from a snapshot it already held; there is no such\n * snapshot here — this route reads `assistSignals` and nothing else — so\n * the labels are fetched, with the same `name` → `slug` → id precedence\n * so one workspace cannot appear under two different labels on two\n * screens.\n *\n * Bounded by what is actually RENDERED, not by the fleet. Both lists the\n * ids come from are already sliced to `limit` (25 by default, 100 at the\n * ceiling), so this is one `getAll` over at most a couple of hundred\n * documents — and it reads the orgs the reader can see rather than\n * scanning a collection to name a handful of rows.\n *\n * An org that has been deleted keeps its id rather than going blank: an\n * unfamiliar id is still a lead a staff member can paste into the org\n * search, and an empty cell is not.\n *=========================================*/\n const orgIds = [\n ...new Set(\n [\n ...report.orgs.map((row) => row.orgId),\n ...report.proseCandidates.map((row) => row.orgId),\n ].filter((orgId) => orgId && orgId !== '(unknown)'),\n ),\n ]\n const orgLabelById = new Map<string, string>()\n if (orgIds.length) {\n const orgDocs = await firestore.getAll(\n ...orgIds.map((orgId) => firestore.collection('orgs').doc(orgId)),\n )\n for (const doc of orgDocs) {\n if (!doc.exists) continue\n const data = doc.data() ?? {}\n const label = (data['name'] ?? data['slug'] ?? '') as string\n if (label) orgLabelById.set(doc.id, label)\n }\n }\n const orgLabel = (orgId: string): string =>\n orgLabelById.get(orgId) ?? orgId\n\n // Today's free-tier spend against the platform ceiling (AGL-2925): one\n // document read, and the only place staff can see the taste switching\n // itself off before the email arrives. Best-effort — a readout that\n // cannot be read must not take the rest of the board down with it.\n const freeSpend = await readPlatformFreeSpend(\n firestore,\n assistUsageDay(),\n ).catch((error) => {\n console.error('[ai/admin/signals] free spend readout failed', error)\n return undefined\n })\n\n return Response.json(\n {\n ...report,\n freeSpend,\n orgs: report.orgs.map((row) => ({\n ...row,\n orgLabel: orgLabel(row.orgId),\n })),\n prose: prose.map((row) => ({ ...row, orgLabel: orgLabel(row.orgId) })),\n spend,\n ceiling: SCAN_CEILING,\n },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/signals]', error)\n return Response.json({ error: 'Assist signal lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","ASSIST_PROVIDER_COST_FIELD","assistCreditsFromUsd","assistProviderCostUsd","hasAiAddon","resolveEffectivePlan","readPlatformFreeSpend","assistRefusalCounts","assistUsageDay","assistUsageMonth","ASSIST_RETURNED_USD_FIELD","assistSpendAfterReturnsUsd","invalidIdTokenResponse","assistSignalRow","mineAssistSignals","rankAssistSpend","SCAN_CEILING","ANSWER_PREVIEW_CHARS","SPEND_SCAN_CEILING","readAssistSpendLeaderboard","firestore","limit","month","rankAssistSpendForMonth","error","console","rows","ranked","truncated","failed","snapshot","collectionGroup","get","size","docs","slice","unranked","doc","id","orgId","ref","parent","billed","Number","billedUsd","isFinite","push","plan","aiAddon","credits","providerCostUsd","refusals","length","orgRefs","map","row","collection","orgSnaps","billingSnaps","Promise","all","getAll","forEach","index","orgData","exists","data","org","label","orgLabel","handler","request","headers","method","query","rawHeaders","Response","json","status","authorization","idToken","startsWith","undefined","decoded","app","auth","verifyIdToken","email_verified","requested","Math","min","floor","report","spend","exchanges","proseCandidates","candidate","exchangeId","prose","expired","question","answer","answerTruncated","orgIds","Set","orgs","filter","orgLabelById","Map","orgDocs","set","freeSpend","catch","ceiling","unauthenticated","GET"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-signals.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 ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport {\n ASSIST_PROVIDER_COST_FIELD,\n assistCreditsFromUsd,\n assistProviderCostUsd,\n} from '../usage/assist-credits'\nimport {\n hasAiAddon,\n resolveEffectivePlan,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { readPlatformFreeSpend } from '../usage/assist-free-taste'\nimport { assistRefusalCounts } from '../usage/assist-refusals'\nimport { assistUsageDay, assistUsageMonth } from '../usage/assist-usage'\nimport {\n ASSIST_RETURNED_USD_FIELD,\n assistSpendAfterReturnsUsd,\n} from '../usage/assist-credit-returns'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n assistSignalRow,\n mineAssistSignals,\n rankAssistSpend,\n type AssistSpendRow,\n} from '../usage/assist-signal-mining'\nimport { assistSignalsView } from '../usage/assist-signals-view'\n\n/**\n * The Assist docs-gap and cost mining view (AGL-1860, AGL-2252), served at\n * `/api/ai/admin/signals` for the plugin's Assist signal staff page.\n * Staff-gated, read-only, and the only reader `assistSignals` has.\n *\n * ## Why a route and not a client query\n *\n * `assistSignals` is absent from `firebase-firestore.rules` deliberately: the\n * org block matches its subcollections BY NAME and carries no wildcard, so an\n * unlisted name is default-deny for every client. That is the property that\n * lets the collection hold cross-tenant analytics at all. Reading it therefore\n * goes through the Admin SDK behind the staff claim, exactly as every staff\n * route does.\n *\n * ## The collection group, and the index it does not need\n *\n * One `collectionGroup('assistSignals')` read gathers every org's signals in a\n * single query; the owning org is `ref.parent.parent.id`, since the document\n * carries no `orgId` field (it carries no identifiers at all — see AGL-1972).\n * The query has no `where` and no `orderBy`, so it needs no composite index,\n * and adding one later would mean declaring a `COLLECTION_GROUP`-scoped\n * override and deploying it by hand. `/api/admin/overview` reads org billing\n * the same way and for the same reason.\n *\n * ## Truncation is reported, never hidden\n *\n * The read is bounded — an unbounded fleet-wide get is how a staff page\n * becomes an outage — but a ranking cut short looks exactly like a complete\n * one, so the ceiling is fetched PLUS ONE and the extra row sets `truncated`.\n * AGL-2220 is the standing example of the alternative: a sweep that stopped at\n * 500 orgs and reported nothing about the rest, for as long as nobody counted.\n */\n\n/**\n * Rows read in one pass. Each signal is a handful of scalars, so this is a\n * few megabytes at the ceiling — large enough to be worth ranking, small\n * enough that the honest answer to \"is it all of them\" is usually yes.\n */\nconst SCAN_CEILING = 20000\n\n/**\n * How much of an answer the prose panel shows.\n *\n * Enough to judge whether the answer was wrong, short enough that twenty-five\n * of them are still a page somebody reads.\n */\nconst ANSWER_PREVIEW_CHARS = 600\n\n/**\n * Month documents read in one pass for the spend leaderboard (AGL-2930).\n *\n * `assistUsage` holds one document per org per month, so this is orgs times\n * months — a fraction of the signal count, and the same PLUS-ONE truncation\n * probe the signals read uses. Read as a collection group with NO `where`\n * and NO `orderBy`, for the reason the module note gives: a filter on\n * `month` would need a COLLECTION_GROUP-scoped index deployed by hand, and\n * filtering the month in memory costs nothing at this size.\n */\nconst SPEND_SCAN_CEILING = 20000\n\nexport interface AssistSpendLeaderboard {\n month: string\n rows: AssistSpendRow[]\n /** How many workspaces had a document for the month — the ranked count. */\n ranked: number\n /** True when the scan hit its ceiling, so the ranking is over a sample. */\n truncated: boolean\n /**\n * True when the month documents could not be read at all. Reported rather\n * than rendered as an empty table: \"no workspace spent anything\" and \"the\n * read failed\" are opposite findings, and the rest of the board — read\n * from a different collection — is still good.\n */\n failed: boolean\n}\n\n/**\n * The month's per-org spend, refusals and plan, dearest first.\n *\n * Plan and add-on come from the org and billing documents of the ranked\n * rows only — bounded by `limit`, never by the fleet — merged org-doc-first\n * exactly as every other staff reader merges them.\n */\nasync function readAssistSpendLeaderboard(\n firestore: FirebaseFirestore.Firestore,\n limit: number,\n month: string,\n): Promise<AssistSpendLeaderboard> {\n try {\n return await rankAssistSpendForMonth(firestore, limit, month)\n } catch (error) {\n console.error('[ai/admin/signals] spend leaderboard read failed', error)\n return { month, rows: [], ranked: 0, truncated: false, failed: true }\n }\n}\n\nasync function rankAssistSpendForMonth(\n firestore: FirebaseFirestore.Firestore,\n limit: number,\n month: string,\n): Promise<AssistSpendLeaderboard> {\n const snapshot = await firestore\n .collectionGroup('assistUsage')\n .limit(SPEND_SCAN_CEILING + 1)\n .get()\n const truncated = snapshot.size > SPEND_SCAN_CEILING\n const docs = truncated ? snapshot.docs.slice(0, SPEND_SCAN_CEILING) : snapshot.docs\n const unranked: AssistSpendRow[] = []\n for (const doc of docs) {\n // The id IS the month; the field is the same value and is what an\n // older document may lack.\n if (doc.id !== month && doc.get('month') !== month) continue\n const orgId = doc.ref.parent.parent?.id\n if (!orgId) continue\n // Two figures off one document (AGL-3015): the credits a workspace drew\n // come from what it was BILLED, and the dollars staff rank workspaces by\n // come from what it COST US. Ranking on the billed figure would sort the\n // fleet by our own markup wherever workspaces differ in model mix.\n const billed = Number(doc.get('estCostUsd') ?? 0)\n const billedUsd = Number.isFinite(billed) && billed > 0 ? billed : 0\n unranked.push({\n orgId,\n plan: 'free',\n aiAddon: false,\n // Net of credits given back (AGL-3595); the provider dollars are not.\n credits: assistCreditsFromUsd(\n assistSpendAfterReturnsUsd(billedUsd, doc.get(ASSIST_RETURNED_USD_FIELD)),\n ),\n providerCostUsd: assistProviderCostUsd(\n billedUsd,\n doc.get(ASSIST_PROVIDER_COST_FIELD),\n ),\n refusals: assistRefusalCounts(doc.get('refusals')),\n })\n }\n const { rows, ranked } = rankAssistSpend(unranked, limit)\n if (rows.length) {\n const orgRefs = rows.map((row) => firestore.collection('orgs').doc(row.orgId))\n const [orgSnaps, billingSnaps] = await Promise.all([\n firestore.getAll(...orgRefs),\n firestore.getAll(\n ...orgRefs.map((ref) =>\n ref.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID),\n ),\n ),\n ])\n rows.forEach((row, index) => {\n const orgData = orgSnaps[index]?.exists ? (orgSnaps[index].data() ?? {}) : {}\n const org = {\n ...orgData,\n ...(billingSnaps[index]?.exists ? billingSnaps[index].data() : {}),\n }\n row.plan = resolveEffectivePlan(org as never)\n row.aiAddon = hasAiAddon(org as never)\n const label = (orgData['name'] ?? orgData['slug'] ?? '') as string\n row.orgLabel = label || null\n })\n }\n return { month, rows, ranked, truncated, failed: false }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const requested = Number(query.limit ?? 25)\n const limit =\n Number.isFinite(requested) && requested > 0\n ? Math.min(Math.floor(requested), 100)\n : 25\n\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collectionGroup('assistSignals')\n // Ceiling PLUS ONE: the extra document is what makes truncation\n // detectable rather than assumed.\n .limit(SCAN_CEILING + 1)\n .get()\n\n const truncated = snapshot.size > SCAN_CEILING\n const docs = truncated ? snapshot.docs.slice(0, SCAN_CEILING) : snapshot.docs\n const rows = docs.map((doc) =>\n assistSignalRow(\n doc.ref.parent.parent?.id ?? '(unknown)',\n // The signal id IS the exchange id (AGL-2314) — one id, two halves,\n // minted together by `recordAssistExchange` precisely so the join is\n // possible. Until now nothing carried it this far.\n doc.id,\n doc.data(),\n ),\n )\n\n const report = mineAssistSignals(rows, { truncated, limit })\n\n // This month's spend, per workspace (AGL-2930) — the board's only\n // month-scoped panel, read off the month documents rather than mined\n // from the all-time signals above.\n const spend = await readAssistSpendLeaderboard(\n firebaseAdmin.app().firestore(),\n limit,\n assistUsageMonth(),\n )\n\n /*==========================================\n * THE VERBATIM HALF (AGL-2314).\n *\n * `assistExchanges` stores the question a customer typed and the answer\n * we gave, for 180 days, and `assist-usage.ts` calls it \"the data loop's\n * corpus\". NOTHING read it — one write, no reader, no collection group.\n * So we retained customers' words, and committed publicly in the privacy\n * policy to retaining them, for zero product value.\n *\n * Read here rather than in the miner because the miner is pure: it does\n * the arithmetic, the route does the reading.\n *\n * ## Only the turns that FAILED\n *\n * The shortlist is thumbs-down and ungrounded turns only, capped by the\n * same `limit` as every other panel. That is a privacy decision as much\n * as a cost one: the counts already answer \"how often\", and the words are\n * fetched only where the counts CANNOT say what went wrong. Pulling every\n * exchange would be surveillance with a dashboard on it.\n *\n * `uid` is deliberately not projected. The corpus question is what people\n * asked, never who asked it — and the exchange is the only document that\n * still carries an identifier at all.\n *\n * ## An expired exchange is an ANSWER\n *\n * The 180-day TTL means a signal can outlive its prose, and it is meant\n * to. A missing document therefore reports `expired: true` rather than\n * being dropped: a shortlist silently shorter than the failure count it\n * came from would read as \"these are all the failures\", which is the\n * AGL-2220 shape.\n *=========================================*/\n const firestore = firebaseAdmin.app().firestore()\n const exchanges = report.proseCandidates.length\n ? await firestore.getAll(\n ...report.proseCandidates.map((candidate) =>\n firestore\n .collection('orgs')\n .doc(candidate.orgId)\n .collection('assistExchanges')\n .doc(candidate.exchangeId),\n ),\n )\n : []\n const prose = report.proseCandidates.map((candidate, index) => {\n const snapshot = exchanges[index]\n const data = (snapshot?.exists ? snapshot.data() : null) ?? {}\n return {\n ...candidate,\n expired: !snapshot?.exists,\n question: typeof data['question'] === 'string' ? data['question'] : null,\n // Trimmed, not omitted: judging whether an answer was wrong needs to\n // see it, and a whole answer per row would make the panel unreadable.\n answer:\n typeof data['answer'] === 'string'\n ? data['answer'].slice(0, ANSWER_PREVIEW_CHARS)\n : null,\n answerTruncated:\n typeof data['answer'] === 'string' &&\n data['answer'].length > ANSWER_PREVIEW_CHARS,\n }\n })\n\n /*==========================================\n * THE WORKSPACE, BY NAME.\n *\n * A staff reader recognizes a customer by name, never by document id,\n * and the cost board's own column header says \"Workspace\" over a column\n * of generated ids. `/api/admin/overview` fixed the same shape by\n * building a label map from a snapshot it already held; there is no such\n * snapshot here — this route reads `assistSignals` and nothing else — so\n * the labels are fetched, with the same `name` → `slug` → id precedence\n * so one workspace cannot appear under two different labels on two\n * screens.\n *\n * Bounded by what is actually RENDERED, not by the fleet. Both lists the\n * ids come from are already sliced to `limit` (25 by default, 100 at the\n * ceiling), so this is one `getAll` over at most a couple of hundred\n * documents — and it reads the orgs the reader can see rather than\n * scanning a collection to name a handful of rows.\n *\n * An org that has been deleted keeps its id rather than going blank: an\n * unfamiliar id is still a lead a staff member can paste into the org\n * search, and an empty cell is not.\n *=========================================*/\n const orgIds = [\n ...new Set(\n [\n ...report.orgs.map((row) => row.orgId),\n ...report.proseCandidates.map((row) => row.orgId),\n ].filter((orgId) => orgId && orgId !== '(unknown)'),\n ),\n ]\n const orgLabelById = new Map<string, string>()\n if (orgIds.length) {\n const orgDocs = await firestore.getAll(\n ...orgIds.map((orgId) => firestore.collection('orgs').doc(orgId)),\n )\n for (const doc of orgDocs) {\n if (!doc.exists) continue\n const data = doc.data() ?? {}\n const label = (data['name'] ?? data['slug'] ?? '') as string\n if (label) orgLabelById.set(doc.id, label)\n }\n }\n const orgLabel = (orgId: string): string =>\n orgLabelById.get(orgId) ?? orgId\n\n // Today's free-tier spend against the platform ceiling (AGL-2925): one\n // document read, and the only place staff can see the taste switching\n // itself off before the email arrives. Best-effort — a readout that\n // cannot be read must not take the rest of the board down with it.\n const freeSpend = await readPlatformFreeSpend(\n firestore,\n assistUsageDay(),\n ).catch((error) => {\n console.error('[ai/admin/signals] free spend readout failed', error)\n return undefined\n })\n\n const answer = {\n ...report,\n freeSpend,\n orgs: report.orgs.map((row) => ({\n ...row,\n orgLabel: orgLabel(row.orgId),\n })),\n prose: prose.map((row) => ({ ...row, orgLabel: orgLabel(row.orgId) })),\n spend,\n ceiling: SCAN_CEILING,\n }\n // The board as the page prints it, for a client that draws no tables (the staff apps).\n if (String(query.view ?? '') === 'prepared') {\n return Response.json(assistSignalsView(answer as never), { status: 200 })\n }\n return Response.json(answer, { status: 200 })\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/signals]', error)\n return Response.json({ error: 'Assist signal lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","ASSIST_PROVIDER_COST_FIELD","assistCreditsFromUsd","assistProviderCostUsd","hasAiAddon","resolveEffectivePlan","readPlatformFreeSpend","assistRefusalCounts","assistUsageDay","assistUsageMonth","ASSIST_RETURNED_USD_FIELD","assistSpendAfterReturnsUsd","invalidIdTokenResponse","assistSignalRow","mineAssistSignals","rankAssistSpend","assistSignalsView","SCAN_CEILING","ANSWER_PREVIEW_CHARS","SPEND_SCAN_CEILING","readAssistSpendLeaderboard","firestore","limit","month","rankAssistSpendForMonth","error","console","rows","ranked","truncated","failed","snapshot","collectionGroup","get","size","docs","slice","unranked","doc","id","orgId","ref","parent","billed","Number","billedUsd","isFinite","push","plan","aiAddon","credits","providerCostUsd","refusals","length","orgRefs","map","row","collection","orgSnaps","billingSnaps","Promise","all","getAll","forEach","index","orgData","exists","data","org","label","orgLabel","handler","request","headers","method","query","rawHeaders","Response","json","status","authorization","idToken","startsWith","undefined","decoded","app","auth","verifyIdToken","email_verified","requested","Math","min","floor","report","spend","exchanges","proseCandidates","candidate","exchangeId","prose","expired","question","answer","answerTruncated","orgIds","Set","orgs","filter","orgLabelById","Map","orgDocs","set","freeSpend","catch","ceiling","String","view","unauthenticated","GET"],"mappings":"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"}
@@ -19,6 +19,7 @@ import { readUserAiUsageMonths } from "../usage/ai-usage-by-user.js";
19
19
  import { freeAccountUsageRef } from "../usage/assist-free-taste.js";
20
20
  import { assistUsageMonth } from "../usage/assist-usage.js";
21
21
  import { composeStaffAiAccountAllowance } from "../usage/staff-org-ai.js";
22
+ import { staffUserAiView } from "../usage/staff-org-ai-view.js";
22
23
  import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
23
24
  /**
24
25
  * ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user
@@ -59,7 +60,7 @@ async function handler(request) {
59
60
  status: 400
60
61
  });
61
62
  try {
62
- var _allowanceSnap_data;
63
+ var _allowanceSnap_data, _query_view;
63
64
  const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
64
65
  if (!decoded.email_verified && !isImpersonationSession(decoded)) {
65
66
  return emailUnverifiedResponse();
@@ -96,6 +97,15 @@ async function handler(request) {
96
97
  const month = assistUsageMonth();
97
98
  const allowanceSnap = await freeAccountUsageRef(firestore, uid, month).get();
98
99
  const allowance = composeStaffAiAccountAllowance(uid, month, allowanceSnap.exists ? (_allowanceSnap_data = allowanceSnap.data()) != null ? _allowanceSnap_data : null : null);
100
+ // The card as it prints, for a client that draws none of it (the staff apps).
101
+ if (String((_query_view = query.view) != null ? _query_view : '') === 'prepared') {
102
+ return Response.json(staffUserAiView({
103
+ rows,
104
+ allowance
105
+ }), {
106
+ status: 200
107
+ });
108
+ }
99
109
  return Response.json({
100
110
  uid,
101
111
  rows,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-user.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 { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { readUserAiUsageMonths } from '../usage/ai-usage-by-user'\nimport { freeAccountUsageRef } from '../usage/assist-free-taste'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { composeStaffAiAccountAllowance } from '../usage/staff-org-ai'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user\n * page's card, beside `admin/users/detail`.\n *\n * Walks the account's reverse index of memberships, the same read the detail\n * route makes, and lists each workspace's months for this uid newest first.\n * A membership walk rather than a collection-group query, so the card works\n * the moment it ships and needs no index deployed first; a workspace the\n * person has left is not listed, and its months expire on the retention\n * window.\n *\n * Read only, and not audited: the card loads with every open of the staff\n * user page, the same reason the org card writes no row.\n */\n\n/** The most workspaces one account is walked across. */\nconst MAX_ORGS = 50\n\nexport interface StaffUserAiUsageRow {\n orgId: string\n orgName: string | null\n slug: string | null\n month: string\n credits: number\n requests: number\n refusals: number\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const uid = String(query.uid ?? '').trim()\n if (!uid) return Response.json({ error: 'Missing uid' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n const firestore = firebaseAdmin.app().firestore()\n const reverse = await firestore\n .collection('users')\n .doc(uid)\n .collection('orgs')\n .limit(MAX_ORGS)\n .get()\n\n const rows: StaffUserAiUsageRow[] = []\n for (const entry of reverse.docs) {\n const months = await readUserAiUsageMonths(firestore, entry.id, uid)\n for (const month of months) {\n rows.push({\n orgId: entry.id,\n orgName: typeof entry.get('orgName') === 'string' ? String(entry.get('orgName')) : null,\n slug: typeof entry.get('slug') === 'string' ? String(entry.get('slug')) : null,\n month: month.month,\n credits: month.credits,\n requests: month.requests,\n refusals: month.refusals,\n })\n }\n }\n // Newest month first, dearest workspace first within it.\n rows.sort(\n (a, b) => b.month.localeCompare(a.month) || b.credits - a.credits,\n )\n // The account's own Free allowance this month (AGL-2925), which every\n // free workspace this person owns draws on and which staff can give\n // credits back to from this page (AGL-3595).\n const month = assistUsageMonth()\n const allowanceSnap = await freeAccountUsageRef(firestore, uid, month).get()\n const allowance = composeStaffAiAccountAllowance(\n uid,\n month,\n allowanceSnap.exists ? (allowanceSnap.data() ?? null) : null,\n )\n return Response.json(\n { uid, rows, truncated: reverse.size >= MAX_ORGS, allowance },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/user]', error)\n return Response.json({ error: 'AI usage lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readUserAiUsageMonths","freeAccountUsageRef","assistUsageMonth","composeStaffAiAccountAllowance","invalidIdTokenResponse","MAX_ORGS","handler","request","headers","query","method","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","uid","String","trim","allowanceSnap","decoded","app","auth","verifyIdToken","email_verified","firestore","reverse","collection","doc","limit","get","rows","entry","docs","months","id","month","push","orgId","orgName","slug","credits","requests","refusals","sort","a","b","localeCompare","allowance","exists","data","truncated","size","unauthenticated","console","GET"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-user.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 { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { readUserAiUsageMonths } from '../usage/ai-usage-by-user'\nimport { freeAccountUsageRef } from '../usage/assist-free-taste'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { composeStaffAiAccountAllowance } from '../usage/staff-org-ai'\nimport { staffUserAiView } from '../usage/staff-org-ai-view'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user\n * page's card, beside `admin/users/detail`.\n *\n * Walks the account's reverse index of memberships, the same read the detail\n * route makes, and lists each workspace's months for this uid newest first.\n * A membership walk rather than a collection-group query, so the card works\n * the moment it ships and needs no index deployed first; a workspace the\n * person has left is not listed, and its months expire on the retention\n * window.\n *\n * Read only, and not audited: the card loads with every open of the staff\n * user page, the same reason the org card writes no row.\n */\n\n/** The most workspaces one account is walked across. */\nconst MAX_ORGS = 50\n\nexport interface StaffUserAiUsageRow {\n orgId: string\n orgName: string | null\n slug: string | null\n month: string\n credits: number\n requests: number\n refusals: number\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const uid = String(query.uid ?? '').trim()\n if (!uid) return Response.json({ error: 'Missing uid' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n const firestore = firebaseAdmin.app().firestore()\n const reverse = await firestore\n .collection('users')\n .doc(uid)\n .collection('orgs')\n .limit(MAX_ORGS)\n .get()\n\n const rows: StaffUserAiUsageRow[] = []\n for (const entry of reverse.docs) {\n const months = await readUserAiUsageMonths(firestore, entry.id, uid)\n for (const month of months) {\n rows.push({\n orgId: entry.id,\n orgName: typeof entry.get('orgName') === 'string' ? String(entry.get('orgName')) : null,\n slug: typeof entry.get('slug') === 'string' ? String(entry.get('slug')) : null,\n month: month.month,\n credits: month.credits,\n requests: month.requests,\n refusals: month.refusals,\n })\n }\n }\n // Newest month first, dearest workspace first within it.\n rows.sort(\n (a, b) => b.month.localeCompare(a.month) || b.credits - a.credits,\n )\n // The account's own Free allowance this month (AGL-2925), which every\n // free workspace this person owns draws on and which staff can give\n // credits back to from this page (AGL-3595).\n const month = assistUsageMonth()\n const allowanceSnap = await freeAccountUsageRef(firestore, uid, month).get()\n const allowance = composeStaffAiAccountAllowance(\n uid,\n month,\n allowanceSnap.exists ? (allowanceSnap.data() ?? null) : null,\n )\n // The card as it prints, for a client that draws none of it (the staff apps).\n if (String(query.view ?? '') === 'prepared') {\n return Response.json(staffUserAiView({ rows, allowance }), { status: 200 })\n }\n return Response.json(\n { uid, rows, truncated: reverse.size >= MAX_ORGS, allowance },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/user]', error)\n return Response.json({ error: 'AI usage lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readUserAiUsageMonths","freeAccountUsageRef","assistUsageMonth","composeStaffAiAccountAllowance","staffUserAiView","invalidIdTokenResponse","MAX_ORGS","handler","request","headers","query","method","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","uid","String","trim","allowanceSnap","decoded","app","auth","verifyIdToken","email_verified","firestore","reverse","collection","doc","limit","get","rows","entry","docs","months","id","month","push","orgId","orgName","slug","credits","requests","refusals","sort","a","b","localeCompare","allowance","exists","data","view","truncated","size","unauthenticated","console","GET"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,sBAAqB;AAC1D,SACEC,uBAAuB,EACvBC,aAAa,EACbC,sBAAsB,QACjB,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,+BAA2B;AACjE,SAASC,mBAAmB,QAAQ,gCAA4B;AAChE,SAASC,gBAAgB,QAAQ,2BAAuB;AACxD,SAASC,8BAA8B,QAAQ,2BAAuB;AACtE,SAASC,eAAe,QAAQ,gCAA4B;AAC5D,SAASC,sBAAsB,QAAQ,mDAAkD;AAEzF;;;;;;;;;;;;;CAaC,GAED,sDAAsD,GACtD,MAAMC,WAAW;AAYjB,eAAeC,QAAQC,OAAgB;QAMfC,wBAKHC;IAVnB,MAAM,EAAEC,MAAM,EAAED,KAAK,EAAED,SAASG,UAAU,EAAE,GAAG,MAAMhB,qBAAqBY;IAC1E,MAAMC,UAAUG;IAChB,IAAID,WAAW,OAAO;QACpB,OAAOE,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAqB,GAAG;YAAEC,QAAQ;QAAI;IACtE;IACA,MAAMC,iBAAgBR,yBAAAA,QAAQQ,aAAa,YAArBR,yBAAyB;IAC/C,MAAMS,UAAUD,cAAcE,UAAU,CAAC,aACrCF,cAAcG,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAOL,SAASC,IAAI,CAAC;QAAEC,OAAO;IAAkB,GAAG;QAAEC,QAAQ;IAAI;IAC/E,MAAMO,MAAMC,QAAOd,aAAAA,MAAMa,GAAG,YAATb,aAAa,IAAIe,IAAI;IACxC,IAAI,CAACF,KAAK,OAAOV,SAASC,IAAI,CAAC;QAAEC,OAAO;IAAc,GAAG;QAAEC,QAAQ;IAAI;IAEvE,IAAI;YA2CwBU,qBAGfhB;QA7CX,MAAMiB,UAAU,MAAM7B,cAAc8B,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACZ;QAC/D,IAAI,CAACS,QAAQI,cAAc,IAAI,CAAChC,uBAAuB4B,UAAU;YAC/D,OAAO9B;QACT;QACA,IAAI,CAAC8B,OAAO,CAAC,QAAQ,EAAE;YACrB,OAAOd,SAASC,IAAI,CAAC;gBAAEC,OAAO;YAAa,GAAG;gBAAEC,QAAQ;YAAI;QAC9D;QACA,MAAMgB,YAAYlC,cAAc8B,GAAG,GAAGI,SAAS;QAC/C,MAAMC,UAAU,MAAMD,UACnBE,UAAU,CAAC,SACXC,GAAG,CAACZ,KACJW,UAAU,CAAC,QACXE,KAAK,CAAC9B,UACN+B,GAAG;QAEN,MAAMC,OAA8B,EAAE;QACtC,KAAK,MAAMC,SAASN,QAAQO,IAAI,CAAE;YAChC,MAAMC,SAAS,MAAMzC,sBAAsBgC,WAAWO,MAAMG,EAAE,EAAEnB;YAChE,KAAK,MAAMoB,SAASF,OAAQ;gBAC1BH,KAAKM,IAAI,CAAC;oBACRC,OAAON,MAAMG,EAAE;oBACfI,SAAS,OAAOP,MAAMF,GAAG,CAAC,eAAe,WAAWb,OAAOe,MAAMF,GAAG,CAAC,cAAc;oBACnFU,MAAM,OAAOR,MAAMF,GAAG,CAAC,YAAY,WAAWb,OAAOe,MAAMF,GAAG,CAAC,WAAW;oBAC1EM,OAAOA,MAAMA,KAAK;oBAClBK,SAASL,MAAMK,OAAO;oBACtBC,UAAUN,MAAMM,QAAQ;oBACxBC,UAAUP,MAAMO,QAAQ;gBAC1B;YACF;QACF;QACA,yDAAyD;QACzDZ,KAAKa,IAAI,CACP,CAACC,GAAGC,IAAMA,EAAEV,KAAK,CAACW,aAAa,CAACF,EAAET,KAAK,KAAKU,EAAEL,OAAO,GAAGI,EAAEJ,OAAO;QAEnE,sEAAsE;QACtE,oEAAoE;QACpE,6CAA6C;QAC7C,MAAML,QAAQzC;QACd,MAAMwB,gBAAgB,MAAMzB,oBAAoB+B,WAAWT,KAAKoB,OAAON,GAAG;QAC1E,MAAMkB,YAAYpD,+BAChBoB,KACAoB,OACAjB,cAAc8B,MAAM,IAAI9B,sBAAAA,cAAc+B,IAAI,cAAlB/B,sBAAwB,OAAQ;QAE1D,8EAA8E;QAC9E,IAAIF,QAAOd,cAAAA,MAAMgD,IAAI,YAAVhD,cAAc,QAAQ,YAAY;YAC3C,OAAOG,SAASC,IAAI,CAACV,gBAAgB;gBAAEkC;gBAAMiB;YAAU,IAAI;gBAAEvC,QAAQ;YAAI;QAC3E;QACA,OAAOH,SAASC,IAAI,CAClB;YAAES;YAAKe;YAAMqB,WAAW1B,QAAQ2B,IAAI,IAAItD;YAAUiD;QAAU,GAC5D;YAAEvC,QAAQ;QAAI;IAElB,EAAE,OAAOD,OAAO;QACd,2DAA2D;QAC3D,uEAAuE;QACvE,MAAM8C,kBAAkBxD,uBAAuBU;QAC/C,IAAI8C,iBAAiB,OAAOA;QAC5BC,QAAQ/C,KAAK,CAAC,mBAAmBA;QACjC,OAAOF,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAyB,GAAG;YAAEC,QAAQ;QAAI;IAC1E;AACF;AAEA,SAAST,WAAWwD,GAAG,GAAE"}
@@ -186,5 +186,8 @@ export interface AiAllotmentsWire {
186
186
  /** Who is reading, so an editor can refuse to offer a self-raise. */
187
187
  callerUid: string;
188
188
  }
189
- /** `2026-09` as a reader says it, on the UTC calendar the key is cut from. */
190
- export declare function aiUsageMonthLabel(month: string): string;
189
+ /**
190
+ * `2026-09` as a reader says it, on the UTC calendar the key is cut from, in
191
+ * the reader's locale (the staff apps' prepared views pass `en-US`).
192
+ */
193
+ export declare function aiUsageMonthLabel(month: string, locale?: string): string;
@@ -81,11 +81,14 @@ export function aiUsageCsvLine(row) {
81
81
  const past = (used, limit)=>limit !== null && limit > 0 && used >= limit * AI_ALLOTMENT_WARN_SHARE;
82
82
  return past(mine.used, mine.limit) || past(pool.used, pool.limit) ? 'warn' : 'ok';
83
83
  }
84
- /** `2026-09` as a reader says it, on the UTC calendar the key is cut from. */ export function aiUsageMonthLabel(month) {
84
+ /**
85
+ * `2026-09` as a reader says it, on the UTC calendar the key is cut from, in
86
+ * the reader's locale (the staff apps' prepared views pass `en-US`).
87
+ */ export function aiUsageMonthLabel(month, locale) {
85
88
  const [year, monthOfYear] = month.split('-').map(Number);
86
89
  if (!year || !monthOfYear) return month;
87
90
  try {
88
- return new Date(Date.UTC(year, monthOfYear - 1, 1)).toLocaleDateString(undefined, {
91
+ return new Date(Date.UTC(year, monthOfYear - 1, 1)).toLocaleDateString(locale, {
89
92
  month: 'long',
90
93
  year: 'numeric',
91
94
  timeZone: 'UTC'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-wire.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// By their own entry points rather than the barrel: the route that serves\n// this shape imports it on the server, where the whole library is dead weight.\nimport {\n topAiUsageKinds,\n type AiUsageByUserMonth,\n} from '../model/ai-usage-by-user'\nimport {\n AI_ALLOTMENT_WARN_SHARE,\n type AiAllotmentMode,\n type AiCreditAllotmentScope,\n} from '../model/ai-allotments'\nimport { csvCell } from '@aglyn/aglyn/app-utils/csv-import'\n\n/**\n * What `/api/ai/usage` answers (AGL-2928), shared by the route and the\n * console surfaces that read it — the Usage table, the roster column, the\n * site collaborators card and the member card. Dependency-free on purpose:\n * the route imports the Admin SDK and a client component may not.\n */\n\n/** Header naming the row count the export undertook to send. */\nexport const AI_USAGE_EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows'\n\n/** A row as a customer sees it — credits and a share, never `estCostUsd`. */\nexport interface OrgAiUsageRowWire {\n uid: string\n /** The roster's name — display name, else email, else the uid. */\n name: string\n email: string | null\n role: string | null\n credits: number\n /** Of the workspace's measured spend for the month, in `[0, 1]`. */\n share: number\n requests: number\n refusals: number\n byKind: Record<string, number>\n byHost: Record<string, number>\n /** Credits on the site the request named, when it named one. */\n hostCredits?: number\n}\n\nexport interface OrgAiUsageTableWire {\n month: string\n /** The months a reader may ask for, newest first. */\n months: string[]\n /** The workspace's own credits for the month — the pool the shares are of. */\n orgCredits: number\n rows: OrgAiUsageRowWire[]\n}\n\n/** One person's month, as the member card reads it. */\nexport interface UserAiUsageMonthWire {\n month: string\n credits: number\n share: number\n requests: number\n refusals: number\n byKind: Record<string, number>\n byHost: Record<string, number>\n}\n\nexport interface UserAiUsageWire {\n uid: string\n /** Newest first. */\n months: UserAiUsageMonthWire[]\n}\n\n/** The roster's month, joined and shared, projected onto the wire. */\nexport function toAiUsageWireRow(\n row: AiUsageByUserMonth & {\n name: string\n email: string | null\n role: string | null\n share: number\n },\n hostId: string,\n): OrgAiUsageRowWire {\n const wire: OrgAiUsageRowWire = {\n uid: row.uid,\n name: row.name,\n email: row.email,\n role: row.role,\n credits: row.credits,\n share: row.share,\n requests: row.requests,\n refusals: row.refusals,\n byKind: row.byKind,\n byHost: row.byHost,\n }\n if (hostId) wire.hostCredits = row.byHost[hostId] ?? 0\n return wire\n}\n\n/** The CSV's columns, in order. */\nexport const AI_USAGE_CSV_HEADER = [\n 'Member',\n 'Email',\n 'Role',\n 'Credits',\n 'Share',\n 'Requests',\n 'Refusals',\n 'Top kinds',\n 'Sites',\n] as const\n\n/** A share as the table and the file print it: one decimal of a percent. */\nexport function formatAiUsageShare(share: number): string {\n return `${Math.round(share * 1000) / 10}%`\n}\n\nexport function aiUsageCsvLine(row: OrgAiUsageRowWire): string {\n const kinds = topAiUsageKinds(row.byKind, 3)\n .map((entry) => `${entry.kind} ${entry.credits}`)\n .join('; ')\n const sites = Object.entries(row.byHost)\n .sort((a, b) => b[1] - a[1])\n .map(([hostId, credits]) => `${hostId} ${credits}`)\n .join('; ')\n return [\n row.name,\n row.email ?? '',\n row.role ?? '',\n row.credits,\n formatAiUsageShare(row.share),\n row.requests,\n row.refusals,\n kinds,\n sites,\n ]\n .map(csvCell)\n .join(',')\n}\n\n/**\n * THE USAGE STRIP'S ENVELOPE (AGL-2942): what every AI door adds to the\n * answer it already sends — the chat door's `done` event, the copy\n * assistant's JSON, a job's create response, and each door's quota refusal —\n * so the strip in the panel and the generation dialogs updates with no read\n * of its own.\n *\n * Credits only. `pool` is the workspace's month against its band; `mine` is\n * the caller's own month, measured against the allotment that binds them\n * when one does — for a site allotment, the site's month, because that is\n * the line the next request meets.\n */\nexport type AiUsageMeterState = 'ok' | 'warn' | 'capped'\n\nexport interface AiUsageMeterWire {\n month: string\n pool: { used: number; limit: number | null }\n mine: {\n used: number\n limit: number | null\n mode: AiAllotmentMode | null\n scope: AiCreditAllotmentScope | null\n /**\n * `mine` is the person's Free allowance (AGL-3722): `used` of the 300\n * free credits a month every Free workspace they hold draws on, net of\n * give-backs. Absent on a paid workspace.\n */\n free?: boolean\n }\n /** Credits the exchange this envelope answers cost; `null` when no model ran. */\n last: number | null\n /** True when the envelope rides a refusal. */\n refused: boolean\n state: AiUsageMeterState\n /** The model that answered, as the catalog names it. */\n model: { id: string; label: string; auto: boolean } | null\n}\n\n/**\n * `capped` when a hard allotment is spent or the request was refused, `warn`\n * from 80% of the allotment that binds or of the pool, `ok` otherwise.\n */\nexport function aiUsageMeterState(\n meter: Pick<AiUsageMeterWire, 'pool' | 'mine' | 'refused'>,\n): AiUsageMeterState {\n const { mine, pool } = meter\n if (meter.refused) return 'capped'\n if (mine.limit !== null && mine.mode === 'hard' && mine.used >= mine.limit) return 'capped'\n const past = (used: number, limit: number | null) =>\n limit !== null && limit > 0 && used >= limit * AI_ALLOTMENT_WARN_SHARE\n return past(mine.used, mine.limit) || past(pool.used, pool.limit) ? 'warn' : 'ok'\n}\n\n/**\n * What `/api/ai/allotments` answers (AGL-2942): the allotments a reader may\n * see, each with what its subject drew this month, and the roster, sites\n * and models the editors pick from. Credits only.\n */\nexport interface AiAllotmentRowWire {\n subject: string\n scope: 'member' | 'collab' | 'host' | 'org'\n uid: string | null\n hostId: string | null\n credits: number | null\n mode: AiAllotmentMode\n models: string[] | null\n /** Credits the subject drew this month — the figure the gate measures. */\n used: number\n}\n\nexport interface AiAllotmentsMemberWire {\n uid: string\n name: string\n email: string | null\n role: string | null\n /** False for a site collaborator, whose allotments are per site. */\n orgWide: boolean\n /** Credits this month, every site. */\n credits: number\n /** Credits this month by site. */\n byHost: Record<string, number>\n}\n\nexport interface AiAllotmentsHostWire {\n hostId: string\n name: string\n /** The site's credits this month, everyone on it. */\n credits: number\n}\n\nexport interface AiAllotmentsWire {\n /** The org the answer is about — resolved from the site when only one was named. */\n orgId: string\n month: string\n pool: { used: number; limit: number | null }\n allotments: AiAllotmentRowWire[]\n members: AiAllotmentsMemberWire[]\n hosts: AiAllotmentsHostWire[]\n /** The catalog models this deployment serves, for an allowlist. */\n models: Array<{ id: string; label: string; tier: string }>\n /** Whether the plan lets the workspace restrict models org-wide. */\n restrictionAvailable: boolean\n canEdit: {\n /** Members, sites and the restriction: `billing.manage`. */\n billing: boolean\n /** Collaborators on the site asked about: `billing.manage`, or its admin. */\n collaborators: boolean\n }\n /** Who is reading, so an editor can refuse to offer a self-raise. */\n callerUid: string\n}\n\n/** `2026-09` as a reader says it, on the UTC calendar the key is cut from. */\nexport function aiUsageMonthLabel(month: string): string {\n const [year, monthOfYear] = month.split('-').map(Number)\n if (!year || !monthOfYear) return month\n try {\n return new Date(Date.UTC(year, monthOfYear - 1, 1)).toLocaleDateString(undefined, {\n month: 'long',\n year: 'numeric',\n timeZone: 'UTC',\n })\n } catch {\n return month\n }\n}\n"],"names":["topAiUsageKinds","AI_ALLOTMENT_WARN_SHARE","csvCell","AI_USAGE_EXPORT_ROWS_HEADER","toAiUsageWireRow","row","hostId","wire","uid","name","email","role","credits","share","requests","refusals","byKind","byHost","hostCredits","AI_USAGE_CSV_HEADER","formatAiUsageShare","Math","round","aiUsageCsvLine","kinds","map","entry","kind","join","sites","Object","entries","sort","a","b","aiUsageMeterState","meter","mine","pool","refused","limit","mode","used","past","aiUsageMonthLabel","month","year","monthOfYear","split","Number","Date","UTC","toLocaleDateString","undefined","timeZone"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-wire.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// By their own entry points rather than the barrel: the route that serves\n// this shape imports it on the server, where the whole library is dead weight.\nimport {\n topAiUsageKinds,\n type AiUsageByUserMonth,\n} from '../model/ai-usage-by-user'\nimport {\n AI_ALLOTMENT_WARN_SHARE,\n type AiAllotmentMode,\n type AiCreditAllotmentScope,\n} from '../model/ai-allotments'\nimport { csvCell } from '@aglyn/aglyn/app-utils/csv-import'\n\n/**\n * What `/api/ai/usage` answers (AGL-2928), shared by the route and the\n * console surfaces that read it — the Usage table, the roster column, the\n * site collaborators card and the member card. Dependency-free on purpose:\n * the route imports the Admin SDK and a client component may not.\n */\n\n/** Header naming the row count the export undertook to send. */\nexport const AI_USAGE_EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows'\n\n/** A row as a customer sees it — credits and a share, never `estCostUsd`. */\nexport interface OrgAiUsageRowWire {\n uid: string\n /** The roster's name — display name, else email, else the uid. */\n name: string\n email: string | null\n role: string | null\n credits: number\n /** Of the workspace's measured spend for the month, in `[0, 1]`. */\n share: number\n requests: number\n refusals: number\n byKind: Record<string, number>\n byHost: Record<string, number>\n /** Credits on the site the request named, when it named one. */\n hostCredits?: number\n}\n\nexport interface OrgAiUsageTableWire {\n month: string\n /** The months a reader may ask for, newest first. */\n months: string[]\n /** The workspace's own credits for the month — the pool the shares are of. */\n orgCredits: number\n rows: OrgAiUsageRowWire[]\n}\n\n/** One person's month, as the member card reads it. */\nexport interface UserAiUsageMonthWire {\n month: string\n credits: number\n share: number\n requests: number\n refusals: number\n byKind: Record<string, number>\n byHost: Record<string, number>\n}\n\nexport interface UserAiUsageWire {\n uid: string\n /** Newest first. */\n months: UserAiUsageMonthWire[]\n}\n\n/** The roster's month, joined and shared, projected onto the wire. */\nexport function toAiUsageWireRow(\n row: AiUsageByUserMonth & {\n name: string\n email: string | null\n role: string | null\n share: number\n },\n hostId: string,\n): OrgAiUsageRowWire {\n const wire: OrgAiUsageRowWire = {\n uid: row.uid,\n name: row.name,\n email: row.email,\n role: row.role,\n credits: row.credits,\n share: row.share,\n requests: row.requests,\n refusals: row.refusals,\n byKind: row.byKind,\n byHost: row.byHost,\n }\n if (hostId) wire.hostCredits = row.byHost[hostId] ?? 0\n return wire\n}\n\n/** The CSV's columns, in order. */\nexport const AI_USAGE_CSV_HEADER = [\n 'Member',\n 'Email',\n 'Role',\n 'Credits',\n 'Share',\n 'Requests',\n 'Refusals',\n 'Top kinds',\n 'Sites',\n] as const\n\n/** A share as the table and the file print it: one decimal of a percent. */\nexport function formatAiUsageShare(share: number): string {\n return `${Math.round(share * 1000) / 10}%`\n}\n\nexport function aiUsageCsvLine(row: OrgAiUsageRowWire): string {\n const kinds = topAiUsageKinds(row.byKind, 3)\n .map((entry) => `${entry.kind} ${entry.credits}`)\n .join('; ')\n const sites = Object.entries(row.byHost)\n .sort((a, b) => b[1] - a[1])\n .map(([hostId, credits]) => `${hostId} ${credits}`)\n .join('; ')\n return [\n row.name,\n row.email ?? '',\n row.role ?? '',\n row.credits,\n formatAiUsageShare(row.share),\n row.requests,\n row.refusals,\n kinds,\n sites,\n ]\n .map(csvCell)\n .join(',')\n}\n\n/**\n * THE USAGE STRIP'S ENVELOPE (AGL-2942): what every AI door adds to the\n * answer it already sends — the chat door's `done` event, the copy\n * assistant's JSON, a job's create response, and each door's quota refusal —\n * so the strip in the panel and the generation dialogs updates with no read\n * of its own.\n *\n * Credits only. `pool` is the workspace's month against its band; `mine` is\n * the caller's own month, measured against the allotment that binds them\n * when one does — for a site allotment, the site's month, because that is\n * the line the next request meets.\n */\nexport type AiUsageMeterState = 'ok' | 'warn' | 'capped'\n\nexport interface AiUsageMeterWire {\n month: string\n pool: { used: number; limit: number | null }\n mine: {\n used: number\n limit: number | null\n mode: AiAllotmentMode | null\n scope: AiCreditAllotmentScope | null\n /**\n * `mine` is the person's Free allowance (AGL-3722): `used` of the 300\n * free credits a month every Free workspace they hold draws on, net of\n * give-backs. Absent on a paid workspace.\n */\n free?: boolean\n }\n /** Credits the exchange this envelope answers cost; `null` when no model ran. */\n last: number | null\n /** True when the envelope rides a refusal. */\n refused: boolean\n state: AiUsageMeterState\n /** The model that answered, as the catalog names it. */\n model: { id: string; label: string; auto: boolean } | null\n}\n\n/**\n * `capped` when a hard allotment is spent or the request was refused, `warn`\n * from 80% of the allotment that binds or of the pool, `ok` otherwise.\n */\nexport function aiUsageMeterState(\n meter: Pick<AiUsageMeterWire, 'pool' | 'mine' | 'refused'>,\n): AiUsageMeterState {\n const { mine, pool } = meter\n if (meter.refused) return 'capped'\n if (mine.limit !== null && mine.mode === 'hard' && mine.used >= mine.limit) return 'capped'\n const past = (used: number, limit: number | null) =>\n limit !== null && limit > 0 && used >= limit * AI_ALLOTMENT_WARN_SHARE\n return past(mine.used, mine.limit) || past(pool.used, pool.limit) ? 'warn' : 'ok'\n}\n\n/**\n * What `/api/ai/allotments` answers (AGL-2942): the allotments a reader may\n * see, each with what its subject drew this month, and the roster, sites\n * and models the editors pick from. Credits only.\n */\nexport interface AiAllotmentRowWire {\n subject: string\n scope: 'member' | 'collab' | 'host' | 'org'\n uid: string | null\n hostId: string | null\n credits: number | null\n mode: AiAllotmentMode\n models: string[] | null\n /** Credits the subject drew this month — the figure the gate measures. */\n used: number\n}\n\nexport interface AiAllotmentsMemberWire {\n uid: string\n name: string\n email: string | null\n role: string | null\n /** False for a site collaborator, whose allotments are per site. */\n orgWide: boolean\n /** Credits this month, every site. */\n credits: number\n /** Credits this month by site. */\n byHost: Record<string, number>\n}\n\nexport interface AiAllotmentsHostWire {\n hostId: string\n name: string\n /** The site's credits this month, everyone on it. */\n credits: number\n}\n\nexport interface AiAllotmentsWire {\n /** The org the answer is about — resolved from the site when only one was named. */\n orgId: string\n month: string\n pool: { used: number; limit: number | null }\n allotments: AiAllotmentRowWire[]\n members: AiAllotmentsMemberWire[]\n hosts: AiAllotmentsHostWire[]\n /** The catalog models this deployment serves, for an allowlist. */\n models: Array<{ id: string; label: string; tier: string }>\n /** Whether the plan lets the workspace restrict models org-wide. */\n restrictionAvailable: boolean\n canEdit: {\n /** Members, sites and the restriction: `billing.manage`. */\n billing: boolean\n /** Collaborators on the site asked about: `billing.manage`, or its admin. */\n collaborators: boolean\n }\n /** Who is reading, so an editor can refuse to offer a self-raise. */\n callerUid: string\n}\n\n/**\n * `2026-09` as a reader says it, on the UTC calendar the key is cut from, in\n * the reader's locale (the staff apps' prepared views pass `en-US`).\n */\nexport function aiUsageMonthLabel(month: string, locale?: string): string {\n const [year, monthOfYear] = month.split('-').map(Number)\n if (!year || !monthOfYear) return month\n try {\n return new Date(Date.UTC(year, monthOfYear - 1, 1)).toLocaleDateString(locale, {\n month: 'long',\n year: 'numeric',\n timeZone: 'UTC',\n })\n } catch {\n return month\n }\n}\n"],"names":["topAiUsageKinds","AI_ALLOTMENT_WARN_SHARE","csvCell","AI_USAGE_EXPORT_ROWS_HEADER","toAiUsageWireRow","row","hostId","wire","uid","name","email","role","credits","share","requests","refusals","byKind","byHost","hostCredits","AI_USAGE_CSV_HEADER","formatAiUsageShare","Math","round","aiUsageCsvLine","kinds","map","entry","kind","join","sites","Object","entries","sort","a","b","aiUsageMeterState","meter","mine","pool","refused","limit","mode","used","past","aiUsageMonthLabel","month","locale","year","monthOfYear","split","Number","Date","UTC","toLocaleDateString","timeZone"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,0EAA0E;AAC1E,+EAA+E;AAC/E,SACEA,eAAe,QAEV,+BAA2B;AAClC,SACEC,uBAAuB,QAGlB,4BAAwB;AAC/B,SAASC,OAAO,QAAQ,oCAAmC;AAE3D;;;;;CAKC,GAED,8DAA8D,GAC9D,OAAO,MAAMC,8BAA8B,sBAAqB;AA8ChE,oEAAoE,GACpE,OAAO,SAASC,iBACdC,GAKC,EACDC,MAAc;QAciBD;IAZ/B,MAAME,OAA0B;QAC9BC,KAAKH,IAAIG,GAAG;QACZC,MAAMJ,IAAII,IAAI;QACdC,OAAOL,IAAIK,KAAK;QAChBC,MAAMN,IAAIM,IAAI;QACdC,SAASP,IAAIO,OAAO;QACpBC,OAAOR,IAAIQ,KAAK;QAChBC,UAAUT,IAAIS,QAAQ;QACtBC,UAAUV,IAAIU,QAAQ;QACtBC,QAAQX,IAAIW,MAAM;QAClBC,QAAQZ,IAAIY,MAAM;IACpB;IACA,IAAIX,QAAQC,KAAKW,WAAW,IAAGb,qBAAAA,IAAIY,MAAM,CAACX,OAAO,YAAlBD,qBAAsB;IACrD,OAAOE;AACT;AAEA,iCAAiC,GACjC,OAAO,MAAMY,sBAAsB;IACjC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAS;AAEV,0EAA0E,GAC1E,OAAO,SAASC,mBAAmBP,KAAa;IAC9C,OAAO,GAAGQ,KAAKC,KAAK,CAACT,QAAQ,QAAQ,GAAG,CAAC,CAAC;AAC5C;AAEA,OAAO,SAASU,eAAelB,GAAsB;QAUjDA,YACAA;IAVF,MAAMmB,QAAQxB,gBAAgBK,IAAIW,MAAM,EAAE,GACvCS,GAAG,CAAC,CAACC,QAAU,GAAGA,MAAMC,IAAI,CAAC,CAAC,EAAED,MAAMd,OAAO,EAAE,EAC/CgB,IAAI,CAAC;IACR,MAAMC,QAAQC,OAAOC,OAAO,CAAC1B,IAAIY,MAAM,EACpCe,IAAI,CAAC,CAACC,GAAGC,IAAMA,CAAC,CAAC,EAAE,GAAGD,CAAC,CAAC,EAAE,EAC1BR,GAAG,CAAC,CAAC,CAACnB,QAAQM,QAAQ,GAAK,GAAGN,OAAO,CAAC,EAAEM,SAAS,EACjDgB,IAAI,CAAC;IACR,OAAO;QACLvB,IAAII,IAAI;SACRJ,aAAAA,IAAIK,KAAK,YAATL,aAAa;SACbA,YAAAA,IAAIM,IAAI,YAARN,YAAY;QACZA,IAAIO,OAAO;QACXQ,mBAAmBf,IAAIQ,KAAK;QAC5BR,IAAIS,QAAQ;QACZT,IAAIU,QAAQ;QACZS;QACAK;KACD,CACEJ,GAAG,CAACvB,SACJ0B,IAAI,CAAC;AACV;AAwCA;;;CAGC,GACD,OAAO,SAASO,kBACdC,KAA0D;IAE1D,MAAM,EAAEC,IAAI,EAAEC,IAAI,EAAE,GAAGF;IACvB,IAAIA,MAAMG,OAAO,EAAE,OAAO;IAC1B,IAAIF,KAAKG,KAAK,KAAK,QAAQH,KAAKI,IAAI,KAAK,UAAUJ,KAAKK,IAAI,IAAIL,KAAKG,KAAK,EAAE,OAAO;IACnF,MAAMG,OAAO,CAACD,MAAcF,QAC1BA,UAAU,QAAQA,QAAQ,KAAKE,QAAQF,QAAQvC;IACjD,OAAO0C,KAAKN,KAAKK,IAAI,EAAEL,KAAKG,KAAK,KAAKG,KAAKL,KAAKI,IAAI,EAAEJ,KAAKE,KAAK,IAAI,SAAS;AAC/E;AA6DA;;;CAGC,GACD,OAAO,SAASI,kBAAkBC,KAAa,EAAEC,MAAe;IAC9D,MAAM,CAACC,MAAMC,YAAY,GAAGH,MAAMI,KAAK,CAAC,KAAKxB,GAAG,CAACyB;IACjD,IAAI,CAACH,QAAQ,CAACC,aAAa,OAAOH;IAClC,IAAI;QACF,OAAO,IAAIM,KAAKA,KAAKC,GAAG,CAACL,MAAMC,cAAc,GAAG,IAAIK,kBAAkB,CAACP,QAAQ;YAC7ED,OAAO;YACPE,MAAM;YACNO,UAAU;QACZ;IACF,EAAE,eAAM;QACN,OAAOT;IACT;AACF"}
@@ -0,0 +1,123 @@
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
+ import { type AssistMiningReport, type AssistSpendRow } from './assist-signal-mining';
18
+ export declare const money: (value: number) => string;
19
+ export declare const percent: (value: number | null) => string;
20
+ /** The line under a ranking, which is a cut and not a page. */
21
+ export declare function rankingFootnote(shown: number, total: number, noun: {
22
+ one: string;
23
+ many: string;
24
+ }): string;
25
+ type SpendBoard = {
26
+ month: string;
27
+ rows: AssistSpendRow[];
28
+ ranked: number;
29
+ truncated: boolean;
30
+ failed: boolean;
31
+ };
32
+ type ProseRow = {
33
+ orgId: string;
34
+ orgLabel?: string | null;
35
+ exchangeId: string;
36
+ route: string;
37
+ feedback: 'up' | 'down' | null;
38
+ expired?: boolean;
39
+ question?: string | null;
40
+ answer?: string | null;
41
+ answerTruncated?: boolean;
42
+ };
43
+ export interface AssistSignalsView {
44
+ truncatedNote: string;
45
+ fleet: {
46
+ chips: string[];
47
+ empty: string;
48
+ };
49
+ freeSpend: {
50
+ line: string;
51
+ tone: 'error' | 'warning' | 'neutral';
52
+ } | null;
53
+ spend: {
54
+ heading: string;
55
+ note: string;
56
+ addonName: string;
57
+ rows: Array<{
58
+ orgId: string;
59
+ workspace: string;
60
+ plan: string;
61
+ addon: string;
62
+ credits: string;
63
+ cost: string;
64
+ refusals: string;
65
+ refusalDetail: string;
66
+ }>;
67
+ footnote: string;
68
+ };
69
+ split: {
70
+ note: string;
71
+ empty: string;
72
+ tier: Array<{
73
+ key: string;
74
+ detail: string;
75
+ }>;
76
+ model: Array<{
77
+ key: string;
78
+ detail: string;
79
+ }>;
80
+ };
81
+ kinds: Array<{
82
+ kind: string;
83
+ headline: string;
84
+ detail: string;
85
+ }>;
86
+ docsGaps: {
87
+ footnote: string;
88
+ rows: Array<{
89
+ path: string;
90
+ headline: string;
91
+ detail: string;
92
+ }>;
93
+ };
94
+ ungrounded: {
95
+ note: string;
96
+ footnote: string;
97
+ rows: Array<{
98
+ route: string;
99
+ detail: string;
100
+ }>;
101
+ };
102
+ prose: Array<{
103
+ flag: string;
104
+ flagTone: string;
105
+ where: string;
106
+ text: string;
107
+ answer: string;
108
+ }>;
109
+ orgs: {
110
+ footnote: string;
111
+ rows: Array<{
112
+ orgId: string;
113
+ workspace: string;
114
+ headline: string;
115
+ detail: string;
116
+ }>;
117
+ };
118
+ }
119
+ export declare function assistSignalsView(report: AssistMiningReport & {
120
+ spend?: SpendBoard | null;
121
+ prose?: ProseRow[];
122
+ }): AssistSignalsView;
123
+ export {};