@noodleseed/one 0.147.1 → 0.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/commands/assistant-ops.d.ts.map +1 -1
  2. package/dist/commands/assistant-ops.js +34 -0
  3. package/dist/commands/assistant-ops.js.map +1 -1
  4. package/dist/commands/assistant-surface-ops.d.ts +14 -1
  5. package/dist/commands/assistant-surface-ops.d.ts.map +1 -1
  6. package/dist/commands/assistant-surface-ops.js +33 -2
  7. package/dist/commands/assistant-surface-ops.js.map +1 -1
  8. package/dist/dev-local-runtime.d.ts +1 -0
  9. package/dist/dev-local-runtime.d.ts.map +1 -1
  10. package/dist/dev-local-runtime.js +3 -0
  11. package/dist/dev-local-runtime.js.map +1 -1
  12. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.d.ts +20 -0
  13. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.js +12 -0
  14. package/node_modules/@noodle-borg/admission-limits/dist/envelope.d.ts +44 -2
  15. package/node_modules/@noodle-borg/admission-limits/dist/envelope.js +43 -11
  16. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.d.ts +1 -0
  17. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.js +14 -1
  18. package/node_modules/@noodle-borg/admission-limits/dist/portable.d.ts +1 -0
  19. package/node_modules/@noodle-borg/admission-limits/dist/portable.js +1 -0
  20. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.d.ts +20 -0
  21. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.js +30 -0
  22. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
  23. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +3 -1
  24. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.js +4 -0
  25. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  26. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.d.ts +14 -0
  27. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.js +21 -0
  28. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +145 -0
  29. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +34 -1
  30. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +53 -1
  31. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-suggestions.js +69 -0
  32. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.d.ts +71 -0
  33. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.js +59 -0
  34. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.d.ts +27 -0
  35. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.js +35 -0
  36. package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-embed-store.js +6 -0
  37. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.d.ts +80 -0
  38. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.js +186 -0
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +93 -0
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/model-runtime.js +2 -0
  41. package/node_modules/@noodle-borg/assistant-gateway/dist/model-stream.d.ts +35 -0
  42. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +3 -0
  43. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +3 -0
  44. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.d.ts +14 -1
  45. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.js +23 -6
  46. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.d.ts +53 -2
  47. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.js +132 -0
  48. package/node_modules/@noodle-borg/assistant-gateway/dist/session-target.d.ts +27 -0
  49. package/node_modules/@noodle-borg/assistant-gateway/dist/session-target.js +50 -0
  50. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.d.ts +15 -1
  51. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.js +14 -0
  52. package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
  53. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +16 -0
  54. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +15 -1
  55. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +12 -0
  56. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +5 -0
  57. package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +19 -2
  58. package/node_modules/@noodle-borg/module/dist/request-analytics.js +15 -2
  59. package/node_modules/@noodle-borg/observability/dist/assistant-usage.js +68 -10
  60. package/node_modules/@noodle-borg/observability/dist/index.js +1 -0
  61. package/node_modules/@noodle-borg/observability/dist/request-event-query.js +48 -0
  62. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +13 -2
  63. package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
  64. package/node_modules/@noodle-borg/service/dist/invocation-context.js +1 -12
  65. package/node_modules/@noodle-borg/service/dist/routes/analytics.js +5 -29
  66. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +152 -176
  67. package/node_modules/@noodle-borg/service/dist/routes/assistant-appearance.js +1 -3
  68. package/node_modules/@noodle-borg/service/dist/routes/assistant-dispatch.js +5 -0
  69. package/node_modules/@noodle-borg/service/dist/routes/assistant-embeds.js +26 -38
  70. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +11 -4
  71. package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +17 -8
  72. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +1 -4
  73. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +7 -3
  74. package/node_modules/@noodle-borg/service/dist/routes/assistant-route-http.js +1 -0
  75. package/node_modules/@noodle-borg/service/dist/routes/assistant-session-target.js +4 -47
  76. package/node_modules/@noodle-borg/service/dist/routes/assistant-suggestions.js +106 -0
  77. package/node_modules/@noodle-borg/service/dist/routes/assistant-transcript.js +6 -1
  78. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +35 -12
  79. package/node_modules/@noodle-borg/service/package.json +1 -1
  80. package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +2 -0
  81. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.d.ts +38 -0
  82. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.js +49 -4
  83. package/node_modules/@noodleseed/assistant/package.json +1 -1
  84. package/package.json +2 -2
  85. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-turn.js +0 -37
@@ -34,13 +34,22 @@ async function handleResolution(req, res, deps, legacyAcceptOnly) {
34
34
  const response = parseInteractionResponse(body.value, legacyAcceptOnly);
35
35
  if (!response.ok)
36
36
  return sendJson(res, 400, { error: response.error });
37
+ const suggestions = response.value.suggestions === true;
38
+ if (suggestions) {
39
+ try {
40
+ await deps.store.replaceLatestSuggestions(session.id, undefined);
41
+ }
42
+ catch {
43
+ // Suggestions are optional; interaction resolution remains authoritative.
44
+ }
45
+ }
37
46
  const scope = assistantInteractionScope(response.value.id, session, now(deps));
38
47
  const interaction = await deps.store.getInteraction(scope);
39
48
  if (!interaction) {
40
49
  return sendJson(res, 409, { error: 'interaction is invalid or expired' });
41
50
  }
42
51
  if (response.value.action !== 'accept') {
43
- return resolveStop(res, deps, session, interaction, response.value.action);
52
+ return resolveStop(res, deps, session, interaction, response.value.action, suggestions);
44
53
  }
45
54
  if (interaction.kind === 'confirmation' && response.value.content !== undefined) {
46
55
  return sendJson(res, 400, { error: 'confirmation content cannot replace reviewed arguments' });
@@ -67,9 +76,9 @@ async function handleResolution(req, res, deps, legacyAcceptOnly) {
67
76
  }
68
77
  acceptedContent = validated.value;
69
78
  }
70
- return resolveAccept(res, deps, session, interaction, acceptedContent);
79
+ return resolveAccept(res, deps, session, interaction, acceptedContent, suggestions);
71
80
  }
72
- async function resolveAccept(res, deps, session, interaction, content) {
81
+ async function resolveAccept(res, deps, session, interaction, content, suggestions) {
73
82
  if (interaction.status !== 'pending') {
74
83
  return replayOrConflict(res, deps, session, interaction, 'accept');
75
84
  }
@@ -149,9 +158,9 @@ async function resolveAccept(res, deps, session, interaction, content) {
149
158
  ? await resumeToolPreparation(target.served.artifact, continuation, { action: 'accept', content }, executeDeps)
150
159
  : await resumeTool(target.served.artifact, continuation, { action: 'accept', content }, executeDeps);
151
160
  }
152
- return finishExecution(res, deps, session, claimed.interaction, context, result, target);
161
+ return finishExecution(res, deps, session, claimed.interaction, context, result, target, suggestions);
153
162
  }
154
- async function finishExecution(res, deps, session, interaction, context, result, target) {
163
+ async function finishExecution(res, deps, session, interaction, context, result, target, suggestions) {
155
164
  const toolName = assistantInteractionTool(interaction) ?? 'unknown_tool';
156
165
  if (result.status === 'confirmation_required') {
157
166
  const tool = target.served.artifact.tools.find((candidate) => candidate.name === toolName);
@@ -357,10 +366,10 @@ async function finishExecution(res, deps, session, interaction, context, result,
357
366
  }, (failure) => deps.logger?.warn('assistant.view.unresolved', { ...failure }));
358
367
  if (view)
359
368
  writeViewAvailable(res, view);
360
- await narrateResolvedInteraction(res, deps, target, session, toolName, 'accept', safeOutput, context);
369
+ await narrateResolvedInteraction(res, deps, target, session, toolName, 'accept', safeOutput, context, suggestions);
361
370
  return endInteractionSse(res);
362
371
  }
363
- async function resolveStop(res, deps, session, interaction, action) {
372
+ async function resolveStop(res, deps, session, interaction, action, suggestions) {
364
373
  if (interaction.status !== 'pending') {
365
374
  return replayOrConflict(res, deps, session, interaction, action);
366
375
  }
@@ -426,7 +435,7 @@ async function resolveStop(res, deps, session, interaction, action) {
426
435
  writeInteractionSseHeaders(res, session.origin);
427
436
  writeInteractionResolved(res, interaction.id, action);
428
437
  if (target && context) {
429
- await narrateResolvedInteraction(res, deps, target, session, toolName, action, undefined, context);
438
+ await narrateResolvedInteraction(res, deps, target, session, toolName, action, undefined, context, suggestions);
430
439
  }
431
440
  return endInteractionSse(res);
432
441
  }
@@ -5,10 +5,7 @@ export async function resolveAssistantModelBinding(target, tenant, deploymentId,
5
5
  if (declaration === undefined)
6
6
  return undefined;
7
7
  if (declaration.kind === 'noodle-managed') {
8
- return deps.managedModelResolver?.resolve({
9
- tenant,
10
- deploymentId,
11
- });
8
+ return deps.managedModelResolver?.resolve({ tenant, deploymentId });
12
9
  }
13
10
  const scope = resolveConfigScope(tenant);
14
11
  const [variables, secrets] = await Promise.all([
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { ADMISSION_DEFAULTS, clientAddressBucket } from '@noodle-borg/admission-limits/portable';
3
3
  import { effectiveAssistantBrowserConfiguration, mintPublicSession, publicEmbedTenant, publicSurfaceOf, } from '@noodle-borg/assistant-gateway/portable';
4
- import { assistantSessionUsageRequestEvent, captureAssistantUsage, } from '@noodle-borg/observability';
4
+ import { assistantSessionUsageRequestEvent, captureAssistantUsage, captureRefusedAssistantSession, } from '@noodle-borg/observability';
5
5
  import { assistantSessionResponseSchema } from '@noodle-borg/wire-contracts';
6
6
  import { readJsonBody, sendJson } from '../http-util.js';
7
7
  import { applyBrowserCors, assistantSessionEndpoints, now } from './assistant-route-http.js';
@@ -25,7 +25,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
25
25
  const body = await readJsonBody(req, deps.maxBody);
26
26
  if (!body.ok)
27
27
  return sendJson(res, body.status, { error: body.error });
28
- const { embedId } = body.value;
28
+ const { embedId, visitorId } = body.value;
29
29
  // The browser's Origin header is the claim under test. The body never asserts its own origin.
30
30
  const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
31
31
  // Admission tier 3. Hashed here so no raw address crosses into the decision, a counter key, or an
@@ -36,7 +36,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
36
36
  let configuration;
37
37
  let sponsoredBudget;
38
38
  let usageSession;
39
- const result = await mintPublicSession({ embedId, origin, ...(addressBucket ? { addressBucket } : {}) }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
39
+ const result = await mintPublicSession({ embedId, visitorId, origin, ...(addressBucket ? { addressBucket } : {}) }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
40
40
  embeds,
41
41
  counters,
42
42
  resolveActiveSurface: async (embed) => {
@@ -86,6 +86,10 @@ export async function handlePublicAssistantSession(req, res, deps) {
86
86
  now: () => now(deps),
87
87
  });
88
88
  if (!result.ok) {
89
+ // A refusal the surface owns is usage. Without it an operator whose visitors are being turned
90
+ // away sees a healthy-looking page; which refusals count is the helper's decision, not this one's.
91
+ const refusedIn = performance.now() - usageStartedAt;
92
+ captureRefusedAssistantSession(deps.captureRequestEvent, result, refusedIn);
89
93
  return sendJson(res, result.status, { error: result.message, code: result.code });
90
94
  }
91
95
  // Only here, with the origin proven against the live surface, may the page read the response.
@@ -56,6 +56,7 @@ export function assistantSessionEndpoints(base) {
56
56
  apps: `${base}/v1/assistant/apps`,
57
57
  sandbox: `${base}/v1/assistant/sandbox`,
58
58
  transcript: `${base}/v1/assistant/transcript`,
59
+ suggestions: `${base}/v1/assistant/suggestions`,
59
60
  };
60
61
  }
61
62
  //# sourceMappingURL=assistant-route-http.js.map
@@ -1,49 +1,6 @@
1
- import { authenticatedSurfaceOf, projectArtifactForSurface, publicSurfaceOf, surfaceBindingForOrigin, } from '@noodle-borg/assistant-gateway/portable';
2
- export async function sessionScopedTarget(registry, session) {
3
- const target = await registry.get(session.deploymentId);
4
- if (!target)
5
- return undefined;
6
- const assistant = target.served.artifact.server.assistant;
7
- const bound = session.boundSurface ?? deriveLegacyBinding(assistant, session);
8
- if (bound === 'pre-surfaces')
9
- return target;
10
- // Fail closed: an origin no surface owns must never widen to the whole server.
11
- if (bound === 'unowned')
12
- return undefined;
13
- // Fail closed on a vanished surface. A rollback to an artifact without the bound surface must end the
14
- // session's reach, never hand it the unprojected server.
15
- if (bound === 'authenticated') {
16
- const surface = authenticatedSurfaceOf(assistant);
17
- if (surface === undefined)
18
- return undefined;
19
- // An omitted allowlist on an authenticated surface is the authored whole-server intent, so the
20
- // exact binding still holds (instructions, budgets, attribution) without narrowing capabilities.
21
- if (surface.capabilities === undefined)
22
- return target;
23
- return projected(target, surface.capabilities);
24
- }
25
- const surface = publicSurfaceOf(assistant);
26
- if (surface === undefined)
27
- return undefined;
28
- return projected(target, surface.capabilities);
29
- }
30
- function projected(target, capabilities) {
31
- return {
32
- ...target,
33
- served: {
34
- ...target.served,
35
- artifact: projectArtifactForSurface(target.served.artifact, capabilities),
36
- },
37
- };
38
- }
39
- /**
40
- * A record minted before `boundSurface` existed: the embed id marks the public surface; otherwise the
41
- * session's origin selects the owning surface on the pinned deployment. `pre-surfaces` means the whole
42
- * server on that released artifact shape; `unowned` fails closed above.
43
- */
44
- function deriveLegacyBinding(assistant, session) {
45
- if (session.publicEmbedId !== undefined)
46
- return 'public';
47
- return surfaceBindingForOrigin(assistant, session.origin).kind;
1
+ import { resolveAssistantSessionTarget, } from '@noodle-borg/assistant-gateway/portable';
2
+ /** Adapt the registry to the gateway-owned, surface-projected session target decision. */
3
+ export function sessionScopedTarget(registry, session) {
4
+ return resolveAssistantSessionTarget((deploymentId) => registry.get(deploymentId), session);
48
5
  }
49
6
  //# sourceMappingURL=assistant-session-target.js.map
@@ -0,0 +1,106 @@
1
+ import { ADMISSION_DEFAULTS } from '@noodle-borg/admission-limits/portable';
2
+ import { parseAssistantContextPreferences, surfaceEnvelope, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
+ import { assistantSuggestionsRequestSchema } from '@noodle-borg/wire-contracts';
4
+ import { readJsonBody, sendJson } from '../http-util.js';
5
+ import { resolveInvocationContextSnapshot } from '../invocation-context.js';
6
+ import { generateInitialAssistantSuggestions } from './assistant-agent.js';
7
+ import { applyBrowserCors, authenticateSession, now } from './assistant-route-http.js';
8
+ import { sessionScopedTarget } from './assistant-session-target.js';
9
+ /** Generate or replay the one bounded initial prompt set for a session whose config omitted it. */
10
+ export async function handleAssistantSuggestions(req, res, deps) {
11
+ const session = await authenticateSession(req, res, deps);
12
+ if (!session)
13
+ return;
14
+ applyBrowserCors(req, res, session.origin);
15
+ const body = await readJsonBody(req, deps.maxBody);
16
+ if (!body.ok)
17
+ return sendJson(res, body.status, { error: body.error });
18
+ const parsed = assistantSuggestionsRequestSchema.safeParse(body.value);
19
+ if (!parsed.success)
20
+ return sendJson(res, 400, { error: 'invalid suggestions request' });
21
+ // The server enforces omission too: a modified client cannot spend a model call when the resolved
22
+ // developer/operator configuration supplied either exact prompts or an explicit empty array.
23
+ if (session.configuration?.assistant?.suggestedPrompts !== undefined) {
24
+ return writeEmptySuggestions(res, session.origin);
25
+ }
26
+ if (session.history.length > 0)
27
+ return writeEmptySuggestions(res, session.origin);
28
+ if (!(await publicSuggestionWorkIsAvailable(session, deps))) {
29
+ return sendJson(res, 429, {
30
+ error: 'assistant is unavailable right now',
31
+ code: 'daily_turn_budget_exhausted',
32
+ });
33
+ }
34
+ const claim = await deps.store.claimInitialSuggestions(session.id);
35
+ if (claim.disposition === 'ready') {
36
+ return writeSuggestions(res, session.origin, claim.prompts);
37
+ }
38
+ if (claim.disposition !== 'generate')
39
+ return writeEmptySuggestions(res, session.origin);
40
+ const target = await sessionScopedTarget(deps.registry, session);
41
+ if (!target) {
42
+ await deps.store.failInitialSuggestions(session.id);
43
+ return writeEmptySuggestions(res, session.origin);
44
+ }
45
+ const clientContext = parsed.data.clientContext === undefined
46
+ ? undefined
47
+ : parseAssistantContextPreferences(parsed.data.clientContext);
48
+ if (clientContext?.ok === false) {
49
+ await deps.store.failInitialSuggestions(session.id);
50
+ return sendJson(res, 400, { error: 'invalid client context' });
51
+ }
52
+ const context = await resolveInvocationContextSnapshot({
53
+ artifact: target.served.artifact,
54
+ executeDeps: withAssistantSessionExecutionAuthority(target.served.deps, target.served.artifact, session),
55
+ caller: session.caller,
56
+ instant: now(deps),
57
+ ...(session.preferences ? { applicationPreference: session.preferences } : {}),
58
+ ...(clientContext?.ok ? { clientHint: clientContext.value } : {}),
59
+ });
60
+ try {
61
+ const prompts = await generateInitialAssistantSuggestions(target, session, context, deps, parsed.data.modelContext, parsed.data.pageContext);
62
+ if (prompts.length === 0) {
63
+ await deps.store.failInitialSuggestions(session.id);
64
+ return writeEmptySuggestions(res, session.origin);
65
+ }
66
+ await deps.store.completeInitialSuggestions(session.id, prompts);
67
+ return writeSuggestions(res, session.origin, prompts);
68
+ }
69
+ catch {
70
+ await deps.store.failInitialSuggestions(session.id);
71
+ return writeEmptySuggestions(res, session.origin);
72
+ }
73
+ }
74
+ async function publicSuggestionWorkIsAvailable(session, deps) {
75
+ if (session.publicEmbedId === undefined)
76
+ return true;
77
+ const embed = await deps.publicEmbeds?.lookup(session.publicEmbedId);
78
+ if (!embed)
79
+ return false;
80
+ const bounds = session.modelSource !== 'noodle-managed'
81
+ ? undefined
82
+ : (await deps.managedModelResolver?.resolve({
83
+ tenant: session.tenant,
84
+ deploymentId: session.deploymentId,
85
+ }))?.publicAdmission;
86
+ const envelope = surfaceEnvelope(deps.admissionEnvelope ?? ADMISSION_DEFAULTS, embed, bounds);
87
+ return envelope.mintsPerDay > 0 && envelope.turnsPerDay > 0;
88
+ }
89
+ function writeSuggestions(res, origin, prompts) {
90
+ writeHeaders(res, origin);
91
+ res.write(`event: suggested_prompts\ndata: ${JSON.stringify({ phase: 'initial', prompts })}\n\n`);
92
+ res.end('event: done\ndata: {}\n\n');
93
+ }
94
+ function writeEmptySuggestions(res, origin) {
95
+ writeHeaders(res, origin);
96
+ res.end('event: done\ndata: {}\n\n');
97
+ }
98
+ function writeHeaders(res, origin) {
99
+ res.writeHead(200, {
100
+ 'content-type': 'text/event-stream; charset=utf-8',
101
+ 'cache-control': 'no-store',
102
+ 'access-control-allow-origin': origin,
103
+ vary: 'Origin',
104
+ });
105
+ }
106
+ //# sourceMappingURL=assistant-suggestions.js.map
@@ -18,7 +18,12 @@ export async function handleAssistantTranscript(req, res, deps) {
18
18
  if (!session)
19
19
  return;
20
20
  applyBrowserCors(req, res, session.origin);
21
- const body = { entries: visibleTranscript(session.history) };
21
+ const body = {
22
+ entries: visibleTranscript(session.history),
23
+ ...(session.latestSuggestions?.phase === 'follow_up'
24
+ ? { suggestions: session.latestSuggestions }
25
+ : {}),
26
+ };
22
27
  // Parse before sending, like the session exchange: a contract break fails loudly here rather than
23
28
  // stranding deployed widgets (ADR 0151).
24
29
  assistantTranscriptResponseSchema.parse(body);
@@ -1,6 +1,6 @@
1
1
  import { clientAddressBucket, } from '@noodle-borg/admission-limits/portable';
2
2
  import { assistantModelFailure, assistantModelSource, assistantModelTransport, } from '@noodle-borg/assistant-gateway/model-runtime';
3
- import { ASSISTANT_SESSION_IDLE_MS, dispatchAssistantTool, effectiveAssistantBrowserConfiguration, isAssistantPageContext, isAssistantVerifiedClaims, parseAssistantContextPreferences, parseAssistantCustomerRouting, resumeTurnMessage, resumeUnavailableMessage, shouldAutoResume, surfaceBindingForOrigin, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
+ import { ASSISTANT_SESSION_IDLE_MS, dispatchAssistantTool, effectiveAssistantBrowserConfiguration, isAssistantPageContext, isAssistantVerifiedClaims, parseAssistantContextPreferences, parseAssistantCustomerRouting, refuseBridgeToolCall, refusePublicTurn, resumeTurnMessage, resumeUnavailableMessage, shouldAutoResume, surfaceBindingForOrigin, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
4
4
  import { canonicalizeAuthorizationClaimValues } from '@noodle-borg/auth';
5
5
  import { validateJsonSchemaWithDefaults } from '@noodle-borg/compiler';
6
6
  import { tenantMcpUrl } from '@noodle-borg/module';
@@ -13,7 +13,6 @@ import { resolveInvocationContextSnapshot } from '../invocation-context.js';
13
13
  import { createAssistantTurnStats, runAgentTurn } from './assistant-agent.js';
14
14
  import { elevateAssistantSession } from './assistant-elevation.js';
15
15
  import { executeAssistantKnowledgeSearch, findAssistantKnowledgeComponent, resolveAssistantKnowledge, } from './assistant-knowledge.js';
16
- import { refusePublicTurn } from './assistant-public-turn.js';
17
16
  import { applyBrowserCors, assistantSessionEndpoints, authenticateSession, handleAssistantPreflight, now, } from './assistant-route-http.js';
18
17
  import { sessionScopedTarget } from './assistant-session-target.js';
19
18
  import { authorizeControlPlane } from './control-plane.js';
@@ -175,6 +174,16 @@ export async function handleAssistantSession(req, res, deps) {
175
174
  captureAssistantUsage(deps.captureRequestEvent, assistantSessionUsageRequestEvent(session.session, performance.now() - usageStartedAt));
176
175
  return sendJson(res, 201, sessionBody);
177
176
  }
177
+ /**
178
+ * Both turn entries — a resume and a typed message — refuse identically: the refusal is spent
179
+ * budget, so it is recorded against the surface before the caller is told no. Kept in one place so
180
+ * the two entries cannot drift into recording it differently.
181
+ */
182
+ function refuseTurn(deps, res, session, refusal, usageStartedAt) {
183
+ const durationMs = performance.now() - usageStartedAt;
184
+ captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, durationMs));
185
+ sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
186
+ }
178
187
  export async function handleAssistantTurn(req, res, deps) {
179
188
  const usageStartedAt = performance.now();
180
189
  const session = await authenticateSession(req, res, deps);
@@ -191,18 +200,19 @@ export async function handleAssistantTurn(req, res, deps) {
191
200
  let turn;
192
201
  let message;
193
202
  let resumedTool;
203
+ let suggestionsRequested = false;
194
204
  if (isRecord(body.value) && 'resume' in body.value) {
195
205
  // The one-shot post-sign-in resume (issue #1177): no user text, the pending intent is server
196
206
  // state. Admission runs FIRST so a refused resume stays armed for a retry; the consume is a
197
207
  // single statement, so two requests racing the arm cannot both run it.
198
- if (!assistantResumeTurnRequestSchema.safeParse(body.value).success) {
208
+ const resumeResult = assistantResumeTurnRequestSchema.safeParse(body.value);
209
+ if (!resumeResult.success) {
199
210
  return sendJson(res, 400, { error: 'invalid turn request' });
200
211
  }
212
+ suggestionsRequested = resumeResult.data.suggestions === true;
201
213
  const refusal = await refusePublicTurn(deps, session, 'resume', addressBucket);
202
- if (refusal) {
203
- captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, performance.now() - usageStartedAt));
204
- return sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
205
- }
214
+ if (refusal)
215
+ return refuseTurn(deps, res, session, refusal, usageStartedAt);
206
216
  const pending = await deps.store.consumePendingResume(session.id);
207
217
  if (!pending) {
208
218
  return sendJson(res, 409, {
@@ -242,6 +252,7 @@ export async function handleAssistantTurn(req, res, deps) {
242
252
  return sendJson(res, 400, { error: 'invalid turn request' });
243
253
  }
244
254
  turn = turnResult.data;
255
+ suggestionsRequested = turn.suggestions === true;
245
256
  message = turn.message;
246
257
  if (message.trim().length === 0) {
247
258
  return sendJson(res, 400, { error: '"message" must be a non-empty string' });
@@ -249,10 +260,8 @@ export async function handleAssistantTurn(req, res, deps) {
249
260
  // Admission before any metered work (ADR 0201 §8): ahead of the deployment read and well ahead
250
261
  // of the model, so a refused turn costs the customer nothing.
251
262
  const refusal = await refusePublicTurn(deps, session, message, addressBucket);
252
- if (refusal) {
253
- captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, performance.now() - usageStartedAt));
254
- return sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
255
- }
263
+ if (refusal)
264
+ return refuseTurn(deps, res, session, refusal, usageStartedAt);
256
265
  // The visitor typed first: any pending post-sign-in resume is moot, and must not fire later.
257
266
  if (session.pendingResume)
258
267
  await deps.store.consumePendingResume(session.id);
@@ -296,7 +305,15 @@ export async function handleAssistantTurn(req, res, deps) {
296
305
  };
297
306
  let turnCompleted = false;
298
307
  try {
299
- await runAgentTurn(target, session, message.trim(), invocationContext, deps, emit, turn?.modelContext, turn?.pageContext, stats);
308
+ if (suggestionsRequested) {
309
+ try {
310
+ await deps.store.replaceLatestSuggestions(session.id, undefined);
311
+ }
312
+ catch {
313
+ // Suggestions are optional; the assistant turn remains authoritative.
314
+ }
315
+ }
316
+ await runAgentTurn(target, session, message.trim(), invocationContext, deps, emit, turn?.modelContext, turn?.pageContext, stats, suggestionsRequested);
300
317
  turnCompleted = true;
301
318
  }
302
319
  catch (error) {
@@ -367,6 +384,12 @@ export async function handleAssistantAppRequest(req, res, deps) {
367
384
  return sendJson(res, 409, { error: 'assistant deployment is unavailable' });
368
385
  const method = body.value.method;
369
386
  const params = body.value.params;
387
+ // Answered before any resolution work, so a refused browser agent costs nothing (ADR 0220). The
388
+ // marker is client-declared: it selects a budget, never authority, which the session below decides.
389
+ const bridged = method === 'tools/call' && body.value.bridge === 'webmcp';
390
+ const refusal = bridged ? await refuseBridgeToolCall(deps, session) : undefined;
391
+ if (refusal)
392
+ return sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
370
393
  const knowledge = await resolveAssistantKnowledge(target.served);
371
394
  if (method === 'tools/list') {
372
395
  return sendJson(res, 200, mapToolsList(target.served.artifact, session.caller, {
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "^1.29.0",
41
41
  "@noodle-borg/admission-limits": "0.0.0",
42
- "@noodle-borg/agent-kit": "0.87.1",
42
+ "@noodle-borg/agent-kit": "0.88.0",
43
43
  "@noodle-borg/app-package": "0.0.0",
44
44
  "@noodle-borg/assistant-gateway": "0.0.0",
45
45
  "@noodle-borg/auth": "0.0.0",
@@ -77,6 +77,7 @@ export function emitRequestEvent(input) {
77
77
  ...(clientVersion !== undefined ? { clientVersion } : {}),
78
78
  clientFamily: family,
79
79
  ...(auth.accessMode !== undefined ? { accessMode: auth.accessMode } : {}),
80
+ surface: 'mcp',
80
81
  subjectKind: subject !== undefined ? 'authenticated' : 'anonymous',
81
82
  method,
82
83
  kind: DISCOVERY_METHODS.has(method) ? 'discovery' : 'usage',
@@ -147,6 +148,7 @@ export function emitDeniedRequestEvent(input) {
147
148
  sessionSource: 'none',
148
149
  clientFamily: clientFamily(undefined, header(req, 'user-agent')),
149
150
  ...(auth.accessMode !== undefined ? { accessMode: auth.accessMode } : {}),
151
+ surface: 'mcp',
150
152
  subjectKind: 'anonymous',
151
153
  method,
152
154
  kind: 'usage',
@@ -21,6 +21,7 @@ export declare const assistantSessionResponseSchema: z.ZodObject<{
21
21
  apps: z.ZodOptional<z.ZodURL>;
22
22
  sandbox: z.ZodOptional<z.ZodURL>;
23
23
  transcript: z.ZodOptional<z.ZodURL>;
24
+ suggestions: z.ZodOptional<z.ZodURL>;
24
25
  }, z.core.$strip>;
25
26
  configuration: z.ZodOptional<z.ZodObject<{
26
27
  branding: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
@@ -45,6 +46,10 @@ export declare const assistantTranscriptResponseSchema: z.ZodObject<{
45
46
  }>;
46
47
  text: z.ZodString;
47
48
  }, z.core.$strict>>;
49
+ suggestions: z.ZodOptional<z.ZodObject<{
50
+ phase: z.ZodLiteral<"follow_up">;
51
+ prompts: z.ZodArray<z.ZodString>;
52
+ }, z.core.$strict>>;
48
53
  }, z.core.$strict>;
49
54
  export type AssistantTranscriptResponse = z.infer<typeof assistantTranscriptResponseSchema>;
50
55
  /** MCP-Apps-shaped, author-selected renderer summary included on one assistant turn. */
@@ -78,9 +83,11 @@ export declare const assistantMessageTurnRequestSchema: z.ZodObject<{
78
83
  structuredContent: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
79
84
  }, z.core.$strict>>;
80
85
  pageContext: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
86
+ suggestions: z.ZodOptional<z.ZodLiteral<true>>;
81
87
  }, z.core.$strict>;
82
88
  export declare const assistantResumeTurnRequestSchema: z.ZodObject<{
83
89
  resume: z.ZodLiteral<true>;
90
+ suggestions: z.ZodOptional<z.ZodLiteral<true>>;
84
91
  }, z.core.$strict>;
85
92
  export type AssistantMessageTurnRequest = z.infer<typeof assistantMessageTurnRequestSchema>;
86
93
  export declare const assistantTurnRequestSchema: z.ZodUnion<readonly [z.ZodObject<{
@@ -97,10 +104,28 @@ export declare const assistantTurnRequestSchema: z.ZodUnion<readonly [z.ZodObjec
97
104
  structuredContent: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
98
105
  }, z.core.$strict>>;
99
106
  pageContext: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
107
+ suggestions: z.ZodOptional<z.ZodLiteral<true>>;
100
108
  }, z.core.$strict>, z.ZodObject<{
101
109
  resume: z.ZodLiteral<true>;
110
+ suggestions: z.ZodOptional<z.ZodLiteral<true>>;
102
111
  }, z.core.$strict>]>;
103
112
  export type AssistantTurnRequest = z.infer<typeof assistantTurnRequestSchema>;
113
+ /** Fresh untrusted context for the optional initial-suggestions request. */
114
+ export declare const assistantSuggestionsRequestSchema: z.ZodObject<{
115
+ clientContext: z.ZodOptional<z.ZodObject<{
116
+ locale: z.ZodOptional<z.ZodString>;
117
+ timeZone: z.ZodOptional<z.ZodString>;
118
+ }, z.core.$strict>>;
119
+ modelContext: z.ZodOptional<z.ZodObject<{
120
+ content: z.ZodOptional<z.ZodArray<z.ZodObject<{
121
+ type: z.ZodLiteral<"text">;
122
+ text: z.ZodString;
123
+ }, z.core.$strict>>>;
124
+ structuredContent: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
125
+ }, z.core.$strict>>;
126
+ pageContext: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
127
+ }, z.core.$strict>;
128
+ export type AssistantSuggestionsRequest = z.infer<typeof assistantSuggestionsRequestSchema>;
104
129
  /**
105
130
  * Resolve one server-held assistant interaction. The client returns only its decision and optional
106
131
  * schema-constrained input; reviewed tool arguments remain server-held and cannot be substituted here.
@@ -109,12 +134,15 @@ export declare const assistantInteractionRequestSchema: z.ZodDiscriminatedUnion<
109
134
  id: z.ZodString;
110
135
  action: z.ZodLiteral<"accept">;
111
136
  content: z.ZodOptional<z.ZodJSONSchema>;
137
+ suggestions: z.ZodOptional<z.ZodLiteral<true>>;
112
138
  }, z.core.$strict>, z.ZodObject<{
113
139
  id: z.ZodString;
114
140
  action: z.ZodLiteral<"decline">;
141
+ suggestions: z.ZodOptional<z.ZodLiteral<true>>;
115
142
  }, z.core.$strict>, z.ZodObject<{
116
143
  id: z.ZodString;
117
144
  action: z.ZodLiteral<"cancel">;
145
+ suggestions: z.ZodOptional<z.ZodLiteral<true>>;
118
146
  }, z.core.$strict>], "action">;
119
147
  export type AssistantInteractionRequest = z.infer<typeof assistantInteractionRequestSchema>;
120
148
  /**
@@ -204,6 +232,16 @@ export declare const assistantWireEventSchema: z.ZodUnion<readonly [z.ZodObject<
204
232
  continuation: z.ZodString;
205
233
  expiresAt: z.ZodString;
206
234
  }, z.core.$strip>;
235
+ }, z.core.$strip>, z.ZodObject<{
236
+ event: z.ZodLiteral<string>;
237
+ data: z.ZodObject<{
238
+ turnId: z.ZodOptional<z.ZodString>;
239
+ phase: z.ZodEnum<{
240
+ follow_up: "follow_up";
241
+ initial: "initial";
242
+ }>;
243
+ prompts: z.ZodArray<z.ZodString>;
244
+ }, z.core.$strip>;
207
245
  }, z.core.$strip>, z.ZodObject<{
208
246
  event: z.ZodLiteral<string>;
209
247
  data: z.ZodObject<{
@@ -49,6 +49,8 @@ export const assistantSessionResponseSchema = z.object({
49
49
  * starts visually fresh exactly as before.
50
50
  */
51
51
  transcript: z.url().optional(),
52
+ /** Additive model-generated prompt suggestions; its presence is the client capability gate. */
53
+ suggestions: z.url().optional(),
52
54
  }),
53
55
  configuration: assistantSessionConfigurationSchema.optional(),
54
56
  /**
@@ -74,6 +76,13 @@ export const assistantTranscriptResponseSchema = z
74
76
  })
75
77
  .strict())
76
78
  .max(40),
79
+ suggestions: z
80
+ .object({
81
+ phase: z.literal('follow_up'),
82
+ prompts: z.array(z.string().trim().min(1).max(240)).max(3),
83
+ })
84
+ .strict()
85
+ .optional(),
77
86
  })
78
87
  .strict();
79
88
  const assistantModelContentPartSchema = z
@@ -120,23 +129,54 @@ export const assistantMessageTurnRequestSchema = z
120
129
  .optional(),
121
130
  modelContext: assistantModelContextUpdateSchema.optional(),
122
131
  pageContext: assistantPageContextSchema.optional(),
132
+ suggestions: z.literal(true).optional(),
123
133
  })
124
134
  .strict();
125
- export const assistantResumeTurnRequestSchema = z.object({ resume: z.literal(true) }).strict();
135
+ export const assistantResumeTurnRequestSchema = z
136
+ .object({ resume: z.literal(true), suggestions: z.literal(true).optional() })
137
+ .strict();
126
138
  export const assistantTurnRequestSchema = z.union([
127
139
  assistantMessageTurnRequestSchema,
128
140
  assistantResumeTurnRequestSchema,
129
141
  ]);
142
+ /** Fresh untrusted context for the optional initial-suggestions request. */
143
+ export const assistantSuggestionsRequestSchema = z
144
+ .object({
145
+ clientContext: z
146
+ .object({ locale: z.string().max(160).optional(), timeZone: z.string().max(160).optional() })
147
+ .strict()
148
+ .optional(),
149
+ modelContext: assistantModelContextUpdateSchema.optional(),
150
+ pageContext: assistantPageContextSchema.optional(),
151
+ })
152
+ .strict();
130
153
  /**
131
154
  * Resolve one server-held assistant interaction. The client returns only its decision and optional
132
155
  * schema-constrained input; reviewed tool arguments remain server-held and cannot be substituted here.
133
156
  */
134
157
  export const assistantInteractionRequestSchema = z.discriminatedUnion('action', [
135
158
  z
136
- .object({ id: z.string().min(1), action: z.literal('accept'), content: z.json().optional() })
159
+ .object({
160
+ id: z.string().min(1),
161
+ action: z.literal('accept'),
162
+ content: z.json().optional(),
163
+ suggestions: z.literal(true).optional(),
164
+ })
165
+ .strict(),
166
+ z
167
+ .object({
168
+ id: z.string().min(1),
169
+ action: z.literal('decline'),
170
+ suggestions: z.literal(true).optional(),
171
+ })
172
+ .strict(),
173
+ z
174
+ .object({
175
+ id: z.string().min(1),
176
+ action: z.literal('cancel'),
177
+ suggestions: z.literal(true).optional(),
178
+ })
137
179
  .strict(),
138
- z.object({ id: z.string().min(1), action: z.literal('decline') }).strict(),
139
- z.object({ id: z.string().min(1), action: z.literal('cancel') }).strict(),
140
180
  ]);
141
181
  const optionalTurnId = z.string().min(1).optional();
142
182
  const eventEnvelope = (event, data) => z.object({ event: z.literal(event), data });
@@ -206,6 +246,11 @@ export const assistantWireEventSchema = z.union([
206
246
  continuation: z.string().min(1),
207
247
  expiresAt: z.string().min(1),
208
248
  })),
249
+ eventEnvelope('suggested_prompts', z.object({
250
+ turnId: optionalTurnId,
251
+ phase: z.enum(['initial', 'follow_up']),
252
+ prompts: z.array(z.string().trim().min(1).max(240)).max(3),
253
+ })),
209
254
  eventEnvelope('error', z.object({
210
255
  turnId: optionalTurnId,
211
256
  code: z.string().min(1),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodleseed/assistant",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "Embed the Noodle Seed customer-branded assistant in your web app with managed or framework-owned UI, a framework-neutral MCP App host, a DOM-free client, and a backend session helper. Authoring and deploying the server is @noodleseed/one.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodleseed/one",
3
- "version": "0.147.1",
3
+ "version": "0.149.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",
@@ -236,7 +236,7 @@
236
236
  "@modelcontextprotocol/client": "2.0.0",
237
237
  "@modelcontextprotocol/server": "2.0.0",
238
238
  "@noodle-borg/admission-limits": "0.0.0",
239
- "@noodle-borg/agent-kit": "0.87.1",
239
+ "@noodle-borg/agent-kit": "0.88.0",
240
240
  "@noodle-borg/app-audit": "0.0.0",
241
241
  "@noodle-borg/app-package": "0.0.0",
242
242
  "@noodle-borg/assistant-gateway": "0.0.0",