@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.
Files changed (52) hide show
  1. package/README.md +74 -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/migrate/apply.js +2 -0
  11. package/dist/migrate/plan.js +14 -4
  12. package/dist/permissions.d.ts +11 -2
  13. package/dist/permissions.js +15 -3
  14. package/dist/replication/baseline.js +1 -1
  15. package/dist/replication/model.js +2 -2
  16. package/dist/replication/schema.js +1 -1
  17. package/dist/runtime/agents.d.ts +1 -0
  18. package/dist/runtime/agents.js +160 -0
  19. package/dist/runtime/generated-actions.d.ts +2 -1
  20. package/dist/runtime/generated-actions.js +18 -13
  21. package/dist/runtime/service-binding.js +3 -0
  22. package/dist/service/agent/attachments.d.ts +37 -0
  23. package/dist/service/agent/attachments.js +225 -0
  24. package/dist/service/agent/channel.d.ts +16 -0
  25. package/dist/service/agent/channel.js +88 -0
  26. package/dist/service/agent/protocol.d.ts +14 -0
  27. package/dist/service/agent/protocol.js +26 -0
  28. package/dist/service/agent/server.d.ts +2 -1
  29. package/dist/service/agent/server.js +24 -2
  30. package/dist/service/aggregate-platform.d.ts +5 -0
  31. package/dist/service/aggregate-platform.js +1 -1
  32. package/dist/service/config.d.ts +10 -0
  33. package/dist/service/config.js +8 -0
  34. package/dist/service/handlers.d.ts +2 -1
  35. package/dist/service/handlers.js +2 -2
  36. package/dist/service/mcp/server.d.ts +3 -0
  37. package/dist/service/mcp/server.js +27 -4
  38. package/dist/service/mcp/tools.d.ts +6 -0
  39. package/dist/service/mcp/tools.js +33 -1
  40. package/dist/service/operations.d.ts +6 -2
  41. package/dist/service/operations.js +13 -5
  42. package/dist/service/runtime-contract.d.ts +29 -0
  43. package/dist/service/service.js +18 -2
  44. package/dist/storage/attachments.d.ts +117 -0
  45. package/dist/storage/attachments.js +536 -0
  46. package/dist/storage/maintenance.d.ts +4 -1
  47. package/dist/storage/maintenance.js +7 -2
  48. package/dist/storage/neon.js +14 -0
  49. package/dist/storage/provider.d.ts +2 -0
  50. package/dist/storage/tables.d.ts +8 -0
  51. package/dist/storage/tables.js +32 -0
  52. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
7
7
  import { queryJsonSchema } from "../../query.js";
8
8
  import { measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
9
9
  import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
10
+ import { uploadFields } from "../../storage/attachments.js";
10
11
  import { actionDescription, requires } from "../descriptions.js";
11
12
  import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, HistoryPage, Me, } from "../schemas.js";
12
13
  const DEFS = "#/$defs/";
@@ -160,6 +161,37 @@ function readTools(planned, schemas, built) {
160
161
  },
161
162
  ];
162
163
  }
164
+ /**
165
+ * The argument an action that consumes an upload takes beside its input: the idempotency key the
166
+ * runtime requires of it, and the create intent the upload was begun with. The service removes it
167
+ * before the input is validated.
168
+ */
169
+ export const ACTION_OPTIONS_ARGUMENT = "_options";
170
+ const ACTION_OPTIONS_SCHEMA = {
171
+ type: "object",
172
+ description: "Options of an action that stores an uploaded file: idempotency_key, required with an upload, " +
173
+ "and create_intent, the one the upload was begun with when it creates a record.",
174
+ properties: {
175
+ idempotency_key: { type: "string", minLength: 1, maxLength: 128 },
176
+ create_intent: { type: "string", format: "uuid" },
177
+ },
178
+ additionalProperties: false,
179
+ };
180
+ /** The input schema of an action tool; one that consumes an upload also takes its options. */
181
+ function actionToolInput(action) {
182
+ const schema = rootObject(actionInputJsonSchema(action.input));
183
+ const properties = schema.properties;
184
+ if (uploadFields(action.input).length === 0 ||
185
+ schema.type !== "object" ||
186
+ properties === undefined ||
187
+ ACTION_OPTIONS_ARGUMENT in properties) {
188
+ return schema;
189
+ }
190
+ return {
191
+ ...schema,
192
+ properties: { ...properties, [ACTION_OPTIONS_ARGUMENT]: ACTION_OPTIONS_SCHEMA },
193
+ };
194
+ }
163
195
  function actionTool(planned, plannedAction) {
164
196
  const { name, action, permissions } = plannedAction;
165
197
  const hints = hintsOf(planned, plannedAction);
@@ -167,7 +199,7 @@ function actionTool(planned, plannedAction) {
167
199
  name: mcpToolName(planned.name, name),
168
200
  title: `${planned.resolved.entity.definition.label}: ${humanize(name).toLowerCase()}`,
169
201
  description: actionDescription(plannedAction),
170
- inputSchema: rootObject(actionInputJsonSchema(action.input)),
202
+ inputSchema: actionToolInput(action),
171
203
  annotations: {
172
204
  readOnlyHint: false,
173
205
  destructiveHint: hints.destructive,
@@ -5,6 +5,7 @@ import type { Query, QueryResult } from "../query.js";
5
5
  import { CurrentPrincipal } from "./authentication.js";
6
6
  import type { AfterCommitHook } from "./config.js";
7
7
  import type { ReplicationKick } from "./replication.js";
8
+ import type { AgentChannelFactory } from "./agent/channel.js";
8
9
  import { type ConnectionTransportFactory } from "./connections.js";
9
10
  import { InvalidActionInput, RequestId } from "./errors.js";
10
11
  import type { ApiPlan, PlannedAction, PlannedEntity, Traversal } from "./plan.js";
@@ -59,8 +60,11 @@ export interface Caller {
59
60
  readonly permissions: ReadonlyArray<string>;
60
61
  }
61
62
  type Backend = OntologyBackend["Service"];
62
- /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
63
- export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick): {
63
+ /**
64
+ * The operations of a planned ontology, bound to its backend, after-commit hooks, connections and,
65
+ * on deployments with one, the workspace's agent.
66
+ */
67
+ export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, agentChannel?: AgentChannelFactory): {
64
68
  /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
65
69
  find: (planned: PlannedEntity, query: Query) => Effect.Effect<QueryResult<Readonly<Record<string, unknown>>>, PermissionDenied | import("../index.js").InvalidQuery, CurrentPrincipal>;
66
70
  /** One record. Requires `read` on the entity. */
@@ -6,6 +6,7 @@ import { PermissionDenied } from "../errors.js";
6
6
  import { toCamelCase } from "../names.js";
7
7
  import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
8
8
  import { CurrentPrincipal } from "./authentication.js";
9
+ import { AgentChannel } from "../agent-chat.js";
9
10
  import { OIDC_TOKEN_HEADER } from "./connections.js";
10
11
  import { InvalidActionInput, RequestId } from "./errors.js";
11
12
  /**
@@ -145,12 +146,12 @@ function traverseOf(backend, targets, planned, traversal, id, query) {
145
146
  return page(yield* backend.find({ entity: traversal.target, query: { ...query, where } }));
146
147
  });
147
148
  }
148
- function runActionOf(backend, ontology, hooks, connections, replicate) {
149
+ function runActionOf(backend, ontology, hooks, connections, replicate, agentChannel) {
149
150
  return (planned, { name, generated }, call) => Effect.gen(function* () {
150
151
  const principal = yield* CurrentPrincipal;
151
152
  const requestId = yield* RequestId;
152
153
  const hook = hooks.get(`${planned.name}.${name}`);
153
- const outcome = yield* backend.runAction({
154
+ const run = backend.runAction({
154
155
  entity: planned.name,
155
156
  action: name,
156
157
  input: call.input,
@@ -175,6 +176,10 @@ function runActionOf(backend, ontology, hooks, connections, replicate) {
175
176
  }
176
177
  : {}),
177
178
  });
179
+ // Built-in actions reach the workspace's agent through it, when the deployment has one.
180
+ const outcome = yield* agentChannel === undefined
181
+ ? run
182
+ : run.pipe(Effect.provideService(AgentChannel, agentChannel({ identityToken: call.identityToken, store: backend.agents })));
178
183
  const { issues } = outcome.validation;
179
184
  if (!outcome.validation.ok && !call.validateOnly) {
180
185
  return yield* new InvalidActionInput({ issues });
@@ -208,8 +213,11 @@ function callerOf(backend) {
208
213
  };
209
214
  });
210
215
  }
211
- /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
212
- export function makeOperations(backend, plan, hooks, connections, replicate) {
216
+ /**
217
+ * The operations of a planned ontology, bound to its backend, after-commit hooks, connections and,
218
+ * on deployments with one, the workspace's agent.
219
+ */
220
+ export function makeOperations(backend, plan, hooks, connections, replicate, agentChannel) {
213
221
  const targets = relationshipTargets(plan);
214
222
  return {
215
223
  /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
@@ -221,7 +229,7 @@ export function makeOperations(backend, plan, hooks, connections, replicate) {
221
229
  /** The records a relationship reaches from one record, as a page of the target entity. */
222
230
  traverse: (planned, traversal, id, query) => traverseOf(backend, targets, planned, traversal, id, query),
223
231
  /** Runs an action as the caller, with the action's after-commit hook and connections. */
224
- runAction: runActionOf(backend, plan.ontology, hooks, connections, replicate),
232
+ runAction: runActionOf(backend, plan.ontology, hooks, connections, replicate, agentChannel),
225
233
  /** The caller's user, roles and permissions. It needs no permission. */
226
234
  caller: callerOf(backend),
227
235
  };
@@ -1,4 +1,5 @@
1
1
  import { Context, Schema, type Effect, type Layer, type Redacted } from "effect";
2
+ import type { AttachmentRuntime } from "../storage/attachments.js";
2
3
  import type { BlobRuntime } from "../storage/sessions.js";
3
4
  import type { BlobStorageBinding } from "../storage/provider.js";
4
5
  import type { AnyAction } from "../action.js";
@@ -180,6 +181,13 @@ export interface AgentMember {
180
181
  readonly name: string;
181
182
  readonly principal: Principal;
182
183
  }
184
+ /** A group chat the agent saw, as `POST /api/ontology/agent/chats/seen` answers. */
185
+ export interface SeenChat {
186
+ readonly id: string;
187
+ readonly title: string;
188
+ readonly purpose: string | null;
189
+ readonly whenToUse: string | null;
190
+ }
183
191
  /**
184
192
  * Where the app's agent keeps pairing codes and the identities they create: the runtime's agent
185
193
  * tables in the workspace database. Codes are stored only as SHA-256 hashes. Expiry is checked
@@ -218,9 +226,30 @@ export interface AgentStore {
218
226
  readonly member: (userId: string) => Effect.Effect<AgentMember | undefined>;
219
227
  /** The member a runtime caller acts for: active, and with a live identity. */
220
228
  readonly actingMember: (userId: string) => Effect.Effect<Principal | undefined>;
229
+ /**
230
+ * Records a message the agent saw in a group chat: creates the `agent_chat` or keeps its title
231
+ * current, records when the chat last had a message (at most every 15 minutes, so that history
232
+ * keeps it without a row per message), links the writer to its members when they are an active
233
+ * member, and unlinks `leftMemberId`, a member who left, when linked. With `agentLeft`, the agent
234
+ * itself left the chat: every member is unlinked, and the chat stays. With `migratedFrom`, the
235
+ * chat had that id before it became a supergroup: its record moves to `externalId`, or, when both
236
+ * ids have one, the new record takes the old one's members, and its purpose and when to use where
237
+ * it has none. Concurrent calls for one chat leave one record.
238
+ */
239
+ readonly seeChat: (input: {
240
+ readonly provider: string;
241
+ readonly externalId: string;
242
+ readonly title: string;
243
+ readonly memberId?: string;
244
+ readonly leftMemberId?: string;
245
+ readonly agentLeft?: true;
246
+ readonly migratedFrom?: string;
247
+ }) => Effect.Effect<SeenChat>;
221
248
  }
222
249
  declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
223
250
  readonly blobs?: BlobRuntime;
251
+ /** The agent's temporary attachments, on deployments with file storage. */
252
+ readonly attachments?: AttachmentRuntime;
224
253
  readonly maintainBlobs?: Effect.Effect<{
225
254
  readonly removed: number;
226
255
  }, unknown>;
@@ -4,6 +4,8 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
4
4
  import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-auth.js";
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
+ import { attachmentsLayer } from "./agent/attachments.js";
8
+ import { deployedAgentChannel } from "./agent/channel.js";
7
9
  import { agentLayer } from "./agent/server.js";
8
10
  import { makeAggregates } from "./aggregate.js";
9
11
  import { platformAggregates } from "./aggregate-platform.js";
@@ -89,11 +91,15 @@ export function createOntologyService(ontology, config) {
89
91
  ? deployedConnections(config)
90
92
  : localConnections(config.localConnections));
91
93
  const replicate = deployedReplication(config);
94
+ // The agent's runtime identity is configured only on deployments that have an agent.
95
+ const agentChannel = config.runtimeIdentity === undefined || config.agentUrl === undefined
96
+ ? undefined
97
+ : deployedAgentChannel(config.agentUrl);
92
98
  const aggregates = makeAggregates({
93
99
  manifest: ontology.manifest,
94
100
  transport: platformAggregates({ ...config, manifestHash: ontology.manifest.hash }),
95
101
  });
96
- const routes = HttpApiBuilder.layer(built.api).pipe(Layer.provide(handlersLayer(built, hooks, connections, replicate, aggregates)), Layer.provide([
102
+ const routes = HttpApiBuilder.layer(built.api).pipe(Layer.provide(handlersLayer(built, hooks, connections, replicate, aggregates, agentChannel)), Layer.provide([
97
103
  authenticationLayer(verifyToken, exchangeSession, verifyRuntime),
98
104
  ErrorResponsesLive,
99
105
  storageMaintenanceAuthenticationLayer(config.storageMaintenanceKey),
@@ -108,9 +114,19 @@ export function createOntologyService(ontology, config) {
108
114
  verifyRuntime,
109
115
  replicate,
110
116
  aggregates,
117
+ agentChannel,
111
118
  }), oauthLayer({ verifyToken }));
112
119
  const agent = agentLayer({ verifyToken, verifyRuntime, exchangeSession });
113
- const application = Layer.mergeAll(routes, mcp, agent, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
120
+ const attachments = attachmentsLayer({
121
+ plan: built.plan,
122
+ hooks,
123
+ connections,
124
+ replicate,
125
+ agentChannel,
126
+ verifyToken,
127
+ verifyRuntime,
128
+ });
129
+ const application = Layer.mergeAll(routes, mcp, agent, attachments, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
114
130
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
115
131
  const hash = ontology.manifest.hash;
116
132
  return {
@@ -0,0 +1,117 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ import { ActionRejected, Conflict, NotFound, type PermissionDenied } from "../errors.js";
4
+ import type { Ontology } from "../ontology.js";
5
+ import type { Principal } from "../runtime.js";
6
+ import type { BlobProvider, TransferGrant } from "./provider.js";
7
+ /**
8
+ * The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
9
+ * `_blobs` revision in the ontology's private file storage, and belongs to that member, the chat it
10
+ * arrived in and the agent session that first reads it. Nothing about it is an ontology record. It
11
+ * expires 24 hours after receipt; within that time the member's agent may read it, discard it, or
12
+ * promote it into a record through an action, which adopts the same stored object without copying
13
+ * it. Every operation re-derives the owner, chat and session from the caller, never from input.
14
+ */
15
+ /** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
16
+ export declare const MAX_ATTACHMENT_BYTES: number;
17
+ /** How long an attachment stays readable after it arrives; reads never extend it. */
18
+ export declare const ATTACHMENT_TTL_HOURS = 24;
19
+ /** Live attachments one member may hold at once, so a busy chat cannot fill the storage. */
20
+ export declare const MAX_LIVE_ATTACHMENTS = 50;
21
+ export declare const ATTACHMENT_SOURCES: readonly ["telegram"];
22
+ /** Who asks, as the agent's runtime knows it: the member, their chat and the agent session. */
23
+ export interface AttachmentCaller {
24
+ readonly principal: Principal;
25
+ /** The chat the attachment arrived in, such as `telegram:12345`. */
26
+ readonly conversation: string;
27
+ /**
28
+ * The agent session the request comes from. The first session that reads an attachment owns it;
29
+ * other sessions of the same chat, such as one after a reset, never see it.
30
+ */
31
+ readonly sessionId?: string;
32
+ }
33
+ export interface ReceiveAttachment {
34
+ readonly source: (typeof ATTACHMENT_SOURCES)[number];
35
+ /** Identifies the file at its source, such as the message and attachment index; retries reuse it. */
36
+ readonly sourceRef: string;
37
+ readonly filename: string;
38
+ readonly contentType: string;
39
+ readonly size: number;
40
+ readonly sha256: string;
41
+ }
42
+ export interface AttachmentView {
43
+ readonly id: string;
44
+ readonly filename: string;
45
+ readonly contentType: string;
46
+ readonly size: number;
47
+ readonly sha256: string;
48
+ readonly state: string;
49
+ readonly createdAt: string;
50
+ readonly expiresAt: string;
51
+ readonly promotion: unknown;
52
+ }
53
+ export interface PromotionTarget {
54
+ /** The entity whose action adopts the file, such as `document`. */
55
+ readonly entity: string;
56
+ /** The action, such as `upload` or `create`. */
57
+ readonly action: string;
58
+ /** The action's input without the file; the field that takes the upload is filled in. */
59
+ readonly input: Readonly<Record<string, unknown>>;
60
+ /** The input field that takes the upload, when the action has more than one. */
61
+ readonly field?: string;
62
+ /** The record the action updates, for an action that sets the file on an existing record. */
63
+ readonly recordId?: string;
64
+ }
65
+ /** Runs the promotion's action as the caller, with the upload's options. */
66
+ export type PromotionRunner = (input: {
67
+ readonly entity: string;
68
+ readonly action: string;
69
+ readonly input: Readonly<Record<string, unknown>>;
70
+ readonly options: {
71
+ readonly idempotencyKey: string;
72
+ readonly createIntent?: string;
73
+ };
74
+ }) => Effect.Effect<{
75
+ readonly result: unknown;
76
+ readonly edits: ReadonlyArray<unknown>;
77
+ }, unknown>;
78
+ /** The fields of an action's input that take an upload, with the property each one writes. */
79
+ export declare function uploadFields(input: Schema.Top): ReadonlyArray<{
80
+ readonly field: string;
81
+ readonly entity: string;
82
+ readonly property: string;
83
+ }>;
84
+ /** Persisted phases surround every provider call; no external I/O holds a database transaction. */
85
+ export declare function attachmentRuntime(ontology: Ontology, sql: SqlClient.SqlClient, provider: BlobProvider["Service"]): {
86
+ receive: (input: ReceiveAttachment, caller: AttachmentCaller) => Effect.Effect<{
87
+ attachment: AttachmentView;
88
+ upload?: undefined;
89
+ } | {
90
+ attachment: AttachmentView;
91
+ upload: TransferGrant;
92
+ }, ActionRejected | PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
93
+ complete: (id: string, caller: AttachmentCaller) => Effect.Effect<AttachmentView, ActionRejected | PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
94
+ list: (caller: AttachmentCaller) => Effect.Effect<AttachmentView[], PermissionDenied | import("effect/unstable/sql/SqlError").SqlError, never>;
95
+ read: (id: string, caller: AttachmentCaller) => Effect.Effect<{
96
+ attachment: AttachmentView;
97
+ download: TransferGrant;
98
+ }, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
99
+ discard: (id: string, caller: AttachmentCaller) => Effect.Effect<AttachmentView, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
100
+ promote: (id: string, target: PromotionTarget, caller: AttachmentCaller, run: PromotionRunner) => Effect.Effect<{
101
+ attachment: AttachmentView;
102
+ deduplicated: boolean;
103
+ result: {} | null;
104
+ }, unknown, never>;
105
+ };
106
+ export type AttachmentRuntime = ReturnType<typeof attachmentRuntime>;
107
+ export type AttachmentFailure = ActionRejected | Conflict | NotFound | PermissionDenied;
108
+ /**
109
+ * Expires attachments past their deadline and removes the bytes of every expired or discarded one
110
+ * that no record adopted. A revision a promotion ticket still points at is left to the upload
111
+ * maintenance, once the ticket is canceled. Runs from the storage maintenance schedule, so files
112
+ * expire while the agent is idle.
113
+ */
114
+ export declare function maintainAttachments(sql: SqlClient.SqlClient, provider: BlobProvider["Service"], limit?: number): Effect.Effect<{
115
+ expired: number;
116
+ removed: number;
117
+ }, import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;