appilot 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -7
- package/dist/dom/surfaces.d.ts +81 -0
- package/dist/dom/surfaces.js +91 -0
- package/dist/dom/uniqueSelector.d.ts +23 -0
- package/dist/dom/uniqueSelector.js +104 -0
- package/dist/domResponder.d.ts +38 -0
- package/dist/domResponder.js +344 -0
- package/dist/focusedSessions/pageSdk.d.ts +107 -0
- package/dist/focusedSessions/pageSdk.js +238 -0
- package/dist/httpFetch.d.ts +52 -0
- package/dist/httpFetch.js +137 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +24 -0
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.js +18 -0
- package/dist/sessions/types.d.ts +140 -0
- package/dist/sessions/types.js +12 -0
- package/dist/webmcp/registry.d.ts +75 -0
- package/dist/webmcp/registry.js +152 -0
- package/dist/webmcp/sdk.d.ts +38 -0
- package/dist/webmcp/sdk.js +81 -0
- package/dist/widget/boot.d.ts +72 -0
- package/dist/widget/boot.js +264 -0
- package/package.json +51 -16
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-proxy DOM tool. Lets the backend issue a fetch from inside the page the
|
|
3
|
+
* user is on so the host-application session cookie is carried automatically
|
|
4
|
+
* (`credentials: 'include'`), without the backend ever seeing a credential.
|
|
5
|
+
*
|
|
6
|
+
* Shared between the two in-page surfaces:
|
|
7
|
+
* - Chrome extension: invoked from the content script (relayed by the
|
|
8
|
+
* background service worker's AgentDomBridge).
|
|
9
|
+
* - Embeddable widget: invoked directly in-page by the widget's
|
|
10
|
+
* AgentDomBridge (the widget already runs inside the host page).
|
|
11
|
+
*
|
|
12
|
+
* Used by customer-authored Tools whose `runtime_spec.kind === 'http_proxy'`.
|
|
13
|
+
* The backend builds the URL + headers + body from the tool's runtime_spec
|
|
14
|
+
* (NOT from agent-supplied args), then asks the client to send it. Defence
|
|
15
|
+
* in depth on this side:
|
|
16
|
+
*
|
|
17
|
+
* 1. Same-origin enforcement against `location.origin`. Even if the backend
|
|
18
|
+
* asked us to hit `https://evil.example`, this responder rejects it.
|
|
19
|
+
* Relative paths only.
|
|
20
|
+
* 2. Method allowlist (GET / POST / PUT / DELETE).
|
|
21
|
+
* 3. AbortController-backed timeout, capped at 15 s.
|
|
22
|
+
* 4. 401/403 surfaces as a distinct `AUTH_REQUIRED` error so the agent loop
|
|
23
|
+
* can stop without nag-retrying.
|
|
24
|
+
* 5. No header echo: headers we forward are taken from the request payload
|
|
25
|
+
* and `credentials: 'include'` is FORCED. We do not write `Authorization`
|
|
26
|
+
* or `Cookie` even if the payload tries.
|
|
27
|
+
*
|
|
28
|
+
* See:
|
|
29
|
+
* - packages/libs/shared/src/types/agent/tools.ts § HttpProxyRuntimeSpec
|
|
30
|
+
* - docs/content-model/tools-http-proxy.md
|
|
31
|
+
*/
|
|
32
|
+
export interface HttpFetchArgs {
|
|
33
|
+
method?: string;
|
|
34
|
+
path?: string;
|
|
35
|
+
query?: Record<string, string>;
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
body?: unknown;
|
|
38
|
+
timeout_ms?: number;
|
|
39
|
+
}
|
|
40
|
+
export type HttpFetchResult = {
|
|
41
|
+
ok: true;
|
|
42
|
+
status: number;
|
|
43
|
+
json: unknown;
|
|
44
|
+
duration_ms: number;
|
|
45
|
+
} | {
|
|
46
|
+
ok: false;
|
|
47
|
+
error: 'BAD_REQUEST' | 'BLOCKED_ORIGIN' | 'BLOCKED_METHOD' | 'TIMEOUT' | 'NETWORK' | 'AUTH_REQUIRED' | 'STATUS' | 'PARSE';
|
|
48
|
+
detail?: string;
|
|
49
|
+
status?: number;
|
|
50
|
+
duration_ms?: number;
|
|
51
|
+
};
|
|
52
|
+
export declare function httpFetch(rawArgs: Record<string, unknown>): Promise<HttpFetchResult>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-proxy DOM tool. Lets the backend issue a fetch from inside the page the
|
|
3
|
+
* user is on so the host-application session cookie is carried automatically
|
|
4
|
+
* (`credentials: 'include'`), without the backend ever seeing a credential.
|
|
5
|
+
*
|
|
6
|
+
* Shared between the two in-page surfaces:
|
|
7
|
+
* - Chrome extension: invoked from the content script (relayed by the
|
|
8
|
+
* background service worker's AgentDomBridge).
|
|
9
|
+
* - Embeddable widget: invoked directly in-page by the widget's
|
|
10
|
+
* AgentDomBridge (the widget already runs inside the host page).
|
|
11
|
+
*
|
|
12
|
+
* Used by customer-authored Tools whose `runtime_spec.kind === 'http_proxy'`.
|
|
13
|
+
* The backend builds the URL + headers + body from the tool's runtime_spec
|
|
14
|
+
* (NOT from agent-supplied args), then asks the client to send it. Defence
|
|
15
|
+
* in depth on this side:
|
|
16
|
+
*
|
|
17
|
+
* 1. Same-origin enforcement against `location.origin`. Even if the backend
|
|
18
|
+
* asked us to hit `https://evil.example`, this responder rejects it.
|
|
19
|
+
* Relative paths only.
|
|
20
|
+
* 2. Method allowlist (GET / POST / PUT / DELETE).
|
|
21
|
+
* 3. AbortController-backed timeout, capped at 15 s.
|
|
22
|
+
* 4. 401/403 surfaces as a distinct `AUTH_REQUIRED` error so the agent loop
|
|
23
|
+
* can stop without nag-retrying.
|
|
24
|
+
* 5. No header echo: headers we forward are taken from the request payload
|
|
25
|
+
* and `credentials: 'include'` is FORCED. We do not write `Authorization`
|
|
26
|
+
* or `Cookie` even if the payload tries.
|
|
27
|
+
*
|
|
28
|
+
* See:
|
|
29
|
+
* - packages/libs/shared/src/types/agent/tools.ts § HttpProxyRuntimeSpec
|
|
30
|
+
* - docs/content-model/tools-http-proxy.md
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
33
|
+
const MAX_TIMEOUT_MS = 15_000;
|
|
34
|
+
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE']);
|
|
35
|
+
const FORBIDDEN_HEADER_NAMES = new Set(['authorization', 'cookie']);
|
|
36
|
+
export async function httpFetch(rawArgs) {
|
|
37
|
+
const args = rawArgs;
|
|
38
|
+
const method = String(args.method || 'GET').toUpperCase();
|
|
39
|
+
if (!ALLOWED_METHODS.has(method)) {
|
|
40
|
+
return { ok: false, error: 'BLOCKED_METHOD', detail: `method=${method}` };
|
|
41
|
+
}
|
|
42
|
+
const pathInput = typeof args.path === 'string' ? args.path : '';
|
|
43
|
+
if (!pathInput) {
|
|
44
|
+
return { ok: false, error: 'BAD_REQUEST', detail: 'missing path' };
|
|
45
|
+
}
|
|
46
|
+
// Same-origin enforcement. `new URL(pathInput, location.origin)` resolves a
|
|
47
|
+
// relative path against the current page origin. An absolute URL that points
|
|
48
|
+
// elsewhere will resolve with a different `.origin` and we reject it.
|
|
49
|
+
let target;
|
|
50
|
+
try {
|
|
51
|
+
target = new URL(pathInput, location.origin);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return { ok: false, error: 'BAD_REQUEST', detail: `invalid path: ${String(err.message)}` };
|
|
55
|
+
}
|
|
56
|
+
if (target.origin !== location.origin) {
|
|
57
|
+
return { ok: false, error: 'BLOCKED_ORIGIN', detail: `target=${target.origin} active_tab=${location.origin}` };
|
|
58
|
+
}
|
|
59
|
+
if (args.query && typeof args.query === 'object') {
|
|
60
|
+
for (const [k, v] of Object.entries(args.query)) {
|
|
61
|
+
if (k && typeof v === 'string' && v.length > 0) {
|
|
62
|
+
target.searchParams.append(k, v);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const headers = new Headers();
|
|
67
|
+
if (args.headers && typeof args.headers === 'object') {
|
|
68
|
+
for (const [k, v] of Object.entries(args.headers)) {
|
|
69
|
+
if (FORBIDDEN_HEADER_NAMES.has(k.toLowerCase()))
|
|
70
|
+
continue;
|
|
71
|
+
if (typeof v === 'string')
|
|
72
|
+
headers.set(k, v);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!headers.has('Accept'))
|
|
76
|
+
headers.set('Accept', 'application/json');
|
|
77
|
+
const requestedTimeout = typeof args.timeout_ms === 'number' && Number.isFinite(args.timeout_ms)
|
|
78
|
+
? args.timeout_ms
|
|
79
|
+
: DEFAULT_TIMEOUT_MS;
|
|
80
|
+
const timeoutMs = Math.min(Math.max(requestedTimeout, 250), MAX_TIMEOUT_MS);
|
|
81
|
+
const init = {
|
|
82
|
+
method,
|
|
83
|
+
headers,
|
|
84
|
+
credentials: 'include',
|
|
85
|
+
redirect: 'follow',
|
|
86
|
+
};
|
|
87
|
+
if (method !== 'GET' && method !== 'DELETE' && args.body !== undefined) {
|
|
88
|
+
if (!headers.has('Content-Type'))
|
|
89
|
+
headers.set('Content-Type', 'application/json');
|
|
90
|
+
try {
|
|
91
|
+
init.body = typeof args.body === 'string' ? args.body : JSON.stringify(args.body);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
return { ok: false, error: 'BAD_REQUEST', detail: `body serialise: ${err.message}` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
init.signal = controller.signal;
|
|
99
|
+
const timeoutHandle = window.setTimeout(() => controller.abort(), timeoutMs);
|
|
100
|
+
const startedAt = performance.now();
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetch(target.toString(), init);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const elapsed = Math.round(performance.now() - startedAt);
|
|
107
|
+
if (controller.signal.aborted) {
|
|
108
|
+
return { ok: false, error: 'TIMEOUT', detail: `${timeoutMs}ms`, duration_ms: elapsed };
|
|
109
|
+
}
|
|
110
|
+
return { ok: false, error: 'NETWORK', detail: err.message, duration_ms: elapsed };
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
window.clearTimeout(timeoutHandle);
|
|
114
|
+
}
|
|
115
|
+
const elapsed = Math.round(performance.now() - startedAt);
|
|
116
|
+
if (response.status === 401 || response.status === 403) {
|
|
117
|
+
return { ok: false, error: 'AUTH_REQUIRED', status: response.status, duration_ms: elapsed };
|
|
118
|
+
}
|
|
119
|
+
let parsed;
|
|
120
|
+
const contentType = response.headers.get('content-type') || '';
|
|
121
|
+
const text = await response.text().catch(() => '');
|
|
122
|
+
if (contentType.includes('application/json') || (text && (text.startsWith('{') || text.startsWith('[')))) {
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(text);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
return { ok: false, error: 'PARSE', status: response.status, detail: err.message, duration_ms: elapsed };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
parsed = { _text: text.slice(0, 4000) };
|
|
132
|
+
}
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
return { ok: false, error: 'STATUS', status: response.status, detail: typeof parsed === 'object' ? JSON.stringify(parsed).slice(0, 500) : String(parsed).slice(0, 500), duration_ms: elapsed };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, status: response.status, json: parsed, duration_ms: elapsed };
|
|
137
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `appilot`: the single developer-facing surface for building on Appilot.
|
|
3
|
+
*
|
|
4
|
+
* Four capabilities, one package:
|
|
5
|
+
* - Boot the assistant: bootAppilotWidget(...) (widget/boot)
|
|
6
|
+
* - Register client actions: registerTool(...) (WebMCP-aligned)
|
|
7
|
+
* - Run Focused Sessions: startFocusedSession(...) (mission-scoped)
|
|
8
|
+
* and resumeFocusedSession(...) to re-attach after a page reload
|
|
9
|
+
* - Coexist with your modals: isAppilotSurface(...) (dom/surfaces)
|
|
10
|
+
*
|
|
11
|
+
* Browser-only, React-free, zero runtime dependencies. The internal runtime the
|
|
12
|
+
* widget/extension use to FULFILL these calls lives at `appilot/runtime`.
|
|
13
|
+
*
|
|
14
|
+
* See the docs-site "Building on Appilot" guide and
|
|
15
|
+
* docs/content-model/tools-client-actions.md.
|
|
16
|
+
*/
|
|
17
|
+
export { bootAppilotWidget, getWidgetAvailability, subscribeWidgetAvailability, type WidgetAvailability, type AppilotWidgetBootOptions, } from './widget/boot.js';
|
|
18
|
+
export { registerTool, registerClientTool, unregisterClientTool, onClientToolsChange, hasNativeWebMcp, type ClientToolDefinition, type ClientToolHandle, } from './webmcp/sdk.js';
|
|
19
|
+
export type { ClientToolContent, ClientToolResult } from './webmcp/registry.js';
|
|
20
|
+
export { isAppilotSurface, markAppilotSurface, APPILOT_SURFACE_ATTR, type AppilotSurfaceKind, } from './dom/surfaces.js';
|
|
21
|
+
export { startFocusedSession, resumeFocusedSession, getResumableFocusedSessionId, forgetFocusedSession, type FocusedSessionPageHandle, } from './focusedSessions/pageSdk.js';
|
|
22
|
+
export type { FocusedSessionMission, FocusedSessionStartRequest, FocusedSessionStartResult, FocusedSessionOutcomeEvent, FocusedSessionPresentation, FocusedSessionEndStatus, FocusedSessionEndedEvent, } from './sessions/types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `appilot`: the single developer-facing surface for building on Appilot.
|
|
3
|
+
*
|
|
4
|
+
* Four capabilities, one package:
|
|
5
|
+
* - Boot the assistant: bootAppilotWidget(...) (widget/boot)
|
|
6
|
+
* - Register client actions: registerTool(...) (WebMCP-aligned)
|
|
7
|
+
* - Run Focused Sessions: startFocusedSession(...) (mission-scoped)
|
|
8
|
+
* and resumeFocusedSession(...) to re-attach after a page reload
|
|
9
|
+
* - Coexist with your modals: isAppilotSurface(...) (dom/surfaces)
|
|
10
|
+
*
|
|
11
|
+
* Browser-only, React-free, zero runtime dependencies. The internal runtime the
|
|
12
|
+
* widget/extension use to FULFILL these calls lives at `appilot/runtime`.
|
|
13
|
+
*
|
|
14
|
+
* See the docs-site "Building on Appilot" guide and
|
|
15
|
+
* docs/content-model/tools-client-actions.md.
|
|
16
|
+
*/
|
|
17
|
+
// --- Boot the assistant -----------------------------------------------------
|
|
18
|
+
export { bootAppilotWidget, getWidgetAvailability, subscribeWidgetAvailability, } from './widget/boot.js';
|
|
19
|
+
// --- Client actions (WebMCP-aligned) ---------------------------------------
|
|
20
|
+
export { registerTool, registerClientTool, unregisterClientTool, onClientToolsChange, hasNativeWebMcp, } from './webmcp/sdk.js';
|
|
21
|
+
// --- Coexisting with the host app's own modal layers ------------------------
|
|
22
|
+
export { isAppilotSurface, markAppilotSurface, APPILOT_SURFACE_ATTR, } from './dom/surfaces.js';
|
|
23
|
+
// --- Focused Sessions -------------------------------------------------------
|
|
24
|
+
export { startFocusedSession, resumeFocusedSession, getResumableFocusedSessionId, forgetFocusedSession, } from './focusedSessions/pageSdk.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `appilot/runtime`: INTERNAL runtime primitives the Appilot widget and
|
|
3
|
+
* browser-extension content scripts use to FULFILL the public SDK calls
|
|
4
|
+
* (register/collect/serialize/invoke client tools, execute the DOM tools +
|
|
5
|
+
* `http_fetch`, and back the Focused Sessions transport).
|
|
6
|
+
*
|
|
7
|
+
* This entry is NOT part of the public developer surface: host apps import from
|
|
8
|
+
* `appilot`. It is exposed as a subpath so the assistant surfaces (which
|
|
9
|
+
* DO ship this logic) import it without pulling it into the public barrel.
|
|
10
|
+
*/
|
|
11
|
+
export { runDomTool, resetDomToolTurn, type DomToolRequest } from './domResponder.js';
|
|
12
|
+
export { httpFetch, type HttpFetchArgs, type HttpFetchResult } from './httpFetch.js';
|
|
13
|
+
export { collectClientTools } from './webmcp/sdk.js';
|
|
14
|
+
export { invokeClientTool, serializeClientTools, normalizeResult, __resetClientToolRegistry, type SerializedClientTool, } from './webmcp/registry.js';
|
|
15
|
+
export { registerFocusedSessionProvider, type FocusedSessionProvider, } from './focusedSessions/pageSdk.js';
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `appilot/runtime`: INTERNAL runtime primitives the Appilot widget and
|
|
3
|
+
* browser-extension content scripts use to FULFILL the public SDK calls
|
|
4
|
+
* (register/collect/serialize/invoke client tools, execute the DOM tools +
|
|
5
|
+
* `http_fetch`, and back the Focused Sessions transport).
|
|
6
|
+
*
|
|
7
|
+
* This entry is NOT part of the public developer surface: host apps import from
|
|
8
|
+
* `appilot`. It is exposed as a subpath so the assistant surfaces (which
|
|
9
|
+
* DO ship this logic) import it without pulling it into the public barrel.
|
|
10
|
+
*/
|
|
11
|
+
// DOM tools + HTTP-proxy executor (the agent_dom_request round-trip).
|
|
12
|
+
export { runDomTool, resetDomToolTurn } from './domResponder.js';
|
|
13
|
+
export { httpFetch } from './httpFetch.js';
|
|
14
|
+
// WebMCP client-action collection / serialization / invocation.
|
|
15
|
+
export { collectClientTools } from './webmcp/sdk.js';
|
|
16
|
+
export { invokeClientTool, serializeClientTools, normalizeResult, __resetClientToolRegistry, } from './webmcp/registry.js';
|
|
17
|
+
// Focused Sessions transport provider (implemented by the assistant surface).
|
|
18
|
+
export { registerFocusedSessionProvider, } from './focusedSessions/pageSdk.js';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focused Sessions: the public type contract for the page-facing SDK surface
|
|
3
|
+
* (`startFocusedSession` and its handle). These are the types a host app touches
|
|
4
|
+
* when it starts an app-initiated, mission-scoped agent conversation and receives
|
|
5
|
+
* its structured outcome. See docs/agent/focused-sessions.md.
|
|
6
|
+
*
|
|
7
|
+
* This file is the SINGLE SOURCE OF TRUTH for the SDK-facing session types.
|
|
8
|
+
* `appilot-shared` re-exports these (type-only) from `appilot-shared/types` so
|
|
9
|
+
* backend/repository code keeps its existing import path; the mission/template
|
|
10
|
+
* cluster that only the backend needs stays in `appilot-shared`.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The RESOLVED mission frame a session runs under (the purely-runtime channel;
|
|
14
|
+
* the DB-anchored channel composes this server-side from a Session Template).
|
|
15
|
+
* Untrusted when page-supplied: sanitized + capped + gated by deployment config.
|
|
16
|
+
*/
|
|
17
|
+
export interface FocusedSessionMission {
|
|
18
|
+
/** Display name for the session header ("Role-play: unhappy customer"). */
|
|
19
|
+
name: string;
|
|
20
|
+
/**
|
|
21
|
+
* The mission prompt: function, persona, boundaries and completion criteria,
|
|
22
|
+
* with variable slots already substituted.
|
|
23
|
+
*/
|
|
24
|
+
prompt: string;
|
|
25
|
+
/**
|
|
26
|
+
* App/client tool names the session may use, on top of a minimal internal
|
|
27
|
+
* set. Empty/absent = no app or client tools. A session can only narrow the
|
|
28
|
+
* catalog, never widen it past what the tenant already has.
|
|
29
|
+
*/
|
|
30
|
+
allowed_tools?: string[];
|
|
31
|
+
/** Optional KB doc-id allowlist; absent = KB tools see the normal tenant scope. */
|
|
32
|
+
kb_doc_ids?: string[];
|
|
33
|
+
/** JSON Schema the `complete_session` outcome payload must satisfy. */
|
|
34
|
+
outcome_schema: Record<string, unknown>;
|
|
35
|
+
/** Turn budget; the session is abandoned server-side when exceeded. */
|
|
36
|
+
max_turns?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* How the assistant surface PRESENTS a focused session.
|
|
40
|
+
*
|
|
41
|
+
* A mission-scoped conversation is a different genre from ambient assistance,
|
|
42
|
+
* and the runtime has always known that (mission, closed tool scope,
|
|
43
|
+
* lifecycle, structured outcome). The UI did not: a role-play ran inside the
|
|
44
|
+
* ambient chrome plus a chip, so an end user saw a KB coverage diagnostic, an
|
|
45
|
+
* input that said "Ask anything about this page", and a one-click "New
|
|
46
|
+
* conversation" that silently orphaned the activity.
|
|
47
|
+
*
|
|
48
|
+
* THIS IS NOT A LAYOUT LANGUAGE, and keeping it that way is the point. Every
|
|
49
|
+
* field is a closed union or a plain string of COPY; none is a color, a size,
|
|
50
|
+
* a layout, a component, or markup. The rule for adding one: it must be
|
|
51
|
+
* justifiable for at least three unrelated app genres (a role-play, a support
|
|
52
|
+
* triage, a guided onboarding). A field only one app would ever set does not
|
|
53
|
+
* belong in a platform contract. A host that needs more than this renders it
|
|
54
|
+
* on its own page and lets the session drive it; an open presentation contract
|
|
55
|
+
* would just rebuild per-app bespoke UI in JSON, which is the cost Appilot
|
|
56
|
+
* exists to remove.
|
|
57
|
+
*/
|
|
58
|
+
export interface FocusedSessionPresentation {
|
|
59
|
+
/**
|
|
60
|
+
* `immersive` suppresses ambient-assistance furniture that is meaningless
|
|
61
|
+
* or harmful inside a mission: the KB coverage chip (an app-owner
|
|
62
|
+
* diagnostic), the welcome tip, and the "New conversation" affordance.
|
|
63
|
+
* Defaults to `ambient`, so every existing session is unchanged.
|
|
64
|
+
*/
|
|
65
|
+
chrome?: 'ambient' | 'immersive';
|
|
66
|
+
/**
|
|
67
|
+
* `agent` runs one opening turn at start, so the assistant speaks first.
|
|
68
|
+
* For an activity this is the difference between a chat window and
|
|
69
|
+
* something that has begun: a learner should not have to type "hello" at a
|
|
70
|
+
* persona to make a role-play start. Defaults to `user`.
|
|
71
|
+
*/
|
|
72
|
+
opening?: 'agent' | 'user';
|
|
73
|
+
/** Replaces "Ask anything about this page..." (e.g. "Reply to Mr. Weber"). */
|
|
74
|
+
input_placeholder?: string;
|
|
75
|
+
/** Relabels the session exit affordance (e.g. "End activity"). */
|
|
76
|
+
exit_label?: string;
|
|
77
|
+
}
|
|
78
|
+
/** Page SDK -> backend: start a focused session. Exactly one of template_id / mission. */
|
|
79
|
+
export interface FocusedSessionStartRequest {
|
|
80
|
+
/** DB-anchored channel: the Session Template to instantiate. */
|
|
81
|
+
template_id?: string;
|
|
82
|
+
/** Purely-runtime channel (dev/demos; gated by deployment config). */
|
|
83
|
+
mission?: FocusedSessionMission;
|
|
84
|
+
/** Values for the template's declared variable slots. */
|
|
85
|
+
variables?: Record<string, string>;
|
|
86
|
+
}
|
|
87
|
+
export interface FocusedSessionStartResult {
|
|
88
|
+
focused_session_id: string;
|
|
89
|
+
/** The conversation the session is bound to (1:1, never rotates). */
|
|
90
|
+
conversation_id: number;
|
|
91
|
+
/** Resolved display name for the session header. */
|
|
92
|
+
name: string;
|
|
93
|
+
/**
|
|
94
|
+
* How the assistant surface should present this session. Resolved
|
|
95
|
+
* server-side from the template; absent means ambient defaults.
|
|
96
|
+
*/
|
|
97
|
+
presentation?: FocusedSessionPresentation;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Payload of the `session_outcome` SSE event and of the host-page callback.
|
|
101
|
+
* Emitted once, on the turn where the agent calls `complete_session` with a
|
|
102
|
+
* schema-valid outcome. The outcome is AI output: apps must run it through their
|
|
103
|
+
* own review model, never treat it as human-validated truth.
|
|
104
|
+
*/
|
|
105
|
+
export interface FocusedSessionOutcomeEvent {
|
|
106
|
+
focused_session_id: string;
|
|
107
|
+
status: 'completed';
|
|
108
|
+
/** The schema-validated structured result. */
|
|
109
|
+
outcome: Record<string, unknown>;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* How a session reached a terminal state WITHOUT delivering an outcome
|
|
113
|
+
* (the `onEnded` page callback):
|
|
114
|
+
* - `abandoned`: the server abandoned it (turn budget exhausted, or an
|
|
115
|
+
* out-of-band end) and the assistant surface learned it from the typed
|
|
116
|
+
* stream error of the refused turn;
|
|
117
|
+
* - `failed`: the mission could not be fulfilled and no outcome will ever
|
|
118
|
+
* exist for it. Emitted server-side as the typed `SESSION_FAILED` stream
|
|
119
|
+
* error. Distinct from `abandoned`, which means the session ran out of
|
|
120
|
+
* room rather than out of premise;
|
|
121
|
+
* - `expired`: reserved for a future session TTL; surfaces map unknown
|
|
122
|
+
* terminal causes here;
|
|
123
|
+
* - `ended`: the page itself called `handle.end()`.
|
|
124
|
+
*
|
|
125
|
+
* NOTE the asymmetry with a mission that fails its PREFLIGHT: that session is
|
|
126
|
+
* never created, so it has no terminal status at all. `startFocusedSession`
|
|
127
|
+
* rejects instead, and the page renders its own error state. See
|
|
128
|
+
* docs/agent/focused-sessions.md § Preflight.
|
|
129
|
+
*/
|
|
130
|
+
export type FocusedSessionEndStatus = 'abandoned' | 'expired' | 'failed' | 'ended';
|
|
131
|
+
/**
|
|
132
|
+
* Payload of the page-facing `onEnded` callback: the session reached a terminal
|
|
133
|
+
* state WITHOUT having delivered an outcome. Fired exactly once per session, and
|
|
134
|
+
* NEVER for a session that delivered a `session_outcome` (`onOutcome` and
|
|
135
|
+
* `onEnded` are mutually exclusive).
|
|
136
|
+
*/
|
|
137
|
+
export interface FocusedSessionEndedEvent {
|
|
138
|
+
focused_session_id: string;
|
|
139
|
+
status: FocusedSessionEndStatus;
|
|
140
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focused Sessions: the public type contract for the page-facing SDK surface
|
|
3
|
+
* (`startFocusedSession` and its handle). These are the types a host app touches
|
|
4
|
+
* when it starts an app-initiated, mission-scoped agent conversation and receives
|
|
5
|
+
* its structured outcome. See docs/agent/focused-sessions.md.
|
|
6
|
+
*
|
|
7
|
+
* This file is the SINGLE SOURCE OF TRUTH for the SDK-facing session types.
|
|
8
|
+
* `appilot-shared` re-exports these (type-only) from `appilot-shared/types` so
|
|
9
|
+
* backend/repository code keeps its existing import path; the mission/template
|
|
10
|
+
* cluster that only the backend needs stays in `appilot-shared`.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page client-action registry: the WebMCP-aligned tool surface a host page
|
|
3
|
+
* declares at runtime so the Appilot agent can call its actions.
|
|
4
|
+
*
|
|
5
|
+
* WHY a window-anchored global (not a module singleton): the registry must be
|
|
6
|
+
* reachable from code that does NOT share this module instance.
|
|
7
|
+
* - Widget: the embedding page and the widget bundle are separate bundles in
|
|
8
|
+
* the same realm; a module-level singleton in one is invisible to the other.
|
|
9
|
+
* - Extension: the content-script runs in an isolated world; the page's tools
|
|
10
|
+
* live in the main world. A main-world bridge collects/invokes via this
|
|
11
|
+
* global. See packages/apps/extension/.../mainWorldWebmcp.ts.
|
|
12
|
+
* Anchoring on `window.__APPILOT_WEBMCP__` (mirroring `navigator.modelContext`)
|
|
13
|
+
* gives every party one stable handle. This file is the only place that reads
|
|
14
|
+
* or writes that global.
|
|
15
|
+
*
|
|
16
|
+
* Standard alignment: we bind to the stable WebMCP descriptor
|
|
17
|
+
* (name/description/inputSchema/execute → {content:[{type,text}]}) and treat the
|
|
18
|
+
* namespace (`navigator.modelContext` vs `document.modelContext`) as detect-only.
|
|
19
|
+
* See docs/content-model/tools-client-actions.md and webmcp-alignment.md.
|
|
20
|
+
*/
|
|
21
|
+
export interface ClientToolContent {
|
|
22
|
+
type: 'text';
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ClientToolResult {
|
|
26
|
+
content?: ClientToolContent[];
|
|
27
|
+
/** Loose escape hatch: handlers that return raw JSON are normalized too. */
|
|
28
|
+
[k: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface ClientToolDefinition {
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
/** JSON Schema for the args. Mirrors WebMCP `inputSchema`. */
|
|
34
|
+
inputSchema?: Record<string, unknown>;
|
|
35
|
+
/** Mirrors WebMCP `annotations.readOnlyHint`. Absent ⇒ treated as mutating. */
|
|
36
|
+
annotations?: {
|
|
37
|
+
readOnlyHint?: boolean;
|
|
38
|
+
};
|
|
39
|
+
execute: (input: Record<string, unknown>) => Promise<ClientToolResult | unknown> | ClientToolResult | unknown;
|
|
40
|
+
}
|
|
41
|
+
/** The agent-facing descriptor (no handler) shipped to the backend per turn. */
|
|
42
|
+
export interface SerializedClientTool {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
input_schema?: Record<string, unknown>;
|
|
46
|
+
read_only: boolean;
|
|
47
|
+
title?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface ClientToolHandle {
|
|
50
|
+
unregister(): void;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Register a client tool/action. Idempotent per name (re-registering replaces).
|
|
54
|
+
* Returns a handle whose `unregister()` removes it (use on route change).
|
|
55
|
+
*
|
|
56
|
+
* Secure-context only (HTTPS / localhost / *.lvh.me), matching the standard.
|
|
57
|
+
* On a non-secure context the call is a no-op and returns an inert handle.
|
|
58
|
+
*/
|
|
59
|
+
export declare function registerClientTool(def: ClientToolDefinition): ClientToolHandle;
|
|
60
|
+
/** Remove a registered tool by name. */
|
|
61
|
+
export declare function unregisterClientTool(name: string): void;
|
|
62
|
+
/** Subscribe to registry changes (WebMCP `toolchange` analogue). */
|
|
63
|
+
export declare function onClientToolsChange(listener: () => void): () => void;
|
|
64
|
+
/** Serialize the current registry to the agent-facing descriptors (no handlers). */
|
|
65
|
+
export declare function serializeClientTools(): SerializedClientTool[];
|
|
66
|
+
/**
|
|
67
|
+
* Invoke a registered handler by name and normalize the result to a plain
|
|
68
|
+
* value the agent loop can serialize. Returns a structured error object when
|
|
69
|
+
* the action is unknown or throws (never rejects).
|
|
70
|
+
*/
|
|
71
|
+
export declare function invokeClientTool(name: string, args: Record<string, unknown>): Promise<unknown>;
|
|
72
|
+
/** Collapse a WebMCP `{content:[{type,text}]}` result or raw JSON to a value. */
|
|
73
|
+
export declare function normalizeResult(raw: unknown): unknown;
|
|
74
|
+
/** Test/inspection helper: clear the global registry. */
|
|
75
|
+
export declare function __resetClientToolRegistry(): void;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page client-action registry: the WebMCP-aligned tool surface a host page
|
|
3
|
+
* declares at runtime so the Appilot agent can call its actions.
|
|
4
|
+
*
|
|
5
|
+
* WHY a window-anchored global (not a module singleton): the registry must be
|
|
6
|
+
* reachable from code that does NOT share this module instance.
|
|
7
|
+
* - Widget: the embedding page and the widget bundle are separate bundles in
|
|
8
|
+
* the same realm; a module-level singleton in one is invisible to the other.
|
|
9
|
+
* - Extension: the content-script runs in an isolated world; the page's tools
|
|
10
|
+
* live in the main world. A main-world bridge collects/invokes via this
|
|
11
|
+
* global. See packages/apps/extension/.../mainWorldWebmcp.ts.
|
|
12
|
+
* Anchoring on `window.__APPILOT_WEBMCP__` (mirroring `navigator.modelContext`)
|
|
13
|
+
* gives every party one stable handle. This file is the only place that reads
|
|
14
|
+
* or writes that global.
|
|
15
|
+
*
|
|
16
|
+
* Standard alignment: we bind to the stable WebMCP descriptor
|
|
17
|
+
* (name/description/inputSchema/execute → {content:[{type,text}]}) and treat the
|
|
18
|
+
* namespace (`navigator.modelContext` vs `document.modelContext`) as detect-only.
|
|
19
|
+
* See docs/content-model/tools-client-actions.md and webmcp-alignment.md.
|
|
20
|
+
*/
|
|
21
|
+
const GLOBAL_KEY = '__APPILOT_WEBMCP__';
|
|
22
|
+
function getWindow() {
|
|
23
|
+
return typeof window !== 'undefined' ? window : undefined;
|
|
24
|
+
}
|
|
25
|
+
function getRegistry() {
|
|
26
|
+
const w = getWindow();
|
|
27
|
+
if (!w)
|
|
28
|
+
return undefined;
|
|
29
|
+
const holder = w;
|
|
30
|
+
if (!holder[GLOBAL_KEY]) {
|
|
31
|
+
holder[GLOBAL_KEY] = { version: 1, tools: new Map(), listeners: new Set() };
|
|
32
|
+
}
|
|
33
|
+
return holder[GLOBAL_KEY];
|
|
34
|
+
}
|
|
35
|
+
function isSecureContextOk() {
|
|
36
|
+
const w = getWindow();
|
|
37
|
+
if (!w)
|
|
38
|
+
return false;
|
|
39
|
+
// Mirror WebMCP's [SecureContext]. Allow localhost/127.* for dev + playground.
|
|
40
|
+
if (w.isSecureContext)
|
|
41
|
+
return true;
|
|
42
|
+
const host = w.location?.hostname ?? '';
|
|
43
|
+
return host === 'localhost' || host === '127.0.0.1' || host.endsWith('.localhost') || host.endsWith('.lvh.me');
|
|
44
|
+
}
|
|
45
|
+
function notify(reg) {
|
|
46
|
+
for (const fn of reg.listeners) {
|
|
47
|
+
try {
|
|
48
|
+
fn();
|
|
49
|
+
}
|
|
50
|
+
catch { /* listener errors are non-fatal */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Register a client tool/action. Idempotent per name (re-registering replaces).
|
|
55
|
+
* Returns a handle whose `unregister()` removes it (use on route change).
|
|
56
|
+
*
|
|
57
|
+
* Secure-context only (HTTPS / localhost / *.lvh.me), matching the standard.
|
|
58
|
+
* On a non-secure context the call is a no-op and returns an inert handle.
|
|
59
|
+
*/
|
|
60
|
+
export function registerClientTool(def) {
|
|
61
|
+
const reg = getRegistry();
|
|
62
|
+
if (!reg || !isSecureContextOk()) {
|
|
63
|
+
return { unregister() { } };
|
|
64
|
+
}
|
|
65
|
+
if (!def || typeof def.name !== 'string' || typeof def.execute !== 'function') {
|
|
66
|
+
throw new Error('registerClientTool: { name, execute } are required');
|
|
67
|
+
}
|
|
68
|
+
reg.tools.set(def.name, def);
|
|
69
|
+
notify(reg);
|
|
70
|
+
let active = true;
|
|
71
|
+
return {
|
|
72
|
+
unregister() {
|
|
73
|
+
if (!active)
|
|
74
|
+
return;
|
|
75
|
+
active = false;
|
|
76
|
+
if (reg.tools.get(def.name) === def) {
|
|
77
|
+
reg.tools.delete(def.name);
|
|
78
|
+
notify(reg);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Remove a registered tool by name. */
|
|
84
|
+
export function unregisterClientTool(name) {
|
|
85
|
+
const reg = getRegistry();
|
|
86
|
+
if (!reg)
|
|
87
|
+
return;
|
|
88
|
+
if (reg.tools.delete(name))
|
|
89
|
+
notify(reg);
|
|
90
|
+
}
|
|
91
|
+
/** Subscribe to registry changes (WebMCP `toolchange` analogue). */
|
|
92
|
+
export function onClientToolsChange(listener) {
|
|
93
|
+
const reg = getRegistry();
|
|
94
|
+
if (!reg)
|
|
95
|
+
return () => { };
|
|
96
|
+
reg.listeners.add(listener);
|
|
97
|
+
return () => { reg.listeners.delete(listener); };
|
|
98
|
+
}
|
|
99
|
+
/** Serialize the current registry to the agent-facing descriptors (no handlers). */
|
|
100
|
+
export function serializeClientTools() {
|
|
101
|
+
const reg = getRegistry();
|
|
102
|
+
if (!reg)
|
|
103
|
+
return [];
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const def of reg.tools.values()) {
|
|
106
|
+
out.push({
|
|
107
|
+
name: def.name,
|
|
108
|
+
description: def.description ?? '',
|
|
109
|
+
input_schema: def.inputSchema,
|
|
110
|
+
read_only: def.annotations?.readOnlyHint === true,
|
|
111
|
+
title: def.name,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Invoke a registered handler by name and normalize the result to a plain
|
|
118
|
+
* value the agent loop can serialize. Returns a structured error object when
|
|
119
|
+
* the action is unknown or throws (never rejects).
|
|
120
|
+
*/
|
|
121
|
+
export async function invokeClientTool(name, args) {
|
|
122
|
+
const reg = getRegistry();
|
|
123
|
+
const def = reg?.tools.get(name);
|
|
124
|
+
if (!def) {
|
|
125
|
+
return { ok: false, error: 'ACTION_NOT_REGISTERED', detail: `no client action registered as "${name}"` };
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const raw = await def.execute(args ?? {});
|
|
129
|
+
return { ok: true, data: normalizeResult(raw) };
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
return { ok: false, error: 'ACTION_FAILED', detail: err instanceof Error ? err.message : String(err) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Collapse a WebMCP `{content:[{type,text}]}` result or raw JSON to a value. */
|
|
136
|
+
export function normalizeResult(raw) {
|
|
137
|
+
if (raw && typeof raw === 'object' && Array.isArray(raw.content)) {
|
|
138
|
+
const parts = raw.content;
|
|
139
|
+
const text = parts.filter(p => p && p.type === 'text').map(p => p.text).join('\n').trim();
|
|
140
|
+
if (text)
|
|
141
|
+
return text;
|
|
142
|
+
}
|
|
143
|
+
return raw;
|
|
144
|
+
}
|
|
145
|
+
/** Test/inspection helper: clear the global registry. */
|
|
146
|
+
export function __resetClientToolRegistry() {
|
|
147
|
+
const reg = getRegistry();
|
|
148
|
+
if (reg) {
|
|
149
|
+
reg.tools.clear();
|
|
150
|
+
reg.listeners.clear();
|
|
151
|
+
}
|
|
152
|
+
}
|