@noodleseed/one 0.154.0 → 0.155.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.
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +2 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-suggestions.js +5 -3
- package/node_modules/@noodle-borg/assistant-gateway/dist/continuity-bounds.d.ts +36 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/continuity-bounds.js +52 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/continuity-store.d.ts +115 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/continuity-store.js +51 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-continuity-store.d.ts +26 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-continuity-store.js +80 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +2 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-response-values.js +11 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-responses.js +1 -10
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-stream.js +1 -10
- package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +3 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +3 -0
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +35 -0
- package/node_modules/@noodle-borg/authoring/dist/assistant.js +4 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/branding-schema.js +86 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +44 -25
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +24 -76
- package/package.json +1 -1
|
@@ -36,7 +36,7 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
36
36
|
{ relPath: "examples/acme-discovery/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } =\n generateHelpers<AppType>();\n" },
|
|
37
37
|
{ relPath: "examples/acme-discovery/src/knowledge/faq.txt", content: "ACME GETAWAYS FAQ\n\nQ: Does the assistant book and take payment?\nA: No. The assistant shapes the trip and hands off to Acmes own checkout with a signed link.\n\nQ: How current are prices?\nA: Starting prices come from Acmes live site; the assistant cites the page it used.\n\nQ: Can I compare destinations?\nA: Yes, ask for a shortlist by vibe, month, or budget.\n" },
|
|
38
38
|
{ relPath: "examples/acme-discovery/src/knowledge/product.md", content: "# Acme Getaways product guide\n\nAcme Getaways curates four destination types: beach, mountains, culture, and city escapes. Every listing shows a real starting price and the best months to travel. Bookings, payments, and date selection happen on Acmes own site through a signed handoff link; the assistant never takes payment details.\n\n## Cancellation and support\n\nAll trips can be cancelled free within 48 hours of the handoff. Support runs seven days a week through the chat on book.acme.example.\n" },
|
|
39
|
-
{ relPath: "examples/acme-discovery/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n embeddedAssistant,\n file,\n knowledge,\n noodleManaged,\n publicWebsite,\n secret,\n server,\n site,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and\n// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,\n// reached through a signed, attributable handoff deep link. Destinations are the partner's own\n// catalog (grounding) — the app never invents a place, price, or best-month.\n//\n// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as\n// `${input.x}` substitutions when placed directly into an output string; do not transform them\n// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The\n// curated catalog below is static data the runtime returns verbatim.\n\nconst catalog = [\n {\n id: 'coral_bay',\n name: 'Coral Bay',\n region: 'Adriatic coast',\n vibe: 'beach',\n priceFrom: 890,\n bestMonths: 'May–Sep',\n why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',\n },\n {\n id: 'monte_alto',\n name: 'Monte Alto',\n region: 'Northern Alps',\n vibe: 'mountains',\n priceFrom: 1120,\n bestMonths: 'Dec–Mar',\n why: 'Ski-in village with beginner slopes and long groomed runs.',\n },\n {\n id: 'old_quarter',\n name: 'Old Quarter',\n region: 'Central Europe',\n vibe: 'culture',\n priceFrom: 640,\n bestMonths: 'Apr–Oct',\n why: 'Dense museum district and food halls, all reachable on foot.',\n },\n {\n id: 'harbor_city',\n name: 'Harbor City',\n region: 'Pacific rim',\n vibe: 'city',\n priceFrom: 980,\n bestMonths: 'Sep–Nov',\n why: 'Waterfront nightlife and day-trip islands a short ferry away.',\n },\n] as const;\n\n// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break\n// substitution), so the deep link carries `month` only if it is already safe — the model maps natural\n// phrasing (\"early June\") onto one of these when it fills the tool.\nconst monthEnum = z\n .enum([\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n ])\n .default('June');\n\n// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so\n// only a real, url-safe id can reach the deep link.\nconst destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);\n\nconst discoverInput = z.object({\n vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n});\n\n// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and\n// shortlisting is a local non-destructive write.\nconst readOnly = annotations.readOnly();\nconst openLink = annotations.openAction();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst destinationOutput = z.object({\n id: z.string(),\n name: z.string(),\n region: z.string(),\n vibe: z.string(),\n priceFrom: z.number(),\n bestMonths: z.string(),\n why: z.string(),\n});\n\nconst discoverGetaways = tool('discover_getaways', {\n title: 'Discover getaways',\n description:\n 'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',\n annotations: readOnly,\n input: discoverInput,\n output: z.object({\n status: z.string(),\n vibe: z.string(),\n month: z.string(),\n travelers: z.number(),\n // Bounded list: the curated catalog is fixed and small, and the declared ceiling tells the\n // model and host the payload cannot grow. `noodle check` reports `tool_design_output_bounds`.\n options: z.array(destinationOutput).max(20),\n }),\n // The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.\n // (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)\n fulfil: ({ input }) => ({\n status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,\n vibe: input.vibe,\n month: input.month,\n travelers: input.travelers,\n options: catalog,\n }),\n viewTitle: 'Discover getaways',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Finding getaways…',\n invoked: 'Getaways ready',\n domain: 'https://getaways.acme.example',\n view: {\n component: 'discovery-carousel',\n entry: './views/discovery-carousel.tsx',\n },\n viewDescription:\n 'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n});\n\nconst createHandoff = tool('create_handoff', {\n title: 'Create booking handoff',\n description:\n 'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +\n 'Pass the destination id (url-safe slug, e.g. \"coral_bay\") and its display name.',\n annotations: openLink,\n input: z.object({\n destination: destinationId,\n destinationName: z.string().min(1),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n summary: z.string(),\n handoffUrl: z.string(),\n }),\n // Inline the inputs directly so they substitute at runtime; every value is already url-safe\n // (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures\n // ChatGPT-sourced conversions on.\n fulfil: ({ input }) => ({\n status: `Ready to continue on Acme for ${input.destinationName}.`,\n destination: input.destination,\n summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,\n handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,\n }),\n});\n\nconst shortlistGetaway = tool('shortlist_getaway', {\n visibility: ['app'],\n description: 'Record the traveler’s shortlisted destination from the discovery widget.',\n annotations: localWrite,\n input: z.object({\n destination: z.string(),\n note: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n note: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Shortlisted ${input.destination}.`,\n destination: input.destination,\n note: input.note,\n }),\n});\n\n// The consultative sales gateway (ADR 0214): when a visitor would rather not sign up, the assistant\n// may — with explicit confirmation — take their details and deliver them to Acme's own sink. The\n// recipe is a composition of existing primitives, not a platform feature: an ordinary confirm-gated\n// action plus a declarative HTTP connector whose endpoint and credential are operator-managed\n// (`noodle variables set LEAD_SINK_URL …`, `noodle secrets set LEAD_SINK_TOKEN …`). The payload\n// rests only in Acme's own system; the platform stores no lead. A vendor sink is the same shape as\n// data: Resend/Postmark take `auth: { kind: 'apiKey', … }`, a HubSpot private app takes\n// `auth: { kind: 'bearer', … }` — never a named vendor package.\nconst leadSink = connector('lead_sink')\n .version('1.0.0')\n .http({\n baseUrl: variable('LEAD_SINK_URL'),\n allowedOrigins: ['https://acme.example'],\n auth: { kind: 'bearer', secret: secret('LEAD_SINK_TOKEN') },\n operations: {\n submit_lead: {\n type: 'action',\n method: 'POST',\n path: '/api/assistant-lead',\n input: z.object({\n name: z.string().trim().min(2).max(120),\n workEmail: z.email().max(240),\n company: z.string().trim().min(2).max(200),\n note: z.string().max(500),\n }),\n output: z.object({ ok: z.boolean() }),\n request: {\n name: '${args.name}',\n workEmail: '${args.workEmail}',\n company: '${args.company}',\n note: '${args.note}',\n // Fixed attribution, set here rather than model-supplied: Acme's sink can trust it.\n source: 'website-assistant',\n },\n response: { ok: '${response.ok}' },\n },\n },\n });\n\nconst captureLead = tool('capture_lead', {\n title: 'Send my details to Acme',\n description:\n 'Send the visitor’s contact details and trip interest to Acme Getaways so the team may follow ' +\n 'up. Call only after the visitor explicitly agrees to be contacted; the confirmation card is ' +\n 'their consent moment. After a confirmed success, say only that the details were sent — never ' +\n 'promise response timing.',\n annotations: annotations.action({ confirm: true }),\n input: z.object({\n name: z.string().trim().min(2).max(120).meta({ title: 'Your name' }),\n workEmail: z.email().max(240).meta({ title: 'Work email' }),\n company: z.string().trim().min(2).max(200).meta({ title: 'Company' }),\n note: z.string().max(500).default('').meta({ title: 'What are you planning?' }),\n }),\n output: z.object({ ok: z.boolean() }),\n fulfil: ({ input, connectors }) => {\n const result = connectors.leads.submitLead({\n name: input.name,\n workEmail: input.workEmail,\n company: input.company,\n note: input.note,\n });\n return { ok: result.ok };\n },\n});\n\n// The mixed surface's sign-in trigger (ADR 0201): reading `${user.id}` classifies this tool\n// requires-identity, so an anonymous visitor who reaches for it sees the sign-in card instead of an\n// error — and after signing in on Acme's account origin, the conversation continues under the\n// authenticated surface below.\nconst myTrips = tool('my_trips', {\n title: 'My saved trips',\n description: 'Read the signed-in traveler’s saved trips and their booking status.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n traveler: z.string(),\n status: z.string(),\n }),\n fulfil: ({ user }) => ({\n traveler: user.id as string,\n status: 'No trips booked yet — shortlist a getaway to start one.',\n }),\n});\n\n// Grounding beyond the catalog: two controlled files answer policy/pricing/support questions with\n// citations, and Acme's live public site is crawled on deploy and re-crawled on the declared\n// refresh cadence — no sync job, no handwritten search tool. One declaration, one generated\n// `search_destinations` capability. The managed crawler and index are the defaults; a component\n// can instead bring its own via `crawler: firecrawl({ apiKey: secret('FIRECRAWL_API_KEY') })`\n// and `index: algolia({ appId: variable('ALGOLIA_APP_ID'), apiKey: secret('ALGOLIA_API_KEY') })`\n// — the code names the config, `noodle secrets|variables set` supplies the values.\nconst destinations = knowledge('destinations', {\n title: 'Acme Getaways destinations',\n description: 'Public destination, pricing, cancellation, and support information.',\n documents: [\n file('./knowledge/product.md', {\n title: 'Product guide',\n sourceUrl: 'https://getaways.acme.example/product',\n }),\n file('./knowledge/faq.txt', { title: 'FAQ' }),\n ],\n sites: [\n site({\n origin: 'https://getaways.acme.example',\n include: ['/destinations/**', '/pricing', '/support'],\n refresh: '12h',\n }),\n ],\n});\n\nexport default server(\n 'acme_discovery',\n {\n title: 'Acme Getaways',\n version: '1.0.0',\n branding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4',\n surface: '#F0FDFA',\n surfaceDark: '#0B1B1B',\n radius: 'lg',\n density: 'comfortable',\n },\n // The only external destinations the app links out to — the compiler derives ChatGPT's\n // redirect_domains from this so the handoff opens without a safe-link warning.\n handoff: {\n allowedDomains: ['https://book.acme.example', 'https://acme.example'],\n },\n use: { leads: leadSink },\n // The same tools also serve Acme's own websites, with no second tool set. The marketing site is\n // a **mixed** surface (`signIn: true`): a visitor with no account gets discovery, the booking\n // handoff, and the confirm-gated lead capture — and reaching for `my_trips` raises the sign-in\n // card instead of an error, with `signUpAction` offering account creation through Acme's own\n // registration. After the login redirect lands on the account origin, the same conversation\n // continues under the authenticated surface's capabilities and instructions (ADR 0201).\n // `capabilities` is the whole externally reachable surface per front door — short enough to\n // review in one glance, and closed by default when a tool is added to the server later.\n assistant: embeddedAssistant({\n model: noodleManaged(),\n access: [\n publicWebsite({\n origins: ['https://getaways.acme.example'],\n // A browser agent on Acme's marketing page (Gemini-in-Chrome, Claude-in-Chrome) discovers\n // exactly the capabilities listed below and reaches them over the same authorization,\n // confirmation, budget, and audit path the panel's own calls take: `capture_lead` still\n // stops for its confirmation card. `site/index.html` is the page this runs on.\n webmcp: { enabled: true },\n capabilities: [\n destinations,\n discoverGetaways,\n createHandoff,\n shortlistGetaway,\n captureLead,\n myTrips,\n ],\n signIn: true,\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step. Ground recommendations in Acme knowledge, and clearly separate discovery from booking. When a visitor’s plans firm up, invite them to sign in or create an account; if they would rather not, offer — once — to send their details to the Acme team instead.',\n }),\n authenticatedWebsite({\n origins: ['https://account.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, myTrips],\n instructions:\n 'The traveler is signed in. Help them plan from their saved trips, and keep booking on Acme’s own pages through the handoff.',\n }),\n ],\n layout: { mode: 'floating', position: 'bottom-right' },\n labels: {\n welcomeHeading: 'Where would you like to go?',\n signInHeading: 'Continue with your Acme account',\n signInBody: 'Saved trips need an account.',\n signInAction: 'Sign in',\n signUpAction: 'Create free account',\n },\n }),\n knowledge: [destinations],\n },\n [discoverGetaways, createHandoff, shortlistGetaway, captureLead, myTrips],\n);\n" },
|
|
39
|
+
{ relPath: "examples/acme-discovery/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n embeddedAssistant,\n file,\n knowledge,\n noodleManaged,\n publicWebsite,\n secret,\n server,\n site,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and\n// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,\n// reached through a signed, attributable handoff deep link. Destinations are the partner's own\n// catalog (grounding) — the app never invents a place, price, or best-month.\n//\n// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as\n// `${input.x}` substitutions when placed directly into an output string; do not transform them\n// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The\n// curated catalog below is static data the runtime returns verbatim.\n\nconst catalog = [\n {\n id: 'coral_bay',\n name: 'Coral Bay',\n region: 'Adriatic coast',\n vibe: 'beach',\n priceFrom: 890,\n bestMonths: 'May–Sep',\n why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',\n },\n {\n id: 'monte_alto',\n name: 'Monte Alto',\n region: 'Northern Alps',\n vibe: 'mountains',\n priceFrom: 1120,\n bestMonths: 'Dec–Mar',\n why: 'Ski-in village with beginner slopes and long groomed runs.',\n },\n {\n id: 'old_quarter',\n name: 'Old Quarter',\n region: 'Central Europe',\n vibe: 'culture',\n priceFrom: 640,\n bestMonths: 'Apr–Oct',\n why: 'Dense museum district and food halls, all reachable on foot.',\n },\n {\n id: 'harbor_city',\n name: 'Harbor City',\n region: 'Pacific rim',\n vibe: 'city',\n priceFrom: 980,\n bestMonths: 'Sep–Nov',\n why: 'Waterfront nightlife and day-trip islands a short ferry away.',\n },\n] as const;\n\n// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break\n// substitution), so the deep link carries `month` only if it is already safe — the model maps natural\n// phrasing (\"early June\") onto one of these when it fills the tool.\nconst monthEnum = z\n .enum([\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n ])\n .default('June');\n\n// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so\n// only a real, url-safe id can reach the deep link.\nconst destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);\n\nconst discoverInput = z.object({\n vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n});\n\n// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and\n// shortlisting is a local non-destructive write.\nconst readOnly = annotations.readOnly();\nconst openLink = annotations.openAction();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst destinationOutput = z.object({\n id: z.string(),\n name: z.string(),\n region: z.string(),\n vibe: z.string(),\n priceFrom: z.number(),\n bestMonths: z.string(),\n why: z.string(),\n});\n\nconst discoverGetaways = tool('discover_getaways', {\n title: 'Discover getaways',\n description:\n 'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',\n annotations: readOnly,\n input: discoverInput,\n output: z.object({\n status: z.string(),\n vibe: z.string(),\n month: z.string(),\n travelers: z.number(),\n // Bounded list: the curated catalog is fixed and small, and the declared ceiling tells the\n // model and host the payload cannot grow. `noodle check` reports `tool_design_output_bounds`.\n options: z.array(destinationOutput).max(20),\n }),\n // The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.\n // (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)\n fulfil: ({ input }) => ({\n status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,\n vibe: input.vibe,\n month: input.month,\n travelers: input.travelers,\n options: catalog,\n }),\n viewTitle: 'Discover getaways',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Finding getaways…',\n invoked: 'Getaways ready',\n domain: 'https://getaways.acme.example',\n view: {\n component: 'discovery-carousel',\n entry: './views/discovery-carousel.tsx',\n },\n viewDescription:\n 'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n});\n\nconst createHandoff = tool('create_handoff', {\n title: 'Create booking handoff',\n description:\n 'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +\n 'Pass the destination id (url-safe slug, e.g. \"coral_bay\") and its display name.',\n annotations: openLink,\n input: z.object({\n destination: destinationId,\n destinationName: z.string().min(1),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n summary: z.string(),\n handoffUrl: z.string(),\n }),\n // Inline the inputs directly so they substitute at runtime; every value is already url-safe\n // (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures\n // ChatGPT-sourced conversions on.\n fulfil: ({ input }) => ({\n status: `Ready to continue on Acme for ${input.destinationName}.`,\n destination: input.destination,\n summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,\n handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,\n }),\n});\n\nconst shortlistGetaway = tool('shortlist_getaway', {\n visibility: ['app'],\n description: 'Record the traveler’s shortlisted destination from the discovery widget.',\n annotations: localWrite,\n input: z.object({\n destination: z.string(),\n note: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n note: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Shortlisted ${input.destination}.`,\n destination: input.destination,\n note: input.note,\n }),\n});\n\n// The consultative sales gateway (ADR 0214): when a visitor would rather not sign up, the assistant\n// may — with explicit confirmation — take their details and deliver them to Acme's own sink. The\n// recipe is a composition of existing primitives, not a platform feature: an ordinary confirm-gated\n// action plus a declarative HTTP connector whose endpoint and credential are operator-managed\n// (`noodle variables set LEAD_SINK_URL …`, `noodle secrets set LEAD_SINK_TOKEN …`). The payload\n// rests only in Acme's own system; the platform stores no lead. A vendor sink is the same shape as\n// data: Resend/Postmark take `auth: { kind: 'apiKey', … }`, a HubSpot private app takes\n// `auth: { kind: 'bearer', … }` — never a named vendor package.\nconst leadSink = connector('lead_sink')\n .version('1.0.0')\n .http({\n baseUrl: variable('LEAD_SINK_URL'),\n allowedOrigins: ['https://acme.example'],\n auth: { kind: 'bearer', secret: secret('LEAD_SINK_TOKEN') },\n operations: {\n submit_lead: {\n type: 'action',\n method: 'POST',\n path: '/api/assistant-lead',\n input: z.object({\n name: z.string().trim().min(2).max(120),\n workEmail: z.email().max(240),\n company: z.string().trim().min(2).max(200),\n note: z.string().max(500),\n }),\n output: z.object({ ok: z.boolean() }),\n request: {\n name: '${args.name}',\n workEmail: '${args.workEmail}',\n company: '${args.company}',\n note: '${args.note}',\n // Fixed attribution, set here rather than model-supplied: Acme's sink can trust it.\n source: 'website-assistant',\n },\n response: { ok: '${response.ok}' },\n },\n },\n });\n\nconst captureLead = tool('capture_lead', {\n title: 'Send my details to Acme',\n description:\n 'Send the visitor’s contact details and trip interest to Acme Getaways so the team may follow ' +\n 'up. Call only after the visitor explicitly agrees to be contacted; the confirmation card is ' +\n 'their consent moment. After a confirmed success, say only that the details were sent — never ' +\n 'promise response timing.',\n annotations: annotations.action({ confirm: true }),\n input: z.object({\n name: z.string().trim().min(2).max(120).meta({ title: 'Your name' }),\n workEmail: z.email().max(240).meta({ title: 'Work email' }),\n company: z.string().trim().min(2).max(200).meta({ title: 'Company' }),\n note: z.string().max(500).default('').meta({ title: 'What are you planning?' }),\n }),\n output: z.object({ ok: z.boolean() }),\n fulfil: ({ input, connectors }) => {\n const result = connectors.leads.submitLead({\n name: input.name,\n workEmail: input.workEmail,\n company: input.company,\n note: input.note,\n });\n return { ok: result.ok };\n },\n});\n\n// The mixed surface's sign-in trigger (ADR 0201): reading `${user.id}` classifies this tool\n// requires-identity, so an anonymous visitor who reaches for it sees the sign-in card instead of an\n// error — and after signing in on Acme's account origin, the conversation continues under the\n// authenticated surface below.\nconst myTrips = tool('my_trips', {\n title: 'My saved trips',\n description: 'Read the signed-in traveler’s saved trips and their booking status.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n traveler: z.string(),\n status: z.string(),\n }),\n fulfil: ({ user }) => ({\n traveler: user.id as string,\n status: 'No trips booked yet — shortlist a getaway to start one.',\n }),\n});\n\n// Grounding beyond the catalog: two controlled files answer policy/pricing/support questions with\n// citations, and Acme's live public site is crawled on deploy and re-crawled on the declared\n// refresh cadence — no sync job, no handwritten search tool. One declaration, one generated\n// `search_destinations` capability. The managed crawler and index are the defaults; a component\n// can instead bring its own via `crawler: firecrawl({ apiKey: secret('FIRECRAWL_API_KEY') })`\n// and `index: algolia({ appId: variable('ALGOLIA_APP_ID'), apiKey: secret('ALGOLIA_API_KEY') })`\n// — the code names the config, `noodle secrets|variables set` supplies the values.\nconst destinations = knowledge('destinations', {\n title: 'Acme Getaways destinations',\n description: 'Public destination, pricing, cancellation, and support information.',\n documents: [\n file('./knowledge/product.md', {\n title: 'Product guide',\n sourceUrl: 'https://getaways.acme.example/product',\n }),\n file('./knowledge/faq.txt', { title: 'FAQ' }),\n ],\n sites: [\n site({\n origin: 'https://getaways.acme.example',\n include: ['/destinations/**', '/pricing', '/support'],\n refresh: '12h',\n }),\n ],\n});\n\nexport default server(\n 'acme_discovery',\n {\n title: 'Acme Getaways',\n version: '1.0.0',\n branding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4',\n surface: '#F0FDFA',\n surfaceDark: '#0B1B1B',\n radius: 'lg',\n density: 'comfortable',\n },\n // The only external destinations the app links out to — the compiler derives ChatGPT's\n // redirect_domains from this so the handoff opens without a safe-link warning.\n handoff: {\n allowedDomains: ['https://book.acme.example', 'https://acme.example'],\n },\n use: { leads: leadSink },\n // The same tools also serve Acme's own websites, with no second tool set. The marketing site is\n // a **mixed** surface (`signIn: true`): a visitor with no account gets discovery, the booking\n // handoff, and the confirm-gated lead capture — and reaching for `my_trips` raises the sign-in\n // card instead of an error, with `signUpAction` offering account creation through Acme's own\n // registration. After the login redirect lands on the account origin, the same conversation\n // continues under the authenticated surface's capabilities and instructions (ADR 0201).\n // `capabilities` is the whole externally reachable surface per front door — short enough to\n // review in one glance, and closed by default when a tool is added to the server later.\n assistant: embeddedAssistant({\n model: noodleManaged(),\n access: [\n publicWebsite({\n origins: ['https://getaways.acme.example'],\n // A browser agent on Acme's marketing page (Gemini-in-Chrome, Claude-in-Chrome) discovers\n // exactly the capabilities listed below and reaches them over the same authorization,\n // confirmation, budget, and audit path the panel's own calls take: `capture_lead` still\n // stops for its confirmation card. `site/index.html` is the page this runs on.\n webmcp: { enabled: true },\n // A visitor who asks about Coral Bay on one page and clicks through to another would\n // otherwise arrive at an empty panel and have to start over. This carries the text they\n // have already read onto the next page, on a fresh session — never the old session's\n // authority, budget, or a half-answered confirmation (ADR 0223). Opt-in because anonymous\n // conversation text is Acme's content on Acme's page; the defaults below are deliberately\n // tighter than the platform ceiling.\n continuity: { enabled: true, windowSeconds: 300, maxRestores: 3 },\n capabilities: [\n destinations,\n discoverGetaways,\n createHandoff,\n shortlistGetaway,\n captureLead,\n myTrips,\n ],\n signIn: true,\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step. Ground recommendations in Acme knowledge, and clearly separate discovery from booking. When a visitor’s plans firm up, invite them to sign in or create an account; if they would rather not, offer — once — to send their details to the Acme team instead.',\n }),\n authenticatedWebsite({\n origins: ['https://account.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, myTrips],\n instructions:\n 'The traveler is signed in. Help them plan from their saved trips, and keep booking on Acme’s own pages through the handoff.',\n }),\n ],\n layout: { mode: 'floating', position: 'bottom-right' },\n labels: {\n welcomeHeading: 'Where would you like to go?',\n signInHeading: 'Continue with your Acme account',\n signInBody: 'Saved trips need an account.',\n signInAction: 'Sign in',\n signUpAction: 'Create free account',\n },\n }),\n knowledge: [destinations],\n },\n [discoverGetaways, createHandoff, shortlistGetaway, captureLead, myTrips],\n);\n" },
|
|
40
40
|
{ relPath: "examples/acme-discovery/src/views/discovery-carousel.tsx", content: "import { useState } from 'react';\nimport { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } from '../helpers.js';\nimport './widget-style.css';\n\ntype Destination = {\n readonly id: string;\n readonly name: string;\n readonly region: string;\n readonly vibe: string;\n readonly priceFrom: number;\n readonly bestMonths: string;\n readonly why: string;\n};\n\nfunction asDiscovery(value: unknown) {\n return value as\n | {\n readonly status?: string;\n readonly month?: string;\n readonly travelers?: number;\n readonly options?: readonly Destination[];\n }\n | undefined;\n}\n\nexport default function DiscoveryCarousel() {\n const { displayMode, theme } = useLayout();\n const openExternal = useOpenExternal();\n const discovery = asDiscovery(useToolInfo('discover_getaways').structuredContent);\n const shortlist = useCallTool('shortlist_getaway');\n const handoff = useCallTool('create_handoff');\n\n const options = discovery?.options ?? [];\n const month = discovery?.month ?? 'June';\n const travelers = discovery?.travelers ?? 2;\n const [chosen, setChosen] = useViewState('chosen', options[0]?.id ?? '');\n const [status, setStatus] = useState(discovery?.status ?? 'Pick a getaway to continue.');\n const selected = options.find((entry) => entry.id === chosen) ?? options[0];\n const continueLabel = handoff.isPending\n ? 'Opening Acme…'\n : `Continue on Acme${selected ? ` · ${selected.name}` : ''}`;\n\n async function shortlistDestination(destination: Destination) {\n setChosen(destination.id);\n try {\n const result = await shortlist.callTool({ destination: destination.name });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Shortlisted ${destination.name}.`);\n } catch {\n setStatus(`Couldn't shortlist ${destination.name} — try again.`);\n }\n }\n\n async function continueOnAcme() {\n if (selected === undefined) return;\n // The handoff is the product: configure here, transact off-app. The deep link carries the trip.\n // Only open the external target on a successful handoff; surface failures instead of failing silently.\n try {\n const result = await handoff.callTool({\n destination: selected.id,\n destinationName: selected.name,\n month,\n travelers,\n });\n const structured = result.structuredContent as { readonly handoffUrl?: string } | undefined;\n if (structured?.handoffUrl) openExternal(structured.handoffUrl);\n else setStatus('Continue on Acme is unavailable right now — try again.');\n } catch {\n setStatus('Continue on Acme failed — try again.');\n }\n }\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Acme Getaways discovery: ${options.length} options for ${month}, ${travelers} traveler(s); shortlisted ${selected?.name ?? 'none'}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <CompassIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">Acme Getaways</h1>\n <p className=\"nw-subtitle\" aria-live=\"polite\">\n {status}\n </p>\n </div>\n <span className=\"nw-chip\">\n {displayMode === 'fullscreen' ? 'Fullscreen' : 'Discover'}\n </span>\n </header>\n\n <div className=\"nw-carousel\">\n {options.map((entry) => (\n <article\n className={`nw-dest${entry.id === chosen ? ' nw-dest-active' : ''}`}\n key={entry.id}\n >\n <div className=\"nw-dest-head\">\n <span className=\"nw-dest-name\">{entry.name}</span>\n <span className=\"nw-price\">from ${entry.priceFrom}</span>\n </div>\n <p className=\"nw-dest-region\">\n {entry.region} · best {entry.bestMonths}\n </p>\n {/* Grounded copy: the \"why\" comes from Acme's catalog, not invented at runtime. */}\n <p className=\"nw-dest-why\">{entry.why}</p>\n <button\n aria-pressed={entry.id === chosen}\n className=\"nw-button nw-button-ghost\"\n type=\"button\"\n onClick={() => shortlistDestination(entry)}\n >\n {entry.id === chosen ? 'Shortlisted' : 'Shortlist'}\n </button>\n </article>\n ))}\n </div>\n\n <div className=\"nw-actions\">\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n disabled={selected === undefined || handoff.isPending}\n onClick={continueOnAcme}\n >\n <ExternalIcon />\n {continueLabel}\n </button>\n </div>\n <p className=\"nw-note\">Booking and payment happen on acme.example — never inside chat.</p>\n </section>\n </main>\n );\n}\n\nfunction CompassIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n" },
|
|
41
41
|
{ relPath: "examples/acme-discovery/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f4fbfa;\n --nw-text: #10201f;\n --nw-muted: #5b6b6a;\n --nw-border: #d4e6e4;\n --nw-accent: #0ea5a4;\n --nw-accent-strong: #0f766e;\n --nw-accent-soft: #e6faf8;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(15 40 40 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #0b1b1b;\n --nw-surface: #102624;\n --nw-text: #eafaf8;\n --nw-muted: #9fb6b3;\n --nw-border: #244341;\n --nw-accent: #2dd4bf;\n --nw-accent-strong: #14b8a6;\n --nw-accent-soft: #0f3835;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 30%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 720px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 26px;\n height: 26px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-carousel {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n gap: 12px;\n padding: 16px;\n}\n\n.nw-dest {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 12px;\n border: 1px solid var(--nw-border);\n border-radius: 12px;\n background: var(--nw-bg);\n}\n\n.nw-dest-active {\n border-color: var(--nw-accent);\n box-shadow: 0 0 0 1px var(--nw-accent);\n}\n\n.nw-dest-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n}\n\n.nw-dest-name {\n font-weight: 700;\n}\n\n.nw-price {\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-dest-region {\n margin: 0;\n font-size: 12px;\n color: var(--nw-muted);\n}\n\n.nw-dest-why {\n margin: 0;\n font-size: 13px;\n flex: 1;\n}\n\n.nw-actions {\n display: flex;\n gap: 8px;\n padding: 0 16px 12px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button svg {\n width: 16px;\n height: 16px;\n fill: none;\n stroke: currentColor;\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-button-ghost {\n align-self: flex-start;\n padding: 6px 12px;\n font-size: 13px;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-note {\n margin: 0;\n padding: 0 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
|
|
42
42
|
{ relPath: "examples/acme-discovery/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-discovery example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the off-app handoff domain the deep link lands on', async () => {\n // Top-of-funnel: the only external target is Acme's booking site, declared once at the server.\n const manifest = await app.toManifest();\n expect(JSON.stringify(manifest)).toContain('https://book.acme.example');\n });\n\n it('exposes the discovery tool and the handoff tool', async () => {\n const manifest = await app.toManifest();\n const text = JSON.stringify(manifest);\n // The discovery tool renders the carousel; the handoff tool emits the deep link; the widget-only\n // helper records a shortlist.\n expect(text).toContain('discover_getaways');\n expect(text).toContain('create_handoff');\n expect(text).toContain('shortlist_getaway');\n });\n\n it('gives the public website a consultative surface-specific goal', async () => {\n const manifest = await app.toManifest();\n expect(manifest.server.assistant?.model).toEqual({ kind: 'noodle-managed' });\n expect(manifest.server.assistant?.surfaces?.[0]?.instructions).toContain(\n 'friendly, consultative travel guide',\n );\n });\n\n it('keeps the lead capture behind explicit confirmation and a managed customer sink', async () => {\n const manifest = (await app.toManifest()) as {\n tools: { name: string; annotations?: Record<string, unknown> }[];\n };\n const captureLead = manifest.tools.find((tool) => tool.name === 'capture_lead');\n // The confirmation card is the visitor's consent moment (ADR 0214): a lead may never leave the\n // conversation without it, and the sink endpoint/credential stay operator-managed data.\n expect(captureLead?.annotations?.confirm).toBe(true);\n const catalog = JSON.stringify(\n (app as unknown as { toConnectorCatalog: () => unknown }).toConnectorCatalog(),\n );\n expect(catalog).toContain('${env.LEAD_SINK_URL}');\n expect(catalog).toContain('LEAD_SINK_TOKEN');\n // Fixed attribution set in the request mapping, never model-supplied; no named vendor host.\n expect(catalog).toContain('website-assistant');\n expect(catalog).not.toContain('api.resend.com');\n expect(catalog).not.toContain('api.hubapi.com');\n });\n\n it('serves a mixed marketing surface and an authenticated account surface from one server', async () => {\n const manifest = await app.toManifest();\n const surfaces = manifest.server.assistant?.surfaces ?? [];\n expect(surfaces.map((surface) => surface.mode)).toEqual(['mixed', 'authenticated']);\n // The sign-in trigger is listed on the mixed surface so the assistant can offer it; the\n // authenticated surface carries its own narrowed list and voice.\n const capabilityNames = (surface: (typeof surfaces)[number]) =>\n surface.capabilities?.map((capability) => capability.name) ?? [];\n expect(capabilityNames(surfaces[0]!)).toContain('my_trips');\n expect(capabilityNames(surfaces[0]!)).toContain('capture_lead');\n expect(capabilityNames(surfaces[1]!)).toEqual([\n 'destinations',\n 'discover_getaways',\n 'create_handoff',\n 'my_trips',\n ]);\n // Authoring the sign-up label is the opt-in for the card's create-account button.\n expect(manifest.server.assistant?.labels?.signUpAction).toBe('Create free account');\n });\n\n it('opens the marketing surface to browser agents and leaves the account surface closed', async () => {\n const manifest = await app.toManifest();\n const surfaces = manifest.server.assistant?.surfaces ?? [];\n\n // Both front doors, asserted by count first: without it the per-surface claims below read a\n // missing surface as `undefined` and pass, so deleting a surface would silently satisfy them.\n expect(surfaces).toHaveLength(2);\n // ADR 0220: the opt-in governs *discovery* — whether the embed registers this session's already\n // projected tools with `document.modelContext`. A browser agent on Acme's marketing page reaches\n // exactly the six capabilities above, over the same authorization, confirmation, and budget path\n // the panel's own calls take. `capture_lead` still stops for its confirmation card.\n expect(surfaces[0]?.webmcp).toEqual({ enabled: true });\n // Per-surface opt-in exists so the two front doors can answer differently, and here they do: the\n // signed-in account surface carries a traveler's identity, so its tools are not advertised to\n // whatever agent happens to be running in that browser.\n expect(surfaces[1]?.webmcp).toBeUndefined();\n });\n\n it('declares the grounded knowledge component and its live site scope', async () => {\n const manifest = (await app.toManifest()) as { server: { knowledge?: unknown[] } };\n // One declaration: controlled files plus the live public site, compiled later into the\n // generated `search_destinations` capability with citations.\n expect(manifest.server.knowledge).toHaveLength(1);\n const text = JSON.stringify(manifest);\n expect(text).toContain('knowledge/product.md');\n expect(text).toContain('https://getaways.acme.example');\n });\n});\n" },
|
|
@@ -202,6 +202,8 @@ export function renderEmbeddedAssistantReference() {
|
|
|
202
202
|
'',
|
|
203
203
|
"Set `webmcp: { enabled: true }` on the assistant to let a browser agent reach this session's tools through the page's WebMCP API, and set it on an individual access surface to override that default in either direction — a marketing surface can opt in while a signed-in one opts out, or the reverse. Off unless set, and inert in browsers without `document.modelContext`. It governs discovery: whether the embed registers the tools this session already projects, narrowed to those that are both app-callable and model-visible. Every call executes over the same apps-bridge path the assistant's own calls take, so a browser agent gets the session's authority and nothing more, and a `confirm: true` tool still stops for a human in the panel rather than being accepted on the agent's behalf. It is not a second authorization boundary — the session is the only one. Bridge calls spend their own per-session and per-day budgets instead of model turns, and the surface's daily kill switch stops them too. Prefer this over hand-registering page-local tools that borrow the visitor's session: those carry no scoped authority, policy, or audit trail.",
|
|
204
204
|
'',
|
|
205
|
+
'Set `continuity: { enabled: true }` on a public or mixed access surface to let an anonymous visitor keep the conversation they can see when they navigate to another page of the same site. Off unless set, and refused on an authenticated surface, which reattaches through a backend-verified sign-in instead. It restores the visible text on a fresh session and never the old one: no tool authority, no share of a spent turn budget, and no pending confirmation carried across, so an unanswered confirmation stays unanswered. `windowSeconds` defaults to 300 with a 600 ceiling, `maxRestores` to 3 with a ceiling of 10, and 0 for either disables continuity outright; an operator may lower what you declare and can never raise it. The handle lives in `sessionStorage` so it dies with the tab, is single-use, and is valid only for the embed, origin, and visitor it was issued to. Declare it when a marketing site spreads one conversation across several pages; leave it off when anonymous conversation text should not survive a navigation at all.',
|
|
206
|
+
'',
|
|
205
207
|
'Give every business action a portable `tool(..., { title: "Complete task", description: "This will mark the task complete for everyone.", input: z.object({ task: z.string().meta({ title: "Task" }) }) })` title. The standard confirmation uses the tool title/description plus schema field `title`, `description`, and `format`; it shows Confirm and Don\'t proceed and keeps technical action details secondary. `behavior.showConfirmationDetails` defaults to `true`; set it to `false` to remove only the built-in card\'s Additional details disclosure and connector mechanics. The business review and decisions remain, `confirm: true` still suspends until acceptance, and headless/BYO `data-confirmation` stays unchanged. Do not put JSON or implementation names in business-facing copy.',
|
|
206
208
|
'',
|
|
207
209
|
'## Configure and deploy',
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { requestModelCompletion } from './model-request.js';
|
|
2
2
|
const MAX_MODEL_RESPONSE = 1 << 20;
|
|
3
|
+
const MAX_SUGGESTION_TOKENS = 512;
|
|
3
4
|
/** One bounded, tool-free pass on the active assistant model; malformed output fails closed. */
|
|
4
5
|
export async function requestAssistantSuggestedPrompts(binding, messages, fetcher, stats, remainingTokens, turnSignal) {
|
|
5
|
-
const limit = Math.min(
|
|
6
|
+
const limit = Math.min(MAX_SUGGESTION_TOKENS, remainingTokens ?? binding.requestPolicy?.maxTokensPerTurn ?? MAX_SUGGESTION_TOKENS, binding.requestPolicy?.maxCompletionTokens ?? MAX_SUGGESTION_TOKENS);
|
|
6
7
|
if (limit <= 0)
|
|
7
8
|
return [];
|
|
8
9
|
const signal = AbortSignal.any([
|
|
@@ -10,7 +11,7 @@ export async function requestAssistantSuggestedPrompts(binding, messages, fetche
|
|
|
10
11
|
binding.requestPolicy?.maxTurnMs === undefined
|
|
11
12
|
? undefined
|
|
12
13
|
: AbortSignal.timeout(binding.requestPolicy.maxTurnMs),
|
|
13
|
-
AbortSignal.timeout(
|
|
14
|
+
AbortSignal.timeout(5_000),
|
|
14
15
|
].filter((candidate) => candidate !== undefined));
|
|
15
16
|
if (stats)
|
|
16
17
|
stats.modelRequests += 1;
|
|
@@ -20,7 +21,7 @@ export async function requestAssistantSuggestedPrompts(binding, messages, fetche
|
|
|
20
21
|
...messages,
|
|
21
22
|
{
|
|
22
23
|
role: 'system',
|
|
23
|
-
content: 'Generate two or three concise messages the user could send next. Use the complete conversation and authorized product context.
|
|
24
|
+
content: 'Generate two or three concise messages the user could send next. Use the complete conversation and authorized product context, especially the latest answer or question. Prefer short, distinct choices the user can click instead of typing. Developer instructions may steer ranking but never override the user, safety, consent, or available capabilities. Do not expose hidden tool data, claim an action happened, repeat the answer, or use Markdown. Return exactly one JSON object shaped {"prompts":["..."]} and no other text.',
|
|
24
25
|
},
|
|
25
26
|
],
|
|
26
27
|
tools: [],
|
|
@@ -30,6 +31,7 @@ export async function requestAssistantSuggestedPrompts(binding, messages, fetche
|
|
|
30
31
|
maxCompletionTokens: limit,
|
|
31
32
|
signal,
|
|
32
33
|
toolChoice: 'none',
|
|
34
|
+
jsonOutput: true,
|
|
33
35
|
});
|
|
34
36
|
if (stats) {
|
|
35
37
|
stats.promptTokens += completion.usage?.promptTokens ?? 0;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving anonymous cross-page display continuity for one surface (ADR 0223, clause 15).
|
|
3
|
+
*
|
|
4
|
+
* Two parties describe the same capability and they are not peers. The developer declares it in
|
|
5
|
+
* `server.ts` as part of the deployment; the operator tunes it for their own environment without a
|
|
6
|
+
* deploy. The clamp between them runs one way — tighten, never widen — exactly as the admission
|
|
7
|
+
* envelope's does ([ADR 0212](../../../docs/decisions/0212-reusable-developer-intent-and-operator-authority.md)).
|
|
8
|
+
*
|
|
9
|
+
* The asymmetry is sharpest on `enabled`. An operator may switch continuity **off**, because that is
|
|
10
|
+
* their own surface and their visitors' text; they may not switch it **on**, because a surface whose
|
|
11
|
+
* author never declared continuity is one whose author never reasoned about whether their pages should
|
|
12
|
+
* carry conversation across a navigation.
|
|
13
|
+
*/
|
|
14
|
+
/** What a public or mixed surface declares in `server.ts`. Shapes match the manifest exactly. */
|
|
15
|
+
export interface AssistantContinuityDeclaration {
|
|
16
|
+
readonly enabled?: boolean;
|
|
17
|
+
readonly windowSeconds?: number;
|
|
18
|
+
readonly maxRestores?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* What an operator sets for their own environment. `enabled: false` disables; `true` is inert, since
|
|
22
|
+
* an operator cannot enable a capability the deployment does not declare.
|
|
23
|
+
*/
|
|
24
|
+
export interface AssistantContinuityOverride {
|
|
25
|
+
readonly enabled?: boolean;
|
|
26
|
+
readonly windowSeconds?: number;
|
|
27
|
+
readonly maxRestores?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface ResolvedAssistantContinuity {
|
|
30
|
+
readonly enabled: boolean;
|
|
31
|
+
/** Zero whenever `enabled` is false, so a caller cannot read a live window off a dead surface. */
|
|
32
|
+
readonly windowMs: number;
|
|
33
|
+
readonly maxRestores: number;
|
|
34
|
+
}
|
|
35
|
+
export declare function resolveContinuity(declaration: AssistantContinuityDeclaration | undefined, override: AssistantContinuityOverride | undefined): ResolvedAssistantContinuity;
|
|
36
|
+
//# sourceMappingURL=continuity-bounds.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ASSISTANT_CONTINUITY_MAX_RESTORES, ASSISTANT_CONTINUITY_RESTORE_CEILING, ASSISTANT_CONTINUITY_WINDOW_CEILING_MS, ASSISTANT_CONTINUITY_WINDOW_MS, continuityMaxRestores, continuityWindowMs, } from './continuity-store.js';
|
|
2
|
+
const OFF = { enabled: false, windowMs: 0, maxRestores: 0 };
|
|
3
|
+
/**
|
|
4
|
+
* A declared bound that is not a non-negative integer fails the surface closed rather than falling back
|
|
5
|
+
* to the shipped default.
|
|
6
|
+
*
|
|
7
|
+
* This is deliberately the opposite of the spend ladder's malformed-allowance rule, which fails to
|
|
8
|
+
* *absent* because an outage costs more than a day of unbudgeted spend. The thing configured here is a
|
|
9
|
+
* capability, and the safe failure for a capability is not to exist: a typo that silently grants a
|
|
10
|
+
* 300-second window nobody asked for is worse than a typo that grants nothing.
|
|
11
|
+
*/
|
|
12
|
+
function boundedInteger(value) {
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
return Number.isInteger(value) && value >= 0 ? value : 'invalid';
|
|
16
|
+
}
|
|
17
|
+
export function resolveContinuity(declaration, override) {
|
|
18
|
+
if (declaration?.enabled !== true)
|
|
19
|
+
return OFF;
|
|
20
|
+
if (override?.enabled === false)
|
|
21
|
+
return OFF;
|
|
22
|
+
const declaredWindow = boundedInteger(declaration.windowSeconds);
|
|
23
|
+
const declaredRestores = boundedInteger(declaration.maxRestores);
|
|
24
|
+
const overriddenWindow = boundedInteger(override?.windowSeconds);
|
|
25
|
+
const overriddenRestores = boundedInteger(override?.maxRestores);
|
|
26
|
+
if (declaredWindow === 'invalid' ||
|
|
27
|
+
declaredRestores === 'invalid' ||
|
|
28
|
+
overriddenWindow === 'invalid' ||
|
|
29
|
+
overriddenRestores === 'invalid') {
|
|
30
|
+
return OFF;
|
|
31
|
+
}
|
|
32
|
+
// Each side is clamped to the structural ceiling first, then the tighter of the two wins. Clamping
|
|
33
|
+
// before the `Math.min` means an over-eager operator value degrades to the ceiling rather than
|
|
34
|
+
// becoming one, which is the same shape `surfaceEnvelope` uses for daily budgets.
|
|
35
|
+
const windowMs = Math.min(declaredWindow === undefined
|
|
36
|
+
? ASSISTANT_CONTINUITY_WINDOW_MS
|
|
37
|
+
: continuityWindowMs(declaredWindow * 1000), overriddenWindow === undefined
|
|
38
|
+
? ASSISTANT_CONTINUITY_WINDOW_CEILING_MS
|
|
39
|
+
: continuityWindowMs(overriddenWindow * 1000));
|
|
40
|
+
const maxRestores = Math.min(declaredRestores === undefined
|
|
41
|
+
? ASSISTANT_CONTINUITY_MAX_RESTORES
|
|
42
|
+
: continuityMaxRestores(declaredRestores), overriddenRestores === undefined
|
|
43
|
+
? ASSISTANT_CONTINUITY_RESTORE_CEILING
|
|
44
|
+
: continuityMaxRestores(overriddenRestores));
|
|
45
|
+
// Zero from either party is a real value and is the kill switch, so it must not read as "unset".
|
|
46
|
+
// Collapsing it to `OFF` here keeps every caller from having to remember that a zero-length window
|
|
47
|
+
// and a disabled surface are the same thing.
|
|
48
|
+
if (windowMs === 0 || maxRestores === 0)
|
|
49
|
+
return OFF;
|
|
50
|
+
return { enabled: true, windowMs, maxRestores };
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=continuity-bounds.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { TenantRef } from './tenant-ref.js';
|
|
2
|
+
/**
|
|
3
|
+
* Anonymous cross-page display continuity (ADR 0223, clauses 11-16).
|
|
4
|
+
*
|
|
5
|
+
* **A display handle, not a session.** Claiming one returns the conversation's visible text for
|
|
6
|
+
* rendering and mints a fresh session; it never revives the original, and grants no tool authority, no
|
|
7
|
+
* share of a spent turn budget, and no bound surface. Deliberately unlike the authenticated reattach in
|
|
8
|
+
* clause 7, it restores no view descriptor and no pending interaction — a capability that survives a
|
|
9
|
+
* navigation is exactly what this design refuses to create.
|
|
10
|
+
*
|
|
11
|
+
* **Its bounds are shaped by one asymmetry.** A sign-in ticket is safe partly because possession alone
|
|
12
|
+
* is worthless: spending one also requires the customer's client credentials, held server-side. An
|
|
13
|
+
* anonymous visitor has no such credential and no server-side counterpart, so possession of a handle is
|
|
14
|
+
* *sufficient by itself*. Hence four independent bounds — single use, a short window, binding to the
|
|
15
|
+
* context it was issued to, and a chain that ends — rather than any one of them carrying the weight.
|
|
16
|
+
*/
|
|
17
|
+
/** What a handle is bound to. All three are hashes; no raw origin or visitor id is ever stored. */
|
|
18
|
+
export interface AssistantContinuityContext {
|
|
19
|
+
readonly embedId: string;
|
|
20
|
+
readonly originHash: string;
|
|
21
|
+
readonly visitorHash: string;
|
|
22
|
+
}
|
|
23
|
+
export interface AssistantContinuityRecord {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly sessionId: string;
|
|
26
|
+
readonly tenant: TenantRef;
|
|
27
|
+
readonly context: AssistantContinuityContext;
|
|
28
|
+
readonly handleHash: string;
|
|
29
|
+
/** Restores already spent on this conversation, carried across every rotation. */
|
|
30
|
+
readonly restoreCount: number;
|
|
31
|
+
/** Clamped at issue time and stored, so a record describes its own limit rather than trusting a caller. */
|
|
32
|
+
readonly maxRestores: number;
|
|
33
|
+
readonly createdAt: string;
|
|
34
|
+
readonly expiresAt: string;
|
|
35
|
+
readonly claimedAt?: string;
|
|
36
|
+
}
|
|
37
|
+
export type AssistantContinuityClaim = {
|
|
38
|
+
readonly ok: true;
|
|
39
|
+
readonly record: AssistantContinuityRecord;
|
|
40
|
+
/**
|
|
41
|
+
* The rotated handle for the next navigation, or `undefined` once the chain is spent. Withholding
|
|
42
|
+
* it is how continuity ends: the visitor still sees the text they were promised, and the next page
|
|
43
|
+
* simply has nothing to present, so no refusal code is needed for an ordinary ending.
|
|
44
|
+
*/
|
|
45
|
+
readonly handle?: string;
|
|
46
|
+
} | {
|
|
47
|
+
readonly ok: false;
|
|
48
|
+
/**
|
|
49
|
+
* One code per refusal, because they are operationally different: `unknown` is a bad or already
|
|
50
|
+
* spent handle, `expired` is a visitor who took too long, and `context_mismatch` is a handle
|
|
51
|
+
* presented from an embed, origin, or visitor it was not issued to — the exfiltration signal, and
|
|
52
|
+
* the one worth alerting on, so it must not collapse into the first.
|
|
53
|
+
*/
|
|
54
|
+
readonly reason: 'unknown' | 'expired' | 'context_mismatch';
|
|
55
|
+
};
|
|
56
|
+
/** Five minutes: long enough to read a page before clicking through, short enough to bound a leak. */
|
|
57
|
+
export declare const ASSISTANT_CONTINUITY_WINDOW_MS: number;
|
|
58
|
+
/**
|
|
59
|
+
* Ten minutes, the sign-in ticket's own TTL: an anonymous display handle must never outlive the
|
|
60
|
+
* authenticated capability it is modelled on, whatever a developer or operator asks for.
|
|
61
|
+
*/
|
|
62
|
+
export declare const ASSISTANT_CONTINUITY_WINDOW_CEILING_MS: number;
|
|
63
|
+
/** Three restores covers the engaged visitor's page depth without letting a chain run indefinitely. */
|
|
64
|
+
export declare const ASSISTANT_CONTINUITY_MAX_RESTORES = 3;
|
|
65
|
+
/** Past ten, chain length stops being a meaningful control, so no configuration may exceed it. */
|
|
66
|
+
export declare const ASSISTANT_CONTINUITY_RESTORE_CEILING = 10;
|
|
67
|
+
export declare function continuityWindowMs(requested?: number): number;
|
|
68
|
+
export declare function continuityMaxRestores(requested?: number): number;
|
|
69
|
+
export interface AssistantContinuityStore {
|
|
70
|
+
/**
|
|
71
|
+
* Issue this session's handle for the next navigation, superseding any unclaimed one so a
|
|
72
|
+
* conversation never accumulates one live key per turn.
|
|
73
|
+
*
|
|
74
|
+
* Returns `undefined` when the effective window or chain clamps to zero. The kill switch is enforced
|
|
75
|
+
* here rather than at the call site, so a caller that forgets to check cannot hand out a handle the
|
|
76
|
+
* operator has switched off.
|
|
77
|
+
*/
|
|
78
|
+
issue(input: {
|
|
79
|
+
readonly sessionId: string;
|
|
80
|
+
readonly tenant: TenantRef;
|
|
81
|
+
readonly context: AssistantContinuityContext;
|
|
82
|
+
readonly windowMs?: number;
|
|
83
|
+
readonly maxRestores?: number;
|
|
84
|
+
readonly now: Date;
|
|
85
|
+
}): Promise<{
|
|
86
|
+
readonly record: AssistantContinuityRecord;
|
|
87
|
+
readonly handle: string;
|
|
88
|
+
} | undefined>;
|
|
89
|
+
/**
|
|
90
|
+
* Spend a handle exactly once, rotating to a fresh one until the chain is spent. Atomic: two backends
|
|
91
|
+
* racing the same value must not both win, which is why this is one store seam rather than a
|
|
92
|
+
* read-then-write in the route.
|
|
93
|
+
*/
|
|
94
|
+
claim(input: {
|
|
95
|
+
readonly handle: string;
|
|
96
|
+
readonly context: AssistantContinuityContext;
|
|
97
|
+
readonly now: Date;
|
|
98
|
+
}): Promise<AssistantContinuityClaim>;
|
|
99
|
+
/**
|
|
100
|
+
* Drop spent and expired rows, returning how many went.
|
|
101
|
+
*
|
|
102
|
+
* Retention belongs to this store rather than to a caller that may forget: handles are short-lived and
|
|
103
|
+
* must be swept, not accumulated. Cleanup may lag expiry safely, because `claim` already refuses a
|
|
104
|
+
* stale handle — the sweep bounds the table, it does not enforce the window.
|
|
105
|
+
*/
|
|
106
|
+
sweepExpired(input: {
|
|
107
|
+
readonly now: Date;
|
|
108
|
+
}): Promise<number>;
|
|
109
|
+
}
|
|
110
|
+
export declare function continuityHandle(): string;
|
|
111
|
+
export declare function continuityDigest(value: string): string;
|
|
112
|
+
export declare function continuityHashesEqual(left: string, right: string): boolean;
|
|
113
|
+
/** Every leg must match: a handle is only valid for the embed, origin, and visitor it was issued to. */
|
|
114
|
+
export declare function sameContinuityContext(left: AssistantContinuityContext, right: AssistantContinuityContext): boolean;
|
|
115
|
+
//# sourceMappingURL=continuity-store.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** Five minutes: long enough to read a page before clicking through, short enough to bound a leak. */
|
|
3
|
+
export const ASSISTANT_CONTINUITY_WINDOW_MS = 5 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Ten minutes, the sign-in ticket's own TTL: an anonymous display handle must never outlive the
|
|
6
|
+
* authenticated capability it is modelled on, whatever a developer or operator asks for.
|
|
7
|
+
*/
|
|
8
|
+
export const ASSISTANT_CONTINUITY_WINDOW_CEILING_MS = 10 * 60 * 1000;
|
|
9
|
+
/** Three restores covers the engaged visitor's page depth without letting a chain run indefinitely. */
|
|
10
|
+
export const ASSISTANT_CONTINUITY_MAX_RESTORES = 3;
|
|
11
|
+
/** Past ten, chain length stops being a meaningful control, so no configuration may exceed it. */
|
|
12
|
+
export const ASSISTANT_CONTINUITY_RESTORE_CEILING = 10;
|
|
13
|
+
/**
|
|
14
|
+
* Clamp a requested bound the way the admission envelope does: one direction only.
|
|
15
|
+
*
|
|
16
|
+
* Zero is the deploy-free kill switch, and anything malformed — negative, fractional, NaN — collapses to
|
|
17
|
+
* zero rather than to the default. A typo must fail closed here: unlike a spend allowance, where an
|
|
18
|
+
* outage is worse than a day of unbudgeted cost, a continuity handle is a capability, and the safe
|
|
19
|
+
* failure for a capability is not to exist.
|
|
20
|
+
*/
|
|
21
|
+
function clampBound(requested, fallback, ceiling) {
|
|
22
|
+
if (requested === undefined)
|
|
23
|
+
return fallback;
|
|
24
|
+
if (!Number.isInteger(requested) || requested < 0)
|
|
25
|
+
return 0;
|
|
26
|
+
return Math.min(requested, ceiling);
|
|
27
|
+
}
|
|
28
|
+
export function continuityWindowMs(requested) {
|
|
29
|
+
return clampBound(requested, ASSISTANT_CONTINUITY_WINDOW_MS, ASSISTANT_CONTINUITY_WINDOW_CEILING_MS);
|
|
30
|
+
}
|
|
31
|
+
export function continuityMaxRestores(requested) {
|
|
32
|
+
return clampBound(requested, ASSISTANT_CONTINUITY_MAX_RESTORES, ASSISTANT_CONTINUITY_RESTORE_CEILING);
|
|
33
|
+
}
|
|
34
|
+
export function continuityHandle() {
|
|
35
|
+
return `cnt_${randomBytes(24).toString('base64url')}`;
|
|
36
|
+
}
|
|
37
|
+
export function continuityDigest(value) {
|
|
38
|
+
return createHash('sha256').update(value).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
export function continuityHashesEqual(left, right) {
|
|
41
|
+
const a = Buffer.from(left, 'utf8');
|
|
42
|
+
const b = Buffer.from(right, 'utf8');
|
|
43
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
44
|
+
}
|
|
45
|
+
/** Every leg must match: a handle is only valid for the embed, origin, and visitor it was issued to. */
|
|
46
|
+
export function sameContinuityContext(left, right) {
|
|
47
|
+
return (left.embedId === right.embedId &&
|
|
48
|
+
left.originHash === right.originHash &&
|
|
49
|
+
left.visitorHash === right.visitorHash);
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=continuity-store.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type AssistantContinuityClaim, type AssistantContinuityContext, type AssistantContinuityRecord, type AssistantContinuityStore } from './continuity-store.js';
|
|
2
|
+
import type { TenantRef } from './tenant-ref.js';
|
|
3
|
+
/** The in-memory half of the pair. Both run the shared parity suite; neither is the only proof. */
|
|
4
|
+
export declare class InMemoryAssistantContinuityStore implements AssistantContinuityStore {
|
|
5
|
+
#private;
|
|
6
|
+
issue(input: {
|
|
7
|
+
readonly sessionId: string;
|
|
8
|
+
readonly tenant: TenantRef;
|
|
9
|
+
readonly context: AssistantContinuityContext;
|
|
10
|
+
readonly windowMs?: number;
|
|
11
|
+
readonly maxRestores?: number;
|
|
12
|
+
readonly now: Date;
|
|
13
|
+
}): Promise<{
|
|
14
|
+
readonly record: AssistantContinuityRecord;
|
|
15
|
+
readonly handle: string;
|
|
16
|
+
} | undefined>;
|
|
17
|
+
claim(input: {
|
|
18
|
+
readonly handle: string;
|
|
19
|
+
readonly context: AssistantContinuityContext;
|
|
20
|
+
readonly now: Date;
|
|
21
|
+
}): Promise<AssistantContinuityClaim>;
|
|
22
|
+
sweepExpired(input: {
|
|
23
|
+
readonly now: Date;
|
|
24
|
+
}): Promise<number>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=in-memory-continuity-store.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { continuityDigest, continuityHandle, continuityHashesEqual, continuityMaxRestores, continuityWindowMs, sameContinuityContext, } from './continuity-store.js';
|
|
3
|
+
/** The in-memory half of the pair. Both run the shared parity suite; neither is the only proof. */
|
|
4
|
+
export class InMemoryAssistantContinuityStore {
|
|
5
|
+
#rows = new Map();
|
|
6
|
+
async issue(input) {
|
|
7
|
+
return this.#write({ ...input, restoreCount: 0 });
|
|
8
|
+
}
|
|
9
|
+
async claim(input) {
|
|
10
|
+
const hash = continuityDigest(input.handle);
|
|
11
|
+
const found = [...this.#rows.values()].find((row) => continuityHashesEqual(row.handleHash, hash));
|
|
12
|
+
if (!found || found.claimedAt !== undefined)
|
|
13
|
+
return { ok: false, reason: 'unknown' };
|
|
14
|
+
// Context before expiry: a handle replayed from somewhere it was never issued should read as the
|
|
15
|
+
// exfiltration signal it is, whether or not the value happened to be stale by then.
|
|
16
|
+
if (!sameContinuityContext(found.context, input.context)) {
|
|
17
|
+
return { ok: false, reason: 'context_mismatch' };
|
|
18
|
+
}
|
|
19
|
+
if (Date.parse(found.expiresAt) <= input.now.getTime())
|
|
20
|
+
return { ok: false, reason: 'expired' };
|
|
21
|
+
const restoreCount = found.restoreCount + 1;
|
|
22
|
+
const claimed = {
|
|
23
|
+
...found,
|
|
24
|
+
restoreCount,
|
|
25
|
+
claimedAt: input.now.toISOString(),
|
|
26
|
+
};
|
|
27
|
+
this.#rows.set(found.id, claimed);
|
|
28
|
+
if (restoreCount >= found.maxRestores)
|
|
29
|
+
return { ok: true, record: claimed };
|
|
30
|
+
const rotated = this.#write({
|
|
31
|
+
sessionId: found.sessionId,
|
|
32
|
+
tenant: found.tenant,
|
|
33
|
+
context: found.context,
|
|
34
|
+
// Re-derive from the record so a rotation can never widen what the first issue clamped.
|
|
35
|
+
windowMs: Date.parse(found.expiresAt) - Date.parse(found.createdAt),
|
|
36
|
+
maxRestores: found.maxRestores,
|
|
37
|
+
restoreCount,
|
|
38
|
+
now: input.now,
|
|
39
|
+
});
|
|
40
|
+
return rotated === undefined
|
|
41
|
+
? { ok: true, record: claimed }
|
|
42
|
+
: { ok: true, record: claimed, handle: rotated.handle };
|
|
43
|
+
}
|
|
44
|
+
async sweepExpired(input) {
|
|
45
|
+
let removed = 0;
|
|
46
|
+
for (const [id, row] of this.#rows) {
|
|
47
|
+
if (row.claimedAt !== undefined || Date.parse(row.expiresAt) <= input.now.getTime()) {
|
|
48
|
+
this.#rows.delete(id);
|
|
49
|
+
removed++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return removed;
|
|
53
|
+
}
|
|
54
|
+
#write(input) {
|
|
55
|
+
const windowMs = continuityWindowMs(input.windowMs);
|
|
56
|
+
const maxRestores = continuityMaxRestores(input.maxRestores);
|
|
57
|
+
if (windowMs === 0 || maxRestores === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
// Supersede rather than accumulate: one live handle per session.
|
|
60
|
+
for (const [id, row] of this.#rows) {
|
|
61
|
+
if (row.sessionId === input.sessionId && row.claimedAt === undefined)
|
|
62
|
+
this.#rows.delete(id);
|
|
63
|
+
}
|
|
64
|
+
const handle = continuityHandle();
|
|
65
|
+
const record = {
|
|
66
|
+
id: `cont_${randomUUID().replaceAll('-', '')}`,
|
|
67
|
+
sessionId: input.sessionId,
|
|
68
|
+
tenant: input.tenant,
|
|
69
|
+
context: input.context,
|
|
70
|
+
handleHash: continuityDigest(handle),
|
|
71
|
+
restoreCount: input.restoreCount,
|
|
72
|
+
maxRestores,
|
|
73
|
+
createdAt: input.now.toISOString(),
|
|
74
|
+
expiresAt: new Date(input.now.getTime() + windowMs).toISOString(),
|
|
75
|
+
};
|
|
76
|
+
this.#rows.set(record.id, record);
|
|
77
|
+
return { record, handle };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=in-memory-continuity-store.js.map
|
|
@@ -94,6 +94,7 @@ export declare function requestModelCompletion(input: {
|
|
|
94
94
|
readonly messages: readonly AssistantModelMessage[];
|
|
95
95
|
readonly tools: readonly AssistantModelTool[];
|
|
96
96
|
readonly toolChoice?: 'auto' | 'none' | 'required';
|
|
97
|
+
readonly jsonOutput?: boolean;
|
|
97
98
|
readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
|
|
98
99
|
readonly onContent?: (delta: string) => void;
|
|
99
100
|
readonly maxResponseBytes?: number;
|
|
@@ -17,6 +17,7 @@ export async function requestModelCompletion(input) {
|
|
|
17
17
|
store: false,
|
|
18
18
|
input: responsesInput(input.messages),
|
|
19
19
|
tools: responsesTools(input.tools),
|
|
20
|
+
...(input.jsonOutput ? { text: { format: { type: 'json_object' } } } : {}),
|
|
20
21
|
...(input.toolChoice === undefined ? {} : { tool_choice: input.toolChoice }),
|
|
21
22
|
...(completionLimit === undefined ? {} : { max_output_tokens: completionLimit }),
|
|
22
23
|
}
|
|
@@ -26,6 +27,7 @@ export async function requestModelCompletion(input) {
|
|
|
26
27
|
stream: true,
|
|
27
28
|
messages: input.messages,
|
|
28
29
|
tools: input.tools,
|
|
30
|
+
...(input.jsonOutput ? { response_format: { type: 'json_object' } } : {}),
|
|
29
31
|
...(input.toolChoice === undefined ? {} : { tool_choice: input.toolChoice }),
|
|
30
32
|
...(completionLimit === undefined ? {} : { max_completion_tokens: completionLimit }),
|
|
31
33
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function nonNegativeInteger(value) {
|
|
2
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
3
|
+
}
|
|
4
|
+
export async function readBoundedText(response, maxBytes) {
|
|
5
|
+
const text = await response.text();
|
|
6
|
+
if (new TextEncoder().encode(text).byteLength > maxBytes) {
|
|
7
|
+
throw new Error('model response too large');
|
|
8
|
+
}
|
|
9
|
+
return text;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=model-response-values.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nonNegativeInteger, readBoundedText } from './model-response-values.js';
|
|
1
2
|
export function responsesInput(messages) {
|
|
2
3
|
return messages.flatMap((message) => {
|
|
3
4
|
if (message.role === 'tool') {
|
|
@@ -150,14 +151,4 @@ function parseRecord(value) {
|
|
|
150
151
|
}
|
|
151
152
|
return value;
|
|
152
153
|
}
|
|
153
|
-
function nonNegativeInteger(value) {
|
|
154
|
-
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
155
|
-
}
|
|
156
|
-
async function readBoundedText(response, maxBytes) {
|
|
157
|
-
const text = await response.text();
|
|
158
|
-
if (new TextEncoder().encode(text).byteLength > maxBytes) {
|
|
159
|
-
throw new Error('model response too large');
|
|
160
|
-
}
|
|
161
|
-
return text;
|
|
162
|
-
}
|
|
163
154
|
//# sourceMappingURL=model-responses.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nonNegativeInteger, readBoundedText } from './model-response-values.js';
|
|
1
2
|
export async function readModelCompletion(response, onContent, maxBytes = 1 << 20) {
|
|
2
3
|
const contentType = response.headers.get('content-type') ?? '';
|
|
3
4
|
if (!contentType.includes('text/event-stream')) {
|
|
@@ -111,14 +112,4 @@ function normalizeUsageValue(usage) {
|
|
|
111
112
|
...(reasoningTokens === undefined ? {} : { reasoningTokens }),
|
|
112
113
|
};
|
|
113
114
|
}
|
|
114
|
-
function nonNegativeInteger(value) {
|
|
115
|
-
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
116
|
-
}
|
|
117
|
-
async function readBoundedText(response, maxBytes) {
|
|
118
|
-
const text = await response.text();
|
|
119
|
-
if (new TextEncoder().encode(text).byteLength > maxBytes) {
|
|
120
|
-
throw new Error('model response too large');
|
|
121
|
-
}
|
|
122
|
-
return text;
|
|
123
|
-
}
|
|
124
115
|
//# sourceMappingURL=model-stream.js.map
|
|
@@ -17,12 +17,15 @@ export * from './assistant-store.js';
|
|
|
17
17
|
export * from './assistant-transcript.js';
|
|
18
18
|
export * from './assistant-transcript-events.js';
|
|
19
19
|
export * from './assistant-view-availability.js';
|
|
20
|
+
export * from './continuity-bounds.js';
|
|
21
|
+
export * from './continuity-store.js';
|
|
20
22
|
export * from './elevation.js';
|
|
21
23
|
export * from './elevation-store.js';
|
|
22
24
|
export * from './embed-operator-view.js';
|
|
23
25
|
export * from './embed-script.js';
|
|
24
26
|
export * from './embed-store.js';
|
|
25
27
|
export * from './in-memory-assistant-appearance-store.js';
|
|
28
|
+
export * from './in-memory-continuity-store.js';
|
|
26
29
|
export * from './in-memory-elevation-store.js';
|
|
27
30
|
export * from './in-memory-embed-store.js';
|
|
28
31
|
export * from './managed-spend.js';
|
|
@@ -17,12 +17,15 @@ export * from './assistant-store.js';
|
|
|
17
17
|
export * from './assistant-transcript.js';
|
|
18
18
|
export * from './assistant-transcript-events.js';
|
|
19
19
|
export * from './assistant-view-availability.js';
|
|
20
|
+
export * from './continuity-bounds.js';
|
|
21
|
+
export * from './continuity-store.js';
|
|
20
22
|
export * from './elevation.js';
|
|
21
23
|
export * from './elevation-store.js';
|
|
22
24
|
export * from './embed-operator-view.js';
|
|
23
25
|
export * from './embed-script.js';
|
|
24
26
|
export * from './embed-store.js';
|
|
25
27
|
export * from './in-memory-assistant-appearance-store.js';
|
|
28
|
+
export * from './in-memory-continuity-store.js';
|
|
26
29
|
export * from './in-memory-elevation-store.js';
|
|
27
30
|
export * from './in-memory-embed-store.js';
|
|
28
31
|
export * from './managed-spend.js';
|
|
@@ -173,6 +173,31 @@ export interface AuthenticatedWebsiteAccess {
|
|
|
173
173
|
readonly enabled?: boolean;
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Anonymous cross-page display continuity for one public surface (ADR 0223, clauses 11-16).
|
|
178
|
+
*
|
|
179
|
+
* Off unless asked for. Anonymous conversation text is your content on your page, so whether it
|
|
180
|
+
* survives a navigation is your call rather than a default the platform imposes.
|
|
181
|
+
*
|
|
182
|
+
* What a visitor gets is the text they have already read, re-rendered on the next page, on a fresh
|
|
183
|
+
* session. What it never grants is the old session itself: no tool authority, no share of a spent
|
|
184
|
+
* turn budget, and no pending confirmation carried across — a capability that survives a navigation
|
|
185
|
+
* is exactly what this design refuses to create.
|
|
186
|
+
*/
|
|
187
|
+
export interface AssistantContinuityDeclaration {
|
|
188
|
+
readonly enabled?: boolean;
|
|
189
|
+
/**
|
|
190
|
+
* How long a handle stays valid after the turn that issued it, measured from when the visitor last
|
|
191
|
+
* spoke. Defaults to 300; 600 is the structural ceiling, and 0 disables continuity outright. An
|
|
192
|
+
* operator may lower what you declare here and can never raise it.
|
|
193
|
+
*/
|
|
194
|
+
readonly windowSeconds?: number;
|
|
195
|
+
/**
|
|
196
|
+
* How many times one conversation may be restored before continuity ends and the next page starts
|
|
197
|
+
* fresh. Defaults to 3, ceiling 10, and 0 disables continuity outright.
|
|
198
|
+
*/
|
|
199
|
+
readonly maxRestores?: number;
|
|
200
|
+
}
|
|
176
201
|
export interface PublicWebsiteAccess {
|
|
177
202
|
readonly mode: 'public' | 'mixed';
|
|
178
203
|
readonly origins: readonly string[];
|
|
@@ -182,6 +207,8 @@ export interface PublicWebsiteAccess {
|
|
|
182
207
|
readonly webmcp?: {
|
|
183
208
|
readonly enabled?: boolean;
|
|
184
209
|
};
|
|
210
|
+
/** @see PublicWebsiteInput.continuity */
|
|
211
|
+
readonly continuity?: AssistantContinuityDeclaration;
|
|
185
212
|
}
|
|
186
213
|
export type AssistantAccess = AuthenticatedWebsiteAccess | PublicWebsiteAccess;
|
|
187
214
|
export interface AuthenticatedWebsiteInput {
|
|
@@ -226,6 +253,14 @@ export interface PublicWebsiteInput {
|
|
|
226
253
|
readonly webmcp?: {
|
|
227
254
|
readonly enabled?: boolean;
|
|
228
255
|
};
|
|
256
|
+
/**
|
|
257
|
+
* Let an anonymous visitor keep the conversation they can see when they navigate to another page
|
|
258
|
+
* of this site. Omitted means off, and no existing embed changes behavior.
|
|
259
|
+
*
|
|
260
|
+
* Declared only here, never on an authenticated surface: that direction reattaches through a
|
|
261
|
+
* backend-verified sign-in instead, so a declaration there would configure nothing.
|
|
262
|
+
*/
|
|
263
|
+
readonly continuity?: AssistantContinuityDeclaration;
|
|
229
264
|
}
|
|
230
265
|
export declare function authenticatedWebsite(input: AuthenticatedWebsiteInput): AuthenticatedWebsiteAccess;
|
|
231
266
|
export declare function publicWebsite(input: PublicWebsiteInput): PublicWebsiteAccess;
|
|
@@ -16,6 +16,7 @@ export function publicWebsite(input) {
|
|
|
16
16
|
capabilities: [...input.capabilities],
|
|
17
17
|
...(input.instructions === undefined ? {} : { instructions: input.instructions }),
|
|
18
18
|
...(input.webmcp === undefined ? {} : { webmcp: { ...input.webmcp } }),
|
|
19
|
+
...(input.continuity === undefined ? {} : { continuity: { ...input.continuity } }),
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
function serializeOrigins(origins, path) {
|
|
@@ -59,6 +60,9 @@ export function embeddedAssistant(input) {
|
|
|
59
60
|
? { sessionClaims: structuredClone(surface.sessionClaims) }
|
|
60
61
|
: {}),
|
|
61
62
|
...(surface.webmcp === undefined ? {} : { webmcp: { ...surface.webmcp } }),
|
|
63
|
+
...(surface.mode !== 'authenticated' && surface.continuity !== undefined
|
|
64
|
+
? { continuity: { ...surface.continuity } }
|
|
65
|
+
: {}),
|
|
62
66
|
})),
|
|
63
67
|
allowedOrigins: surfaces.flatMap((surface) => [...surface.origins]),
|
|
64
68
|
...structuredClone(ui),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Shared manifest primitives: colors, https URLs, managed variable expressions, assistant origins,
|
|
4
|
+
* packaged asset references, and the server brand kit.
|
|
5
|
+
*
|
|
6
|
+
* Extracted verbatim from `schema.ts` so that file stays inside its size budget as the assistant
|
|
7
|
+
* surface grows. These are leaf validators with no dependency on tools, prompts, widgets, or the
|
|
8
|
+
* assistant block, which is what makes the split one-directional and free of an import cycle.
|
|
9
|
+
*/
|
|
10
|
+
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'must be a 6-digit hex color');
|
|
11
|
+
export const httpsUrlSchema = z.url().regex(/^https:\/\//, 'must use https');
|
|
12
|
+
const managedVariableExpressionPattern = /^\$\{env\.[A-Za-z0-9_]+\}$/;
|
|
13
|
+
export const managedVariableExpressionSchema = z
|
|
14
|
+
.string()
|
|
15
|
+
.regex(managedVariableExpressionPattern, 'must be an exact managed variable expression');
|
|
16
|
+
// HTTP is allowed only for loopback development origins (mirrors the host-pattern loopback
|
|
17
|
+
// exception used for connector/CSP origins); production embedding origins stay https-only.
|
|
18
|
+
const assistantOriginOrVariablePattern = /^(?:\$\{env\.[A-Za-z0-9_]+\}|https:\/\/[^/?#@\\]+|http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?)$/;
|
|
19
|
+
function isCanonicalAssistantOrigin(value) {
|
|
20
|
+
try {
|
|
21
|
+
return new URL(value).origin === value;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const assistantOriginOrVariableSchema = z
|
|
28
|
+
.string()
|
|
29
|
+
.regex(assistantOriginOrVariablePattern, 'must be a canonical bare origin using https (http is allowed only for localhost/127.0.0.1/[::1] loopback development origins)')
|
|
30
|
+
.refine((value) => managedVariableExpressionPattern.test(value) || isCanonicalAssistantOrigin(value), 'must be a canonical bare origin');
|
|
31
|
+
const packagedAssetSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
kind: z.literal('asset'),
|
|
34
|
+
sourcePath: z.string().min(1),
|
|
35
|
+
logicalId: z.string().min(1),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
const brandAssetSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
uri: z.union([httpsUrlSchema, packagedAssetSchema]),
|
|
41
|
+
darkUri: z.union([httpsUrlSchema, packagedAssetSchema]).optional(),
|
|
42
|
+
alt: z.string().trim().min(1),
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
const brandThemeTokensSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
surface: hexColorSchema.optional(),
|
|
48
|
+
surfaceRaised: hexColorSchema.optional(),
|
|
49
|
+
surfaceMuted: hexColorSchema.optional(),
|
|
50
|
+
text: hexColorSchema.optional(),
|
|
51
|
+
textMuted: hexColorSchema.optional(),
|
|
52
|
+
accent: hexColorSchema.optional(),
|
|
53
|
+
accentText: hexColorSchema.optional(),
|
|
54
|
+
link: hexColorSchema.optional(),
|
|
55
|
+
border: hexColorSchema.optional(),
|
|
56
|
+
borderStrong: hexColorSchema.optional(),
|
|
57
|
+
focus: hexColorSchema.optional(),
|
|
58
|
+
success: hexColorSchema.optional(),
|
|
59
|
+
warning: hexColorSchema.optional(),
|
|
60
|
+
danger: hexColorSchema.optional(),
|
|
61
|
+
code: hexColorSchema.optional(),
|
|
62
|
+
})
|
|
63
|
+
.strict();
|
|
64
|
+
export const serverBrandingSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
name: z.string().trim().min(1).optional(),
|
|
67
|
+
accent: hexColorSchema.optional(),
|
|
68
|
+
surface: hexColorSchema.optional(),
|
|
69
|
+
surfaceDark: hexColorSchema.optional(),
|
|
70
|
+
logo: brandAssetSchema.optional(),
|
|
71
|
+
mark: brandAssetSchema.optional(),
|
|
72
|
+
avatar: brandAssetSchema.optional(),
|
|
73
|
+
theme: z
|
|
74
|
+
.object({
|
|
75
|
+
light: brandThemeTokensSchema.optional(),
|
|
76
|
+
dark: brandThemeTokensSchema.optional(),
|
|
77
|
+
})
|
|
78
|
+
.strict()
|
|
79
|
+
.optional(),
|
|
80
|
+
radius: z.enum(['none', 'sm', 'md', 'lg']).optional(),
|
|
81
|
+
density: z.enum(['compact', 'comfortable']).optional(),
|
|
82
|
+
typography: z.enum(['system', 'serif', 'mono']).optional(),
|
|
83
|
+
colorScheme: z.enum(['auto', 'light', 'dark']).optional(),
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
|
86
|
+
//# sourceMappingURL=branding-schema.js.map
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { PackagedAssetReference } from '../assets.js';
|
|
3
2
|
type ToolAuthorizationManifest = {
|
|
4
3
|
readonly requiredScopes?: readonly string[] | undefined;
|
|
5
4
|
readonly allowedRoles?: readonly string[] | undefined;
|
|
@@ -381,6 +380,11 @@ export declare const manifestV1Schema: z.ZodObject<{
|
|
|
381
380
|
webmcp: z.ZodOptional<z.ZodObject<{
|
|
382
381
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
383
382
|
}, z.core.$strict>>;
|
|
383
|
+
continuity: z.ZodOptional<z.ZodObject<{
|
|
384
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
385
|
+
windowSeconds: z.ZodOptional<z.ZodNumber>;
|
|
386
|
+
maxRestores: z.ZodOptional<z.ZodNumber>;
|
|
387
|
+
}, z.core.$strict>>;
|
|
384
388
|
}, z.core.$strict>>>;
|
|
385
389
|
sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
386
390
|
exposeToModel: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -395,18 +399,18 @@ export declare const manifestV1Schema: z.ZodObject<{
|
|
|
395
399
|
surface: z.ZodOptional<z.ZodString>;
|
|
396
400
|
surfaceDark: z.ZodOptional<z.ZodString>;
|
|
397
401
|
logo: z.ZodOptional<z.ZodObject<{
|
|
398
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
399
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
402
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
403
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
400
404
|
alt: z.ZodString;
|
|
401
405
|
}, z.core.$strict>>;
|
|
402
406
|
mark: z.ZodOptional<z.ZodObject<{
|
|
403
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
404
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
407
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
408
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
405
409
|
alt: z.ZodString;
|
|
406
410
|
}, z.core.$strict>>;
|
|
407
411
|
avatar: z.ZodOptional<z.ZodObject<{
|
|
408
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
409
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
412
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
413
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
410
414
|
alt: z.ZodString;
|
|
411
415
|
}, z.core.$strict>>;
|
|
412
416
|
theme: z.ZodOptional<z.ZodObject<{
|
|
@@ -864,6 +868,11 @@ export declare const manifestV2Schema: z.ZodObject<{
|
|
|
864
868
|
webmcp: z.ZodOptional<z.ZodObject<{
|
|
865
869
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
866
870
|
}, z.core.$strict>>;
|
|
871
|
+
continuity: z.ZodOptional<z.ZodObject<{
|
|
872
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
873
|
+
windowSeconds: z.ZodOptional<z.ZodNumber>;
|
|
874
|
+
maxRestores: z.ZodOptional<z.ZodNumber>;
|
|
875
|
+
}, z.core.$strict>>;
|
|
867
876
|
}, z.core.$strict>>>;
|
|
868
877
|
sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
869
878
|
exposeToModel: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -878,18 +887,18 @@ export declare const manifestV2Schema: z.ZodObject<{
|
|
|
878
887
|
surface: z.ZodOptional<z.ZodString>;
|
|
879
888
|
surfaceDark: z.ZodOptional<z.ZodString>;
|
|
880
889
|
logo: z.ZodOptional<z.ZodObject<{
|
|
881
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
882
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
890
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
891
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
883
892
|
alt: z.ZodString;
|
|
884
893
|
}, z.core.$strict>>;
|
|
885
894
|
mark: z.ZodOptional<z.ZodObject<{
|
|
886
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
887
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
895
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
896
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
888
897
|
alt: z.ZodString;
|
|
889
898
|
}, z.core.$strict>>;
|
|
890
899
|
avatar: z.ZodOptional<z.ZodObject<{
|
|
891
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
892
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
900
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
901
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
893
902
|
alt: z.ZodString;
|
|
894
903
|
}, z.core.$strict>>;
|
|
895
904
|
theme: z.ZodOptional<z.ZodObject<{
|
|
@@ -1557,6 +1566,11 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
1557
1566
|
webmcp: z.ZodOptional<z.ZodObject<{
|
|
1558
1567
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
1559
1568
|
}, z.core.$strict>>;
|
|
1569
|
+
continuity: z.ZodOptional<z.ZodObject<{
|
|
1570
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
1571
|
+
windowSeconds: z.ZodOptional<z.ZodNumber>;
|
|
1572
|
+
maxRestores: z.ZodOptional<z.ZodNumber>;
|
|
1573
|
+
}, z.core.$strict>>;
|
|
1560
1574
|
}, z.core.$strict>>>;
|
|
1561
1575
|
sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
1562
1576
|
exposeToModel: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -1571,18 +1585,18 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
1571
1585
|
surface: z.ZodOptional<z.ZodString>;
|
|
1572
1586
|
surfaceDark: z.ZodOptional<z.ZodString>;
|
|
1573
1587
|
logo: z.ZodOptional<z.ZodObject<{
|
|
1574
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
1575
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
1588
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
1589
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
1576
1590
|
alt: z.ZodString;
|
|
1577
1591
|
}, z.core.$strict>>;
|
|
1578
1592
|
mark: z.ZodOptional<z.ZodObject<{
|
|
1579
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
1580
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
1593
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
1594
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
1581
1595
|
alt: z.ZodString;
|
|
1582
1596
|
}, z.core.$strict>>;
|
|
1583
1597
|
avatar: z.ZodOptional<z.ZodObject<{
|
|
1584
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
1585
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
1598
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
1599
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
1586
1600
|
alt: z.ZodString;
|
|
1587
1601
|
}, z.core.$strict>>;
|
|
1588
1602
|
theme: z.ZodOptional<z.ZodObject<{
|
|
@@ -2039,6 +2053,11 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
2039
2053
|
webmcp: z.ZodOptional<z.ZodObject<{
|
|
2040
2054
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
2041
2055
|
}, z.core.$strict>>;
|
|
2056
|
+
continuity: z.ZodOptional<z.ZodObject<{
|
|
2057
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
2058
|
+
windowSeconds: z.ZodOptional<z.ZodNumber>;
|
|
2059
|
+
maxRestores: z.ZodOptional<z.ZodNumber>;
|
|
2060
|
+
}, z.core.$strict>>;
|
|
2042
2061
|
}, z.core.$strict>>>;
|
|
2043
2062
|
sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
2044
2063
|
exposeToModel: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -2053,18 +2072,18 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
2053
2072
|
surface: z.ZodOptional<z.ZodString>;
|
|
2054
2073
|
surfaceDark: z.ZodOptional<z.ZodString>;
|
|
2055
2074
|
logo: z.ZodOptional<z.ZodObject<{
|
|
2056
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
2057
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
2075
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
2076
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
2058
2077
|
alt: z.ZodString;
|
|
2059
2078
|
}, z.core.$strict>>;
|
|
2060
2079
|
mark: z.ZodOptional<z.ZodObject<{
|
|
2061
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
2062
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
2080
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
2081
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
2063
2082
|
alt: z.ZodString;
|
|
2064
2083
|
}, z.core.$strict>>;
|
|
2065
2084
|
avatar: z.ZodOptional<z.ZodObject<{
|
|
2066
|
-
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
|
|
2067
|
-
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
|
|
2085
|
+
uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
|
|
2086
|
+
darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
|
|
2068
2087
|
alt: z.ZodString;
|
|
2069
2088
|
}, z.core.$strict>>;
|
|
2070
2089
|
theme: z.ZodOptional<z.ZodObject<{
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { MAX_COMPILED_WIDGET_HTML_BYTES, MAX_RAW_WIDGET_HTML_BYTES } from '../widget-limits.js';
|
|
4
4
|
import { agentGuideSchema } from './agent-guide-schema.js';
|
|
5
5
|
import { serverAuthSchema, serverAuthV2Schema } from './auth-schema.js';
|
|
6
|
+
import { assistantOriginOrVariableSchema, httpsUrlSchema, managedVariableExpressionSchema, serverBrandingSchema, } from './branding-schema.js';
|
|
6
7
|
import { NAME_PATTERN } from './naming.js';
|
|
7
8
|
/**
|
|
8
9
|
* Zod schema for Core v1 and v2 manifests (ADRs 0150 and 0157). V1 remains accepted unchanged;
|
|
@@ -133,82 +134,6 @@ const promptSchema = z.object({
|
|
|
133
134
|
arguments: z.array(promptArgumentSchema).optional(),
|
|
134
135
|
fulfilment: fulfilmentSchema,
|
|
135
136
|
});
|
|
136
|
-
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'must be a 6-digit hex color');
|
|
137
|
-
const httpsUrlSchema = z.url().regex(/^https:\/\//, 'must use https');
|
|
138
|
-
const managedVariableExpressionPattern = /^\$\{env\.[A-Za-z0-9_]+\}$/;
|
|
139
|
-
const managedVariableExpressionSchema = z
|
|
140
|
-
.string()
|
|
141
|
-
.regex(managedVariableExpressionPattern, 'must be an exact managed variable expression');
|
|
142
|
-
// HTTP is allowed only for loopback development origins (mirrors the host-pattern loopback
|
|
143
|
-
// exception used for connector/CSP origins); production embedding origins stay https-only.
|
|
144
|
-
const assistantOriginOrVariablePattern = /^(?:\$\{env\.[A-Za-z0-9_]+\}|https:\/\/[^/?#@\\]+|http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?)$/;
|
|
145
|
-
function isCanonicalAssistantOrigin(value) {
|
|
146
|
-
try {
|
|
147
|
-
return new URL(value).origin === value;
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
const assistantOriginOrVariableSchema = z
|
|
154
|
-
.string()
|
|
155
|
-
.regex(assistantOriginOrVariablePattern, 'must be a canonical bare origin using https (http is allowed only for localhost/127.0.0.1/[::1] loopback development origins)')
|
|
156
|
-
.refine((value) => managedVariableExpressionPattern.test(value) || isCanonicalAssistantOrigin(value), 'must be a canonical bare origin');
|
|
157
|
-
const packagedAssetSchema = z
|
|
158
|
-
.object({
|
|
159
|
-
kind: z.literal('asset'),
|
|
160
|
-
sourcePath: z.string().min(1),
|
|
161
|
-
logicalId: z.string().min(1),
|
|
162
|
-
})
|
|
163
|
-
.strict();
|
|
164
|
-
const brandAssetSchema = z
|
|
165
|
-
.object({
|
|
166
|
-
uri: z.union([httpsUrlSchema, packagedAssetSchema]),
|
|
167
|
-
darkUri: z.union([httpsUrlSchema, packagedAssetSchema]).optional(),
|
|
168
|
-
alt: z.string().trim().min(1),
|
|
169
|
-
})
|
|
170
|
-
.strict();
|
|
171
|
-
const brandThemeTokensSchema = z
|
|
172
|
-
.object({
|
|
173
|
-
surface: hexColorSchema.optional(),
|
|
174
|
-
surfaceRaised: hexColorSchema.optional(),
|
|
175
|
-
surfaceMuted: hexColorSchema.optional(),
|
|
176
|
-
text: hexColorSchema.optional(),
|
|
177
|
-
textMuted: hexColorSchema.optional(),
|
|
178
|
-
accent: hexColorSchema.optional(),
|
|
179
|
-
accentText: hexColorSchema.optional(),
|
|
180
|
-
link: hexColorSchema.optional(),
|
|
181
|
-
border: hexColorSchema.optional(),
|
|
182
|
-
borderStrong: hexColorSchema.optional(),
|
|
183
|
-
focus: hexColorSchema.optional(),
|
|
184
|
-
success: hexColorSchema.optional(),
|
|
185
|
-
warning: hexColorSchema.optional(),
|
|
186
|
-
danger: hexColorSchema.optional(),
|
|
187
|
-
code: hexColorSchema.optional(),
|
|
188
|
-
})
|
|
189
|
-
.strict();
|
|
190
|
-
const serverBrandingSchema = z
|
|
191
|
-
.object({
|
|
192
|
-
name: z.string().trim().min(1).optional(),
|
|
193
|
-
accent: hexColorSchema.optional(),
|
|
194
|
-
surface: hexColorSchema.optional(),
|
|
195
|
-
surfaceDark: hexColorSchema.optional(),
|
|
196
|
-
logo: brandAssetSchema.optional(),
|
|
197
|
-
mark: brandAssetSchema.optional(),
|
|
198
|
-
avatar: brandAssetSchema.optional(),
|
|
199
|
-
theme: z
|
|
200
|
-
.object({
|
|
201
|
-
light: brandThemeTokensSchema.optional(),
|
|
202
|
-
dark: brandThemeTokensSchema.optional(),
|
|
203
|
-
})
|
|
204
|
-
.strict()
|
|
205
|
-
.optional(),
|
|
206
|
-
radius: z.enum(['none', 'sm', 'md', 'lg']).optional(),
|
|
207
|
-
density: z.enum(['compact', 'comfortable']).optional(),
|
|
208
|
-
typography: z.enum(['system', 'serif', 'mono']).optional(),
|
|
209
|
-
colorScheme: z.enum(['auto', 'light', 'dark']).optional(),
|
|
210
|
-
})
|
|
211
|
-
.strict();
|
|
212
137
|
const assistantPresentationToneSchema = z.enum(['neutral', 'success', 'warning', 'danger']);
|
|
213
138
|
const optionalStrictObject = (shape) => z.object(shape).strict().optional();
|
|
214
139
|
const assistantPresentationSchema = z
|
|
@@ -357,6 +282,18 @@ const embeddedAssistantSchema = z
|
|
|
357
282
|
// `assistantUiSchema.shape` — this is not renderer presentation, and a surface that
|
|
358
283
|
// acquired it by a spread would be an accident rather than a decision.
|
|
359
284
|
webmcp: optionalStrictObject({ enabled: z.boolean().optional() }),
|
|
285
|
+
// Anonymous cross-page display continuity (ADR 0223, clauses 11-16), declared per public
|
|
286
|
+
// or mixed surface. These bounds mirror the structural ceilings the gateway clamps to at
|
|
287
|
+
// runtime; the ADR is the single source, and this package cannot import the gateway
|
|
288
|
+
// because the dependency runs the other way. Refusing rather than clamping is deliberate:
|
|
289
|
+
// the gateway narrows an operator's value silently by design, but a developer who asks
|
|
290
|
+
// for more than the ceiling has made a mistake, and silence would hide it until someone
|
|
291
|
+
// measured the live behaviour.
|
|
292
|
+
continuity: optionalStrictObject({
|
|
293
|
+
enabled: z.boolean().optional(),
|
|
294
|
+
windowSeconds: z.number().int().min(0).max(600).optional(),
|
|
295
|
+
maxRestores: z.number().int().min(0).max(10).optional(),
|
|
296
|
+
}),
|
|
360
297
|
})
|
|
361
298
|
.strict())
|
|
362
299
|
.min(1)
|
|
@@ -374,6 +311,17 @@ const embeddedAssistantSchema = z
|
|
|
374
311
|
.strict()
|
|
375
312
|
.superRefine((assistant, ctx) => {
|
|
376
313
|
assistant.surfaces?.forEach((surface, index) => {
|
|
314
|
+
// The authenticated direction already reattaches through ADR 0223 clause 7, authorized by a
|
|
315
|
+
// backend-verified subject rather than by possession of a handle. A `continuity` block here
|
|
316
|
+
// would be a developer believing they configured something nothing reads, so refuse it rather
|
|
317
|
+
// than drop it silently.
|
|
318
|
+
if (surface.mode === 'authenticated' && surface.continuity !== undefined) {
|
|
319
|
+
ctx.addIssue({
|
|
320
|
+
code: 'custom',
|
|
321
|
+
path: ['surfaces', index, 'continuity'],
|
|
322
|
+
message: 'continuity belongs on a public or mixed website surface',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
377
325
|
if (surface.mode === 'authenticated')
|
|
378
326
|
return;
|
|
379
327
|
// A public surface fails closed: an omitted allowlist would otherwise read as "expose
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noodleseed/one",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.155.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Noodle CLI by Noodle Seed — author, run, and deploy declarative MCP servers. Embedding the assistant in your own web app is @noodleseed/assistant.",
|
|
6
6
|
"license": "Apache-2.0",
|