@vinaverse/client 0.0.0-stage → 0.1.0-alpha.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.
@@ -0,0 +1,858 @@
1
+ import { ConversationEngine, randomId, VinaverseSession, VinaverseClientError, isKnownCard } from '../chunk-AWHPWRGT.js';
2
+ export { CONTRACT, VinaverseClientError, isKnownCard } from '../chunk-AWHPWRGT.js';
3
+
4
+ // src/conversation/answers.ts
5
+ var CHOICE = /* @__PURE__ */ new Set([
6
+ "single_choice",
7
+ "multi_choice",
8
+ "image_choice",
9
+ "yes_no",
10
+ "list_choice"
11
+ ]);
12
+ var ONE_CHOICE = /* @__PURE__ */ new Set(["single_choice", "yes_no", "list_choice"]);
13
+ function codePoints(text) {
14
+ let n = 0;
15
+ for (const _ of text) n++;
16
+ return n;
17
+ }
18
+ function answerProblem(question, input) {
19
+ const answer = input;
20
+ if (answer.skipped === true) return question.required ? "required" : null;
21
+ const kind = question.kind;
22
+ if (CHOICE.has(kind)) return choiceProblem(question, answer);
23
+ switch (kind) {
24
+ case "rate_each": {
25
+ const values = answer.values ?? [];
26
+ if (answer.other_text !== void 0) return "not_allowed";
27
+ if (values.length === 0) return "required";
28
+ if (values.length > 1) return "too_many";
29
+ return (question.options ?? []).some((o) => o.value === values[0]) ? null : "not_allowed";
30
+ }
31
+ case "short_text":
32
+ case "long_text": {
33
+ const text = answer.text ?? "";
34
+ if (!text.trim()) return "required";
35
+ return codePoints(text) > (question.text?.max_length ?? 2e3) ? "too_long" : null;
36
+ }
37
+ case "number":
38
+ case "scale": {
39
+ const n = answer.number;
40
+ if (typeof n !== "number" || !Number.isFinite(n)) return "required";
41
+ const range = kind === "number" ? question.number : question.scale;
42
+ if (!range) return null;
43
+ if (n < range.min || n > range.max) return "out_of_range";
44
+ const steps = (n - range.min) / range.step;
45
+ return Math.abs(steps - Math.round(steps)) > 1e-9 ? "out_of_range" : null;
46
+ }
47
+ case "date":
48
+ return dateProblem(question, answer.date);
49
+ case "photo":
50
+ return answer.upload_ref ? null : "required";
51
+ default:
52
+ return null;
53
+ }
54
+ }
55
+ function choiceProblem(question, answer) {
56
+ const values = answer.values ?? [];
57
+ const other = answer.other_text;
58
+ if (other !== void 0) {
59
+ if (!question.allows_other) return "not_allowed";
60
+ if (!other.trim()) return "required";
61
+ if (codePoints(other) > (question.other_max_length ?? 500))
62
+ return "too_long";
63
+ if (values.length > 0 && question.kind !== "single_choice" && question.kind !== "multi_choice")
64
+ return "not_allowed";
65
+ }
66
+ if (values.length === 0) return other !== void 0 ? null : "required";
67
+ if (new Set(values).size !== values.length) return "not_allowed";
68
+ const none = question.none_of_the_above?.value;
69
+ if (none !== void 0 && values.includes(none))
70
+ return values.length === 1 && other === void 0 ? null : "not_allowed";
71
+ const known = new Set((question.options ?? []).map((o) => o.value));
72
+ if (!values.every((v) => known.has(v))) return "not_allowed";
73
+ if (ONE_CHOICE.has(question.kind) && values.length > 1) return "too_many";
74
+ const chosen = values.length + (other !== void 0 ? 1 : 0);
75
+ if (question.min !== void 0 && chosen < question.min) return "too_few";
76
+ if (question.max !== void 0 && chosen > question.max) return "too_many";
77
+ return null;
78
+ }
79
+ function dateProblem(question, date) {
80
+ if (!date) return "required";
81
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
82
+ if (!m) return "bad_date";
83
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
84
+ const at = new Date(Date.UTC(y, mo - 1, d));
85
+ if (at.getUTCFullYear() !== y || at.getUTCMonth() !== mo - 1 || at.getUTCDate() !== d)
86
+ return "bad_date";
87
+ if (question.date?.min !== void 0 && date < question.date.min)
88
+ return "bad_date";
89
+ if (question.date?.max !== void 0 && date > question.date.max)
90
+ return "bad_date";
91
+ return null;
92
+ }
93
+
94
+ // src/conversation/links.ts
95
+ var PREFIX = /^[a-z][a-z0-9+.-]*:(\/\/\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?\/|\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*\/)(\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*\/)*$/;
96
+ var PREFIX_HTTPS = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?\/\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*\//;
97
+ var REFUSED_SCHEMES = /^(javascript|vbscript|data|blob|file|filesystem|about|view-source|intent|android-app|content|jar|mhtml|itms-services|ftp|ws|wss):/;
98
+ var CONTROLS = /[\x00-\x1f\x7f]/;
99
+ var REMAINDER = /^(\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*(\/\.*[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*\/?)?(\?([A-Za-z0-9._~=&-]|%(2[0-46-9A-Ea-e]|3[0-9B-Fb-f]|[46][0-9A-Fa-f]|5[0-9ABD-Fabd-f]|7[0-9A-Ea-e]))*)?$/;
100
+ function isValidLinkPrefix(prefix) {
101
+ if (CONTROLS.test(prefix) || REFUSED_SCHEMES.test(prefix)) return false;
102
+ if (/^https?:/.test(prefix) && !PREFIX_HTTPS.test(prefix)) return false;
103
+ return PREFIX.test(prefix);
104
+ }
105
+ function isAllowedLink(link, prefixes) {
106
+ return prefixes.some(
107
+ (prefix) => isValidLinkPrefix(prefix) && link.startsWith(prefix) && REMAINDER.test(link.slice(prefix.length))
108
+ );
109
+ }
110
+
111
+ // src/conversation/timeline.ts
112
+ function lastOrder(state) {
113
+ let last = -1;
114
+ for (const m of state.messages) last = Math.max(last, m.seq);
115
+ for (const c of state.cards) last = Math.max(last, c.seq);
116
+ if (state.streaming) last = Math.max(last, state.streaming.seq);
117
+ return last;
118
+ }
119
+ function pendingState(id, input) {
120
+ const { state } = input;
121
+ if (input.retried.has(id)) return null;
122
+ if (input.early.has(id)) return "sending";
123
+ if (state.messages.some((m) => m.key === id)) return null;
124
+ const refused = state.refused[id];
125
+ if (refused !== void 0) return refused === "guardrail" ? null : "not_sent";
126
+ if (state.heldForNextSession.includes(id)) return "held";
127
+ if (state.resend.includes(id) || state.held.includes(id) || state.unacknowledged.some((f) => f.client_event_id === id))
128
+ return "sending";
129
+ return null;
130
+ }
131
+ function buildTimeline(input, previous) {
132
+ const before = new Map(previous.map((item) => [item.key, item]));
133
+ const live = [];
134
+ const { state } = input;
135
+ for (const m of state.messages)
136
+ live.push({ item: messageItem(m), order: m.seq });
137
+ if (state.streaming) {
138
+ live.push({
139
+ item: {
140
+ type: "message",
141
+ key: `a:${state.streaming.turnId}`,
142
+ role: "agent",
143
+ text: "",
144
+ state: "streaming"
145
+ },
146
+ order: state.streaming.seq
147
+ });
148
+ }
149
+ for (const card of state.cards) {
150
+ if (!isKnownCard(card.card)) continue;
151
+ live.push({
152
+ item: {
153
+ type: "card",
154
+ key: card.id,
155
+ card,
156
+ busy: input.busy.has(card.id),
157
+ ...input.decisionErrors?.has(card.id) ? { decisionError: true } : {}
158
+ },
159
+ order: card.seq
160
+ });
161
+ }
162
+ for (const { item, order } of input.system) live.push({ item, order });
163
+ for (const [id, sent] of input.sent) {
164
+ const pending = pendingState(id, input);
165
+ if (pending === null) continue;
166
+ const rejection = state.rejections[id];
167
+ live.push({
168
+ item: {
169
+ type: "message",
170
+ key: id,
171
+ role: "person",
172
+ text: sent.text,
173
+ state: pending,
174
+ ...pending === "not_sent" ? {
175
+ rejection: rejection ?? {
176
+ code: state.refused[id] ?? "",
177
+ message: ""
178
+ }
179
+ } : {}
180
+ },
181
+ order: sent.order
182
+ });
183
+ }
184
+ live.sort((a, b) => a.order - b.order);
185
+ const liveByKey = new Map(live.map((l) => [l.item.key, l.item]));
186
+ const transcript = input.transcriptSeen === true;
187
+ const items = [];
188
+ const placed = /* @__PURE__ */ new Set();
189
+ if (!transcript)
190
+ for (const item of input.history) {
191
+ const current = liveByKey.get(item.key);
192
+ items.push(current ?? item);
193
+ if (current) placed.add(item.key);
194
+ }
195
+ for (const { item } of live) if (!placed.has(item.key)) items.push(item);
196
+ let same = items.length === previous.length;
197
+ const shared = items.map((item, i) => {
198
+ const prev = before.get(item.key);
199
+ const kept = prev !== void 0 && equal(prev, item) ? prev : item;
200
+ if (kept !== previous[i]) same = false;
201
+ return kept;
202
+ });
203
+ return same ? previous : shared;
204
+ }
205
+ function messageItem(m) {
206
+ const role = m.role === "assistant" ? "agent" : m.role;
207
+ return {
208
+ type: "message",
209
+ key: m.key,
210
+ role,
211
+ text: m.text,
212
+ state: m.received ? "delivered" : "sent",
213
+ ...m.partial ? { partial: m.partial } : {},
214
+ ...m.refusal ? { refusal: true } : {}
215
+ };
216
+ }
217
+ function equal(a, b) {
218
+ if (a.type !== b.type || a.key !== b.key) return false;
219
+ if (a.type === "message" && b.type === "message")
220
+ return a.role === b.role && a.text === b.text && a.state === b.state && a.partial === b.partial && a.refusal === b.refusal && a.rejection?.code === b.rejection?.code && a.rejection?.message === b.rejection?.message;
221
+ if (a.type === "card" && b.type === "card")
222
+ return a.busy === b.busy && a.restored === b.restored && a.decisionError === b.decisionError && sameCard(a.card, b.card);
223
+ return a === b;
224
+ }
225
+ function sameCard(a, b) {
226
+ return a.card === b.card && a.seq === b.seq && a.turnId === b.turnId && a.outcome === b.outcome && a.locked.length === b.locked.length && a.locked.every((q, i) => q === b.locked[i]) && a.question?.state === b.question?.state && JSON.stringify(a.question?.answer) === JSON.stringify(b.question?.answer) && JSON.stringify(a.question?.row_answers) === JSON.stringify(b.question?.row_answers);
227
+ }
228
+ function persistable(item) {
229
+ if (item.type === "message")
230
+ return item.state === "sent" || item.state === "delivered";
231
+ return item.type === "card";
232
+ }
233
+
234
+ // src/conversation/store.ts
235
+ var MAX_TEXT = 2e3;
236
+ var PERSIST_DELAY = 500;
237
+ var PERSIST_VERSION = 1;
238
+ var NO_ITEMS = [];
239
+ var OUTCOMES = /* @__PURE__ */ new Set([
240
+ "confirmed",
241
+ "declined",
242
+ "failed",
243
+ "expired"
244
+ ]);
245
+ var ConversationStore = class {
246
+ options;
247
+ engine = new ConversationEngine();
248
+ session = null;
249
+ info = null;
250
+ getting = null;
251
+ failed = false;
252
+ disposed = false;
253
+ online = null;
254
+ foreground = true;
255
+ lastStatus = "idle";
256
+ offlineAfterRetries = false;
257
+ /** The id the next send gets, so a text typed before the session keeps its key. */
258
+ nextId = null;
259
+ sent = /* @__PURE__ */ new Map();
260
+ early = /* @__PURE__ */ new Map();
261
+ retried = /* @__PURE__ */ new Set();
262
+ busy = /* @__PURE__ */ new Set();
263
+ /** Proposals whose tenant decision threw: shown as recoverable, the person may decide again. */
264
+ decisionErrors = /* @__PURE__ */ new Set();
265
+ system = [];
266
+ /** Numbers system rows that carry no id of their own. */
267
+ systemSeq = 0;
268
+ history = NO_ITEMS;
269
+ historyState = "none";
270
+ /** A resumed session.ready carried a transcript: the server's history replaced what the device kept. */
271
+ transcriptSeen = false;
272
+ locale;
273
+ /** The request for a session in flight, to abort when it is no longer wanted. */
274
+ fetching = null;
275
+ view;
276
+ listeners = /* @__PURE__ */ new Set();
277
+ streaming = null;
278
+ streamListeners = /* @__PURE__ */ new Set();
279
+ /** The frame that will notify, and what it will notify about. */
280
+ frame = void 0;
281
+ framePending = false;
282
+ viewDirty = false;
283
+ streamDirty = false;
284
+ persistTimer;
285
+ unsubscribe = [];
286
+ constructor(options) {
287
+ this.options = options;
288
+ this.locale = options.locale;
289
+ this.view = this.compute(null);
290
+ this.restore();
291
+ }
292
+ // ------------------------------------------------------------ reading
293
+ getSnapshot = () => this.view;
294
+ subscribe = (listener) => {
295
+ this.listeners.add(listener);
296
+ return () => this.listeners.delete(listener);
297
+ };
298
+ getStreaming = () => this.streaming;
299
+ subscribeStreaming = (listener) => {
300
+ this.streamListeners.add(listener);
301
+ return () => this.streamListeners.delete(listener);
302
+ };
303
+ /** Whether a card's link may be opened (the deep-link rule, against `deepLinkPrefixes`). */
304
+ checkLink(link) {
305
+ return isAllowedLink(link, this.options.deepLinkPrefixes ?? []);
306
+ }
307
+ // ------------------------------------------------------------ the conversation's life
308
+ /** Ask for a session and connect, early: a client secret must open its first connection within two minutes. */
309
+ start() {
310
+ this.live();
311
+ if (this.session) {
312
+ if (this.session.status === "idle") this.session.connect();
313
+ return;
314
+ }
315
+ void this.ensureSession("open");
316
+ }
317
+ /** Close for good. */
318
+ dispose() {
319
+ if (this.disposed) return;
320
+ this.disposed = true;
321
+ this.fetching?.abort();
322
+ this.fetching = null;
323
+ this.session?.close();
324
+ for (const off of this.unsubscribe) off();
325
+ this.unsubscribe = [];
326
+ clearTimeout(this.persistTimer);
327
+ this.update();
328
+ this.flush();
329
+ }
330
+ /** Close for good and forget the conversation kept on the device (sign-out). Never throws for the storage. */
331
+ clear() {
332
+ this.dispose();
333
+ this.history = NO_ITEMS;
334
+ try {
335
+ const removed = this.options.persistence?.removeItem(this.key());
336
+ if (removed) removed.catch(() => void 0);
337
+ } catch {
338
+ }
339
+ this.update();
340
+ this.flush();
341
+ }
342
+ /**
343
+ * The person switched the app's language: the UI's words change at once (the kit's own locale); the next session
344
+ * asks for this locale (`subject.locale`). The open session keeps its language: the agent answers in the new one
345
+ * from the next session, and the store never starts a session for it.
346
+ */
347
+ setLocale(locale) {
348
+ this.locale = locale;
349
+ }
350
+ /** The app came to the foreground or went to the background (AppState, `visibilitychange`). */
351
+ setForeground(foreground) {
352
+ this.foreground = foreground;
353
+ this.session?.setForeground(foreground);
354
+ if (foreground) this.reconnectIfDown();
355
+ }
356
+ /** The network as the app sees it; null when unknown. Back online reconnects a connection that gave up. */
357
+ setOnline(online) {
358
+ this.online = online;
359
+ if (online) this.reconnectIfDown();
360
+ this.update();
361
+ }
362
+ // ------------------------------------------------------------ the person's actions
363
+ /** Send a typed message; returns its `client_event_id`. Typed before a session exists, it is sent once one does. */
364
+ send(text) {
365
+ this.live();
366
+ if (!text.trim()) throw new RangeError("A message needs some text.");
367
+ if ([...text].length > MAX_TEXT)
368
+ throw new RangeError(`A message is at most ${MAX_TEXT} characters.`);
369
+ const id = (this.options.createId ?? randomId)();
370
+ this.sent.set(id, { text, order: lastOrder(this.engine.state()) + 0.5 });
371
+ if (!this.session) {
372
+ this.early.set(id, text);
373
+ void this.ensureSession("open");
374
+ this.update();
375
+ return id;
376
+ }
377
+ if (this.nextFrom(this.engine.state()) === "continue_here") {
378
+ this.early.set(id, text);
379
+ void this.resumeWith("continue_here");
380
+ this.update();
381
+ return id;
382
+ }
383
+ this.nextId = id;
384
+ this.session.sendText(text);
385
+ this.nextId = null;
386
+ this.continueIfEnded();
387
+ return id;
388
+ }
389
+ /** Answer a question card (a `rate_each` row by its `question_id`); returns the `client_event_id`. */
390
+ answer(cardId, questionId, answer) {
391
+ const session = this.required();
392
+ const id = session.answer(cardId, questionId, answer);
393
+ this.continueIfEnded();
394
+ return id;
395
+ }
396
+ /** A card action (`ui.action`) other than a proposal's outcome. */
397
+ cardAction(cardId, action) {
398
+ return this.required().action(cardId, action);
399
+ }
400
+ /**
401
+ * Decide a proposal: `decide` is the tenant's own call (its backend, the person's token), which confirms or
402
+ * declines it and says the outcome; the store then reports it. One decision per card; the card is busy meanwhile.
403
+ * Resolves to the outcome reported, or null when the card was busy or already decided.
404
+ *
405
+ * Only an outcome `decide` resolves with is reported (§6: the first is final). When `decide` throws, or resolves
406
+ * with anything but `confirmed`, `declined`, `failed` or `expired`, nothing is reported: the backend may have
407
+ * applied the change, so the outcome stays unknown; the card shows `decisionError` and the person may decide
408
+ * again. When the conversation is disposed meanwhile (sign-out), the outcome is not reported either: the tenant's
409
+ * backend holds it, and the session takes it as unknown.
410
+ */
411
+ async decideProposal(cardId, decide) {
412
+ this.live();
413
+ const card = this.engine.state().cards.find((c) => c.id === cardId);
414
+ if (card?.kind !== "proposal") return null;
415
+ if (this.busy.has(cardId) || (card.outcome ?? null) !== null) return null;
416
+ this.busy.add(cardId);
417
+ this.decisionErrors.delete(cardId);
418
+ this.update();
419
+ try {
420
+ const outcome = await decide();
421
+ if (typeof outcome !== "string" || !OUTCOMES.has(outcome))
422
+ throw new TypeError(
423
+ `decide() resolved with ${JSON.stringify(outcome)}, not confirmed, declined, failed or expired`
424
+ );
425
+ if (this.disposed) return outcome;
426
+ if (this.view.next === "continue") await this.resumeWith("resume");
427
+ this.required().reportOutcome(cardId, outcome);
428
+ return outcome;
429
+ } catch (error) {
430
+ this.decisionErrors.add(cardId);
431
+ throw error;
432
+ } finally {
433
+ this.busy.delete(cardId);
434
+ this.update();
435
+ }
436
+ }
437
+ /** Send a "not sent" message again, as a new message (the person's action). Returns the new id. */
438
+ retry(key) {
439
+ const sent = this.sent.get(key);
440
+ if (!sent || this.engine.state().refused[key] === void 0) return null;
441
+ this.retried.add(key);
442
+ this.sent.delete(key);
443
+ return this.send(sent.text);
444
+ }
445
+ /** After the conversation continued on another device: continue it here (the person's choice). */
446
+ continueHere() {
447
+ return this.resumeWith("continue_here");
448
+ }
449
+ /** Stop the turn in progress. */
450
+ cancel() {
451
+ const turn = this.engine.state().turn;
452
+ if (this.session && turn) this.session.cancel(turn);
453
+ }
454
+ // ------------------------------------------------------------ sessions
455
+ async ensureSession(reason) {
456
+ if (this.session) return this.session;
457
+ if (this.getting) return this.getting;
458
+ this.getting = this.fetchSession(reason).then((info) => {
459
+ this.getting = null;
460
+ if (!info || this.disposed) return null;
461
+ this.open(info);
462
+ return this.session;
463
+ });
464
+ this.update();
465
+ return this.getting;
466
+ }
467
+ async fetchSession(reason) {
468
+ this.failed = false;
469
+ this.fetching?.abort();
470
+ const Controller = globalThis.AbortController;
471
+ const controller = Controller ? new Controller() : null;
472
+ const mine = { abort: () => controller?.abort() };
473
+ this.fetching = mine;
474
+ let aborted = false;
475
+ const signal = controller?.signal;
476
+ signal?.addEventListener("abort", () => {
477
+ aborted = true;
478
+ });
479
+ try {
480
+ const info = await this.options.getSession({
481
+ reason,
482
+ conversationId: this.engine.state().conversationId,
483
+ ...this.locale !== void 0 ? { locale: this.locale } : {},
484
+ ...this.options.timeZone !== void 0 ? { timeZone: this.options.timeZone } : {},
485
+ ...signal ? { signal } : {}
486
+ });
487
+ return aborted || this.fetching !== mine ? null : info;
488
+ } catch (error) {
489
+ if (aborted || this.fetching !== mine) return null;
490
+ this.failed = true;
491
+ this.getting = null;
492
+ this.options.onError?.(error);
493
+ this.update();
494
+ return null;
495
+ } finally {
496
+ if (this.fetching === mine) this.fetching = null;
497
+ }
498
+ }
499
+ open(info) {
500
+ this.info = info;
501
+ const session = new VinaverseSession({
502
+ ...this.options.transport,
503
+ session: info,
504
+ engine: this.engine,
505
+ ...this.options.refreshSecret ? { refreshSecret: this.options.refreshSecret } : {},
506
+ createId: () => this.nextId ?? (this.options.createId ?? randomId)()
507
+ });
508
+ this.session = session;
509
+ this.unsubscribe.push(
510
+ session.on("state", () => this.update()),
511
+ session.on("status", (status) => this.statusChanged(status)),
512
+ session.on("event", (event) => this.event(event)),
513
+ session.on("error", (error) => this.options.onError?.(error))
514
+ );
515
+ session.setForeground(this.foreground);
516
+ session.connect();
517
+ this.flushEarly();
518
+ }
519
+ /** What was typed before there was a session to take it. */
520
+ flushEarly() {
521
+ const session = this.session;
522
+ if (!session) return;
523
+ for (const [id, text] of this.early) {
524
+ this.nextId = id;
525
+ session.sendText(text);
526
+ }
527
+ this.nextId = null;
528
+ this.early.clear();
529
+ this.update();
530
+ }
531
+ /** A new session for the conversation, then `resume()`: once per person action, never in a loop. */
532
+ resuming = null;
533
+ resumeWith(reason) {
534
+ this.live();
535
+ if (this.resuming) return this.resuming;
536
+ if (!this.session) return this.ensureSession(reason).then(() => void 0);
537
+ this.resuming = this.fetchSession(reason).then((info) => {
538
+ this.resuming = null;
539
+ if (!info || this.disposed || !this.session) return;
540
+ this.info = info;
541
+ this.session.resume(info);
542
+ this.flushEarly();
543
+ });
544
+ return this.resuming;
545
+ }
546
+ /** The person acted after the session ended idle, expired or at its limit: the next session takes what they sent. */
547
+ continueIfEnded() {
548
+ if (this.nextFrom(this.engine.state()) === "continue")
549
+ void this.resumeWith("resume");
550
+ this.update();
551
+ }
552
+ reconnectIfDown() {
553
+ const session = this.session;
554
+ if (session && session.status === "idle" && this.nextFrom(this.engine.state()) === null)
555
+ session.connect();
556
+ }
557
+ statusChanged(status) {
558
+ if (status === "idle")
559
+ this.offlineAfterRetries = this.lastStatus === "reconnecting";
560
+ else if (status !== "reconnecting") this.offlineAfterRetries = false;
561
+ this.lastStatus = status;
562
+ this.update();
563
+ }
564
+ event(event) {
565
+ const frame = event;
566
+ const str = (v) => typeof v === "string" ? v : null;
567
+ const add = (key, kind, data) => {
568
+ if (this.system.some((s) => s.item.key === key)) return;
569
+ this.system = [
570
+ ...this.system,
571
+ {
572
+ item: { type: "system", key, event: kind, data },
573
+ order: lastOrder(this.engine.state()) + 0.75
574
+ }
575
+ ];
576
+ };
577
+ switch (frame.type) {
578
+ case "session.ready":
579
+ if (frame.resumed === true && Array.isArray(frame.transcript))
580
+ this.transcriptSeen = true;
581
+ return;
582
+ // `at` is required (v1-draft.15); the same takeover announced again after a reconnect has the same `at`, so it
583
+ // draws one row. A frame without one still gets a row of its own.
584
+ case "takeover.started":
585
+ add(
586
+ `takeover:${str(frame.at) ?? `#${++this.systemSeq}`}`,
587
+ "takeover_started",
588
+ { by: str(frame.by) }
589
+ );
590
+ return;
591
+ case "takeover.ended":
592
+ add(
593
+ `takeover-ended:${str(frame.at) ?? `#${++this.systemSeq}`}`,
594
+ "takeover_ended",
595
+ {}
596
+ );
597
+ return;
598
+ case "handoff.created": {
599
+ const ticket = frame.ticket;
600
+ add(`handoff:${str(ticket?.id) ?? ""}`, "handoff_created", {
601
+ expected_response: str(ticket?.expected_response)
602
+ });
603
+ return;
604
+ }
605
+ case "flow.completed":
606
+ add(`flow:#${++this.systemSeq}`, "flow_completed", {
607
+ result: str(frame.result),
608
+ code: str(frame.code)
609
+ });
610
+ return;
611
+ case "turn.failed": {
612
+ const turn = str(frame.turn_id) ?? "";
613
+ if (this.engine.state().messages.some((m) => m.key === `a:${turn}`))
614
+ return;
615
+ if (frame.code === "cancelled") return;
616
+ add(`failed:${turn}`, "turn_failed", {
617
+ code: str(frame.code),
618
+ message: str(frame.message),
619
+ retryable: frame.retryable === true
620
+ });
621
+ return;
622
+ }
623
+ default:
624
+ return;
625
+ }
626
+ }
627
+ // ------------------------------------------------------------ the view
628
+ /**
629
+ * Recomputes now (getSnapshot and getStreaming are always current) and notifies from the scheduler, at most once
630
+ * per frame, both channels in the same flush: a streamed reply that completes becomes its message in one frame.
631
+ */
632
+ update() {
633
+ const state = this.engine.state();
634
+ this.stream(state);
635
+ const view = this.compute(state);
636
+ if (view !== this.view) {
637
+ this.view = view;
638
+ this.viewDirty = true;
639
+ this.schedulePersist();
640
+ }
641
+ if (this.viewDirty || this.streamDirty) this.scheduleNotify();
642
+ }
643
+ scheduleNotify() {
644
+ if (this.framePending || this.disposed) return;
645
+ if (this.listeners.size === 0 && this.streamListeners.size === 0) return;
646
+ this.framePending = true;
647
+ this.frame = this.scheduler().request(() => {
648
+ this.frame = void 0;
649
+ this.framePending = false;
650
+ this.flush();
651
+ });
652
+ }
653
+ /** Calls the listeners of what changed since the last flush. */
654
+ flush() {
655
+ if (this.frame !== void 0) {
656
+ this.scheduler().cancel(this.frame);
657
+ this.frame = void 0;
658
+ this.framePending = false;
659
+ }
660
+ const view = this.viewDirty;
661
+ const stream = this.streamDirty;
662
+ this.viewDirty = false;
663
+ this.streamDirty = false;
664
+ if (stream) for (const l of [...this.streamListeners]) l();
665
+ if (view) for (const l of [...this.listeners]) l();
666
+ }
667
+ compute(state) {
668
+ const s = state ?? this.engine.state();
669
+ const prev = this.view;
670
+ for (const id of [...this.sent.keys()])
671
+ if (!this.early.has(id) && s.messages.some((m) => m.key === id))
672
+ this.sent.delete(id);
673
+ const items = buildTimeline(
674
+ {
675
+ state: s,
676
+ history: this.history,
677
+ sent: this.sent,
678
+ early: new Set(this.early.keys()),
679
+ retried: this.retried,
680
+ busy: this.busy,
681
+ decisionErrors: this.decisionErrors,
682
+ transcriptSeen: this.transcriptSeen,
683
+ system: this.system
684
+ },
685
+ prev?.items ?? NO_ITEMS
686
+ );
687
+ const status = this.status();
688
+ const next = this.nextFrom(s);
689
+ const activity = s.turn === null ? [] : s.tools.filter((t) => t.turn_id === s.turn);
690
+ const name = this.info?.agent?.display_name ?? null;
691
+ const avatarUrl = this.info?.agent?.avatar_url ?? null;
692
+ const view = {
693
+ status,
694
+ banner: banner(status, next),
695
+ next,
696
+ agent: sameAgent(prev?.agent, name, avatarUrl) ? prev.agent : { name, avatarUrl },
697
+ disclosure: s.disclosure ?? this.info?.disclosure ?? null,
698
+ takeover: s.takeover,
699
+ items,
700
+ activity: prev && sameTools(prev.activity, activity) ? prev.activity : activity,
701
+ responding: s.turn !== null || s.streaming !== null,
702
+ openQuestion: s.openQuestion,
703
+ answersNeedWebSocket: status === "sse",
704
+ turnsLeft: s.turnsLeft,
705
+ history: this.historyState
706
+ };
707
+ if (prev && shallowEqual(prev, view)) return prev;
708
+ return view;
709
+ }
710
+ status() {
711
+ if (this.disposed) return "closed";
712
+ const session = this.session;
713
+ if (!session) {
714
+ if (this.failed) return "unavailable";
715
+ return this.getting || this.early.size ? "starting" : "idle";
716
+ }
717
+ const status = session.status;
718
+ if (this.failed && status === "ended") return "unavailable";
719
+ if (status === "idle")
720
+ return this.offlineAfterRetries || this.online === false ? "offline" : "idle";
721
+ if (status === "reconnecting" && this.online === false) return "offline";
722
+ return status;
723
+ }
724
+ nextFrom(state) {
725
+ if (this.session?.status !== "ended" && state.reconnect === "allowed")
726
+ return null;
727
+ if (state.reconnect === "new_session_on_next_action") return "continue";
728
+ if (state.reconnect === "user_action_only") return "continue_here";
729
+ return null;
730
+ }
731
+ /** The streamed text, current at once; its listeners hear of it with the next flush. */
732
+ stream(state) {
733
+ const s = state.streaming;
734
+ if (!s) {
735
+ if (this.streaming !== null) {
736
+ this.streaming = null;
737
+ this.streamDirty = true;
738
+ }
739
+ return;
740
+ }
741
+ if (this.streaming?.turnId === s.turnId && this.streaming.text === s.text)
742
+ return;
743
+ this.streaming = { key: `a:${s.turnId}`, turnId: s.turnId, text: s.text };
744
+ this.streamDirty = true;
745
+ }
746
+ scheduler() {
747
+ return this.options.scheduler ?? defaultScheduler;
748
+ }
749
+ // ------------------------------------------------------------ persistence
750
+ key() {
751
+ return this.options.persistenceKey ?? "vinaverse.conversation";
752
+ }
753
+ restore() {
754
+ const store = this.options.persistence;
755
+ if (!store) return;
756
+ const done = () => {
757
+ this.historyState = "restored";
758
+ this.update();
759
+ };
760
+ const apply = (raw) => {
761
+ if (this.disposed) return;
762
+ if (raw === null) return done();
763
+ try {
764
+ const saved = JSON.parse(raw);
765
+ if (saved.v === PERSIST_VERSION && Array.isArray(saved.items))
766
+ this.history = saved.items.map(
767
+ (item) => item.type === "card" ? { ...item, busy: false, restored: true } : item
768
+ );
769
+ } catch {
770
+ }
771
+ done();
772
+ };
773
+ try {
774
+ const raw = store.getItem(this.key());
775
+ if (typeof raw === "string" || raw === null) apply(raw);
776
+ else {
777
+ this.historyState = "restoring";
778
+ this.view = this.compute(null);
779
+ raw.then(apply, () => {
780
+ if (!this.disposed) done();
781
+ });
782
+ }
783
+ } catch {
784
+ done();
785
+ }
786
+ }
787
+ schedulePersist() {
788
+ const store = this.options.persistence;
789
+ if (!store || this.disposed || this.persistTimer !== void 0) return;
790
+ this.persistTimer = setTimeout(() => {
791
+ this.persistTimer = void 0;
792
+ const items = this.view.items.filter(persistable).slice(-100);
793
+ try {
794
+ void store.setItem(
795
+ this.key(),
796
+ JSON.stringify({ v: PERSIST_VERSION, items })
797
+ );
798
+ } catch {
799
+ }
800
+ }, PERSIST_DELAY);
801
+ }
802
+ // ------------------------------------------------------------ guards
803
+ live() {
804
+ if (this.disposed)
805
+ throw new VinaverseClientError(
806
+ "session_ended",
807
+ "This conversation was closed."
808
+ );
809
+ }
810
+ required() {
811
+ this.live();
812
+ if (!this.session)
813
+ throw new VinaverseClientError(
814
+ "session_ended",
815
+ "The conversation has no session yet: start() it first."
816
+ );
817
+ return this.session;
818
+ }
819
+ };
820
+ function createConversation(options) {
821
+ return new ConversationStore(options);
822
+ }
823
+ var defaultScheduler = {
824
+ request(callback) {
825
+ const raf = globalThis.requestAnimationFrame;
826
+ return raf ? { raf: raf(callback) } : { timer: setTimeout(callback, 16) };
827
+ },
828
+ cancel(handle) {
829
+ const h = handle;
830
+ const caf = globalThis.cancelAnimationFrame;
831
+ if (h.raf !== void 0) caf?.(h.raf);
832
+ if (h.timer !== void 0) clearTimeout(h.timer);
833
+ }
834
+ };
835
+ function banner(status, next) {
836
+ if (status === "connecting" || status === "starting") return "connecting";
837
+ if (status === "reconnecting") return "reconnecting";
838
+ if (status === "offline") return "offline";
839
+ if (status === "unavailable" || status === "unauthorized")
840
+ return "unavailable";
841
+ if (next === "continue_here") return "continued_elsewhere";
842
+ return null;
843
+ }
844
+ function sameAgent(agent, name, avatarUrl) {
845
+ return agent?.name === name && agent?.avatarUrl === avatarUrl;
846
+ }
847
+ function sameTools(a, b) {
848
+ return a.length === b.length && a.every(
849
+ (t, i) => t.turn_id === b[i]?.turn_id && t.label === b[i]?.label && t.state === b[i]?.state
850
+ );
851
+ }
852
+ function shallowEqual(a, b) {
853
+ return a.status === b.status && a.banner === b.banner && a.next === b.next && a.agent.name === b.agent.name && a.agent.avatarUrl === b.agent.avatarUrl && a.disclosure === b.disclosure && a.takeover?.by === b.takeover?.by && a.takeover?.at === b.takeover?.at && a.takeover === null === (b.takeover === null) && a.items === b.items && a.activity === b.activity && a.responding === b.responding && a.openQuestion === b.openQuestion && a.answersNeedWebSocket === b.answersNeedWebSocket && a.turnsLeft === b.turnsLeft && a.history === b.history;
854
+ }
855
+
856
+ export { ConversationStore, MAX_TEXT, answerProblem, codePoints, createConversation, isAllowedLink, isValidLinkPrefix };
857
+ //# sourceMappingURL=index.js.map
858
+ //# sourceMappingURL=index.js.map