@setluca/mcp 0.2.0 → 0.3.0

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 (72) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -258
  4. package/dist/annotations.js +35 -20
  5. package/dist/client-address.js +64 -0
  6. package/dist/completions.js +44 -27
  7. package/dist/config.js +89 -38
  8. package/dist/errors.js +99 -22
  9. package/dist/fields.js +68 -0
  10. package/dist/fixed-window.js +46 -0
  11. package/dist/generated/openapi-inputs.js +216 -5820
  12. package/dist/generated/openapi-outputs.js +19014 -14001
  13. package/dist/generated/route-catalog.js +80 -0
  14. package/dist/http.js +108 -44
  15. package/dist/index.js +4 -1
  16. package/dist/oauth-challenge.js +74 -0
  17. package/dist/oauth-resolver.js +58 -61
  18. package/dist/observability.js +31 -16
  19. package/dist/openapi-schema.js +175 -0
  20. package/dist/operations/catalog.js +17 -0
  21. package/dist/operations/groups/analytics.js +20 -10
  22. package/dist/operations/groups/bookings.js +43 -15
  23. package/dist/operations/groups/broadcasts.js +29 -58
  24. package/dist/operations/groups/cadences.js +3 -1
  25. package/dist/operations/groups/callEvents.js +19 -72
  26. package/dist/operations/groups/campaigns.js +28 -32
  27. package/dist/operations/groups/channels.js +23 -0
  28. package/dist/operations/groups/conversations.js +14 -25
  29. package/dist/operations/groups/insights.js +4 -0
  30. package/dist/operations/groups/integrations.js +15 -17
  31. package/dist/operations/groups/knowledge.js +5 -1
  32. package/dist/operations/groups/leads.js +22 -23
  33. package/dist/operations/groups/learning.js +13 -1
  34. package/dist/operations/groups/memory.js +4 -6
  35. package/dist/operations/groups/reports.js +1 -1
  36. package/dist/operations/groups/reviewQueue.js +17 -14
  37. package/dist/operations/groups/safety.js +1 -1
  38. package/dist/operations/groups/voice.js +0 -1
  39. package/dist/operations/groups/webhooks.js +16 -4
  40. package/dist/operations/manifest.js +9 -8
  41. package/dist/operations/registry.js +110 -116
  42. package/dist/operations.js +6 -5
  43. package/dist/optional-field.js +15 -3
  44. package/dist/page-contract.js +74 -0
  45. package/dist/pagination.js +149 -68
  46. package/dist/prompts.js +139 -88
  47. package/dist/provenance.js +2 -2
  48. package/dist/rate-limit.js +40 -0
  49. package/dist/remote.js +145 -65
  50. package/dist/resilience.js +120 -55
  51. package/dist/resolvers.js +125 -0
  52. package/dist/resources.js +17 -2
  53. package/dist/scopes.js +207 -0
  54. package/dist/serialization.js +32 -22
  55. package/dist/server.js +174 -191
  56. package/dist/standard-schema.js +215 -0
  57. package/dist/task-tools/analytics.js +145 -86
  58. package/dist/task-tools/bookings.js +176 -86
  59. package/dist/task-tools/cadences.js +30 -45
  60. package/dist/task-tools/calls.js +132 -125
  61. package/dist/task-tools/inbox.js +89 -53
  62. package/dist/task-tools/leads.js +49 -77
  63. package/dist/task-tools/runtime.js +80 -20
  64. package/dist/task-tools.js +1 -1
  65. package/dist/token-verification.js +42 -0
  66. package/dist/tool-cache.js +3 -2
  67. package/dist/tool.js +155 -100
  68. package/dist/version.js +1 -1
  69. package/package.json +40 -18
  70. package/dist/openapi-zod.js +0 -333
  71. package/dist/page-shape.js +0 -47
  72. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,215 @@
1
+ import * as Arr from "effect/Array";
2
+ import * as Option from "effect/Option";
3
+ import * as P from "effect/Predicate";
4
+ import * as R from "effect/Record";
5
+ import * as Schema from "effect/Schema";
6
+ import * as SchemaAST from "effect/SchemaAST";
7
+ import { fieldsWhen } from "./optional-field.js";
8
+ import { isJsonObject, isJsonArray, } from "./serialization.js";
9
+ /**
10
+ * Annotation keys that reach the JSON Schema a client reads. Effect drops
11
+ * annotations it does not know unless they are named here.
12
+ */
13
+ function isLucaAnnotation(key) {
14
+ return key.startsWith("x-luca-");
15
+ }
16
+ /**
17
+ * Whether an `allOf` entry only says "this object takes other keys too". Effect
18
+ * writes an open object that also lists properties as the object plus such an
19
+ * entry; the key on the object says the same thing with less nesting.
20
+ */
21
+ function isOpenObjectEntry(entry) {
22
+ return (isJsonObject(entry) &&
23
+ entry.type === "object" &&
24
+ "additionalProperties" in entry &&
25
+ Arr.every(R.keys(entry), (key) => key === "type" || key === "additionalProperties"));
26
+ }
27
+ /**
28
+ * Folds an open-object `allOf` entry back into its object. Every other node
29
+ * comes back unchanged.
30
+ */
31
+ function foldOpenObject(node) {
32
+ const allOf = node.allOf;
33
+ if (!isJsonArray(allOf) ||
34
+ allOf.length !== 1 ||
35
+ !isOpenObjectEntry(allOf[0]) ||
36
+ "additionalProperties" in node) {
37
+ return node;
38
+ }
39
+ const { allOf: _folded, ...rest } = node;
40
+ const extra = allOf[0].additionalProperties;
41
+ return {
42
+ ...rest,
43
+ additionalProperties: isJsonObject(extra) && R.isEmptyRecord(extra) ? true : extra,
44
+ };
45
+ }
46
+ /**
47
+ * An `allOf` entry's keywords, flattened through any `allOf` of its own, or
48
+ * `undefined` when it repeats a keyword and so has to stay a separate entry.
49
+ */
50
+ function flatKeywords(entry) {
51
+ if (!isJsonObject(entry)) {
52
+ return undefined;
53
+ }
54
+ const { allOf, ...own } = entry;
55
+ if (allOf === undefined) {
56
+ return own;
57
+ }
58
+ if (!isJsonArray(allOf)) {
59
+ return undefined;
60
+ }
61
+ return Arr.reduce(allOf, own, (merged, child) => {
62
+ const keywords = flatKeywords(child);
63
+ return merged === undefined ||
64
+ keywords === undefined ||
65
+ Arr.some(R.keys(keywords), (key) => key in merged)
66
+ ? undefined
67
+ : { ...merged, ...keywords };
68
+ });
69
+ }
70
+ /**
71
+ * Lifts `allOf` entries onto their node when no keyword collides. Effect
72
+ * writes each check, and a check group such as `isLengthBetween`, as its own
73
+ * entry; `{ minLength: 3, maxLength: 3, description }` says what three nested
74
+ * `allOf` levels say, in one level.
75
+ */
76
+ function flattenAllOf(node) {
77
+ const { allOf, ...rest } = node;
78
+ if (!isJsonArray(allOf)) {
79
+ return node;
80
+ }
81
+ const keywords = flatKeywords({ allOf });
82
+ return keywords === undefined ||
83
+ Arr.some(R.keys(keywords), (key) => key in rest)
84
+ ? node
85
+ : { ...rest, ...keywords };
86
+ }
87
+ /**
88
+ * States the safe-integer range Effect's `isInt` already enforces, on whichever
89
+ * side the schema leaves open, so a client sees the same bounds the server
90
+ * checks.
91
+ */
92
+ function safeIntegerBounds(node) {
93
+ if (node.type !== "integer") {
94
+ return node;
95
+ }
96
+ return {
97
+ ...node,
98
+ ...fieldsWhen(!("minimum" in node || "exclusiveMinimum" in node), {
99
+ minimum: Number.MIN_SAFE_INTEGER,
100
+ }),
101
+ ...fieldsWhen(!("maximum" in node || "exclusiveMaximum" in node), {
102
+ maximum: Number.MAX_SAFE_INTEGER,
103
+ }),
104
+ };
105
+ }
106
+ /** Applies the node rewrites above through the whole tree. */
107
+ function tidy(value) {
108
+ if (isJsonArray(value)) {
109
+ return value.map(tidy);
110
+ }
111
+ if (!isJsonObject(value)) {
112
+ return value;
113
+ }
114
+ return safeIntegerBounds(flattenAllOf(foldOpenObject(R.map(value, tidy))));
115
+ }
116
+ /** The annotations {@link restoreAnnotations} puts back. */
117
+ const RESTORED_ANNOTATIONS = ["title", "description", "format"];
118
+ /**
119
+ * Puts back each property's title, description, and format where Effect
120
+ * dropped them. Effect reads annotations off the schema's last check, and a
121
+ * check with no JSON Schema form, such as the time-zone check on
122
+ * `IanaTimeZone`, takes them down with it. Walks struct properties only, which
123
+ * is where these annotations sit.
124
+ */
125
+ function restoreAnnotations(ast, value) {
126
+ const properties = isJsonObject(value) ? value.properties : undefined;
127
+ if (!isJsonObject(value) ||
128
+ !isJsonObject(properties) ||
129
+ !SchemaAST.isObjects(ast)) {
130
+ return value;
131
+ }
132
+ return R.set(value, "properties", R.map(properties, (property, key) => {
133
+ const signature = Arr.findFirst(ast.propertySignatures, (candidate) => candidate.name === key);
134
+ if (Option.isNone(signature)) {
135
+ return property;
136
+ }
137
+ const restored = restoreAnnotations(signature.value.type, property);
138
+ const annotations = SchemaAST.resolve(signature.value.type) ?? {};
139
+ if (!isJsonObject(restored)) {
140
+ return restored;
141
+ }
142
+ return Arr.reduce(RESTORED_ANNOTATIONS, restored, (node, key) => {
143
+ const annotation = annotations[key];
144
+ return P.isString(annotation) && !(key in node)
145
+ ? R.set(node, key, annotation)
146
+ : node;
147
+ });
148
+ }));
149
+ }
150
+ /**
151
+ * The JSON Schema a client reads for `schema`. An object closes to the keys it
152
+ * declares, since the decoder drops the rest before a handler sees them.
153
+ */
154
+ function jsonSchemaOf(schema) {
155
+ const document = Schema.toJsonSchemaDocument(schema, {
156
+ includeAnnotationKey: isLucaAnnotation,
157
+ onExcessProperty: "error",
158
+ });
159
+ const root = {
160
+ ...document.schema,
161
+ ...fieldsWhen(!R.isEmptyRecord(document.definitions), {
162
+ $defs: document.definitions,
163
+ }),
164
+ };
165
+ // SAFETY: Effect builds the document from JSON values only, though its type
166
+ // leaves keyword values open. Both passes map an object node to an object
167
+ // node.
168
+ return restoreAnnotations(schema.ast, tidy(root));
169
+ }
170
+ /**
171
+ * Runs `build` on the first call and returns that result from then on. The
172
+ * catalog defines hundreds of schemas at import, and a client lists the tools
173
+ * it needs, so each JSON Schema is built when the SDK first asks for it.
174
+ */
175
+ function lazily(build) {
176
+ let built;
177
+ return () => {
178
+ built ??= { value: build() };
179
+ return built.value;
180
+ };
181
+ }
182
+ /**
183
+ * The shape a schema reaches the MCP SDK in: Effect validates the arguments,
184
+ * and the JSON Schema clients see is generated from the same Effect schema,
185
+ * with Luca's `x-luca-*` markers kept.
186
+ *
187
+ * `Schema.toStandardJSONSchemaV1` would do the same, but it takes no options
188
+ * and drops the markers, so the JSON half is built here instead.
189
+ */
190
+ export function toStandardSchema(schema, parseOptions) {
191
+ const standard = Schema.toStandardSchemaV1(schema, { parseOptions })["~standard"];
192
+ return {
193
+ schema,
194
+ "~standard": {
195
+ ...standard,
196
+ jsonSchema: {
197
+ input: lazily(() => jsonSchemaOf(schema)),
198
+ output: lazily(() => jsonSchemaOf(Schema.toType(schema))),
199
+ },
200
+ },
201
+ };
202
+ }
203
+ /** A tool's result object as a {@link toStandardSchema}. */
204
+ export function toolSchema(fields) {
205
+ return toStandardSchema(Schema.Struct(fields));
206
+ }
207
+ /**
208
+ * A tool's argument object as a {@link toStandardSchema}. A key the schema does
209
+ * not declare, at any depth, fails the call with an issue naming it: the JSON
210
+ * Schema a client reads already closes every object, and dropping a misspelt
211
+ * field in silence would run the call without it.
212
+ */
213
+ export function toolInputSchema(fields) {
214
+ return toStandardSchema(Schema.Struct(fields), { onExcessProperty: "error" });
215
+ }
@@ -1,59 +1,15 @@
1
- import { Effect, Record } from "effect";
2
- import * as z from "zod/v4";
1
+ import * as Arr from "effect/Array";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Option from "effect/Option";
4
+ import * as R from "effect/Record";
5
+ import * as Result from "effect/Result";
6
+ import * as Schema from "effect/Schema";
3
7
  import { formatError } from "../errors.js";
4
- import { WORKSPACE_INPUT_FIELDS } from "../operations.js";
8
+ import { NonEmptyString, WORKSPACE_INPUT_FIELDS } from "../fields.js";
5
9
  import { optionalField } from "../optional-field.js";
6
- import { listingIds, numberInput, taskRequest, } from "./runtime.js";
10
+ import { failureAsField, listingIds, numberInput, operationResult, resultOrError, sectionResult, taskTool, } from "./runtime.js";
7
11
  const DEFAULT_CAMPAIGN_LIMIT = 5;
8
12
  const CAMPAIGN_BROADCAST_LIMIT = 10;
9
- const getAnalytics = {
10
- name: "luca_analytics_rollup",
11
- title: "Get campaign and broadcast analytics",
12
- description: "Roll up performance across the coach's campaigns (per-campaign analytics for up to 5 recent campaigns) and recent broadcasts. Example: call with {} for the default rollup, or { campaignLimit: 3 }.",
13
- inputSchema: {
14
- ...WORKSPACE_INPUT_FIELDS,
15
- campaignLimit: z
16
- .number()
17
- .int()
18
- .min(1)
19
- .max(5)
20
- .optional()
21
- .describe("How many recent campaigns to include (default 5)."),
22
- },
23
- readOnly: true,
24
- untrustedContent: false,
25
- composes: ["campaigns.list", "campaigns.analytics", "broadcasts.list"],
26
- run: (api, operationById, input) => Effect.gen(function* () {
27
- const campaignLimit = numberInput(input.campaignLimit, DEFAULT_CAMPAIGN_LIMIT);
28
- const campaignPage = yield* api.request(taskRequest(operationById("campaigns.list"), input, {
29
- query: { limit: campaignLimit },
30
- }));
31
- // broadcasts.list doesn't depend on the per-campaign analytics loop, so
32
- // it runs alongside it instead of after it. Effect.all defaults to
33
- // concurrency 1 (sequential) — without `concurrency: "unbounded"` here
34
- // the broadcasts call would still wait on the whole loop below.
35
- const [perCampaign, broadcasts] = yield* Effect.all([
36
- Effect.forEach(listingIds(campaignPage, campaignLimit), (id) => api
37
- .request(taskRequest(operationById("campaigns.analytics"), input, {
38
- pathParams: { id },
39
- }))
40
- .pipe(Effect.catchAll((error) =>
41
- // Structured formatter (see draft-belt note above): preserves
42
- // the failing campaign's status/requestId instead of "[object …]".
43
- Effect.succeed({ analyticsError: formatError(error) })), Effect.map((analytics) => [
44
- id,
45
- analytics,
46
- ])),
47
- // Effect.forEach also defaults to concurrency 1. campaignLimit caps
48
- // this at 5 independent per-campaign lookups, so unbounded is fine.
49
- { concurrency: "unbounded" }),
50
- api.request(taskRequest(operationById("broadcasts.list"), input, {
51
- query: { limit: CAMPAIGN_BROADCAST_LIMIT },
52
- })),
53
- ], { concurrency: "unbounded" });
54
- return { campaigns: Record.fromEntries(perCampaign), broadcasts };
55
- }),
56
- };
57
13
  const ANALYTICS_PERIOD_VALUES = [
58
14
  "this_week",
59
15
  "this_month",
@@ -61,11 +17,12 @@ const ANALYTICS_PERIOD_VALUES = [
61
17
  "last_month",
62
18
  "last_30_days",
63
19
  ];
20
+ const decodeAnalyticsPeriod = Schema.decodeUnknownOption(Schema.Literals(ANALYTICS_PERIOD_VALUES));
64
21
  /**
65
22
  * Every analytics read either task tool below can compose. magic_monday and
66
- * deep_dive each build their own list from these entries rather than
67
- * hand-listing operation ids a second time, so the two tools cannot drift
68
- * apart on order, ids, or which reads are period-scoped.
23
+ * deep_dive each build their own list from these entries, so the two tools
24
+ * cannot drift apart on order or on which reads are period-scoped. A read whose
25
+ * id is missing from the tool's `composes` does not compile.
69
26
  */
70
27
  const ANALYTICS_READS = {
71
28
  funnel: { key: "funnel", id: "analytics.funnel", periodScoped: true },
@@ -110,57 +67,159 @@ const DEEP_DIVE_READS = [
110
67
  ANALYTICS_READS.ghostedLeads,
111
68
  ANALYTICS_READS.trustScore,
112
69
  ];
70
+ /** A section the report left out because its read failed. */
71
+ const FailedSection = Schema.Struct({
72
+ section: Schema.String,
73
+ error: Schema.String,
74
+ });
75
+ /**
76
+ * Each read's response under the key it lands at, present when the read
77
+ * succeeded, plus `failedSections` naming each read that did not.
78
+ */
79
+ function analyticsOutput(reads) {
80
+ return Schema.Struct({
81
+ ...R.fromEntries(reads.map((read) => [
82
+ read.key,
83
+ Schema.optionalKey(operationResult(read.id)),
84
+ ])),
85
+ failedSections: Schema.optionalKey(Schema.Array(FailedSection).annotate({
86
+ description: "Sections missing from this report because their read failed. The report is partial whenever this is present.",
87
+ })),
88
+ });
89
+ }
90
+ /**
91
+ * Fold the outcome of each section into one report. A failed section is left
92
+ * out and named in `failedSections`, so the numbers that did load still reach
93
+ * the coach. The call fails only when every section does, since an empty
94
+ * report answers nothing.
95
+ */
96
+ function collectSections(outcomes) {
97
+ const sections = outcomes.flatMap(({ key, result }) => Result.isSuccess(result) ? [[key, result.success]] : []);
98
+ const failures = outcomes.flatMap(({ key, result }) => Result.isFailure(result) ? [{ key, error: result.failure }] : []);
99
+ return Arr.match(failures, {
100
+ onEmpty: () => Effect.succeed(R.fromEntries(sections)),
101
+ onNonEmpty: (failed) => Arr.isReadonlyArrayEmpty(sections)
102
+ ? Effect.fail(Arr.headNonEmpty(failed).error)
103
+ : Effect.succeed({
104
+ ...R.fromEntries(sections),
105
+ failedSections: failed.map(({ key, error }) => ({
106
+ section: key,
107
+ error: formatError(error),
108
+ })),
109
+ }),
110
+ });
111
+ }
113
112
  /**
114
113
  * Run a list of analytics reads in parallel and return them keyed by `key`.
115
114
  * Effect.all defaults to concurrency 1 (sequential); these reads don't depend
116
115
  * on each other, so serializing them would only add latency, not correctness.
116
+ *
117
+ * One failed read leaves its section out and names it in `failedSections`; see
118
+ * {@link collectSections}. A refused token or missing scope fails the whole
119
+ * report instead, see {@link sectionResult}.
117
120
  */
118
- function analyticsFanOut(api, operationById, input, period, reads) {
119
- return Effect.all(reads.map((read) => api
120
- .request(taskRequest(operationById(read.id), input, {
121
+ function analyticsFanOut(call, period, reads) {
122
+ return Effect.forEach(reads, (read) => sectionResult(call(read.id, {
121
123
  ...optionalField("query", read.periodScoped ? { period } : undefined),
122
- }))
123
- .pipe(Effect.map((value) => [read.key, value]))), { concurrency: "unbounded" }).pipe(Effect.map((entries) => Record.fromEntries(entries)));
124
+ })).pipe(Effect.map((result) => ({ key: read.key, result }))), { concurrency: "unbounded" }).pipe(Effect.flatMap(collectSections));
124
125
  }
125
- const magicMonday = {
126
+ /**
127
+ * Analytics for each campaign on a listing page, keyed by campaign id. One
128
+ * campaign's failure is reported in its slot rather than dropping the rollup
129
+ * for every other campaign, unless it is an authorization failure, see
130
+ * {@link failureAsField}.
131
+ */
132
+ function campaignAnalytics(call, page, limit) {
133
+ return Effect.forEach(listingIds(page, limit), (id) => failureAsField("analyticsError", call("campaigns.analytics", { pathParams: { id } })).pipe(Effect.map((analytics) => [id, analytics])),
134
+ // Effect.forEach also defaults to concurrency 1. The limit caps this at 5
135
+ // independent per-campaign lookups, so unbounded is fine.
136
+ { concurrency: "unbounded" }).pipe(Effect.map(R.fromEntries));
137
+ }
138
+ const getAnalytics = taskTool({
139
+ name: "luca_analytics_rollup",
140
+ title: "Get campaign and broadcast analytics",
141
+ description: "Roll up performance across the coach's campaigns (per-campaign analytics for up to 5 recent campaigns) and recent broadcasts. When one half fails to load, the other still comes back and failedSections names the one that is missing. Example: call with {} for the default rollup, or { campaignLimit: 3 }.",
142
+ inputSchema: {
143
+ ...WORKSPACE_INPUT_FIELDS,
144
+ campaignLimit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 5 })).annotate({
145
+ description: "How many recent campaigns to include (default 5).",
146
+ })),
147
+ },
148
+ outputSchema: Schema.Struct({
149
+ campaigns: Schema.optionalKey(Schema.Record(Schema.String, resultOrError("campaigns.analytics", "analyticsError"))),
150
+ broadcasts: Schema.optionalKey(operationResult("broadcasts.list")),
151
+ failedSections: Schema.optionalKey(Schema.Array(FailedSection).annotate({
152
+ description: "Sections missing from this rollup because their read failed (campaigns, broadcasts). The rollup is partial whenever this is present.",
153
+ })),
154
+ }),
155
+ composes: ["campaigns.list", "campaigns.analytics", "broadcasts.list"],
156
+ run: (call, input) => Effect.gen(function* () {
157
+ const campaignLimit = numberInput(input.campaignLimit, DEFAULT_CAMPAIGN_LIMIT);
158
+ const campaignPage = yield* sectionResult(call("campaigns.list", { query: { limit: campaignLimit } }));
159
+ // broadcasts.list doesn't depend on the per-campaign analytics loop, so
160
+ // it runs alongside it instead of after it. Effect.all defaults to
161
+ // concurrency 1 (sequential), so without `concurrency: "unbounded"` here
162
+ // the broadcasts call would still wait on the whole loop below.
163
+ const [campaigns, broadcasts] = yield* Effect.all([
164
+ Result.match(campaignPage, {
165
+ onFailure: (error) => Effect.succeed(Result.fail(error)),
166
+ onSuccess: (page) => sectionResult(campaignAnalytics(call, page, campaignLimit)),
167
+ }),
168
+ sectionResult(call("broadcasts.list", {
169
+ query: { limit: CAMPAIGN_BROADCAST_LIMIT },
170
+ })),
171
+ ], { concurrency: "unbounded" });
172
+ return yield* collectSections([
173
+ { key: "campaigns", result: campaigns },
174
+ { key: "broadcasts", result: broadcasts },
175
+ ]);
176
+ }),
177
+ });
178
+ const magicMonday = taskTool({
126
179
  name: "luca_analytics_magic_monday",
127
180
  title: "Get the Magic Monday report",
128
181
  description: "Return the coach's weekly business intelligence report: pipeline funnel, revenue and ROI, response-speed impact, a point-in-time revenue forecast, and call intelligence (show/close rates), all for the current week. Composes five analytics reads into a single call. Example: call with {} on Monday morning for the week's report.",
129
182
  inputSchema: {
130
183
  ...WORKSPACE_INPUT_FIELDS,
131
184
  },
132
- readOnly: true,
133
- untrustedContent: false,
134
- composes: MAGIC_MONDAY_READS.map((read) => read.id),
135
- run: (api, operationById, input) => analyticsFanOut(api, operationById, input, "this_week", MAGIC_MONDAY_READS),
136
- };
137
- const analyticsDeepDive = {
185
+ outputSchema: analyticsOutput(MAGIC_MONDAY_READS),
186
+ composes: [
187
+ "analytics.funnel",
188
+ "analytics.revenue",
189
+ "analytics.speedImpact",
190
+ "analytics.forecast",
191
+ "analytics.callIntelligence",
192
+ ],
193
+ run: (call) => analyticsFanOut(call, "this_week", MAGIC_MONDAY_READS),
194
+ });
195
+ const analyticsDeepDive = taskTool({
138
196
  name: "luca_analytics_deep_dive",
139
197
  title: "Run a deep-dive analytics investigation",
140
198
  description: 'Answer a specific business question by composing all seven analytics reads in one call: pipeline funnel, revenue and ROI, response-speed impact, a point-in-time revenue forecast, call intelligence (show/close rates), ghosted-lead recovery, and coach trust/auto-send status. Broader than luca_analytics_magic_monday (which is the fixed weekly report); use this when the coach asks a specific question that needs cross-referencing several of these numbers together. Example: call with {question: "why did close rate drop this month?", period: "this_month"}.',
141
199
  inputSchema: {
142
200
  ...WORKSPACE_INPUT_FIELDS,
143
- question: z
144
- .string()
145
- .min(1)
146
- .max(500)
147
- .describe("The coach's specific question driving this investigation, e.g. 'why did close rate drop this month?'. Not sent to any API — captured so the caller's reasoning stays anchored to what the coach actually asked."),
148
- period: z
149
- .enum(ANALYTICS_PERIOD_VALUES)
150
- .optional()
151
- .describe("Period for the period-scoped reads (funnel, revenue, speedImpact, callIntelligence, ghostedLeads, trustScore). Forecast ignores this — it is always point-in-time. Defaults to this_month."),
201
+ question: Schema.optionalKey(NonEmptyString.check(Schema.isMaxLength(500)).annotate({
202
+ description: "Optional. The coach's question behind this investigation, e.g. 'why did close rate drop this month?'. Not sent to any API; it only records what the coach asked.",
203
+ })),
204
+ period: Schema.optionalKey(Schema.Literals(ANALYTICS_PERIOD_VALUES).annotate({
205
+ description: "Period for the period-scoped reads (funnel, revenue, speedImpact, callIntelligence, ghostedLeads, trustScore). Forecast ignores this — it is always point-in-time. Defaults to this_month.",
206
+ })),
152
207
  },
153
- readOnly: true,
154
- untrustedContent: false,
155
- composes: DEEP_DIVE_READS.map((read) => read.id),
156
- run: (api, operationById, input) => {
157
- const parsedPeriod = z
158
- .enum(ANALYTICS_PERIOD_VALUES)
159
- .safeParse(input.period);
160
- const period = parsedPeriod.success ? parsedPeriod.data : "this_month";
161
- return analyticsFanOut(api, operationById, input, period, DEEP_DIVE_READS);
208
+ outputSchema: analyticsOutput(DEEP_DIVE_READS),
209
+ composes: [
210
+ "analytics.funnel",
211
+ "analytics.revenue",
212
+ "analytics.speedImpact",
213
+ "analytics.forecast",
214
+ "analytics.callIntelligence",
215
+ "analytics.ghostedLeads",
216
+ "analytics.trustScore",
217
+ ],
218
+ run: (call, input) => {
219
+ const period = Option.getOrElse(decodeAnalyticsPeriod(input.period), () => "this_month");
220
+ return analyticsFanOut(call, period, DEEP_DIVE_READS);
162
221
  },
163
- };
222
+ });
164
223
  export const analyticsTaskTools = [
165
224
  getAnalytics,
166
225
  magicMonday,