@agentforge-io/chat-sdk 2.5.7 → 2.6.1

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.
@@ -171,6 +171,31 @@ export type ChatEvent = {
171
171
  | {
172
172
  type: 'conversation_started';
173
173
  conversationId: string;
174
+ }
175
+ /**
176
+ * Fired exactly once, the first time the session manages to start an
177
+ * ObserverAdapter (after the conversation id is known). Carries the
178
+ * resolved mode so integrators can log/diagnose their WebMCP bridge:
179
+ * - `tool`/`window-events`/`hybrid` — WebMCP took over capture
180
+ * - `dom-only` — fallback to native DOM listeners
181
+ * - `none` — nothing landed (capture flags all off, or no DOM)
182
+ *
183
+ * Absent if the consumer didn't pass `observer` into ChatSessionOptions.
184
+ */
185
+ | {
186
+ type: 'observer_started';
187
+ mode: 'tool' | 'window-events' | 'hybrid' | 'dom-only' | 'none';
188
+ }
189
+ /**
190
+ * Fired when the server's daily-cap guard rejected a batch (HTTP 429
191
+ * with code `observer_throttled`). Per the SDD §8.5 we NEVER surface
192
+ * this to the end-user — it's purely informational for the embedding
193
+ * tenant so they can decide to raise their cap. `droppedEvents` is
194
+ * the size of the batch we couldn't flush.
195
+ */
196
+ | {
197
+ type: 'observer_throttled';
198
+ droppedEvents: number;
174
199
  } | {
175
200
  type: 'destroyed';
176
201
  };
@@ -208,4 +233,20 @@ export interface ChatSessionOptions {
208
233
  * session, the SDK silently falls back to a fresh conversation.
209
234
  */
210
235
  resumeConversationId?: string;
236
+ /**
237
+ * Optional DOM/WebMCP observer adapter. When passed, ChatSession:
238
+ * 1. Calls `observer.start({ sessionRef: conversationId, sessionKind: 'conversation' })`
239
+ * as soon as the conversation id is known.
240
+ * 2. Drains the buffer on every `send()` and ships the events to
241
+ * `POST /public/chat/:token/observer/events` alongside the message.
242
+ * 3. Flushes one last time via `navigator.sendBeacon` on `destroy()`
243
+ * / page unload so tab-closes still ship their tail.
244
+ * 4. Calls `observer.stop()` on destroy.
245
+ *
246
+ * Type is `ObserverAdapterLike` — structurally compatible with the
247
+ * `ObserverAdapter` from `@agentforge-io/observer` but defined
248
+ * locally so the observer package is a TRUE optional dependency.
249
+ * Consumers who never set this don't need to install observer.
250
+ */
251
+ observer?: import('./observer').ObserverAdapterLike;
211
252
  }
package/dist/index.d.ts CHANGED
@@ -11,3 +11,4 @@ export { ChatSession } from './session';
11
11
  export { HttpTransport } from './transport';
12
12
  export type { StreamEvent, ServerStreamChunk, TransportError, } from './transport';
13
13
  export type { ChatAgentSummary, ChatEvent, ChatMessage, ChatMessageMetadata, ChatRole, ChatSessionOptions, ChatSessionState, ChatSessionStatus, ChatTheme, } from './entities';
14
+ export type { ObserverAdapterLike, ObserverEventLike, ObserverMode, } from './observer';
@@ -0,0 +1,27 @@
1
+ export interface ObserverEventLike {
2
+ type: 'click' | 'input' | 'change' | 'navigation' | 'scroll' | 'custom';
3
+ timestampMs: number;
4
+ url?: string;
5
+ targetSelector?: string;
6
+ label?: string;
7
+ payload?: Record<string, unknown>;
8
+ captureSource?: 'dom' | 'webmcp' | 'hybrid' | 'unknown';
9
+ }
10
+ export type ObserverMode = 'tool' | 'window-events' | 'hybrid' | 'dom-only' | 'none';
11
+ export interface ObserverAdapterLike {
12
+ start(opts: {
13
+ sessionRef: string;
14
+ sessionKind: 'recording' | 'conversation';
15
+ }): Promise<{
16
+ active: boolean;
17
+ mode: ObserverMode;
18
+ }>;
19
+ drain(): ObserverEventLike[];
20
+ emit?(name: string, data?: unknown): void;
21
+ stop(): Promise<void>;
22
+ getStats?(): {
23
+ capturedEvents: number;
24
+ droppedEvents: number;
25
+ mode: ObserverMode | 'idle';
26
+ };
27
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ // Structural contract for the @agentforge-io/observer ObserverAdapter.
3
+ //
4
+ // The chat-sdk consumes a duck-typed adapter so the observer package is
5
+ // a truly OPTIONAL dependency. Consumers who never call `new
6
+ // ObserverAdapter()` don't need to install or load it; consumers who do
7
+ // pass an adapter into ChatSession see end-to-end typing because the
8
+ // real ObserverAdapter satisfies this interface structurally.
9
+ //
10
+ // Keep this file in lockstep with @agentforge-io/observer's public
11
+ // surface. The fields here are the ONLY surface the SDK touches at
12
+ // runtime — narrowing the contract on purpose so an SDK bump doesn't
13
+ // require re-publishing observer.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/react.d.ts CHANGED
@@ -181,6 +181,22 @@ export interface ChatWidgetProps {
181
181
  * once `agent_loaded` has fired internally.
182
182
  */
183
183
  handleRef?: React.MutableRefObject<ChatWidgetHandle | null>;
184
+ /**
185
+ * Optional observer adapter. When supplied, the SDK boots it once the
186
+ * conversation id is known (first user message) and flushes captured
187
+ * DOM/WebMCP events to the AgentForge ingestion endpoint after every
188
+ * turn + on page unload. The adapter is a duck-typed contract — see
189
+ * `ObserverAdapterLike` — so the `@agentforge-io/observer` package
190
+ * stays an optional peer dep. Hosts that don't ship observer can
191
+ * omit this and chat behaves identically.
192
+ *
193
+ * Wire this from preview / playground pages where the operator
194
+ * wants to inspect what visitors do, or from production embeds
195
+ * where the agent's `observerEnabled` is true. The server-side
196
+ * agent config determines whether ingestion actually persists;
197
+ * the client-side adapter just captures.
198
+ */
199
+ observer?: import('./observer').ObserverAdapterLike;
184
200
  }
185
201
  /** Imperative surface exposed via ChatWidgetProps.handleRef. Narrow
186
202
  * on purpose — hosts get a verb, not the whole session. */
package/dist/react.js CHANGED
@@ -228,7 +228,7 @@ function fallbackCopy(ctx) {
228
228
  * SDK event. Consumers don't need to read `session.getState()` themselves.
229
229
  */
230
230
  function ChatWidget(props) {
231
- const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, onConversationStart, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, shortcuts, onShortcutClick, inputPlaceholder, members, composerLeftSlot, handleRef, } = props;
231
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, onConversationStart, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, shortcuts, onShortcutClick, inputPlaceholder, members, composerLeftSlot, handleRef, observer, } = props;
232
232
  // Build a lookup so MessageBubble can resolve actingAgentId → identity
233
233
  // in O(1) per render without re-walking the members array. Stable
234
234
  // identity per `members` prop change.
@@ -309,6 +309,12 @@ function ChatWidget(props) {
309
309
  // updates to it are ignored — the SDK already owns the live
310
310
  // conversation id internally via session.state.conversationId.
311
311
  const resumeRef = (0, react_1.useRef)(resumeConversationId);
312
+ // Same rationale as `resumeRef` above — the observer adapter is
313
+ // captured at mount so a parent re-render with a new (but equivalent)
314
+ // adapter identity doesn't tear down the live ChatSession mid-
315
+ // conversation. Hosts that need to swap observers should remount
316
+ // the widget (e.g. via a `key` prop).
317
+ const observerRef = (0, react_1.useRef)(observer);
312
318
  (0, react_1.useEffect)(() => {
313
319
  let cancelled = false;
314
320
  const s = new session_1.ChatSession({
@@ -317,6 +323,7 @@ function ChatWidget(props) {
317
323
  browserSessionId,
318
324
  resumeConversationId: resumeRef.current,
319
325
  stream,
326
+ observer: observerRef.current,
320
327
  });
321
328
  setSession(s);
322
329
  setMessages([]);
package/dist/session.d.ts CHANGED
@@ -21,6 +21,14 @@ export declare class ChatSession {
21
21
  private readonly transport;
22
22
  private readonly stream;
23
23
  private readonly resumeId?;
24
+ /** Optional observer adapter — see ChatSessionOptions.observer. */
25
+ private readonly observer?;
26
+ /** Flips true after `observer.start()` resolves once, so subsequent
27
+ * conversations (resume / restart) don't re-start the adapter. */
28
+ private observerStarted;
29
+ /** Page-unload handler installed when observer is active so we can
30
+ * flush via sendBeacon before the tab closes. Removed in destroy(). */
31
+ private observerUnloadHandler?;
24
32
  private listeners;
25
33
  private state;
26
34
  private startPromise;
@@ -46,6 +54,19 @@ export declare class ChatSession {
46
54
  resolveApproval(approvalId: string, action: 'approve' | 'deny'): Promise<void>;
47
55
  /** Tear down. Future sends throw. Useful for SPA unmount. */
48
56
  destroy(): void;
57
+ /**
58
+ * Boot the observer adapter the first time we know the conversation
59
+ * id. Idempotent — subsequent calls (e.g. resumed conversations) no-op.
60
+ * Failures are swallowed: a broken observer must NOT break the chat.
61
+ */
62
+ private ensureObserverStarted;
63
+ /**
64
+ * Drain the observer buffer and POST it to the ingestion endpoint.
65
+ * Called inline after each send() and via sendBeacon on destroy /
66
+ * page-unload. Swallows errors per the SDD §8.5 "never bill-error"
67
+ * commitment.
68
+ */
69
+ private flushObserver;
49
70
  /**
50
71
  * Send a user message. Opens the conversation on first call. Returns the
51
72
  * final assistant content for callers that want to await it; the same
package/dist/session.js CHANGED
@@ -20,6 +20,9 @@ const transport_1 = require("./transport");
20
20
  */
21
21
  class ChatSession {
22
22
  constructor(opts) {
23
+ /** Flips true after `observer.start()` resolves once, so subsequent
24
+ * conversations (resume / restart) don't re-start the adapter. */
25
+ this.observerStarted = false;
23
26
  this.listeners = new Set();
24
27
  this.state = {
25
28
  status: 'idle',
@@ -39,6 +42,7 @@ class ChatSession {
39
42
  this.stream = opts.stream ?? true;
40
43
  this.browserSessionId = opts.browserSessionId ?? generateBrowserSessionId();
41
44
  this.resumeId = opts.resumeConversationId;
45
+ this.observer = opts.observer;
42
46
  }
43
47
  // ─── Subscriptions ──────────────────────────────────────────────────────
44
48
  /** Returns an unsubscribe function. Listeners are called synchronously. */
@@ -80,6 +84,11 @@ class ChatSession {
80
84
  const history = await this.transport.getConversation(this.resumeId, this.browserSessionId);
81
85
  if (history) {
82
86
  this.state.conversationId = history.id;
87
+ // Resume path: boot the observer the moment we know the
88
+ // conv id, BEFORE replaying history. Otherwise the first
89
+ // user click after page reload would go into a buffer with
90
+ // no sessionRef and we'd lose its early events.
91
+ await this.ensureObserverStarted(history.id);
83
92
  for (const m of history.messages) {
84
93
  // Replay each persisted message as if it had been streamed in
85
94
  // — view layers already render `message_added` events.
@@ -148,11 +157,95 @@ class ChatSession {
148
157
  }
149
158
  /** Tear down. Future sends throw. Useful for SPA unmount. */
150
159
  destroy() {
160
+ // Flush observer first — drain() is cheap and the beacon path can
161
+ // ride out the unmount even when the rest of the session is gone.
162
+ this.flushObserver({ useBeacon: true }).catch(() => {
163
+ /* destroy must not throw */
164
+ });
165
+ if (this.observerUnloadHandler && typeof window !== 'undefined') {
166
+ window.removeEventListener('beforeunload', this.observerUnloadHandler);
167
+ window.removeEventListener('pagehide', this.observerUnloadHandler);
168
+ this.observerUnloadHandler = undefined;
169
+ }
170
+ if (this.observer) {
171
+ // Fire-and-forget — stop() is async but destroy is sync. Errors
172
+ // are swallowed: we're going away anyway.
173
+ this.observer.stop().catch(() => { });
174
+ }
151
175
  this.listeners.clear();
152
176
  this.emit({ type: 'destroyed' });
153
177
  // Drop everything — a destroyed session shouldn't be reused.
154
178
  this.state = { status: 'idle', messages: [] };
155
179
  this.startPromise = null;
180
+ this.observerStarted = false;
181
+ }
182
+ // ─── Observer wiring ────────────────────────────────────────────────────
183
+ /**
184
+ * Boot the observer adapter the first time we know the conversation
185
+ * id. Idempotent — subsequent calls (e.g. resumed conversations) no-op.
186
+ * Failures are swallowed: a broken observer must NOT break the chat.
187
+ */
188
+ async ensureObserverStarted(conversationId) {
189
+ if (!this.observer || this.observerStarted)
190
+ return;
191
+ try {
192
+ const result = await this.observer.start({
193
+ sessionRef: conversationId,
194
+ sessionKind: 'conversation',
195
+ });
196
+ this.observerStarted = true;
197
+ this.emit({ type: 'observer_started', mode: result.mode });
198
+ // Install the unload flush. We listen to BOTH beforeunload (still
199
+ // the most reliable in Chrome/Firefox) and pagehide (Safari
200
+ // doesn't fire beforeunload reliably). The handler fires once
201
+ // per page lifetime — either listener wins, the other is a
202
+ // no-op when the buffer is already empty.
203
+ if (typeof window !== 'undefined') {
204
+ this.observerUnloadHandler = () => {
205
+ void this.flushObserver({ useBeacon: true });
206
+ };
207
+ window.addEventListener('beforeunload', this.observerUnloadHandler);
208
+ window.addEventListener('pagehide', this.observerUnloadHandler);
209
+ }
210
+ }
211
+ catch {
212
+ // Observer is best-effort. A broken adapter shouldn't surface.
213
+ }
214
+ }
215
+ /**
216
+ * Drain the observer buffer and POST it to the ingestion endpoint.
217
+ * Called inline after each send() and via sendBeacon on destroy /
218
+ * page-unload. Swallows errors per the SDD §8.5 "never bill-error"
219
+ * commitment.
220
+ */
221
+ async flushObserver(opts = {}) {
222
+ if (!this.observer || !this.observerStarted)
223
+ return;
224
+ const convId = this.state.conversationId;
225
+ if (!convId)
226
+ return;
227
+ let batch;
228
+ try {
229
+ batch = this.observer.drain();
230
+ }
231
+ catch {
232
+ return;
233
+ }
234
+ if (batch.length === 0)
235
+ return;
236
+ try {
237
+ const result = await this.transport.ingestObserverEvents(convId, batch, opts);
238
+ if (result.throttled) {
239
+ this.emit({
240
+ type: 'observer_throttled',
241
+ droppedEvents: result.dropped ?? 0,
242
+ });
243
+ }
244
+ }
245
+ catch {
246
+ // Server-side failure: drop the batch. Better than spamming a
247
+ // retry loop that delays the user's next chat turn.
248
+ }
156
249
  }
157
250
  // ─── User actions ───────────────────────────────────────────────────────
158
251
  /**
@@ -232,6 +325,11 @@ class ChatSession {
232
325
  if (evt.kind === 'conversation') {
233
326
  this.state.conversationId = evt.id;
234
327
  this.emit({ type: 'conversation_started', conversationId: evt.id });
328
+ // Now that we have a conv id, boot the observer adapter (if
329
+ // the caller passed one). Awaited so the buffer doesn't start
330
+ // ingesting events under a stale sessionRef — but errors are
331
+ // swallowed inside ensureObserverStarted.
332
+ await this.ensureObserverStarted(evt.id);
235
333
  continue;
236
334
  }
237
335
  if (evt.kind === 'chunk') {
@@ -385,6 +483,10 @@ class ChatSession {
385
483
  this.removeMessage(assistant.id);
386
484
  }
387
485
  this.setStatus('ready');
486
+ // Flush observer AFTER the turn lands. Doing it after instead of
487
+ // inline with the SSE keeps the captured timestamps closer to the
488
+ // turn boundary the operator will read in the timeline.
489
+ void this.flushObserver();
388
490
  return totalFull;
389
491
  }
390
492
  catch (err) {
@@ -421,12 +523,16 @@ class ChatSession {
421
523
  const res = await this.transport.createConversation(text, this.browserSessionId);
422
524
  this.state.conversationId = res.conversationId;
423
525
  this.emit({ type: 'conversation_started', conversationId: res.conversationId });
526
+ await this.ensureObserverStarted(res.conversationId);
424
527
  content = res.content;
425
528
  }
426
529
  assistant.content = content;
427
530
  assistant.isStreaming = false;
428
531
  this.updateMessage(assistant);
429
532
  this.setStatus('ready');
533
+ // Inline flush AFTER the message lands so the events ride along
534
+ // with it. Non-blocking: if the cap kicks in we drop silently.
535
+ void this.flushObserver();
430
536
  return content;
431
537
  }
432
538
  catch (err) {
@@ -93,6 +93,25 @@ export declare class HttpTransport {
93
93
  */
94
94
  resolveApproval(approvalId: string, action: 'approve' | 'deny', browserSessionId: string): Promise<void>;
95
95
  endConversation(conversationId: string, browserSessionId: string): Promise<void>;
96
+ /**
97
+ * Ship a batch of captured observer events for the current
98
+ * conversation. The server responds 200 even when the daily cap is
99
+ * exhausted (`throttled: true`) — per SDD §8.5 we NEVER raise a
100
+ * billing-shaped error to the end-user. The session uses the
101
+ * `throttled` field to emit `observer_throttled` for the embedding
102
+ * tenant's telemetry.
103
+ *
104
+ * `useBeacon: true` switches to `navigator.sendBeacon` for the
105
+ * page-unload flush. Beacon sends are fire-and-forget — no response
106
+ * to inspect — so we return a default OK envelope.
107
+ */
108
+ ingestObserverEvents(conversationId: string, events: unknown[], opts?: {
109
+ useBeacon?: boolean;
110
+ }): Promise<{
111
+ accepted: number;
112
+ dropped?: number;
113
+ throttled?: boolean;
114
+ }>;
96
115
  /** Parse the server's SSE response body into typed events. */
97
116
  private readSse;
98
117
  }
package/dist/transport.js CHANGED
@@ -142,6 +142,48 @@ class HttpTransport {
142
142
  throw makeTransportError(data?.message ?? `HTTP ${res.status}`, res.status, data?.error);
143
143
  }
144
144
  }
145
+ /**
146
+ * Ship a batch of captured observer events for the current
147
+ * conversation. The server responds 200 even when the daily cap is
148
+ * exhausted (`throttled: true`) — per SDD §8.5 we NEVER raise a
149
+ * billing-shaped error to the end-user. The session uses the
150
+ * `throttled` field to emit `observer_throttled` for the embedding
151
+ * tenant's telemetry.
152
+ *
153
+ * `useBeacon: true` switches to `navigator.sendBeacon` for the
154
+ * page-unload flush. Beacon sends are fire-and-forget — no response
155
+ * to inspect — so we return a default OK envelope.
156
+ */
157
+ async ingestObserverEvents(conversationId, events, opts = {}) {
158
+ const body = JSON.stringify({ conversationId, events });
159
+ if (opts.useBeacon && typeof navigator !== 'undefined' && navigator.sendBeacon) {
160
+ // Beacon is best-effort. Browsers cap the body at ~64kB; that's
161
+ // well above our flush limit (~200 events * a couple hundred
162
+ // bytes each).
163
+ const blob = new Blob([body], { type: 'application/json' });
164
+ navigator.sendBeacon(this.url('/observer/events'), blob);
165
+ return { accepted: events.length };
166
+ }
167
+ const res = await fetch(this.url('/observer/events'), {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body,
171
+ // We deliberately do NOT credentials: 'include' — the chat token
172
+ // in the URL is the only auth surface for the observer endpoint.
173
+ });
174
+ if (!res.ok) {
175
+ // The endpoint shouldn't fail in normal operation. If it does
176
+ // we silently swallow — the chat UX must keep working even when
177
+ // observer ingest is down.
178
+ return { accepted: 0, dropped: events.length };
179
+ }
180
+ const data = (await safeJson(res));
181
+ return {
182
+ accepted: data?.accepted ?? 0,
183
+ dropped: data?.dropped,
184
+ throttled: data?.throttled,
185
+ };
186
+ }
145
187
  /** Parse the server's SSE response body into typed events. */
146
188
  async *readSse(res) {
147
189
  if (!res.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.5.7",
3
+ "version": "2.6.1",
4
4
  "description": "Framework-free chat session SDK for AgentForge public chat tokens. Headless — no DOM. Drop into any frontend (React, Vue, Svelte, vanilla) and listen for events.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",