@segmentstream-cloud/ontology 0.14.0 → 0.16.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/README.md +74 -10
- package/dist/agent-chat.d.ts +75 -0
- package/dist/agent-chat.js +176 -0
- package/dist/analyze.d.ts +6 -2
- package/dist/analyze.js +29 -18
- package/dist/authoring.d.ts +6 -3
- package/dist/authoring.js +6 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/migrate/apply.js +2 -0
- package/dist/migrate/plan.js +14 -4
- package/dist/permissions.d.ts +11 -2
- package/dist/permissions.js +15 -3
- package/dist/replication/baseline.js +1 -1
- package/dist/replication/model.js +2 -2
- package/dist/replication/schema.js +1 -1
- package/dist/runtime/agents.d.ts +1 -0
- package/dist/runtime/agents.js +160 -0
- package/dist/runtime/generated-actions.d.ts +2 -1
- package/dist/runtime/generated-actions.js +18 -13
- package/dist/runtime/service-binding.js +3 -0
- package/dist/service/agent/attachments.d.ts +37 -0
- package/dist/service/agent/attachments.js +225 -0
- package/dist/service/agent/channel.d.ts +16 -0
- package/dist/service/agent/channel.js +88 -0
- package/dist/service/agent/protocol.d.ts +14 -0
- package/dist/service/agent/protocol.js +26 -0
- package/dist/service/agent/server.d.ts +2 -1
- package/dist/service/agent/server.js +24 -2
- package/dist/service/aggregate-platform.d.ts +5 -0
- package/dist/service/aggregate-platform.js +1 -1
- package/dist/service/config.d.ts +10 -0
- package/dist/service/config.js +8 -0
- package/dist/service/handlers.d.ts +2 -1
- package/dist/service/handlers.js +2 -2
- package/dist/service/mcp/server.d.ts +3 -0
- package/dist/service/mcp/server.js +27 -4
- package/dist/service/mcp/tools.d.ts +6 -0
- package/dist/service/mcp/tools.js +33 -1
- package/dist/service/operations.d.ts +6 -2
- package/dist/service/operations.js +13 -5
- package/dist/service/runtime-contract.d.ts +29 -0
- package/dist/service/service.js +18 -2
- package/dist/storage/attachments.d.ts +117 -0
- package/dist/storage/attachments.js +536 -0
- package/dist/storage/maintenance.d.ts +4 -1
- package/dist/storage/maintenance.js +7 -2
- package/dist/storage/neon.js +14 -0
- package/dist/storage/provider.d.ts +2 -0
- package/dist/storage/tables.d.ts +8 -0
- package/dist/storage/tables.js +32 -0
- package/package.json +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Cause, Effect, Layer, Redacted, Result, Schema } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
|
+
import { ATTACHMENT_SOURCES, } from "../../storage/attachments.js";
|
|
5
|
+
import { schemaIssues } from "../../runtime/validation.js";
|
|
6
|
+
import { CurrentPrincipal } from "../authentication.js";
|
|
7
|
+
import { RequestId, errorBodyForWire, failureResponse, forbidden, toServerResponse, unauthorized, unavailable, } from "../errors.js";
|
|
8
|
+
import { identityToken, makeOperations } from "../operations.js";
|
|
9
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
10
|
+
import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
|
|
11
|
+
import { AGENT_PATH } from "./server.js";
|
|
12
|
+
/**
|
|
13
|
+
* The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
|
|
14
|
+
* deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
|
|
15
|
+
* while they are active and paired. The chat and the agent session come from the agent's runtime in
|
|
16
|
+
* `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
|
|
17
|
+
* tool arguments; every operation is confined to that member, chat and session.
|
|
18
|
+
*/
|
|
19
|
+
export const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
|
|
20
|
+
/** The chat an attachment belongs to, such as `telegram:12345`. */
|
|
21
|
+
export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
22
|
+
/** The agent session the request comes from. */
|
|
23
|
+
export const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
24
|
+
const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
|
|
25
|
+
const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
|
|
26
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
27
|
+
const Received = Schema.Struct({
|
|
28
|
+
source: Schema.Literals(ATTACHMENT_SOURCES),
|
|
29
|
+
source_ref: Schema.String,
|
|
30
|
+
filename: Schema.String,
|
|
31
|
+
content_type: Schema.String,
|
|
32
|
+
size: Schema.Int,
|
|
33
|
+
sha256: Schema.String,
|
|
34
|
+
});
|
|
35
|
+
const Promotion = Schema.Struct({
|
|
36
|
+
entity: Schema.String,
|
|
37
|
+
action: Schema.String,
|
|
38
|
+
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
|
39
|
+
field: Schema.optionalKey(Schema.String),
|
|
40
|
+
record_id: Schema.optionalKey(Schema.String),
|
|
41
|
+
});
|
|
42
|
+
function json(body, status = 200) {
|
|
43
|
+
return HttpServerResponse.jsonUnsafe(body, { status, headers: NO_STORE });
|
|
44
|
+
}
|
|
45
|
+
function attachmentForWire(attachment) {
|
|
46
|
+
return {
|
|
47
|
+
id: attachment.id,
|
|
48
|
+
filename: attachment.filename,
|
|
49
|
+
content_type: attachment.contentType,
|
|
50
|
+
size: attachment.size,
|
|
51
|
+
sha256: attachment.sha256,
|
|
52
|
+
state: attachment.state,
|
|
53
|
+
created_at: attachment.createdAt,
|
|
54
|
+
expires_at: attachment.expiresAt,
|
|
55
|
+
promotion: attachment.promotion,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function grantForWire(grant) {
|
|
59
|
+
return {
|
|
60
|
+
url: grant.url,
|
|
61
|
+
method: grant.method,
|
|
62
|
+
headers: grant.headers,
|
|
63
|
+
expires_at: grant.expiresAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function decodeBody(schema, request) {
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const text = yield* Effect.orDie(request.text);
|
|
69
|
+
let body;
|
|
70
|
+
try {
|
|
71
|
+
body = JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
body = undefined;
|
|
75
|
+
}
|
|
76
|
+
const decoded = yield* Effect.result(Schema.decodeUnknownEffect(schema)(body, { errors: "all", onExcessProperty: "error" }));
|
|
77
|
+
return Result.isFailure(decoded)
|
|
78
|
+
? { ok: false, issues: schemaIssues(decoded.failure) }
|
|
79
|
+
: { ok: true, value: decoded.success };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function invalidBody(requestId, issues) {
|
|
83
|
+
return HttpServerResponse.jsonUnsafe(errorBodyForWire({
|
|
84
|
+
error: "invalid_request",
|
|
85
|
+
detail: "The request body is invalid.",
|
|
86
|
+
requestId,
|
|
87
|
+
issues: issues,
|
|
88
|
+
}), { status: 400 });
|
|
89
|
+
}
|
|
90
|
+
/** Answers a failure the way every other route does: expected ones by status, the rest as 500. */
|
|
91
|
+
function answered(effect) {
|
|
92
|
+
return Effect.gen(function* () {
|
|
93
|
+
const requestId = yield* RequestId;
|
|
94
|
+
return yield* effect.pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
|
|
95
|
+
? Effect.failCause(cause)
|
|
96
|
+
: failureResponse(cause, requestId)));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The attachment routes. Without file storage or the agent tables, or while runtime authentication
|
|
101
|
+
* is disabled, it serves nothing.
|
|
102
|
+
*/
|
|
103
|
+
export function attachmentsLayer(options) {
|
|
104
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
105
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
106
|
+
const backend = yield* OntologyBackend;
|
|
107
|
+
const attachments = backend.attachments;
|
|
108
|
+
const agents = backend.agents;
|
|
109
|
+
const verifyRuntime = options.verifyRuntime;
|
|
110
|
+
if (attachments === undefined || agents === undefined || verifyRuntime === undefined) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const operations = makeOperations(backend, options.plan, options.hooks, options.connections, options.replicate, options.agentChannel);
|
|
114
|
+
const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
|
|
115
|
+
/** Authenticates the runtime, then the member it acts for, the chat and the session. */
|
|
116
|
+
const asAgent = (answer) => (request) => Effect.gen(function* () {
|
|
117
|
+
const requestId = yield* RequestId;
|
|
118
|
+
const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
|
|
119
|
+
const token = Redacted.value(bearer);
|
|
120
|
+
const verified = token === "" ? undefined : yield* Effect.promise(() => verifyRuntime(token));
|
|
121
|
+
switch (verified?.status) {
|
|
122
|
+
case "valid":
|
|
123
|
+
break;
|
|
124
|
+
case "invalid":
|
|
125
|
+
yield* logRuntimeRejection(verified.reason);
|
|
126
|
+
return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
|
|
127
|
+
case "unavailable":
|
|
128
|
+
return toServerResponse(unavailable(requestId));
|
|
129
|
+
default:
|
|
130
|
+
return toServerResponse(forbidden(requestId, "Only the app's agent calls this route."));
|
|
131
|
+
}
|
|
132
|
+
const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
|
|
133
|
+
const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
|
|
134
|
+
const session = request.headers[SESSION_HEADER]?.trim() ?? "";
|
|
135
|
+
const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
|
|
136
|
+
if (member === undefined) {
|
|
137
|
+
return toServerResponse(forbidden(requestId, "The member is inactive or has not paired with the agent."));
|
|
138
|
+
}
|
|
139
|
+
if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
|
|
140
|
+
return toServerResponse(forbidden(requestId, "The agent names the chat and session of every request."));
|
|
141
|
+
}
|
|
142
|
+
return yield* answer(request, {
|
|
143
|
+
principal: member,
|
|
144
|
+
conversation,
|
|
145
|
+
...(session === "" ? {} : { sessionId: session }),
|
|
146
|
+
}, attachments);
|
|
147
|
+
});
|
|
148
|
+
const idOf = Effect.map(HttpRouter.params, (params) => params.id ?? "");
|
|
149
|
+
yield* router.add("POST", ATTACHMENTS_PATH, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
|
|
150
|
+
const requestId = yield* RequestId;
|
|
151
|
+
const body = yield* decodeBody(Received, request);
|
|
152
|
+
if (!body.ok) {
|
|
153
|
+
return invalidBody(requestId, body.issues);
|
|
154
|
+
}
|
|
155
|
+
const received = yield* runtime.receive({
|
|
156
|
+
source: body.value.source,
|
|
157
|
+
sourceRef: body.value.source_ref,
|
|
158
|
+
filename: body.value.filename,
|
|
159
|
+
contentType: body.value.content_type,
|
|
160
|
+
size: body.value.size,
|
|
161
|
+
sha256: body.value.sha256,
|
|
162
|
+
}, caller);
|
|
163
|
+
return json({
|
|
164
|
+
attachment: attachmentForWire(received.attachment),
|
|
165
|
+
...(received.upload ? { upload: grantForWire(received.upload) } : {}),
|
|
166
|
+
}, 201);
|
|
167
|
+
}))));
|
|
168
|
+
yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/complete`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
|
|
169
|
+
const attachment = yield* runtime.complete(yield* idOf, caller);
|
|
170
|
+
return json({ attachment: attachmentForWire(attachment) });
|
|
171
|
+
}))));
|
|
172
|
+
yield* router.add("GET", ATTACHMENTS_PATH, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
|
|
173
|
+
const listed = yield* runtime.list(caller);
|
|
174
|
+
return json({ attachments: listed.map(attachmentForWire) });
|
|
175
|
+
}))));
|
|
176
|
+
yield* router.add("GET", `${ATTACHMENTS_PATH}/:id`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
|
|
177
|
+
const { attachment, download } = yield* runtime.read(yield* idOf, caller);
|
|
178
|
+
return json({
|
|
179
|
+
attachment: attachmentForWire(attachment),
|
|
180
|
+
download: grantForWire(download),
|
|
181
|
+
});
|
|
182
|
+
}))));
|
|
183
|
+
yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/discard`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
|
|
184
|
+
const attachment = yield* runtime.discard(yield* idOf, caller);
|
|
185
|
+
return json({ attachment: attachmentForWire(attachment) });
|
|
186
|
+
}))));
|
|
187
|
+
yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/promote`, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
|
|
188
|
+
const requestId = yield* RequestId;
|
|
189
|
+
const body = yield* decodeBody(Promotion, request);
|
|
190
|
+
if (!body.ok) {
|
|
191
|
+
return invalidBody(requestId, body.issues);
|
|
192
|
+
}
|
|
193
|
+
const promotion = body.value;
|
|
194
|
+
const promoted = yield* runtime.promote(yield* idOf, {
|
|
195
|
+
entity: promotion.entity,
|
|
196
|
+
action: promotion.action,
|
|
197
|
+
input: promotion.input ?? {},
|
|
198
|
+
...(promotion.field === undefined ? {} : { field: promotion.field }),
|
|
199
|
+
...(promotion.record_id === undefined ? {} : { recordId: promotion.record_id }),
|
|
200
|
+
}, caller, (call) => Effect.gen(function* () {
|
|
201
|
+
const planned = options.plan.entities.find((entity) => entity.name === call.entity);
|
|
202
|
+
const action = planned?.actions.find((item) => item.name === call.action);
|
|
203
|
+
if (planned === undefined || action === undefined) {
|
|
204
|
+
return yield* Effect.die(new Error("The promotion's action was not planned."));
|
|
205
|
+
}
|
|
206
|
+
// The action runs as the member, with the after-commit hooks and connections
|
|
207
|
+
// of any other call.
|
|
208
|
+
const answer = yield* operations
|
|
209
|
+
.runAction(planned, action, {
|
|
210
|
+
input: call.input,
|
|
211
|
+
validateOnly: false,
|
|
212
|
+
options: call.options,
|
|
213
|
+
identityToken: identityToken(request),
|
|
214
|
+
})
|
|
215
|
+
.pipe(Effect.provideService(CurrentPrincipal, caller.principal), Effect.provideService(RequestId, requestId));
|
|
216
|
+
return { result: answer.result, edits: answer.edits };
|
|
217
|
+
}));
|
|
218
|
+
return json({
|
|
219
|
+
attachment: attachmentForWire(promoted.attachment),
|
|
220
|
+
deduplicated: promoted.deduplicated,
|
|
221
|
+
result: promoted.result,
|
|
222
|
+
});
|
|
223
|
+
}))));
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AgentChannel } from "../../agent-chat.js";
|
|
2
|
+
import type { AgentStore } from "../runtime-contract.js";
|
|
3
|
+
/** The agent's route for messages the ontology asks it to deliver, on the production deployment. */
|
|
4
|
+
export declare const AGENT_MESSAGES_PATH = "/eve/v1/telegram/messages";
|
|
5
|
+
/** The agent of one action request: its token and the agent tables. */
|
|
6
|
+
export type AgentChannelFactory = (input: {
|
|
7
|
+
/** The deployment's Vercel OIDC token, from the request or the environment. */
|
|
8
|
+
readonly identityToken: string | undefined;
|
|
9
|
+
readonly store: AgentStore | undefined;
|
|
10
|
+
}) => AgentChannel["Service"];
|
|
11
|
+
/**
|
|
12
|
+
* The agent of a deployment that has one, at `https://<VERCEL_PROJECT_PRODUCTION_URL>`. Each message
|
|
13
|
+
* goes to {@link AGENT_MESSAGES_PATH} with the deployment's Vercel OIDC token, which the agent
|
|
14
|
+
* verifies as its own project's. A member's private chat is their live Telegram identity.
|
|
15
|
+
*/
|
|
16
|
+
export declare function deployedAgentChannel(agentUrl: string, fetchImpl?: typeof fetch): AgentChannelFactory;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { TELEGRAM } from "../../agent-chat.js";
|
|
3
|
+
import { ActionRejected } from "../../errors.js";
|
|
4
|
+
import { describeError } from "../aggregate-platform.js";
|
|
5
|
+
/** The agent's route for messages the ontology asks it to deliver, on the production deployment. */
|
|
6
|
+
export const AGENT_MESSAGES_PATH = "/eve/v1/telegram/messages";
|
|
7
|
+
/** How long the agent may take to accept a message; it answers once the message is queued. */
|
|
8
|
+
const DELIVERY_TIMEOUT_MS = 30_000;
|
|
9
|
+
/** The agent's error body: `{ "error": "channels_disabled" }` and the like. */
|
|
10
|
+
const AgentError = Schema.Struct({
|
|
11
|
+
error: Schema.optionalKey(Schema.String),
|
|
12
|
+
detail: Schema.optionalKey(Schema.String),
|
|
13
|
+
});
|
|
14
|
+
const rejected = (message) => new ActionRejected({ message });
|
|
15
|
+
const UNREACHABLE = "The agent could not be reached, so the message was not delivered. Try again.";
|
|
16
|
+
/**
|
|
17
|
+
* A delivery that got no answer (DNS, TLS, a timeout, a dropped body): the caught error is logged
|
|
18
|
+
* for the operator, with the token redacted, and the caller is told only that the agent was
|
|
19
|
+
* unreachable.
|
|
20
|
+
*/
|
|
21
|
+
function deliveryFailed(error, token) {
|
|
22
|
+
const diagnostic = error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "unreachable";
|
|
23
|
+
const redact = (text) => text.split(token).join("[redacted]");
|
|
24
|
+
return Effect.logWarning("ontology.agent.delivery_failed").pipe(Effect.annotateLogs({ diagnostic, error: describeError(error, redact) }), Effect.andThen(Effect.fail(rejected(UNREACHABLE))));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* What the caller is told when the agent does not accept the message, by its status and body. The
|
|
28
|
+
* status and the agent's error code are logged; the body is not.
|
|
29
|
+
*/
|
|
30
|
+
function refusal(status, text) {
|
|
31
|
+
const body = Option.getOrElse(Schema.decodeUnknownOption(Schema.fromJsonString(AgentError))(text), () => ({}));
|
|
32
|
+
return Effect.logWarning("ontology.agent.delivery_refused").pipe(Effect.annotateLogs({ status, diagnostic: body.error ?? "refused" }), Effect.andThen(Effect.fail(refusalMessage(status, body))));
|
|
33
|
+
}
|
|
34
|
+
/** The caller's message for a refusal, by the agent's status and decoded error body. */
|
|
35
|
+
function refusalMessage(status, body) {
|
|
36
|
+
switch (status) {
|
|
37
|
+
case 400:
|
|
38
|
+
return rejected(`The agent refused the message as invalid${body.detail ? `: ${body.detail}` : "."}`);
|
|
39
|
+
case 401:
|
|
40
|
+
case 403:
|
|
41
|
+
return rejected("The agent refused the deployment's token, so the message was not delivered.");
|
|
42
|
+
case 404:
|
|
43
|
+
return rejected("The deployed agent does not take messages from the ontology. Redeploy the agent with an " +
|
|
44
|
+
"SDK that does.");
|
|
45
|
+
case 503:
|
|
46
|
+
return body.error === "channels_disabled"
|
|
47
|
+
? rejected("The agent's channels run only in the production deployment, so it cannot deliver " +
|
|
48
|
+
"the message from here.")
|
|
49
|
+
: rejected("The agent is temporarily unavailable, so the message was not delivered. Try again.");
|
|
50
|
+
default:
|
|
51
|
+
return rejected(UNREACHABLE);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The agent of a deployment that has one, at `https://<VERCEL_PROJECT_PRODUCTION_URL>`. Each message
|
|
56
|
+
* goes to {@link AGENT_MESSAGES_PATH} with the deployment's Vercel OIDC token, which the agent
|
|
57
|
+
* verifies as its own project's. A member's private chat is their live Telegram identity.
|
|
58
|
+
*/
|
|
59
|
+
export function deployedAgentChannel(agentUrl, fetchImpl = fetch) {
|
|
60
|
+
const url = `${agentUrl}${AGENT_MESSAGES_PATH}`;
|
|
61
|
+
return ({ identityToken, store }) => ({
|
|
62
|
+
privateChat: (userId) => store === undefined
|
|
63
|
+
? Effect.succeed(undefined)
|
|
64
|
+
: store
|
|
65
|
+
.listIdentities(userId)
|
|
66
|
+
.pipe(Effect.map((identities) => identities.find((identity) => identity.provider === TELEGRAM)?.externalId)),
|
|
67
|
+
send: (message) => identityToken === undefined || identityToken === ""
|
|
68
|
+
? Effect.fail(rejected("The request carries no Vercel OIDC token, which the agent requires. Messages are " +
|
|
69
|
+
"sent from the production deployment."))
|
|
70
|
+
: Effect.tryPromise({
|
|
71
|
+
try: async (signal) => {
|
|
72
|
+
const response = await fetchImpl(url, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
authorization: `Bearer ${identityToken}`,
|
|
76
|
+
"content-type": "application/json",
|
|
77
|
+
accept: "application/json",
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(message),
|
|
80
|
+
redirect: "error",
|
|
81
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(DELIVERY_TIMEOUT_MS)]),
|
|
82
|
+
});
|
|
83
|
+
return { ok: response.ok, status: response.status, text: await response.text() };
|
|
84
|
+
},
|
|
85
|
+
catch: (error) => error,
|
|
86
|
+
}).pipe(Effect.catch((error) => deliveryFailed(error, identityToken)), Effect.flatMap((answer) => answer.ok ? Effect.void : refusal(answer.status, answer.text))),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -26,6 +26,20 @@ export declare const PairingRequest: Schema.Struct<{
|
|
|
26
26
|
readonly display: Schema.optionalKey<Schema.NullOr<Schema.String>>;
|
|
27
27
|
}>;
|
|
28
28
|
export type PairingRequest = typeof PairingRequest.Type;
|
|
29
|
+
/**
|
|
30
|
+
* `POST /api/ontology/agent/chats/seen`: a group chat the agent saw a message in, and who wrote, or
|
|
31
|
+
* the paired member who left it, or the agent itself leaving it, or the group it was before it
|
|
32
|
+
* became a supergroup.
|
|
33
|
+
*/
|
|
34
|
+
export declare const ChatSeenRequest: Schema.Struct<{
|
|
35
|
+
readonly provider: Schema.String;
|
|
36
|
+
readonly externalId: Schema.String;
|
|
37
|
+
readonly title: Schema.String;
|
|
38
|
+
readonly memberId: Schema.optionalKey<Schema.String>;
|
|
39
|
+
readonly leftMemberId: Schema.optionalKey<Schema.String>;
|
|
40
|
+
readonly agentLeft: Schema.optionalKey<Schema.Literal<true>>;
|
|
41
|
+
readonly migratedFrom: Schema.optionalKey<Schema.String>;
|
|
42
|
+
}>;
|
|
29
43
|
/** An identity on the wire, as the member's connect page and settings show it. */
|
|
30
44
|
export declare function identityForWire(identity: AgentIdentity): {
|
|
31
45
|
id: string;
|
|
@@ -40,6 +40,32 @@ export const PairingRequest = Schema.Struct({
|
|
|
40
40
|
description: "How the channel names the account, shown to the member who approves.",
|
|
41
41
|
})),
|
|
42
42
|
});
|
|
43
|
+
/** A chat's id on the channel. */
|
|
44
|
+
const ChatId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_EXTERNAL_ID_LENGTH));
|
|
45
|
+
/**
|
|
46
|
+
* `POST /api/ontology/agent/chats/seen`: a group chat the agent saw a message in, and who wrote, or
|
|
47
|
+
* the paired member who left it, or the agent itself leaving it, or the group it was before it
|
|
48
|
+
* became a supergroup.
|
|
49
|
+
*/
|
|
50
|
+
export const ChatSeenRequest = Schema.Struct({
|
|
51
|
+
provider: Provider,
|
|
52
|
+
externalId: ChatId.annotate({ description: "The chat's id on the channel." }),
|
|
53
|
+
title: Schema.String.check(Schema.isMaxLength(MAX_DISPLAY_LENGTH)).annotate({
|
|
54
|
+
description: "The chat's name on the channel.",
|
|
55
|
+
}),
|
|
56
|
+
memberId: Schema.optionalKey(Schema.String.annotate({
|
|
57
|
+
description: "The user id of the member who wrote, when the sender is paired.",
|
|
58
|
+
})),
|
|
59
|
+
leftMemberId: Schema.optionalKey(Schema.String.annotate({
|
|
60
|
+
description: "The user id of a paired member who left the chat, to unlink from its members.",
|
|
61
|
+
})),
|
|
62
|
+
agentLeft: Schema.optionalKey(Schema.Literal(true).annotate({
|
|
63
|
+
description: "The agent itself left the chat or was removed: every member is unlinked.",
|
|
64
|
+
})),
|
|
65
|
+
migratedFrom: Schema.optionalKey(ChatId.annotate({
|
|
66
|
+
description: "The chat's id before it became a supergroup: its record moves to externalId.",
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
43
69
|
/** An identity on the wire, as the member's connect page and settings show it. */
|
|
44
70
|
export function identityForWire(identity) {
|
|
45
71
|
return {
|
|
@@ -19,7 +19,8 @@ export interface AgentServerOptions {
|
|
|
19
19
|
readonly exchangeSession: SessionExchange | undefined;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
|
-
* The routes that pair channel accounts with members for the app's own agent
|
|
22
|
+
* The routes that pair channel accounts with members for the app's own agent, and record the group
|
|
23
|
+
* chats it sees. Runtime routes take
|
|
23
24
|
* only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
|
|
24
25
|
* refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
|
|
25
26
|
* Without the agent tables, it serves nothing.
|
|
@@ -10,11 +10,12 @@ import { secretHash } from "../oauth/protocol.js";
|
|
|
10
10
|
import { clientAddress, fixedWindowLimiter, } from "../rate-limit.js";
|
|
11
11
|
import { OntologyBackend } from "../runtime-contract.js";
|
|
12
12
|
import { logRuntimeRejection } from "../runtime-identity.js";
|
|
13
|
-
import { PAIRING_TTL_MS, PairingRequest, canonicalPairingCode, identityForWire, memberForWire, newPairingCode, } from "./protocol.js";
|
|
13
|
+
import { ChatSeenRequest, PAIRING_TTL_MS, PairingRequest, canonicalPairingCode, identityForWire, memberForWire, newPairingCode, } from "./protocol.js";
|
|
14
14
|
/** The agent's endpoints, under the ontology service's path. */
|
|
15
15
|
export const AGENT_PATH = `${BASE_PATH}/agent`;
|
|
16
16
|
const PAIRINGS_PATH = `${AGENT_PATH}/pairings`;
|
|
17
17
|
const IDENTITIES_PATH = `${AGENT_PATH}/identities`;
|
|
18
|
+
const CHATS_SEEN_PATH = `${AGENT_PATH}/chats/seen`;
|
|
18
19
|
/**
|
|
19
20
|
* Pairings one client address may start per minute; each one is a row in the database. The agent's
|
|
20
21
|
* functions share a few egress addresses, so the limit leaves room for a busy channel.
|
|
@@ -153,6 +154,25 @@ function resolveIdentity(store) {
|
|
|
153
154
|
return json(memberForWire(member));
|
|
154
155
|
});
|
|
155
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* `POST /api/ontology/agent/chats/seen` (runtime): the agent saw a message in a group chat. The chat
|
|
159
|
+
* is recorded, or its title kept current, a paired writer joins its members and a paired member who
|
|
160
|
+
* left is unlinked from them, or all of them when the agent itself left; the answer says what the
|
|
161
|
+
* chat is for, so that the agent knows it.
|
|
162
|
+
*/
|
|
163
|
+
function seeChat(store) {
|
|
164
|
+
return (request) => Effect.gen(function* () {
|
|
165
|
+
const requestId = yield* RequestId;
|
|
166
|
+
const decoded = Schema.decodeUnknownResult(ChatSeenRequest)(yield* jsonBody(request), {
|
|
167
|
+
errors: "all",
|
|
168
|
+
onExcessProperty: "error",
|
|
169
|
+
});
|
|
170
|
+
if (Result.isFailure(decoded)) {
|
|
171
|
+
return invalidBody(requestId, schemaIssues(decoded.failure));
|
|
172
|
+
}
|
|
173
|
+
return json(yield* store.seeChat(decoded.success));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
156
176
|
/** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
|
|
157
177
|
function listIdentities(store, member) {
|
|
158
178
|
return Effect.gen(function* () {
|
|
@@ -174,7 +194,8 @@ function revokeIdentity(store, member) {
|
|
|
174
194
|
});
|
|
175
195
|
}
|
|
176
196
|
/**
|
|
177
|
-
* The routes that pair channel accounts with members for the app's own agent
|
|
197
|
+
* The routes that pair channel accounts with members for the app's own agent, and record the group
|
|
198
|
+
* chats it sees. Runtime routes take
|
|
178
199
|
* only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
|
|
179
200
|
* refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
|
|
180
201
|
* Without the agent tables, it serves nothing.
|
|
@@ -230,6 +251,7 @@ export function agentLayer(options) {
|
|
|
230
251
|
yield* router.add("POST", `${PAIRINGS_PATH}/:code/approve`, asMember((_, member) => approvePairing(store, member)));
|
|
231
252
|
yield* router.add("GET", IDENTITIES_PATH, asMember((_, member) => listIdentities(store, member)));
|
|
232
253
|
yield* router.add("GET", `${IDENTITIES_PATH}/:provider/:external_id`, asRuntime(() => resolveIdentity(store)));
|
|
254
|
+
yield* router.add("POST", CHATS_SEEN_PATH, asRuntime(seeChat(store)));
|
|
233
255
|
yield* router.add("DELETE", `${IDENTITIES_PATH}/:id`, asMember((_, member) => revokeIdentity(store, member)));
|
|
234
256
|
}));
|
|
235
257
|
}
|
|
@@ -47,6 +47,11 @@ export interface AggregateEnvironment {
|
|
|
47
47
|
readonly appCredential?: Redacted.Redacted<string>;
|
|
48
48
|
readonly manifestHash: string;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* A failed call for the operator: name, message and stack, then each cause, with every occurrence
|
|
52
|
+
* of the credential replaced.
|
|
53
|
+
*/
|
|
54
|
+
export declare function describeError(error: unknown, redact: (text: string) => string, depth?: number): string;
|
|
50
55
|
/**
|
|
51
56
|
* SegmentStream's `POST /api/v1/workspaces/{id}/aggregate`, and its per-entity
|
|
52
57
|
* `…/entities/{entity}/aggregate`, called with the workspace app credential. `undefined` when the deployment has none, or when SegmentStream's origin is plain
|
|
@@ -58,7 +58,7 @@ function securePlatform(origin) {
|
|
|
58
58
|
* A failed call for the operator: name, message and stack, then each cause, with every occurrence
|
|
59
59
|
* of the credential replaced.
|
|
60
60
|
*/
|
|
61
|
-
function describeError(error, redact, depth = 0) {
|
|
61
|
+
export function describeError(error, redact, depth = 0) {
|
|
62
62
|
if (!(error instanceof Error)) {
|
|
63
63
|
return redact(String(error));
|
|
64
64
|
}
|
package/dist/service/config.d.ts
CHANGED
|
@@ -37,6 +37,11 @@ export declare const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES: {
|
|
|
37
37
|
readonly audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE";
|
|
38
38
|
readonly projectId: "VERCEL_PROJECT_ID";
|
|
39
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* The project's production domain, which Vercel sets: the agent of a deployment with runtime identity
|
|
42
|
+
* takes the messages of `agent_chat.send_message` there.
|
|
43
|
+
*/
|
|
44
|
+
export declare const AGENT_HOST_VARIABLE = "VERCEL_PROJECT_PRODUCTION_URL";
|
|
40
45
|
/** Where the workspace's Neon Auth issues and publishes the keys of its users' tokens. */
|
|
41
46
|
export interface NeonAuthEnvironment {
|
|
42
47
|
/** The Auth URL, such as `https://ep-….neon.tech/ontology/auth`. Its origin is the issuer. */
|
|
@@ -68,6 +73,11 @@ export interface OntologyEnvironment {
|
|
|
68
73
|
* authenticate, acting as the member `X-SegmentStream-Act-As` names.
|
|
69
74
|
*/
|
|
70
75
|
readonly runtimeIdentity?: RuntimeIdentityConfig;
|
|
76
|
+
/**
|
|
77
|
+
* The agent's origin, `https://<VERCEL_PROJECT_PRODUCTION_URL>`, set with `runtimeIdentity`:
|
|
78
|
+
* `agent_chat.send_message` hands messages to the agent there.
|
|
79
|
+
*/
|
|
80
|
+
readonly agentUrl?: string;
|
|
71
81
|
/**
|
|
72
82
|
* The local grant `segmentstream exec --connection …` passed, in development. Without it, actions
|
|
73
83
|
* call connections through SegmentStream with the deployment's Vercel OIDC token.
|
package/dist/service/config.js
CHANGED
|
@@ -34,6 +34,11 @@ export const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES = {
|
|
|
34
34
|
audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE",
|
|
35
35
|
projectId: "VERCEL_PROJECT_ID",
|
|
36
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* The project's production domain, which Vercel sets: the agent of a deployment with runtime identity
|
|
39
|
+
* takes the messages of `agent_chat.send_message` there.
|
|
40
|
+
*/
|
|
41
|
+
export const AGENT_HOST_VARIABLE = "VERCEL_PROJECT_PRODUCTION_URL";
|
|
37
42
|
/** The service cannot start: its configuration is incomplete or invalid. */
|
|
38
43
|
export class OntologyServiceConfigError extends Schema.TaggedError()("OntologyServiceConfigError", { message: Schema.String }) {
|
|
39
44
|
}
|
|
@@ -160,6 +165,9 @@ export function configFromEnv(environment = process.env) {
|
|
|
160
165
|
: {}),
|
|
161
166
|
...(neonAuth === undefined ? {} : { neonAuth }),
|
|
162
167
|
...(runtimeIdentity === undefined ? {} : { runtimeIdentity }),
|
|
168
|
+
...(runtimeIdentity === undefined || read(AGENT_HOST_VARIABLE) === ""
|
|
169
|
+
? {}
|
|
170
|
+
: { agentUrl: normalizeUrl(AGENT_HOST_VARIABLE, `https://${read(AGENT_HOST_VARIABLE)}`) }),
|
|
163
171
|
...(localConnections === undefined ? {} : { localConnections }),
|
|
164
172
|
};
|
|
165
173
|
}
|
|
@@ -4,6 +4,7 @@ import type { Aggregates } from "./aggregate.js";
|
|
|
4
4
|
import { type BuiltApi } from "./api.js";
|
|
5
5
|
import type { AfterCommitHook } from "./config.js";
|
|
6
6
|
import type { ReplicationKick } from "./replication.js";
|
|
7
|
+
import type { AgentChannelFactory } from "./agent/channel.js";
|
|
7
8
|
import type { ConnectionTransportFactory } from "./connections.js";
|
|
8
9
|
import { type RateLimit } from "./rate-limit.js";
|
|
9
10
|
import { OntologyBackend } from "./runtime-contract.js";
|
|
@@ -13,4 +14,4 @@ import { OntologyBackend } from "./runtime-contract.js";
|
|
|
13
14
|
*/
|
|
14
15
|
export declare const SIGN_IN_CHECK_LIMIT: RateLimit;
|
|
15
16
|
/** The handlers of every group of the API; they require {@link OntologyBackend}. */
|
|
16
|
-
export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, aggregates: Aggregates): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
|
|
17
|
+
export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, aggregates: Aggregates, agentChannel: AgentChannelFactory | undefined): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
|
package/dist/service/handlers.js
CHANGED
|
@@ -216,8 +216,8 @@ function storageHandlers(built) {
|
|
|
216
216
|
}));
|
|
217
217
|
}
|
|
218
218
|
/** The handlers of every group of the API; they require {@link OntologyBackend}. */
|
|
219
|
-
export function handlersLayer(built, hooks, connections, replicate, aggregates) {
|
|
220
|
-
const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate);
|
|
219
|
+
export function handlersLayer(built, hooks, connections, replicate, aggregates, agentChannel) {
|
|
220
|
+
const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate, agentChannel);
|
|
221
221
|
const layers = [
|
|
222
222
|
serviceHandlers(built, operationsOf, aggregates.model),
|
|
223
223
|
...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
|
|
@@ -4,6 +4,7 @@ import type { Aggregates } from "../aggregate.js";
|
|
|
4
4
|
import type { BuiltApi } from "../api.js";
|
|
5
5
|
import { type TokenVerifier } from "../authentication.js";
|
|
6
6
|
import type { AfterCommitHook } from "../config.js";
|
|
7
|
+
import type { AgentChannelFactory } from "../agent/channel.js";
|
|
7
8
|
import type { ConnectionTransportFactory } from "../connections.js";
|
|
8
9
|
import { RequestId } from "../errors.js";
|
|
9
10
|
import type { ReplicationKick } from "../replication.js";
|
|
@@ -23,6 +24,8 @@ export interface McpServerOptions {
|
|
|
23
24
|
readonly verifyRuntime?: RuntimeTokenVerifier | undefined;
|
|
24
25
|
/** The facade the aggregate routes run; the aggregate tool runs its model-level aggregate. */
|
|
25
26
|
readonly aggregates: Aggregates;
|
|
27
|
+
/** The workspace's agent, for `agent_chat.send_message`; `undefined` without one. */
|
|
28
|
+
readonly agentChannel?: AgentChannelFactory | undefined;
|
|
26
29
|
}
|
|
27
30
|
/**
|
|
28
31
|
* The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Cause, Data, Effect, Layer, Option, Schema } from "effect";
|
|
1
|
+
import { Cause, Data, Effect, Layer, Option, Schema, SchemaTransformation } from "effect";
|
|
2
2
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
3
|
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
4
|
import { encodeManifest } from "../../manifest.js";
|
|
@@ -14,7 +14,8 @@ import { identityToken, makeOperations, toQuery } from "../operations.js";
|
|
|
14
14
|
import { OntologyBackend } from "../runtime-contract.js";
|
|
15
15
|
import { runtimeCredential } from "../runtime-identity.js";
|
|
16
16
|
import { ActionResponse, HistoryPage, HistoryParams, IdParam, Me, } from "../schemas.js";
|
|
17
|
-
import {
|
|
17
|
+
import { uploadFields } from "../../storage/attachments.js";
|
|
18
|
+
import { ACTION_OPTIONS_ARGUMENT, mcpTools, visibleTools } from "./tools.js";
|
|
18
19
|
/** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
|
|
19
20
|
export const MCP_PATH = "/mcp";
|
|
20
21
|
/** The MCP revisions the server speaks, newest first. */
|
|
@@ -66,6 +67,23 @@ function decodeArguments(schema, args) {
|
|
|
66
67
|
onExcessProperty: "error",
|
|
67
68
|
}).pipe(Effect.mapError((error) => new InvalidArguments({ issues: schemaIssues(error.issue) })));
|
|
68
69
|
}
|
|
70
|
+
/** The `_options` argument of an action tool that consumes an upload. */
|
|
71
|
+
const McpActionOptions = Schema.Struct({
|
|
72
|
+
idempotency_key: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128))),
|
|
73
|
+
create_intent: Schema.optionalKey(Schema.String.check(Schema.isUUID())),
|
|
74
|
+
}).pipe(Schema.decodeTo(Schema.Struct({
|
|
75
|
+
idempotencyKey: Schema.optionalKey(Schema.String),
|
|
76
|
+
createIntent: Schema.optionalKey(Schema.String),
|
|
77
|
+
}), SchemaTransformation.transform({
|
|
78
|
+
decode: (value) => ({
|
|
79
|
+
...(value.idempotency_key === undefined ? {} : { idempotencyKey: value.idempotency_key }),
|
|
80
|
+
...(value.create_intent === undefined ? {} : { createIntent: value.create_intent }),
|
|
81
|
+
}),
|
|
82
|
+
encode: (value) => ({
|
|
83
|
+
...(value.idempotencyKey === undefined ? {} : { idempotency_key: value.idempotencyKey }),
|
|
84
|
+
...(value.createIntent === undefined ? {} : { create_intent: value.createIntent }),
|
|
85
|
+
}),
|
|
86
|
+
})));
|
|
69
87
|
/** What the service sends: a value encoded with the schema of the matching HTTP route. */
|
|
70
88
|
function encoded(schema, value) {
|
|
71
89
|
return Schema.encodeUnknownEffect(schema)(value).pipe(Effect.orDie);
|
|
@@ -178,9 +196,14 @@ function runTool({ built, operations, aggregate }, tool, args, request) {
|
|
|
178
196
|
return structured(yield* encoded(HistoryPage, yield* operations.history(target.planned, id, params)));
|
|
179
197
|
}
|
|
180
198
|
case "action": {
|
|
199
|
+
const { [ACTION_OPTIONS_ARGUMENT]: rawOptions, ...input } = args;
|
|
200
|
+
const options = rawOptions === undefined || uploadFields(target.action.action.input).length === 0
|
|
201
|
+
? undefined
|
|
202
|
+
: yield* decodeArguments(McpActionOptions, rawOptions);
|
|
181
203
|
const answer = yield* operations.runAction(target.planned, target.action, {
|
|
182
|
-
input: args,
|
|
204
|
+
input: options === undefined ? args : input,
|
|
183
205
|
validateOnly: false,
|
|
206
|
+
...(options === undefined ? {} : { options }),
|
|
184
207
|
identityToken: identityToken(request),
|
|
185
208
|
});
|
|
186
209
|
return structured(yield* encoded(ActionResponse, answer));
|
|
@@ -417,7 +440,7 @@ export function mcpLayer(options) {
|
|
|
417
440
|
const server = {
|
|
418
441
|
built: options.built,
|
|
419
442
|
tools,
|
|
420
|
-
operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate),
|
|
443
|
+
operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel),
|
|
421
444
|
aggregate: options.aggregates.model,
|
|
422
445
|
};
|
|
423
446
|
yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, options.verifyRuntime, backend));
|
|
@@ -43,6 +43,12 @@ export interface McpTool {
|
|
|
43
43
|
readonly inputSchemaFor?: (caller: PermissionHolder) => JsonSchemaObject | undefined;
|
|
44
44
|
readonly target: McpToolTarget;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The argument an action that consumes an upload takes beside its input: the idempotency key the
|
|
48
|
+
* runtime requires of it, and the create intent the upload was begun with. The service removes it
|
|
49
|
+
* before the input is validated.
|
|
50
|
+
*/
|
|
51
|
+
export declare const ACTION_OPTIONS_ARGUMENT = "_options";
|
|
46
52
|
/**
|
|
47
53
|
* The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
|
|
48
54
|
* then for every stored entity `find`, `get`,
|