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 CHANGED
@@ -1,12 +1,106 @@
1
1
  # appilot
2
2
 
3
- This version is a name reservation, not a working package. It contains no code.
3
+ The Appilot browser SDK. Boot the assistant, let the agent act through your own
4
+ UI, and run mission-scoped sessions.
4
5
 
5
- Appilot adds intelligence to any web app: its AI helps people operate the app and
6
- act on their behalf, shipping as a browser extension or an embeddable widget.
7
- The developer SDK (widget boot, client-registered actions aligned with WebMCP,
8
- focused sessions) will publish here.
6
+ Browser-only, React-free, zero runtime dependencies.
9
7
 
10
- See https://appilot.space.
8
+ ```bash
9
+ npm install appilot
10
+ ```
11
11
 
12
- Maintained by BetterKnow GmbH.
12
+ Pair it with [`appilot-server`](https://www.npmjs.com/package/appilot-server),
13
+ which runs the identity relay in your backend. The assistant serves identified
14
+ users only, and the credential that mints that identity must never reach a
15
+ browser.
16
+
17
+ ## Boot the assistant
18
+
19
+ ```ts
20
+ import { bootAppilotWidget } from 'appilot';
21
+
22
+ bootAppilotWidget({
23
+ widgetScriptUrl: 'https://cdn.appilot.space/widget/v1/appilot.esm.js',
24
+ widgetKey: 'wk_live_…', // publishable; optional on a registered domain
25
+ tokenEndpoint: '/api/widget/token', // your relay
26
+ brandName: 'Acme', // names the assistant, not your app
27
+ });
28
+ ```
29
+
30
+ On a registered production domain the tenant is resolved from the page's domain,
31
+ so the key is optional. It is required on localhost, where no domain resolves.
32
+
33
+ ## Let the agent act in your app
34
+
35
+ `registerTool` declares a capability your page performs, in the user's own
36
+ session: navigate the SPA, open a modal, or a mutation your page already knows
37
+ how to do safely. It is WebMCP-aligned, so where the browser exposes a native
38
+ `navigator.modelContext` the same call registers there too.
39
+
40
+ ```ts
41
+ import { registerTool } from 'appilot';
42
+
43
+ const handle = registerTool({
44
+ name: 'open_booking',
45
+ description: 'Open a booking by id so the user can see it.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: { booking_id: { type: 'string' } },
49
+ required: ['booking_id'],
50
+ },
51
+ annotations: { readOnlyHint: true }, // absent or false means mutating,
52
+ // and the agent confirms first
53
+ async execute({ booking_id }) {
54
+ navigate(`/bookings/${booking_id}`);
55
+ return { content: [{ type: 'text', text: `Opened booking ${booking_id}.` }] };
56
+ },
57
+ });
58
+
59
+ handle.unregister(); // on route change
60
+ ```
61
+
62
+ Backend operations belong in an HTTP-proxy tool configured server-side instead,
63
+ where the credential stays out of the page.
64
+
65
+ ## Mission-scoped conversations
66
+
67
+ When the conversation *is* the unit of work (a role-play, a tutoring dialogue, an
68
+ assessment), your app opens a Focused Session against a template you authored and
69
+ receives a structured outcome.
70
+
71
+ ```ts
72
+ import { startFocusedSession, resumeFocusedSession } from 'appilot';
73
+
74
+ const handle = await startFocusedSession({
75
+ template_id: 'role-play-activity',
76
+ variables: { role: 'unhappy customer' },
77
+ });
78
+
79
+ handle.onOutcome(outcome => saveEvidence(outcome));
80
+ handle.onEnded(({ status }) => showEndedState(status)); // no outcome is coming
81
+ ```
82
+
83
+ `startFocusedSession` **rejects** when the template's mandatory grounding is
84
+ unavailable, and no session is created. That is the contract, not an edge case:
85
+ your page renders its own error state instead of a model narrating a backend
86
+ problem at your user.
87
+
88
+ After a full page reload, `resumeFocusedSession()` re-attaches to the session
89
+ this tab started, or resolves to `null` when there is nothing running. Ownership
90
+ is checked server-side.
91
+
92
+ Outcomes reach your page through the user's browser, so they are client-trusted.
93
+ Before treating one as evidence, re-read it server to server.
94
+
95
+ ## Also exported
96
+
97
+ `isAppilotSurface(element)` lets your own modal or focus trap recognize Appilot's
98
+ chrome and not dismiss itself when the user interacts with the assistant.
99
+
100
+ ## Docs
101
+
102
+ <https://docs.appilot.space>
103
+
104
+ ## License
105
+
106
+ ISC
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The Appilot surface contract: one attribute that identifies every piece of
3
+ * Appilot chrome living in the host page's top-level DOM.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * `docs/architecture/in-page-overlays.md` settled WHERE our chrome mounts
8
+ * (always under `document.documentElement`). It says nothing about what happens
9
+ * when the host app opens a modal layer, and that is where a co-resident
10
+ * assistant breaks.
11
+ *
12
+ * Every modal library (Radix, Headless UI, MUI, Ant, Vaadin, the native
13
+ * `<dialog>`) implements two behaviours that are hostile to us, because to them
14
+ * we are indistinguishable from the page underneath:
15
+ *
16
+ * 1. Dismiss-on-outside-interaction. A `pointerdown` or `focusin` outside the
17
+ * modal's own subtree closes it. Our chrome is ALWAYS outside, so clicking
18
+ * the Appilot launcher closes the user's half-filled form.
19
+ * 2. Focus trapping. Focus that lands outside the modal is yanked back. Even
20
+ * with (1) fixed, the user cannot type in our composer.
21
+ *
22
+ * A host app can opt out of both, but only if it can answer "is this element
23
+ * Appilot's?" cheaply and stably. Before this contract it had to guess between
24
+ * `#appilot-panel-container`, `#appilot-toggle`, `<appilot-assistant>` and
25
+ * `[data-appilot-widget]` (which is stamped on `<html>` itself, so a naive
26
+ * `closest()` matches the whole document). That is a trap, and hosts fell into
27
+ * it. Now there is one selector: `[data-appilot-surface]`.
28
+ *
29
+ * See `docs/architecture/host-modal-coexistence.md` for the host-side recipes.
30
+ */
31
+ /** The attribute every Appilot-injected top-level element carries. */
32
+ export declare const APPILOT_SURFACE_ATTR = "data-appilot-surface";
33
+ /**
34
+ * What a given piece of chrome is. Hosts may branch on the value (for example,
35
+ * allowing interaction with the panel but still dismissing on a stray click on
36
+ * a transient gesture shield), but the common case only tests for presence.
37
+ */
38
+ export type AppilotSurfaceKind =
39
+ /** The floating launcher button (extension and widget bubble layout). */
40
+ 'launcher'
41
+ /** The assistant panel itself, including its Shadow DOM host. */
42
+ | 'panel'
43
+ /** Transient drag/resize gesture shields. */
44
+ | 'shield'
45
+ /** The coach-mark bubble and its dim layer. */
46
+ | 'coach'
47
+ /** The cyan control-locate comet and impact ring. */
48
+ | 'locate'
49
+ /** The in-context editor's element-picker capture surface. */
50
+ | 'picker';
51
+ /**
52
+ * Stamp an element as Appilot chrome. Call this on EVERY element mounted into
53
+ * the host page's top-level DOM, at creation time, before it is appended.
54
+ *
55
+ * Internal to Appilot's own runtimes (widget, extension, shared overlays).
56
+ * Host apps consume the contract through {@link isAppilotSurface}.
57
+ */
58
+ export declare function markAppilotSurface<T extends Element>(element: T, kind: AppilotSurfaceKind): T;
59
+ /**
60
+ * Does this node belong to Appilot's chrome?
61
+ *
62
+ * Pass the target of a pointer or focus event. Returns true for the chrome
63
+ * element itself and for anything nested inside it, including across a Shadow
64
+ * DOM boundary: our panel lives in a shadow root, so a real click inside the
65
+ * composer retargets at the document level to the shadow HOST, which is the
66
+ * element that carries the attribute.
67
+ *
68
+ * Deliberately tolerant about its input (`EventTarget | null`, text nodes,
69
+ * detached nodes) because callers pass `event.target` straight through and a
70
+ * dismissal guard must never throw.
71
+ *
72
+ * @example Radix / shadcn dialog
73
+ * ```tsx
74
+ * <DialogContent
75
+ * onInteractOutside={(event) => {
76
+ * if (isAppilotSurface(event.target)) event.preventDefault();
77
+ * }}
78
+ * >
79
+ * ```
80
+ */
81
+ export declare function isAppilotSurface(target: EventTarget | Node | null | undefined): boolean;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The Appilot surface contract: one attribute that identifies every piece of
3
+ * Appilot chrome living in the host page's top-level DOM.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * `docs/architecture/in-page-overlays.md` settled WHERE our chrome mounts
8
+ * (always under `document.documentElement`). It says nothing about what happens
9
+ * when the host app opens a modal layer, and that is where a co-resident
10
+ * assistant breaks.
11
+ *
12
+ * Every modal library (Radix, Headless UI, MUI, Ant, Vaadin, the native
13
+ * `<dialog>`) implements two behaviours that are hostile to us, because to them
14
+ * we are indistinguishable from the page underneath:
15
+ *
16
+ * 1. Dismiss-on-outside-interaction. A `pointerdown` or `focusin` outside the
17
+ * modal's own subtree closes it. Our chrome is ALWAYS outside, so clicking
18
+ * the Appilot launcher closes the user's half-filled form.
19
+ * 2. Focus trapping. Focus that lands outside the modal is yanked back. Even
20
+ * with (1) fixed, the user cannot type in our composer.
21
+ *
22
+ * A host app can opt out of both, but only if it can answer "is this element
23
+ * Appilot's?" cheaply and stably. Before this contract it had to guess between
24
+ * `#appilot-panel-container`, `#appilot-toggle`, `<appilot-assistant>` and
25
+ * `[data-appilot-widget]` (which is stamped on `<html>` itself, so a naive
26
+ * `closest()` matches the whole document). That is a trap, and hosts fell into
27
+ * it. Now there is one selector: `[data-appilot-surface]`.
28
+ *
29
+ * See `docs/architecture/host-modal-coexistence.md` for the host-side recipes.
30
+ */
31
+ /** The attribute every Appilot-injected top-level element carries. */
32
+ export const APPILOT_SURFACE_ATTR = 'data-appilot-surface';
33
+ /**
34
+ * Stamp an element as Appilot chrome. Call this on EVERY element mounted into
35
+ * the host page's top-level DOM, at creation time, before it is appended.
36
+ *
37
+ * Internal to Appilot's own runtimes (widget, extension, shared overlays).
38
+ * Host apps consume the contract through {@link isAppilotSurface}.
39
+ */
40
+ export function markAppilotSurface(element, kind) {
41
+ element.setAttribute(APPILOT_SURFACE_ATTR, kind);
42
+ return element;
43
+ }
44
+ /**
45
+ * Does this node belong to Appilot's chrome?
46
+ *
47
+ * Pass the target of a pointer or focus event. Returns true for the chrome
48
+ * element itself and for anything nested inside it, including across a Shadow
49
+ * DOM boundary: our panel lives in a shadow root, so a real click inside the
50
+ * composer retargets at the document level to the shadow HOST, which is the
51
+ * element that carries the attribute.
52
+ *
53
+ * Deliberately tolerant about its input (`EventTarget | null`, text nodes,
54
+ * detached nodes) because callers pass `event.target` straight through and a
55
+ * dismissal guard must never throw.
56
+ *
57
+ * @example Radix / shadcn dialog
58
+ * ```tsx
59
+ * <DialogContent
60
+ * onInteractOutside={(event) => {
61
+ * if (isAppilotSurface(event.target)) event.preventDefault();
62
+ * }}
63
+ * >
64
+ * ```
65
+ */
66
+ export function isAppilotSurface(target) {
67
+ const element = toElement(target);
68
+ if (!element)
69
+ return false;
70
+ // Walk the composed tree by hand rather than trusting `closest()`: `closest()`
71
+ // stops at a shadow root, and an element inside a nested shadow tree would
72
+ // report no match even though its host is ours.
73
+ let node = element;
74
+ while (node) {
75
+ if (node instanceof Element && node.hasAttribute(APPILOT_SURFACE_ATTR))
76
+ return true;
77
+ const parent = node.parentNode;
78
+ node = parent instanceof ShadowRoot ? parent.host : parent;
79
+ }
80
+ return false;
81
+ }
82
+ function toElement(target) {
83
+ if (!target)
84
+ return null;
85
+ if (target instanceof Element)
86
+ return target;
87
+ // Text nodes are legitimate event targets in some engines; climb to the element.
88
+ if (target instanceof Node)
89
+ return target.parentElement;
90
+ return null;
91
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Compute a unique-enough CSS selector for a live element.
3
+ *
4
+ * Shared by:
5
+ * - the in-page DOM responder (`agents/domResponder.ts`), which stamps a
6
+ * `selector` onto every element hit at observe time so the backend can mint
7
+ * a server-trusted `element_ref` -> `dom_target` (Universal Mode Stage 1);
8
+ * - the action-plan runtime's DOM-target resolver
9
+ * (`components/actionPlans/utils/resolveDomTarget.ts`), which re-derives a
10
+ * fresh selector when a page step's original selector goes stale after a
11
+ * re-render (Stage 2 fallback).
12
+ *
13
+ * Strategy, cheapest-first:
14
+ * 1. A stable `#id` when the id is unique and CSS-safe.
15
+ * 2. Otherwise walk up to the nearest id-anchored ancestor (or the root),
16
+ * appending `tag:nth-of-type(k)` segments, stopping as soon as the partial
17
+ * selector matches exactly one element.
18
+ * Returns '' when no selector can be derived (the element is then
19
+ * unreferenceable; callers fall back to role/name or pause).
20
+ *
21
+ * This NEVER trusts model input: it reads only the live DOM.
22
+ */
23
+ export declare function computeUniqueSelector(el: Element): string;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Compute a unique-enough CSS selector for a live element.
3
+ *
4
+ * Shared by:
5
+ * - the in-page DOM responder (`agents/domResponder.ts`), which stamps a
6
+ * `selector` onto every element hit at observe time so the backend can mint
7
+ * a server-trusted `element_ref` -> `dom_target` (Universal Mode Stage 1);
8
+ * - the action-plan runtime's DOM-target resolver
9
+ * (`components/actionPlans/utils/resolveDomTarget.ts`), which re-derives a
10
+ * fresh selector when a page step's original selector goes stale after a
11
+ * re-render (Stage 2 fallback).
12
+ *
13
+ * Strategy, cheapest-first:
14
+ * 1. A stable `#id` when the id is unique and CSS-safe.
15
+ * 2. Otherwise walk up to the nearest id-anchored ancestor (or the root),
16
+ * appending `tag:nth-of-type(k)` segments, stopping as soon as the partial
17
+ * selector matches exactly one element.
18
+ * Returns '' when no selector can be derived (the element is then
19
+ * unreferenceable; callers fall back to role/name or pause).
20
+ *
21
+ * This NEVER trusts model input: it reads only the live DOM.
22
+ */
23
+ function cssEscape(value) {
24
+ const esc = window.CSS?.escape;
25
+ if (typeof esc === 'function')
26
+ return esc(value);
27
+ // Minimal fallback: escape characters that break attribute/id selectors.
28
+ return value.replace(/([^a-zA-Z0-9_-])/g, '\\$1');
29
+ }
30
+ function isUniqueSelector(selector) {
31
+ try {
32
+ return document.querySelectorAll(selector).length === 1;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function nthOfTypeSegment(el) {
39
+ const tag = el.tagName.toLowerCase();
40
+ const parent = el.parentElement;
41
+ if (!parent)
42
+ return tag;
43
+ const sameTag = Array.from(parent.children).filter(c => c.tagName === el.tagName);
44
+ if (sameTag.length === 1)
45
+ return tag;
46
+ const index = sameTag.indexOf(el) + 1;
47
+ return `${tag}:nth-of-type(${index})`;
48
+ }
49
+ // Per-element selector cache. The walk below issues up to 12
50
+ // `querySelectorAll(candidate)` uniqueness probes per element; the action-plan
51
+ // DOM-target heal re-derives a selector for the same live element on repeated
52
+ // ticks, so caching collapses that to a SINGLE validating `querySelector` on
53
+ // the cache hit. The cache is invalidated structurally: a cached selector is
54
+ // only returned when it still resolves to the SAME element (a re-render that
55
+ // moves the element produces a miss and a fresh compute). A WeakMap keyed by
56
+ // the element never leaks across detached nodes.
57
+ const selectorCache = new WeakMap();
58
+ export function computeUniqueSelector(el) {
59
+ if (!(el instanceof Element))
60
+ return '';
61
+ const cached = selectorCache.get(el);
62
+ if (cached) {
63
+ try {
64
+ if (document.querySelector(cached) === el)
65
+ return cached;
66
+ }
67
+ catch {
68
+ /* cached selector went malformed against a changed DOM: recompute */
69
+ }
70
+ }
71
+ const result = computeUniqueSelectorUncached(el);
72
+ if (result)
73
+ selectorCache.set(el, result);
74
+ return result;
75
+ }
76
+ function computeUniqueSelectorUncached(el) {
77
+ const id = el.getAttribute('id');
78
+ if (id && /^[A-Za-z][\w-]*$/.test(id)) {
79
+ const byId = `#${cssEscape(id)}`;
80
+ if (isUniqueSelector(byId))
81
+ return byId;
82
+ }
83
+ const segments = [];
84
+ let node = el;
85
+ let depth = 0;
86
+ while (node && node.nodeType === 1 && depth < 12) {
87
+ const nodeId = node.getAttribute('id');
88
+ if (nodeId && /^[A-Za-z][\w-]*$/.test(nodeId) && isUniqueSelector(`#${cssEscape(nodeId)}`)) {
89
+ segments.unshift(`#${cssEscape(nodeId)}`);
90
+ const candidate = segments.join(' > ');
91
+ if (isUniqueSelector(candidate))
92
+ return candidate;
93
+ break;
94
+ }
95
+ segments.unshift(nthOfTypeSegment(node));
96
+ const candidate = segments.join(' > ');
97
+ if (isUniqueSelector(candidate))
98
+ return candidate;
99
+ node = node.parentElement;
100
+ depth++;
101
+ }
102
+ const full = segments.join(' > ');
103
+ return isUniqueSelector(full) ? full : '';
104
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * In-page DOM responder for the agent runtime.
3
+ *
4
+ * Implements the six Web-MCP-aligned DOM tools (plus the `http_fetch`
5
+ * HTTP-proxy tool) the agent calls via the agent_dom_request SSE round-trip.
6
+ * See docs/agent/dom-tools.md for the wire format.
7
+ *
8
+ * This module is the in-page side of the contract, shared by both surfaces:
9
+ * - Chrome extension: invoked from the content-script message listener
10
+ * (content.ts) when the background relays an agent_dom_request to the
11
+ * active tab.
12
+ * - Embeddable widget: invoked directly by the widget's AgentDomBridge
13
+ * (services/AgentDomBridge.ts), which intercepts agent_dom_request on the
14
+ * widget SSE stream and POSTs the result to /widget/agent/dom-response.
15
+ *
16
+ * Element-id correlation: each call to get_page_outline / find_elements /
17
+ * inspect_element returns ephemeral ids that subsequent calls (inspect /
18
+ * wait_for) can dereference. Ids are kept in `currentTurnElementMap`; the
19
+ * map is cleared on each NEW turn (`resetTurn` on the request, or
20
+ * `resetDomToolTurn()`).
21
+ *
22
+ * Authoring controls bridge: when find_elements / inspect_element hit an
23
+ * element matched by an authored Control's locator, the `matches_authored_control`
24
+ * field returns the Control's semantic_id. The Controls registry is provided
25
+ * per turn (controlsForTurn).
26
+ */
27
+ /** Clear the per-turn element-id map. Call at the start of every new turn. */
28
+ export declare function resetDomToolTurn(): void;
29
+ export interface DomToolRequest {
30
+ tool: string;
31
+ args: Record<string, unknown>;
32
+ controlsForTurn?: Array<{
33
+ semantic_id: string;
34
+ locator: string;
35
+ }>;
36
+ resetTurn?: boolean;
37
+ }
38
+ export declare function runDomTool(req: DomToolRequest): Promise<unknown>;