@segmentstream-cloud/ontology 0.19.0 → 0.20.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.
@@ -0,0 +1,14 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "effect/unstable/http";
3
+ import { type SessionExchange, type TokenVerifier } from "../authentication.js";
4
+ import { RequestId } from "../errors.js";
5
+ import { OntologyBackend } from "../runtime-contract.js";
6
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
7
+ import { type ConversationTransport } from "./conversations.js";
8
+ /** Same member scope as MCP, accessible to app clients with their usual bearer/session. */
9
+ export declare function conversationRoutes(options: {
10
+ readonly transport: ConversationTransport;
11
+ readonly verifyToken: TokenVerifier;
12
+ readonly verifyRuntime?: RuntimeTokenVerifier | undefined;
13
+ readonly exchangeSession?: SessionExchange | undefined;
14
+ }): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
@@ -0,0 +1,43 @@
1
+ import { Effect, Layer, Schema } from "effect";
2
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
+ import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
+ import { CurrentPrincipal, withPrincipal, } from "../authentication.js";
5
+ import { RequestId, expectedFailure, errorBodyForWire } from "../errors.js";
6
+ import { identityToken } from "../operations.js";
7
+ import { OntologyBackend } from "../runtime-contract.js";
8
+ import { runtimeCredential } from "../runtime-identity.js";
9
+ import { ConversationSearch } from "./conversations.js";
10
+ /** Same member scope as MCP, accessible to app clients with their usual bearer/session. */
11
+ export function conversationRoutes(options) {
12
+ return Layer.effectDiscard(Effect.gen(function* () {
13
+ const router = yield* HttpRouter.HttpRouter;
14
+ const backend = yield* OntologyBackend;
15
+ yield* router.add("POST", "/api/ontology/agent/conversations/search", (request) => Effect.gen(function* () {
16
+ const bearer = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.bearer).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request));
17
+ const operation = Effect.gen(function* () {
18
+ const search = yield* Schema.decodeUnknownEffect(ConversationSearch)(yield* request.json);
19
+ const principal = yield* CurrentPrincipal;
20
+ const page = yield* options.transport({
21
+ search,
22
+ principal,
23
+ identityToken: identityToken(request),
24
+ });
25
+ return HttpServerResponse.jsonUnsafe(page, {
26
+ headers: { "cache-control": "private, no-store" },
27
+ });
28
+ }).pipe(Effect.catch((error) => Effect.gen(function* () {
29
+ const requestId = yield* RequestId;
30
+ const failure = expectedFailure(error, requestId);
31
+ return HttpServerResponse.jsonUnsafe(errorBodyForWire(failure?.body ?? {
32
+ error: "invalid_request",
33
+ detail: "The conversation request is invalid.",
34
+ requestId,
35
+ }), { status: failure?.status ?? 400 });
36
+ })));
37
+ return yield* withPrincipal(bearer, options.verifyToken, backend, operation, {
38
+ runtime: runtimeCredential(options.verifyRuntime, request),
39
+ session: { request, exchange: options.exchangeSession },
40
+ });
41
+ }));
42
+ }));
43
+ }
@@ -0,0 +1,35 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { Principal } from "../../runtime.js";
3
+ import { PermissionDenied } from "../../errors.js";
4
+ import type { OntologyEnvironment } from "../config.js";
5
+ export declare const ConversationSearch: Schema.Struct<{
6
+ readonly q: Schema.optionalKey<Schema.String>;
7
+ readonly provider: Schema.optionalKey<Schema.String>;
8
+ readonly chatId: Schema.optionalKey<Schema.String>;
9
+ readonly threadId: Schema.optionalKey<Schema.String>;
10
+ readonly senderId: Schema.optionalKey<Schema.String>;
11
+ readonly from: Schema.optionalKey<Schema.String>;
12
+ readonly to: Schema.optionalKey<Schema.String>;
13
+ readonly before: Schema.optionalKey<Schema.String>;
14
+ readonly limit: Schema.optionalKey<Schema.Int>;
15
+ }>;
16
+ export type ConversationSearch = typeof ConversationSearch.Type;
17
+ export declare const ConversationPage: Schema.Struct<{
18
+ readonly events: Schema.$Array<Schema.$Record<Schema.String, Schema.Unknown>>;
19
+ readonly nextCursor: Schema.NullOr<Schema.String>;
20
+ }>;
21
+ export type ConversationPage = typeof ConversationPage.Type;
22
+ declare const ConversationArchiveUnavailable_base: Schema.Class<ConversationArchiveUnavailable, Schema.TaggedStruct<"ConversationArchiveUnavailable", {
23
+ readonly diagnostic: Schema.String;
24
+ readonly cause: Schema.optionalKey<Schema.Unknown>;
25
+ }>, import("effect/Cause").YieldableError>;
26
+ export declare class ConversationArchiveUnavailable extends ConversationArchiveUnavailable_base {
27
+ }
28
+ export type ConversationTransport = (input: {
29
+ readonly search: ConversationSearch;
30
+ readonly principal: Principal;
31
+ readonly identityToken: string | undefined;
32
+ }) => Effect.Effect<ConversationPage, ConversationArchiveUnavailable | PermissionDenied>;
33
+ /** The authenticated ontology service forwards its resolved member, never a caller-supplied ID. */
34
+ export declare function platformConversations(environment: OntologyEnvironment, fetchImpl?: typeof fetch): ConversationTransport;
35
+ export {};
@@ -0,0 +1,68 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { PermissionDenied } from "../../errors.js";
3
+ import { isAllowed } from "../../permissions.js";
4
+ import { describeError } from "../aggregate-platform.js";
5
+ const Filter = Schema.String.check(Schema.isMaxLength(1000));
6
+ export const ConversationSearch = Schema.Struct({
7
+ q: Schema.optionalKey(Filter),
8
+ provider: Schema.optionalKey(Filter),
9
+ chatId: Schema.optionalKey(Filter),
10
+ threadId: Schema.optionalKey(Filter),
11
+ senderId: Schema.optionalKey(Filter),
12
+ from: Schema.optionalKey(Filter),
13
+ to: Schema.optionalKey(Filter),
14
+ before: Schema.optionalKey(Filter),
15
+ limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 }))),
16
+ });
17
+ export const ConversationPage = Schema.Struct({
18
+ events: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)),
19
+ nextCursor: Schema.NullOr(Schema.String),
20
+ });
21
+ export class ConversationArchiveUnavailable extends Schema.TaggedError()("ConversationArchiveUnavailable", { diagnostic: Schema.String, cause: Schema.optionalKey(Schema.Unknown) }) {
22
+ }
23
+ /** The authenticated ontology service forwards its resolved member, never a caller-supplied ID. */
24
+ export function platformConversations(environment, fetchImpl = fetch) {
25
+ return ({ search, principal, identityToken }) => {
26
+ if (principal.kind !== "member") {
27
+ return Effect.fail(new PermissionDenied({
28
+ message: "Only members may read conversations.",
29
+ permission: "conversation:participant",
30
+ }));
31
+ }
32
+ if (!identityToken) {
33
+ return Effect.fail(new ConversationArchiveUnavailable({ diagnostic: "runtime_identity_missing" }));
34
+ }
35
+ return Effect.tryPromise({
36
+ try: async (signal) => {
37
+ const base = `${environment.segmentstreamUrl}/api/internal/v1/workspaces/`;
38
+ const response = await fetchImpl(`${base}${encodeURIComponent(environment.workspaceId)}/agent/conversations/search`, {
39
+ method: "POST",
40
+ redirect: "error",
41
+ headers: {
42
+ "content-type": "application/json",
43
+ "x-segmentstream-organization-id": environment.organizationId,
44
+ "x-segmentstream-runtime-identity": identityToken,
45
+ },
46
+ body: JSON.stringify({
47
+ search,
48
+ reader: {
49
+ participantId: principal.id,
50
+ groups: isAllowed("agent_chat:read", principal),
51
+ admin: false,
52
+ },
53
+ }),
54
+ signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
55
+ });
56
+ if (!response.ok) {
57
+ throw new Error(`Conversation archive returned HTTP ${response.status}; ` +
58
+ `request ${response.headers.get("x-request-id") ?? "unknown"}.`);
59
+ }
60
+ return Schema.decodeUnknownSync(ConversationPage)(await response.json());
61
+ },
62
+ catch: (cause) => new ConversationArchiveUnavailable({ diagnostic: "archive_request_failed", cause }),
63
+ }).pipe(Effect.tapError((error) => Effect.logWarning("ontology.conversations.read_failed").pipe(Effect.annotateLogs({
64
+ diagnostic: error.diagnostic,
65
+ error: describeError(error.cause, (text) => text.split(identityToken).join("[redacted]")),
66
+ }))));
67
+ };
68
+ }
@@ -385,6 +385,17 @@ export function expectedFailure(error, requestId) {
385
385
  },
386
386
  };
387
387
  }
388
+ if (Predicate.isTagged(error, "ConversationArchiveUnavailable")) {
389
+ return {
390
+ status: 502,
391
+ body: {
392
+ error: "conversation_archive_unavailable",
393
+ detail: "The conversation archive is unavailable. Try again shortly.",
394
+ requestId,
395
+ retryable: true,
396
+ },
397
+ };
398
+ }
388
399
  if (Predicate.isTagged(error, "AggregateUpstreamUnavailable")) {
389
400
  const { reason } = error;
390
401
  return {
@@ -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 ConversationTransport } from "../agent/conversations.js";
7
8
  import type { AgentChannelFactory } from "../agent/channel.js";
8
9
  import type { ConnectionTransportFactory } from "../connections.js";
9
10
  import { RequestId } from "../errors.js";
@@ -26,6 +27,7 @@ export interface McpServerOptions {
26
27
  readonly aggregates: Aggregates;
27
28
  /** The workspace's agent, for `agent_chat.send_message`; `undefined` without one. */
28
29
  readonly agentChannel?: AgentChannelFactory | undefined;
30
+ readonly conversations?: ConversationTransport | undefined;
29
31
  }
30
32
  /**
31
33
  * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
@@ -6,6 +6,7 @@ import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
6
6
  import { SCOPE_MISMATCH } from "../aggregate-platform.js";
7
7
  import { CurrentPrincipal, withPrincipal } from "../authentication.js";
8
8
  import { catalogFor } from "../catalog.js";
9
+ import { ConversationSearch } from "../agent/conversations.js";
9
10
  import { describeCatalog, measureDescription, measuresFor, RECORDS_OR_AGGREGATES, } from "../../render.js";
10
11
  import { RequestId, errorBodyForWire, expectedFailure, internalError, schemaIssues, } from "../errors.js";
11
12
  import { publicOrigin } from "../oauth/protocol.js";
@@ -89,7 +90,11 @@ function encoded(schema, value) {
89
90
  return Schema.encodeUnknownEffect(schema)(value).pipe(Effect.orDie);
90
91
  }
91
92
  const GetArguments = Schema.Struct({ id: IdParam });
92
- const HistoryArguments = Schema.Struct({ id: IdParam, ...HistoryParams });
93
+ const HistoryArguments = Schema.Struct({
94
+ id: IdParam,
95
+ ...HistoryParams,
96
+ conversation_before: Schema.optionalKey(Schema.String),
97
+ });
93
98
  function structured(value) {
94
99
  return { text: JSON.stringify(value), structured: value };
95
100
  }
@@ -163,7 +168,7 @@ function toolFailure(cause, requestId) {
163
168
  isError: true,
164
169
  }));
165
170
  }
166
- function runTool({ built, operations, aggregate }, tool, args, request) {
171
+ function runTool({ built, operations, aggregate, conversations }, tool, args, request) {
167
172
  return Effect.gen(function* () {
168
173
  const { target } = tool;
169
174
  switch (target.kind) {
@@ -191,9 +196,30 @@ function runTool({ built, operations, aggregate }, tool, args, request) {
191
196
  const { id } = yield* decodeArguments(GetArguments, args);
192
197
  return structured(yield* encoded(schemas.record, yield* operations.get(target.planned, id)));
193
198
  }
199
+ case "conversation-search": {
200
+ const search = yield* decodeArguments(ConversationSearch, args);
201
+ const principal = yield* CurrentPrincipal;
202
+ return structured(yield* conversations({ search, principal, identityToken: identityToken(request) }));
203
+ }
194
204
  case "history": {
195
- const { id, ...params } = yield* decodeArguments(HistoryArguments, args);
196
- return structured(yield* encoded(HistoryPage, yield* operations.history(target.planned, id, params)));
205
+ const { id, conversation_before, ...params } = yield* decodeArguments(HistoryArguments, args);
206
+ const audit = yield* encoded(HistoryPage, yield* operations.history(target.planned, id, params));
207
+ if (target.planned.name !== "agent_chat" || conversations === undefined) {
208
+ return structured(audit);
209
+ }
210
+ const chat = yield* operations.get(target.planned, id);
211
+ const principal = yield* CurrentPrincipal;
212
+ const conversation = yield* conversations({
213
+ principal,
214
+ identityToken: identityToken(request),
215
+ search: {
216
+ provider: String(chat.provider),
217
+ chatId: String(chat.external_id),
218
+ ...(params.limit === undefined ? {} : { limit: params.limit }),
219
+ ...(conversation_before === undefined ? {} : { before: conversation_before }),
220
+ },
221
+ });
222
+ return structured({ ...audit, conversation });
197
223
  }
198
224
  case "action": {
199
225
  const { [ACTION_OPTIONS_ARGUMENT]: rawOptions, ...input } = args;
@@ -433,7 +459,7 @@ function handlePost(server, verifyToken, verifyRuntime, backend) {
433
459
  * authentication. `GET` and `DELETE`, which open a server stream and end a session, answer 405.
434
460
  */
435
461
  export function mcpLayer(options) {
436
- const tools = mcpTools(options.built);
462
+ const tools = mcpTools(options.built, options.conversations !== undefined);
437
463
  return Layer.effectDiscard(Effect.gen(function* () {
438
464
  const router = yield* HttpRouter.HttpRouter;
439
465
  const backend = yield* OntologyBackend;
@@ -442,6 +468,7 @@ export function mcpLayer(options) {
442
468
  tools,
443
469
  operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel),
444
470
  aggregate: options.aggregates.model,
471
+ conversations: options.conversations,
445
472
  };
446
473
  yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, options.verifyRuntime, backend));
447
474
  yield* router.add("GET", MCP_PATH, METHOD_NOT_ALLOWED);
@@ -26,6 +26,8 @@ export type McpToolTarget = {
26
26
  readonly kind: "me";
27
27
  } | {
28
28
  readonly kind: "aggregate";
29
+ } | {
30
+ readonly kind: "conversation-search";
29
31
  };
30
32
  /** A tool of the ontology's MCP server, derived from the same plan as the HTTP routes. */
31
33
  export interface McpTool {
@@ -58,7 +60,7 @@ export declare const ACTION_OPTIONS_ARGUMENT = "_options";
58
60
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
59
61
  * `check` already refuses.
60
62
  */
61
- export declare function mcpTools(built: BuiltApi): ReadonlyArray<McpTool>;
63
+ export declare function mcpTools(built: BuiltApi, conversations?: boolean): ReadonlyArray<McpTool>;
62
64
  /**
63
65
  * The tools a caller may see: those whose every permission the caller's roles grant, each with the
64
66
  * caller's input schema.
@@ -409,7 +409,7 @@ function aggregateTool(manifest) {
409
409
  * as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
410
410
  * `check` already refuses.
411
411
  */
412
- export function mcpTools(built) {
412
+ export function mcpTools(built, conversations = false) {
413
413
  const { manifest } = built.plan.ontology;
414
414
  const tools = [
415
415
  ...GLOBAL_TOOLS,
@@ -423,6 +423,50 @@ export function mcpTools(built) {
423
423
  tools.push(...readTools(planned, schemas, built));
424
424
  tools.push(...planned.actions.map((action) => actionTool(planned, action)));
425
425
  }
426
+ if (conversations) {
427
+ const index = tools.findIndex((tool) => tool.name === "agent_chat_history");
428
+ if (index >= 0) {
429
+ const tool = tools[index];
430
+ const rest = { ...tool };
431
+ delete rest.outputSchema;
432
+ tools[index] = {
433
+ ...rest,
434
+ description: tool.description +
435
+ " Also returns conversation.events: message texts, reply/silent decisions and reasons, " +
436
+ "tool calls/results and attachment references. Conversation pagination uses conversation_before " +
437
+ "separately from the audit after cursor.",
438
+ inputSchema: {
439
+ ...tool.inputSchema,
440
+ properties: {
441
+ ...tool.inputSchema.properties,
442
+ conversation_before: {
443
+ type: "string",
444
+ description: "conversation.nextCursor from the previous page.",
445
+ },
446
+ },
447
+ },
448
+ };
449
+ }
450
+ tools.push({
451
+ name: "agent_chat_search",
452
+ title: "Search stored conversations",
453
+ description: "Search retained conversation messages, decisions and tool calls by text, provider chat ID, " +
454
+ "sender ID, topic and date range. Returns only groups you may read (agent_chat:read) and DMs " +
455
+ "in which you participated. Dates are ISO 8601; before is the nextCursor of the previous page. " +
456
+ "Workspace administrators can read all DMs through the platform API.",
457
+ inputSchema: {
458
+ type: "object",
459
+ additionalProperties: false,
460
+ properties: {
461
+ ...Object.fromEntries(["q", "provider", "chatId", "threadId", "senderId", "from", "to", "before"].map((key) => [key, { type: "string", maxLength: 1000 }])),
462
+ limit: { type: "integer", minimum: 1, maximum: 200 },
463
+ },
464
+ },
465
+ annotations: READ,
466
+ permissions: [],
467
+ target: { kind: "conversation-search" },
468
+ });
469
+ }
426
470
  const seen = new Set();
427
471
  const duplicates = tools.filter(({ name }) => seen.size === seen.add(name).size);
428
472
  if (duplicates.length > 0) {
@@ -5,6 +5,8 @@ import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-au
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
7
  import { attachmentsLayer } from "./agent/attachments.js";
8
+ import { platformConversations } from "./agent/conversations.js";
9
+ import { conversationRoutes } from "./agent/conversation-routes.js";
8
10
  import { deployedAgentChannel } from "./agent/channel.js";
9
11
  import { agentLayer } from "./agent/server.js";
10
12
  import { makeAggregates } from "./aggregate.js";
@@ -95,6 +97,7 @@ export function createOntologyService(ontology, config) {
95
97
  const agentChannel = config.runtimeIdentity === undefined || config.agentUrl === undefined
96
98
  ? undefined
97
99
  : deployedAgentChannel(config.agentUrl);
100
+ const conversations = config.runtimeIdentity === undefined ? undefined : platformConversations(config);
98
101
  const aggregates = makeAggregates({
99
102
  manifest: ontology.manifest,
100
103
  transport: platformAggregates({ ...config, manifestHash: ontology.manifest.hash }),
@@ -115,6 +118,7 @@ export function createOntologyService(ontology, config) {
115
118
  replicate,
116
119
  aggregates,
117
120
  agentChannel,
121
+ conversations,
118
122
  }), oauthLayer({ verifyToken }));
119
123
  const agent = agentLayer({ verifyToken, verifyRuntime, exchangeSession });
120
124
  const attachments = attachmentsLayer({
@@ -126,7 +130,14 @@ export function createOntologyService(ontology, config) {
126
130
  verifyToken,
127
131
  verifyRuntime,
128
132
  });
129
- const application = Layer.mergeAll(routes, mcp, agent, attachments, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
133
+ const application = Layer.mergeAll(routes, mcp, agent, attachments, conversations === undefined
134
+ ? Layer.empty
135
+ : conversationRoutes({
136
+ transport: conversations,
137
+ verifyToken,
138
+ verifyRuntime,
139
+ exchangeSession,
140
+ }), DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
130
141
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
131
142
  const hash = ontology.manifest.hash;
132
143
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",