@livx.cc/agentx 0.99.57 → 0.99.59

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.
package/dist/index.js CHANGED
@@ -3391,6 +3391,7 @@ function resolveModelAlias(input) {
3391
3391
  const slash = input.indexOf("/");
3392
3392
  const prefix = slash === -1 ? "" : input.slice(0, slash + 1);
3393
3393
  const rest = slash === -1 ? input : input.slice(slash + 1);
3394
+ if (prefix === "claude-code/") return input;
3394
3395
  return prefix + (MODEL_ALIASES[rest.toLowerCase()] ?? rest);
3395
3396
  }
3396
3397
  function modelProvider(model) {
@@ -6565,6 +6566,130 @@ var EmotionStream = class {
6565
6566
  }
6566
6567
  };
6567
6568
 
6569
+ // node_modules/@bod.ee/voice/src/core/endpointing.ts
6570
+ var INCOMPLETE_ENDINGS = /* @__PURE__ */ new Set([
6571
+ // Conjunctions
6572
+ "and",
6573
+ "but",
6574
+ "or",
6575
+ "so",
6576
+ "because",
6577
+ "although",
6578
+ "though",
6579
+ "while",
6580
+ "since",
6581
+ "unless",
6582
+ "if",
6583
+ "when",
6584
+ "where",
6585
+ // Prepositions
6586
+ "to",
6587
+ "for",
6588
+ "with",
6589
+ "about",
6590
+ "of",
6591
+ "at",
6592
+ "in",
6593
+ "on",
6594
+ "from",
6595
+ "by",
6596
+ "into",
6597
+ "through",
6598
+ // Articles / determiners
6599
+ "the",
6600
+ "a",
6601
+ "an",
6602
+ "this",
6603
+ "that",
6604
+ "these",
6605
+ "those",
6606
+ "my",
6607
+ "your",
6608
+ "his",
6609
+ "her",
6610
+ "our",
6611
+ "their",
6612
+ // Bare pronouns ("…as I asked it" — the live case above)
6613
+ "i",
6614
+ "you",
6615
+ "we",
6616
+ "they",
6617
+ "he",
6618
+ "she",
6619
+ "it",
6620
+ // Fillers (thinking)
6621
+ "um",
6622
+ "uh",
6623
+ "like",
6624
+ "well"
6625
+ ]);
6626
+ var CONTINUATION_MARKERS = [
6627
+ "you know",
6628
+ "i mean",
6629
+ "the thing is",
6630
+ "let me think",
6631
+ "first of all",
6632
+ "on one hand",
6633
+ "for example"
6634
+ ];
6635
+ var COMPLETE_ENDINGS = /* @__PURE__ */ new Set([
6636
+ "yeah",
6637
+ "yes",
6638
+ "no",
6639
+ "nah",
6640
+ "okay",
6641
+ "ok",
6642
+ "sure",
6643
+ "right",
6644
+ "thanks",
6645
+ "bye",
6646
+ "absolutely",
6647
+ "definitely",
6648
+ "exactly",
6649
+ "correct",
6650
+ "true",
6651
+ "false"
6652
+ ]);
6653
+ function isTranscriptComplete(text) {
6654
+ const trimmed = text.trim();
6655
+ if (!trimmed) return false;
6656
+ if (/[.?!]$/.test(trimmed)) return true;
6657
+ const words = trimmed.toLowerCase().split(/\s+/);
6658
+ const lastWord = words[words.length - 1];
6659
+ if (COMPLETE_ENDINGS.has(lastWord)) return true;
6660
+ if (INCOMPLETE_ENDINGS.has(lastWord)) return false;
6661
+ const lower = trimmed.toLowerCase();
6662
+ for (const marker of CONTINUATION_MARKERS) if (lower.endsWith(marker)) return false;
6663
+ if (words.length <= 2) return false;
6664
+ return true;
6665
+ }
6666
+ var MAX_HOLD_MS = 2e3;
6667
+ var MIN_MERGE_HOLD_MS = 800;
6668
+ function endpointTier(transcript) {
6669
+ const t = transcript.trim();
6670
+ if (!isTranscriptComplete(t)) return 1;
6671
+ return /[.?!]$/.test(t) ? 3 : 2;
6672
+ }
6673
+ function tierHoldMs(tier, ctx) {
6674
+ if (tier === 1) return ctx.incompleteMs;
6675
+ if (tier === 2) return Math.round((ctx.baseMs + ctx.incompleteMs) / 2);
6676
+ return ctx.baseMs;
6677
+ }
6678
+ function computeHoldMs(ctx) {
6679
+ const t = ctx.transcript.trim();
6680
+ const tier = endpointTier(t);
6681
+ let ms = tierHoldMs(tier, ctx);
6682
+ const ai = ctx.lastAiMessage?.trim();
6683
+ if (ai) {
6684
+ if (/\?$/.test(ai)) ms -= 300;
6685
+ if (ai.split(/\s+/).length > 60) ms += 400;
6686
+ }
6687
+ const lastWord = t.toLowerCase().split(/\s+/).pop();
6688
+ if (lastWord === "um" || lastWord === "uh" || lastWord === "hmm") ms += 500;
6689
+ const floor = tier === 3 ? ctx.baseMs : Math.max(ctx.baseMs, Math.min(tierHoldMs(tier, ctx), MIN_MERGE_HOLD_MS));
6690
+ return Math.max(floor, Math.min(MAX_HOLD_MS, ms));
6691
+ }
6692
+
6568
6693
  // node_modules/@bod.ee/voice/src/core/engine.ts
6569
6694
  var log13 = forComponent2("VoiceEngine");
6570
6695
  var realClock = {
@@ -6573,7 +6698,7 @@ var realClock = {
6573
6698
  clearTimeout: (h) => clearTimeout(h)
6574
6699
  };
6575
6700
  var forSpeech = (t) => t.replace(/[*`#]+/g, "").replace(/(?<![\p{L}\p{N}])_([^_\n]+)_(?![\p{L}\p{N}])/gu, "$1").replace(/^[ \t]*[-•]\s+/gm, "").replace(/\s*[\u2013\u2014]\s*/g, ", ").replace(/[\u2010\u2011]/g, "-").replace(/\s*\|\s*/g, ", ").replace(/(\d)\s+%/g, "$1%").replace(/\.{3,}/g, ".");
6576
- var normWord = (w) => w.toLowerCase().replace(/[^a-z0-9]/g, "");
6701
+ var normWord = (w) => w.toLowerCase().replace(/[^\p{L}\p{M}\p{N}]/gu, "");
6577
6702
  var SPEC_TRAILING_OK = /* @__PURE__ */ new Set(["please", "thanks", "thank", "you", "now", "okay", "ok", "kindly", "alright", "then", "though", "right", "yeah"]);
6578
6703
  function speculationConfirms(spec, final) {
6579
6704
  const sw = spec.trim().split(/\s+/).map(normWord).filter(Boolean);
@@ -6584,11 +6709,27 @@ function speculationConfirms(spec, final) {
6584
6709
  for (let i = sw.length; i < fw.length; i++) if (!SPEC_TRAILING_OK.has(fw[i])) return false;
6585
6710
  return true;
6586
6711
  }
6712
+ function heardPrefix(text, spoken, lookahead = 4) {
6713
+ const toks = [...text.matchAll(/\S+/g)];
6714
+ let i = 0, at = 0;
6715
+ for (const w of spoken) {
6716
+ const nw = normWord(w);
6717
+ if (!nw) continue;
6718
+ for (let k = i; k < Math.min(toks.length, i + lookahead); k++)
6719
+ if (normWord(toks[k][0]) === nw) {
6720
+ at = toks[k].index;
6721
+ i = k + 1;
6722
+ break;
6723
+ }
6724
+ }
6725
+ return at;
6726
+ }
6587
6727
  var VoiceEngineOptions = class {
6588
6728
  stt;
6589
6729
  tts;
6590
6730
  player;
6591
- /** a final utterance arrived (endpoint) — host dispatches it as a turn */
6731
+ /** a final utterance arrived (endpoint) — host dispatches it as a turn. `meta.supersedes` marks a fuller
6732
+ * re-finalization of the utterance dispatched just before it: replace that turn, don't add one. */
6592
6733
  onUtterance = () => {
6593
6734
  };
6594
6735
  /** live partial transcript while listening (host renders the 🎤 line) */
@@ -6596,6 +6737,14 @@ var VoiceEngineOptions = class {
6596
6737
  };
6597
6738
  onState = () => {
6598
6739
  };
6740
+ /** The assistant's spoken turn finished AUDIBLY — every TTS chunk arrived AND playback drained.
6741
+ * Fires from the same `settle()` that returns the engine to 'listening', just before that state
6742
+ * change. Distinct from the host's `endSpeech()`, which only closes the TEXT stream (audio is
6743
+ * still playing at that point). NOT fired for a turn cut by barge-in — the host gets `onBargeIn`
6744
+ * instead. Also fires for engine-internal speech (adaptive ack, filler, queued utterances), so a
6745
+ * host that only cares about ITS turns should gate on its own open-turn flag. Default no-op. */
6746
+ onSpeechEnd = () => {
6747
+ };
6599
6748
  /** user spoke/acted over playback — host aborts the in-flight turn (called AFTER audio is killed).
6600
6749
  * phase: 'speaking' = cut mid-speech (real interruption); 'drain' = in the final audio tail
6601
6750
  * (normal turn-taking — hosts shouldn't alarm). */
@@ -6610,6 +6759,14 @@ var VoiceEngineOptions = class {
6610
6759
  /** the adaptive ack actually fired (host can mark the turn as spoken — e.g. suppress dead-air repair) */
6611
6760
  onAdaptiveAck = () => {
6612
6761
  };
6762
+ /** HOST-CHOSEN adaptive ack: asked once per armed ack (at beginSpeech(true), i.e. at dispatch) so a slow pick
6763
+ * runs in parallel with the reflex. Return the phrase to speak, or `null` = stay silent this turn. A Promise
6764
+ * still pending when the ack is due gets up to another `adaptiveAckMs` (then the turn stays silent — a wrong
6765
+ * ack is worse than none); a rejection falls back to the default pools. Unset (null) = the engine's own
6766
+ * shape-picked pools (ACKS_NEUTRAL / ACKS_QUESTION). Phrases enter the echo set like the defaults — keep them
6767
+ * short and not a lone word users say on their own ("okay", "yeah"): the ack-squash guard drops a user
6768
+ * utterance that equals the last ack. */
6769
+ pickAck = null;
6613
6770
  /** Endpoint merge window (ms): hold an endpointed utterance briefly — if speech resumes (spelled
6614
6771
  * letters, mid-thought pauses), the next utterance MERGES instead of dispatching a truncated one
6615
6772
  * ("E-L-Y." / "A."). Costs this much latency per turn; 0 disables. */
@@ -6617,6 +6774,21 @@ var VoiceEngineOptions = class {
6617
6774
  /** Extended merge window (ms) for utterances that look incomplete (trailing conjunction/filler).
6618
6775
  * Gives the user time to finish their thought without triggering a model call. */
6619
6776
  incompleteMergeMs = 1500;
6777
+ /** ADAPTIVE endpoint hold (opt-in; default OFF = today's byte-identical two-tier behavior).
6778
+ * The fixed merge tiers (`utteranceMergeMs` vs `incompleteMergeMs`, chosen by a trailing-word
6779
+ * regex, and skipped entirely at ≥4 words) dispatch mid-thought FRAGMENTS as their own turn: live,
6780
+ * "…give me the best" dispatched, got an empty reply, and "…give me the best of it." arrived 766ms
6781
+ * later as a second turn. When true, every endpointed utterance is held for
6782
+ * `computeHoldMs(...)` — sized by linguistic completeness, whether the STT emitted a sentence-final
6783
+ * mark, and what the assistant last said — so the continuation MERGES instead of burning a turn.
6784
+ * COST — this is strictly slower than the default path, never faster: today every ≥4-word utterance
6785
+ * dispatches at 0ms (the fast path below), so adaptive ADDS the whole hold — ~350ms to every
6786
+ * sentence-final utterance, ~925ms to every complete-looking unpunctuated one (the common case),
6787
+ * and up to 2s on a mid-thought fragment. Paid on turns that didn't need it, to stop burning a turn
6788
+ * (empty reply + blank bubble) on the ones that did — hence opt-in. Also changes `onHold` timing:
6789
+ * it now fires on every tier-2/3 hold, not only on `looksIncomplete` (`holdFiller` stays tier-1
6790
+ * only). See core/endpointing.ts. */
6791
+ adaptiveEndpointing = false;
6620
6792
  /** Grace window (ms) after an utterance dispatches, during which the user's own trailing audio cannot
6621
6793
  * barge the reply it requested. Soniox keeps finalizing partials past <end>; without this they read
6622
6794
  * as a barge and abort the fresh turn (live: mid-sentence self-interruption + steps=1→steps=0 double
@@ -6627,6 +6799,15 @@ var VoiceEngineOptions = class {
6627
6799
  * while audible (speaking + drain tail), so echo can never become a phantom turn — the right mode
6628
6800
  * when there's no AEC (e.g. the non-VPIO mic fallback) and no headphones. Cost: can't interrupt. */
6629
6801
  bargeIn = true;
6802
+ /** Which barge-in TIER to run — the host's override of the mic's self-report.
6803
+ * - `'auto'` (default) — trust `stt.usingAec`: AEC tier when the mic reports echo cancellation
6804
+ * applied, heuristic/energy tier otherwise. Unchanged legacy behavior.
6805
+ * - `'aec'` — force the AEC tier (STT stays live through TTS; barge decided by the dominant-novel
6806
+ * gate + overlap pause) even if the mic reports no echo cancellation.
6807
+ * - `'energy'` — force the heuristic tier (echo-baseline EMA spike arms `suspectUntil`, novel-word
6808
+ * count decides; STT buffer reset after our own audio) even if the mic REPORTS echoCancellation.
6809
+ * Mobile Safari reports `true` while cancelling poorly — that report is what this overrides. */
6810
+ bargeInMode = "auto";
6630
6811
  /** Filler phrase spoken when holding for an incomplete utterance ('' disables). */
6631
6812
  holdFiller = "";
6632
6813
  /** Called when the engine holds an incomplete utterance (host can render a visual cue). */
@@ -6650,6 +6831,15 @@ var VoiceEngineOptions = class {
6650
6831
  * Mechanism-based discriminator: a re-PAUSE this soon after a resume = a persistent human, not an
6651
6832
  * echo blip (which pauses once and stalls). Cede on the re-pause regardless of the novel gate. */
6652
6833
  overlapRepauseCedeMs = 1500;
6834
+ /** HOST-DECIDED short overlaps (opt-in; 0 = off = cede on ≥2 genuine words, as always). The fast "≥2 novel words →
6835
+ * cede" rule cannot tell a reaction ("Oh, that's new") from a barge, and it fires ~300ms before a host-side judge
6836
+ * (e.g. an AI instinct) can answer — so a judge could never keep a delivery going (live 20260923-125143: ceded on
6837
+ * "Oh, that", the judge said keep_talking 300ms later). When > 0, a SHORT genuine overlap (≤ overlapDecideMaxWords,
6838
+ * no '?', no stop/question word) keeps the pause and waits for `decideOverlap()`; with no decision after this many
6839
+ * ms it cedes. Long or obvious barges still cede instantly. Audio is already paused, so waiting costs no talk-over. */
6840
+ overlapDecideMs = 0;
6841
+ /** Longest overlap (words) the host decides; also how far an overlap the host kept may grow before it cedes. */
6842
+ overlapDecideMaxWords = 4;
6653
6843
  /** Speculative ENERGY pre-pause while speaking (AEC tier): two residue gate-passes within 350ms →
6654
6844
  * pause ~300ms before the STT tokens land. But energy CANNOT separate residue bursts from speech
6655
6845
  * (the documented whack-a-mole) — so a residue spike during loud playback false-pauses with NO user
@@ -6702,6 +6892,10 @@ var VoiceEngineOptions = class {
6702
6892
  * timestamp-emitting TTS (CartesiaTTS.wantTimestamps) driven over `AudioSink.playedMs`.
6703
6893
  */
6704
6894
  revealMode = "off";
6895
+ /** Locate EXACTLY what the user heard when a barge cuts speech: request word timestamps from the TTS (when it
6896
+ * supports them) and match them against the playback clock (InterruptedReply `heard`/`exact`). Independent of
6897
+ * `revealMode` — no reveal events. Without timestamps the cut falls back to the ~15 chars/s estimate. */
6898
+ heardTimestamps = true;
6705
6899
  /** Called with the cumulative revealed text for the current assistant turn (see `revealMode`).
6706
6900
  * Reset to '' at the start of each spoken turn. */
6707
6901
  onReveal = () => {
@@ -6732,8 +6926,27 @@ var VoiceEngine = class _VoiceEngine {
6732
6926
  // cumulative revealed (on-screen) text for the current turn — see revealMode
6733
6927
  // 'word' karaoke reveal: spoken-word start times (sec, absolute from turn-audio start) + poll state.
6734
6928
  wordStarts = [];
6929
+ wordTexts = [];
6930
+ // the timestamped words, parallel to wordStarts (heard-cut matching)
6931
+ ackWords = 0;
6932
+ // leading timestamped words that are the ack spoken on this turn's context, not the reply
6735
6933
  revealedN = 0;
6736
6934
  revealPoll = null;
6935
+ // Sub-turn timestamp anchoring. A TTS context emits word starts relative to ITS OWN audio start, so
6936
+ // `wordStarts` (one flat turn timeline) is only valid while the turn stays on one context. It does
6937
+ // not: `subturnBoundary()` rotates the context, and Cartesia ALSO rotates it on its own (~5s idle
6938
+ // timeout mid tool-call) — after which starts restart at 0 and every remaining word instantly
6939
+ // satisfies the reveal predicate, dumping the whole segment on screen.
6940
+ // Fix is deterministic, not detective: at a boundary we FORCE the rotation ourselves, so the new
6941
+ // context's origin is always known — it is the turn-audio position where its first byte lands,
6942
+ // captured from the sink at that byte (`playedMs + drainMs` = content seconds written so far).
6943
+ // Starts arriving before that byte are held in `wordPending` and rebased when it does.
6944
+ wordOrigin = 0;
6945
+ // sec offset added to incoming starts (0 = first sub-turn)
6946
+ wordAnchorPending = false;
6947
+ // awaiting the new sub-turn's first audio byte
6948
+ wordPending = [];
6949
+ // [word, start] received while pending, rebased on capture
6737
6950
  clock;
6738
6951
  drainTimer = null;
6739
6952
  // heuristic tier state (inert under AEC) — frozen as validated in the experiment
@@ -6764,6 +6977,8 @@ var VoiceEngine = class _VoiceEngine {
6764
6977
  // endpointed text held for the merge window
6765
6978
  mergePath = "direct";
6766
6979
  // how the pending utterance was assembled (diag)
6980
+ holdTier = 0;
6981
+ // adaptive tier of the hold this dispatch came out of (diag; 0 = none)
6767
6982
  lastGraceDiag = 0;
6768
6983
  // grace_suppress emitted once per grace window
6769
6984
  pendingTimer = null;
@@ -6774,10 +6989,24 @@ var VoiceEngine = class _VoiceEngine {
6774
6989
  // the agent replied (or after 3s) still dispatches.
6775
6990
  lastDispatchFlat = "";
6776
6991
  lastDispatchWords = [];
6992
+ lastDispatchText = "";
6777
6993
  lastDispatchAt = 0;
6778
6994
  repliedSinceDispatch = false;
6995
+ dispatchReply = "";
6996
+ // the agent's prose since the last dispatch (answeredSinceDispatch)
6779
6997
  static DUP_FINAL_MS = 3e3;
6780
6998
  lastInterrupted = null;
6999
+ queuedTurn = false;
7000
+ // the current spoken turn is a queued worker utterance (pumpQueue), not a host turn
7001
+ // host-decided overlap (options.overlapDecideMs): pending decision timer, a final that landed while waiting, and
7002
+ // the window in which the overlap the host chose to talk over is absorbed (its growth + its final)
7003
+ decideTimer = null;
7004
+ decideSince = 0;
7005
+ // when this hold began waiting for the host — caps re-decisions (3× overlapDecideMs)
7006
+ decideHeldFinal = "";
7007
+ overlapKeptUntil = 0;
7008
+ overlapKept = "";
7009
+ // what the host judged keep_talking on — only THOSE words are absorbed
6781
7010
  // overlap (pause) tier state — AEC + pause-capable sinks only
6782
7011
  pausedAt = 0;
6783
7012
  lastResumeAt = 0;
@@ -6826,13 +7055,21 @@ var VoiceEngine = class _VoiceEngine {
6826
7055
  }
6827
7056
  async start() {
6828
7057
  this.tts.onAudio = (c) => {
6829
- if (this.speaking || this.bcActive) this.player.write(c);
7058
+ if (!this.speaking && !this.bcActive) return;
7059
+ if (this.wordAnchorPending && this.speaking) this.anchorSubturn();
7060
+ this.player.write(c);
6830
7061
  };
6831
- if (this.options.revealMode === "word") {
7062
+ if (this.options.revealMode === "word" || this.options.heardTimestamps) {
6832
7063
  this.tts.wantTimestamps = true;
6833
- this.tts.onTimestamps = (_words, start) => {
7064
+ this.tts.onTimestamps = (words, start) => {
6834
7065
  if (this.interrupted) return;
6835
- for (const s of start) this.wordStarts.push(s);
7066
+ start.forEach((s, i) => {
7067
+ if (this.wordAnchorPending) this.wordPending.push([words[i] ?? "", s]);
7068
+ else {
7069
+ this.wordStarts.push(this.wordOrigin + s);
7070
+ this.wordTexts.push(words[i] ?? "");
7071
+ }
7072
+ });
6836
7073
  };
6837
7074
  }
6838
7075
  this.stt.onPartial = (text) => this.handlePartial(text);
@@ -6841,10 +7078,15 @@ var VoiceEngine = class _VoiceEngine {
6841
7078
  await Promise.all([this.tts.connect(), this.stt.start()]);
6842
7079
  this.tts.warmup?.();
6843
7080
  this.setState("listening");
6844
- log13.debug(`voice I/O up (${this.stt.usingAec ? "AEC" : "heuristic echo"} capture)`);
7081
+ if (this.options.bargeInMode === "aec" && !this.stt.usingAec)
7082
+ log13.warn("bargeInMode=aec but mic does not report echoCancellation applied \u2014 STT may transcribe the assistant");
7083
+ log13.debug(`voice I/O up (${this.usingAec ? "AEC" : "heuristic echo"} capture, mode=${this.options.bargeInMode}, mic reports aec=${this.stt.usingAec})`);
6845
7084
  }
7085
+ /** The RESOLVED barge-in tier (the single seam every tier branch reads). `bargeInMode` overrides
7086
+ * the mic's self-report — see the option doc for why the report is not trustworthy on mobile. */
6846
7087
  get usingAec() {
6847
- return this.stt.usingAec;
7088
+ const m = this.options.bargeInMode;
7089
+ return m === "aec" ? true : m === "energy" ? false : this.stt.usingAec;
6848
7090
  }
6849
7091
  /** Flip barge-in at runtime (e.g. the mic fell back to non-VPIO → go half-duplex so echo can't leak). */
6850
7092
  setBargeIn(on) {
@@ -6876,6 +7118,12 @@ var VoiceEngine = class _VoiceEngine {
6876
7118
  for (const r of this.idleWaiters.splice(0)) r();
6877
7119
  }
6878
7120
  }
7121
+ /** True while the assistant is AUDIBLE — deltas flowing or queued audio still draining. Goes false
7122
+ * exactly when `onSpeechEnd` fires (or immediately on barge-in). Lets a host tell "the turn closed
7123
+ * but audio is still playing" from "there was nothing to play", without waiting for a callback. */
7124
+ get audible() {
7125
+ return this.speaking;
7126
+ }
6879
7127
  /** Resolve when the engine is no longer speaking (immediate if already idle). */
6880
7128
  awaitIdle() {
6881
7129
  if (this.state !== "speaking" && this.state !== "thinking") return Promise.resolve();
@@ -6892,19 +7140,31 @@ var VoiceEngine = class _VoiceEngine {
6892
7140
  this.drainTimer = null;
6893
7141
  }
6894
7142
  this.interrupted = false;
7143
+ this.queuedTurn = false;
6895
7144
  this.bcSupersede();
6896
7145
  this.resetOverlap(true);
7146
+ const acked = ack && this.dispatchRefinal && this.ackSpent;
7147
+ if (acked) {
7148
+ ack = false;
7149
+ this.diag("ack_skipped", { reason: "refinal" });
7150
+ }
6897
7151
  this.diag("tts_turn_begin", { ack, gapless: this.speaking });
7152
+ const gapless = this.speaking;
6898
7153
  if (!this.speaking) this.player.markTurn();
6899
7154
  this.speaking = true;
6900
7155
  this.ctxOpen = true;
6901
7156
  this.spokeDeltas = false;
6902
- this.adaptiveAckFired = false;
6903
- this.ackProbe = null;
7157
+ this.adaptiveAckFired = acked;
7158
+ this.ackProbe = acked ? "" : null;
7159
+ this.ackPickedFired = false;
6904
7160
  this.reply = "";
6905
7161
  this.revealText = "";
6906
7162
  this.wordStarts = [];
7163
+ this.wordTexts = [];
7164
+ this.ackWords = 0;
6907
7165
  this.revealedN = 0;
7166
+ this.resetSubturnAnchor();
7167
+ if (gapless) this.wordAnchorPending = true;
6908
7168
  if (this.options.revealMode === "word") this.startWordReveal();
6909
7169
  this.emo = this.options.emotions ? new EmotionStream(this.options.showEmotions) : null;
6910
7170
  this.echoWords = new Set(this.words(this.prevReply));
@@ -6916,23 +7176,27 @@ var VoiceEngine = class _VoiceEngine {
6916
7176
  this.spokeDeltas = true;
6917
7177
  this.ackAt = this.clock.now();
6918
7178
  this.lastAck = this.options.ackPhrase;
7179
+ this.ackSpent = true;
7180
+ this.ackWords = this.options.ackPhrase.trim().split(/\s+/).length;
6919
7181
  for (const w of this.words(this.options.ackPhrase)) this.echoWords.add(w);
6920
7182
  } else if (ack && this.options.adaptiveAckMs > 0) {
7183
+ const pick = this.startAckPick();
7184
+ const ms = this.options.adaptiveAckMs;
6921
7185
  this.ackTimer = this.clock.setTimeout(() => {
6922
7186
  this.ackTimer = null;
6923
- if (!this.speaking || !this.ctxOpen || this.spokeDeltas || this.interrupted) return;
6924
- const phrase = this.pickAck();
6925
- this.diag("ack_fired", { phrase, adaptive: true, waitedMs: this.options.adaptiveAckMs });
6926
- this.tts.speak(phrase + " ", true);
6927
- this.spokeDeltas = true;
6928
- this.ackAt = this.clock.now();
6929
- this.lastAck = phrase;
6930
- for (const w of this.words(phrase)) this.echoWords.add(w);
6931
- this.setState("speaking");
6932
- this.adaptiveAckFired = true;
6933
- this.ackProbe = "";
6934
- this.options.onAdaptiveAck();
6935
- }, this.options.adaptiveAckMs);
7187
+ if (!pick.pending) return this.fireAck(pick.phrase, pick.picked);
7188
+ this.ackTimer = this.clock.setTimeout(() => {
7189
+ this.ackTimer = null;
7190
+ pick.onDone = null;
7191
+ this.ackPick = null;
7192
+ this.diag("ack_cancelled", { reason: "pick_late" });
7193
+ }, ms);
7194
+ pick.onDone = () => {
7195
+ this.clearAckTimer();
7196
+ this.fireAck(pick.phrase, pick.picked);
7197
+ };
7198
+ }, ms);
7199
+ this.ackPick = pick;
6936
7200
  }
6937
7201
  if (!this.turnStartAt) this.turnStartAt = this.clock.now();
6938
7202
  this.setState("thinking");
@@ -6952,11 +7216,16 @@ var VoiceEngine = class _VoiceEngine {
6952
7216
  this.adaptiveAckFired = false;
6953
7217
  const lead = m ? m[1] : bare;
6954
7218
  const norm2 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7219
+ const same = this.ackPickedFired ? bare.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
6955
7220
  if (_VoiceEngine.ACK_LEADING.test(norm2)) {
6956
7221
  const rest = m ? m[2] : "";
6957
7222
  this.diag("reflex_ack_suppressed", { lead: lead.trim() });
6958
7223
  if (!rest.trim()) return "";
6959
7224
  text = rest;
7225
+ } else if (same) {
7226
+ this.diag("reflex_ack_suppressed", { lead: same[0].trim() });
7227
+ text = bare.slice(same[0].length);
7228
+ if (!text.trim()) return "";
6960
7229
  } else {
6961
7230
  text = probe;
6962
7231
  }
@@ -6969,7 +7238,10 @@ var VoiceEngine = class _VoiceEngine {
6969
7238
  prose = " " + prose;
6970
7239
  }
6971
7240
  this.reply += prose;
6972
- if (prose.trim()) this.repliedSinceDispatch = true;
7241
+ if (prose.trim()) {
7242
+ this.repliedSinceDispatch = true;
7243
+ this.dispatchReply += prose;
7244
+ }
6973
7245
  for (const w of this.words(this.reply)) this.echoWords.add(w);
6974
7246
  this.tts.speak(forSpeech(speech), true);
6975
7247
  if (!this.spokeDeltas && this.turnStartAt) log13.debug(`ttft: ${Math.round(this.clock.now() - this.turnStartAt)}ms`);
@@ -6981,6 +7253,42 @@ var VoiceEngine = class _VoiceEngine {
6981
7253
  }
6982
7254
  return display;
6983
7255
  }
7256
+ /** Sub-turn seam: the host's agent stream interrupted the reply with a tool call, so the spoken
7257
+ * turn continues on a FRESH TTS context. Flushes the current context and opens the next, then
7258
+ * re-anchors the karaoke timeline at the position the next context's first audio byte lands on.
7259
+ *
7260
+ * Rotating unconditionally is the point: Cartesia restarts word timestamps at 0 for each context
7261
+ * and ALSO rotates on its own after ~5s idle — so leaving the context alone makes reveal timing
7262
+ * depend on an invisible server-side timer (short gap = one timeline, long gap = a restart we
7263
+ * never saw). By always rotating, the origin is always known and never inferred.
7264
+ *
7265
+ * No-op outside an open turn. Reveal text/word ordering is unchanged — only the time origin. */
7266
+ subturnBoundary() {
7267
+ if (!this.speaking || !this.ctxOpen) return;
7268
+ this.diag("tts_subturn_boundary", { playedMs: Math.round(Math.max(0, this.player.playedMs())) });
7269
+ this.tts.end();
7270
+ this.tts.newContext();
7271
+ this.wordAnchorPending = true;
7272
+ this.wordPending = [];
7273
+ }
7274
+ /** Capture the current sub-turn's time origin = content seconds already written to the sink.
7275
+ * `playedMs + drainMs` is that sum in both regimes (mid-playback it telescopes to the scheduled
7276
+ * content; fully drained, drainMs is 0 and playedMs has reached the scheduled total). */
7277
+ anchorSubturn() {
7278
+ this.wordAnchorPending = false;
7279
+ this.wordOrigin = Math.max(0, this.player.playedMs() + this.player.drainMs()) / 1e3;
7280
+ this.diag("reveal_anchor", { originSec: Number(this.wordOrigin.toFixed(3)), held: this.wordPending.length });
7281
+ for (const [w, s] of this.wordPending) {
7282
+ this.wordStarts.push(this.wordOrigin + s);
7283
+ this.wordTexts.push(w);
7284
+ }
7285
+ this.wordPending = [];
7286
+ }
7287
+ resetSubturnAnchor() {
7288
+ this.wordOrigin = 0;
7289
+ this.wordAnchorPending = false;
7290
+ this.wordPending = [];
7291
+ }
6984
7292
  /** Karaoke reveal poll: reveal the display-word prefix up to the count of spoken words whose
6985
7293
  * Cartesia start time has been reached on the playback clock (`AudioSink.playedMs`, which excludes
6986
7294
  * paused time). Poll-driven (not AudioContext timers) so it's portable across CLI/browser sinks and
@@ -7052,6 +7360,11 @@ var VoiceEngine = class _VoiceEngine {
7052
7360
  if (this.turnStartAt) log13.debug(`turn: ${Math.round(this.clock.now() - this.turnStartAt)}ms (incl. playback)`);
7053
7361
  this.echoUntil = this.clock.now() + 2500;
7054
7362
  if (!this.usingAec) this.stt.reset();
7363
+ try {
7364
+ this.options.onSpeechEnd();
7365
+ } catch (e) {
7366
+ log13.warn(`onSpeechEnd threw: ${e instanceof Error ? e.message : e}`);
7367
+ }
7055
7368
  this.setState("listening");
7056
7369
  if (this.uttQueue.length) this.pumpQueue();
7057
7370
  };
@@ -7066,8 +7379,9 @@ var VoiceEngine = class _VoiceEngine {
7066
7379
  this.drainTimer = this.clock.setTimeout(drainThenSettle, 15e3);
7067
7380
  } else drainThenSettle();
7068
7381
  }
7069
- /** text of the reply cut by the last barge-in — consumed by the host to tell the model what
7070
- * the user did NOT hear. Cleared on read. */
7382
+ /** What the last barge-in cut — the reply that was playing (and its estimated heard part) plus every queued worker
7383
+ * utterance it dropped — consumed by the host to tell the model what the user did NOT hear, or to park the rest of
7384
+ * a delivery for resuming. Cleared on read. */
7071
7385
  takeInterruptedReply() {
7072
7386
  const r = this.lastInterrupted;
7073
7387
  this.lastInterrupted = null;
@@ -7076,11 +7390,12 @@ var VoiceEngine = class _VoiceEngine {
7076
7390
  /** Speak a short filler phrase without starting a model turn (stays in listening mode after). */
7077
7391
  speakFiller(text) {
7078
7392
  if (!text || this.speaking) return;
7079
- const replied = this.repliedSinceDispatch;
7393
+ const replied = this.repliedSinceDispatch, reply = this.dispatchReply;
7080
7394
  this.beginSpeech();
7081
7395
  this.speakDelta(text);
7082
7396
  this.endSpeech();
7083
7397
  this.repliedSinceDispatch = replied;
7398
+ this.dispatchReply = reply;
7084
7399
  }
7085
7400
  /** Enqueue a COMPLETE worker utterance (already-split spoken text) onto the central speech queue.
7086
7401
  * If nothing is currently speaking it plays immediately; otherwise it queues and plays after the
@@ -7098,6 +7413,7 @@ var VoiceEngine = class _VoiceEngine {
7098
7413
  const text = this.uttQueue.shift();
7099
7414
  if (text == null) return;
7100
7415
  this.beginSpeech();
7416
+ this.queuedTurn = true;
7101
7417
  this.speakDelta(text);
7102
7418
  this.endSpeech();
7103
7419
  }
@@ -7112,6 +7428,69 @@ var VoiceEngine = class _VoiceEngine {
7112
7428
  * micro-ack already spoke one. Anchored end-to-end so only a whole ack-only clause matches. */
7113
7429
  static ACK_LEADING = /^(?:(?:okay|ok|alright|sure|yeah|yep|right|got it|on it|still|still on it|still working on it|working on it|checking|one sec|one moment|mm hm|mmhm|hmm|let me (?:see|check|think))[ ,]*)+$/;
7114
7430
  recentAcks = [];
7431
+ /** The armed turn's ack choice: the host's `pickAck` (sync or async); `phrase` undefined = the default pools, drawn
7432
+ * only when the ack actually fires. `onDone` is set only while the ack is due and the pick is still pending. */
7433
+ ackPick = null;
7434
+ startAckPick() {
7435
+ const host = this.options.pickAck;
7436
+ const pick = { pending: false, phrase: void 0, picked: !!host, onDone: null };
7437
+ if (!host) return pick;
7438
+ const ctx = { text: this.lastDispatchText, question: this.lastDispatchWasQuestion, barged: this.bargedBeforeDispatch };
7439
+ const fallback = (e) => {
7440
+ log13.warn(`pickAck failed \u2014 using the default pools: ${e instanceof Error ? e.message : e}`);
7441
+ pick.picked = false;
7442
+ return void 0;
7443
+ };
7444
+ let r;
7445
+ try {
7446
+ r = host(ctx);
7447
+ } catch (e) {
7448
+ pick.phrase = fallback(e);
7449
+ return pick;
7450
+ }
7451
+ if (typeof r?.then !== "function") {
7452
+ pick.phrase = r;
7453
+ return pick;
7454
+ }
7455
+ pick.pending = true;
7456
+ Promise.resolve(r).catch(fallback).then((phrase) => {
7457
+ pick.pending = false;
7458
+ pick.phrase = phrase;
7459
+ if (this.ackPick === pick) pick.onDone?.();
7460
+ });
7461
+ return pick;
7462
+ }
7463
+ /** Speak the adaptive ack (null = the host chose silence, undefined = draw from the default pools) — only if the
7464
+ * turn is still waiting on its reflex. */
7465
+ fireAck(phrase, picked) {
7466
+ this.ackPick = null;
7467
+ if (!this.speaking || !this.ctxOpen || this.spokeDeltas || this.interrupted) return;
7468
+ if (phrase === void 0) phrase = this.pickAck();
7469
+ if (phrase === null || !phrase.trim()) {
7470
+ this.diag("ack_skipped", { reason: "silent" });
7471
+ return;
7472
+ }
7473
+ this.diag("ack_fired", { phrase, adaptive: true, waitedMs: this.options.adaptiveAckMs, ...picked ? { picked } : {} });
7474
+ this.tts.speak(phrase + " ", true);
7475
+ this.spokeDeltas = true;
7476
+ this.ackAt = this.clock.now();
7477
+ this.lastAck = phrase;
7478
+ this.ackSpent = true;
7479
+ this.ackWords = phrase.trim().split(/\s+/).length;
7480
+ for (const w of this.words(phrase)) this.echoWords.add(w);
7481
+ this.setState("speaking");
7482
+ this.adaptiveAckFired = true;
7483
+ this.ackProbe = "";
7484
+ this.ackPickedFired = picked;
7485
+ this.options.onAdaptiveAck();
7486
+ }
7487
+ /** `phrase`'s words at the very start of a text, any punctuation between them ("Oh no." ~ "oh, no —"). */
7488
+ static leadingPhrase(phrase) {
7489
+ const w = phrase.toLowerCase().match(/[a-z0-9]+/g) ?? [];
7490
+ return w.length ? new RegExp(`^[^a-z0-9]*${w.join("[^a-z0-9]+")}(?![a-z0-9])[^a-z0-9]*`, "i") : /$^/;
7491
+ }
7492
+ ackPickedFired = false;
7493
+ // the fired ack was the host's pick → the probe also strips the reflex repeating it
7115
7494
  pickAck() {
7116
7495
  const pool = this.lastDispatchWasQuestion ? _VoiceEngine.ACKS_QUESTION : _VoiceEngine.ACKS_NEUTRAL;
7117
7496
  const fresh = pool.filter((p) => !this.recentAcks.includes(p));
@@ -7121,7 +7500,16 @@ var VoiceEngine = class _VoiceEngine {
7121
7500
  return phrase;
7122
7501
  }
7123
7502
  lastDispatchWasQuestion = false;
7503
+ dispatchRefinal = false;
7504
+ // the last dispatch re-finalized (superseded) the one before it — same logical turn
7505
+ ackSpent = false;
7506
+ // an ack already played for the current logical turn (reset by a non-refinal dispatch)
7507
+ bargedSinceDispatch = false;
7508
+ // an effective interrupt landed since the last dispatch
7509
+ bargedBeforeDispatch = false;
7510
+ // …as of the last dispatch (AckContext.barged)
7124
7511
  clearAckTimer(reason) {
7512
+ if (reason) this.ackPick = null;
7125
7513
  if (!this.ackTimer) return;
7126
7514
  this.clock.clearTimeout(this.ackTimer);
7127
7515
  this.ackTimer = null;
@@ -7137,11 +7525,16 @@ var VoiceEngine = class _VoiceEngine {
7137
7525
  this.clearAckTimer("interrupt");
7138
7526
  this.adaptiveAckFired = false;
7139
7527
  this.ackProbe = null;
7140
- if (this.uttQueue.length) log13.info(`barge-in dropped ${this.uttQueue.length} queued worker utterance(s)`);
7141
- const droppedQueued = this.uttQueue.length;
7142
- this.uttQueue = [];
7143
- if (!this.speaking && !this.drainTimer) return;
7528
+ const queued = this.uttQueue.splice(0);
7529
+ const droppedQueued = queued.length;
7530
+ if (droppedQueued) log13.info(`barge-in cut ${droppedQueued} queued worker utterance(s) \u2014 handed to the host`);
7531
+ this.overlapKeptUntil = 0;
7532
+ if (!this.speaking && !this.drainTimer) {
7533
+ if (droppedQueued) this.lastInterrupted = { full: "", heard: "", queued, queuedTurn: false };
7534
+ return;
7535
+ }
7144
7536
  this.stopWordReveal(false);
7537
+ this.resetSubturnAnchor();
7145
7538
  this.diag("interrupt", { droppedQueued, ctxOpen: this.ctxOpen, playedMs: Math.round(Math.max(0, this.player.playedMs())) });
7146
7539
  if (this.drainTimer) {
7147
7540
  this.clock.clearTimeout(this.drainTimer);
@@ -7149,8 +7542,9 @@ var VoiceEngine = class _VoiceEngine {
7149
7542
  }
7150
7543
  this.resetOverlap(false);
7151
7544
  this.lastResumeAt = 0;
7152
- const heardChars = Math.round(Math.max(0, this.player.playedMs()) / 1e3 * 15);
7153
- if (this.reply) this.lastInterrupted = { full: this.reply, heard: this.reply.slice(0, heardChars) };
7545
+ const { heard, exact } = this.heardCut();
7546
+ if (this.reply || droppedQueued) this.lastInterrupted = { full: this.reply, heard, exact, queued, queuedTurn: this.queuedTurn };
7547
+ if (heard.length < this.reply.length || droppedQueued) this.bargedSinceDispatch = true;
7154
7548
  this.speaking = false;
7155
7549
  this.ctxOpen = false;
7156
7550
  this.interrupted = true;
@@ -7162,8 +7556,23 @@ var VoiceEngine = class _VoiceEngine {
7162
7556
  if (this.reply) this.prevReply = this.reply;
7163
7557
  this.setState("listening");
7164
7558
  }
7559
+ /** How much of the reply the user heard at a cut (paused time is excluded by the sink's playedMs). Exact from the
7560
+ * TTS word timestamps the playback clock has reached (the ack's leading words skipped); without timestamps, an
7561
+ * estimate — played seconds × ~15 chars/s of TTS speech, coarse and often mid-word. */
7562
+ heardCut() {
7563
+ const played = Math.max(0, this.player.playedMs());
7564
+ if (!this.wordTexts.length) return { heard: this.reply.slice(0, Math.round(played / 1e3 * 15)), exact: false };
7565
+ let n = 0;
7566
+ while (n < this.wordStarts.length && this.wordStarts[n] <= played / 1e3) n++;
7567
+ if (!this.ctxOpen && n === this.wordStarts.length && played / 1e3 - this.wordStarts[n - 1] >= 0.4) return { heard: this.reply, exact: true };
7568
+ return { heard: this.reply.slice(0, heardPrefix(this.reply, this.wordTexts.slice(this.ackWords, n))), exact: true };
7569
+ }
7165
7570
  stop() {
7166
7571
  this.uttQueue = [];
7572
+ if (this.decideTimer) {
7573
+ this.clock.clearTimeout(this.decideTimer);
7574
+ this.decideTimer = null;
7575
+ }
7167
7576
  this.clearAckTimer();
7168
7577
  if (this.resumeTimer) this.clock.clearTimeout(this.resumeTimer);
7169
7578
  if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
@@ -7176,9 +7585,41 @@ var VoiceEngine = class _VoiceEngine {
7176
7585
  this.setState("idle");
7177
7586
  }
7178
7587
  // --- listening side (STT-driven) ---
7588
+ /** Echo-comparable tokens. Our text and the STT's rendering of it must tokenize alike: every non-alphanumeric
7589
+ * (em-dash, non-breaking hyphen, nbsp, …) SEPARATES words — deleting it glued "releases—GPT‑6" into
7590
+ * "releasesgpt6" and the echo gate missed our own words. Numbers take the spoken form STT may hear: letter/digit
7591
+ * runs split ("K2.8" → k 2 point 8), a decimal point is "point", number words are digits ("five" → 5).
7592
+ * Apostrophes are dropped (it's → its). Words under 2 letters are noise; digits are kept. Any script counts (Unicode
7593
+ * letters/marks/digits): an ASCII-only split gave Hebrew speech ZERO words, which the echo gate read as pure echo
7594
+ * (live 20260923-125143: "סבתא מחוץ לדלת?" and "תודה." swallowed). */
7179
7595
  words(s) {
7180
- return s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length >= 2);
7181
- }
7596
+ return s.toLowerCase().replace(/['’]/g, "").replace(/(\d)\.(?=\d)/g, "$1 point ").replace(new RegExp("(\\p{L})(?=\\d)|(\\d)(?=\\p{L})", "gu"), "$1$2 ").split(/[^\p{L}\p{M}\p{N}]+/u).map((w) => _VoiceEngine.NUMBER_WORDS[w] ?? w).filter((w) => w.length >= 2 || /\d/.test(w));
7597
+ }
7598
+ static NUMBER_WORDS = Object.fromEntries(
7599
+ [
7600
+ "zero",
7601
+ "one",
7602
+ "two",
7603
+ "three",
7604
+ "four",
7605
+ "five",
7606
+ "six",
7607
+ "seven",
7608
+ "eight",
7609
+ "nine",
7610
+ "ten",
7611
+ "eleven",
7612
+ "twelve",
7613
+ "thirteen",
7614
+ "fourteen",
7615
+ "fifteen",
7616
+ "sixteen",
7617
+ "seventeen",
7618
+ "eighteen",
7619
+ "nineteen",
7620
+ "twenty"
7621
+ ].map((w, i) => [w, String(i)])
7622
+ );
7182
7623
  novelWords(text) {
7183
7624
  return this.words(text).filter((w) => !this.echoWords.has(w));
7184
7625
  }
@@ -7195,6 +7636,22 @@ var VoiceEngine = class _VoiceEngine {
7195
7636
  const novel = this.novelWords(text).length;
7196
7637
  return novel > 0 && novel / Math.max(1, total) > 0.5;
7197
7638
  }
7639
+ /** Echo verdict for a FINAL in the echo window (AEC tier). Bag-of-words genuine() alone swallowed real replies
7640
+ * that reuse a few of our words — live 20260923-102258, right after a barge over "…Gemini 3.8 Flash…":
7641
+ * "Wait, there is a new Gemini model?" (3 novel / 6) was dropped twice and the user had to repeat it. Echo
7642
+ * of our own audio reproduces RUNS of what we said, in order (mis-hearings break a word, not the run); a
7643
+ * reply that shares vocabulary scatters it. So a ≥4-word final that fails the bag test is echo only if at
7644
+ * least half its words sit in a word pair we actually said. Shorter finals can't show order → bag rule. */
7645
+ echoFinal(text) {
7646
+ if (this.genuine(text)) return false;
7647
+ const w = this.words(text);
7648
+ if (w.length < 4) return true;
7649
+ const said = this.words(`${this.prevReply} ${this.reply} ${this.lastAck}`);
7650
+ const pairs = new Set(said.slice(1).map((x, i) => `${said[i]} ${x}`));
7651
+ const inRun = w.map(() => false);
7652
+ for (let i = 1; i < w.length; i++) if (pairs.has(`${w[i - 1]} ${w[i]}`)) inRun[i - 1] = inRun[i] = true;
7653
+ return inRun.filter(Boolean).length * 2 >= w.length;
7654
+ }
7198
7655
  handlePartial(text) {
7199
7656
  if (this.speaking) {
7200
7657
  if (!this.options.bargeIn) return;
@@ -7206,14 +7663,29 @@ var VoiceEngine = class _VoiceEngine {
7206
7663
  if (!this.echoActive() || (this.usingAec ? this.genuine(text) : this.novelWords(text).length >= 1)) this.options.onPartial(text);
7207
7664
  return;
7208
7665
  }
7666
+ if (this.echoesDispatch(text, true)) {
7667
+ if (this.lastDupPartial !== text.trim()) {
7668
+ this.lastDupPartial = text.trim();
7669
+ this.diag("dup_partial", { reason: "dup_final", text: text.slice(0, 60) });
7670
+ }
7671
+ if (this.pausedAt) {
7672
+ this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "dup_final" });
7673
+ this.resetOverlap(true);
7674
+ }
7675
+ return;
7676
+ }
7209
7677
  if (this.overlapCapable) {
7210
7678
  const txt = text.trim();
7211
7679
  if (!txt || txt === this.lastOverlapPartial) return;
7212
7680
  this.lastOverlapPartial = txt;
7213
7681
  if (!this.genuine(txt)) {
7214
- if (this.pausedAt) this.armResume();
7682
+ if (this.pausedAt && !this.decideTimer) this.armResume();
7215
7683
  return;
7216
7684
  }
7685
+ if (this.overlapKeptUntil && this.clock.now() < this.overlapKeptUntil) {
7686
+ if (!this.unjudged(txt)) return;
7687
+ this.overlapKeptUntil = 0;
7688
+ }
7217
7689
  if (!this.pausedAt) {
7218
7690
  this.pausedAt = this.clock.now();
7219
7691
  const sinceResumeMs = this.lastResumeAt ? Math.round(this.clock.now() - this.lastResumeAt) : void 0;
@@ -7228,13 +7700,14 @@ var VoiceEngine = class _VoiceEngine {
7228
7700
  }
7229
7701
  }
7230
7702
  if (this.words(txt).length >= 2) {
7231
- const phase = this.ctxOpen ? "speaking" : "drain";
7232
- this.diag("barge_in", { phase, trigger: "overlap_cede", novel: this.novelWords(txt).length, total: this.words(txt).length });
7233
- this.interrupt();
7234
- this.options.onBargeIn(phase);
7703
+ if (this.options.overlapDecideMs > 0 && this.shortOverlap(txt)) {
7704
+ this.awaitOverlapDecision(txt);
7705
+ return;
7706
+ }
7707
+ this.cedeOverlap("overlap_cede", txt);
7235
7708
  return;
7236
7709
  }
7237
- this.armResume();
7710
+ if (!this.decideTimer) this.armResume();
7238
7711
  return;
7239
7712
  }
7240
7713
  const barge = this.usingAec ? this.genuine(text) : this.novelWords(text).length >= (this.suspectUntil ? 1 : 2);
@@ -7279,7 +7752,7 @@ var VoiceEngine = class _VoiceEngine {
7279
7752
  this.specTimer = null;
7280
7753
  if (this.speaking || this.specSpent || this.pendingUtt) return;
7281
7754
  if (this.words(this.specPartial).length < this.options.speculativeMinWords) return;
7282
- const flat = this.specPartial.toLowerCase().replace(/[^a-z0-9]/g, "");
7755
+ const flat = normWord(this.specPartial);
7283
7756
  if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
7284
7757
  this.specSpent = true;
7285
7758
  this.specText = this.specPartial;
@@ -7318,7 +7791,7 @@ var VoiceEngine = class _VoiceEngine {
7318
7791
  const letters = p.replace(/[^\p{L}]/gu, "");
7319
7792
  const latin = p.replace(/[^A-Za-z]/g, "");
7320
7793
  if (!letters || latin.length / letters.length < 0.7) return veto("non_latin");
7321
- const flat = p.toLowerCase().replace(/[^a-z0-9]/g, "");
7794
+ const flat = normWord(p);
7322
7795
  if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
7323
7796
  this.fireBackchannel();
7324
7797
  }, this.options.backchannelMs);
@@ -7406,6 +7879,10 @@ var VoiceEngine = class _VoiceEngine {
7406
7879
  }
7407
7880
  if (pfx && norm2(nw[pw.length - 1]).startsWith(norm2(pw[pw.length - 1]))) return next;
7408
7881
  }
7882
+ if (_VoiceEngine.restates(prev, next)) {
7883
+ this.diag("merge_restated", { dropped: prev.slice(0, 60) });
7884
+ return next;
7885
+ }
7409
7886
  const max = Math.min(pw.length, nw.length);
7410
7887
  for (let k = max; k > 0; k--) {
7411
7888
  let match = true;
@@ -7420,6 +7897,25 @@ var VoiceEngine = class _VoiceEngine {
7420
7897
  static normWord(w) {
7421
7898
  return normWord(w);
7422
7899
  }
7900
+ /** `next` RESTATES the held, unfinished `prev` — the user restarted ("Wo- wait, can you explain more about" →
7901
+ * "What— wait, can you explain more about that? …", live 20260924-093945, where both landed in the dispatch).
7902
+ * Keep only the later version. Conservative: prev's words from index i ≤ 2 through its END reappear contiguously
7903
+ * in next starting at j ≤ 2, that run is ≥4 words and ≥75% of prev. i > 0 with j = 0 is NOT a restart — that is
7904
+ * an overlapping re-final continuing prev ("Tell me about the" + "about the weather"), deduped by the caller
7905
+ * keeping prev's head. prev's last word may be a char-prefix (a mid-word endpoint). */
7906
+ static restates(prev, next) {
7907
+ const p = prev.split(/\s+/).map(normWord).filter(Boolean), n = next.split(/\s+/).map(normWord).filter(Boolean);
7908
+ for (let i = 0; i <= 2; i++) {
7909
+ const k = p.length - i;
7910
+ if (k < 4 || k < 0.75 * p.length) return false;
7911
+ for (let j = i === 0 ? 0 : 1; j <= 2 && j + k <= n.length; j++) {
7912
+ let ok = true;
7913
+ for (let x = 0; x < k && ok; x++) ok = x === k - 1 ? n[j + x].startsWith(p[i + x]) : n[j + x] === p[i + x];
7914
+ if (ok) return true;
7915
+ }
7916
+ }
7917
+ return false;
7918
+ }
7423
7919
  /** Soniox re-finalization of an ALREADY-DISPATCHED utterance, arriving PAST the merge window:
7424
7920
  * the new final is a strict word-prefix superset of the last dispatch (the last dispatched word
7425
7921
  * may be a char-prefix of the corresponding new word — a mid-word endpoint), and it lands within
@@ -7450,18 +7946,71 @@ var VoiceEngine = class _VoiceEngine {
7450
7946
  if (!nSame.startsWith(pLast)) return false;
7451
7947
  return nw.length > pw.length || nSame.length > pLast.length;
7452
7948
  }
7949
+ /** The STT re-emitting (part of) the utterance just dispatched: ≥2 words that form a contiguous run of the dispatched
7950
+ * words, within DUP_FINAL_MS. A merged dispatch ("Okay." + "Um, check for the recent news in AI.") has its later
7951
+ * segment come back as a partial/final a beat later — the identical/superset guards miss a PIECE. A human cannot
7952
+ * re-speak a ≥2-word stretch of what they said within ~1s; one word ("stop") is never treated as a duplicate.
7953
+ * `partial`: the last word may be cut mid-word ("…news in A"). */
7954
+ echoesDispatch(text, partial = false) {
7955
+ if (!this.lastDispatchWords.length || this.clock.now() - this.lastDispatchAt >= _VoiceEngine.DUP_FINAL_MS) return false;
7956
+ const d = this.lastDispatchWords.map(normWord).filter(Boolean), w = text.trim().split(/\s+/).map(normWord).filter(Boolean);
7957
+ if (w.length < 2 || w.length > d.length) return false;
7958
+ const last = w.length - 1;
7959
+ outer: for (let i = 0; i + w.length <= d.length; i++) {
7960
+ for (let k = 0; k < w.length; k++) if (partial && k === last ? !d[i + k].startsWith(w[k]) : d[i + k] !== w[k]) continue outer;
7961
+ return true;
7962
+ }
7963
+ return false;
7964
+ }
7965
+ /** The agent replied since the dispatch with more than an acknowledgement (ACK_LEADING: "On it.", "Sure, one sec."). */
7966
+ answeredSinceDispatch() {
7967
+ return this.repliedSinceDispatch && !_VoiceEngine.ACK_LEADING.test(this.dispatchReply.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase());
7968
+ }
7969
+ lastDupPartial = "";
7970
+ // dup_partial diag once per distinct partial
7453
7971
  static TRAIL_RE = /(?:^|\s)(?:and|but|or|so|to|the|a|an|of|in|for|with|that|if|uh|um|like|about|from|into|on|is|are|was|were|,)$/i;
7972
+ /** The user's line right after a barge cut our speech (or a re-final growing it), with no terminal punctuation:
7973
+ * they took the floor mid-thought — Soniox closes on the pause, not the thought (live 20260923-192543: "But isn't it"
7974
+ * → "…can be done" → "…with boxing" → "…or semantic?" dispatched 4 times). Held like an incomplete utterance. */
7975
+ unfinishedBargeLine(text) {
7976
+ const line = this.bargedSinceDispatch || this.bargedBeforeDispatch && this.refinalizes(text);
7977
+ return line && !/[.?!…]["'”’)\]]*$/.test(text.trim()) && !_VoiceEngine.CONTROL_RE.test(text.trim());
7978
+ }
7979
+ /** A short line that is complete on its own ("Stop", "Yes", "Okay, stop", "go on") — never held as unfinished. */
7980
+ static CONTROL_RE = ((w) => new RegExp(`^${w}(?:[\\s,]+${w})*[.!?\u2026]*$`, "i"))("(?:stop|wait|yes|yeah|yep|no|nope|okay|ok|go on|continue|never ?mind)");
7454
7981
  /** The utterance sounds like the user paused mid-thought (trailing conjunction/filler/comma). */
7455
7982
  looksIncomplete(text) {
7456
- return _VoiceEngine.TRAIL_RE.test(text.trim());
7983
+ return _VoiceEngine.TRAIL_RE.test(text.trim()) && !_VoiceEngine.CONTROL_RE.test(text.trim());
7457
7984
  }
7458
7985
  handleUtterance(text) {
7459
- if (this.speaking && (this.ctxOpen || this.pausedAt) && this.overlapCapable) {
7986
+ if (this.speaking && this.echoesDispatch(text)) {
7987
+ this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
7988
+ if (this.pausedAt) {
7989
+ this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "dup_final" });
7990
+ this.resetOverlap(true);
7991
+ }
7992
+ return;
7993
+ }
7994
+ if (this.decideTimer) {
7995
+ this.decideHeldFinal = this.mergeUtterance(this.decideHeldFinal, text);
7996
+ this.diag("utterance_held", { reason: "overlap_decide", text: text.slice(0, 60) });
7997
+ return;
7998
+ }
7999
+ if (this.overlapKeptUntil) {
8000
+ const kept = this.clock.now() < this.overlapKeptUntil && !this.unjudged(text);
8001
+ this.overlapKeptUntil = 0;
8002
+ if (kept) {
8003
+ this.diag("utterance_drop", { reason: "overlap_kept", text: text.slice(0, 60) });
8004
+ return;
8005
+ }
8006
+ }
8007
+ const ownTail = !this.pausedAt && (this.clock.now() < this.bargeGraceUntil || this.refinalizes(text));
8008
+ if (this.speaking && (this.ctxOpen || this.pausedAt) && this.overlapCapable && !ownTail) {
7460
8009
  this.diag("utterance_drop", { reason: "overlap_noise", text: text.slice(0, 60) });
7461
8010
  this.stt.reset();
7462
8011
  return;
7463
8012
  }
7464
- if (this.echoActive() && (!this.options.bargeIn || (this.usingAec ? !this.genuine(text) : this.novelWords(text).length < 2))) {
8013
+ if (this.echoActive() && (!this.options.bargeIn || (this.usingAec ? this.echoFinal(text) : this.novelWords(text).length < 2))) {
7465
8014
  this.diag("echo_swallow", { guard: "echo_gate", text: text.slice(0, 60) });
7466
8015
  this.stt.reset();
7467
8016
  return;
@@ -7475,9 +8024,28 @@ var VoiceEngine = class _VoiceEngine {
7475
8024
  if (this.pendingUtt) this.mergePath = "merged";
7476
8025
  this.pendingUtt = this.mergeUtterance(this.pendingUtt, text);
7477
8026
  if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
7478
- if (this.options.incompleteMergeMs && this.looksIncomplete(this.pendingUtt)) {
8027
+ if (this.options.adaptiveEndpointing) {
8028
+ const holdMs = computeHoldMs({
8029
+ transcript: this.pendingUtt,
8030
+ lastAiMessage: this.prevReply,
8031
+ baseMs: this.options.utteranceMergeMs,
8032
+ incompleteMs: this.options.incompleteMergeMs
8033
+ });
8034
+ const tier = endpointTier(this.pendingUtt);
8035
+ this.holdTier = tier;
8036
+ this.diag("hold", { reason: "adaptive", tier, punctuated: tier === 3, holdMs, tail: this.pendingUtt.slice(-40) });
8037
+ if (holdMs > this.options.utteranceMergeMs) {
8038
+ this.options.onHold();
8039
+ if (tier === 1 && this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
8040
+ }
8041
+ if (holdMs <= 0) return this.flushUtterance();
8042
+ this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), holdMs);
8043
+ return;
8044
+ }
8045
+ const bargeHold = !this.looksIncomplete(this.pendingUtt) && this.unfinishedBargeLine(this.pendingUtt);
8046
+ if (this.options.incompleteMergeMs && (bargeHold || this.looksIncomplete(this.pendingUtt))) {
7479
8047
  log13.verbose(`hold: incomplete utterance "${this.pendingUtt.slice(-40)}"`);
7480
- this.diag("hold", { reason: "incomplete", tail: this.pendingUtt.slice(-40) });
8048
+ this.diag("hold", { reason: bargeHold ? "barge_unfinished" : "incomplete", tail: this.pendingUtt.slice(-40) });
7481
8049
  this.options.onHold();
7482
8050
  if (this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
7483
8051
  this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), this.options.incompleteMergeMs);
@@ -7508,9 +8076,13 @@ var VoiceEngine = class _VoiceEngine {
7508
8076
  this.pendingUtt = "";
7509
8077
  const path = this.mergePath;
7510
8078
  this.mergePath = "direct";
8079
+ const heldTier = this.holdTier;
8080
+ this.holdTier = 0;
7511
8081
  if (text) {
7512
- const flat = text.toLowerCase().replace(/[^a-z0-9]/g, "");
7513
- if (flat && flat === this.lastDispatchFlat && !this.repliedSinceDispatch && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS) {
8082
+ const flat = normWord(text);
8083
+ const answered = this.answeredSinceDispatch();
8084
+ const part = flat !== this.lastDispatchFlat && !answered && this.echoesDispatch(text);
8085
+ if (part || flat && flat === this.lastDispatchFlat && !answered && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS) {
7514
8086
  log13.verbose(`dropped duplicate final "${text.slice(0, 40)}" (identical to the just-dispatched utterance, no reply between)`);
7515
8087
  this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
7516
8088
  return;
@@ -7521,22 +8093,121 @@ var VoiceEngine = class _VoiceEngine {
7521
8093
  this.diag("utterance_drop", { reason: "refinalized", text: text.slice(0, 60) });
7522
8094
  this.lastDispatchFlat = flat;
7523
8095
  this.lastDispatchWords = text.trim().split(/\s+/);
8096
+ this.lastDispatchText = text;
7524
8097
  return;
7525
8098
  }
8099
+ const meta = refinal ? { supersedes: this.lastDispatchText } : void 0;
7526
8100
  this.lastDispatchFlat = flat;
7527
8101
  this.lastDispatchWords = text.trim().split(/\s+/);
8102
+ this.lastDispatchText = text;
7528
8103
  this.lastDispatchAt = this.clock.now();
7529
8104
  this.repliedSinceDispatch = false;
8105
+ this.dispatchReply = "";
7530
8106
  this.lastDispatchWasQuestion = /\?\s*$/.test(text);
8107
+ this.bargedBeforeDispatch = refinal ? this.bargedBeforeDispatch || this.bargedSinceDispatch : this.bargedSinceDispatch;
8108
+ this.bargedSinceDispatch = false;
8109
+ this.dispatchRefinal = refinal;
8110
+ if (!refinal) this.ackSpent = false;
7531
8111
  this.turnStartAt = this.clock.now();
7532
8112
  this.bargeGraceUntil = this.clock.now() + this.options.bargeGraceMs;
7533
- this.diag("dispatch", { text, path: refinal ? "refinalized" : path, question: this.lastDispatchWasQuestion });
7534
- this.options.onUtterance(text);
8113
+ this.diag("dispatch", { text, path: refinal ? "refinalized" : path, heldTier, question: this.lastDispatchWasQuestion, ...this.bargedBeforeDispatch ? { barged: true } : {} });
8114
+ this.options.onUtterance(text, meta);
7535
8115
  }
7536
8116
  }
7537
8117
  get overlapCapable() {
7538
8118
  return this.usingAec && this.options.overlapPause && !!this.player.pause && !!this.player.resume;
7539
8119
  }
8120
+ /** Stop/question words that make an overlap an obvious barge — never held for a host decision. */
8121
+ static OVERLAP_CEDE_WORDS = /* @__PURE__ */ new Set([
8122
+ "stop",
8123
+ "wait",
8124
+ "no",
8125
+ "hold",
8126
+ "hang",
8127
+ "pause",
8128
+ "quiet",
8129
+ "enough",
8130
+ "shut",
8131
+ "cancel",
8132
+ "what",
8133
+ "why",
8134
+ "how",
8135
+ "who",
8136
+ "when",
8137
+ "where",
8138
+ "which"
8139
+ ]);
8140
+ /** A short, non-urgent overlap: the kind a listener cue or reaction takes (see overlapDecideMs). */
8141
+ shortOverlap(text) {
8142
+ const w = this.words(text);
8143
+ return w.length <= this.options.overlapDecideMaxWords && !/\?/.test(text) && !w.some((x) => _VoiceEngine.OVERLAP_CEDE_WORDS.has(x));
8144
+ }
8145
+ /** Cede the floor to an overlap: interrupt, tell the host, then dispatch a final that was held while deciding. */
8146
+ cedeOverlap(trigger, txt) {
8147
+ const phase = this.ctxOpen ? "speaking" : "drain";
8148
+ const held = this.decideHeldFinal;
8149
+ this.decideHeldFinal = "";
8150
+ this.diag("barge_in", { phase, trigger, novel: this.novelWords(txt).length, total: this.words(txt).length });
8151
+ this.interrupt();
8152
+ this.options.onBargeIn(phase);
8153
+ if (held) this.handleUtterance(held);
8154
+ }
8155
+ /** Wait (bounded) for the host's verdict. `fresh` = a re-decision on grown text: it gets its own window — the host
8156
+ * was only just asked about it (live: "Oh," → "Oh, that" → "Oh, that— that's new" outgrew every verdict, and the
8157
+ * first window ran out mid-chase, ceding a reaction Jev kept) — but the whole hold is capped at 3× the window. */
8158
+ awaitOverlapDecision(txt, fresh = false) {
8159
+ if (this.decideTimer && !fresh) return;
8160
+ if (this.resumeTimer) {
8161
+ this.clock.clearTimeout(this.resumeTimer);
8162
+ this.resumeTimer = null;
8163
+ }
8164
+ if (this.decideTimer) this.clock.clearTimeout(this.decideTimer);
8165
+ const now3 = this.clock.now(), ms = this.options.overlapDecideMs;
8166
+ if (!this.decideSince) this.decideSince = now3;
8167
+ const wait = Math.min(ms, this.decideSince + 3 * ms - now3);
8168
+ this.diag("overlap_await", { text: txt.slice(0, 60), ms: wait });
8169
+ this.decideTimer = this.clock.setTimeout(() => {
8170
+ this.decideTimer = null;
8171
+ if (this.pausedAt) this.cedeOverlap("overlap_decide_timeout", this.lastOverlapPartial || txt);
8172
+ }, Math.max(0, wait));
8173
+ }
8174
+ /** Words in `text` the host's keep_talking verdict never covered (the overlap grew after it judged). */
8175
+ unjudged(text) {
8176
+ const seen = new Set(this.words(this.overlapKept));
8177
+ return this.words(text).some((w) => !seen.has(w));
8178
+ }
8179
+ /** The host's verdict on the overlap the engine is holding (overlapDecideMs, or any live overlap pause):
8180
+ * 'yield' cedes the floor (a final that landed meanwhile is dispatched); 'keep_talking' resumes playback and
8181
+ * absorbs the rest of THAT reaction — `heard`, the text the verdict judged (default: the live partial); its final
8182
+ * is dropped. Words beyond `heard` are a new overlap, decided afresh: if the partial/final has already grown past
8183
+ * it, the hold stays and `overlap_pause` is re-emitted for a new verdict (bounded by overlapDecideMs, else it
8184
+ * cedes; a re-decision gets its own window, the whole hold capped at 3× it) and this returns false. Also false when no overlap hold is live (the engine already decided). */
8185
+ decideOverlap(choice, heard) {
8186
+ if (!this.pausedAt || !this.speaking) return false;
8187
+ if (choice === "yield") {
8188
+ this.cedeOverlap("host_yield", this.lastOverlapPartial);
8189
+ return true;
8190
+ }
8191
+ this.overlapKept = heard?.trim() || this.lastOverlapPartial;
8192
+ const grown = this.decideHeldFinal || this.lastOverlapPartial;
8193
+ if (this.unjudged(grown)) {
8194
+ if (!(this.options.overlapDecideMs > 0 && this.shortOverlap(grown))) {
8195
+ this.cedeOverlap("overlap_cede", grown);
8196
+ return false;
8197
+ }
8198
+ this.diag("overlap_pause", { text: grown.slice(0, 60), redecide: true });
8199
+ this.awaitOverlapDecision(grown, true);
8200
+ return false;
8201
+ }
8202
+ const held = this.decideHeldFinal;
8203
+ this.decideHeldFinal = "";
8204
+ this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "host_keep" });
8205
+ if (held) this.diag("utterance_drop", { reason: "overlap_kept", text: held.slice(0, 60) });
8206
+ this.overlapKeptUntil = held ? 0 : this.clock.now() + 4e3;
8207
+ this.resetOverlap(true);
8208
+ this.lastResumeAt = 0;
8209
+ return true;
8210
+ }
7540
8211
  armResume() {
7541
8212
  if (this.resumeTimer) this.clock.clearTimeout(this.resumeTimer);
7542
8213
  this.resumeTimer = this.clock.setTimeout(() => {
@@ -7552,6 +8223,20 @@ var VoiceEngine = class _VoiceEngine {
7552
8223
  this.clock.clearTimeout(this.resumeTimer);
7553
8224
  this.resumeTimer = null;
7554
8225
  }
8226
+ if (this.decideTimer) {
8227
+ this.clock.clearTimeout(this.decideTimer);
8228
+ this.decideTimer = null;
8229
+ }
8230
+ this.decideSince = 0;
8231
+ const held = this.decideHeldFinal;
8232
+ this.decideHeldFinal = "";
8233
+ if (held) {
8234
+ this.diag("utterance_release", { reason: "overlap_hold_exit", text: held.slice(0, 60) });
8235
+ this.pendingUtt = this.mergeUtterance(this.pendingUtt, held);
8236
+ if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
8237
+ this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), 0);
8238
+ }
8239
+ if (this.pausedAt) this.diag("overlap_end", { resumed: resume, heldMs: Math.round(this.clock.now() - this.pausedAt) });
7555
8240
  if (this.pausedAt && resume) {
7556
8241
  this.player.resume?.();
7557
8242
  this.lastResumeAt = this.clock.now();
@@ -7617,7 +8302,8 @@ var SentenceCoalescer = class _SentenceCoalescer {
7617
8302
  feed(delta) {
7618
8303
  if (delta) this.buf += delta;
7619
8304
  let cut = -1;
7620
- for (let i = 0; i < this.buf.length; i++) if (_SentenceCoalescer.isEnd(this.buf[i])) cut = i;
8305
+ const decimal = (i) => this.buf[i] === "." && /\d/.test(this.buf[i - 1] ?? "") && (i + 1 === this.buf.length || /\d/.test(this.buf[i + 1]));
8306
+ for (let i = 0; i < this.buf.length; i++) if (_SentenceCoalescer.isEnd(this.buf[i]) && !decimal(i)) cut = i;
7621
8307
  if (cut < 0) return "";
7622
8308
  if (this.buf[cut] !== "\n") while (cut + 1 < this.buf.length && CLOSERS.includes(this.buf[cut + 1])) cut++;
7623
8309
  const ready = this.buf.slice(0, cut + 1).trim();
@@ -7632,9 +8318,14 @@ var SentenceCoalescer = class _SentenceCoalescer {
7632
8318
  };
7633
8319
  var SpokenSplitter = class {
7634
8320
  buf = "";
7635
- inSpoken = false;
8321
+ open = false;
7636
8322
  /** True once any spoken char has ever been emitted (drives the no-spoken fallback). */
7637
8323
  spokeAny = false;
8324
+ /** Inside a `<spoken>` block after the last feed. Once it is false, a caller coalescing sentences can flush: nothing
8325
+ * more of that block is coming (a held "…in 2026." would otherwise wait for the next spoken text or the stream end). */
8326
+ get inSpoken() {
8327
+ return this.open;
8328
+ }
7638
8329
  /** Feed a delta; returns the spoken/detail spans completed by this chunk (either may be ''). */
7639
8330
  feed(delta) {
7640
8331
  this.buf += delta;
@@ -7648,26 +8339,26 @@ var SpokenSplitter = class {
7648
8339
  let spoken = "";
7649
8340
  let detail = "";
7650
8341
  while (this.buf.length) {
7651
- const tag = this.inSpoken ? CLOSE : OPEN;
8342
+ const tag = this.open ? CLOSE : OPEN;
7652
8343
  const idx = this.buf.indexOf(tag);
7653
8344
  if (idx >= 0) {
7654
8345
  const text2 = this.buf.slice(0, idx);
7655
- if (this.inSpoken) spoken += text2;
8346
+ if (this.open) spoken += text2;
7656
8347
  else detail += text2;
7657
8348
  this.buf = this.buf.slice(idx + tag.length);
7658
- this.inSpoken = !this.inSpoken;
8349
+ this.open = !this.open;
7659
8350
  continue;
7660
8351
  }
7661
8352
  const lt = this.buf.lastIndexOf("<");
7662
8353
  const holdStart = lt >= 0 && tag.startsWith(this.buf.slice(lt)) ? lt : this.buf.length;
7663
8354
  const text = this.buf.slice(0, holdStart);
7664
- if (this.inSpoken) spoken += text;
8355
+ if (this.open) spoken += text;
7665
8356
  else detail += text;
7666
8357
  this.buf = this.buf.slice(holdStart);
7667
8358
  break;
7668
8359
  }
7669
8360
  if (final && this.buf) {
7670
- if (this.inSpoken) spoken += this.buf;
8361
+ if (this.open) spoken += this.buf;
7671
8362
  else detail += this.buf;
7672
8363
  this.buf = "";
7673
8364
  }
@@ -7682,7 +8373,11 @@ var now = () => performance.now();
7682
8373
  var SonioxSTTOptions = class {
7683
8374
  auth = "";
7684
8375
  source;
7685
- model = "stt-rt-preview";
8376
+ /** `stt-rt-preview` was RETIRED (aliased to the two-generations-old v3 on 2025-10-31), so every
8377
+ * caller that never set this was silently running an old model while the alias kept it working —
8378
+ * the failure mode of a default that is a moving vendor pointer. Pin a real version instead;
8379
+ * `stt-rt-v5` is live today (verified against GET https://api.soniox.com/v1/models). */
8380
+ model = "stt-rt-v5";
7686
8381
  languageHints = ["en"];
7687
8382
  /** Client-side endpoint: finalized text + no new tokens for this long = utterance (don't wait for
7688
8383
  * Soniox's semantic <end>, which adds 0.5-1.5s — the difference between ping-pong and lag). */
@@ -8055,6 +8750,15 @@ function base64ToBytes(b64) {
8055
8750
  return out;
8056
8751
  }
8057
8752
 
8753
+ // node_modules/@bod.ee/voice/src/adapters/fish.ts
8754
+ var log16 = forComponent2("FishTTS");
8755
+
8756
+ // node_modules/@bod.ee/voice/src/adapters/grok.ts
8757
+ var log17 = forComponent2("GrokTTS");
8758
+
8759
+ // node_modules/@bod.ee/voice/src/adapters/grok-stt.ts
8760
+ var log18 = forComponent2("GrokSTT");
8761
+
8058
8762
  // src/voice/engine.ts
8059
8763
  init_logging();
8060
8764
  configureLogging((name) => {
@@ -8063,8 +8767,158 @@ configureLogging((name) => {
8063
8767
  return { debug: at("debug"), verbose: at("verbose"), info: at("info"), warn: at("warn"), error: at("error") };
8064
8768
  });
8065
8769
 
8770
+ // src/voice/instinct.ts
8771
+ init_logging();
8772
+ var log19 = forComponent("Instinct");
8773
+ var ACK_SITUATIONS = {
8774
+ hard_question: `a question that takes real thought, judgment or looking something up ("what's the biggest takeaway?", "how should I structure this?")`,
8775
+ simple_question: 'a quick question with an instant, obvious answer ("how are you?", "what time is it?", "can you hear me?")',
8776
+ emotional_bad: "they share something bad, sad, painful, worrying or frustrating",
8777
+ emotional_good: "they share good news, excitement, relief or pride",
8778
+ challenge: `they doubt, question or push back on what you just said ("are you sure?", "why do you say that?", "that doesn't sound right")`,
8779
+ surprise: `they react with surprise or disbelief to what you said ("oh, that's new", "really?", "no way", "oh.")`,
8780
+ agreement: `they agree with you, confirm, or tell you to go on ("exactly", "yes, that's it", "okay, so continue")`,
8781
+ goodbye: "they are wrapping up the conversation or saying goodbye",
8782
+ command: 'a request or instruction for you to do, find, check or tell them something ("tell me the news", "check my calendar")',
8783
+ silent: 'ONLY when nothing was really said yet: an unfinished fragment or false start, a mic check or count ("one, two"), a bare greeting or filler ("hey", "um")'
8784
+ };
8785
+ var INTENT_ACK_OVER = {
8786
+ stop: Object.keys(ACK_SITUATIONS).filter((s) => s !== "silent"),
8787
+ steer: ["command"],
8788
+ resume: ["agreement", "silent"],
8789
+ aside: []
8790
+ };
8791
+ function interruptionNote(cut, intent) {
8792
+ const full = cut.full.trim();
8793
+ let at = Math.min(cut.heard.length, cut.full.length);
8794
+ if (/\S/.test(cut.full[at] ?? "") && /\S/.test(cut.full[at - 1] ?? "")) at = cut.full.lastIndexOf(" ", at) + 1;
8795
+ const heardWords = cut.full.slice(0, at).trim().split(/\s+/).filter(Boolean).length;
8796
+ const unheard = cut.full.slice(at).trim();
8797
+ const landed = heardWords < 4 ? "They cut in almost at once \u2014 treat all of it as unsaid." : `They heard everything before this part. They missed the rest of it: "${unheard}".`;
8798
+ const how = {
8799
+ resume: "They did not ask anything new. Carry on with the part they missed \u2014 no restart, no apology.",
8800
+ aside: "It was an aside. Answer it briefly FIRST, then finish the part they missed, adjusted to anything they just said. Say each point once: skip whatever they already heard and whatever your answer just covered.",
8801
+ steer: "They steered away. Follow their new direction; carry over only what still matters from your unfinished point, or drop it.",
8802
+ stop: "They want you to stop. Do not resume; at most a few words of acknowledgement.",
8803
+ unknown: "Decide like a person would: if it was an aside, answer it and then finish your point; if they steered away, follow them and drop or adapt the rest."
8804
+ };
8805
+ return `
8806
+ [the user interrupted you mid-speech. You were saying: "${full}" ${landed} ${how[intent ?? "unknown"]} Just talk, like a person picking the thread back up: never tell them what they heard or missed, never say where you were cut off, and never quote your earlier words back. If their words are still an unfinished fragment, call Hold instead of answering.]`;
8807
+ }
8808
+
8809
+ // src/voice/unsaid.ts
8810
+ init_logging();
8811
+ var log20 = forComponent("Unsaid");
8812
+ var STOP2 = new Set("that this with have from they them their there then than what when were will would your about into just also been some only over more most very which while after before because these those does here".split(" "));
8813
+ var contentStems = (s) => (s.toLowerCase().match(/[a-z0-9']+/g) ?? []).filter((w) => w.length >= 4 && !STOP2.has(w)).map((w) => w.slice(0, 4));
8814
+ var SAID_OVERLAP = 0.6;
8815
+ var UNSAID_OVERLAP = 0.25;
8816
+ var CONVEYED_MIN = 0.3;
8817
+ function splitClauses(text) {
8818
+ return text.split(/(?<=[.!?…])\s+|(?<=;)\s+|(?<=,)\s+(?=(?:and|but)\b)/i).map((s) => s.trim()).filter((s) => contentStems(s).length > 0);
8819
+ }
8820
+ var overlap = (clause, said) => {
8821
+ const w = contentStems(clause);
8822
+ return w.filter((x) => said.has(x)).length / w.length;
8823
+ };
8824
+ var INSTRUCTIONS = "A voice assistant was interrupted mid-delivery; ASSISTANT_SAID is what it then said aloud. Did the MAIN POINT of this clause of the unheard delivery reach the listener through ASSISTANT_SAID \u2014 would hearing the clause too add nothing important? Different words, casual phrasing, rounder numbers, or a missing minor detail (a name, an exact figure) still count as conveyed. Not conveyed only when the clause's main point is absent from ASSISTANT_SAID, even if a neighbouring point is there.";
8825
+ async function unsaidClauses(pieces, spoken, decider, timeoutMs = 5e3) {
8826
+ const said = new Set(contentStems(spoken));
8827
+ const open = splitClauses(pieces.join(" ")).map((c) => ({ c, r: overlap(c, said) })).filter((x) => x.r < SAID_OVERLAP);
8828
+ if (!open.length) return [];
8829
+ const fallback = () => open.filter((x) => x.r < UNSAID_OVERLAP).map((x) => x.c);
8830
+ if (!decider) return fallback();
8831
+ const state = { assistant_said: spoken };
8832
+ const questions = {};
8833
+ open.forEach((x, i) => {
8834
+ state[`c${i}`] = x.c;
8835
+ questions[`c${i}`] = { type: "noul", instructions: `${INSTRUCTIONS} The clause is c${i}.`, criteria: { true: `the main point of c${i} reached the listener`, false: `the main point of c${i} is missing` } };
8836
+ });
8837
+ let timer;
8838
+ try {
8839
+ const { answers } = await Promise.race([
8840
+ decider.decide({ state, questions }),
8841
+ new Promise((_, rej) => {
8842
+ timer = setTimeout(() => rej(new Error(`no verdict in ${timeoutMs}ms`)), timeoutMs);
8843
+ })
8844
+ ]);
8845
+ return open.filter((_, i) => {
8846
+ const p = Number(answers[`c${i}`]?.noul);
8847
+ if (!Number.isFinite(p)) throw new Error(`no noul for c${i}`);
8848
+ return p < CONVEYED_MIN;
8849
+ }).map((x) => x.c);
8850
+ } catch (e) {
8851
+ log20.warn(`unsaid judge failed \u2014 only near-zero-overlap clauses count as unsaid: ${e.message}`);
8852
+ return fallback();
8853
+ } finally {
8854
+ clearTimeout(timer);
8855
+ }
8856
+ }
8857
+
8858
+ // src/voice/leak.ts
8859
+ var TOOL = String.raw`(?:QuickLook|TaskStatus|CancelTask|AnswerTask|ExitSession|Act|Think|Hold(?! on\b))`;
8860
+ var SUSPECT = [
8861
+ /\b(?:user|assistant)\b/i,
8862
+ /\b(?:instructions?|guidelines?|polic(?:y|ies)|system prompt|developer (?:message|instructions?|prompt))\b/i,
8863
+ new RegExp(String.raw`\b${TOOL}\b`),
8864
+ /\bwe (?:need to|have to|should|must|ought to)\b|(?:^|[.!?]\s*)(?:need to|should|must|let's)\b/i,
8865
+ /\bi (?:need to|have to|should|must) (?:respond|reply|answer|output|produce|comply|acknowledge|say|ask|greet|call|use|dispatch|wait|keep|avoid|not)\b/i,
8866
+ /\b(?:respond|reply|output|produce|comply|acknowledge?)\w*\b/i,
8867
+ /\bwe(?: have|'ve)\b|\bwe (?:already )?(?:responded|replied|dispatched|used|called|did|gave|said|asked)\b/i,
8868
+ /\bno (?:further|more|other)\b/i,
8869
+ /\[\s*task\b|\bt\d+\b|\bwait(?:ing)? for (?:the )?(?:events?|results?|task)\b/i,
8870
+ /\w\s*\{\s*"|\bwhat:\s*"/,
8871
+ /\b(?:last|previous) (?:response|reply|message|turn)\b|\bthe (?:conversation|transcript|context)\b/i
8872
+ ];
8873
+ var STRICT = [
8874
+ // the listener as a third-person subject opening the sentence ("The user wants…", telegraphic "User wants…")
8875
+ /^(?:the )?user(?:'s)? (?:asks|asked|wants|wanted|said|says|is (?:asking|saying|speaking|referring|requesting)|just (?:said|asked)|requests|requested|mentioned|seems|keeps|repeated|typed|provided|expects|explicitly)\b/i,
8876
+ /\b(?:if|when|since|because) user (?:is|keeps|wants|says|said|asks|asked|seems)\b/i,
8877
+ /\bwe have (?:an? )?(?:user (?:who|request|message|asking|saying)|conversation (?:where|with)|new user message)\b|^we have task\b/i,
8878
+ /\bthe assistant(?:'s (?:context|last|previous) (?:response|reply|turn|message)| (?:responded|replied) with)\b/i,
8879
+ // planning its own reply
8880
+ /\b(?:we|i) (?:need to|have to|should|must) (?:respond|reply|answer)(?:\s*:|\s+(?:briefly|concisely|accordingly|according to|appropriately|to the user|with (?:a |the )?(?:brief|short|result|acknowledg)))/i,
8881
+ /\b(?:we|i) (?:need to|should|must) output\b|^(?:we|i) must comply[.!]?$/i,
8882
+ /\bno (?:further|more|other) (?:response|reply|output)s? (?:is )?(?:needed|required|necessary)\b/i,
8883
+ /\bwe(?: have|'ve)? (?:already )?(?:responded|replied)[.!]?$/i,
8884
+ // its own protocol: using its tools, tool-call syntax, task events
8885
+ new RegExp(String.raw`(?:\b[Ww]e(?: need to| should| must| have to| will|'ll)|\b[Ll]et'?s|^[Ss]hould|^[Nn]ow we should) (?:call|use|do|dispatch(?: to)?|invoke) (?:the )?${TOOL}\b`),
8886
+ new RegExp(String.raw`\b(?:[Ww]e (?:called|dispatched)|responded with) ${TOOL}\b|\b${TOOL}\s*\{|\bwhat:\s*"|\b${TOOL} (?:tool )?(?:brief|call|args?|arguments)\b`),
8887
+ /\bwait(?:ing)? for (?:the )?(?:events?|t\d+)\b|\bt\d+ (?:completed|failed)\b|\[\s*task\b/i
8888
+ ];
8889
+ var SENTENCE_END = new RegExp(`[.!?\u2026]+["')\\]]*(?:\\s|(?=\\p{Lu}|\\[))`, "gu");
8890
+ var SENTENCE_SPLIT = new RegExp(`(?<=[.!?\u2026]+["')\\]]*)(?:\\s+|(?=\\p{Lu}|\\[))`, "u");
8891
+ var clean2 = (s) => s.replace(/\[[a-z_ ]+\]\s*/gi, "").trim();
8892
+ function isLeakSuspect(sentence) {
8893
+ const s = clean2(sentence);
8894
+ return SUSPECT.some((re) => re.test(s));
8895
+ }
8896
+ function isStrictLeak(sentence) {
8897
+ return sentence.split(SENTENCE_SPLIT).some((x) => {
8898
+ const s = clean2(x);
8899
+ return STRICT.some((re) => re.test(s));
8900
+ });
8901
+ }
8902
+ var JUDGE_INSTRUCTIONS = `This sentence was produced by a voice assistant's reply stream and is about to be read aloud to the user. Is it the assistant's private scratchpad reasoning that leaked out \u2014 talking ABOUT the conversation instead of TO the listener? Leaked reasoning: refers to the listener in the third person ("the user wants\u2026", "user said\u2026"), plans its own reply ("we need to respond\u2026", "we should output\u2026", "I should answer briefly"), cites its own guidelines/instructions, or narrates its internal tool calls and task events (QuickLook, Act, Think, Hold, TaskStatus, "t2 completed") as steps it is taking. Speech: anything said TO the listener \u2014 answers, questions, offers, news, advice, explaining what it can do when asked, even if it mentions a user, an assistant, a policy, instructions, replying, or a tool.`;
8903
+ var LEAK_JUDGE_THRESHOLD = 0.6;
8904
+ async function judgeLeak(decider, sentence, userSaid) {
8905
+ const { answers } = await decider.decide({
8906
+ state: { what_the_user_just_said: userSaid.slice(-600) || null, sentence_about_to_be_spoken: clean2(sentence) },
8907
+ questions: {
8908
+ q: {
8909
+ type: "noul",
8910
+ instructions: JUDGE_INSTRUCTIONS,
8911
+ criteria: { true: "leaked private reasoning \u2014 must not be spoken", false: "said to the listener \u2014 fine to speak" }
8912
+ }
8913
+ }
8914
+ });
8915
+ const p = Number(answers.q.noul);
8916
+ if (!Number.isFinite(p)) throw new Error("leak judge: no noul in the answer");
8917
+ return p;
8918
+ }
8919
+
8066
8920
  // src/duplex.ts
8067
- var log16 = forComponent("DuplexAgent");
8921
+ var log21 = forComponent("DuplexAgent");
8068
8922
  function describeCall(call) {
8069
8923
  const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
8070
8924
  const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
@@ -8106,6 +8960,13 @@ var DuplexAgentOptions = class {
8106
8960
  /** Teach the model to emit inline `[emotion]` tags for Cartesia emotion control. Only set when the
8107
8961
  * TTS actually speaks them — text-duplex (no TTS) would otherwise print literal tags. */
8108
8962
  emotionTags = false;
8963
+ /** Judge a post-barge line while a delivery is parked: resume it now, answer an aside and carry it on (the reflex,
8964
+ * told what was heard — see asideNote), or drop it (steer/stop). Unset → the conservative `isTrivialBarge` allowlist (resume | steer); a throwing judge
8965
+ * falls back to it too. */
8966
+ bargeIntent;
8967
+ /** Typed-decision model (e.g. ai.libx.js DecideClient → Jev). Judges reflex sentences the leak screen flags as
8968
+ * SUSPECT (src/voice/leak.ts) — only suspects pay the ~300ms call. Unset → only the strict leak shapes are cut. */
8969
+ decider;
8109
8970
  /** Awaited BEFORE a worker spawns — open a per-task checkpoint frame, audit, etc.
8110
8971
  * (post-spawn would race the worker's first edits). */
8111
8972
  onTaskStart;
@@ -8114,6 +8975,13 @@ var DuplexAgentOptions = class {
8114
8975
  progressUpdates = false;
8115
8976
  /** Min ms between progress re-voices per task. */
8116
8977
  progressIntervalMs = 25e3;
8978
+ /** Spoken budget of ONE task delivery (chars, ~3 short sentences). Past it the rest is parked — cut at a sentence
8979
+ * boundary, `spokenOffer` spoken instead — so "go on" continues it once (the parked/resume path) and an aside or
8980
+ * steer handles it like any parked delivery. The worker is also asked for a short spoken summary + one offer (the
8981
+ * source); this is the backstop. Live 20260924-093945: ~20s and ~70s monologues whose closing choice went unspoken.
8982
+ * The full result always folds into the transcript. 0 = unbudgeted. */
8983
+ spokenBudgetChars = 0;
8984
+ spokenOffer = "There's more. Want me to go on?";
8117
8985
  /** Relay worker questions (AskUserQuestion + permission asks via parkQuestion) through the VOICE:
8118
8986
  * the question re-voices as '[task <id> asks] …', the user answers conversationally, and the
8119
8987
  * voice model resolves it with the AnswerTask tool. Off → host.ask passthrough (text menus). */
@@ -8132,20 +9000,30 @@ var DuplexAgentOptions = class {
8132
9000
  /** User-scope memory dir for global facts (type=user/feedback). Forwarded to Remember's routing. */
8133
9001
  memoryUserDir;
8134
9002
  };
9003
+ var SESSION_ID_RE = /^\d{8}-\d{6}(?:-[\w-]+)?$/;
8135
9004
  var RESERVED_EVENT_MARKER = /\[task\b[^\]\n]*\b(?:completed|failed|progress|asks)\b/i;
8136
9005
  var RESERVED_EVENT_OPENER = /\[\s*task\b/i;
8137
9006
  var STAGE_DIRECTION_RE = /^\(\s*(?:(?:waiting|checking|searching|thinking|processing|loading|working|fetching|looking)\b[^)]*|[^)]*(?:\.\.\.|…)\s*)\)$/i;
8138
9007
  function nowLine() {
8139
9008
  return `Current date and time: ${(/* @__PURE__ */ new Date()).toLocaleString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" })}`;
8140
9009
  }
9010
+ var STOP_INTENT = /\b(stop|cancel|no|nope|nah|never\s?mind|nvm|forget it|drop it|don'?t|quiet|shut up|enough|hold on|wait|pause|hang on|actually)\b/;
8141
9011
  function isTrivialBarge(text) {
8142
9012
  const t = text.trim().toLowerCase().replace(/[.,!?…\s]+$/g, "").replace(/^[.,!?…\s]+/, "");
8143
9013
  if (!t) return false;
8144
- if (/\b(stop|cancel|no|nope|nah|never\s?mind|nvm|forget it|drop it|don'?t|quiet|shut up|enough|hold on|wait|pause|hang on|actually)\b/.test(t)) return false;
9014
+ if (STOP_INTENT.test(t)) return false;
8145
9015
  const core = t.replace(/^(?:oh|um+|uh+|er+|hmm+|ah+|well|so|okay|ok|yeah|yep)[\s,]+/, "").trim();
8146
9016
  return /^(?:oh|um+|uh+|er+|hmm+|ah+|sorry|oops|whoops|my bad|pardon|excuse me|apologies|go on|go ahead|continue|keep going|carry on|as you were|please continue|you were saying|sorry go on|sorry continue|go on then|go on please)$/.test(core || t);
8147
9017
  }
8148
- var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji. Never emit stage directions or parenthetical asides about your own process \u2014 nothing like "(waiting for the result...)" or "(checking)"; while work runs, either say it as plain speech or end your turn.\nThis holds even when asked to "print", "list", "show", or "make a table" \u2014 there is no screen for the spoken channel. Speak it as flowing prose ("Tuesday is half a meter, Wednesday a bit less\u2026"), or if they truly need it on screen, route it to Act to render. Never emit dashes or pipes into speech.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou have three cognitive tiers \u2014 like a human brain:\n\u2022 YOU (reflex) \u2014 instant, lightweight. Handle greetings, simple questions, status checks, QuickLook.\n\u2022 `Act` \u2014 your hands. A background worker with its own configured tools and access to the user\'s environment (files and shell{{WORKER_WEB}}). Use for reading, editing, searching, running tasks, building \u2014 any real work.\n{{THINK_SLOT}}\nWhen you are unsure whether you can do or access something, do NOT assume and do NOT claim a capability you have not confirmed. To check what you can do, QuickLook `capabilities` (instant \u2014 it lists your worker\'s real tools) and answer from that. Never promise an ability that is not in your capabilities; if it is not there, tell the user plainly you can\'t. To actually DO real work, call `Act`. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), call `Act` IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nYou cannot mute the microphone or stop voice capture yourself \u2014 no tool does it. If the user asks you to stop listening or turn the voice off, never claim you did: tell them to say exactly "voice off" (handled by the app directly), or type /voice.\nYou are NOT a knowledge base. For any question whose answer needs SPECIFIC verifiable facts you do not already have in hand \u2014 how to build/configure/implement something, exact API, library, entitlement, command or option names, current events, or particular numbers, dates, or names \u2014 do NOT answer from your own memory: you will confidently make things up (a fake API, a wrong entitlement, an event that did not happen). Route it to `Act`, which can search and verify, and speak only what its report says. DELEGATION RULE \u2014 decide for yourself, the user never has to push: if you cannot answer confidently from the conversation plus trivial well-known knowledge, do NOT refuse and do NOT guess \u2014 dispatch `Act` immediately with a clear brief and say you are checking. Anything needing CURRENT data (weather, news, prices, dates, sky/astronomy, "right now"), real computation, or verification is an automatic dispatch \u2014 never a refusal. The user should never need to say "search the web" or "think harder" to make you act; needing fresh or verified information IS the trigger. Refuse only what your worker genuinely cannot do (check `capabilities`), and say why. Answer inline ONLY for general conversation, chit-chat, and trivia you are sure of, or facts you can see via QuickLook. When elaborating on a completed task ("tell me more", "the gist"), stay strictly within what that result actually said \u2014 if the user asks for something the result did not cover, that is NEW information: dispatch `Act`, do not improvise.\nALWAYS react before you work: the FIRST thing in your turn is a brief spoken acknowledgement of what you heard and what you are about to do ("got it \u2014 opening that now", "sure, let me pull it up", "okay, checking"). NEVER call a tool (Act, Think, QuickLook) silently \u2014 the user must hear you react before you go quiet to work. After dispatching Act or Think, that same one short sentence IS your turn \u2014 end it and do not wait for the result. Exactly ONE short line: never stack a second acknowledgement, and never narrate your own presence or status while waiting ("I\'m here", "let me see", "still checking" right after you already acked). One clean ack then silence reads as competent; repeated check-ins read as nervous.\nA completed task speaks its OWN result to the user (the worker voices what matters as it finishes) \u2014 you do NOT re-voice clean task results. A FAILED or INCOMPLETE task still arrives as a "[task t1 failed] \u2026" event for you to handle. The completed result stays in YOUR context \u2014 it is yours to draw on. When the user follows up ("tell me more", "what else", "and?"), answer FROM that result first: you already have the detail, so elaborate on what you have. Do NOT spawn a fresh worker to re-search or re-gather what you were just handed. Re-dispatch ONLY when genuinely new information is needed \u2014 e.g. the user wants the full contents of a SPECIFIC source, which is one WebFetch of that URL, not a brand-new search. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 on a genuinely LONG wait you MAY give one brief half-sentence aside, but only occasionally; silence while working is normal and fine (the user knows you are on it). Do NOT narrate every step or re-announce yourself. Never present progress as a finished result.\nCRITICAL: while a task is still running you have NO answer yet \u2014 never state a specific result of any kind (a number, size, count, name, path, or value). The real answer arrives ONLY in the "[task \u2026 completed]" event; inventing one meanwhile (a made-up disk size, commit count, etc.) is a serious error. Until then, only acknowledge and wait.\nNever read raw file paths, diffs, or code aloud verbatim.\nDo NOT end every turn with the same canned offer ("want a rundown?", "want the steps?"). Offer once at most; if the user pushes back, repeats themselves, or sounds unsatisfied ("you know what I mean?", "think deeper", "are you sure?"), do NOT re-offer the same thing \u2014 change approach: dispatch `Act`/`Think` to actually dig in, or ask one concrete clarifying question. Repeating a non-answer is worse than silence.\n"[task t1 asks] \u2026" events are QUESTIONS from a background task \u2014 relay to the user in your own words, short, then end your turn. When the user answers, call `AnswerTask` with that id and their answer. NEVER answer on the user\'s behalf for permissions or risky operations; if their reply is ambiguous, confirm first.\nIf the user\'s message sounds INCOMPLETE \u2014 trailing off mid-sentence, a fragment that needs more context ("and then we", "but the problem is"), hesitation fillers ("uh", "um") \u2014 call `Hold` instead of answering. This keeps listening for the rest of their thought. Only respond with substance when you have a complete question or request.\nDispatch discipline: send ONE self-contained task per request \u2014 a single worker with the full brief beats several workers with fragments (each worker starts fresh and re-discovers context). NEVER dispatch a worker just to read files or gather information \u2014 workers explore and discover context themselves; pass on what you already know and let one worker do the whole job. Split into parallel tasks only when the user asks for genuinely independent things. When a task completes, report its result and stop \u2014 do NOT dispatch follow-up work (verification, polish, extras) the user did not ask for, unless the report itself signals failure or doubt.\nDo not fire a second Act/Think for work already in flight, and NEVER spawn a second task to re-count, cross-check, or verify a result a worker already gave you \u2014 trust its answer; a single question gets ONE task. Call `TaskStatus` at most ONCE per turn; if a task is still running, give one brief status aside and end the turn \u2014 never poll it again and again in a loop. "Still on it" is ONLY for a repeat check on a task you already acknowledged earlier \u2014 NEVER as the ack when you first dispatch work ("still" is wrong when nothing was running yet; use a fresh "on it"/"got it, checking" instead). Use `CancelTask` when the user asks to stop something.\nPRIORITY: when the user says goodbye or wants to end/finish/wrap up the session ("ok bye", "that\'s all", "let\'s finish", "let\'s end", "goodnight", "exit", "wrap up"), call `ExitSession` IMMEDIATELY \u2014 do not act, do not check status, just exit.\nFor TRIVIAL instant lookups only \u2014 current time, git branch, listing a folder, peeking at a small file, or checking your own `capabilities`/tools \u2014 use `QuickLook` (instant, no task). Whenever the user asks what you can do or whether you have some ability, QuickLook `capabilities` and answer from that \u2014 never guess. Anything requiring searching, reasoning, running commands, or editing goes through `Act`.\n{{MEMORY_SLOT}}\nUser messages may arrive via speech-to-text and can carry transcription artifacts \u2014 odd words, cut-offs, homophones ("for you" vs "folder"). Read for INTENT, not surface text. If a message seems garbled, surprising, or only half-parses, do NOT guess an action or improvise content from it \u2014 briefly confirm what they meant ("did you mean\u2026?") and wait. A one-line confirm beats a confident wrong answer or an invented response to a request you did not actually understand.';
9018
+ function isOfferAcceptance(text) {
9019
+ const t = text.trim().toLowerCase().replace(/[.,!?…]+/g, " ").replace(/\s+/g, " ").trim();
9020
+ if (!t || STOP_INTENT.test(t)) return false;
9021
+ const core = t.replace(/^(?:oh|um+|uh+|well|so|okay|ok|alright)\s+/, "");
9022
+ return /^(?:(?:yes|yeah|yep|yup|sure|ok|okay|alright)(?: please| sure| go on| go ahead| tell me more| do it| why not)?|please|please do|sure thing|go for it|do it|more|tell me more|what else|and then|sounds good|absolutely|definitely|of course|why not|i'?d like that|let'?s hear it|let'?s do it)$/.test(core || t);
9023
+ }
9024
+ var isQuestion = (s) => /\?["'”’)\]]*$/.test(s.trim());
9025
+ var OFFER_OVERRUN = 150;
9026
+ var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji. Never emit stage directions or parenthetical asides about your own process \u2014 nothing like "(waiting for the result...)" or "(checking)"; while work runs, either say it as plain speech or end your turn.\nThis holds even when asked to "print", "list", "show", or "make a table" \u2014 there is no screen for the spoken channel. Speak it as flowing prose ("Tuesday is half a meter, Wednesday a bit less\u2026"), or if they truly need it on screen, route it to Act to render. Never emit dashes or pipes into speech.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou have three cognitive tiers \u2014 like a human brain:\n\u2022 YOU (reflex) \u2014 instant, lightweight. Handle greetings, simple questions, status checks, QuickLook.\n\u2022 `Act` \u2014 your hands. A background worker with its own configured tools and access to the user\'s environment (files and shell{{WORKER_WEB}}). Use for reading, editing, searching, running tasks, building \u2014 any real work.\n{{THINK_SLOT}}\nWhen you are unsure whether you can do or access something, do NOT assume and do NOT claim a capability you have not confirmed. To check what you can do, QuickLook `capabilities` (instant \u2014 it lists your worker\'s real tools) and answer from that. Never promise an ability that is not in your capabilities; if it is not there, tell the user plainly you can\'t. To actually DO real work, call `Act`. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), call `Act` IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nYou cannot mute the microphone or stop voice capture yourself \u2014 no tool does it. If the user asks you to stop listening or turn the voice off, never claim you did: tell them to say exactly "voice off" (handled by the app directly), or type /voice.\nYou are NOT a knowledge base. For any question whose answer needs SPECIFIC verifiable facts you do not already have in hand \u2014 how to build/configure/implement something, exact API, library, entitlement, command or option names, current events, or particular numbers, dates, or names \u2014 do NOT answer from your own memory: you will confidently make things up (a fake API, a wrong entitlement, an event that did not happen). Route it to `Act`, which can search and verify, and speak only what its report says. DELEGATION RULE \u2014 decide for yourself, the user never has to push: if you cannot answer confidently from the conversation plus trivial well-known knowledge, do NOT refuse and do NOT guess \u2014 dispatch `Act` immediately with a clear brief and say you are checking. Anything needing CURRENT data (weather, news, prices, dates, sky/astronomy, "right now"), real computation, or verification is an automatic dispatch \u2014 never a refusal. The user should never need to say "search the web" or "think harder" to make you act; needing fresh or verified information IS the trigger. Refuse only what your worker genuinely cannot do (check `capabilities`), and say why. Answer inline ONLY for general conversation, chit-chat, and trivia you are sure of, or facts you can see via QuickLook. When elaborating on a completed task ("tell me more", "the gist"), stay strictly within what that result actually said \u2014 if the user asks for something the result did not cover, that is NEW information: dispatch `Act`, do not improvise.\nALWAYS react before you work: the FIRST thing in your turn is a brief spoken acknowledgement of what you heard and what you are about to do ("got it \u2014 opening that now", "sure, let me pull it up", "okay, checking"). NEVER call a tool (Act, Think, QuickLook) silently \u2014 the user must hear you react before you go quiet to work. After dispatching Act or Think, that same one short sentence IS your turn \u2014 end it and do not wait for the result. Exactly ONE short line: never stack a second acknowledgement, and never narrate your own presence or status while waiting ("I\'m here", "let me see", "still checking" right after you already acked). One clean ack then silence reads as competent; repeated check-ins read as nervous.\nA completed task speaks its OWN result to the user (the worker voices what matters as it finishes) \u2014 you do NOT re-voice clean task results. A FAILED or INCOMPLETE task still arrives as a "[task t1 failed] \u2026" event for you to handle. The completed result stays in YOUR context \u2014 it is yours to draw on. When the user follows up ("tell me more", "what else", "and?"), answer FROM that result first: you already have the detail, so elaborate on what you have. Do NOT spawn a fresh worker to re-search or re-gather what you were just handed. Re-dispatch ONLY when genuinely new information is needed \u2014 e.g. the user wants the full contents of a SPECIFIC source, which is one WebFetch of that URL, not a brand-new search. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 on a genuinely LONG wait you MAY give one brief half-sentence aside, but only occasionally; silence while working is normal and fine (the user knows you are on it). Do NOT narrate every step or re-announce yourself. Never present progress as a finished result.\nCRITICAL: while a task is still running you have NO answer yet \u2014 never state a specific result of any kind (a number, size, count, name, path, or value). The real answer exists ONLY once the task finishes (the worker speaks it, and `TaskStatus` then returns it); inventing one meanwhile (a made-up disk size, commit count, etc.) is a serious error. Until then, only acknowledge and wait.\nNever read raw file paths, diffs, or code aloud verbatim.\nDo NOT end every turn with the same canned offer ("want a rundown?", "want the steps?"). Offer once at most; if the user pushes back, repeats themselves, or sounds unsatisfied ("you know what I mean?", "think deeper", "are you sure?"), do NOT re-offer the same thing \u2014 change approach: dispatch `Act`/`Think` to actually dig in, or ask one concrete clarifying question. Repeating a non-answer is worse than silence.\n"[task t1 asks] \u2026" events are QUESTIONS from a background task \u2014 relay to the user in your own words, short, then end your turn. When the user answers, call `AnswerTask` with that id and their answer. NEVER answer on the user\'s behalf for permissions or risky operations; if their reply is ambiguous, confirm first.\nIf the user\'s message sounds INCOMPLETE \u2014 trailing off mid-sentence, a fragment that needs more context ("and then we", "but the problem is"), hesitation fillers ("uh", "um") \u2014 call `Hold` instead of answering. This keeps listening for the rest of their thought. Only respond with substance when you have a complete question or request.\nDispatch discipline: send ONE self-contained task per request \u2014 a single worker with the full brief beats several workers with fragments (each worker starts fresh and re-discovers context). NEVER dispatch a worker just to read files or gather information \u2014 workers explore and discover context themselves; pass on what you already know and let one worker do the whole job. Split into parallel tasks only when the user asks for genuinely independent things. When a task completes, report its result and stop \u2014 do NOT dispatch follow-up work (verification, polish, extras) the user did not ask for, unless the report itself signals failure or doubt.\nDo not fire a second Act/Think for work already in flight, and NEVER spawn a second task to re-count, cross-check, or verify a result a worker already gave you \u2014 trust its answer; a single question gets ONE task. Call `TaskStatus` at most ONCE per turn; if a task is still running, give one brief status aside and end the turn \u2014 never poll it again and again in a loop. "Still on it" is ONLY for a repeat check on a task you already acknowledged earlier \u2014 NEVER as the ack when you first dispatch work ("still" is wrong when nothing was running yet; use a fresh "on it"/"got it, checking" instead). Use `CancelTask` when the user asks to stop something.\nPRIORITY: when the user says goodbye or wants to end/finish/wrap up the session ("ok bye", "that\'s all", "let\'s finish", "let\'s end", "goodnight", "exit", "wrap up"), call `ExitSession` IMMEDIATELY \u2014 do not act, do not check status, just exit.\nFor TRIVIAL instant lookups only \u2014 current time, git branch, listing a folder, peeking at a small file, or checking your own `capabilities`/tools \u2014 use `QuickLook` (instant, no task). Whenever the user asks what you can do or whether you have some ability, QuickLook `capabilities` and answer from that \u2014 never guess. Anything requiring searching, reasoning, running commands, or editing goes through `Act`.\n{{MEMORY_SLOT}}\nUser messages may arrive via speech-to-text and can carry transcription artifacts \u2014 odd words, cut-offs, homophones ("for you" vs "folder"). Read for INTENT, not surface text. If a message seems garbled, surprising, or only half-parses, do NOT guess an action or improvise content from it \u2014 briefly confirm what they meant ("did you mean\u2026?") and wait. A one-line confirm beats a confident wrong answer or an invented response to a request you did not actually understand.';
8149
9027
  var THINK_GUIDANCE = "\u2022 `Think` \u2014 your brain. A premium reasoning model, FAR more expensive than Act. Reserve it for open-ended architecture/design questions, or a problem Act already FAILED at. ALL implementation work \u2014 coding, refactoring, debugging, edge cases, tests \u2014 goes to Act; Act is highly capable. Never send the same work to both.";
8150
9028
  var THINK_DISABLED_GUIDANCE = "(Think tier is not available \u2014 use Act for all escalations.)";
8151
9029
  var VOICE_STYLE_CONVERSATIONAL = `Speak like a person in a live conversation, not an assistant reading a script. React first, then deliver: a quick impulsive beat ("oh nice", "hmm, hold on", "ah, got it") before the substance. Use contractions always. Vary sentence length \u2014 some very short. Light fillers and backchannels are fine ("mm-hm", "right", "let's see") but at most one per reply \u2014 never stack them. When you escalate to Act or Think, say it like a human would ("hang on, let me actually dig into that \u2014 gimme a minute") instead of announcing a task. When a result comes back, react to it like you just found out ("okay so \u2014 turns out\u2026"). Match the user's energy: a quick question gets a quick answer \u2014 a few words is a perfectly good turn. Prefer a short answer plus an offer ("want the details?") over covering everything. Never narrate your own mechanics (no "I will now act", no task ids out loud).`;
@@ -8160,14 +9038,22 @@ var DuplexAgent = class _DuplexAgent {
8160
9038
  * (TaskRecord.createdTurn) so parking can tell a prior-topic task from the current turn's fresh one. */
8161
9039
  turn = 0;
8162
9040
  pendingEvents = [];
8163
- /** Spoken text of parked deliveries that have SETTLED, awaiting a trivial-barge resume decision on the
8164
- * next turn (a substantive turn clears it — the result stays in the transcript, recoverable by asking). */
9041
+ /** THE parked delivery: every piece the user has NOT heard, in speaking order — the unheard rest a barge cut out of
9042
+ * the voice queue (parkCutDelivery) followed by each segment a barge-parked task produced since (deliverSegment).
9043
+ * Nothing already voiced is ever added, so a resume never repeats what was heard. The next line's barge intent
9044
+ * decides: resume → spoken verbatim, aside → handed to the reflex to carry on after its answer (asideNote) — both
9045
+ * send the barge-parked tasks live again; steer/stop → cleared, and those tasks
9046
+ * are moved on from (the full result stays in the transcript, recoverable by asking). `task` = the owner, so a
9047
+ * task whose outcome goes to an integration turn takes its pieces back (dropParked). */
8165
9048
  parkedRedeliver = [];
8166
- /** A trivial barge arrived while a parked delivery was STILL RUNNING → resume it the moment it settles. */
8167
- awaitTrivialRedeliver = false;
8168
- /** Set by a SUBSTANTIVE turn after a barge: the user moved on, so a parked delivery that settles LATER
8169
- * must NOT arm a resume (else a much-later "go on" replays a stale result). Reset by a fresh barge. */
8170
- suppressParkedResume = false;
9049
+ /** parkedRedeliver length when the last barge parked (parkInFlightDeliveries): the cut it hands over later
9050
+ * (parkCutDelivery) was voiced BEFORE anything parked since, so it is inserted here. */
9051
+ bargeMark = 0;
9052
+ /** The last barge cut a REFLEX reply (parkCutDelivery): a "go on" is then the reflex's to answer — it runs a normal
9053
+ * turn (the host's interruption note continues the reply), and only then does the parked delivery resume. */
9054
+ reflexCut = false;
9055
+ /** Recent live task segments → their task id: attributes a cut delivery (parkCutDelivery) to the task that spoke it. */
9056
+ voicedBy = /* @__PURE__ */ new Map();
8171
9057
  /** Out-of-band follow-up attribution for the events coalescing into the next flush turn: TRUE iff ≥1 of
8172
9058
  * the tasks being integrated was NON-CLEAN (early-stop/failure). Carried out-of-band on the enqueue call
8173
9059
  * by the caller that KNOWS the outcome — a plain boolean the MODEL CANNOT PERTURB. It is NOT scanned from
@@ -8200,6 +9086,24 @@ var DuplexAgent = class _DuplexAgent {
8200
9086
  // chars of reflexBuf already forwarded to the host/TTS
8201
9087
  fabricationCut = false;
8202
9088
  // reflex emitted a reserved [task …] marker → suppress its tail
9089
+ leakCuts = [];
9090
+ // heads of analysis-register leaks cut this turn — scrubbed from the transcript at turn end
9091
+ leakStep = false;
9092
+ // the CURRENT step's tail is muted by a leak cut (the next step speaks again, unlike a fabricated marker)
9093
+ leakHit = false;
9094
+ // this turn (or its repair) had a leak cut — the dead-air fallback must not blame the user
9095
+ stepMarks = [];
9096
+ // reflexBuf offsets where a step ended (its text is complete there)
9097
+ leakVerdicts = /* @__PURE__ */ new Map();
9098
+ // judged suspect sentences this turn (true = leak)
9099
+ judging;
9100
+ // the in-flight leak judge (one at a time — the gate is sequential)
9101
+ turnGen = 0;
9102
+ // bumped whenever the gated stream is replaced — a late verdict for an old one is dropped
9103
+ turnSpoken = "";
9104
+ // reflex text actually forwarded to the host this turn
9105
+ turnBase = 0;
9106
+ // transcript length at turn start (scope for the leak scrub)
8203
9107
  /** TRUE for the duration of a re-voice turn that is integrating ≥1 NON-CLEAN task (turn-eligibility,
8204
9108
  * carried out-of-band — NOT derived from any worker/brief string). ANY Act/Think dispatched in such a
8205
9109
  * turn is stamped followUp:true. This GUARANTEES the dangerous direction is impossible: a genuine
@@ -8248,7 +9152,8 @@ var DuplexAgent = class _DuplexAgent {
8248
9152
  ...new Set(workerToolNames.filter((n) => n.startsWith("mcp__")).map((n) => n.slice(5).split("__")[0]))
8249
9153
  ];
8250
9154
  const workerMcp = mcpNames.length ? `, and it can use these MCP servers: ${[...new Set(mcpNames)].join(", ")}` + (mcpNames.some((n) => /browser/i.test(n)) ? ' \u2014 including driving a REAL browser (open tabs, navigate, click, screenshot), so answer "yes" if asked whether you can control/drive a browser and route an actual browse to Act' : "") : "";
8251
- const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + (o.emotionTags ? "\n" + EMOTION_TAGS_GUIDANCE : "") + `
9155
+ const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.quickLook?.sessions ? `
9156
+ Past conversations with this user are saved. When they refer to an EARLIER conversation or session ("last time", "the previous session", "you already did it before", "earlier today"), call QuickLook what:"sessions" (then what:"sessions" with path:"<id>" to read one) BEFORE answering \u2014 never say it does not exist without checking. Report only what the recap says: if it does not say why something happened, say you don't know \u2014 never guess a motive.` : "") + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + (o.emotionTags ? "\n" + EMOTION_TAGS_GUIDANCE : "") + `
8252
9157
  ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today", "now", "current", "recent", "latest", "this year" \u2014 to THIS moment. When the user asks about something recent (news, sports, events), they mean near this date, not a well-known past instance; brief your worker with the current year so it searches for what is happening NOW, not a famous older event.`;
8253
9158
  const tools = [
8254
9159
  ...o.reflexOptions?.tools ?? [],
@@ -8269,32 +9174,26 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8269
9174
  if (this.heldThisTurn) return;
8270
9175
  if (this.fabricationCut) return;
8271
9176
  if (this.turnDispatched && this.spokeBeforeDispatch) return;
8272
- const msg = ev.message;
8273
- this.reflexBuf += msg;
8274
- this.scrubStageDirections();
9177
+ this.reflexBuf += ev.message;
8275
9178
  const m = this.reflexBuf.match(RESERVED_EVENT_MARKER) ?? this.reflexBuf.match(RESERVED_EVENT_OPENER);
8276
9179
  if (m) {
8277
9180
  this.fabricationCut = true;
8278
- log16.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
8279
- const safe = this.reflexBuf.slice(this.reflexForwarded, m.index);
8280
- if (!safe) return;
8281
- if (safe.trim()) this.spokeThisTurn = true;
8282
- this.emitHost({ ...ev, message: safe });
9181
+ log21.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
9182
+ this.reflexBuf = this.reflexBuf.slice(0, Math.max(m.index, this.reflexForwarded));
9183
+ this.pumpReflex(true);
8283
9184
  return;
8284
9185
  }
8285
- const held = this.reflexBuf.length - this.reflexForwarded;
8286
- const partial = held > 0 && /\[\s*t?a?s?k?$/i.test(this.reflexBuf.slice(-Math.min(held, 6)));
8287
- let upto = partial ? this.reflexBuf.length - this.reflexBuf.slice(-6).match(/\[\s*t?a?s?k?$/i)[0].length : this.reflexBuf.length;
8288
- const paren = this.reflexBuf.lastIndexOf("(");
8289
- if (paren >= this.reflexForwarded && !this.reflexBuf.includes(")", paren) && this.reflexBuf.length - paren <= 80)
8290
- upto = Math.min(upto, paren);
8291
- const out = this.reflexBuf.slice(this.reflexForwarded, upto);
8292
- this.reflexForwarded = upto;
8293
- if (!out) return;
8294
- if (out.trim()) this.spokeThisTurn = true;
8295
- this.emitHost({ ...ev, message: out });
9186
+ this.pumpReflex(false);
8296
9187
  return;
8297
9188
  }
9189
+ if (ev?.kind === "turn_start" || ev?.kind === "tool_use") {
9190
+ this.stepMarks.push(this.reflexBuf.length);
9191
+ if (this.leakStep) {
9192
+ this.leakStep = false;
9193
+ this.reflexForwarded = this.reflexBuf.length;
9194
+ }
9195
+ this.pumpReflex(false);
9196
+ }
8298
9197
  host.notify?.(ev);
8299
9198
  }
8300
9199
  };
@@ -8328,16 +9227,163 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8328
9227
  this.voice.options.tools.push(...mem.tools);
8329
9228
  if (mem.index) this.voice.options.systemPrompt += "\n\n" + mem.index;
8330
9229
  }
8331
- /** Flush any held-back trailing fragment (a possible `[task` opener that never completed) once the
8332
- * turn's stream is done — so a legit message ending in "[t" isn't silently dropped. */
8333
- flushHeldReflexTail() {
8334
- if (this.fabricationCut) return;
8335
- this.scrubStageDirections();
8336
- const tail = this.reflexBuf.slice(this.reflexForwarded);
8337
- this.reflexForwarded = this.reflexBuf.length;
8338
- if (!tail) return;
8339
- if (tail.trim()) this.spokeThisTurn = true;
8340
- this.emitHost({ kind: "text_delta", message: tail });
9230
+ /** Forward what the reflex's spoken-stream gates allow (text_delta → host/TTS; forwarded audio can't be unsent):
9231
+ * - hold a trailing fragment that could be a `[task` opener split across deltas ("[ta" + "sk"), and an UNCLOSED
9232
+ * "(" (a stage direction is judged — and dropped — only once its ")" arrives; bounded at 80 chars);
9233
+ * - the leak gate: whole sentences only, each screened by isLeakSuspect the moment it completes — a strict match
9234
+ * is cut, any other suspect waits for the decider's verdict (judgeLeak) while the rest queues behind it.
9235
+ * `final` = the stream is done: release the holds and judge whatever is left as a sentence. Text before a step
9236
+ * boundary (stepMarks) is complete — no holds, its tail is a sentence. Idempotent; re-run as verdicts land. */
9237
+ pumpReflex(final) {
9238
+ for (; ; ) {
9239
+ if (this.leakStep) return;
9240
+ this.scrubStageDirections();
9241
+ const from = this.reflexForwarded;
9242
+ const buf = this.reflexBuf;
9243
+ const lastMark = Math.max(from, ...this.stepMarks);
9244
+ let upto = buf.length;
9245
+ if (!final && upto > lastMark) {
9246
+ const tail = buf.slice(Math.max(lastMark, upto - 6)).match(/\[\s*t?a?s?k?$/i);
9247
+ if (tail) upto -= tail[0].length;
9248
+ const paren = buf.lastIndexOf("(");
9249
+ if (paren >= lastMark && !buf.includes(")", paren) && buf.length - paren <= 80) upto = Math.min(upto, paren);
9250
+ }
9251
+ if (upto <= from) return;
9252
+ const region = buf.slice(from, upto);
9253
+ const ends = [...region.matchAll(SENTENCE_END)].map((m) => m.index + m[0].length);
9254
+ for (const k of this.stepMarks) if (k > from && k <= upto) ends.push(k - from);
9255
+ if (final || !ends.length && region.length >= 160) ends.push(region.length);
9256
+ ends.sort((a, b) => a - b);
9257
+ let ok = 0, leak;
9258
+ for (const e of ends) {
9259
+ if (e <= ok) continue;
9260
+ const v = this.leakVerdict(region.slice(ok, e));
9261
+ if (v === void 0) break;
9262
+ if (v) {
9263
+ leak = region.slice(ok, e);
9264
+ break;
9265
+ }
9266
+ ok = e;
9267
+ }
9268
+ this.forwardReflex(from + ok);
9269
+ if (leak === void 0) return;
9270
+ const at = from + ok;
9271
+ this.leakHit = true;
9272
+ this.leakCuts.push(leak.trim().slice(0, 40));
9273
+ log21.warn(`reflex spoke its own analysis ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
9274
+ this.notify("diag", "analysis_leak", { text: leak.trim().slice(0, 80), at: this.turnSpoken.trim() ? "mid_reply" : "before_speech" });
9275
+ const next = this.stepMarks.find((k) => k > at);
9276
+ if (next === void 0) {
9277
+ this.reflexForwarded = buf.length;
9278
+ this.leakStep = true;
9279
+ return;
9280
+ }
9281
+ this.reflexForwarded = next;
9282
+ }
9283
+ }
9284
+ forwardReflex(to) {
9285
+ const out = this.reflexBuf.slice(this.reflexForwarded, to);
9286
+ this.reflexForwarded = Math.max(this.reflexForwarded, to);
9287
+ if (!out) return;
9288
+ if (out.trim()) this.spokeThisTurn = true;
9289
+ this.turnSpoken += out;
9290
+ this.emitHost({ kind: "text_delta", message: out });
9291
+ }
9292
+ /** Leak verdict for one complete sentence: false = speak, true = cut, undefined = the judge is still out.
9293
+ * Non-suspects pass free; strict shapes are cut without a call; other suspects go to the decider (if any) —
9294
+ * one call at a time, cached per turn. A failed/slow judge fails OPEN (speech is never muted on a guess). */
9295
+ leakVerdict(sentence) {
9296
+ const s = sentence.trim();
9297
+ if (!s || !isLeakSuspect(s)) return false;
9298
+ if (isStrictLeak(s)) return true;
9299
+ const decider = this.options.decider;
9300
+ if (!decider) return false;
9301
+ const known = this.leakVerdicts.get(s);
9302
+ if (known !== void 0 || this.judging) return known;
9303
+ const gen = this.turnGen, t0 = performance.now();
9304
+ const userSaid = [...this.voice.transcript].reverse().map((m) => m.role === "user" ? contentText(m.content) : "").find((t) => t && !t.startsWith("[reminder]")) ?? "";
9305
+ let timer;
9306
+ const timeout = new Promise((_, rej) => {
9307
+ timer = setTimeout(() => rej(new Error(`no verdict in ${_DuplexAgent.LEAK_JUDGE_TIMEOUT_MS}ms`)), _DuplexAgent.LEAK_JUDGE_TIMEOUT_MS);
9308
+ });
9309
+ this.judging = Promise.race([judgeLeak(decider, s, userSaid), timeout]).then((p) => {
9310
+ this.notify("diag", "leak_judge", { text: s.slice(0, 80), p: Math.round(p * 100) / 100, ms: Math.round(performance.now() - t0) });
9311
+ return p >= LEAK_JUDGE_THRESHOLD;
9312
+ }, (e) => {
9313
+ log21.warn(`leak judge failed \u2014 speaking the sentence: ${e.message}`);
9314
+ return false;
9315
+ }).then((leak) => {
9316
+ clearTimeout(timer);
9317
+ if (gen !== this.turnGen) return;
9318
+ this.judging = void 0;
9319
+ this.leakVerdicts.set(s, leak);
9320
+ this.pumpReflex(false);
9321
+ });
9322
+ return void 0;
9323
+ }
9324
+ static LEAK_JUDGE_TIMEOUT_MS = 1500;
9325
+ /** The stream is done: settle every pending verdict and release everything the gates still hold. */
9326
+ async drainReflex() {
9327
+ for (; ; ) {
9328
+ this.pumpReflex(true);
9329
+ if (!this.judging) return;
9330
+ await this.judging;
9331
+ }
9332
+ }
9333
+ /** Drop any in-flight judge of the current stream (its verdict must not pump a stream that moved on). */
9334
+ dropJudge() {
9335
+ this.turnGen++;
9336
+ this.judging = void 0;
9337
+ }
9338
+ /** Remove a cut analysis leak from this turn's assistant message(s) — left in, it primes the next turn to
9339
+ * leak again and reads as something we said. Keeps any legit prefix; drops a message left empty. */
9340
+ scrubLeakFromTranscript() {
9341
+ const keys = this.leakCuts.splice(0);
9342
+ const t = this.voice.transcript;
9343
+ for (const key of keys) {
9344
+ let i = t.length - 1;
9345
+ while (i >= this.turnBase && !(t[i].role === "assistant" && typeof t[i].content === "string" && t[i].content.includes(key))) i--;
9346
+ if (i < this.turnBase) {
9347
+ log21.warn("analysis leak cut, but its text was not found in the transcript to scrub");
9348
+ continue;
9349
+ }
9350
+ const m = t[i];
9351
+ m.content = m.content.slice(0, m.content.indexOf(key)).trimEnd();
9352
+ if (!m.content && !m.tool_calls?.length) t.splice(i, 1);
9353
+ }
9354
+ }
9355
+ /** A Hold turn is silent BY DESIGN: every reflex text after the Hold is muted (voiceHost drops it), so none of it may
9356
+ * persist either — the transcript is what the agent SAID. Live 20260923-125143 msg 10: the model's reasoning ("We
9357
+ * need to respond according to guidelines… So we should hold") was saved as an assistant reply and fed every later
9358
+ * turn. Strips the text of assistant messages after the Hold call (tool calls stay — they happened); a message left
9359
+ * empty is removed. */
9360
+ scrubHeldText() {
9361
+ if (!this.heldThisTurn) return;
9362
+ const t = this.voice.transcript;
9363
+ let i = this.turnBase;
9364
+ while (i < t.length && !t[i].tool_calls?.some((c) => c.function.name === "Hold")) i++;
9365
+ for (let j = t.length - 1; j > i; j--) {
9366
+ const m = t[j];
9367
+ if (m.role !== "assistant" || !contentText(m.content).trim()) continue;
9368
+ if (m.tool_calls?.length) m.content = "";
9369
+ else t.splice(j, 1);
9370
+ }
9371
+ }
9372
+ /** Shared turn epilogue (user turn, confirmed speculation, re-voice): release held text, scrub a cut leak,
9373
+ * then repair dead air — unless the turn was ABORTED: the user took the floor (barge-in / Esc), which is
9374
+ * not dead air, and a nudge would run on the same aborted signal (live: the "[reminder]" landed in the
9375
+ * transcript as a user turn with no reply, session 20260923-101910 msg 27). */
9376
+ async endTurn(res, fallback) {
9377
+ if (res?.finishReason === "aborted") {
9378
+ this.dropJudge();
9379
+ this.scrubLeakFromTranscript();
9380
+ this.scrubHeldText();
9381
+ return;
9382
+ }
9383
+ await this.drainReflex();
9384
+ this.scrubLeakFromTranscript();
9385
+ this.scrubHeldText();
9386
+ if (this.silentTurn) await this.ackIfSilent(fallback);
8341
9387
  }
8342
9388
  /** Remove complete stage-direction parentheticals from the UNFORWARDED reflex text (STAGE_DIRECTION_RE).
8343
9389
  * Only the unforwarded region is touched — already-spoken audio can't be unsent, and splicing before
@@ -8359,6 +9405,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8359
9405
  this.reflexBuf = "";
8360
9406
  this.reflexForwarded = 0;
8361
9407
  this.fabricationCut = false;
9408
+ this.leakCuts = [];
9409
+ this.leakStep = this.leakHit = false;
9410
+ this.stepMarks = [];
9411
+ this.leakVerdicts.clear();
9412
+ this.turnSpoken = "";
9413
+ this.offerOpen = false;
9414
+ this.dropJudge();
9415
+ this.turnBase = this.voice.transcript.length;
8362
9416
  this.turnFollowUp = false;
8363
9417
  this.voice.options.toolChoice = void 0;
8364
9418
  }
@@ -8370,7 +9424,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8370
9424
  return {
8371
9425
  preToolUse: (call) => {
8372
9426
  if (this.spec?.state === "pending" && !_DuplexAgent.SPEC_SAFE_TOOLS.has(call.name)) {
8373
- log16.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
9427
+ log21.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
8374
9428
  this.abortSpeculation();
8375
9429
  return { block: true, reason: "Speculative turn aborted." };
8376
9430
  }
@@ -8404,16 +9458,27 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8404
9458
  * Wording adapts to whether work was dispatched (an ack) or the inline reply was simply lost. */
8405
9459
  async ackIfSilent(fallback) {
8406
9460
  const dispatched = this.turnDispatched;
9461
+ const reminder = fallback ? "[reminder] You said nothing to the user this turn. Tell them, in ONE short spoken sentence, what just happened \u2014 no tools." : dispatched ? "[reminder] You dispatched a task but said nothing to the user. Say ONE short spoken acknowledgement now \u2014 no tools." : "[reminder] You said nothing to the user this turn. Give your ONE short spoken reply now \u2014 no tools.";
9462
+ const base = this.voice.transcript.length;
9463
+ this.fabricationCut = this.leakStep = false;
9464
+ this.reflexBuf = "";
9465
+ this.reflexForwarded = 0;
9466
+ this.stepMarks = [];
9467
+ this.dropJudge();
8407
9468
  this.nudging = true;
8408
9469
  try {
8409
- await this.voice.send(fallback ? "[reminder] You said nothing to the user this turn. Tell them, in ONE short spoken sentence, what just happened \u2014 no tools." : dispatched ? "[reminder] You dispatched a task but said nothing to the user. Say ONE short spoken acknowledgement now \u2014 no tools." : "[reminder] You said nothing to the user this turn. Give your ONE short spoken reply now \u2014 no tools.");
9470
+ await this.voice.send(reminder);
8410
9471
  } catch (e) {
8411
- log16.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
9472
+ log21.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
8412
9473
  } finally {
8413
9474
  this.nudging = false;
9475
+ const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
9476
+ if (i >= 0) this.voice.transcript.splice(i, 1);
9477
+ await this.drainReflex();
9478
+ this.scrubLeakFromTranscript();
8414
9479
  }
8415
9480
  if (!this.spokeThisTurn) {
8416
- const pool = fallback ? [fallback] : dispatched ? _DuplexAgent.FALLBACK_ACKS : _DuplexAgent.FALLBACK_RETRY;
9481
+ const pool = fallback ? [fallback] : dispatched ? _DuplexAgent.FALLBACK_ACKS : this.leakHit ? _DuplexAgent.FALLBACK_OWN : _DuplexAgent.FALLBACK_RETRY;
8417
9482
  const fresh = pool.filter((p) => p !== this.lastFallback);
8418
9483
  const line = (fresh.length ? fresh : pool)[Math.floor(Math.random() * (fresh.length || pool.length))];
8419
9484
  this.lastFallback = line;
@@ -8424,26 +9489,134 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8424
9489
  * hosts/tests key on it. */
8425
9490
  static FALLBACK_ACKS = ["Okay, on it.", "On it.", "Alright, working on it."];
8426
9491
  static FALLBACK_RETRY = ["Sorry, could you say that again?", "Hm, I missed that \u2014 could you say that again?"];
9492
+ static FALLBACK_OWN = ["Sorry, I lost my train of thought there \u2014 give me that one more time?", "Hm, I got tangled up \u2014 ask me that once more?"];
8427
9493
  lastFallback = "";
8428
9494
  /** One user turn: the voice agent streams the reply (and may Act/Think). Serialized with re-voice turns.
8429
9495
  * If a speculative reflex call is pending and this content CONFIRMS it (the final matches the
8430
9496
  * speculated partial), the in-flight call is adopted as THE turn: its buffered output flushes to the
8431
9497
  * host NOW (this is the latency win) and streaming continues live. Any other content aborts the
8432
- * speculation first (rolled back silently) and runs a normal turn behind it. */
8433
- send(content) {
8434
- if (typeof content === "string" && isTrivialBarge(content) && this.hasParkedDelivery()) {
8435
- if (this.spec) this.abortSpeculation();
8436
- return this.enqueue(async () => {
8437
- this.resetTurn();
8438
- if (!this.redeliverParked()) this.awaitTrivialRedeliver = true;
8439
- return { text: "", steps: 0, finishReason: "stop", messages: [] };
9498
+ * speculation first (rolled back silently) and runs a normal turn behind it.
9499
+ * `opts.id` names the turn; `opts.replaces` names an earlier one this content REPLACES — the voice engine's
9500
+ * fuller re-finalization of an utterance whose reply had not started ("…previous" → "…previous
9501
+ * conversation."). The replaced turn is skipped if it has not run yet, else aborted and rolled back out of
9502
+ * the transcript (kept only if it already dispatched work) — one reply, to the full text. */
9503
+ send(content, opts) {
9504
+ if (opts?.replaces) this.replaceTurn(opts.replaces);
9505
+ const cur = this.voice.options.signal;
9506
+ const ext = cur && cur === this.turnSignal?.own ? this.turnSignal.ext : cur;
9507
+ const rec = opts?.id ? this.trackTurn(opts.id) : void 0;
9508
+ if (rec?.superseded) {
9509
+ this.notify("diag", "turn_replaced", { id: opts.id, ran: false });
9510
+ return Promise.resolve(_DuplexAgent.replacedResult(this.voice.transcript));
9511
+ }
9512
+ const reflexCut = this.reflexCut;
9513
+ this.reflexCut = false;
9514
+ if (typeof content === "string" && this.hasParkedDelivery()) {
9515
+ const fallback = () => isTrivialBarge(content) ? "resume" : "steer";
9516
+ const accepted = this.offerOpen && this.parkedRedeliver.length > 0 && this.parkedRedeliver.every((p) => p.task === this.offer.task) && (isOfferAcceptance(content) || isTrivialBarge(content));
9517
+ const judge = accepted ? void 0 : this.options.bargeIntent;
9518
+ return (accepted ? Promise.resolve("resume") : judge ? judge(content).catch((e) => {
9519
+ log21.warn(`bargeIntent judge failed \u2014 using the allowlist: ${e.message}`);
9520
+ return fallback();
9521
+ }) : Promise.resolve(fallback())).then((intent) => {
9522
+ this.notify("diag", "barge_intent", { intent, text: content.slice(0, 80) });
9523
+ if (intent === "resume" && !reflexCut) return this.resumeParked();
9524
+ if (intent === "aside" || intent === "resume") {
9525
+ const told = intent === "aside" ? [...this.parkedRedeliver] : [];
9526
+ return this.sendTurn(intent === "aside" ? content + this.asideNote(told) : content, true, rec, ext).then(async (r) => {
9527
+ if (r.finishReason === "aborted" || this.heldThisTurn) return r;
9528
+ const missed = unsaidClauses(told.map((p) => p.text), this.turnSpoken, this.options.decider);
9529
+ const turn = this.turn;
9530
+ this.parkedRedeliver = this.parkedRedeliver.filter((p) => !told.includes(p));
9531
+ await this.resumeParked();
9532
+ void missed.then((m) => {
9533
+ const dropped = m.filter((c) => !isQuestion(c)).join(" ").replace(/[,;]$/, ".");
9534
+ if (!dropped || this.turn !== turn) return;
9535
+ this.parkedRedeliver.unshift({ text: dropped, task: told.at(-1)?.task });
9536
+ this.notify("diag", "aside_dropped", { text: dropped.slice(0, 80) });
9537
+ });
9538
+ return r;
9539
+ });
9540
+ }
9541
+ return this.sendTurn(content, false, rec, ext);
8440
9542
  });
8441
9543
  }
9544
+ return this.sendTurn(content, false, rec, ext);
9545
+ }
9546
+ /** Host-named user turns (send opts.id), so a later send can replace one. Bounded; a replace that arrives
9547
+ * BEFORE its target (the host's dispatch hops can reorder) leaves a pre-superseded record behind. */
9548
+ userTurns = /* @__PURE__ */ new Map();
9549
+ turnSignal;
9550
+ // the combined signal a named turn installed while it runs
9551
+ trackTurn(id) {
9552
+ let rec = this.userTurns.get(id);
9553
+ if (!rec) {
9554
+ rec = { ctl: new AbortController(), done: false, superseded: false };
9555
+ this.userTurns.set(id, rec);
9556
+ if (this.userTurns.size > 32) this.userTurns.delete(this.userTurns.keys().next().value);
9557
+ }
9558
+ return rec;
9559
+ }
9560
+ replaceTurn(id) {
9561
+ const rec = this.trackTurn(id);
9562
+ if (rec.done) {
9563
+ const drop = rec.dropped;
9564
+ rec.dropped = void 0;
9565
+ if (drop?.length) void this.enqueue(async () => {
9566
+ const t = this.voice.transcript;
9567
+ for (const m of drop) {
9568
+ const i = t.indexOf(m);
9569
+ if (i >= 0) t.splice(i, 1);
9570
+ }
9571
+ this.notify("diag", "turn_replaced", { ran: true, rolledBack: true, late: true });
9572
+ });
9573
+ return;
9574
+ }
9575
+ if (rec.superseded) return;
9576
+ rec.superseded = true;
9577
+ rec.ctl.abort();
9578
+ }
9579
+ static replacedResult(messages) {
9580
+ return { text: "", steps: 0, finishReason: "aborted", messages };
9581
+ }
9582
+ /** A turn replaced mid-run: mute whatever it still holds, and erase it from the transcript (the replacing turn
9583
+ * carries the user's full words) — unless it already dispatched work or SPOKE: the task is real, and the user
9584
+ * heard those words, so both stay visible to the replacing turn. An aborted stream never reaches the transcript,
9585
+ * so what was spoken is recorded as the replaced turn's reply. */
9586
+ dropReplacedTurn() {
9587
+ this.fabricationCut = true;
9588
+ this.reflexBuf = this.reflexBuf.slice(0, this.reflexForwarded);
9589
+ this.dropJudge();
9590
+ const spoke = this.turnSpoken.trim();
9591
+ const rolledBack = !this.turnDispatched && !spoke;
9592
+ if (rolledBack) this.voice.transcript.length = this.turnBase;
9593
+ else if (spoke) {
9594
+ const t = this.voice.transcript;
9595
+ const have = t.slice(this.turnBase).filter((m) => m.role === "assistant").map((m) => contentText(m.content)).join(" ");
9596
+ if (!have.includes(spoke)) t.push({ role: "assistant", content: spoke });
9597
+ }
9598
+ this.notify("diag", "turn_replaced", { ran: true, rolledBack, spoke: !!spoke });
9599
+ return _DuplexAgent.replacedResult(this.voice.transcript);
9600
+ }
9601
+ resumeParked() {
9602
+ if (this.spec) this.abortSpeculation();
9603
+ return this.enqueue(async () => {
9604
+ this.resetTurn();
9605
+ this.redeliverParked();
9606
+ for (const t of this.tasks.values()) if (t.status === "running" && t.deliveryParked === "barge") t.deliveryParked = void 0;
9607
+ return { text: "", steps: 0, finishReason: "stop", messages: [] };
9608
+ });
9609
+ }
9610
+ sendTurn(content, keepParked = false, rec, ext) {
8442
9611
  const spec = this.spec;
8443
9612
  if (spec?.state === "pending") {
8444
9613
  if (typeof content === "string" && speculationConfirms(spec.text, content)) {
8445
9614
  spec.state = "confirmed";
8446
9615
  spec.finalText = content;
9616
+ if (rec) {
9617
+ spec.rec = rec;
9618
+ rec.ctl.signal.addEventListener("abort", () => spec.ctl.abort(), { once: true });
9619
+ }
8447
9620
  for (const ev of spec.buf.splice(0)) this.options.host?.notify?.(ev);
8448
9621
  spec.decide("confirm");
8449
9622
  this.notify("diag", "speculation_confirmed", { text: spec.text.slice(0, 80) });
@@ -8455,12 +9628,32 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8455
9628
  await this.initMemory();
8456
9629
  this.resetTurn();
8457
9630
  this.turn++;
8458
- this.awaitTrivialRedeliver = false;
8459
- this.parkedRedeliver = [];
8460
- this.suppressParkedResume = true;
8461
- const res = await this.voice.send(content);
8462
- this.flushHeldReflexTail();
8463
- if (this.silentTurn) await this.ackIfSilent();
9631
+ if (!keepParked) {
9632
+ this.parkedRedeliver = [];
9633
+ for (const t of this.tasks.values()) if (t.deliveryParked === "barge") t.deliveryParked = "moved_on";
9634
+ }
9635
+ if (rec?.superseded) {
9636
+ this.notify("diag", "turn_replaced", { ran: false });
9637
+ return _DuplexAgent.replacedResult(this.voice.transcript);
9638
+ }
9639
+ const signal = rec && (ext ? AbortSignal.any([ext, rec.ctl.signal]) : rec.ctl.signal);
9640
+ if (signal) {
9641
+ this.voice.options.signal = signal;
9642
+ this.turnSignal = { own: signal, ext };
9643
+ }
9644
+ let res;
9645
+ try {
9646
+ res = await this.voice.send(content);
9647
+ } finally {
9648
+ if (signal) {
9649
+ this.voice.options.signal = ext;
9650
+ this.turnSignal = void 0;
9651
+ }
9652
+ if (rec) rec.done = true;
9653
+ }
9654
+ if (rec?.superseded) return this.dropReplacedTurn();
9655
+ if (rec && res.finishReason === "aborted" && !this.turnDispatched && !this.turnSpoken.trim()) rec.dropped = this.voice.transcript.slice(this.turnBase);
9656
+ await this.endTurn(res);
8464
9657
  return res;
8465
9658
  });
8466
9659
  }
@@ -8501,7 +9694,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8501
9694
  try {
8502
9695
  res = await this.voice.send(spec.text);
8503
9696
  } catch (e) {
8504
- log16.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
9697
+ log21.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
8505
9698
  } finally {
8506
9699
  this.voice.options.signal = prevSignal;
8507
9700
  }
@@ -8515,7 +9708,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8515
9708
  if (d === "abort") {
8516
9709
  if (this.voice.transcript.length > base) this.voice.transcript.length = base;
8517
9710
  this.speculativeAbortedCalls++;
8518
- log16.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
9711
+ log21.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
8519
9712
  if (this.spec === spec) this.spec = void 0;
8520
9713
  return res ?? empty;
8521
9714
  }
@@ -8527,8 +9720,9 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8527
9720
  }
8528
9721
  }
8529
9722
  this.spec = void 0;
8530
- this.flushHeldReflexTail();
8531
- if (this.silentTurn) await this.ackIfSilent();
9723
+ if (spec.rec) spec.rec.done = true;
9724
+ if (spec.rec?.superseded) return this.dropReplacedTurn();
9725
+ await this.endTurn(res);
8532
9726
  return res ?? empty;
8533
9727
  });
8534
9728
  }
@@ -8557,37 +9751,125 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8557
9751
  * superseded topic never talks over the new one (the debt-after-jokes regression). Tasks keep running and
8558
9752
  * still fold their result into the transcript — recoverable, just not spoken. Returns parked ids (logging).
8559
9753
  * Does NOT cancel. Two callers, two shapes:
8560
- * - BARGE (default): the user interrupted AUDIO. Park EVERY running task (including this turn's) — the
8561
- * worker they cut off must stop, whatever turn launched it — and re-arm trivial-barge auto-resume so a
8562
- * following "go on" can bring the parked delivery back.
8563
- * - SUPERSEDE (`priorTurnsOnly`, `reenableResume:false`, from dispatch()): a new dispatch redirected to
8564
- * fresh work. Park only PRIOR-turn tasks so parallel siblings dispatched in the SAME turn don't silence
8565
- * each other, and keep them suppressed (the user moved on — don't resurface on a stray "go on"). */
8566
- parkInFlightDeliveries(reenableResume = true, priorTurnsOnly = false) {
8567
- if (reenableResume) this.suppressParkedResume = false;
9754
+ * - BARGE (default): the user interrupted AUDIO. Park EVERY live running task (including this turn's) — the
9755
+ * worker they cut off must stop, whatever turn launched it — as 'barge', so a following "go on" can bring
9756
+ * it back. A task the user already moved on from stays moved on: a barge never revives it.
9757
+ * - SUPERSEDE (`priorTurnsOnly`, `barge:false`, from dispatch()): a new dispatch redirected to fresh work.
9758
+ * Park only PRIOR-turn live tasks so parallel siblings dispatched in the SAME turn don't silence each other,
9759
+ * as 'moved_on' (don't resurface on a stray "go on"). */
9760
+ parkInFlightDeliveries(barge = true, priorTurnsOnly = false) {
9761
+ if (barge) {
9762
+ this.bargeMark = this.parkedRedeliver.length;
9763
+ this.reflexCut = false;
9764
+ this.offerOpen = false;
9765
+ }
8568
9766
  const parked = [];
8569
9767
  for (const rec of this.tasks.values())
8570
9768
  if (rec.status === "running" && !rec.deliveryParked && (!priorTurnsOnly || rec.createdTurn < this.turn)) {
8571
- rec.deliveryParked = true;
9769
+ rec.deliveryParked = barge ? "barge" : "moved_on";
8572
9770
  parked.push(rec.id);
8573
9771
  }
8574
9772
  return parked;
8575
9773
  }
8576
- /** True while there is a parked delivery to potentially resume: either one already settled (queued) or
8577
- * one still running that was parked by a barge. */
9774
+ /** A barge-in cut a worker delivery the VOICE ENGINE was still speaking — the queued utterances it dropped plus the
9775
+ * unheard rest of the one playing (cli splitCut). It joins the parked delivery AHEAD of whatever parked tasks
9776
+ * produced since the barge (it was voiced first); the user's next line runs the barge-intent judge — resume it,
9777
+ * answer an aside and carry it on, or drop it on a steer/stop. `heard` = the cut utterance's part the user DID hear
9778
+ * (the aside note tells the reflex what was heard vs missed).
9779
+ * Live 20260923-125143: "Oh, that— that's new." cut an iOS-news delivery and its 6 remaining utterances vanished.
9780
+ * `reflexCut` = the barge cut a REFLEX reply (the host's cut had a reply): a "go on" then runs the reflex (see send).
9781
+ * A cut spoken by a task the user moved on from (or whose outcome went to an integration turn) is not parked. */
9782
+ parkCutDelivery(text, reflexCut = false, heard = "") {
9783
+ this.reflexCut = reflexCut;
9784
+ const t = text.trim();
9785
+ if (!t) return;
9786
+ if (this.offer.text && this.offer.text.endsWith(t)) {
9787
+ this.offerOpen = !reflexCut;
9788
+ return;
9789
+ }
9790
+ if (t.split(SENTENCE_SPLIT).every(isQuestion)) return;
9791
+ const first = t.split(/(?<=[.!?])\s/)[0];
9792
+ const task = [...this.voicedBy].reverse().find(([seg]) => t.includes(seg) || first.length >= 16 && seg.includes(first))?.[1];
9793
+ if (task && this.tasks.get(task)?.deliveryParked === "moved_on") return;
9794
+ this.parkedRedeliver.splice(Math.min(this.bargeMark, this.parkedRedeliver.length), 0, { text: t, task, ...heard ? { heard } : {} });
9795
+ this.notify("diag", "delivery_parked", { chars: t.length });
9796
+ }
9797
+ /** One spoken segment of a task's delivery — the ONLY path a worker's speech takes (streamed segments, the settle
9798
+ * tail, the no-spoken fallback). Live → the voice queue; barge-parked → the parked delivery, in order; superseded
9799
+ * (the user moved on) → nowhere (the result still folds into the transcript at settle). */
9800
+ deliverSegment(rec, seg) {
9801
+ if (!seg.trim()) return;
9802
+ if (rec) rec.spokenText = (rec.spokenText ? rec.spokenText + " " : "") + seg;
9803
+ if (!rec?.deliveryParked) {
9804
+ const [say, rest] = rec ? this.budgetSplit(rec, seg) : [seg, ""];
9805
+ if (say) {
9806
+ this.offerOpen = false;
9807
+ this.options.host?.notify?.({ kind: "speak_utterance", message: say });
9808
+ if (rec) {
9809
+ this.voicedBy.set(say.trim(), rec.id);
9810
+ if (this.voicedBy.size > 64) this.voicedBy.delete(this.voicedBy.keys().next().value);
9811
+ }
9812
+ }
9813
+ if (!rest || !rec) return;
9814
+ rec.deliveryParked = "barge";
9815
+ this.parkedRedeliver.push({ text: rest, task: rec.id });
9816
+ if (this.options.spokenOffer) {
9817
+ this.offer = { text: rec.capOffer = this.options.spokenOffer, task: rec.id };
9818
+ this.offerOpen = true;
9819
+ this.options.host?.notify?.({ kind: "speak_utterance", message: this.offer.text });
9820
+ }
9821
+ this.notify("diag", "delivery_budget", { task: rec.id, spoke: rec.voicedChars, parked: rest.length });
9822
+ } else if (rec.deliveryParked === "barge") this.parkedRedeliver.push({ text: seg, task: rec.id });
9823
+ }
9824
+ /** Split a delivery segment at the spoken budget (whole sentences; the first sentence always speaks): what to voice
9825
+ * now, and the rest to park. Marks the budget spent on a cut, so a resumed delivery runs on unbudgeted. */
9826
+ budgetSplit(rec, seg) {
9827
+ const cap = this.options.spokenBudgetChars;
9828
+ if (!cap || rec.budgetSpent) return [seg, ""];
9829
+ const sents = seg.trim().split(SENTENCE_SPLIT);
9830
+ let used = rec.voicedChars ?? 0, n = 0;
9831
+ while (n < sents.length && (!used || used + sents[n].length <= cap)) used += sents[n++].length + 1;
9832
+ const over = sents.slice(n);
9833
+ if (!over.length || over.length === 1 && isQuestion(over[0]) && over[0].length <= OFFER_OVERRUN && used <= cap) {
9834
+ rec.voicedChars = used + (over[0] ? over[0].length + 1 : 0);
9835
+ return [seg, ""];
9836
+ }
9837
+ rec.voicedChars = used;
9838
+ rec.budgetSpent = true;
9839
+ return [sents.slice(0, n).join(" "), sents.slice(n).join(" ")];
9840
+ }
9841
+ /** The cap offer last spoken and the task whose rest it offers — a barge over it is not a delivery to park
9842
+ * (parkCutDelivery). */
9843
+ offer = { text: "", task: "" };
9844
+ /** The cap offer was the last thing voiced (no delivery or turn has spoken since) — a plain yes accepts it (send). */
9845
+ offerOpen = false;
9846
+ /** The task's outcome now belongs to its integration turn (failed / stopped early): take back its parked pieces and
9847
+ * keep it from being revived — a "go on" must not replay raw segments of a result the reflex is about to explain. */
9848
+ dropParked(rec) {
9849
+ this.parkedRedeliver = this.parkedRedeliver.filter((p) => p.task !== rec.id);
9850
+ if (rec.deliveryParked) rec.deliveryParked = "moved_on";
9851
+ }
9852
+ /** True while there is a parked delivery the next line may resume: unheard text already parked, or a barge-parked
9853
+ * task still running (one the user moved on from does not count). */
8578
9854
  hasParkedDelivery() {
8579
- return this.parkedRedeliver.length > 0 || [...this.tasks.values()].some((t) => t.status === "running" && t.deliveryParked);
8580
- }
8581
- /** Speak any settled-and-queued parked delivery now (trivial-barge resume). Returns false if nothing was
8582
- * queued yet (the caller then arms awaitTrivialRedeliver so the task resumes the instant it settles). */
9855
+ return this.parkedRedeliver.length > 0 || [...this.tasks.values()].some((t) => t.status === "running" && t.deliveryParked === "barge");
9856
+ }
9857
+ /** The note for an aside over a parked delivery: the same reply-interruption note a cut reflex reply gets
9858
+ * (interruptionNote), over the delivery as it was being said — the cut utterance's heard part, then every unheard
9859
+ * piece. Nothing parked yet (the task is still working) → its result simply follows the answer. */
9860
+ asideNote(told) {
9861
+ const rest = told.map((p) => p.text).join(" ").trim();
9862
+ if (!rest) return "\n[you were in the middle of delivering a task result \u2014 answer this briefly; the rest of the result follows right after your answer. If their words are an unfinished fragment, call Hold instead.]";
9863
+ const heard = told[0]?.heard ?? "";
9864
+ return interruptionNote({ full: heard + rest, heard }, "aside");
9865
+ }
9866
+ /** Speak the parked delivery now (resume), in order, and clear it. */
8583
9867
  redeliverParked() {
8584
- if (!this.parkedRedeliver.length) return false;
8585
- const text = this.parkedRedeliver.splice(0).join(" ").trim();
8586
- if (text) {
8587
- this.options.host?.notify?.({ kind: "speak_utterance", message: text });
8588
- this.notify("diag", "parked_redelivered", { chars: text.length });
8589
- }
8590
- return true;
9868
+ const text = this.parkedRedeliver.splice(0).map((p) => p.text).join(" ").trim();
9869
+ if (!text) return;
9870
+ this.offerOpen = false;
9871
+ this.options.host?.notify?.({ kind: "speak_utterance", message: text });
9872
+ this.notify("diag", "parked_redelivered", { chars: text.length });
8591
9873
  }
8592
9874
  /** Resolve when all queued voice turns AND all in-flight worker tasks have settled (tests, graceful shutdown). */
8593
9875
  async idle() {
@@ -8643,9 +9925,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8643
9925
  const failed = events.find((e) => /^\[task\b[^\]\n]*\bfailed\b/i.test(e));
8644
9926
  this.resetTurn();
8645
9927
  this.turnFollowUp = nonCleanTurn;
8646
- await this.voice.send(events.join("\n"));
8647
- this.flushHeldReflexTail();
8648
- if (this.silentTurn) await this.ackIfSilent(failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
9928
+ const res = await this.voice.send(events.join("\n"));
9929
+ await this.endTurn(res, failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
8649
9930
  this.notify("revoice_done", "");
8650
9931
  });
8651
9932
  }
@@ -8653,13 +9934,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8653
9934
  * Act briefs get a self-verify footer — the worker's report is trusted without review, so it
8654
9935
  * must check its own work before reporting (nearly free under prompt caching; measured honest:
8655
9936
  * it does NOT fix one-shot logic bugs — see mind/10). Think tasks are pure reasoning — no footer. */
9937
+ /** Static text snapshot of the last `turns` user/assistant messages (`role: text` lines). */
9938
+ recentExcerpt(turns = this.options.excerptTurns) {
9939
+ return this.voice.transcript.filter((m) => (m.role === "user" || m.role === "assistant") && contentText(m.content).trim()).slice(-turns).map((m) => `${m.role}: ${contentText(m.content)}`).join("\n");
9940
+ }
8656
9941
  buildBrief(brief, tier = "act", deliver = true) {
8657
- const recent = this.voice.transcript.filter((m) => (m.role === "user" || m.role === "assistant") && contentText(m.content).trim()).slice(-this.options.excerptTurns).map((m) => `${m.role}: ${contentText(m.content)}`).join("\n");
9942
+ const recent = this.recentExcerpt();
8658
9943
  const verify = tier === "act" ? "\n\nBefore reporting done: re-read what you changed and check it against EVERY requirement above \u2014 fix any gap first. Your report is trusted without review." : "";
8659
- const deliverContract = deliver ? `
8660
-
8661
- ## DELIVER (spoken delivery)
8662
- You are reporting back to a user who is LISTENING. Stream your work normally \u2014 your prose is the written work record and detail, and is NOT spoken. Wrap anything the user should HEAR in <spoken>\u2026</spoken> tags. LEAD WITH the actual content they asked for: if they asked for a specific piece of content \u2014 a value, a name, the actual lines, the writing itself \u2014 that content goes INSIDE the <spoken> tags, not a remark about it. Your FIRST <spoken> segment is substantive \u2014 never a greeting or an acknowledgement (the front-end has already acked; do not double-ack). Keep spoken text concise and natural for the ear: short sentences, no markdown. NEVER enumerate in speech \u2014 no numbered or bulleted lists ("One. \u2026 Two. \u2026" is robotic). Deliver multiple items as flowing conversation with brief connective phrasing ("here's one\u2026", "and another\u2026", "oh, and\u2026"), pausing between items with sentence breaks, not numbers.` + (this.options.emotionTags ? " Inside <spoken>, you may prefix a sentence with an inline [emotion] tag (e.g. [excited], [curious]) to color how it is voiced \u2014 only when it genuinely fits, and vary it; [laughter] gives a natural laugh." : "") : "";
9944
+ const deliverContract = deliver ? "\n\n## DELIVER (spoken delivery)\nYou are reporting back to a user who is LISTENING. Stream your work normally \u2014 your prose is the written work record and detail, and is NOT spoken. Wrap anything the user should HEAR in <spoken>\u2026</spoken> tags. LEAD WITH the actual content they asked for: if they asked for a specific piece of content \u2014 a value, a name, the actual lines, the writing itself \u2014 that content goes INSIDE the <spoken> tags, not a remark about it. Your FIRST <spoken> segment is substantive \u2014 never a greeting or an acknowledgement (the front-end has already acked; do not double-ack). Keep spoken text concise and natural for the ear: short sentences, no markdown. " + (this.options.spokenBudgetChars ? `Speak a SHORT summary only: at most three short sentences with the one thing that matters most, then ONE short offer that names what else you have, as a question they can answer in a word \u2014 under ${this.options.spokenBudgetChars} characters in all, the offer included. Everything else stays in your written prose (it is kept and they can ask for it); spoken text past that budget is cut and held until they ask. ` : "") + `NEVER enumerate in speech \u2014 no numbered or bulleted lists ("One. \u2026 Two. \u2026" is robotic). Deliver multiple items as flowing conversation with brief connective phrasing ("here's one\u2026", "and another\u2026", "oh, and\u2026"), pausing between items with sentence breaks, not numbers.` + (this.options.emotionTags ? " Inside <spoken>, you may prefix a sentence with an inline [emotion] tag (e.g. [excited], [curious]) to color how it is voiced \u2014 only when it genuinely fits, and vary it; [laughter] gives a natural laugh." : "") : "";
8663
9945
  return `${nowLine()}.
8664
9946
 
8665
9947
  ` + (recent ? `${brief}
@@ -8705,12 +9987,7 @@ ${recent}` : brief) + verify + deliverContract;
8705
9987
  return a || "(no answer from the user \u2014 use your best judgment and note the assumption)";
8706
9988
  };
8707
9989
  const splitter = new SpokenSplitter();
8708
- const speak = (seg) => {
8709
- if (!seg) return;
8710
- const r = this.tasks.get(id);
8711
- if (r) r.spokenText = (r.spokenText ? r.spokenText + " " : "") + seg;
8712
- if (!r?.deliveryParked) o.host?.notify?.({ kind: "speak_utterance", message: seg });
8713
- };
9990
+ const speak = (seg) => this.deliverSegment(this.tasks.get(id), seg);
8714
9991
  const coalescer = new SentenceCoalescer();
8715
9992
  const feedSpoken = (s) => {
8716
9993
  const ready = coalescer.feed(s);
@@ -8724,6 +10001,7 @@ ${recent}` : brief) + verify + deliverContract;
8724
10001
  if (ev?.kind === "text_delta" && typeof ev.message === "string") {
8725
10002
  const { spoken, detail } = splitter.feed(ev.message);
8726
10003
  feedSpoken(spoken);
10004
+ if (!splitter.inSpoken) flushSpoken();
8727
10005
  if (detail.trim()) pushTail(detail.trim());
8728
10006
  return;
8729
10007
  }
@@ -8780,7 +10058,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8780
10058
  this.notify("task_verify", `task ${id}: verifying`, { id });
8781
10059
  const cres = await new Agent(checkerOpts).run(checkBrief);
8782
10060
  if (cres.finishReason !== "stop") {
8783
- log16.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
10061
+ log21.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
8784
10062
  this.notify("task_verify", `task ${id}: verify inconclusive (${cres.finishReason})`, { id, finishReason: cres.finishReason });
8785
10063
  }
8786
10064
  const sum = (a = 0, b = 0) => a + b;
@@ -8811,7 +10089,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8811
10089
  const due = () => {
8812
10090
  if (this.pendingAsks.size) return void 0;
8813
10091
  const rec = this.tasks.get(id);
8814
- return rec && rec.status === "running" && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
10092
+ return rec && rec.status === "running" && !rec.deliveryParked && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
8815
10093
  };
8816
10094
  const emit = (rec, line, call) => {
8817
10095
  lastAt = Date.now();
@@ -8916,7 +10194,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8916
10194
  rec.status = "done";
8917
10195
  rec.result = res.text;
8918
10196
  const incomplete = res.finishReason !== "stop" && res.finishReason !== "empty";
8919
- log16.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
10197
+ log21.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
8920
10198
  this.notify("task_done", `task ${id} (${rec.label}) completed`, {
8921
10199
  id,
8922
10200
  text: res.text,
@@ -8928,46 +10206,39 @@ Another agent just implemented the above. Independently check the CURRENT state
8928
10206
  });
8929
10207
  if (incomplete) {
8930
10208
  if (this.foldIfSuperseded(rec, res.text)) return;
10209
+ this.dropParked(rec);
8931
10210
  return this.queueRevoice(this.integrationPrompt(rec, "incomplete", res.text, res.finishReason), true);
8932
10211
  }
8933
10212
  const tail = rec.splitter?.flush();
8934
- if (tail?.spoken && !rec.deliveryParked) this.options.host?.notify?.({ kind: "speak_utterance", message: tail.spoken });
8935
- if (res.text.trim()) this.voice.transcript.push({ role: "assistant", content: res.text });
8936
- if (rec.deliveryParked && !this.suppressParkedResume) {
8937
- const gist = (rec.spokenText ?? "").trim() || res.text.trim();
8938
- if (gist) {
8939
- if (this.awaitTrivialRedeliver) {
8940
- this.awaitTrivialRedeliver = false;
8941
- this.options.host?.notify?.({ kind: "speak_utterance", message: gist });
8942
- this.notify("diag", "parked_redelivered", { chars: gist.length });
8943
- } else this.parkedRedeliver.push(gist);
8944
- }
8945
- }
8946
- if (!rec.splitter?.spokeAny && res.text.trim() && !rec.deliveryParked)
8947
- this.options.host?.notify?.({ kind: "speak_utterance", message: res.text });
10213
+ if (tail?.spoken) this.deliverSegment(rec, tail.spoken);
10214
+ if (!rec.splitter?.spokeAny && res.text.trim()) this.deliverSegment(rec, res.text);
10215
+ if (res.text.trim()) this.voice.transcript.push({ role: "assistant", content: res.text + (rec.capOffer ? `
10216
+
10217
+ (Said aloud in place of the rest of the spoken summary: "${rec.capOffer}")` : "") });
8948
10218
  }
8949
10219
  onWorkerFailed(id, err) {
8950
10220
  this.failTask(this.tasks.get(id), err instanceof Error ? err.message : String(err));
8951
10221
  }
8952
- /** A delivery the user has SUPERSEDED — parked AND moved on (suppressParkedResume, set by a redirect) —
10222
+ /** A delivery the user has SUPERSEDED — moved on from (deliveryParked 'moved_on', set by a redirect) —
8953
10223
  * must not hijack the voice when it settles late (partial or failed): that would talk over the new topic
8954
10224
  * and, worse, auto-escalate work the user already abandoned. Fold a recoverable note into the transcript
8955
10225
  * instead (the reflex can surface it if asked) and report true so the caller skips queueRevoice. A merely
8956
- * barge-parked task (suppressParkedResume false — a "go on" may still resume it) is NOT superseded: it
10226
+ * barge-parked task ('barge' — a "go on" may still resume it) is NOT superseded: it
8957
10227
  * keeps the normal surfacing path so a failure the user still cares about is not swallowed. */
8958
10228
  foldIfSuperseded(rec, note) {
8959
- if (!(rec.deliveryParked && this.suppressParkedResume)) return false;
10229
+ if (rec.deliveryParked !== "moved_on") return false;
8960
10230
  if (note.trim()) this.voice.transcript.push({ role: "assistant", content: note });
8961
- log16.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
10231
+ log21.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
8962
10232
  return true;
8963
10233
  }
8964
10234
  failTask(rec, msg) {
8965
10235
  this.dropAsk(rec.id);
8966
10236
  rec.status = "error";
8967
10237
  rec.result = msg;
8968
- log16.warn(`task ${rec.id} failed: ${msg}`);
10238
+ log21.warn(`task ${rec.id} failed: ${msg}`);
8969
10239
  this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`);
8970
10240
  if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
10241
+ this.dropParked(rec);
8971
10242
  this.queueRevoice(this.integrationPrompt(rec, "error", msg, "error"), true);
8972
10243
  }
8973
10244
  // --- voice tools (closures over this instance) ---
@@ -8996,10 +10267,18 @@ Another agent just implemented the above. Independently check the CURRENT state
8996
10267
  this.notify("task_started", `task ${id} (${lbl}) started`, { id, brief, tier });
8997
10268
  return id;
8998
10269
  }
10270
+ /** Tool result for a dispatch. The task is ALREADY RUNNING — live, the terse "Acknowledge briefly" drew
10271
+ * "Did you want me to look up today's news?" right after dispatching exactly that (session 20260923-101910
10272
+ * msg 18). Also truthful about delivery: a clean result is spoken by the worker itself and lands in
10273
+ * context (TaskStatus returns it); only failures come back as a [task … failed] event. */
10274
+ dispatchResult(id, verb) {
10275
+ const after = `The worker speaks its result to the user itself when done (TaskStatus ${id} returns it).`;
10276
+ return this.spokeBeforeDispatch ? `${verb} task ${id} \u2014 it is already running. You already acknowledged \u2014 end your turn now with NO further text. ${after}` : `${verb} task ${id} \u2014 it is already running. Now tell the user in ONE short sentence that you are on it (what you are doing) \u2014 do NOT ask whether they want it; it is already happening. ${after}`;
10277
+ }
8999
10278
  actTool() {
9000
10279
  return {
9001
10280
  name: "Act",
9002
- description: 'Escalate real work (reading/editing files, searching, running tasks, building) to a standard background worker. Returns immediately with a task id; the result arrives later as a "[task <id> completed]" event. Provide a clear, self-contained `brief` (the worker does not hear the live conversation).',
10281
+ description: 'Escalate real work (reading/editing files, searching, running tasks, building) to a standard background worker. Returns immediately with a task id; when it finishes the worker speaks its result to the user itself (failures arrive as a "[task <id> failed]" event). Provide a clear, self-contained `brief` (the worker does not hear the live conversation).',
9003
10282
  parameters: {
9004
10283
  type: "object",
9005
10284
  required: ["brief"],
@@ -9014,7 +10293,7 @@ Another agent just implemented the above. Independently check the CURRENT state
9014
10293
  this.turnBriefs.add(String(brief ?? ""));
9015
10294
  this.voice.options.toolChoice = "none";
9016
10295
  const id = await this.dispatch(String(brief ?? ""), "act", label ? String(label) : void 0, this.turnFollowUp);
9017
- return this.spokeBeforeDispatch ? `Acting on task ${id}. You already acknowledged \u2014 end your turn now with NO further text; the result will arrive as a [task ${id} completed] event.` : `Acting on task ${id}. Acknowledge briefly; the result will arrive as a [task ${id} completed] event.`;
10296
+ return this.dispatchResult(id, "Acting on");
9018
10297
  }
9019
10298
  };
9020
10299
  }
@@ -9036,7 +10315,7 @@ Another agent just implemented the above. Independently check the CURRENT state
9036
10315
  this.turnBriefs.add(String(brief ?? ""));
9037
10316
  this.voice.options.toolChoice = "none";
9038
10317
  const id = await this.dispatch(String(brief ?? ""), "think", label ? String(label) : void 0, this.turnFollowUp);
9039
- return this.spokeBeforeDispatch ? `Thinking on task ${id}. You already acknowledged \u2014 end your turn now with NO further text; the result will arrive as a [task ${id} completed] event.` : `Thinking on task ${id}. Acknowledge briefly; the result will arrive as a [task ${id} completed] event.`;
10318
+ return this.dispatchResult(id, "Thinking on");
9040
10319
  }
9041
10320
  };
9042
10321
  }
@@ -9048,7 +10327,13 @@ Another agent just implemented the above. Independently check the CURRENT state
9048
10327
  run: async ({ id }) => {
9049
10328
  const list = id ? [this.tasks.get(String(id))].filter(Boolean) : [...this.tasks.values()];
9050
10329
  if (!list.length) return id ? `No task '${id}'.` : "No background tasks.";
9051
- return list.map((t) => `${t.id} (${t.label}): ${t.status}`).join("\n");
10330
+ const cap = id ? 1500 : 300;
10331
+ const clip = (x) => x.length > cap ? `${x.slice(0, cap)}\u2026 (truncated)` : x;
10332
+ return list.map((t) => {
10333
+ const head = `${t.id} (${t.label}): ${t.status}`;
10334
+ const body = (t.status === "done" ? t.spokenText?.trim() || t.result?.trim() : t.status === "error" ? t.result?.trim() : "") ?? "";
10335
+ return body ? `${head}. ${t.status === "error" ? "Error" : "Result"}: ${clip(body)}` : head;
10336
+ }).join("\n");
9052
10337
  }
9053
10338
  };
9054
10339
  }
@@ -9060,18 +10345,20 @@ Another agent just implemented the above. Independently check the CURRENT state
9060
10345
  const kinds = [.../* @__PURE__ */ new Set(["time", "branch", "ls", "file", "capabilities", ...Object.keys(this.options.quickLook ?? {})])];
9061
10346
  return {
9062
10347
  name: "QuickLook",
9063
- description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. For trivial facts only; anything needing search, commands, or reasoning goes through Act.`,
10348
+ description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. ` + (kinds.includes("sessions") ? '`sessions` = EARLIER conversations with this user: what:"sessions" lists them; what:"sessions" with path:"<session id>" reads one (not what:"file"). ' : "") + "For trivial facts only; anything needing search, commands, or reasoning goes through Act.",
9064
10349
  parameters: {
9065
10350
  type: "object",
9066
10351
  required: ["what"],
9067
10352
  properties: {
9068
10353
  what: { type: "string", enum: kinds, description: "what to look up" },
9069
- path: { type: "string", description: "for ls/file: the path to look at" }
10354
+ path: { type: "string", description: 'for ls/file: the path to look at; for sessions: a session id (or "previous")' }
9070
10355
  }
9071
10356
  },
9072
10357
  run: async ({ what, path }) => {
9073
10358
  const fs = this.options.fs;
9074
10359
  try {
10360
+ const sessions = this.options.quickLook?.sessions;
10361
+ if (sessions && (what === "file" || what === "ls") && SESSION_ID_RE.test(String(path ?? "").trim())) return await sessions(String(path).trim());
9075
10362
  const over = this.options.quickLook?.[String(what)];
9076
10363
  if (over) return await over(path ? String(path) : void 0);
9077
10364
  switch (String(what)) {
@@ -9179,6 +10466,250 @@ Another agent just implemented the above. Independently check the CURRENT state
9179
10466
  }
9180
10467
  };
9181
10468
 
10469
+ // src/modelRouter.ts
10470
+ init_llm();
10471
+ init_logging();
10472
+ var log22 = forComponent("ModelRouter");
10473
+ var ModelRouterOptions = class {
10474
+ classifier = jevClassifier();
10475
+ /** Capability tiers → model id (any provider). fast/standard run the main session; premium is only ever an offload. */
10476
+ models = { fast: "anthropic/claude-haiku-4-5-20251001", standard: "anthropic/claude-sonnet-4-6", premium: "anthropic/claude-opus-5-5" };
10477
+ /** Escalate to a stronger tier when it holds at least this much probability mass (upward bias). */
10478
+ threshold = 0.25;
10479
+ /** P(same thread) at or above which the previous premium side-conversation is resumed. */
10480
+ sameThreadAt = 0.5;
10481
+ classifyTimeoutMs = 1e4;
10482
+ /** Host-supplied pricing (model, usage) → USD, so mixed-model turns are costed per model. */
10483
+ costOf = () => 0;
10484
+ /** Step budget for the premium sub-agent. */
10485
+ offloadMaxSteps = 40;
10486
+ /** Host-supplied provider options per model (e.g. claude-code's subscription env); applied on every tier switch + the offload child. */
10487
+ providerOptionsFor;
10488
+ };
10489
+ function pickTier(c, th) {
10490
+ const o = c.probs.premium ?? 0, s = c.probs.standard ?? 0;
10491
+ if (o >= th || c.choice === "premium") return "premium";
10492
+ if (c.choice === "standard" || s + o >= th) return "standard";
10493
+ return "fast";
10494
+ }
10495
+ function textTurns(msgs) {
10496
+ const out = [];
10497
+ for (const m of msgs) {
10498
+ if (m.role === "user") {
10499
+ const t = contentText(m.content).trim();
10500
+ if (t) out.push({ user: t, assistant: "" });
10501
+ } else if (m.role === "assistant" && out.length) {
10502
+ const t = contentText(m.content).trim();
10503
+ if (t) out[out.length - 1].assistant = (out[out.length - 1].assistant + "\n" + t).trim();
10504
+ }
10505
+ }
10506
+ return out;
10507
+ }
10508
+ var fmtTurns = (ts) => ts.map((t) => `USER: ${t.user}
10509
+ ASSISTANT: ${t.assistant || "(no text reply)"}`).join("\n\n");
10510
+ var addUsage = (a, b) => {
10511
+ if (!a) return b;
10512
+ if (!b) return a;
10513
+ return {
10514
+ promptTokens: a.promptTokens + b.promptTokens,
10515
+ completionTokens: a.completionTokens + b.completionTokens,
10516
+ totalTokens: a.totalTokens + b.totalTokens,
10517
+ cacheCreationTokens: (a.cacheCreationTokens ?? 0) + (b.cacheCreationTokens ?? 0),
10518
+ cacheReadTokens: (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0)
10519
+ };
10520
+ };
10521
+ var ModelRouter = class {
10522
+ constructor(options) {
10523
+ this.options = options;
10524
+ this.options = { ...new ModelRouterOptions(), ...options };
10525
+ }
10526
+ options;
10527
+ state = {};
10528
+ get o() {
10529
+ return this.options;
10530
+ }
10531
+ /** Classify one turn. Never throws: classifier failure/timeout ⇒ standard. */
10532
+ async decide(transcript, next) {
10533
+ const o = this.o;
10534
+ const main = this.state.main === o.models.fast ? "fast" : "standard";
10535
+ const input = {
10536
+ previousModel: main,
10537
+ history: textTurns(transcript).slice(-4).map((t) => ({ user: t.user.slice(0, 1500), assistant: t.assistant.slice(0, 1500) })),
10538
+ next,
10539
+ lastOffload: this.state.offload?.question
10540
+ };
10541
+ const t0 = performance.now();
10542
+ let d;
10543
+ try {
10544
+ let timer;
10545
+ const c = await Promise.race([
10546
+ o.classifier(input),
10547
+ new Promise((_, rej) => {
10548
+ timer = setTimeout(() => rej(new Error(`classifier timeout ${o.classifyTimeoutMs}ms`)), o.classifyTimeoutMs);
10549
+ })
10550
+ ]).finally(() => clearTimeout(timer));
10551
+ const tier = pickTier(c, o.threshold);
10552
+ d = { tier, model: o.models[tier], probs: c.probs, choice: c.choice, sameThread: c.sameThread, classifyMs: Math.round(performance.now() - t0) };
10553
+ if (tier === "premium") d.offload = this.state.offload && (c.sameThread ?? 0) >= o.sameThreadAt ? "continue" : "fresh";
10554
+ } catch (e) {
10555
+ const msg = e instanceof Error ? e.message : String(e);
10556
+ log22.warn(`classifier failed, falling back to standard: ${msg}`);
10557
+ d = { tier: "standard", model: o.models.standard, probs: {}, fallback: msg, classifyMs: Math.round(performance.now() - t0) };
10558
+ }
10559
+ log22.info(`route ${d.tier}${d.offload ? `(${d.offload})` : ""} \xB7 argmax=${d.choice ?? "-"} \xB7 p=${JSON.stringify(d.probs)}${d.sameThread != null ? ` \xB7 same=${d.sameThread.toFixed(2)}` : ""} \xB7 ${d.classifyMs}ms`);
10560
+ return d;
10561
+ }
10562
+ /** Run one user turn on `agent` with routing. Returns a RunResult carrying `costUsd` + `routing`. */
10563
+ async send(agent, content) {
10564
+ const o = this.o;
10565
+ const text = contentText(content);
10566
+ const d = await this.decide(agent.transcript, text);
10567
+ if (d.tier !== "premium") {
10568
+ this.use(agent, this.state.main = d.model);
10569
+ const res = await agent.send(content);
10570
+ return { ...res, costUsd: o.costOf(d.model, res.usage), routing: d };
10571
+ }
10572
+ this.use(agent, this.state.main ??= o.models.standard);
10573
+ return this.offload(agent, content, text, d);
10574
+ }
10575
+ async offload(agent, content, text, d) {
10576
+ const o = this.o, main = this.state.main;
10577
+ const prev = this.state.offload;
10578
+ const cont = d.offload === "continue" && prev && prev.mainLen <= agent.transcript.length;
10579
+ let briefUsage, prompt;
10580
+ if (cont) {
10581
+ const delta = textTurns(agent.transcript.slice(prev.mainLen));
10582
+ prompt = `Follow-up in the same thread.${delta.length ? ` Since your last answer the main conversation continued:
10583
+
10584
+ ${fmtTurns(delta)}
10585
+ ` : ""}
10586
+
10587
+ New request:
10588
+ ${text}`;
10589
+ } else {
10590
+ const b = await this.brief(agent, text);
10591
+ briefUsage = b.usage;
10592
+ const last2 = textTurns(agent.transcript).slice(-2);
10593
+ prompt = `You are handling a hard turn delegated by the main agent of an ongoing session. You do NOT see its conversation \u2014 only this brief. Do the work with your tools, then reply to the user directly (your reply is shown as the session's answer).
10594
+
10595
+ --- brief from the main agent ---
10596
+ ${b.text}
10597
+
10598
+ ${last2.length ? `--- last turns (verbatim) ---
10599
+ ${fmtTurns(last2)}
10600
+
10601
+ ` : ""}--- current user request (verbatim) ---
10602
+ ${text}`;
10603
+ }
10604
+ const child = this.spawn(agent);
10605
+ const res = cont ? (child.transcript = prev.messages, await child.send(prompt)) : await child.run(prompt);
10606
+ this.state.offloads = (this.state.offloads ?? 0) + 1;
10607
+ if (cont) this.state.reuses = (this.state.reuses ?? 0) + 1;
10608
+ const answer = res.text || `(premium sub-agent finished with no reply; finishReason=${res.finishReason})`;
10609
+ agent.transcript.push({ role: "user", content }, { role: "assistant", content: answer });
10610
+ this.state.offload = { messages: child.transcript, mainLen: agent.transcript.length, question: text };
10611
+ log22.info(`offload ${cont ? "continue" : "fresh"} done \xB7 ${res.finishReason} \xB7 ${res.steps} steps`);
10612
+ const costUsd = o.costOf(main, briefUsage) + o.costOf(o.models.premium, res.usage);
10613
+ return { ...res, text: answer, messages: agent.transcript, usage: addUsage(briefUsage, res.usage), costUsd, routing: d };
10614
+ }
10615
+ /** The main model writes the task brief on its own (cached) context, tools off; the transcript is restored after. */
10616
+ async brief(agent, text) {
10617
+ const snap = agent.transcript.slice();
10618
+ const { host, toolChoice, maxSteps } = agent.options;
10619
+ Object.assign(agent.options, { host: void 0, toolChoice: "none", maxSteps: 1 });
10620
+ try {
10621
+ const r = await agent.send(`[router] The next request is being delegated to a stronger sub-agent that will NOT see this conversation. Write the self-contained brief you would hand it (<=200 words): the task, the relevant facts/files/decisions established so far, constraints. Output only the brief.
10622
+
10623
+ The request:
10624
+ ${text}`);
10625
+ return { text: r.text.trim() || text, usage: r.usage };
10626
+ } catch (e) {
10627
+ log22.warn(`brief failed, sending the raw request: ${e instanceof Error ? e.message : String(e)}`);
10628
+ return { text };
10629
+ } finally {
10630
+ agent.transcript = snap;
10631
+ Object.assign(agent.options, { host, toolChoice, maxSteps });
10632
+ }
10633
+ }
10634
+ use(agent, model) {
10635
+ agent.options.model = model;
10636
+ if (this.o.providerOptionsFor) agent.options.providerOptions = this.o.providerOptionsFor(model);
10637
+ }
10638
+ spawn(agent) {
10639
+ const a = agent.options;
10640
+ const co = childOptionsFor(
10641
+ { ai: a.ai, model: a.model, fs: a.fs, tools: a.tools, systemPrompt: a.systemPrompt, hooks: a.hooks, maxSteps: this.o.offloadMaxSteps, traceDir: a.traceDir, providerOptionsFor: this.o.providerOptionsFor },
10642
+ a.fs,
10643
+ a.depth ?? 0,
10644
+ a.maxDepth ?? 2,
10645
+ void 0,
10646
+ this.o.models.premium
10647
+ );
10648
+ if (typeof co === "string") throw new Error(co);
10649
+ co.signal = a.signal;
10650
+ return new Agent(co);
10651
+ }
10652
+ };
10653
+ var JEV_KEYS = ["AI_GATEWAY_API_KEY", "OPENROUTER_API_KEY", "TYPESAFE_API_KEY"];
10654
+ async function loadJev(opts) {
10655
+ const mod = await import(opts.jevModule ?? process.env.AGENTX_JEV_MODULE ?? "@livx.cc/mcp-jev/jev");
10656
+ const env = { ...process.env };
10657
+ const file = opts.envFile ?? process.env.AGENTX_JEV_ENV;
10658
+ if (file) {
10659
+ const { readFileSync: readFileSync3 } = await import("fs");
10660
+ for (const l of readFileSync3(file, "utf8").split("\n")) {
10661
+ const m = l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
10662
+ if (m && !env[m[1]]) env[m[1]] = m[2].replace(/^['"]|['"]$/g, "");
10663
+ }
10664
+ }
10665
+ return { mod, env };
10666
+ }
10667
+ async function jevAvailable(opts = {}) {
10668
+ try {
10669
+ const { env } = await loadJev(opts);
10670
+ return JEV_KEYS.some((k) => env[k]?.trim());
10671
+ } catch (e) {
10672
+ log22.debug(`jev unavailable: ${e instanceof Error ? e.message : String(e)}`);
10673
+ return false;
10674
+ }
10675
+ }
10676
+ function jevClassifier(opts = {}) {
10677
+ let router;
10678
+ const load = async () => {
10679
+ const { mod, env } = await loadJev(opts);
10680
+ return new mod.JevRouter({ env });
10681
+ };
10682
+ return async (input) => {
10683
+ const jev = await (router ??= load().catch((e) => {
10684
+ router = void 0;
10685
+ throw e;
10686
+ }));
10687
+ const questions = [{
10688
+ id: "tier",
10689
+ type: "choice",
10690
+ instructions: "Pick the CHEAPEST model that will handle the NEXT assistant turn correctly for this coding-agent conversation. Staying on previousModel keeps its prompt cache warm; switch only when the next turn clearly needs more (or much less) capability.",
10691
+ criteria: {
10692
+ fast: "Trivial: commands, lookups, acknowledgements, commits, mechanical single-file edits, short rewrites.",
10693
+ standard: "Standard engineering: features, bounded refactors, reviews, tests, following a clear trace, summarising.",
10694
+ premium: "Hard: root-causing subtle/intermittent bugs, concurrency, security semantics, architecture trade-offs, quantitative reasoning."
10695
+ }
10696
+ }];
10697
+ if (input.lastOffload) questions.push({
10698
+ id: "sameThread",
10699
+ type: "boolean",
10700
+ instructions: "Does nextUserMessage continue the same line of work as lastOffload (the previous hard request, handled by a specialist) \u2014 a follow-up, refinement or next step of it \u2014 rather than a new, unrelated topic?"
10701
+ });
10702
+ const state = { previousModel: input.previousModel, history: input.history, nextUserMessage: input.next, ...input.lastOffload ? { lastOffload: input.lastOffload } : {} };
10703
+ const r = await jev.evaluate({ state, questions }, opts.provider);
10704
+ const a = r.answers.tier;
10705
+ const map = (k) => k;
10706
+ const probs = {};
10707
+ for (const [k, v] of Object.entries(a.probabilities ?? {})) probs[map(k)] = v;
10708
+ const st = r.answers.sameThread;
10709
+ return { choice: map(a.choice), probs, sameThread: st ? st.probability ?? (st.value ? 1 : 0) : void 0 };
10710
+ };
10711
+ }
10712
+
9182
10713
  // src/mcp.ts
9183
10714
  function toResult(result) {
9184
10715
  if (result == null) return { text: "" };
@@ -9444,6 +10975,8 @@ export {
9444
10975
  MEMORY_PROMPT,
9445
10976
  MODEL_ALIASES,
9446
10977
  MemFilesystem3 as MemFilesystem,
10978
+ ModelRouter,
10979
+ ModelRouterOptions,
9447
10980
  MountFilesystem,
9448
10981
  NodeDiskFilesystem,
9449
10982
  OverlayFilesystem,
@@ -9505,6 +11038,8 @@ export {
9505
11038
  imagePart,
9506
11039
  imageRefPart,
9507
11040
  imageRefResult,
11041
+ jevAvailable,
11042
+ jevClassifier,
9508
11043
  lessonCapture,
9509
11044
  loadAgents,
9510
11045
  loadCommands,
@@ -9539,6 +11074,7 @@ export {
9539
11074
  parseCron,
9540
11075
  parseDdgHtml,
9541
11076
  parseImageRef,
11077
+ pickTier,
9542
11078
  planMode,
9543
11079
  raceAttempts,
9544
11080
  readTool,