@opengeni/api-router 2.2.0-canary.0 → 2.3.2-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth.d.ts +5 -2
  3. package/dist/auth/managed-email.d.ts +29 -0
  4. package/dist/auth/organization-user-setup.d.ts +57 -0
  5. package/dist/{chunk-3TP54PPX.js → chunk-IBV7Z6F4.js} +3545 -2009
  6. package/dist/chunk-IBV7Z6F4.js.map +1 -0
  7. package/dist/index.js +1 -1
  8. package/dist/integrations/slack-app-home.d.ts +1 -1
  9. package/dist/mcp/server.d.ts +1 -1
  10. package/dist/mcp/session-view.d.ts +1 -0
  11. package/dist/routes/automations.d.ts +13 -0
  12. package/dist/routes/insights.d.ts +2 -1
  13. package/dist/routes/managed-onboarding.d.ts +29 -0
  14. package/dist/routes/pr-review-github.d.ts +3 -0
  15. package/package.json +18 -18
  16. package/src/app.ts +56 -5
  17. package/src/auth/managed-auth.ts +29 -34
  18. package/src/auth/managed-email.ts +174 -0
  19. package/src/auth/organization-user-setup.ts +217 -0
  20. package/src/http/auth.ts +15 -0
  21. package/src/http/sse.ts +62 -13
  22. package/src/integrations/slack-app-home.ts +2 -2
  23. package/src/mcp/company-brain-governed-writes.ts +4 -4
  24. package/src/mcp/company-profile-agent-admin.ts +11 -18
  25. package/src/mcp/remember.ts +4 -4
  26. package/src/mcp/server.ts +50 -4
  27. package/src/mcp/session-view.ts +8 -2
  28. package/src/routes/automations.ts +3 -3
  29. package/src/routes/documents.ts +2 -0
  30. package/src/routes/insights.ts +61 -19
  31. package/src/routes/managed-onboarding.ts +317 -0
  32. package/src/routes/organization-memberships.ts +212 -155
  33. package/src/routes/pr-review-github.ts +844 -0
  34. package/src/routes/pr-review.ts +20 -0
  35. package/src/routes/rigs.ts +37 -4
  36. package/src/routes/sessions.ts +101 -0
  37. package/src/sandbox/channel-a.ts +34 -7
  38. package/src/sandbox/viewer.ts +47 -11
  39. package/dist/chunk-3TP54PPX.js.map +0 -1
@@ -0,0 +1,217 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type { ManagedEmailTransport } from "@opengeni/core";
3
+
4
+ const encoder = new TextEncoder();
5
+
6
+ /**
7
+ * Prove that the stable invited-user setup bearer can be constructed before an
8
+ * invitation commits. Provider availability is intentionally outside this
9
+ * precondition: the durable delivery journal records a failed or ambiguous
10
+ * transport outcome after the invitation exists.
11
+ */
12
+ export function assertOrganizationUserSetupDeliveryConfigured(
13
+ settings: Settings,
14
+ transport: ManagedEmailTransport,
15
+ ): void {
16
+ requiredSetupSecret(settings);
17
+ requiredPublicBaseUrl(settings);
18
+ assertManagedEmailTransportMetadata(transport);
19
+ }
20
+
21
+ /** Reject an invalid embedded-provider contract before any durable boundary. */
22
+ export function assertManagedEmailTransportMetadata(transport: ManagedEmailTransport): void {
23
+ if (
24
+ transport.sender.trim() !== transport.sender ||
25
+ encoder.encode(transport.sender).byteLength < 3 ||
26
+ encoder.encode(transport.sender).byteLength > 320
27
+ ) {
28
+ throw new Error("Managed email sender is invalid");
29
+ }
30
+ const { scope, retentionSeconds } = transport.idempotency;
31
+ if (
32
+ scope.trim() !== scope ||
33
+ !/^[a-z0-9][a-z0-9:._-]*$/.test(scope) ||
34
+ encoder.encode(scope).byteLength > 200 ||
35
+ !Number.isInteger(retentionSeconds) ||
36
+ retentionSeconds < 0 ||
37
+ retentionSeconds > 31_536_000
38
+ ) {
39
+ throw new Error("Managed email idempotency contract is invalid");
40
+ }
41
+ }
42
+
43
+ export async function deriveOrganizationUserSetupToken(
44
+ settings: Settings,
45
+ input: { invitationId: string; deliveryId: string },
46
+ ): Promise<{ token: string; digest: string; url: string }> {
47
+ const secret = requiredSetupSecret(settings);
48
+ const key = await crypto.subtle.importKey(
49
+ "raw",
50
+ encoder.encode(secret),
51
+ { name: "HMAC", hash: "SHA-256" },
52
+ false,
53
+ ["sign"],
54
+ );
55
+ const signature = await crypto.subtle.sign(
56
+ "HMAC",
57
+ key,
58
+ encoder.encode(
59
+ `opengeni:organization-user-setup-delivery:v1:${input.deliveryId}:${input.invitationId}`,
60
+ ),
61
+ );
62
+ const token = base64Url(new Uint8Array(signature));
63
+ const digest = await sha256Hex(token);
64
+ const url = new URL("/setup-account", requiredPublicBaseUrl(settings));
65
+ url.hash = new URLSearchParams({ token }).toString();
66
+ return { token, digest, url: url.toString() };
67
+ }
68
+
69
+ export type OrganizationUserSetupEmailSnapshot = {
70
+ senderEmail: string;
71
+ recipientEmail: string;
72
+ recipientName: string | null;
73
+ organizationName: string;
74
+ organizationRole: "owner" | "admin" | "member";
75
+ sharedWorkspaceAccess: Array<{
76
+ workspaceId: string;
77
+ workspaceName: string;
78
+ role: "viewer" | "member" | "admin";
79
+ }>;
80
+ setupUrl: string;
81
+ };
82
+
83
+ export function renderOrganizationUserSetupEmail(input: OrganizationUserSetupEmailSnapshot): {
84
+ from: string;
85
+ to: string;
86
+ subject: string;
87
+ text: string;
88
+ html: string;
89
+ } {
90
+ const greeting = input.recipientName ? `Hi ${input.recipientName},` : "Hello,";
91
+ const role = titleCase(input.organizationRole);
92
+ const workspaceSummary =
93
+ input.sharedWorkspaceAccess.length === 0
94
+ ? "No shared workspaces are assigned yet."
95
+ : `Shared workspace access:\n${input.sharedWorkspaceAccess
96
+ .map((workspace) => `- ${workspace.workspaceName}: ${titleCase(workspace.role)}`)
97
+ .join("\n")}`;
98
+ const workspaceHtml =
99
+ input.sharedWorkspaceAccess.length === 0
100
+ ? "<p>No shared workspaces are assigned yet.</p>"
101
+ : `<p>Shared workspace access:</p><ul>${input.sharedWorkspaceAccess
102
+ .map(
103
+ (workspace) =>
104
+ `<li>${escapeHtml(workspace.workspaceName)}: ${escapeHtml(titleCase(workspace.role))}</li>`,
105
+ )
106
+ .join("")}</ul>`;
107
+ return {
108
+ from: input.senderEmail,
109
+ to: input.recipientEmail,
110
+ subject: `Join ${input.organizationName} on OpenGeni`,
111
+ text: `${greeting}\n\nYou have been invited to ${input.organizationName} as ${role}.\n\n${workspaceSummary}\n\nThis invitation grants only the organization role and shared workspace access listed above. It never shares anyone's Personal workspace.\n\nSet up your account: ${input.setupUrl}\n\nIf you already have an OpenGeni account, sign in and accept the invitation instead.`,
112
+ html: `<p>${escapeHtml(greeting)}</p><p>You have been invited to <strong>${escapeHtml(input.organizationName)}</strong> as ${escapeHtml(role)}.</p>${workspaceHtml}<p>This invitation grants only the organization role and shared workspace access listed above. It never shares anyone's Personal workspace.</p><p><a href="${escapeHtml(input.setupUrl)}">Set up your account</a></p><p>If you already have an OpenGeni account, sign in and accept the invitation instead.</p>`,
113
+ };
114
+ }
115
+
116
+ export async function organizationUserSetupPayloadDigest(input: {
117
+ from: string;
118
+ to: string;
119
+ subject: string;
120
+ text: string;
121
+ html: string;
122
+ providerIdempotencyScope: string;
123
+ }): Promise<string> {
124
+ return await sha256Hex(
125
+ JSON.stringify({
126
+ version: 2,
127
+ providerIdempotencyScope: input.providerIdempotencyScope,
128
+ from: input.from,
129
+ to: input.to,
130
+ subject: input.subject,
131
+ text: input.text,
132
+ html: input.html,
133
+ }),
134
+ );
135
+ }
136
+
137
+ export async function organizationUserSetupRequestFingerprint(
138
+ settings: Settings,
139
+ input: { tokenDigest: string; name: string; password: string },
140
+ ): Promise<string> {
141
+ const key = await crypto.subtle.importKey(
142
+ "raw",
143
+ encoder.encode(requiredSetupSecret(settings)),
144
+ { name: "HMAC", hash: "SHA-256" },
145
+ false,
146
+ ["sign"],
147
+ );
148
+ const signature = await crypto.subtle.sign(
149
+ "HMAC",
150
+ key,
151
+ encoder.encode(
152
+ JSON.stringify({
153
+ tokenDigest: input.tokenDigest,
154
+ name: input.name,
155
+ password: input.password,
156
+ }),
157
+ ),
158
+ );
159
+ return hex(new Uint8Array(signature));
160
+ }
161
+
162
+ export async function selfServiceOrganizationSetupRequestFingerprint(input: {
163
+ authUserId: string;
164
+ organizationName: string;
165
+ }): Promise<string> {
166
+ const actorSubjectId = `user:${input.authUserId}`;
167
+ const organizationNameBytes = encoder.encode(input.organizationName);
168
+ return await sha256Hex(
169
+ `opengeni:self-service-organization:v1:${actorSubjectId}:${organizationNameBytes.byteLength}:${input.organizationName}`,
170
+ );
171
+ }
172
+
173
+ export async function organizationUserSetupTokenDigest(token: string): Promise<string> {
174
+ return await sha256Hex(token);
175
+ }
176
+
177
+ function requiredSetupSecret(settings: Settings): string {
178
+ if (!settings.betterAuthSecret) {
179
+ throw new Error("OPENGENI_BETTER_AUTH_SECRET is required for organization user setup");
180
+ }
181
+ return settings.betterAuthSecret;
182
+ }
183
+
184
+ function requiredPublicBaseUrl(settings: Settings): string {
185
+ if (!settings.publicBaseUrl) {
186
+ throw new Error("OPENGENI_PUBLIC_BASE_URL is required for organization user setup");
187
+ }
188
+ return settings.publicBaseUrl;
189
+ }
190
+
191
+ async function sha256Hex(value: string): Promise<string> {
192
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
193
+ return hex(new Uint8Array(digest));
194
+ }
195
+
196
+ function hex(bytes: Uint8Array): string {
197
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
198
+ }
199
+
200
+ function base64Url(bytes: Uint8Array): string {
201
+ let binary = "";
202
+ for (const byte of bytes) binary += String.fromCharCode(byte);
203
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
204
+ }
205
+
206
+ function titleCase(value: string): string {
207
+ return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
208
+ }
209
+
210
+ function escapeHtml(value: string): string {
211
+ return value
212
+ .replaceAll("&", "&amp;")
213
+ .replaceAll("<", "&lt;")
214
+ .replaceAll(">", "&gt;")
215
+ .replaceAll('"', "&quot;")
216
+ .replaceAll("'", "&#039;");
217
+ }
package/src/http/auth.ts CHANGED
@@ -5,6 +5,8 @@ import { installExactPaths, isInstallRedirectPath } from "../routes/install";
5
5
 
6
6
  const githubConnectPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/connect$/;
7
7
  const githubInstallationLinkPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/installations$/;
8
+ const prReviewGithubBrowserPathPattern =
9
+ /^\/v1\/workspaces\/[^/]+\/pr-review\/github\/(?:connect|installations\/select|installations\/[^/]+\/configure)$/;
8
10
 
9
11
  export function requireAccessKey(settings: Settings): MiddlewareHandler {
10
12
  return async (c, next) => {
@@ -43,6 +45,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
43
45
  if (c.req.method === "POST" && path.startsWith("/v1/webhooks/automations/")) {
44
46
  return true;
45
47
  }
48
+ if (c.req.method === "POST" && path === "/v1/webhooks/pr-review/github") {
49
+ return true;
50
+ }
46
51
  if (
47
52
  path === "/v1/github/setup" ||
48
53
  path === "/v1/github/install/callback" ||
@@ -51,6 +56,13 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
51
56
  ) {
52
57
  return true;
53
58
  }
59
+ if (
60
+ path === "/v1/pr-review/github/setup" ||
61
+ path === "/v1/pr-review/github/install/callback" ||
62
+ path === "/v1/pr-review/github/oauth/callback"
63
+ ) {
64
+ return true;
65
+ }
54
66
  if (
55
67
  path === "/v1/integrations/oauth/callback" ||
56
68
  path === "/v1/integrations/provider-oauth/callback" ||
@@ -83,6 +95,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
83
95
  if (githubConnectPathPattern.test(path)) {
84
96
  return true;
85
97
  }
98
+ if (prReviewGithubBrowserPathPattern.test(path)) {
99
+ return true;
100
+ }
86
101
  // Compatibility endpoint for stale chooser submissions. It remains public
87
102
  // only so already-rendered forms can authenticate their signed account and
88
103
  // workspace state locally before terminating with 410; it does not parse a
package/src/http/sse.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  coalesceSessionEventDeltas,
14
14
  formatSessionEventSse,
15
15
  formatWorkspaceControlEventSse,
16
+ requireSessionEventDurableFanoutCapability,
16
17
  SESSION_EVENT_SSE_FRAME_MAX_BYTES,
17
18
  sessionEventResumeSequence,
18
19
  type EventBus,
@@ -269,12 +270,14 @@ export async function sseSessionStream(
269
270
  signal: AbortSignal,
270
271
  options: SessionSseDeliveryOptions = {},
271
272
  ): Promise<Response> {
273
+ const durableFanout = requireSessionEventDurableFanoutCapability(bus);
272
274
  const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
273
275
  let lastSent = after;
274
276
  let bootstrapping = true;
275
277
  let newestBuffered: SessionEvent | null = null;
276
278
  let unsubscribe: (() => void) | null = null;
277
279
  let delivery: LatestWinsDelivery<SessionEvent> | null = null;
280
+ let stopReconnectObservation = () => {};
278
281
  let stopReauthorization = () => {};
279
282
  let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
280
283
  let detachAbortListener = () => {};
@@ -282,6 +285,8 @@ export async function sseSessionStream(
282
285
  const stopUpstream = () => {
283
286
  closeMetrics();
284
287
  detachAbortListener();
288
+ stopReconnectObservation();
289
+ stopReconnectObservation = () => {};
285
290
  stopReauthorization();
286
291
  if (heartbeatTimer) {
287
292
  clearTimeout(heartbeatTimer);
@@ -316,17 +321,6 @@ export async function sseSessionStream(
316
321
  writeTail = write.catch(() => {});
317
322
  return write;
318
323
  };
319
- const scheduleHeartbeat = () => {
320
- if (channel.stopped()) return;
321
- heartbeatTimer = setTimeout(() => {
322
- heartbeatTimer = null;
323
- void writeFrame(": heartbeat\n\n")
324
- .then(scheduleHeartbeat)
325
- .catch((error) => {
326
- if (!(error instanceof SseStreamStoppedError)) fail(error);
327
- });
328
- }, heartbeatIntervalMs);
329
- };
330
324
  const deliverDurableThrough = async (targetSequence?: number) => {
331
325
  while (true) {
332
326
  if (targetSequence !== undefined && lastSent >= targetSequence) return;
@@ -364,12 +358,66 @@ export async function sseSessionStream(
364
358
  if (targetSequence === undefined && page.length < limit) return;
365
359
  }
366
360
  };
361
+ let durableDeliveryTail = Promise.resolve();
362
+ const reconcileDurableThrough = (targetSequence?: number): Promise<void> => {
363
+ const deliveryRun = durableDeliveryTail.then(() => deliverDurableThrough(targetSequence));
364
+ durableDeliveryTail = deliveryRun.catch(() => {});
365
+ return deliveryRun;
366
+ };
367
+ let newestReconnectGeneration = 0;
368
+ let reconnectReconcilePending = false;
369
+ let reconnectReconcileRunning = false;
370
+ const drainReconnectReconciliation = () => {
371
+ if (
372
+ bootstrapping ||
373
+ reconnectReconcileRunning ||
374
+ !reconnectReconcilePending ||
375
+ channel.stopped()
376
+ ) {
377
+ return;
378
+ }
379
+ reconnectReconcileRunning = true;
380
+ void (async () => {
381
+ while (reconnectReconcilePending && !channel.stopped()) {
382
+ // Multiple reconnects during one durable read collapse into one newest
383
+ // catch-up. Postgres is authoritative, so that later read covers every
384
+ // disconnect window without one query per heartbeat or buffered event.
385
+ reconnectReconcilePending = false;
386
+ await reconcileDurableThrough();
387
+ }
388
+ })()
389
+ .catch((error) => {
390
+ if (!(error instanceof SseStreamStoppedError)) fail(error);
391
+ })
392
+ .finally(() => {
393
+ reconnectReconcileRunning = false;
394
+ drainReconnectReconciliation();
395
+ });
396
+ };
397
+ const scheduleReconnectReconciliation = (generation: number) => {
398
+ if (generation <= newestReconnectGeneration || channel.stopped()) return;
399
+ newestReconnectGeneration = generation;
400
+ reconnectReconcilePending = true;
401
+ drainReconnectReconciliation();
402
+ };
367
403
  const send = async (event: SessionEvent) => {
368
404
  const targetSequence = sessionEventResumeSequence(event);
369
405
  if (targetSequence <= lastSent) return;
370
- await deliverDurableThrough(targetSequence);
406
+ await reconcileDurableThrough(targetSequence);
407
+ };
408
+ const scheduleHeartbeat = () => {
409
+ if (channel.stopped()) return;
410
+ heartbeatTimer = setTimeout(() => {
411
+ heartbeatTimer = null;
412
+ void writeFrame(": heartbeat\n\n")
413
+ .then(scheduleHeartbeat)
414
+ .catch((error) => {
415
+ if (!(error instanceof SseStreamStoppedError)) fail(error);
416
+ });
417
+ }, heartbeatIntervalMs);
371
418
  };
372
419
  delivery = createLatestWinsDelivery(send, fail);
420
+ stopReconnectObservation = durableFanout.subscribeRecovery(scheduleReconnectReconciliation);
373
421
 
374
422
  void (async () => {
375
423
  const release = await bus.subscribe(workspaceId, sessionId, (events) => {
@@ -389,10 +437,11 @@ export async function sseSessionStream(
389
437
  }
390
438
  unsubscribe = release;
391
439
 
392
- await deliverDurableThrough();
440
+ await reconcileDurableThrough();
393
441
  await writeFrame(": connected\n\n");
394
442
  scheduleHeartbeat();
395
443
  bootstrapping = false;
444
+ drainReconnectReconciliation();
396
445
  const buffered = newestBuffered;
397
446
  newestBuffered = null;
398
447
  if (buffered) delivery.publish([buffered]);
@@ -1,4 +1,4 @@
1
- import type { Session } from "@opengeni/contracts";
1
+ import { AUTOMATIC_SESSION_TITLE_FALLBACK, type Session } from "@opengeni/contracts";
2
2
  import type { SlackHomeBlock } from "./slack-bot";
3
3
 
4
4
  const ATTENTION_LIMIT = 5;
@@ -186,7 +186,7 @@ function appendSessionGroup(
186
186
  );
187
187
  for (const session of sessions) {
188
188
  const url = sessionUrl(session.id);
189
- const title = (session.title || session.initialMessage || "Untitled task").slice(0, 180);
189
+ const title = (session.title?.trim() || AUTOMATIC_SESSION_TITLE_FALLBACK).slice(0, 180);
190
190
  blocks.push({
191
191
  type: "section",
192
192
  block_id: `opengeni_home_session_${session.id}`,
@@ -125,7 +125,7 @@ export function registerCompanyBrainGovernedWriteTools(
125
125
  {
126
126
  description:
127
127
  "Atomically promote one still-active note from this exact root task tree into an inactive workspace instruction-policy draft. The note bytes remain exact evidence and draft content. " +
128
- `Once a human activates the draft, those bytes are composed verbatim into the prompt of every session the target applies to, so a note over ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters is rejected here rather than truncated: write a fresh short imperative note instead of promoting a long working note. ` +
128
+ `Use this only for a universal always-on rule, never for an incident, fact, decision, outcome, or conditional procedure. Once a human activates the draft, those bytes are composed verbatim into the prompt of every session the target applies to, so a note over ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters is rejected here rather than truncated: write a fresh minimal imperative note instead of promoting a long working note. ` +
129
129
  "The frozen learning policy records a decision receipt, but mandatory policy still requires human activation even under Automatic; this never widens scope.",
130
130
  inputSchema: {
131
131
  ...taskNotePromotion,
@@ -149,7 +149,7 @@ export function registerCompanyBrainGovernedWriteTools(
149
149
  "task_note_promote_preference",
150
150
  {
151
151
  description:
152
- "Atomically promote one still-active note from this exact root task tree into a workspace preference proposal. The note bytes remain exact evidence and full proposal content. " +
152
+ "Atomically promote one still-active note from this exact root task tree into a workspace Skill proposal backed by the structured preference authority. Use this for reusable conditional how-to guidance, never for an incident, fact, decision, outcome, or universal always-on rule. The note bytes remain exact evidence and full proposal content. " +
153
153
  `The title and description you supply are what gets composed into every session prompt, so write them as one short imperative statement; the note content is retrieved on demand and a note over ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters is rejected here rather than truncated. ` +
154
154
  "Under Suggest the proposal waits for human review; under Automatic an eligible decision is activated through the preference lifecycle and remains undoable. This never widens scope.",
155
155
  inputSchema: {
@@ -186,7 +186,7 @@ export function registerCompanyBrainGovernedWriteTools(
186
186
  {
187
187
  description:
188
188
  "Materialize an evidence-backed inactive workspace instruction-policy draft. " +
189
- `Once a human activates it, this content is composed verbatim into the prompt of every session the target applies to (every session in this workspace for a global charter or policy, every session bound to the role for a role policy), so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
189
+ `Use this only for a minimal universal rule, never for an incident, fact, decision, outcome, or conditional procedure. Once a human activates it, this content is composed verbatim into the prompt of every session the target applies to (every session in this workspace for a global charter or policy, every session bound to the role for a role policy), so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
190
190
  "The frozen learning policy records a decision receipt, but this tool cannot activate mandatory behavior, including when learning mode is Automatic; a human must activate the draft.",
191
191
  inputSchema: {
192
192
  ...evidence,
@@ -219,7 +219,7 @@ export function registerCompanyBrainGovernedWriteTools(
219
219
  "preference_propose",
220
220
  {
221
221
  description:
222
- "Materialize an evidence-backed workspace preference proposal. " +
222
+ "Materialize an evidence-backed workspace Skill proposal in the structured preference authority. Use this only for reusable conditional how-to guidance, never for an incident, fact, decision, outcome, or universal always-on rule. " +
223
223
  `Its short title and description are what get composed into every session prompt; the content is retrieved on demand, so its length is retrieval cost rather than standing prompt cost. Keep the content under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
224
224
  "Under Suggest it stays inactive for human review; under Automatic an eligible decision is activated through the governed preference lifecycle with an undoable receipt. It never creates mandatory authority.",
225
225
  inputSchema: {
@@ -1,10 +1,8 @@
1
1
  import {
2
- AGENT_AUTHORED_COMPANY_PROFILE_CONTENT_MAX_UTF8_BYTES,
3
2
  AGENT_AUTHORED_COMPANY_PROFILE_ENTRY_MAX_CHARS,
4
3
  AGENT_AUTHORED_COMPANY_PROFILE_SCALAR_MAX_CHARS,
5
4
  AGENT_AUTHORED_COMPANY_PROFILE_TOO_LONG_MESSAGE,
6
5
  AgentAuthoredCompanyProfileContent,
7
- COMPANY_PROFILE_ENTRY_MAX_COUNT,
8
6
  COMPANY_PROFILE_REASON_MAX_CHARS,
9
7
  COMPANY_PROFILE_STABLE_KEY_MAX_CHARS,
10
8
  normalizeCompanyProfileStableKey,
@@ -32,9 +30,9 @@ export type RegisterCompanyProfileAgentAdminToolsInput = {
32
30
  router?: Pick<ReturnType<typeof createCompanyProfileAgentAdminRouter>, "propose" | "confirm">;
33
31
  };
34
32
 
35
- // Agent-only bounds. The human `account:admin` route keeps the wider
36
- // `COMPANY_PROFILE_*` limits; this profile is the largest always-on prompt
37
- // surface in the product, so an agent gets a much smaller budget for it.
33
+ // Agent-only bounds. The human `account:admin` API keeps the wider historical
34
+ // `COMPANY_PROFILE_*` limits; the current agent tool authors only the two
35
+ // always-on identity fields and therefore gets a much smaller budget.
38
36
  const scalar = z
39
37
  .string()
40
38
  .trim()
@@ -55,8 +53,7 @@ const entry = z.object({
55
53
  AGENT_AUTHORED_COMPANY_PROFILE_TOO_LONG_MESSAGE,
56
54
  ),
57
55
  });
58
- const entries = z.array(entry).max(COMPANY_PROFILE_ENTRY_MAX_COUNT);
59
- const DEFAULT_PROPOSAL_REASON = "Activate agent-proposed organization company profile";
56
+ const DEFAULT_PROPOSAL_REASON = "Activate agent-proposed organization identity";
60
57
  const STABLE_KEY_WORDS = 6;
61
58
 
62
59
  type EntryInput = z.infer<typeof entry>;
@@ -127,18 +124,14 @@ export function registerCompanyProfileAgentAdminTools(
127
124
  "company_profile_propose",
128
125
  {
129
126
  description:
130
- "Prepare one complete organization company profile covering identity, mission, products, customers, strategic goals, and critical constraints. Omitted list keys are derived from content. " +
131
- "Once activated, every field here is mandatory prompt context in every session for the whole organization, so write it as a concise profile rather than a document: one plain descriptive statement per field, no numbered procedure and no marketing copy. Longer source material belongs in organization Documents and is retrieved as evidence; never copy it into the profile. " +
132
- `Bounds for this tool are ${AGENT_AUTHORED_COMPANY_PROFILE_SCALAR_MAX_CHARS} characters for identity and mission, ${AGENT_AUTHORED_COMPANY_PROFILE_ENTRY_MAX_CHARS} per list entry, ${COMPANY_PROFILE_ENTRY_MAX_COUNT} entries per list, and ${AGENT_AUTHORED_COMPANY_PROFILE_CONTENT_MAX_UTF8_BYTES} UTF-8 bytes for the whole profile. ` +
127
+ "Prepare the organization's small, stable identity: identity says who the organization is, and mission says why it exists. " +
128
+ "Once activated, both fields are mandatory prompt context in every root session for the whole organization, so use one plain descriptive statement per field with no products, customers, goals, constraints, procedures, or marketing copy. Those details belong in organization-scoped Documents and are retrieved only when relevant. " +
129
+ `Each field is bounded to ${AGENT_AUTHORED_COMPANY_PROFILE_SCALAR_MAX_CHARS} characters for agent-authored proposals. ` +
133
130
  "This creates only an immutable inactive proposal for the exact live turn initiated by the organization owner and does not use workspace learning policy. The receipt returns the exact `humanInput` payload; call `request_human_input` with it verbatim, then call `company_profile_confirm` with the returned requestId.",
134
131
  inputSchema: {
135
132
  operationId: z.string().uuid(),
136
133
  identity: scalar,
137
134
  mission: scalar,
138
- products: entries,
139
- customers: entries,
140
- goals: entries,
141
- constraints: entries,
142
135
  reason: z.string().trim().min(1).max(COMPANY_PROFILE_REASON_MAX_CHARS).optional(),
143
136
  },
144
137
  },
@@ -147,10 +140,10 @@ export function registerCompanyProfileAgentAdminTools(
147
140
  const parsed = AgentAuthoredCompanyProfileContent.safeParse({
148
141
  identity: request.identity,
149
142
  mission: request.mission,
150
- products: resolveCompanyProfileEntries(request.products),
151
- customers: resolveCompanyProfileEntries(request.customers),
152
- goals: resolveCompanyProfileEntries(request.goals),
153
- constraints: resolveCompanyProfileEntries(request.constraints),
143
+ products: [],
144
+ customers: [],
145
+ goals: [],
146
+ constraints: [],
154
147
  });
155
148
  if (!parsed.success) {
156
149
  return input.json({
@@ -70,9 +70,9 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
70
70
  "remember",
71
71
  {
72
72
  description:
73
- "Durably remember something the user explicitly asked to keep for this workspace. Use lane=preference for how agents should act, lane=instruction_policy only when the user stated a hard always/never rule, lane=knowledge for a company/product/people fact. " +
74
- `Write it short. A lane=instruction_policy rule is composed verbatim into the prompt of every session it applies to (every session in this workspace for a global rule, every session bound to the role for a role rule) for as long as it stays active, so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters: one imperative rule in 1-3 sentences, no numbered steps, no examples, no rationale, no restating of defaults. At most three rules compose at once, so this is a standing budget you share. Keep a lane=preference under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters; there only its short title and description are composed and the content is retrieved on demand, so length is retrieval cost rather than standing prompt cost. Prefer several small entries over one long one, and put procedure in a Document or Skill that the rule references instead of inlining it. ` +
75
- "Under Automatic learning a preference activates immediately; otherwise the receipt returns status=confirmation_required with the exact `humanInput` payload: call `request_human_input` with it verbatim, then call `remember_confirm` with the returned requestId. Mandatory rules always need that confirmation. Do not use this for facts you merely inferred; use knowledge_propose or task notes for those. A confirmed lane=knowledge fact enters the human-reviewed Knowledge claim lifecycle; it does not become workspace memory, so do not expect to find it later through `memory_search`.",
73
+ "Durably remember something the user explicitly asked to keep for this workspace. Route by purpose: lane=knowledge for a fact, decision, incident, bug fix, or outcome that should become searchable Memory; lane=preference creates a Skill for reusable conditional how-to guidance; lane=instruction_policy is only for a universal always/never rule that should apply to nearly every task. " +
74
+ `Write the instruction lane as the shortest complete rule, at most ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters and normally 1-3 imperative sentences, with no numbered steps, examples, rationale, or restated defaults. Keep a Skill under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters; only its one-sentence descriptor is composed and the full instructions are retrieved on demand. Do not copy one item into multiple lanes. ` +
75
+ "Under Automatic learning a Skill may activate immediately; otherwise the receipt returns status=confirmation_required with the exact `humanInput` payload: call `request_human_input` with it verbatim, then call `remember_confirm` with the returned requestId. Workspace instructions and Memory always need that confirmation. Do not use this for facts you merely inferred; use knowledge_propose or task notes for those. Confirmed lane=knowledge content keeps its reviewed claim provenance and materializes its exact approved text into Memory for later `memory_search` retrieval.",
76
76
  inputSchema: {
77
77
  lane: z.enum(["preference", "instruction_policy", "knowledge"]),
78
78
  ...laneFields,
@@ -130,7 +130,7 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
130
130
  "remember_confirm",
131
131
  {
132
132
  description:
133
- "Complete a `remember` that returned status=confirmation_required after the human answered the bound `request_human_input` question. For preference/instruction_policy pass proposalId and learning.receiptId (as decisionReceiptId) from that receipt; for knowledge pass claimId. Always pass the requestId returned by request_human_input. Activation only succeeds when the exact initiating human answered Save on this turn; otherwise the proposal stays for review.",
133
+ "Complete a `remember` that returned status=confirmation_required after the human answered the bound `request_human_input` question. For preference/instruction_policy pass proposalId and learning.receiptId (as decisionReceiptId) from that receipt; for knowledge pass claimId. Always pass the requestId returned by request_human_input. Activation only succeeds when the exact initiating human answered Save on this turn; confirmed knowledge is materialized into searchable Memory from the exact approved text.",
134
134
  inputSchema: {
135
135
  operationId: z.string().uuid(),
136
136
  proposalId: z.string().uuid().optional(),
package/src/mcp/server.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
39
39
  SESSION_GOAL_TEXT_MAX_BYTES,
40
40
  SESSION_INSTRUCTIONS_MAX_CHARACTERS,
41
+ MAX_SELECTED_VARIABLE_SETS,
41
42
  sessionGoalUtf8Bytes,
42
43
  TASK_NOTE_LIST_DEFAULT_LIMIT,
43
44
  TASK_NOTE_LIST_MAX_LIMIT,
@@ -755,7 +756,7 @@ export function buildOpenGeniMcpServer(
755
756
  "set_session_title",
756
757
  {
757
758
  description:
758
- "Set this session's display title to a concise 3-7 word summary. The title persists across turns: call once on a new untitled session, then only when the topic materially changes. Never call it as routine setup after a continuation, resume, or interruption, or merely to reassert the same title. A human-set title cannot be replaced.",
759
+ "Set this session's display title to a concise 3-7 word topic label. Use a stable noun phrase about the actual task or subject, never a quote/prefix of a prompt, greeting, request boilerplate, URL, identifier, credential, token, or other sensitive value. Call once on a new session, then only when the topic materially changes. Never call it as routine setup after a continuation, resume, or interruption, or merely to reassert the same title. A human-set title cannot be replaced.",
759
760
  inputSchema: { title: z4.string().min(1).max(200) },
760
761
  },
761
762
  async ({ title }) => {
@@ -3256,7 +3257,7 @@ function registerPreferenceRegistryTools(
3256
3257
  "preference_registry_summary",
3257
3258
  {
3258
3259
  description:
3259
- "List bounded deterministic descriptors for organization, workspace, and immutable initiating-human preferences frozen to this exact attempt. Full content is omitted; retrieve only a relevant returned handle.",
3260
+ "List bounded deterministic Skill descriptors for organization, workspace, and the immutable initiating human, frozen to this exact attempt. Full Skill instructions are omitted; retrieve only a relevant returned handle.",
3260
3261
  inputSchema: {},
3261
3262
  },
3262
3263
  async () => json(await getOrCreatePreferenceRegistrySnapshot(deps.db, attemptClaims())),
@@ -3266,7 +3267,7 @@ function registerPreferenceRegistryTools(
3266
3267
  "preference_registry_get",
3267
3268
  {
3268
3269
  description:
3269
- "Retrieve full content for one preference in this exact attempt snapshot. Handles from another account, workspace, human, or attempt are rejected.",
3270
+ "Retrieve the full instructions for one Skill in this exact attempt snapshot. Handles from another account, workspace, human, or attempt are rejected.",
3270
3271
  inputSchema: { retrievalHandle: z4.string().min(1).max(512) },
3271
3272
  },
3272
3273
  async ({ retrievalHandle }) =>
@@ -4295,6 +4296,7 @@ function registerWorkspaceOrchestrationTools(
4295
4296
  tools: z4.array(z4.unknown()).optional(),
4296
4297
  mcpServers: z4.array(z4.unknown()).optional(),
4297
4298
  variableSetId: z4.string().uuid().optional(),
4299
+ variableSetIds: z4.array(z4.string().uuid()).max(MAX_SELECTED_VARIABLE_SETS).optional(),
4298
4300
  environmentId: z4.string().uuid().optional(),
4299
4301
  rigId: z4.string().uuid().optional(),
4300
4302
  model: z4
@@ -4345,6 +4347,26 @@ function registerWorkspaceOrchestrationTools(
4345
4347
  .union([z4.literal("new"), z4.object({ groupId: z4.string().uuid() })])
4346
4348
  .optional(),
4347
4349
  })
4350
+ .superRefine((value, context) => {
4351
+ if (!value.variableSetIds) return;
4352
+ if (new Set(value.variableSetIds).size !== value.variableSetIds.length) {
4353
+ context.addIssue({
4354
+ code: z4.ZodIssueCode.custom,
4355
+ path: ["variableSetIds"],
4356
+ message: "variableSetIds must not contain duplicates",
4357
+ });
4358
+ }
4359
+ const singular = value.variableSetId ?? value.environmentId;
4360
+ if (singular === undefined) return;
4361
+ const expected = value.variableSetIds[value.variableSetIds.length - 1];
4362
+ if (singular !== expected) {
4363
+ context.addIssue({
4364
+ code: z4.ZodIssueCode.custom,
4365
+ path: ["variableSetId"],
4366
+ message: "variableSetId must match the last variableSetIds entry",
4367
+ });
4368
+ }
4369
+ })
4348
4370
  .strict();
4349
4371
  server.registerTool(
4350
4372
  "session_create",
@@ -4355,6 +4377,11 @@ function registerWorkspaceOrchestrationTools(
4355
4377
  },
4356
4378
  async (args) => {
4357
4379
  try {
4380
+ requireVariableSetsUseForMcpAttachments(grant, {
4381
+ variableSetIds: args.variableSetIds,
4382
+ variableSetId: args.variableSetId,
4383
+ environmentId: args.environmentId,
4384
+ });
4358
4385
  if (callerSessionId !== null) {
4359
4386
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
4360
4387
  }
@@ -4744,7 +4771,7 @@ function registerWorkspaceOrchestrationTools(
4744
4771
  "set_other_session_title",
4745
4772
  {
4746
4773
  description:
4747
- "Set another session's display title to a concise 3-7 word summary. The target session must belong to this workspace. Replaces an existing title unless a human has manually set it.",
4774
+ "Set another session's display title to a concise 3-7 word topic label. Use a stable noun phrase about the actual task or subject, never a quote/prefix of a prompt, greeting, request boilerplate, URL, identifier, credential, token, or other sensitive value. The target session must belong to this workspace. Replaces an existing automatic title unless a human has manually set it.",
4748
4775
  inputSchema: {
4749
4776
  session_id: z4.string().uuid(),
4750
4777
  title: z4.string().min(1).max(200),
@@ -5342,6 +5369,25 @@ function requireVariableSetsUseForMcpAttachment(
5342
5369
  }
5343
5370
  }
5344
5371
 
5372
+ function requireVariableSetsUseForMcpAttachments(
5373
+ grant: AccessGrant,
5374
+ selection: {
5375
+ variableSetIds?: string[] | undefined;
5376
+ variableSetId?: string | undefined;
5377
+ environmentId?: string | undefined;
5378
+ },
5379
+ ): void {
5380
+ const singular = selection.variableSetId ?? selection.environmentId;
5381
+ const variableSetIds = selection.variableSetIds ?? (singular ? [singular] : undefined);
5382
+ if (variableSetIds === undefined) return;
5383
+ if (!hasPermission(grant.permissions, "variable-sets:attach")) {
5384
+ throw new HTTPException(403, { message: "missing permission: variable-sets:attach" });
5385
+ }
5386
+ if (variableSetIds.length > 0 && !hasPermission(grant.permissions, "variable-sets:use")) {
5387
+ throw new HTTPException(403, { message: "missing permission: variable-sets:use" });
5388
+ }
5389
+ }
5390
+
5345
5391
  /**
5346
5392
  * Project one allowlisted GitHub App repository into the resource an agent or
5347
5393
  * scheduled task attaches. Every listed repository is in the workspace