@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.
- package/CHANGELOG.md +426 -0
- package/LICENSE +21 -0
- package/README.md +19 -258
- package/dist/annotations.js +35 -20
- package/dist/client-address.js +64 -0
- package/dist/completions.js +44 -27
- package/dist/config.js +89 -38
- package/dist/errors.js +99 -22
- package/dist/fields.js +68 -0
- package/dist/fixed-window.js +46 -0
- package/dist/generated/openapi-inputs.js +216 -5820
- package/dist/generated/openapi-outputs.js +19014 -14001
- package/dist/generated/route-catalog.js +80 -0
- package/dist/http.js +108 -44
- package/dist/index.js +4 -1
- package/dist/oauth-challenge.js +74 -0
- package/dist/oauth-resolver.js +58 -61
- package/dist/observability.js +31 -16
- package/dist/openapi-schema.js +175 -0
- package/dist/operations/catalog.js +17 -0
- package/dist/operations/groups/analytics.js +20 -10
- package/dist/operations/groups/bookings.js +43 -15
- package/dist/operations/groups/broadcasts.js +29 -58
- package/dist/operations/groups/cadences.js +3 -1
- package/dist/operations/groups/callEvents.js +19 -72
- package/dist/operations/groups/campaigns.js +28 -32
- package/dist/operations/groups/channels.js +23 -0
- package/dist/operations/groups/conversations.js +14 -25
- package/dist/operations/groups/insights.js +4 -0
- package/dist/operations/groups/integrations.js +15 -17
- package/dist/operations/groups/knowledge.js +5 -1
- package/dist/operations/groups/leads.js +22 -23
- package/dist/operations/groups/learning.js +13 -1
- package/dist/operations/groups/memory.js +4 -6
- package/dist/operations/groups/reports.js +1 -1
- package/dist/operations/groups/reviewQueue.js +17 -14
- package/dist/operations/groups/safety.js +1 -1
- package/dist/operations/groups/voice.js +0 -1
- package/dist/operations/groups/webhooks.js +16 -4
- package/dist/operations/manifest.js +9 -8
- package/dist/operations/registry.js +110 -116
- package/dist/operations.js +6 -5
- package/dist/optional-field.js +15 -3
- package/dist/page-contract.js +74 -0
- package/dist/pagination.js +149 -68
- package/dist/prompts.js +139 -88
- package/dist/provenance.js +2 -2
- package/dist/rate-limit.js +40 -0
- package/dist/remote.js +145 -65
- package/dist/resilience.js +120 -55
- package/dist/resolvers.js +125 -0
- package/dist/resources.js +17 -2
- package/dist/scopes.js +207 -0
- package/dist/serialization.js +32 -22
- package/dist/server.js +174 -191
- package/dist/standard-schema.js +215 -0
- package/dist/task-tools/analytics.js +145 -86
- package/dist/task-tools/bookings.js +176 -86
- package/dist/task-tools/cadences.js +30 -45
- package/dist/task-tools/calls.js +132 -125
- package/dist/task-tools/inbox.js +89 -53
- package/dist/task-tools/leads.js +49 -77
- package/dist/task-tools/runtime.js +80 -20
- package/dist/task-tools.js +1 -1
- package/dist/token-verification.js +42 -0
- package/dist/tool-cache.js +3 -2
- package/dist/tool.js +155 -100
- package/dist/version.js +1 -1
- package/package.json +40 -18
- package/dist/openapi-zod.js +0 -333
- package/dist/page-shape.js +0 -47
- 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
|
|
2
|
-
import * as
|
|
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 "../
|
|
8
|
+
import { NonEmptyString, WORKSPACE_INPUT_FIELDS } from "../fields.js";
|
|
5
9
|
import { optionalField } from "../optional-field.js";
|
|
6
|
-
import { listingIds, numberInput,
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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(
|
|
119
|
-
return Effect.
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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:
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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,
|