@fiodos/web-core 0.1.12 → 0.1.15

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 (44) hide show
  1. package/dist/cjs/adapters/webCredentialAutofillAdapter.d.ts +16 -0
  2. package/dist/cjs/adapters/webCredentialAutofillAdapter.js +47 -0
  3. package/dist/cjs/api/backendClient.d.ts +10 -3
  4. package/dist/cjs/api/backendClient.js +31 -9
  5. package/dist/cjs/config/types.d.ts +22 -2
  6. package/dist/cjs/controller/AgentController.d.ts +14 -0
  7. package/dist/cjs/controller/AgentController.js +65 -2
  8. package/dist/cjs/dropin/createFiodosAgent.d.ts +27 -1
  9. package/dist/cjs/dropin/createFiodosAgent.js +48 -4
  10. package/dist/cjs/embed/dynamics.d.ts +61 -0
  11. package/dist/cjs/embed/dynamics.js +173 -0
  12. package/dist/cjs/embed/global.d.ts +45 -0
  13. package/dist/cjs/embed/global.js +48 -0
  14. package/dist/cjs/index.d.ts +3 -0
  15. package/dist/cjs/index.js +8 -1
  16. package/dist/cjs/orb/mountOrb.js +172 -28
  17. package/dist/cjs/orb/orbView.js +42 -8
  18. package/dist/cjs/orb/publishedConfig.d.ts +4 -2
  19. package/dist/cjs/orb/publishedConfig.js +56 -7
  20. package/dist/cjs/version.d.ts +1 -1
  21. package/dist/cjs/version.js +1 -1
  22. package/dist/embed/fiodos-embed.js +66 -0
  23. package/dist/esm/adapters/webCredentialAutofillAdapter.d.ts +16 -0
  24. package/dist/esm/adapters/webCredentialAutofillAdapter.js +44 -0
  25. package/dist/esm/api/backendClient.d.ts +10 -3
  26. package/dist/esm/api/backendClient.js +32 -10
  27. package/dist/esm/config/types.d.ts +22 -2
  28. package/dist/esm/controller/AgentController.d.ts +14 -0
  29. package/dist/esm/controller/AgentController.js +65 -2
  30. package/dist/esm/dropin/createFiodosAgent.d.ts +27 -1
  31. package/dist/esm/dropin/createFiodosAgent.js +48 -4
  32. package/dist/esm/embed/dynamics.d.ts +61 -0
  33. package/dist/esm/embed/dynamics.js +168 -0
  34. package/dist/esm/embed/global.d.ts +45 -0
  35. package/dist/esm/embed/global.js +46 -0
  36. package/dist/esm/index.d.ts +3 -0
  37. package/dist/esm/index.js +3 -0
  38. package/dist/esm/orb/mountOrb.js +173 -29
  39. package/dist/esm/orb/orbView.js +43 -9
  40. package/dist/esm/orb/publishedConfig.d.ts +4 -2
  41. package/dist/esm/orb/publishedConfig.js +56 -7
  42. package/dist/esm/version.d.ts +1 -1
  43. package/dist/esm/version.js +1 -1
  44. package/package.json +5 -3
@@ -3,7 +3,7 @@
3
3
  * Mirrors @fiodos/react's FiodosWebAgentConfig (same adapters, same lifecycle).
4
4
  * Shared by every framework binding through the AgentController.
5
5
  */
6
- import type { ActionRegistries, AgentBackendClient, AgentMessages, AppManifest, ConfirmationLexicon, MessageCatalog, NavigationAdapter, Sanitizer, StorageAdapter, TelemetryAdapter, VoiceAdapter } from '@fiodos/core';
6
+ import type { ActionRegistries, AgentBackendClient, AgentMessages, AppManifest, ConfirmationLexicon, CredentialAutofillAdapter, MessageCatalog, NavigationAdapter, Sanitizer, StorageAdapter, TelemetryAdapter, VoiceAdapter } from '@fiodos/core';
7
7
  import type { ResolveUiMessagesOptions, WebUiMessages } from '../ui/messages.js';
8
8
  export type AgentPhase = 'idle' | 'listening' | 'thinking' | 'speaking' | 'confirming';
9
9
  export interface AgentTimings {
@@ -44,6 +44,13 @@ export interface FiodosWebAgentConfig {
44
44
  manifest: AppManifest;
45
45
  /** Agent locale (reply language, message catalogs). MANDATORY. */
46
46
  locale: string;
47
+ /**
48
+ * LIVE locale resolver. When set, the orb re-reads it periodically so the
49
+ * thought bubble follows the app's ACTIVE language (host language switcher,
50
+ * `<html lang>`, i18n runtime) without remounting. Falls back to `locale`
51
+ * whenever it returns nothing.
52
+ */
53
+ getLocale?: () => string | null | undefined;
47
54
  /** Speech recognition locale, e.g. 'en-US', 'es-ES'. MANDATORY. */
48
55
  sttLocale: string;
49
56
  /** Device-TTS fallback locale. Defaults to sttLocale. */
@@ -55,7 +62,20 @@ export interface FiodosWebAgentConfig {
55
62
  storage: StorageAdapter;
56
63
  telemetry?: TelemetryAdapter;
57
64
  /** Host auth check, required by manifest actions declaring requiresAuth. */
58
- isAuthenticated?: () => boolean;
65
+ isAuthenticated?: () => boolean | null | undefined;
66
+ /**
67
+ * Fine-grained session capability flags from the wired session bridge
68
+ * (audience map: e.g. { has_payment_method: true }). Sent with each turn so
69
+ * the backend shows this user only THEIR face of the orb. Boolean flags
70
+ * only — never user data. null/undefined = unknown (nothing is filtered).
71
+ */
72
+ getSessionCapabilities?: () => Record<string, boolean> | null | undefined;
73
+ /**
74
+ * Platform saved-credential service powering the built-in SMART SIGN-IN
75
+ * flow (browser password manager). Defaults to the web Credential
76
+ * Management adapter; pass `null` to disable the feature entirely.
77
+ */
78
+ credentialAutofill?: CredentialAutofillAdapter | null;
59
79
  /**
60
80
  * Host end-user identity. Conversation memory is namespaced per identity
61
81
  * (hashed LOCALLY, never sent by this module): logout/account switches swap
@@ -74,6 +74,7 @@ export declare class AgentController {
74
74
  private readonly telemetry;
75
75
  private readonly consent;
76
76
  private readonly executor;
77
+ private readonly credentialAutofill;
77
78
  private readonly timings;
78
79
  private readonly chaining;
79
80
  private readonly ttsLocale;
@@ -191,6 +192,19 @@ export declare class AgentController {
191
192
  confirmPendingAction(): Promise<void>;
192
193
  cancelPendingAction(): void;
193
194
  private handleConfirmationTranscript;
195
+ /**
196
+ * Device capability flags advertised on each turn (never user data). Fresh
197
+ * per turn: cheap, and honest if the environment changes mid-session.
198
+ */
199
+ private platformCapabilities;
200
+ /**
201
+ * Live session verdict advertised on each turn (audience map — never user
202
+ * data): whether the user is signed in plus the app's boolean capability
203
+ * flags. Fresh per turn so login/logout mid-session is honest. Undefined
204
+ * when the host wired neither source (older installs — backend filters
205
+ * nothing, exactly today's behavior).
206
+ */
207
+ private sessionContext;
194
208
  private routeLabel;
195
209
  private actionLabel;
196
210
  /**
@@ -16,6 +16,7 @@
16
16
  * - The agent only ever executes actions declared in the manifest.
17
17
  */
18
18
  import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, buildBubbleExchangeWindow, BUBBLE_PAGE_EXCHANGES, clearPersistedConversation, conversationIdentityKey, conversationRetentionOptions, createConversationSession, persistConversationSession, restoreConversationSession, defaultRouteContextText, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
19
+ import { createWebCredentialAutofillAdapter } from '../adapters/webCredentialAutofillAdapter.js';
19
20
  import { AgentApiError } from '../api/errors.js';
20
21
  import { formatAgentTurnError } from '../api/formatTurnError.js';
21
22
  import { buildManifestPayload } from '../api/backendClient.js';
@@ -91,12 +92,19 @@ export class AgentController {
91
92
  appId: config.manifest.appId,
92
93
  version: config.consentVersion,
93
94
  });
95
+ // SMART SIGN-IN ships by default on web (browser password manager);
96
+ // hosts can override the adapter or pass null to opt out entirely.
97
+ this.credentialAutofill =
98
+ config.credentialAutofill === undefined
99
+ ? createWebCredentialAutofillAdapter()
100
+ : config.credentialAutofill;
94
101
  this.executor = createActionExecutor({
95
102
  manifest: config.manifest,
96
103
  registries: config.registries,
97
104
  navigation: config.navigation,
98
105
  messages: this.messages,
99
106
  isAuthenticated: config.isAuthenticated,
107
+ credentialAutofill: this.credentialAutofill,
100
108
  });
101
109
  this.timings = { ...DEFAULT_AGENT_TIMINGS, ...config.timings };
102
110
  // Chaining OFF by default; clamp to the same safe bounds as react/react-native.
@@ -581,6 +589,57 @@ export class AgentController {
581
589
  this.beginConfirmationVoiceWindow();
582
590
  }
583
591
  }
592
+ /**
593
+ * Device capability flags advertised on each turn (never user data). Fresh
594
+ * per turn: cheap, and honest if the environment changes mid-session.
595
+ */
596
+ platformCapabilities() {
597
+ let autofill = false;
598
+ try {
599
+ autofill = this.credentialAutofill?.isSupported() === true;
600
+ }
601
+ catch {
602
+ autofill = false;
603
+ }
604
+ return autofill ? { credentialAutofill: true } : undefined;
605
+ }
606
+ /**
607
+ * Live session verdict advertised on each turn (audience map — never user
608
+ * data): whether the user is signed in plus the app's boolean capability
609
+ * flags. Fresh per turn so login/logout mid-session is honest. Undefined
610
+ * when the host wired neither source (older installs — backend filters
611
+ * nothing, exactly today's behavior).
612
+ */
613
+ sessionContext() {
614
+ const { isAuthenticated, getSessionCapabilities } = this.config;
615
+ if (!isAuthenticated && !getSessionCapabilities)
616
+ return undefined;
617
+ let authenticated = null;
618
+ try {
619
+ const v = isAuthenticated?.();
620
+ authenticated = v == null ? null : Boolean(v);
621
+ }
622
+ catch {
623
+ authenticated = null;
624
+ }
625
+ let capabilities;
626
+ try {
627
+ const raw = getSessionCapabilities?.();
628
+ if (raw && typeof raw === 'object') {
629
+ const clean = {};
630
+ for (const [k, v] of Object.entries(raw)) {
631
+ if (typeof v === 'boolean')
632
+ clean[k] = v;
633
+ }
634
+ if (Object.keys(clean).length > 0)
635
+ capabilities = clean;
636
+ }
637
+ }
638
+ catch {
639
+ capabilities = undefined;
640
+ }
641
+ return { authenticated, ...(capabilities ? { capabilities } : {}) };
642
+ }
584
643
  routeLabel(intent) {
585
644
  return this.config.manifest.routes.find((r) => r.intent === intent)?.label;
586
645
  }
@@ -766,7 +825,9 @@ export class AgentController {
766
825
  isContinuation: true,
767
826
  chainStep: step,
768
827
  lastStep,
769
- ...buildManifestPayload(this.config.manifest),
828
+ platformCapabilities: this.platformCapabilities(),
829
+ sessionContext: this.sessionContext(),
830
+ ...buildManifestPayload(this.config.manifest, this.config.registries),
770
831
  }, { signal: controller.signal });
771
832
  oc = await this.applyOutcome(turn, routeNow, snap?.context ?? {}, state.userMessage);
772
833
  }
@@ -923,7 +984,9 @@ export class AgentController {
923
984
  conversationHistory: conv.history,
924
985
  conversationSummary: conv.summary ?? undefined,
925
986
  sessionId: this.sessionId,
926
- ...buildManifestPayload(this.config.manifest),
987
+ platformCapabilities: this.platformCapabilities(),
988
+ sessionContext: this.sessionContext(),
989
+ ...buildManifestPayload(this.config.manifest, this.config.registries),
927
990
  }, { signal: controller.signal });
928
991
  outcome = await this.applyOutcome(turn, currentRoute, snapshot?.context ?? {}, text);
929
992
  }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { type ActionRegistries, type AppManifest } from '@fiodos/core';
15
15
  import { AgentController } from '../controller/AgentController.js';
16
+ import type { AgentScreenSnapshot } from '../context/screenContextStore.js';
16
17
  import { type MountOrbOptions, type MountedOrb } from '../orb/mountOrb.js';
17
18
  import type { FiodosWebAgentConfig } from '../config/types.js';
18
19
  export interface CreateFiodosAgentOptions {
@@ -37,8 +38,22 @@ export interface CreateFiodosAgentOptions {
37
38
  sttLocale?: string;
38
39
  /** Host router navigate delegate. Defaults to location.assign. */
39
40
  navigate?: (route: string) => void;
41
+ /**
42
+ * Current route/screen resolver for the agent's screen awareness. Defaults
43
+ * to location.pathname + search. Apps whose screens are CLIENT STATE (no URL
44
+ * change) pass the generated `fyodosGetCurrentRoute` here so the orb knows
45
+ * which screen the user is on (see screen-truth wiring).
46
+ */
47
+ getCurrentRoute?: () => string | null;
40
48
  /** Host auth check for actions requiring auth. */
41
- isAuthenticated?: () => boolean;
49
+ isAuthenticated?: () => boolean | null | undefined;
50
+ /**
51
+ * Fine-grained session capability flags (audience map, e.g.
52
+ * { has_payment_method: true }) from the generated session wiring. Boolean
53
+ * flags only; sent with each turn so the orb shows this user only THEIR
54
+ * available actions.
55
+ */
56
+ getSessionCapabilities?: () => Record<string, boolean> | null | undefined;
42
57
  /** Voice id forwarded to the backend TTS. */
43
58
  ttsVoice?: string | (() => string | undefined);
44
59
  /** End-user id for rate limiting / telemetry correlation. */
@@ -64,6 +79,17 @@ export interface FiodosAgentHandle {
64
79
  * Call it from the app's logout for a hard wipe; no-op until ready.
65
80
  */
66
81
  resetConversation(): void;
82
+ /**
83
+ * Publishes WHAT THE USER IS SEEING so 'execute' actions can target it —
84
+ * the embed-host equivalent of the React `useAgentScreenContext` hook.
85
+ * Hosts without our SDK on screens (Dynamics web resources, WinCC CWCs)
86
+ * call this whenever their form/record/view changes. On the auto-fetch
87
+ * path, calls made before the controller is ready are kept and applied on
88
+ * ready, so hosts don't need to await `ready` themselves.
89
+ */
90
+ setScreenContext(snapshot: AgentScreenSnapshot): void;
91
+ /** Clears a previously published snapshot (e.g. the record was closed). */
92
+ clearScreenContext(): void;
67
93
  destroy(): void;
68
94
  }
69
95
  export declare function createFiodosAgent(options: CreateFiodosAgentOptions): FiodosAgentHandle;
@@ -29,6 +29,11 @@ function browserLocale() {
29
29
  const lang = typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en-US';
30
30
  return { locale: lang.split('-')[0] || 'en', sttLocale: lang };
31
31
  }
32
+ function documentLocale() {
33
+ return typeof document !== 'undefined'
34
+ ? document.documentElement.lang?.split(/[-_]/)[0]?.trim() || ''
35
+ : '';
36
+ }
32
37
  function resolveAgentLocale(options) {
33
38
  const fromApp = options.getLocale?.()?.trim();
34
39
  if (fromApp)
@@ -38,12 +43,16 @@ function resolveAgentLocale(options) {
38
43
  if (options.localeDetection === 'browser')
39
44
  return browserLocale().locale;
40
45
  if (options.localeDetection === 'document') {
41
- const docLang = typeof document !== 'undefined'
42
- ? document.documentElement.lang?.split(/[-_]/)[0]?.trim()
43
- : '';
46
+ const docLang = documentLocale();
44
47
  if (docLang)
45
48
  return docLang;
46
49
  }
50
+ // Default (nothing configured): follow the HOST PAGE's language when the
51
+ // page declares one (<html lang>), so the orb bubble speaks the language the
52
+ // end user is actually browsing in. English only as the last resort.
53
+ const docLang = documentLocale();
54
+ if (docLang)
55
+ return docLang;
47
56
  return 'en';
48
57
  }
49
58
  /** Build the controller + adapters from a known manifest (shared by both paths). */
@@ -64,14 +73,22 @@ function assemble(options, manifest, baseUrl) {
64
73
  const config = {
65
74
  manifest,
66
75
  locale,
76
+ // Live resolver: re-runs the same resolution chain (app getLocale → static
77
+ // locale → detection) on every read, so the bubble follows the host app's
78
+ // language switcher / <html lang> without remounting the agent.
79
+ getLocale: () => resolveAgentLocale(options),
67
80
  sttLocale,
68
- navigation: createWebNavigationAdapter({ navigate: options.navigate ?? defaultNavigate }),
81
+ navigation: createWebNavigationAdapter({
82
+ navigate: options.navigate ?? defaultNavigate,
83
+ getCurrentRoute: options.getCurrentRoute,
84
+ }),
69
85
  registries: options.registries ?? { handlers: {}, idempotencyCheckers: {} },
70
86
  backend,
71
87
  voice: createWebVoiceAdapter(),
72
88
  storage: createWebStorageAdapter(),
73
89
  telemetry,
74
90
  isAuthenticated: options.isAuthenticated,
91
+ getSessionCapabilities: options.getSessionCapabilities,
75
92
  getUserId: options.getUserId,
76
93
  ttsVoice: options.ttsVoice,
77
94
  ...options.configOverrides,
@@ -83,6 +100,9 @@ function assemble(options, manifest, baseUrl) {
83
100
  export function createFiodosAgent(options) {
84
101
  const baseUrl = (options.baseUrl ?? DEFAULT_WEB_API_URL).replace(/\/$/, '');
85
102
  const shouldMount = options.mount !== false;
103
+ // One stable owner id per handle: the host is "one screen" from the store's
104
+ // point of view, so consecutive setScreenContext calls replace each other.
105
+ const contextOwner = Symbol('fiodos-embed-host');
86
106
  // ── Manual path: manifest provided → fully synchronous (back-compat). ───────
87
107
  if (options.manifest) {
88
108
  const controller = assemble(options, options.manifest, baseUrl);
@@ -98,6 +118,12 @@ export function createFiodosAgent(options) {
98
118
  resetConversation() {
99
119
  controller.resetConversation();
100
120
  },
121
+ setScreenContext(snapshot) {
122
+ controller.screenStore.set(contextOwner, snapshot);
123
+ },
124
+ clearScreenContext() {
125
+ controller.screenStore.clear(contextOwner);
126
+ },
101
127
  destroy() {
102
128
  orb?.unmount();
103
129
  controller.dispose();
@@ -106,6 +132,9 @@ export function createFiodosAgent(options) {
106
132
  return handle;
107
133
  }
108
134
  // ── Auto path (React parity): fetch manifest by apiKey, then assemble. ──────
135
+ // Context published before the controller resolves is buffered (last one
136
+ // wins) and applied on ready — hosts fire form events without awaiting us.
137
+ let pendingSnapshot;
109
138
  const handle = {
110
139
  controller: null,
111
140
  orb: null,
@@ -113,6 +142,18 @@ export function createFiodosAgent(options) {
113
142
  resetConversation() {
114
143
  handle.controller?.resetConversation();
115
144
  },
145
+ setScreenContext(snapshot) {
146
+ if (handle.controller)
147
+ handle.controller.screenStore.set(contextOwner, snapshot);
148
+ else
149
+ pendingSnapshot = snapshot;
150
+ },
151
+ clearScreenContext() {
152
+ if (handle.controller)
153
+ handle.controller.screenStore.clear(contextOwner);
154
+ else
155
+ pendingSnapshot = null;
156
+ },
116
157
  destroy() {
117
158
  cancelled = true;
118
159
  handle.orb?.unmount();
@@ -148,6 +189,9 @@ export function createFiodosAgent(options) {
148
189
  return null;
149
190
  }
150
191
  handle.controller = controller;
192
+ if (pendingSnapshot)
193
+ controller.screenStore.set(contextOwner, pendingSnapshot);
194
+ pendingSnapshot = undefined;
151
195
  handle.orb = shouldMount ? mountOrb(controller, options.orb) : null;
152
196
  if (shouldMount && options.apiKey) {
153
197
  void sendOrbSeen({ baseUrl, apiKey: options.apiKey });
@@ -0,0 +1,61 @@
1
+ import type { AgentScreenSnapshot } from '../context/screenContextStore.js';
2
+ interface XrmAttributeLike {
3
+ getName(): string;
4
+ getValue(): unknown;
5
+ /** Present on optionset/lookup attributes; preferred over raw values. */
6
+ getText?(): string | string[] | null;
7
+ }
8
+ interface XrmEntityLike {
9
+ getEntityName(): string;
10
+ /** Record GUID, usually brace-wrapped: "{1D2C...}". Empty on create forms. */
11
+ getId(): string;
12
+ getPrimaryAttributeValue?(): string | null;
13
+ attributes?: {
14
+ forEach(cb: (attribute: XrmAttributeLike) => void): void;
15
+ };
16
+ /** Model-driven apps expose save events here (Xrm ≥ 9.x). */
17
+ addOnPostSave?(handler: () => void): void;
18
+ removeOnPostSave?(handler: () => void): void;
19
+ }
20
+ export interface DynamicsFormContextLike {
21
+ data: {
22
+ entity: XrmEntityLike;
23
+ };
24
+ ui?: {
25
+ getFormType?(): number;
26
+ };
27
+ }
28
+ /** The subset of FiodosAgentHandle the bridge needs (keeps this module cheap to test). */
29
+ export interface ScreenContextSink {
30
+ setScreenContext(snapshot: AgentScreenSnapshot): void;
31
+ clearScreenContext(): void;
32
+ }
33
+ export interface DynamicsSnapshotOptions {
34
+ /**
35
+ * Logical names of the attributes to expose to the agent. When omitted, the
36
+ * first `maxAttributes` non-empty scalar attributes are included.
37
+ */
38
+ attributes?: string[];
39
+ /** Cap when no allowlist is given. Default 12. */
40
+ maxAttributes?: number;
41
+ }
42
+ /** "{1D2C…}" → "1d2c…" (Web API URLs want bare lowercase GUIDs). */
43
+ export declare function normalizeDynamicsId(rawId: string): string;
44
+ /**
45
+ * Builds the AgentScreenSnapshot for the current form state, or null when
46
+ * there is nothing referenceable yet (create form: no record id).
47
+ */
48
+ export declare function dynamicsFormSnapshot(formContext: DynamicsFormContextLike, options?: DynamicsSnapshotOptions): AgentScreenSnapshot | null;
49
+ export interface DynamicsContextBridge {
50
+ /** Re-reads the form and republishes the snapshot (wire to OnChange/OnSave). */
51
+ refresh(): void;
52
+ /** Stops publishing and clears the agent's screen context. */
53
+ disconnect(): void;
54
+ }
55
+ /**
56
+ * Publishes the form snapshot now, republishes after every save (when the
57
+ * form exposes OnPostSave), and clears the context on disconnect. Call it from
58
+ * the form's OnLoad handler.
59
+ */
60
+ export declare function connectDynamicsFormContext(agent: ScreenContextSink, formContext: DynamicsFormContextLike, options?: DynamicsSnapshotOptions): DynamicsContextBridge;
61
+ export {};
@@ -0,0 +1,168 @@
1
+ // ── Value normalization ──────────────────────────────────────────────────────
2
+ const MAX_VALUE_CHARS = 120;
3
+ function clip(text) {
4
+ const t = text.trim();
5
+ return t.length > MAX_VALUE_CHARS ? `${t.slice(0, MAX_VALUE_CHARS - 1)}…` : t;
6
+ }
7
+ /** Lookup values arrive as [{ id, name, entityType }] — show the names. */
8
+ function lookupNames(value) {
9
+ const names = value
10
+ .map((v) => v && typeof v === 'object' && typeof v.name === 'string'
11
+ ? (v.name)
12
+ : null)
13
+ .filter((n) => !!n);
14
+ return names.length ? names.join(', ') : null;
15
+ }
16
+ function normalizeAttributeValue(attribute) {
17
+ // Optionset / lookup / boolean attributes carry a human label — prefer it.
18
+ try {
19
+ const text = attribute.getText?.();
20
+ if (typeof text === 'string' && text.trim())
21
+ return clip(text);
22
+ if (Array.isArray(text) && text.length)
23
+ return clip(text.join(', '));
24
+ }
25
+ catch {
26
+ /* some attribute types throw on getText — fall through to raw value */
27
+ }
28
+ let value;
29
+ try {
30
+ value = attribute.getValue();
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ if (value == null)
36
+ return null;
37
+ if (typeof value === 'string')
38
+ return value.trim() ? clip(value) : null;
39
+ if (typeof value === 'number' || typeof value === 'boolean')
40
+ return value;
41
+ if (value instanceof Date)
42
+ return value.toISOString();
43
+ if (Array.isArray(value)) {
44
+ const names = lookupNames(value);
45
+ return names ? clip(names) : null;
46
+ }
47
+ return null; // collections / unknown object shapes are skipped, never guessed
48
+ }
49
+ /** "{1D2C…}" → "1d2c…" (Web API URLs want bare lowercase GUIDs). */
50
+ export function normalizeDynamicsId(rawId) {
51
+ return rawId.replace(/[{}]/g, '').trim().toLowerCase();
52
+ }
53
+ // ── Snapshot builder ─────────────────────────────────────────────────────────
54
+ /**
55
+ * Builds the AgentScreenSnapshot for the current form state, or null when
56
+ * there is nothing referenceable yet (create form: no record id).
57
+ */
58
+ export function dynamicsFormSnapshot(formContext, options = {}) {
59
+ const entity = formContext.data?.entity;
60
+ if (!entity)
61
+ return null;
62
+ const entityName = entity.getEntityName();
63
+ const recordId = normalizeDynamicsId(entity.getId() ?? '');
64
+ const allowlist = options.attributes?.length
65
+ ? new Set(options.attributes.map((a) => a.trim().toLowerCase()).filter(Boolean))
66
+ : null;
67
+ const maxAttributes = options.maxAttributes ?? 12;
68
+ const fields = {};
69
+ let count = 0;
70
+ entity.attributes?.forEach((attribute) => {
71
+ let name;
72
+ try {
73
+ name = attribute.getName();
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ if (!name)
79
+ return;
80
+ if (allowlist) {
81
+ if (!allowlist.has(name.toLowerCase()))
82
+ return;
83
+ }
84
+ else if (count >= maxAttributes) {
85
+ return;
86
+ }
87
+ const value = normalizeAttributeValue(attribute);
88
+ if (value == null)
89
+ return;
90
+ fields[name] = value;
91
+ count += 1;
92
+ });
93
+ let title;
94
+ try {
95
+ title = entity.getPrimaryAttributeValue?.() ?? undefined;
96
+ }
97
+ catch {
98
+ title = undefined;
99
+ }
100
+ // Create forms have no id yet: the agent can still SEE the fields, but there
101
+ // is no target for execute actions, so we publish text-only context.
102
+ const item = recordId
103
+ ? { id: recordId, kind: entityName, title, metadata: fields }
104
+ : null;
105
+ const fieldSummary = Object.entries(fields)
106
+ .map(([k, v]) => `${k}: ${v}`)
107
+ .join('; ');
108
+ const textParts = [
109
+ `Dynamics 365 form — entity "${entityName}"`,
110
+ recordId ? `record "${title ?? recordId}" (id ${recordId})` : 'new record (not saved yet)',
111
+ ];
112
+ if (fieldSummary)
113
+ textParts.push(`Fields: ${fieldSummary}`);
114
+ const context = {
115
+ ...(item ? { visibleContent: { items: [item], focusedIndex: 0 } } : {}),
116
+ custom: {
117
+ platform: 'dynamics365',
118
+ entityName,
119
+ ...(recordId ? { recordId } : {}),
120
+ ...(typeof formContext.ui?.getFormType === 'function'
121
+ ? { formType: formContext.ui.getFormType() }
122
+ : {}),
123
+ },
124
+ };
125
+ return { kind: `dynamics-form:${entityName}`, text: textParts.join('. '), context };
126
+ }
127
+ /**
128
+ * Publishes the form snapshot now, republishes after every save (when the
129
+ * form exposes OnPostSave), and clears the context on disconnect. Call it from
130
+ * the form's OnLoad handler.
131
+ */
132
+ export function connectDynamicsFormContext(agent, formContext, options = {}) {
133
+ let disconnected = false;
134
+ const publish = () => {
135
+ if (disconnected)
136
+ return;
137
+ const snapshot = dynamicsFormSnapshot(formContext, options);
138
+ if (snapshot)
139
+ agent.setScreenContext(snapshot);
140
+ else
141
+ agent.clearScreenContext();
142
+ };
143
+ publish();
144
+ const entity = formContext.data?.entity;
145
+ if (entity?.addOnPostSave) {
146
+ try {
147
+ entity.addOnPostSave(publish);
148
+ }
149
+ catch {
150
+ /* older clients — the host can still call refresh() manually */
151
+ }
152
+ }
153
+ return {
154
+ refresh: publish,
155
+ disconnect() {
156
+ if (disconnected)
157
+ return;
158
+ disconnected = true;
159
+ try {
160
+ entity?.removeOnPostSave?.(publish);
161
+ }
162
+ catch {
163
+ /* best-effort unsubscribe */
164
+ }
165
+ agent.clearScreenContext();
166
+ },
167
+ };
168
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Embed entrypoint — single-file IIFE bundle for CLOSED third-party hosts
3
+ * where npm/ESM is not available: Dynamics 365 web resources, WinCC Unified
4
+ * Custom Web Controls, Salesforce LWC iframes, plain <script> tags.
5
+ *
6
+ * Built by `npm run build:embed` into dist/embed/fiodos-embed.js and exposed
7
+ * as the `Fiodos` global:
8
+ *
9
+ * <script src="fiodos-embed.js"></script>
10
+ * <script>
11
+ * const agent = Fiodos.createFiodosAgent({
12
+ * manifest, // e.g. from `fiodos from-odata`
13
+ * registries: Fiodos.buildApiActionRegistries(manifest, {
14
+ * baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
15
+ * authProviders: { dataverse: () => ({ Authorization: `Bearer ${token}` }) },
16
+ * }),
17
+ * });
18
+ * </script>
19
+ *
20
+ * The bundle is self-contained (core + web-core inlined) and side-effect-free
21
+ * beyond assigning the global — it never auto-mounts; the host decides when.
22
+ */
23
+ import { createFiodosAgent } from '../dropin/createFiodosAgent.js';
24
+ import { mountOrb } from '../orb/mountOrb.js';
25
+ import { AgentController } from '../controller/AgentController.js';
26
+ import { createScreenContextStore } from '../context/screenContextStore.js';
27
+ import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId } from './dynamics.js';
28
+ import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
29
+ declare const api: {
30
+ readonly version: "0.1.15";
31
+ readonly createFiodosAgent: typeof createFiodosAgent;
32
+ readonly mountOrb: typeof mountOrb;
33
+ readonly AgentController: typeof AgentController;
34
+ readonly createScreenContextStore: typeof createScreenContextStore;
35
+ readonly buildApiActionRegistries: typeof buildApiActionRegistries;
36
+ readonly validateManifest: typeof validateManifest;
37
+ readonly connectDynamicsFormContext: typeof connectDynamicsFormContext;
38
+ readonly dynamicsFormSnapshot: typeof dynamicsFormSnapshot;
39
+ readonly normalizeDynamicsId: typeof normalizeDynamicsId;
40
+ };
41
+ declare global {
42
+ var Fiodos: typeof api | undefined;
43
+ }
44
+ export type FiodosEmbedApi = typeof api;
45
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Embed entrypoint — single-file IIFE bundle for CLOSED third-party hosts
3
+ * where npm/ESM is not available: Dynamics 365 web resources, WinCC Unified
4
+ * Custom Web Controls, Salesforce LWC iframes, plain <script> tags.
5
+ *
6
+ * Built by `npm run build:embed` into dist/embed/fiodos-embed.js and exposed
7
+ * as the `Fiodos` global:
8
+ *
9
+ * <script src="fiodos-embed.js"></script>
10
+ * <script>
11
+ * const agent = Fiodos.createFiodosAgent({
12
+ * manifest, // e.g. from `fiodos from-odata`
13
+ * registries: Fiodos.buildApiActionRegistries(manifest, {
14
+ * baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
15
+ * authProviders: { dataverse: () => ({ Authorization: `Bearer ${token}` }) },
16
+ * }),
17
+ * });
18
+ * </script>
19
+ *
20
+ * The bundle is self-contained (core + web-core inlined) and side-effect-free
21
+ * beyond assigning the global — it never auto-mounts; the host decides when.
22
+ */
23
+ import { createFiodosAgent } from '../dropin/createFiodosAgent.js';
24
+ import { mountOrb } from '../orb/mountOrb.js';
25
+ import { AgentController } from '../controller/AgentController.js';
26
+ import { createScreenContextStore } from '../context/screenContextStore.js';
27
+ import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId, } from './dynamics.js';
28
+ import { SDK_VERSION } from '../version.js';
29
+ import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
30
+ const api = {
31
+ version: SDK_VERSION,
32
+ createFiodosAgent,
33
+ mountOrb,
34
+ AgentController,
35
+ createScreenContextStore,
36
+ // Closed-software mode: turn ApiExecutionSpec manifests into live handlers,
37
+ // and validate hand-written / from-odata manifests before mounting.
38
+ buildApiActionRegistries,
39
+ validateManifest,
40
+ // Context bridges: tell the orb what the host is showing. Dynamics gets a
41
+ // dedicated mapper (formContext); any other host calls agent.setScreenContext.
42
+ connectDynamicsFormContext,
43
+ dynamicsFormSnapshot,
44
+ normalizeDynamicsId,
45
+ };
46
+ globalThis.Fiodos = api;