@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.
- package/dist/action-plan-routes.d.ts +2 -0
- package/dist/action-plan-routes.js +3 -1
- package/dist/agents-proxy.d.ts +9 -0
- package/dist/agents-proxy.js +4 -1
- package/dist/datastore-access.d.ts +2 -0
- package/dist/datastore-access.js +3 -3
- package/dist/dev-session.d.ts +3 -0
- package/dist/dev-session.js +18 -1
- package/dist/env.d.ts +4 -1
- package/dist/horn-routes.d.ts +2 -0
- package/dist/horn-routes.js +87 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -3
- package/dist/sparql-update.d.ts +2 -0
- package/dist/sparql-update.js +2 -2
- package/dist/studio-agent-class.d.ts +41 -0
- package/dist/studio-agent-class.js +171 -0
- package/dist/studio-agent-routes.d.ts +18 -0
- package/dist/studio-agent-routes.js +37 -0
- package/dist/studio-agent.d.ts +20 -0
- package/dist/studio-agent.js +44 -0
- package/dist/studio-deps.d.ts +15 -0
- package/dist/studio-deps.js +14 -0
- package/dist/studio-model.d.ts +30 -0
- package/dist/studio-model.js +104 -0
- package/dist/studio-opening.d.ts +42 -0
- package/dist/studio-opening.js +72 -0
- package/dist/studio-routes.d.ts +12 -3
- package/dist/studio-routes.js +8 -90
- package/dist/studio-worker.d.ts +12 -0
- package/dist/studio-worker.js +34 -0
- package/dist/worker.d.ts +8 -3
- package/dist/worker.js +6 -2
- package/package.json +29 -7
- package/dist/llm.d.ts +0 -45
- package/dist/llm.js +0 -124
|
@@ -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,
|
package/dist/agents-proxy.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/agents-proxy.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/datastore-access.js
CHANGED
|
@@ -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
|
|
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
|
|
96
|
+
response = await fetchUpstream(env.UPSTREAM, url, { headers: { cookie, accept: "application/json" } });
|
|
97
97
|
}
|
|
98
98
|
catch {
|
|
99
99
|
return { ok: false, reason: "unavailable" };
|
package/dist/dev-session.d.ts
CHANGED
|
@@ -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;
|
package/dist/dev-session.js
CHANGED
|
@@ -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 "./
|
|
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?: {
|
package/dist/horn-routes.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/horn-routes.js
CHANGED
|
@@ -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 {
|
|
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/
|
|
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 {
|
|
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";
|
package/dist/sparql-update.d.ts
CHANGED
|
@@ -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;
|
package/dist/sparql-update.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|