@segmentstream-cloud/ontology 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +35 -10
  2. package/dist/agent-chat.d.ts +75 -0
  3. package/dist/agent-chat.js +176 -0
  4. package/dist/analyze.d.ts +6 -2
  5. package/dist/analyze.js +29 -18
  6. package/dist/authoring.d.ts +6 -3
  7. package/dist/authoring.js +6 -3
  8. package/dist/index.d.ts +4 -2
  9. package/dist/index.js +2 -1
  10. package/dist/permissions.d.ts +11 -2
  11. package/dist/permissions.js +15 -3
  12. package/dist/replication/baseline.js +1 -1
  13. package/dist/replication/model.js +2 -2
  14. package/dist/replication/schema.js +1 -1
  15. package/dist/runtime/agents.d.ts +1 -0
  16. package/dist/runtime/agents.js +160 -0
  17. package/dist/runtime/generated-actions.d.ts +2 -1
  18. package/dist/runtime/generated-actions.js +18 -13
  19. package/dist/service/agent/channel.d.ts +16 -0
  20. package/dist/service/agent/channel.js +88 -0
  21. package/dist/service/agent/protocol.d.ts +14 -0
  22. package/dist/service/agent/protocol.js +26 -0
  23. package/dist/service/agent/server.d.ts +2 -1
  24. package/dist/service/agent/server.js +24 -2
  25. package/dist/service/aggregate-platform.d.ts +5 -0
  26. package/dist/service/aggregate-platform.js +1 -1
  27. package/dist/service/config.d.ts +10 -0
  28. package/dist/service/config.js +8 -0
  29. package/dist/service/handlers.d.ts +2 -1
  30. package/dist/service/handlers.js +2 -2
  31. package/dist/service/mcp/server.d.ts +3 -0
  32. package/dist/service/mcp/server.js +1 -1
  33. package/dist/service/operations.d.ts +6 -2
  34. package/dist/service/operations.js +13 -5
  35. package/dist/service/runtime-contract.d.ts +26 -0
  36. package/dist/service/service.js +7 -1
  37. package/package.json +1 -1
@@ -3,12 +3,18 @@ import { GENERATED_UPSERT_PREFIX, toSnakeCase } from "./names.js";
3
3
  * Permissions are `<entity>:<verb>` strings. The verbs are `read`, the generated `create`, `update`,
4
4
  * `delete`, `link` and `unlink`, and the snake_case name of an authored action. A role may use `*` for
5
5
  * the entity (`*:read`), the verb (`lead:*`) or both (`*:*`); what an action or a read requires never
6
- * holds a wildcard.
6
+ * holds a wildcard. A wildcard entity does not cover the entities in {@link EXPLICIT_GRANT_ENTITIES}.
7
7
  */
8
8
  /** Any entity or any verb, in a role's permissions. */
9
9
  export const WILDCARD = "*";
10
10
  /** The verb of every read: lists, queries, records, history, traversals and `include`. */
11
11
  export const READ_VERB = "read";
12
+ /**
13
+ * Entities only a permission that names them covers: `*:read` or `*:*` grant nothing on them. The
14
+ * built-in agent chat is in every ontology, and member's default `*:read` would otherwise show anyone
15
+ * who signs in the workspace's chats and who is in them; `agent_chat:read` or `agent_chat:*` does.
16
+ */
17
+ export const EXPLICIT_GRANT_ENTITIES = new Set(["agent_chat"]);
12
18
  /** What the built-in `member` role holds when `defineOntology` does not say: reading everything. */
13
19
  export const DEFAULT_MEMBER_PERMISSIONS = [`${WILDCARD}:${READ_VERB}`];
14
20
  /** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
@@ -25,14 +31,20 @@ export function parsePermission(value) {
25
31
  export function permissionOf(entity, verb) {
26
32
  return `${entity}:${verb}`;
27
33
  }
28
- /** Whether a granted permission, which may hold wildcards, covers the required one. */
34
+ /**
35
+ * Whether a granted permission, which may hold wildcards, covers the required one. A wildcard entity
36
+ * covers every entity but those in {@link EXPLICIT_GRANT_ENTITIES}.
37
+ */
29
38
  export function grants(granted, required) {
30
39
  const held = parsePermission(granted);
31
40
  const wanted = parsePermission(required);
32
41
  if (held === undefined || wanted === undefined) {
33
42
  return false;
34
43
  }
35
- return ((held.entity === WILDCARD || toSnakeCase(held.entity) === toSnakeCase(wanted.entity)) &&
44
+ const entity = toSnakeCase(wanted.entity);
45
+ return ((held.entity === WILDCARD
46
+ ? !EXPLICIT_GRANT_ENTITIES.has(entity)
47
+ : toSnakeCase(held.entity) === entity) &&
36
48
  (held.verb === WILDCARD || toSnakeCase(held.verb) === toSnakeCase(wanted.verb)));
37
49
  }
38
50
  /**
@@ -23,7 +23,7 @@ export const createReplicationBaseline = (sql, input) => sql.withTransaction(Eff
23
23
  yield* Effect.sync(() => validateReplicationSelection(manifest, input.selection));
24
24
  // Drain older SDK transactions that started before the compatibility fence was enabled.
25
25
  const tables = manifest.entities
26
- .filter((e) => e.source === undefined && e.name !== "user")
26
+ .filter((e) => e.source === undefined && !e.builtIn)
27
27
  .flatMap((e) => [
28
28
  tableName(e.name),
29
29
  ...e.relationships
@@ -69,7 +69,7 @@ export function validateReplicationSelection(manifest, selection) {
69
69
  };
70
70
  for (const item of selection) {
71
71
  const entity = manifest.entities.find((e) => e.id === item.entityId);
72
- if (!entity || entity.name === "user" || entity.source !== undefined) {
72
+ if (!entity || entity.builtIn || entity.source !== undefined) {
73
73
  throw new Error(`Only stored business entities can be replicated: ${item.entityId}.`);
74
74
  }
75
75
  addView(entity.name);
@@ -82,7 +82,7 @@ export function validateReplicationSelection(manifest, selection) {
82
82
  const target = manifest.entities.find((e) => e.name === relationship?.to);
83
83
  if (relationship?.cardinality !== "many" ||
84
84
  !target ||
85
- target.name === "user" ||
85
+ target.builtIn ||
86
86
  !selection.some((s) => s.entityId === target.id)) {
87
87
  throw new Error(`Replication of ${entity.name}.${name} requires both business entities.`);
88
88
  }
@@ -55,7 +55,7 @@ export function snapshotExpression(entity, alias) {
55
55
  /** History is the durable source. Triggers also reject old writers while capture is enabled. */
56
56
  export function replicationEntityDdl(manifest) {
57
57
  return manifest.entities
58
- .filter((e) => e.source === undefined && e.name !== "user")
58
+ .filter((e) => e.source === undefined && !e.builtIn)
59
59
  .flatMap((entity) => {
60
60
  const history = qi(historyTableName(entity.name));
61
61
  const table = qi(tableName(entity.name));
@@ -5,5 +5,6 @@ import type { AgentStore } from "../service/runtime-contract.js";
5
5
  * The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
6
6
  * `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
7
7
  * approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
8
+ * Chats the agent sees are `agent_chat` records, written as the platform with history.
8
9
  */
9
10
  export declare function sqlAgentStore(ontology: Ontology, sql: SqlClient.SqlClient): AgentStore;
@@ -1,8 +1,13 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Effect } from "effect";
2
3
  import { SqlClient } from "effect/unstable/sql";
4
+ import { AgentChat } from "../agent-chat.js";
3
5
  import { AGENT_IDENTITIES_TABLE, AGENT_PAIRINGS_TABLE, USER_TABLE } from "../migrate/storage.js";
6
+ import { MAX_QUERY_LIMIT } from "../query.js";
7
+ import { beginReplicationWrite } from "../replication/transaction.js";
4
8
  import { resolveMemberById } from "./principals.js";
5
9
  import { isRecordId } from "./records.js";
10
+ import { makeOntologyRuntime } from "./sql-runtime.js";
6
11
  const PAIRINGS = `"${AGENT_PAIRINGS_TABLE}"`;
7
12
  const IDENTITIES = `"${AGENT_IDENTITIES_TABLE}"`;
8
13
  const USERS = `"${USER_TABLE}"`;
@@ -14,6 +19,16 @@ const PAIRING_COLUMNS = `"id"::text AS "id", "provider", "external_id", "display
14
19
  `${epochMs(`"expires_at"`)} AS "expires_at"`;
15
20
  const IDENTITY_COLUMNS = `"id"::text AS "id", "provider", "external_id", "user_id"::text AS "user_id", "display", ` +
16
21
  `${epochMs(`"created_at"`)} AS "created_at", ${epochMs(`"revoked_at"`)} AS "revoked_at"`;
22
+ /**
23
+ * How often a chat's `last_message_at` is recorded: each record is a history row, and a busy chat
24
+ * would otherwise add one per message.
25
+ */
26
+ const CHAT_MESSAGE_RECORD_INTERVAL_MS = 15 * 60 * 1000;
27
+ /** Whether a chat's latest message is recorded: it has none yet, or none this interval. */
28
+ function messageDue(lastMessageAt, now) {
29
+ const last = typeof lastMessageAt === "string" ? Date.parse(lastMessageAt) : Number.NaN;
30
+ return Number.isNaN(last) || now.getTime() - last >= CHAT_MESSAGE_RECORD_INTERVAL_MS;
31
+ }
17
32
  function nullableText(value) {
18
33
  return value === null || value === undefined ? null : String(value);
19
34
  }
@@ -41,6 +56,7 @@ function identityOf(row) {
41
56
  * The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
42
57
  * `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
43
58
  * approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
59
+ * Chats the agent sees are `agent_chat` records, written as the platform with history.
44
60
  */
45
61
  export function sqlAgentStore(ontology, sql) {
46
62
  const rows = (statement, values = []) => sql.unsafe(statement, values).pipe(Effect.orDie);
@@ -141,5 +157,149 @@ export function sqlAgentStore(ontology, sql) {
141
157
  const [paired] = yield* rows(`SELECT 1 FROM ${IDENTITIES} WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL LIMIT 1`, [userId]);
142
158
  return paired === undefined ? undefined : yield* memberPrincipal(userId);
143
159
  }),
160
+ seeChat: ({ provider, externalId, title, memberId, leftMemberId, agentLeft, migratedFrom }) => sql
161
+ .withTransaction(Effect.gen(function* () {
162
+ yield* beginReplicationWrite(sql);
163
+ const previous = migratedFrom === undefined || migratedFrom === externalId ? undefined : migratedFrom;
164
+ // Calls for one chat run one after the other: the first creates it, the rest find it. A
165
+ // migration locks both chats, in one order, so that two calls never wait on each other.
166
+ for (const id of [externalId, ...(previous === undefined ? [] : [previous])].sort()) {
167
+ yield* rows(`SELECT pg_advisory_xact_lock(hashtext($1))`, [`chat:${provider}:${id}`]);
168
+ }
169
+ const runtime = makeOntologyRuntime(ontology, sql, {
170
+ action: `${AgentChat.name}.seen`,
171
+ actionId: randomUUID(),
172
+ principalId: null,
173
+ });
174
+ const now = new Date();
175
+ const chatOf = (id) => runtime
176
+ .find({
177
+ entity: AgentChat.name,
178
+ options: { where: { provider, external_id: id }, limit: 1, forUpdate: true },
179
+ })
180
+ .pipe(Effect.map(([chat]) => chat));
181
+ const membersOf = (id) => runtime
182
+ .related({
183
+ entity: AgentChat.name,
184
+ id,
185
+ relationship: "members",
186
+ query: { limit: MAX_QUERY_LIMIT },
187
+ })
188
+ .pipe(Effect.map((page) => page.items.map((member) => String(member.id))));
189
+ // Unlinks every member of the chat, linking each to `to` first when given. Each page
190
+ // unlinked is gone, so the first page is read until it is empty.
191
+ const releaseMembers = (id, to) => Effect.gen(function* () {
192
+ for (;;) {
193
+ const members = yield* membersOf(id);
194
+ if (members.length === 0) {
195
+ return;
196
+ }
197
+ for (const targetId of members) {
198
+ if (to !== undefined) {
199
+ yield* runtime.link({
200
+ entity: AgentChat.name,
201
+ relationship: "members",
202
+ id: to,
203
+ targetId,
204
+ });
205
+ }
206
+ yield* runtime.unlink({
207
+ entity: AgentChat.name,
208
+ relationship: "members",
209
+ id,
210
+ targetId,
211
+ });
212
+ }
213
+ }
214
+ });
215
+ let found = yield* chatOf(externalId);
216
+ if (previous !== undefined) {
217
+ const old = yield* chatOf(previous);
218
+ if (old !== undefined && found === undefined) {
219
+ // The group became a supergroup: the chat keeps its record under its new id.
220
+ found = yield* runtime.update({
221
+ entity: AgentChat.name,
222
+ id: String(old.id),
223
+ values: { external_id: externalId },
224
+ });
225
+ }
226
+ else if (old !== undefined && found !== undefined) {
227
+ // Both were recorded: the new one keeps what members said and gains the members,
228
+ // and the old one is deleted.
229
+ const kept = {};
230
+ for (const key of ["purpose", "when_to_use"]) {
231
+ if (nullableText(found[key]) === null && nullableText(old[key]) !== null) {
232
+ kept[key] = old[key];
233
+ }
234
+ }
235
+ if (Object.keys(kept).length > 0) {
236
+ found = yield* runtime.update({
237
+ entity: AgentChat.name,
238
+ id: String(found.id),
239
+ values: kept,
240
+ });
241
+ }
242
+ yield* releaseMembers(String(old.id), String(found.id));
243
+ yield* runtime.delete({ entity: AgentChat.name, id: String(old.id) });
244
+ }
245
+ }
246
+ const changes = {
247
+ ...(found?.title === title ? {} : { title }),
248
+ ...(messageDue(found?.last_message_at, now)
249
+ ? { last_message_at: now.toISOString() }
250
+ : {}),
251
+ };
252
+ const chat = found === undefined
253
+ ? yield* runtime.create({
254
+ entity: AgentChat.name,
255
+ values: { provider, external_id: externalId, ...changes },
256
+ })
257
+ : Object.keys(changes).length === 0
258
+ ? found
259
+ : yield* runtime.update({
260
+ entity: AgentChat.name,
261
+ id: String(found.id),
262
+ values: changes,
263
+ });
264
+ if (memberId !== undefined && isRecordId(memberId)) {
265
+ const [member] = yield* rows(`SELECT 1 FROM ${USERS} WHERE "id" = $1::uuid AND "kind"::text = 'member' AND "active"`, [memberId]);
266
+ // A link already there changes nothing and records no history.
267
+ if (member !== undefined) {
268
+ yield* runtime.link({
269
+ entity: AgentChat.name,
270
+ relationship: "members",
271
+ id: String(chat.id),
272
+ targetId: memberId,
273
+ });
274
+ }
275
+ }
276
+ if (leftMemberId !== undefined && isRecordId(leftMemberId)) {
277
+ const [user] = yield* rows(`SELECT 1 FROM ${USERS} WHERE "id" = $1::uuid`, [
278
+ leftMemberId,
279
+ ]);
280
+ // Whatever their state now; a member who is not linked changes nothing and records no
281
+ // history.
282
+ if (user !== undefined) {
283
+ yield* runtime.unlink({
284
+ entity: AgentChat.name,
285
+ relationship: "members",
286
+ id: String(chat.id),
287
+ targetId: leftMemberId,
288
+ });
289
+ }
290
+ }
291
+ if (agentLeft === true) {
292
+ // The agent left: no member is in the chat with it any more. The chat stays; members
293
+ // link again when they write after the agent is back.
294
+ yield* releaseMembers(String(chat.id));
295
+ }
296
+ return {
297
+ id: String(chat.id),
298
+ title: String(chat.title),
299
+ purpose: nullableText(chat.purpose),
300
+ whenToUse: nullableText(chat.when_to_use),
301
+ };
302
+ }))
303
+ .pipe(Effect.orDie),
144
304
  };
145
305
  }
@@ -12,7 +12,8 @@ export declare function upsertActionName(property: string): string;
12
12
  * are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
13
13
  * the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
14
14
  * as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
15
- * and services by the app or the CLI.
15
+ * and services by the app or the CLI. The built-in agent chat has only `update`, for its unmanaged
16
+ * properties: the agent records chats and their members.
16
17
  */
17
18
  export declare function generatedActions(ontology: Ontology, entityName: string): GeneratedActions;
18
19
  /** An action's input as JSON Schema (draft 2020-12), as the manifest describes authored actions. */
@@ -64,7 +64,7 @@ function valueFields(entity, { mode }) {
64
64
  ? target
65
65
  : Schema.optionalKey(link.optional ? Schema.NullOr(target) : target);
66
66
  }
67
- else if (link.via === "join" && !link.inverse) {
67
+ else if (link.via === "join" && !link.inverse && !entity.resolved.builtIn) {
68
68
  fields[link.name] = Schema.optionalKey(Schema.Array(reference(link, `The id of a ${link.target} record.`)).annotate({
69
69
  description: `${relationshipDescription(entity, link)} The list replaces the current links.`,
70
70
  }));
@@ -87,7 +87,8 @@ function valuesOf(input, omit) {
87
87
  * are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
88
88
  * the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
89
89
  * as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
90
- * and services by the app or the CLI.
90
+ * and services by the app or the CLI. The built-in agent chat has only `update`, for its unmanaged
91
+ * properties: the agent records chats and their members.
91
92
  */
92
93
  export function generatedActions(ontology, entityName) {
93
94
  if (ontology.manifest.entities.find((e) => e.name === entityName)?.source !== undefined) {
@@ -101,7 +102,7 @@ export function generatedActions(ontology, entityName) {
101
102
  }
102
103
  function buildGeneratedActions(ontology, entityName) {
103
104
  const entity = entityModel(ontologyModel(ontology), entityName);
104
- if (entity.resolved.builtIn) {
105
+ if (entity.resolved.builtIn && entity.resolved.permissions.update === undefined) {
105
106
  return {};
106
107
  }
107
108
  const label = entity.label.toLowerCase();
@@ -109,22 +110,26 @@ function buildGeneratedActions(ontology, entityName) {
109
110
  const managed = entity.fields
110
111
  .filter((field) => field.property?.definition.managed === true)
111
112
  .map((field) => field.name);
113
+ const update = action({
114
+ description: `Change ${article(label)}'s properties and relationships; omitted values stay as they are.` +
115
+ (managed.length > 0 ? ` ${managed.join(", ")} change only through other actions.` : ""),
116
+ input: Schema.Struct({ id: id("change"), ...valueFields(entity, { mode: "update" }) }),
117
+ handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.update({
118
+ entity: entityName,
119
+ id: input.id,
120
+ values: valuesOf(input, ["id"]),
121
+ })),
122
+ });
123
+ if (entity.resolved.builtIn) {
124
+ return { update };
125
+ }
112
126
  const actions = {
113
127
  create: action({
114
128
  description: `Create ${article(label)}.`,
115
129
  input: Schema.Struct(valueFields(entity, { mode: "create" })),
116
130
  handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.create({ entity: entityName, values: input })),
117
131
  }),
118
- update: action({
119
- description: `Change ${article(label)}'s properties and relationships; omitted values stay as they are.` +
120
- (managed.length > 0 ? ` ${managed.join(", ")} change only through other actions.` : ""),
121
- input: Schema.Struct({ id: id("change"), ...valueFields(entity, { mode: "update" }) }),
122
- handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.update({
123
- entity: entityName,
124
- id: input.id,
125
- values: valuesOf(input, ["id"]),
126
- })),
127
- }),
132
+ update,
128
133
  delete: action({
129
134
  description: `Delete ${article(label)}. Records that refer to it are deleted, cleared or protect it, ` +
130
135
  "as each relationship's onDelete says.",
@@ -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. Runtime routes take
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. Runtime routes take
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
  }
@@ -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.