@manta-eu/horns-server 0.1.4 → 0.2.1

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.
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import { type UpstreamFetcher } from "./agents-proxy.ts";
2
3
  import type { HornRoutesEnv } from "./horn-routes.ts";
3
4
  import type { MantaSessionState } from "./session.ts";
4
5
  export declare const planNotFoundMessage = "There is no plan like that in this datastore.";
@@ -12,6 +13,7 @@ export type PlanRunResult = {
12
13
  };
13
14
  export declare function executeActionPlan(args: {
14
15
  agentsUrl: string | undefined;
16
+ upstream?: UpstreamFetcher | undefined;
15
17
  session: MantaSessionState;
16
18
  datastoreId: string;
17
19
  plan: string;
@@ -24,7 +24,7 @@ const executeAnswerSchema = z.object({
24
24
  error: z.string().nullish(),
25
25
  });
26
26
  export async function executeActionPlan(args) {
27
- const sent = await agentsGraphql({ agentsUrl: args.agentsUrl, session: args.session, datastoreId: args.datastoreId }, executeActionPlanMutation, { input: { namedGraphIri: args.plan, dryRun: args.preview } }, "executeActionPlan");
27
+ const sent = await agentsGraphql({ agentsUrl: args.agentsUrl, upstream: args.upstream, session: args.session, datastoreId: args.datastoreId }, executeActionPlanMutation, { input: { namedGraphIri: args.plan, dryRun: args.preview } }, "executeActionPlan");
28
28
  // Being signed out and having no address are about the caller, so they are said plainly. What agents
29
29
  // answered is about the plan, so it is said as such.
30
30
  if (!sent.ok) {
@@ -58,6 +58,7 @@ export function actionPlanRoutes() {
58
58
  try {
59
59
  const found = await agentsSparqlSelect({
60
60
  agentsUrl: c.env.MANTA_AGENTS_URL,
61
+ upstream: c.env.UPSTREAM,
61
62
  session: authorized.session,
62
63
  datastoreId,
63
64
  })(isPlanQuery(realm, body.data.plan), undefined);
@@ -69,6 +70,7 @@ export function actionPlanRoutes() {
69
70
  }
70
71
  const started = await executeActionPlan({
71
72
  agentsUrl: c.env.MANTA_AGENTS_URL,
73
+ upstream: c.env.UPSTREAM,
72
74
  session: authorized.session,
73
75
  datastoreId,
74
76
  plan: body.data.plan,
@@ -1,9 +1,18 @@
1
1
  import { type MantaSessionState } from "./session.ts";
2
2
  export { checkReadOnlySparql, crossOriginBlockedMessage, evaluateProxyPolicy, isAllowedProxyPath, isAuthProxyPath, isReadOnlySparql, originAllowsRequest, proxyMethodBlockedMessage, proxyPathBlockedMessage, sameOriginDeniedResponse, sparqlEmptyMessage, sparqlFederationBlockedMessage, sparqlParseFailedMessage, sparqlWriteBlockedMessage, } from "./manta-proxy-policy.ts";
3
3
  export declare const missingAgentsUrlMessage = "MANTA_AGENTS_URL is not set \u2014 the live data source has no address.";
4
+ /**
5
+ * How this worker reaches the upstream, where a host has a way of its own. A host that is the upstream passes
6
+ * itself, so a call stays in the process rather than leaving through the public hostname; a service binding fits too.
7
+ */
8
+ export type UpstreamFetcher = {
9
+ fetch: (request: Request) => Response | Promise<Response>;
10
+ };
11
+ export declare function fetchUpstream(upstream: UpstreamFetcher | undefined, url: URL, init: RequestInit): Promise<Response>;
4
12
  export declare const signedOutMessage = "Not signed in \u2014 sign in to Manta to reach the live data source.";
5
13
  export type AgentsCaller = {
6
14
  agentsUrl: string | undefined;
15
+ upstream?: UpstreamFetcher | undefined;
7
16
  session: MantaSessionState;
8
17
  datastoreId: string;
9
18
  };
@@ -5,6 +5,9 @@ import { forwardedOriginHeader, logoutPath } from "./auth-routes.js";
5
5
  import { captureUpstreamCookies, clearSessionSetCookies, isSignedIn, requestIsSecure, sessionSetCookies, sessionsDiffer, setCookieHeadersOf, upstreamCookieHeader, } from "./session.js";
6
6
  export { checkReadOnlySparql, crossOriginBlockedMessage, evaluateProxyPolicy, isAllowedProxyPath, isAuthProxyPath, isReadOnlySparql, originAllowsRequest, proxyMethodBlockedMessage, proxyPathBlockedMessage, sameOriginDeniedResponse, sparqlEmptyMessage, sparqlFederationBlockedMessage, sparqlParseFailedMessage, sparqlWriteBlockedMessage, } from "./manta-proxy-policy.js";
7
7
  export const missingAgentsUrlMessage = "MANTA_AGENTS_URL is not set — the live data source has no address.";
8
+ export async function fetchUpstream(upstream, url, init) {
9
+ return isPresent(upstream) ? upstream.fetch(new Request(url, init)) : fetch(url, init);
10
+ }
8
11
  export const signedOutMessage = "Not signed in — sign in to Manta to reach the live data source.";
9
12
  // The one viewer-attributed GraphQL call. Both writers the worker has — staging a rule, uploading a file —
10
13
  // go through it, so a timeout or a request id is added once. `status` is carried for the caller that answers
@@ -13,7 +16,7 @@ export async function agentsGraphql(caller, query, variables, field, signal) {
13
16
  if (!isPresent(caller.agentsUrl))
14
17
  return { ok: false, status: 500, error: missingAgentsUrlMessage };
15
18
  const url = agentsPathUrl(caller.agentsUrl, "/graphql");
16
- const response = await fetch(url, {
19
+ const response = await fetchUpstream(caller.upstream, url, {
17
20
  method: "POST",
18
21
  headers: {
19
22
  cookie: upstreamCookieHeader(caller.session),
@@ -1,6 +1,8 @@
1
+ import { type UpstreamFetcher } from "./agents-proxy.ts";
1
2
  import { type MantaSessionState } from "./session.ts";
2
3
  export type DatastoreAccessEnv = {
3
4
  MANTA_AGENTS_URL?: string;
5
+ UPSTREAM?: UpstreamFetcher;
4
6
  };
5
7
  export type DatastoreAccess = {
6
8
  active?: string;
@@ -1,6 +1,6 @@
1
1
  import { isPresent } from "@manta-eu/lib/present";
2
2
  import { isRecord } from "@manta-eu/horns";
3
- import { agentsPathUrl } from "./agents-proxy.js";
3
+ import { agentsPathUrl, fetchUpstream } from "./agents-proxy.js";
4
4
  import { CustomerDatastoresDocument } from "@manta-eu/client";
5
5
  import { upstreamCookieHeader } from "./session.js";
6
6
  export const datastoreNotGrantedMessage = "That datastore is not one this session can open.";
@@ -57,7 +57,7 @@ async function customerDatastores(env, cookie, active) {
57
57
  if (!isPresent(env.MANTA_AGENTS_URL))
58
58
  return [];
59
59
  try {
60
- const response = await fetch(agentsPathUrl(env.MANTA_AGENTS_URL, "/graphql"), {
60
+ const response = await fetchUpstream(env.UPSTREAM, agentsPathUrl(env.MANTA_AGENTS_URL, "/graphql"), {
61
61
  method: "POST",
62
62
  headers: {
63
63
  cookie,
@@ -93,7 +93,7 @@ export async function datastoreAccessFor(env, session) {
93
93
  const url = agentsPathUrl(env.MANTA_AGENTS_URL, "/api/data/context");
94
94
  let response;
95
95
  try {
96
- response = await fetch(url, { headers: { cookie, accept: "application/json" } });
96
+ response = await fetchUpstream(env.UPSTREAM, url, { headers: { cookie, accept: "application/json" } });
97
97
  }
98
98
  catch {
99
99
  return { ok: false, reason: "unavailable" };
@@ -3,5 +3,8 @@ export type DevSessionEnv = {
3
3
  DEV_AGENTS_COOKIE?: string;
4
4
  /** A host that signs people in itself names its own session cookies here. Defaults to Horns' own. */
5
5
  HORNS_SESSION_COOKIE_PREFIX?: string;
6
+ /** `upstream` when the host is the upstream itself: the session is its own sign-in cookies, read as they are. */
7
+ HORNS_SESSION_SOURCE?: string;
6
8
  };
9
+ export declare const upstreamSessionSource = "upstream";
7
10
  export declare function requestSession(request: Request, env: DevSessionEnv): MantaSessionState | undefined;
@@ -1,10 +1,27 @@
1
1
  import { isPresent } from "@manta-eu/lib/present";
2
- import { agentsJwtCookieName, parseCookieHeader, readSessionState, sessionCookiePrefixOf, } from "./session.js";
2
+ import { agentsJwtCookieName, isForwardableCookie, parseCookieHeader, readSessionState, sessionCookiePrefixOf, } from "./session.js";
3
+ export const upstreamSessionSource = "upstream";
4
+ /** The upstream's access token and the session id it refreshes it with: all a call needs from the browser. */
5
+ const upstreamSessionCookies = [agentsJwtCookieName, `${agentsJwtCookieName}_sid`];
6
+ function upstreamSession(request) {
7
+ const jar = parseCookieHeader(request.headers.get("cookie"));
8
+ const upstreamCookies = Object.fromEntries(upstreamSessionCookies.flatMap((name) => {
9
+ const value = jar[name];
10
+ return isPresent(value) && isForwardableCookie(name, value) ? [[name, value]] : [];
11
+ }));
12
+ return isPresent(upstreamCookies[agentsJwtCookieName]) ? { upstreamCookies } : undefined;
13
+ }
3
14
  const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
4
15
  // HireManta staging will not send a sign-in back to localhost, so `pnpm dev` can borrow a staging agents session
5
16
  // captured by `dev:borrow-session`. It stands in only for requests to this machine that carry no Horns session
6
17
  // yet (agents' token refreshes then land in a real one), and production builds drop it with import.meta.env.DEV.
7
18
  export function requestSession(request, env) {
19
+ if (isPresent(env.HORNS_SESSION_SOURCE)) {
20
+ if (env.HORNS_SESSION_SOURCE !== upstreamSessionSource) {
21
+ throw new Error(`HORNS_SESSION_SOURCE must be "${upstreamSessionSource}" or unset, not "${env.HORNS_SESSION_SOURCE}".`);
22
+ }
23
+ return upstreamSession(request);
24
+ }
8
25
  const session = readSessionState(request.headers.get("cookie"), sessionCookiePrefixOf(env));
9
26
  if (isPresent(session) || !import.meta.env.DEV || !isPresent(env.DEV_AGENTS_COOKIE))
10
27
  return session;
package/dist/env.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { HornRoutesEnv } from "./horn-routes.ts";
2
2
  import type { StudioRoutesEnv } from "./studio-routes.ts";
3
- import type { StudioModelEnv } from "./llm.ts";
3
+ import type { StudioModelEnv } from "./studio-model.ts";
4
+ import type { HornStudioAgent } from "./studio-agent-class.ts";
4
5
  /** Everything the assembled worker reads. A host mounting only some routes needs only those routes' env. */
5
6
  export type Env = HornRoutesEnv & StudioRoutesEnv & StudioModelEnv & {
7
+ /** The Studio conversation object, where a host binds one. */
8
+ HORN_STUDIO?: DurableObjectNamespace<HornStudioAgent>;
6
9
  SITE_PASSWORD?: string;
7
10
  SENTRY_DSN?: string;
8
11
  CF_VERSION_METADATA?: {
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { type RevisionLookup } from "@manta-eu/horns";
3
3
  import type { DevSessionEnv } from "./dev-session.ts";
4
+ import type { UpstreamFetcher } from "./agents-proxy.ts";
4
5
  export declare const maxScreenshotBytes: number;
5
6
  type AssetFetcher = {
6
7
  fetch: (request: Request) => Response | Promise<Response>;
@@ -8,6 +9,7 @@ type AssetFetcher = {
8
9
  export type HornRoutesEnv = DevSessionEnv & {
9
10
  ASSETS: AssetFetcher;
10
11
  MANTA_AGENTS_URL?: string;
12
+ UPSTREAM?: UpstreamFetcher;
11
13
  VITE_KEYCLOAK_REALM?: string;
12
14
  SPARQL_UPDATE_PSK?: string;
13
15
  };
@@ -43,6 +43,7 @@ async function realmLibraryLookup(args) {
43
43
  try {
44
44
  const results = await agentsSparqlSelect({
45
45
  agentsUrl: args.env.MANTA_AGENTS_URL,
46
+ upstream: args.env.UPSTREAM,
46
47
  session: args.session,
47
48
  datastoreId: args.realm,
48
49
  })(libraryItemsQuery(args.realm), undefined);
@@ -91,6 +92,7 @@ export function hornRoutes(deps = {}) {
91
92
  horn: args.body.horn,
92
93
  select: agentsSparqlSelect({
93
94
  agentsUrl: args.env.MANTA_AGENTS_URL,
95
+ upstream: args.env.UPSTREAM,
94
96
  session: args.session,
95
97
  datastoreId: args.datastoreId,
96
98
  }),
@@ -132,6 +134,7 @@ export function hornRoutes(deps = {}) {
132
134
  try {
133
135
  const found = await agentsSparqlSelect({
134
136
  agentsUrl: c.env.MANTA_AGENTS_URL,
137
+ upstream: c.env.UPSTREAM,
135
138
  session: authorized.session,
136
139
  datastoreId,
137
140
  })(hornsInDatastoreQuery(realm), undefined);
@@ -172,6 +175,7 @@ export function hornRoutes(deps = {}) {
172
175
  return refuse(500, "This deployment has no realm configured.").response;
173
176
  const select = agentsSparqlSelect({
174
177
  agentsUrl: c.env.MANTA_AGENTS_URL,
178
+ upstream: c.env.UPSTREAM,
175
179
  session: authorized.session,
176
180
  datastoreId,
177
181
  });
@@ -215,11 +219,13 @@ export function hornRoutes(deps = {}) {
215
219
  const slug = c.req.param("slug");
216
220
  const select = agentsSparqlSelect({
217
221
  agentsUrl: c.env.MANTA_AGENTS_URL,
222
+ upstream: c.env.UPSTREAM,
218
223
  session: authorized.session,
219
224
  datastoreId,
220
225
  });
221
226
  const update = agentsSparqlUpdate({
222
227
  agentsUrl: c.env.MANTA_AGENTS_URL,
228
+ upstream: c.env.UPSTREAM,
223
229
  datastoreId,
224
230
  updateKey: c.env.SPARQL_UPDATE_PSK,
225
231
  purpose: { kind: "horn-save", realm },
@@ -478,6 +484,83 @@ export function hornRoutes(deps = {}) {
478
484
  * Two segments because one would not be safe: `about` is a legal slug, so `/horns/about` is a Horn
479
485
  * somebody may have saved, and `/horns/:slug` would answer for it either way.
480
486
  */
487
+ // Three fit beside a thing without pushing it off the screen; ten is where the reading cost stops being
488
+ // one query and starts being a page of them.
489
+ const defaultAboutLimit = 3;
490
+ const maxAboutLimit = 10;
491
+ /**
492
+ * Every Horn about a thing, for a host that has room for more than one.
493
+ *
494
+ * The singular route answers with one and counts the rest, which is what a panel beside a plan wants.
495
+ * A host showing a thing's whole story wants them all: `?limit=` caps how many come back whole, and
496
+ * `total` says how many there are. Nothing kept falls back the same way the singular route does, so an
497
+ * IRI nobody has made a Horn for still draws.
498
+ */
499
+ routes.get("/api/d/:datastoreId/horns/by/about/list", async (c) => {
500
+ const datastoreId = c.req.param("datastoreId");
501
+ const authorized = await authorize(c.req.raw, c.env, datastoreId);
502
+ if (!authorized.ok)
503
+ return authorized.response;
504
+ const realm = c.env.VITE_KEYCLOAK_REALM;
505
+ if (!isPresent(realm) || !isRealm(realm))
506
+ return refuse(500, "This deployment has no realm configured.").response;
507
+ const about = c.req.query("iri");
508
+ if (!isPresent(about))
509
+ return refuse(400, "Ask with ?iri=<the thing's IRI>.").response;
510
+ if (!isAbsoluteIri(about))
511
+ return refuse(400, `"${about}" is not an IRI.`).response;
512
+ const asked = Number(c.req.query("limit") ?? defaultAboutLimit);
513
+ if (!Number.isInteger(asked) || asked < 1 || asked > maxAboutLimit) {
514
+ return refuse(400, `limit is a whole number from 1 to ${maxAboutLimit}.`).response;
515
+ }
516
+ const select = agentsSparqlSelect({
517
+ agentsUrl: c.env.MANTA_AGENTS_URL,
518
+ upstream: c.env.UPSTREAM,
519
+ session: authorized.session,
520
+ datastoreId,
521
+ });
522
+ try {
523
+ const found = await select(hornsAboutQuery(realm, about), undefined);
524
+ // One row per revision, and a Horn can bring two of those, so the Horn is what is counted and the
525
+ // first row for each is the one read.
526
+ const firstRows = new Map();
527
+ for (const row of found.results.bindings) {
528
+ const hornIri = row["horn"]?.value;
529
+ if (isPresent(hornIri) && !firstRows.has(hornIri))
530
+ firstRows.set(hornIri, row);
531
+ }
532
+ if (firstRows.size === 0) {
533
+ const made = (await hornForRule({ select, realm, datastoreId, about })) ??
534
+ (await hornForPlan({ select, realm, datastoreId, about }));
535
+ return made === undefined
536
+ ? Response.json({ horns: [], total: 0 }, { headers: noStore })
537
+ : Response.json({ horns: [{ horn: made, kept: false }], total: 0 }, { headers: noStore });
538
+ }
539
+ const wanted = [...firstRows.entries()].slice(0, asked);
540
+ const read = await Promise.all(wanted.map(async ([hornIri, row]) => {
541
+ const revision = row["revision"]?.value;
542
+ if (!isPresent(revision))
543
+ return undefined;
544
+ const one = await hornAtRevision({ select, realm, hornIri, revision, number: Number(row["number"]?.value) });
545
+ if (one === undefined)
546
+ return undefined;
547
+ const slug = row["slug"]?.value;
548
+ return {
549
+ horn: one.horn,
550
+ kept: true,
551
+ revision,
552
+ contentHash: await contentHash(one.stored),
553
+ ...(isPresent(slug) ? { slug } : {}),
554
+ };
555
+ }));
556
+ // A revision that no longer reads as a Horn is left out rather than failing the lot: the others are
557
+ // still worth drawing, and `total` already says more exist.
558
+ return Response.json({ horns: read.filter(isPresent), total: firstRows.size }, { headers: noStore });
559
+ }
560
+ catch (caught) {
561
+ return refuse(502, caught instanceof Error ? caught.message : String(caught)).response;
562
+ }
563
+ });
481
564
  routes.get("/api/d/:datastoreId/horns/by/about", async (c) => {
482
565
  const datastoreId = c.req.param("datastoreId");
483
566
  const authorized = await authorize(c.req.raw, c.env, datastoreId);
@@ -496,6 +579,7 @@ export function hornRoutes(deps = {}) {
496
579
  return refuse(400, `"${about}" is not an IRI.`).response;
497
580
  const select = agentsSparqlSelect({
498
581
  agentsUrl: c.env.MANTA_AGENTS_URL,
582
+ upstream: c.env.UPSTREAM,
499
583
  session: authorized.session,
500
584
  datastoreId,
501
585
  });
@@ -590,6 +674,7 @@ export function hornRoutes(deps = {}) {
590
674
  const slug = c.req.param("slug");
591
675
  const select = agentsSparqlSelect({
592
676
  agentsUrl: c.env.MANTA_AGENTS_URL,
677
+ upstream: c.env.UPSTREAM,
593
678
  session: authorized.session,
594
679
  datastoreId,
595
680
  });
@@ -601,12 +686,13 @@ export function hornRoutes(deps = {}) {
601
686
  if (!isPresent(revision))
602
687
  return refuse(404, `There is no Horn called "${slug}" here.`).response;
603
688
  const number = Number(current?.["number"]?.value) || 1;
604
- const uploaded = await uploadPng({ agentsUrl: c.env.MANTA_AGENTS_URL, session: authorized.session, datastoreId }, { fileName: `horn-${slug}-r${number}.png`, bytes });
689
+ const uploaded = await uploadPng({ agentsUrl: c.env.MANTA_AGENTS_URL, upstream: c.env.UPSTREAM, session: authorized.session, datastoreId }, { fileName: `horn-${slug}-r${number}.png`, bytes });
605
690
  if (!uploaded.ok)
606
691
  return refuse(uploaded.status, uploaded.error).response;
607
692
  const capturedAt = (deps.now?.() ?? new Date()).toISOString();
608
693
  await agentsSparqlUpdate({
609
694
  agentsUrl: c.env.MANTA_AGENTS_URL,
695
+ upstream: c.env.UPSTREAM,
610
696
  datastoreId,
611
697
  updateKey: c.env.SPARQL_UPDATE_PSK,
612
698
  purpose: { kind: "horn-save", realm },
package/dist/index.d.ts CHANGED
@@ -6,14 +6,16 @@ export { studioRoutes, maxHistoryTurns, type StudioRoutesDeps, type StudioRoutes
6
6
  export { actionPlanRoutes } from "./action-plan-routes.ts";
7
7
  export { authUnavailableResponse, callbackPath, localSignOutResponse, loginPath, loginRequestWithMarker, logoutPath, logoutResponseReturningTo, } from "./auth-routes.ts";
8
8
  export { signedInParam } from "./auth-params.ts";
9
- export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.ts";
9
+ export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage, type UpstreamFetcher, } from "./agents-proxy.ts";
10
+ export { contentSecurityPolicy } from "./csp.ts";
10
11
  export { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, resetDatastoreAccessCache, } from "./datastore-access.ts";
11
12
  export { agentsSparqlSelect } from "./sparql-select.ts";
12
13
  export { agentsSparqlUpdate, WriteRefusedError } from "./sparql-update.ts";
13
- export { criticModelFromEnv, studioModelFromEnv, type StudioModelEnv, type StudioModelResult } from "./llm.ts";
14
+ export { criticModel, studioModel, type StudioModelEnv, type StudioModelChoice } from "./studio-model.ts";
14
15
  export { badSessionCookiePrefixMessage, isSignedIn, sessionCookiePrefix, sessionCookiePrefixOf, sessionSetCookies, type MantaSessionState, } from "./session.ts";
15
16
  export { requestSession, type DevSessionEnv } from "./dev-session.ts";
16
17
  export { hornTemplatePath, renderHornDocument } from "./render-document.ts";
17
18
  export { sentryOptionsForDeploy } from "./sentry.ts";
18
19
  export { withoutPiiErrorEvent } from "./pii.ts";
19
20
  export { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.ts";
21
+ export { authorizeStudioConversation, studioConversationName, studioOpening, type StudioAgentProps, type StudioConversationEnv, type StudioOpening, } from "./studio-opening.ts";
package/dist/index.js CHANGED
@@ -5,16 +5,20 @@ export { studioRoutes, maxHistoryTurns } from "./studio-routes.js";
5
5
  export { actionPlanRoutes } from "./action-plan-routes.js";
6
6
  export { authUnavailableResponse, callbackPath, localSignOutResponse, loginPath, loginRequestWithMarker, logoutPath, logoutResponseReturningTo, } from "./auth-routes.js";
7
7
  export { signedInParam } from "./auth-params.js";
8
- export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage } from "./agents-proxy.js";
8
+ export { proxyToMantaAgents, sameOriginDeniedResponse, signedOutMessage, } from "./agents-proxy.js";
9
+ // A host serving Horns' pages from its own documents sends the same policy, since connect-src 'self' is part
10
+ // of what holds a written view in.
11
+ export { contentSecurityPolicy } from "./csp.js";
9
12
  export { datastoreAccessFor, datastoreAccessUnavailableMessage, datastoreNotGrantedMessage, forgetDatastoreAccess, grantsDatastore, resetDatastoreAccessCache, } from "./datastore-access.js";
10
13
  export { agentsSparqlSelect } from "./sparql-select.js";
11
14
  export { agentsSparqlUpdate, WriteRefusedError } from "./sparql-update.js";
12
- // Also its own entry, `@manta-eu/horns-server/llm`: building a model client must not drag in the render
15
+ // Also its own entry, `@manta-eu/horns-server/studio-model`: building a model must not drag in the render
13
16
  // path, which is JSX and which a Node script cannot load.
14
- export { criticModelFromEnv, studioModelFromEnv } from "./llm.js";
17
+ export { criticModel, studioModel } from "./studio-model.js";
15
18
  export { badSessionCookiePrefixMessage, isSignedIn, sessionCookiePrefix, sessionCookiePrefixOf, sessionSetCookies, } from "./session.js";
16
19
  export { requestSession } from "./dev-session.js";
17
20
  export { hornTemplatePath, renderHornDocument } from "./render-document.js";
18
21
  export { sentryOptionsForDeploy } from "./sentry.js";
19
22
  export { withoutPiiErrorEvent } from "./pii.js";
20
23
  export { asJsonRequest, followedLink, noPageToOpen, signInFirst } from "./entity-links.js";
24
+ export { authorizeStudioConversation, studioConversationName, studioOpening, } from "./studio-opening.js";
@@ -1,4 +1,5 @@
1
1
  import { type WritePurpose } from "@manta-eu/horns";
2
+ import { type UpstreamFetcher } from "./agents-proxy.ts";
2
3
  export declare const updateKeyHeader = "x-sparql-update-key";
3
4
  export declare const missingUpdateKeyMessage = "SPARQL_UPDATE_PSK is not set \u2014 this deployment cannot write.";
4
5
  export declare class WriteRefusedError extends Error {
@@ -7,6 +8,7 @@ export declare class WriteRefusedError extends Error {
7
8
  export type SparqlUpdate = (update: string) => Promise<void>;
8
9
  export declare function agentsSparqlUpdate(args: {
9
10
  agentsUrl: string | undefined;
11
+ upstream?: UpstreamFetcher | undefined;
10
12
  datastoreId: string;
11
13
  updateKey: string | undefined;
12
14
  purpose: WritePurpose;
@@ -1,6 +1,6 @@
1
1
  import { isPresent } from "@manta-eu/lib/present";
2
2
  import { checkWrite } from "@manta-eu/horns";
3
- import { agentsPathUrl, missingAgentsUrlMessage } from "./agents-proxy.js";
3
+ import { agentsPathUrl, fetchUpstream, missingAgentsUrlMessage } from "./agents-proxy.js";
4
4
  // Agents' `/d/:ds/sparql-update` authorizes on the pre-shared key alone and does not restrict `GRAPH`, so this
5
5
  // worker is the write-authorization boundary. The guard runs here rather than at each call site: there is one
6
6
  // way to send an update and it cannot be the way that forgot to check.
@@ -23,7 +23,7 @@ export function agentsSparqlUpdate(args) {
23
23
  if (args.updateKey === undefined)
24
24
  throw new Error(missingUpdateKeyMessage);
25
25
  const url = agentsPathUrl(args.agentsUrl, `/d/${encodeURIComponent(args.datastoreId)}/sparql-update`);
26
- const response = await fetch(url, {
26
+ const response = await fetchUpstream(args.upstream, url, {
27
27
  method: "POST",
28
28
  headers: {
29
29
  [updateKeyHeader]: args.updateKey,
@@ -0,0 +1,41 @@
1
+ import { Think } from "@cloudflare/think";
2
+ import type { Connection, ConnectionContext } from "agents";
3
+ import { type LanguageModel, type ModelMessage } from "ai";
4
+ import { type Horn } from "@manta-eu/horns";
5
+ import { type StudioMode } from "@manta-eu/horns/studio";
6
+ import type { UpstreamFetcher } from "./agents-proxy.ts";
7
+ import { type StudioModelEnv } from "./studio-model.ts";
8
+ export { studioOpening, type StudioAgentProps, type StudioOpening } from "./studio-opening.ts";
9
+ export type StudioAgentEnv = StudioModelEnv & {
10
+ MANTA_AGENTS_URL?: string;
11
+ VITE_KEYCLOAK_REALM?: string;
12
+ UPSTREAM?: UpstreamFetcher;
13
+ };
14
+ export type StudioAgentState = {
15
+ draft: Horn | null;
16
+ mode: StudioMode;
17
+ };
18
+ export declare class HornStudioAgent extends Think<StudioAgentEnv, StudioAgentState> {
19
+ #private;
20
+ initialState: StudioAgentState;
21
+ maxSteps: number;
22
+ onRequest(request: Request): Promise<Response>;
23
+ onConnect(connection: Connection, ctx: ConnectionContext): void | Promise<void>;
24
+ private get opened();
25
+ private get realm();
26
+ private select;
27
+ getModel(): LanguageModel;
28
+ /** Where the draft has got to: nothing drawn is designing, drawn but unbound is hydrating. */
29
+ private get stage();
30
+ beforeStep(): {
31
+ activeTools: string[];
32
+ system: string;
33
+ } | undefined;
34
+ beforeTurn(): Promise<{
35
+ instructions: string;
36
+ messages: ModelMessage[];
37
+ tools: {
38
+ [x: string]: (import("ai").Tool<never, never, any> | import("ai").Tool<any, any, any> | import("ai").Tool<any, never, any> | import("ai").Tool<never, any, any>) & Pick<import("ai").Tool<any, any, any>, "execute" | "onInputAvailable" | "onInputStart" | "onInputDelta" | "needsApproval">;
39
+ };
40
+ }>;
41
+ }
@@ -0,0 +1,171 @@
1
+ import { Think } from "@cloudflare/think";
2
+ import { convertToModelMessages } from "ai";
3
+ import { isPresent } from "@manta-eu/lib/present";
4
+ import { hornSchema } from "@manta-eu/horns";
5
+ import { z } from "zod";
6
+ import { mantaReactCatalog } from "@manta-eu/horns-kit";
7
+ import { shapeViewSpecs } from "@manta-eu/horns-kit/shape-views";
8
+ import { buildUsefulContext, datastoreFacts, builtInSkills, selectSkills, studioReadHandlers, studioSystemPrompt, studioWriteHandlers, stageGuidance, stageOf, toolsForStage, } from "@manta-eu/horns/studio";
9
+ import { agentsSparqlSelect } from "./sparql-select.js";
10
+ import { stageRule } from "./manta-rules.js";
11
+ import { studioDeps } from "./studio-deps.js";
12
+ import { pageToolSet, studioToolSet } from "./studio-agent.js";
13
+ import { studioModel } from "./studio-model.js";
14
+ import { readStudioOpening } from "./studio-opening.js";
15
+ export { studioOpening } from "./studio-opening.js";
16
+ const openRequestSchema = z.object({
17
+ horn: hornSchema,
18
+ mode: z.enum(["plan", "build", "investigate"]).optional(),
19
+ });
20
+ export class HornStudioAgent extends Think {
21
+ initialState = { draft: null, mode: "build" };
22
+ maxSteps = 24;
23
+ /** In memory only: a session must not outlive the connection that sent it. */
24
+ #opened;
25
+ async onRequest(request) {
26
+ const opened = readStudioOpening(request);
27
+ if (opened !== undefined)
28
+ this.#opened = opened;
29
+ return this.#answer(request);
30
+ }
31
+ onConnect(connection, ctx) {
32
+ const opened = readStudioOpening(ctx.request);
33
+ if (opened !== undefined)
34
+ this.#opened = opened;
35
+ return super.onConnect(connection, ctx);
36
+ }
37
+ get opened() {
38
+ if (this.#opened === undefined)
39
+ throw new Error("This Studio was reached without a session.");
40
+ return this.#opened;
41
+ }
42
+ get realm() {
43
+ return this.opened.realm;
44
+ }
45
+ select() {
46
+ return agentsSparqlSelect({
47
+ agentsUrl: this.env.MANTA_AGENTS_URL,
48
+ upstream: this.env.UPSTREAM,
49
+ session: this.opened.session,
50
+ datastoreId: this.opened.datastoreId,
51
+ });
52
+ }
53
+ getModel() {
54
+ const chosen = studioModel(this.env);
55
+ if (!chosen.ok)
56
+ throw new Error(chosen.reason);
57
+ return chosen.model;
58
+ }
59
+ /** The Horn this conversation is about. One draft, so a turn and an edit cannot disagree. */
60
+ async #answer(request) {
61
+ const url = new URL(request.url);
62
+ if (!url.pathname.endsWith("/open") || request.method !== "POST")
63
+ return super.onRequest(request);
64
+ const body = await request.json().catch(() => undefined);
65
+ const opened = openRequestSchema.safeParse(body);
66
+ if (!opened.success)
67
+ return Response.json({ error: "Send the Horn to open." }, { status: 400 });
68
+ this.setState({ ...this.state, draft: opened.data.horn, mode: opened.data.mode ?? this.state.mode });
69
+ return Response.json({ opened: true });
70
+ }
71
+ /** Where the draft has got to: nothing drawn is designing, drawn but unbound is hydrating. */
72
+ get stage() {
73
+ const views = this.state.draft?.views ?? [];
74
+ const drawn = views.length > 0 && views.every((view) => "ref" in view || isPresent(view.render));
75
+ const bound = drawn && views.every((view) => "ref" in view || isPresent(view.binding));
76
+ return stageOf({ drawn, bound });
77
+ }
78
+ beforeStep() {
79
+ if (!isPresent(this.state.draft))
80
+ return undefined;
81
+ const stage = this.stage;
82
+ return { activeTools: [...toolsForStage(stage)], system: stageGuidance(stage) };
83
+ }
84
+ async beforeTurn() {
85
+ const draft = this.state.draft;
86
+ if (!isPresent(draft))
87
+ throw new Error("This Studio has no Horn open.");
88
+ const { mode } = this.state;
89
+ const realm = this.realm;
90
+ const select = this.select();
91
+ const shapes = studioDeps().shapes(realm);
92
+ const deps = studioDeps();
93
+ const asked = userWords(this.messages);
94
+ const useful = buildUsefulContext({
95
+ realm,
96
+ prompt: asked,
97
+ horn: draft,
98
+ ...(await datastoreFacts({ realm, select, shapes, prompt: asked })),
99
+ library: [],
100
+ catalog: mantaReactCatalog,
101
+ skills: selectSkills(asked, builtInSkills),
102
+ });
103
+ const handlers = {
104
+ ...studioReadHandlers({
105
+ realm,
106
+ select,
107
+ shapes,
108
+ views: shapeViewSpecs,
109
+ model: this.getModel(),
110
+ ...(deps.componentApi ? { componentApi: deps.componentApi } : {}),
111
+ }),
112
+ ...studioWriteHandlers({
113
+ realm,
114
+ select,
115
+ catalog: mantaReactCatalog,
116
+ lookup: () => undefined,
117
+ ...(deps.typecheck ? { typecheck: deps.typecheck } : {}),
118
+ // As the viewer: staging a rule is attributed to a person, and agents refuses it otherwise.
119
+ stageRule: (rule) => stageRule({
120
+ agentsUrl: this.env.MANTA_AGENTS_URL,
121
+ session: this.opened.session,
122
+ datastoreId: this.opened.datastoreId,
123
+ }, rule),
124
+ }),
125
+ };
126
+ return {
127
+ instructions: studioSystemPrompt({ mode, realm }),
128
+ // Inside the last thing the user said, so the ask and the context are read as one.
129
+ messages: withUsefulContext(await convertToModelMessages(this.messages), useful.text),
130
+ tools: {
131
+ ...studioToolSet({
132
+ mode,
133
+ handlers,
134
+ draft: () => this.state.draft ?? draft,
135
+ onDraft: (next) => this.setState({ ...this.state, draft: next }),
136
+ }),
137
+ // Drawing a Horn needs the design system and a browser, so the page runs this one.
138
+ ...pageToolSet(["look_at_it"]),
139
+ },
140
+ };
141
+ }
142
+ }
143
+ // Every ask, not just the latest: the context is ranked against this.
144
+ function userWords(messages) {
145
+ return messages
146
+ .filter((message) => message.role === "user")
147
+ .flatMap((message) => (message.parts ?? []).flatMap((part) => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part
148
+ ? [String(part.text)]
149
+ : []))
150
+ .join("\n");
151
+ }
152
+ /** Puts the context into the last thing the user said, leaving earlier messages alone. */
153
+ function withUsefulContext(messages, context) {
154
+ const at = messages.map((message) => message.role).lastIndexOf("user");
155
+ if (at < 0)
156
+ return [...messages, { role: "user", content: context }];
157
+ return messages.map((message, index) => {
158
+ if (index !== at || message.role !== "user")
159
+ return message;
160
+ return { role: "user", content: `${wordsOf(message.content)}\n\n${context}` };
161
+ });
162
+ }
163
+ /** A converted message carries parts; what is sent must be one string. */
164
+ function wordsOf(content) {
165
+ if (typeof content === "string")
166
+ return content;
167
+ return content
168
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
169
+ .join("\n")
170
+ .trim();
171
+ }