@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
@@ -0,0 +1,35 @@
1
+ export interface ModelToolCall {
2
+ readonly id: string;
3
+ readonly type: 'function';
4
+ readonly function: {
5
+ readonly name: string;
6
+ readonly arguments: string;
7
+ };
8
+ /**
9
+ * Opaque provider round-trip state, echoed back unread on the follow-up request.
10
+ *
11
+ * Gemini 3.x returns a `thought_signature` here and **rejects the next request** without it, so a
12
+ * client that drops this can call a tool once and never finish the turn. The OpenAI wire format
13
+ * reserves `extra_content` for exactly this, so it is passed through rather than special-cased —
14
+ * nothing here reads it, and no provider is named in the code that carries it.
15
+ */
16
+ readonly extra_content?: unknown;
17
+ }
18
+ export interface ModelCompletion {
19
+ readonly choices: readonly {
20
+ readonly message: {
21
+ readonly role: 'assistant';
22
+ readonly content?: string | null;
23
+ readonly tool_calls?: readonly ModelToolCall[];
24
+ };
25
+ }[];
26
+ readonly usage?: ModelUsage;
27
+ }
28
+ export interface ModelUsage {
29
+ readonly promptTokens: number;
30
+ readonly completionTokens: number;
31
+ readonly totalTokens: number;
32
+ readonly reasoningTokens?: number;
33
+ }
34
+ export declare function readModelCompletion(response: Response, onContent: (delta: string) => void, maxBytes?: number): Promise<ModelCompletion>;
35
+ //# sourceMappingURL=model-stream.d.ts.map
@@ -17,16 +17,19 @@ export * from './assistant-transcript.js';
17
17
  export * from './assistant-view-availability.js';
18
18
  export * from './elevation.js';
19
19
  export * from './elevation-store.js';
20
+ export * from './embed-operator-view.js';
20
21
  export * from './embed-script.js';
21
22
  export * from './embed-store.js';
22
23
  export * from './in-memory-assistant-appearance-store.js';
23
24
  export * from './in-memory-elevation-store.js';
24
25
  export * from './in-memory-embed-store.js';
26
+ export * from './managed-spend.js';
25
27
  export * from './public-configuration.js';
26
28
  export * from './public-session.js';
27
29
  export * from './public-surface.js';
28
30
  export * from './public-turn.js';
29
31
  export * from './session-resume.js';
32
+ export * from './session-target.js';
30
33
  export * from './surface-budget.js';
31
34
  export * from './tenant-ref.js';
32
35
  //# sourceMappingURL=portable.d.ts.map
@@ -17,16 +17,19 @@ export * from './assistant-transcript.js';
17
17
  export * from './assistant-view-availability.js';
18
18
  export * from './elevation.js';
19
19
  export * from './elevation-store.js';
20
+ export * from './embed-operator-view.js';
20
21
  export * from './embed-script.js';
21
22
  export * from './embed-store.js';
22
23
  export * from './in-memory-assistant-appearance-store.js';
23
24
  export * from './in-memory-elevation-store.js';
24
25
  export * from './in-memory-embed-store.js';
26
+ export * from './managed-spend.js';
25
27
  export * from './public-configuration.js';
26
28
  export * from './public-session.js';
27
29
  export * from './public-surface.js';
28
30
  export * from './public-turn.js';
29
31
  export * from './session-resume.js';
32
+ export * from './session-target.js';
30
33
  export * from './surface-budget.js';
31
34
  export * from './tenant-ref.js';
32
35
  //# sourceMappingURL=portable.js.map
@@ -1,4 +1,4 @@
1
- import type { AdmissionEnvelope, DailyCounterStore } from '@noodle-borg/admission-limits/portable';
1
+ import { type AdmissionEnvelope, type DailyCounterStore } from '@noodle-borg/admission-limits/portable';
2
2
  import { type PublicEmbedRecord, type PublicEmbedStore } from './embed-store.js';
3
3
  import type { PublicSurface } from './public-surface.js';
4
4
  import { type SurfaceBudgetBounds } from './surface-budget.js';
@@ -43,6 +43,13 @@ export interface PublicSessionRequest {
43
43
  * be parsed, in which case the surface tier alone applies: fairness degrades, solvency does not.
44
44
  */
45
45
  readonly addressBucket?: string | undefined;
46
+ /**
47
+ * The browser's own visitor identifier (admission tier 4), unvalidated exactly like `embedId`:
48
+ * it arrives from the request body, so the decision owns its parse rather than the transport.
49
+ * Client-supplied and therefore rotatable, so it bounds fairness and never abuse — the address
50
+ * tier underneath is the one that cannot be rotated.
51
+ */
52
+ readonly visitorId?: unknown;
46
53
  }
47
54
  export type PublicSessionResult = {
48
55
  readonly ok: true;
@@ -54,6 +61,12 @@ export type PublicSessionResult = {
54
61
  readonly status: number;
55
62
  readonly code: string;
56
63
  readonly message: string;
64
+ /**
65
+ * The surface this refusal belongs to, once it is known. Present for every refusal an operator
66
+ * needs to see — out of budget, address at its ceiling, origin off the live allowlist — and
67
+ * absent for the ones that cannot be attributed to a tenant at all, which are prober noise.
68
+ */
69
+ readonly embed?: PublicEmbedRecord;
57
70
  };
58
71
  export declare function mintPublicSession(request: PublicSessionRequest, envelope: AdmissionEnvelope, ports: PublicSessionPorts): Promise<PublicSessionResult>;
59
72
  //# sourceMappingURL=public-session.d.ts.map
@@ -1,7 +1,8 @@
1
+ import { visitorBucket, } from '@noodle-borg/admission-limits/portable';
1
2
  import { isPublicEmbedId } from './embed-store.js';
2
3
  import { surfaceEnvelope } from './surface-budget.js';
3
- function refuse(status, code, message) {
4
- return { ok: false, status, code, message };
4
+ function refuse(status, code, message, embed) {
5
+ return { ok: false, status, code, message, ...(embed === undefined ? {} : { embed }) };
5
6
  }
6
7
  export async function mintPublicSession(request, envelope, ports) {
7
8
  if (typeof request.embedId !== 'string' || !isPublicEmbedId(request.embedId)) {
@@ -22,17 +23,33 @@ export async function mintPublicSession(request, envelope, ports) {
22
23
  return refuse(403, 'embed_not_found', 'embed is not available');
23
24
  const surface = await ports.resolveActiveSurface(embed);
24
25
  if (surface === undefined) {
25
- return refuse(409, 'surface_unavailable', 'assistant deployment is unavailable');
26
+ return refuse(409, 'surface_unavailable', 'assistant deployment is unavailable', embed);
26
27
  }
27
28
  // Origins come from the live artifact, so `noodle deploy` alone updates a pasted snippet.
28
29
  if (!surface.origins.includes(request.origin)) {
29
- return refuse(403, 'origin_not_allowed', 'origin is not allowed');
30
+ return refuse(403, 'origin_not_allowed', 'origin is not allowed', embed);
30
31
  }
31
32
  // The surface's own budget, which an operator may have lowered — or set to zero, which is the kill
32
33
  // switch. Resolved after the embed is known and before anything is spent.
33
34
  const budgetBounds = await ports.resolveBudgetBounds?.(embed);
34
35
  const surfaceLimits = surfaceEnvelope(envelope, embed, budgetBounds);
35
36
  const now = ports.now();
37
+ // Tier 4 before tier 3, which is before solvency. A visitor at their own hourly ceiling is refused
38
+ // without spending any of the shared address allowance, so one person behind a corporate NAT cannot
39
+ // consume the allowance the other three hundred are also using. An abuser who rotates the
40
+ // identifier simply never meets this tier and lands on the address bound below, which is the point:
41
+ // this is fairness, that is abuse control, and they are deliberately not the same limit.
42
+ const visitor = visitorBucket(request.visitorId);
43
+ if (visitor !== undefined) {
44
+ const perVisitor = await ports.counters.consume({
45
+ key: `mints:vis:${embed.embedId}:${visitor}`,
46
+ limit: surfaceLimits.mintsPerVisitorHour,
47
+ window: 'hour',
48
+ }, now);
49
+ if (!perVisitor.allowed) {
50
+ return refuse(429, 'visitor_session_budget_exhausted', 'assistant is unavailable right now', embed);
51
+ }
52
+ }
36
53
  // Fairness before solvency: an address at its hourly ceiling is refused without spending any of the
37
54
  // surface's day, so one visitor cannot burn the customer's budget on refusals.
38
55
  if (typeof request.addressBucket === 'string') {
@@ -42,14 +59,14 @@ export async function mintPublicSession(request, envelope, ports) {
42
59
  window: 'hour',
43
60
  }, now);
44
61
  if (!perAddress.allowed) {
45
- return refuse(429, 'address_session_budget_exhausted', 'assistant is unavailable right now');
62
+ return refuse(429, 'address_session_budget_exhausted', 'assistant is unavailable right now', embed);
46
63
  }
47
64
  }
48
65
  const mints = await ports.counters.consume({ key: `mints:${embed.embedId}`, limit: surfaceLimits.mintsPerDay }, now);
49
66
  if (!mints.allowed) {
50
67
  // Distinct from a hard error: the widget renders this calmly, and an operator sees a surface that
51
68
  // is switched off (limit 0) or spent, not a broken embed.
52
- return refuse(429, 'daily_session_budget_exhausted', 'assistant is unavailable right now');
69
+ return refuse(429, 'daily_session_budget_exhausted', 'assistant is unavailable right now', embed);
53
70
  }
54
71
  const session = await ports.createSession({
55
72
  embed,
@@ -1,6 +1,7 @@
1
- import type { AdmissionEnvelope, DailyCounterStore } from '@noodle-borg/admission-limits/portable';
2
- import type { AssistantTurnConsumption } from './assistant-store.js';
1
+ import { type AdmissionEnvelope, type DailyCounterStore } from '@noodle-borg/admission-limits/portable';
2
+ import type { AssistantSessionRecord, AssistantStore, AssistantTurnConsumption } from './assistant-store.js';
3
3
  import type { PublicEmbedRecord, PublicEmbedStore } from './embed-store.js';
4
+ import type { ManagedAssistantModelResolver } from './model-request.js';
4
5
  import { type SurfaceBudgetBounds } from './surface-budget.js';
5
6
  /**
6
7
  * Whether an anonymous visitor's next turn runs.
@@ -43,5 +44,55 @@ export type PublicTurnResult = {
43
44
  readonly code: string;
44
45
  readonly message: string;
45
46
  };
47
+ export interface AssistantPublicTurnDeps {
48
+ readonly publicEmbeds?: PublicEmbedStore;
49
+ readonly admissionCounters?: DailyCounterStore;
50
+ readonly admissionEnvelope?: AdmissionEnvelope;
51
+ readonly managedModelResolver?: ManagedAssistantModelResolver;
52
+ readonly store: Pick<AssistantStore, 'consumeTurn'>;
53
+ readonly clock?: () => Date;
54
+ }
55
+ export interface PublicTurnRefusal {
56
+ readonly status: number;
57
+ readonly code: string;
58
+ readonly message: string;
59
+ }
60
+ /** Adapt a session plus serving ports to the transport-free public-turn decision below. */
61
+ export declare function refusePublicTurn(deps: AssistantPublicTurnDeps, session: AssistantSessionRecord, message: string, addressBucket: string | undefined): Promise<PublicTurnRefusal | undefined>;
62
+ /**
63
+ * The same gate in front of a WebMCP bridge tool call (ADR 0220). A browser agent can call governed
64
+ * tools without ever running a model turn, so the turn budget above would never refuse it.
65
+ */
66
+ export declare function refuseBridgeToolCall(deps: AssistantPublicTurnDeps, session: AssistantSessionRecord): Promise<PublicTurnRefusal | undefined>;
46
67
  export declare function admitPublicTurn(request: PublicTurnRequest, defaults: AdmissionEnvelope, ports: PublicTurnPorts): Promise<PublicTurnResult>;
68
+ /**
69
+ * Whether a browser agent's next bridge tool call runs (ADR 0220).
70
+ *
71
+ * Separate from {@link admitPublicTurn} because a WebMCP call is not a turn: it spends no model budget,
72
+ * so the turn caps would never refuse it however long an agent kept going. It still reaches connectors
73
+ * and customer backends, so it gets its own pair of bounds — and, like every other admission decision
74
+ * here, the envelope is read per call so an operator lowering a cap stops traffic already under way.
75
+ *
76
+ * Authority is not decided here and never could be: this runs *after* the route has authenticated the
77
+ * session, and the call it admits still goes through tool authorization and confirmation downstream.
78
+ */
79
+ export interface BridgeToolCallPorts {
80
+ readonly counters: DailyCounterStore;
81
+ readonly embeds: Pick<PublicEmbedStore, 'lookup'>;
82
+ readonly resolveBudgetBounds?: (embed: PublicEmbedRecord) => Promise<SurfaceBudgetBounds | undefined>;
83
+ now(): Date;
84
+ }
85
+ export interface BridgeToolCallRequest {
86
+ readonly sessionId: string;
87
+ readonly publicEmbedId: string;
88
+ }
89
+ export type BridgeToolCallResult = {
90
+ readonly ok: true;
91
+ } | {
92
+ readonly ok: false;
93
+ readonly status: number;
94
+ readonly code: string;
95
+ readonly message: string;
96
+ };
97
+ export declare function admitBridgeToolCall(request: BridgeToolCallRequest, defaults: AdmissionEnvelope, ports: BridgeToolCallPorts): Promise<BridgeToolCallResult>;
47
98
  //# sourceMappingURL=public-turn.d.ts.map
@@ -1,4 +1,65 @@
1
+ import { ADMISSION_DEFAULTS, isDisabled, } from '@noodle-borg/admission-limits/portable';
1
2
  import { surfaceEnvelope } from './surface-budget.js';
3
+ /** Adapt a session plus serving ports to the transport-free public-turn decision below. */
4
+ export async function refusePublicTurn(deps, session, message, addressBucket) {
5
+ const publicEmbedId = session.publicEmbedId;
6
+ if (publicEmbedId === undefined)
7
+ return undefined;
8
+ if (!deps.publicEmbeds || !deps.admissionCounters) {
9
+ return {
10
+ status: 503,
11
+ code: 'admission_unavailable',
12
+ message: 'assistant is unavailable right now',
13
+ };
14
+ }
15
+ const result = await admitPublicTurn({ sessionId: session.id, publicEmbedId, message, ...(addressBucket ? { addressBucket } : {}) }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
16
+ counters: deps.admissionCounters,
17
+ embeds: deps.publicEmbeds,
18
+ resolveBudgetBounds: async () => session.modelSource !== 'noodle-managed'
19
+ ? undefined
20
+ : (await deps.managedModelResolver?.resolve({
21
+ tenant: session.tenant,
22
+ deploymentId: session.deploymentId,
23
+ }))?.publicAdmission,
24
+ consumeTurn: (id, limit) => deps.store.consumeTurn(id, limit),
25
+ now: () => deps.clock?.() ?? new Date(),
26
+ });
27
+ return result.ok
28
+ ? undefined
29
+ : { status: result.status, code: result.code, message: result.message };
30
+ }
31
+ /**
32
+ * The same gate in front of a WebMCP bridge tool call (ADR 0220). A browser agent can call governed
33
+ * tools without ever running a model turn, so the turn budget above would never refuse it.
34
+ */
35
+ export async function refuseBridgeToolCall(deps, session) {
36
+ const publicEmbedId = session.publicEmbedId;
37
+ if (publicEmbedId === undefined)
38
+ return undefined;
39
+ if (!deps.publicEmbeds || !deps.admissionCounters) {
40
+ return {
41
+ status: 503,
42
+ code: 'admission_unavailable',
43
+ message: 'assistant is unavailable right now',
44
+ };
45
+ }
46
+ const result = await admitBridgeToolCall({ sessionId: session.id, publicEmbedId }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
47
+ counters: deps.admissionCounters,
48
+ embeds: deps.publicEmbeds,
49
+ // Resolved live rather than snapshotted, so removing a surface from a sponsored cohort lowers
50
+ // its envelope immediately instead of at the next mint.
51
+ resolveBudgetBounds: async () => session.modelSource !== 'noodle-managed'
52
+ ? undefined
53
+ : (await deps.managedModelResolver?.resolve({
54
+ tenant: session.tenant,
55
+ deploymentId: session.deploymentId,
56
+ }))?.publicAdmission,
57
+ now: () => deps.clock?.() ?? new Date(),
58
+ });
59
+ return result.ok
60
+ ? undefined
61
+ : { status: result.status, code: result.code, message: result.message };
62
+ }
2
63
  export async function admitPublicTurn(request, defaults, ports) {
3
64
  // Cheapest first, so an oversized body costs neither a session slot nor surface budget. The public
4
65
  // bound is narrower than the shared request schema's, which stays as it is for authenticated callers.
@@ -64,6 +125,77 @@ export async function admitPublicTurn(request, defaults, ports) {
64
125
  message: 'assistant is unavailable right now',
65
126
  };
66
127
  }
128
+ // Platform spend last, when someone other than the customer is paying. Every counter here is
129
+ // all-or-nothing with no refund, so whichever runs first is charged for turns the ones after it
130
+ // refuse — and a session ending at its length limit is ordinary traffic, many times a day, while
131
+ // spend exhaustion is rare and for one day only. Charging the sponsor for every visitor's last
132
+ // turn is the worse of the two leaks, so this is the counter that goes last.
133
+ //
134
+ // It reports the surface's own exhaustion code on purpose: every published widget already treats
135
+ // `daily_turn_budget_exhausted` as final, and a new code would be read as retryable by all of them
136
+ // — precisely the load this ceiling exists to refuse. At the ladder's closing rung the surface
137
+ // ceiling is already zero, so that refusal arrives above; this is the backstop between rungs.
138
+ if (budgetBounds?.spend !== undefined) {
139
+ const { key, units, allowance } = budgetBounds.spend;
140
+ const spend = await ports.counters.consume({ key, limit: allowance, amount: units }, ports.now());
141
+ if (!spend.allowed) {
142
+ return {
143
+ ok: false,
144
+ status: 429,
145
+ code: 'daily_turn_budget_exhausted',
146
+ message: 'assistant is unavailable right now',
147
+ };
148
+ }
149
+ }
67
150
  return { ok: true, turnCount: session.turnCount };
68
151
  }
152
+ export async function admitBridgeToolCall(request, defaults, ports) {
153
+ try {
154
+ const embed = await ports.embeds.lookup(request.publicEmbedId);
155
+ if (embed === undefined) {
156
+ return {
157
+ ok: false,
158
+ status: 403,
159
+ code: 'embed_not_found',
160
+ message: 'assistant is unavailable right now',
161
+ };
162
+ }
163
+ const budgetBounds = await ports.resolveBudgetBounds?.(embed);
164
+ const envelope = surfaceEnvelope(defaults, embed, budgetBounds);
165
+ // One kill switch, not two. An operator who zeroes a surface's day means all of it, including the
166
+ // tools a page agent can reach without ever running a turn.
167
+ if (isDisabled(envelope))
168
+ return bridgeBudgetExhausted('daily');
169
+ // The session's own allowance first, so a session with nothing left cannot spend the customer's
170
+ // day on a call that is refused a moment later. Mirrors the turn path's ordering.
171
+ const session = await ports.counters.consume({
172
+ key: `bridge:ses:${request.sessionId}`,
173
+ limit: envelope.bridgeToolCallsPerSession,
174
+ }, ports.now());
175
+ if (!session.allowed)
176
+ return bridgeBudgetExhausted('session');
177
+ const surface = await ports.counters.consume({ key: `bridge:${request.publicEmbedId}`, limit: envelope.bridgeToolCallsPerDay }, ports.now());
178
+ if (!surface.allowed)
179
+ return bridgeBudgetExhausted('daily');
180
+ return { ok: true };
181
+ }
182
+ catch {
183
+ // Fail closed. A counter store we cannot reach is a budget we cannot enforce, and an unbounded
184
+ // agent loop against a customer's backend is the exact outcome these caps exist to prevent.
185
+ return {
186
+ ok: false,
187
+ status: 503,
188
+ code: 'bridge_admission_unavailable',
189
+ message: 'assistant is unavailable right now',
190
+ };
191
+ }
192
+ }
193
+ function bridgeBudgetExhausted(scope) {
194
+ return {
195
+ ok: false,
196
+ status: 429,
197
+ code: `${scope}_bridge_budget_exhausted`,
198
+ message: 'assistant is unavailable right now',
199
+ };
200
+ }
69
201
  //# sourceMappingURL=public-turn.js.map
@@ -0,0 +1,27 @@
1
+ import type { RuntimeArtifact } from '@noodle-borg/compiler';
2
+ import type { AssistantSessionRecord } from './assistant-store.js';
3
+ /**
4
+ * The deployment a session may act on, already narrowed to the surface that admitted it.
5
+ *
6
+ * This is the seam ADR 0201's projection rests on. Every session-authenticated route resolves its target
7
+ * through here instead of calling `registry.get` directly, so the dozen `artifact.tools.find(...)` sites
8
+ * downstream get a projected artifact without knowing projection exists. Delete the projection call
9
+ * below and the projection tests fail — one deletion, not a dozen.
10
+ *
11
+ * The surface is read from the session's pinned deployment, never from whatever is active now: projecting
12
+ * artifact N by a surface read from artifact N+1 is a skew bug that would widen or narrow a live session
13
+ * on someone else's deploy. 5.1b's `assertDistinctSurfaces` guarantees at most one public-audience
14
+ * surface and at most one authenticated surface per artifact, so a binding never needs disambiguation.
15
+ *
16
+ * Sessions carry their binding explicitly (`boundSurface`, written at mint). A record from before the
17
+ * binding existed derives it here from the pinned deployment and the session's own origin, so in-flight
18
+ * sessions stay correct across the deploy that introduced binding; only a pre-surfaces artifact still
19
+ * reaches the whole server, because the deployment-wide union is that released shape's entire contract.
20
+ */
21
+ export interface AssistantSessionTarget {
22
+ readonly served: {
23
+ readonly artifact: RuntimeArtifact;
24
+ };
25
+ }
26
+ export declare function resolveAssistantSessionTarget<Target extends AssistantSessionTarget>(load: (deploymentId: string) => Promise<Target | undefined>, session: AssistantSessionRecord): Promise<Target | undefined>;
27
+ //# sourceMappingURL=session-target.d.ts.map
@@ -0,0 +1,50 @@
1
+ import { projectArtifactForSurface } from './artifact-projection.js';
2
+ import { authenticatedSurfaceOf, publicSurfaceOf, surfaceBindingForOrigin, } from './public-surface.js';
3
+ export async function resolveAssistantSessionTarget(load, session) {
4
+ const target = await load(session.deploymentId);
5
+ if (!target)
6
+ return undefined;
7
+ const assistant = target.served.artifact.server.assistant;
8
+ const bound = session.boundSurface ?? deriveLegacyBinding(assistant, session);
9
+ if (bound === 'pre-surfaces')
10
+ return target;
11
+ // Fail closed: an origin no surface owns must never widen to the whole server.
12
+ if (bound === 'unowned')
13
+ return undefined;
14
+ // Fail closed on a vanished surface. A rollback to an artifact without the bound surface must end the
15
+ // session's reach, never hand it the unprojected server.
16
+ if (bound === 'authenticated') {
17
+ const surface = authenticatedSurfaceOf(assistant);
18
+ if (surface === undefined)
19
+ return undefined;
20
+ // An omitted allowlist on an authenticated surface is the authored whole-server intent, so the
21
+ // exact binding still holds (instructions, budgets, attribution) without narrowing capabilities.
22
+ if (surface.capabilities === undefined)
23
+ return target;
24
+ return projected(target, surface.capabilities);
25
+ }
26
+ const surface = publicSurfaceOf(assistant);
27
+ if (surface === undefined)
28
+ return undefined;
29
+ return projected(target, surface.capabilities);
30
+ }
31
+ function projected(target, capabilities) {
32
+ return {
33
+ ...target,
34
+ served: {
35
+ ...target.served,
36
+ artifact: projectArtifactForSurface(target.served.artifact, capabilities),
37
+ },
38
+ };
39
+ }
40
+ /**
41
+ * A record minted before `boundSurface` existed: the embed id marks the public surface; otherwise the
42
+ * session's origin selects the owning surface on the pinned deployment. `pre-surfaces` means the whole
43
+ * server on that released artifact shape; `unowned` fails closed above.
44
+ */
45
+ function deriveLegacyBinding(assistant, session) {
46
+ if (session.publicEmbedId !== undefined)
47
+ return 'public';
48
+ return surfaceBindingForOrigin(assistant, session.origin).kind;
49
+ }
50
+ //# sourceMappingURL=session-target.js.map
@@ -4,6 +4,20 @@ import type { PublicEmbedRecord } from './embed-store.js';
4
4
  export interface SurfaceBudgetBounds {
5
5
  readonly defaults: Pick<AdmissionEnvelope, 'turnsPerSession' | 'turnsPerDay' | 'mintsPerDay'>;
6
6
  readonly ceiling: Pick<AdmissionEnvelope, 'turnsPerDay' | 'mintsPerDay'>;
7
+ /**
8
+ * Platform spend accounting for this turn, present only when someone other than the customer is
9
+ * paying. Absent means customer-funded, and nothing is charged.
10
+ *
11
+ * `units` is what this turn costs at the tenant's current rung and `allowance` is its ceiling for
12
+ * the day; the ladder that produces both lives in `managed-spend.ts`. The ceilings above remain the
13
+ * primary mechanism — this counter is the exact backstop for the window in which a cached rung
14
+ * read still says "plenty left".
15
+ */
16
+ readonly spend?: {
17
+ readonly key: string;
18
+ readonly units: number;
19
+ readonly allowance: number;
20
+ };
7
21
  }
8
22
  /**
9
23
  * One surface's daily budget, resolved from the operator's override and the deployment's defaults.
@@ -17,5 +31,5 @@ export interface SurfaceBudgetBounds {
17
31
  * operator any more than by app code, so an over-eager `--turns-per-day 999999999` degrades to the
18
32
  * ceiling instead of becoming the ceiling. Zero survives clamping and is the kill switch.
19
33
  */
20
- export declare function surfaceEnvelope(base: AdmissionEnvelope, embed: Pick<PublicEmbedRecord, 'turnsPerDay' | 'mintsPerDay'>, bounds?: SurfaceBudgetBounds): AdmissionEnvelope;
34
+ export declare function surfaceEnvelope(base: AdmissionEnvelope, embed: Pick<PublicEmbedRecord, 'turnsPerDay' | 'mintsPerDay' | 'mintsPerAddressHour' | 'turnsPerAddressHour'>, bounds?: SurfaceBudgetBounds): AdmissionEnvelope;
21
35
  //# sourceMappingURL=surface-budget.d.ts.map
@@ -17,6 +17,20 @@ export function surfaceEnvelope(base, embed, bounds) {
17
17
  ...effectiveBase,
18
18
  ...(embed.turnsPerDay !== undefined ? { turnsPerDay: embed.turnsPerDay } : {}),
19
19
  ...(embed.mintsPerDay !== undefined ? { mintsPerDay: embed.mintsPerDay } : {}),
20
+ // The per-address bounds carry no hosted ceiling, because they govern abuse rather than spend —
21
+ // so the deployed default is what caps them, and an operator may only lower it. A surface that
22
+ // could raise its own abuse bound toward the structural maximum would be turning the bound off
23
+ // for everyone behind one address, which is not a budget an operator gets to spend.
24
+ ...(embed.mintsPerAddressHour !== undefined
25
+ ? {
26
+ mintsPerAddressHour: Math.min(embed.mintsPerAddressHour, effectiveBase.mintsPerAddressHour),
27
+ }
28
+ : {}),
29
+ ...(embed.turnsPerAddressHour !== undefined
30
+ ? {
31
+ turnsPerAddressHour: Math.min(embed.turnsPerAddressHour, effectiveBase.turnsPerAddressHour),
32
+ }
33
+ : {}),
20
34
  });
21
35
  if (bounds === undefined)
22
36
  return requested;
@@ -41,7 +41,7 @@
41
41
  "@noodle-borg/module": "0.0.0",
42
42
  "@noodle-borg/protocol": "0.0.0",
43
43
  "@noodle-borg/runtime": "0.0.0",
44
- "@noodleseed/assistant": "1.28.0",
44
+ "@noodleseed/assistant": "1.29.0",
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
@@ -102,6 +102,7 @@ export interface AssistantUiOptions {
102
102
  };
103
103
  readonly labels?: AssistantLabels;
104
104
  readonly presentation?: AssistantPresentationOptions;
105
+ /** Exact initial prompts only; omit to generate context-aware initial prompts, or pass [] for none. */
105
106
  readonly suggestedPrompts?: readonly string[];
106
107
  readonly privacyUrl?: string;
107
108
  readonly termsUrl?: string;
@@ -207,6 +208,17 @@ export interface EmbeddedAssistantOptions extends AssistantUiOptions {
207
208
  readonly model: AssistantModel;
208
209
  /** One surface, or every front door this assistant serves. */
209
210
  readonly access: AssistantAccess | readonly AssistantAccess[];
211
+ /**
212
+ * Let a browser agent (Gemini-in-Chrome, Claude-in-Chrome) call this assistant's tools through the
213
+ * page's WebMCP API. Off unless set. Calls carry exactly the embed session's authority and take the
214
+ * same authorization, limits, confirmation, and audit path as the assistant's own — see ADR 0220.
215
+ *
216
+ * Deployment-wide on purpose: it decides which callers may reach the server's tools, which is
217
+ * surface authority rather than presentation, so it is not refinable per surface.
218
+ */
219
+ readonly webmcp?: {
220
+ readonly enabled?: boolean;
221
+ };
210
222
  }
211
223
  /** One projected surface as it appears in compiled data. */
212
224
  export interface AssistantSurfaceConfig {
@@ -226,6 +238,10 @@ export interface EmbeddedAssistantConfig extends AssistantUiOptions {
226
238
  readonly allowedOrigins: readonly string[];
227
239
  /** Mirrored from the authenticated surface, for the same reason. */
228
240
  readonly sessionClaims?: Readonly<Record<string, SessionClaimDeclaration>>;
241
+ /** @see EmbeddedAssistantOptions.webmcp — deployment-wide, never per surface. */
242
+ readonly webmcp?: {
243
+ readonly enabled?: boolean;
244
+ };
229
245
  }
230
246
  export declare function openAICompatible(input: OpenAICompatibleModelInput): OpenAICompatibleModel;
231
247
  /** Use Noodle Seed Cloud's operator-selected model. Provider details never enter authored source. */
@@ -96,7 +96,7 @@ const EMBEDS_SUBCOMMAND = subcommand({
96
96
  const BUDGET_SUBCOMMAND = subcommand({
97
97
  ...SUBCOMMAND_FIELDS,
98
98
  name: 'budget',
99
- summary: 'Set what a public website surface may spend per day. Zero is the kill switch.',
99
+ summary: 'Set what a public website surface may spend, per day and per source address.',
100
100
  subcommands: [
101
101
  clientLeaf('set', 'Raise, lower, or switch off a public surface’s daily caps.', {
102
102
  ...LEAF_FIELDS,
@@ -115,6 +115,20 @@ const BUDGET_SUBCOMMAND = subcommand({
115
115
  value: '<count>',
116
116
  summary: 'Sessions this surface may open per UTC day. 0 stops it serving anyone.',
117
117
  }),
118
+ flag({
119
+ ...FLAG_FIELDS,
120
+ name: 'mints-per-address-hour',
121
+ type: 'string',
122
+ value: '<count>',
123
+ summary: 'Sessions one source address may open per hour. The abuse bound, not fairness.',
124
+ }),
125
+ flag({
126
+ ...FLAG_FIELDS,
127
+ name: 'turns-per-address-hour',
128
+ type: 'string',
129
+ value: '<count>',
130
+ summary: 'Model turns one source address may run per hour. The abuse bound, not fairness.',
131
+ }),
118
132
  flag({
119
133
  ...FLAG_FIELDS,
120
134
  name: 'surface',
@@ -381,6 +381,9 @@ export declare const manifestV1Schema: z.ZodObject<{
381
381
  sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
382
382
  exposeToModel: z.ZodOptional<z.ZodBoolean>;
383
383
  }, z.core.$strict>>>;
384
+ webmcp: z.ZodOptional<z.ZodObject<{
385
+ enabled: z.ZodOptional<z.ZodBoolean>;
386
+ }, z.core.$strict>>;
384
387
  }, z.core.$strict>>;
385
388
  branding: z.ZodOptional<z.ZodObject<{
386
389
  name: z.ZodOptional<z.ZodString>;
@@ -857,6 +860,9 @@ export declare const manifestV2Schema: z.ZodObject<{
857
860
  sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
858
861
  exposeToModel: z.ZodOptional<z.ZodBoolean>;
859
862
  }, z.core.$strict>>>;
863
+ webmcp: z.ZodOptional<z.ZodObject<{
864
+ enabled: z.ZodOptional<z.ZodBoolean>;
865
+ }, z.core.$strict>>;
860
866
  }, z.core.$strict>>;
861
867
  branding: z.ZodOptional<z.ZodObject<{
862
868
  name: z.ZodOptional<z.ZodString>;
@@ -1543,6 +1549,9 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1543
1549
  sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
1544
1550
  exposeToModel: z.ZodOptional<z.ZodBoolean>;
1545
1551
  }, z.core.$strict>>>;
1552
+ webmcp: z.ZodOptional<z.ZodObject<{
1553
+ enabled: z.ZodOptional<z.ZodBoolean>;
1554
+ }, z.core.$strict>>;
1546
1555
  }, z.core.$strict>>;
1547
1556
  branding: z.ZodOptional<z.ZodObject<{
1548
1557
  name: z.ZodOptional<z.ZodString>;
@@ -2018,6 +2027,9 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
2018
2027
  sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
2019
2028
  exposeToModel: z.ZodOptional<z.ZodBoolean>;
2020
2029
  }, z.core.$strict>>>;
2030
+ webmcp: z.ZodOptional<z.ZodObject<{
2031
+ enabled: z.ZodOptional<z.ZodBoolean>;
2032
+ }, z.core.$strict>>;
2021
2033
  }, z.core.$strict>>;
2022
2034
  branding: z.ZodOptional<z.ZodObject<{
2023
2035
  name: z.ZodOptional<z.ZodString>;
@@ -359,6 +359,11 @@ const embeddedAssistantSchema = z
359
359
  // Mirrored from the authenticated surface; the session exchange reads it here (ADR 0141,
360
360
  // 2026-07-14). Keys become `${user.claims.<key>}`; `exposeToModel` adds it to the identity line.
361
361
  sessionClaims: sessionClaimsSchema.optional(),
362
+ // Additive optional field: a v1.x minor under ADR 0150, so no existing manifest can observe it.
363
+ // Declared here rather than in `assistantUiSchema` deliberately — that shape is also spread into
364
+ // every surface, and which callers may reach the server's tools is surface authority, not
365
+ // renderer-owned presentation a surface may refine.
366
+ webmcp: optionalStrictObject({ enabled: z.boolean().optional() }),
362
367
  ...assistantUiSchema.shape,
363
368
  })
364
369
  .strict()