akanjs 3.0.0-alpha.50 → 3.0.0-alpha.51

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 (49) hide show
  1. package/constant/fieldInfo.ts +32 -0
  2. package/constant/mask.ts +11 -4
  3. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  4. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  5. package/package.json +1 -1
  6. package/server/akanServer.ts +13 -0
  7. package/server/mcp/McpDispatcher.ts +42 -2
  8. package/server/mcp/McpRouter.ts +2 -0
  9. package/signal/schema/JsonSchemaBuilder.ts +10 -6
  10. package/store/agent/ScreenReader.ts +36 -6
  11. package/store/agent/ScreenTarget.ts +13 -7
  12. package/store/agent/StoreSurfaceSource.ts +1 -1
  13. package/types/constant/fieldInfo.d.ts +10 -0
  14. package/types/constant/mask.d.ts +10 -3
  15. package/types/server/akanServer.d.ts +7 -0
  16. package/types/server/mcp/McpDispatcher.d.ts +7 -0
  17. package/types/server/mcp/McpRouter.d.ts +2 -0
  18. package/types/signal/schema/JsonSchemaBuilder.d.ts +9 -5
  19. package/types/store/agent/ScreenReader.d.ts +5 -0
  20. package/types/store/agent/ScreenTarget.d.ts +5 -4
  21. package/types/ui/Agent/Approval.d.ts +4 -3
  22. package/types/ui/Agent/Bubble.d.ts +5 -2
  23. package/types/ui/Agent/Chat.d.ts +17 -1
  24. package/types/ui/Agent/Composer.d.ts +6 -6
  25. package/types/ui/Agent/Launcher.d.ts +3 -3
  26. package/types/ui/Agent/Markdown.d.ts +11 -3
  27. package/types/ui/Agent/Menu.d.ts +4 -3
  28. package/types/ui/Agent/Question.d.ts +4 -3
  29. package/types/ui/Agent/Skip.d.ts +22 -0
  30. package/types/ui/Agent/index.d.ts +1 -0
  31. package/types/ui/Agent/markdownBlocks.d.ts +1 -0
  32. package/types/ui/Agent/useKeyboardInset.d.ts +8 -0
  33. package/types/ui/UiOverride/context.d.ts +15 -0
  34. package/types/ui/index.d.ts +13 -0
  35. package/ui/Agent/Approval.tsx +6 -3
  36. package/ui/Agent/Bubble.tsx +7 -2
  37. package/ui/Agent/Chat.tsx +73 -27
  38. package/ui/Agent/Composer.tsx +27 -10
  39. package/ui/Agent/Launcher.tsx +5 -2
  40. package/ui/Agent/Markdown.tsx +32 -15
  41. package/ui/Agent/Menu.tsx +6 -3
  42. package/ui/Agent/Question.tsx +6 -3
  43. package/ui/Agent/Skip.tsx +30 -0
  44. package/ui/Agent/index.ts +14 -1
  45. package/ui/Agent/markdownBlocks.ts +7 -5
  46. package/ui/Agent/useKeyboardInset.ts +26 -0
  47. package/ui/UiOverride/context.ts +16 -0
  48. package/ui/index.ts +15 -0
  49. package/vendor/use-agentic/AgentSession.ts +3 -2
@@ -106,6 +106,13 @@ export interface ConstantFieldProps<
106
106
  validate?: (value: FieldValue, model: any) => boolean;
107
107
  text?: TextFieldRole;
108
108
  cascade?: CascadeAction;
109
+ /**
110
+ * Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
111
+ * in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
112
+ * placeholder still ships to `<Image>`. Unlike `hidden`/`secret` this is about cost, not secrecy: a field
113
+ * nothing can answer a question with is pure spend on every turn it rides.
114
+ */
115
+ visual?: boolean;
109
116
  meta?: Metadata;
110
117
  }
111
118
  export const fieldPresets = ["email", "password", "url"] as const;
@@ -197,6 +204,7 @@ interface ConstantFieldBuildProps<
197
204
  validate?: (value: FieldValue, model: any) => boolean;
198
205
  text?: TextFieldRole;
199
206
  cascade?: CascadeAction;
207
+ visual: boolean;
200
208
  modelRef: ConstantModelRef;
201
209
  arrDepth: number;
202
210
  optArrDepth: number;
@@ -321,6 +329,7 @@ export class ConstantField<
321
329
  readonly validate?: (value: FieldValue, model: any) => boolean;
322
330
  readonly text?: TextFieldRole;
323
331
  readonly cascade?: CascadeAction;
332
+ readonly visual: boolean;
324
333
  readonly modelRef: ConstantModelRef;
325
334
  readonly arrDepth: number;
326
335
  readonly optArrDepth: number;
@@ -353,6 +362,7 @@ export class ConstantField<
353
362
  this.validate = props.validate;
354
363
  this.text = props.text;
355
364
  this.cascade = props.cascade;
365
+ this.visual = props.visual;
356
366
  this.modelRef = props.modelRef;
357
367
  this.arrDepth = props.arrDepth;
358
368
  this.optArrDepth = props.optArrDepth;
@@ -432,6 +442,7 @@ export class ConstantField<
432
442
  validate: option.validate,
433
443
  text: option.text,
434
444
  cascade: option.cascade,
445
+ visual: option.visual ?? false,
435
446
  modelRef,
436
447
  arrDepth: arrDepth,
437
448
  optArrDepth: optArrDepth,
@@ -478,6 +489,7 @@ export class ConstantField<
478
489
  validate: this.validate,
479
490
  text: this.text,
480
491
  cascade: this.cascade,
492
+ visual: this.visual,
481
493
  modelRef: this.modelRef,
482
494
  arrDepth: this.arrDepth,
483
495
  optArrDepth: this.optArrDepth,
@@ -530,6 +542,26 @@ export const field = <
530
542
  fieldType: "property",
531
543
  });
532
544
 
545
+ /**
546
+ * A stored property the page renders and an agent never sees — `field(value, { visual: true })`, spelled short
547
+ * because the reason to reach for it is always the same one. A blur placeholder, a rendered HTML body, a
548
+ * serialized geometry: real data the screen needs, and hundreds of tokens per record that no question is answered
549
+ * from. It stays a plain `property` everywhere else, so persistence, search, forms and the page response are
550
+ * untouched.
551
+ */
552
+ field.visual = <
553
+ ExplicitType,
554
+ Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>,
555
+ MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never,
556
+ >(
557
+ value: Value,
558
+ option: FieldOption<Value, MapValue> = {},
559
+ ) =>
560
+ new FieldInfo<"property", Value, ExplicitType, MapValue>(value, {
561
+ ...option,
562
+ fieldType: "property",
563
+ visual: true,
564
+ });
533
565
  field.hidden = <
534
566
  ExplicitType,
535
567
  Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>,
package/constant/mask.ts CHANGED
@@ -15,6 +15,7 @@ interface MaskField {
15
15
  fieldType?: string;
16
16
  isClass?: boolean;
17
17
  modelRef?: MaskModel;
18
+ visual?: boolean;
18
19
  }
19
20
 
20
21
  export const maskFieldsOf = (model: MaskModel): Record<string, MaskField> | null => {
@@ -22,7 +23,12 @@ export const maskFieldsOf = (model: MaskModel): Record<string, MaskField> | null
22
23
  return fields && typeof fields === "object" ? (fields as Record<string, MaskField>) : null;
23
24
  };
24
25
 
25
- /** The `hidden` and `secret` field names of `model` that `value` still carries populated. */
26
+ /**
27
+ * The `hidden` and `secret` field names of `model` that `value` still carries populated.
28
+ *
29
+ * `visual` is deliberately not among them. A refusal here means a value must not be published at all, and a blur
30
+ * placeholder is not a secret — it is merely not worth its tokens, which masking answers by dropping it.
31
+ */
26
32
  export const leakingFieldsOf = (model: MaskModel, value: Record<string, unknown>): string[] => {
27
33
  const fields = maskFieldsOf(model);
28
34
  if (!fields) return [];
@@ -32,8 +38,9 @@ export const leakingFieldsOf = (model: MaskModel, value: Record<string, unknown>
32
38
  };
33
39
 
34
40
  /**
35
- * Strips what a model marks `hidden` or `secret`, by the model the caller names rather than by the one the value
36
- * happens to still carry.
41
+ * Strips what a model marks `hidden`, `secret`, or `visual`, by the model the caller names rather than by the one
42
+ * the value happens to still carry. The first two are secrecy and the third is cost, but the answer is the same
43
+ * one — leave the field out — and this is the only place every AI-facing read already passes through.
37
44
  *
38
45
  * That distinction is the whole point. A check that reads the class off the value can only mask what arrives as an
39
46
  * instance, so a `{ ...doc }` spread, a `toJSON()`, an `immerify()`, or a round-trip through `JSON.stringify` reaches
@@ -53,7 +60,7 @@ export const mask = (model: MaskModel, value: unknown): unknown => {
53
60
  const source = value as Record<string, unknown>;
54
61
  const masked: Record<string, unknown> = {};
55
62
  for (const [key, field] of Object.entries(fields)) {
56
- if (field.fieldType === "hidden" || field.fieldType === "secret" || !(key in source)) continue;
63
+ if (field.fieldType === "hidden" || field.fieldType === "secret" || field.visual || !(key in source)) continue;
57
64
  masked[key] = field.isClass && field.modelRef ? mask(field.modelRef, source[key]) : source[key];
58
65
  }
59
66
  return masked;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.50",
3
+ "version": "3.0.0-alpha.51",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -80,6 +80,13 @@ export interface McpServerOption {
80
80
  * the document is built once at boot and cached by clients, and it is read by a model rather than by a person.
81
81
  */
82
82
  language?: string;
83
+ /**
84
+ * Whether a structured result also ships as serialized JSON in the text block, `true` by default because that
85
+ * is what the spec asks of a server for clients that predate `structuredContent`. It is also a flat doubling:
86
+ * every model-returning tool sends its whole payload twice, so a deployment whose clients read the structured
87
+ * half turns this off and halves what each of those calls costs the model. `AKAN_MCP_LEGACY_TEXT=false`.
88
+ */
89
+ legacyTextBlock?: boolean;
83
90
  /** OAuth resource-server identity: which issuers a client may authenticate with, and the scopes to demand. */
84
91
  auth?: McpAuthOption;
85
92
  }
@@ -622,6 +629,11 @@ export class AkanServer {
622
629
  return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
623
630
  }
624
631
 
632
+ /** Named rather than defaulted: an absent env must leave the option unset so a value written in code still wins. */
633
+ static #isEnvOff(...names: string[]) {
634
+ return names.some((name) => process.env[name] === "false" || process.env[name] === "0");
635
+ }
636
+
625
637
  /** Both differ per environment, so they belong in env rather than in the app's source alongside the switch. */
626
638
  static #mcpAuthFromEnv(): McpAuthOption {
627
639
  const authorizationServers = AkanServer.#envList("AKAN_MCP_AUTH_SERVERS");
@@ -657,6 +669,7 @@ export class AkanServer {
657
669
  ...(allowedOrigins?.length ? { allowedOrigins } : {}),
658
670
  ...(Number.isInteger(pageSize) && pageSize > 0 ? { pageSize } : {}),
659
671
  ...(process.env.AKAN_MCP_LANGUAGE ? { language: process.env.AKAN_MCP_LANGUAGE } : {}),
672
+ ...(AkanServer.#isEnvOff("AKAN_MCP_LEGACY_TEXT") ? { legacyTextBlock: false } : {}),
660
673
  };
661
674
  }
662
675
 
@@ -1,5 +1,6 @@
1
1
  import { type BackendEnv, ENDPOINT_META } from "akanjs/base";
2
2
  import { Logger } from "akanjs/common";
3
+ import { ConstantRegistry, mask } from "akanjs/constant";
3
4
  import { DictionaryLookup } from "akanjs/dictionary";
4
5
  import { NoDocumentError } from "akanjs/document";
5
6
  import type { InjectRegistry, LiveRegistry } from "akanjs/service";
@@ -42,12 +43,20 @@ interface McpDispatcherProps {
42
43
  middleware: Map<string, MiddlewareCls>;
43
44
  /** The one language error text is resolved in, matching the catalogue the client was handed. */
44
45
  language?: string;
46
+ /**
47
+ * Whether a structured result also ships as serialized JSON in the text block. `true` by default, which is what
48
+ * the spec asks for; `false` halves what every model-returning tool costs.
49
+ */
50
+ legacyTextBlock?: boolean;
45
51
  }
46
52
 
47
53
  /** Executes one MCP tool call or resource read through the ordinary signal pipeline. */
48
54
  export class McpDispatcher {
49
55
  static readonly logger = new Logger("McpDispatcher");
50
56
 
57
+ /** What the text block says when the serialized duplicate is off. Read by a model, so English. */
58
+ static readonly structuredNote = "The result is in this call's structuredContent.";
59
+
51
60
  readonly #props: McpDispatcherProps;
52
61
  #endpoints: Map<string, { endpointInfo: EndpointInfo; endpoint: Endpoint }> | null = null;
53
62
  /**
@@ -68,10 +77,10 @@ export class McpDispatcher {
68
77
  const found = this.#index().get(exposed.key);
69
78
  if (!found) return McpDispatcher.#failure(`Tool "${exposed.key}" is declared but not mounted on this server.`);
70
79
  try {
71
- const value = await this.#exec(exposed.key, found, args, req);
80
+ const value = McpDispatcher.#readable(exposed, await this.#exec(exposed.key, found, args, req));
72
81
  const structuredContent = McpDocument.structuredContent(exposed.endpoint, value);
73
82
  return {
74
- content: [{ type: "text", text: McpDispatcher.#text(structuredContent, value) }],
83
+ content: this.#content(structuredContent, value),
75
84
  ...(structuredContent === undefined ? {} : { structuredContent }),
76
85
  isError: false,
77
86
  };
@@ -223,6 +232,19 @@ export class McpDispatcher {
223
232
  return status && status < 500 ? McpErrorCode.invalidParams : McpErrorCode.internal;
224
233
  }
225
234
 
235
+ /**
236
+ * A structured result rides twice by default: once as `structuredContent`, once as the same JSON here, which is
237
+ * what the spec asks of a server for clients that predate the structured field. Every model return therefore
238
+ * costs the model twice what it carries, and `legacyTextBlock: false` is the deployment that has decided its
239
+ * clients read the structured half — the pointer keeps `content` non-empty, since a client that renders
240
+ * `content[0].text` and finds nothing shows an empty answer rather than a missing one.
241
+ */
242
+ #content(structuredContent: unknown, value: unknown): McpToolResult["content"] {
243
+ if (structuredContent !== undefined && this.#props.legacyTextBlock === false)
244
+ return [{ type: "text", text: McpDispatcher.structuredNote }];
245
+ return [{ type: "text", text: McpDispatcher.#text(structuredContent, value) }];
246
+ }
247
+
226
248
  /**
227
249
  * The text block every result carries, whether or not a structured one goes with it.
228
250
  *
@@ -239,6 +261,24 @@ export class McpDispatcher {
239
261
  return JSON.stringify(structuredContent ?? value) ?? "null";
240
262
  }
241
263
 
264
+ /**
265
+ * Strips what the return model marks `visual` — a field the page renders and no question is answered from.
266
+ *
267
+ * Done here rather than in `resolveReturn`, which every ordinary HTTP response also passes through: the point of
268
+ * a `visual` field is that a browser still receives it. MCP results reach an agent and nothing else, so this is
269
+ * where the model's own declaration is honoured, and it is the same `mask` the in-page agent's reads use.
270
+ */
271
+ static #readable(exposed: McpExposedEndpoint, value: unknown): unknown {
272
+ const { refName, modelType } = exposed.endpoint.returns;
273
+ if (!modelType) return value;
274
+ try {
275
+ return mask(ConstantRegistry.getModelRef(refName, modelType), value);
276
+ } catch {
277
+
278
+ return value;
279
+ }
280
+ }
281
+
242
282
  static #failure(message: string): McpToolResult {
243
283
  return { content: [{ type: "text", text: message }], isError: true };
244
284
  }
@@ -45,6 +45,8 @@ export interface McpRouterProps {
45
45
  * rarely sees. Defaults to `en`, falling back to the first registered language when the app has no `en`.
46
46
  */
47
47
  language?: string;
48
+ /** Whether a structured result also ships as serialized JSON in the text block. Default `true`. */
49
+ legacyTextBlock?: boolean;
48
50
  auth?: McpAuthOption;
49
51
  }
50
52
 
@@ -14,11 +14,15 @@ export interface JsonSchemaBuilderOptions {
14
14
 
15
15
  export interface JsonSchemaModelOptions {
16
16
  /**
17
- * Drops `hidden` and `secret` fields. `SignalContext.resolveReturn` strips both from every response, so naming
18
- * them describes a value the caller can never read — and on a model like `user` the names are themselves the
19
- * leak: `password`, `accountId`, `phone` published as readable properties of the model. This is the one place a
20
- * field the framework blocks on every value path is still visible, so it is scoped to schemas that describe a
21
- * *response*. A request body is a different shape and legitimately carries both.
17
+ * Drops `hidden`, `secret`, and `visual` fields. `SignalContext.resolveReturn` strips the first two from every
18
+ * response, so naming them describes a value the caller can never read — and on a model like `user` the names
19
+ * are themselves the leak: `password`, `accountId`, `phone` published as readable properties of the model. This
20
+ * is the one place a field the framework blocks on every value path is still visible, so it is scoped to schemas
21
+ * that describe a *response*. A request body is a different shape and legitimately carries all three.
22
+ *
23
+ * `visual` is dropped here because it is dropped from the payload an AI caller receives, and a schema that
24
+ * promises a field the value omits is worse than one that never named it: a non-optional visual field would be
25
+ * listed `required` and a validating client would refuse the whole result.
22
26
  */
23
27
  readable?: boolean;
24
28
  }
@@ -56,7 +60,7 @@ export class JsonSchemaBuilder {
56
60
  const required: string[] = [];
57
61
  for (const [key, field] of Object.entries(fields)) {
58
62
  const props = field.getProps();
59
- if (readable && (props.fieldType === "hidden" || props.fieldType === "secret")) continue;
63
+ if (readable && (props.fieldType === "hidden" || props.fieldType === "secret" || props.visual)) continue;
60
64
  properties[key] = this.#field(field);
61
65
  if (!props.nullable) required.push(key);
62
66
  }
@@ -51,6 +51,11 @@ const blockTags = new Set([
51
51
  * agent's own UI is marked `data-agent-ui` and skipped, so a turn never re-reads its own transcript, and a
52
52
  * password value is never read — the screen shows dots, so the DOM holds more than the user sees.
53
53
  *
54
+ * A region the app marks `data-agent-skip` — what `Agent.Skip` renders — costs a `[skipped: <name>]` line instead
55
+ * of its text. It stands in the output rather than vanishing because a deleted region reads as an absent one, and
56
+ * an agent asked about a footer it never saw answers that the page has none. Naming the region as `section` reads
57
+ * it: the marker is what the default read leaves out, not a wall.
58
+ *
54
59
  * A heading carries `(#anchor)` whenever it opens a container that has an id or a scope path, because that is the
55
60
  * name `readScreen({ section })` and `highlight` take: printing the text without the name leaves an agent
56
61
  * guessing at a slug. For the same reason a truncated read ends with the headings below the cut instead of only
@@ -64,7 +69,7 @@ export class ScreenReader {
64
69
  const reader = new ScreenReader();
65
70
  const title = document.title.trim();
66
71
  if (title) reader.#lines.push(`Page: ${title}`);
67
- reader.#walk(root ?? document.body);
72
+ reader.#walk(root ?? document.body, true);
68
73
  reader.#flush();
69
74
  return reader.#text() || "The page is rendering nothing readable.";
70
75
  }
@@ -78,10 +83,10 @@ export class ScreenReader {
78
83
  if (typeof document === "undefined") return "No rendered document is available.";
79
84
  const reader = new ScreenReader();
80
85
  const container = ScreenReader.#sectionOf(heading, root);
81
- if (container) reader.#walk(container);
86
+ if (container) reader.#walk(container, true);
82
87
  else {
83
88
  const level = headingLevels[heading.tagName.toUpperCase() as keyof typeof headingLevels] ?? 1;
84
- reader.#walk(heading);
89
+ reader.#walk(heading, true);
85
90
  let next = heading.nextElementSibling;
86
91
  while (next && !ScreenReader.#stops(next, level)) {
87
92
  reader.#walk(next);
@@ -123,6 +128,17 @@ export class ScreenReader {
123
128
  return "";
124
129
  }
125
130
 
131
+ /**
132
+ * `checkVisibility()` reports whether the element has a layout box, and a `display: contents` wrapper has none
133
+ * while its children do — so Chrome answers false for a wrapper the user is looking straight at, and skipping it
134
+ * would drop that whole subtree (happy-dom answers true, so no DOM test sees the difference). Reading through it
135
+ * is safe only because the walk is top-down: a `display: none` ancestor bails on its own computed display first.
136
+ */
137
+ static #rendered(el: HTMLElement) {
138
+ if (typeof el.checkVisibility !== "function" || el.checkVisibility()) return true;
139
+ return getComputedStyle(el).display === "contents";
140
+ }
141
+
126
142
  static #stops(el: Element, level: number) {
127
143
  const found = headingLevels[el.tagName.toUpperCase() as keyof typeof headingLevels];
128
144
  return !!found && found <= level;
@@ -141,7 +157,7 @@ export class ScreenReader {
141
157
  if (node.nodeType !== Node.ELEMENT_NODE) return;
142
158
  const el = node as HTMLElement;
143
159
  const tag = el.tagName.toUpperCase();
144
- if (skipTags.has(tag) || el.hasAttribute("data-agent-ui")) return;
160
+ if (skipTags.has(tag) || el.hasAttribute("data-agent-ui") || el.hasAttribute("data-agent-skip")) return;
145
161
  const level = headingLevels[tag as keyof typeof headingLevels];
146
162
  if (level) {
147
163
  const anchor = ScreenReader.anchorOf(el);
@@ -163,7 +179,15 @@ export class ScreenReader {
163
179
  : `${kept}${note}`;
164
180
  }
165
181
 
166
- #walk(node: Node): void {
182
+ /** What stands where a skipped region was: its own name, and the anchor `section` takes to read it on request. */
183
+ #mark(el: HTMLElement, label: string) {
184
+ const anchor = el.getAttribute("data-agent-scope") ?? el.getAttribute("data-agent-zone") ?? el.id;
185
+ this.#flush();
186
+ this.#buffer = `[skipped: ${label || el.tagName.toLowerCase()}${anchor ? ` (#${anchor})` : ""}]`;
187
+ this.#flush();
188
+ }
189
+
190
+ #walk(node: Node, isRoot = false): void {
167
191
  if (this.#length > ScreenReader.limit * 2) {
168
192
  this.#outline(node);
169
193
  return;
@@ -179,7 +203,13 @@ export class ScreenReader {
179
203
  if (skipTags.has(tag)) return;
180
204
  if (el.hasAttribute("data-agent-ui") || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true")
181
205
  return;
182
- if (typeof el.checkVisibility === "function" && !el.checkVisibility()) return;
206
+ if (!ScreenReader.#rendered(el)) return;
207
+
208
+ const skipped = isRoot ? null : el.getAttribute("data-agent-skip");
209
+ if (skipped !== null) {
210
+ this.#mark(el, skipped);
211
+ return;
212
+ }
183
213
  if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
184
214
  this.#control(el, tag);
185
215
  return;
@@ -1,6 +1,6 @@
1
1
  import { ScreenReader } from "./ScreenReader";
2
2
 
3
- const containerAttrs = ["data-agent-zone", "data-agent-scope"] as const;
3
+ const containerAttrs = ["data-agent-zone", "data-agent-scope", "data-agent-skip"] as const;
4
4
  const controlAttrs = ["data-akan-action", "data-akan-state"] as const;
5
5
  const headingSelector = "h1, h2, h3, h4, h5, h6";
6
6
  const nameCap = 40;
@@ -9,9 +9,9 @@ const nameCap = 40;
9
9
  * Resolves a name the agent read on screen to the element it names.
10
10
  *
11
11
  * Four vocabularies, every one of them something already on the screen rather than a selector the model invented:
12
- * the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it, an
13
- * `Agent.Zone` or `useScreenScope` container, a plain element id — what a docs slide is addressed by — and, last, a
14
- * **heading by its own text**.
12
+ * the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it, a
13
+ * container — an `Agent.Zone`, a `useScreenScope` scope, or an `Agent.Skip` region named by the marker left in its
14
+ * place — a plain element id, what a docs slide is addressed by, and, last, a **heading by its own text**.
15
15
  *
16
16
  * A heading is matched on letters and digits alone, so the slug an agent naturally writes for a heading it read
17
17
  * ("images-and-public-env") finds "Images And Public Env". That tolerance is for headings only: a heading is a
@@ -20,7 +20,8 @@ const nameCap = 40;
20
20
  *
21
21
  * Nothing hidden ever resolves, by the same rule `readScreen` skips it. A collapsed panel or an off-variant
22
22
  * duplicate is not what the user is looking at, and scrolling to one flashes a ring nobody can see — which reads
23
- * as the tool being broken rather than as a miss.
23
+ * as the tool being broken rather than as a miss. A `display: contents` wrapper does not resolve either, even
24
+ * though the reader now reads through it: it has no box, so `scrollIntoView` has nothing to scroll to.
24
25
  */
25
26
  export class ScreenTarget {
26
27
  static container(name: string, root?: HTMLElement | null): HTMLElement | null {
@@ -28,8 +29,13 @@ export class ScreenTarget {
28
29
  if (!scope || !name) return null;
29
30
  if (ScreenTarget.#named(scope, containerAttrs, name)) return scope;
30
31
  const escaped = CSS.escape(name);
31
- const selector = [...containerAttrs.map((attr) => `[${attr}="${escaped}"]`), `#${escaped}`].join(", ");
32
- return ScreenTarget.#first(scope, selector);
32
+ const selector = containerAttrs.map((attr) => `[${attr}="${escaped}"]`).join(", ");
33
+
34
+ return (
35
+ ScreenTarget.#first(scope, selector) ??
36
+ [...scope.querySelectorAll<HTMLElement>("[id]")].find((el) => el.id === name && ScreenTarget.#visible(el)) ??
37
+ null
38
+ );
33
39
  }
34
40
 
35
41
  static control(name: string, root?: HTMLElement | null): HTMLElement | null {
@@ -107,7 +107,7 @@ export class StoreSurfaceSource implements SurfaceSource {
107
107
  section: {
108
108
  type: "string",
109
109
  description:
110
- "One region of the screen: a heading's anchor as readScreen prints it, the heading's own text, or a scope path from the screen context. Omit to read all of it.",
110
+ "One region of the screen: a heading's anchor as readScreen prints it, the heading's own text, a scope path from the screen context, or the name in a `[skipped: name]` marker. Omit to read all of it.",
111
111
  },
112
112
  },
113
113
  additionalProperties: false,
@@ -47,6 +47,13 @@ export interface ConstantFieldProps<FieldType extends ConstantFieldKind = Consta
47
47
  validate?: (value: FieldValue, model: any) => boolean;
48
48
  text?: TextFieldRole;
49
49
  cascade?: CascadeAction;
50
+ /**
51
+ * Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
52
+ * in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
53
+ * placeholder still ships to `<Image>`. Unlike `hidden`/`secret` this is about cost, not secrecy: a field
54
+ * nothing can answer a question with is pure spend on every turn it rides.
55
+ */
56
+ visual?: boolean;
50
57
  meta?: Metadata;
51
58
  }
52
59
  export declare const fieldPresets: readonly ["email", "password", "url"];
@@ -94,6 +101,7 @@ interface ConstantFieldBuildProps<FieldType extends ConstantFieldKind = any, Fie
94
101
  validate?: (value: FieldValue, model: any) => boolean;
95
102
  text?: TextFieldRole;
96
103
  cascade?: CascadeAction;
104
+ visual: boolean;
97
105
  modelRef: ConstantModelRef;
98
106
  arrDepth: number;
99
107
  optArrDepth: number;
@@ -139,6 +147,7 @@ export declare class ConstantField<FieldType extends ConstantFieldKind = Constan
139
147
  readonly validate?: (value: FieldValue, model: any) => boolean;
140
148
  readonly text?: TextFieldRole;
141
149
  readonly cascade?: CascadeAction;
150
+ readonly visual: boolean;
142
151
  readonly modelRef: ConstantModelRef;
143
152
  readonly arrDepth: number;
144
153
  readonly optArrDepth: number;
@@ -172,6 +181,7 @@ export type PlainTypeToFieldType<PlainType> = PlainType extends [infer First, ..
172
181
  /** Builds a stored property field with optional validation, default, ref, text, and metadata options. */
173
182
  export declare const field: {
174
183
  <ExplicitType, Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>, MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never>(value: Value, option?: FieldOption<Value, MapValue>): FieldInfo<"property", Value, ExplicitType, MapValue, FieldKindSource<Value>, UnCls<FieldKindSource<Value>> extends BaseObject ? true : false, FieldKindSource<Value> extends infer T ? T extends FieldKindSource<Value> ? T extends EnumInstance<string, any> ? true : false : never : never, FieldKindSource<Value> extends infer T_1 ? T_1 extends FieldKindSource<Value> ? T_1 extends typeof PrimitiveScalar ? true : false : never : never, FieldKindSource<Value> extends infer T_2 ? T_2 extends FieldKindSource<Value> ? T_2 extends MapConstructor ? true : false : never : never, (UnCls<FieldKindSource<Value>> extends BaseObject ? true : false) extends infer T_3 ? T_3 extends (UnCls<FieldKindSource<Value>> extends BaseObject ? true : false) ? T_3 extends true ? false : (FieldKindSource<Value> extends infer T_4 ? T_4 extends FieldKindSource<Value> ? T_4 extends EnumInstance<string, any> ? true : false : never : never) extends infer T_5 ? T_5 extends (FieldKindSource<Value> extends infer T_12 ? T_12 extends FieldKindSource<Value> ? T_12 extends EnumInstance<string, any> ? true : false : never : never) ? T_5 extends true ? false : (FieldKindSource<Value> extends infer T_6 ? T_6 extends FieldKindSource<Value> ? T_6 extends typeof PrimitiveScalar ? true : false : never : never) extends infer T_7 ? T_7 extends (FieldKindSource<Value> extends infer T_11 ? T_11 extends FieldKindSource<Value> ? T_11 extends typeof PrimitiveScalar ? true : false : never : never) ? T_7 extends true ? false : (FieldKindSource<Value> extends infer T_8 ? T_8 extends FieldKindSource<Value> ? T_8 extends MapConstructor ? true : false : never : never) extends infer T_9 ? T_9 extends (FieldKindSource<Value> extends infer T_10 ? T_10 extends FieldKindSource<Value> ? T_10 extends MapConstructor ? true : false : never : never) ? T_9 extends true ? false : true : never : never : never : never : never : never : never : never, false, false>;
184
+ visual<ExplicitType, Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>, MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never>(value: Value, option?: FieldOption<Value, MapValue>): FieldInfo<"property", Value, ExplicitType, MapValue, FieldKindSource<Value>, UnCls<FieldKindSource<Value>> extends BaseObject ? true : false, FieldKindSource<Value> extends infer T ? T extends FieldKindSource<Value> ? T extends EnumInstance<string, any> ? true : false : never : never, FieldKindSource<Value> extends infer T_1 ? T_1 extends FieldKindSource<Value> ? T_1 extends typeof PrimitiveScalar ? true : false : never : never, FieldKindSource<Value> extends infer T_2 ? T_2 extends FieldKindSource<Value> ? T_2 extends MapConstructor ? true : false : never : never, (UnCls<FieldKindSource<Value>> extends BaseObject ? true : false) extends infer T_3 ? T_3 extends (UnCls<FieldKindSource<Value>> extends BaseObject ? true : false) ? T_3 extends true ? false : (FieldKindSource<Value> extends infer T_4 ? T_4 extends FieldKindSource<Value> ? T_4 extends EnumInstance<string, any> ? true : false : never : never) extends infer T_5 ? T_5 extends (FieldKindSource<Value> extends infer T_12 ? T_12 extends FieldKindSource<Value> ? T_12 extends EnumInstance<string, any> ? true : false : never : never) ? T_5 extends true ? false : (FieldKindSource<Value> extends infer T_6 ? T_6 extends FieldKindSource<Value> ? T_6 extends typeof PrimitiveScalar ? true : false : never : never) extends infer T_7 ? T_7 extends (FieldKindSource<Value> extends infer T_11 ? T_11 extends FieldKindSource<Value> ? T_11 extends typeof PrimitiveScalar ? true : false : never : never) ? T_7 extends true ? false : (FieldKindSource<Value> extends infer T_8 ? T_8 extends FieldKindSource<Value> ? T_8 extends MapConstructor ? true : false : never : never) extends infer T_9 ? T_9 extends (FieldKindSource<Value> extends infer T_10 ? T_10 extends FieldKindSource<Value> ? T_10 extends MapConstructor ? true : false : never : never) ? T_9 extends true ? false : true : never : never : never : never : never : never : never : never, false, false>;
175
185
  hidden<ExplicitType, Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>, MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never>(value: Value, option?: FieldOption<Value, MapValue>): FieldInfo<"hidden", Value, ExplicitType, MapValue, FieldKindSource<Value>, UnCls<FieldKindSource<Value>> extends BaseObject ? true : false, FieldKindSource<Value> extends infer T ? T extends FieldKindSource<Value> ? T extends EnumInstance<string, any> ? true : false : never : never, FieldKindSource<Value> extends infer T_1 ? T_1 extends FieldKindSource<Value> ? T_1 extends typeof PrimitiveScalar ? true : false : never : never, FieldKindSource<Value> extends infer T_2 ? T_2 extends FieldKindSource<Value> ? T_2 extends MapConstructor ? true : false : never : never, (UnCls<FieldKindSource<Value>> extends BaseObject ? true : false) extends infer T_3 ? T_3 extends (UnCls<FieldKindSource<Value>> extends BaseObject ? true : false) ? T_3 extends true ? false : (FieldKindSource<Value> extends infer T_4 ? T_4 extends FieldKindSource<Value> ? T_4 extends EnumInstance<string, any> ? true : false : never : never) extends infer T_5 ? T_5 extends (FieldKindSource<Value> extends infer T_12 ? T_12 extends FieldKindSource<Value> ? T_12 extends EnumInstance<string, any> ? true : false : never : never) ? T_5 extends true ? false : (FieldKindSource<Value> extends infer T_6 ? T_6 extends FieldKindSource<Value> ? T_6 extends typeof PrimitiveScalar ? true : false : never : never) extends infer T_7 ? T_7 extends (FieldKindSource<Value> extends infer T_11 ? T_11 extends FieldKindSource<Value> ? T_11 extends typeof PrimitiveScalar ? true : false : never : never) ? T_7 extends true ? false : (FieldKindSource<Value> extends infer T_8 ? T_8 extends FieldKindSource<Value> ? T_8 extends MapConstructor ? true : false : never : never) extends infer T_9 ? T_9 extends (FieldKindSource<Value> extends infer T_10 ? T_10 extends FieldKindSource<Value> ? T_10 extends MapConstructor ? true : false : never : never) ? T_9 extends true ? false : true : never : never : never : never : never : never : never : never, true, false>;
176
186
  secret<ExplicitType, Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>, MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never>(value: Value, option?: FieldOption<Value, MapValue>): FieldInfo<"secret", Value | null, ExplicitType | null, MapValue, SingleValue<Value> & {}, UnCls<SingleValue<Value> & {}> extends BaseObject ? true : false, SingleValue<Value> & {} extends infer T ? T extends SingleValue<Value> & {} ? T extends EnumInstance<string, any> ? true : false : never : never, SingleValue<Value> & {} extends infer T_1 ? T_1 extends SingleValue<Value> & {} ? T_1 extends typeof PrimitiveScalar ? true : false : never : never, SingleValue<Value> & {} extends infer T_2 ? T_2 extends SingleValue<Value> & {} ? T_2 extends MapConstructor ? true : false : never : never, (UnCls<SingleValue<Value> & {}> extends BaseObject ? true : false) extends infer T_3 ? T_3 extends (UnCls<SingleValue<Value> & {}> extends BaseObject ? true : false) ? T_3 extends true ? false : (SingleValue<Value> & {} extends infer T_4 ? T_4 extends SingleValue<Value> & {} ? T_4 extends EnumInstance<string, any> ? true : false : never : never) extends infer T_5 ? T_5 extends (SingleValue<Value> & {} extends infer T_12 ? T_12 extends SingleValue<Value> & {} ? T_12 extends EnumInstance<string, any> ? true : false : never : never) ? T_5 extends true ? false : (SingleValue<Value> & {} extends infer T_6 ? T_6 extends SingleValue<Value> & {} ? T_6 extends typeof PrimitiveScalar ? true : false : never : never) extends infer T_7 ? T_7 extends (SingleValue<Value> & {} extends infer T_11 ? T_11 extends SingleValue<Value> & {} ? T_11 extends typeof PrimitiveScalar ? true : false : never : never) ? T_7 extends true ? false : (SingleValue<Value> & {} extends infer T_8 ? T_8 extends SingleValue<Value> & {} ? T_8 extends MapConstructor ? true : false : never : never) extends infer T_9 ? T_9 extends (SingleValue<Value> & {} extends infer T_10 ? T_10 extends SingleValue<Value> & {} ? T_10 extends MapConstructor ? true : false : never : never) ? T_9 extends true ? false : true : never : never : never : never : never : never : never : never, false, true>;
177
187
  };
@@ -12,13 +12,20 @@ interface MaskField {
12
12
  fieldType?: string;
13
13
  isClass?: boolean;
14
14
  modelRef?: MaskModel;
15
+ visual?: boolean;
15
16
  }
16
17
  export declare const maskFieldsOf: (model: MaskModel) => Record<string, MaskField> | null;
17
- /** The `hidden` and `secret` field names of `model` that `value` still carries populated. */
18
+ /**
19
+ * The `hidden` and `secret` field names of `model` that `value` still carries populated.
20
+ *
21
+ * `visual` is deliberately not among them. A refusal here means a value must not be published at all, and a blur
22
+ * placeholder is not a secret — it is merely not worth its tokens, which masking answers by dropping it.
23
+ */
18
24
  export declare const leakingFieldsOf: (model: MaskModel, value: Record<string, unknown>) => string[];
19
25
  /**
20
- * Strips what a model marks `hidden` or `secret`, by the model the caller names rather than by the one the value
21
- * happens to still carry.
26
+ * Strips what a model marks `hidden`, `secret`, or `visual`, by the model the caller names rather than by the one
27
+ * the value happens to still carry. The first two are secrecy and the third is cost, but the answer is the same
28
+ * one — leave the field out — and this is the only place every AI-facing read already passes through.
22
29
  *
23
30
  * That distinction is the whole point. A check that reads the class off the value can only mask what arrives as an
24
31
  * instance, so a `{ ...doc }` spread, a `toJSON()`, an `immerify()`, or a round-trip through `JSON.stringify` reaches
@@ -52,6 +52,13 @@ export interface McpServerOption {
52
52
  * the document is built once at boot and cached by clients, and it is read by a model rather than by a person.
53
53
  */
54
54
  language?: string;
55
+ /**
56
+ * Whether a structured result also ships as serialized JSON in the text block, `true` by default because that
57
+ * is what the spec asks of a server for clients that predate `structuredContent`. It is also a flat doubling:
58
+ * every model-returning tool sends its whole payload twice, so a deployment whose clients read the structured
59
+ * half turns this off and halves what each of those calls costs the model. `AKAN_MCP_LEGACY_TEXT=false`.
60
+ */
61
+ legacyTextBlock?: boolean;
55
62
  /** OAuth resource-server identity: which issuers a client may authenticate with, and the scopes to demand. */
56
63
  auth?: McpAuthOption;
57
64
  }
@@ -24,11 +24,18 @@ interface McpDispatcherProps {
24
24
  middleware: Map<string, MiddlewareCls>;
25
25
  /** The one language error text is resolved in, matching the catalogue the client was handed. */
26
26
  language?: string;
27
+ /**
28
+ * Whether a structured result also ships as serialized JSON in the text block. `true` by default, which is what
29
+ * the spec asks for; `false` halves what every model-returning tool costs.
30
+ */
31
+ legacyTextBlock?: boolean;
27
32
  }
28
33
  /** Executes one MCP tool call or resource read through the ordinary signal pipeline. */
29
34
  export declare class McpDispatcher {
30
35
  #private;
31
36
  static readonly logger: Logger;
37
+ /** What the text block says when the serialized duplicate is off. Read by a model, so English. */
38
+ static readonly structuredNote = "The result is in this call's structuredContent.";
32
39
  constructor(props: McpDispatcherProps);
33
40
  call(exposed: McpExposedEndpoint, args: Record<string, unknown>, req: Request): Promise<McpToolResult>;
34
41
  /**
@@ -26,6 +26,8 @@ export interface McpRouterProps {
26
26
  * rarely sees. Defaults to `en`, falling back to the first registered language when the app has no `en`.
27
27
  */
28
28
  language?: string;
29
+ /** Whether a structured result also ships as serialized JSON in the text block. Default `true`. */
30
+ legacyTextBlock?: boolean;
29
31
  auth?: McpAuthOption;
30
32
  }
31
33
  /**
@@ -10,11 +10,15 @@ export interface JsonSchemaBuilderOptions {
10
10
  }
11
11
  export interface JsonSchemaModelOptions {
12
12
  /**
13
- * Drops `hidden` and `secret` fields. `SignalContext.resolveReturn` strips both from every response, so naming
14
- * them describes a value the caller can never read — and on a model like `user` the names are themselves the
15
- * leak: `password`, `accountId`, `phone` published as readable properties of the model. This is the one place a
16
- * field the framework blocks on every value path is still visible, so it is scoped to schemas that describe a
17
- * *response*. A request body is a different shape and legitimately carries both.
13
+ * Drops `hidden`, `secret`, and `visual` fields. `SignalContext.resolveReturn` strips the first two from every
14
+ * response, so naming them describes a value the caller can never read — and on a model like `user` the names
15
+ * are themselves the leak: `password`, `accountId`, `phone` published as readable properties of the model. This
16
+ * is the one place a field the framework blocks on every value path is still visible, so it is scoped to schemas
17
+ * that describe a *response*. A request body is a different shape and legitimately carries all three.
18
+ *
19
+ * `visual` is dropped here because it is dropped from the payload an AI caller receives, and a schema that
20
+ * promises a field the value omits is worse than one that never named it: a non-optional visual field would be
21
+ * listed `required` and a validating client would refuse the whole result.
18
22
  */
19
23
  readable?: boolean;
20
24
  }
@@ -4,6 +4,11 @@
4
4
  * agent's own UI is marked `data-agent-ui` and skipped, so a turn never re-reads its own transcript, and a
5
5
  * password value is never read — the screen shows dots, so the DOM holds more than the user sees.
6
6
  *
7
+ * A region the app marks `data-agent-skip` — what `Agent.Skip` renders — costs a `[skipped: <name>]` line instead
8
+ * of its text. It stands in the output rather than vanishing because a deleted region reads as an absent one, and
9
+ * an agent asked about a footer it never saw answers that the page has none. Naming the region as `section` reads
10
+ * it: the marker is what the default read leaves out, not a wall.
11
+ *
7
12
  * A heading carries `(#anchor)` whenever it opens a container that has an id or a scope path, because that is the
8
13
  * name `readScreen({ section })` and `highlight` take: printing the text without the name leaves an agent
9
14
  * guessing at a slug. For the same reason a truncated read ends with the headings below the cut instead of only
@@ -2,9 +2,9 @@
2
2
  * Resolves a name the agent read on screen to the element it names.
3
3
  *
4
4
  * Four vocabularies, every one of them something already on the screen rather than a selector the model invented:
5
- * the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it, an
6
- * `Agent.Zone` or `useScreenScope` container, a plain element id — what a docs slide is addressed by — and, last, a
7
- * **heading by its own text**.
5
+ * the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it, a
6
+ * container — an `Agent.Zone`, a `useScreenScope` scope, or an `Agent.Skip` region named by the marker left in its
7
+ * place — a plain element id, what a docs slide is addressed by, and, last, a **heading by its own text**.
8
8
  *
9
9
  * A heading is matched on letters and digits alone, so the slug an agent naturally writes for a heading it read
10
10
  * ("images-and-public-env") finds "Images And Public Env". That tolerance is for headings only: a heading is a
@@ -13,7 +13,8 @@
13
13
  *
14
14
  * Nothing hidden ever resolves, by the same rule `readScreen` skips it. A collapsed panel or an off-variant
15
15
  * duplicate is not what the user is looking at, and scrolling to one flashes a ring nobody can see — which reads
16
- * as the tool being broken rather than as a miss.
16
+ * as the tool being broken rather than as a miss. A `display: contents` wrapper does not resolve either, even
17
+ * though the reader now reads through it: it has no box, so `scrollIntoView` has nothing to scroll to.
17
18
  */
18
19
  export declare class ScreenTarget {
19
20
  #private;