@setluca/mcp 0.2.1 → 0.3.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.
- package/CHANGELOG.md +331 -0
- package/LICENSE +21 -0
- package/README.md +19 -288
- package/dist/annotations.js +33 -18
- package/dist/client-address.js +64 -0
- package/dist/completions.js +24 -27
- package/dist/config.js +89 -38
- package/dist/errors.js +94 -13
- 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 +107 -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 +30 -15
- 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 +15 -3
- package/dist/operations/manifest.js +7 -6
- package/dist/operations/registry.js +102 -108
- package/dist/operations.js +4 -3
- package/dist/optional-field.js +15 -3
- package/dist/page-contract.js +74 -0
- package/dist/pagination.js +147 -67
- package/dist/prompts.js +122 -97
- package/dist/rate-limit.js +40 -0
- package/dist/remote.js +145 -65
- package/dist/resilience.js +107 -52
- package/dist/resolvers.js +125 -0
- package/dist/scopes.js +70 -17
- package/dist/serialization.js +32 -22
- package/dist/server.js +148 -132
- package/dist/standard-schema.js +215 -0
- package/dist/task-tools/analytics.js +145 -86
- package/dist/task-tools/bookings.js +168 -81
- package/dist/task-tools/cadences.js +30 -45
- package/dist/task-tools/calls.js +131 -124
- package/dist/task-tools/inbox.js +37 -47
- package/dist/task-tools/leads.js +49 -77
- package/dist/task-tools/runtime.js +75 -15
- package/dist/token-verification.js +47 -0
- package/dist/tool-cache.js +3 -2
- package/dist/tool.js +151 -96
- package/dist/version.js +1 -1
- package/package.json +37 -16
- package/dist/openapi-zod.js +0 -335
- package/dist/page-shape.js +0 -62
- package/dist/tsconfig.tsbuildinfo +0 -1
package/dist/config.js
CHANGED
|
@@ -1,73 +1,124 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as
|
|
1
|
+
import * as Config from "effect/Config";
|
|
2
|
+
import * as ConfigProvider from "effect/ConfigProvider";
|
|
3
|
+
import * as Context from "effect/Context";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as HashMap from "effect/HashMap";
|
|
6
|
+
import * as Layer from "effect/Layer";
|
|
7
|
+
import * as Option from "effect/Option";
|
|
8
|
+
import * as Redacted from "effect/Redacted";
|
|
9
|
+
import * as Schema from "effect/Schema";
|
|
3
10
|
import { LucaConfigError } from "./errors.js";
|
|
4
11
|
import { optionalField } from "./optional-field.js";
|
|
5
12
|
const TRAILING_SLASHES = /\/+$/;
|
|
6
13
|
/** How long one HTTP attempt may run before it is cancelled and retried. */
|
|
7
14
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
8
|
-
export const LucaConfig = Context.
|
|
15
|
+
export const LucaConfig = Context.Service("@luca/mcp/LucaConfig");
|
|
9
16
|
function nonEmpty(value) {
|
|
10
17
|
// An all-whitespace variable is the same as an unset one, so it collapses to
|
|
11
18
|
// undefined and lets every caller's `??` fallback take over.
|
|
12
19
|
return value?.trim() || undefined;
|
|
13
20
|
}
|
|
21
|
+
/** A variable's trimmed value, undefined when it is unset or blank. */
|
|
22
|
+
function optionalVar(name) {
|
|
23
|
+
return Config.option(Config.String(name)).pipe(Config.map((value) => nonEmpty(Option.getOrUndefined(value))));
|
|
24
|
+
}
|
|
25
|
+
function trimmedSecret(secret) {
|
|
26
|
+
return Option.map(Option.fromNullishOr(nonEmpty(Redacted.value(secret))), (value) => Redacted.make(value));
|
|
27
|
+
}
|
|
28
|
+
/** A secret variable, trimmed and still redacted; None when unset or blank. */
|
|
29
|
+
function optionalSecret(name) {
|
|
30
|
+
return Config.option(Config.Redacted(name)).pipe(Config.map(Option.flatMap(trimmedSecret)));
|
|
31
|
+
}
|
|
32
|
+
/** The URL with any trailing slashes removed, so paths can be appended to it. */
|
|
33
|
+
export function withoutTrailingSlashes(url) {
|
|
34
|
+
return url.replace(TRAILING_SLASHES, "");
|
|
35
|
+
}
|
|
14
36
|
function normalizeBaseUrl(value) {
|
|
15
|
-
|
|
16
|
-
return raw.replace(TRAILING_SLASHES, "");
|
|
37
|
+
return withoutTrailingSlashes(value ?? "https://api.setluca.com");
|
|
17
38
|
}
|
|
18
39
|
/**
|
|
19
40
|
* Every spelling of LUCA_AUTH_HEADER we accept, mapped to the header the client
|
|
20
41
|
* actually sends. "bearer" is here because it is what people write when they
|
|
21
42
|
* mean the Authorization header, and refusing it would be a papercut.
|
|
22
43
|
*/
|
|
23
|
-
const AUTH_HEADER_ALIASES =
|
|
44
|
+
const AUTH_HEADER_ALIASES = HashMap.fromIterable([
|
|
24
45
|
["x-api-key", "x-api-key"],
|
|
25
46
|
["authorization", "authorization"],
|
|
26
47
|
["bearer", "authorization"],
|
|
27
48
|
]);
|
|
49
|
+
const decodeTimeoutMs = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0)));
|
|
28
50
|
/**
|
|
29
51
|
* The per-attempt timeout a variable asks for, absent when it is unset or is
|
|
30
52
|
* not a positive whole number of milliseconds. Absent means the caller's
|
|
31
53
|
* default applies, so a typo degrades to the default rather than to no timeout
|
|
32
54
|
* at all.
|
|
33
55
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.number()
|
|
37
|
-
.int()
|
|
38
|
-
.positive()
|
|
39
|
-
.safeParse(nonEmpty(value) ?? undefined);
|
|
40
|
-
return parsed.success ? parsed.data : undefined;
|
|
56
|
+
function parseTimeoutMs(value) {
|
|
57
|
+
return Option.getOrUndefined(decodeTimeoutMs(value));
|
|
41
58
|
}
|
|
42
59
|
function parseAuthHeader(value) {
|
|
43
|
-
const alias =
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
});
|
|
48
|
-
}), Match.orElse((header) => header));
|
|
60
|
+
const alias = value?.toLowerCase() ?? "x-api-key";
|
|
61
|
+
return Effect.fromNullishOr(Option.getOrUndefined(HashMap.get(AUTH_HEADER_ALIASES, alias))).pipe(Effect.mapError(() => new LucaConfigError({
|
|
62
|
+
message: "LUCA_AUTH_HEADER must be x-api-key or authorization",
|
|
63
|
+
})));
|
|
49
64
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
/** LUCA_API_KEY, or the older LUCA_API_TOKEN when it is unset. */
|
|
66
|
+
const ApiKeyConfig = Config.all([
|
|
67
|
+
optionalSecret("LUCA_API_KEY"),
|
|
68
|
+
optionalSecret("LUCA_API_TOKEN"),
|
|
69
|
+
]).pipe(Config.map(([apiKey, legacy]) => Option.orElse(apiKey, () => legacy)));
|
|
70
|
+
const RemoteSettingsConfig = Config.all({
|
|
71
|
+
apiBaseUrl: optionalVar("LUCA_API_BASE_URL"),
|
|
72
|
+
authHeader: optionalVar("LUCA_AUTH_HEADER"),
|
|
73
|
+
requestTimeoutMs: optionalVar("LUCA_REQUEST_TIMEOUT_MS"),
|
|
74
|
+
});
|
|
75
|
+
const WorkspaceConfig = Config.all({
|
|
76
|
+
workspaceId: optionalVar("LUCA_WORKSPACE_ID"),
|
|
77
|
+
workspaceSlug: optionalVar("LUCA_WORKSPACE_SLUG"),
|
|
78
|
+
});
|
|
79
|
+
/** Reads `config` from `env`, failing as {@link LucaConfigError}. */
|
|
80
|
+
function readFrom(config, env) {
|
|
81
|
+
return config
|
|
82
|
+
.parse(ConfigProvider.fromEnvRecord(env))
|
|
83
|
+
.pipe(Effect.mapError((error) => new LucaConfigError({ message: error.message })));
|
|
84
|
+
}
|
|
85
|
+
function requireApiKey(env) {
|
|
86
|
+
return readFrom(ApiKeyConfig, env).pipe(Effect.flatMap(Option.match({
|
|
87
|
+
onNone: () => Effect.fail(new LucaConfigError({
|
|
88
|
+
message: "Set LUCA_API_KEY to a Luca developer API key.",
|
|
89
|
+
})),
|
|
90
|
+
onSome: Effect.succeed,
|
|
53
91
|
})));
|
|
54
|
-
return {
|
|
55
|
-
apiBaseUrl: normalizeBaseUrl(env.LUCA_API_BASE_URL),
|
|
56
|
-
apiKey,
|
|
57
|
-
authHeader: parseAuthHeader(env.LUCA_AUTH_HEADER),
|
|
58
|
-
...optionalField("workspaceId", nonEmpty(env.LUCA_WORKSPACE_ID)),
|
|
59
|
-
...optionalField("workspaceSlug", nonEmpty(env.LUCA_WORKSPACE_SLUG)),
|
|
60
|
-
...optionalField("requestTimeoutMs", parseTimeoutMs(env.LUCA_REQUEST_TIMEOUT_MS)),
|
|
61
|
-
};
|
|
62
92
|
}
|
|
63
93
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
94
|
+
* Read the API settings that don't depend on the API key. The Worker reads
|
|
95
|
+
* them once and gives them to every token it resolves, so a remote call
|
|
96
|
+
* reaches the API exactly as a stdio call would. An unknown LUCA_AUTH_HEADER
|
|
97
|
+
* fails with {@link LucaConfigError}.
|
|
98
|
+
*/
|
|
99
|
+
export function loadRemoteSettings(env) {
|
|
100
|
+
return readFrom(RemoteSettingsConfig, env).pipe(Effect.flatMap((vars) => parseAuthHeader(vars.authHeader).pipe(Effect.map((authHeader) => ({
|
|
101
|
+
apiBaseUrl: normalizeBaseUrl(vars.apiBaseUrl),
|
|
102
|
+
authHeader,
|
|
103
|
+
...optionalField("requestTimeoutMs", parseTimeoutMs(vars.requestTimeoutMs)),
|
|
104
|
+
})))));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Read the stdio server's configuration from the environment. A missing API
|
|
108
|
+
* key or an unknown LUCA_AUTH_HEADER fails with {@link LucaConfigError}; every
|
|
109
|
+
* other variable falls back to its default when unset or malformed.
|
|
69
110
|
*/
|
|
70
|
-
export function
|
|
71
|
-
return
|
|
111
|
+
export function loadConfig(env = process.env) {
|
|
112
|
+
return Effect.all({
|
|
113
|
+
apiKey: requireApiKey(env),
|
|
114
|
+
settings: loadRemoteSettings(env),
|
|
115
|
+
workspace: readFrom(WorkspaceConfig, env),
|
|
116
|
+
}, { concurrency: 1 }).pipe(Effect.map(({ apiKey, settings, workspace }) => ({
|
|
117
|
+
...settings,
|
|
118
|
+
apiKey,
|
|
119
|
+
...optionalField("workspaceId", workspace.workspaceId),
|
|
120
|
+
...optionalField("workspaceSlug", workspace.workspaceSlug),
|
|
121
|
+
})));
|
|
72
122
|
}
|
|
73
|
-
|
|
123
|
+
/** The stdio server's configuration, read from `process.env` when built. */
|
|
124
|
+
export const LucaConfigLive = Layer.effect(LucaConfig, Effect.suspend(() => loadConfig()));
|
package/dist/errors.js
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as
|
|
1
|
+
import * as Match from "effect/Match";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import * as P from "effect/Predicate";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
3
5
|
import { toJsonPayload } from "./serialization.js";
|
|
4
|
-
export class LucaConfigError extends
|
|
6
|
+
export class LucaConfigError extends Schema.TaggedError()("LucaConfigError", {
|
|
7
|
+
message: Schema.String,
|
|
8
|
+
}) {
|
|
5
9
|
}
|
|
6
|
-
export class LucaNetworkError extends
|
|
10
|
+
export class LucaNetworkError extends Schema.TaggedError()("LucaNetworkError", {
|
|
11
|
+
message: Schema.String,
|
|
12
|
+
cause: Schema.optionalKey(Schema.Unknown),
|
|
13
|
+
}) {
|
|
7
14
|
}
|
|
8
|
-
export class LucaDecodeError extends
|
|
15
|
+
export class LucaDecodeError extends Schema.TaggedError()("LucaDecodeError", {
|
|
16
|
+
message: Schema.String,
|
|
17
|
+
body: Schema.String,
|
|
18
|
+
}) {
|
|
9
19
|
}
|
|
10
|
-
export class LucaHttpError extends
|
|
20
|
+
export class LucaHttpError extends Schema.TaggedError()("LucaHttpError", {
|
|
21
|
+
status: Schema.Number,
|
|
22
|
+
statusText: Schema.String,
|
|
23
|
+
body: Schema.Json,
|
|
24
|
+
requestId: Schema.optionalKey(Schema.String),
|
|
25
|
+
/**
|
|
26
|
+
* How long the server asked us to wait, from its `Retry-After` header. Absent
|
|
27
|
+
* when the header was missing or unreadable.
|
|
28
|
+
*/
|
|
29
|
+
retryAfterMs: Schema.optionalKey(Schema.Number),
|
|
30
|
+
}) {
|
|
11
31
|
}
|
|
12
32
|
/**
|
|
13
33
|
* One attempt ran past the request timeout and was cancelled. It is its own
|
|
@@ -15,7 +35,9 @@ export class LucaHttpError extends Data.TaggedError("LucaHttpError") {
|
|
|
15
35
|
* the same rules but cannot tell from a network failure whether the request
|
|
16
36
|
* reached the API.
|
|
17
37
|
*/
|
|
18
|
-
export class LucaTimeoutError extends
|
|
38
|
+
export class LucaTimeoutError extends Schema.TaggedError()("LucaTimeoutError", {
|
|
39
|
+
message: Schema.String,
|
|
40
|
+
}) {
|
|
19
41
|
}
|
|
20
42
|
/**
|
|
21
43
|
* A tool was called with arguments that are not a JSON object. The MCP SDK
|
|
@@ -23,14 +45,73 @@ export class LucaTimeoutError extends Data.TaggedError("LucaTimeoutError") {
|
|
|
23
45
|
* real client cannot reach this. It exists so no code ahead of `runPromise` can
|
|
24
46
|
* throw where the error channel cannot see it.
|
|
25
47
|
*/
|
|
26
|
-
export class LucaToolInputError extends
|
|
48
|
+
export class LucaToolInputError extends Schema.TaggedError()("LucaToolInputError", {
|
|
49
|
+
message: Schema.String,
|
|
50
|
+
}) {
|
|
27
51
|
}
|
|
28
|
-
|
|
52
|
+
/**
|
|
53
|
+
* A gated tool was called without `confirm: true`. It stays out of
|
|
54
|
+
* {@link LucaError} because no request was sent: the tool pipeline turns it
|
|
55
|
+
* into its own result telling the agent which argument to add.
|
|
56
|
+
*/
|
|
57
|
+
export class LucaConfirmationRequired extends Schema.TaggedError()("LucaConfirmationRequired", {
|
|
58
|
+
toolName: Schema.String,
|
|
59
|
+
}) {
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The service that verifies bearer tokens could not give an answer: it was
|
|
63
|
+
* unreachable, timed out, or answered with a server error. This is not a
|
|
64
|
+
* verdict on the token. The transport replies 503 so a client retries instead
|
|
65
|
+
* of discarding a token that may be valid and starting a new sign-in.
|
|
66
|
+
*/
|
|
67
|
+
export class TokenVerificationUnavailable extends Schema.TaggedError()("TokenVerificationUnavailable", {
|
|
68
|
+
message: Schema.String,
|
|
69
|
+
cause: Schema.optionalKey(Schema.Unknown),
|
|
70
|
+
}) {
|
|
71
|
+
}
|
|
72
|
+
/** Whether the API answered that the thing asked for does not exist. */
|
|
73
|
+
export function isNotFound(error) {
|
|
74
|
+
return P.isTagged(error, "LucaHttpError") && error.status === 404;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* An API error body whose code means the key is valid but its scopes fall
|
|
78
|
+
* short: `scope_required` for a missing API scope, `needs_scope` for a
|
|
79
|
+
* capability tier below the route's.
|
|
80
|
+
*/
|
|
81
|
+
const ScopeErrorBody = Schema.Struct({
|
|
82
|
+
error: Schema.Struct({
|
|
83
|
+
code: Schema.Literals(["scope_required", "needs_scope"]),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const isScopeErrorBody = Schema.is(ScopeErrorBody);
|
|
87
|
+
/**
|
|
88
|
+
* Whether the API answered 403 because the caller's scopes fall short. Any
|
|
89
|
+
* other 403 covers plan limits and workspace access, which re-authorizing
|
|
90
|
+
* cannot fix.
|
|
91
|
+
*/
|
|
92
|
+
export function isScopeRefusal(error) {
|
|
93
|
+
return (P.isTagged(error, "LucaHttpError") &&
|
|
94
|
+
error.status === 403 &&
|
|
95
|
+
isScopeErrorBody(error.body));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether the API refused the caller's credentials rather than the request: a
|
|
99
|
+
* 401, or a scope refusal. Re-authorizing fixes these.
|
|
100
|
+
*/
|
|
101
|
+
export function isAuthorizationFailure(error) {
|
|
102
|
+
return ((P.isTagged(error, "LucaHttpError") && error.status === 401) ||
|
|
103
|
+
isScopeRefusal(error));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A body the API could not send as JSON, named by its length only. It is
|
|
107
|
+
* usually an edge or proxy page, text no Luca schema shaped, and quoting it
|
|
108
|
+
* would hand the agent instructions with nothing marking them as untrusted.
|
|
109
|
+
*/
|
|
29
110
|
function formatDecodeError(error) {
|
|
30
|
-
return `${error.message}
|
|
111
|
+
return `${error.message} (${error.body.length} characters)`;
|
|
31
112
|
}
|
|
32
113
|
function formatHttpError(error) {
|
|
33
|
-
const request = Option.
|
|
114
|
+
const request = Option.fromNullishOr(error.requestId).pipe(Option.match({
|
|
34
115
|
onNone: () => "",
|
|
35
116
|
onSome: (requestId) => ` requestId=${requestId}`,
|
|
36
117
|
}));
|
|
@@ -44,11 +125,11 @@ function formatHttpError(error) {
|
|
|
44
125
|
* their own: JSON renders each of them exactly as `String` would.
|
|
45
126
|
*/
|
|
46
127
|
function formatJsonValue(error) {
|
|
47
|
-
|
|
48
|
-
return text.success ? text.data : toJsonPayload(error);
|
|
128
|
+
return P.isString(error) ? error : toJsonPayload(error);
|
|
49
129
|
}
|
|
50
130
|
/** Any failure this server can hand a client, as one line of text. */
|
|
51
131
|
export function formatError(error) {
|
|
132
|
+
// oxlint-disable-next-line effect/avoid-untagged-errors -- boundary narrows an unknown thrown value that carries no tag
|
|
52
133
|
return error instanceof Error ? formatThrown(error) : formatJsonValue(error);
|
|
53
134
|
}
|
|
54
135
|
/**
|
package/dist/fields.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as DateTime from "effect/DateTime";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
/**
|
|
5
|
+
* Input field schemas that operation tools and task tools share. Both kinds of
|
|
6
|
+
* tool reach a client in one list, so a field means the same thing wherever it
|
|
7
|
+
* appears.
|
|
8
|
+
*/
|
|
9
|
+
/** A UUID in its canonical 8-4-4-4-12 hex form. */
|
|
10
|
+
export const UUID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
11
|
+
/** A leap year: divisible by 4, except centuries not divisible by 400. */
|
|
12
|
+
const LEAP_YEAR = String.raw `(?:\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)`;
|
|
13
|
+
/**
|
|
14
|
+
* A calendar date that exists: each month's own last day, and 29 February
|
|
15
|
+
* only in a leap year. `Date` cannot check this, because it rolls 31 February
|
|
16
|
+
* over to early March instead of refusing it.
|
|
17
|
+
*/
|
|
18
|
+
const CALENDAR_DATE = [
|
|
19
|
+
String.raw `\d{4}-(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])`,
|
|
20
|
+
String.raw `\d{4}-(?:0[469]|11)-(?:0[1-9]|[12]\d|30)`,
|
|
21
|
+
String.raw `\d{4}-02-(?:0[1-9]|1\d|2[0-8])`,
|
|
22
|
+
`${LEAP_YEAR}-02-29`,
|
|
23
|
+
].join("|");
|
|
24
|
+
const CLOCK_TIME = String.raw `(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?`;
|
|
25
|
+
const UTC_OFFSET = String.raw `(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)`;
|
|
26
|
+
/**
|
|
27
|
+
* An ISO-8601 instant with an explicit offset, such as `2026-07-14T15:00:00Z`,
|
|
28
|
+
* that names a real moment: no month 13, no 31 February, no hour 25. It is one
|
|
29
|
+
* pattern rather than a pattern plus a filter so the generated input schemas,
|
|
30
|
+
* which can only print patterns, enforce the same rule as the task tools.
|
|
31
|
+
*/
|
|
32
|
+
// fallow-ignore-next-line security-sink -- the pattern is built from three fixed string constants in this file, never from caller input
|
|
33
|
+
export const ISO_DATE_TIME_PATTERN = new RegExp(`^(?:${CALENDAR_DATE})T${CLOCK_TIME}${UTC_OFFSET}$`);
|
|
34
|
+
/** A string of at least one character. */
|
|
35
|
+
export const NonEmptyString = Schema.String.check(Schema.isMinLength(1));
|
|
36
|
+
const Uuid = Schema.String.check(Schema.isPattern(UUID_PATTERN)).annotate({
|
|
37
|
+
format: "uuid",
|
|
38
|
+
});
|
|
39
|
+
/** An instant matching {@link ISO_DATE_TIME_PATTERN}. */
|
|
40
|
+
export const IsoDateTime = Schema.String.check(Schema.isPattern(ISO_DATE_TIME_PATTERN)).annotate({ format: "date-time" });
|
|
41
|
+
/** An IANA time zone name the runtime knows, such as `America/New_York`. */
|
|
42
|
+
export const IanaTimeZone = NonEmptyString.check(
|
|
43
|
+
// oxlint-disable-next-line effect/require-filter-metadata -- an identifier turns this field into a `$ref` in every tool's input schema.
|
|
44
|
+
Schema.makeFilter((value) => Option.isSome(DateTime.zoneMakeNamed(value)), { expected: "an IANA time zone name" }));
|
|
45
|
+
/** The messaging channels a lead can arrive on. */
|
|
46
|
+
export const MESSAGING_CHANNELS = [
|
|
47
|
+
"telegram",
|
|
48
|
+
"instagram",
|
|
49
|
+
"messenger",
|
|
50
|
+
"whatsapp",
|
|
51
|
+
];
|
|
52
|
+
export const MessagingChannel = Schema.Literals(MESSAGING_CHANNELS);
|
|
53
|
+
export const WORKSPACE_INPUT_FIELDS = {
|
|
54
|
+
workspaceId: Schema.optionalKey(Uuid.annotate({ description: "Optional Luca workspace id override." })),
|
|
55
|
+
workspaceSlug: Schema.optionalKey(NonEmptyString.annotate({
|
|
56
|
+
description: "Optional Luca workspace slug override.",
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The confirmation gate's input field. One wording covers both kinds of tool
|
|
61
|
+
* because a client sees one tool list: the effects named here are the union of
|
|
62
|
+
* what either kind can do, not a guess at which one the reader is holding.
|
|
63
|
+
*/
|
|
64
|
+
export const confirmInput = {
|
|
65
|
+
confirm: Schema.optionalKey(Schema.Boolean.annotate({
|
|
66
|
+
description: "This tool has a real-world side effect: it messages real leads, books, moves, or cancels a call, changes a lead's consent, imports leads, writes to a connected calendar or CRM, sends workspace events to an outside URL, or re-triggers downstream automations. Pass confirm: true to proceed; the call is rejected without it.",
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Table bound shared by the local limiters and the key-check cache. */
|
|
2
|
+
const MAX_FIXED_WINDOW_KEYS = 10_000;
|
|
3
|
+
export function createFixedWindowTable(windowMs) {
|
|
4
|
+
// oxlint-disable-next-line effect/avoid-native-object-helpers -- Evicts windows in insertion order; restarting a key moves it to the end.
|
|
5
|
+
const windows = new Map();
|
|
6
|
+
const live = (key, now) => {
|
|
7
|
+
const window = windows.get(key);
|
|
8
|
+
return window !== undefined && now - window.windowStartedAt < windowMs
|
|
9
|
+
? window
|
|
10
|
+
: undefined;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Drops expired windows. A window is re-inserted when it restarts, so Map
|
|
14
|
+
* order is start order and the sweep stops at the first live one.
|
|
15
|
+
*/
|
|
16
|
+
const sweepExpired = (now) => {
|
|
17
|
+
// oxlint-disable-next-line effect/imperative-loops -- stops at the first live entry, which a combinator cannot express
|
|
18
|
+
for (const [key, window] of windows) {
|
|
19
|
+
if (now - window.windowStartedAt < windowMs) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
windows.delete(key);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
charge: (key, now) => {
|
|
27
|
+
const current = live(key, now);
|
|
28
|
+
if (current !== undefined) {
|
|
29
|
+
current.count += 1;
|
|
30
|
+
return current.count;
|
|
31
|
+
}
|
|
32
|
+
windows.delete(key);
|
|
33
|
+
if (windows.size >= MAX_FIXED_WINDOW_KEYS) {
|
|
34
|
+
sweepExpired(now);
|
|
35
|
+
}
|
|
36
|
+
if (windows.size >= MAX_FIXED_WINDOW_KEYS) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
windows.set(key, { windowStartedAt: now, count: 1 });
|
|
40
|
+
return 1;
|
|
41
|
+
},
|
|
42
|
+
isLive: (key, now) => live(key, now) !== undefined,
|
|
43
|
+
size: () => windows.size,
|
|
44
|
+
keys: () => [...windows.keys()],
|
|
45
|
+
};
|
|
46
|
+
}
|