@setluca/mcp 0.1.1 → 0.2.1

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 (63) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +178 -80
  3. package/dist/annotations.js +45 -0
  4. package/dist/completions.js +110 -0
  5. package/dist/config.js +55 -29
  6. package/dist/errors.js +56 -15
  7. package/dist/generated/openapi-inputs.js +4324 -1429
  8. package/dist/generated/openapi-outputs.js +21992 -8516
  9. package/dist/generated/route-catalog.js +1480 -0
  10. package/dist/http.js +97 -103
  11. package/dist/index.js +16 -10
  12. package/dist/oauth-resolver.js +59 -37
  13. package/dist/observability.js +12 -4
  14. package/dist/openapi-zod.js +138 -72
  15. package/dist/operations/catalog.js +50 -0
  16. package/dist/operations/groups/analytics.js +122 -0
  17. package/dist/operations/groups/bookings.js +189 -0
  18. package/dist/operations/groups/broadcasts.js +106 -0
  19. package/dist/operations/groups/cadences.js +14 -0
  20. package/dist/operations/groups/callEvents.js +131 -0
  21. package/dist/operations/groups/campaigns.js +132 -0
  22. package/dist/operations/groups/capabilities.js +8 -0
  23. package/dist/operations/groups/coach.js +43 -0
  24. package/dist/operations/groups/conversations.js +48 -0
  25. package/dist/operations/groups/insights.js +33 -0
  26. package/dist/operations/groups/integrations.js +80 -0
  27. package/dist/operations/groups/knowledge.js +34 -0
  28. package/dist/operations/groups/leads.js +101 -0
  29. package/dist/operations/groups/learning.js +24 -0
  30. package/dist/operations/groups/memory.js +27 -0
  31. package/dist/operations/groups/reports.js +14 -0
  32. package/dist/operations/groups/reviewQueue.js +57 -0
  33. package/dist/operations/groups/safety.js +8 -0
  34. package/dist/operations/groups/usage.js +13 -0
  35. package/dist/operations/groups/voice.js +14 -0
  36. package/dist/operations/groups/webhooks.js +76 -0
  37. package/dist/operations/manifest.js +40 -0
  38. package/dist/operations/registry.js +331 -0
  39. package/dist/operations.js +11 -780
  40. package/dist/optional-field.js +12 -0
  41. package/dist/page-shape.js +62 -0
  42. package/dist/pagination.js +165 -0
  43. package/dist/prompts.js +89 -8
  44. package/dist/provenance.js +18 -2
  45. package/dist/remote.js +80 -49
  46. package/dist/resilience.js +127 -0
  47. package/dist/resources.js +17 -2
  48. package/dist/scopes.js +154 -0
  49. package/dist/serialization.js +36 -0
  50. package/dist/server.js +258 -316
  51. package/dist/task-tools/analytics.js +168 -0
  52. package/dist/task-tools/bookings.js +157 -0
  53. package/dist/task-tools/cadences.js +65 -0
  54. package/dist/task-tools/calls.js +202 -0
  55. package/dist/task-tools/inbox.js +120 -0
  56. package/dist/task-tools/leads.js +123 -0
  57. package/dist/task-tools/runtime.js +92 -0
  58. package/dist/task-tools.js +21 -0
  59. package/dist/tool-cache.js +39 -0
  60. package/dist/tool.js +228 -0
  61. package/dist/tsconfig.tsbuildinfo +1 -1
  62. package/dist/version.js +14 -0
  63. package/package.json +38 -34
@@ -7,6 +7,10 @@ function nonNullTypes(schema) {
7
7
  if (Array.isArray(schema.type)) {
8
8
  return schema.type.filter((type) => type !== "null");
9
9
  }
10
+ // Forcing this guard true yields `[undefined]`, which `baseSchema` reads
11
+ // exactly as it reads the empty list: the first element is undefined either
12
+ // way, so the same Zod schema comes out.
13
+ // Stryker disable next-line ConditionalExpression: `[undefined]` and `[]` are indistinguishable in `baseSchema`
10
14
  if (schema.type) {
11
15
  return [schema.type];
12
16
  }
@@ -15,37 +19,72 @@ function nonNullTypes(schema) {
15
19
  function withDescription(schema, description) {
16
20
  return description ? schema.describe(description) : schema;
17
21
  }
22
+ const JsonLiteralSchema = z.union([
23
+ z.string(),
24
+ z.number(),
25
+ z.boolean(),
26
+ z.null(),
27
+ ]);
18
28
  function enumSchema(values) {
19
- if (values.length === 0) {
29
+ const strings = z.array(z.string()).safeParse(values);
30
+ if (strings.success) {
31
+ // An empty enum lands here too, since the empty list is a list of strings.
32
+ // It admits nothing, which is also the only thing `z.enum` cannot express.
33
+ const [first, ...rest] = strings.data;
34
+ return first === undefined ? z.never() : z.enum([first, ...rest]);
35
+ }
36
+ const literals = z.array(JsonLiteralSchema).safeParse(values);
37
+ if (!literals.success) {
20
38
  return z.never();
21
39
  }
22
- if (values.every((value) => typeof value === "string")) {
23
- return z.enum(values);
40
+ const [firstValue, secondValue, ...restValues] = literals.data;
41
+ // The empty list already went down the string branch, so this one is not
42
+ // empty. The guard exists to satisfy the compiler's view of destructuring.
43
+ // Stryker disable next-line ConditionalExpression,BlockStatement: unreachable, an empty enum is a list of strings
44
+ if (firstValue === undefined) {
45
+ return z.never();
24
46
  }
25
- if (values.length === 1) {
26
- return z.literal(values[0]);
47
+ const first = z.literal(firstValue);
48
+ if (secondValue === undefined) {
49
+ return first;
27
50
  }
28
- const literals = values.map((value) => z.literal(value));
29
- return z.union(literals);
51
+ const second = z.literal(secondValue);
52
+ const rest = restValues.map((value) => z.literal(value));
53
+ return z.union([first, second, ...rest]);
30
54
  }
31
- function stringSchema(schema) {
32
- let result = z.string();
33
- if (schema.format === "uuid") {
34
- result = result.uuid();
55
+ /**
56
+ * Zod 4 moved the string formats to top-level constructors: the `.uuid()` /
57
+ * `.url()` / `.datetime()` methods on a ZodString are deprecated. They all
58
+ * still subclass ZodString, so the length and pattern refinements below chain
59
+ * onto whichever base a format picks.
60
+ */
61
+ function formatBase(format) {
62
+ if (format === "uuid") {
63
+ return z.uuid();
35
64
  }
36
- else if (schema.format === "uri") {
37
- result = result.url();
65
+ if (format === "uri") {
66
+ return z.url();
38
67
  }
39
- else if (schema.format === "date-time") {
40
- result = result.datetime({ offset: true });
68
+ if (format === "date-time") {
69
+ return z.iso.datetime({ offset: true });
41
70
  }
71
+ return z.string();
72
+ }
73
+ function stringSchema(schema) {
74
+ let result = formatBase(schema.format);
42
75
  if (schema.minLength !== undefined) {
43
76
  result = result.min(schema.minLength);
44
77
  }
45
78
  if (schema.maxLength !== undefined) {
46
79
  result = result.max(schema.maxLength);
47
80
  }
81
+ // Forcing this guard true is an equivalent mutation rather than an untested
82
+ // branch: with no pattern the body builds `new RegExp(undefined)`, which is
83
+ // the empty pattern `/(?:)/` and matches every string, so the constraint it
84
+ // adds rejects nothing. Forcing it false is a real change and is covered.
85
+ // Stryker disable next-line ConditionalExpression: an absent pattern compiles to /(?:)/, which constrains nothing
48
86
  if (schema.pattern) {
87
+ // fallow-ignore-next-line security-sink -- patterns come from Luca's checked-in generated OpenAPI document at startup, never from MCP tool arguments or request data
49
88
  result = result.regex(new RegExp(schema.pattern));
50
89
  }
51
90
  return result;
@@ -100,20 +139,25 @@ function scalarSchemaWithoutConstraints(schema, type) {
100
139
  function objectSchema(schema) {
101
140
  const properties = schema.properties ?? {};
102
141
  const required = new Set(schema.required ?? []);
103
- const shape = {};
142
+ const fields = {};
104
143
  for (const [key, property] of Object.entries(properties)) {
105
144
  const propertySchema = schemaToZod(property);
106
- shape[key] = required.has(key) ? propertySchema : propertySchema.optional();
145
+ fields[key] = required.has(key)
146
+ ? propertySchema
147
+ : propertySchema.optional();
107
148
  }
108
- const result = z.object(shape);
149
+ const result = z.object(fields);
109
150
  if (schema.additionalProperties === true) {
110
151
  return result.catchall(z.unknown());
111
152
  }
112
- if (schema.additionalProperties &&
113
- typeof schema.additionalProperties === "object") {
114
- return result.catchall(schemaToZod(schema.additionalProperties));
153
+ // Absent, `false`, and the malformed scalars a generated document can carry
154
+ // all close the object. Only a nested schema opens it, and every extra key
155
+ // then has to match that schema.
156
+ const extra = schema.additionalProperties;
157
+ if (typeof extra !== "object") {
158
+ return result;
115
159
  }
116
- return result;
160
+ return result.catchall(schemaToZod(extra));
117
161
  }
118
162
  function schemaToZod(schema) {
119
163
  const literal = literalOrEnumSchema(schema);
@@ -128,7 +172,8 @@ function schemaToZod(schema) {
128
172
  }
129
173
  function literalOrEnumSchema(schema) {
130
174
  if (schema.const !== undefined) {
131
- return z.literal(schema.const);
175
+ const literal = JsonLiteralSchema.safeParse(schema.const);
176
+ return literal.success ? z.literal(literal.data) : z.never();
132
177
  }
133
178
  if (schema.enum) {
134
179
  return withDescription(enumSchema(schema.enum), schema.description);
@@ -147,12 +192,17 @@ function compositeSchema(schema) {
147
192
  return;
148
193
  }
149
194
  function variantSchema(schema, variants) {
150
- const unionItems = variants.map(schemaToZod);
151
- if (unionItems.length === 1) {
152
- const single = unionItems[0] ?? z.never();
153
- return withDescription(isNullable(schema) ? single.nullable() : single, schema.description);
195
+ const [first, second, ...rest] = variants.map(schemaToZod);
196
+ // `compositeSchema` only calls here for a non-empty variant list, so there is
197
+ // always a first. The guard exists to satisfy the compiler's view of
198
+ // destructuring.
199
+ // Stryker disable next-line ConditionalExpression,BlockStatement: unreachable, the variant list is non-empty by construction
200
+ if (first === undefined) {
201
+ return z.never();
154
202
  }
155
- const union = z.union(unionItems);
203
+ // A single variant is the variant itself. Wrapping one schema in a union
204
+ // would change nothing except what a client reads in the JSON Schema.
205
+ const union = second === undefined ? first : z.union([first, second, ...rest]);
156
206
  return withDescription(isNullable(schema) ? union.nullable() : union, schema.description);
157
207
  }
158
208
  function describedBaseSchema(schema) {
@@ -170,37 +220,37 @@ function mergeAllOf(allOf) {
170
220
  }
171
221
  return {
172
222
  ...merged,
173
- ...(Object.keys(properties).length > 0 ? { properties } : {}),
174
- ...(required.length > 0 ? { required } : {}),
223
+ ...(Object.keys(properties).length > 0 ? { properties } : undefined),
224
+ // Equivalent under this module's public contract: an empty `required: []`
225
+ // and an absent `required` produce the identical Zod object schema, because
226
+ // `objectSchema` derives its required-set from `schema.required ?? []`, so
227
+ // both yield an all-optional object. The guard only avoids emitting a noise key.
228
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: empty and absent `required` are indistinguishable downstream
229
+ ...(required.length > 0 ? { required } : undefined),
175
230
  };
176
231
  }
177
232
  function baseSchema(schema) {
178
233
  const types = nonNullTypes(schema);
179
- if (types.length === 0) {
234
+ const [first, second, ...rest] = types;
235
+ if (first === undefined) {
180
236
  return schema.properties ? objectSchema(schema) : z.unknown();
181
237
  }
182
- if (types.length === 1) {
183
- return scalarSchema(schema, types[0] ?? "unknown");
238
+ if (second === undefined) {
239
+ return scalarSchema(schema, first);
184
240
  }
185
- return z.union(types.map((type) => scalarSchema(schema, type)));
241
+ return z.union([
242
+ scalarSchema(schema, first),
243
+ scalarSchema(schema, second),
244
+ ...rest.map((type) => scalarSchema(schema, type)),
245
+ ]);
186
246
  }
187
- /**
188
- * Relax a response JSON schema to a **structural, type-only** shape for output
189
- * validation. The goal is a check that fails loud on a genuinely malformed
190
- * payload (a field typed as the wrong primitive, `result` that is not an object
191
- * at all) while never rejecting a well-formed real result.
192
- *
193
- * So it keeps each field's base `type` (and `nullable`) but drops value-level
194
- * constraints — `format` (uuid/date-time), `pattern`, length/range bounds,
195
- * `enum`, and deep array-item schemas — and makes every object open with no
196
- * required properties. Those constraints are the false-positive risk: they would
197
- * 500 a real tool call on a non-v4 uuid, an enum that drifted, or an omitted
198
- * optional field, and they also break the pagination merge (which drops
199
- * `nextCursor` and adds a `pagination` block). The trade: array element shapes
200
- * and formats are not deeply validated; the structural contract still is.
201
- */
202
247
  function relaxJsonSchema(schema) {
203
248
  const relaxed = {};
249
+ // Forcing this guard true assigns `relaxed.type = undefined` when there is no
250
+ // type, which is indistinguishable downstream from omitting the key: both
251
+ // `nonNullTypes` and the object-routing in `baseSchema` treat an undefined
252
+ // `type` and an absent `type` identically, producing the same Zod schema.
253
+ // Stryker disable next-line ConditionalExpression: an undefined `type` and an absent one yield the identical relaxed schema
204
254
  if (schema.type !== undefined) {
205
255
  relaxed.type = schema.type;
206
256
  }
@@ -215,55 +265,71 @@ function relaxJsonSchema(schema) {
215
265
  relaxed.properties = properties;
216
266
  relaxed.required = [];
217
267
  relaxed.additionalProperties = true;
268
+ // Defensive belt-and-suspenders: with `properties` present, `baseSchema`
269
+ // routes an absent type and an explicit `"object"` type to the same
270
+ // `objectSchema` call, so setting the type here never changes the produced
271
+ // Zod schema. Verified equivalent through both public entry points.
272
+ // Stryker disable next-line ConditionalExpression,BlockStatement: object routing is identical with or without an explicit type when properties exist
218
273
  if (relaxed.type === undefined) {
274
+ // Stryker disable next-line StringLiteral: type value is inert here, properties already force objectSchema downstream
219
275
  relaxed.type = "object";
220
276
  }
221
277
  }
222
- for (const key of ["anyOf", "oneOf", "allOf"]) {
223
- const variants = schema[key];
224
- if (variants) {
225
- relaxed[key] = variants.map(relaxJsonSchema);
226
- }
278
+ if (schema.anyOf) {
279
+ relaxed.anyOf = schema.anyOf.map(relaxJsonSchema);
280
+ }
281
+ if (schema.oneOf) {
282
+ relaxed.oneOf = schema.oneOf.map(relaxJsonSchema);
283
+ }
284
+ if (schema.allOf) {
285
+ relaxed.allOf = schema.allOf.map(relaxJsonSchema);
227
286
  }
228
287
  // Array items and scalar formats/patterns/ranges/enums are intentionally
229
- // dropped — see the doc comment.
288
+ // dropped. See the doc comment.
230
289
  return relaxed;
231
290
  }
232
291
  const untrustedProvenanceSchema = z
233
292
  .object({ untrusted: z.literal(true), note: z.string() })
234
293
  .loose();
235
294
  /**
236
- * The `structuredContent` shape for a tool: `{ result }`, plus an optional
295
+ * The `structuredContent` fields for a tool: `{ result }`, plus an optional
237
296
  * `provenance` marker for operations that carry lead-authored text. `result` is
238
297
  * the operation's success-response body, deep-relaxed (see
239
298
  * {@link relaxJsonSchema}) so real results validate while malformed ones fail
240
299
  * loud.
241
300
  */
242
- export function openApiToolOutputShape(input) {
301
+ export function openApiToolOutputFields(input) {
243
302
  const resultSchema = input.body
244
303
  ? schemaToZod(relaxJsonSchema(input.body))
245
304
  : z.unknown();
246
- const shape = { result: resultSchema };
247
- if (input.untrustedContent) {
248
- shape.provenance = untrustedProvenanceSchema.optional();
249
- }
250
- return shape;
305
+ return input.untrustedContent
306
+ ? {
307
+ result: resultSchema,
308
+ provenance: untrustedProvenanceSchema.optional(),
309
+ }
310
+ : { result: resultSchema };
251
311
  }
252
- export function openApiToolInputShape(input) {
253
- const shape = {};
254
- Object.assign(shape, input.workspaceInput);
312
+ export function openApiToolInputFields(input) {
313
+ const fields = new Map(Object.entries(input.workspaceInput));
255
314
  if (input.idempotencyRequired) {
256
- Object.assign(shape, input.idempotencyInput);
315
+ for (const [key, schema] of Object.entries(input.idempotencyInput)) {
316
+ fields.set(key, schema);
317
+ }
257
318
  }
258
- const pathShape = input.spec?.path ? schemaToZod(input.spec.path) : undefined;
259
- if (pathShape instanceof z.ZodObject) {
260
- Object.assign(shape, pathShape.shape);
319
+ const pathSchema = input.spec?.path
320
+ ? schemaToZod(input.spec.path)
321
+ : undefined;
322
+ if (pathSchema instanceof z.ZodObject) {
323
+ // oxlint-disable-next-line typescript/dot-notation -- Zod owns this public property name; bracket access avoids classifying the library member as a local shape symbol.
324
+ for (const [key, schema] of Object.entries(pathSchema["shape"])) {
325
+ fields.set(key, schema);
326
+ }
261
327
  }
262
328
  if (input.spec?.query) {
263
- shape.query = schemaToZod(input.spec.query).optional();
329
+ fields.set("query", schemaToZod(input.spec.query).optional());
264
330
  }
265
331
  if (input.spec?.body) {
266
- shape.body = schemaToZod(input.spec.body);
332
+ fields.set("body", schemaToZod(input.spec.body));
267
333
  }
268
- return shape;
334
+ return Object.fromEntries(fields);
269
335
  }
@@ -0,0 +1,50 @@
1
+ import { analyticsOperations } from "./groups/analytics.js";
2
+ import { bookingsOperations } from "./groups/bookings.js";
3
+ import { broadcastsOperations } from "./groups/broadcasts.js";
4
+ import { cadencesOperations } from "./groups/cadences.js";
5
+ import { callEventsOperations } from "./groups/callEvents.js";
6
+ import { campaignsOperations } from "./groups/campaigns.js";
7
+ // The operation table: every public Luca API route this server exposes as a
8
+ // tool, one `op({...})` entry per route, split into one file per group under
9
+ // `groups/`. Data only. The model that gives those entries their shape lives in
10
+ // `registry.ts`, and the views derived from the table (the manifest, the group
11
+ // summary) live in `manifest.ts`, so adding a route stays a one-entry edit in
12
+ // the file its group already owns.
13
+ import { capabilitiesOperations } from "./groups/capabilities.js";
14
+ import { coachOperations } from "./groups/coach.js";
15
+ import { conversationsOperations } from "./groups/conversations.js";
16
+ import { insightsOperations } from "./groups/insights.js";
17
+ import { integrationsOperations } from "./groups/integrations.js";
18
+ import { knowledgeOperations } from "./groups/knowledge.js";
19
+ import { leadsOperations } from "./groups/leads.js";
20
+ import { learningOperations } from "./groups/learning.js";
21
+ import { memoryOperations } from "./groups/memory.js";
22
+ import { reportsOperations } from "./groups/reports.js";
23
+ import { reviewQueueOperations } from "./groups/reviewQueue.js";
24
+ import { safetyOperations } from "./groups/safety.js";
25
+ import { usageOperations } from "./groups/usage.js";
26
+ import { voiceOperations } from "./groups/voice.js";
27
+ import { webhooksOperations } from "./groups/webhooks.js";
28
+ export const LUCA_OPERATIONS = [
29
+ ...capabilitiesOperations,
30
+ ...leadsOperations,
31
+ ...conversationsOperations,
32
+ ...reviewQueueOperations,
33
+ ...bookingsOperations,
34
+ ...callEventsOperations,
35
+ ...campaignsOperations,
36
+ ...broadcastsOperations,
37
+ ...webhooksOperations,
38
+ ...integrationsOperations,
39
+ ...voiceOperations,
40
+ ...coachOperations,
41
+ ...usageOperations,
42
+ ...safetyOperations,
43
+ ...reportsOperations,
44
+ ...insightsOperations,
45
+ ...learningOperations,
46
+ ...knowledgeOperations,
47
+ ...memoryOperations,
48
+ ...cadencesOperations,
49
+ ...analyticsOperations,
50
+ ];
@@ -0,0 +1,122 @@
1
+ import { op } from "../registry.js";
2
+ export const analyticsOperations = [
3
+ op({
4
+ id: "analytics.funnel",
5
+ title: "Read the funnel",
6
+ description: "Pipeline funnel stage counts with period and channel filters. Returns counts and conversion rates for each funnel stage plus period-over-period deltas.",
7
+ }),
8
+ op({
9
+ id: "analytics.revenue",
10
+ title: "Read revenue",
11
+ description: "Revenue attribution and plan ROI for a period. Returns total revenue, ROI, and a per-channel revenue breakdown.",
12
+ }),
13
+ op({
14
+ id: "analytics.speedImpact",
15
+ title: "Read what reply speed is worth",
16
+ description: "Fast-vs-slow first-reply booking-rate impact for a period. Returns fast- and slow-reply conversion rates, a lost-opportunity estimate, and the peak performance reply window.",
17
+ }),
18
+ op({
19
+ id: "analytics.forecast",
20
+ title: "Read the revenue forecast",
21
+ description: "Point-in-time pipeline revenue forecast with on-track, optimistic, and conservative scenarios based on pipeline stage probabilities and historical conversion rates.",
22
+ }),
23
+ op({
24
+ id: "analytics.callIntelligence",
25
+ title: "Read call intelligence",
26
+ description: "Post-call intelligence for a period: show rate, close rate, average rating and rating distribution, call-to-close lag, repeat-call cohort, day-of-week and call-sequence show rates, and surprise patterns, derived from post-call events and coach-recorded outcomes.",
27
+ redactedFields: ["surprisePatterns"],
28
+ }),
29
+ op({
30
+ id: "analytics.callIntelligenceCalls",
31
+ title: "List calls behind call intelligence",
32
+ description: "Most recent completed calls with a generated summary, newest first: the coach-facing summary, attendance outcome, rating, and lead name.",
33
+ redactedFields: ["calls[].leadName", "calls[].summary"],
34
+ }),
35
+ op({
36
+ id: "analytics.ghostedLeads",
37
+ title: "List ghosted leads",
38
+ description: "Ghosted-lead counts, rescue-recovery rate, lost-revenue estimate, and top conversation-killer clusters for a period.",
39
+ }),
40
+ op({
41
+ id: "analytics.trustScore",
42
+ title: "Read the trust score",
43
+ description: "Draft approval, edit, and rejection rates plus auto-send gate state (auto-send flag and any open human-approval window) for a period.",
44
+ }),
45
+ op({
46
+ id: "analytics.leadScore",
47
+ title: "Read a lead's score",
48
+ description: "Predictive booking-likelihood score (0-100) for one lead from the coach's trained bucket-rate model, with a confidence tier and the top contributing features. Honest about insufficient data: an under-trained model returns a null score and low confidence instead of a fabricated number.",
49
+ }),
50
+ op({
51
+ id: "analytics.lossReasons",
52
+ title: "Read why deals are lost",
53
+ description: "Category breakdown of automatic 'why you lost' analyses for a period: count and estimated lost revenue per category (price objection, ghosted after a call-to-action, competitor, slow reply, bad fit, other), derived from a per-lead loss analysis generated when a lead goes silent or lost.",
54
+ }),
55
+ op({
56
+ id: "analytics.contentAttribution",
57
+ title: "Read which content brings leads",
58
+ description: "Per-content rollup (leads touched, qualified, booked, revenue) of attributed touches for a period. Content is attributed automatically from an Instagram comment or Story reply that carries a real media id, or tagged manually; an honest empty list means no content is attributed yet, not zero performance.",
59
+ }),
60
+ op({
61
+ id: "analytics.contentPerformance",
62
+ title: "Read content performance",
63
+ description: "Per-post rollup (reach, engagement rate, likes/comments/saves/shares, attributed leads/bookings/revenue) for each Instagram post, reel, story, or carousel over a period. Insight fields stay null until the coach's account grants Instagram insights access — read that as not connected yet, not zero engagement.",
64
+ }),
65
+ op({
66
+ id: "analytics.topicPerformance",
67
+ title: "Read topic performance",
68
+ description: "Weekly rollup by normalized content topic (post count, average engagement rate, attributed leads/bookings/revenue per theme). Defaults to the most recently computed week when weekStart is omitted; an empty items list is the honest, expected state until content topics are extracted, not zero performance.",
69
+ }),
70
+ op({
71
+ id: "analytics.benchmarks",
72
+ title: "Read peer benchmarks",
73
+ description: "This coach's own cross-coach benchmark percentiles (e.g. 'top 15% for response speed among fitness coaches'), never another coach's data. `comingSoon: true` with an empty metrics list is the honest, expected state until the cross-coach benchmark pipeline populates this coach's row — never a fabricated comparison.",
74
+ }),
75
+ op({
76
+ id: "analytics.insights",
77
+ title: "List insights",
78
+ description: "The coach's proactive-insights feed (metric drops/spikes, opportunities, risks, milestones) surfaced by the daily insight-detection cron, newest first. Optionally filter to one status via the status query param (e.g. unread). An empty items list is the honest, expected state for a coach nothing has been flagged for yet — never a fabricated alert.",
79
+ }),
80
+ op({
81
+ id: "analytics.trends",
82
+ title: "Read trends",
83
+ description: "The coach's weekly metrics as a trend series (leads, qualified, booked, revenue, and rates week over week) for charting direction of travel. An empty series is the honest, expected state until enough weeks of history accrue — never fabricated points.",
84
+ }),
85
+ op({
86
+ id: "analytics.recommendations",
87
+ title: "Read recommendations",
88
+ description: "The coach's active prescriptive recommendations for a week (what to change and why), generated weekly from detected insights and deduped by title. An empty list is the honest, expected state when nothing is recommended for the window — never a fabricated action.",
89
+ redactedFields: ["recommendations[].body"],
90
+ }),
91
+ op({
92
+ id: "analytics.benchmarkConsent",
93
+ title: "Check benchmark sharing consent",
94
+ description: "The coach's cross-coach benchmark opt-in state (whether this workspace contributes to and sees anonymized niche cohort percentiles). Read-only; the opt-in is changed from the app, not through this key surface.",
95
+ }),
96
+ op({
97
+ id: "analytics.learningInsights",
98
+ title: "Read what Luca has learned",
99
+ description: "Read-only rollup of the agent-improvement signals mined from post-call feedback for a period: total/applied/pending signal counts, a per-type breakdown (qualification, objection handling, lead quality, general), and recent example notes. An empty rollup is the honest, expected state until the coach submits call feedback — never fabricated learning.",
100
+ redactedFields: ["recentExamples[].note"],
101
+ }),
102
+ op({
103
+ id: "analytics.crmSyncHealth",
104
+ title: "Check CRM sync health",
105
+ description: "Aggregate CRM-sync health for the coach's post-call summaries: counts of synced, pending, local-only, failed, and permanently-failed pushes. All zeros is the honest, expected state for a coach with no synced summaries yet — never a fabricated status.",
106
+ }),
107
+ op({
108
+ id: "analytics.objections",
109
+ title: "Read objection patterns",
110
+ description: "Objection-bucket rollup over the review queue's qualifier signals for a period (price, timing, trust, fit, authority, smokescreen): per-bucket occurrence count, distinct leads, handled rate, wins, and up to three of the most frequent verbatim lead quotes. Filter by channel. An empty bucket list is the honest, expected state until objections are raised — never fabricated.",
111
+ redactedFields: ["buckets[].topPhrases"],
112
+ }),
113
+ op({
114
+ id: "analytics.objectionDrilldown",
115
+ title: "Drill into one objection type",
116
+ description: "Per-conversation drilldown for one objection bucket: one row per matching conversation with the lead's display name, the verbatim objection quote, whether the draft was sent without an edit, and the lead's most recent call outcome. Bounded by a limit with a hasMore truncation flag; an empty list is the honest, expected state for a bucket with no conversations in the window.",
117
+ redactedFields: [
118
+ "conversations[].leadDisplayName",
119
+ "conversations[].phrase",
120
+ ],
121
+ }),
122
+ ];