@nxisai/agent-harness 0.4.1 → 0.5.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 (103) hide show
  1. package/README.md +3 -1
  2. package/dist/harness/harness.d.ts.map +1 -1
  3. package/dist/harness/harness.js +31 -9
  4. package/dist/harness/harness.js.map +1 -1
  5. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  6. package/dist/harness/tools/action-tools.js +7 -5
  7. package/dist/harness/tools/action-tools.js.map +1 -1
  8. package/dist/harness/tools/bridge.d.ts.map +1 -1
  9. package/dist/harness/tools/bridge.js +33 -12
  10. package/dist/harness/tools/bridge.js.map +1 -1
  11. package/dist/harness/tools/local-context.d.ts +49 -9
  12. package/dist/harness/tools/local-context.d.ts.map +1 -1
  13. package/dist/harness/tools/local-context.js +377 -22
  14. package/dist/harness/tools/local-context.js.map +1 -1
  15. package/dist/harness/tools/open-page.d.ts +16 -0
  16. package/dist/harness/tools/open-page.d.ts.map +1 -1
  17. package/dist/harness/tools/open-page.js +45 -9
  18. package/dist/harness/tools/open-page.js.map +1 -1
  19. package/dist/harness/tools/retry.d.ts +53 -0
  20. package/dist/harness/tools/retry.d.ts.map +1 -0
  21. package/dist/harness/tools/retry.js +96 -0
  22. package/dist/harness/tools/retry.js.map +1 -0
  23. package/dist/harness/tools/schema.d.ts +18 -0
  24. package/dist/harness/tools/schema.d.ts.map +1 -1
  25. package/dist/harness/tools/schema.js +38 -0
  26. package/dist/harness/tools/schema.js.map +1 -1
  27. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  28. package/dist/harness/tools/site-actions.js +3 -2
  29. package/dist/harness/tools/site-actions.js.map +1 -1
  30. package/dist/harness/tools/site-nav.d.ts +168 -0
  31. package/dist/harness/tools/site-nav.d.ts.map +1 -0
  32. package/dist/harness/tools/site-nav.js +316 -0
  33. package/dist/harness/tools/site-nav.js.map +1 -0
  34. package/dist/harness/tools/site-pages.d.ts +4 -0
  35. package/dist/harness/tools/site-pages.d.ts.map +1 -1
  36. package/dist/harness/tools/site-pages.js +19 -3
  37. package/dist/harness/tools/site-pages.js.map +1 -1
  38. package/dist/harness/tools/source.d.ts +9 -0
  39. package/dist/harness/tools/source.d.ts.map +1 -1
  40. package/dist/harness/tools/source.js +54 -14
  41. package/dist/harness/tools/source.js.map +1 -1
  42. package/dist/harness/tools/webmcp.d.ts +39 -3
  43. package/dist/harness/tools/webmcp.d.ts.map +1 -1
  44. package/dist/harness/tools/webmcp.js +93 -14
  45. package/dist/harness/tools/webmcp.js.map +1 -1
  46. package/dist/index.d.ts +5 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/types.d.ts +2 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  53. package/dist/ui/agent-fullscreen.js +12 -1
  54. package/dist/ui/agent-fullscreen.js.map +1 -1
  55. package/dist/ui/tailwind.generated.d.ts +1 -1
  56. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  57. package/dist/ui/tailwind.generated.js +1 -1
  58. package/dist/ui/tailwind.generated.js.map +1 -1
  59. package/package.json +2 -1
  60. package/src/a2ui/cards.ts +177 -0
  61. package/src/a2ui/emit.ts +147 -0
  62. package/src/a2ui/form.ts +117 -0
  63. package/src/a2ui/host.ts +73 -0
  64. package/src/a2ui/index.ts +22 -0
  65. package/src/a2ui/stepper.ts +211 -0
  66. package/src/a2ui/theme.ts +134 -0
  67. package/src/a2ui/to-v09.ts +118 -0
  68. package/src/engine.ts +701 -0
  69. package/src/harness/harness.ts +956 -0
  70. package/src/harness/plan.ts +91 -0
  71. package/src/harness/policy.ts +59 -0
  72. package/src/harness/renderer.ts +208 -0
  73. package/src/harness/router/coordinator.ts +220 -0
  74. package/src/harness/router/rules.ts +65 -0
  75. package/src/harness/slots.ts +117 -0
  76. package/src/harness/steps.ts +156 -0
  77. package/src/harness/tools/action-tools.ts +870 -0
  78. package/src/harness/tools/bridge.ts +393 -0
  79. package/src/harness/tools/client.ts +26 -0
  80. package/src/harness/tools/discovery.ts +55 -0
  81. package/src/harness/tools/local-context.ts +480 -0
  82. package/src/harness/tools/open-page.ts +273 -0
  83. package/src/harness/tools/pending-action.ts +232 -0
  84. package/src/harness/tools/resume.ts +134 -0
  85. package/src/harness/tools/retry.ts +117 -0
  86. package/src/harness/tools/schema.ts +245 -0
  87. package/src/harness/tools/site-actions.ts +356 -0
  88. package/src/harness/tools/site-nav.ts +432 -0
  89. package/src/harness/tools/site-pages.ts +288 -0
  90. package/src/harness/tools/source.ts +353 -0
  91. package/src/harness/tools/webmcp.ts +200 -0
  92. package/src/index.ts +193 -0
  93. package/src/providers/capability.ts +167 -0
  94. package/src/providers/proxy.ts +191 -0
  95. package/src/providers/types.ts +150 -0
  96. package/src/types.ts +258 -0
  97. package/src/ui/agent-fullscreen.ts +3984 -0
  98. package/src/ui/agent.tw.css +49 -0
  99. package/src/ui/config.ts +138 -0
  100. package/src/ui/index.ts +32 -0
  101. package/src/ui/journey.ts +57 -0
  102. package/src/ui/tailwind.generated.ts +2 -0
  103. package/src/webmcp-contract.ts +65 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Tool discovery and execution over WebMCP.
3
+ *
4
+ * Discovery and execution work the same way whichever model is answering, so
5
+ * they sit with the harness rather than behind {@link ModelProvider}.
6
+ *
7
+ * @module
8
+ */
9
+ import { TOOLS_TIMEOUT_MS } from '../../engine.js';
10
+ import type {
11
+ ModelContext,
12
+ ModelContextToolInfo,
13
+ ModelContextToolResponse,
14
+ } from '../../types.js';
15
+
16
+ /**
17
+ * The default tool source, backed by the page's `document.modelContext`.
18
+ *
19
+ * Supply an instance as {@link HarnessOptions.toolSource}, or substitute any
20
+ * object with the same shape to draw tools from somewhere else.
21
+ */
22
+ export class WebMcpTools {
23
+ /**
24
+ * The page's model context.
25
+ *
26
+ * @returns The context, or null on a page where WebMCP is not present.
27
+ */
28
+ modelContext(): ModelContext | null {
29
+ return document.modelContext || navigator.modelContext || null;
30
+ }
31
+
32
+ /**
33
+ * Wait for tools to appear on a model context.
34
+ *
35
+ * Tools register asynchronously, so a context can be present but empty for a
36
+ * short time after page load. Polls, and also listens for `toolchange`.
37
+ *
38
+ * @param mc - The context to watch.
39
+ * @param signal - Abort to stop waiting early. Resolves empty when aborted.
40
+ * @returns The registered tools, or an empty array if none appear within
41
+ * {@link TOOLS_TIMEOUT_MS}.
42
+ */
43
+ waitForTools(mc: ModelContext, signal?: AbortSignal): Promise<ModelContextToolInfo[]> {
44
+ return new Promise((resolve) => {
45
+ let done = false;
46
+ const finish = async (tools?: ModelContextToolInfo[]) => {
47
+ if (done) return;
48
+ done = true;
49
+ clearInterval(iv);
50
+ clearTimeout(to);
51
+ mc.removeEventListener('toolchange', onChange);
52
+ signal?.removeEventListener('abort', onAbort);
53
+ resolve(tools || (await mc.getTools()));
54
+ };
55
+ const onAbort = () => finish([]);
56
+ if (signal?.aborted) return finish([]);
57
+ signal?.addEventListener('abort', onAbort, { once: true });
58
+ const check = async () => {
59
+ const tools = await mc.getTools();
60
+ if (tools && tools.length) finish(tools);
61
+ };
62
+ const onChange = () => check();
63
+ mc.addEventListener('toolchange', onChange);
64
+ const iv = setInterval(check, 800);
65
+ const to = setTimeout(() => finish(), TOOLS_TIMEOUT_MS);
66
+ check();
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Every tool registered on the page.
72
+ *
73
+ * @param signal - Abort to stop waiting for tools to appear.
74
+ * @returns The registered tools, or an empty array when WebMCP is absent,
75
+ * discovery fails, or the wait is aborted.
76
+ */
77
+ async discoverTools(signal?: AbortSignal): Promise<ModelContextToolInfo[]> {
78
+ const mc = this.modelContext();
79
+ if (!mc) return [];
80
+ try {
81
+ return await this.waitForTools(mc, signal);
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Call a tool.
89
+ *
90
+ * Handles each runtime's calling convention — see {@link executeRegistered} —
91
+ * trying the spec's form first and falling back when the context rejects it.
92
+ *
93
+ * @param toolObj - The tool as reported by discovery, or null to call by name.
94
+ * @param name - The tool's name, used for the fallback call.
95
+ * @param args - The arguments to send.
96
+ * @returns The tool's response.
97
+ * @throws If WebMCP is not available, or the tool itself throws.
98
+ */
99
+ async execTool(
100
+ toolObj: ModelContextToolInfo | null,
101
+ name: string,
102
+ args: Record<string, unknown>,
103
+ ): Promise<ModelContextToolResponse> {
104
+ const mc = this.modelContext();
105
+ if (!mc) throw new Error('WebMCP is not available on this page.');
106
+ return executeRegistered(mc, toolObj, name, args);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A registry refusing the SHAPE of a call, as opposed to the tool failing.
112
+ *
113
+ * Every message here was measured to come back before the tool's own code ran,
114
+ * which is what makes trying another form safe. Chrome 153 refuses an object
115
+ * input with "Failed to parse input arguments" having run nothing; a tool that
116
+ * ran and threw says "Tool was executed but the invocation failed", which is
117
+ * deliberately not here — it may already have done something.
118
+ */
119
+ const REFUSED_SHAPE =
120
+ /RegisteredTool|not of type|not an object|invalid input|Failed to parse input arguments|arguments required|No tool registered|NotFound/i;
121
+
122
+ /**
123
+ * Call a registered tool the way the runtime in front of us accepts.
124
+ *
125
+ * Runtimes disagree, and each refuses the others' forms. Measured:
126
+ *
127
+ * - Chrome 155 (Beta), and the WebMCP spec: the registered tool object and an
128
+ * arguments *object*. A JSON string is refused ("invalid input object: value
129
+ * is not an object"), and so is a name ("not of type 'RegisteredTool'").
130
+ * - Chrome 153 (stable): the tool object and a JSON *string*, required. An
131
+ * object is refused, before the tool runs, with "Failed to parse input
132
+ * arguments".
133
+ * - Older polyfills (`@nxisai/webmcp` 0.1) took a name. Today's, and the
134
+ * harness's own registry, follow the spec.
135
+ *
136
+ * So: object, then string, then name — each tried only when the one before was
137
+ * refused for its shape. A tool that ran and failed is reported as it failed,
138
+ * never retried in another form, since it may already have done something.
139
+ *
140
+ * @returns The tool's response as an object, whichever runtime answered — see
141
+ * {@link asResponse}.
142
+ */
143
+ export async function executeRegistered(
144
+ mc: ModelContext,
145
+ toolObj: ModelContextToolInfo | null,
146
+ name: string,
147
+ args: Record<string, unknown>,
148
+ ): Promise<ModelContextToolResponse> {
149
+ const input = args ?? {};
150
+ const attempts: (() => Promise<ModelContextToolResponse>)[] = [
151
+ ...(toolObj
152
+ ? [
153
+ () => mc.executeTool(toolObj as unknown as object, input),
154
+ () => mc.executeTool(toolObj as unknown as object, JSON.stringify(input)),
155
+ ]
156
+ : []),
157
+ () => mc.executeTool(name, input),
158
+ ];
159
+ let refused: unknown;
160
+ for (const attempt of attempts) {
161
+ try {
162
+ return asResponse(await attempt());
163
+ } catch (err) {
164
+ if (!REFUSED_SHAPE.test(String((err as Error)?.message || err))) throw err;
165
+ refused = err;
166
+ }
167
+ }
168
+ throw refused;
169
+ }
170
+
171
+ /**
172
+ * Whatever a registry returned, as the response object the tool produced.
173
+ *
174
+ * The spec's `executeTool` resolves with a string — the tool's result
175
+ * serialized — and native Chrome does exactly that. Read as text, a tool that
176
+ * reported `isError` looks like a success and its `structuredContent` is gone:
177
+ * a form that refused the input would be shown as sent. Parsing it back here,
178
+ * once, gives every caller the same object whichever runtime answered.
179
+ *
180
+ * A tool that returned text comes back as that text. Under the spec every
181
+ * result is JSON, so text arrives quoted (`"\"Done\""`); Chrome 155 passes it
182
+ * unquoted, and `"undefined"` for a tool that returned nothing. Both read the
183
+ * same here.
184
+ */
185
+ export function asResponse(raw: unknown): ModelContextToolResponse {
186
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as ModelContextToolResponse;
187
+ if (typeof raw !== 'string') return { content: raw === undefined ? [] : [{ type: 'text', text: String(raw) }] };
188
+ const trimmed = raw.trim();
189
+ if (trimmed === 'undefined' || trimmed === '') return { content: [] };
190
+ if (trimmed.startsWith('{') || trimmed.startsWith('"')) {
191
+ try {
192
+ const parsed = JSON.parse(trimmed) as unknown;
193
+ if (typeof parsed === 'string') return { content: parsed ? [{ type: 'text', text: parsed }] : [] };
194
+ if (parsed && Array.isArray((parsed as ModelContextToolResponse).content)) return parsed as ModelContextToolResponse;
195
+ } catch {
196
+ // Not JSON; the tool's own text.
197
+ }
198
+ }
199
+ return { content: [{ type: 'text', text: raw }] };
200
+ }
package/src/index.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Headless entry point for `@nxisai/agent-harness`.
3
+ *
4
+ * Everything exported here runs without a UI. For the packaged chat element,
5
+ * import `@nxisai/agent-harness/ui`; to keep this decision logic behind an
6
+ * interface of your own, implement {@link HarnessRenderer}.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { AgentHarness } from '@nxisai/agent-harness';
11
+ *
12
+ * const harness = new AgentHarness({ renderer });
13
+ * await harness.turn('book me in for Thursday', messages);
14
+ * ```
15
+ *
16
+ * @module
17
+ */
18
+
19
+ export { AgentHarness } from './harness/harness.js';
20
+
21
+ // ── Model providers ──────────────────────────────────────────────────────────
22
+ export { LocalGemmaProvider, AgentEngine, shouldUseCache } from './engine.js';
23
+ export { createProxyProvider } from './providers/proxy.js';
24
+ export type { ProxyProviderOptions } from './providers/proxy.js';
25
+ export { assessLocalCapability, requestPersistentStorage } from './providers/capability.js';
26
+ export type { LocalCapability } from './providers/capability.js';
27
+ export type {
28
+ ModelProvider,
29
+ CompletionRequest,
30
+ Completion,
31
+ CacheHint,
32
+ } from './providers/types.js';
33
+
34
+ // ── Tools, as the harness sees them ──────────────────────────────────────────
35
+ export { WebMcpTools } from './harness/tools/webmcp.js';
36
+ export {
37
+ toolSchema,
38
+ missingRequired,
39
+ reconcileArgs,
40
+ extractResult,
41
+ proseAnswer,
42
+ } from './harness/tools/schema.js';
43
+ export type { HarnessOptions, TurnResult } from './harness/harness.js';
44
+
45
+ export { policyFor, decide } from './harness/policy.js';
46
+ export type { ToolPolicy, Decision } from './harness/policy.js';
47
+
48
+ export { resolveSlots, summarySlots } from './harness/slots.js';
49
+ export type { Slot, Resolution, ResolveSources } from './harness/slots.js';
50
+
51
+ export { buildPlan } from './harness/plan.js';
52
+
53
+ export { AgentCoordinator } from './harness/router/coordinator.js';
54
+ export type { CoordinatorOptions, RoutedTurn, RouteSource } from './harness/router/coordinator.js';
55
+ export { matchRoute } from './harness/router/rules.js';
56
+ export type { RouteRule, RouteDecision } from './harness/router/rules.js';
57
+ export type { ClarificationPlan, PlanStep } from './harness/plan.js';
58
+
59
+ export type {
60
+ HarnessRenderer,
61
+ AskRequest,
62
+ ConfirmRequest,
63
+ StreamingBubble,
64
+ } from './harness/renderer.js';
65
+
66
+ export {
67
+ parseToolCall,
68
+ stripMarkers,
69
+ humanize,
70
+ lcFirst,
71
+ escapeHtml,
72
+ MODEL_ID,
73
+ MODEL_HOST,
74
+ MODEL_PATH_TEMPLATE,
75
+ SYSTEM_PROMPT,
76
+ MAX_STEPS,
77
+ MAX_NEW_TOKENS,
78
+ TOOLS_TIMEOUT_MS,
79
+ } from './engine.js';
80
+
81
+ /**
82
+ * Brand marks and icons used by the packaged UI.
83
+ *
84
+ * Exported for custom renderers. Inline SVG using `currentColor`, so each icon
85
+ * takes the surrounding text colour.
86
+ */
87
+ export {
88
+ NXIS_LOGO_SVG,
89
+ ICON_CHECK,
90
+ ICON_WARN,
91
+ ICON_ARROW,
92
+ ICON_PLUS,
93
+ ICON_MENU,
94
+ ICON_TRASH,
95
+ ICON_STOP,
96
+ ICON_COPY,
97
+ ICON_PENCIL,
98
+ ICON_REDO,
99
+ ICON_CHECK_MARK,
100
+ } from './engine.js';
101
+ export type {
102
+ AgentEngineOptions,
103
+ ChatMessage,
104
+ ToolDef,
105
+ GenerationUsage,
106
+ ParsedToolCall,
107
+ ProgressDetail,
108
+ PreloadMode,
109
+ } from './engine.js';
110
+
111
+ export { connectTools, waitForModelContext } from './harness/tools/source.js';
112
+ export {
113
+ buildSiteActionTools,
114
+ registerSiteActionTools,
115
+ withoutShadowedTools,
116
+ handoffFor,
117
+ findPageTool,
118
+ awaitPageTool,
119
+ pageToolRef,
120
+ siteToolName,
121
+ sanitizeToolName,
122
+ selectSiteTools,
123
+ DEFAULT_SITE_TOOL_LIMIT,
124
+ } from './harness/tools/action-tools.js';
125
+ export {
126
+ beginJourney,
127
+ currentJourney,
128
+ journeySteps,
129
+ onJourneyChange,
130
+ recordStep,
131
+ restoreJourney,
132
+ } from './harness/steps.js';
133
+ export type { AgentStep, AgentStepId, AgentStepStatus } from './harness/steps.js';
134
+ export { resumePendingAction } from './harness/tools/resume.js';
135
+ export type { ResumeOutcome, ResumeOptions } from './harness/tools/resume.js';
136
+ export {
137
+ loadSitePages,
138
+ parsePage,
139
+ pagesFromActions,
140
+ mergePages,
141
+ pageForEntity,
142
+ findPage,
143
+ } from './harness/tools/site-pages.js';
144
+ export type { SitePage, SitePagesOptions } from './harness/tools/site-pages.js';
145
+ export { buildOpenPageTool, registerOpenPageTool, resolveDestination, visitFor, OPEN_PAGE_TOOL } from './harness/tools/open-page.js';
146
+ export { isRateLimited, retryAfterMs, withRetry } from './harness/tools/retry.js';
147
+ export type { RetryOptions } from './harness/tools/retry.js';
148
+ export {
149
+ applyTrails,
150
+ buildNav,
151
+ buildPageLinksTool,
152
+ describeNav,
153
+ loadSiteTrails,
154
+ nameOf,
155
+ navFor,
156
+ parentOf,
157
+ parseTrail,
158
+ registerPageLinksTool,
159
+ PAGE_LINKS_TOOL,
160
+ } from './harness/tools/site-nav.js';
161
+ export type { NavLink, PageNav, SiteNav, SiteTrail, SiteTrailsOptions, TrailLevel } from './harness/tools/site-nav.js';
162
+ export type { OpenPageOptions, Destination } from './harness/tools/open-page.js';
163
+ export type { SiteActionTool, Handoff, PageToolRef } from './harness/tools/action-tools.js';
164
+ export {
165
+ savePendingAction,
166
+ takePendingAction,
167
+ clearPendingAction,
168
+ PENDING_TTL_MS,
169
+ savePendingVisit,
170
+ takePendingVisit,
171
+ setConversationSource,
172
+ } from './harness/tools/pending-action.js';
173
+ export type { PendingAction, PendingVisit } from './harness/tools/pending-action.js';
174
+ export { loadSiteActions, parseAction } from './harness/tools/site-actions.js';
175
+ export type { ConnectResult, ConnectOptions, SiteActionsOptions } from './harness/tools/source.js';
176
+ export type { BridgeOptions } from './harness/tools/bridge.js';
177
+
178
+ export type {
179
+ JsonSchema,
180
+ ModelContext,
181
+ ModelContextTool,
182
+ ModelContextToolInfo,
183
+ ModelContextToolResponse,
184
+ McpServerInfo,
185
+ NxisConfig,
186
+ } from './types.js';
187
+
188
+ export type {
189
+ NxisAction,
190
+ NxisEntryPoint,
191
+ NxisPropertyValueSpecification,
192
+ NxisValueOption,
193
+ } from './webmcp-contract.js';
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Whether this device can load the local model without harming the visitor.
3
+ *
4
+ * {@link LocalGemmaProvider} downloads and holds roughly 3.4 GB of weights in
5
+ * the tab. A mobile renderer cannot take that: it is killed, and the visitor
6
+ * loses the whole host page rather than just the agent.
7
+ *
8
+ * That failure cannot be caught. An out-of-memory kill runs no `catch`, no
9
+ * `onerror` and no `unload` handler, so there is no recovery path and nothing
10
+ * to report afterwards. The only defence is deciding before the load starts,
11
+ * which is what this module is for.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ /** Fields the standard DOM types do not declare. */
17
+ type CapabilityNavigator = Navigator & {
18
+ deviceMemory?: number;
19
+ userAgentData?: { mobile?: boolean };
20
+ gpu?: {
21
+ requestAdapter(): Promise<{
22
+ features: { has(feature: string): boolean };
23
+ limits: Record<string, number>;
24
+ } | null>;
25
+ };
26
+ };
27
+
28
+ /** Approximate footprint of the q4f16 weights, in bytes. */
29
+ export const MODEL_BYTES = 3.4e9;
30
+
31
+ /** Weights plus room for the browser's own use, for the storage estimate. */
32
+ const REQUIRED_QUOTA = MODEL_BYTES * 1.8;
33
+
34
+ /** Reported memory, in GB, below which the load is not attempted. */
35
+ const MIN_DEVICE_MEMORY_GB = 8;
36
+
37
+ /** What {@link assessLocalCapability} found. */
38
+ export interface LocalCapability {
39
+ /** Whether the local model should be loaded on this device. */
40
+ ok: boolean;
41
+ /** Why not, when `ok` is false. Suitable for a log, not for a visitor. */
42
+ reason?: string;
43
+ /** The individual findings, for a host that wants to decide for itself. */
44
+ checks: {
45
+ /** True for a phone or tablet, by client hints or user-agent string. */
46
+ mobile: boolean;
47
+ /** Whether `navigator.gpu` exists. The API, not a usable adapter. */
48
+ webgpuApi: boolean;
49
+ /** Whether an adapter was actually returned. */
50
+ adapter: boolean;
51
+ /** Whether the adapter supports fp16 compute, which `q4f16` requires. */
52
+ shaderF16: boolean;
53
+ /** `navigator.deviceMemory` in GB, where the browser reports it. */
54
+ deviceMemoryGb?: number;
55
+ /** Storage quota in bytes, where the browser estimates it. */
56
+ storageQuota?: number;
57
+ };
58
+ }
59
+
60
+ /** Is this a phone or tablet? Client hints where available, UA string if not. */
61
+ function isMobile(nav: CapabilityNavigator): boolean {
62
+ if (typeof nav.userAgentData?.mobile === 'boolean') return nav.userAgentData.mobile;
63
+ return /Android|iPhone|iPad|iPod|Mobile|Silk|Kindle/i.test(nav.userAgent);
64
+ }
65
+
66
+ /**
67
+ * Decide whether the local model can be loaded here, without loading it.
68
+ *
69
+ * Safe and cheap to call: it acquires a WebGPU adapter and reads storage
70
+ * estimates, and downloads nothing. Hosts can call it directly to decide
71
+ * whether to offer the agent at all, rather than rendering a control that
72
+ * cannot work.
73
+ *
74
+ * Deliberately conservative, because the two ways of being wrong are not
75
+ * equal. A false negative costs a capable visitor the local model, and a
76
+ * remote provider still works. A false positive costs a visitor their browser
77
+ * tab, and takes the host's page with it.
78
+ *
79
+ * There is no smaller quantisation to fall back to: `q4f16` is already the
80
+ * smallest build of this model, and `q4` is larger. So a device without fp16
81
+ * compute is declined rather than degraded.
82
+ *
83
+ * @returns The verdict, and the individual findings behind it.
84
+ */
85
+ export async function assessLocalCapability(): Promise<LocalCapability> {
86
+ const checks: LocalCapability['checks'] = {
87
+ mobile: false,
88
+ webgpuApi: false,
89
+ adapter: false,
90
+ shaderF16: false,
91
+ };
92
+
93
+ if (typeof navigator === 'undefined') {
94
+ return { ok: false, reason: 'no navigator', checks };
95
+ }
96
+ const nav = navigator as CapabilityNavigator;
97
+
98
+ checks.mobile = isMobile(nav);
99
+ checks.webgpuApi = !!nav.gpu;
100
+ if (typeof nav.deviceMemory === 'number') checks.deviceMemoryGb = nav.deviceMemory;
101
+
102
+ // Mobile is the case this exists for. Even a phone reporting ample memory is
103
+ // subject to a renderer cap far below what these weights need.
104
+ if (checks.mobile) return { ok: false, reason: 'mobile device', checks };
105
+ if (!nav.gpu) return { ok: false, reason: 'no WebGPU', checks };
106
+
107
+ if (checks.deviceMemoryGb !== undefined && checks.deviceMemoryGb < MIN_DEVICE_MEMORY_GB) {
108
+ return { ok: false, reason: `deviceMemory ${checks.deviceMemoryGb}GB`, checks };
109
+ }
110
+
111
+ let adapter;
112
+ try {
113
+ adapter = await nav.gpu.requestAdapter();
114
+ } catch (error) {
115
+ return { ok: false, reason: `adapter request failed: ${String(error)}`, checks };
116
+ }
117
+ // The API can exist with no adapter behind it, which is a load that fails
118
+ // after the download rather than before it.
119
+ if (!adapter) return { ok: false, reason: 'no WebGPU adapter', checks };
120
+ checks.adapter = true;
121
+
122
+ checks.shaderF16 = adapter.features.has('shader-f16');
123
+ if (!checks.shaderF16) return { ok: false, reason: 'no shader-f16', checks };
124
+
125
+ // Storage is checked last because it is advisory: browsers report a quota
126
+ // they do not promise to honour, and a missing estimate is not a refusal.
127
+ try {
128
+ const estimate = await nav.storage?.estimate?.();
129
+ if (typeof estimate?.quota === 'number') {
130
+ checks.storageQuota = estimate.quota;
131
+ if (estimate.quota < REQUIRED_QUOTA) {
132
+ return {
133
+ ok: false,
134
+ reason: `storage quota ${(estimate.quota / 1e9).toFixed(1)}GB`,
135
+ checks,
136
+ };
137
+ }
138
+ }
139
+ } catch {
140
+ // An unavailable estimate says nothing either way.
141
+ }
142
+
143
+ return { ok: true, checks };
144
+ }
145
+
146
+ /**
147
+ * Ask the browser to keep this origin's storage rather than evicting it.
148
+ *
149
+ * Cache Storage is best-effort by default, and a browser under pressure evicts
150
+ * best-effort data without warning. A multi-gigabyte model is the first thing
151
+ * to go, which looks exactly like caching never having worked.
152
+ *
153
+ * Browsers grant this on their own heuristics, so it is a request rather than
154
+ * a guarantee. Not asking guarantees best-effort; asking sometimes gets more.
155
+ *
156
+ * @returns Whether the origin's storage is persistent afterwards.
157
+ */
158
+ export async function requestPersistentStorage(): Promise<boolean> {
159
+ try {
160
+ const storage = navigator?.storage;
161
+ if (!storage?.persist) return false;
162
+ if (await storage.persisted?.()) return true;
163
+ return await storage.persist();
164
+ } catch {
165
+ return false;
166
+ }
167
+ }