@zackbart/connecta 0.38.0 → 0.40.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 +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- 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 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- 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 +7 -4
- 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 +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -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 +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- 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/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 +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- 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/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- 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 +2 -4
- 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 +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- 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 +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -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 +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- 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/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
package/dist/skills.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { META_ROUTES, routeNames } from "./agent-routes.js";
|
|
1
2
|
import { bytesToHex, utf8Length } from "./bytes.js";
|
|
2
3
|
import { duringViewEffect } from "./runtime/call.js";
|
|
3
4
|
import { RequestScope, requestOwner } from "./runtime/request-scope.js";
|
|
@@ -9,15 +10,17 @@ import { Effect } from "effect";
|
|
|
9
10
|
import { runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
10
11
|
import { closeScopeOnExit } from "./runtime/connector-scope.js";
|
|
11
12
|
import { sentSecretsForRequest } from "./sent-secrets.js";
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// The meta instructions and built-in guides use the meta names: built-in guides
|
|
14
|
+
// are byte-identical across deployments, and the eval code arm replaces usage.
|
|
15
|
+
const ROUTE = `Choose a route before discovery. One known-address read uses ${META_ROUTES.read}; one known-address write uses ${META_ROUTES.write}. Unknown-address read-only work starts with ${META_ROUTES.program} to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Sample unfamiliar reads.`;
|
|
16
|
+
const RECOVERY = `When an auth error includes a handoff, give it to the user or operator. Use ${META_ROUTES.authorize} for a fresh link. Page resultId notices and format:"paged" handles with connecta.result(handleOrId, { page? }). Missing guidance/repair only: skills tool "usage" or connecta.skill("usage").`;
|
|
14
17
|
/**
|
|
15
18
|
* The always-loaded MCP `instructions` string. An endpoint is writable or
|
|
16
19
|
* read-only: a writable one lets programs and call_destructive_tool write,
|
|
17
20
|
* and the client owns approval; a read-only one omits write tools from search
|
|
18
21
|
* and describe and refuses every write call. Guides may still name them.
|
|
19
22
|
*/
|
|
20
|
-
export const CONNECTA_INSTRUCTIONS = `${ROUTE} On a writable endpoint, programs and
|
|
23
|
+
export const CONNECTA_INSTRUCTIONS = `${ROUTE} On a writable endpoint, programs and ${META_ROUTES.write} may write. On a read-only endpoint, search omits write tools and every write call is refused; guides may still name them. The ${META_ROUTES.program} description says which this endpoint is. Never repeat a write to recover its output. ${RECOVERY}`;
|
|
21
24
|
/**
|
|
22
25
|
* The instructions one endpoint serves: {@link CONNECTA_INSTRUCTIONS}, then
|
|
23
26
|
* the admitted pool's `description` after a fixed lead-in. Plain `/mcp` and a
|
|
@@ -31,7 +34,8 @@ export function endpointInstructions(description, native) {
|
|
|
31
34
|
}
|
|
32
35
|
/** Instructions for pools whose host searches the listed native tools. */
|
|
33
36
|
export function nativeInstructions(executeCode) {
|
|
34
|
-
|
|
37
|
+
const routes = routeNames(executeCode ? "native-code" : "native");
|
|
38
|
+
return `Each tool here is one operation; annotations classify reads/writes. Call known tools directly; ${executeCode ? `combine work in ${routes.program} using the program addresses in tool descriptions.` : "find tools with your host's search."} Read-only endpoints omit and refuse writes. Never repeat a write for output. Give auth handoffs to the user/operator. Tools missing? Call authorize_connector; after repair, reconnect to refresh your list. Follow nextAction to page with ${executeCode ? `connecta.result in ${routes.program}` : "read_result"}. ${routes.skills} lists guidance.`;
|
|
35
39
|
}
|
|
36
40
|
/** True when at least one of `connectors` carries a usage guide. */
|
|
37
41
|
export function hasConnectorGuides(connectors) {
|
|
@@ -46,36 +50,37 @@ export function hasConnectorGuides(connectors) {
|
|
|
46
50
|
function usageSkill() {
|
|
47
51
|
return USAGE_SKILL;
|
|
48
52
|
}
|
|
53
|
+
const INVESTIGATE_DESCRIPTION = "Plan an investigation that spans several services; resolve scope, identities, and capability limits before querying, then return the answer with its evidence.";
|
|
49
54
|
const INVESTIGATE_SKILL = `---
|
|
50
55
|
name: investigate
|
|
51
|
-
description:
|
|
56
|
+
description: ${INVESTIGATE_DESCRIPTION}
|
|
52
57
|
---
|
|
53
58
|
|
|
54
59
|
# Investigate across services
|
|
55
60
|
|
|
56
61
|
## Plan the investigation
|
|
57
62
|
|
|
58
|
-
Start from the user's question and the evidence that would answer it. Select the
|
|
63
|
+
Start from the user's question and the evidence that would answer it. Select the account, environment, and time window from connector titles, purposes, and relevant guides before looking up records. Reuse verified ids within the task; never carry an id across connectors just because its name matches.
|
|
59
64
|
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
65
|
+
- Resolve the same subject in each service separately. A record in one service does not prove the matching state in another, and a missing record in one does not prove failure elsewhere.
|
|
66
|
+
- Confirm the scope a comparison needs (which records, which window, which population) before comparing. Return the requested comparison and any missing evidence; do not expand into an unrelated audit.
|
|
67
|
+
- Locate the exact record first, then follow only the records needed to explain the reported symptom. Use provider links or ids so the answer can be checked.
|
|
63
68
|
|
|
64
69
|
Establish capability limits early. A partial search is not proof that a tool is absent, and an unavailable catalog is not an empty dataset. Try a scoped search for the missing operation, inspect its guide when relevant, and distinguish unsupported work from missing data or authorization. If the required evidence is unavailable, return what was verified and the specific gap instead of approximating a different question through repeated calls.
|
|
65
70
|
|
|
66
|
-
A host transport error that requires reconnecting this MCP server cannot be repaired by a downstream tool. Reconnect in the host; do not repeatedly call \`
|
|
71
|
+
A host transport error that requires reconnecting this MCP server cannot be repaired by a downstream tool. Reconnect in the host; do not repeatedly call \`${META_ROUTES.authorize}\` through the failed connection.
|
|
67
72
|
|
|
68
|
-
Return the answer first, then the evidence and any unresolved gap. Include the
|
|
73
|
+
Return the answer first, then the evidence and any unresolved gap. Include the account or environment, time window, and source ids or links needed to check it. Separate observed facts from inferences. Stop when the requested evidence is sufficient.
|
|
69
74
|
`;
|
|
70
75
|
const AVAILABLE_SKILLS = [
|
|
71
76
|
{
|
|
72
77
|
name: "usage",
|
|
73
|
-
description:
|
|
78
|
+
description: `How to route work between one ${META_ROUTES.program} program and Connecta's explicit call, authorization, and result tools.`,
|
|
74
79
|
content: usageSkill,
|
|
75
80
|
},
|
|
76
81
|
{
|
|
77
82
|
name: "investigate",
|
|
78
|
-
description:
|
|
83
|
+
description: INVESTIGATE_DESCRIPTION,
|
|
79
84
|
content: () => INVESTIGATE_SKILL,
|
|
80
85
|
},
|
|
81
86
|
];
|
|
@@ -84,7 +89,8 @@ const AVAILABLE_SKILLS = [
|
|
|
84
89
|
* are bare identifiers and never contain ":", so `connector:<id>` cannot
|
|
85
90
|
* collide with one — not even when a connector's id is literally "usage".
|
|
86
91
|
* Canonical skill URIs and the prefixed compatibility alias reach connector
|
|
87
|
-
* guides. A bare connector id
|
|
92
|
+
* guides. A bare connector id reaches its guide too, unless it is a built-in
|
|
93
|
+
* skill name: built-ins always win, so nothing shadows them.
|
|
88
94
|
*/
|
|
89
95
|
const CONNECTOR_SKILL_PREFIX = "connector:";
|
|
90
96
|
/** The skill name that fetches `connector`'s guide. */
|
|
@@ -369,6 +375,13 @@ const localRecordsNative = /* @__PURE__ */ registerAuth(localRecords, function (
|
|
|
369
375
|
return [builtIns[0], ...guides, ...builtIns.slice(1)];
|
|
370
376
|
});
|
|
371
377
|
});
|
|
378
|
+
/** `granola` names connector granola's guide; built-in names and URIs never do. */
|
|
379
|
+
function bareConnectorGuide(records, name) {
|
|
380
|
+
if (name.includes(":") || AVAILABLE_SKILLS.some((skill) => skill.name === name))
|
|
381
|
+
return undefined;
|
|
382
|
+
const alias = connectorSkillName(name);
|
|
383
|
+
return records.find((record) => record.connector !== undefined && record.aliases.includes(alias));
|
|
384
|
+
}
|
|
372
385
|
/** Keep the downstream path tree, so relative supporting-file references still resolve. */
|
|
373
386
|
export function downstreamSkillUri(connectorId, uri) {
|
|
374
387
|
if (uri.length > 32_768)
|
|
@@ -525,12 +538,14 @@ export class SkillsRegistry {
|
|
|
525
538
|
const connector = this.registry.getConnector(id);
|
|
526
539
|
if (connector)
|
|
527
540
|
this.options.onRead?.(connector, "local");
|
|
528
|
-
|
|
529
|
-
|
|
541
|
+
// Skill names resolve a bare id with a guide before reaching here; a
|
|
542
|
+
// resource URI read does not.
|
|
543
|
+
const message = connector
|
|
544
|
+
? connectorGuide(connector) === undefined
|
|
530
545
|
? `Connector "${boundedEchoText(id)}" has no usage guide.`
|
|
531
|
-
: `
|
|
532
|
-
:
|
|
533
|
-
? `Unknown
|
|
546
|
+
: `Connector "${boundedEchoText(id)}" has its guide at "${boundedEchoText(connectorSkillName(id))}".`
|
|
547
|
+
: name.startsWith(CONNECTOR_SKILL_PREFIX)
|
|
548
|
+
? `Unknown connector "${boundedEchoText(id)}".`
|
|
534
549
|
: `Unknown skill "${boundedEchoText(name)}".`;
|
|
535
550
|
return new ConnectorCallError("not_found", `${message} Available skills: ${available}.`);
|
|
536
551
|
}
|
|
@@ -675,7 +690,9 @@ export class SkillsRegistry {
|
|
|
675
690
|
registerAuthMethod(SkillsRegistry.prototype, "lookup", function (uri) {
|
|
676
691
|
const self = this;
|
|
677
692
|
return duringViewEffect({ requestScope: this.scope, signal: this.options.requestSignal }, () => authFlow(function* () {
|
|
678
|
-
const
|
|
693
|
+
const records = yield* upwardStep(self.local);
|
|
694
|
+
const local = records.find((record) => record.entry.uri === uri || record.aliases.includes(uri)) ??
|
|
695
|
+
bareConnectorGuide(records, uri);
|
|
679
696
|
if (local) {
|
|
680
697
|
const record = yield* upwardStep(authCall(withManifest, [local]));
|
|
681
698
|
self.assertSafe([record]);
|
package/dist/storage/keys.js
CHANGED
|
@@ -153,7 +153,33 @@ export const catalogKeys = {
|
|
|
153
153
|
manifest: (connectorId) => validateStorageKey(`catalog:${connectorId}`),
|
|
154
154
|
chunk: (connectorId, revision, index) => validateStorageKey(`catalog:${connectorId}:chunk:${revision}:${index}`),
|
|
155
155
|
};
|
|
156
|
-
/**
|
|
156
|
+
/** How long a connector-health record outlives its retry time, fencing the next probe's claim. */
|
|
157
|
+
export const CONNECTOR_HEALTH_LINGER_MS = 60_000;
|
|
158
|
+
/**
|
|
159
|
+
* Bounded connector health by connector id and a digest of the owner
|
|
160
|
+
* partition and described configuration. `discovery` holds the latest failed
|
|
161
|
+
* discovery's typed code, observation time, retry time and the catalog
|
|
162
|
+
* generation it saw; `status` the operator page's latest status observation
|
|
163
|
+
* (state, problem code, tool count, observation time, generation). Values
|
|
164
|
+
* never carry downstream text, tools or credentials.
|
|
165
|
+
*/
|
|
166
|
+
export const connectorHealthKeys = {
|
|
167
|
+
family: {
|
|
168
|
+
name: "connector-health",
|
|
169
|
+
scope: "root",
|
|
170
|
+
prefixes: ["connector-health:v1:"],
|
|
171
|
+
version: { number: 1, in: "key" },
|
|
172
|
+
codec: jsonCodec,
|
|
173
|
+
ttl: { kind: "configured", by: "discovery: retry time plus 60 seconds; status: 120 seconds" },
|
|
174
|
+
durable: false,
|
|
175
|
+
},
|
|
176
|
+
discovery: (connectorId, partitionDigest) => validateStorageKey(`connector-health:v1:${connectorId}:${partitionDigest}:discovery`),
|
|
177
|
+
status: (connectorId, partitionDigest) => validateStorageKey(`connector-health:v1:${connectorId}:${partitionDigest}:status`),
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* SDK catalog response cache, complete manifests published after their chunks.
|
|
181
|
+
* Secret-bearing inputs reach these keys only through `secretDigest`.
|
|
182
|
+
*/
|
|
157
183
|
export const responseCacheKeys = {
|
|
158
184
|
family: {
|
|
159
185
|
name: "response-cache",
|
|
@@ -339,8 +365,31 @@ export const oauthConnectKeys = {
|
|
|
339
365
|
used: (nonce) => validateStorageKey(`oauth:connect-used:${nonce}`),
|
|
340
366
|
retry: (nonce) => validateStorageKey(`oauth:request-used:${nonce}`),
|
|
341
367
|
};
|
|
342
|
-
/**
|
|
368
|
+
/**
|
|
369
|
+
* The last verification of a connector's operator-managed credential:
|
|
370
|
+
* `verification:v1`, holding an outcome class, its time and the vault
|
|
371
|
+
* revision of the save it observed. Payload-free and long-lived; a lost
|
|
372
|
+
* record only makes the operator page say "not verified".
|
|
373
|
+
*/
|
|
374
|
+
export const verificationKeys = {
|
|
375
|
+
family: {
|
|
376
|
+
name: "verification",
|
|
377
|
+
scope: "connector",
|
|
378
|
+
prefixes: ["verification:v1"],
|
|
379
|
+
version: { number: 1, in: "key" },
|
|
380
|
+
codec: jsonCodec,
|
|
381
|
+
ttl: { kind: "durable" },
|
|
382
|
+
durable: false,
|
|
383
|
+
},
|
|
384
|
+
record: "verification:v1",
|
|
385
|
+
};
|
|
386
|
+
/** A bounded, credential-partitioned downstream protocol verdict, keyed through `secretDigest`. */
|
|
343
387
|
export const NEGOTIATION_TTL_SECONDS = 300;
|
|
388
|
+
/**
|
|
389
|
+
* A legacy verdict lives longer: a server that upgrades still answers the
|
|
390
|
+
* legacy handshake, and one that stops answering it is renegotiated at once.
|
|
391
|
+
*/
|
|
392
|
+
export const LEGACY_NEGOTIATION_TTL_SECONDS = 6 * 60 * 60;
|
|
344
393
|
export const negotiationKeys = {
|
|
345
394
|
family: {
|
|
346
395
|
name: "negotiation",
|
|
@@ -348,7 +397,8 @@ export const negotiationKeys = {
|
|
|
348
397
|
prefixes: ["negotiation:v1:"],
|
|
349
398
|
version: { number: 1, in: "key" },
|
|
350
399
|
codec: jsonCodec,
|
|
351
|
-
|
|
400
|
+
// The longest verdict lifetime; a modern verdict expires sooner.
|
|
401
|
+
ttl: { kind: "fixed", seconds: LEGACY_NEGOTIATION_TTL_SECONDS },
|
|
352
402
|
durable: false,
|
|
353
403
|
},
|
|
354
404
|
verdict: (partitionDigest) => validateStorageKey(`negotiation:v1:${partitionDigest}`),
|
package/dist/storage/memory.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateStorageKey } from "./keys.js";
|
|
2
|
+
import { noteStorageWrite, observeStorageWrites } from "../runtime/request-reads.js";
|
|
2
3
|
/** In-memory KV store with expiry. The default for dev and Node. */
|
|
3
4
|
export function memoryStorage() {
|
|
4
5
|
const map = new Map();
|
|
@@ -40,7 +41,9 @@ export function memoryStorage() {
|
|
|
40
41
|
...(exp !== undefined ? { exp } : {}),
|
|
41
42
|
});
|
|
42
43
|
};
|
|
43
|
-
|
|
44
|
+
// Each write settles synchronously, so one note covers it (request-reads.ts).
|
|
45
|
+
const root = {};
|
|
46
|
+
return observeStorageWrites({
|
|
44
47
|
capabilities: { absoluteExpiry: true },
|
|
45
48
|
describe: () => ({ kind: "memory" }),
|
|
46
49
|
async get(key) {
|
|
@@ -49,10 +52,12 @@ export function memoryStorage() {
|
|
|
49
52
|
},
|
|
50
53
|
async set(key, value, opts) {
|
|
51
54
|
validateStorageKey(key);
|
|
55
|
+
noteStorageWrite(root, key);
|
|
52
56
|
write(key, value, opts);
|
|
53
57
|
},
|
|
54
58
|
async delete(key) {
|
|
55
59
|
validateStorageKey(key);
|
|
60
|
+
noteStorageWrite(root, key);
|
|
56
61
|
map.delete(key);
|
|
57
62
|
},
|
|
58
63
|
async list(prefix) {
|
|
@@ -68,6 +73,7 @@ export function memoryStorage() {
|
|
|
68
73
|
// runs to completion before any other call on this map can start.
|
|
69
74
|
async compareAndSet(key, expected, next, opts) {
|
|
70
75
|
validateStorageKey(key);
|
|
76
|
+
noteStorageWrite(root, key);
|
|
71
77
|
if ((fresh(key)?.value ?? null) !== expected)
|
|
72
78
|
return false;
|
|
73
79
|
if (next === null)
|
|
@@ -76,5 +82,5 @@ export function memoryStorage() {
|
|
|
76
82
|
write(key, next, opts);
|
|
77
83
|
return true;
|
|
78
84
|
},
|
|
79
|
-
};
|
|
85
|
+
}, { root });
|
|
80
86
|
}
|
|
@@ -27,7 +27,7 @@ import { Effect } from "effect";
|
|
|
27
27
|
import { activityBehaviorFacts, activityPackageVersion, activityClientFact, InvalidActivityCursorError, } from "../activity.js";
|
|
28
28
|
import { assertKnownOptions, ConfigError, keys, optionsOf } from "../config-schema.js";
|
|
29
29
|
import { agentFrictionForCode } from "../activity-friction.js";
|
|
30
|
-
import { validateStorageKey } from "./keys.js";
|
|
30
|
+
import { accessTokenKeys, validateStorageKey } from "./keys.js";
|
|
31
31
|
const sql = (text, ...params) => ({
|
|
32
32
|
sql: text,
|
|
33
33
|
params,
|
|
@@ -61,6 +61,8 @@ function schemaOnce(driver, ddl) {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
// --- key-value ----------------------------------------------------------
|
|
64
|
+
/** Keys one `getMany` statement binds. */
|
|
65
|
+
const GET_MANY_KEYS = 64;
|
|
64
66
|
/** Expired rows one write removes on its way. */
|
|
65
67
|
const SWEEP_ROWS = 16;
|
|
66
68
|
function ttlMillis(ttlSeconds) {
|
|
@@ -105,6 +107,56 @@ export function nativeSqlStorage(driver) {
|
|
|
105
107
|
return yield* current(key);
|
|
106
108
|
});
|
|
107
109
|
},
|
|
110
|
+
accessToken(hash) {
|
|
111
|
+
return Effect.gen(function* () {
|
|
112
|
+
const key = accessTokenKeys.lookup(hash);
|
|
113
|
+
yield* ensure();
|
|
114
|
+
// The links select candidate rows only. Admission still parses every
|
|
115
|
+
// raw value and addresses it by its physical key, so JSON extraction
|
|
116
|
+
// differences (e.g. duplicate fields) cannot substitute another slot.
|
|
117
|
+
// json_valid guards malformed stored JSON; expiry uses the DB clock.
|
|
118
|
+
const rows = yield* driver.all(sql(`WITH lookup AS (
|
|
119
|
+
SELECT key, value FROM connecta_kv WHERE key = ? AND ${live}
|
|
120
|
+
), record AS (
|
|
121
|
+
SELECT key, value FROM connecta_kv WHERE key = ? ||
|
|
122
|
+
(SELECT CASE WHEN json_valid(value) THEN json_extract(value, '$.id') END FROM lookup)
|
|
123
|
+
AND ${live}
|
|
124
|
+
), slot AS (
|
|
125
|
+
SELECT key, value FROM connecta_kv WHERE key = ? ||
|
|
126
|
+
(SELECT CASE WHEN json_valid(value) THEN json_extract(value, '$.agent') END FROM record)
|
|
127
|
+
AND ${live}
|
|
128
|
+
)
|
|
129
|
+
SELECT key, ${textColumn("value")} FROM lookup
|
|
130
|
+
UNION ALL SELECT key, ${textColumn("value")} FROM record
|
|
131
|
+
UNION ALL SELECT key, ${textColumn("value")} FROM slot`, key, accessTokenKeys.recordPrefix, accessTokenKeys.agent("")));
|
|
132
|
+
return new Map(rows.map((row) => [row.key, textOf(row.value, row.value_bytes, "Stored access token metadata")]));
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
getMany(keys) {
|
|
136
|
+
return Effect.gen(function* () {
|
|
137
|
+
for (const key of keys)
|
|
138
|
+
validateStorageKey(key);
|
|
139
|
+
if (keys.length === 0)
|
|
140
|
+
return [];
|
|
141
|
+
yield* ensure();
|
|
142
|
+
// One statement for every key: a chunked value costs one round trip.
|
|
143
|
+
// Rows answer by position, never by the key text a driver returns.
|
|
144
|
+
// Bound parameters stay under D1's per-statement limit of 100.
|
|
145
|
+
const values = keys.map(() => null);
|
|
146
|
+
for (let offset = 0; offset < keys.length; offset += GET_MANY_KEYS) {
|
|
147
|
+
const batch = keys.slice(offset, offset + GET_MANY_KEYS);
|
|
148
|
+
const rows = yield* driver.all({
|
|
149
|
+
sql: `WITH wanted (position, key) AS (VALUES ${batch.map((_, i) => `(${offset + i}, ?)`).join(", ")})
|
|
150
|
+
SELECT wanted.position AS position, ${textColumn("value")}
|
|
151
|
+
FROM wanted JOIN connecta_kv ON connecta_kv.key = wanted.key AND ${live}`,
|
|
152
|
+
params: batch,
|
|
153
|
+
});
|
|
154
|
+
for (const row of rows)
|
|
155
|
+
values[row.position] = textOf(row.value, row.value_bytes, `the stored value of ${JSON.stringify(keys[row.position])}`);
|
|
156
|
+
}
|
|
157
|
+
return values;
|
|
158
|
+
});
|
|
159
|
+
},
|
|
108
160
|
set(key, value, opts) {
|
|
109
161
|
return Effect.gen(function* () {
|
|
110
162
|
validateStorageKey(key);
|
package/dist/storage/sql.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { runEdge } from "../runtime/run.js";
|
|
2
2
|
import { registerNativeStorage, registerNativeActivity } from "../runtime/native-storage.js";
|
|
3
|
+
import { observedWrite, observeStorageWrites } from "../runtime/request-reads.js";
|
|
3
4
|
import { nativeSqlDriver } from "./sql-driver.js";
|
|
4
5
|
import { nativeSqlStorage, nativeSqlActivityStore } from "./sql-native.js";
|
|
5
6
|
/** `KVStorage` over one `connecta_kv` table. */
|
|
6
7
|
export function sqlStorage(driver, kind) {
|
|
7
|
-
const
|
|
8
|
-
|
|
8
|
+
const store = nativeSqlStorage(nativeSqlDriver(driver));
|
|
9
|
+
// Request read memos see every write begin and settle (request-reads.ts).
|
|
10
|
+
const root = {};
|
|
11
|
+
const native = {
|
|
12
|
+
...store,
|
|
13
|
+
set: (key, value, options) => observedWrite(root, key, store.set(key, value, options)),
|
|
14
|
+
delete: (key) => observedWrite(root, key, store.delete(key)),
|
|
15
|
+
compareAndSet: (key, expected, next, options) => observedWrite(root, key, store.compareAndSet(key, expected, next, options)),
|
|
16
|
+
};
|
|
17
|
+
const edge = registerNativeStorage({
|
|
9
18
|
capabilities: { absoluteExpiry: true },
|
|
10
19
|
describe: () => ({ kind }),
|
|
11
20
|
get: (key) => runEdge(native.get(key)),
|
|
@@ -14,6 +23,7 @@ export function sqlStorage(driver, kind) {
|
|
|
14
23
|
list: (prefix) => runEdge(native.list(prefix)),
|
|
15
24
|
compareAndSet: (key, expected, next, options) => runEdge(native.compareAndSet(key, expected, next, options)),
|
|
16
25
|
}, native);
|
|
26
|
+
return observeStorageWrites(edge, { root });
|
|
17
27
|
}
|
|
18
28
|
/**
|
|
19
29
|
* One append-only row per completed downstream call. Rows carry no arguments,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import { bytesToHex } from "./bytes.js";
|
|
3
|
+
import { serviceFor, setViewService } from "./runtime/call.js";
|
|
4
|
+
class StorageDigestKey extends Context.Service()("connecta/operation/StorageDigestKey") {
|
|
5
|
+
}
|
|
6
|
+
const importHmac = (raw) => crypto.subtle.importKey("raw", new Uint8Array(raw), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
7
|
+
// Drawn synchronously on first use, so concurrent first digests share one key.
|
|
8
|
+
let processBytes;
|
|
9
|
+
let processKey;
|
|
10
|
+
const fallbackKey = () => (processKey ??= importHmac((processBytes ??= crypto.getRandomValues(new Uint8Array(32)))));
|
|
11
|
+
const vaultKeys = new WeakMap();
|
|
12
|
+
/** Bind the vault's storage digest key to a registry-built context, when it has one. */
|
|
13
|
+
export function attachStorageDigestKey(ctx, vault) {
|
|
14
|
+
const derive = vault?.storageDigestKey;
|
|
15
|
+
if (typeof derive !== "function")
|
|
16
|
+
return;
|
|
17
|
+
let key = vaultKeys.get(vault);
|
|
18
|
+
if (!key) {
|
|
19
|
+
let pending;
|
|
20
|
+
key = () => (pending ??= derive.call(vault));
|
|
21
|
+
vaultKeys.set(vault, key);
|
|
22
|
+
}
|
|
23
|
+
setViewService(ctx, StorageDigestKey, key);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `hmac:<hex>` of a secret-bearing value, for a stored key only. Stable for one
|
|
27
|
+
* deployment key, so equal inputs still address the same entry.
|
|
28
|
+
*/
|
|
29
|
+
export async function secretDigest(ctx, value) {
|
|
30
|
+
const key = await (serviceFor(ctx, StorageDigestKey) ?? fallbackKey)();
|
|
31
|
+
return `hmac:${bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))))}`;
|
|
32
|
+
}
|
package/dist/timeout.js
CHANGED
|
@@ -9,6 +9,14 @@ import { runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
|
9
9
|
* to trip only on a pathological hang, not a realistically slow probe.
|
|
10
10
|
*/
|
|
11
11
|
export const DEFAULT_PROBE_TIMEOUT_MS = CONFIG_DEFAULTS.discovery.probeTimeoutMs;
|
|
12
|
+
/**
|
|
13
|
+
* Each connector's probe deadline in an unscoped search, or
|
|
14
|
+
* `discovery.probeTimeoutMs` when that is shorter. Scoped search, describe
|
|
15
|
+
* and calls keep the full probe deadline.
|
|
16
|
+
*/
|
|
17
|
+
export const UNSCOPED_PROBE_TIMEOUT_MS = 5_000;
|
|
18
|
+
/** One unscoped search's whole discovery fan-out ends within this many ms. */
|
|
19
|
+
export const UNSCOPED_DISCOVERY_BUDGET_MS = 9_000;
|
|
12
20
|
/** A finite, positive integer number of milliseconds, or undefined. */
|
|
13
21
|
export function normalizeTimeoutMs(value) {
|
|
14
22
|
if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
|
package/dist/types.d.ts
CHANGED
|
@@ -193,6 +193,14 @@ export interface ConnectorCredentialConfig {
|
|
|
193
193
|
*/
|
|
194
194
|
fields?: ConnectorCredentialFieldConfig[];
|
|
195
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Why a credential test did not pass, as a typed fact with no downstream text.
|
|
198
|
+
* `rejected`: the service refused the credential. `scope`: it accepted the
|
|
199
|
+
* credential but refused a permission the connection needs. `unreachable`: the
|
|
200
|
+
* service could not be reached or was unavailable. `timeout`: it did not
|
|
201
|
+
* answer within the test's deadline. `failed`: anything else.
|
|
202
|
+
*/
|
|
203
|
+
export type CredentialFailureClass = "rejected" | "scope" | "unreachable" | "timeout" | "failed";
|
|
196
204
|
export interface CredentialTestResult {
|
|
197
205
|
ok: boolean;
|
|
198
206
|
/**
|
|
@@ -201,6 +209,25 @@ export interface CredentialTestResult {
|
|
|
201
209
|
* `ok`, and the log records only that a test failed.
|
|
202
210
|
*/
|
|
203
211
|
message?: string;
|
|
212
|
+
/**
|
|
213
|
+
* Why a failed test failed. Optional: an unclassified failure is `failed`.
|
|
214
|
+
* Ignored when `ok` is true.
|
|
215
|
+
*/
|
|
216
|
+
failure?: CredentialFailureClass;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* How agent-facing text names Connecta's routes on the surface serving an
|
|
220
|
+
* operation: `call_tool`, `call_destructive_tool`, `search_tools`,
|
|
221
|
+
* `execute_code`, `authorize_connector` and `skills` on the six-tool surface,
|
|
222
|
+
* and other wording where those tools are not listed.
|
|
223
|
+
*/
|
|
224
|
+
export interface AgentRouteNames {
|
|
225
|
+
readonly search: string;
|
|
226
|
+
readonly read: string;
|
|
227
|
+
readonly write: string;
|
|
228
|
+
readonly program: string;
|
|
229
|
+
readonly authorize: string;
|
|
230
|
+
readonly skills: string;
|
|
204
231
|
}
|
|
205
232
|
export interface ConnectorContext {
|
|
206
233
|
/** Explicit downstream consent initiation, never set by status/catalog/calls. */
|
|
@@ -236,6 +263,12 @@ export interface ConnectorContext {
|
|
|
236
263
|
defer?: (promise: Promise<unknown>) => void;
|
|
237
264
|
/** Requested connector-operation deadline in milliseconds. */
|
|
238
265
|
timeoutMs?: number;
|
|
266
|
+
/**
|
|
267
|
+
* Route names for text this operation writes for the agent, such as "call
|
|
268
|
+
* authorize_connector" in an auth error. Connecta sets it on every context
|
|
269
|
+
* it builds; build such text from it rather than spelling a tool name.
|
|
270
|
+
*/
|
|
271
|
+
routes?: AgentRouteNames;
|
|
239
272
|
}
|
|
240
273
|
type ConnectorStatusState = "ok" | "per_caller" | "auth_required" | "credential_required" | "error";
|
|
241
274
|
/** The last agent-facing catalog read this runtime served for one connector. */
|
package/dist/usage-guide.js
CHANGED
|
@@ -1,43 +1,36 @@
|
|
|
1
|
+
import { META_ROUTES } from "./agent-routes.js";
|
|
2
|
+
import { UNCOUNTED_GUEST_FUNCTIONS } from "./guest-functions.js";
|
|
1
3
|
/** Self-contained content for the usage skill; independent of skill registration. */
|
|
2
4
|
export const USAGE_SKILL = `---
|
|
3
5
|
name: usage
|
|
4
|
-
description: How to route work between one
|
|
6
|
+
description: How to route work between one ${META_ROUTES.program} program and Connecta's explicit call, authorization, and result tools.
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
# Connecta usage
|
|
8
10
|
|
|
9
11
|
## Choose the route
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Read one account with call_tool: \`{ "address": "crm.get_account", "args": { "id": "acct_42" } }\`.
|
|
18
|
-
Discover and call together when schemas suffice. search_tools covers catalog
|
|
19
|
-
inspection. On a read-only endpoint, as execute_code's description says, search
|
|
20
|
-
omits writes (guides may name them) and each fails read_only_endpoint; tell the
|
|
21
|
-
user. Unannotated tools fail closed to writes.
|
|
22
|
-
For a requested write, make the call through the write route; the host may ask
|
|
23
|
-
the user to confirm it. Connecta classifies and routes writes; the host owns approval.
|
|
13
|
+
Route as the server instructions and ${META_ROUTES.program}'s first sentence say.
|
|
14
|
+
Read one account with ${META_ROUTES.read}: \`{ "address": "crm.get_account", "args": { "id": "acct_42" } }\`.
|
|
15
|
+
On a read-only endpoint search omits writes (guides may name them) and each
|
|
16
|
+
fails read_only_endpoint; tell the user. Unannotated tools fail closed to writes.
|
|
17
|
+
Connecta classifies and routes a requested write; the host owns approval.
|
|
24
18
|
|
|
25
19
|
## The global and the envelopes
|
|
26
20
|
|
|
27
21
|
Pass JavaScript \`async () => { ... }\`, no arguments. Do not shadow \`connecta\`.
|
|
28
22
|
No imports, require, filesystem, fetch, or timers.
|
|
29
23
|
|
|
30
|
-
|
|
31
|
-
\`connecta.call({ address, args, timeoutMs })
|
|
32
|
-
\`format: "
|
|
33
|
-
when documented as JSON. Verify collection roots.
|
|
24
|
+
\`connecta.call(address, args, { timeoutMs })\` and
|
|
25
|
+
\`connecta.call({ address, args, timeoutMs })\` return \`{ data, format }\`.
|
|
26
|
+
Parse \`format: "text"\` only when documented as JSON. Verify collection roots.
|
|
34
27
|
|
|
35
28
|
\`connecta.read("resource://id/"+encodeURIComponent(uri))\` returns \`{ contents }\` for advertised URIs.
|
|
36
29
|
|
|
37
|
-
Search/describe return \`{ tools }\`, never an array.
|
|
38
|
-
\`catalogErrors
|
|
39
|
-
|
|
40
|
-
\`
|
|
30
|
+
Search/describe return \`{ tools }\`, never an array. Check search's
|
|
31
|
+
\`catalogErrors\` and \`absence\` before selecting a match. Search defaults to a
|
|
32
|
+
TypeScript \`signature\`; text schemas have \`schemaFormat: "text"\` and no .properties.
|
|
33
|
+
A \`readyCalls\` entry is a complete read: \`connecta.call(entry.address, entry.args)\`. Select the exact tool and
|
|
41
34
|
account/environment, inspect its schema, and supply its required keys and types.
|
|
42
35
|
|
|
43
36
|
Discover an unfamiliar read and reduce its result:
|
|
@@ -79,7 +72,7 @@ async () => {
|
|
|
79
72
|
|
|
80
73
|
Host-call budget exhaustion ends the run with budget_exceeded. Catch and
|
|
81
74
|
allSettled cannot recover it or return partial output. Bound batches before
|
|
82
|
-
starting them; every guest call except
|
|
75
|
+
starting them; every guest call except ${UNCOUNTED_GUEST_FUNCTIONS} spends host calls.
|
|
83
76
|
Split remaining reads. Never replay writes for their output.
|
|
84
77
|
|
|
85
78
|
## Per-connector guides
|
|
@@ -134,15 +127,3 @@ base64 and PNG/JPEG/GIF/WebP MIME. Emit works without await, delivers on success
|
|
|
134
127
|
and obeys byte/block budgets; text shares the return cap. Return reduced JSON.
|
|
135
128
|
|
|
136
129
|
`;
|
|
137
|
-
/** Kept in sync with GuestApi by the declaration test. These are type notation, not runnable JS. */
|
|
138
|
-
export const GUEST_API_DECLARATION = `search(args?: CatalogSearchArgs): Promise<CatalogSearchResult>;
|
|
139
|
-
describe(args?: CatalogDescribeArgs): Promise<{ tools: CatalogDescription[] }>;
|
|
140
|
-
call(address: string, args?: unknown, options?: { timeoutMs?: number }): Promise<GuestResult>;
|
|
141
|
-
call(request: { address: string; args?: unknown; timeoutMs?: number }): Promise<GuestResult>;
|
|
142
|
-
read(uri: string): Promise<GuestResourceResult>;
|
|
143
|
-
result(
|
|
144
|
-
id: string | GuestResultHandle,
|
|
145
|
-
options?: { page?: number; offset?: number; maxBytes?: number },
|
|
146
|
-
): Promise<GuestResultPage>;
|
|
147
|
-
skill(name: string): Promise<{ name: string; text: string; format: "text" }>;
|
|
148
|
-
emit(block: GuestNativeBlock | GuestResult): PromiseLike<void>;`;
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConnectorCallError } from "./errors.js";
|
|
2
|
+
import type { ArgumentValidationDetails } from "./errors.js";
|
|
2
3
|
import { type FailureSubject } from "./operator-record.js";
|
|
3
4
|
import type { JsonSchema, Logger } from "./types.js";
|
|
4
5
|
export interface ValidateToolInputOptions {
|
|
@@ -31,6 +32,8 @@ export interface CompileValidatorOptions {
|
|
|
31
32
|
}
|
|
32
33
|
/** Optional in-process properties have the same absence semantics as JSON input. Never mutate the caller. */
|
|
33
34
|
export declare function normalizeToolInput<A>(value: A): A;
|
|
35
|
+
/** One example synthesized from the published schema alone, checked by its validator; never caller values. */
|
|
36
|
+
export declare function schemaExample(schema: JsonSchema): Pick<ArgumentValidationDetails, "example" | "exampleUnavailable">;
|
|
34
37
|
/**
|
|
35
38
|
* Validate call arguments against a tool's JSON Schema.
|
|
36
39
|
*
|