@setluca/mcp 0.2.1 → 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 (69) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -288
  4. package/dist/annotations.js +33 -18
  5. package/dist/client-address.js +64 -0
  6. package/dist/completions.js +24 -27
  7. package/dist/config.js +89 -38
  8. package/dist/errors.js +94 -13
  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 +103 -41
  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 +30 -15
  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 +15 -3
  40. package/dist/operations/manifest.js +7 -6
  41. package/dist/operations/registry.js +102 -108
  42. package/dist/operations.js +4 -3
  43. package/dist/optional-field.js +15 -3
  44. package/dist/page-contract.js +74 -0
  45. package/dist/pagination.js +147 -67
  46. package/dist/prompts.js +122 -97
  47. package/dist/rate-limit.js +40 -0
  48. package/dist/remote.js +145 -65
  49. package/dist/resilience.js +107 -52
  50. package/dist/resolvers.js +125 -0
  51. package/dist/scopes.js +70 -17
  52. package/dist/serialization.js +32 -22
  53. package/dist/server.js +148 -132
  54. package/dist/standard-schema.js +215 -0
  55. package/dist/task-tools/analytics.js +145 -86
  56. package/dist/task-tools/bookings.js +168 -81
  57. package/dist/task-tools/cadences.js +30 -45
  58. package/dist/task-tools/calls.js +131 -124
  59. package/dist/task-tools/inbox.js +37 -47
  60. package/dist/task-tools/leads.js +49 -77
  61. package/dist/task-tools/runtime.js +75 -15
  62. package/dist/token-verification.js +42 -0
  63. package/dist/tool-cache.js +3 -2
  64. package/dist/tool.js +151 -96
  65. package/dist/version.js +1 -1
  66. package/package.json +37 -16
  67. package/dist/openapi-zod.js +0 -335
  68. package/dist/page-shape.js +0 -62
  69. package/dist/tsconfig.tsbuildinfo +0 -1
package/dist/config.js CHANGED
@@ -1,73 +1,124 @@
1
- import { Context, Effect, Layer, Match, Option } from "effect";
2
- import * as z from "zod/v4";
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.GenericTag("@luca/mcp/LucaConfig");
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
- const raw = nonEmpty(value) ?? "https://api.setluca.com";
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 = new Map([
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
- export function parseTimeoutMs(value) {
35
- const parsed = z.coerce
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 = nonEmpty(value)?.toLowerCase() ?? "x-api-key";
44
- return Match.value(AUTH_HEADER_ALIASES.get(alias)).pipe(Match.when(Match.undefined, () => {
45
- throw new LucaConfigError({
46
- message: "LUCA_AUTH_HEADER must be x-api-key or authorization",
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
- export function loadConfig(env = process.env) {
51
- const apiKey = Option.fromNullable(nonEmpty(env.LUCA_API_KEY) ?? nonEmpty(env.LUCA_API_TOKEN)).pipe(Option.getOrThrowWith(() => new LucaConfigError({
52
- message: "Set LUCA_API_KEY to a Luca developer API key.",
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
- * Every way `loadConfig` can fail has to reach the caller as a `LucaConfigError`
65
- * so the layer's error channel stays one type. A config error passes through
66
- * untouched. Anything else is wrapped rather than escaping as an untyped
67
- * defect, whether that is a getter on the environment that throws or a future
68
- * caller of this helper raising something unexpected.
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 toConfigError(cause) {
71
- return Match.value(cause).pipe(Match.when(Match.instanceOf(LucaConfigError), (error) => error), Match.orElse((other) => new LucaConfigError({ message: String(other) })));
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
- export const LucaConfigLive = Layer.effect(LucaConfig, Effect.try({ try: () => loadConfig(), catch: toConfigError }));
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 { Data, Match, Option } from "effect";
2
- import * as z from "zod/v4";
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 Data.TaggedError("LucaConfigError") {
6
+ export class LucaConfigError extends Schema.TaggedError()("LucaConfigError", {
7
+ message: Schema.String,
8
+ }) {
5
9
  }
6
- export class LucaNetworkError extends Data.TaggedError("LucaNetworkError") {
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 Data.TaggedError("LucaDecodeError") {
15
+ export class LucaDecodeError extends Schema.TaggedError()("LucaDecodeError", {
16
+ message: Schema.String,
17
+ body: Schema.String,
18
+ }) {
9
19
  }
10
- export class LucaHttpError extends Data.TaggedError("LucaHttpError") {
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 Data.TaggedError("LucaTimeoutError") {
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 Data.TaggedError("LucaToolInputError") {
48
+ export class LucaToolInputError extends Schema.TaggedError()("LucaToolInputError", {
49
+ message: Schema.String,
50
+ }) {
27
51
  }
28
- const DECODE_BODY_EXCERPT = 500;
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}: ${error.body.slice(0, DECODE_BODY_EXCERPT)}`;
111
+ return `${error.message} (${error.body.length} characters)`;
31
112
  }
32
113
  function formatHttpError(error) {
33
- const request = Option.fromNullable(error.requestId).pipe(Option.match({
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
- const text = z.string().safeParse(error);
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
+ }