@fiodos/web-core 0.1.10 → 0.1.12

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 (41) hide show
  1. package/dist/cjs/api/backendClient.js +20 -0
  2. package/dist/cjs/config/types.d.ts +24 -0
  3. package/dist/cjs/config/types.js +6 -1
  4. package/dist/cjs/controller/AgentController.d.ts +105 -2
  5. package/dist/cjs/controller/AgentController.js +572 -75
  6. package/dist/cjs/dropin/createFiodosAgent.d.ts +5 -0
  7. package/dist/cjs/dropin/createFiodosAgent.js +7 -0
  8. package/dist/cjs/index.d.ts +3 -3
  9. package/dist/cjs/index.js +2 -1
  10. package/dist/cjs/orb/mountOrb.d.ts +6 -0
  11. package/dist/cjs/orb/mountOrb.js +229 -33
  12. package/dist/cjs/orb/orbView.d.ts +2 -0
  13. package/dist/cjs/orb/publishedConfig.d.ts +18 -0
  14. package/dist/cjs/orb/publishedConfig.js +6 -0
  15. package/dist/cjs/speech/bubbleDictation.d.ts +11 -0
  16. package/dist/cjs/speech/bubbleDictation.js +88 -0
  17. package/dist/cjs/ui/messages.d.ts +15 -0
  18. package/dist/cjs/ui/messages.js +12 -0
  19. package/dist/cjs/version.d.ts +1 -1
  20. package/dist/cjs/version.js +1 -1
  21. package/dist/esm/api/backendClient.js +20 -0
  22. package/dist/esm/config/types.d.ts +24 -0
  23. package/dist/esm/config/types.js +5 -0
  24. package/dist/esm/controller/AgentController.d.ts +105 -2
  25. package/dist/esm/controller/AgentController.js +574 -77
  26. package/dist/esm/dropin/createFiodosAgent.d.ts +5 -0
  27. package/dist/esm/dropin/createFiodosAgent.js +7 -0
  28. package/dist/esm/index.d.ts +3 -3
  29. package/dist/esm/index.js +1 -1
  30. package/dist/esm/orb/mountOrb.d.ts +6 -0
  31. package/dist/esm/orb/mountOrb.js +230 -34
  32. package/dist/esm/orb/orbView.d.ts +2 -0
  33. package/dist/esm/orb/publishedConfig.d.ts +18 -0
  34. package/dist/esm/orb/publishedConfig.js +6 -0
  35. package/dist/esm/speech/bubbleDictation.d.ts +11 -0
  36. package/dist/esm/speech/bubbleDictation.js +84 -0
  37. package/dist/esm/ui/messages.d.ts +15 -0
  38. package/dist/esm/ui/messages.js +12 -0
  39. package/dist/esm/version.d.ts +1 -1
  40. package/dist/esm/version.js +1 -1
  41. package/package.json +2 -2
@@ -30,6 +30,7 @@ const messages_1 = require("../ui/messages");
30
30
  function randomSessionId() {
31
31
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
32
32
  }
33
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
34
  class AgentController {
34
35
  constructor(config) {
35
36
  this.enabled = true;
@@ -42,6 +43,7 @@ class AgentController {
42
43
  this.consentModalVisible = false;
43
44
  this.sessionId = randomSessionId();
44
45
  this.sessionStarted = false;
46
+ // Replaced wholesale on identity swaps / explicit resets.
45
47
  this.conversation = (0, core_1.createConversationSession)();
46
48
  this.pending = null;
47
49
  this.confirmationReasked = false;
@@ -58,6 +60,22 @@ class AgentController {
58
60
  // audio), skip the extra /preview-tts round-trip and speak via device voice.
59
61
  this.serverTtsUnavailable = false;
60
62
  this.pendingConsentIntent = null;
63
+ /** Parked chain state (set when a chain pauses on a sensitive action). */
64
+ this.chainState = null;
65
+ /** REAL live activity for the panel (replaces the old canned walk-through). */
66
+ this.liveActivity = null;
67
+ this.chainActive = false;
68
+ /** How many exchanges the bubble currently mounts (grows one page at a time). */
69
+ this.bubbleWindowSize = core_1.BUBBLE_PAGE_EXCHANGES;
70
+ /** Raw dashboard retention (minutes; 0 = never; null = default). Persisted
71
+ * with the session so a restore applies the same expiry rule. */
72
+ this.conversationRetentionMinutes = null;
73
+ /** Persistence scope: internal orbs keep their memory under a separate key
74
+ * so a public orb sharing the same appId can never read it. */
75
+ this.conversationScope = 'public';
76
+ this.removeIdentityListeners = null;
77
+ this.loadingOlderExchanges = false;
78
+ this.loadOlderTimer = null;
61
79
  this.listeners = new Set();
62
80
  this.unsubscribeSpeech = null;
63
81
  // Fail fast on an invalid manifest — same contract as every platform.
@@ -84,6 +102,13 @@ class AgentController {
84
102
  isAuthenticated: config.isAuthenticated,
85
103
  });
86
104
  this.timings = { ...types_1.DEFAULT_AGENT_TIMINGS, ...config.timings };
105
+ // Chaining OFF by default; clamp to the same safe bounds as react/react-native.
106
+ const chainingMerged = { ...types_1.DEFAULT_AGENT_CHAINING, ...config.chaining };
107
+ this.chaining = {
108
+ enabled: chainingMerged.enabled === true,
109
+ maxSteps: Math.max(1, Math.min(6, Math.floor(chainingMerged.maxSteps || 6))),
110
+ dwellMs: Math.max(0, Math.min(3000, Math.floor(chainingMerged.dwellMs ?? 450))),
111
+ };
87
112
  this.ttsLocale = config.ttsLocale ?? config.sttLocale;
88
113
  this.notifyError =
89
114
  config.notifyError ??
@@ -108,12 +133,84 @@ class AgentController {
108
133
  });
109
134
  // Re-emit combined state whenever the speech machine advances.
110
135
  this.unsubscribeSpeech = this.speech.subscribe(() => this.emit());
136
+ this.conversationIdentity = (0, core_1.conversationIdentityKey)(config.getUserId?.());
137
+ // Rehydrate the previous conversation (page reloads): the persisted blob
138
+ // carries its own retention rule, so expiry is decided correctly even
139
+ // before the published config arrives. Best-effort and non-blocking.
140
+ void this.restorePersistedConversation();
141
+ // Logout/login usually happens on another screen: re-check identity when
142
+ // the tab regains focus or becomes visible again, besides before every turn.
143
+ if (typeof window !== 'undefined') {
144
+ const check = () => void this.syncConversationIdentity();
145
+ window.addEventListener('focus', check);
146
+ document.addEventListener('visibilitychange', check);
147
+ this.removeIdentityListeners = () => {
148
+ window.removeEventListener('focus', check);
149
+ document.removeEventListener('visibilitychange', check);
150
+ };
151
+ }
152
+ }
153
+ // ── Conversation persistence (survives page reloads) ───────────────────────
154
+ async restorePersistedConversation() {
155
+ const identityAtStart = this.conversationIdentity;
156
+ const restored = await (0, core_1.restoreConversationSession)(this.config.storage, this.config.manifest.appId, Date.now(), this.conversationScope, identityAtStart);
157
+ // The identity may have changed again while the read resolved.
158
+ if (!restored || this.conversationIdentity !== identityAtStart)
159
+ return;
160
+ // Never clobber turns the user already made while the restore resolved.
161
+ if (this.conversation.turns.length > 0 || this.conversation.uiArchive?.length)
162
+ return;
163
+ this.conversation.turns = restored.turns;
164
+ this.conversation.summary = restored.summary;
165
+ this.conversation.uiArchive = restored.uiArchive;
166
+ this.conversation.lastActivityAt = restored.lastActivityAt;
167
+ this.emit();
168
+ }
169
+ persistConversation() {
170
+ void (0, core_1.persistConversationSession)(this.config.storage, this.config.manifest.appId, this.conversation, this.conversationRetentionMinutes, this.conversationScope, this.conversationIdentity);
171
+ }
172
+ /**
173
+ * Re-evaluates the host identity and, when it changed, swaps conversations:
174
+ * the outgoing identity's session is saved under ITS private key (so a user
175
+ * who logs back in recovers it, honouring "never clear"), all in-memory
176
+ * state and UI are wiped, and the incoming identity's own session (usually
177
+ * empty) is restored. An anonymous visitor after a logout always starts
178
+ * clean — the previous user's transcript is never inherited.
179
+ */
180
+ syncConversationIdentity() {
181
+ const next = (0, core_1.conversationIdentityKey)(this.config.getUserId?.());
182
+ if (next === this.conversationIdentity)
183
+ return false;
184
+ // 1. Park the outgoing identity's session under its own key.
185
+ this.persistConversation();
186
+ // 2. Wipe every trace from memory and the panel.
187
+ this.conversation = (0, core_1.createConversationSession)();
188
+ this.conversationIdentity = next;
189
+ this.lastExchange = null;
190
+ this.emit();
191
+ // 3. Bring in the new identity's own session, if any.
192
+ void this.restorePersistedConversation();
193
+ return true;
194
+ }
195
+ /**
196
+ * Wipes the CURRENT identity's conversation: live memory, panel and the
197
+ * persisted blob. Hosts can call it from their logout for a hard wipe (by
198
+ * default a logout only parks the conversation under the user's private
199
+ * local key).
200
+ */
201
+ resetConversation() {
202
+ this.cancelAll();
203
+ this.conversation = (0, core_1.createConversationSession)();
204
+ this.lastExchange = null;
205
+ void (0, core_1.clearPersistedConversation)(this.config.storage, this.config.manifest.appId, this.conversationScope, this.conversationIdentity);
206
+ this.emit();
111
207
  }
112
208
  // ── Subscription / state ───────────────────────────────────────────────────
113
209
  getState() {
114
210
  const speech = this.speech.getSnapshot();
115
211
  const showWaveform = (this.phase === 'listening' || this.phase === 'confirming') && speech.state === 'recording';
116
212
  const showThinking = this.phase === 'thinking' || (this.phase === 'listening' && speech.state === 'transcribing');
213
+ const bubbleWindow = (0, core_1.buildBubbleExchangeWindow)(this.conversation, this.lastExchange, this.bubbleWindowSize, this.conversationOptions);
117
214
  return {
118
215
  phase: this.phase,
119
216
  interimText: speech.interimText,
@@ -123,12 +220,32 @@ class AgentController {
123
220
  isBusy: showThinking || this.phase === 'speaking',
124
221
  isListening: this.phase === 'listening' && speech.state !== 'idle',
125
222
  lastExchange: this.lastExchange,
223
+ recentExchanges: bubbleWindow.exchanges,
224
+ hasOlderExchanges: bubbleWindow.hasOlder,
225
+ loadingOlderExchanges: this.loadingOlderExchanges,
126
226
  confirmationMessage: this.confirmationMessage,
127
227
  confirmationVoiceHint: this.confirmationVoiceHint,
128
228
  consentModalVisible: this.consentModalVisible,
129
229
  voiceBlocked: this.voiceBlocked,
230
+ liveActivity: this.liveActivity,
231
+ chainActive: this.chainActive,
130
232
  };
131
233
  }
234
+ /** Update the REAL live activity and re-render (no-op when unchanged). */
235
+ setActivity(next) {
236
+ const prev = this.liveActivity;
237
+ if (prev === next || (prev && next && prev.kind === next.kind && prev.label === next.label)) {
238
+ return;
239
+ }
240
+ this.liveActivity = next;
241
+ this.emit();
242
+ }
243
+ setChainActive(value) {
244
+ if (this.chainActive === value)
245
+ return;
246
+ this.chainActive = value;
247
+ this.emit();
248
+ }
132
249
  /** Whether speech recognition can actually run (API present AND mic not denied). */
133
250
  get voiceAvailable() {
134
251
  return this.config.voice.isRecognitionAvailable() && !this.voiceBlocked;
@@ -147,6 +264,53 @@ class AgentController {
147
264
  this.voiceBlocked = blocked;
148
265
  this.emit();
149
266
  }
267
+ /**
268
+ * Page one more block of older session messages into the bubble (scroll-to-top).
269
+ * The mount is deferred behind a short loading state so old messages stream in
270
+ * on demand instead of all rendering at once (keeps the open bubble light).
271
+ */
272
+ loadOlderExchanges() {
273
+ if (this.loadingOlderExchanges)
274
+ return;
275
+ const win = (0, core_1.buildBubbleExchangeWindow)(this.conversation, this.lastExchange, this.bubbleWindowSize, this.conversationOptions);
276
+ if (!win.hasOlder)
277
+ return;
278
+ this.loadingOlderExchanges = true;
279
+ this.emit();
280
+ this.loadOlderTimer = setTimeout(() => {
281
+ this.loadOlderTimer = null;
282
+ this.bubbleWindowSize += core_1.BUBBLE_PAGE_EXCHANGES;
283
+ this.loadingOlderExchanges = false;
284
+ this.emit();
285
+ }, 350);
286
+ }
287
+ /**
288
+ * Apply the dashboard's context-retention setting (minutes; 0 = never clear,
289
+ * null/undefined = the 30-minute default). Called when the published config
290
+ * arrives/refreshes, so the developer's choice is live without a reload.
291
+ * `expandedMemory` = INTERNAL orb profile: use the expanded conversation
292
+ * memory window (more literal turns + longer rolling summary per request).
293
+ */
294
+ setConversationRetention(minutes, expandedMemory = false) {
295
+ this.conversationOptions = (0, core_1.conversationRetentionOptions)(minutes, expandedMemory);
296
+ this.conversationRetentionMinutes = typeof minutes === 'number' ? minutes : null;
297
+ const scope = expandedMemory ? 'internal' : 'public';
298
+ if (scope !== this.conversationScope) {
299
+ this.conversationScope = scope;
300
+ // The published profile arrived after the constructor's restore attempt:
301
+ // retry under the right storage key (guarded — never clobbers live turns).
302
+ void this.restorePersistedConversation();
303
+ }
304
+ }
305
+ /** Collapse the bubble history back to the initial page (bubble closed). */
306
+ resetBubbleWindow() {
307
+ if (this.loadOlderTimer) {
308
+ clearTimeout(this.loadOlderTimer);
309
+ this.loadOlderTimer = null;
310
+ }
311
+ this.loadingOlderExchanges = false;
312
+ this.bubbleWindowSize = core_1.BUBBLE_PAGE_EXCHANGES;
313
+ }
150
314
  subscribe(listener) {
151
315
  this.listeners.add(listener);
152
316
  return () => {
@@ -305,6 +469,11 @@ class AgentController {
305
469
  if (this.phase === 'confirming')
306
470
  this.setPhase('idle');
307
471
  this.emit();
472
+ // REAL activity: the confirmed action is executing right now.
473
+ this.setActivity({
474
+ kind: 'executing',
475
+ label: this.actionLabel(pending.intent) ?? pending.intent,
476
+ });
308
477
  const result = await this.executor.execute(pending.action, pending.context);
309
478
  this.telemetry.logEvent({
310
479
  eventType: result.success ? 'action_executed' : 'action_failed',
@@ -318,6 +487,39 @@ class AgentController {
318
487
  this.emit();
319
488
  await this.speakReply(result.message, null);
320
489
  }
490
+ // RESUME the autonomous chain if this sensitive action paused one — the
491
+ // human confirmation requirement is fully preserved: the chain only
492
+ // advances past a sensitive step AFTER the user confirmed and it executed.
493
+ const chainState = this.chainState;
494
+ if (chainState) {
495
+ this.chainState = null;
496
+ if (result.success && this.chaining.enabled) {
497
+ this.telemetry.logEvent({
498
+ eventType: 'agent_chain_step',
499
+ intentDetected: pending.intent,
500
+ sessionId: this.sessionId,
501
+ chainStep: chainState.step,
502
+ });
503
+ if (chainState.pendingKey)
504
+ chainState.visitedKeys.add(chainState.pendingKey);
505
+ await this.runChainLoop({
506
+ userMessage: chainState.userMessage,
507
+ step: chainState.step,
508
+ lastStep: chainState.pendingStep ?? chainState.lastStep,
509
+ visitedKeys: chainState.visitedKeys,
510
+ });
511
+ }
512
+ else if (!result.success) {
513
+ this.telemetry.logEvent({
514
+ eventType: 'agent_chain_aborted',
515
+ sessionId: this.sessionId,
516
+ chainAbortReason: 'error',
517
+ });
518
+ }
519
+ }
520
+ // The confirmed action (and any resumed chain) finished: clear the live row.
521
+ if (!this.pending)
522
+ this.setActivity(null);
321
523
  }
322
524
  cancelPendingAction() {
323
525
  this.clearConfirmWindow();
@@ -325,9 +527,20 @@ class AgentController {
325
527
  this.pending = null;
326
528
  this.confirmationMessage = null;
327
529
  this.confirmationVoiceHint = null;
530
+ this.setActivity(null);
328
531
  if (this.phase === 'confirming')
329
532
  this.setPhase('idle');
330
533
  this.emit();
534
+ // Declining a sensitive action stops any chain it was part of, cleanly.
535
+ const chainState = this.chainState;
536
+ this.chainState = null;
537
+ if (chainState) {
538
+ this.telemetry.logEvent({
539
+ eventType: 'agent_chain_aborted',
540
+ sessionId: this.sessionId,
541
+ chainAbortReason: 'user_interrupt',
542
+ });
543
+ }
331
544
  if (!pending)
332
545
  return;
333
546
  this.telemetry.logEvent({
@@ -371,6 +584,286 @@ class AgentController {
371
584
  this.beginConfirmationVoiceWindow();
372
585
  }
373
586
  }
587
+ routeLabel(intent) {
588
+ return this.config.manifest.routes.find((r) => r.intent === intent)?.label;
589
+ }
590
+ actionLabel(intent) {
591
+ return this.config.manifest.actions.find((a) => a.intent === intent)?.label;
592
+ }
593
+ /**
594
+ * Resolve ONE backend turn into user-facing reply/audio: run its action (or
595
+ * stage its confirmation), emit telemetry, and report whether the autonomous
596
+ * chain may take another step. Shared by the first turn and every
597
+ * continuation so both follow the exact same security path — disabled
598
+ * capabilities, confidentiality and sensitive confirmations apply per step.
599
+ */
600
+ async applyOutcome(turn, routeAtSend, snapshotContext, userMessage) {
601
+ let outReply = turn.reply;
602
+ let outAudio = turn.audioBase64;
603
+ if (outReply)
604
+ this.serverTtsUnavailable = !outAudio;
605
+ const decision = await (0, turnEngine_1.decideAction)({
606
+ action: turn.action,
607
+ manifest: this.config.manifest,
608
+ executor: this.executor,
609
+ context: snapshotContext,
610
+ messages: this.messages,
611
+ userMessage,
612
+ });
613
+ let canContinue = false;
614
+ let lastStep;
615
+ let actionKey;
616
+ let hasPending = false;
617
+ let pendingInProgress = false;
618
+ let pendingStep;
619
+ let pendingKey;
620
+ if (decision.kind === 'executed') {
621
+ const r = decision.result;
622
+ // REAL activity for the panel: this action actually just ran — the
623
+ // label comes from the manifest, never invented.
624
+ if (r.success && turn.action?.type === 'navigate') {
625
+ this.setActivity({
626
+ kind: 'navigating',
627
+ label: this.routeLabel(turn.action.intent ?? '') ?? turn.action.intent ?? '',
628
+ });
629
+ }
630
+ else if (r.success && !r.recoverable && turn.action?.type === 'execute') {
631
+ this.setActivity({
632
+ kind: 'executing',
633
+ label: this.actionLabel(turn.action.intent ?? '') ?? turn.action.intent ?? '',
634
+ });
635
+ }
636
+ if (turn.action?.type === 'navigate') {
637
+ this.telemetry.logEvent({
638
+ eventType: r.success ? 'navigation_executed' : 'navigation_failed',
639
+ intentDetected: turn.action.intent,
640
+ sessionId: this.sessionId,
641
+ currentRoute: routeAtSend ?? undefined,
642
+ });
643
+ }
644
+ else if (turn.action?.type === 'execute') {
645
+ this.telemetry.logEvent({
646
+ eventType: r.recoverable
647
+ ? 'action_needs_context'
648
+ : r.success
649
+ ? 'action_executed'
650
+ : 'action_failed',
651
+ intentDetected: turn.action.intent,
652
+ sessionId: this.sessionId,
653
+ actionExecuted: { intent: turn.action.intent, confirmed: false },
654
+ });
655
+ }
656
+ if (r.recoverable) {
657
+ /* keep the assistant's guiding reply; never speak a refusal. */
658
+ }
659
+ else if (r.message) {
660
+ outReply = r.message;
661
+ outAudio = null;
662
+ }
663
+ // Chain signal: only a cleanly EXECUTED, non-recoverable step may advance,
664
+ // and only when the backend asked to continue (task_status=in_progress).
665
+ if (turn.action &&
666
+ (turn.action.type === 'navigate' || turn.action.type === 'execute') &&
667
+ r.success &&
668
+ !r.recoverable) {
669
+ const intent = turn.action.intent ?? '';
670
+ lastStep = {
671
+ type: turn.action.type,
672
+ intent,
673
+ label: turn.action.type === 'navigate' ? this.routeLabel(intent) : this.actionLabel(intent),
674
+ success: true,
675
+ };
676
+ actionKey = `${turn.action.type}:${intent}`;
677
+ canContinue = turn.taskStatus === 'in_progress';
678
+ }
679
+ }
680
+ else if (decision.kind === 'precheck-resolved') {
681
+ if (decision.result.message && !decision.result.recoverable) {
682
+ outReply = decision.result.message;
683
+ outAudio = null;
684
+ }
685
+ }
686
+ else if (decision.kind === 'needs-confirmation') {
687
+ // A sensitive action ALWAYS pauses (chaining or not): stage the
688
+ // confirmation and stop advancing. Resumes after the user confirms.
689
+ this.pending = { ...decision.pending, silent: this.silentTurn };
690
+ this.confirmationReasked = false;
691
+ this.confirmationMessage = decision.pending.question;
692
+ this.confirmationVoiceHint =
693
+ decision.pending.voiceMode === 'strict' &&
694
+ decision.pending.strictPhrase &&
695
+ !this.silentTurn
696
+ ? (0, core_1.formatMessage)(this.messages.strictConfirmationModalHint, {
697
+ phrase: decision.pending.strictPhrase,
698
+ })
699
+ : null;
700
+ this.emit();
701
+ this.telemetry.logEvent({
702
+ eventType: 'action_confirmation_requested',
703
+ intentDetected: decision.pending.intent,
704
+ sessionId: this.sessionId,
705
+ });
706
+ // REAL activity: the agent is genuinely waiting for the human now.
707
+ this.setActivity({ kind: 'waiting_confirmation' });
708
+ hasPending = true;
709
+ pendingInProgress = turn.taskStatus === 'in_progress';
710
+ const pIntent = decision.pending.intent || '';
711
+ pendingStep = { type: 'execute', intent: pIntent, label: this.actionLabel(pIntent), success: true };
712
+ pendingKey = `execute:${pIntent}`;
713
+ }
714
+ return {
715
+ reply: outReply,
716
+ audioBase64: outAudio,
717
+ hasPending,
718
+ canContinue,
719
+ lastStep,
720
+ actionKey,
721
+ pendingInProgress,
722
+ pendingStep,
723
+ pendingKey,
724
+ };
725
+ }
726
+ /**
727
+ * The autonomous continuation loop. Extracted so confirmPendingAction can
728
+ * RESUME the SAME chain after a sensitive step is confirmed. Capped at
729
+ * maxSteps, with no-progress detection and sensitive-confirmation pauses.
730
+ */
731
+ async runChainLoop(state) {
732
+ let step = state.step;
733
+ let lastStep = state.lastStep;
734
+ const visitedKeys = state.visitedKeys;
735
+ // Keep the panel's live row visible for the WHOLE chain (including the
736
+ // dwell between steps), so the real narration never flickers away.
737
+ this.setChainActive(true);
738
+ try {
739
+ while (step < this.chaining.maxSteps) {
740
+ step += 1;
741
+ if (this.chaining.dwellMs > 0)
742
+ await sleep(this.chaining.dwellMs);
743
+ if (this.pending != null)
744
+ return; // paused / interrupted elsewhere
745
+ const routeNow = this.config.navigation.getCurrentRoute();
746
+ const snap = this.screenStore.getSnapshot();
747
+ const rawCtx = (0, core_1.combineContexts)(this.config.getUserContextText?.() ?? null, this.screenStore.getText(), {
748
+ truncationSuffix: this.messages.contextTruncationSuffix,
749
+ maxChars: this.config.contextMaxChars,
750
+ });
751
+ const san = rawCtx ? this.sanitizer(rawCtx).sanitized : undefined;
752
+ const conv = (0, core_1.prepareConversationForTurn)(this.conversation, Date.now(), this.conversationOptions);
753
+ this.setPhase('thinking');
754
+ // Honest state: the model is choosing the next step right now.
755
+ this.setActivity({ kind: 'thinking' });
756
+ const controller = new AbortController();
757
+ this.abort = controller;
758
+ let oc;
759
+ try {
760
+ const turn = await this.config.backend.sendTurn({
761
+ message: state.userMessage,
762
+ language: this.config.locale,
763
+ voice: this.getTtsVoice(),
764
+ screenContext: san,
765
+ currentRoute: routeNow ?? undefined,
766
+ conversationHistory: conv.history,
767
+ conversationSummary: conv.summary ?? undefined,
768
+ sessionId: this.sessionId,
769
+ isContinuation: true,
770
+ chainStep: step,
771
+ lastStep,
772
+ ...(0, backendClient_1.buildManifestPayload)(this.config.manifest),
773
+ }, { signal: controller.signal });
774
+ oc = await this.applyOutcome(turn, routeNow, snap?.context ?? {}, state.userMessage);
775
+ }
776
+ catch (e) {
777
+ this.abort = null;
778
+ const interrupted = e instanceof errors_1.AgentApiError && e.code === 'cancelled';
779
+ if (interrupted) {
780
+ if (this.phase === 'thinking')
781
+ this.setPhase('idle');
782
+ }
783
+ else {
784
+ if (e instanceof errors_1.AgentApiError && e.code === 'quota_exceeded') {
785
+ this.config.onQuotaExceeded?.();
786
+ }
787
+ const errMsg = (0, formatTurnError_1.formatAgentTurnError)(e, this.config.locale);
788
+ this.lastExchange = { userText: state.userMessage, replyText: errMsg };
789
+ if (this.phase === 'thinking')
790
+ this.setPhase('idle');
791
+ this.emit();
792
+ this.notifyError(errMsg);
793
+ }
794
+ this.telemetry.logEvent({
795
+ eventType: 'agent_chain_aborted',
796
+ sessionId: this.sessionId,
797
+ chainStep: step,
798
+ chainAbortReason: interrupted ? 'user_interrupt' : 'error',
799
+ });
800
+ return;
801
+ }
802
+ this.abort = null;
803
+ if (oc.reply) {
804
+ this.lastExchange = { userText: state.userMessage, replyText: oc.reply };
805
+ this.emit();
806
+ (0, core_1.recordConversationExchange)(this.conversation, state.userMessage, oc.reply, Date.now(), this.conversationOptions);
807
+ this.persistConversation();
808
+ }
809
+ await this.speakReply(oc.reply, oc.audioBase64);
810
+ this.telemetry.logEvent({
811
+ eventType: 'agent_chain_step',
812
+ intentDetected: oc.lastStep?.intent ?? oc.pendingStep?.intent ?? null,
813
+ sessionId: this.sessionId,
814
+ chainStep: step,
815
+ });
816
+ // Sensitive action → ALWAYS pause for confirmation. Park the chain so
817
+ // confirmPendingAction can resume it (only if the model wants to keep
818
+ // going); a rejection stops it cleanly.
819
+ if (oc.hasPending) {
820
+ this.chainState = oc.pendingInProgress
821
+ ? {
822
+ userMessage: state.userMessage,
823
+ step,
824
+ lastStep,
825
+ visitedKeys,
826
+ pendingKey: oc.pendingKey,
827
+ pendingStep: oc.pendingStep,
828
+ }
829
+ : null;
830
+ this.beginConfirmationVoiceWindow();
831
+ return;
832
+ }
833
+ if (this.phase !== 'speaking')
834
+ this.setPhase('idle');
835
+ if (oc.actionKey && visitedKeys.has(oc.actionKey)) {
836
+ this.telemetry.logEvent({
837
+ eventType: 'agent_chain_aborted',
838
+ sessionId: this.sessionId,
839
+ chainStep: step,
840
+ chainAbortReason: 'no_progress',
841
+ });
842
+ return;
843
+ }
844
+ if (oc.actionKey)
845
+ visitedKeys.add(oc.actionKey);
846
+ if (!oc.canContinue) {
847
+ this.telemetry.logEvent({
848
+ eventType: 'agent_chain_completed',
849
+ sessionId: this.sessionId,
850
+ chainStep: step,
851
+ });
852
+ return;
853
+ }
854
+ lastStep = oc.lastStep;
855
+ }
856
+ this.telemetry.logEvent({
857
+ eventType: 'agent_chain_aborted',
858
+ sessionId: this.sessionId,
859
+ chainStep: step,
860
+ chainAbortReason: 'max_steps',
861
+ });
862
+ }
863
+ finally {
864
+ this.setChainActive(false);
865
+ }
866
+ }
374
867
  // ── Main turn ────────────────────────────────────────────────────────────────
375
868
  async handleTranscript(rawText) {
376
869
  const text = rawText.trim();
@@ -379,7 +872,12 @@ class AgentController {
379
872
  this.setPhase('idle');
380
873
  return;
381
874
  }
875
+ // Identity gate: if the host user changed since the last turn (login,
876
+ // logout, account switch), swap conversations BEFORE recording anything.
877
+ this.syncConversationIdentity();
382
878
  this.setPhase('thinking');
879
+ // REAL activity: the request is genuinely in flight from this moment.
880
+ this.setActivity({ kind: 'thinking' });
383
881
  this.pendingTurnText = text;
384
882
  this.lastExchange = { userText: text, replyText: '' };
385
883
  this.emit();
@@ -393,6 +891,7 @@ class AgentController {
393
891
  : 'The response took too long. Please try again.';
394
892
  this.lastExchange = { userText, replyText };
395
893
  this.pendingTurnText = null;
894
+ this.setActivity(null);
396
895
  this.setPhase('idle');
397
896
  this.emit();
398
897
  }
@@ -413,11 +912,10 @@ class AgentController {
413
912
  maxChars: this.config.contextMaxChars,
414
913
  });
415
914
  const sanitized = rawContext ? this.sanitizer(rawContext).sanitized : undefined;
416
- const conv = (0, core_1.prepareConversationForTurn)(this.conversation);
915
+ const conv = (0, core_1.prepareConversationForTurn)(this.conversation, Date.now(), this.conversationOptions);
417
916
  const controller = new AbortController();
418
917
  this.abort = controller;
419
- let reply = '';
420
- let audioBase64 = null;
918
+ let outcome = null;
421
919
  try {
422
920
  const turn = await this.config.backend.sendTurn({
423
921
  message: text,
@@ -430,79 +928,12 @@ class AgentController {
430
928
  sessionId: this.sessionId,
431
929
  ...(0, backendClient_1.buildManifestPayload)(this.config.manifest),
432
930
  }, { signal: controller.signal });
433
- reply = turn.reply;
434
- audioBase64 = turn.audioBase64;
435
- // Learn whether this backend ships server-side TTS: a non-empty reply
436
- // with no audio means it doesn't — later lines then speak instantly.
437
- if (reply)
438
- this.serverTtsUnavailable = !audioBase64;
439
- const decision = await (0, turnEngine_1.decideAction)({
440
- action: turn.action,
441
- manifest: this.config.manifest,
442
- executor: this.executor,
443
- context: snapshot?.context ?? {},
444
- messages: this.messages,
445
- userMessage: text,
446
- });
447
- if (decision.kind === 'executed') {
448
- const r = decision.result;
449
- if (turn.action?.type === 'navigate') {
450
- this.telemetry.logEvent({
451
- eventType: r.success ? 'navigation_executed' : 'navigation_failed',
452
- intentDetected: turn.action.intent,
453
- sessionId: this.sessionId,
454
- currentRoute,
455
- });
456
- }
457
- else if (turn.action?.type === 'execute') {
458
- this.telemetry.logEvent({
459
- eventType: r.recoverable
460
- ? 'action_needs_context'
461
- : r.success
462
- ? 'action_executed'
463
- : 'action_failed',
464
- intentDetected: turn.action.intent,
465
- sessionId: this.sessionId,
466
- actionExecuted: { intent: turn.action.intent, confirmed: false },
467
- });
468
- }
469
- if (r.recoverable) {
470
- /* keep the assistant's guiding reply; never speak a refusal. */
471
- }
472
- else if (r.message) {
473
- reply = r.message;
474
- audioBase64 = null;
475
- }
476
- }
477
- else if (decision.kind === 'precheck-resolved') {
478
- if (decision.result.message && !decision.result.recoverable) {
479
- reply = decision.result.message;
480
- audioBase64 = null;
481
- }
482
- }
483
- else if (decision.kind === 'needs-confirmation') {
484
- this.pending = { ...decision.pending, silent: this.silentTurn };
485
- this.confirmationReasked = false;
486
- this.confirmationMessage = decision.pending.question;
487
- this.confirmationVoiceHint =
488
- decision.pending.voiceMode === 'strict' &&
489
- decision.pending.strictPhrase &&
490
- !this.silentTurn
491
- ? (0, core_1.formatMessage)(this.messages.strictConfirmationModalHint, {
492
- phrase: decision.pending.strictPhrase,
493
- })
494
- : null;
495
- this.emit();
496
- this.telemetry.logEvent({
497
- eventType: 'action_confirmation_requested',
498
- intentDetected: decision.pending.intent,
499
- sessionId: this.sessionId,
500
- });
501
- }
931
+ outcome = await this.applyOutcome(turn, currentRoute, snapshot?.context ?? {}, text);
502
932
  }
503
933
  catch (e) {
504
934
  this.clearWatchdog();
505
935
  this.abort = null;
936
+ this.setActivity(null);
506
937
  if (e instanceof errors_1.AgentApiError && e.code === 'cancelled') {
507
938
  if (this.phase === 'thinking')
508
939
  this.setPhase('idle');
@@ -524,10 +955,14 @@ class AgentController {
524
955
  this.clearWatchdog();
525
956
  this.abort = null;
526
957
  this.pendingTurnText = null;
958
+ if (!outcome)
959
+ return;
960
+ const reply = outcome.reply;
527
961
  if (reply) {
528
962
  this.lastExchange = { userText: text, replyText: reply };
529
963
  this.emit();
530
- (0, core_1.recordConversationExchange)(this.conversation, text, reply);
964
+ (0, core_1.recordConversationExchange)(this.conversation, text, reply, Date.now(), this.conversationOptions);
965
+ this.persistConversation();
531
966
  }
532
967
  else {
533
968
  const fallback = this.config.locale.startsWith('es')
@@ -536,14 +971,53 @@ class AgentController {
536
971
  this.lastExchange = { userText: text, replyText: fallback };
537
972
  this.emit();
538
973
  }
539
- const hasPending = this.pending != null;
540
- await this.speakReply(reply, audioBase64);
974
+ const hasPending = outcome.hasPending;
975
+ await this.speakReply(reply, outcome.audioBase64);
541
976
  if (hasPending) {
542
977
  this.beginConfirmationVoiceWindow();
978
+ // First step is a sensitive action AND the model wants to keep going:
979
+ // park the chain so it RESUMES after the user confirms.
980
+ if (this.chaining.enabled && outcome.pendingInProgress) {
981
+ this.telemetry.logEvent({
982
+ eventType: 'agent_chain_started',
983
+ sessionId: this.sessionId,
984
+ chainStep: 1,
985
+ });
986
+ this.chainState = {
987
+ userMessage: text,
988
+ step: 1,
989
+ lastStep: outcome.pendingStep,
990
+ visitedKeys: new Set(),
991
+ pendingKey: outcome.pendingKey,
992
+ pendingStep: outcome.pendingStep,
993
+ };
994
+ }
543
995
  }
544
996
  else if (this.phase !== 'speaking') {
545
997
  this.setPhase('idle');
546
998
  }
999
+ // Kick off the autonomous chain when the first step executed cleanly and
1000
+ // the backend asked to continue (no sensitive confirmation pending).
1001
+ if (this.chaining.enabled && outcome.canContinue && !hasPending) {
1002
+ this.telemetry.logEvent({
1003
+ eventType: 'agent_chain_started',
1004
+ sessionId: this.sessionId,
1005
+ chainStep: 1,
1006
+ });
1007
+ const visitedKeys = new Set();
1008
+ if (outcome.actionKey)
1009
+ visitedKeys.add(outcome.actionKey);
1010
+ await this.runChainLoop({
1011
+ userMessage: text,
1012
+ step: 1,
1013
+ lastStep: outcome.lastStep,
1014
+ visitedKeys,
1015
+ });
1016
+ }
1017
+ // Final reply is on screen: the live narrative disappears (unless the
1018
+ // agent is genuinely waiting for a confirmation, which stays honest).
1019
+ if (!this.pending)
1020
+ this.setActivity(null);
547
1021
  this.config.onTurnCompleted?.();
548
1022
  }
549
1023
  // ── Speech session wiring ────────────────────────────────────────────────────
@@ -656,6 +1130,11 @@ class AgentController {
656
1130
  this.emit();
657
1131
  this.telemetry.logEvent({ eventType: 'agent_consent_declined', sessionId: this.sessionId });
658
1132
  }
1133
+ /**
1134
+ * Cancel everything in flight. If a first turn was still awaiting its reply,
1135
+ * its message is removed from the panel and returned here so the UI can put
1136
+ * it back into the input for editing; null otherwise.
1137
+ */
659
1138
  cancelAll() {
660
1139
  this.abort?.abort();
661
1140
  this.abort = null;
@@ -665,10 +1144,22 @@ class AgentController {
665
1144
  this.speech.cancel();
666
1145
  this.config.voice.stopPlayback();
667
1146
  this.pending = null;
1147
+ this.chainState = null;
668
1148
  this.confirmationMessage = null;
669
1149
  this.confirmationVoiceHint = null;
1150
+ this.liveActivity = null;
1151
+ this.chainActive = false;
1152
+ // A first turn was still in flight: remove its message from the panel and
1153
+ // hand the text back for editing (the turn never completed, so nothing
1154
+ // was recorded in the conversation).
1155
+ const draft = this.pendingTurnText;
1156
+ if (draft) {
1157
+ this.pendingTurnText = null;
1158
+ this.lastExchange = null;
1159
+ }
670
1160
  this.setPhase('idle');
671
1161
  this.emit();
1162
+ return draft;
672
1163
  }
673
1164
  /** Best-effort backend warm-up (cold-start mitigation). */
674
1165
  warmUp() {
@@ -676,6 +1167,12 @@ class AgentController {
676
1167
  }
677
1168
  dispose() {
678
1169
  this.cancelAll();
1170
+ this.removeIdentityListeners?.();
1171
+ this.removeIdentityListeners = null;
1172
+ if (this.loadOlderTimer) {
1173
+ clearTimeout(this.loadOlderTimer);
1174
+ this.loadOlderTimer = null;
1175
+ }
679
1176
  this.unsubscribeSpeech?.();
680
1177
  this.unsubscribeSpeech = null;
681
1178
  this.speech.dispose();