@vinaverse/client 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (55) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +127 -1
  3. package/dist/agent/index.cjs +3321 -0
  4. package/dist/agent/index.cjs.map +1 -0
  5. package/dist/agent/index.d.cts +57 -0
  6. package/dist/agent/index.d.ts +57 -0
  7. package/dist/agent/index.js +99 -0
  8. package/dist/agent/index.js.map +1 -0
  9. package/dist/backend/index.cjs +295 -0
  10. package/dist/backend/index.cjs.map +1 -0
  11. package/dist/backend/index.d.cts +82 -0
  12. package/dist/backend/index.d.ts +82 -0
  13. package/dist/backend/index.js +277 -0
  14. package/dist/backend/index.js.map +1 -0
  15. package/dist/chunk-46D3FSLU.js +997 -0
  16. package/dist/chunk-46D3FSLU.js.map +1 -0
  17. package/dist/chunk-D56MNG3E.js +9 -0
  18. package/dist/chunk-D56MNG3E.js.map +1 -0
  19. package/dist/{chunk-AWHPWRGT.js → chunk-GWLLUOFY.js} +71 -15
  20. package/dist/chunk-GWLLUOFY.js.map +1 -0
  21. package/dist/chunk-JI6HUXH3.js +861 -0
  22. package/dist/chunk-JI6HUXH3.js.map +1 -0
  23. package/dist/contract-Be2oVHNr.d.cts +6 -0
  24. package/dist/contract-Be2oVHNr.d.ts +6 -0
  25. package/dist/conversation/index.cjs +356 -27
  26. package/dist/conversation/index.cjs.map +1 -1
  27. package/dist/conversation/index.d.cts +23 -307
  28. package/dist/conversation/index.d.ts +23 -307
  29. package/dist/conversation/index.js +36 -760
  30. package/dist/conversation/index.js.map +1 -1
  31. package/dist/history/index.cjs +282 -0
  32. package/dist/history/index.cjs.map +1 -0
  33. package/dist/history/index.d.cts +91 -0
  34. package/dist/history/index.d.ts +91 -0
  35. package/dist/history/index.js +261 -0
  36. package/dist/history/index.js.map +1 -0
  37. package/dist/index.cjs +70 -8
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.d.cts +2 -1
  40. package/dist/index.d.ts +2 -1
  41. package/dist/index.js +2 -1
  42. package/dist/runtime-6O0qIPmo.d.cts +394 -0
  43. package/dist/runtime-CNgz2_8G.d.ts +394 -0
  44. package/dist/{session--W6JXfwo.d.cts → session-D0ijOQX3.d.cts} +3330 -1614
  45. package/dist/{session--W6JXfwo.d.ts → session-D0ijOQX3.d.ts} +3330 -1614
  46. package/dist/store-CGRSxVcL.d.ts +414 -0
  47. package/dist/store-CxoHwd64.d.cts +414 -0
  48. package/dist/testing/index.cjs +418 -0
  49. package/dist/testing/index.cjs.map +1 -0
  50. package/dist/testing/index.d.cts +97 -0
  51. package/dist/testing/index.d.ts +97 -0
  52. package/dist/testing/index.js +403 -0
  53. package/dist/testing/index.js.map +1 -0
  54. package/package.json +105 -11
  55. package/dist/chunk-AWHPWRGT.js.map +0 -1
@@ -0,0 +1,3321 @@
1
+ 'use strict';
2
+
3
+ // src/events.ts
4
+ var CARD_KINDS = {
5
+ professional: true,
6
+ workspace: true,
7
+ service: true,
8
+ time_slots: true,
9
+ booking: true,
10
+ quote: true,
11
+ policy: true,
12
+ handoff: true,
13
+ metric_chart: true,
14
+ metric_table: true,
15
+ gap_list: true,
16
+ cluster: true,
17
+ claim: true,
18
+ document: true,
19
+ question: true,
20
+ proposal: true,
21
+ next_step: true,
22
+ links: true
23
+ };
24
+ function isKnownCard(card) {
25
+ return Object.hasOwn(CARD_KINDS, card.kind);
26
+ }
27
+
28
+ // src/agent/cards.ts
29
+ var LISTING = /* @__PURE__ */ new Set(["professional", "workspace"]);
30
+ var PURPOSES = /* @__PURE__ */ new Set(["help", "support", "policy", "contact", "feedback"]);
31
+ var GROUP_ID = /^[A-Za-z0-9_-]{1,64}$/;
32
+ var CURRENCY = /^[A-Z]{3}$/;
33
+ var isFields = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
34
+ var text = (v) => typeof v === "string" ? v : null;
35
+ var whole = (v) => typeof v === "number" && Number.isInteger(v) ? v : null;
36
+ var list = (v) => Array.isArray(v) ? v : [];
37
+ var https = (v) => {
38
+ const url = text(v);
39
+ return url !== null && /^https:\/\//i.test(url) ? url : null;
40
+ };
41
+ var instant = (v) => {
42
+ const at = text(v);
43
+ return at !== null && !Number.isNaN(Date.parse(at)) ? at : null;
44
+ };
45
+ function money(v) {
46
+ if (!isFields(v)) return null;
47
+ const amount = whole(v.amount);
48
+ const currency = text(v.currency);
49
+ return amount !== null && amount >= 0 && currency !== null && CURRENCY.test(currency) ? { amount, currency } : null;
50
+ }
51
+ function hero(card) {
52
+ for (const image of list(card.images)) {
53
+ if (!isFields(image)) continue;
54
+ const url = https(image.url);
55
+ if (url === null) continue;
56
+ const width = whole(image.width);
57
+ const height = whole(image.height);
58
+ return {
59
+ url,
60
+ ratio: width && height && width > 0 && height > 0 ? width / height : null
61
+ };
62
+ }
63
+ const legacy = https(card.image_url);
64
+ return legacy === null ? null : { url: legacy, ratio: null };
65
+ }
66
+ function readCard(value) {
67
+ if (!isFields(value)) return null;
68
+ const id = text(value.id);
69
+ const kind = text(value.kind);
70
+ const title = text(value.title);
71
+ if (id === null || kind === null || title === null) return null;
72
+ if (!isKnownCard({ kind })) return null;
73
+ const rating = isFields(value.rating) ? value.rating : null;
74
+ const ppm = whole(rating?.value_ppm);
75
+ const count = whole(rating?.count);
76
+ const group = isFields(value.group) ? value.group : null;
77
+ const groupId = text(group?.id);
78
+ const ref = isFields(value.ref) ? value.ref : null;
79
+ const refType = text(ref?.type);
80
+ const refId = text(ref?.id);
81
+ const proposal = isFields(value.proposal) ? value.proposal : null;
82
+ return {
83
+ id,
84
+ kind,
85
+ listing: LISTING.has(kind),
86
+ title,
87
+ subtitle: text(value.subtitle),
88
+ body: text(value.body),
89
+ badges: list(value.badges).filter(
90
+ (b) => typeof b === "string"
91
+ ),
92
+ actions: list(value.actions).flatMap((a) => {
93
+ if (!isFields(a)) return [];
94
+ const action = text(a.action);
95
+ const label = text(a.label);
96
+ return action === null || label === null ? [] : [{ action, label, deepLink: text(a.deep_link) }];
97
+ }),
98
+ target: refType !== null && refId !== null ? { type: refType, id: refId } : null,
99
+ hero: hero(value),
100
+ rating: ppm !== null && count !== null && ppm >= 0 && ppm <= 5e6 && count >= 0 ? { stars: ppm / 1e6, count } : null,
101
+ priceFrom: money(value.price_from),
102
+ price: money(value.price),
103
+ nextAvailable: instant(value.next_available),
104
+ startsAt: instant(value.starts_at),
105
+ group: groupId !== null && GROUP_ID.test(groupId) ? {
106
+ id: groupId,
107
+ layout: group?.layout === "carousel" ? "carousel" : "list"
108
+ } : null,
109
+ links: list(value.links).flatMap((l) => {
110
+ if (!isFields(l)) return [];
111
+ const label = text(l.label);
112
+ const url = text(l.url)?.trim() ?? "";
113
+ const purpose = text(l.purpose);
114
+ return label && [...label].length <= 40 && url && purpose && PURPOSES.has(purpose) ? [{ label, url, purpose }] : [];
115
+ }),
116
+ proposalId: kind === "proposal" ? text(proposal?.id) : null
117
+ };
118
+ }
119
+
120
+ // src/engine.ts
121
+ var UNLOCKS_KEEP_LOCKED = /* @__PURE__ */ new Set([
122
+ "card_superseded",
123
+ "client_event_id_reused"
124
+ ]);
125
+ var RESEND = /* @__PURE__ */ new Set(["turn_in_progress", "screen_unavailable"]);
126
+ var FLOW_RESULTS = /* @__PURE__ */ new Set(["ok", "refused", "failed"]);
127
+ var OUTCOMES = /* @__PURE__ */ new Set(["confirmed", "declined", "failed", "expired"]);
128
+ var RECONNECTABLE = /* @__PURE__ */ new Set(["server_restart"]);
129
+ var NEW_SESSION = /* @__PURE__ */ new Set(["idle", "expired", "limit_reached"]);
130
+ var str = (v) => typeof v === "string" ? v : void 0;
131
+ var ConversationEngine = class {
132
+ sessionId = null;
133
+ conversationId = null;
134
+ connection = null;
135
+ reconnect = "allowed";
136
+ messages = [];
137
+ cards = /* @__PURE__ */ new Map();
138
+ unacknowledged = /* @__PURE__ */ new Map();
139
+ resend = [];
140
+ held = [];
141
+ refused = {};
142
+ flow = null;
143
+ turn = null;
144
+ tools = [];
145
+ handoffs = [];
146
+ errors = [];
147
+ expiring = null;
148
+ streaming = null;
149
+ takeover = null;
150
+ rejections = {};
151
+ disclosure = null;
152
+ turnsLeft = null;
153
+ /** The next arrival number for a message or a card. */
154
+ seq = 0;
155
+ /** The cards drawn before a resumed flow's transcript replaced them: proposal outcomes carry over. */
156
+ replaced = null;
157
+ /** client_event_id → the first event the app sent under it, until it is settled. */
158
+ inputs = /* @__PURE__ */ new Map();
159
+ // ------------------------------------------------------------ what happens around the frames
160
+ /** The tenant's backend created a session (and named its conversation). */
161
+ sessionCreated(sessionId, conversationId) {
162
+ this.sessionId = sessionId;
163
+ this.conversationId = conversationId ?? null;
164
+ }
165
+ /** A connection opened: the user's own action, or an allowed reconnect, so it lifts a hold on reconnecting. */
166
+ connected(name) {
167
+ this.connection = { name, state: "open" };
168
+ this.reconnect = "allowed";
169
+ }
170
+ /** A connection closed with its code. `4403` (revoked) and `4409` (replaced) hold reconnecting for a user action. */
171
+ closed(name, code) {
172
+ if (this.connection?.name !== name) return;
173
+ this.connection.state = "closed";
174
+ this.connection.code = code;
175
+ if (this.connection.reason === void 0) {
176
+ if (code === 4403 || code === 4409) this.reconnect = "user_action_only";
177
+ else if (code === 4408) this.reconnect = "new_session_on_next_action";
178
+ }
179
+ this.cutOff("interrupted");
180
+ this.turn = null;
181
+ }
182
+ /** The app was killed: what it drew is gone; what it sent and never saw acknowledged it kept, to resend. */
183
+ killed() {
184
+ this.messages = [];
185
+ this.cards = /* @__PURE__ */ new Map();
186
+ this.turn = null;
187
+ this.streaming = null;
188
+ this.takeover = null;
189
+ }
190
+ // ------------------------------------------------------------ what the app sends
191
+ sent(event) {
192
+ const id = str(event.client_event_id);
193
+ switch (event.type) {
194
+ case "ui.answer": {
195
+ if (id === void 0) return;
196
+ if (!this.inputs.has(id)) this.inputs.set(id, event);
197
+ this.unqueue(id);
198
+ this.unacknowledged.set(id, event);
199
+ const card = this.cards.get(str(event.card_id) ?? "");
200
+ const questionId = str(event.question_id);
201
+ if (card && questionId) lock(card, questionId);
202
+ return;
203
+ }
204
+ case "input.text": {
205
+ if (id === void 0) return;
206
+ if (!this.inputs.has(id)) this.inputs.set(id, event);
207
+ this.unqueue(id);
208
+ delete this.refused[id];
209
+ this.unacknowledged.set(id, event);
210
+ return;
211
+ }
212
+ case "ui.action": {
213
+ if (id !== void 0) {
214
+ if (!this.inputs.has(id)) this.inputs.set(id, event);
215
+ this.unqueue(id);
216
+ this.unacknowledged.set(id, event);
217
+ }
218
+ const card = this.cards.get(str(event.card_id) ?? "");
219
+ const action = str(event.action);
220
+ if (card?.kind === "proposal" && action && OUTCOMES.has(action) && card.outcome === null) {
221
+ card.outcome = action;
222
+ if (id !== void 0) card.outcomeBy = id;
223
+ }
224
+ return;
225
+ }
226
+ default:
227
+ return;
228
+ }
229
+ }
230
+ // ------------------------------------------------------------ what the server sends
231
+ received(event) {
232
+ switch (event.type) {
233
+ case "session.ready":
234
+ this.ready(event);
235
+ return;
236
+ case "turn.started": {
237
+ this.turn = str(event.turn_id) ?? null;
238
+ const id = str(event.client_event_id);
239
+ if (id !== void 0) {
240
+ const sent = this.inputs.get(id);
241
+ this.settle(id);
242
+ if (sent?.type === "input.text") {
243
+ this.say("person", str(sent.text) ?? "", id);
244
+ }
245
+ }
246
+ return;
247
+ }
248
+ case "message.delta": {
249
+ const turnId = str(event.turn_id) ?? "";
250
+ const text2 = str(event.text) ?? "";
251
+ if (this.streaming?.turnId === turnId)
252
+ this.streaming = {
253
+ ...this.streaming,
254
+ text: this.streaming.text + text2
255
+ };
256
+ else {
257
+ this.cutOff("interrupted");
258
+ this.streaming = { turnId, text: text2, seq: this.seq++ };
259
+ }
260
+ return;
261
+ }
262
+ case "message.completed": {
263
+ const turnId = str(event.turn_id) ?? "";
264
+ const streaming = this.streaming?.turnId === turnId ? this.streaming : null;
265
+ if (streaming) this.streaming = null;
266
+ this.say(
267
+ "assistant",
268
+ str(event.text) ?? "",
269
+ `a:${turnId}`,
270
+ {},
271
+ streaming?.seq
272
+ );
273
+ return;
274
+ }
275
+ case "ui.cards":
276
+ for (const card of asArray(event.cards))
277
+ this.hold(card, str(event.turn_id));
278
+ return;
279
+ case "tool.status": {
280
+ const line = {
281
+ turn_id: str(event.turn_id) ?? "",
282
+ label: str(event.label) ?? "",
283
+ state: str(event.state) ?? ""
284
+ };
285
+ const at = this.tools.findIndex(
286
+ (t) => t.turn_id === line.turn_id && t.label === line.label
287
+ );
288
+ if (at === -1) this.tools = [...this.tools, line].slice(-100);
289
+ else this.tools[at] = line;
290
+ return;
291
+ }
292
+ case "handoff.created":
293
+ this.handoffs.push(event.ticket);
294
+ return;
295
+ case "turn.completed":
296
+ this.turn = null;
297
+ return;
298
+ case "turn.failed": {
299
+ const code = str(event.code) ?? "";
300
+ if (this.streaming?.turnId === str(event.turn_id))
301
+ this.cutOff(
302
+ code === "output_blocked" ? "blocked" : code === "cancelled" ? "interrupted" : "failed"
303
+ );
304
+ this.turn = null;
305
+ this.error(code);
306
+ return;
307
+ }
308
+ case "takeover.started":
309
+ this.takeover = {
310
+ by: str(event.by) ?? null,
311
+ at: str(event.at) ?? ""
312
+ };
313
+ return;
314
+ case "takeover.ended":
315
+ this.takeover = null;
316
+ return;
317
+ case "message.staff": {
318
+ const id = str(event.message_id);
319
+ if (id !== void 0)
320
+ this.say("staff", str(event.text) ?? "", `s:${id}`);
321
+ return;
322
+ }
323
+ case "input.received": {
324
+ const id = str(event.client_event_id);
325
+ if (id === void 0) return;
326
+ const sent = this.inputs.get(id);
327
+ this.settle(id);
328
+ if (sent?.type === "input.text")
329
+ this.say("person", str(sent.text) ?? "", id, { received: true });
330
+ return;
331
+ }
332
+ case "ui.answer.recorded":
333
+ this.recorded(event);
334
+ return;
335
+ case "ui.answer.rejected":
336
+ this.rejected(event);
337
+ return;
338
+ case "input.rejected":
339
+ this.inputRejected(event);
340
+ return;
341
+ case "flow.completed": {
342
+ const result = str(event.result) ?? "";
343
+ this.flow = {
344
+ flow: event.flow,
345
+ result: FLOW_RESULTS.has(result) ? result : "failed"
346
+ };
347
+ return;
348
+ }
349
+ case "session.closed": {
350
+ const reason = str(event.reason);
351
+ if (reason === void 0) return;
352
+ if (NEW_SESSION.has(reason))
353
+ this.reconnect = "new_session_on_next_action";
354
+ else if (!RECONNECTABLE.has(reason))
355
+ this.reconnect = "user_action_only";
356
+ if (this.connection) this.connection.reason = reason;
357
+ return;
358
+ }
359
+ case "session.expiring":
360
+ this.expiring = typeof event.seconds_left === "number" ? event.seconds_left : null;
361
+ return;
362
+ case "error": {
363
+ this.error(str(event.code) ?? "");
364
+ const id = str(event.client_event_id);
365
+ if (id !== void 0 && event.code === "screen_unavailable") {
366
+ this.inputRejected(event);
367
+ } else if (id !== void 0) {
368
+ this.settle(id);
369
+ }
370
+ return;
371
+ }
372
+ default:
373
+ return;
374
+ }
375
+ }
376
+ ready(event) {
377
+ const session = event.session;
378
+ this.sessionId = str(session?.id) ?? this.sessionId;
379
+ this.turn = null;
380
+ this.expiring = null;
381
+ this.disclosure = str(event.disclosure) ?? this.disclosure;
382
+ const limits = event.limits;
383
+ this.turnsLeft = typeof limits?.turns_left === "number" ? limits.turns_left : null;
384
+ this.takeover = null;
385
+ this.cutOff("interrupted");
386
+ if (event.resumed !== true) return;
387
+ if (event.transcript !== void 0 && transcriptHasIds(event.transcript)) {
388
+ this.mergeTranscript(asArray(event.transcript));
389
+ } else if (event.transcript !== void 0) {
390
+ this.messages = [];
391
+ this.replaced = this.cards;
392
+ this.cards = /* @__PURE__ */ new Map();
393
+ for (const item of asArray(event.transcript)) {
394
+ if (item.role === "card")
395
+ this.hold(item.card);
396
+ else {
397
+ const role = str(item.role) ?? "";
398
+ this.say(
399
+ role,
400
+ str(item.text) ?? "",
401
+ transcriptKey(role, str(item.id), str(item.at)),
402
+ item.interrupted === true ? { partial: "interrupted" } : {}
403
+ );
404
+ }
405
+ }
406
+ }
407
+ for (const card of asArray(event.pending_cards))
408
+ this.hold(card, str(card.turn_id));
409
+ this.replaced = null;
410
+ }
411
+ /**
412
+ * A transcript merged by key: an item the app holds keeps its place, with the text the server recorded; one it does
413
+ * not hold goes just after the nearest earlier item it does (just before the first later one when none came before,
414
+ * at the end when it holds none of them). Nothing the app holds is dropped.
415
+ */
416
+ mergeTranscript(items) {
417
+ const seqOf = (key) => key === void 0 ? void 0 : (this.messages.find((m) => m.key === key) ?? this.cards.get(key))?.seq;
418
+ const keys = items.map(
419
+ (item) => item.role === "card" ? str(item.card?.id) : transcriptKey(str(item.role) ?? "", str(item.id), str(item.at))
420
+ );
421
+ let prev;
422
+ items.forEach((item, i) => {
423
+ const key = keys[i];
424
+ if (key === void 0) return;
425
+ const known = seqOf(key);
426
+ let at;
427
+ if (known === void 0) {
428
+ const near = prev ?? keys.slice(i + 1).map(seqOf).find((q) => q !== void 0);
429
+ if (near !== void 0) {
430
+ const seqs = [...this.messages, ...this.cards.values()].map(
431
+ (h) => h.seq
432
+ );
433
+ const bound = prev === void 0 ? Math.max(near - 1, ...seqs.filter((q) => q < near)) : Math.min(...seqs.filter((q) => q > near));
434
+ if (Number.isFinite(bound)) at = (near + bound) / 2;
435
+ }
436
+ }
437
+ if (item.role === "card") this.hold(item.card, void 0, at);
438
+ else {
439
+ const text2 = str(item.text) ?? "";
440
+ const partial = item.interrupted === true ? "interrupted" : void 0;
441
+ if (known === void 0)
442
+ this.say(
443
+ str(item.role) ?? "",
444
+ text2,
445
+ key,
446
+ partial ? { partial } : {},
447
+ at
448
+ );
449
+ else
450
+ this.messages = this.messages.map((m) => {
451
+ if (m.key !== key) return m;
452
+ const { partial: local, ...rest } = m;
453
+ const kept = partial ?? (local === "interrupted" ? void 0 : local);
454
+ return {
455
+ ...rest,
456
+ text: text2 || m.text,
457
+ ...kept ? { partial: kept } : {}
458
+ };
459
+ });
460
+ }
461
+ prev = seqOf(key);
462
+ });
463
+ this.messages.sort((a, b) => a.seq - b.seq);
464
+ }
465
+ hold(raw, turnId, at) {
466
+ if (!raw) return;
467
+ const id = str(raw.id);
468
+ if (id === void 0) return;
469
+ const kind = str(raw.kind) ?? "";
470
+ const question = raw.question;
471
+ const card = {
472
+ id,
473
+ kind,
474
+ card: raw,
475
+ ...question ? { question: structuredCopy(question) } : {},
476
+ locked: lockedFrom(question),
477
+ ...turnId !== void 0 ? { turnId } : {},
478
+ // A card still held keeps its place when it is drawn again (a reconnect's pending cards, a repeated ui.cards).
479
+ seq: this.cards.get(id)?.seq ?? at ?? this.seq++
480
+ };
481
+ for (const held of this.held) {
482
+ const sent = this.inputs.get(held);
483
+ if (sent?.type === "ui.answer" && sent.card_id === id) {
484
+ const questionId = str(sent.question_id);
485
+ if (questionId !== void 0) lock(card, questionId);
486
+ }
487
+ }
488
+ if (kind === "proposal") {
489
+ const held = this.cards.get(id) ?? this.replaced?.get(id);
490
+ card.outcome = held?.outcome ?? null;
491
+ if (held?.outcomeBy !== void 0) card.outcomeBy = held.outcomeBy;
492
+ }
493
+ this.cards.delete(id);
494
+ this.cards.set(id, card);
495
+ }
496
+ recorded(event) {
497
+ const id = str(event.client_event_id) ?? "";
498
+ const card = this.cards.get(str(event.card_id) ?? "");
499
+ const sent = this.inputs.get(id);
500
+ this.settle(id);
501
+ if (!card?.question || !sent) return;
502
+ const q = card.question;
503
+ const questionId = str(event.question_id) ?? "";
504
+ lock(card, questionId);
505
+ if (q.kind === "rate_each") {
506
+ const rows = (q.row_answers ?? []).filter(
507
+ (r) => r.question_id !== questionId
508
+ );
509
+ q.row_answers = [
510
+ ...rows,
511
+ { question_id: questionId, answer: sent.answer }
512
+ ];
513
+ const answered = q.row_answers;
514
+ if ((q.rows ?? []).every(
515
+ (r) => answered.some((a) => a.question_id === r.question_id)
516
+ )) {
517
+ q.state = "answered";
518
+ lock(card, q.question_id);
519
+ }
520
+ } else {
521
+ q.answer = sent.answer;
522
+ q.state = "answered";
523
+ }
524
+ }
525
+ rejected(event) {
526
+ this.settle(str(event.client_event_id) ?? "");
527
+ const card = this.cards.get(str(event.card_id) ?? "");
528
+ const questionId = str(event.question_id);
529
+ if (!card || questionId === void 0) return;
530
+ if (UNLOCKS_KEEP_LOCKED.has(str(event.code) ?? "")) lock(card, questionId);
531
+ else unlock(card, questionId);
532
+ }
533
+ inputRejected(event) {
534
+ const id = str(event.client_event_id);
535
+ if (id === void 0) return;
536
+ this.unacknowledged.delete(id);
537
+ const code = str(event.code) ?? "";
538
+ this.rejections[id] = { code, message: str(event.message) ?? "" };
539
+ if (RESEND.has(code)) {
540
+ if (!this.resend.includes(id)) this.resend.push(id);
541
+ return;
542
+ }
543
+ if (code === "limit_reached") {
544
+ this.unqueue(id);
545
+ const kind = this.inputs.get(id)?.type;
546
+ if (kind === "input.text" || kind === "ui.answer") {
547
+ if (!this.held.includes(id)) this.held.push(id);
548
+ } else this.inputs.delete(id);
549
+ this.reconnect = "new_session_on_next_action";
550
+ return;
551
+ }
552
+ if (code !== "duplicate") this.refused[id] = code;
553
+ const sent = this.inputs.get(id);
554
+ if (code === "guardrail" && sent?.type === "input.text") {
555
+ this.say("person", str(sent.text) ?? "", id);
556
+ this.say("assistant", str(event.message) ?? "", `r:${id}`, {
557
+ refusal: true
558
+ });
559
+ }
560
+ this.inputs.delete(id);
561
+ }
562
+ /** No longer to resend, nor held for the next session (sent again, or settled). */
563
+ unqueue(id) {
564
+ this.resend = this.resend.filter((r) => r !== id);
565
+ this.held = this.held.filter((r) => r !== id);
566
+ }
567
+ /** An input acknowledged or refused for good: nothing more is resent under its id. */
568
+ settle(id) {
569
+ delete this.rejections[id];
570
+ this.unacknowledged.delete(id);
571
+ this.inputs.delete(id);
572
+ this.unqueue(id);
573
+ }
574
+ /** Adds a message once: a key already held (a frame seen twice) changes nothing. */
575
+ say(role, text2, key, extra = {}, seq) {
576
+ if (this.messages.some((m) => m.key === key)) return;
577
+ this.messages.push({ role, text: text2, key, seq: seq ?? this.seq++, ...extra });
578
+ }
579
+ /** The turn's streamed text, kept as the agent's message when the turn ends without its `message.completed`. */
580
+ cutOff(partial) {
581
+ const streaming = this.streaming;
582
+ this.streaming = null;
583
+ const text2 = streaming?.text.trim();
584
+ if (streaming && text2)
585
+ this.say(
586
+ "assistant",
587
+ text2,
588
+ `a:${streaming.turnId}`,
589
+ { partial },
590
+ streaming.seq
591
+ );
592
+ }
593
+ error(code) {
594
+ this.errors = [...this.errors, code].slice(-100);
595
+ }
596
+ /**
597
+ * An input that could not be delivered, or whose retries are spent (`screen_unavailable` after the last try): it is
598
+ * no longer resent. A typed message is shown as not sent, with `code`; an answer's question unlocks so the person
599
+ * can answer again; a proposal outcome it reported is taken back, so it can be reported again.
600
+ */
601
+ giveUp(clientEventId, code) {
602
+ const sent = this.inputs.get(clientEventId);
603
+ if (!sent) return;
604
+ const rejection = this.rejections[clientEventId];
605
+ this.resend = this.resend.filter((r) => r !== clientEventId);
606
+ this.settle(clientEventId);
607
+ const card = this.cards.get(str(sent.card_id) ?? "");
608
+ if (sent.type === "input.text") {
609
+ this.refused[clientEventId] = code;
610
+ if (rejection) this.rejections[clientEventId] = rejection;
611
+ } else if (sent.type === "ui.answer") {
612
+ const questionId = str(sent.question_id);
613
+ if (card && questionId !== void 0) unlock(card, questionId);
614
+ } else if (card?.outcomeBy === clientEventId) {
615
+ card.outcome = null;
616
+ delete card.outcomeBy;
617
+ }
618
+ }
619
+ /** A turn the app stopped where no event will say so (an SSE turn cancelled): none is in progress. */
620
+ turnEnded() {
621
+ this.turn = null;
622
+ }
623
+ // ------------------------------------------------------------ reading it
624
+ /** The event first sent under a `client_event_id`, to resend it unchanged. */
625
+ input(clientEventId) {
626
+ return this.inputs.get(clientEventId);
627
+ }
628
+ state() {
629
+ const cards = [...this.cards.values()].map(freeze);
630
+ const open = cards.filter(
631
+ (c) => c.kind === "question" && c.question?.state === "open"
632
+ );
633
+ return {
634
+ sessionId: this.sessionId,
635
+ conversationId: this.conversationId,
636
+ connection: this.connection ? { ...this.connection } : null,
637
+ reconnect: this.reconnect,
638
+ messages: [...this.messages],
639
+ cards,
640
+ openQuestion: open.length ? open[open.length - 1].id : null,
641
+ unacknowledged: [...this.unacknowledged.values()],
642
+ resend: [...this.resend],
643
+ held: [...this.held],
644
+ refused: { ...this.refused },
645
+ flow: this.flow,
646
+ turn: this.turn,
647
+ tools: [...this.tools],
648
+ handoffs: [...this.handoffs],
649
+ errors: [...this.errors],
650
+ streaming: this.streaming,
651
+ takeover: this.takeover,
652
+ rejections: { ...this.rejections },
653
+ disclosure: this.disclosure,
654
+ turnsLeft: this.turnsLeft,
655
+ heldForNextSession: this.reconnect === "new_session_on_next_action" ? [
656
+ .../* @__PURE__ */ new Set([
657
+ ...this.unacknowledged.keys(),
658
+ ...this.resend,
659
+ ...this.held
660
+ ])
661
+ ] : [],
662
+ expiring: this.expiring
663
+ };
664
+ }
665
+ };
666
+ function transcriptKey(role, id, at) {
667
+ if (id === void 0) return `t:${at ?? ""}:${role}`;
668
+ if (role === "assistant") return `a:${id}`;
669
+ if (role === "staff") return `s:${id}`;
670
+ return id;
671
+ }
672
+ function transcriptHasIds(transcript) {
673
+ return asArray(transcript).every(
674
+ (item) => item.role === "card" ? str(item.card?.id) !== void 0 : str(item.id) !== void 0
675
+ );
676
+ }
677
+ function lockedFrom(question) {
678
+ if (!question) return [];
679
+ if (question.state !== "open") {
680
+ return [
681
+ question.question_id,
682
+ ...(question.rows ?? []).map((r) => r.question_id)
683
+ ];
684
+ }
685
+ return (question.row_answers ?? []).map((r) => r.question_id);
686
+ }
687
+ function lock(card, id) {
688
+ if (!card.locked.includes(id)) card.locked.push(id);
689
+ }
690
+ function unlock(card, id) {
691
+ card.locked = card.locked.filter((q) => q !== id);
692
+ }
693
+ function freeze(card) {
694
+ return {
695
+ id: card.id,
696
+ kind: card.kind,
697
+ card: card.card,
698
+ ...card.question ? { question: { ...card.question } } : {},
699
+ locked: [...card.locked],
700
+ ...card.turnId !== void 0 ? { turnId: card.turnId } : {},
701
+ ...card.outcome !== void 0 ? { outcome: card.outcome } : {},
702
+ seq: card.seq
703
+ };
704
+ }
705
+ function asArray(value) {
706
+ return Array.isArray(value) ? value : [];
707
+ }
708
+ function structuredCopy(value) {
709
+ return JSON.parse(JSON.stringify(value));
710
+ }
711
+
712
+ // src/errors.ts
713
+ var VinaverseClientError = class extends Error {
714
+ name = "VinaverseClientError";
715
+ code;
716
+ status;
717
+ constructor(code, message, status) {
718
+ super(message);
719
+ this.code = code;
720
+ this.status = status;
721
+ }
722
+ };
723
+
724
+ // src/secret.ts
725
+ var CLIENT_SECRET = /^vv_cs_[A-Za-z0-9_-]{16,}$/;
726
+ function clientSecret(value) {
727
+ const secret = typeof value === "object" && value !== null ? value.value : value;
728
+ if (typeof secret === "string" && secret.startsWith("vv_sk_")) {
729
+ throw new VinaverseClientError(
730
+ "service_key",
731
+ "A Vinaverse service key belongs on your backend only. Pass the session client secret (vv_cs_\u2026) to the app."
732
+ );
733
+ }
734
+ if (typeof secret !== "string" || !CLIENT_SECRET.test(secret)) {
735
+ throw new VinaverseClientError(
736
+ "invalid_secret",
737
+ "Expected a session client secret (vv_cs_\u2026)."
738
+ );
739
+ }
740
+ return secret;
741
+ }
742
+
743
+ // src/sse.ts
744
+ var MAX_EVENT_CHARS = 1024 * 1024;
745
+ function turnSse(target, input) {
746
+ return post(target, target.turnsUrl, input);
747
+ }
748
+ function cardActionSse(target, turnId, input) {
749
+ return post(
750
+ target,
751
+ `${target.turnsUrl.replace(/\/+$/, "")}/${encodeURIComponent(turnId)}/actions`,
752
+ input
753
+ );
754
+ }
755
+ async function* post(target, url, body) {
756
+ const doFetch = target.fetch ?? globalThis.fetch;
757
+ const response = await doFetch(url, {
758
+ method: "POST",
759
+ headers: {
760
+ authorization: `Bearer ${target.secret}`,
761
+ "content-type": "application/json",
762
+ accept: "text/event-stream"
763
+ },
764
+ body: JSON.stringify(body),
765
+ ...target.signal ? { signal: target.signal } : {}
766
+ });
767
+ if (!response.ok) {
768
+ throw new VinaverseClientError(
769
+ "http_error",
770
+ `The turn request failed with HTTP ${response.status}.`,
771
+ response.status
772
+ );
773
+ }
774
+ const chunks = response.body ? decode(response.body) : once(await response.text());
775
+ yield* sseFrames(chunks);
776
+ }
777
+ async function* sseFrames(chunks) {
778
+ let buffer = "";
779
+ for await (const chunk of chunks) {
780
+ buffer += chunk;
781
+ if (buffer.length > MAX_EVENT_CHARS && boundary(buffer) === null) {
782
+ throw new VinaverseClientError(
783
+ "http_error",
784
+ "An SSE event is larger than 1 MiB."
785
+ );
786
+ }
787
+ let end = boundary(buffer);
788
+ while (end !== null) {
789
+ const frame = parseEvent(buffer.slice(0, end.at));
790
+ buffer = buffer.slice(end.at + end.length);
791
+ if (frame) yield frame;
792
+ end = boundary(buffer);
793
+ }
794
+ }
795
+ const last = parseEvent(buffer);
796
+ if (last) yield last;
797
+ }
798
+ function boundary(buffer) {
799
+ const match = /\r?\n\r?\n|\r\r/.exec(buffer);
800
+ return match ? { at: match.index, length: match[0].length } : null;
801
+ }
802
+ function parseEvent(block) {
803
+ let type;
804
+ const data = [];
805
+ for (const line of block.split(/\r?\n|\r/)) {
806
+ if (line === "" || line.startsWith(":")) continue;
807
+ const colon = line.indexOf(":");
808
+ const field2 = colon === -1 ? line : line.slice(0, colon);
809
+ let value = colon === -1 ? "" : line.slice(colon + 1);
810
+ if (value.startsWith(" ")) value = value.slice(1);
811
+ if (field2 === "event") type = value;
812
+ else if (field2 === "data") data.push(value);
813
+ }
814
+ if (data.length === 0) return null;
815
+ let parsed;
816
+ try {
817
+ parsed = JSON.parse(data.join("\n"));
818
+ } catch {
819
+ return null;
820
+ }
821
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
822
+ return null;
823
+ const frame = parsed;
824
+ const kind = typeof frame.type === "string" ? frame.type : type;
825
+ return kind === void 0 ? null : { ...frame, type: kind };
826
+ }
827
+ async function* decode(body) {
828
+ const reader = body.getReader();
829
+ const decoder = new TextDecoder();
830
+ try {
831
+ for (; ; ) {
832
+ const { done, value } = await reader.read();
833
+ if (done) break;
834
+ yield decoder.decode(value, { stream: true });
835
+ }
836
+ const rest = decoder.decode();
837
+ if (rest) yield rest;
838
+ } finally {
839
+ reader.releaseLock();
840
+ }
841
+ }
842
+ async function* once(text2) {
843
+ yield text2;
844
+ }
845
+
846
+ // src/session.ts
847
+ var OPEN = 1;
848
+ var SUBPROTOCOL = "vinaverse.v1";
849
+ var DEFAULT_RECONNECT = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
850
+ var DEFAULT_SCREEN_RETRY = [2e3, 4e3, 8e3];
851
+ var NO_RECONNECT = /* @__PURE__ */ new Set([4403, 4409]);
852
+ var FALLBACK_AFTER = 2;
853
+ var STABLE_MS = 3e4;
854
+ var VinaverseSession = class {
855
+ engine;
856
+ info;
857
+ secret;
858
+ options;
859
+ socket = null;
860
+ /** The engine's name for the connection in `socket`, once it opened. */
861
+ opened = null;
862
+ connection = 0;
863
+ ready = false;
864
+ current = "idle";
865
+ everOpened = false;
866
+ firstFailures = 0;
867
+ attempts = 0;
868
+ /** The reason of the `session.closed` that preceded the close, on the current connection. */
869
+ closeReason = null;
870
+ stableTimer;
871
+ /** SSE requests in flight. */
872
+ streams = /* @__PURE__ */ new Map();
873
+ reconnectTimer;
874
+ pingTimer;
875
+ foreground = true;
876
+ refreshed = false;
877
+ /** client_event_id → why it waits: after the turn, or after a backoff. */
878
+ waiting = /* @__PURE__ */ new Map();
879
+ /** client_event_id → the session that refused it for its turn limit: resent only on another session. */
880
+ heldForSession = /* @__PURE__ */ new Map();
881
+ screenTries = /* @__PURE__ */ new Map();
882
+ screenTimers = /* @__PURE__ */ new Map();
883
+ listeners = {
884
+ event: /* @__PURE__ */ new Set(),
885
+ state: /* @__PURE__ */ new Set(),
886
+ status: /* @__PURE__ */ new Set(),
887
+ error: /* @__PURE__ */ new Set()
888
+ };
889
+ constructor(options) {
890
+ this.options = options;
891
+ this.info = options.session;
892
+ this.secret = clientSecret(options.session.client_secret);
893
+ this.engine = options.engine ?? new ConversationEngine();
894
+ this.named(options.session);
895
+ }
896
+ /** The engine names the session, and keeps the conversation it already knew when this one does not say. */
897
+ named(session) {
898
+ this.engine.sessionCreated(
899
+ session.id,
900
+ session.conversation_id ?? this.engine.state().conversationId
901
+ );
902
+ }
903
+ get status() {
904
+ return this.current;
905
+ }
906
+ get state() {
907
+ return this.engine.state();
908
+ }
909
+ on(name, listener) {
910
+ this.listeners[name].add(listener);
911
+ return () => this.listeners[name].delete(listener);
912
+ }
913
+ // ------------------------------------------------------------ connecting
914
+ /**
915
+ * Connect, or reconnect: the user's own action, so it also resumes after the conversation continued elsewhere
916
+ * or the secret was refused.
917
+ */
918
+ connect() {
919
+ if (this.current === "closed") throw ended();
920
+ if (this.engine.state().reconnect === "new_session_on_next_action") {
921
+ throw new VinaverseClientError(
922
+ "needs_new_session",
923
+ "This session has ended; create a new session for the conversation and call resume() with it."
924
+ );
925
+ }
926
+ this.start();
927
+ }
928
+ start() {
929
+ this.cancelReconnect();
930
+ this.attempts = 0;
931
+ this.refreshed = false;
932
+ this.open();
933
+ }
934
+ /**
935
+ * Continue the conversation on a new session the tenant's backend created for it (by `conversation_key` or
936
+ * `conversation_id`): its `session.ready` brings the transcript and pending cards, and what was never
937
+ * acknowledged is resent there. A user action, like `connect()`.
938
+ */
939
+ resume(session) {
940
+ const secret = clientSecret(session.client_secret);
941
+ if (this.current === "closed") throw ended();
942
+ this.info = session;
943
+ this.secret = secret;
944
+ this.named(session);
945
+ this.drop(1e3);
946
+ this.start();
947
+ }
948
+ /** Close for good: the connection, any SSE turn still streaming, and every timer. */
949
+ close() {
950
+ this.cancelReconnect();
951
+ this.drop(1e3);
952
+ this.stopSse("close");
953
+ for (const timer of this.screenTimers.values()) clearTimeout(timer);
954
+ this.screenTimers.clear();
955
+ this.setStatus("closed");
956
+ }
957
+ /** Ping only while the app is in the foreground (every 25 s); in the background the connection may idle out. */
958
+ setForeground(foreground) {
959
+ this.foreground = foreground;
960
+ if (foreground && this.ready) this.startPing();
961
+ else this.stopPing();
962
+ }
963
+ open() {
964
+ this.drop(1e3);
965
+ const factory = this.options.webSocket ?? defaultWebSocket();
966
+ if (!factory) {
967
+ this.fallBack();
968
+ return;
969
+ }
970
+ const auth = this.options.auth ?? "subprotocol";
971
+ const protocols = auth === "subprotocol" ? [SUBPROTOCOL, `bearer.${this.secret}`] : [SUBPROTOCOL];
972
+ const headers = auth === "header" ? { authorization: `Bearer ${this.secret}` } : void 0;
973
+ const name = `c${++this.connection}`;
974
+ this.closeReason = null;
975
+ let socket;
976
+ try {
977
+ socket = factory(this.info.realtime_url, protocols, headers);
978
+ } catch {
979
+ this.fallBack();
980
+ return;
981
+ }
982
+ this.socket = socket;
983
+ this.ready = false;
984
+ this.setStatus(this.attempts > 0 ? "reconnecting" : "connecting");
985
+ socket.onopen = () => {
986
+ if (this.socket !== socket) return;
987
+ this.opened = name;
988
+ this.everOpened = true;
989
+ this.engine.connected(name);
990
+ this.emitState();
991
+ if (auth === "first_frame")
992
+ socket.send(
993
+ JSON.stringify({ type: "session.auth", client_secret: this.secret })
994
+ );
995
+ };
996
+ socket.onmessage = (event) => {
997
+ if (this.socket === socket) this.frame(event.data);
998
+ };
999
+ socket.onerror = () => {
1000
+ };
1001
+ socket.onclose = (event) => {
1002
+ if (this.socket !== socket) return;
1003
+ const opened = this.opened !== null;
1004
+ this.forget(event.code);
1005
+ this.afterClose(event.code, opened);
1006
+ };
1007
+ }
1008
+ afterClose(code, opened) {
1009
+ if (this.current === "closed") return;
1010
+ if (this.engine.state().reconnect !== "allowed" || NO_RECONNECT.has(code)) {
1011
+ this.setStatus("ended");
1012
+ return;
1013
+ }
1014
+ if (code === 4401) {
1015
+ this.refresh();
1016
+ return;
1017
+ }
1018
+ if (!opened && !this.everOpened) {
1019
+ this.firstFailures++;
1020
+ if (this.firstFailures >= FALLBACK_AFTER && this.options.sseFallback !== false) {
1021
+ this.fallBack();
1022
+ return;
1023
+ }
1024
+ this.scheduleReconnect();
1025
+ return;
1026
+ }
1027
+ if (this.closeReason === "server_restart") {
1028
+ this.scheduleReconnect();
1029
+ return;
1030
+ }
1031
+ if (code === 1e3) {
1032
+ this.setStatus("idle");
1033
+ return;
1034
+ }
1035
+ this.scheduleReconnect();
1036
+ }
1037
+ /** After a 4401: one fresh secret for the session in use, unless it moved on or was closed meanwhile. */
1038
+ refresh() {
1039
+ const refreshSecret = this.options.refreshSecret;
1040
+ if (!refreshSecret || this.refreshed) {
1041
+ this.setStatus("unauthorized");
1042
+ return;
1043
+ }
1044
+ this.refreshed = true;
1045
+ const sessionId = this.info.id;
1046
+ const current = () => this.current !== "closed" && this.info.id === sessionId;
1047
+ refreshSecret(sessionId).then((fresh) => {
1048
+ if (!current()) return;
1049
+ this.secret = clientSecret(fresh);
1050
+ this.open();
1051
+ }).catch((error) => {
1052
+ if (!current()) return;
1053
+ this.setStatus("unauthorized");
1054
+ this.emit("error", asClientError(error, "invalid_secret"));
1055
+ });
1056
+ }
1057
+ scheduleReconnect() {
1058
+ const max = this.options.maxReconnects ?? 8;
1059
+ if (this.attempts >= max) {
1060
+ this.setStatus("idle");
1061
+ return;
1062
+ }
1063
+ const delays = this.options.reconnectDelays ?? DEFAULT_RECONNECT;
1064
+ const delay = delays[Math.min(this.attempts, delays.length - 1)] ?? 0;
1065
+ this.attempts++;
1066
+ this.setStatus("reconnecting");
1067
+ this.reconnectTimer = setTimeout(() => {
1068
+ this.reconnectTimer = void 0;
1069
+ this.open();
1070
+ }, delay);
1071
+ }
1072
+ cancelReconnect() {
1073
+ if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
1074
+ this.reconnectTimer = void 0;
1075
+ }
1076
+ fallBack() {
1077
+ this.socket = null;
1078
+ if (this.options.sseFallback === false) {
1079
+ this.setStatus("idle");
1080
+ return;
1081
+ }
1082
+ this.setStatus("sse");
1083
+ for (const event of this.engine.state().unacknowledged) this.overSse(event);
1084
+ if (this.streams.size === 0) this.resendWaiting("turn");
1085
+ }
1086
+ /** Close the current connection, if any, without reacting to its close event. */
1087
+ drop(code) {
1088
+ const socket = this.socket;
1089
+ this.forget(code);
1090
+ if (socket && socket.readyState <= OPEN) {
1091
+ try {
1092
+ socket.close(code);
1093
+ } catch {
1094
+ }
1095
+ }
1096
+ }
1097
+ forget(code) {
1098
+ this.socket = null;
1099
+ this.ready = false;
1100
+ this.stopPing();
1101
+ if (this.stableTimer !== void 0) clearTimeout(this.stableTimer);
1102
+ this.stableTimer = void 0;
1103
+ if (this.opened !== null) {
1104
+ this.engine.closed(this.opened, code);
1105
+ this.opened = null;
1106
+ this.emitState();
1107
+ }
1108
+ }
1109
+ // ------------------------------------------------------------ receiving
1110
+ frame(data) {
1111
+ if (typeof data !== "string") return;
1112
+ let parsed;
1113
+ try {
1114
+ parsed = JSON.parse(data);
1115
+ } catch {
1116
+ return;
1117
+ }
1118
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string")
1119
+ return;
1120
+ this.receive(parsed);
1121
+ }
1122
+ receive(event) {
1123
+ if (this.current === "closed") return;
1124
+ this.engine.received(event);
1125
+ this.emit("event", event);
1126
+ const id = typeof event.client_event_id === "string" ? event.client_event_id : void 0;
1127
+ switch (event.type) {
1128
+ case "session.ready":
1129
+ this.ready = true;
1130
+ this.refreshed = false;
1131
+ this.firstFailures = 0;
1132
+ this.stableTimer = setTimeout(() => {
1133
+ this.stableTimer = void 0;
1134
+ this.attempts = 0;
1135
+ }, STABLE_MS);
1136
+ this.setStatus("ready");
1137
+ if (this.foreground) this.startPing();
1138
+ this.resendUnacknowledged();
1139
+ this.resendWaiting("turn");
1140
+ this.resendHeldForSession();
1141
+ break;
1142
+ case "turn.completed":
1143
+ case "turn.failed":
1144
+ // A tap or an answer refused with turn_in_progress during a takeover waits for no turn: the hand-back frees it.
1145
+ case "takeover.ended":
1146
+ this.resendWaiting("turn");
1147
+ break;
1148
+ case "turn.started":
1149
+ case "ui.answer.recorded":
1150
+ case "ui.answer.rejected":
1151
+ if (id !== void 0) this.screenTries.delete(id);
1152
+ break;
1153
+ case "input.rejected":
1154
+ case "error":
1155
+ this.refusedForNow(event);
1156
+ break;
1157
+ case "session.closed":
1158
+ this.closeReason = typeof event.reason === "string" ? event.reason : "";
1159
+ if (this.engine.state().reconnect !== "allowed")
1160
+ this.setStatus("ended");
1161
+ break;
1162
+ }
1163
+ this.emitState();
1164
+ }
1165
+ refusedForNow(event) {
1166
+ const id = typeof event.client_event_id === "string" ? event.client_event_id : void 0;
1167
+ if (id === void 0) return;
1168
+ if (event.code === "limit_reached") {
1169
+ this.heldForSession.set(id, this.info.id);
1170
+ this.setStatus("ended");
1171
+ return;
1172
+ }
1173
+ if (!this.engine.state().resend.includes(id)) return;
1174
+ if (event.code === "turn_in_progress") {
1175
+ this.waiting.set(id, "turn");
1176
+ return;
1177
+ }
1178
+ const tries = this.screenTries.get(id) ?? 0;
1179
+ const delays = this.options.screenRetryDelays ?? DEFAULT_SCREEN_RETRY;
1180
+ if (tries >= delays.length) {
1181
+ this.screenTries.delete(id);
1182
+ this.waiting.delete(id);
1183
+ this.engine.giveUp(id, "screen_unavailable");
1184
+ return;
1185
+ }
1186
+ this.screenTries.set(id, tries + 1);
1187
+ this.waiting.set(id, "screen");
1188
+ this.screenTimers.set(
1189
+ id,
1190
+ setTimeout(() => {
1191
+ this.screenTimers.delete(id);
1192
+ if (this.waiting.get(id) === "screen") this.resend(id);
1193
+ }, delays[tries])
1194
+ );
1195
+ }
1196
+ resendUnacknowledged() {
1197
+ for (const event of this.engine.state().unacknowledged)
1198
+ this.transmit(event);
1199
+ }
1200
+ /** What a turn limit held, on a session other than the one that refused it. */
1201
+ resendHeldForSession() {
1202
+ for (const [id, refusedIn] of [...this.heldForSession]) {
1203
+ if (refusedIn === this.info.id) continue;
1204
+ this.heldForSession.delete(id);
1205
+ this.resend(id);
1206
+ }
1207
+ }
1208
+ resendWaiting(reason) {
1209
+ for (const [id, why] of [...this.waiting])
1210
+ if (why === reason) this.resend(id);
1211
+ }
1212
+ resend(id) {
1213
+ this.waiting.delete(id);
1214
+ const event = this.engine.input(id);
1215
+ if (!event) return;
1216
+ if (this.live() || this.awaitingNewSession()) this.deliver(event);
1217
+ else this.hold(event);
1218
+ }
1219
+ /** Keep an input for a later session: unacknowledged, so the next `session.ready` (after resume()) sends it. */
1220
+ hold(event) {
1221
+ if (this.current === "closed") return;
1222
+ this.engine.sent(event);
1223
+ this.emitState();
1224
+ }
1225
+ /** The session ended: retries waiting on a timer would go nowhere, so their inputs are held for the next session. */
1226
+ holdRetries() {
1227
+ for (const [id, timer] of [...this.screenTimers]) {
1228
+ clearTimeout(timer);
1229
+ this.screenTimers.delete(id);
1230
+ this.waiting.delete(id);
1231
+ const event = this.engine.input(id);
1232
+ if (event) this.hold(event);
1233
+ }
1234
+ }
1235
+ // ------------------------------------------------------------ sending
1236
+ /** Send a typed message; returns its `client_event_id`. Over SSE it runs the turn there. */
1237
+ sendText(text2) {
1238
+ const id = this.newId();
1239
+ this.deliver({ type: "input.text", text: text2, client_event_id: id });
1240
+ return id;
1241
+ }
1242
+ /** Answer a question card (or a `rate_each` row, by the row's `question_id`); returns the `client_event_id`. */
1243
+ answer(cardId, questionId, answer) {
1244
+ if (this.current === "sse") {
1245
+ throw new VinaverseClientError(
1246
+ "needs_websocket",
1247
+ "Question cards are answered over the WebSocket."
1248
+ );
1249
+ }
1250
+ const id = this.newId();
1251
+ this.deliver({
1252
+ type: "ui.answer",
1253
+ client_event_id: id,
1254
+ card_id: cardId,
1255
+ question_id: questionId,
1256
+ answer
1257
+ });
1258
+ return id;
1259
+ }
1260
+ /**
1261
+ * Report a proposal's outcome after the tenant's backend answered (§6): `confirmed`, `declined`, `failed` or
1262
+ * `expired`. The first outcome per card is final. Returns the `client_event_id`.
1263
+ */
1264
+ reportOutcome(cardId, outcome) {
1265
+ return this.action(cardId, outcome);
1266
+ }
1267
+ /** A card action (`ui.action`), named by the card's `id`; the card's turn is taken from the state. */
1268
+ action(cardId, action) {
1269
+ const turnId = this.engine.state().cards.find((c) => c.id === cardId)?.turnId;
1270
+ if (turnId === void 0)
1271
+ throw new RangeError(`No turn is known for card ${cardId}.`);
1272
+ const id = this.newId();
1273
+ this.deliver({
1274
+ type: "ui.action",
1275
+ client_event_id: id,
1276
+ turn_id: turnId,
1277
+ card_id: cardId,
1278
+ action
1279
+ });
1280
+ return id;
1281
+ }
1282
+ /** Stop the turn in progress: `input.cancel` on the WebSocket; over SSE, the turn's request is stopped. */
1283
+ cancel(turnId) {
1284
+ if (this.current === "sse") {
1285
+ this.stopSse("cancel");
1286
+ this.engine.turnEnded();
1287
+ this.emitState();
1288
+ return;
1289
+ }
1290
+ this.transmit({ type: "input.cancel", turn_id: turnId });
1291
+ }
1292
+ /** Ended idle: inputs are held for the new session that `resume()` brings. */
1293
+ awaitingNewSession() {
1294
+ return this.current === "ended" && this.engine.state().reconnect === "new_session_on_next_action";
1295
+ }
1296
+ live() {
1297
+ return this.current !== "closed" && this.current !== "ended" && this.current !== "unauthorized";
1298
+ }
1299
+ /**
1300
+ * Every input goes through here: recorded as sent (unacknowledged until the server answers it), then over SSE,
1301
+ * or on the WebSocket when the session is ready, or held and resent once `session.ready` arrives.
1302
+ */
1303
+ deliver(event) {
1304
+ if (!this.live() && !this.awaitingNewSession()) throw ended();
1305
+ this.engine.sent(event);
1306
+ this.emitState();
1307
+ if (this.current === "sse") this.overSse(event);
1308
+ else if (this.ready) this.transmit(event);
1309
+ else if (this.current === "idle") this.connect();
1310
+ }
1311
+ transmit(event) {
1312
+ const socket = this.socket;
1313
+ if (!socket || socket.readyState !== OPEN) return;
1314
+ socket.send(JSON.stringify(event));
1315
+ }
1316
+ /** One input over SSE: a typed message or a card action; an answer cannot go, and is shown as not sent. */
1317
+ overSse(event) {
1318
+ const id = typeof event.client_event_id === "string" ? event.client_event_id : void 0;
1319
+ const notSent = (error) => {
1320
+ if (id !== void 0) this.engine.giveUp(id, error.code);
1321
+ this.emitState();
1322
+ this.emit("error", error);
1323
+ };
1324
+ if (id !== void 0 && this.inFlight(id)) return;
1325
+ const controller = new AbortController();
1326
+ const target = { ...this.sseTarget(), signal: controller.signal };
1327
+ let frames;
1328
+ if (event.type === "input.text") {
1329
+ frames = turnSse(target, {
1330
+ text: String(event.text),
1331
+ ...id !== void 0 ? { client_event_id: id } : {}
1332
+ });
1333
+ } else if (event.type === "ui.action") {
1334
+ frames = cardActionSse(target, String(event.turn_id), {
1335
+ card_id: String(event.card_id),
1336
+ action: String(event.action),
1337
+ ...id !== void 0 ? { client_event_id: id } : {}
1338
+ });
1339
+ } else {
1340
+ notSent(
1341
+ new VinaverseClientError(
1342
+ "needs_websocket",
1343
+ "Question cards are answered over the WebSocket."
1344
+ )
1345
+ );
1346
+ return;
1347
+ }
1348
+ const stream = { id };
1349
+ this.streams.set(controller, stream);
1350
+ void (async () => {
1351
+ try {
1352
+ for await (const frame of frames) {
1353
+ if (controller.signal.aborted) break;
1354
+ this.receive(frame);
1355
+ }
1356
+ } catch (error) {
1357
+ if (!controller.signal.aborted) {
1358
+ notSent(asClientError(error, "http_error"));
1359
+ return;
1360
+ }
1361
+ } finally {
1362
+ this.streams.delete(controller);
1363
+ }
1364
+ if (controller.signal.aborted) {
1365
+ if (stream.stop === "cancel" && id !== void 0) {
1366
+ this.engine.giveUp(id, "cancelled");
1367
+ this.emitState();
1368
+ }
1369
+ return;
1370
+ }
1371
+ if (id !== void 0 && this.isUnacknowledged(id)) {
1372
+ notSent(
1373
+ new VinaverseClientError(
1374
+ "http_error",
1375
+ "The turn ended without taking the input."
1376
+ )
1377
+ );
1378
+ }
1379
+ })();
1380
+ }
1381
+ isUnacknowledged(id) {
1382
+ return this.engine.state().unacknowledged.some((e) => e.client_event_id === id);
1383
+ }
1384
+ inFlight(id) {
1385
+ for (const stream of this.streams.values())
1386
+ if (stream.id === id) return true;
1387
+ return false;
1388
+ }
1389
+ stopSse(why) {
1390
+ for (const [controller, stream] of [...this.streams]) {
1391
+ stream.stop = why;
1392
+ controller.abort();
1393
+ }
1394
+ }
1395
+ sseTarget() {
1396
+ return {
1397
+ turnsUrl: this.info.turns_url,
1398
+ secret: this.secret,
1399
+ ...this.options.fetch ? { fetch: this.options.fetch } : {}
1400
+ };
1401
+ }
1402
+ newId() {
1403
+ return (this.options.createId ?? randomId)();
1404
+ }
1405
+ // ------------------------------------------------------------ pings and listeners
1406
+ startPing() {
1407
+ this.stopPing();
1408
+ this.pingTimer = setInterval(
1409
+ () => this.transmit({ type: "ping" }),
1410
+ this.options.pingInterval ?? 25e3
1411
+ );
1412
+ }
1413
+ stopPing() {
1414
+ if (this.pingTimer !== void 0) clearInterval(this.pingTimer);
1415
+ this.pingTimer = void 0;
1416
+ }
1417
+ setStatus(status) {
1418
+ if (this.current === status) return;
1419
+ this.current = status;
1420
+ if (status === "ended") this.holdRetries();
1421
+ this.emit("status", status);
1422
+ }
1423
+ emitState() {
1424
+ if (this.listeners.state.size) this.emit("state", this.engine.state());
1425
+ }
1426
+ emit(name, value) {
1427
+ for (const listener of this.listeners[name]) listener(value);
1428
+ }
1429
+ };
1430
+ function ended() {
1431
+ return new VinaverseClientError(
1432
+ "session_ended",
1433
+ "The session has ended; resume it on a new session."
1434
+ );
1435
+ }
1436
+ function asClientError(error, code) {
1437
+ if (error instanceof VinaverseClientError) return error;
1438
+ return new VinaverseClientError(
1439
+ code,
1440
+ error instanceof Error ? error.message : String(error)
1441
+ );
1442
+ }
1443
+ function defaultWebSocket() {
1444
+ const Ctor = globalThis.WebSocket;
1445
+ if (!Ctor) return null;
1446
+ const scope = globalThis;
1447
+ const node = typeof scope.process?.versions?.node === "string" && scope.document === void 0;
1448
+ return (url, protocols, headers) => {
1449
+ if (!headers) return new Ctor(url, protocols);
1450
+ return node ? new Ctor(url, { protocols, headers }) : new Ctor(url, protocols, { headers });
1451
+ };
1452
+ }
1453
+ var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1454
+ function randomId() {
1455
+ const bytes = new Uint8Array(22);
1456
+ const crypto = globalThis.crypto;
1457
+ if (crypto?.getRandomValues) crypto.getRandomValues(bytes);
1458
+ else
1459
+ for (let i = 0; i < bytes.length; i++)
1460
+ bytes[i] = Math.floor(Math.random() * 256);
1461
+ let id = "ce_";
1462
+ for (const byte of bytes) id += ALPHABET[byte % ALPHABET.length];
1463
+ return id;
1464
+ }
1465
+
1466
+ // src/conversation/links.ts
1467
+ 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._~-]*\/)*$/;
1468
+ 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._~-]*\//;
1469
+ var REFUSED_SCHEMES = /^(javascript|vbscript|data|blob|file|filesystem|about|view-source|intent|android-app|content|jar|mhtml|itms-services|ftp|ws|wss):/;
1470
+ var CONTROLS = /[\x00-\x1f\x7f]/;
1471
+ 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]))*)?$/;
1472
+ function isValidLinkPrefix(prefix) {
1473
+ if (CONTROLS.test(prefix) || REFUSED_SCHEMES.test(prefix)) return false;
1474
+ if (/^https?:/.test(prefix) && !PREFIX_HTTPS.test(prefix)) return false;
1475
+ return PREFIX.test(prefix);
1476
+ }
1477
+ function isAllowedLink(link, prefixes) {
1478
+ return prefixes.some(
1479
+ (prefix) => isValidLinkPrefix(prefix) && link.startsWith(prefix) && REMAINDER.test(link.slice(prefix.length))
1480
+ );
1481
+ }
1482
+
1483
+ // src/conversation/timeline.ts
1484
+ function lastOrder(state) {
1485
+ let last = -1;
1486
+ for (const m of state.messages) last = Math.max(last, m.seq);
1487
+ for (const c of state.cards) last = Math.max(last, c.seq);
1488
+ if (state.streaming) last = Math.max(last, state.streaming.seq);
1489
+ return last;
1490
+ }
1491
+ function pendingState(id, input) {
1492
+ const { state } = input;
1493
+ if (input.retried.has(id)) return null;
1494
+ if (input.early.has(id)) return "sending";
1495
+ if (state.messages.some((m) => m.key === id)) return null;
1496
+ const refused = state.refused[id];
1497
+ if (refused !== void 0) return refused === "guardrail" ? null : "not_sent";
1498
+ if (state.heldForNextSession.includes(id)) return "held";
1499
+ if (state.resend.includes(id) || state.held.includes(id) || state.unacknowledged.some((f) => f.client_event_id === id))
1500
+ return "sending";
1501
+ return null;
1502
+ }
1503
+ function buildTimeline(input, previous) {
1504
+ const before = new Map(previous.map((item) => [item.key, item]));
1505
+ const live = [];
1506
+ const { state } = input;
1507
+ for (const m of state.messages)
1508
+ live.push({ item: messageItem(m), order: m.seq });
1509
+ if (state.streaming) {
1510
+ live.push({
1511
+ item: {
1512
+ type: "message",
1513
+ key: `a:${state.streaming.turnId}`,
1514
+ role: "agent",
1515
+ text: "",
1516
+ state: "streaming"
1517
+ },
1518
+ order: state.streaming.seq
1519
+ });
1520
+ }
1521
+ for (const card of state.cards) {
1522
+ if (!isKnownCard(card.card)) continue;
1523
+ live.push({
1524
+ item: {
1525
+ type: "card",
1526
+ key: card.id,
1527
+ card,
1528
+ busy: !input.inert?.has(card.id) && input.busy.has(card.id),
1529
+ ...input.inert?.has(card.id) ? { restored: true } : {},
1530
+ ...input.decisionErrors?.has(card.id) ? { decisionError: true } : {}
1531
+ },
1532
+ order: input.cardOrder?.get(card.id) ?? card.seq
1533
+ });
1534
+ }
1535
+ for (const { item, order } of input.system) live.push({ item, order });
1536
+ const held = new Set(state.cards.map((c) => c.id));
1537
+ for (const { item, order } of input.kept ?? [])
1538
+ if (!held.has(item.key)) live.push({ item, order });
1539
+ for (const [id, sent] of input.sent) {
1540
+ const pending = pendingState(id, input);
1541
+ if (pending === null) continue;
1542
+ const rejection = state.rejections[id];
1543
+ live.push({
1544
+ item: {
1545
+ type: "message",
1546
+ key: id,
1547
+ role: "person",
1548
+ text: sent.text,
1549
+ state: pending,
1550
+ ...pending === "not_sent" ? {
1551
+ rejection: rejection ?? {
1552
+ code: state.refused[id] ?? "",
1553
+ message: ""
1554
+ }
1555
+ } : {}
1556
+ },
1557
+ order: sent.order
1558
+ });
1559
+ }
1560
+ live.sort((a, b) => a.order - b.order);
1561
+ const items = mergeByKey(
1562
+ input.transcriptSeen === true ? [] : input.history,
1563
+ live.map((l) => l.item)
1564
+ );
1565
+ let same = items.length === previous.length;
1566
+ const shared = items.map((item, i) => {
1567
+ const prev = before.get(item.key);
1568
+ const kept = prev !== void 0 && equal(prev, item) ? prev : item;
1569
+ if (kept !== previous[i]) same = false;
1570
+ return kept;
1571
+ });
1572
+ return same ? previous : shared;
1573
+ }
1574
+ function mergeByKey(history, live) {
1575
+ if (!history.length) return [...live];
1576
+ const items = [...history];
1577
+ const at = (key) => items.findIndex((item) => item.key === key);
1578
+ let last = -1;
1579
+ live.forEach((item, i) => {
1580
+ const found = at(item.key);
1581
+ if (found >= 0) {
1582
+ items[found] = item;
1583
+ last = found;
1584
+ return;
1585
+ }
1586
+ const next = live.slice(i + 1).map((l) => at(l.key)).find((p) => p >= 0);
1587
+ last = next === void 0 ? items.length : last >= 0 ? last + 1 : next;
1588
+ items.splice(last, 0, item);
1589
+ });
1590
+ return items;
1591
+ }
1592
+ function messageItem(m) {
1593
+ const role = m.role === "assistant" ? "agent" : m.role;
1594
+ return {
1595
+ type: "message",
1596
+ key: m.key,
1597
+ role,
1598
+ text: m.text,
1599
+ state: m.received ? "delivered" : "sent",
1600
+ ...m.partial ? { partial: m.partial } : {},
1601
+ ...m.refusal ? { refusal: true } : {}
1602
+ };
1603
+ }
1604
+ function equal(a, b) {
1605
+ if (a.type !== b.type || a.key !== b.key) return false;
1606
+ if (a.type === "message" && b.type === "message")
1607
+ 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;
1608
+ if (a.type === "card" && b.type === "card")
1609
+ return a.busy === b.busy && a.restored === b.restored && a.decisionError === b.decisionError && sameCard(a.card, b.card);
1610
+ return a === b;
1611
+ }
1612
+ function sameCard(a, b) {
1613
+ 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);
1614
+ }
1615
+ function persistable(item) {
1616
+ if (item.type === "message")
1617
+ return item.state === "sent" || item.state === "delivered";
1618
+ return item.type === "card";
1619
+ }
1620
+
1621
+ // src/conversation/store.ts
1622
+ var MAX_TEXT = 2e3;
1623
+ var TURN_INPUTS = 50;
1624
+ var PERSIST_DELAY = 500;
1625
+ var PERSIST_VERSION = 1;
1626
+ var NO_ITEMS = [];
1627
+ var OUTCOMES2 = /* @__PURE__ */ new Set([
1628
+ "confirmed",
1629
+ "declined",
1630
+ "failed",
1631
+ "expired"
1632
+ ]);
1633
+ var ConversationStore = class {
1634
+ options;
1635
+ engine = new ConversationEngine();
1636
+ session = null;
1637
+ info = null;
1638
+ getting = null;
1639
+ failed = false;
1640
+ disposed = false;
1641
+ online = null;
1642
+ foreground = true;
1643
+ lastStatus = "idle";
1644
+ offlineAfterRetries = false;
1645
+ /** The id the next send gets, so a text typed before the session keeps its key. */
1646
+ nextId = null;
1647
+ sent = /* @__PURE__ */ new Map();
1648
+ early = /* @__PURE__ */ new Map();
1649
+ retried = /* @__PURE__ */ new Set();
1650
+ busy = /* @__PURE__ */ new Set();
1651
+ /** Proposals whose tenant decision threw: shown as recoverable, the person may decide again. */
1652
+ decisionErrors = /* @__PURE__ */ new Set();
1653
+ system = [];
1654
+ /** The person's text that started each recent turn, by `turn_id` (`turn.started` echoes its `client_event_id`). */
1655
+ turnInputs = /* @__PURE__ */ new Map();
1656
+ /** Numbers system rows that carry no id of their own. */
1657
+ systemSeq = 0;
1658
+ history = NO_ITEMS;
1659
+ historyState = "none";
1660
+ /** A resumed session.ready carried a transcript: the server's history replaced what the device kept. */
1661
+ transcriptSeen = false;
1662
+ /** The session of the last session.ready: a card of another one takes no tap (§6). */
1663
+ readySession = null;
1664
+ cardOrder = /* @__PURE__ */ new Map();
1665
+ kept = [];
1666
+ /** Cards an earlier session drew, kept by a merged transcript: drawn as restored, taking no tap. */
1667
+ inert = /* @__PURE__ */ new Set();
1668
+ locale;
1669
+ /** The request for a session in flight, to abort when it is no longer wanted. */
1670
+ fetching = null;
1671
+ view;
1672
+ listeners = /* @__PURE__ */ new Set();
1673
+ streaming = null;
1674
+ streamListeners = /* @__PURE__ */ new Set();
1675
+ /** The frame that will notify, and what it will notify about. */
1676
+ frame = void 0;
1677
+ framePending = false;
1678
+ viewDirty = false;
1679
+ streamDirty = false;
1680
+ persistTimer;
1681
+ unsubscribe = [];
1682
+ constructor(options) {
1683
+ this.options = options;
1684
+ this.locale = options.locale;
1685
+ this.view = this.compute(null);
1686
+ this.restore();
1687
+ }
1688
+ // ------------------------------------------------------------ reading
1689
+ getSnapshot = () => this.view;
1690
+ subscribe = (listener) => {
1691
+ this.listeners.add(listener);
1692
+ return () => this.listeners.delete(listener);
1693
+ };
1694
+ getStreaming = () => this.streaming;
1695
+ subscribeStreaming = (listener) => {
1696
+ this.streamListeners.add(listener);
1697
+ return () => this.streamListeners.delete(listener);
1698
+ };
1699
+ /** Whether a card's link may be opened (the deep-link rule, against `deepLinkPrefixes`). */
1700
+ checkLink(link) {
1701
+ return isAllowedLink(link, this.options.deepLinkPrefixes ?? []);
1702
+ }
1703
+ // ------------------------------------------------------------ the conversation's life
1704
+ /** Ask for a session and connect, early: a client secret must open its first connection within two minutes. */
1705
+ start() {
1706
+ this.live();
1707
+ if (this.session) {
1708
+ if (this.session.status === "idle") this.session.connect();
1709
+ else if (this.session.status === "unauthorized")
1710
+ void this.resumeWith("resume");
1711
+ return;
1712
+ }
1713
+ void this.ensureSession("open");
1714
+ }
1715
+ /** Close for good. */
1716
+ dispose() {
1717
+ if (this.disposed) return;
1718
+ this.disposed = true;
1719
+ this.fetching?.abort();
1720
+ this.fetching = null;
1721
+ this.session?.close();
1722
+ for (const off of this.unsubscribe) off();
1723
+ this.unsubscribe = [];
1724
+ clearTimeout(this.persistTimer);
1725
+ this.update();
1726
+ this.flush();
1727
+ }
1728
+ /** Close for good and forget the conversation kept on the device (sign-out). Never throws for the storage. */
1729
+ clear() {
1730
+ this.dispose();
1731
+ this.history = NO_ITEMS;
1732
+ try {
1733
+ const removed = this.options.persistence?.removeItem(this.key());
1734
+ if (removed) removed.catch(() => void 0);
1735
+ } catch {
1736
+ }
1737
+ this.update();
1738
+ this.flush();
1739
+ }
1740
+ /**
1741
+ * The person switched the app's language: the UI's words change at once (the kit's own locale); the next session
1742
+ * asks for this locale (`subject.locale`). The open session keeps its language: the agent answers in the new one
1743
+ * from the next session, and the store never starts a session for it.
1744
+ */
1745
+ setLocale(locale) {
1746
+ this.locale = locale;
1747
+ }
1748
+ /** The app came to the foreground or went to the background (AppState, `visibilitychange`). */
1749
+ setForeground(foreground) {
1750
+ this.foreground = foreground;
1751
+ if (this.options.closeInBackground && !this.disposed) {
1752
+ if (!foreground) {
1753
+ this.suspend();
1754
+ return;
1755
+ }
1756
+ if (this.suspended) {
1757
+ void this.wake();
1758
+ return;
1759
+ }
1760
+ }
1761
+ this.session?.setForeground(foreground);
1762
+ if (foreground) this.reconnectIfDown();
1763
+ }
1764
+ /** In the background (`closeInBackground`): the connection is closed; the engine keeps what was sent, to resend. */
1765
+ suspended = false;
1766
+ suspend() {
1767
+ const session = this.session;
1768
+ if (!session || this.suspended || session.status === "ended") return;
1769
+ this.suspended = true;
1770
+ for (const off of this.unsubscribe) off();
1771
+ this.unsubscribe = [];
1772
+ session.close();
1773
+ this.update();
1774
+ }
1775
+ /**
1776
+ * Back in the foreground, or the person acted while away: a new connection to the same session, with a fresh secret
1777
+ * when the backend gives one. A session that passed its end meanwhile is never reopened (a fresh secret cannot bring
1778
+ * back an expired tool token): the person's action continues the conversation in a new session from `getSession`,
1779
+ * and until then nothing connects.
1780
+ */
1781
+ async wake(byPerson = false) {
1782
+ const info = this.info;
1783
+ if (info && this.sessionOver()) {
1784
+ if (byPerson) await this.resumeWith("resume");
1785
+ else this.update();
1786
+ return;
1787
+ }
1788
+ this.suspended = false;
1789
+ if (!info) return;
1790
+ let next = info;
1791
+ if (this.options.refreshSecret) {
1792
+ try {
1793
+ next = {
1794
+ ...info,
1795
+ client_secret: await this.options.refreshSecret(info.id)
1796
+ };
1797
+ } catch (error) {
1798
+ this.options.onError?.(error);
1799
+ }
1800
+ }
1801
+ if (this.disposed || this.suspended) return;
1802
+ this.open(next);
1803
+ }
1804
+ /** The network as the app sees it; null when unknown. Back online reconnects a connection that gave up. */
1805
+ setOnline(online) {
1806
+ this.online = online;
1807
+ if (online) this.reconnectIfDown();
1808
+ this.update();
1809
+ }
1810
+ // ------------------------------------------------------------ the person's actions
1811
+ /** Send a typed message; returns its `client_event_id`. Typed before a session exists, it is sent once one does. */
1812
+ send(text2) {
1813
+ this.live();
1814
+ if (!text2.trim()) throw new RangeError("A message needs some text.");
1815
+ if ([...text2].length > MAX_TEXT)
1816
+ throw new RangeError(`A message is at most ${MAX_TEXT} characters.`);
1817
+ const id = (this.options.createId ?? randomId)();
1818
+ this.sent.set(id, { text: text2, order: lastOrder(this.engine.state()) + 0.5 });
1819
+ this.tellActivity({ type: "message_sent", id });
1820
+ if (!this.session) {
1821
+ this.early.set(id, text2);
1822
+ void this.ensureSession("open");
1823
+ this.update();
1824
+ return id;
1825
+ }
1826
+ if (this.suspended) {
1827
+ this.early.set(id, text2);
1828
+ void this.wake(true);
1829
+ this.update();
1830
+ return id;
1831
+ }
1832
+ if (this.nextFrom(this.engine.state()) === "continue_here") {
1833
+ this.early.set(id, text2);
1834
+ void this.resumeWith("continue_here");
1835
+ this.update();
1836
+ return id;
1837
+ }
1838
+ if (this.sessionOver()) {
1839
+ this.early.set(id, text2);
1840
+ void this.resumeWith("resume");
1841
+ this.update();
1842
+ return id;
1843
+ }
1844
+ this.nextId = id;
1845
+ this.session.sendText(text2);
1846
+ this.nextId = null;
1847
+ this.continueIfEnded();
1848
+ return id;
1849
+ }
1850
+ /** Answer a question card (a `rate_each` row by its `question_id`); returns the `client_event_id`. */
1851
+ answer(cardId, questionId, answer) {
1852
+ const session = this.required();
1853
+ const id = session.answer(cardId, questionId, answer);
1854
+ this.continueIfEnded();
1855
+ return id;
1856
+ }
1857
+ /** A card action (`ui.action`) other than a proposal's outcome. */
1858
+ cardAction(cardId, action) {
1859
+ return this.required().action(cardId, action);
1860
+ }
1861
+ /**
1862
+ * Decide a proposal: `decide` is the tenant's own call (its backend, the person's token), which confirms or
1863
+ * declines it and says the outcome; the store then reports it. One decision per card; the card is busy meanwhile.
1864
+ * Resolves to the outcome reported, or null when the card was busy or already decided.
1865
+ *
1866
+ * Only an outcome `decide` resolves with is reported (§6: the first is final). When `decide` throws, or resolves
1867
+ * with anything but `confirmed`, `declined`, `failed` or `expired`, nothing is reported: the backend may have
1868
+ * applied the change, so the outcome stays unknown; the card shows `decisionError` and the person may decide
1869
+ * again. When the conversation is disposed meanwhile (sign-out), the outcome is not reported either: the tenant's
1870
+ * backend holds it, and the session takes it as unknown.
1871
+ */
1872
+ async decideProposal(cardId, decide) {
1873
+ this.live();
1874
+ const card = this.engine.state().cards.find((c) => c.id === cardId);
1875
+ if (card?.kind !== "proposal") return null;
1876
+ if (this.busy.has(cardId) || (card.outcome ?? null) !== null) return null;
1877
+ this.busy.add(cardId);
1878
+ this.decisionErrors.delete(cardId);
1879
+ this.update();
1880
+ try {
1881
+ const outcome = await decide();
1882
+ if (typeof outcome !== "string" || !OUTCOMES2.has(outcome))
1883
+ throw new TypeError(
1884
+ `decide() resolved with ${JSON.stringify(outcome)}, not confirmed, declined, failed or expired`
1885
+ );
1886
+ if (this.disposed) return outcome;
1887
+ if (this.view.next === "continue") await this.resumeWith("resume");
1888
+ this.required().reportOutcome(cardId, outcome);
1889
+ return outcome;
1890
+ } catch (error) {
1891
+ this.decisionErrors.add(cardId);
1892
+ throw error;
1893
+ } finally {
1894
+ this.busy.delete(cardId);
1895
+ this.update();
1896
+ }
1897
+ }
1898
+ /** Send a "not sent" message again, as a new message (the person's action). Returns the new id. */
1899
+ retry(key) {
1900
+ const sent = this.sent.get(key);
1901
+ if (!sent || this.engine.state().refused[key] === void 0) return null;
1902
+ this.retried.add(key);
1903
+ this.sent.delete(key);
1904
+ return this.send(sent.text);
1905
+ }
1906
+ /**
1907
+ * Send again the person's text whose turn failed before any reply (a `turn_failed` row with `data.retry`), as a new
1908
+ * message (the person's action). Once per row: its `retry` turns false. Returns the new id, or null when the row
1909
+ * allows no retry.
1910
+ */
1911
+ retryTurn(key) {
1912
+ const at = this.system.findIndex(
1913
+ (s) => s.item.key === key && s.item.event === "turn_failed"
1914
+ );
1915
+ const entry = this.system[at];
1916
+ if (entry?.item.data.retry !== true) return null;
1917
+ const text2 = this.turnText(String(entry.item.data.turn ?? ""));
1918
+ if (text2 === void 0) return null;
1919
+ this.system = this.system.map(
1920
+ (s, i) => i === at ? { ...s, item: { ...s.item, data: { ...s.item.data, retry: false } } } : s
1921
+ );
1922
+ return this.send(text2);
1923
+ }
1924
+ /** After the conversation continued on another device: continue it here (the person's choice). */
1925
+ continueHere() {
1926
+ return this.resumeWith("continue_here");
1927
+ }
1928
+ /** Stop the turn in progress. */
1929
+ cancel() {
1930
+ const turn = this.engine.state().turn;
1931
+ if (this.session && turn) this.session.cancel(turn);
1932
+ }
1933
+ // ------------------------------------------------------------ sessions
1934
+ async ensureSession(reason) {
1935
+ if (this.session) return this.session;
1936
+ if (this.getting) return this.getting;
1937
+ this.getting = this.fetchSession(reason).then((info) => {
1938
+ this.getting = null;
1939
+ if (!info || this.disposed) return null;
1940
+ this.open(info);
1941
+ return this.session;
1942
+ });
1943
+ this.update();
1944
+ return this.getting;
1945
+ }
1946
+ async fetchSession(reason) {
1947
+ this.failed = false;
1948
+ this.fetching?.abort();
1949
+ const Controller = globalThis.AbortController;
1950
+ const controller = Controller ? new Controller() : null;
1951
+ const mine = { abort: () => controller?.abort() };
1952
+ this.fetching = mine;
1953
+ let aborted = false;
1954
+ const signal = controller?.signal;
1955
+ signal?.addEventListener("abort", () => {
1956
+ aborted = true;
1957
+ });
1958
+ try {
1959
+ const info = await this.options.getSession({
1960
+ reason,
1961
+ conversationId: this.engine.state().conversationId,
1962
+ ...this.locale !== void 0 ? { locale: this.locale } : {},
1963
+ ...this.options.timeZone !== void 0 ? { timeZone: this.options.timeZone } : {},
1964
+ ...signal ? { signal } : {}
1965
+ });
1966
+ return aborted || this.fetching !== mine ? null : info;
1967
+ } catch (error) {
1968
+ if (aborted || this.fetching !== mine) return null;
1969
+ this.failed = true;
1970
+ this.getting = null;
1971
+ this.options.onError?.(error);
1972
+ this.update();
1973
+ return null;
1974
+ } finally {
1975
+ if (this.fetching === mine) this.fetching = null;
1976
+ }
1977
+ }
1978
+ open(info) {
1979
+ this.info = info;
1980
+ this.suspended = false;
1981
+ const refreshSecret = this.options.refreshSecret;
1982
+ const session = new VinaverseSession({
1983
+ ...this.options.transport,
1984
+ session: info,
1985
+ engine: this.engine,
1986
+ ...refreshSecret ? {
1987
+ // Past the session's end a fresh secret cannot help: refused here, and the store rolls over instead.
1988
+ refreshSecret: (id) => this.sessionOver() ? Promise.reject(
1989
+ new VinaverseClientError(
1990
+ "needs_new_session",
1991
+ "This session has ended."
1992
+ )
1993
+ ) : refreshSecret(id)
1994
+ } : {},
1995
+ createId: () => this.nextId ?? (this.options.createId ?? randomId)()
1996
+ });
1997
+ this.session = session;
1998
+ this.unsubscribe.push(
1999
+ session.on("state", () => this.update()),
2000
+ session.on("status", (status) => this.statusChanged(status)),
2001
+ session.on("event", (event) => this.event(event)),
2002
+ session.on("error", (error) => {
2003
+ if (!this.sessionOver()) this.options.onError?.(error);
2004
+ })
2005
+ );
2006
+ session.setForeground(this.foreground);
2007
+ session.connect();
2008
+ this.flushEarly();
2009
+ }
2010
+ /** What was typed before there was a session to take it. */
2011
+ flushEarly() {
2012
+ const session = this.session;
2013
+ if (!session) return;
2014
+ for (const [id, text2] of this.early) {
2015
+ this.nextId = id;
2016
+ session.sendText(text2);
2017
+ }
2018
+ this.nextId = null;
2019
+ this.early.clear();
2020
+ this.update();
2021
+ }
2022
+ /** A new session for the conversation, then `resume()`: once per person action, never in a loop. */
2023
+ resuming = null;
2024
+ resumeWith(reason) {
2025
+ this.live();
2026
+ if (this.resuming) return this.resuming;
2027
+ if (!this.session) return this.ensureSession(reason).then(() => void 0);
2028
+ this.resuming = this.fetchSession(reason).then((info) => {
2029
+ this.resuming = null;
2030
+ if (!info || this.disposed || !this.session) return;
2031
+ if (this.session.status === "closed") {
2032
+ this.open(info);
2033
+ return;
2034
+ }
2035
+ this.info = info;
2036
+ this.session.resume(info);
2037
+ this.flushEarly();
2038
+ });
2039
+ return this.resuming;
2040
+ }
2041
+ /** The person acted after the session ended idle, expired or at its limit: the next session takes what they sent. */
2042
+ continueIfEnded() {
2043
+ if (this.nextFrom(this.engine.state()) === "continue")
2044
+ void this.resumeWith("resume");
2045
+ this.update();
2046
+ }
2047
+ reconnectIfDown() {
2048
+ const session = this.session;
2049
+ if (session && session.status === "idle" && !this.sessionOver() && // past its end: the person's next action gets a new session
2050
+ this.nextFrom(this.engine.state()) === null)
2051
+ session.connect();
2052
+ }
2053
+ /** A refusal already led to a new session, and none has been ready since: another refusal is no cue. */
2054
+ rolled = false;
2055
+ statusChanged(status) {
2056
+ if (status === "ready") this.rolled = false;
2057
+ if (status === "unauthorized" && !this.rolled) {
2058
+ this.rolled = true;
2059
+ void this.resumeWith("resume");
2060
+ }
2061
+ if (status === "idle")
2062
+ this.offlineAfterRetries = this.lastStatus === "reconnecting";
2063
+ else if (status !== "reconnecting") this.offlineAfterRetries = false;
2064
+ this.lastStatus = status;
2065
+ this.update();
2066
+ }
2067
+ /** The app's analytics hears of a step; what it throws is reported (`onError`), never in the way of a send or a frame. */
2068
+ tellActivity(activity) {
2069
+ try {
2070
+ this.options.onActivity?.(activity);
2071
+ } catch (error) {
2072
+ this.options.onError?.(error);
2073
+ }
2074
+ }
2075
+ /** The live frames analytics hears of: a reply finished, cards drawn. */
2076
+ activity(frame) {
2077
+ if (!this.options.onActivity) return;
2078
+ const on = (a) => this.tellActivity(a);
2079
+ if (frame.type === "message.completed" && typeof frame.turn_id === "string")
2080
+ on({ type: "reply_completed", turnId: frame.turn_id });
2081
+ if (frame.type === "ui.cards" && Array.isArray(frame.cards)) {
2082
+ for (const card of frame.cards)
2083
+ if (typeof card.id === "string" && typeof card.kind === "string")
2084
+ on({ type: "card_shown", cardId: card.id, kind: card.kind });
2085
+ }
2086
+ }
2087
+ event(event) {
2088
+ const frame = event;
2089
+ const str2 = (v) => typeof v === "string" ? v : null;
2090
+ this.activity(frame);
2091
+ const add = (key, kind, data) => {
2092
+ if (this.system.some((s) => s.item.key === key)) return;
2093
+ this.system = [
2094
+ ...this.system,
2095
+ {
2096
+ item: { type: "system", key, event: kind, data },
2097
+ order: lastOrder(this.engine.state()) + 0.75
2098
+ }
2099
+ ];
2100
+ };
2101
+ switch (frame.type) {
2102
+ case "session.ready": {
2103
+ const session = frame.session?.id;
2104
+ const moved = typeof session === "string" && session !== this.readySession;
2105
+ if (typeof session === "string") this.readySession = session;
2106
+ if (frame.resumed === true && Array.isArray(frame.transcript) && !transcriptHasIds(frame.transcript)) {
2107
+ this.transcriptSeen = true;
2108
+ this.anchorCards(moved);
2109
+ } else if (moved && frame.resumed === true) {
2110
+ const pending = new Set(
2111
+ (Array.isArray(frame.pending_cards) ? frame.pending_cards : []).map(
2112
+ (c) => str2(c.id)
2113
+ )
2114
+ );
2115
+ for (const card of this.engine.state().cards)
2116
+ if (pending.has(card.id)) this.inert.delete(card.id);
2117
+ else this.inert.add(card.id);
2118
+ }
2119
+ return;
2120
+ }
2121
+ // `at` is required (v1-draft.15); the same takeover announced again after a reconnect has the same `at`, so it
2122
+ // draws one row. A frame without one still gets a row of its own.
2123
+ case "takeover.started":
2124
+ add(
2125
+ `takeover:${str2(frame.at) ?? `#${++this.systemSeq}`}`,
2126
+ "takeover_started",
2127
+ { by: str2(frame.by) }
2128
+ );
2129
+ return;
2130
+ case "takeover.ended":
2131
+ add(
2132
+ `takeover-ended:${str2(frame.at) ?? `#${++this.systemSeq}`}`,
2133
+ "takeover_ended",
2134
+ {}
2135
+ );
2136
+ return;
2137
+ case "handoff.created": {
2138
+ const ticket = frame.ticket;
2139
+ add(`handoff:${str2(ticket?.id) ?? ""}`, "handoff_created", {
2140
+ expected_response: str2(ticket?.expected_response)
2141
+ });
2142
+ return;
2143
+ }
2144
+ case "flow.completed":
2145
+ add(`flow:#${++this.systemSeq}`, "flow_completed", {
2146
+ result: str2(frame.result),
2147
+ code: str2(frame.code)
2148
+ });
2149
+ return;
2150
+ case "turn.started": {
2151
+ const turn = str2(frame.turn_id);
2152
+ const input = str2(frame.client_event_id);
2153
+ if (turn === null || input === null) return;
2154
+ this.turnInputs.set(turn, input);
2155
+ if (this.turnInputs.size > TURN_INPUTS) {
2156
+ const oldest = this.turnInputs.keys().next().value;
2157
+ if (oldest !== void 0) this.turnInputs.delete(oldest);
2158
+ }
2159
+ return;
2160
+ }
2161
+ case "turn.failed": {
2162
+ const turn = str2(frame.turn_id) ?? "";
2163
+ if (this.engine.state().messages.some((m) => m.key === `a:${turn}`))
2164
+ return;
2165
+ if (frame.code === "cancelled") return;
2166
+ add(`failed:${turn}`, "turn_failed", {
2167
+ code: str2(frame.code),
2168
+ message: str2(frame.message),
2169
+ retryable: frame.retryable === true,
2170
+ retry: frame.retryable === true && this.turnText(turn) !== void 0,
2171
+ turn
2172
+ });
2173
+ return;
2174
+ }
2175
+ default:
2176
+ return;
2177
+ }
2178
+ }
2179
+ /** The person's text that started a turn, while its message is on screen. */
2180
+ turnText(turn) {
2181
+ const input = this.turnInputs.get(turn);
2182
+ if (input === void 0) return void 0;
2183
+ const message = this.engine.state().messages.find((m) => m.key === input && m.role === "person");
2184
+ return message?.text || void 0;
2185
+ }
2186
+ /**
2187
+ * A resumed session's transcript replaced the messages, and a chat transcript carries text only: the engine drops
2188
+ * the cards it drew, and draws the pending ones again at the end. Each card on screen is anchored just after the
2189
+ * message it followed (by key; the transcript keeps the live keys), so a pending proposal stays where it was, and a
2190
+ * card the transcript dropped stays drawn there; taking no tap when it belongs to an earlier session.
2191
+ */
2192
+ anchorCards(sessionChanged) {
2193
+ const state = this.engine.state();
2194
+ const seqOf = new Map(state.messages.map((m) => [m.key, m.seq]));
2195
+ const first = Math.min(...state.messages.map((m) => m.seq), 0);
2196
+ const held = new Set(state.cards.map((c) => c.id));
2197
+ const order = /* @__PURE__ */ new Map();
2198
+ const kept = [];
2199
+ let anchor = first - 1;
2200
+ let step = 0;
2201
+ for (const item of this.view.items) {
2202
+ if (item.type === "message" && seqOf.has(item.key)) {
2203
+ anchor = seqOf.get(item.key);
2204
+ step = 0;
2205
+ } else if (item.type === "card") {
2206
+ const at = anchor + 0.5 + ++step / 1e3;
2207
+ order.set(item.key, at);
2208
+ if (!held.has(item.key))
2209
+ kept.push({
2210
+ item: sessionChanged ? { ...item, busy: false, restored: true } : item,
2211
+ order: at
2212
+ });
2213
+ }
2214
+ }
2215
+ this.cardOrder = order;
2216
+ this.kept = kept;
2217
+ }
2218
+ // ------------------------------------------------------------ the view
2219
+ /**
2220
+ * Recomputes now (getSnapshot and getStreaming are always current) and notifies from the scheduler, at most once
2221
+ * per frame, both channels in the same flush: a streamed reply that completes becomes its message in one frame.
2222
+ */
2223
+ update() {
2224
+ const state = this.engine.state();
2225
+ this.stream(state);
2226
+ const view = this.compute(state);
2227
+ if (view !== this.view) {
2228
+ this.view = view;
2229
+ this.viewDirty = true;
2230
+ this.schedulePersist();
2231
+ }
2232
+ if (this.viewDirty || this.streamDirty) this.scheduleNotify();
2233
+ }
2234
+ scheduleNotify() {
2235
+ if (this.framePending || this.disposed) return;
2236
+ if (this.listeners.size === 0 && this.streamListeners.size === 0) return;
2237
+ this.framePending = true;
2238
+ this.frame = this.scheduler().request(() => {
2239
+ this.frame = void 0;
2240
+ this.framePending = false;
2241
+ this.flush();
2242
+ });
2243
+ }
2244
+ /** Calls the listeners of what changed since the last flush. */
2245
+ flush() {
2246
+ if (this.frame !== void 0) {
2247
+ this.scheduler().cancel(this.frame);
2248
+ this.frame = void 0;
2249
+ this.framePending = false;
2250
+ }
2251
+ const view = this.viewDirty;
2252
+ const stream = this.streamDirty;
2253
+ this.viewDirty = false;
2254
+ this.streamDirty = false;
2255
+ if (stream) for (const l of [...this.streamListeners]) l();
2256
+ if (view) for (const l of [...this.listeners]) l();
2257
+ }
2258
+ compute(state) {
2259
+ const s = state ?? this.engine.state();
2260
+ const prev = this.view;
2261
+ for (const id of [...this.sent.keys()])
2262
+ if (!this.early.has(id) && s.messages.some((m) => m.key === id))
2263
+ this.sent.delete(id);
2264
+ const items = buildTimeline(
2265
+ {
2266
+ state: s,
2267
+ history: this.history,
2268
+ sent: this.sent,
2269
+ early: new Set(this.early.keys()),
2270
+ retried: this.retried,
2271
+ busy: this.busy,
2272
+ decisionErrors: this.decisionErrors,
2273
+ transcriptSeen: this.transcriptSeen,
2274
+ cardOrder: this.cardOrder,
2275
+ kept: this.kept,
2276
+ inert: this.inert,
2277
+ system: this.system
2278
+ },
2279
+ prev?.items ?? NO_ITEMS
2280
+ );
2281
+ const status = this.status();
2282
+ const next = this.nextFrom(s);
2283
+ const activity = s.turn === null ? [] : s.tools.filter((t) => t.turn_id === s.turn);
2284
+ const name = this.info?.agent?.display_name ?? null;
2285
+ const avatarUrl = this.info?.agent?.avatar_url ?? null;
2286
+ const view = {
2287
+ status,
2288
+ conversationId: s.conversationId,
2289
+ banner: banner(status, next),
2290
+ next,
2291
+ agent: sameAgent(prev?.agent, name, avatarUrl) ? prev.agent : { name, avatarUrl },
2292
+ disclosure: s.disclosure ?? this.info?.disclosure ?? null,
2293
+ welcome: this.info?.welcome ?? null,
2294
+ takeover: s.takeover,
2295
+ items,
2296
+ activity: prev && sameTools(prev.activity, activity) ? prev.activity : activity,
2297
+ responding: s.turn !== null || s.streaming !== null,
2298
+ openQuestion: s.openQuestion,
2299
+ answersNeedWebSocket: status === "sse",
2300
+ turnsLeft: s.turnsLeft,
2301
+ history: this.historyState
2302
+ };
2303
+ if (prev && shallowEqual(prev, view)) return prev;
2304
+ return view;
2305
+ }
2306
+ status() {
2307
+ if (this.disposed) return "closed";
2308
+ if (this.suspended) return "idle";
2309
+ const session = this.session;
2310
+ if (!session) {
2311
+ if (this.failed) return "unavailable";
2312
+ return this.getting || this.early.size ? "starting" : "idle";
2313
+ }
2314
+ const status = session.status;
2315
+ if (this.failed && status === "ended") return "unavailable";
2316
+ if (status === "idle")
2317
+ return this.offlineAfterRetries || this.online === false ? "offline" : "idle";
2318
+ if (status === "reconnecting" && this.online === false) return "offline";
2319
+ return status;
2320
+ }
2321
+ nextFrom(state) {
2322
+ if (this.session?.status !== "ended" && state.reconnect === "allowed")
2323
+ return null;
2324
+ if (state.reconnect === "new_session_on_next_action") return "continue";
2325
+ if (state.reconnect === "user_action_only") return "continue_here";
2326
+ return null;
2327
+ }
2328
+ /** The streamed text, current at once; its listeners hear of it with the next flush. */
2329
+ stream(state) {
2330
+ const s = state.streaming;
2331
+ if (!s) {
2332
+ if (this.streaming !== null) {
2333
+ this.streaming = null;
2334
+ this.streamDirty = true;
2335
+ }
2336
+ return;
2337
+ }
2338
+ if (this.streaming?.turnId === s.turnId && this.streaming.text === s.text)
2339
+ return;
2340
+ this.streaming = { key: `a:${s.turnId}`, turnId: s.turnId, text: s.text };
2341
+ this.streamDirty = true;
2342
+ }
2343
+ scheduler() {
2344
+ return this.options.scheduler ?? defaultScheduler;
2345
+ }
2346
+ // ------------------------------------------------------------ persistence
2347
+ key() {
2348
+ return this.options.persistenceKey ?? "vinaverse.conversation";
2349
+ }
2350
+ restore() {
2351
+ const store = this.options.persistence;
2352
+ if (!store) return;
2353
+ const done = () => {
2354
+ this.historyState = "restored";
2355
+ this.update();
2356
+ };
2357
+ const apply = (raw) => {
2358
+ if (this.disposed) return;
2359
+ if (raw === null) return done();
2360
+ try {
2361
+ const saved = JSON.parse(raw);
2362
+ if (saved.v === PERSIST_VERSION && Array.isArray(saved.items))
2363
+ this.history = saved.items.map(
2364
+ (item) => item.type === "card" ? { ...item, busy: false, restored: true } : item
2365
+ );
2366
+ } catch {
2367
+ }
2368
+ done();
2369
+ };
2370
+ try {
2371
+ const raw = store.getItem(this.key());
2372
+ if (typeof raw === "string" || raw === null) apply(raw);
2373
+ else {
2374
+ this.historyState = "restoring";
2375
+ this.view = this.compute(null);
2376
+ raw.then(apply, () => {
2377
+ if (!this.disposed) done();
2378
+ });
2379
+ }
2380
+ } catch {
2381
+ done();
2382
+ }
2383
+ }
2384
+ schedulePersist() {
2385
+ const store = this.options.persistence;
2386
+ if (!store || this.disposed || this.persistTimer !== void 0) return;
2387
+ this.persistTimer = setTimeout(() => {
2388
+ this.persistTimer = void 0;
2389
+ const items = this.view.items.filter(persistable).slice(-100);
2390
+ try {
2391
+ void store.setItem(
2392
+ this.key(),
2393
+ JSON.stringify({ v: PERSIST_VERSION, items })
2394
+ );
2395
+ } catch {
2396
+ }
2397
+ }, PERSIST_DELAY);
2398
+ }
2399
+ // ------------------------------------------------------------ guards
2400
+ /** Past the session's end (`expires_at`), when the backend passed it on. */
2401
+ sessionOver() {
2402
+ const end = this.info?.expires_at;
2403
+ return end !== void 0 && (this.options.clock?.now() ?? Date.now()) >= Date.parse(end);
2404
+ }
2405
+ live() {
2406
+ if (this.disposed)
2407
+ throw new VinaverseClientError(
2408
+ "session_ended",
2409
+ "This conversation was closed."
2410
+ );
2411
+ }
2412
+ required() {
2413
+ this.live();
2414
+ if (!this.session)
2415
+ throw new VinaverseClientError(
2416
+ "session_ended",
2417
+ "The conversation has no session yet: start() it first."
2418
+ );
2419
+ return this.session;
2420
+ }
2421
+ };
2422
+ function createConversation(options) {
2423
+ return new ConversationStore(options);
2424
+ }
2425
+ var defaultScheduler = {
2426
+ request(callback) {
2427
+ const raf = globalThis.requestAnimationFrame;
2428
+ return raf ? { raf: raf(callback) } : { timer: setTimeout(callback, 16) };
2429
+ },
2430
+ cancel(handle) {
2431
+ const h = handle;
2432
+ const caf = globalThis.cancelAnimationFrame;
2433
+ if (h.raf !== void 0) caf?.(h.raf);
2434
+ if (h.timer !== void 0) clearTimeout(h.timer);
2435
+ }
2436
+ };
2437
+ function banner(status, next) {
2438
+ if (status === "connecting" || status === "starting") return "connecting";
2439
+ if (status === "reconnecting") return "reconnecting";
2440
+ if (status === "offline") return "offline";
2441
+ if (status === "unavailable" || status === "unauthorized")
2442
+ return "unavailable";
2443
+ if (next === "continue_here") return "continued_elsewhere";
2444
+ return null;
2445
+ }
2446
+ function sameAgent(agent, name, avatarUrl) {
2447
+ return agent?.name === name && agent?.avatarUrl === avatarUrl;
2448
+ }
2449
+ function sameTools(a, b) {
2450
+ return a.length === b.length && a.every(
2451
+ (t, i) => t.turn_id === b[i]?.turn_id && t.label === b[i]?.label && t.state === b[i]?.state
2452
+ );
2453
+ }
2454
+ function shallowEqual(a, b) {
2455
+ 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;
2456
+ }
2457
+
2458
+ // src/agent/runtime.ts
2459
+ var AgentRefusal = class extends Error {
2460
+ constructor(kind, options = {}) {
2461
+ super(
2462
+ options.message ?? (kind === "ended" ? "The conversation is over." : "No agent now.")
2463
+ );
2464
+ this.kind = kind;
2465
+ this.name = "AgentRefusal";
2466
+ this.reason = options.reason ?? (kind === "ended" ? "ended" : "unknown");
2467
+ this.code = options.code ?? null;
2468
+ }
2469
+ kind;
2470
+ reason;
2471
+ code;
2472
+ };
2473
+ var GREETED = "vinaverse.greeted";
2474
+ var GREETED_BEFORE = "vinaverse.agent.greeted";
2475
+ function greetedKey(subject, mode) {
2476
+ return `${GREETED}.${JSON.stringify([subject, mode])}`;
2477
+ }
2478
+ function tupleOf(key, prefix) {
2479
+ if (!key.startsWith(`${prefix}.[`)) return null;
2480
+ try {
2481
+ const t = JSON.parse(key.slice(prefix.length + 1));
2482
+ return Array.isArray(t) ? t : null;
2483
+ } catch {
2484
+ return null;
2485
+ }
2486
+ }
2487
+ function legacyTranscripts(keys, prefix) {
2488
+ const out = [];
2489
+ for (const key of keys) {
2490
+ const t = tupleOf(key, prefix);
2491
+ if (t)
2492
+ out.push(
2493
+ `${prefix}.${t[1] ?? "guest"}|${t[2]}|${t[3] ?? "-"}|${t[4] ?? "chat"}`
2494
+ );
2495
+ }
2496
+ return out;
2497
+ }
2498
+ function legacyGreetings(pairs) {
2499
+ const out = [];
2500
+ for (const [subject, mode] of pairs)
2501
+ out.push(`${GREETED_BEFORE}.${subject ?? "guest"}:${mode}`);
2502
+ return out;
2503
+ }
2504
+ var NOT_KEPT = /* @__PURE__ */ new Set(["starting", "unavailable", "unauthorized", "closed"]);
2505
+ var CLEARED = { screen: "other" };
2506
+ function hashKey(key, salt) {
2507
+ return `k${sha256(`${salt}\0${key}`).slice(0, 32)}`;
2508
+ }
2509
+ var K;
2510
+ var H;
2511
+ function sha256(text2) {
2512
+ if (!K || !H) {
2513
+ K = [];
2514
+ H = [];
2515
+ for (let n = 2; K.length < 64; n++) {
2516
+ let prime = true;
2517
+ for (let d = 2; d * d <= n; d++) if (n % d === 0) prime = false;
2518
+ if (!prime) continue;
2519
+ if (H.length < 8) H.push(Math.sqrt(n) % 1 * 2 ** 32 | 0);
2520
+ K.push(Math.cbrt(n) % 1 * 2 ** 32 | 0);
2521
+ }
2522
+ }
2523
+ const bytes = [];
2524
+ const s = encodeURIComponent(text2);
2525
+ for (let i = 0; i < s.length; i++)
2526
+ if (s[i] === "%") {
2527
+ bytes.push(Number.parseInt(s.slice(i + 1, i + 3), 16));
2528
+ i += 2;
2529
+ } else bytes.push(s.charCodeAt(i));
2530
+ const bits = bytes.length * 8;
2531
+ bytes.push(128);
2532
+ while (bytes.length % 64 !== 56) bytes.push(0);
2533
+ for (const word of [Math.floor(bits / 2 ** 32), bits >>> 0])
2534
+ bytes.push(
2535
+ word >>> 24,
2536
+ word >>> 16 & 255,
2537
+ word >>> 8 & 255,
2538
+ word & 255
2539
+ );
2540
+ const h = [...H];
2541
+ const w = new Array(64);
2542
+ const ror = (x, n) => x >>> n | x << 32 - n;
2543
+ for (let at = 0; at < bytes.length; at += 64) {
2544
+ for (let i = 0; i < 64; i++) {
2545
+ if (i < 16) {
2546
+ const b2 = at + i * 4;
2547
+ w[i] = bytes[b2] << 24 | bytes[b2 + 1] << 16 | bytes[b2 + 2] << 8 | bytes[b2 + 3];
2548
+ } else {
2549
+ const x = w[i - 15];
2550
+ const y = w[i - 2];
2551
+ w[i] = w[i - 16] + (ror(x, 7) ^ ror(x, 18) ^ x >>> 3) + w[i - 7] + (ror(y, 17) ^ ror(y, 19) ^ y >>> 10) | 0;
2552
+ }
2553
+ }
2554
+ let [a, b, c, d, e, f, g, k] = h;
2555
+ for (let i = 0; i < 64; i++) {
2556
+ const t1 = k + (ror(e, 6) ^ ror(e, 11) ^ ror(e, 25)) + (e & f ^ ~e & g) + K[i] + w[i] | 0;
2557
+ const t2 = (ror(a, 2) ^ ror(a, 13) ^ ror(a, 22)) + (a & b ^ a & c ^ b & c) | 0;
2558
+ k = g;
2559
+ g = f;
2560
+ f = e;
2561
+ e = d + t1 | 0;
2562
+ d = c;
2563
+ c = b;
2564
+ b = a;
2565
+ a = t1 + t2 | 0;
2566
+ }
2567
+ [a, b, c, d, e, f, g, k].forEach((v, i) => {
2568
+ h[i] = h[i] + v | 0;
2569
+ });
2570
+ }
2571
+ return h.map((v) => (v >>> 0).toString(16).padStart(8, "0")).join("");
2572
+ }
2573
+ var NO_FEATURES = {
2574
+ history: false,
2575
+ memory: false,
2576
+ businessAgent: false,
2577
+ businessSetup: false
2578
+ };
2579
+ var SYSTEM_CLOCK = { now: () => Date.now() };
2580
+ function agentKey(key, agent = null) {
2581
+ return JSON.stringify([
2582
+ agent,
2583
+ key.subject,
2584
+ key.mode,
2585
+ key.scope ?? null,
2586
+ key.thread ?? "chat"
2587
+ ]);
2588
+ }
2589
+ var samePlace = (a, b) => a?.screen === b?.screen && (a?.entity?.type ?? null) === (b?.entity?.type ?? null) && (a?.entity?.id ?? null) === (b?.entity?.id ?? null) && JSON.stringify(a?.filters ?? null) === JSON.stringify(b?.filters ?? null);
2590
+ function usable(session, insecure) {
2591
+ return typeof session.id === "string" && session.id !== "" && (insecure.realtime === true || /^wss:\/\//i.test(session.realtime_url ?? "")) && (insecure.turns === true || /^https:\/\//i.test(session.turns_url ?? "")) && Boolean(session.client_secret);
2592
+ }
2593
+ function warnInsecure(insecure, warn) {
2594
+ if (!(insecure.realtime || insecure.turns)) return;
2595
+ if (globalThis.__DEV__ === true) return;
2596
+ warn(
2597
+ "@vinaverse/client/agent: insecureAddresses lets sessions use plaintext ws:// or http:// addresses. For mocks and local development only.",
2598
+ { code: "insecure_addresses" }
2599
+ );
2600
+ }
2601
+ var OPEN_KEPT_MS = 24 * 36e5;
2602
+ var MAX_WAITING = 100;
2603
+ function createAgentRuntime(options) {
2604
+ const { backend } = options;
2605
+ const policy = {
2606
+ delays: options.retry?.delays ?? [500, 1500, 3500],
2607
+ isRetryable: options.retry?.isRetryable ?? retryable
2608
+ };
2609
+ const clock = options.clock ?? SYSTEM_CLOCK;
2610
+ const keepAlive = options.keepAlive ?? "session";
2611
+ const staleMs = options.statusStaleMs ?? 5 * 6e4;
2612
+ const insecure = options.insecureAddresses ?? {};
2613
+ warnInsecure(insecure, options.onWarning ?? ((m) => console.warn(m)));
2614
+ const live = /* @__PURE__ */ new Map();
2615
+ const current = /* @__PURE__ */ new Map();
2616
+ const listeners = /* @__PURE__ */ new Set();
2617
+ let status = null;
2618
+ let reading = null;
2619
+ const NO_STATUS = {
2620
+ status: null,
2621
+ reading: false,
2622
+ failed: false
2623
+ };
2624
+ let statusState = NO_STATUS;
2625
+ const setStatusState = (next) => {
2626
+ statusState = { status: status?.value ?? null, ...next };
2627
+ changed();
2628
+ };
2629
+ let sessionName = null;
2630
+ let generation = 0;
2631
+ const keyOf = (key) => agentKey(key, options.agent ?? null);
2632
+ const prefix = options.conversation?.persistenceKey ?? "vinaverse.agent";
2633
+ const storage = guardedStorage(
2634
+ options.conversation?.persistence,
2635
+ prefix,
2636
+ () => generation
2637
+ );
2638
+ const greetings = guardedStorage(
2639
+ options.greetingStorage,
2640
+ GREETED,
2641
+ () => generation
2642
+ );
2643
+ const warn = options.onWarning ?? ((m) => console.warn(m));
2644
+ const isolated = (what, call) => {
2645
+ try {
2646
+ call();
2647
+ } catch (error) {
2648
+ warn(
2649
+ `@vinaverse/client/agent: the app's ${what} threw (${error instanceof Error ? error.message : String(error)}); the conversation went on.`,
2650
+ { code: "callback_threw", callback: what }
2651
+ );
2652
+ }
2653
+ };
2654
+ const saltStore = options.conversation?.persistence ?? options.greetingStorage;
2655
+ const saltKey = `${prefix}:salt`;
2656
+ let salt = null;
2657
+ const waiting = [];
2658
+ const loadSalt = () => {
2659
+ if (!options.onEvent) return;
2660
+ const settle = (kept2) => {
2661
+ if (salt !== null) return;
2662
+ if (kept2) salt = kept2;
2663
+ else {
2664
+ salt = randomId().slice(3);
2665
+ void Promise.resolve(saltStore?.setItem(saltKey, salt)).catch(
2666
+ () => void 0
2667
+ );
2668
+ }
2669
+ for (const event of waiting.splice(0)) deliver(event);
2670
+ };
2671
+ try {
2672
+ const got = saltStore?.getItem(saltKey);
2673
+ if (typeof got === "string" || got == null) settle(got);
2674
+ else got.then(settle, () => settle(null));
2675
+ } catch {
2676
+ settle(null);
2677
+ }
2678
+ };
2679
+ const deliver = (event) => isolated(
2680
+ "onEvent",
2681
+ () => options.onEvent?.(
2682
+ event.key ? { ...event, key: hashKey(event.key, salt ?? "") } : event
2683
+ )
2684
+ );
2685
+ let dropping = false;
2686
+ const emit = (event) => {
2687
+ if (!options.onEvent) return;
2688
+ if (salt !== null) return deliver(event);
2689
+ if (waiting.push(event) > MAX_WAITING) {
2690
+ waiting.shift();
2691
+ if (!dropping)
2692
+ warn(
2693
+ "@vinaverse/client/agent: the analytics salt is still being read from storage; the oldest events are dropped.",
2694
+ { code: "events_dropped" }
2695
+ );
2696
+ dropping = true;
2697
+ }
2698
+ };
2699
+ loadSalt();
2700
+ const changed = () => {
2701
+ for (const l of [...listeners]) l();
2702
+ };
2703
+ const update = (entry, next) => {
2704
+ entry.state = { ...entry.state, ...next };
2705
+ for (const l of [...entry.listeners]) l();
2706
+ };
2707
+ const end = (k) => {
2708
+ const entry = live.get(k);
2709
+ if (!entry) return;
2710
+ live.delete(k);
2711
+ clearTimeout(entry.timer);
2712
+ entry.store.dispose();
2713
+ emit({ type: "conversation_ended", key: k });
2714
+ changed();
2715
+ };
2716
+ const patiently = (run, signal, key) => retrying(
2717
+ run,
2718
+ policy,
2719
+ signal,
2720
+ (attempt) => emit({ type: "session_retried", key, attempt })
2721
+ );
2722
+ async function open(entry, key, request) {
2723
+ if (live.get(entry.key) !== entry) throw new AgentRefusal("unavailable");
2724
+ const held = entry.failedOpen && clock.now() - entry.failedOpen.at < OPEN_KEPT_MS ? entry.failedOpen : void 0;
2725
+ entry.failedOpen = void 0;
2726
+ const asked = held ? {
2727
+ ...held.request,
2728
+ ...request.signal ? { signal: request.signal } : {}
2729
+ } : {
2730
+ ...request,
2731
+ conversationId: request.conversationId ?? current.get(entry.key) ?? null,
2732
+ key,
2733
+ openId: `op_${randomId().slice(3)}`,
2734
+ agent: options.agent ?? null,
2735
+ place: entry.place,
2736
+ placeRevision: ++entry.revision,
2737
+ entryPoint: entry.entryPoint
2738
+ };
2739
+ const { conversationId, openId, place: told } = asked;
2740
+ let session;
2741
+ try {
2742
+ session = await patiently(
2743
+ () => backend.openSession(asked),
2744
+ request.signal,
2745
+ entry.key
2746
+ );
2747
+ if (!usable(session, insecure))
2748
+ throw new AgentRefusal("unavailable", {
2749
+ reason: "invalid_session",
2750
+ message: "Unusable session: an id, a secret, wss:// and https:// addresses."
2751
+ });
2752
+ } catch (error) {
2753
+ if (live.get(entry.key) === entry) {
2754
+ const failure = isRefusal(error) ? error.kind === "ended" && conversationId !== null ? "ended" : "unavailable" : policy.isRetryable(error) ? "unreachable" : "unavailable";
2755
+ const reason = reasonOf(error, failure);
2756
+ const code = isRefusal(error) ? error.code : codeOf(error);
2757
+ if (failure === "unreachable") {
2758
+ const { signal, ...again } = asked;
2759
+ entry.failedOpen = { request: again, at: held?.at ?? clock.now() };
2760
+ }
2761
+ const unsettled = reason === "unknown_open" ? String(field(error, "openId") ?? openId) : null;
2762
+ update(entry, { failure, reason, code, openId: unsettled });
2763
+ emit({ type: "session_failed", key: entry.key, failure, reason });
2764
+ }
2765
+ throw error;
2766
+ }
2767
+ if (live.get(entry.key) !== entry) throw new AgentRefusal("unavailable");
2768
+ entry.told = told;
2769
+ entry.expiresAt = session.expires_at ? Date.parse(session.expires_at) : null;
2770
+ if (session.conversation_id)
2771
+ current.set(entry.key, session.conversation_id);
2772
+ const name = session.agent?.display_name?.trim();
2773
+ if (name && name !== sessionName) {
2774
+ sessionName = name;
2775
+ changed();
2776
+ }
2777
+ update(entry, {
2778
+ failure: null,
2779
+ reason: null,
2780
+ code: null,
2781
+ openId: null,
2782
+ sessionId: session.id,
2783
+ conversationId: session.conversation_id ?? conversationId,
2784
+ moved: !samePlace(told, entry.place)
2785
+ });
2786
+ emit({
2787
+ type: conversationId === null ? "session_opened" : "session_resumed",
2788
+ key: entry.key,
2789
+ sessionId: session.id,
2790
+ ...entry.entryPoint ? { from: entry.entryPoint } : {}
2791
+ });
2792
+ return session;
2793
+ }
2794
+ const kept = (k) => `${prefix}.${k}`;
2795
+ const signOut = async (erasing) => {
2796
+ generation++;
2797
+ if (erasing) salt = null;
2798
+ for (const entry of live.values()) {
2799
+ clearTimeout(entry.timer);
2800
+ entry.store.clear();
2801
+ }
2802
+ const known = /* @__PURE__ */ new Set([...live.keys(), ...current.keys()]);
2803
+ live.clear();
2804
+ current.clear();
2805
+ sessionName = null;
2806
+ status = null;
2807
+ reading = null;
2808
+ statusState = NO_STATUS;
2809
+ changed();
2810
+ const pairs = [];
2811
+ await Promise.all([
2812
+ backend.endSessions?.().catch(() => void 0),
2813
+ storage?.purge(
2814
+ [`${prefix}.`],
2815
+ (keys) => {
2816
+ const all = /* @__PURE__ */ new Set([...keys, ...[...known].map(kept)]);
2817
+ for (const key of all) {
2818
+ const t = tupleOf(key, prefix);
2819
+ if (t) pairs.push([t[1], t[2]]);
2820
+ }
2821
+ return [
2822
+ ...all,
2823
+ ...legacyTranscripts(all, prefix),
2824
+ ...options.legacyKeys ?? []
2825
+ ];
2826
+ },
2827
+ `${GREETED}.`
2828
+ )
2829
+ ]);
2830
+ if (!erasing) return;
2831
+ await Promise.all([
2832
+ Promise.resolve(saltStore?.removeItem(saltKey)).catch(() => void 0),
2833
+ greetings?.purge([`${GREETED}.`, `${GREETED_BEFORE}.`], (keys) => {
2834
+ for (const key of keys) {
2835
+ const t = tupleOf(key, GREETED);
2836
+ if (t) pairs.push([t[0], t[1]]);
2837
+ }
2838
+ return [...legacyGreetings(pairs), ...options.legacyKeys ?? []];
2839
+ })
2840
+ ]);
2841
+ loadSalt();
2842
+ };
2843
+ const forget = (k) => {
2844
+ current.delete(k);
2845
+ const entry = live.get(k);
2846
+ if (entry) {
2847
+ entry.store.clear();
2848
+ end(k);
2849
+ } else void storage?.removeItem(kept(k));
2850
+ };
2851
+ const activity = (entry, a) => {
2852
+ const sessionId = entry.state.sessionId;
2853
+ if (a.type === "reply_completed" && sessionId) {
2854
+ entry.turns.set(a.turnId, sessionId);
2855
+ update(entry, {});
2856
+ }
2857
+ emit({
2858
+ type: a.type,
2859
+ key: entry.key,
2860
+ ...sessionId ? { sessionId } : {},
2861
+ ...a.type === "reply_completed" ? { turnId: a.turnId } : {},
2862
+ ...a.type === "card_shown" ? { cardId: a.cardId, kind: a.kind } : {}
2863
+ });
2864
+ };
2865
+ function make(key) {
2866
+ const k = keyOf(key);
2867
+ const own = options.conversation ?? {};
2868
+ const refresh = backend.refreshSecret?.bind(backend);
2869
+ const entry = {
2870
+ key: k,
2871
+ store: createConversation({
2872
+ ...own,
2873
+ ...storage ? {
2874
+ persistence: storage,
2875
+ persistenceKey: kept(k)
2876
+ } : {},
2877
+ onActivity: (a) => {
2878
+ activity(entry, a);
2879
+ isolated("onActivity", () => own.onActivity?.(a));
2880
+ },
2881
+ getSession: (request) => open(entry, key, request),
2882
+ ...refresh ? {
2883
+ // A refusal (`session_closed`) is the store's cue to go on in a new session, never a fault.
2884
+ refreshSecret: (id) => patiently(() => refresh(id), void 0, k).catch((error) => {
2885
+ throw isRefusal(error) ? new VinaverseClientError(
2886
+ "needs_new_session",
2887
+ error.message
2888
+ ) : error;
2889
+ })
2890
+ } : {},
2891
+ clock,
2892
+ onError: (error) => {
2893
+ if (!isRefusal(error) && codeOf(error) !== "needs_new_session")
2894
+ own.onError?.(error);
2895
+ }
2896
+ }),
2897
+ listeners: /* @__PURE__ */ new Set(),
2898
+ state: {
2899
+ failure: null,
2900
+ reason: null,
2901
+ code: null,
2902
+ moved: false,
2903
+ ratings: /* @__PURE__ */ new Map(),
2904
+ sessionId: null,
2905
+ conversationId: current.get(k) ?? null,
2906
+ openId: null
2907
+ },
2908
+ place: null,
2909
+ told: null,
2910
+ revision: 0,
2911
+ entryPoint: null,
2912
+ expiresAt: null,
2913
+ shown: 0,
2914
+ started: false,
2915
+ suspended: false,
2916
+ sentOnce: /* @__PURE__ */ new Set(),
2917
+ timer: void 0,
2918
+ turns: /* @__PURE__ */ new Map(),
2919
+ failedOpen: void 0
2920
+ };
2921
+ return entry;
2922
+ }
2923
+ const over = (entry) => entry.expiresAt !== null && entry.expiresAt <= clock.now();
2924
+ const expireLater = (entry) => {
2925
+ clearTimeout(entry.timer);
2926
+ if (entry.expiresAt === null) return;
2927
+ entry.timer = setTimeout(
2928
+ () => {
2929
+ entry.timer = void 0;
2930
+ if (live.get(entry.key) !== entry || entry.shown > 0) return;
2931
+ if (over(entry)) end(entry.key);
2932
+ else expireLater(entry);
2933
+ },
2934
+ Math.max(0, entry.expiresAt - clock.now())
2935
+ );
2936
+ };
2937
+ const tell = (entry, at) => {
2938
+ if (at.place !== void 0) entry.place = at.place;
2939
+ if (at.entryPoint !== void 0) entry.entryPoint = at.entryPoint;
2940
+ const sessionId = entry.state.sessionId;
2941
+ const moved = sessionId !== null && !samePlace(entry.told, entry.place);
2942
+ update(entry, { moved });
2943
+ const updateContext = backend.updateContext?.bind(backend);
2944
+ if (!moved || !updateContext || sessionId === null) return;
2945
+ const asked = generation;
2946
+ const current2 = () => asked === generation && live.get(entry.key) === entry && entry.state.sessionId === sessionId;
2947
+ const send = (again) => {
2948
+ const place = entry.place;
2949
+ const revision = ++entry.revision;
2950
+ updateContext(sessionId, place ?? CLEARED, revision).then(
2951
+ () => {
2952
+ if (!current2() || entry.revision !== revision) return;
2953
+ entry.told = place;
2954
+ update(entry, { moved: !samePlace(entry.told, entry.place) });
2955
+ emit({ type: "context_updated", key: entry.key, sessionId });
2956
+ },
2957
+ (error) => {
2958
+ const theirs = field(error, "currentRevision");
2959
+ const conflict = field(error, "outcome") === "context_conflict" || field(error, "reason") === "conflict";
2960
+ if (!conflict || again || !current2() || entry.revision !== revision)
2961
+ return;
2962
+ if (typeof theirs === "number" && theirs >= entry.revision)
2963
+ entry.revision = theirs;
2964
+ send(true);
2965
+ }
2966
+ );
2967
+ };
2968
+ send(false);
2969
+ };
2970
+ function handle(entry) {
2971
+ return {
2972
+ key: entry.key,
2973
+ store: entry.store,
2974
+ show(at = {}) {
2975
+ if (live.get(entry.key) !== entry) return () => void 0;
2976
+ if (entry.shown === 0 && over(entry)) {
2977
+ end(entry.key);
2978
+ return () => void 0;
2979
+ }
2980
+ entry.shown++;
2981
+ clearTimeout(entry.timer);
2982
+ entry.timer = void 0;
2983
+ if (entry.suspended) {
2984
+ entry.suspended = false;
2985
+ entry.store.setForeground(true);
2986
+ }
2987
+ tell(entry, at);
2988
+ if (!entry.started) {
2989
+ entry.started = true;
2990
+ entry.store.start();
2991
+ }
2992
+ let released = false;
2993
+ return () => {
2994
+ if (released) return;
2995
+ released = true;
2996
+ entry.shown--;
2997
+ if (entry.shown > 0 || live.get(entry.key) !== entry) return;
2998
+ if (keepAlive === "view" || NOT_KEPT.has(entry.store.getSnapshot().status) || entry.expiresAt === null || over(entry)) {
2999
+ end(entry.key);
3000
+ return;
3001
+ }
3002
+ entry.suspended = true;
3003
+ entry.store.setForeground(false);
3004
+ expireLater(entry);
3005
+ };
3006
+ },
3007
+ tell: (at) => tell(entry, at),
3008
+ getState: () => entry.state,
3009
+ subscribe(listener) {
3010
+ entry.listeners.add(listener);
3011
+ return () => entry.listeners.delete(listener);
3012
+ },
3013
+ retry() {
3014
+ update(entry, {
3015
+ failure: null,
3016
+ reason: null,
3017
+ code: null,
3018
+ openId: null
3019
+ });
3020
+ entry.store.start();
3021
+ },
3022
+ canRate: (turnId) => entry.turns.has(turnId),
3023
+ rate(turnId, rating) {
3024
+ const sessionId = entry.turns.get(turnId);
3025
+ if (!sessionId || entry.state.ratings.get(turnId) === rating) return;
3026
+ update(entry, {
3027
+ ratings: new Map(entry.state.ratings).set(turnId, rating)
3028
+ });
3029
+ emit({ type: "rated", key: entry.key, sessionId, turnId, rating });
3030
+ void backend.sendFeedback?.({ sessionId, turnId, rating }).catch(() => void 0);
3031
+ },
3032
+ sendOnce(text2) {
3033
+ if (entry.sentOnce.has(text2)) return;
3034
+ entry.sentOnce.add(text2);
3035
+ entry.store.send(text2);
3036
+ }
3037
+ };
3038
+ }
3039
+ const handles = /* @__PURE__ */ new WeakMap();
3040
+ const lookup = (key) => {
3041
+ const k = keyOf(key);
3042
+ let entry = live.get(k);
3043
+ if (!entry) {
3044
+ entry = make(key);
3045
+ live.set(k, entry);
3046
+ }
3047
+ let h = handles.get(entry);
3048
+ if (!h) {
3049
+ h = handle(entry);
3050
+ handles.set(entry, h);
3051
+ }
3052
+ return h;
3053
+ };
3054
+ return {
3055
+ conversation(key) {
3056
+ const k = keyOf(key);
3057
+ const entry = live.get(k);
3058
+ if (entry && entry.shown === 0 && over(entry)) end(k);
3059
+ return lookup(key);
3060
+ },
3061
+ peek: lookup,
3062
+ startNew(key) {
3063
+ const k = keyOf(key);
3064
+ current.delete(k);
3065
+ end(k);
3066
+ },
3067
+ dispose(key) {
3068
+ end(keyOf(key));
3069
+ },
3070
+ forget(key) {
3071
+ forget(keyOf(key));
3072
+ },
3073
+ sweep() {
3074
+ for (const entry of [...live.values()])
3075
+ if (entry.shown === 0 && over(entry)) end(entry.key);
3076
+ },
3077
+ continue(key, conversationId) {
3078
+ const k = keyOf(key);
3079
+ if (current.get(k) !== conversationId) {
3080
+ forget(k);
3081
+ current.set(k, conversationId);
3082
+ }
3083
+ return lookup(key);
3084
+ },
3085
+ async signOut() {
3086
+ await signOut(false);
3087
+ },
3088
+ async erase() {
3089
+ await signOut(true);
3090
+ },
3091
+ async deleteConversation(key, conversationId) {
3092
+ if (!backend.deleteConversation)
3093
+ throw new Error(
3094
+ "@vinaverse/client/agent: deleteConversation needs AgentBackend.deleteConversation."
3095
+ );
3096
+ await backend.deleteConversation(conversationId);
3097
+ const k = keyOf(key);
3098
+ const continued = current.get(k) ?? live.get(k)?.store.getSnapshot().conversationId ?? null;
3099
+ if (continued === conversationId) forget(k);
3100
+ },
3101
+ greetings: greetings ?? null,
3102
+ status() {
3103
+ if (status && clock.now() - status.at < staleMs)
3104
+ return Promise.resolve(status.value);
3105
+ if (reading) return reading;
3106
+ const asked = generation;
3107
+ const read = readStatus(backend).then(
3108
+ (value) => {
3109
+ if (asked !== generation) return value;
3110
+ reading = null;
3111
+ status = { value, at: clock.now() };
3112
+ setStatusState({ reading: false, failed: false });
3113
+ return value;
3114
+ },
3115
+ (error) => {
3116
+ if (asked === generation) {
3117
+ reading = null;
3118
+ setStatusState({ reading: false, failed: true });
3119
+ }
3120
+ throw error;
3121
+ }
3122
+ );
3123
+ reading = read;
3124
+ setStatusState({ reading: true, failed: statusState.failed });
3125
+ return read;
3126
+ },
3127
+ getStatus: () => status?.value ?? null,
3128
+ getStatusState: () => statusState,
3129
+ backend,
3130
+ epoch: () => generation,
3131
+ getName: () => sessionName ?? status?.value.displayName ?? null,
3132
+ subscribe(listener) {
3133
+ listeners.add(listener);
3134
+ return () => listeners.delete(listener);
3135
+ },
3136
+ emit
3137
+ };
3138
+ }
3139
+ function guardedStorage(raw, prefix, generation) {
3140
+ if (!raw) return void 0;
3141
+ const indexKey = `${prefix}:index`;
3142
+ const chains = /* @__PURE__ */ new Map();
3143
+ const pending = /* @__PURE__ */ new Map();
3144
+ const queued = (key, op) => {
3145
+ pending.set(key, (pending.get(key) ?? 0) + 1);
3146
+ const run = (chains.get(key) ?? Promise.resolve()).then(op);
3147
+ const settled = run.then(
3148
+ () => void 0,
3149
+ () => void 0
3150
+ );
3151
+ chains.set(key, settled);
3152
+ void settled.then(() => {
3153
+ const left = (pending.get(key) ?? 1) - 1;
3154
+ if (left > 0) pending.set(key, left);
3155
+ else {
3156
+ pending.delete(key);
3157
+ if (chains.get(key) === settled) chains.delete(key);
3158
+ }
3159
+ });
3160
+ return run;
3161
+ };
3162
+ const safe = (p) => p.catch(() => void 0);
3163
+ const readIndex = async () => {
3164
+ try {
3165
+ const value = await raw.getItem(indexKey);
3166
+ const list3 = value ? JSON.parse(value) : [];
3167
+ return new Set(
3168
+ Array.isArray(list3) ? list3.filter((k) => typeof k === "string") : []
3169
+ );
3170
+ } catch {
3171
+ return /* @__PURE__ */ new Set();
3172
+ }
3173
+ };
3174
+ const listed = /* @__PURE__ */ new Set();
3175
+ const list2 = (key) => {
3176
+ if (listed.has(key)) return;
3177
+ listed.add(key);
3178
+ void safe(
3179
+ queued(indexKey, async () => {
3180
+ const all = await readIndex();
3181
+ if (all.has(key)) return;
3182
+ all.add(key);
3183
+ await raw.setItem(indexKey, JSON.stringify([...all]));
3184
+ })
3185
+ );
3186
+ };
3187
+ return {
3188
+ getItem(key) {
3189
+ const asked = generation();
3190
+ const read = pending.has(key) ? queued(key, () => raw.getItem(key)) : raw.getItem(key);
3191
+ if (typeof read === "string" || read === null) return read;
3192
+ return Promise.resolve(read).then(
3193
+ (v) => asked === generation() ? v ?? null : null,
3194
+ () => null
3195
+ );
3196
+ },
3197
+ setItem(key, value) {
3198
+ const asked = generation();
3199
+ return safe(
3200
+ queued(key, () => {
3201
+ if (asked !== generation()) return;
3202
+ list2(key);
3203
+ return raw.setItem(key, value);
3204
+ })
3205
+ );
3206
+ },
3207
+ removeItem: (key) => safe(queued(key, () => raw.removeItem(key))),
3208
+ /**
3209
+ * Everything kept: the index, what the app's storage finds under `under` (`removePrefix`, else `keys`), and `extra`
3210
+ * (older key shapes). Nothing under `keep` goes, so `removePrefix` is used only for a prefix that cannot reach it.
3211
+ */
3212
+ async purge(under, extra = () => [], keep) {
3213
+ const all = /* @__PURE__ */ new Set([...listed, ...await readIndex()]);
3214
+ listed.clear();
3215
+ for (const p of under)
3216
+ try {
3217
+ if (raw.removePrefix && !keep?.startsWith(p))
3218
+ await raw.removePrefix(p);
3219
+ else if (raw.keys) {
3220
+ for (const key of await raw.keys(p))
3221
+ if (!keep || !key.startsWith(keep)) all.add(key);
3222
+ }
3223
+ } catch {
3224
+ }
3225
+ for (const key of extra(all)) all.add(key);
3226
+ await Promise.all(
3227
+ [...all].map((key) => safe(queued(key, () => raw.removeItem(key))))
3228
+ );
3229
+ await safe(queued(indexKey, () => raw.removeItem(indexKey)));
3230
+ }
3231
+ };
3232
+ }
3233
+ async function readStatus(backend) {
3234
+ const read = backend.status ? await backend.status() : { available: true };
3235
+ const available = read.available === true;
3236
+ const features = read.features ?? NO_FEATURES;
3237
+ return {
3238
+ available,
3239
+ launcher: available && read.launcher === true,
3240
+ displayName: read.displayName?.trim() || null,
3241
+ avatarUrl: read.avatarUrl?.startsWith("https://") ? read.avatarUrl : null,
3242
+ features: {
3243
+ history: available && features.history === true,
3244
+ memory: available && features.memory === true,
3245
+ businessAgent: available && features.businessAgent === true,
3246
+ businessSetup: available && features.businessSetup === true
3247
+ }
3248
+ };
3249
+ }
3250
+ function isRefusal(error) {
3251
+ return error instanceof AgentRefusal || field(error, "name") === "AgentRefusal" && (field(error, "kind") === "ended" || field(error, "kind") === "unavailable");
3252
+ }
3253
+ var field = (error, name) => typeof error === "object" && error !== null ? error[name] : void 0;
3254
+ var retryable = (error) => field(error, "retryable") === true;
3255
+ var REASONS = /* @__PURE__ */ new Set([
3256
+ "ended",
3257
+ "not_found",
3258
+ "expired",
3259
+ "mismatch",
3260
+ "completed",
3261
+ "forbidden",
3262
+ "conflict",
3263
+ "unknown_open",
3264
+ "auth",
3265
+ "off",
3266
+ "rate_limited",
3267
+ "network",
3268
+ "server",
3269
+ "invalid_session",
3270
+ "unknown"
3271
+ ]);
3272
+ function reasonOf(error, failure) {
3273
+ if (isRefusal(error)) return error.reason;
3274
+ const given = field(error, "reason");
3275
+ if (typeof given === "string" && REASONS.has(given))
3276
+ return given;
3277
+ const status = field(error, "status");
3278
+ if (status === 401 || status === 403) return "auth";
3279
+ if (status === 429) return "rate_limited";
3280
+ if (typeof status === "number" && status >= 500) return "server";
3281
+ return failure === "unreachable" ? "network" : "unknown";
3282
+ }
3283
+ function codeOf(error) {
3284
+ const code = field(error, "code") ?? field(error, "outcome");
3285
+ return typeof code === "string" ? code : null;
3286
+ }
3287
+ async function retrying(run, policy, signal, onRetry) {
3288
+ for (let attempt = 0; ; attempt++) {
3289
+ try {
3290
+ return await run();
3291
+ } catch (error) {
3292
+ const delay = policy.delays[attempt];
3293
+ if (delay === void 0 || !policy.isRetryable(error) || signal?.aborted)
3294
+ throw error;
3295
+ const asked = field(error, "retryAfterMs");
3296
+ const wait = typeof asked === "number" && asked > delay ? Math.min(asked, 6e4) : delay;
3297
+ await new Promise((resolve, reject) => {
3298
+ const timer = setTimeout(() => {
3299
+ signal?.removeEventListener("abort", stop);
3300
+ resolve();
3301
+ }, wait);
3302
+ function stop() {
3303
+ clearTimeout(timer);
3304
+ reject(error);
3305
+ }
3306
+ signal?.addEventListener("abort", stop, { once: true });
3307
+ });
3308
+ onRetry(attempt + 1);
3309
+ }
3310
+ }
3311
+ }
3312
+
3313
+ exports.AgentRefusal = AgentRefusal;
3314
+ exports.agentKey = agentKey;
3315
+ exports.createAgentRuntime = createAgentRuntime;
3316
+ exports.greetedKey = greetedKey;
3317
+ exports.hashKey = hashKey;
3318
+ exports.isRefusal = isRefusal;
3319
+ exports.readCard = readCard;
3320
+ //# sourceMappingURL=index.cjs.map
3321
+ //# sourceMappingURL=index.cjs.map