@zackbart/connecta 0.37.1 → 0.39.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 +94 -0
- package/README.md +5 -4
- package/bin/connecta.mjs +1 -39
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +2 -4
- package/dist/agent-identity.js +1 -1
- package/dist/agent-routes.js +231 -0
- package/dist/auth/clerk-sdk/generated.js +2 -1
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2516
- package/dist/auth/downstream-revocation.js +1 -1
- package/dist/auth/native.js +27 -0
- package/dist/auth/oauth-grant.js +24 -0
- package/dist/auth/oauth-token-response.js +1 -1
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth/vercel-agent.js +2 -1
- package/dist/auth-elicitation.js +9 -3
- package/dist/authorization-handoff.js +11 -5
- package/dist/branding.d.ts +1 -15
- package/dist/branding.js +2 -2
- package/dist/bytes.js +42 -0
- package/dist/call-admission-context.js +2 -5
- package/dist/call-recovery.js +0 -3
- package/dist/catalog-cache.js +130 -43
- package/dist/catalog-fingerprint.js +2 -18
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +285 -48
- package/dist/catalog.js +18 -2
- package/dist/config-defaults.d.ts +1 -0
- package/dist/config-defaults.js +1 -0
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +6 -2
- package/dist/config.d.ts +57 -25
- package/dist/config.js +30 -77
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +11 -7
- package/dist/connectors/guarded-fetch.d.ts +0 -6
- package/dist/connectors/guarded-fetch.js +1 -11
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +671 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +469 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +544 -0
- package/dist/connectors/remote-mcp.js +61 -2898
- package/dist/connectors/resource-uri.js +0 -4
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/d1.d.ts +0 -89
- package/dist/d1.js +0 -34
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +4 -2
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +172 -125
- package/dist/executor-result.js +5 -2
- package/dist/executors/quickjs-runtime.js +2 -25
- package/dist/executors/quickjs.js +1 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +63 -20
- package/dist/guest-types.d.ts +24 -10
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/client.js +1 -1
- package/dist/http/errors.js +7 -6
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/json-schema.js +1 -1
- package/dist/http/node-transport.js +0 -1
- package/dist/http/oauth.js +4 -3
- package/dist/http/paging.js +6 -9
- package/dist/http/provider.js +33 -5
- package/dist/http/safety.js +9 -1
- package/dist/http/services.js +1 -8
- package/dist/http/tools.js +45 -30
- package/dist/http/vendor.js +7 -7
- package/dist/identity.js +2 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation-auth.js +2 -1
- package/dist/invocation.js +21 -6
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +50 -35
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +6 -2
- package/dist/oauth-handoff.js +2 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +136 -14
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -49
- package/dist/operator-ui/contract.d.ts +12 -2
- package/dist/operator-ui/generated.js +3 -3
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/program-writes.js +3 -8
- package/dist/provider-call.js +0 -7
- package/dist/provider.d.ts +1 -1
- package/dist/providers/_shared/google/kit.js +1 -6
- package/dist/providers/_shared/google/service-account.js +1 -3
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/guide.js +3 -0
- package/dist/providers/_shared/json.js +12 -0
- package/dist/providers/_shared/rest/compact-index.js +1 -1
- package/dist/providers/_shared/rest/kit.js +1 -3
- package/dist/providers/_shared/rest/operation-index.js +49 -53
- package/dist/providers/_shared/rest/value-safety.js +106 -28
- package/dist/providers/breeze/api.js +9 -8
- package/dist/providers/breeze/index.js +4 -2
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +7 -7
- package/dist/providers/ccb/api.js +3 -7
- package/dist/providers/ccb/index.js +7 -1
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/ccb/tools.js +1 -6
- package/dist/providers/cloudflare/api.js +10 -3
- package/dist/providers/cloudflare/index.js +2 -1
- package/dist/providers/cloudflare/safety.js +3 -3
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +4 -14
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +23 -41
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +11 -16
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +12 -26
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +8 -4
- package/dist/providers/overflow/index.js +2 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/overflow/tools.js +3 -7
- package/dist/providers/planning-center/api.js +5 -2
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/lib/json-api.js +1 -9
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/planning-center/tools.js +2 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +14 -23
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +19 -26
- package/dist/providers/stripe/index.js +2 -1
- package/dist/providers/stripe/safety.js +2 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +5 -8
- package/dist/providers/tithely/index.js +3 -2
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/tithely/tools.js +7 -5
- package/dist/providers/vercel/api.js +2 -7
- package/dist/providers/vercel/lib/pin.js +1 -1
- package/dist/providers/vercel/safety.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +6 -4
- package/dist/redaction-marker.js +2 -2
- package/dist/registry.d.ts +11 -5
- package/dist/registry.js +46 -20
- package/dist/request-principal.js +1 -1
- package/dist/request-state.js +2 -2
- package/dist/routes/access-tokens.js +1 -1
- package/dist/routes/activity-disclosure.js +1 -1
- package/dist/routes/connect.js +2 -2
- package/dist/routes/credentials.js +130 -61
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +34 -17
- package/dist/routes/oauth-management.js +1 -1
- package/dist/routes/operator.js +6 -5
- package/dist/routes/shared.d.ts +13 -8
- package/dist/routes/shared.js +15 -9
- package/dist/routes/ui-config.js +3 -3
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +17 -1
- package/dist/runtime/call-admission.js +1 -1
- package/dist/runtime/call.js +8 -3
- package/dist/runtime/native-storage.js +9 -0
- package/dist/runtime/request-reads.js +182 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/settlement.js +1 -1
- package/dist/runtime/shared-read.js +2 -15
- package/dist/runtime/storage.js +12 -0
- package/dist/sent-secrets.d.ts +1 -2
- package/dist/sent-secrets.js +3 -8
- package/dist/server.js +4 -4
- package/dist/skills.js +44 -54
- package/dist/sqlite.d.ts +0 -19
- package/dist/sqlite.js +2 -83
- package/dist/storage/keys.js +69 -128
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +27 -101
- package/dist/storage/sql.d.ts +1 -24
- package/dist/storage/sql.js +13 -14
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/tool-error.js +4 -0
- package/dist/types.d.ts +33 -0
- package/dist/url-safety.js +0 -4
- package/dist/usage-guide.js +18 -37
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +19 -55
- package/documentation/architecture.md +436 -515
- package/documentation/auth.md +229 -88
- package/documentation/code-mode.md +201 -94
- package/documentation/deploying.md +25 -43
- package/documentation/integrating.md +18 -19
- package/documentation/meta-tools.md +225 -51
- package/documentation/migration-0.33.md +10 -6
- package/documentation/migration-0.34.md +5 -5
- package/documentation/migration-retired-layouts.md +74 -0
- package/documentation/operator-tests.md +20 -120
- package/documentation/operator-ui.md +108 -16
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/provider-auth-migration.md +4 -10
- package/documentation/provider-migration-0.29.md +4 -15
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +14 -16
- package/examples/worker/README.md +24 -118
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +9 -31
- package/guides/how-it-works.md +6 -4
- package/guides/safety-and-trust.md +4 -1
- package/package.json +19 -19
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/README.md +9 -26
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
- package/dist/auth/oauth-v2-migration.js +0 -135
- package/dist/provider-call-facts.d.ts +0 -115
- package/dist/provider-call.d.ts +0 -112
- package/dist/storage/d1-copy.js +0 -255
- package/dist/storage/sql-schema.d.ts +0 -6
- package/examples/worker/kv-to-d1.wrangler.jsonc +0 -26
- package/examples/worker/scripts/copy-kv-to-d1.mjs +0 -162
|
@@ -3,7 +3,7 @@ import { api } from "../../connectors/api.js";
|
|
|
3
3
|
import { deriveContext } from "../../derived-context.js";
|
|
4
4
|
import { ConnectorCallError } from "../../errors.js";
|
|
5
5
|
import { Refusal } from "../../http/errors.js";
|
|
6
|
-
import {
|
|
6
|
+
import { fieldsContract, selectFields } from "../../http/fields.js";
|
|
7
7
|
import { hatch } from "../../http/hatch.js";
|
|
8
8
|
import { githubPage } from "../../http/hatch-paging.js";
|
|
9
9
|
import { Integer, isMinChars } from "../../http/json-schema.js";
|
|
@@ -44,10 +44,9 @@ const common = {
|
|
|
44
44
|
default: 30,
|
|
45
45
|
})),
|
|
46
46
|
cursor: Schema.optionalKey(text("Opaque page.nextCursor from this same search or scope listing.")),
|
|
47
|
-
fields: Schema.optionalKey(fieldsInput("Dot paths into each item; only narrows already-safe data.")),
|
|
48
47
|
};
|
|
49
|
-
const
|
|
50
|
-
const
|
|
48
|
+
const SCOPE_FIELDS = fieldsContract("item", ScopeRow, "It only narrows already-safe data.");
|
|
49
|
+
const SEARCH_FIELDS = fieldsContract("item", SearchRow, "Vendor fields are under item, e.g. item.title; it only narrows already-safe data.", "an item (vendor fields are under item)");
|
|
51
50
|
const contextOf = Effect.gen(function* () {
|
|
52
51
|
const op = yield* Effect.serviceOption(Operation);
|
|
53
52
|
if (Option.isNone(op) || !op.value)
|
|
@@ -74,6 +73,36 @@ function selectors(input, policy) {
|
|
|
74
73
|
return { repo: `${t.owner}/${t.repo}`, access: grant.access };
|
|
75
74
|
});
|
|
76
75
|
}
|
|
76
|
+
const SEARCH_TERMS = /^[a-z0-9_. -]{1,128}$/i;
|
|
77
|
+
const BOOLEAN_TERMS = /\b(?:OR|NOT|AND)\b/i;
|
|
78
|
+
/**
|
|
79
|
+
* Record every selector problem by shape alone: a refusal never repeats a
|
|
80
|
+
* submitted org or repository, and list_scopes names the configured grants.
|
|
81
|
+
*/
|
|
82
|
+
function selectorIssues(problems, input, policy) {
|
|
83
|
+
input?.forEach((item, index) => {
|
|
84
|
+
const value = row(item), at = `/scopes/${index}`;
|
|
85
|
+
if ((value.org === undefined) === (value.repo === undefined))
|
|
86
|
+
return problems.add(at, 'exactly one key: {"org": "<org>"} for an org-wide grant or {"repo": "<owner>/<repo>"}', value.org === undefined ? "neither org nor repo" : "both org and repo");
|
|
87
|
+
let admitted = false;
|
|
88
|
+
try {
|
|
89
|
+
if (value.org !== undefined)
|
|
90
|
+
admitted = policy.scopes.some((scope) => scope.org === policy.owner(value.org));
|
|
91
|
+
else {
|
|
92
|
+
const parts = typeof value.repo === "string" ? value.repo.split("/") : [];
|
|
93
|
+
admitted = !!policy.scope(target(parts[0], parts.length === 2 ? parts[1] : undefined));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!(error instanceof ConnectorCallError))
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
if (!admitted)
|
|
101
|
+
problems.add(`${at}/${value.org !== undefined ? "org" : "repo"}`, value.org !== undefined
|
|
102
|
+
? "an organization configured for org-wide access; list_scopes names them"
|
|
103
|
+
: '"<owner>/<repo>" of a configured repository or one in a configured org; list_scopes names them');
|
|
104
|
+
});
|
|
105
|
+
}
|
|
77
106
|
function searchPartitions(scopes) {
|
|
78
107
|
const orgs = new Set(scopes.flatMap((scope) => (scope.org ? [scope.org] : [])));
|
|
79
108
|
const repos = new Map();
|
|
@@ -232,18 +261,30 @@ export function restConnector(id, auth, policy) {
|
|
|
232
261
|
throw new ConnectorCallError("invalid_args", `This cursor was issued with limit ${position.limit}; repeat limit ${position.limit} or start a new listing.`);
|
|
233
262
|
return position;
|
|
234
263
|
};
|
|
235
|
-
const namesCheck = (fields, names) => checked(() => {
|
|
236
|
-
const bad = fieldsProblem(fields, names, "an item");
|
|
237
|
-
if (bad)
|
|
238
|
-
throw bad;
|
|
239
|
-
});
|
|
240
264
|
const scopesTool = read(connection, {
|
|
241
265
|
name: "list_scopes",
|
|
242
266
|
description: "List configured GitHub grants locally, or verified reachable repositories for one configured owner. Without owner this reads configuration without network access.",
|
|
243
|
-
input: Schema.Struct({
|
|
267
|
+
input: Schema.Struct({
|
|
268
|
+
owner: Schema.optionalKey(text("Configured owner; omit for local grants.")),
|
|
269
|
+
...common,
|
|
270
|
+
fields: Schema.optionalKey(SCOPE_FIELDS.input),
|
|
271
|
+
}),
|
|
244
272
|
output: Schema.Struct({ items: Schema.Array(ScopeRow), page: StandardPage }),
|
|
273
|
+
check: (args, problems) => {
|
|
274
|
+
SCOPE_FIELDS.issues(problems, args.fields);
|
|
275
|
+
if (args.owner === undefined)
|
|
276
|
+
return;
|
|
277
|
+
try {
|
|
278
|
+
policy.owner(args.owner);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (!(error instanceof ConnectorCallError))
|
|
282
|
+
throw error;
|
|
283
|
+
// Without owner, the listing reads the configured grants locally.
|
|
284
|
+
problems.add("/owner", "a configured owner; omit owner to list the configured grants");
|
|
285
|
+
}
|
|
286
|
+
},
|
|
245
287
|
run: (args) => Effect.gen(function* () {
|
|
246
|
-
yield* namesCheck(args.fields, scopeNames);
|
|
247
288
|
const limit = args.limit ?? 30, normalized = { ...args, limit };
|
|
248
289
|
const raw = yield* decode("list_scopes", normalized);
|
|
249
290
|
const position = yield* checked(() => parse(raw, limit));
|
|
@@ -309,15 +350,18 @@ export function restConnector(id, auth, policy) {
|
|
|
309
350
|
.check(Schema.isMinLength(1), Schema.isMaxLength(100))
|
|
310
351
|
.annotate({ description: "Optional subset of the configured scopes." })),
|
|
311
352
|
...common,
|
|
353
|
+
fields: Schema.optionalKey(SEARCH_FIELDS.input),
|
|
312
354
|
}),
|
|
313
355
|
output: Schema.Struct({ items: Schema.Array(SearchRow), page: StandardPage, incomplete_results: Schema.Boolean }),
|
|
356
|
+
check: (args, problems) => {
|
|
357
|
+
// Rows wrap the vendor's record under item, so a vendor field is item.<field>.
|
|
358
|
+
SEARCH_FIELDS.issues(problems, args.fields);
|
|
359
|
+
if (!SEARCH_TERMS.test(args.terms) || BOOLEAN_TERMS.test(args.terms))
|
|
360
|
+
problems.add("/terms", "literal words of letters, digits, spaces, _ . or -, at most 128 characters, without qualifiers such as is:open or label:x and without OR, AND or NOT; kind and scopes select the rest");
|
|
361
|
+
selectorIssues(problems, args.scopes, policy);
|
|
362
|
+
},
|
|
314
363
|
run: (args) => Effect.gen(function* () {
|
|
315
|
-
yield*
|
|
316
|
-
const selected = yield* checked(() => {
|
|
317
|
-
if (!/^[a-z0-9_. -]{1,128}$/i.test(args.terms) || /\b(?:OR|NOT|AND)\b/i.test(args.terms))
|
|
318
|
-
throw new ConnectorCallError("invalid_args", "Search terms must be literal words, without qualifiers or Boolean operators.");
|
|
319
|
-
return args.scopes === undefined ? policy.scopes : selectors(args.scopes, policy);
|
|
320
|
-
});
|
|
364
|
+
const selected = yield* checked(() => args.scopes === undefined ? policy.scopes : selectors(args.scopes, policy));
|
|
321
365
|
const partitions = yield* checked(() => searchPartitions(selected)), limit = args.limit ?? 30, normalized = { ...args, limit };
|
|
322
366
|
const raw = yield* decode("search_scoped", normalized), at = yield* checked(() => parse(raw, limit));
|
|
323
367
|
const partitionIndex = at.partition ?? 0, page = at.page ?? 1;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Real Effect declarations for Gmail's curated surface. MIME interpretation stays in tools.ts.
|
|
2
|
+
import { routeDescription } from "../../../agent-routes.js";
|
|
2
3
|
import { Schema } from "effect";
|
|
3
4
|
import { Integer, isMinChars, isMaxChars } from "../../../http/json-schema.js";
|
|
4
5
|
import { StandardPage } from "../../../http/paging.js";
|
|
5
|
-
import {
|
|
6
|
+
import { fieldsContract } from "../../../http/fields.js";
|
|
6
7
|
const optional = Schema.optionalKey;
|
|
7
8
|
const pattern = (regex) => Schema.makeFilter((value) => regex.test(value), { toJsonSchema: () => ({ pattern: regex.source }) });
|
|
8
9
|
const string = (description, max, min = 0) => Schema.String.check(isMinChars(min), isMaxChars(max)).annotate({ description });
|
|
@@ -29,54 +30,6 @@ const compose = {
|
|
|
29
30
|
htmlBody: optional(string("Optional HTML alternative to the plain-text body.", 500000)),
|
|
30
31
|
replyToMessageId: optional(id("Message to reply to; sets its thread and guarded In-Reply-To/References.")),
|
|
31
32
|
};
|
|
32
|
-
const resultFields = optional(fieldsInput("Dot paths to keep in the result. It only narrows the result."));
|
|
33
|
-
const itemFields = optional(fieldsInput("Dot paths to keep in each item. It only narrows the projection."));
|
|
34
|
-
export const inputs = {
|
|
35
|
-
search_threads: Schema.Struct({
|
|
36
|
-
query,
|
|
37
|
-
includeSpamTrash: optional(Schema.Boolean),
|
|
38
|
-
limit,
|
|
39
|
-
cursor,
|
|
40
|
-
fields: itemFields,
|
|
41
|
-
}),
|
|
42
|
-
get_thread: Schema.Struct({
|
|
43
|
-
threadId: id("Thread id from search_threads or a message."),
|
|
44
|
-
maxBodyChars: bodyChars(3000),
|
|
45
|
-
cursor,
|
|
46
|
-
fields: resultFields,
|
|
47
|
-
}),
|
|
48
|
-
get_message: Schema.Struct({
|
|
49
|
-
messageId: id("Message id from a thread or draft."),
|
|
50
|
-
maxBodyChars: bodyChars(12000),
|
|
51
|
-
fields: resultFields,
|
|
52
|
-
}),
|
|
53
|
-
get_attachment: Schema.Struct({
|
|
54
|
-
fields: resultFields,
|
|
55
|
-
messageId: id("Message carrying the attachment."),
|
|
56
|
-
partId: optional(string("Immutable partId from the attachment listing, including an empty root id. Takes precedence over attachmentId.", 256)),
|
|
57
|
-
attachmentId: optional(string("Alternative rotating attachmentId from a fresh listing; prefer partId.", 2048, 1).check(pattern(/^[A-Za-z0-9_-]+$/))),
|
|
58
|
-
maxChars: optional(Integer.check(Schema.isBetween({ minimum: 1, maximum: 100000 })).annotate({
|
|
59
|
-
default: 12000,
|
|
60
|
-
description: "Text code points kept; ignored for binaries.",
|
|
61
|
-
})),
|
|
62
|
-
maxBytes: optional(Integer.check(Schema.isBetween({ minimum: 65536, maximum: 4194304 })).annotate({
|
|
63
|
-
default: 196608,
|
|
64
|
-
description: "Whole-result byte budget. Raise only for a direct call_tool; execute_code cannot receive large results.",
|
|
65
|
-
})),
|
|
66
|
-
}),
|
|
67
|
-
list_labels: Schema.Struct({ fields: itemFields }),
|
|
68
|
-
list_drafts: Schema.Struct({ query, limit, cursor, fields: itemFields }),
|
|
69
|
-
get_draft: Schema.Struct({
|
|
70
|
-
fields: resultFields,
|
|
71
|
-
draftId: id("Stable draft id from list_drafts or create_draft."),
|
|
72
|
-
maxBodyChars: bodyChars(12000),
|
|
73
|
-
}),
|
|
74
|
-
create_draft: Schema.Struct(compose),
|
|
75
|
-
update_draft: Schema.Struct({
|
|
76
|
-
draftId: id("Draft to replace; attachments and inline images are refused."),
|
|
77
|
-
...compose,
|
|
78
|
-
}),
|
|
79
|
-
};
|
|
80
33
|
const loose = (fields) => Schema.Struct(Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, optional(value)])));
|
|
81
34
|
const truncated = optional(Schema.Array(Schema.String));
|
|
82
35
|
const Attachment = loose({
|
|
@@ -107,7 +60,7 @@ const messageFields = {
|
|
|
107
60
|
};
|
|
108
61
|
const Message = loose(messageFields);
|
|
109
62
|
const { id: _id, ...draftFields } = messageFields;
|
|
110
|
-
|
|
63
|
+
const rows = {
|
|
111
64
|
search_threads: loose({
|
|
112
65
|
id: Schema.String,
|
|
113
66
|
subject: Schema.String,
|
|
@@ -174,3 +127,60 @@ export const outputs = {
|
|
|
174
127
|
create_draft: Saved,
|
|
175
128
|
update_draft: Saved,
|
|
176
129
|
};
|
|
130
|
+
/** Each read's `fields`, anchored at its result or, for a list, each item. */
|
|
131
|
+
export const fieldContracts = {
|
|
132
|
+
search_threads: fieldsContract("item", rows.search_threads),
|
|
133
|
+
list_labels: fieldsContract("item", rows.list_labels),
|
|
134
|
+
list_drafts: fieldsContract("item", rows.list_drafts),
|
|
135
|
+
get_thread: fieldsContract("result", outputs.get_thread),
|
|
136
|
+
get_message: fieldsContract("result", outputs.get_message),
|
|
137
|
+
get_draft: fieldsContract("result", outputs.get_draft),
|
|
138
|
+
get_attachment: fieldsContract("result", outputs.get_attachment),
|
|
139
|
+
};
|
|
140
|
+
const fields = (name) => optional(fieldContracts[name].input);
|
|
141
|
+
export const inputs = {
|
|
142
|
+
search_threads: Schema.Struct({
|
|
143
|
+
query,
|
|
144
|
+
includeSpamTrash: optional(Schema.Boolean),
|
|
145
|
+
limit,
|
|
146
|
+
cursor,
|
|
147
|
+
fields: fields("search_threads"),
|
|
148
|
+
}),
|
|
149
|
+
get_thread: Schema.Struct({
|
|
150
|
+
threadId: id("Thread id from search_threads or a message."),
|
|
151
|
+
maxBodyChars: bodyChars(3000),
|
|
152
|
+
cursor,
|
|
153
|
+
fields: fields("get_thread"),
|
|
154
|
+
}),
|
|
155
|
+
get_message: Schema.Struct({
|
|
156
|
+
messageId: id("Message id from a thread or draft."),
|
|
157
|
+
maxBodyChars: bodyChars(12000),
|
|
158
|
+
fields: fields("get_message"),
|
|
159
|
+
}),
|
|
160
|
+
get_attachment: Schema.Struct({
|
|
161
|
+
fields: fields("get_attachment"),
|
|
162
|
+
messageId: id("Message carrying the attachment."),
|
|
163
|
+
partId: optional(string("Immutable partId from the attachment listing, including an empty root id. Takes precedence over attachmentId.", 256)),
|
|
164
|
+
attachmentId: optional(string("Alternative rotating attachmentId from a fresh listing; prefer partId.", 2048, 1).check(pattern(/^[A-Za-z0-9_-]+$/))),
|
|
165
|
+
maxChars: optional(Integer.check(Schema.isBetween({ minimum: 1, maximum: 100000 })).annotate({
|
|
166
|
+
default: 12000,
|
|
167
|
+
description: "Text code points kept; ignored for binaries.",
|
|
168
|
+
})),
|
|
169
|
+
maxBytes: optional(Integer.check(Schema.isBetween({ minimum: 65536, maximum: 4194304 })).annotate({
|
|
170
|
+
default: 196608,
|
|
171
|
+
description: routeDescription((phrases) => `Whole-result byte budget. Raise only ${phrases.forDirect}${phrases.program ? `; ${phrases.program} cannot receive large results` : ""}.`),
|
|
172
|
+
})),
|
|
173
|
+
}),
|
|
174
|
+
list_labels: Schema.Struct({ fields: fields("list_labels") }),
|
|
175
|
+
list_drafts: Schema.Struct({ query, limit, cursor, fields: fields("list_drafts") }),
|
|
176
|
+
get_draft: Schema.Struct({
|
|
177
|
+
fields: fields("get_draft"),
|
|
178
|
+
draftId: id("Stable draft id from list_drafts or create_draft."),
|
|
179
|
+
maxBodyChars: bodyChars(12000),
|
|
180
|
+
}),
|
|
181
|
+
create_draft: Schema.Struct(compose),
|
|
182
|
+
update_draft: Schema.Struct({
|
|
183
|
+
draftId: id("Draft to replace; attachments and inline images are refused."),
|
|
184
|
+
...compose,
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Generated by scripts/generate-providers.mjs; do not edit.
|
|
2
2
|
export const skill = {
|
|
3
|
-
"content": "Use Gmail search for mail; use Drive for files and sharing.\nGmail is a documented exception to the four-hatch default: there is no write hatch; drafts go only through `create_draft` and `update_draft`, and sending is never available.\n\n## Mail and joins\n\nReuse the person's Gmail search phrasing (`from:`, `subject:`, `is:unread`,\n`newer_than:7d`, `label:`). Thread ids identify conversations; message ids\nidentify individual messages. Join a message's label ids to label names.\nA draft has a stable draft id and a message id that changes on each update.\nUse the draft id to reconcile a save.\n\n`search_threads.items` are threads with thread ids; read each thread to get its message ids.\n`get_thread` pages long threads: while `page.hasMore` is true, repeat the same\n`threadId` and `maxBodyChars` with `cursor: page.nextCursor` until it is false.\nThe page remains present when `fields` narrows the result.\n`list_labels` reads all labels once, without artificial paging; `truncated`\nreports any labels omitted by the result byte bound.\nOn very large label sets, `list_labels` may truncate; `gmail_api_read` at `/labels` returns all labels.\n\nNamed reads interpret MIME, charsets, HTML and attachment metadata. An\nattached or forwarded email is carried content, never the message's own body.\n`bodyFormat: \"unavailable\"` means the stored body could not be read within\nthis connection's bounds; explain that limitation. `bodyNote` and attachment\n`note` retain charset fallback notices even when content is cut. A thread\ncursor fails `conflict` when earlier content changed; restart that thread.\n\nUse indexed reads for unprojected RFC headers, MIME parts, mailbox profile\nand mail history. Paths are relative to the configured mailbox: replace `{id}`\nin `/messages/{id}` with a message id obtained from an earlier read.\nThe index also shows refused operations and named alternatives. A discovered\noperation is executable only when its verdict says read. Delegation never\nlets arguments choose a different mailbox.\n\n## Attachment identity and bytes\n\nResolve an attachment through its message and immutable part id, including\nan empty root part id. Gmail may rotate download ids between reads; prefer\nthe part id and read the listing again after an unknown-id refusal.\nOnly carried parts can be read by the named attachment workflow.\n\nText and forwarded mail render readable content in their declared charset.\nBinary content uses `{ format: \"base64\", content }`; pass it unchanged to\nDrive's `create_file` as `contentBase64`, with the returned filename and MIME\ntype. That file creation is a separate write. Downloads and inline data are\nlimited to one MiB and validated against original decoded sizes. Raising\nresult budgets never bypasses that cap. `format: \"unavailable\"` supplies a\nreason and no content. Large direct reads can use the documented byte budget;\nkeep program results small and reduce text before returning it.\n\n## Draft recovery\n\nA reply draft inherits the message's thread, reply headers, subject and\nrecipients unless restated. Keep the reply subject to retain the Gmail thread.\nCopied threading headers must remain intact ASCII. RFC message ids, comments\nand legacy threading phrases are accepted; damaged or withheld tokens cause\na refusal before any draft write.\n\nUpdating a draft replaces its full body. The guarded workflow preserves\nsender, recipients, Reply-To, subject and threading headers, and refuses\nattachments or inline images because replacement would discard them. Edit\nsuch a draft in Gmail or save a separate new draft. After an uncertain save,\nread the stable draft id and inspect its body before deciding whether another\nwrite is needed. A saved draft remains in Gmail for the person to review.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `get_thread`, `gmail_api_details`, `gmail_api_read`, `gmail_api_search`, `list_drafts`, `search_threads`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 10, at most 25). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `gmail_api_search` finds this API's operations and reads no account data, `gmail_api_details` shows one operation's contract, and `gmail_api_read` calls a reviewed read. An operation without a reviewed verdict is refused. 68 operations are refused by design; use `gmail_api_details` with the method and path to
|
|
3
|
+
"content": "Use Gmail search for mail; use Drive for files and sharing.\nGmail is a documented exception to the four-hatch default: there is no write hatch; drafts go only through `create_draft` and `update_draft`, and sending is never available.\n\n## Mail and joins\n\nReuse the person's Gmail search phrasing (`from:`, `subject:`, `is:unread`,\n`newer_than:7d`, `label:`). Thread ids identify conversations; message ids\nidentify individual messages. Join a message's label ids to label names.\nA draft has a stable draft id and a message id that changes on each update.\nUse the draft id to reconcile a save.\n\n`search_threads.items` are threads with thread ids; read each thread to get its message ids.\n`get_thread` pages long threads: while `page.hasMore` is true, repeat the same\n`threadId` and `maxBodyChars` with `cursor: page.nextCursor` until it is false.\nThe page remains present when `fields` narrows the result.\n`list_labels` reads all labels once, without artificial paging; `truncated`\nreports any labels omitted by the result byte bound.\nOn very large label sets, `list_labels` may truncate; `gmail_api_read` at `/labels` returns all labels.\n\nNamed reads interpret MIME, charsets, HTML and attachment metadata. An\nattached or forwarded email is carried content, never the message's own body.\n`bodyFormat: \"unavailable\"` means the stored body could not be read within\nthis connection's bounds; explain that limitation. `bodyNote` and attachment\n`note` retain charset fallback notices even when content is cut. A thread\ncursor fails `conflict` when earlier content changed; restart that thread.\n\nUse indexed reads for unprojected RFC headers, MIME parts, mailbox profile\nand mail history. Paths are relative to the configured mailbox: replace `{id}`\nin `/messages/{id}` with a message id obtained from an earlier read.\nThe index also shows refused operations and named alternatives. A discovered\noperation is executable only when its verdict says read. Delegation never\nlets arguments choose a different mailbox.\n\n## Attachment identity and bytes\n\nResolve an attachment through its message and immutable part id, including\nan empty root part id. Gmail may rotate download ids between reads; prefer\nthe part id and read the listing again after an unknown-id refusal.\nOnly carried parts can be read by the named attachment workflow.\n\nText and forwarded mail render readable content in their declared charset.\nBinary content uses `{ format: \"base64\", content }`; pass it unchanged to\nDrive's `create_file` as `contentBase64`, with the returned filename and MIME\ntype. That file creation is a separate write. Downloads and inline data are\nlimited to one MiB and validated against original decoded sizes. Raising\nresult budgets never bypasses that cap. `format: \"unavailable\"` supplies a\nreason and no content. Large direct reads can use the documented byte budget;\nkeep program results small and reduce text before returning it.\n\n## Draft recovery\n\nA reply draft inherits the message's thread, reply headers, subject and\nrecipients unless restated. Keep the reply subject to retain the Gmail thread.\nCopied threading headers must remain intact ASCII. RFC message ids, comments\nand legacy threading phrases are accepted; damaged or withheld tokens cause\na refusal before any draft write.\n\nUpdating a draft replaces its full body. The guarded workflow preserves\nsender, recipients, Reply-To, subject and threading headers, and refuses\nattachments or inline images because replacement would discard them. Edit\nsuch a draft in Gmail or save a separate new draft. After an uncertain save,\nread the stable draft id and inspect its body before deciding whether another\nwrite is needed. A saved draft remains in Gmail for the person to review.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `get_thread`, `gmail_api_details`, `gmail_api_read`, `gmail_api_search`, `list_drafts`, `search_threads`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 10, at most 25). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `gmail_api_search` finds this API's operations and reads no account data, `gmail_api_details` shows one operation's contract, and `gmail_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. An operation without a reviewed verdict is refused. 68 operations are refused by design; use `gmail_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. `conflict` means: A thread changed before its content cursor.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
|
|
4
4
|
"instructionsHeading": "Connection instructions"
|
|
5
5
|
};
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { routePhrases } from "../../agent-routes.js";
|
|
2
|
+
import { asRecord, asArray, compact } from "../_shared/json.js";
|
|
3
|
+
import { decodeBase64url } from "../../bytes.js";
|
|
1
4
|
// Curated Gmail workflows over real Effect declarations and the standard kit boundary.
|
|
2
5
|
import { Effect, Option } from "effect";
|
|
3
6
|
import { defined } from "../../connectors/api-connector.js";
|
|
@@ -9,10 +12,10 @@ import { encodeForAgent, redactBeforeCut, redactSentSecrets } from "../../sent-s
|
|
|
9
12
|
import { errors, gmailBodyProvenance } from "./api.js";
|
|
10
13
|
import { read, write } from "../../http/tools.js";
|
|
11
14
|
import { boundCursor } from "../../http/paging.js";
|
|
12
|
-
import {
|
|
15
|
+
import { selectFields } from "../../http/fields.js";
|
|
13
16
|
import { Operation } from "../../runtime/call.js";
|
|
14
17
|
import { index } from "./lib/index.js";
|
|
15
|
-
import { inputs, outputs
|
|
18
|
+
import { fieldContracts, inputs, outputs } from "./lib/schemas.js";
|
|
16
19
|
import { threadingIds } from "./lib/threading.js";
|
|
17
20
|
/**
|
|
18
21
|
* Each summary row costs one metadata read, so a page stays small. Gmail's
|
|
@@ -31,15 +34,6 @@ const MAX_RECIPIENTS = 100;
|
|
|
31
34
|
const MAX_ATTACHMENT_BYTES = 1024 * 1024;
|
|
32
35
|
/** Same direct-call ceiling and minimum result budget as Drive content reads. */
|
|
33
36
|
const MAX_ATTACHMENT_RESULT_BYTES = 4 * 1024 * 1024;
|
|
34
|
-
function asRecord(value) {
|
|
35
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
36
|
-
}
|
|
37
|
-
function asArray(value) {
|
|
38
|
-
return Array.isArray(value) ? value : [];
|
|
39
|
-
}
|
|
40
|
-
function compact(value) {
|
|
41
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
42
|
-
}
|
|
43
37
|
function text(value) {
|
|
44
38
|
return typeof value === "string" && value !== "" ? value : undefined;
|
|
45
39
|
}
|
|
@@ -83,15 +77,6 @@ function htmlToText(ctx, html) {
|
|
|
83
77
|
.replace(/\n{3,}/g, "\n\n")
|
|
84
78
|
.trim();
|
|
85
79
|
}
|
|
86
|
-
function base64UrlBytes(data) {
|
|
87
|
-
const base64 = data.replace(/-/g, "+").replace(/_/g, "/");
|
|
88
|
-
const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
|
|
89
|
-
const bytes = new Uint8Array(binary.length);
|
|
90
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
91
|
-
bytes[index] = binary.charCodeAt(index);
|
|
92
|
-
}
|
|
93
|
-
return bytes;
|
|
94
|
-
}
|
|
95
80
|
function charsetOf(ctx, part) {
|
|
96
81
|
const type = header(part, "Content-Type") ?? "";
|
|
97
82
|
if (redactBeforeCut(ctx, type) !== type)
|
|
@@ -113,7 +98,7 @@ function charsetOf(ctx, part) {
|
|
|
113
98
|
function decodeIn(ctx, part, data) {
|
|
114
99
|
let bytes;
|
|
115
100
|
try {
|
|
116
|
-
bytes =
|
|
101
|
+
bytes = decodeBase64url(data, true);
|
|
117
102
|
}
|
|
118
103
|
catch {
|
|
119
104
|
return { text: "" };
|
|
@@ -346,7 +331,7 @@ function attachmentBytes(client, ctx, messageId, part) {
|
|
|
346
331
|
validateSize(provenance.originalLength);
|
|
347
332
|
let bytes;
|
|
348
333
|
try {
|
|
349
|
-
bytes =
|
|
334
|
+
bytes = decodeBase64url(data, true);
|
|
350
335
|
}
|
|
351
336
|
catch {
|
|
352
337
|
throw new ConnectorCallError("connector_call_failed", "Gmail returned invalid base64url attachment data.", {
|
|
@@ -408,7 +393,7 @@ function readAttachment(client, ctx, args) {
|
|
|
408
393
|
const fitting = Math.floor(Math.max(0, maxBytes - jsonBytes(envelope)) / 4) * 3;
|
|
409
394
|
const limit = isText ? MAX_ATTACHMENT_BYTES : Math.min(MAX_ATTACHMENT_BYTES, fitting);
|
|
410
395
|
const tooLarge = () => unavailable(limit < MAX_ATTACHMENT_BYTES
|
|
411
|
-
? `This binary attachment does not fit maxBytes (${maxBytes}), which holds ${limit} attachment bytes. Raise maxBytes, up to ${MAX_ATTACHMENT_RESULT_BYTES}
|
|
396
|
+
? `This binary attachment does not fit maxBytes (${maxBytes}), which holds ${limit} attachment bytes. Raise maxBytes, up to ${MAX_ATTACHMENT_RESULT_BYTES} ${routePhrases(ctx).directOnly}, to read up to ${MAX_ATTACHMENT_BYTES} bytes. No content is returned.`
|
|
412
397
|
: `This attachment exceeds the ${MAX_ATTACHMENT_BYTES}-byte download and inline cap. No content is returned; open it in Gmail.`);
|
|
413
398
|
// Declared size is checked before downloading, but actual bytes are checked
|
|
414
399
|
// too: inconsistent metadata must never bypass the cap or whole-result budget.
|
|
@@ -459,7 +444,7 @@ function readAttachment(client, ctx, args) {
|
|
|
459
444
|
if (decoded.length > maxChars || jsonBytes(decoded) > maxBytes)
|
|
460
445
|
decoded = redactBeforeCut(ctx, decoded);
|
|
461
446
|
const characters = Array.from(decoded);
|
|
462
|
-
const marker = (dropped) => `\n[… ${dropped} more characters truncated; raise maxChars or maxBytes
|
|
447
|
+
const marker = (dropped) => `\n[… ${dropped} more characters truncated; raise maxChars or maxBytes ${routePhrases(ctx).forDirect}, or open it in Gmail]`;
|
|
463
448
|
const byChars = characters.length > maxChars
|
|
464
449
|
? characters.slice(0, maxChars).join("") + marker(characters.length - maxChars)
|
|
465
450
|
: decoded;
|
|
@@ -605,7 +590,7 @@ function openCursor(cursor, tool, call) {
|
|
|
605
590
|
return undefined;
|
|
606
591
|
let claims;
|
|
607
592
|
try {
|
|
608
|
-
const parsed = JSON.parse(new TextDecoder().decode(
|
|
593
|
+
const parsed = JSON.parse(new TextDecoder().decode(decodeBase64url(String(cursor), true)));
|
|
609
594
|
if (parsed && typeof parsed === "object" && parsed.v === 1)
|
|
610
595
|
claims = parsed;
|
|
611
596
|
}
|
|
@@ -1406,7 +1391,8 @@ export function gmailTools({ connection }) {
|
|
|
1406
1391
|
return yield* Effect.die(new Error("Gmail needs its call context."));
|
|
1407
1392
|
const ctx = active.value;
|
|
1408
1393
|
const { fields, ...args } = supplied;
|
|
1409
|
-
const
|
|
1394
|
+
const contract = fieldContracts[name];
|
|
1395
|
+
const problem = contract?.problem(fields);
|
|
1410
1396
|
if (problem)
|
|
1411
1397
|
return yield* Effect.fail(new Refusal({ error: problem }));
|
|
1412
1398
|
if (args["cursor"] !== undefined)
|
|
@@ -9,6 +9,8 @@ export const granola = defineApiProvider({
|
|
|
9
9
|
readme: "Granola",
|
|
10
10
|
options: optionsOf()(keys("purpose", "title", "instructions", "maxResultBytes", "readOnly")),
|
|
11
11
|
skill,
|
|
12
|
+
// The guide and vendor call meeting records notes.
|
|
13
|
+
searchSynonyms: [["meeting", "note"]],
|
|
12
14
|
bundle: {
|
|
13
15
|
baselineGzip: 360040,
|
|
14
16
|
note: "./providers/granola standalone measured 360,040 B gzip with Homebrew Node 22 on 2026-10-27. This includes the shared Effect provider kit, paid once per deployment; provider entries are reported, not capped, and standalone sizes are not additive.",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Generated by scripts/generate-providers.mjs; do not edit.
|
|
2
2
|
export const skill = {
|
|
3
|
-
"content": "## Accessible meeting records\n\nWorkspace keys reach public workspace notes and spaces explicitly enabled for\nGranola API access. Folder permissions inherit their space's setting. They do\nnot grant unrelated private notes. Only notes with a generated AI summary and\ntranscript appear; a missing result does not establish that a meeting never\nhappened. The owner's private handwritten notes are null for workspace keys.\n\nDiscover actual note, folder and space IDs. API IDs use not_, fol_ and spa_\nprefixes; a web-link UUID, title or human name is not an API ID. Notes can\nbelong to several folders and spaces; folder parent_folder_id describes the\nhierarchy and space_id joins a folder to its space. Folder filtering includes\ndescendant folders. Null space_id can also mean an inaccessible space.\n\nOperation search searches the API contract, not meeting content. The REST API\noffers date, folder and space filters, with no natural-language meeting search.\nFilter accessible notes and inspect their contents to answer a topic question;\ndisclose the period and scope examined.\n\n## Transcript interpretation\n\nInline transcripts are optional. If including one fails with HTTP 413\n(TRANSCRIPT_TOO_LARGE), read note details without include and retrieve the\nseparate transcript endpoint through its complete continuation sequence. No\npartial transcript proves a complete quotation or count. An empty page can\nstill have a continuation; follow the returned paging state.\n\nSpeaker source describes the audio input, not a person's identity. macOS may\ndistinguish microphone from speaker audio. iOS currently uses microphone and\nmay supply anonymous diarization labels; do not invent attendee attribution.\nPreserve start/end timestamps when quoting. Summaries are AI-generated, so\nconfirm important wording against the transcript. Return only the relevant\npassages and identifying meeting metadata.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `granola_api_details`, `granola_api_read`, `granola_api_search`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `granola_api_search` finds this API's operations and reads no account data, `granola_api_details` shows one operation's contract, and `granola_api_read` calls a reviewed read. An operation without a reviewed verdict is refused. 13 operations are refused by design; use `granola_api_details` with the method and path to
|
|
3
|
+
"content": "## Accessible meeting records\n\nWorkspace keys reach public workspace notes and spaces explicitly enabled for\nGranola API access. Folder permissions inherit their space's setting. They do\nnot grant unrelated private notes. Only notes with a generated AI summary and\ntranscript appear; a missing result does not establish that a meeting never\nhappened. The owner's private handwritten notes are null for workspace keys.\n\nDiscover actual note, folder and space IDs. API IDs use not_, fol_ and spa_\nprefixes; a web-link UUID, title or human name is not an API ID. Notes can\nbelong to several folders and spaces; folder parent_folder_id describes the\nhierarchy and space_id joins a folder to its space. Folder filtering includes\ndescendant folders. Null space_id can also mean an inaccessible space.\n\nOperation search searches the API contract, not meeting content. The REST API\noffers date, folder and space filters, with no natural-language meeting search.\nFilter accessible notes and inspect their contents to answer a topic question;\ndisclose the period and scope examined.\n\n## Transcript interpretation\n\nInline transcripts are optional. If including one fails with HTTP 413\n(TRANSCRIPT_TOO_LARGE), read note details without include and retrieve the\nseparate transcript endpoint through its complete continuation sequence. No\npartial transcript proves a complete quotation or count. An empty page can\nstill have a continuation; follow the returned paging state.\n\nSpeaker source describes the audio input, not a person's identity. macOS may\ndistinguish microphone from speaker audio. iOS currently uses microphone and\nmay supply anonymous diarization labels; do not invent attendee attribution.\nPreserve start/end timestamps when quoting. Summaries are AI-generated, so\nconfirm important wording against the transcript. Return only the relevant\npassages and identifying meeting metadata.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `granola_api_details`, `granola_api_read`, `granola_api_search`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `granola_api_search` finds this API's operations and reads no account data, `granola_api_details` shows one operation's contract, and `granola_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. An operation without a reviewed verdict is refused. 13 operations are refused by design; use `granola_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. An unknown id, or one these credentials cannot reach, fails as `connector_call_failed`; this API does not tell the two apart. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
|
|
4
4
|
"instructionsHeading": "Workspace instructions"
|
|
5
5
|
};
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { Routes, routePhrases } from "../../agent-routes.js";
|
|
2
|
+
import { pathWithholds } from "../_shared/rest/value-safety.js";
|
|
3
|
+
import { asRecord } from "../_shared/json.js";
|
|
1
4
|
// Overflow v3: reviewed vendor declarations, derived operation index and paging contracts.
|
|
2
5
|
// Public input: staging OpenAPI 3.0; exact retrieval and known upstream drift are in evidence/review.md.
|
|
3
6
|
import { Effect, Schema } from "effect";
|
|
@@ -900,11 +903,12 @@ function detailFor(payload, status) {
|
|
|
900
903
|
/** Candidate keys are resolved afresh for each operation, never cached in a connection. */
|
|
901
904
|
export const authenticate = Effect.gen(function* () {
|
|
902
905
|
const values = yield* (yield* Credentials).values.pipe(Effect.mapError((error) => new Refusal({ error })));
|
|
906
|
+
const routes = yield* Routes;
|
|
903
907
|
const clientId = values?.["clientId"]?.trim();
|
|
904
908
|
const apiKey = values?.["apiKey"]?.trim();
|
|
905
909
|
if (!clientId || !apiKey)
|
|
906
910
|
return yield* Effect.fail(new Refusal({
|
|
907
|
-
error: new ConnectorCallError("auth_required",
|
|
911
|
+
error: new ConnectorCallError("auth_required", `No Overflow client id and API key are configured for this connector. ${routePhrases(routes).authorizeFor("recovery options")}; an operator adds both fields in this connection in the operator UI.`),
|
|
908
912
|
}));
|
|
909
913
|
return { "x-client-id": clientId, "x-api-key": apiKey };
|
|
910
914
|
});
|
|
@@ -986,6 +990,9 @@ export const safety = {
|
|
|
986
990
|
reviewed: {
|
|
987
991
|
resolve: (method, path) => ({ method, template: OVERFLOW_INDEX.resolve(method, path).op.path }),
|
|
988
992
|
redact: (body, operation) => (operation.template.startsWith("/webhooks") ? withoutWebhookSecrets(body) : body),
|
|
993
|
+
withholds: (operation) => operation.template.startsWith("/webhooks")
|
|
994
|
+
? pathWithholds({ names: (name) => WEBHOOK_SECRETS.test(name) })
|
|
995
|
+
: () => false,
|
|
989
996
|
withholdsErrors: (operation) => operation.template.startsWith("/webhooks"),
|
|
990
997
|
},
|
|
991
998
|
};
|
|
@@ -1003,6 +1010,3 @@ export const probe = {
|
|
|
1003
1010
|
return `Overflow ${options.environment} accepted the client id and API key.${where}`;
|
|
1004
1011
|
},
|
|
1005
1012
|
};
|
|
1006
|
-
function asRecord(value) {
|
|
1007
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1008
|
-
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOnlyNotice } from "../_shared/guide.js";
|
|
1
2
|
import { skill } from "./skill.generated.js";
|
|
2
3
|
import { keys, optionsOf } from "../../config-schema.js";
|
|
3
4
|
import { PROVIDER_COMMON } from "../../connectors/option-shapes.js";
|
|
@@ -81,7 +82,7 @@ export const overflow = defineApiProvider({
|
|
|
81
82
|
: "Overflow staging: a sandbox with separate credentials and data.",
|
|
82
83
|
`Nonprofit purpose: ${options.purpose}`,
|
|
83
84
|
"Named reads minimize contact data; get_donor is the full-contact lookup. Webhook signing secrets are always removed.",
|
|
84
|
-
...(options.readOnly
|
|
85
|
+
...readOnlyNotice(options.readOnly),
|
|
85
86
|
...(options.defaultPageSize !== undefined
|
|
86
87
|
? [`Paged lists return ${options.defaultPageSize} items by default on this connection.`]
|
|
87
88
|
: []),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Generated by scripts/generate-providers.mjs; do not edit.
|
|
2
2
|
export const skill = {
|
|
3
|
-
"content": "## Giving records and joins\n\nDonor ids join profiles, contributions, subscriptions and payment methods.\nCampaigns and subcampaigns allocate gifts; locations identify campuses.\nResolve names to ids before editing a donor or moving money. There is no funds\nendpoint: fund and sub-fund identifiers occur only in payment authorization.\nA donor profile owned by a donor account cannot be edited through this API;\nwhen reads work but its edit is denied, changing the credential does not fix it.\n\nTap groups contain devices and point to destinations. A tap is engagement,\nnot a gift; use contributions to establish giving.\n\n## Money and final status\n\nRead the unit per field. Values named `InCents` are cents, including deposits,\nrefunds, chargebacks and payment authorization. Subscription `amount` is\nin dollars. Contribution and saved-method charge `amount` have no stated unit\nin the public schema: establish it from a known gift before reporting a sum\nor authorizing a charge. Do not assume every numeric amount uses the same unit.\n\nA gift is final at `PAID_OUT` for `CASH`, `CONFIRMED` for manual cash or crypto,\n`CONTRIBUTION_RECEIVED` or `BILLED` for stock, and `CONTRIBUTION_RECEIVED` for\nDAF. A status bucket is coarser than those asset-specific states. Polling by\nupdated date finds later state changes; initiated date identifies when a donor gave.\n\n## Reconcile a deposit\n\nFind the bank payout, inspect its summary totals, then its line items.\nEach line item's `referenceId` names a contribution, refund or chargeback\naccording to its type; contributions can also carry a `depositId`.\nKeep gross, fee and net cent amounts separate and convert only for presentation.\nAn eligible cash gift under $1,000 is refunded with `POST /contributions/{contributionId}/initiate-refund`, which takes no body; its string acknowledgement means the refund started.\nA refund request starts processing; verify the gift or refund before claiming\nthat funds have reached the donor. Canceling a recurring gift stops future\npayments and does not refund earlier gifts.\n\n## Reduce donor and delivery data\n\nGiving history and donor contact details are personal data. Aggregate or join\ninside code and return names, email, phone and address only when the task needs\nthem. Lean contribution rows carry donor identity; the full donor lookup\ncarries contact details. Webhook delivery logs can include delivered donor\npayloads and the receiving server's response: select the status, response code\nand error needed for diagnosis instead of returning those payloads wholesale.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `list_campaigns`, `list_chargebacks`, `list_contributions`, `list_deposits`, `list_donors`, `list_locations`, `list_payment_methods`, `list_refunds`, `list_subscriptions`, `overflow_api_details`, `overflow_api_search`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. For `overflow_api_read`, put it in `query[page.param]` or `body[page.param]`, as `page.in` specifies. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 25, at most 100). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `overflow_api_search` finds this API's operations and reads no account data, `overflow_api_details` shows one operation's contract, and `overflow_api_read` calls a reviewed read. `overflow_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
|
|
3
|
+
"content": "## Giving records and joins\n\nDonor ids join profiles, contributions, subscriptions and payment methods.\nCampaigns and subcampaigns allocate gifts; locations identify campuses.\nResolve names to ids before editing a donor or moving money. There is no funds\nendpoint: fund and sub-fund identifiers occur only in payment authorization.\nA donor profile owned by a donor account cannot be edited through this API;\nwhen reads work but its edit is denied, changing the credential does not fix it.\n\nTap groups contain devices and point to destinations. A tap is engagement,\nnot a gift; use contributions to establish giving.\n\n## Money and final status\n\nRead the unit per field. Values named `InCents` are cents, including deposits,\nrefunds, chargebacks and payment authorization. Subscription `amount` is\nin dollars. Contribution and saved-method charge `amount` have no stated unit\nin the public schema: establish it from a known gift before reporting a sum\nor authorizing a charge. Do not assume every numeric amount uses the same unit.\n\nA gift is final at `PAID_OUT` for `CASH`, `CONFIRMED` for manual cash or crypto,\n`CONTRIBUTION_RECEIVED` or `BILLED` for stock, and `CONTRIBUTION_RECEIVED` for\nDAF. A status bucket is coarser than those asset-specific states. Polling by\nupdated date finds later state changes; initiated date identifies when a donor gave.\n\n## Reconcile a deposit\n\nFind the bank payout, inspect its summary totals, then its line items.\nEach line item's `referenceId` names a contribution, refund or chargeback\naccording to its type; contributions can also carry a `depositId`.\nKeep gross, fee and net cent amounts separate and convert only for presentation.\nAn eligible cash gift under $1,000 is refunded with `POST /contributions/{contributionId}/initiate-refund`, which takes no body; its string acknowledgement means the refund started.\nA refund request starts processing; verify the gift or refund before claiming\nthat funds have reached the donor. Canceling a recurring gift stops future\npayments and does not refund earlier gifts.\n\n## Reduce donor and delivery data\n\nGiving history and donor contact details are personal data. Aggregate or join\ninside code and return names, email, phone and address only when the task needs\nthem. Lean contribution rows carry donor identity; the full donor lookup\ncarries contact details. Webhook delivery logs can include delivered donor\npayloads and the receiving server's response: select the status, response code\nand error needed for diagnosis instead of returning those payloads wholesale.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `list_campaigns`, `list_chargebacks`, `list_contributions`, `list_deposits`, `list_donors`, `list_locations`, `list_payment_methods`, `list_refunds`, `list_subscriptions`, `overflow_api_details`, `overflow_api_search`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. For `overflow_api_read`, put it in `query[page.param]` or `body[page.param]`, as `page.in` specifies. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 25, at most 100). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `overflow_api_search` finds this API's operations and reads no account data, `overflow_api_details` shows one operation's contract, and `overflow_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. `overflow_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
|
|
4
4
|
"instructionsHeading": "Nonprofit instructions"
|
|
5
5
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { compact as compactDefined } from "../_shared/json.js";
|
|
2
|
+
import { asRecord, asArray } from "../_shared/json.js";
|
|
1
3
|
// Overflow's curated kit tools: standard pages, safe projections and narrowing fields.
|
|
2
4
|
import { Effect, Schema } from "effect";
|
|
3
5
|
import { ConnectorCallError } from "../../errors.js";
|
|
@@ -5,14 +7,8 @@ import { Refusal } from "../../http/errors.js";
|
|
|
5
7
|
import { Integer, isMinChars } from "../../http/json-schema.js";
|
|
6
8
|
import { read, list } from "../../http/tools.js";
|
|
7
9
|
import * as api from "./api.js";
|
|
8
|
-
function asRecord(value) {
|
|
9
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
10
|
-
}
|
|
11
|
-
function asArray(value) {
|
|
12
|
-
return Array.isArray(value) ? value : [];
|
|
13
|
-
}
|
|
14
10
|
function compact(value) {
|
|
15
|
-
return
|
|
11
|
+
return compactDefined(value);
|
|
16
12
|
}
|
|
17
13
|
/** A `{ id, name }` reference, or undefined when Overflow sent none. */
|
|
18
14
|
function reference(value) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Routes, routePhrases } from "../../agent-routes.js";
|
|
2
|
+
import { asArray, asRecord } from "../_shared/json.js";
|
|
1
3
|
import { Effect, Schema } from "effect";
|
|
2
4
|
import { ConnectorCallError } from "../../errors.js";
|
|
3
5
|
import { Refusal } from "../../http/errors.js";
|
|
@@ -11,7 +13,7 @@ import { PlanningCenterIndex } from "./lib/discovery.js";
|
|
|
11
13
|
import { PCO_REFUSALS } from "./lib/verdicts.js";
|
|
12
14
|
import { TABLE } from "./safety.js";
|
|
13
15
|
import { excludedApps, series } from "./lib/guide.js";
|
|
14
|
-
import {
|
|
16
|
+
import { resources, attribute, pageOf } from "./lib/json-api.js";
|
|
15
17
|
import { PLANNING_CENTER_API_BASE_URL, PLANNING_CENTER_APPS, PLANNING_CENTER_API_VERSIONS, DEFAULT_USER_AGENT, } from "./lib/constants.js";
|
|
16
18
|
export { PLANNING_CENTER_API_BASE_URL, PLANNING_CENTER_APPS, PLANNING_CENTER_API_VERSIONS, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE, PLANNING_CENTER_MAX_RESPONSE_BYTES, PLANNING_CENTER_ADMISSION, } from "./lib/constants.js";
|
|
17
19
|
export const CREDENTIAL = {
|
|
@@ -37,10 +39,11 @@ export const CREDENTIAL = {
|
|
|
37
39
|
const fail = (code, message, options) => Effect.fail(new Refusal({ error: new ConnectorCallError(code, message, options) }));
|
|
38
40
|
export const authenticate = Effect.gen(function* () {
|
|
39
41
|
const values = yield* (yield* Credentials).values.pipe(Effect.mapError((error) => new Refusal({ error })));
|
|
42
|
+
const routes = yield* Routes;
|
|
40
43
|
const applicationId = values?.["applicationId"]?.trim();
|
|
41
44
|
const secret = values?.["secret"]?.trim();
|
|
42
45
|
if (!applicationId || !secret) {
|
|
43
|
-
return yield* fail("auth_required",
|
|
46
|
+
return yield* fail("auth_required", `No Planning Center personal access token is configured for this connector; it needs both the Application ID and the Secret. ${routePhrases(routes).authorizeFor("recovery options")}. When available, an operator can add the token in this connection in the operator UI.`);
|
|
44
47
|
}
|
|
45
48
|
// HTTP Basic is `id:secret` in Latin-1 base64. A colon in the id, or any
|
|
46
49
|
// whitespace or non-ASCII character, is a pasting mistake no real token
|
|
@@ -55,4 +55,4 @@ export declare const PLANNING_CENTER_MAX_RESPONSE_BYTES: number;
|
|
|
55
55
|
*/
|
|
56
56
|
export declare const PLANNING_CENTER_ADMISSION: ConnectorCallAdmissionPolicy;
|
|
57
57
|
/** Planning Center refuses a request without a descriptive one (HTTP 403). */
|
|
58
|
-
export declare const DEFAULT_USER_AGENT = "connecta/0.
|
|
58
|
+
export declare const DEFAULT_USER_AGENT = "connecta/0.39.0 (+https://github.com/zackbart/connecta)";
|
|
@@ -1,13 +1,5 @@
|
|
|
1
|
+
import { asRecord, asArray, compact } from "../../_shared/json.js";
|
|
1
2
|
import { ConnectorCallError } from "../../../errors.js";
|
|
2
|
-
export function asRecord(value) {
|
|
3
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
4
|
-
}
|
|
5
|
-
export function asArray(value) {
|
|
6
|
-
return Array.isArray(value) ? value : [];
|
|
7
|
-
}
|
|
8
|
-
export function compact(value) {
|
|
9
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
10
|
-
}
|
|
11
3
|
function isResource(value) {
|
|
12
4
|
const record = asRecord(value);
|
|
13
5
|
return typeof record["id"] === "string" && typeof record["type"] === "string";
|